fix(nav): stop the tab pill from running under the logo and the user menu

The centred tab menu was absolutely positioned, so it had no relationship to
what sat beside it. Its width grows with every enabled addon and every page
plugin, and once it outgrew the free space in the middle of the bar it simply
ran underneath the logo on one side and the bell and user menu on the other.

The only adaptation was a hard-coded 1024px breakpoint that drops the tab
labels — a constant chosen when two or three addons was the whole story, and
one that cannot know a plugin has added anything.

So the bar is three columns in the flow now: the brand block and the action
cluster carry equal weight either side, and the pill sits between them. That
keeps it centred on the bar as before, and makes overlap something that
cannot happen rather than something a breakpoint has to predict. With
min-w-0 the pill gives way and scrolls when even it runs out of room,
instead of pushing the actions off the edge.

Measuring the pill against the free space and folding the overflow into a
menu was the other option. It buys a tidier look at a narrow width and costs
a ResizeObserver on every desktop page, a cache to keep the measurement from
oscillating against the mode it chose, and a new string in 23 locales. Not
for a fix to overlapping.

Closes #1983
This commit is contained in:
Maurice
2026-08-20 17:41:37 +02:00
committed by Maurice
parent bb0d65bb19
commit f76ee87336
2 changed files with 88 additions and 9 deletions
@@ -559,3 +559,63 @@ describe('Navbar styling and menu details', () => {
document.documentElement.classList.remove('trek-theme-transitioning');
});
});
/**
* The bar's three columns (#1983).
*
* The tab pill was absolutely positioned on the centre of the bar, so it had no
* relationship to what sat beside it. Its width grows with every enabled addon
* and every page plugin, and once it outgrew the free space in the middle it
* ran underneath the logo on one side and the user menu on the other. The only
* adaptation was a fixed 1024px breakpoint that drops the labels, tuned when
* two or three addons was the whole story and blind to plugins entirely.
*
* These check the shape rather than pixels, because that is where the defect
* was: three columns in the flow cannot overlap, whatever ends up in them, and
* no measurement has to be right for that to hold. jsdom reports every width as
* zero, so an assertion on how wide anything is would pass for the wrong reason.
*/
describe('Navbar layout (#1983)', () => {
const withAddons = (n: number) => {
const addons = Array.from({ length: n }, (_, i) => ({
id: `addon${i}`, name: `Addon ${i}`, icon: 'CalendarDays', type: 'global' as const, enabled: true,
}));
server.use(http.get('/api/addons', () => HttpResponse.json({ addons })));
seedStore(useAddonStore, { addons });
};
it('keeps the tab pill in the flow rather than floating over its neighbours', () => {
withAddons(4);
const { container } = render(<Navbar />);
const pill = container.querySelector('.trek-nav-pill') as HTMLElement;
expect(pill).toBeTruthy();
// The assertion that would have caught the overlap.
expect(pill.style.position).not.toBe('absolute');
});
it('gives the columns either side of it equal weight, so it stays centred', () => {
withAddons(4);
const { container } = render(<Navbar />);
const nav = container.querySelector('nav') as HTMLElement;
const columns = Array.from(nav.children).filter(c => c.classList.contains('flex-1'));
// Left brand column and right action cluster, both flex-1 basis-0.
expect(columns).toHaveLength(2);
for (const c of columns) expect(c.classList.contains('basis-0')).toBe(true);
});
it('lets the pill shrink instead of pushing the actions off the bar', () => {
withAddons(8);
const { container } = render(<Navbar />);
const pill = container.querySelector('.trek-nav-pill') as HTMLElement;
expect(pill.classList.contains('min-w-0')).toBe(true);
expect(pill.style.overflowX).toBe('auto');
});
it('still renders every addon as a reachable link, however many there are', () => {
withAddons(8);
render(<Navbar />);
for (let i = 0; i < 8; i++) {
expect(screen.getByRole('link', { name: new RegExp(`Addon ${i}`, 'i') })).toBeInTheDocument();
}
});
});
+28 -9
View File
@@ -110,8 +110,10 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
height: 'var(--nav-h)',
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
{/* Left side */}
<div className="flex items-center gap-3 min-w-0">
{/* 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">
{showBack && (
<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"
@@ -137,14 +139,27 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
)}
</div>
{/* Centred liquid-glass tab menu (design handoff). Absolutely positioned so
the left brand block and the right action cluster keep their layout. */}
{/* Centred liquid-glass tab menu (design handoff).
In the flow, between two equally weighted flex columns, rather than
absolutely positioned on the centre of the bar. Out of the flow it had
no relationship to its neighbours at all: its width grows with every
enabled addon and every page plugin, and once it outgrew the free
space in the middle it simply ran underneath the logo on one side and
the user menu on the other (#1983). The only adaptation was a fixed
1024px breakpoint that drops the labels, which was tuned for two or
three addons and cannot know about plugins.
Now the three columns share the bar, so overlap is not something that
can happen: the pill takes the width it needs and the columns beside
it give way. min-w-0 lets it shrink past its content and scroll rather
than push the actions off the bar. */}
{(globalAddons.length > 0 || pagePlugins.length > 0) && !tripTitle && (
<div
className="trek-nav-pill"
className="trek-nav-pill min-w-0"
style={{
position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
display: 'flex', gap: 4, padding: 4, borderRadius: 14,
display: 'flex', gap: 4, padding: 4, borderRadius: 14, flexShrink: 1,
overflowX: 'auto', scrollbarWidth: 'none',
background: dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
backdropFilter: 'blur(20px) saturate(180%)', WebkitBackdropFilter: 'blur(20px) saturate(180%)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)'}`,
@@ -192,8 +207,11 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
/>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Right side. Same weight as the left column, so whatever is between them
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">
{/* Share button */}
{onShare && (
@@ -324,6 +342,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
)}
</div>
)}
</div>
</nav>
)
}