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:
@@ -11,6 +11,7 @@ import {
|
||||
contextMenuStyles,
|
||||
} from '../../utils/context-menu-controller';
|
||||
import { ICON_MORE_ACTIONS } from '../../utils/icon-language';
|
||||
import '../search-dialog/search-trigger';
|
||||
|
||||
/**
|
||||
* The one arrangement every primary view uses to say what it is.
|
||||
@@ -457,6 +458,20 @@ export class PageHeader extends LitElement {
|
||||
${this.renderCount()}
|
||||
<div class="spacer"></div>
|
||||
${this.renderScope()} ${this.renderSort()}
|
||||
<!-- #57. Below 600px the top bar is out of the layout,
|
||||
so the search box has to be reachable from here.
|
||||
It renders nothing at every other width and on
|
||||
every view search-store says has nothing to
|
||||
search, which is why no host declares it: the map
|
||||
of searchable views already exists and this is one
|
||||
more reader of it, not a second copy.
|
||||
|
||||
Before the actions, and never one of them: an
|
||||
action can collapse into the overflow menu, and on
|
||||
a phone that menu is already where the page's own
|
||||
actions live -- search behind an ellipsis is the
|
||||
top bar's problem moved rather than fixed. -->
|
||||
<search-trigger></search-trigger>
|
||||
${this.renderActions()}
|
||||
<slot name="actions"></slot>
|
||||
</header>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { queueStore } from '@store/queue-store';
|
||||
import { creditStore } from '@store/credit-store';
|
||||
import { PlayerController } from '@store/controllers/player-controller';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import '../search-dialog/search-trigger';
|
||||
import { SelectionController } from '@utils/selection-controller';
|
||||
import type { SelectionHost } from '@utils/selection-controller';
|
||||
import {
|
||||
@@ -1039,6 +1040,16 @@ export class PlaylistDetails
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* #57. This view is in search-store's map and filters on the
|
||||
term, but it is a detail view and so has no page-header to
|
||||
carry the phone's search button. Pushed to the end of the
|
||||
header row, which is where page-header puts it too. */
|
||||
.header-end {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
@@ -1383,6 +1394,9 @@ export class PlaylistDetails
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
<div class="header-end">
|
||||
<search-trigger></search-trigger>
|
||||
</div>
|
||||
</div>
|
||||
${searchBar}
|
||||
<div
|
||||
|
||||
@@ -62,7 +62,10 @@ export class SearchBar extends LitElement {
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
min-width: 200px;
|
||||
max-width: 360px;
|
||||
/* A cap for a header, not for the box. search-dialog gives
|
||||
it the whole of a modal, where 360px of a 424px screen
|
||||
would read as a control that failed to size itself. */
|
||||
max-width: var(--yj-search-max-width, 360px);
|
||||
width: 100%;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* The phone's search surface (#57).
|
||||
*
|
||||
* Below 600px there is no top bar to hold a search box — the bar is out
|
||||
* of the layout entirely, which is the single biggest vertical win
|
||||
* available on a 439 CSS px viewport. So the box moves into a modal and
|
||||
* the *trigger* moves into the row that already says which page you are
|
||||
* on (`search-trigger`, beside this file).
|
||||
*
|
||||
* **It is a `wa-dialog`, and that is a mechanism rather than a taste.**
|
||||
* #60 read this out of the Web Awesome source: `wa-popup` renders
|
||||
* `<div popover="manual">` and feature-detects the Popover API, falling
|
||||
* back to `strategy: "fixed"` where there is none — which is the
|
||||
* reference device, Chrome 113, since `popover` is Chrome 114. And
|
||||
* `position: fixed` escapes ancestor *overflow* but not `contain:
|
||||
* paint`, which makes an element a containing block for fixed
|
||||
* descendants **and clips them**; `index.css` puts `contain: layout
|
||||
* style paint` on `.main-panel`, which is the ancestor of every view.
|
||||
* A popup-shaped search panel opened from a view's header would
|
||||
* therefore be structurally clipped on the one device this issue is
|
||||
* about, and **no tier here could see it** — CI's Chromium and WebKit
|
||||
* both have the Popover API, so the popup is top-layered and correct.
|
||||
* `<dialog>`/`showModal()` is Chrome 37 and uses the real top layer, so
|
||||
* this is immune by construction.
|
||||
*
|
||||
* **It carries the real `<search-bar>`**, not a second input. That is
|
||||
* what keeps one debounce, one clear button, one accessible name and
|
||||
* one view-scoped placeholder — and it is why `store/search-store.ts`
|
||||
* is still the only statement of which views can search and what they
|
||||
* search. The modal is a presentation of the control, not a copy of it.
|
||||
*
|
||||
* **The results are the view, not a list in here.** The Direction says
|
||||
* "the box and live results"; the live results already exist, because
|
||||
* the term is view-scoped and the page behind this dialog filters on it
|
||||
* and says so in `page-header`'s "Showing albums matching …" line.
|
||||
* Rendering results in the dialog would be a second implementation of
|
||||
* every view's own filtering, and a worse one — it could not offer the
|
||||
* row actions the view does. So Enter closes and hands the screen back.
|
||||
*
|
||||
* A singleton in `index.html` for the reason `shortcuts-overlay` is:
|
||||
* one instance, one `data-testid`, one document listener, and no
|
||||
* `data-testid="search-input"` resolving to two elements while it is
|
||||
* shut.
|
||||
*/
|
||||
import { LitElement, css, html, nothing } from 'lit';
|
||||
import { customElement, query, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { nameDialogsIn } from '@utils/name-dialog';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import type { SearchBar } from '../search-bar/search-bar';
|
||||
import '../search-bar/search-bar';
|
||||
|
||||
/** The event any trigger dispatches to open this. */
|
||||
export const OPEN_SEARCH_EVENT = 'open-search';
|
||||
|
||||
@customElement('search-dialog')
|
||||
export class SearchDialog extends LitElement {
|
||||
private searchCtrl = new SearchController(this);
|
||||
|
||||
@query('wa-dialog') private dialog?: HTMLElement & { open: boolean };
|
||||
|
||||
@query('search-bar') private bar?: SearchBar;
|
||||
|
||||
@state() private isOpen = false;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog) {
|
||||
background: var(--yj-bg-surface, #212529);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
/* The box is the whole content, so it gets the whole width
|
||||
rather than the 360px cap it wears in a header. */
|
||||
search-bar {
|
||||
display: block;
|
||||
width: 100%;
|
||||
--yj-search-max-width: none;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0.75em 0 0;
|
||||
font-size: var(--yj-text-sm, 0.8125rem);
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
document.addEventListener(OPEN_SEARCH_EVENT, this.open);
|
||||
// Capture, on the host: the path runs document -> host ->
|
||||
// shadow root -> the input inside `search-bar`, so a capture
|
||||
// listener here is the only one that gets the key *before* the
|
||||
// input's own handler. A `@keydown` in the template is a
|
||||
// bubbling listener and would run after the term was cleared,
|
||||
// and there is nowhere to put a `firstUpdated` hook -- the
|
||||
// first render of this element produces no content at all.
|
||||
this.addEventListener('keydown', this.onKeydown, true);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener(OPEN_SEARCH_EVENT, this.open);
|
||||
this.removeEventListener('keydown', this.onKeydown, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not a toggle, for `shortcuts-overlay`'s reason: a dialog owns
|
||||
* every unmodified key while it is up, so a second press of the
|
||||
* shortcut that opened it never reaches the shortcut service.
|
||||
*/
|
||||
private open = (): void => {
|
||||
if (this.isOpen) return;
|
||||
|
||||
// Nothing to search here is not an error; it is the state the
|
||||
// trigger already declines to render in. Guarding here too is
|
||||
// what makes the keyboard route (Ctrl+F on a phone) agree with
|
||||
// the button.
|
||||
if (!this.searchCtrl.isSearchableView) return;
|
||||
|
||||
this.isOpen = true;
|
||||
|
||||
void this.updateComplete.then(() => {
|
||||
if (this.dialog) this.dialog.open = true;
|
||||
|
||||
// `wa-dialog` positions and shows in its own update, and
|
||||
// `search-bar` populates its own shadow root in one more —
|
||||
// the same lifecycle trap `name-dialog.ts` documents. One
|
||||
// more frame, and the box has an input to focus.
|
||||
requestAnimationFrame(() => this.bar?.focusInput());
|
||||
});
|
||||
};
|
||||
|
||||
private close(): void {
|
||||
if (this.dialog) this.dialog.open = false;
|
||||
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape closes and **keeps the term**; Enter closes and shows the
|
||||
* results.
|
||||
*
|
||||
* Escape is the one worth stating. `search-bar`'s input treats it
|
||||
* as *clear the search*, which is right in a header — the box is on
|
||||
* screen either way, so clearing is the only thing left for the key
|
||||
* to mean. Here it would make dismissing the search surface
|
||||
* silently discard the search, and discarding is what the clear
|
||||
* button inside it is for. So this runs first and closes; the term
|
||||
* survives, and the page behind is still filtered by it.
|
||||
*/
|
||||
private onKeydown = (e: KeyboardEvent): void => {
|
||||
if (!this.isOpen) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
this.close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Web Awesome renders `label` into a heading it never points the
|
||||
* `<dialog>` at. See `utils/name-dialog.ts`.
|
||||
*/
|
||||
override updated(): void {
|
||||
nameDialogsIn(this.shadowRoot);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.isOpen) return nothing;
|
||||
|
||||
const scope = this.searchCtrl.scopeLabel;
|
||||
|
||||
return html`
|
||||
<wa-dialog
|
||||
label=${`Search ${scope}`}
|
||||
data-testid="search-dialog"
|
||||
@wa-hide=${() => this.close()}
|
||||
>
|
||||
<search-bar></search-bar>
|
||||
<p class="hint">
|
||||
Results appear on the page behind this. Press Enter
|
||||
or close to see them.
|
||||
</p>
|
||||
</wa-dialog>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'search-dialog': SearchDialog;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* The phone's way into search (#57): one button, in the row that
|
||||
* already says which page you are on.
|
||||
*
|
||||
* **Which views show it is not a decision this component makes.**
|
||||
* `store/search-store.ts` has held the map of what each view searches
|
||||
* since plan 007, and #57's own Findings say so — "that is exactly the
|
||||
* condition for showing the button". So this asks `isSearchableView`
|
||||
* and renders nothing otherwise, and no second list of searchable views
|
||||
* exists to fall out of step with the first.
|
||||
*
|
||||
* **It is an element rather than a `PageAction`**, and that is the
|
||||
* whole reason it is a component at all. Two of the seven searchable
|
||||
* views — `playlist-details` and `smart-playlist-details` — have no
|
||||
* `page-header`; they filter on the term and say so in their own
|
||||
* headers. Declaring search as an action would mean seven hosts each
|
||||
* writing it out, which is the second list again, and it would put a
|
||||
* *phone mode for actions* inside `page-header`, which that component
|
||||
* documents its refusal to grow. An element three headers place is one
|
||||
* statement of the rule, placed three times.
|
||||
*
|
||||
* It does not participate in `page-header`'s overflow measurement, for
|
||||
* the reason the count and the sort control do not: it is 32px, it is
|
||||
* `flex-shrink: 0`, and the header's `fits()` sees its width like any
|
||||
* other child. What it must never do is collapse into the overflow
|
||||
* menu — on a phone that menu is the only home for the page's actions
|
||||
* already, and search would be two taps behind an ellipsis.
|
||||
*/
|
||||
import { LitElement, css, html, nothing } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { PHONE_QUERY } from '@utils/breakpoints';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import { ICON_SEARCH } from '@utils/icon-language';
|
||||
import { OPEN_SEARCH_EVENT } from './search-dialog';
|
||||
|
||||
@customElement('search-trigger')
|
||||
export class SearchTrigger extends LitElement {
|
||||
private searchCtrl = new SearchController(this);
|
||||
|
||||
/**
|
||||
* From `matchMedia` rather than a media query, because this decides
|
||||
* whether the button *exists* — `job-band`'s rule, and for the same
|
||||
* consequence: a header that renders it at every width puts a
|
||||
* second search affordance beside the desktop's own box.
|
||||
*/
|
||||
@state() private phone = false;
|
||||
|
||||
private media?: MediaQueryList;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* The smallest a touch target should be. The header's
|
||||
own action buttons are smaller because they carry a
|
||||
label; this one is a glyph. */
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 1px solid var(--yj-border-subtle, #555);
|
||||
border-radius: 4px;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* A search that is *on* says so without a second control:
|
||||
the page already carries "Showing albums matching ...",
|
||||
and this is the button that reopens the box to change or
|
||||
clear it. */
|
||||
button.filtering {
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
color: var(--yj-accent-text, #ffd43b);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.media = window.matchMedia(PHONE_QUERY);
|
||||
this.phone = this.media.matches;
|
||||
this.media.addEventListener('change', this.onMedia);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.media?.removeEventListener('change', this.onMedia);
|
||||
}
|
||||
|
||||
private onMedia = (e: MediaQueryListEvent): void => {
|
||||
this.phone = e.matches;
|
||||
};
|
||||
|
||||
private onClick = (): void => {
|
||||
document.dispatchEvent(new CustomEvent(OPEN_SEARCH_EVENT));
|
||||
};
|
||||
|
||||
override render() {
|
||||
if (!this.phone || !this.searchCtrl.isSearchableView) return nothing;
|
||||
|
||||
const scope = this.searchCtrl.scopeLabel;
|
||||
const term = this.searchCtrl.term;
|
||||
|
||||
// The name carries the state, because the colour cannot: a
|
||||
// control that is a different colour and the same word is a
|
||||
// control that says nothing to anyone not seeing it. Same rule
|
||||
// `library-status.ts` states for a partial badge.
|
||||
const label = term
|
||||
? `Search ${scope}, showing matches for ${term}`
|
||||
: `Search ${scope}`;
|
||||
|
||||
return html`
|
||||
<button
|
||||
data-testid="search-trigger"
|
||||
class=${term ? 'filtering' : ''}
|
||||
aria-label=${label}
|
||||
title=${label}
|
||||
@click=${this.onClick}
|
||||
>
|
||||
<wa-icon name=${ICON_SEARCH}></wa-icon>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'search-trigger': SearchTrigger;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { queueStore } from '@store/queue-store';
|
||||
import { creditStore } from '@store/credit-store';
|
||||
import { PlayerController } from '@store/controllers/player-controller';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import '../search-dialog/search-trigger';
|
||||
import { SelectionController } from '@utils/selection-controller';
|
||||
import type { SelectionHost } from '@utils/selection-controller';
|
||||
import {
|
||||
@@ -359,6 +360,15 @@ export class SmartPlaylistDetails
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* #57. Like playlist-details, this view filters on the search
|
||||
term and has no page-header to carry the phone's search
|
||||
button, so the action row does. */
|
||||
.actions-end {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
background: none;
|
||||
border: 1px solid var(--yj-border-subtle, #555);
|
||||
@@ -1300,6 +1310,9 @@ export class SmartPlaylistDetails
|
||||
Edit Rules
|
||||
</button>
|
||||
`}
|
||||
<div class="actions-end">
|
||||
<search-trigger></search-trigger>
|
||||
</div>
|
||||
</div>
|
||||
${this.editing
|
||||
? html`
|
||||
|
||||
Reference in New Issue
Block a user