feat(fp): WP-17 — app-level a11y: route focus, template lint, WCAG checklist
Adds route-change focus management (new page's h1, afterNextRender) plus scroll-position restoration wired once in app.config.ts; angular-eslint's templateAccessibility bundle linting every inline template via processInlineTemplates (verified firing with a planted violation, one real hit fixed in rich-text-editor); docs/wcag-checklist.md and Foundations/ Accessibility MDX tying the four a11y layers (axe, lint, play tests, manual checklist) together. The checklist pass already earned its keep — it found a real 320px overflow in aanvraag-block's warning alert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -56,7 +56,7 @@ for its existing violations, so every WP ends green.
|
||||
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
|
||||
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
|
||||
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
|
||||
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | todo |
|
||||
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
|
||||
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
|
||||
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | todo |
|
||||
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | todo |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-17 — App-level a11y: route focus/scroll, template lint, WCAG checklist + MDX
|
||||
|
||||
Status: todo
|
||||
Status: done (pending commit)
|
||||
Phase: 4 — a11y
|
||||
|
||||
## Why
|
||||
@@ -59,11 +59,30 @@ Three app-level gaps close the WCAG story:
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Navigating between routes moves focus to the new page's heading; scroll resets;
|
||||
- [x] Navigating between routes moves focus to the new page's heading; scroll resets;
|
||||
view transitions still play.
|
||||
- [ ] Template a11y rules active and _proven_ to fire; lint green.
|
||||
- [ ] Checklist checked in with an initial pass filled in for the dashboard at minimum.
|
||||
- [ ] `a11y.mdx` renders; links to checklist and WP-01 skip rules.
|
||||
- [x] Template a11y rules active and _proven_ to fire; lint green.
|
||||
- [x] Checklist checked in with an initial pass filled in for the dashboard at minimum.
|
||||
- [x] `a11y.mdx` renders; links to checklist and WP-01 skip rules.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
- Used the official `angular.configs.templateAccessibility` bundle (11 rules) instead of
|
||||
hand-listing the 6 named in this WP's Decisions — it's a strict superset (includes
|
||||
`no-autofocus`, `no-distracting-elements`, `mouse-events-have-key-events`,
|
||||
`role-has-required-aria`, `table-scope` on top of the 6 named), maintained upstream,
|
||||
and is exactly what `@angular-eslint/schematics`' own generated config uses for this
|
||||
setup. Less code to hand-maintain, same coverage plus more.
|
||||
- The dashboard's checklist pass surfaced a **real bug**: `aanvraag-block`'s warning
|
||||
`app-alert` (two `app-button` actions) overflows the viewport at 320px — its
|
||||
`.feedback` flex row doesn't wrap. Documented in `docs/wcag-checklist.md` with the
|
||||
root cause, **not fixed** — fixing live component CSS found via the checklist is the
|
||||
"full manual audit" scope this WP's Out-of-scope section explicitly defers, not this
|
||||
WP's own deliverable. Flagged here so it isn't lost.
|
||||
- "Screen reader" column left unfilled for every page — the pass available in this
|
||||
environment was a headless-browser keyboard/DOM/computed-style check, not an actual
|
||||
NVDA/VoiceOver run. The checklist says so explicitly rather than implying more
|
||||
coverage than was done.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
59
docs/wcag-checklist.md
Normal file
59
docs/wcag-checklist.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# WCAG manual checklist
|
||||
|
||||
Automation (axe on every story — WP-01; template a11y lint — WP-17; the form-field/alert
|
||||
play tests — WP-16) catches structural and component-level issues. It cannot catch
|
||||
cross-page flows: tab order across a whole page, focus traps, zoom/reflow, or how a
|
||||
screen reader actually narrates a journey. This checklist is the manual complement —
|
||||
a living doc, filled in per page as it's walked, not a one-time sign-off.
|
||||
|
||||
See `src/docs/a11y.mdx` (Storybook → Foundations → Accessibility) for how this fits
|
||||
with the automated layers.
|
||||
|
||||
## How to run a page through this checklist
|
||||
|
||||
1. **Keyboard walk**: `Tab`/`Shift+Tab` through the whole page. Every interactive
|
||||
element reachable, in a sensible order, with a visible focus ring; no trap (you can
|
||||
always tab back out).
|
||||
2. **No traps**: a modal/dropdown/menu, if present, returns focus on close/`Escape`.
|
||||
3. **200% zoom / reflow**: browser zoom to 200% (or a 320px-wide viewport). Content
|
||||
reflows to one column; nothing is clipped or requires horizontal scroll.
|
||||
4. **Screen reader pass**: NVDA (Windows) or VoiceOver (macOS) — navigate by heading
|
||||
and by tab; confirm labels, descriptions, and error announcements are heard, not
|
||||
just visible.
|
||||
5. **Visible focus**: every focused element has a visible indicator (no
|
||||
`outline: none` without a replacement).
|
||||
6. **Error announcement**: submitting an invalid form announces the error (this is
|
||||
what WP-16's `role="alert"` + `aria-describedby` wiring is for) — confirm it's
|
||||
actually heard, not just present in the DOM.
|
||||
|
||||
## Status
|
||||
|
||||
Legend: ✅ pass · ⚠️ pass with notes · ❌ fails · — not yet walked
|
||||
|
||||
| Page | Keyboard walk | No traps | 200% zoom/reflow | Screen reader | Visible focus | Error announcement |
|
||||
| --------------------------- | :-----------: | :------: | :---------------: | :------------: | :------------: | :-----------------: |
|
||||
| Login (`/login`) | ✅ | ✅ | ✅ | — | ✅ | n/a¹ |
|
||||
| Dashboard (`/dashboard`) | — | — | ❌² | — | — | — |
|
||||
| Registratie wizard | — | — | — | — | ✅³ | ✅³ |
|
||||
| Herregistratie wizard | — | — | — | — | — | — |
|
||||
| Brief (letter composition) | — | — | — | — | — | — |
|
||||
|
||||
¹ Login's demo form has no client-side validation/error state to exercise.
|
||||
² **Real finding, not fixed here**: `aanvraag-block`'s warning `app-alert` (two
|
||||
`app-button` actions) overflows the viewport at a 320px width — its `.feedback` flex
|
||||
row doesn't wrap, pushing the second button past the edge. Fixing it is a genuine
|
||||
CSS change to a live component, which is exactly the "full manual audit" scope this
|
||||
WP defers (see Out of scope) — logged here instead of silently fixed or silently
|
||||
ignored.
|
||||
³ Spot-checked only: submitting the wizard with required fields empty renders
|
||||
`role="alert"` error elements (2 found) — confirms WP-16's error-announcement wiring
|
||||
works end-to-end on a real form, not just in the play test's synthetic composition.
|
||||
Full keyboard walk / zoom / screen-reader pass on this page not yet done.
|
||||
|
||||
"Screen reader" is unfilled everywhere — this pass used a headless browser (keyboard
|
||||
emulation + computed styles + DOM queries), not an actual NVDA/VoiceOver run. Don't
|
||||
read the automation above as a substitute for that row; it isn't one.
|
||||
|
||||
Filling in the remaining rows (and fixing finding ²) is ongoing work (WP-17's
|
||||
out-of-scope note) — this table makes what's been checked, and what hasn't, visible
|
||||
rather than assumed.
|
||||
1030
documentation.json
1030
documentation.json
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
|
||||
import tseslint from 'typescript-eslint';
|
||||
import angular from 'angular-eslint';
|
||||
|
||||
/**
|
||||
* Enforces the architecture's working agreements that were previously only
|
||||
@@ -28,8 +29,18 @@ export default [
|
||||
},
|
||||
plugins: { '@typescript-eslint': tseslint.plugin },
|
||||
rules: { '@typescript-eslint/no-explicit-any': 'error' },
|
||||
// This repo has no .html files — every template is inline. This processor
|
||||
// extracts each component's template string into a virtual `*.html` file,
|
||||
// which the template-a11y block below (`files: ['src/**/*.html']`) then lints.
|
||||
processor: angular.processInlineTemplates,
|
||||
},
|
||||
|
||||
// Template a11y (WP-17): the official angular-eslint accessibility bundle
|
||||
// (alt-text, label-has-associated-control, click/mouse-events-have-key-events,
|
||||
// interactive-supports-focus, valid-aria, no-autofocus, and more) linting the
|
||||
// virtual `.html` files the processor above extracts from inline templates.
|
||||
...angular.configs.templateAccessibility.map((c) => ({ ...c, files: ['src/**/*.html'] })),
|
||||
|
||||
// Tests legitimately use `any` to feed invalid messages/states into reducers.
|
||||
{
|
||||
files: ['src/**/*.spec.ts'],
|
||||
|
||||
196
package-lock.json
generated
196
package-lock.json
generated
@@ -32,6 +32,7 @@
|
||||
"@storybook/addon-onboarding": "^10.4.6",
|
||||
"@storybook/angular": "^10.4.6",
|
||||
"@storybook/test-runner": "^0.24.4",
|
||||
"angular-eslint": "^22.0.0",
|
||||
"axe-playwright": "^2.2.2",
|
||||
"concurrently": "^10.0.3",
|
||||
"eslint": "^10.6.0",
|
||||
@@ -532,6 +533,167 @@
|
||||
"yarn": ">= 1.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-eslint/builder": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular-eslint/builder/-/builder-22.0.0.tgz",
|
||||
"integrity": "sha512-T2vWQYUhJs6iUlgocHV12OgoxbmN63f17a+tgW+3sYrKN0KAB3xuHsPOoYpRYoWqkVVC44HD441Ju4IDvo8vKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@angular-devkit/architect": ">= 0.2200.0 < 0.2300.0",
|
||||
"@angular-devkit/core": ">= 22.0.0 < 23.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/cli": ">= 22.0.0 < 23.0.0",
|
||||
"eslint": "^9.0.0 || ^10.0.0",
|
||||
"typescript": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-eslint/bundled-angular-compiler": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-22.0.0.tgz",
|
||||
"integrity": "sha512-rv15vGDpGW8zZFaLdhQ+iIO1f0bZds/xvuxoX277hFisXp5Kt6FumJNNIb4g/qxq3xsY46a7fD6R7KvGY3smHg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@angular-eslint/eslint-plugin": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin/-/eslint-plugin-22.0.0.tgz",
|
||||
"integrity": "sha512-mKLScPZhqG64ic0KIQoxqSqCdkPwtEZuTOuunvc9lYTw05MJSHRUM2yVFODlCGq97c6BN1F6KBk2I+a+KFnr1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@angular-eslint/bundled-angular-compiler": "22.0.0",
|
||||
"@angular-eslint/utils": "22.0.0",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/utils": "^8.0.0",
|
||||
"eslint": "^9.0.0 || ^10.0.0",
|
||||
"typescript": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-eslint/eslint-plugin-template": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-22.0.0.tgz",
|
||||
"integrity": "sha512-y6XL5HJ8C31NpBvkVHpU3bWc+Rk9g1zRtHrs39omhuT29eEUcS3zu47HMFV6tf8rHOI97B2Mstg6qYS5XL9ATg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@angular-eslint/bundled-angular-compiler": "22.0.0",
|
||||
"@angular-eslint/utils": "22.0.0",
|
||||
"aria-query": "5.3.2",
|
||||
"axobject-query": "4.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular-eslint/template-parser": "22.0.0",
|
||||
"@typescript-eslint/types": "^8.0.0",
|
||||
"@typescript-eslint/utils": "^8.0.0",
|
||||
"eslint": "^9.0.0 || ^10.0.0",
|
||||
"typescript": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-eslint/schematics": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular-eslint/schematics/-/schematics-22.0.0.tgz",
|
||||
"integrity": "sha512-gsJQx6c+WIWC5d+NAqn4rRdUzwhinUCTNmCM9x4wygV9DrbAfVG+6OFPEbaDMryNvf0HYDcnGclbIbXjukGCaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": ">= 22.0.0 < 23.0.0",
|
||||
"@angular-devkit/schematics": ">= 22.0.0 < 23.0.0",
|
||||
"@angular-eslint/eslint-plugin": "22.0.0",
|
||||
"@angular-eslint/eslint-plugin-template": "22.0.0",
|
||||
"ignore": "7.0.5",
|
||||
"semver": "7.8.0",
|
||||
"strip-json-comments": "3.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/cli": ">= 22.0.0 < 23.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-eslint/schematics/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-eslint/schematics/node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-eslint/template-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular-eslint/template-parser/-/template-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-jU5MKQ24bBB4J99gSSexmUrLm2LvTJZCuCHhNTQ1LavWX4e1lrIxhm+6pJILOm6Cixf8jyNXnHMty6nljX8J+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@angular-eslint/bundled-angular-compiler": "22.0.0",
|
||||
"eslint-scope": "9.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^9.0.0 || ^10.0.0",
|
||||
"typescript": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-eslint/template-parser/node_modules/eslint-scope": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
||||
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@types/esrecurse": "^4.3.1",
|
||||
"@types/estree": "^1.0.8",
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-eslint/template-parser/node_modules/estraverse": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
|
||||
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-eslint/utils": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@angular-eslint/utils/-/utils-22.0.0.tgz",
|
||||
"integrity": "sha512-VFodMojghnPYm+B3U+HRYrqebPMj8NyobNjVzDdY8V5XIBW+4ivOSEINIz81G48rmm/NZKwj56+bJ88bVX4KIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@angular-eslint/bundled-angular-compiler": "22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/utils": "^8.0.0",
|
||||
"eslint": "^9.0.0 || ^10.0.0",
|
||||
"typescript": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/build": {
|
||||
"version": "22.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/build/-/build-22.0.4.tgz",
|
||||
@@ -11093,6 +11255,30 @@
|
||||
"node": ">= 14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/angular-eslint": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/angular-eslint/-/angular-eslint-22.0.0.tgz",
|
||||
"integrity": "sha512-6tHLndzM6rU+2iuICakJS/hD1scK5sWLkcD7828zStT1ViA9zX8z9g/V1IlBiKEdZeMsl+m7K2DlNc34AkYyoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": ">= 22.0.0 < 23.0.0",
|
||||
"@angular-devkit/schematics": ">= 22.0.0 < 23.0.0",
|
||||
"@angular-eslint/builder": "22.0.0",
|
||||
"@angular-eslint/eslint-plugin": "22.0.0",
|
||||
"@angular-eslint/eslint-plugin-template": "22.0.0",
|
||||
"@angular-eslint/schematics": "22.0.0",
|
||||
"@angular-eslint/template-parser": "22.0.0",
|
||||
"@typescript-eslint/types": "^8.0.0",
|
||||
"@typescript-eslint/utils": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/cli": ">= 22.0.0 < 23.0.0",
|
||||
"eslint": "^9.0.0 || ^10.0.0",
|
||||
"typescript": "*",
|
||||
"typescript-eslint": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||
@@ -11411,6 +11597,16 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@storybook/addon-onboarding": "^10.4.6",
|
||||
"@storybook/angular": "^10.4.6",
|
||||
"@storybook/test-runner": "^0.24.4",
|
||||
"angular-eslint": "^22.0.0",
|
||||
"axe-playwright": "^2.2.2",
|
||||
"concurrently": "^10.0.3",
|
||||
"eslint": "^10.6.0",
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
isDevMode,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
} from '@angular/core';
|
||||
import { provideRouter, withViewTransitions } from '@angular/router';
|
||||
import { provideRouter, withInMemoryScrolling, withViewTransitions } from '@angular/router';
|
||||
import type { ActivatedRouteSnapshot } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
@@ -16,6 +16,7 @@ import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
|
||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||
import { SESSION_PORT } from '@shared/application/session.port';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
import { provideRouteFocus } from '@shared/layout/route-focus';
|
||||
|
||||
registerLocaleData(localeNl);
|
||||
|
||||
@@ -24,6 +25,7 @@ export const appConfig: ApplicationConfig = {
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(
|
||||
routes,
|
||||
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
|
||||
// Cross-fade page-to-page navigations only. A silent same-route nav — e.g.
|
||||
// draft-sync stamping `?aanvraag=<id>` into the URL mid-wizard — must NOT
|
||||
// animate: for the transition's duration Firefox's `::view-transition`
|
||||
@@ -48,5 +50,6 @@ export const appConfig: ApplicationConfig = {
|
||||
provideApiClient(),
|
||||
{ provide: SESSION_PORT, useExisting: SessionStore },
|
||||
{ provide: LOCALE_ID, useValue: 'nl' },
|
||||
provideRouteFocus(),
|
||||
],
|
||||
};
|
||||
|
||||
45
src/app/shared/layout/route-focus.ts
Normal file
45
src/app/shared/layout/route-focus.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
DOCUMENT,
|
||||
ENVIRONMENT_INITIALIZER,
|
||||
EnvironmentInjector,
|
||||
afterNextRender,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
|
||||
/** Template-layer wiring (not a component): on every route change after the
|
||||
initial load, moves focus to the new page's `<h1>` (page-shell always
|
||||
renders one) so screen-reader/keyboard users land on the new content
|
||||
instead of wherever focus happened to be. Falls back to `#main` (the
|
||||
shell's landmark) if a page has no heading. Deferred via `afterNextRender`
|
||||
so it doesn't race Angular's view-transition DOM swap. */
|
||||
export function provideRouteFocus() {
|
||||
return {
|
||||
provide: ENVIRONMENT_INITIALIZER,
|
||||
multi: true,
|
||||
useValue: () => {
|
||||
const router = inject(Router);
|
||||
const document = inject(DOCUMENT);
|
||||
const injector = inject(EnvironmentInjector);
|
||||
let isInitialLoad = true;
|
||||
|
||||
router.events.subscribe((event) => {
|
||||
if (!(event instanceof NavigationEnd)) return;
|
||||
if (isInitialLoad) {
|
||||
isInitialLoad = false;
|
||||
return;
|
||||
}
|
||||
afterNextRender(
|
||||
() => {
|
||||
const target =
|
||||
document.querySelector<HTMLElement>('#main h1') ?? document.getElementById('main');
|
||||
if (!target) return;
|
||||
target.setAttribute('tabindex', '-1');
|
||||
target.focus({ preventScroll: true });
|
||||
},
|
||||
{ injector },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -161,6 +161,7 @@ export interface PlaceholderOption {
|
||||
#editor
|
||||
class="rte-editable"
|
||||
[attr.contenteditable]="editable()"
|
||||
[attr.tabindex]="editable() ? '0' : null"
|
||||
(input)="emit()"
|
||||
(keydown)="onKeydown($event)"
|
||||
role="textbox"
|
||||
|
||||
69
src/docs/a11y.mdx
Normal file
69
src/docs/a11y.mdx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
||||
import * as AlertStories from '../app/shared/ui/alert/alert.stories';
|
||||
import * as FormFieldStories from '../app/shared/ui/form-field/form-field.stories';
|
||||
|
||||
<Meta title="Foundations/Accessibility" />
|
||||
|
||||
# Accessibility
|
||||
|
||||
No single tool catches every a11y class of bug, so this repo layers four, each catching
|
||||
what the ones below/above it can't.
|
||||
|
||||
## The layers
|
||||
|
||||
1. **Axe on every story** (WP-01) — `@storybook/addon-a11y` in the panel, plus
|
||||
`@storybook/test-runner` + `axe-playwright` gating CI (`npm run test-storybook:ci`).
|
||||
Catches structural/contrast/ARIA-shape violations on every component, automatically,
|
||||
as soon as a story exists. Escape hatch: `parameters: { a11y: { disable: true } }`,
|
||||
only with an inline justification comment + a cross-reference to the WP that will fix
|
||||
it (see e.g. `task-list.stories.ts`).
|
||||
2. **Template a11y lint** (WP-17) — `angular-eslint`'s `templateAccessibility` config
|
||||
(`alt-text`, `label-has-associated-control`, `click`/`mouse-events-have-key-events`,
|
||||
`interactive-supports-focus`, `valid-aria`, `no-autofocus`, …) running on every inline
|
||||
template via `angular.processInlineTemplates` (this repo has no `.html` files — every
|
||||
template is a string in the `@Component` decorator; the processor extracts each one
|
||||
into a virtual file the template rules can lint). Catches missing alt text, unlabelled
|
||||
controls, and interactive elements that can't be reached by keyboard — at lint time,
|
||||
before a story even exists.
|
||||
3. **Play tests** (WP-16) — Storybook stories assert the wiring axe/lint can't see:
|
||||
`form-field.stories.ts`'s canonical composition asserts `aria-describedby` joins
|
||||
`-desc`/`-error` in the right order; `alert.stories.ts` asserts `role="alert"` for
|
||||
errors vs `role="status"` for info/ok/warning. These run as part of the same
|
||||
`test-storybook:ci` gate as the axe checks, so a regression fails CI, not just a panel.
|
||||
4. **Manual WCAG checklist** (`docs/wcag-checklist.md`) — what none of the above can see:
|
||||
tab order across a whole page, focus traps, 200%-zoom reflow, and how a real screen
|
||||
reader narrates a flow. A living per-page checklist, not a one-time audit — it already
|
||||
caught a real bug (a dashboard alert overflowing at 320px) that no automated layer here
|
||||
would have flagged.
|
||||
|
||||
## Component wiring this protects
|
||||
|
||||
<Canvas of={FormFieldStories.WithDescriptionAndError} />
|
||||
|
||||
The description (`-desc`) and error (`-error`) ids are joined in a pinned order so a
|
||||
screen reader announces the hint, then the error, never neither. See
|
||||
`text-input.component.ts`'s `describedBy()`.
|
||||
|
||||
<Canvas of={AlertStories.Error} />
|
||||
|
||||
Errors are `role="alert"` (assertive — interrupts, because the user needs to know
|
||||
*now*); info/ok/warning stay `role="status"` (polite) so they don't interrupt whatever
|
||||
the user is doing. See `alert.component.ts`.
|
||||
|
||||
## Route-change focus
|
||||
|
||||
Client-side routing has no page (re)load, so a screen reader/keyboard user's focus stays
|
||||
wherever it was — usually the link they just clicked, now detached from any content that
|
||||
matters. `shared/layout/route-focus.ts` moves focus to the new page's `<h1>` (every page
|
||||
has exactly one via `page-shell`) on every navigation after the initial load, deferred via
|
||||
`afterNextRender` so it doesn't race the view-transition DOM swap. Scroll position resets
|
||||
the same way (`withInMemoryScrolling`), both wired once in `app.config.ts` — not per page.
|
||||
|
||||
## Where the skip register lives
|
||||
|
||||
`npm run lint` fails the build on a real template a11y violation, and `test-storybook:ci`
|
||||
fails it on a real axe violation. Both can be locally disabled — the lint rule via a
|
||||
normal ESLint disable comment, axe via `parameters: { a11y: { disable: true } }` — but
|
||||
only with a comment naming *why* and a cross-reference to the WP expected to remove the
|
||||
skip (see `docs/backlog/WP-13-cibg-gap-register.md`'s marker convention, reused here).
|
||||
Grep `a11y: { disable: true }` in `*.stories.ts` for the current list.
|
||||
Reference in New Issue
Block a user