FN-6766: fix mobile nav icon spacing

Keeps mobile bottom navigation icons evenly centered across tab variants.

- Make each mobile nav tab an equal-width flex column that ignores intrinsic label width.
- Center and truncate labels without affecting icon spacing.
- Add coverage for optional tabs, active tabs, badges, status dots, and overflow plugin views.
- Add a patch changeset for the published CLI bundle.

Files changed:
 .changeset/fn-6766-mobile-nav-spacing.md           |  5 ++
 packages/dashboard/app/components/MobileNavBar.css | 18 ++--
 .../app/components/__tests__/MobileNavBar.test.tsx | 95 ++++++++++++++++++++++
 3 files changed, 113 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-6766

Fusion-Task-Lineage: 0fe92813-9110-4bc9-b0d0-39d98dc7dba9
This commit is contained in:
gsxdsm
2026-06-20 02:38:27 -07:00
parent c4f34ceed1
commit 98720f3626
3 changed files with 113 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix mobile bottom tab navigation icon spacing so every tab uses an equal-width column across optional tabs, badges, and status dots.

View File

@@ -74,23 +74,28 @@
}
/* Individual tab button */
/*
FNXC:MobileNav 2026-06-20-02:04:
Every mobile nav tab must be an equal-width column with a centered icon so inter-icon spacing stays uniform across tab counts, long labels, active state, badges, and status dots.
Use a zero flex basis plus min-width:0 so intrinsic label width cannot bias flex distribution; badges and status dots stay absolutely positioned and out of flow.
*/
.mobile-nav-tab {
flex: 1;
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-width: 36px;
gap: calc(var(--space-xs) / 2);
min-height: 36px;
padding: 6px 0;
padding: calc(var(--space-sm) - (var(--space-xs) / 2)) 0;
background: none;
border: none;
color: var(--text-muted);
font-size: 10px;
line-height: 1.2;
cursor: pointer;
transition: color 0.15s ease;
transition: color var(--transition-fast);
position: relative;
-webkit-tap-highlight-color: transparent;
}
@@ -120,11 +125,14 @@
}
.mobile-nav-tab-label {
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
font-size: 10px;
line-height: 1.2;
text-align: center;
white-space: nowrap;
}

View File

@@ -1,3 +1,5 @@
import { readFileSync } from "fs";
import { resolve } from "path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MobileNavBar } from "../MobileNavBar";
@@ -26,6 +28,48 @@ function mockViewport(mode: "mobile" | "desktop") {
});
}
const mobileNavCss = readFileSync(resolve(process.cwd(), "app/components/MobileNavBar.css"), "utf8");
function extractRuleBlock(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([\\s\\S]*?)\\}`));
return match?.[1] ?? "";
}
function getRenderedMobileTabs(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(".mobile-nav-bar > .mobile-nav-tab"));
}
function expectUniformMobileNavColumns(container: HTMLElement, expectedTabCount: number) {
const tabs = getRenderedMobileTabs(container);
expect(tabs).toHaveLength(expectedTabCount);
const tabRule = extractRuleBlock(mobileNavCss, ".mobile-nav-tab");
expect(tabRule).toContain("flex: 1 1 0");
expect(tabRule).toContain("min-width: 0");
expect(tabRule).toContain("align-items: center");
expect(tabRule).toMatch(/padding:\s*[^;]+\s+0;/);
expect(tabRule).not.toMatch(/margin-left|margin-right/);
const labelRule = extractRuleBlock(mobileNavCss, ".mobile-nav-tab-label");
expect(labelRule).toContain("width: 100%");
expect(labelRule).toContain("min-width: 0");
expect(labelRule).toContain("text-align: center");
for (const tab of tabs) {
expect(tab.className).toContain("mobile-nav-tab");
expect(tab.querySelector(".mobile-nav-tab-label")).toBeInTheDocument();
}
if (container.querySelector(".mobile-nav-tab-badge")) {
expect(extractRuleBlock(mobileNavCss, ".mobile-nav-tab-badge")).toContain("position: absolute");
}
if (container.querySelector(".mobile-nav-chat-unread-dot")) {
expect(extractRuleBlock(mobileNavCss, ".mobile-nav-chat-unread-dot")).toContain("position: absolute");
}
}
const createDefaultProps = () => ({
view: "board" as const,
onChangeView: vi.fn(),
@@ -105,6 +149,57 @@ describe("MobileNavBar", () => {
expect(screen.queryByTestId("mobile-nav-tab-skills")).toBeNull();
});
it("keeps every mobile tab in an equal-width column across tab, active, badge, and status-dot variants", () => {
const sevenTabRender = render(
<MobileNavBar
{...createDefaultProps()}
showSkillsTab={false}
view="command-center"
chatHasUnreadResponse={true}
mailboxUnreadCount={7}
mailboxPendingApprovalCount={2}
/>,
);
expectUniformMobileNavColumns(sevenTabRender.container, 7);
expect(screen.getByTestId("mobile-nav-tab-command-center").className).toContain("mobile-nav-tab--active");
expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
expect(screen.getByLabelText("Pending approvals")).toBeInTheDocument();
expect(screen.getByTestId("mobile-nav-tab-mailbox").querySelector(".mobile-nav-tab-badge")?.textContent).toBe("7");
sevenTabRender.unmount();
const eightTabRender = render(
<MobileNavBar
{...createDefaultProps()}
showSkillsTab={true}
view="skills"
chatHasUnreadResponse={true}
mailboxUnreadCount={101}
mailboxPendingApprovalCount={1}
/>,
);
expectUniformMobileNavColumns(eightTabRender.container, 8);
expect(screen.getByTestId("mobile-nav-tab-skills").className).toContain("mobile-nav-tab--active");
expect(screen.getByTestId("mobile-nav-tab-mailbox").querySelector(".mobile-nav-tab-badge")?.textContent).toBe("99+");
eightTabRender.unmount();
const pluginVariantRender = render(
<MobileNavBar
{...createDefaultProps()}
showSkillsTab={true}
pluginDashboardViews={[
{
pluginId: "fusion-plugin-spacing-check",
view: { viewId: "wide", label: "Very Long Plugin Destination", componentPath: "./WidePluginView", icon: "Workflow", placement: "primary", order: 1 },
},
]}
/>,
);
expectUniformMobileNavColumns(pluginVariantRender.container, 8);
expect(screen.queryByTestId("mobile-nav-tab-plugin-fusion-plugin-spacing-check-wide")).toBeNull();
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
expect(screen.getByTestId("mobile-more-item-plugin-fusion-plugin-spacing-check-wide")).toBeDefined();
});
it("keeps Todos in the mobile More sheet when todoView is enabled", () => {
const onOpenTodos = vi.fn();
render(