diff --git a/components/layout/__tests__/navigation-plugin-pages.test.tsx b/components/layout/__tests__/navigation-plugin-pages.test.tsx index 6608ea3d8..47aed50a1 100644 --- a/components/layout/__tests__/navigation-plugin-pages.test.tsx +++ b/components/layout/__tests__/navigation-plugin-pages.test.tsx @@ -80,3 +80,27 @@ describe('Mountain View app grid', () => { expect(screen.getByText('Stats')).toBeInTheDocument(); }); }); + +describe('tabBarItemWidth', () => { + it('leaves entries alone when they all fit', async () => { + const { tabBarItemWidth } = await import('../navigation-rail'); + expect(tabBarItemWidth(338, 6)).toBeNull(); + expect(tabBarItemWidth(312, 6)).toBeNull(); + expect(tabBarItemWidth(0, 6)).toBeNull(); + }); + + it('shows whole entries and half of the next when they do not fit', async () => { + const { tabBarItemWidth } = await import('../navigation-rail'); + // 320 px phone with Settings pinned: 268 px for six entries. + const width = tabBarItemWidth(268, 6)!; + expect(width).toBeGreaterThanOrEqual(52); + const visible = 268 / width; + expect(visible - Math.floor(visible)).toBeCloseTo(0.5); + // The same at any width that overflows. + for (const bar of [120, 200, 268, 300, 311]) { + const w = tabBarItemWidth(bar, 6)!; + expect(w).toBeGreaterThanOrEqual(52); + expect(bar / w - Math.floor(bar / w)).toBeCloseTo(0.5); + } + }); +}); diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 9bc1c542e..9202b5b19 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -45,6 +45,18 @@ interface NavItem { badge?: number; } +/** + * How wide a phone tab bar entry should be, or null when all of them fit at + * the normal width. A bar that doesn't fit shows whole entries and half of + * the next: half an entry says there is more to scroll to, a few cut-off + * pixels only look broken. + */ +export function tabBarItemWidth(barWidth: number, count: number, minWidth = 52): number | null { + if (!barWidth || count * minWidth <= barWidth) return null; + const whole = Math.max(1, Math.floor(barWidth / minWidth - 0.5)); + return barWidth / (whole + 0.5); +} + interface NavigationRailProps { orientation?: "vertical" | "horizontal"; collapsed?: boolean; @@ -236,6 +248,14 @@ export function NavigationRail({ useEffect(() => { tabBarScrollRef.current?.querySelector('[aria-current="page"]')?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); }, [pathname]); + const [tabBarWidth, setTabBarWidth] = useState(0); + useEffect(() => { + const bar = tabBarScrollRef.current; + if (!bar || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => setTabBarWidth(bar.clientWidth)); + observer.observe(bar); + return () => observer.disconnect(); + }, [orientation]); const router = useRouter(); const { appLogoLightUrl, appLogoDarkUrl } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); @@ -406,6 +426,7 @@ export function NavigationRail({ const scrollingCount = visibleItems.length + pluginPages.length + visibleSidebarApps.filter((app) => app.showOnMobile).length + (isStalwartAdmin || adminActive ? 1 : 0); + const itemWidth = tabBarItemWidth(tabBarWidth, scrollingCount); return (