Merge remote-tracking branch 'origin/main' into wails-v3
This commit is contained in:
@@ -17,6 +17,14 @@ import * as download$0 from "../download/models.js";
|
||||
// @ts-ignore: Unused imports
|
||||
import * as tracklist$0 from "../tracklist/models.js";
|
||||
|
||||
/**
|
||||
* GetAllowMeteredCatalogDownload reports whether the ~0.6 GB Explore
|
||||
* catalog may be fetched on a metered connection.
|
||||
*/
|
||||
export function GetAllowMeteredCatalogDownload(): $CancellablePromise<boolean> {
|
||||
return $Call.ByID(2258585139);
|
||||
}
|
||||
|
||||
/**
|
||||
* GetDefaultPage returns the view the app opens to on launch.
|
||||
*/
|
||||
@@ -127,6 +135,17 @@ export function Save(): $CancellablePromise<void> {
|
||||
return $Call.ByID(1988945736);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetAllowMeteredCatalogDownload saves the metered-download permission.
|
||||
*
|
||||
* There is nothing to validate and nothing to restart: the policy is
|
||||
* read at the moment a download would start, so turning it on takes
|
||||
* effect on the next attempt rather than needing this launch to be over.
|
||||
*/
|
||||
export function SetAllowMeteredCatalogDownload(allow: boolean): $CancellablePromise<void> {
|
||||
return $Call.ByID(192700351, allow);
|
||||
}
|
||||
|
||||
/**
|
||||
* SetDefaultPage validates and saves a new launch page.
|
||||
*/
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
SetDefaultPage,
|
||||
GetQueueFallback,
|
||||
SetQueueFallback,
|
||||
GetAllowMeteredCatalogDownload,
|
||||
SetAllowMeteredCatalogDownload,
|
||||
} from '@go/config/config.js';
|
||||
import { GetIndexStatus } from '@go/explore/service.js';
|
||||
import { notificationStore } from '@store/notification-store';
|
||||
@@ -75,6 +77,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
// --- Now Playing state ---
|
||||
@state() private scrollMode = 'hover';
|
||||
|
||||
/** Whether the ~0.6 GB catalog may be fetched on mobile data. */
|
||||
@state() private allowMeteredCatalogDownload = false;
|
||||
|
||||
// --- Favorites state ---
|
||||
@state() private playlists: playlist.Summary[] = [];
|
||||
|
||||
@@ -888,17 +893,20 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
|
||||
private async loadLibraries(): Promise<void> {
|
||||
try {
|
||||
const [libs, mode, defaultPage, queueFallback] = await Promise.all([
|
||||
GetAllLibrariesWithTrackCounts(),
|
||||
GetScanConcurrency(),
|
||||
GetDefaultPage(),
|
||||
GetQueueFallback(),
|
||||
]);
|
||||
const [libs, mode, defaultPage, queueFallback, allowMetered] =
|
||||
await Promise.all([
|
||||
GetAllLibrariesWithTrackCounts(),
|
||||
GetScanConcurrency(),
|
||||
GetDefaultPage(),
|
||||
GetQueueFallback(),
|
||||
GetAllowMeteredCatalogDownload(),
|
||||
]);
|
||||
|
||||
this.libraries = libs ?? [];
|
||||
this.concurrencyMode = mode;
|
||||
this.defaultPage = defaultPage;
|
||||
this.queueFallback = queueFallback;
|
||||
this.allowMeteredCatalogDownload = allowMetered;
|
||||
|
||||
} catch (err) {
|
||||
console.error(
|
||||
@@ -1500,10 +1508,53 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
</div>`
|
||||
: html`<div class="index-loading">Loading status…</div>`}
|
||||
</div>
|
||||
|
||||
<config-field
|
||||
.schema=${{
|
||||
key: 'allowMeteredCatalogDownload',
|
||||
label: 'Download the catalog on mobile data',
|
||||
description:
|
||||
'The catalog is about 0.6 GB. It is skipped on a '
|
||||
+ 'cellular connection unless this is on; a '
|
||||
+ 'metered Wi-Fi network cannot be detected.',
|
||||
type: 'toggle' as const,
|
||||
}}
|
||||
.value=${this.allowMeteredCatalogDownload}
|
||||
@config-change=${this.handleAllowMeteredChange}
|
||||
></config-field>
|
||||
</config-section>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog download's one permission (plan 016 B4).
|
||||
*
|
||||
* It is in this section rather than General because it is about
|
||||
* *this* download and nothing else, and because the section already
|
||||
* explains what the catalog is — the toggle would be unreadable
|
||||
* beside "Default page".
|
||||
*/
|
||||
private handleAllowMeteredChange = (
|
||||
e: CustomEvent<ConfigFieldChangeEvent>,
|
||||
): void => {
|
||||
const allow = Boolean(e.detail.value);
|
||||
const previous = this.allowMeteredCatalogDownload;
|
||||
|
||||
this.allowMeteredCatalogDownload = allow;
|
||||
|
||||
void SetAllowMeteredCatalogDownload(allow).catch((err: unknown) => {
|
||||
console.error('failed to save metered download permission', err);
|
||||
// The visible state reverted, so this is the Transient case:
|
||||
// a small action the user can simply repeat.
|
||||
this.allowMeteredCatalogDownload = previous;
|
||||
notificationStore.transient({
|
||||
key: 'metered-catalog-setting',
|
||||
title: 'Setting not saved',
|
||||
text: describeError(err, 'That setting could not be saved.'),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
private tierIcon(state: string): string {
|
||||
switch (state) {
|
||||
case 'complete':
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { formatMilliseconds } from '@utils/time';
|
||||
import { html, nothing } from 'lit';
|
||||
|
||||
import { highlightText } from './search-ranking';
|
||||
|
||||
/** Compares two strings using locale-aware ordering. */
|
||||
const compareStr = (
|
||||
a: string,
|
||||
@@ -36,8 +38,17 @@ export interface ColumnDef {
|
||||
defaultWidth: string;
|
||||
/** Text alignment. Defaults to left. */
|
||||
align?: 'left' | 'right';
|
||||
/** Optional custom render function returning an HTML template. */
|
||||
renderCell?: (track: library.Track) => unknown;
|
||||
/**
|
||||
* Optional custom render function returning an HTML template.
|
||||
*
|
||||
* `term` is the active search term, for a cell that wants to
|
||||
* highlight its own text: the default path applies `highlightText`
|
||||
* to `accessor`'s value, and a cell that renders itself has to do
|
||||
* that itself or silently lose the highlight. Only `titleArtist`
|
||||
* needs it, which is why it is optional rather than a second
|
||||
* required parameter on all of them.
|
||||
*/
|
||||
renderCell?: (track: library.Track, term?: string) => unknown;
|
||||
/**
|
||||
* Comparison function for sorting two tracks by this column.
|
||||
* Returns negative if a < b, positive if a > b, zero if equal.
|
||||
@@ -85,6 +96,27 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
|
||||
/>`;
|
||||
},
|
||||
},
|
||||
titleArtist: {
|
||||
id: 'titleArtist',
|
||||
// Named for what it sorts by, since that is the only place the
|
||||
// label is user-visible: the phone has no column headers, and
|
||||
// the page header's sort list is built from the *configured*
|
||||
// columns rather than the drawn ones.
|
||||
label: 'Track Name',
|
||||
accessor: (t) => t.TrackName,
|
||||
defaultWidth: '1fr',
|
||||
comparator: (a, b) => compareStr(a.TrackName, b.TrackName),
|
||||
renderCell: (t, term) => html`
|
||||
<div class="stacked">
|
||||
<span class="stacked-title"
|
||||
>${term ? highlightText(t.TrackName, term) : t.TrackName}</span
|
||||
>
|
||||
<span class="stacked-sub"
|
||||
>${term ? highlightText(t.ArtistName, term) : t.ArtistName}</span
|
||||
>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
trackName: {
|
||||
id: 'trackName',
|
||||
label: 'Track Name',
|
||||
@@ -249,6 +281,24 @@ export const CORE_SEARCH_COLUMN_IDS: string[] = [
|
||||
'album',
|
||||
];
|
||||
|
||||
/**
|
||||
* The one column a phone shows, and it is two lines.
|
||||
*
|
||||
* At 424 CSS px -- the width of the phone this was measured on -- four
|
||||
* columns fit the row exactly and none of them fits its *content*:
|
||||
* `--grid-cols` came out `24px 102px 101px 101px 80px`, so "Duration"
|
||||
* did not fit its own header and a title had ~20 characters. The
|
||||
* columns were never too wide; there were too many of them.
|
||||
*
|
||||
* So the phone gets the title with the artist under it, which is the
|
||||
* shape every phone music list has, and the full row width to put them
|
||||
* in. It is a *column definition* rather than a second row template on
|
||||
* purpose: the row, the delegated events, the selection semantics, the
|
||||
* playing marker and the virtualizer all keep working, because from
|
||||
* their side nothing has changed except how many columns there are.
|
||||
*/
|
||||
export const PHONE_COLUMN_IDS: string[] = ['titleArtist', 'trackLength'];
|
||||
|
||||
/**
|
||||
* Default column IDs. Album is in them (H-15): without it, the three
|
||||
* `Tideline / Aurora Fields / 00:06` rows in this app's own fixture
|
||||
|
||||
@@ -31,6 +31,7 @@ import { LibraryController } from '@store/controllers/library-controller';
|
||||
import {
|
||||
COLUMN_DEFS,
|
||||
DEFAULT_COLUMN_IDS,
|
||||
PHONE_COLUMN_IDS,
|
||||
} from './columns';
|
||||
import type { ColumnDef } from './columns';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
@@ -92,6 +93,18 @@ const ROW_PADDING_X = 8;
|
||||
const ROW_CHROME_WIDTH =
|
||||
FAV_COL_WIDTH + ROW_PADDING_X * 2;
|
||||
|
||||
/**
|
||||
* Row heights, in the same relationship as the widths above: the number
|
||||
* is read by the CSS *and* by the virtualizer's layout, so they cannot
|
||||
* disagree. A phone row is two lines (title over artist).
|
||||
*/
|
||||
const ROW_HEIGHT = 33;
|
||||
const PHONE_ROW_HEIGHT = 52;
|
||||
|
||||
/** The shell's phone breakpoint, as `index.css` and every component
|
||||
* stylesheet spells it. */
|
||||
const PHONE_QUERY = '(max-width: 599px)';
|
||||
|
||||
// Inline SVG paths for favorite icons — eliminates wa-icon shadow DOM
|
||||
// overhead (30-50 shadow roots during scroll). Font Awesome 6 paths.
|
||||
const FAV_ICONS = {
|
||||
@@ -177,19 +190,34 @@ export class TrackList
|
||||
* Resolved column definitions for the currently configured
|
||||
* column IDs. Falls back to defaults for any unknown ID.
|
||||
*/
|
||||
private get activeColumns(): ColumnDef[] {
|
||||
/**
|
||||
* The columns the user has chosen — what a desktop draws, and what
|
||||
* *anything* may be sorted by.
|
||||
*
|
||||
* This is deliberately separate from `activeColumns`: "which columns
|
||||
* are drawn" and "what can I sort by" are different questions, and
|
||||
* the phone is exactly where they diverge. Building the sort list
|
||||
* from the drawn columns would silently take sort-by-artist and
|
||||
* sort-by-album away from the phone, which has no other route to
|
||||
* them since it has no column headers either.
|
||||
*/
|
||||
private get configuredColumns(): ColumnDef[] {
|
||||
const ids = this.trackListCtrl.columnIds;
|
||||
const chosen = !ids || ids.length === 0 ? DEFAULT_COLUMN_IDS : ids;
|
||||
|
||||
if (!ids || ids.length === 0) {
|
||||
return DEFAULT_COLUMN_IDS
|
||||
.map((id) => COLUMN_DEFS[id])
|
||||
.filter(
|
||||
(d): d is ColumnDef =>
|
||||
d !== undefined,
|
||||
);
|
||||
}
|
||||
return chosen
|
||||
.map((id) => COLUMN_DEFS[id])
|
||||
.filter(
|
||||
(d): d is ColumnDef =>
|
||||
d !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
return ids
|
||||
/** The columns actually drawn: two stacked lines on a phone. */
|
||||
private get activeColumns(): ColumnDef[] {
|
||||
if (!this.phone) return this.configuredColumns;
|
||||
|
||||
return PHONE_COLUMN_IDS
|
||||
.map((id) => COLUMN_DEFS[id])
|
||||
.filter(
|
||||
(d): d is ColumnDef =>
|
||||
@@ -376,9 +404,42 @@ export class TrackList
|
||||
// doesn't need to measure items. Without this hint, the default 100px
|
||||
// estimate causes constant scroll error correction (scrollTo() calls)
|
||||
// that produce visible jumping/skipping during scroll.
|
||||
/**
|
||||
* The virtualizer's item size and the CSS row height are the same
|
||||
* number in two places, and they must agree: the layout positions
|
||||
* rows from this figure, so a row that is really taller overlaps its
|
||||
* neighbour and a shorter one leaves a gap. Both come from here.
|
||||
*/
|
||||
private flowLayout = flow({
|
||||
_itemSize: { width: 100, height: 33 },
|
||||
_itemSize: { width: 100, height: ROW_HEIGHT },
|
||||
} as Parameters<typeof flow>[0]);
|
||||
|
||||
private phoneFlowLayout = flow({
|
||||
_itemSize: { width: 100, height: PHONE_ROW_HEIGHT },
|
||||
} as Parameters<typeof flow>[0]);
|
||||
|
||||
private get rowLayout(): Parameters<typeof flow>[0] {
|
||||
return this.phone ? this.phoneFlowLayout : this.flowLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone width, from the shell's own breakpoint.
|
||||
*
|
||||
* A media query *inside* a shadow root is answered by the viewport,
|
||||
* which is what lets every other component state what it drops at
|
||||
* phone width in its own stylesheet. This list cannot: its grid is
|
||||
* computed in JS from the host width, so the same threshold has to
|
||||
* be readable from JS as well. One breakpoint, two expressions of
|
||||
* it, and the reason is written here rather than inferred.
|
||||
*/
|
||||
@state()
|
||||
private phone = matchMedia(PHONE_QUERY).matches;
|
||||
|
||||
private phoneQuery = matchMedia(PHONE_QUERY);
|
||||
|
||||
private onPhoneChange = (e: MediaQueryListEvent): void => {
|
||||
this.phone = e.matches;
|
||||
};
|
||||
private hasRestoredScroll = false;
|
||||
private scrollSaveRAFId: number | null = null;
|
||||
|
||||
@@ -545,6 +606,20 @@ export class TrackList
|
||||
}
|
||||
|
||||
private initColumnWidths() {
|
||||
// A phone's widths are never the saved ones. `loadColumnWidths`
|
||||
// is keyed by column *id* and fills a gap with
|
||||
// `MIN_COLUMN_WIDTH`, so the phone's stacked column -- which
|
||||
// nothing has ever saved a width for, there being no handles to
|
||||
// drag -- came out at the minimum while the duration column
|
||||
// inherited a width saved for a four-column desktop row. Found
|
||||
// on the device: `24px 148px 236px`, the duration column with
|
||||
// 55% of a phone's row.
|
||||
if (this.phone) {
|
||||
this.computeDefaultWidths();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = this.loadColumnWidths();
|
||||
const cols = this.activeColumns;
|
||||
|
||||
@@ -675,6 +750,13 @@ export class TrackList
|
||||
}
|
||||
|
||||
private saveColumnWidths() {
|
||||
// And a phone's widths are never *saved*: they are computed from
|
||||
// a column set the user did not choose, and writing them would
|
||||
// overwrite the width they dragged for the same column on a
|
||||
// desktop. Nothing on a phone can resize a column anyway, so
|
||||
// this is only reachable by a window crossing the breakpoint.
|
||||
if (this.phone) return;
|
||||
|
||||
try {
|
||||
const cols = this.activeColumns;
|
||||
|
||||
@@ -1047,6 +1129,35 @@ export class TrackList
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
/* A phone row is two lines, and this height must equal
|
||||
PHONE_ROW_HEIGHT: the virtualizer positions rows from that number,
|
||||
so a taller row overlaps its neighbour and a shorter one gaps. */
|
||||
@media (max-width: 599px) {
|
||||
.track-row {
|
||||
height: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
.stacked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stacked-title,
|
||||
.stacked-sub {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stacked-sub {
|
||||
font-size: var(--yj-text-xs);
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
.track-row > * {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1173,9 +1284,17 @@ export class TrackList
|
||||
);
|
||||
|
||||
this.resizeObserver.observe(this);
|
||||
|
||||
// Connection, not the view lifecycle: this only sets state, so
|
||||
// it is harmless (and wanted) while the list is off screen -- a
|
||||
// rotation on another view must not leave this one laid out for
|
||||
// the wrong width when the user comes back to it.
|
||||
this.phoneQuery.addEventListener('change', this.onPhoneChange);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.phoneQuery.removeEventListener('change', this.onPhoneChange);
|
||||
|
||||
// Remove delegated event handlers from virtualizer.
|
||||
const virt = this.virtualizer;
|
||||
if (virt) {
|
||||
@@ -2030,7 +2149,7 @@ export class TrackList
|
||||
</svg>
|
||||
</div>
|
||||
${cols.map((col) => {
|
||||
const customCell = col.renderCell?.(track);
|
||||
const customCell = col.renderCell?.(track, term);
|
||||
if (customCell !== undefined && customCell !== nothing) {
|
||||
return html`<div role="gridcell" class="cell">${customCell}</div>`;
|
||||
}
|
||||
@@ -2086,7 +2205,7 @@ export class TrackList
|
||||
private renderPageHeader() {
|
||||
const options: SortOption[] = [
|
||||
{ id: '', label: 'Default' },
|
||||
...this.activeColumns
|
||||
...this.configuredColumns
|
||||
.filter((c) => c.comparator)
|
||||
.map((c) => ({ id: c.id, label: c.label })),
|
||||
];
|
||||
@@ -2136,7 +2255,7 @@ export class TrackList
|
||||
aria-busy=${this.loadingTracks}
|
||||
@keydown=${this.onListKeydown}
|
||||
>
|
||||
<div class="header-row" role="row">
|
||||
${this.phone ? nothing : html`<div class="header-row" role="row">
|
||||
<div role="columnheader" aria-label="Favourite"></div>
|
||||
${cols.map(
|
||||
(col) => html`
|
||||
@@ -2170,7 +2289,7 @@ export class TrackList
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</div>`}
|
||||
${visibleTracks.length === 0
|
||||
? html`<p class="no-results">
|
||||
No tracks match your search.
|
||||
@@ -2181,12 +2300,14 @@ export class TrackList
|
||||
.items=${visibleTracks}
|
||||
.renderItem=${this.renderTrackRow}
|
||||
.keyFunction=${(track: library.Track) => track.FilePath}
|
||||
.layout=${this.flowLayout}
|
||||
.layout=${this.rowLayout}
|
||||
></lit-virtualizer>
|
||||
`}
|
||||
|
||||
<!-- Resizing is a pointer gesture with no touch equivalent, and
|
||||
the phone's two columns are not the user's to arrange. -->
|
||||
<div class="resize-overlay">
|
||||
${this.colBoundaryPositions.map(
|
||||
${(this.phone ? [] : this.colBoundaryPositions).map(
|
||||
(pos, i) => html`
|
||||
<div
|
||||
class="col-resize-handle ${this.resizingColumn === i ? 'active' : ''}"
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* The track list on a phone (plan 016 B2 phase 4).
|
||||
*
|
||||
* Measured on the device this was built for: at 424 CSS px the four
|
||||
* configured columns fit the row *exactly* — `--grid-cols` came out
|
||||
* `24px 102px 101px 101px 80px` — and not one of them fit its content.
|
||||
* "Duration" did not fit its own header. The columns were never too
|
||||
* wide; there were too many of them.
|
||||
*
|
||||
* So a phone draws one column of two lines plus the duration, and the
|
||||
* split is a *column set* rather than a second row template: everything
|
||||
* about a row that is not "how many columns" keeps working, which is
|
||||
* what these tests pin. The `matchMedia` stub is the same one
|
||||
* `now-playing.test.ts` uses — the component reads the breakpoint from
|
||||
* JS because its grid is computed in JS, so this is the seam.
|
||||
*/
|
||||
import { describe, expect, it, afterEach } from 'vitest';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import '@components/track-list/track-list';
|
||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||
|
||||
const TRACKS = Array.from({ length: 12 }, (_, i) => ({
|
||||
FilePath: `/music/track-${i}.mp3`,
|
||||
TrackName: `Track ${i}`,
|
||||
ArtistName: `Artist ${i}`,
|
||||
Album: 'An Album',
|
||||
Duration: 180 + i,
|
||||
})) as never[];
|
||||
|
||||
const real = window.matchMedia.bind(window);
|
||||
|
||||
/** Force the shell's phone breakpoint on or off. */
|
||||
function stubPhone(phone: boolean): void {
|
||||
window.matchMedia = ((q: string) =>
|
||||
q.includes('max-width: 599px')
|
||||
? {
|
||||
matches: phone,
|
||||
media: q,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}
|
||||
: real(q)) as typeof window.matchMedia;
|
||||
}
|
||||
|
||||
async function mount(phone: boolean): Promise<LitElement> {
|
||||
stubPhone(phone);
|
||||
|
||||
// Narrow, so a desktop layout at this width would be the cramped one
|
||||
// the plan describes rather than a comfortable one.
|
||||
const el = await fixture<LitElement>('track-list', {
|
||||
externalTracks: TRACKS,
|
||||
});
|
||||
|
||||
el.style.width = '424px';
|
||||
el.style.height = '400px';
|
||||
await el.updateComplete;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = real;
|
||||
localStorage.removeItem('track-list-column-widths');
|
||||
});
|
||||
|
||||
describe('the track list at phone width', () => {
|
||||
it('draws the title with the artist under it, and the duration', async () => {
|
||||
const el = await mount(true);
|
||||
const row = shadow(el, '.track-row');
|
||||
|
||||
expect(row).not.toBeNull();
|
||||
expect(shadow(el, '.stacked-title')?.textContent?.trim()).toBe('Track 0');
|
||||
expect(shadow(el, '.stacked-sub')?.textContent?.trim()).toBe('Artist 0');
|
||||
|
||||
// Two drawn columns plus the favourite: three grid tracks, not five.
|
||||
const tracks = getComputedStyle(row as Element)
|
||||
.gridTemplateColumns.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
expect(tracks).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('drops the column headers and the resize handles', async () => {
|
||||
const el = await mount(true);
|
||||
|
||||
// Both are pointer affordances: a header is where a click sorts and
|
||||
// a handle is where a drag resizes, and a phone can do neither.
|
||||
expect(shadow(el, '.header-row')).toBeNull();
|
||||
expect(shadowAll(el, '.col-resize-handle')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps every sort the desktop offers', async () => {
|
||||
const el = await mount(true);
|
||||
const header = shadow(el, 'page-header') as
|
||||
| (Element & { sortOptions?: { id: string }[] })
|
||||
| null;
|
||||
|
||||
// The regression this guards: building the sort list from the
|
||||
// *drawn* columns would leave a phone able to sort by title and
|
||||
// duration only, with no column headers to reach the rest by.
|
||||
const ids = (header?.sortOptions ?? []).map((o) => o.id);
|
||||
|
||||
expect(ids).toContain('artistName');
|
||||
expect(ids).toContain('album');
|
||||
});
|
||||
|
||||
it('still marks the row a screen reader has to understand', async () => {
|
||||
const el = await mount(true);
|
||||
const row = shadow(el, '.track-row');
|
||||
|
||||
// The row is the same row: only the cells inside it changed, which
|
||||
// is the entire argument for doing this as a column set.
|
||||
expect(row?.getAttribute('role')).toBe('row');
|
||||
expect(row?.getAttribute('aria-selected')).toBe('false');
|
||||
expect(row?.getAttribute('data-testid')).toBe('track-row');
|
||||
expect(shadowAll(el, '[role="gridcell"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('ignores widths saved for the desktop, and does not overwrite them', async () => {
|
||||
// The bug the device found, in the fixture that reproduces it.
|
||||
// `loadColumnWidths` is keyed by column *id* and fills a gap with
|
||||
// MIN_COLUMN_WIDTH, so the phone's stacked column -- which nothing
|
||||
// can ever have saved a width for -- came out at the minimum while
|
||||
// the duration column inherited a width dragged on a wide window:
|
||||
// measured `24px 148px 236px` on a 424px phone.
|
||||
const desktop = { trackName: 300, artistName: 200, album: 200, trackLength: 236 };
|
||||
|
||||
localStorage.setItem('track-list-column-widths', JSON.stringify(desktop));
|
||||
|
||||
const el = await mount(true);
|
||||
const tracks = getComputedStyle(shadow(el, '.track-row') as Element)
|
||||
.gridTemplateColumns.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((t) => Math.round(parseFloat(t)));
|
||||
|
||||
// Favourite, then the stacked column, then the duration -- and the
|
||||
// stacked one is the widest thing in the row.
|
||||
expect(tracks[1]).toBeGreaterThan(tracks[2] ?? 0);
|
||||
|
||||
// And the desktop's own widths survive being on a phone: writing the
|
||||
// computed phone widths back would silently replace the width the
|
||||
// user dragged for the same column id.
|
||||
expect(JSON.parse(localStorage.getItem('track-list-column-widths') ?? '{}'))
|
||||
.toMatchObject(desktop);
|
||||
});
|
||||
|
||||
it('leaves the desktop alone', async () => {
|
||||
const el = await mount(false);
|
||||
const row = shadow(el, '.track-row');
|
||||
|
||||
expect(shadow(el, '.header-row')).not.toBeNull();
|
||||
expect(shadow(el, '.stacked-title')).toBeNull();
|
||||
|
||||
const tracks = getComputedStyle(row as Element)
|
||||
.gridTemplateColumns.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
expect(tracks).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user