Plan 016 B2, phase 1. Below 600px the grid drops its sidebar column, `bottom-nav` becomes the primary navigation, and the shell fits the viewport instead of scrolling sideways out of it. 600 rather than the sidebar's own 900, because 900 is a laptop and the answer there is a narrower sidebar, which is still a sidebar. Under 600 there is no room for one at all: 360px of viewport over a 200px nav is not a layout. **The tab bar is four destinations and a way to everything else.** Three to five is where touch targets stop being thumb-sized -- eleven over 360px is 32px each -- so the four are the ones plan 016's subset says a phone is for, and "More" opens the *existing* `app-sidebar` in a drawer rather than listing the destinations a second time. Two lists is two places to add the next view to. That reuse has a cost this found the hard way: a shared component brings its `data-testid`s with it, so rendering the drawer's sidebar unconditionally put a second `nav-home` (and ten siblings) in the DOM and **failed 30 existing specs** with "resolved to 2 elements" -- on a desktop viewport, where this element is `display: none` and the drawer can never open. It renders only while the drawer is open, and the component test asserts the absence, because the failure is invisible from inside the component and lands in files nobody touched. **What made the shell overflow was minimums, not padding.** Measured at 360px: the body was 652px wide, because a `min-width` in a flex row is a hard floor and a grid item's implicit minimum is its content. So `min-width: 0` on the boxes between the viewport and the content, and each component stands its own non-essential parts down in its *own* stylesheet -- search-bar's 200px floor, job-indicator's label (the visible one; the live region that announces it is untouched), audio-player's seek bar and volume. A media query inside a shadow root is answered by the viewport, so this is the component saying what it drops rather than the shell reaching in. Volume goes because the hardware keys own it on a phone, which is the same reason mediacontrols' Android handler implements no volume callback. Seeking goes because 4px is not a thumb target; it belongs to the full-screen now-playing view, which is the next phase. An existing spec therefore asserts the opposite of what it did: layout-overflow's 320px case used to require that the 464px behind `overflow: hidden` could be *scrolled to*, which was the remedy available while the shell had one layout. It reflows now -- 320px in a 320px viewport, exactly -- and reflow is what WCAG 1.4.10 asked for.
226 lines
6.8 KiB
TypeScript
226 lines
6.8 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;
|
|
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>
|
|
`;
|
|
}
|
|
}
|