Files
yellowjacket/frontend/test/components/settings-names.test.ts
T
logan f00d0c4655 fix(a11y): name every form control in Settings
Measured with Accessibility.getFullAXTree against the running app with
all seven sections expanded: 24 of 93 controls computed an empty name.
Every config-field select and toggle, and all eighteen track-list
column checkboxes, had a <label> sitting right beside them with nothing
associating the two. Now 0 of 93.

Not in the audit, and a11y.6 says why in its own line: it scanned every
<button>, and none of these is one. Same shape as the count that sent
Phase 1 looking for an unnamed sort control — the claim was answering a
narrower question than it reads as.

The fields use `for`/`id` rather than aria-label, for what it buys
beyond the name: the label text becomes a click target for the control.
A fixed id is safe only because each config-field is its own shadow
root.

Two more are named but identify nothing, which is a11y.32's complaint
one page over: three shortcut buttons announced themselves as "S", and
thirty-six column arrows as "Move up".
2026-08-13 01:52:08 -04:00

117 lines
3.8 KiB
TypeScript

/**
* Every form control in Settings had a visible label and no name.
*
* Not in the audit, and `a11y.6` says why in its own text: it scanned
* every `<button>`, and a `<select>` is not one. Measured with
* `Accessibility.getFullAXTree` against the running app with all seven
* sections expanded — **24 of 93 controls unnamed**: every
* `config-field` select and toggle, and all eighteen track-list column
* checkboxes. In each case a `<label>` sat right beside the control
* with nothing associating the two.
*
* Two of the fixes here are about a name that exists and does not
* identify anything, which is `a11y.32`'s complaint one page over:
* three shortcut buttons announced themselves as "S", and thirty-six
* column arrows as "Move up" or "Move down".
*/
import { describe, expect, it } from 'vitest';
import '@components/config-page/config-field';
import '@components/config-page/shortcut-capture';
import { fixture, shadow } from '@test/support/render';
/** What the `<label>` in this shadow root actually points at. */
function labelledControl(host: Element): Element | null {
const forId = shadow(host, 'label[for]')?.getAttribute('for');
return forId ? shadow(host, `#${CSS.escape(forId)}`) : null;
}
describe('a config field names its control', () => {
it('associates the label with a select', async () => {
const el = await fixture('config-field', {
schema: {
key: 'theme.shade',
label: 'Shade',
type: 'select',
options: [{ value: 'dark', label: 'Dark' }],
},
value: 'dark',
});
expect(labelledControl(el)?.tagName).toBe('SELECT');
});
it('associates the label with a toggle, which is a second label deep', async () => {
const el = await fixture('config-field', {
schema: { key: 'x', label: 'Scan on startup', type: 'toggle' },
value: true,
});
// The checkbox is wrapped in a *styling* label carrying only a
// span, which contributes no text and so named nothing.
const control = labelledControl(el) as HTMLInputElement | null;
expect(control?.type).toBe('checkbox');
});
it.each(['text', 'number', 'color', 'directory'] as const)(
'associates the label with a %s field',
async (type) => {
const el = await fixture('config-field', {
schema: { key: 'k', label: 'Music folder', type },
value: '',
});
expect(labelledControl(el)).toBeTruthy();
},
);
it('says which field a Browse button browses for', async () => {
const el = await fixture('config-field', {
schema: { key: 'k', label: 'Music folder', type: 'directory' },
value: '',
});
expect(shadow(el, 'button')?.getAttribute('aria-label'))
.toBe('Browse for Music folder');
});
});
describe('a shortcut button says what it binds', () => {
it('names the action, and keeps the key as the value', async () => {
const el = await fixture('shortcut-capture', {
action: 'player.playPause',
label: 'Play / Pause',
currentKey: 'Space',
});
expect(shadow(el, 'button')?.getAttribute('aria-label'))
.toBe('Play / Pause shortcut: Space');
expect(shadow(el, 'button')?.textContent?.trim()).toBe('Space');
});
it('says so when there is no key rather than announcing nothing', async () => {
const el = await fixture('shortcut-capture', {
action: 'tracklist.delete',
label: 'Delete Track',
currentKey: '',
});
expect(shadow(el, 'button')?.getAttribute('aria-label'))
.toBe('Delete Track shortcut: not set');
});
it('names the action in the reset button too', async () => {
const el = await fixture('shortcut-capture', {
action: 'player.next',
label: 'Next Track',
currentKey: 'X',
defaultKey: 'N',
});
expect(shadow(el, '.reset-btn')?.getAttribute('aria-label'))
.toBe('Reset Next Track to N');
});
});