feat(ui): give every primary view the same page header
Build & publish Arch package / arch-package (push) Successful in 2m0s
CI / check (push) Canceled after 14s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s

Four views had a heading and four did not, two had a sort control and
none showed a count, so the app changed shape as you moved through it
and "how many albums have I got" could only be answered by counting.
The reason they disagreed is that each had written its own arrangement:
the sort toolbar existed three times, in track-list, cover-grid and
playlist-view, as the same twenty lines with different bugs.

<page-header> is that arrangement once - title, count, sort, actions -
and nine views adopt it. Artists and Genres gain the sort control they
never had; Artists sorts by name only, because library.Artist carries
nothing countable, so the header renders a label and a direction button
rather than a select with one option in it. The header keeps its place
while a view loads: a heading that appears only once the data does is
the shifting layout this is meant to stop. The count is omitted, not
zero, until the view has an answer.

The header search box keeps its slot on every view instead of vanishing
on the ones it cannot serve - which is what moved the library filter
and the job indicator on every navigation. It is view-scoped by
decision and now says so: "Search albums" in the placeholder, the scope
named in the header ("Showing artists matching 'tide'"), and disabled
with a reason where there is nothing to search or the page has a search
of its own.

Also fixes an e2e trap this uncovered: the view-lifecycle spec toggled
shuffle and never toggled it back, so a second run against the same app
failed playback.spec's shuffle assertion - a failure that reads exactly
like a regression in whatever you are holding.
This commit is contained in:
2026-08-12 02:35:35 -04:00
parent cee19d7ef9
commit b7dc368d7c
21 changed files with 1121 additions and 1037 deletions
@@ -5,8 +5,20 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
/**
* Global search bar displayed in the top bar.
* Hides itself when the active view is not searchable.
* 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 {
@@ -22,10 +34,24 @@ export class SearchBar extends LitElement {
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;
@@ -84,16 +110,6 @@ export class SearchBar extends LitElement {
}
`];
override updated() {
// Toggle the hidden attribute based on whether the
// current view supports searching.
if (this.searchCtrl.isSearchableView) {
this.removeAttribute('hidden');
} else {
this.setAttribute('hidden', '');
}
}
/**
* Focus the search input and select all text.
* Called by the global Ctrl+F handler.
@@ -147,24 +163,39 @@ export class SearchBar extends LitElement {
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">
<div class="search-container ${enabled ? '' : 'disabled'}">
<wa-icon
class="search-icon"
name="magnifying-glass"
></wa-icon>
<input
type="text"
placeholder="Search..."
.value=${term}
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}
/>
${term
${enabled && term
? html`
<button
class="clear-button"
aria-label="Clear search"
title="Clear search"
@click=${this.handleClear}
>
<wa-icon