feat(shell): make search a button and a modal where searching applies

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.
This commit is contained in:
2026-08-20 20:02:50 -04:00
parent 8879192097
commit b801fa533a
11 changed files with 695 additions and 3 deletions
@@ -16,6 +16,7 @@ import { playerStore } from '@store/player-store';
import { queueStore } from '@store/queue-store';
import * as Player from '@go/player/player.js';
import type { SearchBar } from '@components/search-bar/search-bar';
import { OPEN_SEARCH_EVENT } from '@components/search-dialog/search-dialog';
// ===================================================================
// KEY STRING UTILITIES
@@ -387,14 +388,24 @@ async function dispatch(action: string): Promise<void> {
break;
// Navigation
// The key has one meaning -- *let me search this page* -- and
// two surfaces since #57. The header box is gone below 600px,
// so scoping the query to the bar is not tidiness: an unscoped
// `search-bar` also matches the one inside `search-dialog`
// while that is open, and would focus a box the user is
// already typing in while leaving the phone with nothing at
// all. The dialog declines to open on a view with nothing to
// search, which is the same condition the trigger renders on.
case 'nav.search':
case 'nav.searchAlt': {
const bar = document.querySelector(
'search-bar',
'header.top-bar search-bar',
) as SearchBar | null;
if (bar && !bar.hasAttribute('hidden')) {
if (bar && bar.checkVisibility()) {
bar.focusInput();
} else {
document.dispatchEvent(new CustomEvent(OPEN_SEARCH_EVENT));
}
break;