The phone's top bar is about to go, and the search box is the one thing in it that is an action rather than chrome. It becomes a button in the row that already says which page you are on, opening a wa-dialog with the real search box in it. Three decisions worth the words. **A wa-dialog, and that is a mechanism rather than a taste.** 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. And `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 component test asserts the *mechanism*, a native `<dialog>` in the tree, rather than the symptom. **An element, not a PageAction.** Two of the seven searchable views are detail views with 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 a second list of searchable views, and it would put a phone mode for actions inside page-header, which that component documents its refusal to grow. search-store's own map is the condition, asked by one component placed three times. **The modal carries the real search-bar**, so there is still one debounce, one clear button and one view-scoped placeholder. Escape closes it and *keeps* the term -- the input treats Escape as "clear the search", which is right in a header where the box stays on screen and wrong in a surface whose dismissal would then discard the search.
249 lines
7.8 KiB
TypeScript
249 lines
7.8 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|