fix(a11y): mark the playing row with a shape, not only a colour
Build & publish Arch package / arch-package (push) Successful in 2m2s
CI / check (push) Successful in 2m18s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Canceled after 3m51s

a11y.22, WCAG 1.4.1: `.track-row.active` was a background tint and a
text colour, and the row markup carried no aria-current either — so a
colour-blind user could not find the playing row and AT had no signal
at all. The queue panel had aria-current from Phase 1 and the same
colour-only visual.

A triangle drawn in each row's own left padding by `::before`. It is a
shape that is present or absent, and it costs no layout: track-list's
grid columns are computed from the host width, so a marker in the flow
would move every cell on the playing row and nothing else.

Both directions are asserted in both tiers. A marker that renders on
every row satisfies "the playing row has one" for free, which is this
plan's oldest rule.

And one thing the reproduction found: a track started from the *list*
leaves the queue's currentIndex at -1, so the panel has no current row
in that flow at all. Pre-existing, and the reason this looked broken
the first time it was checked in the running app.
This commit is contained in:
2026-08-13 02:14:55 -04:00
parent 9d420cda0a
commit 254646da5e
5 changed files with 170 additions and 0 deletions
@@ -235,3 +235,87 @@ describe('a clipped value is readable somewhere', () => {
expect(shadow(el, '.secondary')?.getAttribute('title')).toBeTruthy();
});
});
describe('the playing row is more than a colour', () => {
beforeEach(async () => {
resetHarness();
searchStore.setTerm('');
stub('library.Library.GetAllTracks', TRACKS);
stub('library.Library.GetAllAlbums', []);
emit(Events.LibraryScanComplete);
});
/** The row the player says it is on, once the list has settled. */
async function listWithPlayingTrack(): Promise<LitElement> {
const el = await fixture<LitElement>('track-list');
sized(el);
await settle(el);
emit(Events.TrackChanged, {
fileName: 'a.mp3',
filePath: '/m/a.mp3',
trackLength: 4,
seekPosition: 0,
state: 'playing',
title: 'Departure',
artist: 'Aurora Fields',
album: 'Glass Harbour',
coverArt: '',
coverArtSmall: '',
coverArtMedium: '',
coverArtLarge: '',
trackChangeId: 1,
artistMbid: '',
releaseGroupMbid: '',
recordingMbid: '',
});
await settle(el);
return el;
}
it('marks exactly one row aria-current', async () => {
const el = await listWithPlayingTrack();
const current = shadowAll(el, '.track-row[aria-current="true"]');
// The positive case first: a guard that marks nothing passes
// "at most one" for free.
expect(current).toHaveLength(1);
expect(current[0]!.classList.contains('active')).toBe(true);
});
it('draws a marker that is a shape, not a hue', async () => {
const el = await listWithPlayingTrack();
const active = shadow(el, '.track-row.active');
const other = shadowAll(el, '.track-row:not(.active)')[0];
expect(active, 'no active row rendered').toBeTruthy();
// `::before` has no box unless it has content, so a width of zero
// on the inactive row is the assertion that the marker is *absent*
// there — which is the half that makes the present one mean
// something (a11y.22, WCAG 1.4.1).
const width = (el: Element) =>
parseFloat(getComputedStyle(el, '::before').borderLeftWidth) || 0;
expect(width(active!)).toBeGreaterThan(0);
expect(width(other!)).toBe(0);
});
it('does not move the row it marks', async () => {
const el = await listWithPlayingTrack();
const rows = shadowAll(el, '.track-row');
const lefts = rows.map(
(r) => r.querySelector('.cell')!.getBoundingClientRect().left,
);
// The marker lives in the row's own padding: the grid columns are
// computed from the host width, so anything in the flow would move
// every cell on the playing row and nothing else.
expect(new Set(lefts.map(Math.round)).size).toBe(1);
});
});
@@ -194,6 +194,21 @@ describe('a queue row says which track its controls act on', () => {
]);
});
it('marks the playing row with a shape, not only a colour', async () => {
const el = await panelWithQueue();
const rows = shadowAll(el, '.track-item');
const width = (r: Element) =>
parseFloat(getComputedStyle(r, '::before').borderLeftWidth) || 0;
// `a11y.22`, the same rule as track-list one panel over. Both
// directions: absent on the rows that are not playing is what
// makes the present one mean anything.
expect(rows[0]!.getAttribute('aria-current')).toBe('true');
expect(width(rows[0]!)).toBeGreaterThan(0);
expect(width(rows[1]!)).toBe(0);
});
it('gives the title and artist a tooltip, since the panel is resizable', async () => {
const el = await panelWithQueue();