From b7dc368d7c168e10f9f7839a40d9479d46b7c359 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 12 Aug 2026 02:35:35 -0400 Subject: [PATCH] feat(ui): give every primary view the same page header 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. 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. --- e2e/specs/page-header.spec.ts | 142 +++++++ e2e/specs/view-lifecycle.spec.ts | 9 + .../components/artists-view/artists-view.ts | 98 ++++- .../cover-grid/cover-grid-styles.ts | 141 ------- .../src/components/cover-grid/cover-grid.ts | 209 ++-------- .../downloads-view/downloads-view.ts | 13 +- .../components/explore-view/explore-view.ts | 8 + .../src/components/genres-view/genres-view.ts | 108 ++++- .../src/components/home-view/home-view.ts | 25 +- frontend/src/components/jobs/jobs-view.ts | 9 +- .../src/components/page-header/page-header.ts | 342 ++++++++++++++++ .../components/playlist-view/playlist-view.ts | 374 ++---------------- .../src/components/search-bar/search-bar.ts | 63 ++- .../src/components/track-list/track-list.ts | 353 ++--------------- .../store/controllers/search-controller.ts | 10 + frontend/src/store/search-store.ts | 52 ++- ...ader-filtered-by-search-chromium-linux.png | Bin 0 -> 6511 bytes ...-header-title-and-count-chromium-linux.png | Bin 0 -> 3332 bytes ...er-title-count-and-sort-chromium-linux.png | Bin 0 -> 4280 bytes .../page-header-title-only-chromium-linux.png | Bin 0 -> 1165 bytes frontend/test/components/page-header.test.ts | 202 ++++++++++ 21 files changed, 1121 insertions(+), 1037 deletions(-) create mode 100644 e2e/specs/page-header.spec.ts create mode 100644 frontend/src/components/page-header/page-header.ts create mode 100644 frontend/test/components/__screenshots__/page-header.test.ts/page-header-filtered-by-search-chromium-linux.png create mode 100644 frontend/test/components/__screenshots__/page-header.test.ts/page-header-title-and-count-chromium-linux.png create mode 100644 frontend/test/components/__screenshots__/page-header.test.ts/page-header-title-count-and-sort-chromium-linux.png create mode 100644 frontend/test/components/__screenshots__/page-header.test.ts/page-header-title-only-chromium-linux.png create mode 100644 frontend/test/components/page-header.test.ts diff --git a/e2e/specs/page-header.spec.ts b/e2e/specs/page-header.spec.ts new file mode 100644 index 0000000..faf16ed --- /dev/null +++ b/e2e/specs/page-header.spec.ts @@ -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 = { + 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', + ); + }); +}); diff --git a/e2e/specs/view-lifecycle.spec.ts b/e2e/specs/view-lifecycle.spec.ts index 5777b15..bb53faf 100644 --- a/e2e/specs/view-lifecycle.spec.ts +++ b/e2e/specs/view-lifecycle.spec.ts @@ -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 ({ diff --git a/frontend/src/components/artists-view/artists-view.ts b/frontend/src/components/artists-view/artists-view.ts index df751db..d697dbb 100644 --- a/frontend/src/components/artists-view/artists-view.ts +++ b/frontend/src/components/artists-view/artists-view.ts @@ -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` +
Loading artists...
@@ -1342,15 +1407,18 @@ export class ArtistsView } const entries = this.cachedGridEntries; - const searchBar = this.searchCtrl.term - ? html`
-
- Showing results for - “${this.searchCtrl - .term}” -
-
` - : nothing; + const searchBar = html` + + `; if (entries.length === 0) { return html` diff --git a/frontend/src/components/cover-grid/cover-grid-styles.ts b/frontend/src/components/cover-grid/cover-grid-styles.ts index 8f2c329..68a2f1b 100644 --- a/frontend/src/components/cover-grid/cover-grid-styles.ts +++ b/frontend/src/components/cover-grid/cover-grid-styles.ts @@ -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; diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts index cab9235..abb2654 100644 --- a/frontend/src/components/cover-grid/cover-grid.ts +++ b/frontend/src/components/cover-grid/cover-grid.ts @@ -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` -
- Sort: - - - ${this.searchCtrl.term - ? html`
- Showing results for - “${this.searchCtrl.term}” -
` - : nothing} -
- ${this.renderSortDropdownPopup()} + ({ + id: o.id, + label: o.label, + }))} + sort-field=${this.sortField} + sort-direction=${this.sortDirection} + search-term=${this.searchCtrl.term} + @sort-change=${this.onPageHeaderSort} + > `; } - /** Render the sort dropdown popup. */ - private renderSortDropdownPopup() { - return html` - - ${this.sortDropdownOpen - ? html` -
- ${ALBUM_SORT_OPTIONS.map( - (opt) => html` - - this.onSortDropdownSelect( - opt.id, - )} - > - ${opt.label} - - `, - )} -
- ` - : nothing} -
- `; - } + 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()}

No albums match your search.

@@ -1897,7 +1762,7 @@ export class CoverGrid const gridContent = this.renderSingleGrid(); return html` - ${this.renderSortToolbar()} + ${this.renderPageHeader()}
-

