Files
yellowjacket/frontend/src/store/history-store.ts
logan 018d857746 feat(shell): global back and forward in the top bar
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
2026-08-19 18:08:55 -04:00

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();