mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(nav): put the prerelease badge beside the logo and never let the actions cover the tab pill
Both side columns keep their content width, so the pill gives way: it drops its labels to icons when they no longer fit (measured, since plugins and languages change the width) and scrolls only after that. The username is no longer cut at 96px.
This commit is contained in:
@@ -612,6 +612,27 @@ describe('Navbar layout (#1983)', () => {
|
||||
expect(pill.style.overflowX).toBe('auto');
|
||||
});
|
||||
|
||||
it('keeps both columns at their content width, so the actions never spill over the pill', () => {
|
||||
withAddons(8);
|
||||
const { container } = render(<Navbar />);
|
||||
const nav = container.querySelector('nav') as HTMLElement;
|
||||
const columns = Array.from(nav.children).filter(c => c.classList.contains('flex-1'));
|
||||
// A min-w-0 column shrinks below its content and its justify-end children
|
||||
// overflow leftwards across the pill (5.0.0-pre.1 feedback).
|
||||
for (const c of columns) {
|
||||
expect(c.classList.contains('min-w-0')).toBe(false);
|
||||
expect(c.classList.contains('min-w-max')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the prerelease badge in the brand column, beside the logo', () => {
|
||||
seedStore(useAuthStore, { isPrerelease: true, appVersion: '5.0.0-pre.1' });
|
||||
const { container } = render(<Navbar />);
|
||||
const nav = container.querySelector('nav') as HTMLElement;
|
||||
const brandColumn = nav.children[0] as HTMLElement;
|
||||
expect(brandColumn.contains(screen.getByText('5.0.0-pre.1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('still renders every addon as a reachable link, however many there are', () => {
|
||||
withAddons(8);
|
||||
render(<Navbar />);
|
||||
|
||||
@@ -12,6 +12,7 @@ import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
import { resolvePluginIcon } from '../shared/PluginIcon'
|
||||
import { visibleManagedNavItems } from '../../managed'
|
||||
import { useCollapseOnOverflow } from '../../hooks/useCollapseOnOverflow'
|
||||
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
|
||||
|
||||
@@ -95,6 +96,16 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
return translated !== key ? translated : addon.name
|
||||
}
|
||||
|
||||
const tabs = [{ 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 }))
|
||||
]
|
||||
// Labels go when they no longer fit beside the logo and the actions; the
|
||||
// title and aria-label on each tab keep it named.
|
||||
const { ref: pillRef, collapsed: pillCollapsed } = useCollapseOnOverflow<HTMLDivElement>(tabs.map(tab => tab.label).join('|'))
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
background: dark
|
||||
@@ -113,8 +124,12 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
|
||||
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
|
||||
the tab pill between them sits in the middle of the bar rather than
|
||||
being centred on top of both (#1983). */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1 basis-0">
|
||||
being centred on top of both (#1983). Both columns keep at least their
|
||||
content width (min-w-max): a column squeezed below it spilled over the
|
||||
pill, so the pill is the one that gives way, first by dropping its
|
||||
labels and then by scrolling. On a trip page there is no pill, and the
|
||||
trip title truncates instead. */}
|
||||
<div className={`flex items-center gap-3 flex-1 basis-0 ${tripTitle ? 'min-w-0' : 'min-w-max'}`}>
|
||||
{showBack && (
|
||||
<button type="button" onClick={onBack}
|
||||
className="trek-back-btn p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0 text-content-muted"
|
||||
@@ -130,6 +145,18 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
<img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="TREK" className="hidden sm:block" style={{ height: 28 }} />
|
||||
</Link>
|
||||
|
||||
{/* 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 && (
|
||||
<span
|
||||
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0 bg-[rgba(245,158,11,0.15)] text-[#d97706] border border-[rgba(245,158,11,0.3)]"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-[#f59e0b]" />
|
||||
{appVersion}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{tripTitle && (
|
||||
<>
|
||||
<span className="hidden sm:inline text-content-faint">/</span>
|
||||
@@ -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 && (
|
||||
<div
|
||||
ref={pillRef}
|
||||
className="trek-nav-pill min-w-0"
|
||||
style={{
|
||||
display: 'flex', gap: 4, padding: 4, borderRadius: 14, flexShrink: 1,
|
||||
@@ -166,17 +194,12 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
border: `1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)'}`,
|
||||
}}
|
||||
>
|
||||
{[{ 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 (
|
||||
<Link key={tab.id} to={tab.path}
|
||||
title={tab.label} aria-label={tab.label}
|
||||
className="flex items-center gap-1.5 transition-colors"
|
||||
className="flex items-center gap-1.5 whitespace-nowrap transition-colors"
|
||||
style={{
|
||||
padding: '5px 16px', borderRadius: 9, fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
@@ -186,7 +209,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.color = 'var(--text-muted)' }}>
|
||||
<tab.Icon className="w-4 h-4" />
|
||||
<span className="hidden lg:inline">{tab.label}</span>
|
||||
<span className={pillCollapsed ? 'hidden' : 'hidden lg:inline'}>{tab.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
@@ -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. */}
|
||||
<div className="flex items-center gap-4 flex-1 basis-0 min-w-0 justify-end">
|
||||
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
|
||||
|
||||
{/* Share button */}
|
||||
{onShare && (
|
||||
@@ -225,16 +248,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Prerelease badge */}
|
||||
{isPrerelease && appVersion && (
|
||||
<span
|
||||
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0 bg-[rgba(245,158,11,0.15)] text-[#d97706] border border-[rgba(245,158,11,0.3)]"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-[#f59e0b]" />
|
||||
{appVersion}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
|
||||
{user && <HelpButton />}
|
||||
|
||||
@@ -268,7 +281,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
{user.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm hidden sm:inline max-w-24 truncate text-content-secondary">
|
||||
<span className="text-sm hidden sm:inline max-w-40 truncate text-content-secondary">
|
||||
{user.username}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4 text-content-faint" />
|
||||
|
||||
@@ -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<HTMLDivElement>(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();
|
||||
});
|
||||
});
|
||||
@@ -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<T extends HTMLElement = HTMLElement>(
|
||||
itemsKey: string
|
||||
): {
|
||||
ref: React.RefObject<T | null>;
|
||||
collapsed: boolean;
|
||||
} {
|
||||
const ref = useRef<T | null>(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 };
|
||||
}
|
||||
Reference in New Issue
Block a user