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
+142
View File
@@ -0,0 +1,142 @@
import { test, expect } from '../support/fixtures.js';
/**
* H-19: Playlists, Downloads, Jobs, Settings and Home had a page
* title; Artists, Genres, Albums and Tracks had none, none of the nine
* showed a count, and two of them had a sort control that the other
* seven had written out (or not) for themselves.
*
* This is the spec that stops the tenth view inventing a tenth
* arrangement: every primary view is asked for its heading, and the
* ones that show a list are asked for a count as well.
*/
/** view id -> [heading, has a count] */
const VIEWS: [string, string, boolean][] = [
['home', 'Home', false],
['playlists', 'Playlists', true],
['artists', 'Artists', true],
['genres', 'Genres', true],
['albums', 'Albums', true],
['tracks', 'Tracks', true],
['explore', 'Explore', false],
['downloads', 'Downloads', false],
['jobs', 'Background jobs', false],
];
/** The header lives in the view's shadow root, inside its own. */
const header = (page: import('@playwright/test').Page, view: string) =>
page.evaluate((v) => {
const el = document.querySelector(`[data-testid="main-content"] ${v}`);
const ph = el?.shadowRoot?.querySelector('page-header');
const root = ph?.shadowRoot;
return {
heading:
root
?.querySelector('[data-testid="page-heading"]')
?.textContent?.trim() ?? null,
count:
root
?.querySelector('[data-testid="page-count"]')
?.textContent?.trim() ?? null,
};
}, view);
/** Which element a view id renders as. */
const TAGS: Record<string, string> = {
home: 'home-view',
playlists: 'playlist-view',
artists: 'artists-view',
genres: 'genres-view',
albums: 'cover-grid',
tracks: 'track-list',
explore: 'explore-view',
downloads: 'downloads-view',
jobs: 'jobs-view',
};
test.describe('every primary view says what it is', () => {
test('each one has the shared header, with a heading', async ({ app }) => {
for (const [view, heading, hasCount] of VIEWS) {
await app.getByTestId(`nav-${view}`).click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
view,
);
// Poll: a view is a chunk, so the first visit awaits an import.
await expect
.poll(async () => (await header(app, TAGS[view]!)).heading)
.toBe(heading);
if (hasCount) {
// Polled, not read: the count is deliberately absent until the
// view has an answer — "0 albums" while loading is a lie that
// corrects itself, which is worse than saying nothing.
await expect
.poll(async () => (await header(app, TAGS[view]!)).count)
.toMatch(/^[\d,]+ \w+$/);
} else {
expect((await header(app, TAGS[view]!)).count).toBeNull();
}
}
// Leave the app where the next spec expects it.
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
test('the count is of what is on screen, not of the library', async ({
app,
}) => {
await app.getByTestId('nav-artists').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'artists',
);
// Polled, and cleared first: the term survives navigation (by
// decision), and the count is absent until the view has an answer
// — so reading either one straight after a click can capture null
// and make every assertion after it meaningless.
await app.getByTestId('search-input').fill('');
await expect
.poll(async () => (await header(app, 'artists-view')).count)
.toMatch(/^[\d,]+ \w+$/);
const all = (await header(app, 'artists-view')).count;
// The header search is view-scoped by decision (Decisions, 2), and
// the header is where that scope is finally admitted to.
await app.getByTestId('search-input').fill('aurora');
await expect
.poll(async () => (await header(app, 'artists-view')).count)
.not.toBe(all);
const scope = await app.evaluate(() =>
document
.querySelector('[data-testid="main-content"] artists-view')
?.shadowRoot?.querySelector('page-header')
?.shadowRoot?.querySelector('[data-testid="page-search-scope"]')
?.textContent?.trim(),
);
expect(scope).toContain('artists matching');
await app.getByTestId('search-input').fill('');
await expect
.poll(async () => (await header(app, 'artists-view')).count)
.toBe(all);
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
});
});
+9
View File
@@ -66,6 +66,15 @@ test.describe('view lifecycle', () => {
.toBe(1);
expect(await pendingCount(app)).toBe(before);
// Toggle it back. Shuffle is backend state that outlives the page,
// so leaving it on fails `playback.spec`'s shuffle assertion on the
// *next* run against the same app — the specs share one process,
// and this one was quietly spending state it never returned.
await app.keyboard.press('s');
await expect
.poll(() => eventNames(app).then((n) => n.QueueModeChanged ?? 0))
.toBe(2);
});
test('on Autotag, the same key skips and does not also shuffle', async ({
@@ -19,6 +19,7 @@ import { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller';
import { libraryStore } from '@store/library-store';
import { SearchController } from '@store/controllers/search-controller';
import '@components/page-header/page-header';
import { queueStore } from '@store/queue-store';
import {
ContextMenuController,
@@ -52,6 +53,11 @@ const SCROLL_DEBOUNCE_MS = 100;
/**
* Grid entry for the virtualized artist grid.
*/
const ARTIST_SORT_DIR_KEY = 'artists-view-sort-dir';
/** One key, so the header renders a label and a direction button. */
const ARTIST_SORT_OPTIONS = [{ id: 'name', label: 'Name' }];
interface ArtistEntry {
artist: library.Artist;
index: number;
@@ -184,12 +190,22 @@ export class ArtistsView
});
}
/** Sort direction for the artist grid.
*
* There is only one key to sort by: `library.Artist` carries a
* name, an MBID and three image URLs, and nothing countable — so
* the header shows "Sort: Name" and this button, rather than a
* select with one option in it (H-19). */
@state()
private sortDirection: 'asc' | 'desc' = 'asc';
// -- Memoisation caches for filtered artists --
private cachedFilteredArtists: library.Artist[] =
[];
private cachedGridEntries: ArtistEntry[] = [];
private prevFilterArtists: library.Artist[] = [];
private prevFilterTerm = '';
private prevFilterDir: 'asc' | 'desc' | '' = '';
/**
* Recompute the filtered-artists and grid-entries
@@ -202,10 +218,12 @@ export class ArtistsView
if (
this.artists !== this.prevFilterArtists ||
term !== this.prevFilterTerm
term !== this.prevFilterTerm ||
this.sortDirection !== this.prevFilterDir
) {
this.prevFilterArtists = this.artists;
this.prevFilterTerm = term;
this.prevFilterDir = this.sortDirection;
this.cachedFilteredArtists =
this.computeFilteredArtists();
this.cachedGridEntries =
@@ -222,15 +240,38 @@ export class ArtistsView
const term =
this.searchCtrl.term.toLowerCase();
if (!term) {
return this.artists;
}
const matching = term
? this.artists.filter((a) =>
a.Name.toLowerCase().includes(term),
)
: this.artists;
return this.artists.filter((a) =>
a.Name.toLowerCase().includes(term),
// Descending is the only reordering available, so ascending
// keeps the backend's order rather than re-sorting it: the
// array's identity is what tells the virtualizer to repaint,
// and copying it every pass would repaint on every keystroke.
if (this.sortDirection === 'asc') return matching;
return [...matching].sort((a, b) =>
b.Name.localeCompare(a.Name),
);
}
private onPageHeaderSort = (
e: CustomEvent<{ direction: 'asc' | 'desc' }>,
) => {
this.sortDirection = e.detail.direction;
try {
localStorage.setItem(
ARTIST_SORT_DIR_KEY,
this.sortDirection,
);
} catch {
// Ignore storage errors.
}
};
static override styles = [
contextMenuStyles,
css`
@@ -415,9 +456,24 @@ export class ArtistsView
override connectedCallback() {
super.connectedCallback();
this.loadCardSize();
this.loadSortDirection();
this.loadArtists();
}
private loadSortDirection() {
try {
const saved = localStorage.getItem(
ARTIST_SORT_DIR_KEY,
);
if (saved === 'asc' || saved === 'desc') {
this.sortDirection = saved;
}
} catch {
// Ignore storage errors.
}
}
override disconnectedCallback() {
super.disconnectedCallback();
this.detachWheelListener();
@@ -1334,7 +1390,16 @@ export class ArtistsView
override render() {
if (this.loading) {
// The header keeps its place while the view loads: a
// heading that appears only once the data does is the
// shifting layout this component exists to stop.
return html`
<page-header
heading="Artists"
.sortOptions=${ARTIST_SORT_OPTIONS}
sort-field="name"
sort-direction=${this.sortDirection}
></page-header>
<div class="loading-message">
Loading artists...
</div>
@@ -1342,15 +1407,18 @@ export class ArtistsView
}
const entries = this.cachedGridEntries;
const searchBar = this.searchCtrl.term
? html`<div class="search-bar-row">
<div class="search-indicator">
Showing results for
&ldquo;${this.searchCtrl
.term}&rdquo;
</div>
</div>`
: nothing;
const searchBar = html`
<page-header
heading="Artists"
.count=${entries.length}
count-noun="artist"
.sortOptions=${ARTIST_SORT_OPTIONS}
sort-field="name"
sort-direction=${this.sortDirection}
search-term=${this.searchCtrl.term}
@sort-change=${this.onPageHeaderSort}
></page-header>
`;
if (entries.length === 0) {
return html`
@@ -13,127 +13,6 @@ const gridStyles = css`
contain: layout style;
}
/* ========================================
* Sort toolbar
* ======================================== */
.sort-toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: var(--yj-text-sm);
color: var(
--yj-text-secondary,
#b3b3b3
);
border-bottom: 1px solid
var(--yj-border-subtle, #333);
flex-shrink: 0;
user-select: none;
}
.sort-anchor {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
background: transparent;
border: none;
color: inherit;
font: inherit;
}
.sort-anchor:hover {
background: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
}
.sort-anchor .sort-label {
color: var(--yj-text-primary, #fff);
}
.sort-dir-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
cursor: pointer;
border: none;
border-radius: 4px;
background: transparent;
color: var(
--yj-text-secondary,
#b3b3b3
);
font-size: var(--yj-text-sm);
padding: 0;
}
.sort-dir-btn:hover {
background: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
color: var(--yj-text-primary, #fff);
}
.sort-dropdown-panel {
background-color: var(
--yj-bg-elevated,
#343a40
);
border: 1px solid
var(--yj-border, #444);
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px
rgba(0, 0, 0, 0.5);
min-width: 140px;
}
.sort-dropdown-panel wa-dropdown-item {
cursor: pointer;
--wa-color-text-normal: var(
--yj-text-primary,
#fff
);
font-size: var(--yj-text-md);
}
.sort-dropdown-panel
wa-dropdown-item:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.1)
);
}
.sort-dropdown-panel
wa-dropdown-item.active-sort {
color: var(--yj-accent, #ffd43b);
--wa-color-text-normal: var(
--yj-accent,
#ffd43b
);
}
#sort-dropdown {
z-index: 200;
}
.grid-scroll-container {
flex: 1;
position: relative;
overflow-y: auto;
contain: paint;
}
/* ========================================
* Album card
* ======================================== */
@@ -245,26 +124,6 @@ const gridStyles = css`
color: var(--yj-text-secondary, #b3b3b3);
}
.sort-toolbar {
position: relative;
}
.search-indicator {
position: absolute;
left: 50%;
transform: translateX(-50%);
pointer-events: none;
background: var(--yj-bg-overlay, #495057);
color: var(--yj-text-secondary, #b3b3b3);
font-size: var(--yj-text-sm);
padding: 2px 14px;
border-radius: 12px;
border: 1px solid
var(--yj-border-subtle, #555);
white-space: nowrap;
opacity: 0.92;
}
.empty-state {
display: flex;
flex-direction: column;
+37 -172
View File
@@ -57,6 +57,7 @@ import {
removeDragImage,
} from '@utils/drag-image';
import { coverGridStyles } from './cover-grid-styles.js';
import '@components/page-header/page-header';
import {
ALBUM_SORT_OPTIONS,
SORT_DIR_KEY,
@@ -315,13 +316,6 @@ export class CoverGrid
@state()
private sortDirection: SortDirection = 'asc';
/** Whether the sort dropdown popup is open. */
@state()
private sortDropdownOpen = false;
@query('#sort-dropdown')
private sortDropdownPopup!: WaPopup;
/** ID of the album whose dropdown is currently open, or null. */
@state()
expandedAlbumId: number | null = null;
@@ -427,81 +421,6 @@ export class CoverGrid
}
}
/** Set the sort field from the dropdown. */
private onSortDropdownSelect(
field: AlbumSortField,
) {
this.sortField = field;
this.saveSortPreferences();
this.closeSortDropdown();
}
/** Toggle sort direction. */
private toggleSortDirection() {
this.sortDirection =
this.sortDirection === 'asc'
? 'desc'
: 'asc';
this.saveSortPreferences();
}
private toggleSortDropdown() {
if (this.sortDropdownOpen) {
this.closeSortDropdown();
} else {
this.openSortDropdown();
}
}
private async openSortDropdown() {
this.sortDropdownOpen = true;
await this.updateComplete;
const popup = this.sortDropdownPopup;
const anchor =
this.shadowRoot?.querySelector(
'.sort-anchor',
);
if (popup && anchor) {
popup.anchor = anchor;
popup.active = true;
}
}
private closeSortDropdown() {
if (!this.sortDropdownOpen) return;
this.sortDropdownOpen = false;
const popup = this.sortDropdownPopup;
if (popup) {
popup.active = false;
}
}
private sortDropdownCloseHandler = (
e: MouseEvent,
) => {
if (!this.sortDropdownOpen) return;
const path = e.composedPath();
const popup = this.sortDropdownPopup;
if (popup && path.includes(popup)) return;
const anchor =
this.shadowRoot?.querySelector(
'.sort-anchor',
);
if (anchor && path.includes(anchor)) return;
this.closeSortDropdown();
};
/* ====================================================================
* Lifecycle
* ==================================================================== */
@@ -524,14 +443,6 @@ export class CoverGrid
);
}
protected override onViewActivate(): void {
this.listenWhileActive(
document,
'mousedown',
this.sortDropdownCloseHandler,
);
}
override disconnectedCallback() {
super.disconnectedCallback();
@@ -1642,90 +1553,42 @@ export class CoverGrid
* ==================================================================== */
/** Render the sort toolbar above the grid. */
private renderSortToolbar() {
const activeOpt = ALBUM_SORT_OPTIONS.find(
(o) => o.id === this.sortField,
);
const label = activeOpt
? activeOpt.label
: 'Name';
const dirIcon =
this.sortDirection === 'asc'
? 'arrow-up-short-wide'
: 'arrow-down-wide-short';
/**
* The page header: title, count, sort, and what the header search
* box is filtering by. This was a hand-rolled toolbar written out
* twice — the other copy was in `track-list` — which is how Albums
* and Tracks came to have a sort control while Artists and Genres
* had none (H-19).
*
* `externalAlbums` means this grid is a section of the artist page,
* which has a heading of its own; there it keeps the count and the
* sort and drops the title.
*/
private renderPageHeader() {
return html`
<div class="sort-toolbar">
<span>Sort:</span>
<button
class="sort-anchor"
@click=${() =>
this.toggleSortDropdown()}
>
<span class="sort-label">
${label}
</span>
<wa-icon
name="chevron-down"
></wa-icon>
</button>
<button
class="sort-dir-btn"
title="${this.sortDirection === 'asc' ? 'Ascending' : 'Descending'}"
@click=${() =>
this.toggleSortDirection()}
>
<wa-icon
name=${dirIcon}
></wa-icon>
</button>
${this.searchCtrl.term
? html`<div class="search-indicator">
Showing results for
&ldquo;${this.searchCtrl.term}&rdquo;
</div>`
: nothing}
</div>
${this.renderSortDropdownPopup()}
<page-header
heading=${this.externalAlbums === undefined ? 'Albums' : ''}
.count=${this.cachedFilteredAlbums.length}
count-noun="album"
.sortOptions=${ALBUM_SORT_OPTIONS.map((o) => ({
id: o.id,
label: o.label,
}))}
sort-field=${this.sortField}
sort-direction=${this.sortDirection}
search-term=${this.searchCtrl.term}
@sort-change=${this.onPageHeaderSort}
></page-header>
`;
}
/** Render the sort dropdown popup. */
private renderSortDropdownPopup() {
return html`
<wa-popup
id="sort-dropdown"
placement="bottom-start"
flip
shift
.active=${this.sortDropdownOpen}
>
${this.sortDropdownOpen
? html`
<div
class="sort-dropdown-panel"
>
${ALBUM_SORT_OPTIONS.map(
(opt) => html`
<wa-dropdown-item
class=${this.sortField === opt.id ? 'active-sort' : ''}
@click=${() =>
this.onSortDropdownSelect(
opt.id,
)}
>
${opt.label}
</wa-dropdown-item>
`,
)}
</div>
`
: nothing}
</wa-popup>
`;
}
private onPageHeaderSort = (
e: CustomEvent<{ field: string; direction: SortDirection }>,
) => {
this.sortField = e.detail.field as AlbumSortField;
this.sortDirection = e.detail.direction;
this.saveSortPreferences();
};
/* ====================================================================
* Rendering helpers
@@ -1769,6 +1632,8 @@ export class CoverGrid
return album.CoverArtPath;
}
/* ====================================================================
/* ====================================================================
* Render: grid entry (virtualizer renderItem)
* ==================================================================== */
@@ -1887,7 +1752,7 @@ export class CoverGrid
if (this.cachedFilteredAlbums.length === 0) {
return html`
${this.renderSortToolbar()}
${this.renderPageHeader()}
<div class="empty-state">
<p>No albums match your search.</p>
</div>
@@ -1897,7 +1762,7 @@ export class CoverGrid
const gridContent = this.renderSingleGrid();
return html`
${this.renderSortToolbar()}
${this.renderPageHeader()}
<div
class="grid-scroll-container"
@click=${this.onGridClick}
@@ -2,6 +2,7 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css';
import { downloadStore, stateLabel } from '@store/download-store';
import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store';
@@ -57,6 +58,12 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
box-sizing: border-box;
}
/* The header supplies its own padding and rule, so it runs
to the edge of a host that pads its own content. */
page-header {
margin: -20px -20px 1em;
}
header {
display: flex;
align-items: center;
@@ -227,11 +234,11 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
override render() {
return html`
<header>
<h1>Downloads</h1>
<page-header heading="Downloads">
${this.tab === 'requests'
? html`
<wa-button
slot="actions"
size="small"
appearance="outlined"
?disabled=${this.checking}
@@ -243,7 +250,7 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
</wa-button>
`
: nothing}
</header>
</page-header>
<p class="subtitle">
Music you have requested, and the download attempts that
@@ -1,5 +1,6 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js';
import '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css';
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, RecordSearchClick } from '@go/explore/Service';
import { libraryStore } from '../../store/library-store';
@@ -165,6 +166,12 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
box-sizing: border-box;
}
/* The header supplies its own padding and rule, so it runs
to the edge of a host that pads its own content. */
page-header {
margin: -24px -24px 1em;
}
/* ── Search mode tabs ── */
.search-mode-tabs {
display: flex;
@@ -1311,6 +1318,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
override render() {
return html`
<page-header heading="Explore"></page-header>
${this.renderSearchInput()}
${this.loading
? html`<div class="loading-indicator">Searching\u2026</div>`
@@ -16,6 +16,7 @@ import {
import type { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller';
import { SearchController } from '@store/controllers/search-controller';
import '@components/page-header/page-header';
import { queueStore } from '@store/queue-store';
import {
ContextMenuController,
@@ -47,6 +48,13 @@ const CARD_SIZE_DEFAULT = 176;
const SCROLL_DEBOUNCE_MS = 100;
/** A genre extracted from the track library. */
const GENRE_SORT_KEY = 'genres-view-sort';
const GENRE_SORT_OPTIONS = [
{ id: 'name', label: 'Name' },
{ id: 'tracks', label: 'Tracks' },
];
interface Genre {
name: string;
trackCount: number;
@@ -189,11 +197,19 @@ export class GenresView
});
}
/** Sort key and direction for the genre grid (H-19: it had none). */
@state()
private sortField: 'name' | 'tracks' = 'name';
@state()
private sortDirection: 'asc' | 'desc' = 'asc';
// -- Memoisation caches for filtered genres --
private cachedFilteredGenres: Genre[] = [];
private cachedGridEntries: GenreEntry[] = [];
private prevFilterGenres: Genre[] = [];
private prevFilterTerm = '';
private prevFilterSort = '';
/**
* Recompute the filtered-genres and grid-entries
@@ -204,12 +220,16 @@ export class GenresView
private recomputeGenreCaches() {
const term = this.searchCtrl.term;
const sortKey = `${this.sortField}:${this.sortDirection}`;
if (
this.genres !== this.prevFilterGenres ||
term !== this.prevFilterTerm
term !== this.prevFilterTerm ||
sortKey !== this.prevFilterSort
) {
this.prevFilterGenres = this.genres;
this.prevFilterTerm = term;
this.prevFilterSort = sortKey;
this.cachedFilteredGenres =
this.computeFilteredGenres();
this.cachedGridEntries =
@@ -226,15 +246,62 @@ export class GenresView
const term =
this.searchCtrl.term.toLowerCase();
if (!term) {
return this.genres;
const matching = term
? this.genres.filter((g) =>
g.name.toLowerCase().includes(term),
)
: this.genres;
// The default order is the backend's, and the array's identity
// is what makes the virtualizer repaint — so leave it alone
// unless the user asked for something else.
if (this.sortField === 'name' && this.sortDirection === 'asc') {
return matching;
}
return this.genres.filter((g) =>
g.name.toLowerCase().includes(term),
const dir = this.sortDirection === 'asc' ? 1 : -1;
return [...matching].sort((a, b) =>
this.sortField === 'tracks'
? dir * (a.trackCount - b.trackCount)
: dir * a.name.localeCompare(b.name),
);
}
private onPageHeaderSort = (
e: CustomEvent<{ field: string; direction: 'asc' | 'desc' }>,
) => {
this.sortField =
e.detail.field === 'tracks' ? 'tracks' : 'name';
this.sortDirection = e.detail.direction;
try {
localStorage.setItem(
GENRE_SORT_KEY,
`${this.sortField}:${this.sortDirection}`,
);
} catch {
// Ignore storage errors.
}
};
private loadSortPreferences() {
try {
const saved = localStorage.getItem(GENRE_SORT_KEY);
const [field, dir] = (saved ?? '').split(':');
if (field === 'name' || field === 'tracks') {
this.sortField = field;
}
if (dir === 'asc' || dir === 'desc') {
this.sortDirection = dir;
}
} catch {
// Ignore storage errors.
}
}
static override styles = [
contextMenuStyles,
css`
@@ -407,6 +474,7 @@ export class GenresView
override connectedCallback() {
super.connectedCallback();
this.loadCardSize();
this.loadSortPreferences();
this.loadGenres();
}
@@ -1175,7 +1243,16 @@ export class GenresView
override render() {
if (this.loading) {
// The header keeps its place while the view loads: a
// heading that appears only once the data does is the
// shifting layout this component exists to stop.
return html`
<page-header
heading="Genres"
.sortOptions=${GENRE_SORT_OPTIONS}
sort-field=${this.sortField}
sort-direction=${this.sortDirection}
></page-header>
<div class="loading-message">
Loading genres...
</div>
@@ -1183,15 +1260,18 @@ export class GenresView
}
const entries = this.cachedGridEntries;
const searchBar = this.searchCtrl.term
? html`<div class="search-bar-row">
<div class="search-indicator">
Showing results for
&ldquo;${this.searchCtrl
.term}&rdquo;
</div>
</div>`
: nothing;
const searchBar = html`
<page-header
heading="Genres"
.count=${entries.length}
count-noun="genre"
.sortOptions=${GENRE_SORT_OPTIONS}
sort-field=${this.sortField}
sort-direction=${this.sortDirection}
search-term=${this.searchCtrl.term}
@sort-change=${this.onPageHeaderSort}
></page-header>
`;
if (entries.length === 0) {
return html`
+9 -16
View File
@@ -9,6 +9,7 @@ import { queueStore } from '@store/queue-store';
import { libraryStore } from '@store/library-store';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
@@ -60,19 +61,11 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
box-sizing: border-box;
}
header {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 4px;
}
h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--yj-text-primary, #fff);
flex: 1;
/* The header brings its own padding, and the host already
has some — without this the title sits indented from the
lede directly beneath it. */
page-header {
margin: -24px -20px 4px;
}
.lede {
@@ -244,9 +237,9 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
override render() {
return html`
<header>
<h1>Home</h1>
<page-header heading="Home">
<wa-button
slot="actions"
size="small"
appearance="plain"
title="Reshuffle the suggestions"
@@ -255,7 +248,7 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
<wa-icon slot="start" name="shuffle"></wa-icon>
Shuffle
</wa-button>
</header>
</page-header>
<p class="lede">Somewhere to start listening.</p>
${this.renderBody()}
`;
+8 -1
View File
@@ -1,6 +1,7 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css';
import {
GetAllLibrariesWithTrackCounts,
@@ -78,6 +79,12 @@ export class JobsView extends ViewLifecycleMixin(LitElement) {
box-sizing: border-box;
}
/* The header supplies its own padding and rule, so it runs
to the edge of a host that pads its own content. */
page-header {
margin: -1.5em -1.75em 1em;
}
h1 {
font-size: var(--yj-text-xl);
color: var(--yj-text-primary, #e9ecef);
@@ -460,7 +467,7 @@ export class JobsView extends ViewLifecycleMixin(LitElement) {
);
return html`
<h1>Background jobs</h1>
<page-header heading="Background jobs"></page-header>
<p class="page-sub">
Library scans and search index builds, with their progress and
output.
@@ -0,0 +1,342 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
/**
* The one arrangement every primary view uses to say what it is.
*
* Before this, four views had a heading and four did not, two had sort
* controls and none showed a count (`hands-on.md`, H-19) — so the page
* shifted its shape as you moved through it, and the answer to "how
* many albums do I have" was to count them. Each view had also written
* its own arrangement, which is why they disagreed: the sort toolbar in
* `track-list` and the one in `cover-grid` are the same twenty lines
* twice, and the ninth view would have made a ninth.
*
* Title, count, sort, actions — in that order, in one component, so a
* new view gets the shape by using it rather than by copying whichever
* neighbour it happened to read.
*/
export interface SortOption {
id: string;
label: string;
}
export type SortDirection = 'asc' | 'desc';
@customElement('page-header')
export class PageHeader extends LitElement {
/**
* The page's name. Rendered as the view's only `h1`.
*
* Empty is a real mode, not a missing value: `cover-grid` and
* `track-list` are also *embedded* in the artist and genre pages,
* which already have a heading of their own. There they keep the
* count and the sort and drop the title, rather than growing a
* second arrangement for the same three controls.
*/
@property({ type: String })
heading = '';
/**
* How many things are on the page. `null` means "not applicable"
* (Jobs, Settings) rather than zero, and renders nothing — an
* empty page says so in its empty state, which has room for a
* sentence.
*/
@property({ type: Number })
count: number | null = null;
/** Singular noun for the count; pluralised with a trailing `s`. */
@property({ type: String, attribute: 'count-noun' })
countNoun = 'item';
/** Irregular plural, where a trailing `s` will not do. */
@property({ type: String, attribute: 'count-plural' })
countPlural = '';
/** Sort choices. Empty (the default) renders no sort control. */
@property({ attribute: false })
sortOptions: SortOption[] = [];
@property({ type: String, attribute: 'sort-field' })
sortField = '';
@property({ type: String, attribute: 'sort-direction' })
sortDirection: SortDirection = 'asc';
/**
* The search term the page is filtered by, if any. The header says
* so, because the *scope* of the header search box is the view —
* so a page showing three of forty albums has to admit why.
*/
@property({ type: String, attribute: 'search-term' })
searchTerm = '';
/** Shown while the view is refetching, next to the heading. */
@property({ type: Boolean })
busy = false;
static override styles = [
designTokens,
css`
:host {
display: block;
flex-shrink: 0;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px 10px;
border-bottom: 1px solid var(--yj-border-subtle, #333);
}
h1 {
margin: 0;
font-size: var(--yj-text-xl, 18px);
font-weight: 600;
color: var(--yj-text-primary, #fff);
white-space: nowrap;
}
.count {
font-size: var(--yj-text-sm, 12px);
color: var(--yj-text-tertiary, #888);
white-space: nowrap;
}
.scope {
font-size: var(--yj-text-sm, 12px);
color: var(--yj-text-secondary, #b3b3b3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
/* Actions sit at the right; everything before them is the
page's identity and stays left. */
.spacer {
flex: 1 1 auto;
min-width: 0;
}
.sort {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--yj-text-sm, 12px);
color: var(--yj-text-secondary, #b3b3b3);
flex-shrink: 0;
}
.sort select {
font: inherit;
color: inherit;
background: var(--yj-bg-surface, #212529);
border: 1px solid var(--yj-border, #444);
border-radius: 4px;
padding: 3px 6px;
cursor: pointer;
}
.sort-dir {
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: inherit;
cursor: pointer;
padding: 3px 5px;
}
.sort-dir:hover {
background: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
}
.sort-dir:focus-visible,
.sort select:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: -1px;
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid var(--yj-border, #444);
border-top-color: var(--yj-accent, #ffd43b);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation-duration: 3s;
}
}
::slotted(*) {
flex-shrink: 0;
}
`,
];
override render() {
return html`
<header class="page-header" part="header">
${this.heading === ''
? nothing
: html`<h1 data-testid="page-heading">
${this.heading}
</h1>`}
${this.busy
? html`<span
class="spinner"
role="status"
aria-label="Refreshing"
></span>`
: nothing}
${this.renderCount()}
<div class="spacer"></div>
${this.renderScope()} ${this.renderSort()}
<slot name="actions"></slot>
</header>
`;
}
private renderCount() {
if (this.count === null) return nothing;
const plural =
this.countPlural !== ''
? this.countPlural
: `${this.countNoun}s`;
const noun = this.count === 1 ? this.countNoun : plural;
return html`<span class="count" data-testid="page-count"
>${this.count.toLocaleString()} ${noun}</span
>`;
}
private renderScope() {
if (this.searchTerm === '') return nothing;
const what =
this.heading === ''
? 'results'
: this.heading.toLowerCase();
return html`<span class="scope" data-testid="page-search-scope"
>Showing ${what} matching &ldquo;${this.searchTerm}&rdquo;</span
>`;
}
private renderSort() {
if (this.sortOptions.length === 0) return nothing;
const ascending = this.sortDirection === 'asc';
// One option is not a choice: Artists can only be sorted by
// name, because `library.Artist` carries no counts to sort by.
// A select with a single option is a control that does
// nothing, so it says what the order is and lets the direction
// button do the work.
if (this.sortOptions.length === 1) {
return html`
<div class="sort">
<span>Sort: ${this.sortOptions[0]?.label}</span>
${this.renderDirectionButton(ascending)}
</div>
`;
}
return html`
<div class="sort">
<label>
Sort:
<select
data-testid="page-sort"
.value=${this.sortField}
@change=${this.onSortFieldChange}
>
${this.sortOptions.map(
(o) => html`
<option
value=${o.id}
?selected=${o.id === this.sortField}
>
${o.label}
</option>
`,
)}
</select>
</label>
${this.renderDirectionButton(ascending)}
</div>
`;
}
private renderDirectionButton(ascending: boolean) {
return html`
<button
class="sort-dir"
type="button"
data-testid="page-sort-direction"
aria-label=${ascending ? 'Sort ascending' : 'Sort descending'}
aria-pressed=${ascending ? 'false' : 'true'}
title=${ascending ? 'Ascending' : 'Descending'}
@click=${this.onDirectionClick}
>
<wa-icon
name=${ascending
? 'arrow-up-short-wide'
: 'arrow-down-wide-short'}
></wa-icon>
</button>
`;
}
private onSortFieldChange = (e: Event) => {
const field = (e.target as HTMLSelectElement).value;
this.emitSort(field, this.sortDirection);
};
private onDirectionClick = () => {
this.emitSort(
this.sortField,
this.sortDirection === 'asc' ? 'desc' : 'asc',
);
};
/** The host owns the sort state and persists it; this only asks. */
private emitSort(field: string, direction: SortDirection) {
this.dispatchEvent(
new CustomEvent('sort-change', {
detail: { field, direction },
bubbles: true,
composed: true,
}),
);
}
}
declare global {
interface HTMLElementTagNameMap {
'page-header': PageHeader;
}
}
@@ -122,12 +122,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
/** Sort direction. */
@state() private sortDirection: SortDirection = 'desc';
/** Whether the sort dropdown is open. */
@state() private sortDropdownOpen = false;
@query('#sort-dropdown')
private sortDropdownPopup!: WaPopup;
/**
* File paths from a drop that landed outside any playlist.
* When non-empty the create form is in "create-and-add" mode.
@@ -185,29 +179,9 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
contain: layout style;
}
.header {
.header-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
flex-shrink: 0;
border-bottom: 1px solid var(--yj-border-subtle, #333);
}
.header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--yj-text-primary, #fff);
display: flex;
align-items: center;
gap: 10px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
gap: 8px;
}
.header-spinner {
@@ -374,29 +348,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
color: var(--yj-text-secondary, #b3b3b3);
}
.sort-toolbar {
position: relative;
}
.search-indicator {
position: absolute;
left: 50%;
transform: translateX(-50%);
pointer-events: none;
background: var(--yj-bg-overlay, #495057);
color: var(
--yj-text-secondary,
#b3b3b3
);
font-size: 12px;
padding: 2px 14px;
border-radius: 12px;
border: 1px solid
var(--yj-border-subtle, #555);
white-space: nowrap;
opacity: 0.92;
}
.empty-state {
display: flex;
flex-direction: column;
@@ -523,103 +474,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
var(--yj-error, #e03131);
}
/* ---- Sort toolbar ---- */
.sort-toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 12px;
color: var(--yj-text-secondary, #b3b3b3);
border-bottom: 1px solid
var(--yj-border-subtle, #333);
flex-shrink: 0;
user-select: none;
}
.sort-anchor {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
background: transparent;
border: none;
color: inherit;
font: inherit;
}
.sort-anchor:hover {
background: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
}
.sort-anchor .sort-label {
color: var(--yj-text-primary, #fff);
}
.sort-dir-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
cursor: pointer;
border: none;
border-radius: 4px;
background: transparent;
color: var(--yj-text-secondary, #b3b3b3);
font-size: 12px;
padding: 0;
}
.sort-dir-btn:hover {
background: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
color: var(--yj-text-primary, #fff);
}
.sort-dropdown-panel {
background-color: var(
--yj-bg-elevated,
#343a40
);
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
min-width: 140px;
}
.sort-dropdown-panel wa-dropdown-item {
cursor: pointer;
--wa-color-text-normal: var(
--yj-text-primary,
#fff
);
font-size: 13px;
}
.sort-dropdown-panel wa-dropdown-item:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.1)
);
}
.sort-dropdown-panel wa-dropdown-item.active-sort {
color: var(--yj-accent, #ffd43b);
--wa-color-text-normal: var(
--yj-accent,
#ffd43b
);
}
#sort-dropdown {
z-index: 200;
@@ -728,79 +582,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
});
}
private toggleSortDropdown() {
if (this.sortDropdownOpen) {
this.closeSortDropdown();
} else {
this.openSortDropdown();
}
}
private async openSortDropdown() {
this.sortDropdownOpen = true;
await this.updateComplete;
const popup = this.sortDropdownPopup;
const anchor =
this.shadowRoot?.querySelector(
'.sort-anchor',
);
if (popup && anchor) {
popup.anchor = anchor;
popup.active = true;
}
}
private closeSortDropdown() {
if (!this.sortDropdownOpen) return;
this.sortDropdownOpen = false;
const popup = this.sortDropdownPopup;
if (popup) {
popup.active = false;
}
}
private onSortDropdownSelect(
field: PlaylistSortField,
) {
this.sortField = field;
this.saveSortPreferences();
this.closeSortDropdown();
}
private toggleSortDirection() {
this.sortDirection =
this.sortDirection === 'asc'
? 'desc'
: 'asc';
this.saveSortPreferences();
}
private sortDropdownCloseHandler = (
e: MouseEvent,
) => {
if (!this.sortDropdownOpen) return;
const path = e.composedPath();
const popup = this.sortDropdownPopup;
if (popup && path.includes(popup)) return;
const anchor =
this.shadowRoot?.querySelector(
'.sort-anchor',
);
if (anchor && path.includes(anchor)) return;
this.closeSortDropdown();
};
override connectedCallback() {
super.connectedCallback();
this.restoreSortPreferences();
@@ -828,11 +609,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
'click',
this.clearSelectionHandler,
);
this.listenWhileActive(
document,
'mousedown',
this.sortDropdownCloseHandler,
);
}
override disconnectedCallback() {
@@ -1644,138 +1420,56 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
// Render
// =================================================================
private renderSortToolbar() {
const activeOption = SORT_OPTIONS.find(
(o) => o.id === this.sortField,
);
const label = activeOption?.label ?? 'Recent';
const dirIcon =
this.sortDirection === 'asc'
? 'arrow-up-short-wide'
: 'arrow-down-wide-short';
return html`
<div class="sort-toolbar">
<span>Sort:</span>
<button
class="sort-anchor"
@click=${() =>
this.toggleSortDropdown()}
>
<span class="sort-label">
${label}
</span>
<wa-icon
name="chevron-down"
></wa-icon>
</button>
<button
class="sort-dir-btn"
title="${this.sortDirection === 'asc' ? 'Ascending' : 'Descending'}"
@click=${() =>
this.toggleSortDirection()}
>
<wa-icon
name=${dirIcon}
></wa-icon>
</button>
${this.searchCtrl.term
? html`<div class="search-indicator">
Showing results for
&ldquo;${this.searchCtrl.term}&rdquo;
</div>`
: nothing}
</div>
${this.renderSortDropdownPopup()}
`;
}
private renderSortDropdownPopup() {
return html`
<wa-popup
id="sort-dropdown"
placement="bottom-start"
flip
shift
.active=${this.sortDropdownOpen}
>
${this.sortDropdownOpen
? html`
<div
class="sort-dropdown-panel"
>
${SORT_OPTIONS.map(
(opt) => html`
<wa-dropdown-item
class=${this.sortField === opt.id ? 'active-sort' : ''}
@click=${() =>
this.onSortDropdownSelect(
opt.id,
)}
>
${opt.label}
</wa-dropdown-item>
`,
)}
</div>
`
: nothing}
</wa-popup>
`;
}
private onPageHeaderSort = (
e: CustomEvent<{ field: string; direction: 'asc' | 'desc' }>,
) => {
this.sortField = e.detail.field as PlaylistSortField;
this.sortDirection = e.detail.direction;
this.saveSortPreferences();
};
override render() {
return html`
<div class="header">
<h2>
Playlists
${this.refreshing
? html`<span
class="header-spinner"
></span>`
: nothing}
</h2>
<div
style="display: flex; gap: 8px;"
>
<page-header
heading="Playlists"
.count=${this.loading && this.entries.length === 0
? null
: this.sortedEntries.length}
count-noun="playlist"
.sortOptions=${SORT_OPTIONS}
sort-field=${this.sortField}
sort-direction=${this.sortDirection}
search-term=${this.searchCtrl.term}
?busy=${this.refreshing}
@sort-change=${this.onPageHeaderSort}
>
<div slot="actions" class="header-actions">
<button
class="import-button"
@click=${this
.handleImportPlaylist}
@click=${this.handleImportPlaylist}
>
<wa-icon
name="file-import"
></wa-icon>
<wa-icon name="file-import"></wa-icon>
Import
</button>
<button
class="new-playlist-button ${this.dragOverNewButton ? 'drag-over' : ''}"
@click=${this
.handleNewPlaylistClick}
@dragover=${this
.onNewButtonDragOver}
@dragleave=${this
.onNewButtonDragLeave}
@drop=${this
.onNewButtonDrop}
@click=${this.handleNewPlaylistClick}
@dragover=${this.onNewButtonDragOver}
@dragleave=${this.onNewButtonDragLeave}
@drop=${this.onNewButtonDrop}
>
<wa-icon
name="plus"
></wa-icon>
<wa-icon name="plus"></wa-icon>
New Playlist
</button>
<button
class="new-playlist-button"
@click=${this
.handleNewSmartPlaylistClick}
@click=${this.handleNewSmartPlaylistClick}
>
<wa-icon
name="filter"
></wa-icon>
<wa-icon name="filter"></wa-icon>
New Smart Playlist
</button>
</div>
</div>
</page-header>
${this.importError
? html`<div class="import-error">
@@ -1783,8 +1477,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
</div>`
: nothing}
${this.renderSortToolbar()}
${this.creating || this.creatingSmart
? this.renderCreateForm()
: nothing}
@@ -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
+38 -315
View File
@@ -18,6 +18,8 @@ import {
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import { PlayerController } from '@store/controllers/player-controller';
import { SearchController } from '@store/controllers/search-controller';
import '@components/page-header/page-header';
import type { SortOption } from '@components/page-header/page-header';
import { TrackListController } from '@store/controllers/tracklist-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { queueStore } from '@store/queue-store';
@@ -312,13 +314,6 @@ export class TrackList
@state()
private sortDirection: SortDirection = 'asc';
/** Whether the sort dropdown popup is open. */
@state()
private sortDropdownOpen = false;
@query('#sort-dropdown')
private sortDropdownPopup!: WaPopup;
/** Whether delegated event handlers have been attached to the virtualizer. */
private delegationAttached = false;
@@ -899,109 +894,6 @@ export class TrackList
overflow: hidden;
}
/* ---- Sort toolbar ---- */
.sort-toolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
border-bottom: 1px solid
var(--yj-border-subtle, #333);
flex-shrink: 0;
user-select: none;
}
.sort-anchor {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
background: transparent;
border: none;
color: inherit;
font: inherit;
}
.sort-anchor:hover {
background: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
}
.sort-anchor .sort-label {
color: var(--yj-text-primary, #fff);
}
.sort-dir-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
cursor: pointer;
border: none;
border-radius: 4px;
background: transparent;
color: var(--yj-text-secondary, #b3b3b3);
font-size: var(--yj-text-sm);
padding: 0;
}
.sort-dir-btn:hover {
background: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.05)
);
color: var(--yj-text-primary, #fff);
}
.sort-dropdown-panel {
background-color: var(
--yj-bg-elevated,
#343a40
);
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
min-width: 140px;
}
.sort-dropdown-panel wa-dropdown-item {
cursor: pointer;
--wa-color-text-normal: var(
--yj-text-primary,
#fff
);
font-size: var(--yj-text-md);
}
.sort-dropdown-panel wa-dropdown-item:hover {
background-color: var(
--yj-hover-overlay,
rgba(255, 255, 255, 0.1)
);
}
.sort-dropdown-panel wa-dropdown-item.active-sort {
color: var(--yj-accent, #ffd43b);
--wa-color-text-normal: var(
--yj-accent,
#ffd43b
);
}
#sort-dropdown {
z-index: 200;
}
/* ---- Header row ---- */
.header-row {
@@ -1068,25 +960,6 @@ export class TrackList
background-color: var(--yj-text-tertiary, #6c757d);
}
.sort-toolbar {
position: relative;
}
.search-indicator {
position: absolute;
left: 50%;
transform: translateX(-50%);
pointer-events: none;
background: var(--yj-bg-overlay, #495057);
color: var(--yj-text-secondary, #b3b3b3);
font-size: var(--yj-text-sm);
padding: 2px 14px;
border-radius: 12px;
border: 1px solid var(--yj-border-subtle, #555);
white-space: nowrap;
opacity: 0.92;
}
.no-results {
padding: 24px 16px;
color: var(--yj-text-secondary, #b3b3b3);
@@ -1259,11 +1132,6 @@ export class TrackList
* list is never disconnected, so this is the only place they can be
* taken down again. */
protected override onViewActivate(): void {
this.listenWhileActive(
document,
'mousedown',
this.sortDropdownCloseHandler,
);
this.listenWhileActive(document, 'click', this.clearSelectionHandler);
this.listenWhileActive(
document,
@@ -1851,86 +1719,6 @@ export class TrackList
this.saveSortPreferences();
}
/** Set sort from the dropdown and close it. */
private onSortDropdownSelect(
colId: string | null,
) {
if (colId === null) {
this.sortField = null;
this.sortDirection = 'asc';
} else {
this.sortField = colId;
}
this.saveSortPreferences();
this.closeSortDropdown();
}
/** Toggle sort direction via the toolbar button. */
private toggleSortDirection() {
this.sortDirection =
this.sortDirection === 'asc'
? 'desc'
: 'asc';
this.saveSortPreferences();
}
private toggleSortDropdown() {
if (this.sortDropdownOpen) {
this.closeSortDropdown();
} else {
this.openSortDropdown();
}
}
private async openSortDropdown() {
this.sortDropdownOpen = true;
await this.updateComplete;
const popup = this.sortDropdownPopup;
const anchor = this.shadowRoot?.querySelector(
'.sort-anchor',
);
if (popup && anchor) {
popup.anchor = anchor;
popup.active = true;
}
}
private closeSortDropdown() {
if (!this.sortDropdownOpen) return;
this.sortDropdownOpen = false;
const popup = this.sortDropdownPopup;
if (popup) {
popup.active = false;
}
}
private sortDropdownCloseHandler = (
e: MouseEvent,
) => {
if (!this.sortDropdownOpen) return;
const path = e.composedPath();
const popup = this.sortDropdownPopup;
if (popup && path.includes(popup)) return;
const anchor =
this.shadowRoot?.querySelector(
'.sort-anchor',
);
if (anchor && path.includes(anchor)) return;
this.closeSortDropdown();
};
private isActiveTrack(track: library.Track): boolean {
const currentTrack = this.player.currentTrack;
@@ -2023,121 +1811,56 @@ export class TrackList
`;
};
/** Render the sort toolbar above the header row. */
private renderSortToolbar() {
const activeCol = this.sortField
? COLUMN_DEFS[this.sortField]
: null;
const label = activeCol
? activeCol.label
: 'Default';
const dirIcon =
this.sortDirection === 'asc'
? 'arrow-up-short-wide'
: 'arrow-down-wide-short';
/**
* The page header, which carries what used to be a hand-rolled
* sort toolbar written out twice (here and in `cover-grid`).
*
* `externalTracks` means this list is a section of some other page
* — the genre and playlist details — which has a heading already,
* so the header keeps the count and the sort and drops the title.
*/
private renderPageHeader() {
const options: SortOption[] = [
{ id: '', label: 'Default' },
...this.activeColumns
.filter((c) => c.comparator)
.map((c) => ({ id: c.id, label: c.label })),
];
return html`
<div class="sort-toolbar">
<span>Sort:</span>
<button
class="sort-anchor"
@click=${() =>
this.toggleSortDropdown()}
>
<span class="sort-label">
${label}
</span>
<wa-icon
name="chevron-down"
></wa-icon>
</button>
${this.sortField
? html`
<button
class="sort-dir-btn"
title="${this.sortDirection === 'asc' ? 'Ascending' : 'Descending'}"
@click=${() =>
this.toggleSortDirection()}
>
<wa-icon
name=${dirIcon}
></wa-icon>
</button>
`
: nothing}
${this.searchCtrl.term
? html`<div class="search-indicator">
Showing results for
&ldquo;${this.searchCtrl.term}&rdquo;
</div>`
: nothing}
</div>
${this.renderSortDropdownPopup()}
<page-header
heading=${this.externalTracks === undefined ? 'Tracks' : ''}
.count=${this.loadingTracks
? null
: this.cachedSortedTracks.length}
count-noun="track"
.sortOptions=${options}
sort-field=${this.sortField ?? ''}
sort-direction=${this.sortDirection}
search-term=${this.searchCtrl.term}
@sort-change=${this.onPageHeaderSort}
></page-header>
`;
}
/** Render the sort dropdown popup. */
private renderSortDropdownPopup() {
const cols = this.activeColumns;
return html`
<wa-popup
id="sort-dropdown"
placement="bottom-start"
flip
shift
.active=${this.sortDropdownOpen}
>
${this.sortDropdownOpen
? html`
<div
class="sort-dropdown-panel"
>
<wa-dropdown-item
class=${!this.sortField ? 'active-sort' : ''}
@click=${() =>
this.onSortDropdownSelect(
null,
)}
>
Default
</wa-dropdown-item>
${cols
.filter(
(c) =>
c.comparator,
)
.map(
(col) => html`
<wa-dropdown-item
class=${this.sortField === col.id ? 'active-sort' : ''}
@click=${() =>
this.onSortDropdownSelect(
col.id,
)}
>
${col.label}
</wa-dropdown-item>
`,
)}
</div>
`
: nothing}
</wa-popup>
`;
}
private onPageHeaderSort = (
e: CustomEvent<{ field: string; direction: 'asc' | 'desc' }>,
) => {
this.sortField =
e.detail.field === '' ? null : e.detail.field;
this.sortDirection = e.detail.direction;
this.saveSortPreferences();
};
override render() {
const visibleTracks = this.cachedSortedTracks;
const cols = this.activeColumns;
return html`
${this.renderPageHeader()}
${this.tracks.length === 0
? this.renderPlaceholder()
: html`
${this.renderSortToolbar()}
<div
class="table-container"
role="grid"
@@ -51,4 +51,14 @@ export class SearchController implements ReactiveController {
get isSearchableView(): boolean {
return searchStore.isSearchableView();
}
/** What this view searches ('albums'), or '' if it searches nothing. */
get scopeLabel(): string {
return searchStore.scopeLabel();
}
/** Why the box is disabled here, or '' if it is not. */
get disabledReason(): string {
return searchStore.disabledReason();
}
}
+49 -3
View File
@@ -1,5 +1,33 @@
/** Searchable views that respond to the global search term. */
const SEARCHABLE_VIEWS = new Set(['tracks', 'albums', 'playlists', 'playlist-details', 'artists', 'genres']);
/**
* What the header search box searches, per view.
*
* The box is **view-scoped by decision** (plan 007, Decisions 2), and
* H-10 is that it never said so: it sits in the global header,
* placeheld "Search…", and typing `tide` on Playlists answered "No
* playlists match your search" with three *Tideline* tracks in the
* library. The scope was always real; the copy is what was missing.
*
* A view absent from this map is one with nothing of its own to
* search. The box keeps its slot there and is disabled, rather than
* being removed — its appearing and disappearing is what made the
* whole header change shape between pages.
*/
const SEARCH_SCOPES: Record<string, string> = {
tracks: 'tracks',
albums: 'albums',
artists: 'artists',
genres: 'genres',
playlists: 'playlists',
'playlist-details': 'tracks in this playlist',
};
/**
* Views with a search of their own in the page. The header box points
* at it rather than pretending to be it.
*/
const OWN_SEARCH_VIEWS: Record<string, string> = {
explore: 'Search the catalog in the page',
};
type Subscriber = () => void;
@@ -39,7 +67,25 @@ class SearchStore {
}
isSearchableView(): boolean {
return SEARCHABLE_VIEWS.has(this.currentView);
return this.currentView in SEARCH_SCOPES;
}
/** What this view searches, e.g. `albums`. Empty if it does not. */
scopeLabel(): string {
return SEARCH_SCOPES[this.currentView] ?? '';
}
/**
* What the box should say when it cannot be used here: either that
* the page has its own search, or that there is nothing to search.
*/
disabledReason(): string {
if (this.isSearchableView()) return '';
return (
OWN_SEARCH_VIEWS[this.currentView] ??
'Nothing to search on this page'
);
}
// ===================================================================
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,202 @@
/**
* H-19: four views had a heading and four did not, two had sort
* controls, 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 them.
*
* `<page-header>` is the one arrangement they all use now. The
* behavioural assertions are here; the baselines below are what catch
* the thing no assertion can — the header looking wrong.
*/
import { describe, expect, it } from 'vitest';
import type { PageHeader } from '@components/page-header/page-header';
import '@components/page-header/page-header';
import { fixture, shadow, shadowAll, update, visual } from '@test/support/render';
const SORTS = [
{ id: 'name', label: 'Name' },
{ id: 'tracks', label: 'Tracks' },
];
describe('<page-header>', () => {
it('renders the heading as the page\u2019s only h1', async () => {
const el = await fixture<PageHeader>('page-header', {
heading: 'Albums',
});
expect(shadowAll(el, 'h1').map((h) => h.textContent?.trim())).toEqual([
'Albums',
]);
});
it('counts in the plural, and in the singular when there is one', async () => {
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 4,
countNoun: 'playlist',
});
expect(shadow(el, '[data-testid="page-count"]')?.textContent?.trim()).toBe(
'4 playlists',
);
await update(el, { count: 1 });
expect(shadow(el, '[data-testid="page-count"]')?.textContent?.trim()).toBe(
'1 playlist',
);
});
it('says nothing about a count it has not been given', async () => {
// `null` is "not applicable" (Jobs, Settings), not zero — a page
// with nothing on it says so in its empty state, which has room
// for a sentence.
const el = await fixture<PageHeader>('page-header', {
heading: 'Background jobs',
});
expect(shadow(el, '[data-testid="page-count"]')).toBeNull();
});
it('renders zero, which is a real answer', async () => {
const el = await fixture<PageHeader>('page-header', {
heading: 'Albums',
count: 0,
countNoun: 'album',
});
expect(shadow(el, '[data-testid="page-count"]')?.textContent?.trim()).toBe(
'0 albums',
);
});
it('asks for a sort rather than performing one', async () => {
// The host owns the sort state and its persistence; the header is
// a control, not a source of truth.
const el = await fixture<PageHeader>('page-header', {
heading: 'Genres',
sortOptions: SORTS,
sortField: 'name',
sortDirection: 'asc',
});
const seen: unknown[] = [];
el.addEventListener('sort-change', (e) =>
seen.push((e as CustomEvent).detail),
);
const select = shadow<HTMLSelectElement>(el, '[data-testid="page-sort"]');
select!.value = 'tracks';
select!.dispatchEvent(new Event('change'));
shadow<HTMLElement>(el, '[data-testid="page-sort-direction"]')?.click();
expect(seen).toEqual([
{ field: 'tracks', direction: 'asc' },
{ field: 'name', direction: 'desc' },
]);
// Unchanged: the host had not applied either.
expect(el.sortField).toBe('name');
});
it('offers no sort control when there is nothing to sort by', async () => {
const el = await fixture<PageHeader>('page-header', { heading: 'Home' });
expect(shadow(el, '.sort')).toBeNull();
});
it('shows one sort key as a label, not a select with one option', async () => {
// Artists: `library.Artist` carries nothing countable, so the only
// key is the name and the direction button does the work.
const el = await fixture<PageHeader>('page-header', {
heading: 'Artists',
sortOptions: [{ id: 'name', label: 'Name' }],
sortField: 'name',
});
expect(shadow(el, '[data-testid="page-sort"]')).toBeNull();
expect(shadow(el, '[data-testid="page-sort-direction"]')).not.toBeNull();
expect(shadow(el, '.sort')?.textContent).toContain('Name');
});
it('names the scope of the search that is filtering it', async () => {
// The header search is view-scoped by decision, so a page showing
// three of forty has to say why — "No playlists match your search"
// arriving after the fact was the whole of H-10.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 3,
countNoun: 'playlist',
searchTerm: 'tide',
});
expect(
shadow(el, '[data-testid="page-search-scope"]')?.textContent,
).toContain('playlists matching');
});
it('drops the title where it is embedded in a page that has one', async () => {
// `cover-grid` inside the artist page, `track-list` inside the
// genre page: the count and the sort still apply, a second h1 does
// not.
const el = await fixture<PageHeader>('page-header', {
heading: '',
count: 12,
countNoun: 'album',
});
expect(shadow(el, 'h1')).toBeNull();
expect(shadow(el, '[data-testid="page-count"]')).not.toBeNull();
});
});
describe('<page-header> as each view wears it', () => {
// One baseline per arrangement rather than per view: the point is
// that eight views produce four shapes, not eight.
const cases: [string, Record<string, unknown>][] = [
['title-only', { heading: 'Home' }],
[
'title-and-count',
{ heading: 'Downloads', count: 12, countNoun: 'request' },
],
[
'title-count-and-sort',
{
heading: 'Genres',
count: 5,
countNoun: 'genre',
sortOptions: SORTS,
sortField: 'tracks',
sortDirection: 'desc',
},
],
[
'filtered-by-search',
{
heading: 'Tracks',
count: 3,
countNoun: 'track',
sortOptions: SORTS,
sortField: 'name',
searchTerm: 'tide',
},
],
];
for (const [name, props] of cases) {
it(`looks right: ${name}`, async () => {
const el = await fixture<PageHeader>('page-header', props);
el.style.width = '900px';
await el.updateComplete;
await visual(el, `page-header-${name}`);
// The baseline is opt-in (`make ui-visual`); this keeps the
// default run asserting something rather than nothing.
expect(shadow(el, '.page-header')).not.toBeNull();
});
}
});