Merge pull request 'Drop the phone's top bar; search becomes a button and a modal' (#167) from feat/57-drop-the-android-top-bar into main
CI / check (push) Successful in 2m30s
CI / e2e (push) Successful in 8m17s

This commit was merged in pull request #167.
This commit is contained in:
2026-08-21 00:18:33 +00:00
18 changed files with 1449 additions and 87 deletions
+79
View File
@@ -4350,3 +4350,82 @@ The corollary for anything that draws over the shell: **run the whole
e2e suite, not the spec you wrote.** A spec written for a feature
asserts the feature works; what a new overlay breaks is everything
else, and only the suite is looking at that.
## `contain: paint` is why a Web Awesome popup is clipped on the device (read 2026-08-20, applied 2026-08-21)
Recorded here because it outlives #57 and #60 both, and because the
next person to reach for a floating surface will reach for `wa-popup`.
`wa-popup` renders `<div popover="manual">` and feature-detects the
Popover API, falling back to `strategy: "fixed"` where there is none.
The reference device is Chrome 113 and `popover` is Chrome 114, so
every popup in the app takes the fallback there. `position: fixed`
escapes ancestor *overflow* but not `contain: paint`, which makes an
element a containing block for fixed descendants **and clips them** —
and `index.css` puts `contain: layout style paint` on `.main-panel`
and on `div.sidebar`.
So the rule is: **a floating surface opened from inside the main panel
must be a `wa-dialog`, not a `wa-popup`,** because `<dialog>` /
`showModal()` is Chrome 37 and uses the real top layer. #57's search
modal is one on that ground alone; #60 is the same finding applied to
the six context menus.
The half that costs time is the second one. **No tier here can
reproduce the clip.** CI's Chromium and WebKit both have the Popover
API, so a popup is top-layered and correct, and a spec asserting "the
surface is not clipped" is green on the broken build. Assert the
*mechanism* — that there is a native `<dialog>` in the tree at phone
width — which is the one form of the question a browser here answers
honestly.
## Removing the phone's top bar cost the page header its count (measured 2026-08-21)
#57 deletes the `top-bar` grid row below 600px and puts a 40px search
button in `page-header` instead. That button is 43px more than the row
has at 320px, which is a width the app promises (WCAG 1.4.10 reflow,
and `header-action-overflow.spec.ts` asks about it).
Measured on Playlists at 320px, after the fit pass had already
collapsed all three actions into "More actions" and truncated the title
to nothing: title 0, count 50, sort 143, search 40, More 38, five 12px
gaps, 32px of gutters — **363 in 320**, with the More button ending
27px past the edge. So an *action* was clipped, which is the exact
defect #69 exists to prevent.
What yields is the **count**, last, after everything else. It is the
only item on that row that is neither an identity (the title, which the
navigation repeats) nor an action (the sort control and the buttons,
each the only place they are said). With it gone the header is 304 in
304 and the title even comes back to 19px.
Two things worth keeping:
- **The failure was found by the suite, not by the spec.** `make
ui-test` (964), `tsc` in both packages, `make lint`, `make test` and
the new `phone-search.spec.ts` were all green; what failed was
`header-action-overflow.spec.ts` at 320×600, which has nothing to do
with search. That is #62's lesson holding for a second change in a
row: anything that adds to or reflows the shell has a blast radius
the spec you wrote cannot see.
- **A collapsed thing has to still be in the DOM.** Returning `nothing`
from `renderCount()` would have taken the count away for the rest of
the session the first time a 320px window appeared, because
`measureFit` starts every pass from all-visible and needs a node to
un-hide. Same shape as the action buttons, which is where the pattern
was already written down.
## The e2e app is long-lived, so a staged job outlives the spec that staged it (measured 2026-08-21)
`make dev-headless` runs one app across every `make e2e` invocation, and
`/__test/emit` writes to a store that nothing clears. A first draft of
`phone-search.spec.ts` asserted the content starts at y=0 with the top
bar gone; it passed alone and failed in a suite run, because
`top-bar-fit.spec.ts` had staged a long-titled scan and `<job-band>` is
a real grid row whenever work is in flight.
The fix is not `beforeEach` cleanup — it is measuring the right thing:
the content starts where the **row above it** ends, which is true with a
job running and without one. An assertion against an absolute
coordinate was quietly also asserting "and no background job exists",
which is not something that spec is about or can arrange.
+108 -11
View File
@@ -1559,8 +1559,8 @@ still permits *programmatic* scrolling, so a probe that sets
**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
sidebar column *and* (since #57) its top-bar row, `<bottom-nav>` takes
over as the primary navigation, 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:
@@ -1601,9 +1601,36 @@ three, *no action is ever unreachable at any supported size*. The bands
themselves already existed; what was new is that they are a promise and
that the queue panel is inside it.
**The top bar decides what it can afford, and what it gives up is never
an action.** Its five children do not fit at the bottom of the Compact
band: the bar was 611px inside a 600px viewport idle and **862px while
**And below 600px there is no top bar at all** (#57). The row is gone
from the phone's grid template — not the header hidden, the row deleted
— which is 3.25em of a 439 CSS px viewport, the single biggest vertical
win the reference device has to give. Each of its five children has
somewhere else to be there: `nav-history` is the platform's own back
gesture (already gone from 899 down), the job indicator is `<job-band>`
(#62, which is why this was blocked on it), the search box is a
`wa-dialog` opened from the view's own header, the library filter is
Settings → Libraries (#148), and the wordmark stays exactly where it is.
Three things about it are load-bearing. **The header is visually hidden
rather than `display: none`**, because that `h1` is the document's
top-level heading and several pages have no other one — `page-header`
renders no `h1` when `heading` is `''`, and Settings has no
`page-header` at all. Its four *controls* are `display: none` inside it,
which is what keeps them out of the tab order: a visually-hidden
container is still focusable, and tabbing into a search box nobody can
see is worse than not having one. **The fit pass stands down**, from the
bar's computed `position` rather than from a width — with the bar out of
flow there is no content box to measure children against, and a pass
that ran would collapse the wordmark every time and report success about
a 1px box. And **`top-bar-fit.spec.ts` keeps 390 in its list and asserts
the stronger property there**: "nothing hangs out of the bar" is
trivially true of a bar with no row, and would have passed on a build
that merely broke it, so what that width asks now is that the content
starts where the row above it ends.
**Above 600px the top bar decides what it can afford, and what it gives
up is never an action.** Its five children do not fit at the bottom of
the Compact band: the bar was 611px inside a 600px viewport idle and **862px while
a scan ran**, because `job-indicator` is `hidden` when idle and 235px
wide showing a real library's scan title (#143). So `services/
top-bar-fit.ts` is `page-header`'s treatment one bar up — a
@@ -1624,12 +1651,17 @@ fixed whichever case happened to be idle when it was measured.
**What yields is decided by the promise above, which rules out the two
cheapest answers.** Hiding the library filter takes away an action —
`library-filter` is the only control in the app that calls
`setSelectedLibrary` — so it trades this promise for the same promise
(#148 is the phone already doing that). Collapsing the search box to an
icon is what #57 wants and #57 is blocked behind #62, so building it
here is building it without the thing that blocks it. The two that
yield are the two that are **not** actions: the wordmark, which the
`library-filter` was the only control in the app that called
`setSelectedLibrary` — so it trades this promise for the same promise.
That is #148, and #57 fixed it by giving the selection a *second
placement* rather than a second definition: the same component, in
Settings → Libraries under a "Showing" label, at every width. A
phone-only copy was the obvious cheaper answer and is the fault, not the
fix — "where do I change which library I am browsing" having two answers
by viewport is exactly what one control in two places avoids.
Collapsing the search box to an icon is what #57 wanted and #57 was
blocked behind #62, so building it here would have been building it
without the thing that blocked it. The two that yield are the two that are **not** actions: the wordmark, which the
window's own title bar repeats and which #48 wants down to "YJ" at
every width anyway, and then the job indicator's *label*, leaving the
ring — which is not a new judgement, since the component already drops
@@ -2403,6 +2435,23 @@ Six things about it are load-bearing:
without that half it would pass vacuously on a build that renders no
actions at all.
**The count is the last thing to yield, and only at 320px.** Four
things compete for that row and three of them cannot go: the title
yields first and is allowed to ellipsis away entirely, because the
navigation also says which page you are on; the sort control and the
actions are each the only place they are said, which is what the
overflow menu exists for. That leaves the count, which is the one
purely informational item there — an empty page says so in its empty
state and a full one is being looked at. It became reachable rather
than theoretical with #57, since below 600px this header also carries
the phone's search button: measured on Playlists at 320px, title 0,
count 50, sort 143, search 40, "More actions" 38, five 12px gaps and
32px of gutters — 363 in 320, with the More button ending 27px past
the edge. It is rendered and hidden with an attribute rather than
returned as `nothing`, for the reason the action buttons are: every
pass starts from all-visible and needs a node to un-hide, or the first
320px window costs the count for the rest of the session.
One thing it deliberately does **not** grow is a phone mode for the
actions. `PHONE_COLUMN_IDS` is the precedent for "what is drawn and
what can be sorted are different questions", but it exists because the
@@ -2430,6 +2479,54 @@ term belongs in that map**, detail views included —
placeholder saying there was nothing to search here, because its
sibling was in the map and it was not.
**On a phone the box is a modal, and the map is what decides who gets
one** (#57). There is no header to hold it below 600px, so
`<search-trigger>` is a button in the row that already says which page
you are on and `<search-dialog>` is where the box goes — and both ask
`searchStore.isSearchableView()` rather than being told, which is the
whole reason the trigger is an element and not a `PageAction`. Seven
hosts each declaring a search action would be a second list of
searchable views, and putting the decision inside `page-header` would
be the phone mode for actions that component documents its refusal to
grow.
Four things about it are load-bearing.
**It is a `wa-dialog`, and that is a mechanism rather than a taste.**
#60 read out of the Web Awesome source that `wa-popup` renders
`<div popover="manual">` and feature-detects the Popover API, falling
back to `strategy: "fixed"` where there is none — which is Chrome 113,
the reference device, since `popover` is Chrome 114. `position: fixed`
escapes ancestor overflow but **not** `contain: paint`, which
`.main-panel` carries, so a popup-shaped search panel opened from a
view's header is structurally clipped on that device. `<dialog>` /
`showModal()` is Chrome 37 and uses the real top layer. **No tier here
can see the difference** — CI's Chromium and WebKit both have the
Popover API, so the popup would be top-layered and correct and a spec
asserting "not clipped" would pass on the broken build. The component
tier asserts the *mechanism* instead: that there is a native `<dialog>`
in the tree.
**It carries the real `<search-bar>`**, not a second input, which is
what keeps one debounce, one clear button and one view-scoped
placeholder. `--yj-search-max-width` is the one thing the modal changes
about it: 360px is a cap for a header, not for a control that has the
whole of a 424px screen.
**The results are the page, not a list in the modal.** The term is
view-scoped and the view behind already filters on it and says
"Showing tracks matching …", so Enter closes and hands the screen back.
Rendering results in the dialog would be a second implementation of
every view's filtering, and one that could not offer the row actions
the view does.
**Escape closes and keeps the term.** `search-bar`'s own input treats
Escape as *clear the search*, which is right in a header where the box
is on screen either way; in a modal it would mean dismissing the search
surface silently discarded the search. The dialog takes the key in the
capture phase on its own host, which is the only listener that runs
before the input inside `search-bar`'s shadow root.
**The window's minimum is measured, not aspirational.** `MinWidth`/
`MinHeight` are 800×600 because that is where the shell was checked to
still work: below ~780 the header subtitle wraps and pushes the title
+322
View File
@@ -0,0 +1,322 @@
import { test, expect } from '../support/fixtures.js';
/**
* #57. Below 600px the top bar is not in the layout, and search is a
* button that opens a modal on the pages where searching means
* anything.
*
* **This is the tier that can answer it, with one honest exception.**
* The shell's breakpoints are media queries, which the component tier
* cannot set so whether the bar is a grid row, and whether a header
* grows a search button, is a question for a real viewport. What this
* tier *cannot* answer is the reason the surface is a `wa-dialog`:
* #60 read out of the Web Awesome source that `wa-popup` falls back to
* `position: fixed` where there is no Popover API (Chrome 113, the
* reference device) and that `.main-panel`'s `contain: paint` clips a
* fixed descendant. Chromium and WebKit here both have the Popover API,
* so a popup is top-layered and correct, and **an assertion that the
* modal is not clipped would pass on the broken build.** The mechanism
* is asserted in `frontend/test/components/search-dialog.test.ts`
* instead, where "is there a native <dialog>" is a question a browser
* can answer without lying.
*
* **And it is measured per element.** `layout-overflow.spec.ts` asks
* whether the *shell* needs sideways scrolling and was green throughout
* the defect it is named for; the win this issue is for is vertical and
* belongs to one element, so it is that element's box that is read.
*/
type Page = import('@playwright/test').Page;
/** The reference device's own viewport, and a common small phone. */
const DEVICE = { width: 424, height: 439 };
const PHONE = { width: 390, height: 780 };
/**
* Where the top bar is, and how much of the screen it costs.
*
* `contentTop` is measured against the *jobs band* rather than against
* the window, because that band is a real grid row whenever work is in
* flight (#62) and the app under these specs is long-lived a job
* staged by another file is still in the store. Measuring against zero
* makes this assertion say "and no background job is running", which is
* not what it is for and is not something it can arrange.
*/
const barBox = (page: Page) =>
page.evaluate(() => {
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
const main = document.querySelector<HTMLElement>('.main-panel')!;
const band = document.querySelector<HTMLElement>('job-band');
const cs = getComputedStyle(bar);
return {
position: cs.position,
height: Math.round(bar.getBoundingClientRect().height),
/** Where the content starts, and where the row above it ends. */
contentTop: Math.round(main.getBoundingClientRect().top),
aboveBottom: Math.round(band?.getBoundingClientRect().bottom ?? 0),
};
});
test.describe('the phone has no top bar', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
/**
* The vertical win, measured rather than asserted by the absence of
* an element: `display: none` on the header would satisfy "the bar is
* hidden" while leaving a 3.25em grid row exactly where it was.
*/
test('gives the row back to the content', async ({ app }) => {
const box = await barBox(app);
// Out of flow, so it takes no row — and 1px rather than 0, because
// it still carries the document's h1.
expect(box.position).toBe('absolute');
expect(box.height).toBeLessThanOrEqual(1);
// The content starts where the row above it ends, and there is no
// row above it but the jobs band. On `main` at the time of writing
// the content started 52px down from that point.
expect(box.contentTop).toBe(box.aboveBottom);
});
/**
* The wordmark yields its width and not its existence, which is the
* rule `top-bar-fit.ts` already lives by one band up: with the bar
* gone, `display: none` would take this document from one top-level
* heading to none on every page whose own header has no h1
* Settings has no `page-header` at all.
*/
test('still has a top-level heading', async ({ app }) => {
await expect(
app.getByRole('heading', { name: 'YellowJacket', level: 1 }),
).toHaveCount(1);
});
/**
* And its four controls are gone from the tab order, not merely from
* sight. A visually-hidden container is still focusable, and tabbing
* into a search box nobody can see is worse than not having one.
*/
test('leaves nothing in the bar to tab into', async ({ app }) => {
for (const tag of [
'nav-history',
'library-filter',
'search-bar',
'job-indicator',
]) {
await expect(app.locator(`header.top-bar ${tag}`)).toBeHidden();
}
const focusable = await app.evaluate(
() =>
document
.querySelector('header.top-bar')!
.querySelectorAll('input, select, button, a[href]').length,
);
// Nothing in the bar is *rendered*, so nothing in it can be
// focused; the controls are display:none, which takes their own
// shadow content with them.
expect(focusable).toBe(0);
});
});
test.describe('search on a phone', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(PHONE);
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('is a button in the view that can be searched', async ({ app }) => {
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
// Scoped to the view: every cached primary view holds a
// `page-header`, and an unscoped testid is `bottom-nav`'s
// "resolved to 2 elements" trap again.
const trigger = app.locator('track-list page-header search-trigger button');
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAttribute('aria-label', 'Search tracks');
});
/**
* The whole journey, which is the thing the issue asks for: a button,
* a modal, and the results on the page behind it saying what they are
* showing.
*/
test('opens a modal, filters the page, and says so', async ({ app }) => {
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
await app.locator('track-list page-header search-trigger button').click();
const dialog = app.getByTestId('search-dialog');
// Attached, not visible: `wa-dialog`'s host is `display: contents`,
// so the element carrying the testid always reports hidden — what
// is visible is the native `<dialog>` inside it. That awkwardness
// is written down in CLAUDE.md and is why the assertion that this
// is really up is the role query below.
await expect(dialog).toBeAttached();
// Named, which `getByRole` can answer and the a11y snapshot cannot
// — the snapshot never prints a dialog's name, named or not. This
// is also the assertion that the dialog is genuinely showing.
await expect(
app.getByRole('dialog', { name: 'Search tracks' }),
).toBeVisible();
// Scoped: the header's own box is still in the document, hidden.
// This is the one moment there are two `search-input`s.
await dialog.getByTestId('search-input').fill('aurora');
// Enter hands the screen back, because the results are the page.
await app.keyboard.press('Enter');
await expect(dialog).not.toBeAttached();
// Polled: the box debounces by 150ms, so reading the page once
// straight after closing the dialog can capture the state before
// the term ever reached the store.
await expect
.poll(() =>
app.evaluate(
() =>
document
.querySelector('[data-testid="main-content"] track-list')
?.shadowRoot?.querySelector('page-header')
?.shadowRoot?.querySelector('[data-testid="page-search-scope"]')
?.textContent?.trim() ?? '',
),
)
.toMatch(/matching.*aurora/);
// And the button says the search is on, in its name rather than
// only in its colour.
await expect(
app.locator('track-list page-header search-trigger button'),
).toHaveAttribute('aria-label', /aurora/);
// Leave the app as the next spec expects to find it.
await app.locator('track-list page-header search-trigger button').click();
await app.getByTestId('search-dialog').getByTestId('search-input').fill('');
await app.keyboard.press('Escape');
});
/**
* Two of the seven searchable views have no `page-header` they are
* detail views that filter on the term and say so in their own
* headers. A trigger placed only in `page-header` would leave them
* with a search they can show and no way to set it, which is #24's
* sentence broken in the band it was written for.
*/
test('reaches the playlist detail view too', async ({ app }) => {
await app.getByTestId('tab-playlists').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'playlists',
);
// `.playlist-item`, which is what the list renders. Asserted to
// exist rather than skipped on: the seed has a playlist, and a
// spec that quietly skips when its selector stops matching is a
// spec that reports success for a renamed class.
const first = app.locator('playlist-view .playlist-item').first();
await expect(first).toBeVisible();
await first.dblclick();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'playlist-details',
);
await expect(
app.locator('playlist-details search-trigger button'),
).toBeVisible();
});
/**
* A button that cannot do anything is worse than none the rule
* `library-status-indicator` was rewritten on. Home has nothing of
* its own to search and is not in the store's map.
*/
test('offers no button where there is nothing to search', async ({ app }) => {
await app.getByTestId('tab-home').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'home',
);
await expect(
app.locator('home-view page-header search-trigger button'),
).toHaveCount(0);
});
test('offers no button on a desktop, where the header has a box', async ({
app,
}) => {
await app.setViewportSize({ width: 1440, height: 900 });
await app.getByTestId('nav-tracks').click();
await expect(
app.locator('track-list page-header search-trigger button'),
).toHaveCount(0);
await expect(app.locator('header.top-bar search-bar')).toBeVisible();
});
});
/**
* #148, which #57 inherits: `library-filter` is the only control in the
* app that calls `setSelectedLibrary`, and the bar it lived in is gone
* on a phone. #143 refused to hide it as a fit step for exactly this
* reason, so dropping it here would have been the same trade.
*/
test.describe('the library filter has a home that is not the bar', () => {
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('is in Settings, and is reachable from a phone', async ({ app }) => {
await app.setViewportSize(PHONE);
await app.getByTestId('tab-more').click();
await app.getByTestId('nav-drawer').getByTestId('nav-settings').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'settings',
);
const filter = app.getByTestId('settings-library-filter');
await expect(filter).toBeVisible();
await expect(filter.locator('select')).toBeVisible();
});
test('and it is the same control at every width', async ({ app }) => {
// Not a phone-only copy: "where do I change which library I am
// browsing" having two answers by viewport is the fault, not the
// fix.
await app.setViewportSize({ width: 1440, height: 900 });
await app.getByTestId('nav-settings').click();
await expect(app.getByTestId('settings-library-filter')).toBeVisible();
await expect(app.locator('header.top-bar library-filter')).toBeVisible();
});
});
+66 -20
View File
@@ -26,10 +26,22 @@ type Page = import('@playwright/test').Page;
* 600 is the bottom of the Compact band (#24) and where the defect
* lands; 899 and 900 straddle `nav-history` appearing (68px more to
* find, at the width that just gained the sidebar's labels); 800 is the
* enforced minimum; 390 is a phone, where the answer must be that
* nothing collapses because the media queries already did the work.
* enforced minimum.
*
* **390 is kept, and what it asks changed with #57.** There is no bar
* to fit below 600px any more it is out of the grid and visually
* hidden so "nothing hangs out of it" is a claim about an element
* with no row, and would pass on a build that had merely broken the
* bar. Dropping the width would be dropping the one place this file
* can still say something true about a phone, so it asserts the
* *stronger* property instead, below: the bar is out of the layout
* altogether, which is the thing #57 wanted and the thing that makes
* fitting moot.
*/
const WIDTHS = [390, 600, 800, 899, 900, 1440];
const WIDTHS = [600, 800, 899, 900, 1440];
/** Where #57 leaves the bar, and where the desktop still has one. */
const PHONE_WIDTH = 390;
/**
* A scan whose title is as long as a real one gets. The label is capped
@@ -90,6 +102,56 @@ const collapsed = (page: Page) =>
}));
test.describe('the top bar fits the window', () => {
/**
* The phone's answer, which is not "it fits" (#57).
*
* The bar has no grid row below 600px, so measuring its children
* against its content box is measuring a 1px box that is already
* invisible a fit pass would collapse the wordmark every time and
* report success about nothing, which is why `measureTopBarFit`
* declines to run at all when the bar is out of flow. What is worth
* asserting here is that the fit pass has not quietly started
* *undoing* that: a rule that put the bar back in the layout would
* pass every assertion in this file and cost a 439px screen 12% of
* its height.
*/
test(`the bar is out of the layout at ${PHONE_WIDTH}px, with a job running`, async ({
app,
testctl,
}) => {
await app.setViewportSize({ width: PHONE_WIDTH, height: 600 });
await testctl.emit('JobsChanged', [LONG_JOB]);
// Not merely hidden: `display: none` on the header would satisfy
// "invisible" and leave the 3.25em row exactly where it was. So
// the assertion is that the content starts where the row above it
// ends -- and with a job staged, the row above it is the jobs
// band, which is the whole reason this row could go.
await expect
.poll(() =>
app.evaluate(() => {
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
const main = document.querySelector<HTMLElement>('.main-panel')!;
const band = document.querySelector<HTMLElement>('job-band')!;
return {
position: getComputedStyle(bar).position,
gap:
Math.round(main.getBoundingClientRect().top) -
Math.round(band.getBoundingClientRect().bottom),
};
}),
)
.toEqual({ position: 'absolute', gap: 0 });
// And the work is still visible, in the band that replaced the
// indicator (#62) — which is what made this row removable at all.
await expect(app.locator('job-indicator')).toBeHidden();
await expect(app.locator('job-band').locator('job-row')).toHaveCount(1);
await app.setViewportSize({ width: 1440, height: 900 });
});
for (const width of WIDTHS) {
test(`no control sits outside the bar at ${width}px, idle`, async ({
app,
@@ -108,23 +170,7 @@ test.describe('the top bar fits the window', () => {
// The indicator has to actually be up, or this test passes by
// measuring the idle case under another name.
//
// Below 600px there is deliberately no indicator to measure:
// #62 stands it down and puts the rows in `<job-band>` instead,
// in the layout under the bar. So at 390 the assertion is that
// it *is* away and the bar still fits -- which is the same
// property (the bar has nothing hanging out of it) reached by the
// other branch of the same rule, rather than a width quietly
// dropped from the list.
const phone = width < 600;
await expect(app.locator('job-indicator'))[
phone ? 'toBeHidden' : 'toBeVisible'
]();
if (phone) {
await expect(app.locator('job-band').locator('job-row')).toHaveCount(1);
}
await expect(app.locator('job-indicator')).toBeVisible();
await expect.poll(() => overflowingChildren(app)).toEqual([]);
});
+53 -44
View File
@@ -412,8 +412,17 @@ body div.sidebar {
=================================================================== */
@media (max-width: 599px) {
body {
/* **There is no top-bar row here (#57).** Every one of the five
things that bar held has somewhere else to be below 600px:
`nav-history` is the platform's own gesture (gone from 899
down), the job indicator is `<job-band>` (#62), the search
box is a modal opened from the view's own header
(`search-trigger`), the library filter is Settings ->
Libraries (#148), and the wordmark is below. That is 3.25em
of a 439 CSS px viewport -- the single biggest vertical win
available on the reference device, which is why #57 asks for
the row rather than for a smaller bar. */
grid-template:
"top-bar" 3.25em
"jobs-band" auto
"main-panel" 1fr
"bottom-bar" auto
@@ -433,46 +442,55 @@ body div.sidebar {
grid-area: bottom-nav;
}
/* The 2em gutters are half a thumb each at this width, and the
subtitle is already gone from 900 down.
/* The bar is out of the layout, and out of it the way the *wordmark*
already goes at desktop widths: visually hidden rather than
`display: none`, because that `h1` is the document's top-level
heading and this app would otherwise have none on the pages whose
own header is empty by design (`page-header` renders no `h1` when
`heading` is '', and Settings has no `page-header` at all).
`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. */
Its four *controls* are `display: none` below, which is what
keeps them out of the tab order -- a visually-hidden container is
still focusable, and tabbing into a search box nobody can see is
worse than not having one.
This is `styles/sr-only.css.ts`'s recipe again, written out
because that one is a `CSSResult` for shadow roots and this is
the light DOM. `position: absolute` is also what tells
`services/top-bar-fit.ts` there is no row to fit into. */
.top-bar {
padding-left: 0.75em;
padding-right: 0.75em;
gap: 0.5em;
min-width: 0;
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
gap: 0;
min-width: 0;
}
.top-bar nav-history,
.top-bar library-filter,
.top-bar search-bar,
.top-bar job-indicator {
display: none;
}
/* `min-width: 0` is load-bearing wherever a box sits between the
viewport and content that must shrink. A grid item's implicit
minimum is `auto` -- its content -- so one child insisting on
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. */
.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.
`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;
}
/* The full-screen now-playing view *is* the transport, so the bar
repeating it underneath is 4em of a small screen spent saying
the same thing twice -- visible in a screenshot, invisible to
@@ -486,11 +504,6 @@ body div.sidebar {
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar {
display: none;
}
.top-bar search-bar {
flex: 1 1 auto;
min-width: 0;
}
}
@media (max-width: 599px) {
@@ -543,16 +556,12 @@ body job-band {
deletes this bar entirely and is blocked on the indicator having
somewhere else to live -- this is that somewhere.
`display: none` rather than a fit step: `services/top-bar-fit.ts`
already skips children whose computed display is none, so the bar's
measurement simply sees one fewer child, and `[compact]` toggling on
a hidden element costs nothing. */
#57 has since done exactly that, so the indicator's own rule now
lives with the other three in the phone block above, where the bar
goes out of the layout in one statement rather than four. What stays
here is the band, and the argument for it. */
@media (max-width: 599px) {
.top-bar job-indicator {
display: none;
}
/* ...and its rows appear here, in the grid row above the content.
/* The indicator's rows appear here, in the grid row above the content.
In flow rather than over it: a fixed band reads fine in a
screenshot and is unusable, because at 424x439 a compact panel
is ~200px of a 439px screen and it *covers* what is under it.
+13
View File
@@ -14,6 +14,13 @@
user is not walked through the header, the library filter, the
search box and eleven nav items on every navigation. -->
<a class="skip-link" href="#main-content">Skip to content</a>
<!-- Below 600px this bar is not in the layout at all (#57): index.css
takes its grid row away and leaves the element visually hidden,
carrying nothing but the `h1` below. Every control in it has
somewhere else to be there -- `nav-history` is the platform's
own back gesture, `job-indicator` is `<job-band>`, `search-bar`
is `<search-dialog>` opened from the view's own header, and
`library-filter` is Settings -> Libraries (#148). -->
<header class="top-bar">
<hgroup>
<h1 class="title">YellowJacket</h1>
@@ -84,6 +91,12 @@
<first-run-wizard></first-run-wizard>
<notification-host></notification-host>
<shortcuts-overlay></shortcuts-overlay>
<!-- The phone's search surface (#57). A singleton here for the
reason shortcuts-overlay is one: one instance, one document
listener, and no `data-testid="search-input"` resolving to two
elements. It renders nothing while shut, so the header's box
is still the only one on a desktop. -->
<search-dialog></search-dialog>
</body>
</html>
+5
View File
@@ -28,6 +28,11 @@ 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';
// The phone's search surface (#57). Eager, because below 600px it is
// the *only* way to search and a modal that has to fetch a chunk before
// it can take a keystroke is late by exactly the interval it exists to
// remove. It renders nothing until asked.
import '@components/search-dialog/search-dialog.ts';
import '@components/library-filter/library-filter.ts';
import '@components/first-run-wizard/first-run-wizard.ts';
import '@components/notifications/notification-host.ts';
@@ -56,6 +56,10 @@ import {
import './config-field';
import './config-section';
// The view filter's home (#148). The same component the top bar
// carries, placed a second time rather than reimplemented -- two
// definitions of "which library am I browsing" is what this is for.
import '@components/library-filter/library-filter';
import './download-clients';
import './shortcut-capture';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
@@ -231,6 +235,42 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
flex-wrap: wrap;
}
/* #148, and the second half of #57.
library-filter is the only control in the app that calls
setSelectedLibrary, and it lived in the top bar -- which
#57 takes out of the layout on a phone, and which #143
already refused to hide as a fit step precisely because
hiding it takes away an action. So the selection gets a home
that does not depend on that bar existing.
At every width, not below 600px: a phone-only copy would be
a second place the control lives, and "where do I change
which library I am browsing" having two answers by size is
the fault, not the fix. */
.library-scope {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
flex-wrap: wrap;
margin-bottom: 1em;
}
.library-scope .scope-label {
font-weight: 600;
font-size: 0.85em;
color: var(--yj-text-primary, #fff);
display: block;
}
.library-scope .scope-description {
font-size: 0.75em;
color: var(--yj-text-tertiary, #888);
margin: 0.35em 0 0;
max-width: 40em;
}
.save-row {
display: flex;
gap: 0.5em;
@@ -2370,6 +2410,21 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
for new and changed files."
.open=${true}
>
<div class="library-scope">
<div>
<span class="scope-label">Showing</span>
<p class="scope-description">
Which library the Albums, Artists and Genres
views show. This is a view filter, not a
setting about the libraries themselves the
list below is where they are added, renamed
and scanned.
</p>
</div>
<library-filter data-testid="settings-library-filter">
</library-filter>
</div>
<div class="scan-actions">
<button
class="btn-primary"
@@ -11,6 +11,7 @@ import {
contextMenuStyles,
} from '../../utils/context-menu-controller';
import { ICON_MORE_ACTIONS } from '../../utils/icon-language';
import '../search-dialog/search-trigger';
/**
* The one arrangement every primary view uses to say what it is.
@@ -161,6 +162,15 @@ export class PageHeader extends LitElement {
@state()
private collapsed: ReadonlySet<string> = new Set();
/**
* Whether the count has been given up. Derived, like `collapsed`.
*
* It is the last thing to yield and the only thing here that is
* neither an identity nor an action see `measureFit`.
*/
@state()
private countCollapsed = false;
@state()
private menuOpen = false;
@@ -457,6 +467,20 @@ export class PageHeader extends LitElement {
${this.renderCount()}
<div class="spacer"></div>
${this.renderScope()} ${this.renderSort()}
<!-- #57. Below 600px the top bar is out of the layout,
so the search box has to be reachable from here.
It renders nothing at every other width and on
every view search-store says has nothing to
search, which is why no host declares it: the map
of searchable views already exists and this is one
more reader of it, not a second copy.
Before the actions, and never one of them: an
action can collapse into the overflow menu, and on
a phone that menu is already where the page's own
actions live -- search behind an ellipsis is the
top bar's problem moved rather than fixed. -->
<search-trigger></search-trigger>
${this.renderActions()}
<slot name="actions"></slot>
</header>
@@ -516,12 +540,6 @@ export class PageHeader extends LitElement {
if (!header) return;
if (this.actions.length === 0) {
this.commitCollapsed(new Set());
return;
}
const buttons = new Map<string, HTMLElement>();
for (const el of this.renderRoot.querySelectorAll<HTMLElement>(
@@ -534,6 +552,7 @@ export class PageHeader extends LitElement {
const more = this.moreButton;
const title = this.renderRoot.querySelector('h1');
const count = this.renderRoot.querySelector<HTMLElement>('.count');
/**
* Nothing is clipped which is not the same as the header not
@@ -555,6 +574,8 @@ export class PageHeader extends LitElement {
if (more) more.hidden = true;
if (count) count.hidden = false;
const collapsed = new Set<string>();
if (!fits()) {
@@ -571,7 +592,42 @@ export class PageHeader extends LitElement {
}
}
this.commitCollapsed(collapsed);
this.commitCollapsed(collapsed, this.collapseCount(count, fits));
}
/**
* The last thing to give way, after every action is in the menu and
* the title has already run out.
*
* There are four things competing for this row and three of them
* cannot go. The **title** yields first and is allowed to ellipsis
* away entirely at 320px, because the navigation also says which
* page you are on. The **sort** control and the **actions** are
* each the only place they are said, so an action collapses into
* the menu rather than disappearing and the sort control stays.
* That leaves the **count**, which is the one purely informational
* item on the row an empty page says so in its empty state, and a
* full one is being looked at.
*
* It became reachable rather than theoretical with #57: below 600px
* the header also carries the phone's search button, and on
* Playlists at 320px that is 43px more than the row has. Measured
* there: title 0, count 50, sort 143, search 40, "More actions" 38,
* five 12px gaps and 32px of gutters 363 in 320, with the More
* button ending 27px past the edge. Something has to go, and this
* is the only candidate that is not an action.
*
* @returns whether the count was given up.
*/
private collapseCount(
count: HTMLElement | null,
fits: () => boolean,
): boolean {
if (count === null || fits()) return false;
count.hidden = true;
return true;
}
/** Lowest priority first; ties broken from the right. */
@@ -586,7 +642,9 @@ export class PageHeader extends LitElement {
.map(({ action }) => action);
}
private commitCollapsed(next: Set<string>): void {
private commitCollapsed(next: Set<string>, countHidden: boolean): void {
this.countCollapsed = countHidden;
const same =
next.size === this.collapsed.size &&
[...next].every((id) => this.collapsed.has(id));
@@ -754,7 +812,16 @@ export class PageHeader extends LitElement {
const noun = this.count === 1 ? this.countNoun : plural;
return html`<span class="count" data-testid="page-count"
// Rendered whether or not it fits, and hidden with an
// attribute -- the same shape the action buttons use, and for
// the same reason: `measureFit` starts every pass from
// all-visible, so it needs a node to un-hide. Returning
// `nothing` here would take the count away for the rest of the
// session the first time a 320px window appeared.
return html`<span
class="count"
data-testid="page-count"
?hidden=${this.countCollapsed}
>${this.count.toLocaleString()} ${noun}</span
>`;
}
@@ -27,6 +27,7 @@ import { queueStore } from '@store/queue-store';
import { creditStore } from '@store/credit-store';
import { PlayerController } from '@store/controllers/player-controller';
import { SearchController } from '@store/controllers/search-controller';
import '../search-dialog/search-trigger';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import {
@@ -1039,6 +1040,16 @@ export class PlaylistDetails
min-width: 0;
}
/* #57. This view is in search-store's map and filters on the
term, but it is a detail view and so has no page-header to
carry the phone's search button. Pushed to the end of the
header row, which is where page-header puts it too. */
.header-end {
margin-left: auto;
display: flex;
align-items: center;
}
.playlist-title {
font-size: 24px;
font-weight: 700;
@@ -1383,6 +1394,9 @@ export class PlaylistDetails
`
: ''}
</div>
<div class="header-end">
<search-trigger></search-trigger>
</div>
</div>
${searchBar}
<div
@@ -62,7 +62,10 @@ export class SearchBar extends LitElement {
gap: 8px;
height: 32px;
min-width: 200px;
max-width: 360px;
/* A cap for a header, not for the box. search-dialog gives
it the whole of a modal, where 360px of a 424px screen
would read as a control that failed to size itself. */
max-width: var(--yj-search-max-width, 360px);
width: 100%;
transition: border-color 0.15s ease;
}
@@ -0,0 +1,210 @@
/**
* The phone's search surface (#57).
*
* Below 600px there is no top bar to hold a search box the bar is out
* of the layout entirely, which is the single biggest vertical win
* available on a 439 CSS px viewport. So the box moves into a modal and
* the *trigger* moves into the row that already says which page you are
* on (`search-trigger`, beside this file).
*
* **It is a `wa-dialog`, and that is a mechanism rather than a taste.**
* #60 read this out of the Web Awesome source: `wa-popup` renders
* `<div popover="manual">` and feature-detects the Popover API, falling
* back to `strategy: "fixed"` where there is none which is the
* reference device, Chrome 113, since `popover` is Chrome 114. And
* `position: fixed` escapes ancestor *overflow* but not `contain:
* paint`, which makes an element a containing block for fixed
* descendants **and clips them**; `index.css` puts `contain: layout
* style paint` on `.main-panel`, which is the ancestor of every view.
* A popup-shaped search panel opened from a view's header would
* therefore be structurally clipped on the one device this issue is
* about, and **no tier here could see it** CI's Chromium and WebKit
* both have the Popover API, so the popup is top-layered and correct.
* `<dialog>`/`showModal()` is Chrome 37 and uses the real top layer, so
* this is immune by construction.
*
* **It carries the real `<search-bar>`**, not a second input. That is
* what keeps one debounce, one clear button, one accessible name and
* one view-scoped placeholder and it is why `store/search-store.ts`
* is still the only statement of which views can search and what they
* search. The modal is a presentation of the control, not a copy of it.
*
* **The results are the view, not a list in here.** The Direction says
* "the box and live results"; the live results already exist, because
* the term is view-scoped and the page behind this dialog filters on it
* and says so in `page-header`'s "Showing albums matching …" line.
* Rendering results in the dialog would be a second implementation of
* every view's own filtering, and a worse one it could not offer the
* row actions the view does. So Enter closes and hands the screen back.
*
* A singleton in `index.html` for the reason `shortcuts-overlay` is:
* one instance, one `data-testid`, one document listener, and no
* `data-testid="search-input"` resolving to two elements while it is
* shut.
*/
import { LitElement, css, html, nothing } from 'lit';
import { customElement, query, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import { designTokens } from '../../styles/tokens.css';
import { nameDialogsIn } from '@utils/name-dialog';
import { SearchController } from '@store/controllers/search-controller';
import type { SearchBar } from '../search-bar/search-bar';
import '../search-bar/search-bar';
/** The event any trigger dispatches to open this. */
export const OPEN_SEARCH_EVENT = 'open-search';
@customElement('search-dialog')
export class SearchDialog extends LitElement {
private searchCtrl = new SearchController(this);
@query('wa-dialog') private dialog?: HTMLElement & { open: boolean };
@query('search-bar') private bar?: SearchBar;
@state() private isOpen = false;
static override styles = [
designTokens,
css`
:host {
display: contents;
}
wa-dialog::part(dialog) {
background: var(--yj-bg-surface, #212529);
color: var(--yj-text-primary, #fff);
}
/* The box is the whole content, so it gets the whole width
rather than the 360px cap it wears in a header. */
search-bar {
display: block;
width: 100%;
--yj-search-max-width: none;
}
.hint {
margin: 0.75em 0 0;
font-size: var(--yj-text-sm, 0.8125rem);
color: var(--yj-text-secondary, #b3b3b3);
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
document.addEventListener(OPEN_SEARCH_EVENT, this.open);
// Capture, on the host: the path runs document -> host ->
// shadow root -> the input inside `search-bar`, so a capture
// listener here is the only one that gets the key *before* the
// input's own handler. A `@keydown` in the template is a
// bubbling listener and would run after the term was cleared,
// and there is nowhere to put a `firstUpdated` hook -- the
// first render of this element produces no content at all.
this.addEventListener('keydown', this.onKeydown, true);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
document.removeEventListener(OPEN_SEARCH_EVENT, this.open);
this.removeEventListener('keydown', this.onKeydown, true);
}
/**
* Not a toggle, for `shortcuts-overlay`'s reason: a dialog owns
* every unmodified key while it is up, so a second press of the
* shortcut that opened it never reaches the shortcut service.
*/
private open = (): void => {
if (this.isOpen) return;
// Nothing to search here is not an error; it is the state the
// trigger already declines to render in. Guarding here too is
// what makes the keyboard route (Ctrl+F on a phone) agree with
// the button.
if (!this.searchCtrl.isSearchableView) return;
this.isOpen = true;
void this.updateComplete.then(() => {
if (this.dialog) this.dialog.open = true;
// `wa-dialog` positions and shows in its own update, and
// `search-bar` populates its own shadow root in one more —
// the same lifecycle trap `name-dialog.ts` documents. One
// more frame, and the box has an input to focus.
requestAnimationFrame(() => this.bar?.focusInput());
});
};
private close(): void {
if (this.dialog) this.dialog.open = false;
this.isOpen = false;
}
/**
* Escape closes and **keeps the term**; Enter closes and shows the
* results.
*
* Escape is the one worth stating. `search-bar`'s input treats it
* as *clear the search*, which is right in a header the box is on
* screen either way, so clearing is the only thing left for the key
* to mean. Here it would make dismissing the search surface
* silently discard the search, and discarding is what the clear
* button inside it is for. So this runs first and closes; the term
* survives, and the page behind is still filtered by it.
*/
private onKeydown = (e: KeyboardEvent): void => {
if (!this.isOpen) return;
if (e.key === 'Escape') {
e.stopPropagation();
this.close();
return;
}
if (e.key === 'Enter') {
e.stopPropagation();
e.preventDefault();
this.close();
}
};
/**
* Web Awesome renders `label` into a heading it never points the
* `<dialog>` at. See `utils/name-dialog.ts`.
*/
override updated(): void {
nameDialogsIn(this.shadowRoot);
}
override render() {
if (!this.isOpen) return nothing;
const scope = this.searchCtrl.scopeLabel;
return html`
<wa-dialog
label=${`Search ${scope}`}
data-testid="search-dialog"
@wa-hide=${() => this.close()}
>
<search-bar></search-bar>
<p class="hint">
Results appear on the page behind this. Press Enter
or close to see them.
</p>
</wa-dialog>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'search-dialog': SearchDialog;
}
}
@@ -0,0 +1,147 @@
/**
* The phone's way into search (#57): one button, in the row that
* already says which page you are on.
*
* **Which views show it is not a decision this component makes.**
* `store/search-store.ts` has held the map of what each view searches
* since plan 007, and #57's own Findings say so "that is exactly the
* condition for showing the button". So this asks `isSearchableView`
* and renders nothing otherwise, and no second list of searchable views
* exists to fall out of step with the first.
*
* **It is an element rather than a `PageAction`**, and that is the
* whole reason it is a component at all. Two of the seven searchable
* views `playlist-details` and `smart-playlist-details` have no
* `page-header`; they filter on the term and say so in their own
* headers. Declaring search as an action would mean seven hosts each
* writing it out, which is the second list again, and it would put a
* *phone mode for actions* inside `page-header`, which that component
* documents its refusal to grow. An element three headers place is one
* statement of the rule, placed three times.
*
* It does not participate in `page-header`'s overflow measurement, for
* the reason the count and the sort control do not: it is 32px, it is
* `flex-shrink: 0`, and the header's `fits()` sees its width like any
* other child. What it must never do is collapse into the overflow
* menu on a phone that menu is the only home for the page's actions
* already, and search would be two taps behind an ellipsis.
*/
import { LitElement, css, html, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import { PHONE_QUERY } from '@utils/breakpoints';
import { SearchController } from '@store/controllers/search-controller';
import { ICON_SEARCH } from '@utils/icon-language';
import { OPEN_SEARCH_EVENT } from './search-dialog';
@customElement('search-trigger')
export class SearchTrigger extends LitElement {
private searchCtrl = new SearchController(this);
/**
* From `matchMedia` rather than a media query, because this decides
* whether the button *exists* `job-band`'s rule, and for the same
* consequence: a header that renders it at every width puts a
* second search affordance beside the desktop's own box.
*/
@state() private phone = false;
private media?: MediaQueryList;
static override styles = [
designTokens,
css`
:host {
display: contents;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
/* The smallest a touch target should be. The header's
own action buttons are smaller because they carry a
label; this one is a glyph. */
min-width: 40px;
min-height: 40px;
padding: 0;
background: none;
border: 1px solid var(--yj-border-subtle, #555);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
cursor: pointer;
flex-shrink: 0;
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -1px;
}
/* A search that is *on* says so without a second control:
the page already carries "Showing albums matching ...",
and this is the button that reopens the box to change or
clear it. */
button.filtering {
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-text, #ffd43b);
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
this.media = window.matchMedia(PHONE_QUERY);
this.phone = this.media.matches;
this.media.addEventListener('change', this.onMedia);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.media?.removeEventListener('change', this.onMedia);
}
private onMedia = (e: MediaQueryListEvent): void => {
this.phone = e.matches;
};
private onClick = (): void => {
document.dispatchEvent(new CustomEvent(OPEN_SEARCH_EVENT));
};
override render() {
if (!this.phone || !this.searchCtrl.isSearchableView) return nothing;
const scope = this.searchCtrl.scopeLabel;
const term = this.searchCtrl.term;
// The name carries the state, because the colour cannot: a
// control that is a different colour and the same word is a
// control that says nothing to anyone not seeing it. Same rule
// `library-status.ts` states for a partial badge.
const label = term
? `Search ${scope}, showing matches for ${term}`
: `Search ${scope}`;
return html`
<button
data-testid="search-trigger"
class=${term ? 'filtering' : ''}
aria-label=${label}
title=${label}
@click=${this.onClick}
>
<wa-icon name=${ICON_SEARCH}></wa-icon>
</button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'search-trigger': SearchTrigger;
}
}
@@ -18,6 +18,7 @@ import { queueStore } from '@store/queue-store';
import { creditStore } from '@store/credit-store';
import { PlayerController } from '@store/controllers/player-controller';
import { SearchController } from '@store/controllers/search-controller';
import '../search-dialog/search-trigger';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import {
@@ -359,6 +360,15 @@ export class SmartPlaylistDetails
flex-shrink: 0;
}
/* #57. Like playlist-details, this view filters on the search
term and has no page-header to carry the phone's search
button, so the action row does. */
.actions-end {
margin-left: auto;
display: flex;
align-items: center;
}
.action-button {
background: none;
border: 1px solid var(--yj-border-subtle, #555);
@@ -1300,6 +1310,9 @@ export class SmartPlaylistDetails
Edit Rules
</button>
`}
<div class="actions-end">
<search-trigger></search-trigger>
</div>
</div>
${this.editing
? html`
@@ -16,6 +16,7 @@ import { playerStore } from '@store/player-store';
import { queueStore } from '@store/queue-store';
import * as Player from '@go/player/player.js';
import type { SearchBar } from '@components/search-bar/search-bar';
import { OPEN_SEARCH_EVENT } from '@components/search-dialog/search-dialog';
// ===================================================================
// KEY STRING UTILITIES
@@ -387,14 +388,24 @@ async function dispatch(action: string): Promise<void> {
break;
// Navigation
// The key has one meaning -- *let me search this page* -- and
// two surfaces since #57. The header box is gone below 600px,
// so scoping the query to the bar is not tidiness: an unscoped
// `search-bar` also matches the one inside `search-dialog`
// while that is open, and would focus a box the user is
// already typing in while leaving the phone with nothing at
// all. The dialog declines to open on a view with nothing to
// search, which is the same condition the trigger renders on.
case 'nav.search':
case 'nav.searchAlt': {
const bar = document.querySelector(
'search-bar',
'header.top-bar search-bar',
) as SearchBar | null;
if (bar && !bar.hasAttribute('hidden')) {
if (bar && bar.checkVisibility()) {
bar.focusInput();
} else {
document.dispatchEvent(new CustomEvent(OPEN_SEARCH_EVENT));
}
break;
+10
View File
@@ -119,6 +119,16 @@ export const FIT_STEPS: readonly FitStep[] = [
* @returns the ids collapsed, in the order they were given up.
*/
export function measureTopBarFit(bar: HTMLElement): string[] {
// Below 600px there is no bar to fit (#57): `index.css` takes it
// out of the grid and leaves it visually hidden at 1px, carrying
// nothing but the document's `h1`. Measuring that reports the
// wordmark as overflowing 1px of content box and collapses it every
// time -- true, and about nothing, since the whole bar is already
// invisible. Asking the *computed position* rather than the
// viewport width is what keeps this file free of a breakpoint the
// stylesheet already owns.
if (getComputedStyle(bar).position === 'absolute') return [];
const fits = () => {
const style = getComputedStyle(bar);
const box = bar.getBoundingClientRect();
+13
View File
@@ -124,6 +124,19 @@ export const ICON_DOWNLOADING = 'download';
*/
export const ICON_MORE_ACTIONS = 'ellipsis';
/**
* Look for something.
*
* Deliberately **not** governed by the sweep in
* `icon-language.test.ts`: `magnifying-glass` has only ever meant this,
* in the header box and in Explore's own catalog search alike, so
* governing it would force a rename on two call sites that are already
* right. It is written down because #57 gave the meaning a *button* as
* well as a box, and a second surface for the same verb is exactly the
* point at which two spellings start.
*/
export const ICON_SEARCH = 'magnifying-glass';
/**
* Take this away.
*
@@ -0,0 +1,248 @@
/**
* The phone's search surface (#57).
*
* Two things are asserted here that the e2e tier cannot reach, and one
* that it deliberately must not be trusted with.
*
* **Which views show the trigger is `search-store`'s answer**, so this
* walks the map rather than sampling a view: the fault the issue guards
* against is a second list of searchable views, and a spec that checks
* Albums checks nothing about Playlists.
*
* **The dialog is a `<dialog>`, not a popup.** #60 established from the
* Web Awesome source that `wa-popup` falls back to `position: fixed`
* without the Popover API Chrome 113, the reference device and that
* `.main-panel`'s `contain: paint` clips a fixed descendant. Every tier
* available here has the Popover API, so a popup renders perfectly in
* CI and is clipped on the device: **an assertion that the surface is
* not clipped passes on the broken build.** So the assertion is the
* *mechanism* a real `<dialog>` in the tree which is the one form
* of this that a browser here can answer honestly.
*
* The breakpoint is stubbed rather than emulated, for the reason
* `now-playing-phone.test.ts` gives: the runner's viewport is fixed at
* 1280x800, and the component reads `matchMedia` in `connectedCallback`
* precisely so a test can answer it first.
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import '@components/search-dialog/search-dialog';
import '@components/search-dialog/search-trigger';
import { searchStore } from '@store/search-store';
import { fixture, shadow, deepShadow } from '@test/support/render';
import { flush } from '@test/support/harness';
/** Views the store says can be searched, and what they search. */
const SEARCHABLE: [string, string][] = [
['tracks', 'tracks'],
['albums', 'albums'],
['artists', 'artists'],
['genres', 'genres'],
['playlists', 'playlists'],
['playlist-details', 'tracks in this playlist'],
['smart-playlist-details', 'tracks in this smart playlist'],
];
/** Views with nothing of their own to search, or a search of their own. */
const UNSEARCHABLE = ['home', 'explore', 'settings', 'downloads', 'autotag'];
let restoreMedia: (() => void) | null = null;
/** Answer the shell's phone query with `phone` until restored. */
function stubPhone(phone: boolean): void {
const real = window.matchMedia.bind(window);
window.matchMedia = ((q: string) =>
q.includes('max-width: 599px')
? {
matches: phone,
media: q,
addEventListener() {},
removeEventListener() {},
}
: real(q)) as typeof window.matchMedia;
restoreMedia = () => {
window.matchMedia = real;
};
}
beforeEach(() => {
searchStore.setTerm('');
searchStore.setCurrentView('tracks');
});
afterEach(() => {
restoreMedia?.();
restoreMedia = null;
searchStore.setTerm('');
searchStore.setCurrentView('tracks');
});
describe('<search-trigger>', () => {
it('is offered on every view the store says can be searched', async () => {
stubPhone(true);
// One element, walked across the views: the trigger reads the store
// on every render, so remounting per view would test mounting
// rather than the condition.
const el = await fixture('search-trigger');
for (const [view] of SEARCHABLE) {
searchStore.setCurrentView(view);
await el.updateComplete;
expect(
shadow(el, '[data-testid="search-trigger"]'),
`no trigger on ${view}`,
).not.toBeNull();
}
});
it('names what the button will search', async () => {
stubPhone(true);
const el = await fixture('search-trigger');
for (const [view, scope] of SEARCHABLE) {
searchStore.setCurrentView(view);
await el.updateComplete;
expect(
shadow(el, '[data-testid="search-trigger"]')?.getAttribute(
'aria-label',
),
).toBe(`Search ${scope}`);
}
});
it('is absent where there is nothing to search', async () => {
stubPhone(true);
const el = await fixture('search-trigger');
for (const view of UNSEARCHABLE) {
searchStore.setCurrentView(view);
await el.updateComplete;
expect(
shadow(el, '[data-testid="search-trigger"]'),
`a trigger appeared on ${view}`,
).toBeNull();
}
});
it('is absent above the phone breakpoint, where the header has a box', async () => {
stubPhone(false);
const el = await fixture('search-trigger');
expect(shadow(el, '[data-testid="search-trigger"]')).toBeNull();
});
/**
* A colour is not a signal on its own. The button is the only thing
* on screen that reopens a filtered search, so the state it is in has
* to reach someone who cannot see the accent border.
*/
it('says in its name that a search is applied', async () => {
stubPhone(true);
const el = await fixture('search-trigger');
searchStore.setTerm('aurora');
await el.updateComplete;
const button = shadow(el, '[data-testid="search-trigger"]');
expect(button?.getAttribute('aria-label')).toContain('aurora');
expect(button?.className).toContain('filtering');
});
});
describe('<search-dialog>', () => {
it('opens on the event the trigger dispatches, as a real dialog', async () => {
stubPhone(true);
const el = await fixture('search-dialog');
const trigger = await fixture('search-trigger');
shadow<HTMLElement>(trigger, '[data-testid="search-trigger"]')?.click();
await flush();
await el.updateComplete;
expect(shadow(el, '[data-testid="search-dialog"]')).not.toBeNull();
// The mechanism, not the appearance: a native <dialog> is what
// reaches the top layer on Chrome 113, and a wa-popup would look
// identical in this browser while being clipped on the device.
expect(deepShadow(el, 'dialog')).not.toBeNull();
});
/**
* It carries the real box rather than a second input, which is what
* keeps one debounce, one clear button and one view-scoped
* placeholder and what keeps `search-store` the only statement of
* what a view searches.
*/
it('carries the header search box itself', async () => {
const el = await fixture('search-dialog');
document.dispatchEvent(new CustomEvent('open-search'));
await flush();
await el.updateComplete;
expect(shadow(el, 'search-bar')).not.toBeNull();
});
/**
* The one place the shortcut route and the button could disagree.
* Ctrl+F on a view with nothing to search dispatches the same event
* the button would, and the button is not there to be pressed.
*/
it('declines to open where there is nothing to search', async () => {
const el = await fixture('search-dialog');
searchStore.setCurrentView('home');
document.dispatchEvent(new CustomEvent('open-search'));
await flush();
await el.updateComplete;
expect(shadow(el, '[data-testid="search-dialog"]')).toBeNull();
});
/**
* Escape closes and **keeps the term**.
*
* `search-bar`'s own input treats Escape as "clear the search", which
* is right in a header where the box stays on screen either way. Here
* it would mean dismissing the surface silently discarded the search,
* and the page behind would refill without being asked to.
*/
it('keeps the search when it is dismissed', async () => {
const el = await fixture('search-dialog');
document.dispatchEvent(new CustomEvent('open-search'));
await flush();
await el.updateComplete;
searchStore.setTerm('aurora');
const input = deepShadow<HTMLInputElement>(el, 'input');
expect(input).not.toBeNull();
input!.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
composed: true,
}),
);
await flush();
await el.updateComplete;
expect(searchStore.getTerm()).toBe('aurora');
expect(shadow(el, '[data-testid="search-dialog"]')).toBeNull();
});
});