feat(ui): the track list a phone can read
Build & publish Arch package / arch-package (push) Successful in 2m33s
CI / check (push) Successful in 2m26s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 6m15s

B2 phase 4, and the last of it. Measured on the device: 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, with "Duration" too narrow for its own header. The columns were
never too wide; there were too many of them.

So a phone draws `titleArtist` (the title with the artist under it,
across the row's whole width) plus the duration, and drops the column
headers and the resize handles, which are a click-to-sort and a drag
with no touch equivalent. It is a **column set, not a second row
template**: the row, its delegated events, the selection semantics, the
playing marker and the virtualizer never learn anything changed, because
from their side only the number of columns did.

Three rules come with it. The row height is in two places
(`PHONE_ROW_HEIGHT` and the CSS rule) and must agree, since the
virtualizer positions rows from that number and a taller row overlaps
its neighbour. What is drawn and what can be sorted are different
questions, so the sort list is built from `configuredColumns` -- a phone
has no headers either, and building it from the drawn columns would
leave it able to sort by title and duration alone. And a phone's column
widths are neither loaded nor saved.

That third rule is the bug the device found with the arrangement already
passing five component tests and five e2e specs at the phone's own
viewport. `loadColumnWidths` is keyed by column *id* and fills a gap
with `MIN_COLUMN_WIDTH`, so the stacked column -- which nothing can ever
have saved a width for -- came out at 148px beside a duration column of
236. The mirror image was worse and unreachable from a phone at all:
saving would have written those widths back under the same ids,
replacing the width the user dragged on a desktop. The specs asserted
shape, and the fault depended on what `localStorage` held for a
different column set; the unit test now carries that map as a fixture.

Verified: 809 component tests, 112 e2e specs, and on the phone at
424x439 -- `24px 304px 80px`, 52px rows, no truncation, no overflow.
One full e2e run of three saw an unrelated autotag keypress spec flake
and pass on retry.
This commit is contained in:
2026-08-17 10:36:29 -04:00
parent a9852c18a0
commit 2c78b58207
7 changed files with 569 additions and 23 deletions
+44
View File
@@ -3257,3 +3257,47 @@ done (`adb uninstall app.yellowjacket.dev` — the sibling id is exactly
what makes that safe). And `am start` does not reliably take focus while
another app is foreground: check `topResumedActivity` before trusting a
screenshot, or you will read someone else's app.
## The phone track list, and the bug a viewport could not have found (2026-08-17)
B2 phase 4. A phone draws `titleArtist` — the title with the artist
under it — plus the duration, and drops the column headers and the
resize handles. It is a **column set, not a second row template**: the
row, its delegated events, the selection semantics, the playing marker
and the virtualizer never learn that anything changed, because from
their side only the number of columns did.
Three rules, each one a way it breaks otherwise. The row height is in
two places (`PHONE_ROW_HEIGHT` and the CSS) and they must agree, since
the virtualizer positions rows from that number. What is *drawn* and
what can be *sorted* are separate questions — the sort list is built
from `configuredColumns`, or a phone with no headers could sort by
nothing but title and duration. And a phone's widths are neither loaded
nor saved.
**That last one is the finding, and it came from the device.** With the
arrangement passing five component tests and five e2e specs at
424x439, the phone showed `24px 148px 236px`: the duration column with
55% of the row. `loadColumnWidths` is keyed by column *id* and fills a
gap with `MIN_COLUMN_WIDTH`, so the stacked column — which nothing can
ever have saved a width for, there being no handles to drag — came out
at the minimum while `trackLength` inherited a width saved for a
four-column desktop row. The mirror image is worse and was never
reachable from a phone at all: `saveColumnWidths` would have written the
computed phone widths back under the same ids, replacing the width the
user dragged on a desktop.
**Why every browser test missed it.** The specs assert the *shape* — how
many grid tracks, no header, no overflow, the title's share of the row —
and the width bug depends on what is in `localStorage` for a *different*
column set. dev-headless's seed happened to hold widths that split the
other way, so the same assertion passed in the browser and failed on the
phone. The unit test now carries the desktop map as a fixture, which is
the reproduction the browser needed to have.
Two tooling notes worth keeping. `playwright-cli` holds its page across
a `make dev-headless` restart, so a probe after a rebuild can be
answering for the *old* bundle — it reported the desktop layout at 424 px
until the page was reopened. And wireless adb dropped twice more mid-
session when the screen slept; USB for anything longer than a few
probes.
@@ -315,8 +315,8 @@ places had to agree — `abiFilters`, the Makefile's `android:package`
anchor is what stops it also matching the fat APK's line. Adding the
ABI back, if modernc ever fixes `Xlstat64`, is those same three edits.
**B2, the desktop shell.** Scope decided (below); **phases 1, 2 and 3
are done.**
**B2, the desktop shell.** Scope decided (below); **all four phases are
done.**
- *Phase 1, the shell.* Below 600px the sidebar column is gone,
`<bottom-nav>` is the primary navigation, and the shell fits 320px
@@ -338,8 +338,19 @@ are done.**
cannot dispatch a trusted event and that path would otherwise be the
only uncovered one.
What is left of B2 is the track list, whose resizable columns are a
pointer feature with no touch equivalent. Not started.
- *Phase 4, the track list.* A phone draws `titleArtist` (title over
artist) plus the duration, and drops the column headers and the resize
handles — a column set rather than a second row template, so the row
and everything delegated on it is unchanged. Verified at the device's
own 424x439: `24px 304px 80px`, 52 px rows, no truncation, no
overflow. The device also found the bug in it, which no browser
viewport would have: saved *desktop* column widths reached the phone
through an id-keyed store and gave the duration column 55% of the row.
**B2 is complete.** What is left in this plan is B3 (tag writing, which
needs a device), B4 (the catalog download on a metered connection), and
the standing question of the Light Phone's Chrome 113 — which so far has
cost nothing: menus, dialogs and long-press all work on it.
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
with all-files access, `tagwriter` can write in place.
+22
View File
@@ -1536,6 +1536,28 @@ by the three places that need them (the default widths, the
normaliser, and the resize handles' positions), because they were
written out separately and that is how they came to disagree.
**A phone draws one column of two lines, and that is a column set
rather than a second row template.** Measured on the device: at 424 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. `PHONE_COLUMN_IDS` is `titleArtist`
(title over artist, sharing the row's whole width) plus the duration, so
the row, the delegated events, the selection semantics, the playing
marker and the virtualizer are all untouched: from their side only the
number of columns changed. Three rules come with it. **The row height
lives in two places and they must agree** — `PHONE_ROW_HEIGHT` and the
CSS rule — because the virtualizer positions rows from that number, so a
taller row overlaps its neighbour. **What is drawn and what can be
sorted are different questions**: the page header's sort list is built
from `configuredColumns`, or a phone (which has no column headers
either) could sort by nothing but title and duration. And **a phone's
widths are neither loaded nor saved**: `loadColumnWidths` is keyed by
column *id* and fills a gap with the minimum, so the stacked column —
which nothing can ever have saved a width for — came out at 148 px
beside a duration column of 236, and saving would have replaced the
width the user dragged on a desktop for the same id.
**The default columns are declared twice and must agree.**
`tracklist.DefaultColumns` is what a fresh install persists;
`DEFAULT_COLUMN_IDS` in `track-list/columns.ts` is what the list draws
+137
View File
@@ -0,0 +1,137 @@
import { test, expect } from '../support/fixtures.js';
/**
* The track list on a phone (plan 016 B2 phase 4).
*
* The component tier pins the arrangement; this pins it in the real
* shell, at the viewport of the device the work was measured on — 424 x
* 439, a Light Phone III — because the fault it fixes was invisible to
* every assertion the app had. The columns *fit*: `--grid-cols` summed
* to exactly the host width, nothing overflowed, and every column was
* still unreadable. Only a measurement of what a cell can hold, or a
* screenshot, shows that.
*/
type Page = import('@playwright/test').Page;
/** The phone this was built against, in CSS pixels. */
const DEVICE = { width: 424, height: 439 };
/** A common small phone, as the shell specs use. */
const PHONE = { width: 390, height: 844 };
const list = (page: Page) => page.locator('track-list');
/** The row's grid tracks and the widest text a cell can show. */
const rowGeometry = (page: Page) =>
page.evaluate(() => {
const sr = document.querySelector('track-list')?.shadowRoot;
const row = sr?.querySelector('.track-row');
if (!row) return null;
const title = row.querySelector('.stacked-title');
const sub = row.querySelector('.stacked-sub');
return {
tracks: getComputedStyle(row)
.gridTemplateColumns.split(/\s+/)
.filter(Boolean).length,
rowHeight: Math.round(row.getBoundingClientRect().height),
headerRow: !!sr?.querySelector('.header-row'),
handles: sr?.querySelectorAll('.col-resize-handle').length ?? 0,
titleWidth: title ? Math.round(title.getBoundingClientRect().width) : 0,
// A truncated cell is the fault; a cell wider than its text is fine.
titleTruncated: title ? title.scrollWidth > title.clientWidth + 1 : null,
subText: sub?.textContent?.trim() ?? null,
};
});
test.describe('the track list on a phone', () => {
test.beforeEach(async ({ app }) => {
await app.setViewportSize(DEVICE);
await app.getByTestId('tab-tracks').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'tracks',
);
await expect(list(app).first()).toBeVisible();
});
test.afterEach(async ({ app }) => {
await app.setViewportSize({ width: 1440, height: 900 });
});
test('stacks the title over the artist and drops the pointer affordances', async ({
app,
}) => {
const geo = await rowGeometry(app);
expect(geo).not.toBeNull();
// Favourite + one stacked column + duration.
expect(geo?.tracks).toBe(3);
expect(geo?.headerRow).toBe(false);
expect(geo?.handles).toBe(0);
expect(geo?.subText).toBeTruthy();
// The row height has to match the virtualizer's item size, or rows
// overlap; 52 is that number.
expect(geo?.rowHeight).toBe(52);
});
test('gives the title most of the row instead of a quarter of it', async ({
app,
}) => {
const geo = await rowGeometry(app);
// Four columns at this width gave a title ~102px. The measurement
// that matters is the share of the row, not the pixel count.
expect(geo?.titleWidth ?? 0).toBeGreaterThan(DEVICE.width * 0.55);
});
test('needs no sideways scrolling, and neither does the shell', async ({
app,
}) => {
const overflow = await app.evaluate(() => ({
body: [document.body.scrollWidth, document.body.clientWidth],
list: (() => {
const sr = document.querySelector('track-list')?.shadowRoot;
const row = sr?.querySelector('.track-row');
return row ? [row.scrollWidth, row.clientWidth] : null;
})(),
}));
expect(overflow.body[0]).toBe(overflow.body[1]);
expect(overflow.list?.[0]).toBe(overflow.list?.[1]);
});
test('keeps the sorts a phone has no headers to reach', async ({ app }) => {
// With no column headers, the page header's sort control is the only
// route to sort-by-artist — so it must still offer the columns the
// phone does not draw.
const ids = await app.evaluate(() => {
const header = document
.querySelector('track-list')
?.shadowRoot?.querySelector('page-header') as
| (Element & { sortOptions?: { id: string }[] })
| null;
return (header?.sortOptions ?? []).map((o) => o.id);
});
expect(ids).toContain('artistName');
expect(ids).toContain('album');
});
test('is the desktop list again above the breakpoint', async ({ app }) => {
await app.setViewportSize(PHONE);
await expect.poll(async () => (await rowGeometry(app))?.tracks).toBe(3);
await app.setViewportSize({ width: 1024, height: 800 });
// The same element, re-laid-out: this is one component with two
// column sets, not two components.
await expect.poll(async () => (await rowGeometry(app))?.headerRow).toBe(true);
await expect.poll(async () => (await rowGeometry(app))?.tracks).toBe(5);
});
});
+52 -2
View File
@@ -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
+138 -17
View File
@@ -30,6 +30,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';
@@ -90,6 +91,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 = {
@@ -175,19 +188,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 =>
@@ -374,9 +402,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;
@@ -543,6 +604,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;
@@ -673,6 +748,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;
@@ -1045,6 +1127,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;
}
@@ -1171,9 +1282,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) {
@@ -2017,7 +2136,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>`;
}
@@ -2065,7 +2184,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 })),
];
@@ -2115,7 +2234,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`
@@ -2149,7 +2268,7 @@ export class TrackList
</div>
`,
)}
</div>
</div>`}
${visibleTracks.length === 0
? html`<p class="no-results">
No tracks match your search.
@@ -2160,12 +2279,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);
});
});