Compare commits

..
Author SHA1 Message Date
logan f34777d3e4 docs: record the one statement of which view is active
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m35s
CI / e2e (pull_request) Successful in 7m2s
It belongs beside the two rules that already keep the history stack and
the in-app back buttons agreeing, and for the same reason: a second
component-local idea of where the user is, is how they came to
disagree.
2026-08-19 16:45:58 -04:00
logan fef8c8b7f6 test(e2e): assert the nav highlight, not the shell's own bookkeeping
`back-navigation.spec.ts` covered exactly the journeys #72 breaks and
was green throughout it, because every assertion in it was
`data-active-view` — which the shell sets on every path including the
back one, and which was the one thing already correct. The same trap
`layout-overflow.spec.ts` set for #69: a spec named for the behaviour,
measuring the plumbing.

The assertions go here rather than in a second file, or the first would
carry on passing vacuously. They are `aria-current="page"` through
`getByRole`, which is the accessible fact — `.active` is a class and
could be restyled without breaking anything real — and the role query
resolves to whichever nav is in the accessibility tree at that
viewport, so one helper covers the sidebar and the tab bar.

Three of the four fail on the build before the fix. The fourth, the
parent staying lit while a detail view is open, passed by accident and
says so.
2026-08-19 16:45:58 -04:00
logan 9d65d9acdd fix(shell): publish the active view, so both navs follow the back path
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
2026-08-19 16:45:47 -04:00
16 changed files with 21 additions and 686 deletions
+1 -37
View File
@@ -945,16 +945,7 @@ 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. **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
the app will close. 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
@@ -997,33 +988,6 @@ 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.** `<nav-history>` 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
+1 -10
View File
@@ -24,19 +24,10 @@ func DefaultBindings() map[string]string {
"player.repeat": "R",
"player.mute": "M",
// 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.
// Navigation (Global scope)
"nav.search": "/",
"nav.searchAlt": "Ctrl+F",
"nav.queue": "Q",
"nav.back": "Alt+Left",
"nav.forward": "Alt+Right",
// App actions
"app.selectAll": "Ctrl+A",
-108
View File
@@ -79,114 +79,6 @@ async function openAnArtist(app: Page): Promise<void> {
);
}
/**
* 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,
+1 -33
View File
@@ -104,15 +104,6 @@ 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;
}
@@ -142,23 +133,6 @@ 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 {
@@ -372,13 +346,7 @@ 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.
`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. */
the drawer's Settings. */
.top-bar library-filter {
display: none;
}
-6
View File
@@ -20,12 +20,6 @@
<!-- a11y.29: a heading level was being used for type size. -->
<p class="subtitle">Music how it was meant to bee.</p>
</hgroup>
<!-- Global back/forward (#6). Before the library filter so the
two navigation controls in this bar are adjacent, and after
the brand because that is where a window's chrome ends and
the app's begins. Hidden below 600px by index.css: the
phone has a system back, and this bar has no room. -->
<nav-history></nav-history>
<library-filter></library-filter>
<search-bar></search-bar>
<job-indicator></job-indicator>
+18 -90
View File
@@ -23,7 +23,6 @@ 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';
@@ -42,7 +41,6 @@ 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';
@@ -217,101 +215,45 @@ 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, 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 };
/** 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 } };
/** 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;
// 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);
}
/** 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;
function recordNavigation(detail: { view: string; [key: string]: any }): void {
// `_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;
}
// `_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 };
// Same URL, deliberately: the app has no routes, and a path a
// reload cannot resolve is worse than no path at all.
if (historyStarted) {
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 }, '');
history.pushState(state, '');
pushedEntries += 1;
} else {
currentIndex = 0;
maxIndex = 0;
history.replaceState({ yjNav: nav, yjIdx: 0 }, '');
history.replaceState(state, '');
historyStarted = true;
}
publishDepth();
}
window.addEventListener('popstate', (e: PopStateEvent) => {
const state = e.state as NavState | null;
const nav = state?.yjNav;
const nav = (e.state as NavState | null)?.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;
// 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();
pushedEntries = Math.max(0, pushedEntries - 1);
void handleNavigate({ ...nav, _isBack: true });
});
@@ -570,18 +512,7 @@ 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 (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();
if (pushedEntries > 0) history.back();
});
// Navigate to the user's configured launch page. Falls back to 'home'
@@ -591,17 +522,14 @@ GetDefaultPage()
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
// 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 },
detail: { view: view || 'home' },
}));
})
.catch(() => {
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { view: 'home', _replace: true },
detail: { view: 'home' },
}));
});
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M502.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L402.7 224 32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l370.7 0-105.4 105.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160z"/></svg>

Before

Width:  |  Height:  |  Size: 532 B

@@ -1,133 +0,0 @@
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`
<button
type="button"
data-testid="history-back"
aria-label="Back"
?disabled=${!canBack}
@click=${() => this.go('back')}
>
<wa-icon name="arrow-left"></wa-icon>
</button>
<button
type="button"
data-testid="history-forward"
aria-label="Forward"
?disabled=${!canForward}
@click=${() => this.go('forward')}
>
<wa-icon name="arrow-right"></wa-icon>
</button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'nav-history': NavHistory;
}
}
-1
View File
@@ -19,7 +19,6 @@ 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
@@ -400,20 +400,6 @@ async function dispatch(action: string): Promise<void> {
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',
-12
View File
@@ -103,18 +103,6 @@ export const SHORTCUT_META: Record<string, ShortcutMeta> = {
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',
@@ -1,40 +0,0 @@
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();
}
}
-66
View File
@@ -1,66 +0,0 @@
/**
* 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();
-3
View File
@@ -11,9 +11,6 @@ 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';
@@ -1,91 +0,0 @@
/**
* 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([]);
});
});
-41
View File
@@ -8,7 +8,6 @@ import { describe, expect, it, beforeEach } from 'vitest';
import { searchStore } from '@store/search-store';
import { activeViewStore } from '@store/active-view-store';
import { historyStore } from '@store/history-store';
import { trackListStore } from '@store/tracklist-store';
import { exploreCache, ARTIST_IMAGE_CACHE_LIMIT } from '@store/explore-cache';
import { Events } from '../../src/events';
@@ -134,46 +133,6 @@ describe('active view store', () => {
});
});
describe('history store', () => {
beforeEach(() => {
historyStore.setDepth(false, false);
});
it('holds both answers, because forward is not back negated', () => {
historyStore.setDepth(true, false);
expect(historyStore.get()).toEqual({ canBack: true, canForward: false });
// The middle of the list: both directions available at once, which
// a single depth counter cannot express and which is the state the
// old `pushedEntries` got wrong.
historyStore.setDepth(true, true);
expect(historyStore.get()).toEqual({ canBack: true, canForward: true });
});
it('does not notify when neither answer changed', () => {
let notifications = 0;
const off = historyStore.subscribe(() => {
notifications += 1;
});
historyStore.setDepth(true, true);
historyStore.setDepth(true, true);
off();
expect(notifications).toBe(1);
});
it('starts with both unavailable, which is the truth at launch', () => {
// A fresh session is one entry deep and that entry is *replaced*,
// not pushed, so there is nothing of ours behind it. A control
// that assumed otherwise would offer a press that does nothing --
// and on Android, one the OS would have used to exit the app.
expect(historyStore.get()).toEqual({ canBack: false, canForward: false });
});
});
describe('track list store', () => {
it('starts from the default column set', () => {
expect(trackListStore.getState().columnIds.length).toBeGreaterThan(0);