The nav components learned where the user was from the `navigate` CustomEvent, which only the outbound path dispatches: `popstate` calls `handleNavigate()` directly. So a back-navigation left both of them highlighting the view just left — desktop included, at any width, on any back across two primary views. Opening a detail view was the same cause wearing a different symptom: `app-sidebar` guarded on its own item list and kept its highlight, `bottom-nav` did not and lit nothing. It cannot be fixed by re-dispatching `navigate` — `index.ts` is that event's document listener, so that is an infinite loop, and "please go to X" is not the statement being made. `activeViewStore` is the shell saying "the active view is now X", once per navigation, `popstate` included; both navs read it through a controller and hold no `activeView` of their own. A store rather than an event because a component that mounts *after* a navigation still has to know: `bottom-nav`'s drawer builds its `app-sidebar` on open, and that copy had heard nothing at all, so the drawer opened on Home from any page in the app. Closes #72
197 lines
7.0 KiB
TypeScript
197 lines
7.0 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 reads
|
|
* which tab is lit from `activeViewStore` — the shell's one statement
|
|
* of where the user is — so it follows a navigation from anywhere: a
|
|
* card, a detail view, the drawer's own sidebar, or the back gesture.
|
|
*
|
|
* That last one is why the source is the store and not the `navigate`
|
|
* event these tests used to dispatch. `popstate` dispatches no
|
|
* `navigate` (index.ts calls `handleNavigate` directly), so a tab bar
|
|
* listening for the event looked right until the user pressed back —
|
|
* #72.
|
|
*/
|
|
import { describe, expect, it, beforeEach } from 'vitest';
|
|
|
|
import '@components/bottom-nav/bottom-nav';
|
|
import { activeViewStore } from '@store/active-view-store';
|
|
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');
|
|
|
|
/** The testids of whatever the bar says is the current page. */
|
|
const current = (el: HTMLElement) =>
|
|
tabs(el)
|
|
.filter((b) => b.getAttribute('aria-current') === 'page')
|
|
.map((b) => b.dataset.testid);
|
|
|
|
/** 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');
|
|
|
|
activeViewStore.setView('tracks', true);
|
|
await update(el, {});
|
|
|
|
expect(current(el)).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');
|
|
|
|
activeViewStore.setView('settings', 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(current(el)).toEqual([]);
|
|
});
|
|
|
|
it('keeps the parent tab lit while a detail view is open', async () => {
|
|
const el = await fixture<Nav>('bottom-nav');
|
|
|
|
activeViewStore.setView('albums', true);
|
|
// A detail view reports itself and is not primary, so it changes
|
|
// nothing. This is the first half of #72: the bar used to take the
|
|
// name, match it against no tab, and light nothing at all — while
|
|
// `app-sidebar`, which guarded on its own item list, kept the
|
|
// highlight. Neither was deliberate and the two disagreed.
|
|
activeViewStore.setView('explore-album-details', false);
|
|
await update(el, {});
|
|
|
|
expect(current(el)).toEqual(['tab-albums']);
|
|
});
|
|
|
|
it('follows the back path, which dispatches no navigate event', async () => {
|
|
const el = await fixture<Nav>('bottom-nav');
|
|
|
|
activeViewStore.setView('albums', true);
|
|
activeViewStore.setView('tracks', true);
|
|
await update(el, {});
|
|
expect(current(el)).toEqual(['tab-tracks']);
|
|
|
|
// What `popstate` does: the shell replays the entry through
|
|
// `handleNavigate` without dispatching `navigate`. A bar listening
|
|
// for the event stayed on Tracks — the view just left, confidently
|
|
// wrong rather than merely blank.
|
|
activeViewStore.setView('albums', true);
|
|
await update(el, {});
|
|
|
|
expect(current(el)).toEqual(['tab-albums']);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|