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:
@@ -19,6 +19,7 @@ import { library } from '@go/models';
|
||||
import { LibraryController } from '@store/controllers/library-controller';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import '@components/page-header/page-header';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import {
|
||||
ContextMenuController,
|
||||
@@ -52,6 +53,11 @@ const SCROLL_DEBOUNCE_MS = 100;
|
||||
/**
|
||||
* Grid entry for the virtualized artist grid.
|
||||
*/
|
||||
const ARTIST_SORT_DIR_KEY = 'artists-view-sort-dir';
|
||||
|
||||
/** One key, so the header renders a label and a direction button. */
|
||||
const ARTIST_SORT_OPTIONS = [{ id: 'name', label: 'Name' }];
|
||||
|
||||
interface ArtistEntry {
|
||||
artist: library.Artist;
|
||||
index: number;
|
||||
@@ -184,12 +190,22 @@ export class ArtistsView
|
||||
});
|
||||
}
|
||||
|
||||
/** Sort direction for the artist grid.
|
||||
*
|
||||
* There is only one key to sort by: `library.Artist` carries a
|
||||
* name, an MBID and three image URLs, and nothing countable — so
|
||||
* the header shows "Sort: Name" and this button, rather than a
|
||||
* select with one option in it (H-19). */
|
||||
@state()
|
||||
private sortDirection: 'asc' | 'desc' = 'asc';
|
||||
|
||||
// -- Memoisation caches for filtered artists --
|
||||
private cachedFilteredArtists: library.Artist[] =
|
||||
[];
|
||||
private cachedGridEntries: ArtistEntry[] = [];
|
||||
private prevFilterArtists: library.Artist[] = [];
|
||||
private prevFilterTerm = '';
|
||||
private prevFilterDir: 'asc' | 'desc' | '' = '';
|
||||
|
||||
/**
|
||||
* Recompute the filtered-artists and grid-entries
|
||||
@@ -202,10 +218,12 @@ export class ArtistsView
|
||||
|
||||
if (
|
||||
this.artists !== this.prevFilterArtists ||
|
||||
term !== this.prevFilterTerm
|
||||
term !== this.prevFilterTerm ||
|
||||
this.sortDirection !== this.prevFilterDir
|
||||
) {
|
||||
this.prevFilterArtists = this.artists;
|
||||
this.prevFilterTerm = term;
|
||||
this.prevFilterDir = this.sortDirection;
|
||||
this.cachedFilteredArtists =
|
||||
this.computeFilteredArtists();
|
||||
this.cachedGridEntries =
|
||||
@@ -222,15 +240,38 @@ export class ArtistsView
|
||||
const term =
|
||||
this.searchCtrl.term.toLowerCase();
|
||||
|
||||
if (!term) {
|
||||
return this.artists;
|
||||
}
|
||||
const matching = term
|
||||
? this.artists.filter((a) =>
|
||||
a.Name.toLowerCase().includes(term),
|
||||
)
|
||||
: this.artists;
|
||||
|
||||
return this.artists.filter((a) =>
|
||||
a.Name.toLowerCase().includes(term),
|
||||
// Descending is the only reordering available, so ascending
|
||||
// keeps the backend's order rather than re-sorting it: the
|
||||
// array's identity is what tells the virtualizer to repaint,
|
||||
// and copying it every pass would repaint on every keystroke.
|
||||
if (this.sortDirection === 'asc') return matching;
|
||||
|
||||
return [...matching].sort((a, b) =>
|
||||
b.Name.localeCompare(a.Name),
|
||||
);
|
||||
}
|
||||
|
||||
private onPageHeaderSort = (
|
||||
e: CustomEvent<{ direction: 'asc' | 'desc' }>,
|
||||
) => {
|
||||
this.sortDirection = e.detail.direction;
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
ARTIST_SORT_DIR_KEY,
|
||||
this.sortDirection,
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
};
|
||||
|
||||
static override styles = [
|
||||
contextMenuStyles,
|
||||
css`
|
||||
@@ -415,9 +456,24 @@ export class ArtistsView
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadCardSize();
|
||||
this.loadSortDirection();
|
||||
this.loadArtists();
|
||||
}
|
||||
|
||||
private loadSortDirection() {
|
||||
try {
|
||||
const saved = localStorage.getItem(
|
||||
ARTIST_SORT_DIR_KEY,
|
||||
);
|
||||
|
||||
if (saved === 'asc' || saved === 'desc') {
|
||||
this.sortDirection = saved;
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.detachWheelListener();
|
||||
@@ -1334,7 +1390,16 @@ export class ArtistsView
|
||||
|
||||
override render() {
|
||||
if (this.loading) {
|
||||
// The header keeps its place while the view loads: a
|
||||
// heading that appears only once the data does is the
|
||||
// shifting layout this component exists to stop.
|
||||
return html`
|
||||
<page-header
|
||||
heading="Artists"
|
||||
.sortOptions=${ARTIST_SORT_OPTIONS}
|
||||
sort-field="name"
|
||||
sort-direction=${this.sortDirection}
|
||||
></page-header>
|
||||
<div class="loading-message">
|
||||
Loading artists...
|
||||
</div>
|
||||
@@ -1342,15 +1407,18 @@ export class ArtistsView
|
||||
}
|
||||
|
||||
const entries = this.cachedGridEntries;
|
||||
const searchBar = this.searchCtrl.term
|
||||
? html`<div class="search-bar-row">
|
||||
<div class="search-indicator">
|
||||
Showing results for
|
||||
“${this.searchCtrl
|
||||
.term}”
|
||||
</div>
|
||||
</div>`
|
||||
: nothing;
|
||||
const searchBar = html`
|
||||
<page-header
|
||||
heading="Artists"
|
||||
.count=${entries.length}
|
||||
count-noun="artist"
|
||||
.sortOptions=${ARTIST_SORT_OPTIONS}
|
||||
sort-field="name"
|
||||
sort-direction=${this.sortDirection}
|
||||
search-term=${this.searchCtrl.term}
|
||||
@sort-change=${this.onPageHeaderSort}
|
||||
></page-header>
|
||||
`;
|
||||
|
||||
if (entries.length === 0) {
|
||||
return html`
|
||||
|
||||
@@ -13,127 +13,6 @@ const gridStyles = css`
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* Sort toolbar
|
||||
* ======================================== */
|
||||
|
||||
.sort-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: var(--yj-text-sm);
|
||||
color: var(
|
||||
--yj-text-secondary,
|
||||
#b3b3b3
|
||||
);
|
||||
border-bottom: 1px solid
|
||||
var(--yj-border-subtle, #333);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sort-anchor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.sort-anchor:hover {
|
||||
background: var(
|
||||
--yj-hover-overlay,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
}
|
||||
|
||||
.sort-anchor .sort-label {
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.sort-dir-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(
|
||||
--yj-text-secondary,
|
||||
#b3b3b3
|
||||
);
|
||||
font-size: var(--yj-text-sm);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sort-dir-btn:hover {
|
||||
background: var(
|
||||
--yj-hover-overlay,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.sort-dropdown-panel {
|
||||
background-color: var(
|
||||
--yj-bg-elevated,
|
||||
#343a40
|
||||
);
|
||||
border: 1px solid
|
||||
var(--yj-border, #444);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 8px 24px
|
||||
rgba(0, 0, 0, 0.5);
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.sort-dropdown-panel wa-dropdown-item {
|
||||
cursor: pointer;
|
||||
--wa-color-text-normal: var(
|
||||
--yj-text-primary,
|
||||
#fff
|
||||
);
|
||||
font-size: var(--yj-text-md);
|
||||
}
|
||||
|
||||
.sort-dropdown-panel
|
||||
wa-dropdown-item:hover {
|
||||
background-color: var(
|
||||
--yj-hover-overlay,
|
||||
rgba(255, 255, 255, 0.1)
|
||||
);
|
||||
}
|
||||
|
||||
.sort-dropdown-panel
|
||||
wa-dropdown-item.active-sort {
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
--wa-color-text-normal: var(
|
||||
--yj-accent,
|
||||
#ffd43b
|
||||
);
|
||||
}
|
||||
|
||||
#sort-dropdown {
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.grid-scroll-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
contain: paint;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* Album card
|
||||
* ======================================== */
|
||||
@@ -245,26 +124,6 @@ const gridStyles = css`
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
.sort-toolbar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-indicator {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
background: var(--yj-bg-overlay, #495057);
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
font-size: var(--yj-text-sm);
|
||||
padding: 2px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid
|
||||
var(--yj-border-subtle, #555);
|
||||
white-space: nowrap;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import '@components/page-header/page-header';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { downloadStore, stateLabel } from '@store/download-store';
|
||||
import type { Request, RequestSummary, DownloadView as DownloadRecord } from '@store/download-store';
|
||||
@@ -57,6 +58,12 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* The header supplies its own padding and rule, so it runs
|
||||
to the edge of a host that pads its own content. */
|
||||
page-header {
|
||||
margin: -20px -20px 1em;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -227,11 +234,11 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<header>
|
||||
<h1>Downloads</h1>
|
||||
<page-header heading="Downloads">
|
||||
${this.tab === 'requests'
|
||||
? html`
|
||||
<wa-button
|
||||
slot="actions"
|
||||
size="small"
|
||||
appearance="outlined"
|
||||
?disabled=${this.checking}
|
||||
@@ -243,7 +250,7 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
|
||||
</wa-button>
|
||||
`
|
||||
: nothing}
|
||||
</header>
|
||||
</page-header>
|
||||
|
||||
<p class="subtitle">
|
||||
Music you have requested, and the download attempts that
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state, query as litQuery } from 'lit/decorators.js';
|
||||
import '@components/page-header/page-header';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, RecordSearchClick } from '@go/explore/Service';
|
||||
import { libraryStore } from '../../store/library-store';
|
||||
@@ -165,6 +166,12 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* The header supplies its own padding and rule, so it runs
|
||||
to the edge of a host that pads its own content. */
|
||||
page-header {
|
||||
margin: -24px -24px 1em;
|
||||
}
|
||||
|
||||
/* ── Search mode tabs ── */
|
||||
.search-mode-tabs {
|
||||
display: flex;
|
||||
@@ -1311,6 +1318,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<page-header heading="Explore"></page-header>
|
||||
${this.renderSearchInput()}
|
||||
${this.loading
|
||||
? html`<div class="loading-indicator">Searching\u2026</div>`
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { library } from '@go/models';
|
||||
import { LibraryController } from '@store/controllers/library-controller';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import '@components/page-header/page-header';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
import {
|
||||
ContextMenuController,
|
||||
@@ -47,6 +48,13 @@ const CARD_SIZE_DEFAULT = 176;
|
||||
const SCROLL_DEBOUNCE_MS = 100;
|
||||
|
||||
/** A genre extracted from the track library. */
|
||||
const GENRE_SORT_KEY = 'genres-view-sort';
|
||||
|
||||
const GENRE_SORT_OPTIONS = [
|
||||
{ id: 'name', label: 'Name' },
|
||||
{ id: 'tracks', label: 'Tracks' },
|
||||
];
|
||||
|
||||
interface Genre {
|
||||
name: string;
|
||||
trackCount: number;
|
||||
@@ -189,11 +197,19 @@ export class GenresView
|
||||
});
|
||||
}
|
||||
|
||||
/** Sort key and direction for the genre grid (H-19: it had none). */
|
||||
@state()
|
||||
private sortField: 'name' | 'tracks' = 'name';
|
||||
|
||||
@state()
|
||||
private sortDirection: 'asc' | 'desc' = 'asc';
|
||||
|
||||
// -- Memoisation caches for filtered genres --
|
||||
private cachedFilteredGenres: Genre[] = [];
|
||||
private cachedGridEntries: GenreEntry[] = [];
|
||||
private prevFilterGenres: Genre[] = [];
|
||||
private prevFilterTerm = '';
|
||||
private prevFilterSort = '';
|
||||
|
||||
/**
|
||||
* Recompute the filtered-genres and grid-entries
|
||||
@@ -204,12 +220,16 @@ export class GenresView
|
||||
private recomputeGenreCaches() {
|
||||
const term = this.searchCtrl.term;
|
||||
|
||||
const sortKey = `${this.sortField}:${this.sortDirection}`;
|
||||
|
||||
if (
|
||||
this.genres !== this.prevFilterGenres ||
|
||||
term !== this.prevFilterTerm
|
||||
term !== this.prevFilterTerm ||
|
||||
sortKey !== this.prevFilterSort
|
||||
) {
|
||||
this.prevFilterGenres = this.genres;
|
||||
this.prevFilterTerm = term;
|
||||
this.prevFilterSort = sortKey;
|
||||
this.cachedFilteredGenres =
|
||||
this.computeFilteredGenres();
|
||||
this.cachedGridEntries =
|
||||
@@ -226,15 +246,62 @@ export class GenresView
|
||||
const term =
|
||||
this.searchCtrl.term.toLowerCase();
|
||||
|
||||
if (!term) {
|
||||
return this.genres;
|
||||
const matching = term
|
||||
? this.genres.filter((g) =>
|
||||
g.name.toLowerCase().includes(term),
|
||||
)
|
||||
: this.genres;
|
||||
|
||||
// The default order is the backend's, and the array's identity
|
||||
// is what makes the virtualizer repaint — so leave it alone
|
||||
// unless the user asked for something else.
|
||||
if (this.sortField === 'name' && this.sortDirection === 'asc') {
|
||||
return matching;
|
||||
}
|
||||
|
||||
return this.genres.filter((g) =>
|
||||
g.name.toLowerCase().includes(term),
|
||||
const dir = this.sortDirection === 'asc' ? 1 : -1;
|
||||
|
||||
return [...matching].sort((a, b) =>
|
||||
this.sortField === 'tracks'
|
||||
? dir * (a.trackCount - b.trackCount)
|
||||
: dir * a.name.localeCompare(b.name),
|
||||
);
|
||||
}
|
||||
|
||||
private onPageHeaderSort = (
|
||||
e: CustomEvent<{ field: string; direction: 'asc' | 'desc' }>,
|
||||
) => {
|
||||
this.sortField =
|
||||
e.detail.field === 'tracks' ? 'tracks' : 'name';
|
||||
this.sortDirection = e.detail.direction;
|
||||
|
||||
try {
|
||||
localStorage.setItem(
|
||||
GENRE_SORT_KEY,
|
||||
`${this.sortField}:${this.sortDirection}`,
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
};
|
||||
|
||||
private loadSortPreferences() {
|
||||
try {
|
||||
const saved = localStorage.getItem(GENRE_SORT_KEY);
|
||||
const [field, dir] = (saved ?? '').split(':');
|
||||
|
||||
if (field === 'name' || field === 'tracks') {
|
||||
this.sortField = field;
|
||||
}
|
||||
|
||||
if (dir === 'asc' || dir === 'desc') {
|
||||
this.sortDirection = dir;
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
contextMenuStyles,
|
||||
css`
|
||||
@@ -407,6 +474,7 @@ export class GenresView
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.loadCardSize();
|
||||
this.loadSortPreferences();
|
||||
this.loadGenres();
|
||||
}
|
||||
|
||||
@@ -1175,7 +1243,16 @@ export class GenresView
|
||||
|
||||
override render() {
|
||||
if (this.loading) {
|
||||
// The header keeps its place while the view loads: a
|
||||
// heading that appears only once the data does is the
|
||||
// shifting layout this component exists to stop.
|
||||
return html`
|
||||
<page-header
|
||||
heading="Genres"
|
||||
.sortOptions=${GENRE_SORT_OPTIONS}
|
||||
sort-field=${this.sortField}
|
||||
sort-direction=${this.sortDirection}
|
||||
></page-header>
|
||||
<div class="loading-message">
|
||||
Loading genres...
|
||||
</div>
|
||||
@@ -1183,15 +1260,18 @@ export class GenresView
|
||||
}
|
||||
|
||||
const entries = this.cachedGridEntries;
|
||||
const searchBar = this.searchCtrl.term
|
||||
? html`<div class="search-bar-row">
|
||||
<div class="search-indicator">
|
||||
Showing results for
|
||||
“${this.searchCtrl
|
||||
.term}”
|
||||
</div>
|
||||
</div>`
|
||||
: nothing;
|
||||
const searchBar = html`
|
||||
<page-header
|
||||
heading="Genres"
|
||||
.count=${entries.length}
|
||||
count-noun="genre"
|
||||
.sortOptions=${GENRE_SORT_OPTIONS}
|
||||
sort-field=${this.sortField}
|
||||
sort-direction=${this.sortDirection}
|
||||
search-term=${this.searchCtrl.term}
|
||||
@sort-change=${this.onPageHeaderSort}
|
||||
></page-header>
|
||||
`;
|
||||
|
||||
if (entries.length === 0) {
|
||||
return html`
|
||||
|
||||
@@ -9,6 +9,7 @@ import { queueStore } from '@store/queue-store';
|
||||
import { libraryStore } from '@store/library-store';
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import { Events } from '../../events';
|
||||
import '@components/page-header/page-header';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||
|
||||
@@ -60,19 +61,11 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
flex: 1;
|
||||
/* The header brings its own padding, and the host already
|
||||
has some — without this the title sits indented from the
|
||||
lede directly beneath it. */
|
||||
page-header {
|
||||
margin: -24px -20px 4px;
|
||||
}
|
||||
|
||||
.lede {
|
||||
@@ -244,9 +237,9 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<header>
|
||||
<h1>Home</h1>
|
||||
<page-header heading="Home">
|
||||
<wa-button
|
||||
slot="actions"
|
||||
size="small"
|
||||
appearance="plain"
|
||||
title="Reshuffle the suggestions"
|
||||
@@ -255,7 +248,7 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
|
||||
<wa-icon slot="start" name="shuffle"></wa-icon>
|
||||
Shuffle
|
||||
</wa-button>
|
||||
</header>
|
||||
</page-header>
|
||||
<p class="lede">Somewhere to start listening.</p>
|
||||
${this.renderBody()}
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@components/page-header/page-header';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import {
|
||||
GetAllLibrariesWithTrackCounts,
|
||||
@@ -78,6 +79,12 @@ export class JobsView extends ViewLifecycleMixin(LitElement) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* The header supplies its own padding and rule, so it runs
|
||||
to the edge of a host that pads its own content. */
|
||||
page-header {
|
||||
margin: -1.5em -1.75em 1em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--yj-text-xl);
|
||||
color: var(--yj-text-primary, #e9ecef);
|
||||
@@ -460,7 +467,7 @@ export class JobsView extends ViewLifecycleMixin(LitElement) {
|
||||
);
|
||||
|
||||
return html`
|
||||
<h1>Background jobs</h1>
|
||||
<page-header heading="Background jobs"></page-header>
|
||||
<p class="page-sub">
|
||||
Library scans and search index builds, with their progress and
|
||||
output.
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
|
||||
/**
|
||||
* The one arrangement every primary view uses to say what it is.
|
||||
*
|
||||
* Before this, four views had a heading and four did not, two had sort
|
||||
* controls and none showed a count (`hands-on.md`, H-19) — so the page
|
||||
* shifted its shape as you moved through it, and the answer to "how
|
||||
* many albums do I have" was to count them. Each view had also written
|
||||
* its own arrangement, which is why they disagreed: the sort toolbar in
|
||||
* `track-list` and the one in `cover-grid` are the same twenty lines
|
||||
* twice, and the ninth view would have made a ninth.
|
||||
*
|
||||
* Title, count, sort, actions — in that order, in one component, so a
|
||||
* new view gets the shape by using it rather than by copying whichever
|
||||
* neighbour it happened to read.
|
||||
*/
|
||||
|
||||
export interface SortOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
@customElement('page-header')
|
||||
export class PageHeader extends LitElement {
|
||||
/**
|
||||
* The page's name. Rendered as the view's only `h1`.
|
||||
*
|
||||
* Empty is a real mode, not a missing value: `cover-grid` and
|
||||
* `track-list` are also *embedded* in the artist and genre pages,
|
||||
* which already have a heading of their own. There they keep the
|
||||
* count and the sort and drop the title, rather than growing a
|
||||
* second arrangement for the same three controls.
|
||||
*/
|
||||
@property({ type: String })
|
||||
heading = '';
|
||||
|
||||
/**
|
||||
* How many things are on the page. `null` means "not applicable"
|
||||
* (Jobs, Settings) rather than zero, and renders nothing — an
|
||||
* empty page says so in its empty state, which has room for a
|
||||
* sentence.
|
||||
*/
|
||||
@property({ type: Number })
|
||||
count: number | null = null;
|
||||
|
||||
/** Singular noun for the count; pluralised with a trailing `s`. */
|
||||
@property({ type: String, attribute: 'count-noun' })
|
||||
countNoun = 'item';
|
||||
|
||||
/** Irregular plural, where a trailing `s` will not do. */
|
||||
@property({ type: String, attribute: 'count-plural' })
|
||||
countPlural = '';
|
||||
|
||||
/** Sort choices. Empty (the default) renders no sort control. */
|
||||
@property({ attribute: false })
|
||||
sortOptions: SortOption[] = [];
|
||||
|
||||
@property({ type: String, attribute: 'sort-field' })
|
||||
sortField = '';
|
||||
|
||||
@property({ type: String, attribute: 'sort-direction' })
|
||||
sortDirection: SortDirection = 'asc';
|
||||
|
||||
/**
|
||||
* The search term the page is filtered by, if any. The header says
|
||||
* so, because the *scope* of the header search box is the view —
|
||||
* so a page showing three of forty albums has to admit why.
|
||||
*/
|
||||
@property({ type: String, attribute: 'search-term' })
|
||||
searchTerm = '';
|
||||
|
||||
/** Shown while the view is refetching, next to the heading. */
|
||||
@property({ type: Boolean })
|
||||
busy = false;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px 10px;
|
||||
border-bottom: 1px solid var(--yj-border-subtle, #333);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: var(--yj-text-xl, 18px);
|
||||
font-weight: 600;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: var(--yj-text-sm, 12px);
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scope {
|
||||
font-size: var(--yj-text-sm, 12px);
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Actions sit at the right; everything before them is the
|
||||
page's identity and stays left. */
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sort {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--yj-text-sm, 12px);
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sort select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--yj-bg-surface, #212529);
|
||||
border: 1px solid var(--yj-border, #444);
|
||||
border-radius: 4px;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sort-dir {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
.sort-dir:hover {
|
||||
background: var(--yj-hover-overlay, rgba(255, 255, 255, 0.05));
|
||||
}
|
||||
|
||||
.sort-dir:focus-visible,
|
||||
.sort select:focus-visible {
|
||||
outline: 2px solid var(--yj-accent, #ffd43b);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--yj-border, #444);
|
||||
border-top-color: var(--yj-accent, #ffd43b);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner {
|
||||
animation-duration: 3s;
|
||||
}
|
||||
}
|
||||
|
||||
::slotted(*) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<header class="page-header" part="header">
|
||||
${this.heading === ''
|
||||
? nothing
|
||||
: html`<h1 data-testid="page-heading">
|
||||
${this.heading}
|
||||
</h1>`}
|
||||
${this.busy
|
||||
? html`<span
|
||||
class="spinner"
|
||||
role="status"
|
||||
aria-label="Refreshing"
|
||||
></span>`
|
||||
: nothing}
|
||||
${this.renderCount()}
|
||||
<div class="spacer"></div>
|
||||
${this.renderScope()} ${this.renderSort()}
|
||||
<slot name="actions"></slot>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCount() {
|
||||
if (this.count === null) return nothing;
|
||||
|
||||
const plural =
|
||||
this.countPlural !== ''
|
||||
? this.countPlural
|
||||
: `${this.countNoun}s`;
|
||||
|
||||
const noun = this.count === 1 ? this.countNoun : plural;
|
||||
|
||||
return html`<span class="count" data-testid="page-count"
|
||||
>${this.count.toLocaleString()} ${noun}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
private renderScope() {
|
||||
if (this.searchTerm === '') return nothing;
|
||||
|
||||
const what =
|
||||
this.heading === ''
|
||||
? 'results'
|
||||
: this.heading.toLowerCase();
|
||||
|
||||
return html`<span class="scope" data-testid="page-search-scope"
|
||||
>Showing ${what} matching “${this.searchTerm}”</span
|
||||
>`;
|
||||
}
|
||||
|
||||
private renderSort() {
|
||||
if (this.sortOptions.length === 0) return nothing;
|
||||
|
||||
const ascending = this.sortDirection === 'asc';
|
||||
|
||||
// One option is not a choice: Artists can only be sorted by
|
||||
// name, because `library.Artist` carries no counts to sort by.
|
||||
// A select with a single option is a control that does
|
||||
// nothing, so it says what the order is and lets the direction
|
||||
// button do the work.
|
||||
if (this.sortOptions.length === 1) {
|
||||
return html`
|
||||
<div class="sort">
|
||||
<span>Sort: ${this.sortOptions[0]?.label}</span>
|
||||
${this.renderDirectionButton(ascending)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="sort">
|
||||
<label>
|
||||
Sort:
|
||||
<select
|
||||
data-testid="page-sort"
|
||||
.value=${this.sortField}
|
||||
@change=${this.onSortFieldChange}
|
||||
>
|
||||
${this.sortOptions.map(
|
||||
(o) => html`
|
||||
<option
|
||||
value=${o.id}
|
||||
?selected=${o.id === this.sortField}
|
||||
>
|
||||
${o.label}
|
||||
</option>
|
||||
`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
${this.renderDirectionButton(ascending)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDirectionButton(ascending: boolean) {
|
||||
return html`
|
||||
<button
|
||||
class="sort-dir"
|
||||
type="button"
|
||||
data-testid="page-sort-direction"
|
||||
aria-label=${ascending ? 'Sort ascending' : 'Sort descending'}
|
||||
aria-pressed=${ascending ? 'false' : 'true'}
|
||||
title=${ascending ? 'Ascending' : 'Descending'}
|
||||
@click=${this.onDirectionClick}
|
||||
>
|
||||
<wa-icon
|
||||
name=${ascending
|
||||
? 'arrow-up-short-wide'
|
||||
: 'arrow-down-wide-short'}
|
||||
></wa-icon>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private onSortFieldChange = (e: Event) => {
|
||||
const field = (e.target as HTMLSelectElement).value;
|
||||
|
||||
this.emitSort(field, this.sortDirection);
|
||||
};
|
||||
|
||||
private onDirectionClick = () => {
|
||||
this.emitSort(
|
||||
this.sortField,
|
||||
this.sortDirection === 'asc' ? 'desc' : 'asc',
|
||||
);
|
||||
};
|
||||
|
||||
/** The host owns the sort state and persists it; this only asks. */
|
||||
private emitSort(field: string, direction: SortDirection) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('sort-change', {
|
||||
detail: { field, direction },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'page-header': PageHeader;
|
||||
}
|
||||
}
|
||||
@@ -122,12 +122,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
||||
/** Sort direction. */
|
||||
@state() private sortDirection: SortDirection = 'desc';
|
||||
|
||||
/** Whether the sort dropdown is open. */
|
||||
@state() private sortDropdownOpen = false;
|
||||
|
||||
@query('#sort-dropdown')
|
||||
private sortDropdownPopup!: WaPopup;
|
||||
|
||||
/**
|
||||
* File paths from a drop that landed outside any playlist.
|
||||
* When non-empty the create form is in "create-and-add" mode.
|
||||
@@ -185,29 +179,9 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.header {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--yj-border-subtle, #333);
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-spinner {
|
||||
@@ -374,29 +348,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
.sort-toolbar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-indicator {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
background: var(--yj-bg-overlay, #495057);
|
||||
color: var(
|
||||
--yj-text-secondary,
|
||||
#b3b3b3
|
||||
);
|
||||
font-size: 12px;
|
||||
padding: 2px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid
|
||||
var(--yj-border-subtle, #555);
|
||||
white-space: nowrap;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -523,103 +474,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
||||
var(--yj-error, #e03131);
|
||||
}
|
||||
|
||||
/* ---- Sort toolbar ---- */
|
||||
|
||||
.sort-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
border-bottom: 1px solid
|
||||
var(--yj-border-subtle, #333);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sort-anchor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.sort-anchor:hover {
|
||||
background: var(
|
||||
--yj-hover-overlay,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
}
|
||||
|
||||
.sort-anchor .sort-label {
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.sort-dir-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sort-dir-btn:hover {
|
||||
background: var(
|
||||
--yj-hover-overlay,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.sort-dropdown-panel {
|
||||
background-color: var(
|
||||
--yj-bg-elevated,
|
||||
#343a40
|
||||
);
|
||||
border: 1px solid var(--yj-border, #444);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.sort-dropdown-panel wa-dropdown-item {
|
||||
cursor: pointer;
|
||||
--wa-color-text-normal: var(
|
||||
--yj-text-primary,
|
||||
#fff
|
||||
);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sort-dropdown-panel wa-dropdown-item:hover {
|
||||
background-color: var(
|
||||
--yj-hover-overlay,
|
||||
rgba(255, 255, 255, 0.1)
|
||||
);
|
||||
}
|
||||
|
||||
.sort-dropdown-panel wa-dropdown-item.active-sort {
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
--wa-color-text-normal: var(
|
||||
--yj-accent,
|
||||
#ffd43b
|
||||
);
|
||||
}
|
||||
|
||||
#sort-dropdown {
|
||||
z-index: 200;
|
||||
@@ -728,79 +582,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private toggleSortDropdown() {
|
||||
if (this.sortDropdownOpen) {
|
||||
this.closeSortDropdown();
|
||||
} else {
|
||||
this.openSortDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
private async openSortDropdown() {
|
||||
this.sortDropdownOpen = true;
|
||||
|
||||
await this.updateComplete;
|
||||
|
||||
const popup = this.sortDropdownPopup;
|
||||
const anchor =
|
||||
this.shadowRoot?.querySelector(
|
||||
'.sort-anchor',
|
||||
);
|
||||
|
||||
if (popup && anchor) {
|
||||
popup.anchor = anchor;
|
||||
popup.active = true;
|
||||
}
|
||||
}
|
||||
|
||||
private closeSortDropdown() {
|
||||
if (!this.sortDropdownOpen) return;
|
||||
|
||||
this.sortDropdownOpen = false;
|
||||
|
||||
const popup = this.sortDropdownPopup;
|
||||
|
||||
if (popup) {
|
||||
popup.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private onSortDropdownSelect(
|
||||
field: PlaylistSortField,
|
||||
) {
|
||||
this.sortField = field;
|
||||
this.saveSortPreferences();
|
||||
this.closeSortDropdown();
|
||||
}
|
||||
|
||||
private toggleSortDirection() {
|
||||
this.sortDirection =
|
||||
this.sortDirection === 'asc'
|
||||
? 'desc'
|
||||
: 'asc';
|
||||
this.saveSortPreferences();
|
||||
}
|
||||
|
||||
private sortDropdownCloseHandler = (
|
||||
e: MouseEvent,
|
||||
) => {
|
||||
if (!this.sortDropdownOpen) return;
|
||||
|
||||
const path = e.composedPath();
|
||||
const popup = this.sortDropdownPopup;
|
||||
|
||||
if (popup && path.includes(popup)) return;
|
||||
|
||||
const anchor =
|
||||
this.shadowRoot?.querySelector(
|
||||
'.sort-anchor',
|
||||
);
|
||||
|
||||
if (anchor && path.includes(anchor)) return;
|
||||
|
||||
this.closeSortDropdown();
|
||||
};
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.restoreSortPreferences();
|
||||
@@ -828,11 +609,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
||||
'click',
|
||||
this.clearSelectionHandler,
|
||||
);
|
||||
this.listenWhileActive(
|
||||
document,
|
||||
'mousedown',
|
||||
this.sortDropdownCloseHandler,
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
@@ -1644,138 +1420,56 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
||||
// Render
|
||||
// =================================================================
|
||||
|
||||
private renderSortToolbar() {
|
||||
const activeOption = SORT_OPTIONS.find(
|
||||
(o) => o.id === this.sortField,
|
||||
);
|
||||
const label = activeOption?.label ?? 'Recent';
|
||||
const dirIcon =
|
||||
this.sortDirection === 'asc'
|
||||
? 'arrow-up-short-wide'
|
||||
: 'arrow-down-wide-short';
|
||||
|
||||
return html`
|
||||
<div class="sort-toolbar">
|
||||
<span>Sort:</span>
|
||||
<button
|
||||
class="sort-anchor"
|
||||
@click=${() =>
|
||||
this.toggleSortDropdown()}
|
||||
>
|
||||
<span class="sort-label">
|
||||
${label}
|
||||
</span>
|
||||
<wa-icon
|
||||
name="chevron-down"
|
||||
></wa-icon>
|
||||
</button>
|
||||
<button
|
||||
class="sort-dir-btn"
|
||||
title="${this.sortDirection === 'asc' ? 'Ascending' : 'Descending'}"
|
||||
@click=${() =>
|
||||
this.toggleSortDirection()}
|
||||
>
|
||||
<wa-icon
|
||||
name=${dirIcon}
|
||||
></wa-icon>
|
||||
</button>
|
||||
${this.searchCtrl.term
|
||||
? html`<div class="search-indicator">
|
||||
Showing results for
|
||||
“${this.searchCtrl.term}”
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
${this.renderSortDropdownPopup()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSortDropdownPopup() {
|
||||
return html`
|
||||
<wa-popup
|
||||
id="sort-dropdown"
|
||||
placement="bottom-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this.sortDropdownOpen}
|
||||
>
|
||||
${this.sortDropdownOpen
|
||||
? html`
|
||||
<div
|
||||
class="sort-dropdown-panel"
|
||||
>
|
||||
${SORT_OPTIONS.map(
|
||||
(opt) => html`
|
||||
<wa-dropdown-item
|
||||
class=${this.sortField === opt.id ? 'active-sort' : ''}
|
||||
@click=${() =>
|
||||
this.onSortDropdownSelect(
|
||||
opt.id,
|
||||
)}
|
||||
>
|
||||
${opt.label}
|
||||
</wa-dropdown-item>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</wa-popup>
|
||||
`;
|
||||
}
|
||||
private onPageHeaderSort = (
|
||||
e: CustomEvent<{ field: string; direction: 'asc' | 'desc' }>,
|
||||
) => {
|
||||
this.sortField = e.detail.field as PlaylistSortField;
|
||||
this.sortDirection = e.detail.direction;
|
||||
this.saveSortPreferences();
|
||||
};
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="header">
|
||||
<h2>
|
||||
Playlists
|
||||
${this.refreshing
|
||||
? html`<span
|
||||
class="header-spinner"
|
||||
></span>`
|
||||
: nothing}
|
||||
</h2>
|
||||
<div
|
||||
style="display: flex; gap: 8px;"
|
||||
>
|
||||
<page-header
|
||||
heading="Playlists"
|
||||
.count=${this.loading && this.entries.length === 0
|
||||
? null
|
||||
: this.sortedEntries.length}
|
||||
count-noun="playlist"
|
||||
.sortOptions=${SORT_OPTIONS}
|
||||
sort-field=${this.sortField}
|
||||
sort-direction=${this.sortDirection}
|
||||
search-term=${this.searchCtrl.term}
|
||||
?busy=${this.refreshing}
|
||||
@sort-change=${this.onPageHeaderSort}
|
||||
>
|
||||
<div slot="actions" class="header-actions">
|
||||
<button
|
||||
class="import-button"
|
||||
@click=${this
|
||||
.handleImportPlaylist}
|
||||
@click=${this.handleImportPlaylist}
|
||||
>
|
||||
<wa-icon
|
||||
name="file-import"
|
||||
></wa-icon>
|
||||
<wa-icon name="file-import"></wa-icon>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
class="new-playlist-button ${this.dragOverNewButton ? 'drag-over' : ''}"
|
||||
@click=${this
|
||||
.handleNewPlaylistClick}
|
||||
@dragover=${this
|
||||
.onNewButtonDragOver}
|
||||
@dragleave=${this
|
||||
.onNewButtonDragLeave}
|
||||
@drop=${this
|
||||
.onNewButtonDrop}
|
||||
@click=${this.handleNewPlaylistClick}
|
||||
@dragover=${this.onNewButtonDragOver}
|
||||
@dragleave=${this.onNewButtonDragLeave}
|
||||
@drop=${this.onNewButtonDrop}
|
||||
>
|
||||
<wa-icon
|
||||
name="plus"
|
||||
></wa-icon>
|
||||
<wa-icon name="plus"></wa-icon>
|
||||
New Playlist
|
||||
</button>
|
||||
<button
|
||||
class="new-playlist-button"
|
||||
@click=${this
|
||||
.handleNewSmartPlaylistClick}
|
||||
@click=${this.handleNewSmartPlaylistClick}
|
||||
>
|
||||
<wa-icon
|
||||
name="filter"
|
||||
></wa-icon>
|
||||
<wa-icon name="filter"></wa-icon>
|
||||
New Smart Playlist
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</page-header>
|
||||
|
||||
${this.importError
|
||||
? html`<div class="import-error">
|
||||
@@ -1783,8 +1477,6 @@ export class PlaylistView extends ViewLifecycleMixin(LitElement) {
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
${this.renderSortToolbar()}
|
||||
|
||||
${this.creating || this.creatingSmart
|
||||
? this.renderCreateForm()
|
||||
: nothing}
|
||||
|
||||
@@ -5,8 +5,20 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
|
||||
/**
|
||||
* Global search bar displayed in the top bar.
|
||||
* Hides itself when the active view is not searchable.
|
||||
* The header search box.
|
||||
*
|
||||
* It is **view-scoped** (plan 007, Decisions 2) and used to look
|
||||
* global: placeheld "Search…", sitting in the app header, and silently
|
||||
* doing nothing on the pages that do not read the term. It now names
|
||||
* what it searches — "Search albums" — so "No playlists match your
|
||||
* search" arrives having already said it was only ever looking at
|
||||
* playlists (H-10).
|
||||
*
|
||||
* It also used to *hide* on those pages, which moved the library
|
||||
* filter and the job indicator every time the user navigated. It keeps
|
||||
* its slot now and is disabled, with the reason in its title and its
|
||||
* placeholder: either the page has a search of its own (Explore), or
|
||||
* there is nothing on it to search.
|
||||
*/
|
||||
@customElement('search-bar')
|
||||
export class SearchBar extends LitElement {
|
||||
@@ -22,10 +34,24 @@ export class SearchBar extends LitElement {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Kept, because the first-run wizard hides the whole header;
|
||||
navigation no longer sets it. */
|
||||
:host([hidden]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-container.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.search-container.disabled:focus-within {
|
||||
border-color: var(--yj-border-subtle, #555);
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -84,16 +110,6 @@ export class SearchBar extends LitElement {
|
||||
}
|
||||
`];
|
||||
|
||||
override updated() {
|
||||
// Toggle the hidden attribute based on whether the
|
||||
// current view supports searching.
|
||||
if (this.searchCtrl.isSearchableView) {
|
||||
this.removeAttribute('hidden');
|
||||
} else {
|
||||
this.setAttribute('hidden', '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the search input and select all text.
|
||||
* Called by the global Ctrl+F handler.
|
||||
@@ -147,24 +163,39 @@ export class SearchBar extends LitElement {
|
||||
|
||||
override render() {
|
||||
const term = this.searchCtrl.term;
|
||||
const scope = this.searchCtrl.scopeLabel;
|
||||
const disabledReason = this.searchCtrl.disabledReason;
|
||||
const enabled = disabledReason === '';
|
||||
|
||||
const placeholder = enabled
|
||||
? `Search ${scope}\u2026`
|
||||
: disabledReason;
|
||||
|
||||
return html`
|
||||
<div class="search-container">
|
||||
<div class="search-container ${enabled ? '' : 'disabled'}">
|
||||
<wa-icon
|
||||
class="search-icon"
|
||||
name="magnifying-glass"
|
||||
></wa-icon>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
.value=${term}
|
||||
data-testid="search-input"
|
||||
aria-label=${enabled
|
||||
? `Search ${scope}`
|
||||
: disabledReason}
|
||||
title=${enabled ? '' : disabledReason}
|
||||
placeholder=${placeholder}
|
||||
?disabled=${!enabled}
|
||||
.value=${enabled ? term : ''}
|
||||
@input=${this.handleInput}
|
||||
@keydown=${this.handleKeydown}
|
||||
/>
|
||||
${term
|
||||
${enabled && term
|
||||
? html`
|
||||
<button
|
||||
class="clear-button"
|
||||
aria-label="Clear search"
|
||||
title="Clear search"
|
||||
@click=${this.handleClear}
|
||||
>
|
||||
<wa-icon
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
|
||||
import { PlayerController } from '@store/controllers/player-controller';
|
||||
import { SearchController } from '@store/controllers/search-controller';
|
||||
import '@components/page-header/page-header';
|
||||
import type { SortOption } from '@components/page-header/page-header';
|
||||
import { TrackListController } from '@store/controllers/tracklist-controller';
|
||||
import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||
import { queueStore } from '@store/queue-store';
|
||||
@@ -312,13 +314,6 @@ export class TrackList
|
||||
@state()
|
||||
private sortDirection: SortDirection = 'asc';
|
||||
|
||||
/** Whether the sort dropdown popup is open. */
|
||||
@state()
|
||||
private sortDropdownOpen = false;
|
||||
|
||||
@query('#sort-dropdown')
|
||||
private sortDropdownPopup!: WaPopup;
|
||||
|
||||
/** Whether delegated event handlers have been attached to the virtualizer. */
|
||||
private delegationAttached = false;
|
||||
|
||||
@@ -899,109 +894,6 @@ export class TrackList
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Sort toolbar ---- */
|
||||
|
||||
.sort-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: var(--yj-text-sm);
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
border-bottom: 1px solid
|
||||
var(--yj-border-subtle, #333);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
||||
.sort-anchor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.sort-anchor:hover {
|
||||
background: var(
|
||||
--yj-hover-overlay,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
}
|
||||
|
||||
.sort-anchor .sort-label {
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.sort-dir-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
font-size: var(--yj-text-sm);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sort-dir-btn:hover {
|
||||
background: var(
|
||||
--yj-hover-overlay,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.sort-dropdown-panel {
|
||||
background-color: var(
|
||||
--yj-bg-elevated,
|
||||
#343a40
|
||||
);
|
||||
border: 1px solid var(--yj-border, #444);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.sort-dropdown-panel wa-dropdown-item {
|
||||
cursor: pointer;
|
||||
--wa-color-text-normal: var(
|
||||
--yj-text-primary,
|
||||
#fff
|
||||
);
|
||||
font-size: var(--yj-text-md);
|
||||
}
|
||||
|
||||
.sort-dropdown-panel wa-dropdown-item:hover {
|
||||
background-color: var(
|
||||
--yj-hover-overlay,
|
||||
rgba(255, 255, 255, 0.1)
|
||||
);
|
||||
}
|
||||
|
||||
.sort-dropdown-panel wa-dropdown-item.active-sort {
|
||||
color: var(--yj-accent, #ffd43b);
|
||||
--wa-color-text-normal: var(
|
||||
--yj-accent,
|
||||
#ffd43b
|
||||
);
|
||||
}
|
||||
|
||||
#sort-dropdown {
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
/* ---- Header row ---- */
|
||||
|
||||
.header-row {
|
||||
@@ -1068,25 +960,6 @@ export class TrackList
|
||||
background-color: var(--yj-text-tertiary, #6c757d);
|
||||
}
|
||||
|
||||
.sort-toolbar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-indicator {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
background: var(--yj-bg-overlay, #495057);
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
font-size: var(--yj-text-sm);
|
||||
padding: 2px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--yj-border-subtle, #555);
|
||||
white-space: nowrap;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 24px 16px;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
@@ -1259,11 +1132,6 @@ export class TrackList
|
||||
* list is never disconnected, so this is the only place they can be
|
||||
* taken down again. */
|
||||
protected override onViewActivate(): void {
|
||||
this.listenWhileActive(
|
||||
document,
|
||||
'mousedown',
|
||||
this.sortDropdownCloseHandler,
|
||||
);
|
||||
this.listenWhileActive(document, 'click', this.clearSelectionHandler);
|
||||
this.listenWhileActive(
|
||||
document,
|
||||
@@ -1851,86 +1719,6 @@ export class TrackList
|
||||
this.saveSortPreferences();
|
||||
}
|
||||
|
||||
/** Set sort from the dropdown and close it. */
|
||||
private onSortDropdownSelect(
|
||||
colId: string | null,
|
||||
) {
|
||||
if (colId === null) {
|
||||
this.sortField = null;
|
||||
this.sortDirection = 'asc';
|
||||
} else {
|
||||
this.sortField = colId;
|
||||
}
|
||||
|
||||
this.saveSortPreferences();
|
||||
this.closeSortDropdown();
|
||||
}
|
||||
|
||||
/** Toggle sort direction via the toolbar button. */
|
||||
private toggleSortDirection() {
|
||||
this.sortDirection =
|
||||
this.sortDirection === 'asc'
|
||||
? 'desc'
|
||||
: 'asc';
|
||||
this.saveSortPreferences();
|
||||
}
|
||||
|
||||
private toggleSortDropdown() {
|
||||
if (this.sortDropdownOpen) {
|
||||
this.closeSortDropdown();
|
||||
} else {
|
||||
this.openSortDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
private async openSortDropdown() {
|
||||
this.sortDropdownOpen = true;
|
||||
|
||||
await this.updateComplete;
|
||||
|
||||
const popup = this.sortDropdownPopup;
|
||||
const anchor = this.shadowRoot?.querySelector(
|
||||
'.sort-anchor',
|
||||
);
|
||||
|
||||
if (popup && anchor) {
|
||||
popup.anchor = anchor;
|
||||
popup.active = true;
|
||||
}
|
||||
}
|
||||
|
||||
private closeSortDropdown() {
|
||||
if (!this.sortDropdownOpen) return;
|
||||
|
||||
this.sortDropdownOpen = false;
|
||||
|
||||
const popup = this.sortDropdownPopup;
|
||||
|
||||
if (popup) {
|
||||
popup.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sortDropdownCloseHandler = (
|
||||
e: MouseEvent,
|
||||
) => {
|
||||
if (!this.sortDropdownOpen) return;
|
||||
|
||||
const path = e.composedPath();
|
||||
const popup = this.sortDropdownPopup;
|
||||
|
||||
if (popup && path.includes(popup)) return;
|
||||
|
||||
const anchor =
|
||||
this.shadowRoot?.querySelector(
|
||||
'.sort-anchor',
|
||||
);
|
||||
|
||||
if (anchor && path.includes(anchor)) return;
|
||||
|
||||
this.closeSortDropdown();
|
||||
};
|
||||
|
||||
private isActiveTrack(track: library.Track): boolean {
|
||||
const currentTrack = this.player.currentTrack;
|
||||
|
||||
@@ -2023,121 +1811,56 @@ export class TrackList
|
||||
`;
|
||||
};
|
||||
|
||||
/** Render the sort toolbar above the header row. */
|
||||
private renderSortToolbar() {
|
||||
const activeCol = this.sortField
|
||||
? COLUMN_DEFS[this.sortField]
|
||||
: null;
|
||||
|
||||
const label = activeCol
|
||||
? activeCol.label
|
||||
: 'Default';
|
||||
|
||||
const dirIcon =
|
||||
this.sortDirection === 'asc'
|
||||
? 'arrow-up-short-wide'
|
||||
: 'arrow-down-wide-short';
|
||||
/**
|
||||
* The page header, which carries what used to be a hand-rolled
|
||||
* sort toolbar written out twice (here and in `cover-grid`).
|
||||
*
|
||||
* `externalTracks` means this list is a section of some other page
|
||||
* — the genre and playlist details — which has a heading already,
|
||||
* so the header keeps the count and the sort and drops the title.
|
||||
*/
|
||||
private renderPageHeader() {
|
||||
const options: SortOption[] = [
|
||||
{ id: '', label: 'Default' },
|
||||
...this.activeColumns
|
||||
.filter((c) => c.comparator)
|
||||
.map((c) => ({ id: c.id, label: c.label })),
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="sort-toolbar">
|
||||
<span>Sort:</span>
|
||||
<button
|
||||
class="sort-anchor"
|
||||
@click=${() =>
|
||||
this.toggleSortDropdown()}
|
||||
>
|
||||
<span class="sort-label">
|
||||
${label}
|
||||
</span>
|
||||
<wa-icon
|
||||
name="chevron-down"
|
||||
></wa-icon>
|
||||
</button>
|
||||
${this.sortField
|
||||
? html`
|
||||
<button
|
||||
class="sort-dir-btn"
|
||||
title="${this.sortDirection === 'asc' ? 'Ascending' : 'Descending'}"
|
||||
@click=${() =>
|
||||
this.toggleSortDirection()}
|
||||
>
|
||||
<wa-icon
|
||||
name=${dirIcon}
|
||||
></wa-icon>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
${this.searchCtrl.term
|
||||
? html`<div class="search-indicator">
|
||||
Showing results for
|
||||
“${this.searchCtrl.term}”
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
${this.renderSortDropdownPopup()}
|
||||
<page-header
|
||||
heading=${this.externalTracks === undefined ? 'Tracks' : ''}
|
||||
.count=${this.loadingTracks
|
||||
? null
|
||||
: this.cachedSortedTracks.length}
|
||||
count-noun="track"
|
||||
.sortOptions=${options}
|
||||
sort-field=${this.sortField ?? ''}
|
||||
sort-direction=${this.sortDirection}
|
||||
search-term=${this.searchCtrl.term}
|
||||
@sort-change=${this.onPageHeaderSort}
|
||||
></page-header>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Render the sort dropdown popup. */
|
||||
private renderSortDropdownPopup() {
|
||||
const cols = this.activeColumns;
|
||||
|
||||
return html`
|
||||
<wa-popup
|
||||
id="sort-dropdown"
|
||||
placement="bottom-start"
|
||||
flip
|
||||
shift
|
||||
.active=${this.sortDropdownOpen}
|
||||
>
|
||||
${this.sortDropdownOpen
|
||||
? html`
|
||||
<div
|
||||
class="sort-dropdown-panel"
|
||||
>
|
||||
<wa-dropdown-item
|
||||
class=${!this.sortField ? 'active-sort' : ''}
|
||||
@click=${() =>
|
||||
this.onSortDropdownSelect(
|
||||
null,
|
||||
)}
|
||||
>
|
||||
Default
|
||||
</wa-dropdown-item>
|
||||
${cols
|
||||
.filter(
|
||||
(c) =>
|
||||
c.comparator,
|
||||
)
|
||||
.map(
|
||||
(col) => html`
|
||||
<wa-dropdown-item
|
||||
class=${this.sortField === col.id ? 'active-sort' : ''}
|
||||
@click=${() =>
|
||||
this.onSortDropdownSelect(
|
||||
col.id,
|
||||
)}
|
||||
>
|
||||
${col.label}
|
||||
</wa-dropdown-item>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</wa-popup>
|
||||
`;
|
||||
}
|
||||
private onPageHeaderSort = (
|
||||
e: CustomEvent<{ field: string; direction: 'asc' | 'desc' }>,
|
||||
) => {
|
||||
this.sortField =
|
||||
e.detail.field === '' ? null : e.detail.field;
|
||||
this.sortDirection = e.detail.direction;
|
||||
this.saveSortPreferences();
|
||||
};
|
||||
|
||||
override render() {
|
||||
const visibleTracks = this.cachedSortedTracks;
|
||||
const cols = this.activeColumns;
|
||||
|
||||
return html`
|
||||
${this.renderPageHeader()}
|
||||
${this.tracks.length === 0
|
||||
? this.renderPlaceholder()
|
||||
: html`
|
||||
${this.renderSortToolbar()}
|
||||
<div
|
||||
class="table-container"
|
||||
role="grid"
|
||||
|
||||
Reference in New Issue
Block a user