fix(a11y): let a clipped value be read, and name a row's own buttons
Build & publish Arch package / arch-package (push) Successful in 1m55s
CI / check (push) Successful in 2m33s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m23s

a11y.24: `text-overflow: ellipsis` in 40+ places, and the four
highest-density lists were the ones with no `title` — the queue panel
(whose width is user-resizable down to MIN_WIDTH), track-info, every
track-list cell, and the playlist sidebar.

In track-list the attribute is on the *cell*, not on what is inside it:
the value may be a link, a highlighted search match or plain text, and
a tooltip is inherited by descendants either way. One binding rather
than three, and the same value the accessor already computed.

a11y.32: every queue row's remove button was named "Remove from queue",
so a list whose entire purpose is which track is where had four
identically named controls.
This commit is contained in:
2026-08-13 01:55:17 -04:00
parent f00d0c4655
commit 2b41c27616
6 changed files with 99 additions and 7 deletions
@@ -15,6 +15,7 @@ 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';
@@ -191,3 +192,46 @@ describe('a selectable grid is a listbox, not a row of buttons', () => {
expect(list!.getAttribute('aria-multiselectable')).toBe('true');
});
});
describe('a clipped value is readable somewhere', () => {
beforeEach(async () => {
resetHarness();
searchStore.setTerm('');
stub('library.Library.GetAllTracks', TRACKS);
stub('library.Library.GetAllAlbums', []);
emit(Events.LibraryScanComplete);
});
it('gives every track-list cell the value it may be clipping', async () => {
const el = await fixture<LitElement>('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<LitElement>('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();
});
});
+34 -1
View File
@@ -19,7 +19,7 @@ import { describe, expect, it, beforeEach } from 'vitest';
import '@components/queue-panel/queue-panel';
import type { QueuePanel } from '@components/queue-panel/queue-panel';
import { Events } from '../../src/events';
import { emit, calls, flush, lastArgs } from '@test/support/harness';
import { emit, calls, flush, lastArgs, resetHarness } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
import type { QueueTrack } from '@store/queue-store';
@@ -170,3 +170,36 @@ describe('<queue-panel> keyboard reorder', () => {
expect(calls().some((c) => c.path.includes('Move'))).toBe(false);
});
});
describe('a queue row says which track its controls act on', () => {
beforeEach(() => {
resetHarness();
});
it('names each remove button after its own track', async () => {
const el = await panelWithQueue();
const labels = shadowAll(el, '.remove-button').map((b) =>
b.getAttribute('aria-label'),
);
// `a11y.32`: `title="Remove from queue"` on every row is a name
// that never identifies which track — four identical buttons in a
// list whose whole purpose is the order.
expect(labels.slice(0, 4)).toEqual([
'Remove First from queue',
'Remove Second from queue',
'Remove Third from queue',
'Remove Fourth from queue',
]);
});
it('gives the title and artist a tooltip, since the panel is resizable', async () => {
const el = await panelWithQueue();
// `a11y.24` calls this one acute: MIN_WIDTH is narrow enough that
// both lines clip routinely, and nothing else can show the value.
expect(shadow(el, '.track-title')?.getAttribute('title')).toBe('First');
expect(shadow(el, '.track-artist')?.getAttribute('title')).toBe('Artist');
});
});