/** * The ARIA tail of `a11y.md`, pinned. * * Every assertion here is a finding that was reproduced in the running * app first. Three of them are the kind that no other tier can see: a * grid that sorts and never says so, a list whose loading/empty/failed * states are text nobody is watching, and `aria-selected` on * `role="button"`, which is not merely useless but *invalid* — the * attribute is dropped, so the state the whole ctrl/shift interaction * exists to produce was invisible. */ import { describe, expect, it, beforeEach } from 'vitest'; import type { LitElement } from 'lit'; import '@components/track-list/track-list'; import '@components/artists-view/artists-view'; import '@components/genres-view/genres-view'; import '@components/track-info/track-info'; import { emit, stub, flush, resetHarness } from '@test/support/harness'; import { Events } from '../../src/events'; import { fixture, shadow, shadowAll } from '@test/support/render'; import { searchStore } from '@store/search-store'; /** * The searchable columns' accessors read these fields and call * `.toLowerCase()` on the result, so a sparse fixture throws inside the * ranker rather than failing an assertion. Real tracks always carry * them; a fixture has to as well. */ const TRACKS = [ { FilePath: '/m/a.mp3', TrackName: 'Departure', ArtistName: 'Aurora Fields', Album: 'Glass Harbour', AlbumArtist: 'Aurora Fields', Composer: '', Genre: ['Ambient'], TrackLength: 4000, }, { FilePath: '/m/b.mp3', TrackName: 'Tideline', ArtistName: 'Aurora Fields', Album: 'Glass Harbour', AlbumArtist: 'Aurora Fields', Composer: '', Genre: ['Ambient'], TrackLength: 6000, }, ]; const ARTISTS = [ { ID: 1, Name: 'Alpha', AlbumCount: 2, TrackCount: 9 }, { ID: 2, Name: 'Beta', AlbumCount: 1, TrackCount: 4 }, ]; const GENRES = [ { name: 'Ambient', count: 12 }, { name: 'Doom', count: 3 }, ]; function sized(el: HTMLElement): void { el.style.display = 'block'; el.style.height = '600px'; el.style.width = '900px'; } async function settle(el: LitElement): Promise { await flush(); await el.updateComplete; await new Promise((r) => setTimeout(r, 80)); } describe('the track list says how it is sorted', () => { beforeEach(async () => { resetHarness(); searchStore.setTerm(''); stub('library.Library.GetTracks', TRACKS); stub('library.Library.GetAlbums', []); emit(Events.LibraryScanComplete); }); it('gives every column an aria-sort, defaulting to none', async () => { const el = await fixture('track-list'); sized(el); await settle(el); const sorts = shadowAll(el, '.header-cell[role="columnheader"]').map((h) => h.getAttribute('aria-sort'), ); expect(sorts.length, 'no sortable column headers').toBeGreaterThan(0); // The list opens in file order — `sortField` is null — so no column // claims to be the sort until one is chosen. expect(sorts.every((s) => s === 'none')).toBe(true); }); it('changes aria-sort when a column is activated from the keyboard', async () => { const el = await fixture('track-list'); sized(el); await settle(el); const title = shadowAll(el, '.header-cell[role="columnheader"]').find((h) => /track name/i.test(h.textContent ?? ''), ); expect(title, 'no Track Name column header').toBeTruthy(); // Reading the DOM synchronously after this would report the state // *before* Lit rendered, which is how a fix for nothing gets shipped. title!.dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, composed: true }), ); await settle(el); const after = shadowAll(el, '.header-cell[role="columnheader"]').find((h) => /track name/i.test(h.textContent ?? ''), ); expect(after?.getAttribute('aria-sort')).toBe('ascending'); }); }); describe('the track list has a voice for its own state', () => { beforeEach(() => { resetHarness(); searchStore.setTerm(''); stub('library.Library.GetAlbums', []); }); it('announces the result of a search that matches nothing', async () => { stub('library.Library.GetTracks', TRACKS); emit(Events.LibraryScanComplete); const el = await fixture('track-list'); sized(el); await settle(el); const live = shadow(el, '[role="status"][aria-live="polite"]'); // The region exists *before* it has anything to say — a region that // appears with its text already in it is not announced by most // screen readers. expect(live, 'no live region on the track list').toBeTruthy(); searchStore.setCurrentView('tracks'); searchStore.setTerm('nothing matches this'); await settle(el); expect(shadow(el, '[role="status"]')?.textContent?.trim()).toMatch( /No tracks match/i, ); }); }); describe('a selectable grid is a listbox, not a row of buttons', () => { beforeEach(() => { resetHarness(); searchStore.setTerm(''); stub('library.Library.GetArtists', ARTISTS); stub('library.Library.GetGenres', GENRES); stub('library.Library.GetTracks', []); stub('library.Library.GetAlbums', []); emit(Events.LibraryScanComplete); }); it.each([ ['artists-view', '.artist-card'], ['genres-view', '.genre-card'], ])('%s cards are options carrying aria-selected', async (tag, cardSelector) => { const el = await fixture(tag); sized(el); await settle(el); const card = shadowAll(el, cardSelector)[0]; expect(card, `no cards rendered in ${tag}`).toBeTruthy(); // role=button + aria-selected is invalid: the attribute is dropped, // and the selection is invisible to anything but a sighted user. expect(card!.getAttribute('role')).toBe('option'); expect(card!.hasAttribute('aria-selected')).toBe(true); const list = shadow(el, '[role="listbox"]'); expect(list, `${tag} has options with no listbox`).toBeTruthy(); expect(list!.getAttribute('aria-multiselectable')).toBe('true'); }); }); describe('a clipped value is readable somewhere', () => { beforeEach(async () => { resetHarness(); searchStore.setTerm(''); stub('library.Library.GetTracks', TRACKS); stub('library.Library.GetAlbums', []); emit(Events.LibraryScanComplete); }); it('gives every track-list cell the value it may be clipping', async () => { const el = await fixture('track-list'); sized(el); await settle(el); const titles = shadowAll(el, '.track-row [role="gridcell"].cell').map((c) => c.getAttribute('title'), ); // `a11y.24`: `text-overflow: ellipsis` in 40+ places, and the // highest-density lists were the ones without a `title`. The // attribute is on the cell rather than on what is inside it, // because the value may be a link or a highlighted match and a // tooltip is inherited by descendants either way. expect(titles.length).toBeGreaterThan(0); expect(titles).toContain('Departure'); expect(titles.every((t) => t !== null && t !== '')).toBe(true); }); it('gives track-info its own title and secondary line', async () => { const el = await fixture('track-info', { trackTitle: 'An Exhaustively Overlong Track Name', artist: 'Aurora Fields', }); await el.updateComplete; expect(shadow(el, '.title')?.getAttribute('title')) .toBe('An Exhaustively Overlong Track Name'); expect(shadow(el, '.secondary')?.getAttribute('title')).toBeTruthy(); }); }); describe('the playing row is more than a colour', () => { beforeEach(async () => { resetHarness(); searchStore.setTerm(''); stub('library.Library.GetTracks', TRACKS); stub('library.Library.GetAlbums', []); emit(Events.LibraryScanComplete); }); /** The row the player says it is on, once the list has settled. */ async function listWithPlayingTrack(): Promise { const el = await fixture('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); }); });