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".
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user