diff --git a/CLAUDE.md b/CLAUDE.md index 894630d..c0e7826 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -945,7 +945,16 @@ change at all. Two rules hold it up. The **first** navigation *replaces* the launch entry rather than pushing one, or every launch costs a back press before -the app will close. And the in-app back buttons (`navigate-back`, fired +the app will close. **There are two launch navigations**, which is what +defeated that rule for five phases: the eager `navigate → home` at the +foot of `index.ts` and the configured page `GetDefaultPage()` resolves +to later. Only the first replaced, so a fresh session was already one +entry deep, the first back press replayed home over home, and on Android +`canGoBack()` was true so the press that should have exited the app did +nothing (#142). The landing-page navigation carries `_replace`, honoured +only while still at index 0 — past that the user has navigated during +the backend call, and a slow answer must not overwrite an entry they +made. And the in-app back buttons (`navigate-back`, fired by the detail views and `now-playing-view`) go through `history.back()` rather than a stack of their own: the old `navStack` is **deleted**, not kept beside it, because two stacks is precisely how a view's own back @@ -988,6 +997,33 @@ empty rather than defaulting to `home`, which is what `app-sidebar`'s field used to do to match the landing view — a default that is correct only while `GetDefaultPage()` agrees with it. +**Back and forward are chrome, and the depth is the shell's own +count.** `` in the top bar is #6: the stack was always +global — every navigation is an entry and `popstate` restores any of +them in either direction — so what was missing was an affordance, since +the only way back was a detail view's own button, which leaves the +screen with the view it belongs to. The buttons dispatch +`navigate-back` / `navigate-forward` and the shell owns both guards, +for the reason the old `navStack` was deleted: a second caller reaching +for `history` is how two stacks come to disagree. + +Three things about it are load-bearing. **Forward is not back +negated**, so the single `pushedEntries` counter could not express it — +`popstate` carries no direction and fires identically both ways, so a +counter decremented on every pop reads a forward as a second back. Each +entry carries its index (`yjIdx`) and the shell keeps the current one +and a high-water mark; that also survives a jump of more than one, +which `history.go(-n)` and a long-press on a browser's back button both +produce. **A control that cannot act is `disabled` here**, which is the +documented exception to `library-status-indicator`'s rule: the two are +a pair whose positions the user learns, and hiding one moves the other +under the cursor. And **it stands down below 900px** — the top bar is +what runs out of room first below that (it already overflows 600px by +11px, #143), and nothing becomes unreachable: `nav.back` / `nav.forward` +(`Alt+Left` / `Alt+Right`, the browser's own combination, and clear of +the bare arrows that seek) are global at every width, and the phone has +the platform's gesture. + The assertion is `aria-current="page"`, in `e2e/specs/back-navigation.spec.ts`. That file existed throughout the bug, covered exactly these journeys, and asserted only diff --git a/backend/shortcuts/config.go b/backend/shortcuts/config.go index 6ce12c1..df45fd1 100644 --- a/backend/shortcuts/config.go +++ b/backend/shortcuts/config.go @@ -24,10 +24,19 @@ func DefaultBindings() map[string]string { "player.repeat": "R", "player.mute": "M", - // Navigation (Global scope) + // Navigation (Global scope). Back and forward are the browser's + // own combination on every platform, which is the whole design + // brief for them: the app has one global history and this is the + // gesture people already have for it. The modifier is what keeps + // them clear of `player.seekBack`/`seekForward`, which are the + // bare arrows -- a binding is matched on its full canonical + // string, so "Alt+Left" and "Left" are different keys and not a + // conflict. "nav.search": "/", "nav.searchAlt": "Ctrl+F", "nav.queue": "Q", + "nav.back": "Alt+Left", + "nav.forward": "Alt+Right", // App actions "app.selectAll": "Ctrl+A", diff --git a/e2e/specs/back-navigation.spec.ts b/e2e/specs/back-navigation.spec.ts index e82402d..1d3d41a 100644 --- a/e2e/specs/back-navigation.spec.ts +++ b/e2e/specs/back-navigation.spec.ts @@ -79,6 +79,114 @@ async function openAnArtist(app: Page): Promise { ); } +/** + * The global back/forward control (#6). + * + * It is desktop chrome — hidden below 900px, where the sidebar has + * already given up its labels — so these set a desktop viewport + * explicitly rather than trusting the runner's default. + */ +const DESKTOP = { width: 1280, height: 800 }; + +const backButton = (page: Page) => + page.locator('nav-history').getByRole('button', { name: 'Back' }); + +const forwardButton = (page: Page) => + page.locator('nav-history').getByRole('button', { name: 'Forward' }); + +test.describe('global back and forward', () => { + test.beforeEach(async ({ app }) => { + await app.setViewportSize(DESKTOP); + }); + + test('offers nothing at launch, in either direction', async ({ app }) => { + // The launch entry is *replaced*, not pushed, so there is nothing + // of ours behind it — and a Back button that is live at the root + // is a press that does nothing on desktop and, on Android, the + // press that should have exited the app (#142). This assertion is + // what pins that: it failed before the launch navigation stopped + // recording two entries. + await expect(backButton(app)).toBeDisabled(); + await expect(forwardButton(app)).toBeDisabled(); + }); + + test('walks the history in both directions, and says which are available', async ({ + app, + }) => { + await app.getByTestId('nav-albums').click(); + await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); + await expect(backButton(app)).toBeEnabled(); + await expect(forwardButton(app)).toBeDisabled(); + + await app.getByTestId('nav-tracks').click(); + await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks'); + + await backButton(app).click(); + + await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums'); + // Standing in the middle of the list: both directions live, which + // is the state a single depth counter cannot express. + await expect(backButton(app)).toBeEnabled(); + await expect(forwardButton(app)).toBeEnabled(); + + await forwardButton(app).click(); + + await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks'); + await expect(forwardButton(app)).toBeDisabled(); + }); + + test('reaches the detail view a tab click left behind', async ({ app }) => { + // The report, exactly: the album is one entry away the whole time, + // and before this control the only way back to it was a button + // that had gone off screen with the view it belonged to. + await app.getByTestId('nav-artists').click(); + await openAnArtist(app); + + await app.getByTestId('nav-tracks').click(); + await expect(activeView(app)).toHaveAttribute('data-active-view', 'tracks'); + + await backButton(app).click(); + + await expect(activeView(app)).toHaveAttribute( + 'data-active-view', + 'explore-artist-details', + ); + }); + + test('drops the forward list when the user navigates from the middle', async ({ + app, + }) => { + await app.getByTestId('nav-albums').click(); + await app.getByTestId('nav-tracks').click(); + await backButton(app).click(); + await expect(forwardButton(app)).toBeEnabled(); + + // A browser truncates here, and so does this: what was ahead is no + // longer reachable, and a Forward button still offering it would + // be pointing at an entry that has been overwritten. + await app.getByTestId('nav-genres').click(); + + await expect(activeView(app)).toHaveAttribute('data-active-view', 'genres'); + await expect(forwardButton(app)).toBeDisabled(); + await expect(backButton(app)).toBeEnabled(); + }); + + test('is absent below the desktop band, where nothing needs it', async ({ + app, + }) => { + // Alt+Left/Right survive at every width, the detail views keep + // their own back buttons and the phone has the platform's gesture + // — so this is a control standing down, not an action becoming + // unreachable. It is hidden at 899 because the top bar is what + // runs out of room first below 900 (#143). + await app.setViewportSize({ width: 899, height: 600 }); + await expect(app.locator('nav-history')).toBeHidden(); + + await app.setViewportSize({ width: 390, height: 844 }); + await expect(app.locator('nav-history')).toBeHidden(); + }); +}); + test.describe('the back gesture', () => { test('leaves a detail view for the view it was opened from', async ({ app, diff --git a/frontend/index.css b/frontend/index.css index 71b65db..44e6a47 100644 --- a/frontend/index.css +++ b/frontend/index.css @@ -104,6 +104,15 @@ p { flex: 0 1 320px; } +/* The bar is `justify-content: space-between`, which with four children + spreads them evenly and left back/forward floating in the middle of + nothing. Collecting the free space *after* this one puts the pair + beside the brand, where a browser keeps them, and leaves the + right-hand group exactly as it was. */ +.top-bar nav-history { + margin-right: auto; +} + ul { list-style-type: none; } @@ -133,6 +142,23 @@ ul { .subtitle { display: none; } + + /* Back/forward is Desktop-band chrome (#6), and 900 is the same + line the sidebar's labels and the subtitle are already given up + at -- below it the shell is narrow enough that the header is + what runs out of room first. Measured at 600, the bottom of the + Compact band: the bar is 611px inside a 600px viewport *before* + this component exists (filed separately), and 695px with it, so + keeping it here would be widening a violation of the promise + that nothing scrolls sideways at a supported size. + + Nothing is unreachable as a result, which is the rule that + decides it: Alt+Left / Alt+Right are global and every width has + them, the detail views keep their own back buttons, and the + phone additionally has the platform's gesture. */ + .top-bar nav-history { + display: none; + } } body div.sidebar { @@ -346,7 +372,13 @@ body div.sidebar { /* The search box is the one header control worth its width; the library filter is a rarely-changed setting and reachable from - the drawer's Settings. */ + the drawer's Settings. + + `nav-history` is already gone from 899 down. It would belong + here anyway and for a stronger reason than width: the phone has + Back as a gesture or a button the OS owns, and this app hooks it + (`popstate`), so a second Back in the chrome duplicates a + control the platform provides. */ .top-bar library-filter { display: none; } diff --git a/frontend/index.html b/frontend/index.html index cb14257..dbfa05c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -20,6 +20,12 @@

Music how it was meant to bee.

+ + diff --git a/frontend/index.ts b/frontend/index.ts index cb7396a..7b51668 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -23,6 +23,7 @@ import '@components/now-playing/now-playing.ts'; import '@components/sidebar/app-sidebar.ts'; import '@components/bottom-nav/bottom-nav.ts'; import '@components/queue-panel/queue-panel.ts'; +import '@components/nav-history/nav-history.ts'; import '@components/search-bar/search-bar.ts'; import '@components/library-filter/library-filter.ts'; import '@components/first-run-wizard/first-run-wizard.ts'; @@ -41,6 +42,7 @@ import { registerBundledIcons } from './src/icons'; import { queueStore } from '@store/queue-store'; import { searchStore } from '@store/search-store'; import { activeViewStore } from '@store/active-view-store'; +import { historyStore } from '@store/history-store'; import * as Player from '@go/player/player.js'; import * as Queue from '@go/queue/queue.js'; import { GetDefaultPage } from '@go/config/config.js'; @@ -215,45 +217,101 @@ document.addEventListener('navigate', (e: Event) => { // go through `history.back()` rather than popping `navStack` // themselves, so one press cannot consume two entries. -/** The navigation an entry stands for. `undefined` on the entry that - * predates the app's own routing, which is the one back exits from. */ -type NavState = { yjNav?: { view: string; [key: string]: any } }; +/** The navigation an entry stands for, and where it sits in this + * session's list. `undefined` on the entry that predates the app's own + * routing, which is the one back exits from. */ +type NavState = { yjNav?: { view: string; [key: string]: any }; yjIdx?: number }; /** Whether the app's first navigation has been recorded. It *replaces* * the launch entry rather than pushing, or every launch would cost one * back press before the app would exit. */ let historyStarted = false; -/** How many entries this session has pushed beyond that first one -- - * i.e. how deep back can go while staying inside the app. */ -let pushedEntries = 0; +// Back and forward are the *same* `popstate` event -- it carries no +// direction, and the History API exposes neither the current position +// nor a reachable depth. So the shell numbers its own entries: the +// index of the one showing, and the highest index reachable from here. +// +// The counter this replaced (`pushedEntries`, one number decremented on +// every pop) could not express forward at all: going forward looked +// exactly like going back again, so two presses of a Forward button +// would have claimed the app was at its root. + +/** Index of the entry now showing. 0 is the launch entry, which is + * replaced rather than pushed -- so this is also how deep back can go + * while staying inside the app. */ +let currentIndex = 0; + +/** The highest index reachable from here: how far forward is left. + * A new navigation truncates the forward list, exactly as a browser + * does, so this is reset to the entry being pushed. */ +let maxIndex = 0; + +function publishDepth(): void { + historyStore.setDepth(currentIndex > 0, currentIndex < maxIndex); +} function recordNavigation(detail: { view: string; [key: string]: any }): void { - // `_isBack` is bookkeeping, not destination: keeping it in the entry - // would make a replayed navigation claim to be a back-navigation. - const { _isBack: _ignored, ...nav } = detail; - const state: NavState = { yjNav: nav }; + // `_isBack` and `_replace` are bookkeeping, not destination: keeping + // either in the entry would make a replayed navigation claim to be + // one. + const { _isBack: _ignored, _replace: replace, ...nav } = detail; + + // Still launching: the configured landing page is not a navigation + // *away* from the eager one, it is the same arrival arriving late + // (#142). Pushing it left the app one entry deep before the user + // had touched anything, so the first back press replayed home over + // home -- invisible on desktop until #6 drew a Back button, and on + // Android the press that should have exited the app instead did + // nothing, because `canGoBack()` was true. + // + // Guarded on being at the root rather than on a flag, because + // `GetDefaultPage()` is a backend call and the user can navigate + // while it is in flight: past index 0 this is an ordinary + // navigation, or a slow answer would overwrite an entry they made. + if (historyStarted && replace && currentIndex === 0) { + history.replaceState({ yjNav: nav, yjIdx: 0 }, ''); + maxIndex = 0; + publishDepth(); + + return; + } // Same URL, deliberately: the app has no routes, and a path a // reload cannot resolve is worse than no path at all. if (historyStarted) { - history.pushState(state, ''); - pushedEntries += 1; + currentIndex += 1; + // Navigating from the middle of the list drops what was ahead + // of it -- there is no longer a forward to go to. + maxIndex = currentIndex; + history.pushState({ yjNav: nav, yjIdx: currentIndex }, ''); } else { - history.replaceState(state, ''); + currentIndex = 0; + maxIndex = 0; + history.replaceState({ yjNav: nav, yjIdx: 0 }, ''); historyStarted = true; } + + publishDepth(); } window.addEventListener('popstate', (e: PopStateEvent) => { - const nav = (e.state as NavState | null)?.yjNav; + const state = e.state as NavState | null; + const nav = state?.yjNav; // Before the app's first navigation, or an entry somebody else // pushed: nothing to restore, and the activity should be free to // finish. if (!nav) return; - pushedEntries = Math.max(0, pushedEntries - 1); + // The entry says where it is, so this works in both directions and + // across a jump of more than one -- which a long-press on a + // browser's back button, and `history.go(-n)`, both produce. + // The fallback is for an entry pushed before this numbering + // existed; it can only be wrong about a control's disabled state, + // never about which view is restored. + currentIndex = state?.yjIdx ?? Math.max(0, currentIndex - 1); + publishDepth(); void handleNavigate({ ...nav, _isBack: true }); }); @@ -512,7 +570,18 @@ function schedule(fn: () => void): void { // anyway would leave the app: the depth check is what stops a stray // `navigate-back` closing it. document.addEventListener('navigate-back', () => { - if (pushedEntries > 0) history.back(); + if (currentIndex > 0) history.back(); +}); + +// Forward: the other half of #6. The stack was always global -- every +// navigation is an entry and `popstate` restores any of them -- so what +// was missing is a way to ask for one, and a truthful answer to whether +// there is one to ask for. It is guarded for the same reason back is: +// `history.forward()` at the end of the list is silent, so a button +// that offers it when there is nothing there is a button that does +// nothing. +document.addEventListener('navigate-forward', () => { + if (currentIndex < maxIndex) history.forward(); }); // Navigate to the user's configured launch page. Falls back to 'home' @@ -522,14 +591,17 @@ GetDefaultPage() document.dispatchEvent(new CustomEvent('navigate', { bubbles: true, composed: true, - detail: { view: view || 'home' }, + // Part of launching, not a navigation away from the eager + // 'home' above: it replaces that entry rather than + // stacking on it (#142). + detail: { view: view || 'home', _replace: true }, })); }) .catch(() => { document.dispatchEvent(new CustomEvent('navigate', { bubbles: true, composed: true, - detail: { view: 'home' }, + detail: { view: 'home', _replace: true }, })); }); diff --git a/frontend/src/assets/icons/fa/solid/arrow-right.svg b/frontend/src/assets/icons/fa/solid/arrow-right.svg new file mode 100644 index 0000000..f37a77c --- /dev/null +++ b/frontend/src/assets/icons/fa/solid/arrow-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/nav-history/nav-history.ts b/frontend/src/components/nav-history/nav-history.ts new file mode 100644 index 0000000..81efc6c --- /dev/null +++ b/frontend/src/components/nav-history/nav-history.ts @@ -0,0 +1,133 @@ +import { LitElement, html, css } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import '@awesome.me/webawesome/dist/components/icon/icon.js'; +import { designTokens } from '../../styles/tokens.css'; +import { HistoryController } from '@store/controllers/history-controller'; + +/** + * Global back and forward, in the top bar (#6). + * + * **The stack was already global; the affordance was not.** Every + * navigation has been a history entry since the Android back gesture + * landed, and `popstate` restores any of them in either direction -- + * `back-navigation.spec.ts` has asserted `goForward()` since it was + * written. What the report describes as "back is tab-scoped" is that + * the *only* way back was a detail view's own button, which vanishes + * the moment you leave for another tab: the album you were reading is + * still one entry away, and nothing on screen says so or offers it. + * + * Four things about this are load-bearing. + * + * **It asks the shell rather than the History API.** `history.length` + * counts entries this app did not push and never shrinks, and there is + * no way to ask where in the list you are -- so a control derived from + * it is confidently wrong at both ends. `historyStore` is the shell's + * own numbering. + * + * **A control that cannot act is `disabled`, not hidden.** This is the + * one place in the app where that is right rather than the fault + * `library-status-indicator` was: back and forward are a *pair* whose + * positions the user learns, and a button that disappears at the end + * of the list moves the other one under the cursor. It is also what + * every browser does, which is the whole design brief here. + * + * **The buttons dispatch the events the rest of the app already + * dispatches**, `navigate-back` and `navigate-forward`, rather than + * calling `history.back()` themselves. The shell owns the guard -- one + * press is one entry, and at the root there is nothing of ours to go + * back to -- and a second caller reaching for `history` directly is + * how the old `navStack` came to disagree with the platform. + * + * **It is desktop chrome.** Below 600px the phone has a system back + * gesture (and, on Android, a hardware/gesture Back that this app + * hooks), the top bar is 3.25em with three other things in it, and two + * more 32px targets there would be the first thing to overflow. Hidden + * by `index.css` at that width, next to the rest of the phone header's + * concessions. + */ +@customElement('nav-history') +export class NavHistory extends LitElement { + private historyCtrl = new HistoryController(this); + + static override styles = [designTokens, css` + :host { + display: flex; + align-items: center; + gap: 0.25em; + /* A grid item's implicit minimum is its content; this one + genuinely cannot shrink, so it says so rather than + letting the header widen the body. */ + flex: 0 0 auto; + } + + button { + display: flex; + align-items: center; + justify-content: center; + width: 2em; + height: 2em; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: var(--yj-text-primary, #f8f9fa); + cursor: pointer; + font-size: 1em; + } + + button:hover:not(:disabled) { + background-color: var(--yj-bg-overlay, #495057); + } + + button:focus-visible { + outline: 2px solid var(--yj-accent, #ffd43b); + outline-offset: 2px; + } + + button:disabled { + /* Not a contrast failure: a disabled control is exempt from + 1.4.3, and the pair has to read as unavailable rather + than merely quiet. */ + color: var(--yj-text-tertiary, #868e96); + cursor: default; + } + `]; + + private go(direction: 'back' | 'forward') { + this.dispatchEvent(new CustomEvent(`navigate-${direction}`, { + bubbles: true, + composed: true, + })); + } + + override render() { + const { canBack, canForward } = this.historyCtrl.depth; + + return html` + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'nav-history': NavHistory; + } +} diff --git a/frontend/src/icons/names.txt b/frontend/src/icons/names.txt index f0eba3f..6154848 100644 --- a/frontend/src/icons/names.txt +++ b/frontend/src/icons/names.txt @@ -19,6 +19,7 @@ regular/heart regular/star solid/arrow-down-wide-short solid/arrow-left +solid/arrow-right solid/arrow-rotate-right solid/arrows-rotate solid/arrow-up-short-wide diff --git a/frontend/src/services/keyboard-shortcut-service.ts b/frontend/src/services/keyboard-shortcut-service.ts index ed8d049..12689cf 100644 --- a/frontend/src/services/keyboard-shortcut-service.ts +++ b/frontend/src/services/keyboard-shortcut-service.ts @@ -400,6 +400,20 @@ async function dispatch(action: string): Promise { break; } + // The keyboard half of #6. It dispatches the same events the + // header's buttons and the detail views' own back buttons do, + // rather than calling `history.back()` here: the shell owns the + // guard that stops a press at the root leaving the app, and a + // second caller reaching for `history` directly is how the old + // `navStack` came to disagree with the platform. + case 'nav.back': + document.dispatchEvent(new CustomEvent('navigate-back')); + break; + + case 'nav.forward': + document.dispatchEvent(new CustomEvent('navigate-forward')); + break; + case 'nav.queue': { const queuePanel = document.getElementById( 'queue-panel', diff --git a/frontend/src/services/shortcut-meta.ts b/frontend/src/services/shortcut-meta.ts index eb1d0f9..1458b84 100644 --- a/frontend/src/services/shortcut-meta.ts +++ b/frontend/src/services/shortcut-meta.ts @@ -103,6 +103,18 @@ export const SHORTCUT_META: Record = { scope: 'global', defaultKey: 'Q', }, + 'nav.back': { + label: 'Back', + category: 'Navigation', + scope: 'global', + defaultKey: 'Alt+Left', + }, + 'nav.forward': { + label: 'Forward', + category: 'Navigation', + scope: 'global', + defaultKey: 'Alt+Right', + }, 'app.shortcuts': { label: 'Keyboard Shortcuts', category: 'App', diff --git a/frontend/src/store/controllers/history-controller.ts b/frontend/src/store/controllers/history-controller.ts new file mode 100644 index 0000000..3fe77d8 --- /dev/null +++ b/frontend/src/store/controllers/history-controller.ts @@ -0,0 +1,40 @@ +import type { + ReactiveController, + ReactiveControllerHost, +} from 'lit'; +import { historyStore, type HistoryDepth } from '../history-store'; + +/** + * HistoryController connects a Lit component to the HistoryStore. + * + * Usage in a component: + * + * private historyCtrl = new HistoryController(this); + * + * render() { + * const { canBack } = this.historyCtrl.depth; + * } + */ +export class HistoryController implements ReactiveController { + private host: ReactiveControllerHost; + private unsubscribe?: () => void; + + constructor(host: ReactiveControllerHost) { + this.host = host; + host.addController(this); + } + + hostConnected(): void { + this.unsubscribe = historyStore.subscribe(() => { + this.host.requestUpdate(); + }); + } + + hostDisconnected(): void { + this.unsubscribe?.(); + } + + get depth(): HistoryDepth { + return historyStore.get(); + } +} diff --git a/frontend/src/store/history-store.ts b/frontend/src/store/history-store.ts new file mode 100644 index 0000000..ba30064 --- /dev/null +++ b/frontend/src/store/history-store.ts @@ -0,0 +1,66 @@ +/** + * 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(); + + 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(); diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index d957fcc..b0f9f92 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -11,6 +11,9 @@ export { searchStore } from './search-store'; export { SearchController } from './controllers/search-controller'; export { activeViewStore } from './active-view-store'; export { ActiveViewController } from './controllers/active-view-controller'; +export { historyStore } from './history-store'; +export type { HistoryDepth } from './history-store'; +export { HistoryController } from './controllers/history-controller'; export { shortcutsStore } from './shortcuts-store'; export type { ShortcutsState } from './shortcuts-store'; export { ShortcutsController } from './controllers/shortcuts-controller'; diff --git a/frontend/test/components/nav-history.test.ts b/frontend/test/components/nav-history.test.ts new file mode 100644 index 0000000..c46135f --- /dev/null +++ b/frontend/test/components/nav-history.test.ts @@ -0,0 +1,91 @@ +/** + * 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 `