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 | 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`
${enabled && term ? html` ` : nothing}
`; } }