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
This commit was merged in pull request #167.
This commit is contained in:
@@ -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
|
e2e suite, not the spec you wrote.** A spec written for a feature
|
||||||
asserts the feature works; what a new overlay breaks is everything
|
asserts the feature works; what a new overlay breaks is everything
|
||||||
else, and only the suite is looking at that.
|
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.
|
||||||
|
|||||||
@@ -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
|
**Below 600px it reflows instead, and that is the phone.** The sideways
|
||||||
scroll above was the concession available while the shell had one
|
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
|
layout; plan 016 B2 gives it a second. Under 600px the grid drops its
|
||||||
sidebar column, `<bottom-nav>` takes over as the primary navigation,
|
sidebar column *and* (since #57) its top-bar row, `<bottom-nav>` takes
|
||||||
the header's controls shrink or stand down, and the shell measures
|
over as the primary navigation, and the shell measures
|
||||||
exactly 320px in a 320px viewport — so `layout-overflow.spec.ts` now
|
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
|
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:
|
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
|
themselves already existed; what was new is that they are a promise and
|
||||||
that the queue panel is inside it.
|
that the queue panel is inside it.
|
||||||
|
|
||||||
**The top bar decides what it can afford, and what it gives up is never
|
**And below 600px there is no top bar at all** (#57). The row is gone
|
||||||
an action.** Its five children do not fit at the bottom of the Compact
|
from the phone's grid template — not the header hidden, the row deleted
|
||||||
band: the bar was 611px inside a 600px viewport idle and **862px while
|
— 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
|
a scan ran**, because `job-indicator` is `hidden` when idle and 235px
|
||||||
wide showing a real library's scan title (#143). So `services/
|
wide showing a real library's scan title (#143). So `services/
|
||||||
top-bar-fit.ts` is `page-header`'s treatment one bar up — a
|
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
|
**What yields is decided by the promise above, which rules out the two
|
||||||
cheapest answers.** Hiding the library filter takes away an action —
|
cheapest answers.** Hiding the library filter takes away an action —
|
||||||
`library-filter` is the only control in the app that calls
|
`library-filter` was the only control in the app that called
|
||||||
`setSelectedLibrary` — so it trades this promise for the same promise
|
`setSelectedLibrary` — so it trades this promise for the same promise.
|
||||||
(#148 is the phone already doing that). Collapsing the search box to an
|
That is #148, and #57 fixed it by giving the selection a *second
|
||||||
icon is what #57 wants and #57 is blocked behind #62, so building it
|
placement* rather than a second definition: the same component, in
|
||||||
here is building it without the thing that blocks it. The two that
|
Settings → Libraries under a "Showing" label, at every width. A
|
||||||
yield are the two that are **not** actions: the wordmark, which the
|
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
|
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
|
every width anyway, and then the job indicator's *label*, leaving the
|
||||||
ring — which is not a new judgement, since the component already drops
|
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
|
without that half it would pass vacuously on a build that renders no
|
||||||
actions at all.
|
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
|
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
|
actions. `PHONE_COLUMN_IDS` is the precedent for "what is drawn and
|
||||||
what can be sorted are different questions", but it exists because the
|
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
|
placeholder saying there was nothing to search here, because its
|
||||||
sibling was in the map and it was not.
|
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`/
|
**The window's minimum is measured, not aspirational.** `MinWidth`/
|
||||||
`MinHeight` are 800×600 because that is where the shell was checked to
|
`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
|
still work: below ~780 the header subtitle wraps and pushes the title
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,10 +26,22 @@ type Page = import('@playwright/test').Page;
|
|||||||
* 600 is the bottom of the Compact band (#24) and where the defect
|
* 600 is the bottom of the Compact band (#24) and where the defect
|
||||||
* lands; 899 and 900 straddle `nav-history` appearing (68px more to
|
* 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
|
* 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
|
* enforced minimum.
|
||||||
* nothing collapses because the media queries already did the work.
|
*
|
||||||
|
* **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
|
* 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', () => {
|
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) {
|
for (const width of WIDTHS) {
|
||||||
test(`no control sits outside the bar at ${width}px, idle`, async ({
|
test(`no control sits outside the bar at ${width}px, idle`, async ({
|
||||||
app,
|
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
|
// The indicator has to actually be up, or this test passes by
|
||||||
// measuring the idle case under another name.
|
// measuring the idle case under another name.
|
||||||
//
|
await expect(app.locator('job-indicator')).toBeVisible();
|
||||||
// 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.poll(() => overflowingChildren(app)).toEqual([]);
|
await expect.poll(() => overflowingChildren(app)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
+53
-44
@@ -412,8 +412,17 @@ body div.sidebar {
|
|||||||
=================================================================== */
|
=================================================================== */
|
||||||
@media (max-width: 599px) {
|
@media (max-width: 599px) {
|
||||||
body {
|
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:
|
grid-template:
|
||||||
"top-bar" 3.25em
|
|
||||||
"jobs-band" auto
|
"jobs-band" auto
|
||||||
"main-panel" 1fr
|
"main-panel" 1fr
|
||||||
"bottom-bar" auto
|
"bottom-bar" auto
|
||||||
@@ -433,46 +442,55 @@ body div.sidebar {
|
|||||||
grid-area: bottom-nav;
|
grid-area: bottom-nav;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The 2em gutters are half a thumb each at this width, and the
|
/* The bar is out of the layout, and out of it the way the *wordmark*
|
||||||
subtitle is already gone from 900 down.
|
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
|
Its four *controls* are `display: none` below, which is what
|
||||||
minimum is `auto` -- its content -- so a header whose children
|
keeps them out of the tab order -- a visually-hidden container is
|
||||||
ask for 580px makes the *body* 580px wide inside a 360px
|
still focusable, and tabbing into a search box nobody can see is
|
||||||
viewport, and `overflow-x: hidden` then hides the right-hand
|
worse than not having one.
|
||||||
third of the app rather than fitting it. Every box between the
|
|
||||||
viewport and the content that must shrink needs this. */
|
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 {
|
.top-bar {
|
||||||
padding-left: 0.75em;
|
position: absolute;
|
||||||
padding-right: 0.75em;
|
width: 1px;
|
||||||
gap: 0.5em;
|
height: 1px;
|
||||||
min-width: 0;
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
overflow: hidden;
|
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,
|
.content-area,
|
||||||
.main-panel,
|
.main-panel,
|
||||||
.bottom-bar {
|
.bottom-bar {
|
||||||
min-width: 0;
|
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
|
/* The full-screen now-playing view *is* the transport, so the bar
|
||||||
repeating it underneath is 4em of a small screen spent saying
|
repeating it underneath is 4em of a small screen spent saying
|
||||||
the same thing twice -- visible in a screenshot, invisible to
|
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 {
|
body:has(#main-content[data-active-view="now-playing"]) .bottom-bar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-bar search-bar {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 599px) {
|
@media (max-width: 599px) {
|
||||||
@@ -543,16 +556,12 @@ body job-band {
|
|||||||
deletes this bar entirely and is blocked on the indicator having
|
deletes this bar entirely and is blocked on the indicator having
|
||||||
somewhere else to live -- this is that somewhere.
|
somewhere else to live -- this is that somewhere.
|
||||||
|
|
||||||
`display: none` rather than a fit step: `services/top-bar-fit.ts`
|
#57 has since done exactly that, so the indicator's own rule now
|
||||||
already skips children whose computed display is none, so the bar's
|
lives with the other three in the phone block above, where the bar
|
||||||
measurement simply sees one fewer child, and `[compact]` toggling on
|
goes out of the layout in one statement rather than four. What stays
|
||||||
a hidden element costs nothing. */
|
here is the band, and the argument for it. */
|
||||||
@media (max-width: 599px) {
|
@media (max-width: 599px) {
|
||||||
.top-bar job-indicator {
|
/* The indicator's rows appear here, in the grid row above the content.
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ...and its rows appear here, in the grid row above the content.
|
|
||||||
In flow rather than over it: a fixed band reads fine in a
|
In flow rather than over it: a fixed band reads fine in a
|
||||||
screenshot and is unusable, because at 424x439 a compact panel
|
screenshot and is unusable, because at 424x439 a compact panel
|
||||||
is ~200px of a 439px screen and it *covers* what is under it.
|
is ~200px of a 439px screen and it *covers* what is under it.
|
||||||
|
|||||||
@@ -14,6 +14,13 @@
|
|||||||
user is not walked through the header, the library filter, the
|
user is not walked through the header, the library filter, the
|
||||||
search box and eleven nav items on every navigation. -->
|
search box and eleven nav items on every navigation. -->
|
||||||
<a class="skip-link" href="#main-content">Skip to content</a>
|
<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">
|
<header class="top-bar">
|
||||||
<hgroup>
|
<hgroup>
|
||||||
<h1 class="title">YellowJacket</h1>
|
<h1 class="title">YellowJacket</h1>
|
||||||
@@ -84,6 +91,12 @@
|
|||||||
<first-run-wizard></first-run-wizard>
|
<first-run-wizard></first-run-wizard>
|
||||||
<notification-host></notification-host>
|
<notification-host></notification-host>
|
||||||
<shortcuts-overlay></shortcuts-overlay>
|
<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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ import '@components/bottom-nav/bottom-nav.ts';
|
|||||||
import '@components/queue-panel/queue-panel.ts';
|
import '@components/queue-panel/queue-panel.ts';
|
||||||
import '@components/nav-history/nav-history.ts';
|
import '@components/nav-history/nav-history.ts';
|
||||||
import '@components/search-bar/search-bar.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/library-filter/library-filter.ts';
|
||||||
import '@components/first-run-wizard/first-run-wizard.ts';
|
import '@components/first-run-wizard/first-run-wizard.ts';
|
||||||
import '@components/notifications/notification-host.ts';
|
import '@components/notifications/notification-host.ts';
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ import {
|
|||||||
|
|
||||||
import './config-field';
|
import './config-field';
|
||||||
import './config-section';
|
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 './download-clients';
|
||||||
import './shortcut-capture';
|
import './shortcut-capture';
|
||||||
import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
import { confirmAction } from '../confirm-dialog/confirm-dialog';
|
||||||
@@ -231,6 +235,42 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
flex-wrap: wrap;
|
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 {
|
.save-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
@@ -2370,6 +2410,21 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
for new and changed files."
|
for new and changed files."
|
||||||
.open=${true}
|
.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">
|
<div class="scan-actions">
|
||||||
<button
|
<button
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
contextMenuStyles,
|
contextMenuStyles,
|
||||||
} from '../../utils/context-menu-controller';
|
} from '../../utils/context-menu-controller';
|
||||||
import { ICON_MORE_ACTIONS } from '../../utils/icon-language';
|
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.
|
* The one arrangement every primary view uses to say what it is.
|
||||||
@@ -161,6 +162,15 @@ export class PageHeader extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private collapsed: ReadonlySet<string> = new Set();
|
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()
|
@state()
|
||||||
private menuOpen = false;
|
private menuOpen = false;
|
||||||
|
|
||||||
@@ -457,6 +467,20 @@ export class PageHeader extends LitElement {
|
|||||||
${this.renderCount()}
|
${this.renderCount()}
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
${this.renderScope()} ${this.renderSort()}
|
${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()}
|
${this.renderActions()}
|
||||||
<slot name="actions"></slot>
|
<slot name="actions"></slot>
|
||||||
</header>
|
</header>
|
||||||
@@ -516,12 +540,6 @@ export class PageHeader extends LitElement {
|
|||||||
|
|
||||||
if (!header) return;
|
if (!header) return;
|
||||||
|
|
||||||
if (this.actions.length === 0) {
|
|
||||||
this.commitCollapsed(new Set());
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttons = new Map<string, HTMLElement>();
|
const buttons = new Map<string, HTMLElement>();
|
||||||
|
|
||||||
for (const el of this.renderRoot.querySelectorAll<HTMLElement>(
|
for (const el of this.renderRoot.querySelectorAll<HTMLElement>(
|
||||||
@@ -534,6 +552,7 @@ export class PageHeader extends LitElement {
|
|||||||
|
|
||||||
const more = this.moreButton;
|
const more = this.moreButton;
|
||||||
const title = this.renderRoot.querySelector('h1');
|
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
|
* 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 (more) more.hidden = true;
|
||||||
|
|
||||||
|
if (count) count.hidden = false;
|
||||||
|
|
||||||
const collapsed = new Set<string>();
|
const collapsed = new Set<string>();
|
||||||
|
|
||||||
if (!fits()) {
|
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. */
|
/** Lowest priority first; ties broken from the right. */
|
||||||
@@ -586,7 +642,9 @@ export class PageHeader extends LitElement {
|
|||||||
.map(({ action }) => action);
|
.map(({ action }) => action);
|
||||||
}
|
}
|
||||||
|
|
||||||
private commitCollapsed(next: Set<string>): void {
|
private commitCollapsed(next: Set<string>, countHidden: boolean): void {
|
||||||
|
this.countCollapsed = countHidden;
|
||||||
|
|
||||||
const same =
|
const same =
|
||||||
next.size === this.collapsed.size &&
|
next.size === this.collapsed.size &&
|
||||||
[...next].every((id) => this.collapsed.has(id));
|
[...next].every((id) => this.collapsed.has(id));
|
||||||
@@ -754,7 +812,16 @@ export class PageHeader extends LitElement {
|
|||||||
|
|
||||||
const noun = this.count === 1 ? this.countNoun : plural;
|
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
|
>${this.count.toLocaleString()} ${noun}</span
|
||||||
>`;
|
>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { queueStore } from '@store/queue-store';
|
|||||||
import { creditStore } from '@store/credit-store';
|
import { creditStore } from '@store/credit-store';
|
||||||
import { PlayerController } from '@store/controllers/player-controller';
|
import { PlayerController } from '@store/controllers/player-controller';
|
||||||
import { SearchController } from '@store/controllers/search-controller';
|
import { SearchController } from '@store/controllers/search-controller';
|
||||||
|
import '../search-dialog/search-trigger';
|
||||||
import { SelectionController } from '@utils/selection-controller';
|
import { SelectionController } from '@utils/selection-controller';
|
||||||
import type { SelectionHost } from '@utils/selection-controller';
|
import type { SelectionHost } from '@utils/selection-controller';
|
||||||
import {
|
import {
|
||||||
@@ -1039,6 +1040,16 @@ export class PlaylistDetails
|
|||||||
min-width: 0;
|
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 {
|
.playlist-title {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -1383,6 +1394,9 @@ export class PlaylistDetails
|
|||||||
`
|
`
|
||||||
: ''}
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="header-end">
|
||||||
|
<search-trigger></search-trigger>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${searchBar}
|
${searchBar}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -62,7 +62,10 @@ export class SearchBar extends LitElement {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
min-width: 200px;
|
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%;
|
width: 100%;
|
||||||
transition: border-color 0.15s ease;
|
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 { creditStore } from '@store/credit-store';
|
||||||
import { PlayerController } from '@store/controllers/player-controller';
|
import { PlayerController } from '@store/controllers/player-controller';
|
||||||
import { SearchController } from '@store/controllers/search-controller';
|
import { SearchController } from '@store/controllers/search-controller';
|
||||||
|
import '../search-dialog/search-trigger';
|
||||||
import { SelectionController } from '@utils/selection-controller';
|
import { SelectionController } from '@utils/selection-controller';
|
||||||
import type { SelectionHost } from '@utils/selection-controller';
|
import type { SelectionHost } from '@utils/selection-controller';
|
||||||
import {
|
import {
|
||||||
@@ -359,6 +360,15 @@ export class SmartPlaylistDetails
|
|||||||
flex-shrink: 0;
|
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 {
|
.action-button {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--yj-border-subtle, #555);
|
border: 1px solid var(--yj-border-subtle, #555);
|
||||||
@@ -1300,6 +1310,9 @@ export class SmartPlaylistDetails
|
|||||||
Edit Rules
|
Edit Rules
|
||||||
</button>
|
</button>
|
||||||
`}
|
`}
|
||||||
|
<div class="actions-end">
|
||||||
|
<search-trigger></search-trigger>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${this.editing
|
${this.editing
|
||||||
? html`
|
? html`
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { playerStore } from '@store/player-store';
|
|||||||
import { queueStore } from '@store/queue-store';
|
import { queueStore } from '@store/queue-store';
|
||||||
import * as Player from '@go/player/player.js';
|
import * as Player from '@go/player/player.js';
|
||||||
import type { SearchBar } from '@components/search-bar/search-bar';
|
import type { SearchBar } from '@components/search-bar/search-bar';
|
||||||
|
import { OPEN_SEARCH_EVENT } from '@components/search-dialog/search-dialog';
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// KEY STRING UTILITIES
|
// KEY STRING UTILITIES
|
||||||
@@ -387,14 +388,24 @@ async function dispatch(action: string): Promise<void> {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
// Navigation
|
// 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.search':
|
||||||
case 'nav.searchAlt': {
|
case 'nav.searchAlt': {
|
||||||
const bar = document.querySelector(
|
const bar = document.querySelector(
|
||||||
'search-bar',
|
'header.top-bar search-bar',
|
||||||
) as SearchBar | null;
|
) as SearchBar | null;
|
||||||
|
|
||||||
if (bar && !bar.hasAttribute('hidden')) {
|
if (bar && bar.checkVisibility()) {
|
||||||
bar.focusInput();
|
bar.focusInput();
|
||||||
|
} else {
|
||||||
|
document.dispatchEvent(new CustomEvent(OPEN_SEARCH_EVENT));
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -119,6 +119,16 @@ export const FIT_STEPS: readonly FitStep[] = [
|
|||||||
* @returns the ids collapsed, in the order they were given up.
|
* @returns the ids collapsed, in the order they were given up.
|
||||||
*/
|
*/
|
||||||
export function measureTopBarFit(bar: HTMLElement): string[] {
|
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 fits = () => {
|
||||||
const style = getComputedStyle(bar);
|
const style = getComputedStyle(bar);
|
||||||
const box = bar.getBoundingClientRect();
|
const box = bar.getBoundingClientRect();
|
||||||
|
|||||||
@@ -124,6 +124,19 @@ export const ICON_DOWNLOADING = 'download';
|
|||||||
*/
|
*/
|
||||||
export const ICON_MORE_ACTIONS = 'ellipsis';
|
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.
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user