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:
5
.changeset/fn-6766-mobile-nav-spacing.md
Normal file
5
.changeset/fn-6766-mobile-nav-spacing.md
Normal 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.
|
||||||
@@ -74,23 +74,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Individual tab button */
|
/* 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 {
|
.mobile-nav-tab {
|
||||||
flex: 1;
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 2px;
|
gap: calc(var(--space-xs) / 2);
|
||||||
min-width: 36px;
|
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 6px 0;
|
padding: calc(var(--space-sm) - (var(--space-xs) / 2)) 0;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.15s ease;
|
transition: color var(--transition-fast);
|
||||||
position: relative;
|
position: relative;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
@@ -120,11 +125,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-nav-tab-label {
|
.mobile-nav-tab-label {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { resolve } from "path";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { MobileNavBar } from "../MobileNavBar";
|
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 = () => ({
|
const createDefaultProps = () => ({
|
||||||
view: "board" as const,
|
view: "board" as const,
|
||||||
onChangeView: vi.fn(),
|
onChangeView: vi.fn(),
|
||||||
@@ -105,6 +149,57 @@ describe("MobileNavBar", () => {
|
|||||||
expect(screen.queryByTestId("mobile-nav-tab-skills")).toBeNull();
|
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", () => {
|
it("keeps Todos in the mobile More sheet when todoView is enabled", () => {
|
||||||
const onOpenTodos = vi.fn();
|
const onOpenTodos = vi.fn();
|
||||||
render(
|
render(
|
||||||
|
|||||||
Reference in New Issue
Block a user