# 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 `
` that **no control ever references** — `text-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 - [x] Description text is programmatically associated in the canonical composition; login-form BSN hint announced. - [x] `-error` id appended exactly when invalid; order stable. - [x] 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.