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 */
|
||||
/*
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user