+ {/* Prerelease badge. Beside the logo it names the build, and it stays out
+ of the action cluster on the right, which has no room to spare once
+ plugins add tabs to the pill. */}
+ {isPrerelease && appVersion && (
+
@@ -157,6 +184,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
than push the actions off the bar. */}
{(globalAddons.length > 0 || pagePlugins.length > 0) && !tripTitle && (
- {[{ id: '__trips', path: '/dashboard', label: t('nav.myTrips'), Icon: Briefcase },
- ...globalAddons.map(a => ({ id: a.id, path: `/${a.id}`, label: getAddonName(a), Icon: ADDON_ICONS[a.icon] || CalendarDays })),
- ...pagePlugins.map(p => ({ id: `plugin:${p.id}`, path: `/plugins/${p.id}`, label: p.name, Icon: resolvePluginIcon(p.icon) })),
- // Empty in this repository — see client/src/managed.
- ...visibleManagedNavItems(user?.role === 'admin').map(m => ({ id: `managed:${m.id}`, path: m.path, label: m.label, Icon: m.Icon }))
- ].map(tab => {
+ {tabs.map(tab => {
const isActive = location.pathname === tab.path
return (
{ if (!isActive) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.color = 'var(--text-muted)' }}>
-
{tab.label}
+
{tab.label}
)
})}
@@ -212,7 +235,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
is centred on the bar (#1983). Was a bare flex-1 spacer followed by
loose siblings, which centred nothing and left the pill to fend for
itself on top of them. */}
-
+
{/* Share button */}
{onShare && (
@@ -225,16 +248,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
)}
- {/* Prerelease badge */}
- {isPrerelease && appVersion && (
-
-
- {appVersion}
-
- )}
-
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
{user && }
@@ -268,7 +281,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
{user.username?.charAt(0).toUpperCase()}
)}
-
+
{user.username}
diff --git a/client/src/hooks/useCollapseOnOverflow.test.ts b/client/src/hooks/useCollapseOnOverflow.test.ts
new file mode 100644
index 000000000..ca04f468d
--- /dev/null
+++ b/client/src/hooks/useCollapseOnOverflow.test.ts
@@ -0,0 +1,109 @@
+// FE-HOOK-COLLAPSE-001 to FE-HOOK-COLLAPSE-006
+import { act, renderHook } from '@testing-library/react';
+import { useCollapseOnOverflow } from './useCollapseOnOverflow';
+
+function strip(scrollWidth: number, clientWidth: number): HTMLDivElement {
+ const el = document.createElement('div');
+ setWidths(el, scrollWidth, clientWidth);
+ return el;
+}
+
+function setWidths(el: HTMLElement, scrollWidth: number, clientWidth: number): void {
+ Object.defineProperty(el, 'scrollWidth', { value: scrollWidth, configurable: true });
+ Object.defineProperty(el, 'clientWidth', { value: clientWidth, configurable: true });
+}
+
+function resizeWindow(width: number): void {
+ act(() => {
+ window.innerWidth = width;
+ window.dispatchEvent(new Event('resize'));
+ });
+}
+
+function mount(el: HTMLDivElement, key = 'a|b|c') {
+ return renderHook(
+ ({ k }) => {
+ const result = useCollapseOnOverflow(k);
+ result.ref.current = el;
+ return result;
+ },
+ { initialProps: { k: key } }
+ );
+}
+
+const originalWidth = window.innerWidth;
+afterEach(() => {
+ window.innerWidth = originalWidth;
+});
+
+describe('useCollapseOnOverflow', () => {
+ it('FE-HOOK-COLLAPSE-001: keeps the labels while the strip fits', () => {
+ window.innerWidth = 1600;
+ const { result } = mount(strip(500, 500));
+ resizeWindow(1500);
+ expect(result.current.collapsed).toBe(false);
+ });
+
+ it('FE-HOOK-COLLAPSE-002: collapses once the strip overflows', () => {
+ window.innerWidth = 1600;
+ const el = strip(500, 500);
+ const { result } = mount(el);
+ setWidths(el, 740, 640);
+ resizeWindow(1200);
+ expect(result.current.collapsed).toBe(true);
+ });
+
+ it('FE-HOOK-COLLAPSE-003: brings the labels back only once the window has the missing width', () => {
+ window.innerWidth = 1200;
+ const el = strip(740, 640);
+ const { result } = mount(el);
+ expect(result.current.collapsed).toBe(true);
+ // Collapsed, the strip fits by definition, so only the window width counts.
+ setWidths(el, 320, 320);
+ resizeWindow(1250);
+ expect(result.current.collapsed).toBe(true);
+ setWidths(el, 740, 740);
+ resizeWindow(1300);
+ expect(result.current.collapsed).toBe(false);
+ });
+
+ it('FE-HOOK-COLLAPSE-004: measures again when the set of items changes', () => {
+ window.innerWidth = 1200;
+ const el = strip(740, 640);
+ const { result, rerender } = mount(el);
+ expect(result.current.collapsed).toBe(true);
+ // One tab fewer: the labels fit at the same window width.
+ setWidths(el, 600, 600);
+ rerender({ k: 'a|b' });
+ expect(result.current.collapsed).toBe(false);
+ });
+
+ it('FE-HOOK-COLLAPSE-005: stops listening on unmount', () => {
+ const remove = vi.spyOn(window, 'removeEventListener');
+ const { unmount } = mount(strip(500, 500));
+ unmount();
+ expect(remove).toHaveBeenCalledWith('resize', expect.any(Function));
+ remove.mockRestore();
+ });
+ it('FE-HOOK-COLLAPSE-006: measures again when the strip itself changes size, a font or text size with it', () => {
+ let resized: (() => void) | null = null;
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ constructor(cb: () => void) {
+ resized = cb;
+ }
+ observe() {}
+ disconnect() {}
+ }
+ );
+ window.innerWidth = 1600;
+ const el = strip(500, 500);
+ const { result } = mount(el);
+ expect(result.current.collapsed).toBe(false);
+ setWidths(el, 700, 600);
+ act(() => resized?.());
+ expect(result.current.collapsed).toBe(true);
+ vi.unstubAllGlobals();
+ });
+});
diff --git a/client/src/hooks/useCollapseOnOverflow.ts b/client/src/hooks/useCollapseOnOverflow.ts
new file mode 100644
index 000000000..b70f09b7b
--- /dev/null
+++ b/client/src/hooks/useCollapseOnOverflow.ts
@@ -0,0 +1,54 @@
+import { useLayoutEffect, useRef, useState } from 'react';
+
+/**
+ * Tells a horizontal strip when to drop its labels and show icons only.
+ *
+ * A fixed breakpoint cannot do this for the navbar pill: how wide the labels
+ * are depends on how many addons and page plugins are enabled and on the
+ * language. So the strip is measured instead. When it overflows with labels,
+ * the viewport width it would need to fit them is remembered, and the labels
+ * come back once the window is at least that wide. Measuring the collapsed
+ * strip would not work, since without labels it always fits.
+ *
+ * `itemsKey` names the current set of items. When it changes the labels are
+ * shown and measured again. Besides a window resize, a change in the strip's
+ * own size (a web font arriving, a larger text size) is measured too.
+ */
+export function useCollapseOnOverflow(
+ itemsKey: string
+): {
+ ref: React.RefObject;
+ collapsed: boolean;
+} {
+ const ref = useRef(null);
+ const [state, setState] = useState<{ key: string; fitsFrom: number } | null>(null);
+ const fitsFrom = state?.key === itemsKey ? state.fitsFrom : null;
+
+ useLayoutEffect(() => {
+ const check = () => {
+ if (fitsFrom !== null) {
+ if (window.innerWidth >= fitsFrom) setState(null);
+ return;
+ }
+ const el = ref.current;
+ if (!el) return;
+ const overflow = el.scrollWidth - el.clientWidth;
+ if (overflow > 0) setState({ key: itemsKey, fitsFrom: window.innerWidth + overflow });
+ };
+ check();
+ window.addEventListener('resize', check);
+ const el = ref.current;
+ const observer = el && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(check) : null;
+ if (observer && el) {
+ // The strip stops growing once it is squeezed; its items keep reporting.
+ observer.observe(el);
+ for (const child of Array.from(el.children)) observer.observe(child);
+ }
+ return () => {
+ window.removeEventListener('resize', check);
+ observer?.disconnect();
+ };
+ }, [itemsKey, fitsFrom]);
+
+ return { ref, collapsed: fitsFrom !== null };
+}