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:
@@ -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;
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
removeDragImage,
|
||||
} from '@utils/drag-image';
|
||||
import { coverGridStyles } from './cover-grid-styles.js';
|
||||
import '@components/page-header/page-header';
|
||||
import {
|
||||
ALBUM_SORT_OPTIONS,
|
||||
SORT_DIR_KEY,
|
||||
@@ -315,13 +316,6 @@ export class CoverGrid
|
||||
@state()
|
||||
private sortDirection: SortDirection = 'asc';
|
||||
|
||||
/** Whether the sort dropdown popup is open. */
|
||||
@state()
|
||||
private sortDropdownOpen = false;
|
||||
|
||||
@query('#sort-dropdown')
|
||||
private sortDropdownPopup!: WaPopup;
|
||||
|
||||
/** ID of the album whose dropdown is currently open, or null. */
|
||||
@state()
|
||||
expandedAlbumId: number | null = null;
|
||||
@@ -427,81 +421,6 @@ export class CoverGrid
|
||||
}
|
||||
}
|
||||
|
||||
/** Set the sort field from the dropdown. */
|
||||
private onSortDropdownSelect(
|
||||
field: AlbumSortField,
|
||||
) {
|
||||
this.sortField = field;
|
||||
this.saveSortPreferences();
|
||||
this.closeSortDropdown();
|
||||
}
|
||||
|
||||
/** Toggle sort direction. */
|
||||
private toggleSortDirection() {
|
||||
this.sortDirection =
|
||||
this.sortDirection === 'asc'
|
||||
? 'desc'
|
||||
: 'asc';
|
||||
this.saveSortPreferences();
|
||||
}
|
||||
|
||||
private toggleSortDropdown() {
|
||||
if (this.sortDropdownOpen) {
|
||||
this.closeSortDropdown();
|
||||
} else {
|
||||
this.openSortDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
private async openSortDropdown() {
|
||||
this.sortDropdownOpen = true;
|
||||
|
||||
await this.updateComplete;
|
||||
|
||||
const popup = this.sortDropdownPopup;
|
||||
const anchor =
|
||||
this.shadowRoot?.querySelector(
|
||||
'.sort-anchor',
|
||||
);
|
||||
|
||||
if (popup && anchor) {
|
||||
popup.anchor = anchor;
|
||||
popup.active = true;
|
||||
}
|
||||
}
|
||||
|
||||
private closeSortDropdown() {
|
||||
if (!this.sortDropdownOpen) return;
|
||||
|
||||
this.sortDropdownOpen = false;
|
||||
|
||||
const popup = this.sortDropdownPopup;
|
||||
|
||||
if (popup) {
|
||||
popup.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sortDropdownCloseHandler = (
|
||||
e: MouseEvent,
|
||||
) => {
|
||||
if (!this.sortDropdownOpen) return;
|
||||
|
||||
const path = e.composedPath();
|
||||
const popup = this.sortDropdownPopup;
|
||||
|
||||
if (popup && path.includes(popup)) return;
|
||||
|
||||
const anchor =
|
||||
this.shadowRoot?.querySelector(
|
||||
'.sort-anchor',
|
||||
);
|
||||
|
||||
if (anchor && path.includes(anchor)) return;
|
||||
|
||||
this.closeSortDropdown();
|
||||
};
|
||||
|
||||
/* ====================================================================
|
||||
* Lifecycle
|
||||
* ==================================================================== */
|
||||
@@ -524,14 +443,6 @@ export class CoverGrid
|
||||
);
|
||||
}
|
||||
|
||||
protected override onViewActivate(): void {
|
||||
this.listenWhileActive(
|
||||
document,
|
||||
'mousedown',
|
||||
this.sortDropdownCloseHandler,
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
@@ -1642,90 +1553,42 @@ export class CoverGrid
|
||||
* ==================================================================== */
|
||||
|
||||
/** Render the sort toolbar above the grid. */
|
||||
private renderSortToolbar() {
|
||||
const activeOpt = ALBUM_SORT_OPTIONS.find(
|
||||
(o) => o.id === this.sortField,
|
||||
);
|
||||
|
||||
const label = activeOpt
|
||||
? activeOpt.label
|
||||
: 'Name';
|
||||
|
||||
const dirIcon =
|
||||
this.sortDirection === 'asc'
|
||||
? 'arrow-up-short-wide'
|
||||
: 'arrow-down-wide-short';
|
||||
|
||||
/**
|
||||
* The page header: title, count, sort, and what the header search
|
||||
* box is filtering by. This was a hand-rolled toolbar written out
|
||||
* twice — the other copy was in `track-list` — which is how Albums
|
||||
* and Tracks came to have a sort control while Artists and Genres
|
||||
* had none (H-19).
|
||||
*
|
||||
* `externalAlbums` means this grid is a section of the artist page,
|
||||
* which has a heading of its own; there it keeps the count and the
|
||||
* sort and drops the title.
|
||||
*/
|
||||
private renderPageHeader() {
|
||||
return html`
|
||||
<div class="sort-toolbar">
|
||||
<span>Sort:</span>
|
||||
<button
|
||||
class="sort-anchor"
|
||||
@click=${() =>
|
||||
this.toggleSortDropdown()}
|
||||
>
|
||||
<span class="sort-label">
|
||||
${label}
|
||||
</span>
|
||||
<wa-icon
|
||||
name="chevron-down"
|
||||
></wa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="sort-dir-btn"
|
||||
title="${this.sortDirection === 'asc' ? 'Ascending' : 'Descending'}"
|
||||
@click=${() =>
|
||||
this.toggleSortDirection()}
|
||||
>
|
||||
<wa-icon
|
||||
name=${dirIcon}
|
||||
></wa-icon>
|
||||
</button>
|
||||
${this.searchCtrl.term
|
||||
? html`<div class="search-indicator">
|
||||
Showing results for
|
||||
“${this.searchCtrl.term}”
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
${this.renderSortDropdownPopup()}
|
||||
<page-header
|
||||
heading=${this.externalAlbums === undefined ? 'Albums' : ''}
|
||||
.count=${this.cachedFilteredAlbums.length}
|
||||
count-noun="album"
|
||||
.sortOptions=${ALBUM_SORT_OPTIONS.map((o) => ({
|
||||
id: o.id,
|
||||
label: o.label,
|
||||
}))}
|
||||
sort-field=${this.sortField}
|
||||
sort-direction=${this.sortDirection}
|
||||
search-term=${this.searchCtrl.term}
|
||||
@sort-change=${this.onPageHeaderSort}
|
||||
></page-header>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Render the sort dropdown popup. */
|
||||
private renderSortDropdownPopup() {
|
||||
return html`
|
||||
<wa-popup
|
||||
id="sort-dropdown"
|
||||
placement="bottom-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this.sortDropdownOpen}
|
||||
>
|
||||
${this.sortDropdownOpen
|
||||
? html`
|
||||
<div
|
||||
class="sort-dropdown-panel"
|
||||
>
|
||||
${ALBUM_SORT_OPTIONS.map(
|
||||
(opt) => html`
|
||||
<wa-dropdown-item
|
||||
class=${this.sortField === opt.id ? 'active-sort' : ''}
|
||||
@click=${() =>
|
||||
this.onSortDropdownSelect(
|
||||
opt.id,
|
||||
)}
|
||||
>
|
||||
${opt.label}
|
||||
</wa-dropdown-item>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</wa-popup>
|
||||
`;
|
||||
}
|
||||
private onPageHeaderSort = (
|
||||
e: CustomEvent<{ field: string; direction: SortDirection }>,
|
||||
) => {
|
||||
this.sortField = e.detail.field as AlbumSortField;
|
||||
this.sortDirection = e.detail.direction;
|
||||
this.saveSortPreferences();
|
||||
};
|
||||
|
||||
/* ====================================================================
|
||||
* Rendering helpers
|
||||
@@ -1769,6 +1632,8 @@ export class CoverGrid
|
||||
return album.CoverArtPath;
|
||||
}
|
||||
|
||||
/* ====================================================================
|
||||
|
||||
/* ====================================================================
|
||||
* Render: grid entry (virtualizer renderItem)
|
||||
* ==================================================================== */
|
||||
@@ -1887,7 +1752,7 @@ export class CoverGrid
|
||||
|
||||
if (this.cachedFilteredAlbums.length === 0) {
|
||||
return html`
|
||||
${this.renderSortToolbar()}
|
||||
${this.renderPageHeader()}
|
||||
<div class="empty-state">
|
||||
<p>No albums match your search.</p>
|
||||
</div>
|
||||
@@ -1897,7 +1762,7 @@ export class CoverGrid
|
||||
const gridContent = this.renderSingleGrid();
|
||||
|
||||
return html`
|
||||
${this.renderSortToolbar()}
|
||||
${this.renderPageHeader()}
|
||||
<div
|
||||
class="grid-scroll-container"
|
||||
@click=${this.onGridClick}
|
||||
|
||||
Reference in New Issue
Block a user