Downloads

+ ${this.tab === 'requests' ? html` ` : nothing} - +

Music you have requested, and the download attempts that diff --git a/frontend/src/components/explore-view/explore-view.ts b/frontend/src/components/explore-view/explore-view.ts index 6fd8321..762e4f0 100644 --- a/frontend/src/components/explore-view/explore-view.ts +++ b/frontend/src/components/explore-view/explore-view.ts @@ -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` + ${this.renderSearchInput()} ${this.loading ? html`

Searching\u2026
` diff --git a/frontend/src/components/genres-view/genres-view.ts b/frontend/src/components/genres-view/genres-view.ts index 5b641ff..0a54945 100644 --- a/frontend/src/components/genres-view/genres-view.ts +++ b/frontend/src/components/genres-view/genres-view.ts @@ -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` +
Loading genres...
@@ -1183,15 +1260,18 @@ export class GenresView } const entries = this.cachedGridEntries; - const searchBar = this.searchCtrl.term - ? html`
-
- Showing results for - “${this.searchCtrl - .term}” -
-
` - : nothing; + const searchBar = html` + + `; if (entries.length === 0) { return html` diff --git a/frontend/src/components/home-view/home-view.ts b/frontend/src/components/home-view/home-view.ts index 3cd9b74..ec9935c 100644 --- a/frontend/src/components/home-view/home-view.ts +++ b/frontend/src/components/home-view/home-view.ts @@ -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` -
-

Home

+ Shuffle -
+

Somewhere to start listening.

${this.renderBody()} `; diff --git a/frontend/src/components/jobs/jobs-view.ts b/frontend/src/components/jobs/jobs-view.ts index e19eac5..90c34fd 100644 --- a/frontend/src/components/jobs/jobs-view.ts +++ b/frontend/src/components/jobs/jobs-view.ts @@ -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` -

Background jobs

+

Library scans and search index builds, with their progress and output. diff --git a/frontend/src/components/page-header/page-header.ts b/frontend/src/components/page-header/page-header.ts new file mode 100644 index 0000000..06eda0e --- /dev/null +++ b/frontend/src/components/page-header/page-header.ts @@ -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` +

+ `; + } + + 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`${this.count.toLocaleString()} ${noun}`; + } + + private renderScope() { + if (this.searchTerm === '') return nothing; + + const what = + this.heading === '' + ? 'results' + : this.heading.toLowerCase(); + + return html`Showing ${what} matching “${this.searchTerm}”`; + } + + 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` +
+ Sort: ${this.sortOptions[0]?.label} + ${this.renderDirectionButton(ascending)} +
+ `; + } + + return html` +
+ + ${this.renderDirectionButton(ascending)} +
+ `; + } + + private renderDirectionButton(ascending: boolean) { + return html` + + `; + } + + 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; + } +} diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index a15ffce..1fa257c 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -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` -
- Sort: - - - ${this.searchCtrl.term - ? html`
- Showing results for - “${this.searchCtrl.term}” -
` - : nothing} -
- ${this.renderSortDropdownPopup()} - `; - } - - private renderSortDropdownPopup() { - return html` - - ${this.sortDropdownOpen - ? html` -
- ${SORT_OPTIONS.map( - (opt) => html` - - this.onSortDropdownSelect( - opt.id, - )} - > - ${opt.label} - - `, - )} -
- ` - : nothing} -
- `; - } + 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` -
-

- Playlists - ${this.refreshing - ? html`` - : nothing} -

-
+ +
-
+ ${this.importError ? html`
@@ -1783,8 +1477,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
` : nothing} - ${this.renderSortToolbar()} - ${this.creating || this.creatingSmart ? this.renderCreateForm() : nothing} diff --git a/frontend/src/components/search-bar/search-bar.ts b/frontend/src/components/search-bar/search-bar.ts index 8c9c9b8..deaea42 100644 --- a/frontend/src/components/search-bar/search-bar.ts +++ b/frontend/src/components/search-bar/search-bar.ts @@ -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` -
+
- ${term + ${enabled && term ? html` - ${this.sortField - ? html` - - ` - : nothing} - ${this.searchCtrl.term - ? html`
- Showing results for - “${this.searchCtrl.term}” -
` - : nothing} -
- ${this.renderSortDropdownPopup()} + `; } - /** Render the sort dropdown popup. */ - private renderSortDropdownPopup() { - const cols = this.activeColumns; - - return html` - - ${this.sortDropdownOpen - ? html` -
- - this.onSortDropdownSelect( - null, - )} - > - Default - - ${cols - .filter( - (c) => - c.comparator, - ) - .map( - (col) => html` - - this.onSortDropdownSelect( - col.id, - )} - > - ${col.label} - - `, - )} -
- ` - : nothing} -
- `; - } + 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()}
= { + 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 = { + 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' + ); } // =================================================================== diff --git a/frontend/test/components/__screenshots__/page-header.test.ts/page-header-filtered-by-search-chromium-linux.png b/frontend/test/components/__screenshots__/page-header.test.ts/page-header-filtered-by-search-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..16ec3799e4c149658b45e4512fe32d5b665fa8b4 GIT binary patch literal 6511 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 literal 0 HcmV?d00001 diff --git a/frontend/test/components/__screenshots__/page-header.test.ts/page-header-title-and-count-chromium-linux.png b/frontend/test/components/__screenshots__/page-header.test.ts/page-header-title-and-count-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..b39bcb0a6a0b78c6d10478040acd9d27b9094054 GIT binary patch literal 3332 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 literal 0 HcmV?d00001 diff --git a/frontend/test/components/__screenshots__/page-header.test.ts/page-header-title-only-chromium-linux.png b/frontend/test/components/__screenshots__/page-header.test.ts/page-header-title-only-chromium-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..c2d5f4c42e9fda1b0cc7be437690cad052fe13a3 GIT binary patch literal 1165 zcmeAS@N?(olHy`uVBq!ia0y~yVAcY%wK>>;cbC6k5uydM>QcQjiZeVv))zcIC8`&{&qr_iL+j2v+MA_KbG^U>h*~%4tL~VEBW47}5_f#jJG_bQfH(ft|N5Mm; z%a<=d`CK_`>-O!&Mn+EA+1fzx=HA}f2M;=`sjI(y^(rYdv$MqN?90o`ch>*6dtLVC z#=$dZc;?QXTl4EnXJ;p8*HNW@dHZKqRt7`t>EhkAVZ(v?NoUhuzI?gxVn)HAAB9>| zxjH|7EIc>Y`uN-1+b!$=*}UGCclXqd8xrFBaVm5DgpV8k{{H^?fktK-%c2&GKFi`~ zJWq>e_Vw`vtrYoMH8*;D-oLo#%Wp8h}K6-L;vdXbz$G|$C6j_22Myu{owwLeTEsI)vDW|S( zp3}kuyJGa*lPW7`rrqCHt31(f`Oo03QK@%7eKP7?x>R-6)z_ula&9_#dvkZ@n0Y6a zluWsJF>qF@ktEbGwO=0}vv*#*7B=hcot=}9JbI*59~B$>)PH~d|7Y|6{P{WAN6k4u zU;q96{mUcw#<>H%7<3~jXVJ=)no_-NN0TN39nj7%?@>}-K6%F6xv9!~Dn2#=#TGiZ zOUTG9iCeD>bo0ym%16~!g{`)!`qB}!^2qA&^)qJ7P?&$d_{)nxF>&#oWpAaV7w+Fb z|J*shbMtJyH%2`9`T6;lty?FaO`ADq&XZ?nXM-a&9GGxQUJCvF`*+KhEdg3nZ*0$( z_n&XKbno7|>*M!t5)=~?vnYJT!Y^<4` 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('', () => { + it('renders the heading as the page\u2019s only h1', async () => { + const el = await fixture('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('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('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('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('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(el, '[data-testid="page-sort"]'); + + select!.value = 'tracks'; + select!.dispatchEvent(new Event('change')); + + shadow(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('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('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('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('page-header', { + heading: '', + count: 12, + countNoun: 'album', + }); + + expect(shadow(el, 'h1')).toBeNull(); + expect(shadow(el, '[data-testid="page-count"]')).not.toBeNull(); + }); +}); + +describe(' 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][] = [ + ['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('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(); + }); + } +});