Files
yellowjacket/frontend/test/components/bottom-nav.test.ts
T
logan 57fbbdf0d2
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m40s
feat(ui): a shell a phone can be held in
Plan 016 B2, phase 1. Below 600px the grid drops its sidebar column,
`bottom-nav` becomes the primary navigation, and the shell fits the
viewport instead of scrolling sideways out of it.

600 rather than the sidebar's own 900, because 900 is a laptop and the
answer there is a narrower sidebar, which is still a sidebar. Under 600
there is no room for one at all: 360px of viewport over a 200px nav is
not a layout.

**The tab bar is four destinations and a way to everything else.**
Three to five is where touch targets stop being thumb-sized -- eleven
over 360px is 32px each -- so the four are the ones plan 016's subset
says a phone is for, and "More" opens the *existing* `app-sidebar` in a
drawer rather than listing the destinations a second time. Two lists is
two places to add the next view to.

That reuse has a cost this found the hard way: a shared component
brings its `data-testid`s with it, so rendering the drawer's sidebar
unconditionally put a second `nav-home` (and ten siblings) in the DOM
and **failed 30 existing specs** with "resolved to 2 elements" -- on a
desktop viewport, where this element is `display: none` and the drawer
can never open. It renders only while the drawer is open, and the
component test asserts the absence, because the failure is invisible
from inside the component and lands in files nobody touched.

**What made the shell overflow was minimums, not padding.** Measured at
360px: the body was 652px wide, because a `min-width` in a flex row is
a hard floor and a grid item's implicit minimum is its content. So
`min-width: 0` on the boxes between the viewport and the content, and
each component stands its own non-essential parts down in its *own*
stylesheet -- search-bar's 200px floor, job-indicator's label (the
visible one; the live region that announces it is untouched),
audio-player's seek bar and volume. A media query inside a shadow root
is answered by the viewport, so this is the component saying what it
drops rather than the shell reaching in.

Volume goes because the hardware keys own it on a phone, which is the
same reason mediacontrols' Android handler implements no volume
callback. Seeking goes because 4px is not a thumb target; it belongs to
the full-screen now-playing view, which is the next phase.

An existing spec therefore asserts the opposite of what it did:
layout-overflow's 320px case used to require that the 464px behind
`overflow: hidden` could be *scrolled to*, which was the remedy
available while the shell had one layout. It reflows now -- 320px in a
320px viewport, exactly -- and reflow is what WCAG 1.4.10 asked for.
2026-08-16 23:19:26 -04:00

166 lines
5.5 KiB
TypeScript

/**
* The phone's primary navigation (plan 016 B2).
*
* Three of these are about the thing that makes a second nav dangerous:
* it has to agree with the first one. `bottom-nav` emits the same
* bubbling, composed `navigate` event `app-sidebar` does and listens
* for that event globally, so a navigation from anywhere — a card, a
* detail view, the drawer's own sidebar — moves its highlight too. A
* tab bar that only tracks its own clicks looks right until the moment
* the user arrives somewhere by another route.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/bottom-nav/bottom-nav';
import type { BottomNav } from '@components/bottom-nav/bottom-nav';
import { fixture, shadow, shadowAll, update } from '@test/support/render';
import { resetHarness } from '@test/support/harness';
type Nav = BottomNav;
const tabs = (el: HTMLElement) =>
shadowAll<HTMLButtonElement>(el, 'nav button');
/** Resolve on one occurrence of an event, or reject loudly on time. */
const once = (el: Element, name: string, timeoutMs = 2000) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`${name} never fired`)),
timeoutMs,
);
el.addEventListener(name, () => {
clearTimeout(timer);
resolve();
}, { once: true });
});
describe('bottom-nav', () => {
beforeEach(() => {
resetHarness();
});
it('offers the four phone destinations and a way to the rest', async () => {
const el = await fixture<Nav>('bottom-nav');
expect(tabs(el).map((b) => b.dataset.testid)).toEqual([
'tab-home',
'tab-albums',
'tab-tracks',
'tab-playlists',
'tab-more',
]);
});
it('emits a navigate event that escapes its shadow root', async () => {
const el = await fixture<Nav>('bottom-nav');
const seen: string[] = [];
document.addEventListener('navigate', (e) => {
seen.push((e as CustomEvent<{ view: string }>).detail.view);
});
shadow<HTMLButtonElement>(el, '[data-testid="tab-albums"]')?.click();
// Composed and bubbling, or index.ts's document-level listener --
// the only thing that actually changes the view -- never hears it.
expect(seen).toEqual(['albums']);
});
it('follows a navigation it did not send', async () => {
const el = await fixture<Nav>('bottom-nav');
document.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'tracks' },
bubbles: true,
composed: true,
}));
await update(el, {});
const current = tabs(el)
.filter((b) => b.getAttribute('aria-current') === 'page')
.map((b) => b.dataset.testid);
expect(current).toEqual(['tab-tracks']);
});
it('marks exactly one tab current, and none for a view it has no tab for', async () => {
const el = await fixture<Nav>('bottom-nav');
document.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'settings' },
bubbles: true,
composed: true,
}));
await update(el, {});
// Settings lives in the drawer, so nothing in the bar is current.
// Leaving Home highlighted would be a tab bar lying about where
// the user is.
expect(
tabs(el).filter((b) => b.getAttribute('aria-current') === 'page'),
).toHaveLength(0);
});
it('closes the drawer when a navigation happens', async () => {
const el = await fixture<Nav>('bottom-nav');
const drawer = shadow<HTMLElement & { open: boolean }>(el, 'wa-drawer');
if (!drawer) throw new Error('no drawer');
// The drawer animates, so the assertion is its own event rather
// than the `open` property: setting `open = false` starts a hide
// that has not finished on the next microtask, and a test that
// reads the property in between sees the state it is leaving.
const shown = once(drawer, 'wa-after-show');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await shown;
const hidden = once(drawer, 'wa-after-hide');
document.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'settings' },
bubbles: true,
composed: true,
}));
await hidden;
expect(drawer.open).toBe(false);
});
it('gives every tab a name and a target big enough to hit', async () => {
const el = await fixture<Nav>('bottom-nav');
for (const button of tabs(el)) {
expect(button.textContent?.trim()).not.toBe('');
// 48px is the floor for a touch target; the bar is the one
// surface in this app that has no pointer to fall back on.
expect(button.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
}
});
it('holds no second sidebar until the drawer is asked for', async () => {
const el = await fixture<Nav>('bottom-nav');
// `app-sidebar` carries a data-testid per destination, so a spare
// copy standing by makes every `nav-*` testid ambiguous for the
// *whole app*: rendering it unconditionally failed 30 existing
// specs with "strict mode violation: resolved to 2 elements", on a
// desktop viewport where this element is not even visible.
expect(shadow(el, 'app-sidebar')).toBeNull();
});
it('keeps the drawer sidebar expanded, where there is room for labels', async () => {
const el = await fixture<Nav>('bottom-nav');
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
await update(el, {});
// Without this the sidebar's own auto-collapse (a response to a
// narrow *shell*) would render icons in a full-width drawer.
expect(shadow<HTMLElement>(el, 'app-sidebar')?.hasAttribute('expanded'))
.toBe(true);
});
});