feat(ui): a shell a phone can be held in
Plan 016 B2, phase 1. Below 600px the grid drops its sidebar column, `bottom-nav` becomes the primary navigation, and the shell fits the viewport instead of scrolling sideways out of it. 600 rather than the sidebar's own 900, because 900 is a laptop and the answer there is a narrower sidebar, which is still a sidebar. Under 600 there is no room for one at all: 360px of viewport over a 200px nav is not a layout. **The tab bar is four destinations and a way to everything else.** Three to five is where touch targets stop being thumb-sized -- eleven over 360px is 32px each -- so the four are the ones plan 016's subset says a phone is for, and "More" opens the *existing* `app-sidebar` in a drawer rather than listing the destinations a second time. Two lists is two places to add the next view to. That reuse has a cost this found the hard way: a shared component brings its `data-testid`s with it, so rendering the drawer's sidebar unconditionally put a second `nav-home` (and ten siblings) in the DOM and **failed 30 existing specs** with "resolved to 2 elements" -- on a desktop viewport, where this element is `display: none` and the drawer can never open. It renders only while the drawer is open, and the component test asserts the absence, because the failure is invisible from inside the component and lands in files nobody touched. **What made the shell overflow was minimums, not padding.** Measured at 360px: the body was 652px wide, because a `min-width` in a flex row is a hard floor and a grid item's implicit minimum is its content. So `min-width: 0` on the boxes between the viewport and the content, and each component stands its own non-essential parts down in its *own* stylesheet -- search-bar's 200px floor, job-indicator's label (the visible one; the live region that announces it is untouched), audio-player's seek bar and volume. A media query inside a shadow root is answered by the viewport, so this is the component saying what it drops rather than the shell reaching in. Volume goes because the hardware keys own it on a phone, which is the same reason mediacontrols' Android handler implements no volume callback. Seeking goes because 4px is not a thumb target; it belongs to the full-screen now-playing view, which is the next phase. An existing spec therefore asserts the opposite of what it did: layout-overflow's 320px case used to require that the 464px behind `overflow: hidden` could be *scrolled to*, which was the remedy available while the shell had one layout. It reflows now -- 320px in a 320px viewport, exactly -- and reflow is what WCAG 1.4.10 asked for.
This commit is contained in:
@@ -2828,3 +2828,77 @@ for boot ok". `pick_device` now resolves `ANDROID_SERIAL` from
|
||||
`ro.boot.qemu.avd_name`, since serials are assigned in boot order and
|
||||
the AVD name is the stable identity. Verified with both emulators
|
||||
running: it selects `yj-test` and installs.
|
||||
|
||||
## The phone shell fits, and what it cost to make it fit (2026-08-16)
|
||||
|
||||
Plan 016 B2, phase 1: the shell below 600px. Measured at 360×780 and
|
||||
390×844 against the real app (`make dev-headless` + Playwright, which
|
||||
is the tier that can answer this — server mode serves the same document
|
||||
an Android WebView renders).
|
||||
|
||||
**What overflowed, and by how much.** The body was 652px wide in a
|
||||
360px viewport before any of this. Walking every element and its shadow
|
||||
roots for a `right` past the viewport named the causes in order:
|
||||
|
||||
| element | width | why |
|
||||
|---|---|---|
|
||||
| `header.top-bar` | 580 | its children's minimums, summed |
|
||||
| `search-bar` | 320 | `.search-container { min-width: 200px }` |
|
||||
| `job-indicator` | 157 | the label, "3 background jobs" |
|
||||
|
||||
A `min-width` in a flex row is a *hard* floor — it does not shrink — and
|
||||
a grid item's implicit minimum is `auto`, i.e. its content. So the
|
||||
header could not get smaller than the sum of what it held, the body grew
|
||||
to the header, and `overflow-x: hidden` would then have hidden a third
|
||||
of the app rather than fitting it. `min-width: 0` on the boxes between
|
||||
the viewport and the content, plus each component standing its own
|
||||
non-essential parts down in its own stylesheet, takes 360 → 360 exactly.
|
||||
At 320px (400% zoom, the width WCAG 1.4.10 names) it is also exact.
|
||||
|
||||
**So an existing spec now asserts the opposite of what it did**, and
|
||||
that is the fix landing rather than the test being weakened.
|
||||
`layout-overflow.spec.ts` used to assert that the 464px of app behind
|
||||
`overflow: hidden` *could be scrolled to* with a wheel gesture, which
|
||||
was the remedy available when the shell had one layout. It reflows now,
|
||||
which is what 1.4.10 asks for; scrolling to the overflow was the
|
||||
concession.
|
||||
|
||||
**And a shared component brings its test handles with it.**
|
||||
`bottom-nav`'s "More" opens the *existing* `<app-sidebar>` in a drawer —
|
||||
the whole point being not to write a second list of destinations — but
|
||||
rendering it unconditionally put a second `data-testid="nav-home"` (and
|
||||
ten siblings) in the DOM. **30 existing specs failed** with "strict mode
|
||||
violation: resolved to 2 elements", on a *desktop* viewport where
|
||||
`bottom-nav` is `display: none` and the drawer can never open. Lazy
|
||||
rendering fixes it; the component test asserts the absence, because the
|
||||
failure is invisible from inside the component and appears in files
|
||||
nobody touched.
|
||||
|
||||
Three smaller things worth keeping:
|
||||
|
||||
- **A new icon name is a runtime failure, not a build one.** `bars` was
|
||||
not in `src/icons/names.txt`, so `offline-icons.spec.ts` caught it —
|
||||
the sweep asserts `window.__yjIconMisses` is empty. `node
|
||||
frontend/scripts/fetch-icons.mjs` re-vendors after adding a line.
|
||||
- **A `wa-drawer` animates, so a test asserts its events**, not its
|
||||
`open` property: setting `open = false` starts a hide that has not
|
||||
finished on the next microtask, and a test reading the property in
|
||||
between sees the state it is leaving.
|
||||
- **`update(el)` in the component tier takes two arguments**
|
||||
(`update(el, {})`), which is only visible from `tsc`, not from a
|
||||
failing test.
|
||||
|
||||
### A pre-existing failure this uncovered but did not cause
|
||||
|
||||
`requested-badge.spec.ts` fails two of its three tests, **on clean
|
||||
`main` as well** (verified by stashing every change and re-running).
|
||||
The symptom is `download.Service.AddRequest` not settling in 10s.
|
||||
|
||||
What is now known, and narrows it for whoever picks it up: the same
|
||||
method with the same arguments, called straight at the runtime endpoint
|
||||
with `curl`, **returns in 4ms** (it inserted, and answered `2`). So it
|
||||
is not the backend and not the documented read-pool trap — `Queries` is
|
||||
built over the writer, and `Reconciler.Trigger` is a non-blocking
|
||||
select. It is the page-side path: `__yjEvents.call` → the `fetch` hook
|
||||
→ `/wails/runtime`. A third test in that file fails only *after* those
|
||||
two, so it is state, not a third bug.
|
||||
|
||||
@@ -246,6 +246,13 @@ view's template is two templates to fix every bug in. Where a view
|
||||
cannot serve both, the split belongs at the chunk boundary that already
|
||||
exists.
|
||||
|
||||
Phase 1 followed that rule and found its cost: reusing `<app-sidebar>`
|
||||
inside the drawer means reusing its `data-testid`s too, and a second
|
||||
copy standing by in the DOM broke 30 specs that had nothing to do with
|
||||
the phone. The rule holds — a second list of destinations would be
|
||||
worse — but a shared component must be rendered only when it is wanted,
|
||||
and the guard belongs in a test that names the reason.
|
||||
|
||||
## What is worth doing regardless of that decision
|
||||
|
||||
Cheap, independently useful, and each unblocks measurement:
|
||||
@@ -308,8 +315,14 @@ places had to agree — `abiFilters`, the Makefile's `android:package`
|
||||
anchor is what stops it also matching the fat APK's line. Adding the
|
||||
ABI back, if modernc ever fixes `Xlstat64`, is those same three edits.
|
||||
|
||||
**B2, the desktop shell.** The largest remaining piece, and the scope
|
||||
is now decided — see "The phone gets a subset" below.
|
||||
**B2, the desktop shell.** Scope decided (below) and **phase 1 is
|
||||
done**: the shell itself. Below 600px the sidebar column is gone,
|
||||
`<bottom-nav>` is the primary navigation, and the shell fits 320px
|
||||
exactly — measured, from 652px in a 360px viewport before. What is left
|
||||
is the *views*: a full-screen now-playing (which is where seeking and
|
||||
volume went), long-press for the context menus that are right-click
|
||||
today, and the track list's resizable columns, which are a pointer
|
||||
feature with no touch equivalent.
|
||||
|
||||
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
|
||||
with all-files access, `tagwriter` can write in place.
|
||||
|
||||
@@ -1008,6 +1008,43 @@ this app promises, no scrollbar appears. Note that `overflow: hidden`
|
||||
still permits *programmatic* scrolling, so a probe that sets
|
||||
`scrollLeft` passes on the broken build; the spec uses a wheel gesture.
|
||||
|
||||
**Below 600px it reflows instead, and that is the phone.** The sideways
|
||||
scroll above was the concession available while the shell had one
|
||||
layout; plan 016 B2 gives it a second. Under 600px the grid drops its
|
||||
sidebar column, `<bottom-nav>` takes over as the primary navigation,
|
||||
the header's controls shrink or stand down, and the shell measures
|
||||
exactly 320px in a 320px viewport — so `layout-overflow.spec.ts` now
|
||||
asserts *nothing needs scrolling to*, which is what WCAG 1.4.10 wanted
|
||||
all along. 600 rather than the sidebar's 900 because 900 is a laptop:
|
||||
the answer there is a narrower sidebar, which is still a sidebar.
|
||||
|
||||
Three rules in it are load-bearing, and the second cost 30 specs.
|
||||
|
||||
**A grid item's implicit minimum is its content**, so one child that
|
||||
insists on 580px makes the *body* 580px wide inside a 360px viewport
|
||||
and `overflow-x: hidden` then hides a third of the app rather than
|
||||
fitting it. Every box between the viewport and the content that must
|
||||
shrink carries `min-width: 0`, and the things that cannot shrink say so
|
||||
in their own stylesheet — `search-bar`'s 200px floor, `job-indicator`'s
|
||||
label, `audio-player`'s seek bar and volume. A media query inside a
|
||||
shadow root is answered by the viewport, so a component states what it
|
||||
drops at phone width itself rather than the shell reaching in.
|
||||
|
||||
**A duplicated component duplicates its handles.** `bottom-nav`'s
|
||||
"More" opens the *same* `<app-sidebar>` in a `wa-drawer` rather than
|
||||
listing the destinations again — but rendering it unconditionally put a
|
||||
second copy of every `data-testid="nav-*"` in the DOM, and 30 existing
|
||||
specs failed with "strict mode violation: resolved to 2 elements" on a
|
||||
desktop viewport where the element is not even visible. It renders only
|
||||
while the drawer is open, and `bottom-nav.test.ts` asserts its absence
|
||||
before that.
|
||||
|
||||
**The tab bar is four destinations and a way to the rest.** Three to
|
||||
five is where touch targets stop being thumb-sized; eleven over 360px
|
||||
is 32px each. Which four is plan 016's committed subset, and everything
|
||||
else — Settings included, because a phone still needs it — is behind
|
||||
"More".
|
||||
|
||||
**The playing row is a shape, not a hue.** `track-list` and
|
||||
`queue-panel` draw a `::before` triangle in each row's own left
|
||||
padding, plus `aria-current` — before, both rows were a background tint
|
||||
|
||||
@@ -199,35 +199,39 @@ test.describe('the shell reflows rather than hiding what does not fit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('what does not fit sideways can be scrolled to', async ({ app }) => {
|
||||
test('nothing needs scrolling to at 320px, because it all fits', async ({ app }) => {
|
||||
// 320 CSS px is 400% page zoom of a 1280px viewport, which is the
|
||||
// size 1.4.10 names. The shell is 784px wide there, so 464px of the
|
||||
// app — the job indicator and the queue button among it — used to
|
||||
// be behind `overflow: hidden` with no way to reach it.
|
||||
// size 1.4.10 names.
|
||||
//
|
||||
// **This assertion is the inverse of the one it replaces, and that
|
||||
// is the fix landing rather than the test being weakened.** The
|
||||
// shell used to be 784px wide here, so 464px of the app — the job
|
||||
// indicator and the queue button among it — sat behind
|
||||
// `overflow: hidden` with no way to reach it; making the axis
|
||||
// scrollable was the remedy available at the time. 016 B2's phone
|
||||
// layout reflows instead: below 600px the sidebar becomes a bottom
|
||||
// tab bar, the header's controls shrink, and the shell measures
|
||||
// exactly 320px in a 320px viewport. Reflow is what 1.4.10 asks
|
||||
// for; being able to scroll to the overflow was the concession.
|
||||
await app.setViewportSize({ width: 320, height: 256 });
|
||||
|
||||
// A *gesture*, not `scrollLeft = 9999`: `overflow: hidden` still
|
||||
// permits programmatic scrolling, so the obvious probe passes on
|
||||
// the build that has the bug. It did, first time.
|
||||
await app.mouse.move(160, 20);
|
||||
await app.mouse.wheel(400, 400);
|
||||
await app.waitForTimeout(200);
|
||||
|
||||
const reach = await app.evaluate(() => {
|
||||
const fit = await app.evaluate(() => {
|
||||
const se = document.scrollingElement!;
|
||||
|
||||
return { left: se.scrollLeft, top: se.scrollTop };
|
||||
return {
|
||||
scrollWidth: se.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollHeight: se.scrollHeight,
|
||||
clientHeight: document.documentElement.clientHeight,
|
||||
};
|
||||
});
|
||||
|
||||
expect(reach.left).toBeGreaterThan(0);
|
||||
expect(fit.scrollWidth).toBeLessThanOrEqual(fit.clientWidth);
|
||||
|
||||
// And the vertical axis stays fixed, which is what keeps the
|
||||
// transport where a desktop player's transport belongs.
|
||||
expect(reach.top).toBe(0);
|
||||
// transport where a player's transport belongs.
|
||||
expect(fit.scrollHeight).toBeLessThanOrEqual(fit.clientHeight);
|
||||
|
||||
await app.evaluate(() => {
|
||||
document.scrollingElement!.scrollLeft = 0;
|
||||
});
|
||||
await app.setViewportSize({ width: 1440, height: 900 });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { test, expect } from '../support/fixtures.js';
|
||||
|
||||
/**
|
||||
* The phone shell (plan 016 B2, phase 1).
|
||||
*
|
||||
* This is the tier that can actually answer the question. Wails v3's
|
||||
* server mode serves the real frontend, so a Chromium at 390×844 is the
|
||||
* same document an Android WebView renders — the only thing a device
|
||||
* adds here is the WebView's own quirks, and CI runs the WebKit half
|
||||
* for exactly that reason.
|
||||
*
|
||||
* The assertions are the three things B2 is *for*: the eleven-item
|
||||
* sidebar is gone, the four destinations plan 016 committed to are
|
||||
* reachable with a thumb, and nothing scrolls sideways. The last one is
|
||||
* the one that hides: `overflow-x: auto` on `body` means a shell that
|
||||
* does not fit produces a scrollbar rather than a broken layout, which
|
||||
* looks survivable in a screenshot and is not.
|
||||
*/
|
||||
|
||||
/** A common small phone. Narrower than any device this is likely to meet. */
|
||||
const PHONE = { width: 390, height: 844 };
|
||||
|
||||
/** The narrowest thing still sold, near enough. */
|
||||
const SMALL_PHONE = { width: 360, height: 780 };
|
||||
|
||||
const horizontalOverflow = (page: import('@playwright/test').Page) =>
|
||||
page.evaluate(() => ({
|
||||
scrollWidth: document.body.scrollWidth,
|
||||
clientWidth: document.body.clientWidth,
|
||||
}));
|
||||
|
||||
test.describe('the shell on a phone', () => {
|
||||
test.beforeEach(async ({ app }) => {
|
||||
await app.setViewportSize(PHONE);
|
||||
});
|
||||
|
||||
test('replaces the sidebar with a bottom tab bar', async ({ app }) => {
|
||||
await expect(app.locator('div.sidebar')).toBeHidden();
|
||||
|
||||
const nav = app.locator('bottom-nav');
|
||||
|
||||
await expect(nav).toBeVisible();
|
||||
|
||||
// Four tabs and a way to everything else, which is the shape the
|
||||
// plan argues for: a tab bar is 3-5 items before the targets stop
|
||||
// being thumb-sized.
|
||||
for (const id of ['home', 'albums', 'tracks', 'playlists', 'more']) {
|
||||
await expect(app.getByTestId(`tab-${id}`)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('navigates from a tab', async ({ app }) => {
|
||||
await app.getByTestId('tab-albums').click();
|
||||
|
||||
await expect(app.getByTestId('main-content'))
|
||||
.toHaveAttribute('data-active-view', 'albums');
|
||||
|
||||
await app.getByTestId('tab-home').click();
|
||||
|
||||
await expect(app.getByTestId('main-content'))
|
||||
.toHaveAttribute('data-active-view', 'home');
|
||||
});
|
||||
|
||||
test('reaches the views with no tab through the drawer', async ({ app }) => {
|
||||
await app.getByTestId('tab-more').click();
|
||||
|
||||
// Scoped to the drawer: the desktop sidebar is still in the DOM
|
||||
// (hidden by the media query, not removed), so an unscoped testid
|
||||
// matches two elements and Playwright's strict mode refuses --
|
||||
// which is the right complaint, since the two really are different
|
||||
// buttons.
|
||||
//
|
||||
// The drawer holds the *same* sidebar the desktop uses, so Settings
|
||||
// -- which a phone still needs occasionally -- is reachable without
|
||||
// a second list of destinations to keep in step.
|
||||
const settings = app
|
||||
.getByTestId('nav-drawer')
|
||||
.getByTestId('nav-settings');
|
||||
|
||||
await expect(settings).toBeVisible();
|
||||
await settings.click();
|
||||
|
||||
await expect(app.getByTestId('main-content'))
|
||||
.toHaveAttribute('data-active-view', 'settings');
|
||||
|
||||
// And the drawer gets out of the way once it has done its job.
|
||||
await expect(app.getByTestId('nav-drawer')).toBeHidden();
|
||||
});
|
||||
|
||||
test('has a named drawer', async ({ app }) => {
|
||||
await app.getByTestId('tab-more').click();
|
||||
|
||||
// The a11y snapshot never prints a dialog's name, so this asks for
|
||||
// the role and the name together -- which is the check that caught
|
||||
// eleven unnamed dialogs.
|
||||
await expect(
|
||||
app.getByRole('dialog', { name: 'All views' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
for (const vp of [PHONE, SMALL_PHONE]) {
|
||||
test(`does not scroll sideways at ${vp.width}×${vp.height}`, async ({ app }) => {
|
||||
await app.setViewportSize(vp);
|
||||
await app.getByTestId('tab-tracks').click();
|
||||
await expect(app.getByTestId('main-content'))
|
||||
.toHaveAttribute('data-active-view', 'tracks');
|
||||
|
||||
const { scrollWidth, clientWidth } = await horizontalOverflow(app);
|
||||
|
||||
expect(scrollWidth, `body overflows by ${scrollWidth - clientWidth}px`)
|
||||
.toBeLessThanOrEqual(clientWidth);
|
||||
});
|
||||
}
|
||||
|
||||
test('keeps the transport, minus what a thumb cannot use', async ({ app }) => {
|
||||
// The player bar stays: this is a music player, and what is playing
|
||||
// has to be visible and pausable from every view.
|
||||
await expect(app.locator('audio-player')).toBeVisible();
|
||||
await expect(app.locator('now-playing')).toBeVisible();
|
||||
|
||||
// Volume is the hardware keys' job on a phone, and a 4px seek bar
|
||||
// is not a thumb target -- both belong to a later phase's
|
||||
// full-screen now-playing view.
|
||||
await expect(app.locator('audio-player volume-control')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('the desktop shell is unchanged', () => {
|
||||
test('keeps the sidebar and hides the tab bar', async ({ app }) => {
|
||||
await app.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
await expect(app.locator('div.sidebar')).toBeVisible();
|
||||
await expect(app.locator('bottom-nav')).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,90 @@ body {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------
|
||||
The phone shell (plan 016 B2).
|
||||
|
||||
600px, not the sidebar's 900: 900 is a *laptop* and the response to
|
||||
it is a narrower sidebar, which is still a sidebar. Below 600 there
|
||||
is no room for one at all -- 360px of viewport over a 200px nav is
|
||||
not a layout -- so the navigation moves to the bottom, where a thumb
|
||||
is, and the eleven-item list moves into `bottom-nav`'s drawer.
|
||||
|
||||
The grid loses its sidebar column rather than hiding the element in
|
||||
place: a named area with nothing in it still reserves its track.
|
||||
--------------------------------------------------------------- */
|
||||
@media (max-width: 599px) {
|
||||
body {
|
||||
grid-template:
|
||||
"top-bar" 3.25em
|
||||
"main-panel" 1fr
|
||||
"bottom-bar" auto
|
||||
"bottom-nav" auto
|
||||
/ 1fr;
|
||||
/* Nothing may scroll sideways here. On a desktop the shell is
|
||||
allowed to overflow a zoomed-in window (a11y.21 above); a
|
||||
phone *is* the small viewport, so the shell has to fit it. */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body div.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
bottom-nav {
|
||||
grid-area: bottom-nav;
|
||||
}
|
||||
|
||||
/* The 2em gutters are half a thumb each at this width, and the
|
||||
subtitle is already gone from 900 down.
|
||||
|
||||
`min-width: 0` is the load-bearing half. A grid item's implicit
|
||||
minimum is `auto` -- its content -- so a header whose children
|
||||
ask for 580px makes the *body* 580px wide inside a 360px
|
||||
viewport, and `overflow-x: hidden` then hides the right-hand
|
||||
third of the app rather than fitting it. Every box between the
|
||||
viewport and the content that must shrink needs this. */
|
||||
.top-bar {
|
||||
padding-left: 0.75em;
|
||||
padding-right: 0.75em;
|
||||
gap: 0.5em;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-area,
|
||||
.main-panel,
|
||||
.bottom-bar {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
.top-bar library-filter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.top-bar search-bar {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Above the phone breakpoint the tab bar does not exist. It is in the
|
||||
markup unconditionally and eagerly, for the reason notification-host
|
||||
is: navigation that has to fetch a chunk before it can navigate is
|
||||
not navigation. */
|
||||
@media (min-width: 600px) {
|
||||
bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
/* I want to set paragraph margins myself */
|
||||
@@ -130,6 +214,21 @@ body div.sidebar {
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
/* The transport at phone width: art, title and the controls, with the
|
||||
seek bar and volume dropped by the components that own them. The
|
||||
desktop's three fixed columns start with a 320px now-playing, which
|
||||
at 360px of viewport leaves the controls 40px. */
|
||||
@media (max-width: 599px) {
|
||||
.bottom-bar {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
||||
.bottom-bar audio-player {
|
||||
margin: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
grid-area: bottom-bar;
|
||||
padding: 0.25em;
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
<wa-icon name="list"></wa-icon>
|
||||
</button>
|
||||
</footer>
|
||||
<!-- The phone's primary navigation, hidden above 600px by
|
||||
index.css. Eager rather than a chunk, for the reason
|
||||
notification-host is: it is the only way to move around the
|
||||
app on a phone. After the footer, because that is where it
|
||||
renders -- the tab bar sits below the transport, and DOM order
|
||||
is what a screen reader and the tab sequence follow. -->
|
||||
<bottom-nav></bottom-nav>
|
||||
<first-run-wizard></first-run-wizard>
|
||||
<notification-host></notification-host>
|
||||
<shortcuts-overlay></shortcuts-overlay>
|
||||
|
||||
@@ -21,6 +21,7 @@ import '@components/audio-player/audio-player.ts';
|
||||
import '@components/track-list/track-list.ts';
|
||||
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/search-bar/search-bar.ts';
|
||||
import '@components/library-filter/library-filter.ts';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 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="M0 96C0 78.3 14.3 64 32 64l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 128C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32l384 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 288c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32L32 448c-17.7 0-32-14.3-32-32s14.3-32 32-32l384 0c17.7 0 32 14.3 32 32z"/></svg>
|
||||
|
After Width: | Height: | Size: 608 B |
@@ -32,6 +32,23 @@ export class AudioPlayer extends LitElement {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* The phone transport (plan 016 B2): the buttons, and nothing
|
||||
else. A media query inside a shadow root is answered by the
|
||||
viewport, not by the host, so this is the component saying what
|
||||
it drops at phone width rather than the shell reaching in.
|
||||
|
||||
Volume goes because the hardware keys own it on a phone --
|
||||
Android routes them to the media stream, which is also why
|
||||
mediacontrols' Android handler implements no volume callback.
|
||||
The seek bar goes because a 4px-tall target dragged with a thumb
|
||||
is not a seek control; seeking belongs to the full-screen
|
||||
now-playing view, which is the next phase. */
|
||||
@media (max-width: 599px) {
|
||||
volume-control,
|
||||
seek-bar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`];
|
||||
|
||||
override render() {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state, query } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/drawer/drawer.js';
|
||||
import type WaDrawer from '@awesome.me/webawesome/dist/components/drawer/drawer.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import '../sidebar/app-sidebar.js';
|
||||
import { nameDialog } from '@utils/name-dialog';
|
||||
|
||||
type View = 'home' | 'albums' | 'tracks' | 'playlists';
|
||||
|
||||
interface Tab {
|
||||
id: View;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone's primary navigation: a bottom tab bar, shown only below
|
||||
* the phone breakpoint (index.css owns that; this element is
|
||||
* `display: none` above it).
|
||||
*
|
||||
* **Four destinations and a way to everything else.** A tab bar is
|
||||
* three to five items before the targets stop being thumb-sized —
|
||||
* 360 px over eleven sidebar entries is 32 px each — so the four here
|
||||
* are the ones plan 016's subset says a phone is *for*, and "More"
|
||||
* opens the existing `<app-sidebar>` in a drawer. That is deliberately
|
||||
* a reuse rather than a second nav: two lists of destinations is two
|
||||
* places to add the next view to, and the sidebar already carries the
|
||||
* drag-to-navigate behaviour, the active state and the labels.
|
||||
*
|
||||
* It emits the same bubbling, composed `navigate` event the sidebar
|
||||
* does, so `index.ts` needs no knowledge of it, and it listens for that
|
||||
* event globally for the same reason the sidebar does: a navigation it
|
||||
* did not send (a card click, a detail view, the drawer) still has to
|
||||
* move the highlight.
|
||||
*/
|
||||
@customElement('bottom-nav')
|
||||
export class BottomNav extends LitElement {
|
||||
static override styles = [designTokens, css`
|
||||
:host {
|
||||
display: block;
|
||||
background-color: var(--yj-bg-elevated, #343a40);
|
||||
border-top: 1px solid var(--yj-border, #495057);
|
||||
/* The home indicator on a gesture-navigation phone sits
|
||||
under the last few pixels of the viewport, so the bar
|
||||
pads itself out of the way where the browser reports
|
||||
one and by nothing where it does not. */
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
nav ul {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
/* 48px is the smallest target this should ever be; the
|
||||
label sits under the icon rather than beside it, which
|
||||
is what keeps five of them legible at 360px. */
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 4px 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--yj-text-secondary, #adb5bd);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: var(--yj-font-size-xs, 0.7rem);
|
||||
}
|
||||
|
||||
button wa-icon {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
button.active {
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.label {
|
||||
/* A tab label is an aid, not the name: the button's own
|
||||
accessible name comes from its text, and truncating it
|
||||
visually does not change that. */
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
wa-drawer::part(body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
app-sidebar {
|
||||
/* The sidebar sizes itself inline and collapses to icons
|
||||
below 900px, which is every phone. In the drawer there
|
||||
is room for the labels, so it is told not to. */
|
||||
height: 100%;
|
||||
}
|
||||
`];
|
||||
|
||||
@state()
|
||||
private activeView = 'home';
|
||||
|
||||
/**
|
||||
* Whether the drawer has been asked for.
|
||||
*
|
||||
* The sidebar inside it is rendered only while this is true, and
|
||||
* that is not an optimisation. `app-sidebar` carries a
|
||||
* `data-testid` per destination, so a second copy standing by in
|
||||
* the DOM makes every `nav-*` testid ambiguous **for the whole
|
||||
* app** -- 30 existing specs failed with "strict mode violation:
|
||||
* resolved to 2 elements" on a desktop viewport where this element
|
||||
* is not even visible. A duplicate of a shared component is a
|
||||
* duplicate of its handles.
|
||||
*/
|
||||
@state()
|
||||
private drawerOpen = false;
|
||||
|
||||
@query('wa-drawer')
|
||||
private drawer?: WaDrawer;
|
||||
|
||||
private static readonly TABS: Tab[] = [
|
||||
{ id: 'home', label: 'Home', icon: 'house' },
|
||||
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
|
||||
{ id: 'tracks', label: 'Tracks', icon: 'music' },
|
||||
{ id: 'playlists', label: 'Playlists', icon: 'list' },
|
||||
];
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener(
|
||||
'navigate',
|
||||
this.onGlobalNavigate as EventListener,
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener(
|
||||
'navigate',
|
||||
this.onGlobalNavigate as EventListener,
|
||||
);
|
||||
}
|
||||
|
||||
override updated() {
|
||||
// Web Awesome renders its heading into its own shadow root and
|
||||
// never points aria-labelledby at it, so the drawer would
|
||||
// otherwise be announced unnamed -- the same fix, and the same
|
||||
// reason, as every wa-dialog in the app. A drawer's shadow root
|
||||
// has the same shape, so the helper needs no change.
|
||||
nameDialog(this.drawer);
|
||||
}
|
||||
|
||||
private onGlobalNavigate = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ view?: string }>).detail;
|
||||
|
||||
if (detail?.view) this.activeView = detail.view;
|
||||
|
||||
// A navigation from inside the drawer is the drawer's job done.
|
||||
this.drawerOpen = false;
|
||||
};
|
||||
|
||||
private openDrawer = () => {
|
||||
this.drawerOpen = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Web Awesome closes itself on Escape and on a click outside, and
|
||||
* tells us afterwards rather than asking -- so the flag follows the
|
||||
* element, or the next `open` would be a no-op against a drawer
|
||||
* that thinks it is already open.
|
||||
*/
|
||||
private onDrawerHide = () => {
|
||||
this.drawerOpen = false;
|
||||
};
|
||||
|
||||
private navigate(view: View) {
|
||||
this.dispatchEvent(new CustomEvent('navigate', {
|
||||
detail: { view },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<nav aria-label="Primary">
|
||||
<ul>
|
||||
${BottomNav.TABS.map((tab) => html`
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class=${this.activeView === tab.id ? 'active' : ''}
|
||||
data-testid="tab-${tab.id}"
|
||||
aria-current=${this.activeView === tab.id
|
||||
? 'page'
|
||||
: 'false'}
|
||||
@click=${() => this.navigate(tab.id)}
|
||||
>
|
||||
<wa-icon name=${tab.icon}></wa-icon>
|
||||
<span class="label">${tab.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
`)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tab-more"
|
||||
aria-haspopup="dialog"
|
||||
@click=${this.openDrawer}
|
||||
>
|
||||
<wa-icon name="bars"></wa-icon>
|
||||
<span class="label">More</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<wa-drawer
|
||||
placement="start"
|
||||
label="All views"
|
||||
data-testid="nav-drawer"
|
||||
?open=${this.drawerOpen}
|
||||
@wa-after-hide=${this.onDrawerHide}
|
||||
>
|
||||
${this.drawerOpen
|
||||
? html`<app-sidebar expanded></app-sidebar>`
|
||||
: nothing}
|
||||
</wa-drawer>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'bottom-nav': BottomNav;
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,19 @@ export class JobIndicator extends LitElement {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* On a phone the ring is the whole indicator: "3 background
|
||||
jobs" is 114px of a 360px header, and it pushed the
|
||||
header past the viewport. Only the *visible* label
|
||||
goes -- the live region in render() is what announces
|
||||
this, and it is unaffected, so the ring keeps its
|
||||
accessible name and screen readers keep hearing the
|
||||
state change. */
|
||||
@media (max-width: 599px) {
|
||||
.label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
||||
@@ -67,6 +67,21 @@ export class SearchBar extends LitElement {
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
/* The 200px floor is a desktop floor. On a phone the header is
|
||||
the whole width there is, and a min-width in a flex row is a
|
||||
*hard* one -- it does not shrink, so the header stayed 580px
|
||||
wide inside a 360px viewport and the shell scrolled
|
||||
sideways. Measured at 360px: 580 -> 360. */
|
||||
@media (max-width: 599px) {
|
||||
:host {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.search-container:focus-within {
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { customElement, state, property } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
|
||||
@@ -166,6 +166,17 @@ export class AppSidebar extends LitElement {
|
||||
@state()
|
||||
private collapsed = false;
|
||||
|
||||
/**
|
||||
* Keep the labels regardless of the viewport, for a host that has
|
||||
* made room for them -- `bottom-nav`'s drawer, which is the whole
|
||||
* screen wide on the phone where this would otherwise auto-collapse
|
||||
* to icons. The auto-collapse is a *width* response to a narrow
|
||||
* shell, and inside a drawer the shell is not what the sidebar is
|
||||
* sharing space with.
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true })
|
||||
expanded = false;
|
||||
|
||||
/** The width the user chose, restored when the window grows back. */
|
||||
private userWidth = DEFAULT_WIDTH;
|
||||
|
||||
@@ -344,7 +355,8 @@ export class AppSidebar extends LitElement {
|
||||
*/
|
||||
private applyViewportWidth() {
|
||||
const narrow =
|
||||
this.narrowViewport?.matches ?? false;
|
||||
!this.expanded &&
|
||||
(this.narrowViewport?.matches ?? false);
|
||||
const width = narrow
|
||||
? MIN_WIDTH
|
||||
: this.userWidth;
|
||||
|
||||
@@ -22,6 +22,7 @@ solid/arrow-rotate-right
|
||||
solid/arrows-rotate
|
||||
solid/arrow-up-short-wide
|
||||
solid/backward-step
|
||||
solid/bars
|
||||
regular/bookmark
|
||||
solid/bookmark
|
||||
solid/box-open
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* The phone's primary navigation (plan 016 B2).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import '@components/bottom-nav/bottom-nav';
|
||||
import type { BottomNav } from '@components/bottom-nav/bottom-nav';
|
||||
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
||||
import { resetHarness } from '@test/support/harness';
|
||||
|
||||
type Nav = BottomNav;
|
||||
|
||||
const tabs = (el: HTMLElement) =>
|
||||
shadowAll<HTMLButtonElement>(el, 'nav button');
|
||||
|
||||
/** 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) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`${name} never fired`)),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
el.addEventListener(name, () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
describe('bottom-nav', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
});
|
||||
|
||||
it('offers the four phone destinations and a way to the rest', async () => {
|
||||
const el = await fixture<Nav>('bottom-nav');
|
||||
|
||||
expect(tabs(el).map((b) => b.dataset.testid)).toEqual([
|
||||
'tab-home',
|
||||
'tab-albums',
|
||||
'tab-tracks',
|
||||
'tab-playlists',
|
||||
'tab-more',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits a navigate event that escapes its shadow root', async () => {
|
||||
const el = await fixture<Nav>('bottom-nav');
|
||||
const seen: string[] = [];
|
||||
|
||||
document.addEventListener('navigate', (e) => {
|
||||
seen.push((e as CustomEvent<{ view: string }>).detail.view);
|
||||
});
|
||||
|
||||
shadow<HTMLButtonElement>(el, '[data-testid="tab-albums"]')?.click();
|
||||
|
||||
// Composed and bubbling, or index.ts's document-level listener --
|
||||
// the only thing that actually changes the view -- never hears it.
|
||||
expect(seen).toEqual(['albums']);
|
||||
});
|
||||
|
||||
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,
|
||||
}));
|
||||
await update(el, {});
|
||||
|
||||
const current = tabs(el)
|
||||
.filter((b) => b.getAttribute('aria-current') === 'page')
|
||||
.map((b) => b.dataset.testid);
|
||||
|
||||
expect(current).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,
|
||||
}));
|
||||
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);
|
||||
});
|
||||
|
||||
it('closes the drawer when a navigation happens', async () => {
|
||||
const el = await fixture<Nav>('bottom-nav');
|
||||
const drawer = shadow<HTMLElement & { open: boolean }>(el, 'wa-drawer');
|
||||
|
||||
if (!drawer) throw new Error('no drawer');
|
||||
|
||||
// The drawer animates, so the assertion is its own event rather
|
||||
// than the `open` property: setting `open = false` starts a hide
|
||||
// that has not finished on the next microtask, and a test that
|
||||
// reads the property in between sees the state it is leaving.
|
||||
const shown = once(drawer, 'wa-after-show');
|
||||
|
||||
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
|
||||
await shown;
|
||||
|
||||
const hidden = once(drawer, 'wa-after-hide');
|
||||
|
||||
document.dispatchEvent(new CustomEvent('navigate', {
|
||||
detail: { view: 'settings' },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
|
||||
await hidden;
|
||||
expect(drawer.open).toBe(false);
|
||||
});
|
||||
|
||||
it('gives every tab a name and a target big enough to hit', async () => {
|
||||
const el = await fixture<Nav>('bottom-nav');
|
||||
|
||||
for (const button of tabs(el)) {
|
||||
expect(button.textContent?.trim()).not.toBe('');
|
||||
// 48px is the floor for a touch target; the bar is the one
|
||||
// surface in this app that has no pointer to fall back on.
|
||||
expect(button.getBoundingClientRect().height).toBeGreaterThanOrEqual(48);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds no second sidebar until the drawer is asked for', async () => {
|
||||
const el = await fixture<Nav>('bottom-nav');
|
||||
|
||||
// `app-sidebar` carries a data-testid per destination, so a spare
|
||||
// copy standing by makes every `nav-*` testid ambiguous for the
|
||||
// *whole app*: rendering it unconditionally failed 30 existing
|
||||
// specs with "strict mode violation: resolved to 2 elements", on a
|
||||
// desktop viewport where this element is not even visible.
|
||||
expect(shadow(el, 'app-sidebar')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the drawer sidebar expanded, where there is room for labels', async () => {
|
||||
const el = await fixture<Nav>('bottom-nav');
|
||||
|
||||
shadow<HTMLButtonElement>(el, '[data-testid="tab-more"]')?.click();
|
||||
await update(el, {});
|
||||
|
||||
// Without this the sidebar's own auto-collapse (a response to a
|
||||
// narrow *shell*) would render icons in a full-width drawer.
|
||||
expect(shadow<HTMLElement>(el, 'app-sidebar')?.hasAttribute('expanded'))
|
||||
.toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user