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:
@@ -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