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 0000000..16ec379 Binary files /dev/null and b/frontend/test/components/__screenshots__/page-header.test.ts/page-header-filtered-by-search-chromium-linux.png differ 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 0000000..b39bcb0 Binary files /dev/null and b/frontend/test/components/__screenshots__/page-header.test.ts/page-header-title-and-count-chromium-linux.png differ diff --git a/frontend/test/components/__screenshots__/page-header.test.ts/page-header-title-count-and-sort-chromium-linux.png b/frontend/test/components/__screenshots__/page-header.test.ts/page-header-title-count-and-sort-chromium-linux.png new file mode 100644 index 0000000..f0e6892 Binary files /dev/null and b/frontend/test/components/__screenshots__/page-header.test.ts/page-header-title-count-and-sort-chromium-linux.png differ 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 0000000..c2d5f4c Binary files /dev/null and b/frontend/test/components/__screenshots__/page-header.test.ts/page-header-title-only-chromium-linux.png differ diff --git a/frontend/test/components/page-header.test.ts b/frontend/test/components/page-header.test.ts new file mode 100644 index 0000000..854c14f --- /dev/null +++ b/frontend/test/components/page-header.test.ts @@ -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. + * + * `` 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(); + }); + } +});