The history stack has been global since the Android back gesture landed -- every navigation is an entry and `popstate` restores any of them in either direction. What the report describes as "back is tab-scoped" is that the only way back was a detail view's own button, which leaves the screen with the view it belongs to: click over to Tracks and the album you were reading is still one entry away with nothing on screen saying so. `<nav-history>` is that affordance, plus `nav.back` / `nav.forward` on Alt+Left / Alt+Right -- the browser's own combination, and clear of the bare arrows that seek, since a binding matches on its full canonical string. Forward is not back negated, which is why the old `pushedEntries` counter is gone rather than extended: `popstate` carries no direction and fires identically both ways, so one counter decremented on every pop reads a forward as a second back. Each entry carries its index and the shell keeps the current one and a high-water mark, which also survives a jump of more than one. The buttons dispatch the events the rest of the app already dispatches rather than calling `history` themselves -- the shell owns the guard that stops a press at the root leaving the app, and a second caller reaching for history is how the old `navStack` came to disagree with the platform. Below 900px the control stands down: the top bar is what runs out of room first below that, and nothing becomes unreachable -- the shortcuts are global at every width and the phone has the platform's gesture. Closes #6
92 lines
3.1 KiB
TypeScript
92 lines
3.1 KiB
TypeScript
/**
|
|
* The global back/forward control (#6).
|
|
*
|
|
* The interesting half of this component is what it does when it
|
|
* *cannot* act. The app's rule is that a control which cannot do
|
|
* anything should not be a button at all — `library-status-indicator`
|
|
* spent a release as a `<button>` whose handler was a comment — and
|
|
* this is the documented exception: back and forward are a pair whose
|
|
* positions the user learns, so the unavailable one greys out rather
|
|
* than disappearing and moving the other one under the cursor.
|
|
*/
|
|
import { describe, expect, it, beforeEach } from 'vitest';
|
|
|
|
import '@components/nav-history/nav-history';
|
|
import { fixture, shadow, update } from '@test/support/render';
|
|
import { historyStore } from '@store/history-store';
|
|
|
|
const back = (el: HTMLElement) =>
|
|
shadow<HTMLButtonElement>(el, '[data-testid="history-back"]');
|
|
|
|
const forward = (el: HTMLElement) =>
|
|
shadow<HTMLButtonElement>(el, '[data-testid="history-forward"]');
|
|
|
|
describe('nav-history', () => {
|
|
beforeEach(() => {
|
|
historyStore.setDepth(false, false);
|
|
});
|
|
|
|
it('offers both directions, named', async () => {
|
|
const el = await fixture('nav-history');
|
|
|
|
// The name is the whole control: two arrows side by side are
|
|
// indistinguishable to anything not looking at them.
|
|
expect(back(el)?.getAttribute('aria-label')).toBe('Back');
|
|
expect(forward(el)?.getAttribute('aria-label')).toBe('Forward');
|
|
});
|
|
|
|
it('disables what cannot be done, in both directions independently', async () => {
|
|
const el = await fixture('nav-history');
|
|
|
|
expect(back(el)?.disabled).toBe(true);
|
|
expect(forward(el)?.disabled).toBe(true);
|
|
|
|
historyStore.setDepth(true, false);
|
|
await update(el, {});
|
|
|
|
expect(back(el)?.disabled).toBe(false);
|
|
expect(forward(el)?.disabled).toBe(true);
|
|
|
|
// Standing in the middle of the list, which is what a back press
|
|
// followed by a look at the toolbar produces.
|
|
historyStore.setDepth(true, true);
|
|
await update(el, {});
|
|
|
|
expect(back(el)?.disabled).toBe(false);
|
|
expect(forward(el)?.disabled).toBe(false);
|
|
});
|
|
|
|
it('asks the shell rather than reaching for history itself', async () => {
|
|
const el = await fixture('nav-history');
|
|
const seen: string[] = [];
|
|
|
|
for (const name of ['navigate-back', 'navigate-forward']) {
|
|
document.addEventListener(name, () => seen.push(name));
|
|
}
|
|
|
|
historyStore.setDepth(true, true);
|
|
await update(el, {});
|
|
|
|
back(el)?.click();
|
|
forward(el)?.click();
|
|
|
|
// Composed and bubbling, or index.ts's document listener — which
|
|
// owns the guard that stops a press at the root leaving the app —
|
|
// never hears them. A second caller reaching for `history`
|
|
// directly is how the old `navStack` came to disagree with the
|
|
// platform.
|
|
expect(seen).toEqual(['navigate-back', 'navigate-forward']);
|
|
});
|
|
|
|
it('says nothing when it cannot act', async () => {
|
|
const el = await fixture('nav-history');
|
|
const seen: string[] = [];
|
|
|
|
document.addEventListener('navigate-back', () => seen.push('back'));
|
|
|
|
back(el)?.click();
|
|
|
|
expect(seen).toEqual([]);
|
|
});
|
|
});
|