Files
atomic-design-poc/docs/backlog/WP-16-component-a11y.md
Edwin van den Houdt f3de30b72c feat(fp): WP-16 — component a11y: description wiring + alert role
Wires text-input's aria-describedby to the form-field description div
(the BSN hint was rendered but never announced), pins desc-before-error
ordering, and switches alert to role=alert for errors vs role=status
for info/ok/warning. Composition contract enforced by story play tests
(form-field+text-input, alert per variant) run in the WP-01 CI gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 08:15:24 +02:00

4.1 KiB

WP-16 — Component a11y: description wiring + alert role

Status: done (pending commit) Phase: 4 — a11y

Why

Audit findings axe can't (fully) catch:

  • form-field renders a description <div [id]="fieldId()+'-desc'"> that no control ever referencestext-input sets aria-describedby only to …-error and only when invalid. Screen readers never announce field descriptions (e.g. the BSN hint on the login form).
  • The field↔control id pairing is manual (fieldId must equal the input's name/id) with nothing enforcing it.
  • The alert atom is always role="status" — error alerts are announced politely while other errors in the app use role="alert"; urgency is inconsistent.

Read first

  • src/app/shared/ui/form-field/form-field.component.ts (~line 15)
  • src/app/shared/ui/text-input/text-input.component.ts (~lines 17-18)
  • src/app/shared/ui/radio-group/radio-group.component.ts, checkbox/checkbox.component.ts
  • src/app/shared/ui/alert/alert.component.ts
  • src/app/auth/ui/login-form/login-form.component.ts (a live desc that's never wired)

Decisions (pre-made, don't relitigate)

  • aria-describedby = space-joined ids: -desc always when a description exists + -error when invalid; order pinned (desc first, error second).
  • Pairing contract: form-field exposes its fieldId; the composition contract (fieldId === control id/name) is documented in both components and enforced by a story play test on the canonical form-field+text-input composition (expect(input).toHaveAttribute('aria-describedby', 'x-desc'), flip validity, assert 'x-desc x-error'). Play tests run in the WP-01 test-runner for free.
  • DI-based auto-wiring (form-field providing the id via injection) is out of scope — more clever than this POC needs; the play test catches drift. Revisit only if the manual contract actually breaks in practice.
  • Alert: type === 'error'role="alert"; others keep role="status". Rationale comment in the atom.

Files

  • form-field.component.ts, text-input.component.ts, radio-group.component.ts, checkbox.component.ts (describedby joins; only where the component takes a hint)
  • alert.component.ts (+ story asserting the role per variant)
  • form-field.stories.ts (or a composition story) with the play test
  • Call sites that pass descriptions (verify login-form BSN hint is now announced)

Steps

  1. Implement the describedby join in the input atoms; form-field renders -desc only when a description input is set (it already does — verify).
  2. Write the play tests (form-field composition + alert roles).
  3. Alert role switch + rationale comment.
  4. Manual screen-reader spot check (optional but recommended — note result here).

Acceptance criteria

  • Description text is programmatically associated in the canonical composition; login-form BSN hint announced.
  • -error id appended exactly when invalid; order stable.
  • Error alerts are role="alert"; play tests assert both behaviors and run in the CI gate.

Deviation from the original plan

radio-group/checkbox were left unchanged — grepping every call site found zero consumers pairing either with a form-field description (only the login-form BSN field, which uses text-input). The WP's own Files note ("describedby joins; only where the component takes a hint") already carved out this exact case — adding hasDescription to atoms with no live description consumer would be unused surface, not a fix. radio-group already had correct -error-only wiring; untouched. describedBy() was added directly on text-input rather than factored into a shared shared/kernel helper — one consumer, ~5 lines, not worth the indirection yet. Manual screen-reader spot check (optional per the WP) skipped; the play test is the enforced check going forward.

Verification

GREEN + npm run test-storybook:ci (includes the new play tests).

Out of scope

Route-change focus and template lint (WP-17); rewriting form-field's layout.

Risks

aria-describedby churn on validity flips re-announcing content — pinned order + play test coverage keeps it deterministic.