Compare commits

..
Author SHA1 Message Date
logan ea16e07c46 docs: record the back/forward chrome and the two launch navigations
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 7m4s
Both belong beside the rules that already keep the history stack
honest: the depth counting, because forward is the case one counter
cannot express, and the second launch navigation, because it is what
silently defeated the first rule on the list.
2026-08-19 18:09:10 -04:00
logan 4f47c85208 test(e2e): cover global back/forward, including the launch entry
Five journeys the control has to get right: nothing offered at the root
in either direction, walking both ways with the availability changing
as it goes, reaching the detail view a tab click left behind (which is
the report), the forward list being dropped when the user navigates
from the middle, and the control standing down below 900px.

The first fails on the build before the launch entry was fixed --
enabled, and doing nothing when pressed. It is the assertion that pins
that defect, which otherwise has no visible symptom on desktop at all.
2026-08-19 18:09:10 -04:00
logan 603728a3fb fix(shell): let the landing page replace the launch entry
The app navigates twice on startup and both are deliberate: the eager
`navigate -> home` that paints without waiting for the backend, and the
configured page `GetDefaultPage()` resolves to a moment later. Only the
first replaced the launch entry, so the second stacked on it and a
fresh session was already one entry deep before the user had touched
anything.

The first back press therefore replayed home over home. On desktop that
was invisible until this branch drew a Back button, which rendered live
at the root and did nothing; on Android `webView.canGoBack()` was true,
so the press that should have exited the app was swallowed -- the exact
fault the replace-the-launch-entry rule exists to prevent, defeated by
there being two launch navigations rather than one.

Guarded on still being at index 0 rather than on a flag: that call is
asynchronous and the user can navigate while it is in flight, so past
the root this is an ordinary navigation and a slow answer cannot
overwrite an entry they made.

