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.
<page-header> is that arrangement once - title, count, sort, actions -
and nine views adopt it. Artists and Genres gain the sort control they
never had; Artists sorts by name only, because library.Artist carries
nothing countable, so the header renders a label and a direction button
rather than a select with one option in it. The header keeps its place
while a view loads: a heading that appears only once the data does is
the shifting layout this is meant to stop. The count is omitted, not
zero, until the view has an answer.
The header search box keeps its slot on every view instead of vanishing
on the ones it cannot serve - which is what moved the library filter
and the job indicator on every navigation. It is view-scoped by
decision and now says so: "Search albums" in the placeholder, the scope
named in the header ("Showing artists matching 'tide'"), and disabled
with a reason where there is nothing to search or the page has a search
of its own.
Also fixes an e2e trap this uncovered: the view-lifecycle spec toggled
shuffle and never toggled it back, so a second run against the same app
failed playback.spec's shuffle assertion - a failure that reads exactly
like a regression in whatever you are holding.
This commit is contained in:
@@ -51,4 +51,14 @@ export class SearchController implements ReactiveController {
|
||||
get isSearchableView(): boolean {
|
||||
return searchStore.isSearchableView();
|
||||
}
|
||||
|
||||
/** What this view searches ('albums'), or '' if it searches nothing. */
|
||||
get scopeLabel(): string {
|
||||
return searchStore.scopeLabel();
|
||||
}
|
||||
|
||||
/** Why the box is disabled here, or '' if it is not. */
|
||||
get disabledReason(): string {
|
||||
return searchStore.disabledReason();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
/** Searchable views that respond to the global search term. */
|
||||
const SEARCHABLE_VIEWS = new Set(['tracks', 'albums', 'playlists', 'playlist-details', 'artists', 'genres']);
|
||||
/**
|
||||
* What the header search box searches, per view.
|
||||
*
|
||||
* The box is **view-scoped by decision** (plan 007, Decisions 2), and
|
||||
* H-10 is that it never said so: it sits in the global header,
|
||||
* placeheld "Search…", and typing `tide` on Playlists answered "No
|
||||
* playlists match your search" with three *Tideline* tracks in the
|
||||
* library. The scope was always real; the copy is what was missing.
|
||||
*
|
||||
* A view absent from this map is one with nothing of its own to
|
||||
* search. The box keeps its slot there and is disabled, rather than
|
||||
* being removed — its appearing and disappearing is what made the
|
||||
* whole header change shape between pages.
|
||||
*/
|
||||
const SEARCH_SCOPES: Record<string, string> = {
|
||||
tracks: 'tracks',
|
||||
albums: 'albums',
|
||||
artists: 'artists',
|
||||
genres: 'genres',
|
||||
playlists: 'playlists',
|
||||
'playlist-details': 'tracks in this playlist',
|
||||
};
|
||||
|
||||
/**
|
||||
* Views with a search of their own in the page. The header box points
|
||||
* at it rather than pretending to be it.
|
||||
*/
|
||||
const OWN_SEARCH_VIEWS: Record<string, string> = {
|
||||
explore: 'Search the catalog in the page',
|
||||
};
|
||||
|
||||
type Subscriber = () => void;
|
||||
|
||||
@@ -39,7 +67,25 @@ class SearchStore {
|
||||
}
|
||||
|
||||
isSearchableView(): boolean {
|
||||
return SEARCHABLE_VIEWS.has(this.currentView);
|
||||
return this.currentView in SEARCH_SCOPES;
|
||||
}
|
||||
|
||||
/** What this view searches, e.g. `albums`. Empty if it does not. */
|
||||
scopeLabel(): string {
|
||||
return SEARCH_SCOPES[this.currentView] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* What the box should say when it cannot be used here: either that
|
||||
* the page has its own search, or that there is nothing to search.
|
||||
*/
|
||||
disabledReason(): string {
|
||||
if (this.isSearchableView()) return '';
|
||||
|
||||
return (
|
||||
OWN_SEARCH_VIEWS[this.currentView] ??
|
||||
'Nothing to search on this page'
|
||||
);
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
Reference in New Issue
Block a user