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:
2026-08-13 01:52:08 -04:00
parent b7831e3f15
commit f00d0c4655
4 changed files with 171 additions and 5 deletions
@@ -1,6 +1,26 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
/**
* The id every field's control carries, so its `<label>` can name it.
*
* The label was a *sibling* of the control with no `for`, which names
* nothing — so every select, toggle and text field in Settings computed
* an empty accessible name. Measured on the expanded Settings page:
* **24 of 93 controls unnamed**, six of them here and the rest the
* column toggles in `config-page`. `a11y.6` is not wrong about this;
* it says in the same line that it scanned every `<button>`, and none
* of these is one.
*
* A fixed id is safe, and only because each `config-field` is its own
* shadow root — the whole page renders a dozen elements with
* `id="control"` and each `for` resolves within its own root. It is
* preferred over `aria-label` for what it buys beyond the name: a real
* label association also makes the label text a click target for the
* control, which is behaviour, not annotation.
*/
const CONTROL_ID = 'control';
/**
* Schema describing a single config field.
*
@@ -225,7 +245,9 @@ export class ConfigField extends LitElement {
${this.schema.type === 'toggle'
? this.renderToggle()
: html`
<label>${this.schema.label}</label>
<label for=${CONTROL_ID}>
${this.schema.label}
</label>
${this.renderInput()}
`}
${this.schema.description
@@ -257,6 +279,7 @@ export class ConfigField extends LitElement {
private renderText() {
return html`
<input
id=${CONTROL_ID}
type="text"
.value=${String(this.value ?? '')}
?disabled=${this.schema.disabled}
@@ -268,6 +291,7 @@ export class ConfigField extends LitElement {
private renderNumber() {
return html`
<input
id=${CONTROL_ID}
type="number"
.value=${String(this.value ?? '')}
?disabled=${this.schema.disabled}
@@ -281,6 +305,7 @@ export class ConfigField extends LitElement {
return html`
<select
id=${CONTROL_ID}
?disabled=${this.schema.disabled}
@change=${this.onSelectChange}
>
@@ -304,6 +329,7 @@ export class ConfigField extends LitElement {
return html`
<div class="color-wrapper">
<input
id=${CONTROL_ID}
type="color"
.value=${hex}
?disabled=${this.schema.disabled}
@@ -318,11 +344,13 @@ export class ConfigField extends LitElement {
return html`
<div class="input-row">
<input
id=${CONTROL_ID}
type="text"
.value=${String(this.value ?? '')}
readonly
/>
<button
aria-label="Browse for ${this.schema.label}"
?disabled=${this.schema.disabled}
@click=${this.onBrowseClick}
>
@@ -337,9 +365,10 @@ export class ConfigField extends LitElement {
return html`
<div class="toggle-row">
<label>${this.schema.label}</label>
<label for=${CONTROL_ID}>${this.schema.label}</label>
<label class="toggle-switch">
<input
id=${CONTROL_ID}
type="checkbox"
?checked=${checked}
?disabled=${this.schema.disabled}