From 30bbdd7c53721708948c137b28cdee6d2d3d64f7 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Sun, 22 Feb 2026 17:16:01 -0500 Subject: [PATCH] added basic sorting in track-list and cover-grid --- .../src/components/cover-grid/cover-grid.ts | 440 ++++++++++++++++- frontend/src/components/track-list/columns.ts | 49 ++ .../src/components/track-list/track-list.ts | 466 +++++++++++++++++- 3 files changed, 935 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/cover-grid/cover-grid.ts b/frontend/src/components/cover-grid/cover-grid.ts index 2c3c6b1..735f7c9 100644 --- a/frontend/src/components/cover-grid/cover-grid.ts +++ b/frontend/src/components/cover-grid/cover-grid.ts @@ -61,6 +61,67 @@ const SCROLL_DEBOUNCE_MS = 100; /** Pixels to change card width per scroll tick. */ const ZOOM_STEP = 16; +/** localStorage keys for sort preferences. */ +const SORT_FIELD_KEY = 'cover-grid-sort-field'; +const SORT_DIR_KEY = 'cover-grid-sort-direction'; + +/** Available sort fields for the album grid. */ +type AlbumSortField = 'name' | 'artist' | 'year'; + +/** Sort option definition for the dropdown. */ +interface AlbumSortOption { + id: AlbumSortField; + label: string; + comparator: ( + a: library.Album, + b: library.Album, + ) => number; +} + +/** All available sort options for albums. */ +const ALBUM_SORT_OPTIONS: AlbumSortOption[] = [ + { + id: 'name', + label: 'Name', + comparator: (a, b) => + a.Name.localeCompare(b.Name), + }, + { + id: 'artist', + label: 'Artist', + comparator: (a, b) => { + const cmp = a.ArtistName.localeCompare( + b.ArtistName, + ); + + if (cmp !== 0) return cmp; + + return a.Name.localeCompare(b.Name); + }, + }, + { + id: 'year', + label: 'Year', + comparator: (a, b) => { + // Albums without a year sort last. + if (!a.Year && !b.Year) { + return a.Name.localeCompare(b.Name); + } + + if (!a.Year) return 1; + if (!b.Year) return -1; + + const cmp = a.Year - b.Year; + + if (cmp !== 0) return cmp; + + return a.Name.localeCompare(b.Name); + }, + }, +]; + +type SortDirection = 'asc' | 'desc'; + @customElement('cover-grid') export class CoverGrid extends LitElement { private libraryCtrl = new LibraryController(this); @@ -186,14 +247,34 @@ export class CoverGrid extends LitElement { const term = this.searchCtrl.term.toLowerCase(); - if (!term) return this.albums; + let albums: library.Album[]; - return this.albums.filter( - (a) => - a.Name.toLowerCase().includes(term) || - a.ArtistName.toLowerCase().includes( - term, - ), + if (!term) { + albums = this.albums; + } else { + albums = this.albums.filter( + (a) => + a.Name.toLowerCase().includes( + term, + ) || + a.ArtistName.toLowerCase().includes( + term, + ), + ); + } + + // Apply sort. + const opt = ALBUM_SORT_OPTIONS.find( + (o) => o.id === this.sortField, + ); + + if (!opt) return albums; + + const dir = + this.sortDirection === 'asc' ? 1 : -1; + + return [...albums].sort( + (a, b) => dir * opt.comparator(a, b), ); } @@ -215,6 +296,120 @@ export class CoverGrid extends LitElement { position: relative; } + /* ======================================== + * 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; + } + .grid-scroll-container { flex: 1; position: relative; @@ -438,6 +633,21 @@ export class CoverGrid extends LitElement { @state() private playlistFilePaths: string[] = []; + /** Current album sort field. */ + @state() + private sortField: AlbumSortField = 'name'; + + /** Current sort direction. */ + @state() + private sortDirection: SortDirection = 'asc'; + + /** Whether the sort dropdown popup is open. */ + @state() + private sortDropdownOpen = false; + + @query('#sort-dropdown') + private sortDropdownPopup!: HTMLElement; + /** ID of the album whose dropdown is currently open, or null. */ @state() private expandedAlbumId: number | null = null; @@ -531,12 +741,135 @@ export class CoverGrid extends LitElement { private transitionOverlay: HTMLDivElement | null = null; + /* ==================================================================== + * Sort controls + * ==================================================================== */ + + /** Restore sort preferences from localStorage. */ + private restoreSortPreferences() { + try { + const field = localStorage.getItem( + SORT_FIELD_KEY, + ); + const dir = + localStorage.getItem(SORT_DIR_KEY); + + if ( + field && + ALBUM_SORT_OPTIONS.some( + (o) => o.id === field, + ) + ) { + this.sortField = + field as AlbumSortField; + } + + if (dir === 'asc' || dir === 'desc') { + this.sortDirection = dir; + } + } catch { + // Ignore storage errors. + } + } + + /** Persist sort preferences to localStorage. */ + private saveSortPreferences() { + try { + localStorage.setItem( + SORT_FIELD_KEY, + this.sortField, + ); + localStorage.setItem( + SORT_DIR_KEY, + this.sortDirection, + ); + } catch { + // Ignore storage errors. + } + } + + /** 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 as any).anchor = anchor; + (popup as any).active = true; + } + } + + private closeSortDropdown() { + if (!this.sortDropdownOpen) return; + + this.sortDropdownOpen = false; + + const popup = this.sortDropdownPopup; + + if (popup) { + (popup as any).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 * ==================================================================== */ override connectedCallback() { super.connectedCallback(); + this.restoreSortPreferences(); this.loadAlbums(); this.cancelScanComplete = EventsOn( Events.LibraryScanComplete, @@ -554,6 +887,10 @@ export class CoverGrid extends LitElement { 'mousedown', this.mousedownCloseHandler, ); + document.addEventListener( + 'mousedown', + this.sortDropdownCloseHandler, + ); // error events do not bubble — use capture // phase to catch load failures. @@ -579,6 +916,10 @@ export class CoverGrid extends LitElement { 'mousedown', this.mousedownCloseHandler, ); + document.removeEventListener( + 'mousedown', + this.sortDropdownCloseHandler, + ); this.removeEventListener( 'error', this.onGridImageError, @@ -2764,6 +3105,90 @@ export class CoverGrid extends LitElement { this.closeContextMenu(); }; + /* ==================================================================== + * Render: sort toolbar + * ==================================================================== */ + + /** 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'; + + return html` +
+ Sort: + + +
+ ${this.renderSortDropdownPopup()} + `; + } + + /** 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} +
+ `; + } + /* ==================================================================== * Rendering helpers * ==================================================================== */ @@ -2934,6 +3359,7 @@ export class CoverGrid extends LitElement { : this.renderSingleGrid(); return html` + ${this.renderSortToolbar()} ${this.searchCtrl.term ? html`
Showing results for diff --git a/frontend/src/components/track-list/columns.ts b/frontend/src/components/track-list/columns.ts index c3b7af2..ce85dcf 100644 --- a/frontend/src/components/track-list/columns.ts +++ b/frontend/src/components/track-list/columns.ts @@ -1,6 +1,21 @@ import type { library } from '@go/models'; import { formatMilliseconds } from '@utils/time'; +/** Compares two strings using locale-aware ordering. */ +const compareStr = ( + a: string, + b: string, +): number => a.localeCompare(b); + +/** Compares two numbers, treating 0 as "empty" (sorted last). */ +const compareNum = (a: number, b: number): number => { + if (!a && !b) return 0; + if (!a) return 1; + if (!b) return -1; + + return a - b; +}; + /** Definition for a single displayable column. */ export interface ColumnDef { /** Unique identifier matching the backend ColumnID. */ @@ -13,6 +28,15 @@ export interface ColumnDef { defaultWidth: string; /** Text alignment. Defaults to left. */ align?: 'left' | 'right'; + /** + * Comparison function for sorting two tracks by this column. + * Returns negative if a < b, positive if a > b, zero if equal. + * If omitted the column is not sortable. + */ + comparator?: ( + a: library.Track, + b: library.Track, + ) => number; } /** Registry of every available column keyed by ID. */ @@ -22,30 +46,43 @@ export const COLUMN_DEFS: Record = { label: 'Track Name', accessor: (t) => t.TrackName, defaultWidth: '1fr', + comparator: (a, b) => + compareStr(a.TrackName, b.TrackName), }, artistName: { id: 'artistName', label: 'Artist', accessor: (t) => t.ArtistName, defaultWidth: '1fr', + comparator: (a, b) => + compareStr(a.ArtistName, b.ArtistName), }, trackLength: { id: 'trackLength', label: 'Duration', accessor: (t) => formatMilliseconds(t.TrackLength), defaultWidth: '80px', + comparator: (a, b) => + Number(a.TrackLength) - Number(b.TrackLength), }, album: { id: 'album', label: 'Album', accessor: (t) => t.Album, defaultWidth: '1fr', + comparator: (a, b) => + compareStr(a.Album, b.Album), }, genre: { id: 'genre', label: 'Genre', accessor: (t) => (t.Genre ?? []).join(', '), defaultWidth: '120px', + comparator: (a, b) => + compareStr( + (a.Genre ?? []).join(', '), + (b.Genre ?? []).join(', '), + ), }, year: { id: 'year', @@ -53,12 +90,16 @@ export const COLUMN_DEFS: Record = { accessor: (t) => t.Year ? String(t.Year) : '', defaultWidth: '60px', + comparator: (a, b) => + compareNum(a.Year, b.Year), }, composer: { id: 'composer', label: 'Composer', accessor: (t) => t.Composer, defaultWidth: '1fr', + comparator: (a, b) => + compareStr(a.Composer, b.Composer), }, trackNumber: { id: 'trackNumber', @@ -66,6 +107,8 @@ export const COLUMN_DEFS: Record = { accessor: (t) => t.TrackNumber ? String(t.TrackNumber) : '', defaultWidth: '60px', + comparator: (a, b) => + compareNum(a.TrackNumber, b.TrackNumber), }, discNumber: { id: 'discNumber', @@ -73,18 +116,24 @@ export const COLUMN_DEFS: Record = { accessor: (t) => t.DiscNumber ? String(t.DiscNumber) : '', defaultWidth: '60px', + comparator: (a, b) => + compareNum(a.DiscNumber, b.DiscNumber), }, filePath: { id: 'filePath', label: 'File Path', accessor: (t) => t.FilePath, defaultWidth: '1fr', + comparator: (a, b) => + compareStr(a.FilePath, b.FilePath), }, fileType: { id: 'fileType', label: 'File Type', accessor: (t) => t.FileType, defaultWidth: '80px', + comparator: (a, b) => + compareStr(a.FileType, b.FileType), }, }; diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index fe986ca..217e665 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -36,9 +36,13 @@ import '@components/playlist-picker/playlist-picker.js'; import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js'; const COLUMN_STORAGE_KEY = 'track-list-column-widths'; +const SORT_FIELD_KEY = 'track-list-sort-field'; +const SORT_DIR_KEY = 'track-list-sort-direction'; const MIN_COLUMN_WIDTH = 50; const DEFAULT_FIXED_WIDTH = 80; +type SortDirection = 'asc' | 'desc'; + @customElement('track-list') export class TrackList extends LitElement implements SelectionHost { private player = new PlayerController(this); @@ -127,6 +131,21 @@ export class TrackList extends LitElement implements SelectionHost { @state() private columnWidths: number[] = []; + /** Column ID to sort by, or null for default order. */ + @state() + private sortField: string | null = null; + + /** Current sort direction. */ + @state() + private sortDirection: SortDirection = 'asc'; + + /** Whether the sort dropdown popup is open. */ + @state() + private sortDropdownOpen = false; + + @query('#sort-dropdown') + private sortDropdownPopup!: HTMLElement; + private resizingColumn: number | null = null; private resizeStartX = 0; private resizeStartWidths: number[] = []; @@ -157,16 +176,37 @@ export class TrackList extends LitElement implements SelectionHost { ); } + // ================================================================= + // Sorted tracks + // ================================================================= + + private get sortedTracks(): library.Track[] { + const tracks = this.filteredTracks; + + if (!this.sortField) return tracks; + + const col = COLUMN_DEFS[this.sortField]; + + if (!col?.comparator) return tracks; + + const dir = + this.sortDirection === 'asc' ? 1 : -1; + + return [...tracks].sort( + (a, b) => dir * col.comparator!(a, b), + ); + } + // ================================================================= // SelectionHost interface // ================================================================= getItemKey(index: number): string | undefined { - return this.filteredTracks[index]?.FilePath; + return this.sortedTracks[index]?.FilePath; } getItemCount(): number { - return this.filteredTracks.length; + return this.sortedTracks.length; } onSelectionChanged(): void { @@ -564,19 +604,131 @@ export class TrackList extends LitElement implements SelectionHost { static override styles = css` :host { - position: relative; display: flex; flex-direction: column; overflow: hidden; } + .table-container { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + } + + /* ---- 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; + } + + /* ---- Header row ---- */ + .header-row { display: grid; grid-template-columns: var(--grid-cols); padding: 8px; font-weight: bold; color: var(--yj-text-primary, #fff); - border-bottom: 1px solid var(--yj-text-tertiary, #666); + border-bottom: 1px solid + var(--yj-text-tertiary, #666); flex-shrink: 0; overflow: hidden; } @@ -585,6 +737,20 @@ export class TrackList extends LitElement implements SelectionHost { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + } + + .header-cell:hover { + color: var(--yj-accent, #ffd43b); + } + + .sort-arrow { + font-size: 10px; + flex-shrink: 0; + color: var(--yj-accent, #ffd43b); } .resize-overlay { @@ -746,6 +912,7 @@ export class TrackList extends LitElement implements SelectionHost { override connectedCallback() { super.connectedCallback(); + this.restoreSortPreferences(); this.loadTracks(); this.cancelScanComplete = EventsOn( Events.LibraryScanComplete, @@ -754,6 +921,7 @@ export class TrackList extends LitElement implements SelectionHost { document.addEventListener('click', this.closeHandler); document.addEventListener('contextmenu', this.closeHandler); document.addEventListener('mousedown', this.mousedownCloseHandler); + document.addEventListener('mousedown', this.sortDropdownCloseHandler); document.addEventListener('click', this.clearSelectionHandler); document.addEventListener('mousemove', this.onColResizeMove); document.addEventListener('mouseup', this.onColResizeEnd); @@ -778,6 +946,7 @@ export class TrackList extends LitElement implements SelectionHost { document.removeEventListener('click', this.closeHandler); document.removeEventListener('contextmenu', this.closeHandler); document.removeEventListener('mousedown', this.mousedownCloseHandler); + document.removeEventListener('mousedown', this.sortDropdownCloseHandler); document.removeEventListener('click', this.clearSelectionHandler); document.removeEventListener('mousemove', this.onColResizeMove); document.removeEventListener('mouseup', this.onColResizeEnd); @@ -1062,6 +1231,161 @@ export class TrackList extends LitElement implements SelectionHost { this.closeContextMenu(true); }; + // ================================================================= + // Sort controls + // ================================================================= + + /** Restore sort preferences from localStorage. */ + private restoreSortPreferences() { + try { + const field = + localStorage.getItem(SORT_FIELD_KEY); + const dir = + localStorage.getItem(SORT_DIR_KEY); + + if ( + field && + COLUMN_DEFS[field]?.comparator + ) { + this.sortField = field; + } + + if (dir === 'asc' || dir === 'desc') { + this.sortDirection = dir; + } + } catch { + // Ignore storage errors. + } + } + + /** Persist sort preferences to localStorage. */ + private saveSortPreferences() { + try { + if (this.sortField) { + localStorage.setItem( + SORT_FIELD_KEY, + this.sortField, + ); + } else { + localStorage.removeItem( + SORT_FIELD_KEY, + ); + } + + localStorage.setItem( + SORT_DIR_KEY, + this.sortDirection, + ); + } catch { + // Ignore storage errors. + } + } + + /** + * Handle a click on a column header to toggle sorting. + * First click: sort ascending. Second: descending. + * Third: clear sort (back to default order). + */ + private onHeaderCellClick(colId: string) { + const col = COLUMN_DEFS[colId]; + + if (!col?.comparator) return; + + if (this.sortField === colId) { + if (this.sortDirection === 'asc') { + this.sortDirection = 'desc'; + } else { + this.sortField = null; + this.sortDirection = 'asc'; + } + } else { + this.sortField = colId; + this.sortDirection = 'asc'; + } + + 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 as any).anchor = anchor; + (popup as any).active = true; + } + } + + private closeSortDropdown() { + if (!this.sortDropdownOpen) return; + + this.sortDropdownOpen = false; + + const popup = this.sortDropdownPopup; + + if (popup) { + (popup as any).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; @@ -1114,19 +1438,134 @@ export class TrackList extends LitElement implements SelectionHost { `; }; + /** 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'; + + return html` +
+ Sort: + + ${this.sortField + ? html` + + ` + : 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} +
+ `; + } + override render() { - const visibleTracks = this.filteredTracks; + const visibleTracks = this.sortedTracks; const cols = this.activeColumns; return html` ${this.tracks.length === 0 ? html`

Loading tracks...

` : html` + ${this.renderSortToolbar()} +
${cols.map( (col) => html` -
- ${col.label} +
+ this.onHeaderCellClick( + col.id, + )} + > + ${col.label} + ${this.sortField === col.id + ? html` + ${this.sortDirection === 'asc' ? '\u25B2' : '\u25BC'} + ` + : nothing}
`, )} @@ -1143,27 +1582,28 @@ export class TrackList extends LitElement implements SelectionHost { .layout=${this.flowLayout} > `} - `} ${this.searchCtrl.term && visibleTracks.length > 0 - ? html`
+ ? html`
Showing results for “${this.searchCtrl.term}”
` - : nothing} + : nothing}
${this.colBoundaryPositions.map( - (pos, i) => html` + (pos, i) => html`
- this.onColResizeStart(e, i)} + this.onColResizeStart(e, i)} >
`, - )} + )}
+
+ `}