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
67 lines
2.0 KiB
TypeScript
67 lines
2.0 KiB
TypeScript
/**
|
|
* How far the session can go back and forward.
|
|
*
|
|
* The History API exposes `length` and nothing useful: it counts
|
|
* entries the app did not push, does not say where in the list the
|
|
* current entry is, and `popstate` fires *identically* whether the
|
|
* user went back or forward. So a control that wants to grey itself
|
|
* out has to be told, and the shell is the only thing in a position to
|
|
* know (#6).
|
|
*
|
|
* Two rules follow from how the shell counts, and both are the reason
|
|
* this is a pair of booleans rather than one depth:
|
|
*
|
|
* **Forward is not "back, negated".** `pushedEntries` -- the counter
|
|
* this replaces -- decremented on every `popstate`, which made a
|
|
* forward navigation look like a second back. The shell keeps an index
|
|
* per entry and a high-water mark instead, and publishes the two
|
|
* answers rather than the arithmetic.
|
|
*
|
|
* **Back stops at the app's own floor.** The launch entry is
|
|
* *replaced*, not pushed, so that one back press from the root exits
|
|
* the app on Android; `canBack` is false there, which is what stops
|
|
* the header's own button being the thing that quits.
|
|
*/
|
|
|
|
type Subscriber = () => void;
|
|
|
|
export interface HistoryDepth {
|
|
canBack: boolean;
|
|
canForward: boolean;
|
|
}
|
|
|
|
class HistoryStore {
|
|
private depth: HistoryDepth = { canBack: false, canForward: false };
|
|
|
|
private subscribers = new Set<Subscriber>();
|
|
|
|
get(): HistoryDepth {
|
|
return this.depth;
|
|
}
|
|
|
|
/** Called by the shell whenever an entry is pushed or restored. */
|
|
setDepth(canBack: boolean, canForward: boolean): void {
|
|
if (
|
|
canBack === this.depth.canBack &&
|
|
canForward === this.depth.canForward
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.depth = { canBack, canForward };
|
|
this.notify();
|
|
}
|
|
|
|
subscribe(fn: Subscriber): () => void {
|
|
this.subscribers.add(fn);
|
|
|
|
return () => this.subscribers.delete(fn);
|
|
}
|
|
|
|
private notify(): void {
|
|
this.subscribers.forEach((fn) => fn());
|
|
}
|
|
}
|
|
|
|
export const historyStore = new HistoryStore();
|