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.
229 lines
7.0 KiB
TypeScript
229 lines
7.0 KiB
TypeScript
import { LitElement, html, css, nothing } from 'lit';
|
|
import { customElement, query } from 'lit/decorators.js';
|
|
import { SearchController } from '@store/controllers/search-controller';
|
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
|
import { designTokens } from '../../styles/tokens.css';
|
|
|
|
/**
|
|
* The header search box.
|
|
*
|
|
* It is **view-scoped** (plan 007, Decisions 2) and used to look
|
|
* global: placeheld "Search…", sitting in the app header, and silently
|
|
* doing nothing on the pages that do not read the term. It now names
|
|
* what it searches — "Search albums" — so "No playlists match your
|
|
* search" arrives having already said it was only ever looking at
|
|
* playlists (H-10).
|
|
*
|
|
* It also used to *hide* on those pages, which moved the library
|
|
* filter and the job indicator every time the user navigated. It keeps
|
|
* its slot now and is disabled, with the reason in its title and its
|
|
* placeholder: either the page has a search of its own (Explore), or
|
|
* there is nothing on it to search.
|
|
*/
|
|
@customElement('search-bar')
|
|
export class SearchBar extends LitElement {
|
|
private searchCtrl = new SearchController(this);
|
|
private searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
@query('input')
|
|
private inputEl!: HTMLInputElement;
|
|
|
|
static override styles = [designTokens, css`
|
|
:host {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
/* Kept, because the first-run wizard hides the whole header;
|
|
navigation no longer sets it. */
|
|
:host([hidden]) {
|
|
display: none;
|
|
}
|
|
|
|
.search-container.disabled {
|
|
opacity: 0.55;
|
|
}
|
|
|
|
.search-container.disabled:focus-within {
|
|
border-color: var(--yj-border-subtle, #555);
|
|
}
|
|
|
|
input:disabled {
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.search-container {
|
|
display: flex;
|
|
align-items: center;
|
|
background: var(--yj-bg-surface, #212529);
|
|
border: 1px solid var(--yj-border-subtle, #555);
|
|
border-radius: 6px;
|
|
padding: 0 10px;
|
|
gap: 8px;
|
|
height: 32px;
|
|
min-width: 200px;
|
|
/* 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;
|
|
}
|
|
|
|
/* The 200px floor is a desktop floor. On a phone the header is
|
|
the whole width there is, and a min-width in a flex row is a
|
|
*hard* one -- it does not shrink, so the header stayed 580px
|
|
wide inside a 360px viewport and the shell scrolled
|
|
sideways. Measured at 360px: 580 -> 360. */
|
|
@media (max-width: 599px) {
|
|
:host {
|
|
min-width: 0;
|
|
}
|
|
|
|
.search-container {
|
|
min-width: 0;
|
|
}
|
|
}
|
|
|
|
.search-container:focus-within {
|
|
border-color: var(--yj-accent, #ffd43b);
|
|
}
|
|
|
|
.search-icon {
|
|
color: var(--yj-text-tertiary, #888);
|
|
font-size: var(--yj-icon-sm);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
input {
|
|
flex: 1;
|
|
background: none;
|
|
border: none;
|
|
outline: none;
|
|
color: var(--yj-text-primary, #fff);
|
|
font-size: var(--yj-text-md);
|
|
font-family: inherit;
|
|
min-width: 0;
|
|
}
|
|
|
|
input::placeholder {
|
|
color: var(--yj-text-tertiary, #888);
|
|
}
|
|
|
|
.clear-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: none;
|
|
border: none;
|
|
color: var(--yj-text-tertiary, #888);
|
|
cursor: pointer;
|
|
padding: 0;
|
|
font-size: var(--yj-text-sm);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.clear-button:hover {
|
|
color: var(--yj-text-primary, #fff);
|
|
}
|
|
`];
|
|
|
|
/**
|
|
* Focus the search input and select all text.
|
|
* Called by the global Ctrl+F handler.
|
|
*/
|
|
focusInput(): void {
|
|
if (!this.inputEl) return;
|
|
|
|
this.inputEl.focus();
|
|
this.inputEl.select();
|
|
}
|
|
|
|
private handleInput = (e: Event) => {
|
|
const input = e.target as HTMLInputElement;
|
|
const value = input.value;
|
|
|
|
if (this.searchDebounceTimer !== null) {
|
|
clearTimeout(this.searchDebounceTimer);
|
|
this.searchDebounceTimer = null;
|
|
}
|
|
|
|
if (value === '') {
|
|
// Instant clear for responsive feedback.
|
|
this.searchCtrl.term = '';
|
|
} else {
|
|
this.searchDebounceTimer = setTimeout(() => {
|
|
this.searchDebounceTimer = null;
|
|
this.searchCtrl.term = value;
|
|
}, 150);
|
|
}
|
|
};
|
|
|
|
private handleClear = () => {
|
|
this.searchCtrl.term = '';
|
|
|
|
if (this.inputEl) {
|
|
this.inputEl.value = '';
|
|
this.inputEl.focus();
|
|
}
|
|
};
|
|
|
|
private handleKeydown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
this.searchCtrl.term = '';
|
|
|
|
if (this.inputEl) {
|
|
this.inputEl.value = '';
|
|
this.inputEl.blur();
|
|
}
|
|
}
|
|
};
|
|
|
|
override render() {
|
|
const term = this.searchCtrl.term;
|
|
const scope = this.searchCtrl.scopeLabel;
|
|
const disabledReason = this.searchCtrl.disabledReason;
|
|
const enabled = disabledReason === '';
|
|
|
|
const placeholder = enabled
|
|
? `Search ${scope}\u2026`
|
|
: disabledReason;
|
|
|
|
return html`
|
|
<div class="search-container ${enabled ? '' : 'disabled'}">
|
|
<wa-icon
|
|
class="search-icon"
|
|
name="magnifying-glass"
|
|
></wa-icon>
|
|
<input
|
|
type="text"
|
|
data-testid="search-input"
|
|
aria-label=${enabled
|
|
? `Search ${scope}`
|
|
: disabledReason}
|
|
title=${enabled ? '' : disabledReason}
|
|
placeholder=${placeholder}
|
|
?disabled=${!enabled}
|
|
.value=${enabled ? term : ''}
|
|
@input=${this.handleInput}
|
|
@keydown=${this.handleKeydown}
|
|
/>
|
|
${enabled && term
|
|
? html`
|
|
<button
|
|
class="clear-button"
|
|
aria-label="Clear search"
|
|
title="Clear search"
|
|
@click=${this.handleClear}
|
|
>
|
|
<wa-icon
|
|
name="xmark"
|
|
></wa-icon>
|
|
</button>
|
|
`
|
|
: nothing}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|