Closes #142
2026-08-19 18:09:10 -04:00
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
logan a7ac2b4a3e Merge pull request 'Publish the active view from the shell, so both navs survive the back path' (#141) from fix/72-active-view-broadcast into main
CI / check (push) Successful in 2m29s
CI / e2e (push) Successful in 6m59s
The shell knew which view was active and never said so on the back path, so both navs highlighted the view just left.

Closes #72
2026-08-19 21:10:54 +00:00
logan d347809e6e docs: record the one statement of which view is active
CI / e2e (push) Skipped
CI / check (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 6m53s
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 17:00:39 -04:00
logan a5ffcc22e3 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 17:00:39 -04:00
logan f18691560d 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 17:00:39 -04:00
logan c84a9069ef Merge pull request 'Collapse the page header actions that do not fit, instead of clipping them' (#136) from fix/69-page-header-action-overflow into main
CI / check (push) Successful in 2m33s
CI / e2e (push) Successful in 6m53s
Closes #69
2026-08-19 19:29:15 +00:00
22 changed files with 1221 additions and 86 deletions
+81 -1
View File
@@ -945,13 +945,93 @@ 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
button and the phone's gesture come to disagree about what one press
means.
**And there is one statement of which view is active**, for the same
reason: `popstate` calls `handleNavigate()` directly and dispatches no
`navigate`, so the two nav components — which learned the active view
from that event — kept highlighting the view the user had just *left*.
`store/active-view-store.ts` is the shell saying where the user is, and
both navs read it through `ActiveViewController` rather than holding an
`activeView` of their own.
Four things about it are load-bearing.
**"Please go to X" and "the active view is now X" are different
statements**, and only the first existed — dispatched from 28 call
sites across 18 files. A re-dispatch from inside `handleNavigate` is
not the fix and cannot be: that function is the `document` listener for
`navigate`, so it is an infinite loop.
**It is a store rather than an event, because a component that mounts
after a navigation still has to know.** `bottom-nav`'s "More" drawer
creates its `<app-sidebar>` on open, and that copy had heard no
`navigate` at all — standing on Albums, the drawer opened highlighting
Home. An event has no answer for a listener that was not there.
**A detail view is not a view here**, so the destination it was opened
from stays lit. `app-sidebar` did that by accident (it guarded on
`navItems.some(...)`, so an unmatched name left its highlight alone)
and `bottom-nav` had no such guard and so lit *nothing* — which is why
one looked right and the other looked broken on the same screen.
Whether a view is primary is the shell's fact: `view in VIEW_TAGS` is
passed to `setView`, never re-derived, because a second copy of that
list is a second thing to forget.
**Nothing is lit until the shell has navigated.** The store starts
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
`data-active-view` — the shell's own bookkeeping, which was right the
whole way through — so it was green on the broken build. Same trap as
`layout-overflow.spec.ts` and `page-header`: a spec named for the
behaviour, measuring the plumbing.
**A primary view is cached, not unmounted.** `index.ts` keeps every
primary view in the DOM and toggles a `.view-hidden` class, because that
is what preserves `scrollTop` across navigation — so
+10 -1
View File
@@ -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",
+244
View File
@@ -15,12 +15,49 @@ import { test, expect } from '../support/fixtures.js';
*
* What it cannot answer is whether Android's *gesture* reaches the
* WebView, which is between the OS and the scaffold.
*
* **And `data-active-view` is not the behaviour.** Every assertion here
* used to be that attribute, which the shell sets on every path
* including `_isBack` — so this file was green throughout #72, in
* which both navs highlighted the view the user had just *left*. The
* shell's own bookkeeping was the one thing that was already right;
* what a person sees is `aria-current`, and that is asserted below as
* well. This is the same trap `layout-overflow.spec.ts` set for #69: a
* spec named for the behaviour, measuring the plumbing.
*/
type Page = import('@playwright/test').Page;
const activeView = (page: Page) =>
page.getByTestId('main-content');
/** A common phone, where the bottom bar is the primary navigation. */
const PHONE = { width: 390, height: 844 };
/**
* The nav item for a destination, in whichever navigation is on screen.
*
* Both navs carry a button named `Albums`, and only one of them is ever
* in the accessibility tree — the other is `display: none` — so the
* role query resolves to the one the user can see at this viewport.
* That is the point: the highlight has to be right in both, and #72 was
* two different-looking symptoms of one cause.
*/
const navItem = (page: Page, label: string) =>
page.getByRole('button', { name: label, exact: true });
/**
* `aria-current="page"` is the accessible fact and the assertion worth
* making; `.active` is a class and could be restyled without breaking
* anything real.
*/
async function expectHighlighted(page: Page, label: string): Promise<void> {
await expect(navItem(page, label)).toHaveAttribute('aria-current', 'page');
}
async function expectNotHighlighted(page: Page, label: string): Promise<void> {
await expect(navItem(page, label)).toHaveAttribute('aria-current', 'false');
}
/**
* Open an artist's detail view, which is the deepest ordinary route.
*
@@ -42,6 +79,114 @@ 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,
@@ -71,6 +216,105 @@ test.describe('the back gesture', () => {
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
});
test('leaves the nav highlighting the view it landed on, not the one it left', async ({
app,
}) => {
await app.getByTestId('nav-albums').click();
await expectHighlighted(app, 'Albums');
await app.getByTestId('nav-tracks').click();
await expectHighlighted(app, 'Tracks');
await app.goBack();
// #72, and the half of it the report did not describe: this is
// desktop, and before the shell published the active view *both*
// navs stayed on Tracks. An absent highlight reads as a glitch; a
// confident wrong one is worse, and any back across two primary
// views produced it.
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expectHighlighted(app, 'Albums');
await expectNotHighlighted(app, 'Tracks');
});
test('keeps the parent destination lit while a detail view is open', async ({
app,
}) => {
await app.getByTestId('nav-artists').click();
await expectHighlighted(app, 'Artists');
await openAnArtist(app);
// A detail view is not a destination in either nav, and the user is
// still inside Artists. `app-sidebar` did this by accident -- it
// guarded on its own item list, so an unmatched name left the
// highlight alone -- and that accident is why the sidebar looked
// right on a detail view while the tab bar lit nothing. This test
// therefore passed before the fix and is here to keep the rule from
// being lost while the others are made to pass; the *tab bar's*
// half of it is the phone test below, which did not.
await expectHighlighted(app, 'Artists');
await app.goBack();
await expectHighlighted(app, 'Artists');
});
test('the tab bar survives the same journey on a phone', async ({ app }) => {
await app.setViewportSize(PHONE);
// The reported shape: Albums, open an album, press back. The tab
// bar had a highlight, then no highlight at all, and never got it
// back — `bottom-nav` took the detail view's name, matched it
// against no tab, and lit nothing.
await navItem(app, 'Albums').click();
await expectHighlighted(app, 'Albums');
await app.locator('cover-grid').getByText('Glass Harbour').first().click();
await expect(activeView(app)).toHaveAttribute(
'data-active-view',
'explore-album-details',
);
await expectHighlighted(app, 'Albums');
await app.goBack();
await expect(activeView(app)).toHaveAttribute('data-active-view', 'albums');
await expectHighlighted(app, 'Albums');
});
test('the drawer sidebar opens on the page you are standing on', async ({
app,
}) => {
await app.setViewportSize(PHONE);
await navItem(app, 'Tracks').click();
await expectHighlighted(app, 'Tracks');
// A third symptom of the same cause, found while measuring #72 and
// not in the report: `bottom-nav` mounts its `<app-sidebar>` when
// the drawer opens, so that copy had heard no `navigate` at all and
// showed its own default — Home, from any page in the app. An event
// has no answer for a listener that was not there; a store does.
await navItem(app, 'More').click();
// The element carrying the testid is the `wa-drawer` host, which
// always reports hidden -- what is visible is the `<dialog>` in its
// shadow root -- so the drawer being open is asserted of the
// sidebar it holds rather than of itself.
const drawer = app.getByTestId('nav-drawer');
await expect(drawer.locator('app-sidebar')).toBeVisible();
await expect(drawer.getByTestId('nav-tracks')).toHaveAttribute(
'aria-current',
'page',
);
await expect(drawer.getByTestId('nav-home')).toHaveAttribute(
'aria-current',
'false',
);
});
test('an in-app back button consumes exactly one entry', async ({ app }) => {
await app.getByTestId('nav-tracks').click();
await openAnArtist(app);
+33 -1
View File
@@ -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;
}
+6
View File
@@ -20,6 +20,12 @@
<!-- 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>
+105 -18
View File
@@ -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';
@@ -40,6 +41,8 @@ import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
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';
@@ -214,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 });
});
@@ -279,6 +338,20 @@ async function handleNavigate(
// attribute keeps e2e selectors semantic instead of structural.
mainContent.dataset.activeView = view;
// And publishing it as a *value* is what the nav components read.
// They used to learn the active view from the `navigate` event,
// which only the outbound path dispatches -- so a back-navigation
// left both of them highlighting the view it had just left (#72).
// Re-dispatching `navigate` here is not the fix: this file is a
// document listener for it, so that is an infinite loop, and
// "please go to X" is not the statement being made.
//
// `view in VIEW_TAGS` is the primary/detail split, and it is passed
// rather than re-derived because this table is where it is written
// down. A detail view therefore leaves the tab it was opened from
// lit, which is what the report asks for.
activeViewStore.setView(view, view in VIEW_TAGS);
// --- Primary (cacheable) views ----------------------------------------
if (view in VIEW_TAGS) {
// Remove any active detail view first
@@ -497,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'
@@ -507,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 },
}));
});
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 532 B

@@ -7,6 +7,7 @@ import { designTokens } from '../../styles/tokens.css';
import '../sidebar/app-sidebar.js';
import { nameDialog } from '@utils/name-dialog';
import { ICON_PLAYLIST } from '@utils/icon-language';
import { ActiveViewController } from '@store/controllers/active-view-controller';
type View = 'home' | 'albums' | 'tracks' | 'playlists';
@@ -114,8 +115,19 @@ export class BottomNav extends LitElement {
}
`];
@state()
private activeView = 'home';
/**
* Which tab is lit, read from the shell rather than tracked here.
*
* This was a `@state()` field set from the `navigate` event, which
* only the outbound path dispatches -- so backing out of a detail
* view left the highlight wherever it had been (#72). It had no
* equivalent of `app-sidebar`'s `navItems.some(...)` guard either,
* so a detail view set it to a name matching no tab and *nothing*
* was lit; that asymmetry is why one nav looked broken and the
* other looked fine. The store answers both: a detail view leaves
* the tab it was opened from lit, in both components.
*/
private activeCtrl = new ActiveViewController(this);
/**
* Whether the drawer has been asked for.
@@ -167,12 +179,9 @@ export class BottomNav extends LitElement {
nameDialog(this.drawer);
}
private onGlobalNavigate = (e: Event) => {
const detail = (e as CustomEvent<{ view?: string }>).detail;
if (detail?.view) this.activeView = detail.view;
private onGlobalNavigate = () => {
// A navigation from inside the drawer is the drawer's job done.
// The highlight is not this listener's business any more.
this.drawerOpen = false;
};
@@ -206,9 +215,11 @@ export class BottomNav extends LitElement {
<li>
<button
type="button"
class=${this.activeView === tab.id ? 'active' : ''}
class=${this.activeCtrl.isActive(tab.id)
? 'active'
: ''}
data-testid="tab-${tab.id}"
aria-current=${this.activeView === tab.id
aria-current=${this.activeCtrl.isActive(tab.id)
? 'page'
: 'false'}
@click=${() => this.navigate(tab.id)}
@@ -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`
<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;
}
}
+23 -29
View File
@@ -4,6 +4,7 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import type { DragActiveDetail } from '@utils/drag-controller';
import { ActiveViewController } from '@store/controllers/active-view-controller';
import {
ICON_PLAYLIST,
ICON_AUTOTAG,
@@ -159,11 +160,20 @@ export class AppSidebar extends LitElement {
/** Delay in ms before a drag-hover triggers navigation. */
private static readonly HOVER_NAV_DELAY = 600;
/** Home, because that is where `index.ts` now navigates on startup
* (H-8). The sidebar does not hear a `navigate` it did not send,
* so this default is what keeps `aria-current` honest on arrival. */
@state()
private activeView: View = 'home';
/**
* Which item is lit, read from the shell rather than tracked here.
*
* This used to be a `@state()` field defaulting to `home` -- the
* landing view -- because "the sidebar does not hear a `navigate`
* it did not send". That default was the only honest moment it
* ever had: a back-navigation dispatches no `navigate`, so the
* highlight stayed on the view the user had just left (#72), and
* the copy of this component that `bottom-nav` mounts inside its
* drawer opened on `home` from whatever page you were standing on.
* The shell publishes the active view now, so there is nothing to
* default and nothing to keep in step.
*/
private activeCtrl = new ActiveViewController(this);
@state()
private isDragging = false;
@@ -237,10 +247,6 @@ export class AppSidebar extends LitElement {
'yj-drag-active',
this.onDragActive as EventListener,
);
document.addEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
}
override disconnectedCallback() {
@@ -262,10 +268,6 @@ export class AppSidebar extends LitElement {
'yj-drag-active',
this.onDragActive as EventListener,
);
document.removeEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
this.clearDragHoverTimer();
}
@@ -282,8 +284,9 @@ export class AppSidebar extends LitElement {
<nav aria-label="Main">
<ul>
${this.navItems.map((item) => {
const active = this.activeCtrl.isActive(item.id);
const classes = [
this.activeView === item.id
active
? 'active'
: '',
this.dragHoverView === item.id
@@ -299,7 +302,7 @@ export class AppSidebar extends LitElement {
type="button"
class=${classes}
data-testid="nav-${item.id}"
aria-current=${this.activeView === item.id
aria-current=${active
? 'page'
: 'false'}
@click=${() =>
@@ -382,19 +385,6 @@ export class AppSidebar extends LitElement {
private static readonly DROP_VIEWS: Set<View> =
new Set(['playlists']);
/** Keeps the highlighted nav item in sync with navigation that
* originates outside the sidebar itself (e.g. the launch-page
* dispatch in index.ts). */
private onGlobalNavigate = (
e: CustomEvent<{ view?: string }>,
) => {
const view = e.detail.view;
if (view && this.navItems.some((item) => item.id === view)) {
this.activeView = view as View;
}
};
private onDragActive = (
e: CustomEvent<DragActiveDetail>,
) => {
@@ -460,7 +450,11 @@ export class AppSidebar extends LitElement {
}
private navigate(view: View) {
this.activeView = view;
// No optimistic highlight: the shell answers, and it answers
// synchronously in `handleNavigate` before it awaits anything.
// Setting it here as well is the second opinion this fix
// removes -- it is what let a click's highlight survive a
// navigation the shell then handled differently.
this.dispatchEvent(new CustomEvent('navigate', {
detail: { view },
bubbles: true,
+1
View File
@@ -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
@@ -400,6 +400,20 @@ 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,6 +103,18 @@ 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',
+90
View File
@@ -0,0 +1,90 @@
/**
* Which primary view the app is showing.
*
* The shell has always known this -- `handleNavigate()` sets
* `#main-content`'s `data-active-view` on every path, `_isBack`
* included -- and never told anyone. The nav components learned it
* from the `navigate` CustomEvent instead, which only the *outbound*
* path dispatches: the `popstate` listener calls `handleNavigate()`
* directly. So both navs kept highlighting the view you had just left
* (#72).
*
* The fix cannot be a re-dispatch of `navigate`. `index.ts` is itself a
* document listener for it, so emitting one from inside
* `handleNavigate` is an infinite loop -- and the two statements are
* different anyway: `navigate` means *please go to X*, and 28 call
* sites across 18 files say it. This says *the active view is now X*,
* which only the shell is in a position to say and only once per
* navigation.
*
* Three things about it are load-bearing.
*
* **It is a store rather than an event**, because a component that
* mounts *after* a navigation still has to know. `bottom-nav`'s "More"
* drawer creates its `<app-sidebar>` on open, and that copy had heard
* no `navigate` at all: standing on Albums, the drawer highlighted
* Home -- its `activeView` default, which existed to match the landing
* view and matched nothing else ever after. An event has no answer for
* a listener that was not there; a value does.
*
* **A detail view is not a view here.** Opening one leaves the primary
* view it was opened from lit, which is what #72 asks for and what
* `app-sidebar` used to do by accident -- it guarded on
* `navItems.some(...)`, so a name matching no item left its highlight
* alone. `bottom-nav` had no such guard and so lit nothing on a detail
* view. Neither was correct; the sidebar was stale-but-lucky, and
* stating the rule once is what makes the two agree.
*
* **Whether a view is primary is the shell's fact, not this store's.**
* `VIEW_TAGS` in `index.ts` is the list, and a copy of it here is a
* second list to forget -- so the caller passes the answer it already
* has rather than this file re-deriving it.
*/
type Subscriber = () => void;
class ActiveViewStore {
/** Empty until the shell's first navigation, which happens at
* startup from `GetDefaultPage()`. Nothing is highlighted for that
* moment, which is honest: the alternative is a written-down
* default that is right only when the default page agrees with it. */
private activeView = '';
private subscribers = new Set<Subscriber>();
/** The active primary view, e.g. `albums`. */
get(): string {
return this.activeView;
}
isActive(view: string): boolean {
return this.activeView !== '' && this.activeView === view;
}
/**
* Called by the shell on every navigation, `popstate` included.
*
* `isPrimary` is `view in VIEW_TAGS` at the call site: a detail
* view reports itself and deliberately changes nothing, so the view
* it was opened from stays lit until the user picks another one.
*/
setView(view: string, isPrimary: boolean): void {
if (!isPrimary) return;
if (view === this.activeView) return;
this.activeView = view;
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 activeViewStore = new ActiveViewStore();
@@ -0,0 +1,59 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from 'lit';
import { activeViewStore } from '../active-view-store';
/**
* ActiveViewController connects a Lit component to the
* ActiveViewStore.
*
* Usage in a component:
*
* private activeCtrl = new ActiveViewController(this);
*
* render() {
* const lit = this.activeCtrl.isActive('albums');
* }
*
* It reads through to the store rather than copying the value into a
* `@state()` field, which is the point of #72: two components holding
* their own idea of the active view is what let them disagree with the
* shell and with each other.
*/
export class ActiveViewController implements ReactiveController {
private host: ReactiveControllerHost;
private unsubscribe?: () => void;
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
// ===============================================================
// LIFECYCLE HOOKS
// ===============================================================
hostConnected(): void {
this.unsubscribe = activeViewStore.subscribe(() => {
this.host.requestUpdate();
});
}
hostDisconnected(): void {
this.unsubscribe?.();
}
// ===============================================================
// DATA ACCESS
// ===============================================================
/** The active primary view, e.g. `albums`. */
get current(): string {
return activeViewStore.get();
}
isActive(view: string): boolean {
return activeViewStore.isActive(view);
}
}
@@ -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();
}
}
+66
View File
@@ -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<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();
+5
View File
@@ -9,6 +9,11 @@ export type { ThemeState, BackgroundShade } from './theme-store';
export { ThemeController } from './controllers/theme-controller';
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';
+54 -23
View File
@@ -3,15 +3,21 @@
*
* Three of these are about the thing that makes a second nav dangerous:
* it has to agree with the first one. `bottom-nav` emits the same
* bubbling, composed `navigate` event `app-sidebar` does and listens
* for that event globally, so a navigation from anywhere — a card, a
* detail view, the drawer's own sidebar — moves its highlight too. A
* tab bar that only tracks its own clicks looks right until the moment
* the user arrives somewhere by another route.
* bubbling, composed `navigate` event `app-sidebar` does, and reads
* which tab is lit from `activeViewStore`the shell's one statement
* of where the user is — so it follows a navigation from anywhere: a
* card, a detail view, the drawer's own sidebar, or the back gesture.
*
* That last one is why the source is the store and not the `navigate`
* event these tests used to dispatch. `popstate` dispatches no
* `navigate` (index.ts calls `handleNavigate` directly), so a tab bar
* listening for the event looked right until the user pressed back —
* #72.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/bottom-nav/bottom-nav';
import { activeViewStore } from '@store/active-view-store';
import type { BottomNav } from '@components/bottom-nav/bottom-nav';
import { fixture, shadow, shadowAll, update } from '@test/support/render';
import { resetHarness } from '@test/support/harness';
@@ -21,6 +27,12 @@ type Nav = BottomNav;
const tabs = (el: HTMLElement) =>
shadowAll<HTMLButtonElement>(el, 'nav button');
/** The testids of whatever the bar says is the current page. */
const current = (el: HTMLElement) =>
tabs(el)
.filter((b) => b.getAttribute('aria-current') === 'page')
.map((b) => b.dataset.testid);
/** Resolve on one occurrence of an event, or reject loudly on time. */
const once = (el: Element, name: string, timeoutMs = 2000) =>
new Promise<void>((resolve, reject) => {
@@ -70,36 +82,55 @@ describe('bottom-nav', () => {
it('follows a navigation it did not send', async () => {
const el = await fixture<Nav>('bottom-nav');
document.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'tracks' },
bubbles: true,
composed: true,
}));
activeViewStore.setView('tracks', true);
await update(el, {});
const current = tabs(el)
.filter((b) => b.getAttribute('aria-current') === 'page')
.map((b) => b.dataset.testid);
expect(current).toEqual(['tab-tracks']);
expect(current(el)).toEqual(['tab-tracks']);
});
it('marks exactly one tab current, and none for a view it has no tab for', async () => {
const el = await fixture<Nav>('bottom-nav');
document.dispatchEvent(new CustomEvent('navigate', {
detail: { view: 'settings' },
bubbles: true,
composed: true,
}));
activeViewStore.setView('settings', true);
await update(el, {});
// Settings lives in the drawer, so nothing in the bar is current.
// Leaving Home highlighted would be a tab bar lying about where
// the user is.
expect(
tabs(el).filter((b) => b.getAttribute('aria-current') === 'page'),
).toHaveLength(0);
expect(current(el)).toEqual([]);
});
it('keeps the parent tab lit while a detail view is open', async () => {
const el = await fixture<Nav>('bottom-nav');
activeViewStore.setView('albums', true);
// A detail view reports itself and is not primary, so it changes
// nothing. This is the first half of #72: the bar used to take the
// name, match it against no tab, and light nothing at all — while
// `app-sidebar`, which guarded on its own item list, kept the
// highlight. Neither was deliberate and the two disagreed.
activeViewStore.setView('explore-album-details', false);
await update(el, {});
expect(current(el)).toEqual(['tab-albums']);
});
it('follows the back path, which dispatches no navigate event', async () => {
const el = await fixture<Nav>('bottom-nav');
activeViewStore.setView('albums', true);
activeViewStore.setView('tracks', true);
await update(el, {});
expect(current(el)).toEqual(['tab-tracks']);
// What `popstate` does: the shell replays the entry through
// `handleNavigate` without dispatching `navigate`. A bar listening
// for the event stayed on Tracks — the view just left, confidently
// wrong rather than merely blank.
activeViewStore.setView('albums', true);
await update(el, {});
expect(current(el)).toEqual(['tab-albums']);
});
it('closes the drawer when a navigation happens', async () => {
+36 -1
View File
@@ -10,6 +10,7 @@ import '@components/sidebar/app-sidebar';
import '@components/library-filter/library-filter';
import '@components/library-status-indicator/library-status-indicator';
import { Events } from '../../src/events';
import { activeViewStore } from '@store/active-view-store';
import { emit, stub, flush, calls, lastArgs } from '@test/support/harness';
import {
fixture,
@@ -49,6 +50,8 @@ describe('<app-sidebar>', () => {
});
it('marks exactly one item as the current page', async () => {
activeViewStore.setView('home', true);
const el = await fixture('app-sidebar');
const current = shadowAll(el, 'li button').filter(
@@ -72,17 +75,49 @@ describe('<app-sidebar>', () => {
expect(seen).toEqual(['artists']);
});
it('moves aria-current to the clicked destination', async () => {
it('moves aria-current with the shell, not with the click', async () => {
activeViewStore.setView('home', true);
const el = await fixture('app-sidebar');
shadow<HTMLElement>(el, '[data-testid="nav-genres"]')?.click();
await el.updateComplete;
// The click asks; it does not answer. The sidebar used to move its
// own highlight optimistically, which is the second opinion #72
// removed -- one component deciding where the user is, while the
// shell decided separately and `bottom-nav` decided a third way.
expect(
shadow(el, '[data-testid="nav-genres"]')?.getAttribute('aria-current'),
).toBe('false');
// What the shell does with that event, in one line.
activeViewStore.setView('genres', true);
await update(el, {});
expect(
shadow(el, '[data-testid="nav-genres"]')?.getAttribute('aria-current'),
).toBe('page');
});
it('follows the back path, which dispatches no navigate event', async () => {
activeViewStore.setView('albums', true);
const el = await fixture('app-sidebar');
// `popstate` replays an entry through `handleNavigate` directly, so
// there is no `navigate` event to hear -- which is why the sidebar
// stayed on the view the user had just left (#72).
activeViewStore.setView('tracks', true);
await update(el, {});
expect(
shadowAll(el, 'li button')
.filter((item) => item.getAttribute('aria-current') === 'page')
.map((item) => item.getAttribute('data-testid')),
).toEqual(['nav-tracks']);
});
it('looks the way it did last time', async () => {
const el = await fixture('app-sidebar');
@@ -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 `<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([]);
});
});
+97 -3
View File
@@ -1,11 +1,14 @@
/**
* The three small stores behind view chrome: the global search term,
* the track list's column set, and the explore cache that keeps detail
* pages from re-fetching what a search already returned.
* The small stores behind view chrome: the global search term, the
* active view both navs highlight, the track list's column set, and
* the explore cache that keeps detail pages from re-fetching what a
* search already returned.
*/
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';
@@ -80,6 +83,97 @@ describe('search store', () => {
});
});
describe('active view store', () => {
beforeEach(() => {
activeViewStore.setView('home', true);
});
it('holds the primary view the shell navigated to', () => {
activeViewStore.setView('albums', true);
expect(activeViewStore.get()).toBe('albums');
expect(activeViewStore.isActive('albums')).toBe(true);
expect(activeViewStore.isActive('tracks')).toBe(false);
});
it('leaves the primary view lit while a detail view is open', () => {
activeViewStore.setView('albums', true);
activeViewStore.setView('explore-album-details', false);
// #72's third finding, made deliberate: a detail view is not a
// destination in either nav, and the tab it was opened from is
// where the user still is. `app-sidebar` did this by accident (it
// guarded on its own item list) and `bottom-nav` did not do it at
// all, which is why one looked right and the other looked broken.
expect(activeViewStore.get()).toBe('albums');
});
it('does not notify when the view is unchanged', () => {
let notifications = 0;
const off = activeViewStore.subscribe(() => {
notifications += 1;
});
activeViewStore.setView('albums', true);
activeViewStore.setView('albums', true);
activeViewStore.setView('explore-album-details', false);
off();
expect(notifications).toBe(1);
});
it('lights nothing for a view with no name', () => {
// The store starts empty rather than defaulting to a view, because
// a written-down default is right only while `GetDefaultPage()`
// agrees with it. That is only safe if the empty value matches
// nothing: `isActive` compares strings, and a component asking
// about an id it does not have must not light up.
activeViewStore.setView('', true);
expect(activeViewStore.isActive('')).toBe(false);
});
});
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);