Accessibilité — CDK a11y, ARIA, focus, screen readers
TL;DR L'accessibilité (a11y) Angular en 2026 repose sur trois piliers : HTML sémantique (la base, souvent oubliée), CDK a11y (
FocusTrap,LiveAnnouncer,FocusMonitor) pour gérer le focus dans des UI complexes (modals, menus, routing), et tests outillés (axe-core, eslint-plugin-@angular-eslint/template, accessibility-insights, screen readers). Les composants Angular Material sont accessibles par défaut — mais cela ne dispense pas de tester avec des lecteurs d'écran réels (NVDA sur Windows, VoiceOver sur macOS/iOS, TalkBack sur Android). L'a11y n'est pas un « bonus » : c'est une obligation légale (RGAA en France, WCAG 2.2 international, ADA aux US) et un levier d'inclusion qui bénéficie à tous les utilisateurs.
🧠 Mental model — ASCII + analogie
L'accessibilité, c'est s'assurer que l'information et les interactions d'une app sont perceptibles, opérables, compréhensibles et robustes (les quatre principes WCAG : P.O.U.R.) pour tous les utilisateurs, y compris ceux qui utilisent un lecteur d'écran, le clavier uniquement, une commande vocale, ou ont des troubles visuels/cognitifs.
┌─────────────────────────────────────────────────────────────┐
│ Pyramide d'accessibilité Angular │
└─────────────────────────────────────────────────────────────┘
▲
Tests
(NVDA, VO) ~5%
┌─────────────┐
│ ARIA │ ~15%
│ custom │ rôles, états
│ (en dernier)│
├─────────────┤
│ CDK a11y │ ~30%
│ FocusTrap │ gestion focus
│ Live region │
│ FocusMonitor│
├─────────────┤
│ HTML │ ~50%
│ sémantique │ <button>, <nav>,
│ │ <main>, <h1-h6>
└─────────────┘
Règle d'or : 90% de l'a11y est dans la sémantique HTML correcte.
ARIA est un complément quand HTML ne suffit pas — pas un remplacement.L'analogie : un site web accessible est comme un bâtiment public bien conçu. La sémantique HTML, c'est la structure de l'immeuble (entrées larges, escaliers et rampe d'accès). CDK a11y, c'est l'aménagement intérieur (signalisation tactile, ascenseurs, sas). ARIA custom, c'est de la rénovation pour adapter un espace existant. Et les tests avec lecteurs d'écran, c'est inviter de vrais utilisateurs à essayer le bâtiment. Si la structure de base est mauvaise (HTML non sémantique), aucun ARIA ne peut compenser — comme aucune rampe ne sauve un immeuble sans entrée.
Le modèle mental qui débloque tout : l'arbre d'accessibilité
Pour un dev expérimenté venu de PHP/TS, le déclic n'est pas « apprendre des attributs ARIA » mais comprendre qu'il existe un second arbre. Le navigateur construit le DOM (pour le rendu visuel) ET, en parallèle, un accessibility tree : une projection du DOM où chaque nœud a un role, un name (nom accessible), un state (expanded, checked, disabled…) et des properties. Les lecteurs d'écran, la commande vocale et les outils d'automatisation ne « voient » jamais vos <div class="..."> — ils naviguent cet arbre. Inspectez-le : Chrome DevTools → onglet Accessibility → Full-page accessibility tree (Ctrl+Shift+P → "Enable full-page accessibility tree").
DOM (ce que tu écris) Accessibility tree (ce que NVDA lit)
───────────────────── ────────────────────────────────────
<button aria-label="Fermer"> → button name="Fermer" state=focusable
<svg aria-hidden="true">…</svg> (le svg est élagué de l'arbre)
<div class="btn" onclick=…> → generic name="" (NON focusable, NON activable)
Fermer text "Fermer"Le nom accessible se calcule selon un algorithme de priorité précis (spec Accessible Name and Description Computation) : aria-labelledby > aria-label > contenu textuel / <label> associé > title/placeholder (dernier recours, fragile).
C'est la règle qui explique 80 % des bugs ARIA : un aria-label gagne toujours contre le texte visible. Mettre aria-label="Soumettre" sur <button>Envoyer</button> fait diverger ce que l'utilisateur voit (« Envoyer ») de ce que la commande vocale cible (« Soumettre ») — l'utilisateur dit « clique Envoyer » et rien ne se passe. Mental model staff : on ne « décore » pas du HTML avec ARIA, on sculpte un arbre d'accessibilité. Avant d'ajouter un attribut, ouvre l'onglet Accessibility et regarde l'arbre que tu produis.
🛠️ Code minimal (ts + html)
HTML sémantique — la fondation.
<!-- Mauvais : pas d'information sémantique -->
<div class="header">
<div class="logo">Mon Site</div>
<div class="menu">
<div class="link">Accueil</div>
<div class="link">À propos</div>
</div>
</div>
<div class="content">...</div>
<!-- Bon : sémantique claire -->
<header>
<a href="/" aria-label="Retour à l'accueil">
<img src="logo.svg" alt="Mon Site" />
</a>
<nav aria-label="Navigation principale">
<ul>
<li><a href="/">Accueil</a></li>
<li><a href="/about">À propos</a></li>
</ul>
</nav>
</header>
<main>...</main>
<footer>...</footer>Composant accessible avec gestion clavier. Pattern roving tabindex : un seul élément focusable (tabindex="0") à la fois, les autres à -1, et c'est le code qui déplace le focus DOM via les flèches.
Le piège classique de ce composant. Naïvement, on incrémente un
activeIndexsignal en espérant que « le focus suit ». Faux : changer[attr.tabindex]ne déplace PAS le focus DOM — il faut appeler.focus()impérativement sur le bon<li>. Sans ça, la touche flèche met à jour le visuel mais le lecteur d'écran et la touche Tab restent bloqués sur l'ancien élément. La version ci-dessous corrige ce bug avec uneffect()qui synchronise focus DOM ↔ signal.
// dropdown.component.ts
import {
ChangeDetectionStrategy, Component, ElementRef, effect, input, output, signal, viewChildren,
} from '@angular/core';
interface DropdownOption {
readonly id: string;
readonly label: string;
}
@Component({
selector: 'app-dropdown',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="dropdown">
<button
type="button"
[attr.aria-expanded]="isOpen()"
[attr.aria-haspopup]="'listbox'"
[attr.aria-labelledby]="labelId()"
(click)="toggle()"
(keydown.enter)="toggle()"
(keydown.space)="$event.preventDefault(); toggle()"
(keydown.arrowdown)="$event.preventDefault(); open()"
(keydown.escape)="close()"
>
{{ selected()?.label ?? placeholder() }}
<span aria-hidden="true">▼</span>
</button>
@if (isOpen()) {
<ul
role="listbox"
[attr.aria-labelledby]="labelId()"
(keydown.escape)="close()"
>
@for (option of options(); track option.id; let i = $index) {
<li
#opt
role="option"
[attr.aria-selected]="option.id === selected()?.id"
[attr.tabindex]="i === activeIndex() ? 0 : -1"
(click)="select(option)"
(keydown.enter)="select(option)"
(keydown.arrowdown)="$event.preventDefault(); focusNext()"
(keydown.arrowup)="$event.preventDefault(); focusPrev()"
(keydown.home)="$event.preventDefault(); activeIndex.set(0)"
(keydown.end)="$event.preventDefault(); activeIndex.set(options().length - 1)"
>
{{ option.label }}
</li>
}
</ul>
}
</div>
`,
})
export class DropdownComponent {
readonly options = input.required<DropdownOption[]>();
readonly selected = input<DropdownOption | null>(null);
readonly placeholder = input<string>('Sélectionner…');
readonly labelId = input<string>('');
readonly selectionChange = output<DropdownOption>();
protected readonly isOpen = signal(false);
protected readonly activeIndex = signal(0);
// Les <li> rendus ; viewChildren est lui-même un signal (Angular 17.2+).
private readonly optionEls = viewChildren<ElementRef<HTMLLIElement>>('opt');
constructor() {
// Synchronise le focus DOM avec activeIndex — LE correctif du bug ci-dessus.
effect(() => {
if (!this.isOpen()) return;
const i = this.activeIndex();
this.optionEls()[i]?.nativeElement.focus();
});
}
toggle(): void {
this.isOpen.update((v) => !v);
}
open(): void {
this.isOpen.set(true);
// Ouvre sur l'option sélectionnée, sinon la première (attendu APG listbox).
const idx = this.options().findIndex((o) => o.id === this.selected()?.id);
this.activeIndex.set(idx >= 0 ? idx : 0);
}
close(): void {
this.isOpen.set(false);
}
select(option: DropdownOption): void {
this.selectionChange.emit(option);
this.close();
}
focusNext(): void {
this.activeIndex.update((i) => Math.min(this.options().length - 1, i + 1));
}
focusPrev(): void {
this.activeIndex.update((i) => Math.max(0, i - 1));
}
}Sous zoneless (Angular 20). L'
effect()ci-dessus s'exécute après le rendu, donc les<li>existent quand on appelle.focus()— pas besoin desetTimeout. En revanche, déplacer le focus dans uneffectrend l'effet impur (effet de bord DOM) : c'est acceptable ici car c'est précisément le rôle du composant, mais en cas de doute, le CDKActiveDescendantKeyManager/FocusKeyManagerindustrialise exactement ce pattern (voir Exercice 2) et gère en plus le typeahead (taper « pa » saute à « Paris »).
CDK FocusTrap pour les modals.
// modal.component.ts
import { CdkTrapFocus } from '@angular/cdk/a11y';
import { ChangeDetectionStrategy, Component, ElementRef, OnDestroy, OnInit, output, viewChild } from '@angular/core';
@Component({
selector: 'app-modal',
imports: [CdkTrapFocus],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="overlay"
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="'modal-title'"
cdkTrapFocus
cdkTrapFocusAutoCapture
#modalRoot
(keydown.escape)="close.emit()"
>
<h2 id="modal-title">
<ng-content select="[modal-title]" />
</h2>
<div>
<ng-content />
</div>
<button type="button" (click)="close.emit()">Fermer</button>
</div>
`,
})
export class ModalComponent implements OnInit, OnDestroy {
protected readonly modalRoot = viewChild<ElementRef<HTMLElement>>('modalRoot');
readonly close = output<void>();
private previousFocus: HTMLElement | null = null;
private readonly inertedSiblings: HTMLElement[] = [];
ngOnInit(): void {
// 1. Mémoriser le déclencheur pour restaurer le focus à la fermeture.
this.previousFocus = document.activeElement as HTMLElement;
document.body.style.overflow = 'hidden';
// 2. Rendre le RESTE de la page inerte : sans ça, un lecteur d'écran en
// "mode navigation" (curseur virtuel) parcourt le contenu DERRIÈRE la
// modal, que cdkTrapFocus n'empêche pas (le trap ne borne que le Tab).
this.makeBackgroundInert();
}
ngOnDestroy(): void {
document.body.style.overflow = '';
this.inertedSiblings.forEach((el) => el.removeAttribute('inert'));
// 3. Restaurer le focus sur l'élément qui a ouvert la modal.
this.previousFocus?.focus();
}
private makeBackgroundInert(): void {
const host = this.modalRoot()?.nativeElement.closest('app-modal') ?? null;
for (const el of Array.from(document.body.children) as HTMLElement[]) {
if (el !== host && !el.hasAttribute('inert')) {
el.setAttribute('inert', '');
this.inertedSiblings.push(el);
}
}
}
}Les trois invariants d'une modal accessible (souvent posés en entretien) : (1)
cdkTrapFocusborne leTab, mais il ne suffit PAS — il faut aussi (2) rendre l'arrière-planinert(HTML natif, supporté partout en 2026) pour que le curseur virtuel du lecteur d'écran ne traverse pas le contenu masqué, et (3) restaurer le focus sur le déclencheur à la fermeture, sinon l'utilisateur clavier est éjecté en haut du<body>. Le CDKDialog/MatDialogfait les trois automatiquement (FocusTrap+aria-hidden/inertsur le reste +restoreFocus) — d'où la règle : pour une vraie modal, utilise leDialogdu CDK plutôt que de recoder ces trois pièges.
LiveAnnouncer pour les notifications sonores aux lecteurs d'écran.
// notification.service.ts
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { inject, Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class NotificationService {
private readonly announcer = inject(LiveAnnouncer);
success(message: string): void {
this.announcer.announce(`Succès : ${message}`, 'polite');
}
error(message: string): void {
this.announcer.announce(`Erreur : ${message}`, 'assertive');
}
loading(label: string): void {
this.announcer.announce(`${label} en cours de chargement`, 'polite');
}
}Choisir la politesse — le tableau de décision.
politevsassertiven'est pas cosmétique :assertiveinterrompt ce que le lecteur d'écran est en train de dire (y compris la frappe de l'utilisateur). Mal employé, c'est agressif et désorientant.
Live region Politeness implicite Quand l'utiliser Piège role="status"/aria-live="polite"polite Succès, sauvegarde, résultats de recherche, fin de chargement N'interrompt pas — peut être raté si l'utilisateur parle déjà role="alert"/aria-live="assertive"assertive Erreur bloquante, session expirée, perte de connexion Réservé à l'urgent ; abuser = utilisateur qui coupe le lecteur d'écran role="log"+aria-relevant="additions"polite Chat, console, flux d'événements chronologiques Annonce les ajouts de nœuds, pas les mutations de texte aria-busy="true"— Indiquer qu'une région se reconstruit (suspend l'annonce jusqu'à false)Oublier de remettre false= annonces geléesRègle staff :
politepar défaut,assertivejamais sauf urgence réelle. EtLiveAnnouncerdu CDK est préférable à une<div aria-live>posée à la main, parce qu'il garantit que la région existe AVANT la mutation (pitfall #5) et purge le texte précédent (clearTimeout-like) pour éviter que deux annonces fusionnent en une bouillie.
FocusMonitor pour distinguer focus clavier / focus souris.
// focus-styled.directive.ts
import { FocusMonitor } from '@angular/cdk/a11y';
import { DestroyRef, Directive, ElementRef, inject, OnInit, signal } from '@angular/core';
@Directive({
selector: '[appFocusStyled]',
standalone: true,
host: {
'[class.focus-keyboard]': 'isKeyboardFocused()',
},
})
export class FocusStyledDirective implements OnInit {
private readonly el = inject(ElementRef<HTMLElement>);
private readonly focusMonitor = inject(FocusMonitor);
private readonly destroyRef = inject(DestroyRef);
protected readonly isKeyboardFocused = signal(false);
ngOnInit(): void {
this.focusMonitor.monitor(this.el).subscribe((origin) => {
this.isKeyboardFocused.set(origin === 'keyboard');
});
this.destroyRef.onDestroy(() => this.focusMonitor.stopMonitoring(this.el));
}
}Focus management lors du routing.
// app.component.ts
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
@Component({
selector: 'app-root',
template: `
<a href="#main-content" class="skip-link">Aller au contenu principal</a>
<header>...</header>
<main id="main-content" tabindex="-1" #mainContent>
<router-outlet />
</main>
`,
})
export class AppComponent implements OnInit {
private readonly router = inject(Router);
private readonly announcer = inject(LiveAnnouncer);
private readonly destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.router.events
.pipe(filter((e) => e instanceof NavigationEnd), takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
const main = document.getElementById('main-content');
main?.focus();
const title = document.title;
this.announcer.announce(`Navigation vers ${title}`, 'polite');
});
}
}CSS pour le skip link et focus visible.
/* styles.css */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
/* Focus visible — utilise :focus-visible pour ne styliser que le focus clavier */
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* Désactiver l'outline par défaut au mouse */
:focus:not(:focus-visible) {
outline: none;
}🎯 Patterns courants
HTML sémantique avant tout. Avant d'ajouter role="button" à un <div>, utiliser un vrai <button>. Avant role="navigation" à un <div>, utiliser <nav>. Les éléments natifs apportent : focusabilité, gestion clavier, sémantique pour les screen readers, traduction par les outils de traduction.
Hiérarchie de titres logique. Un seul <h1> par page (le titre principal), puis <h2>, <h3> etc. sans sauter de niveau. Les utilisateurs de screen readers naviguent par titres — une hiérarchie cassée rend la page incompréhensible. Le <h1> doit décrire la page, pas le site.
Labels explicites pour tous les inputs. Chaque <input> doit avoir un <label> associé (via for/id ou imbrication). Les placeholder ne remplacent pas les labels. Pour les inputs sans label visible, utiliser aria-label ou aria-labelledby.
alt pour les images. Image informative : alt="Description courte du contenu". Image décorative : alt="" (vide, mais l'attribut doit exister). Image qui est un lien : décrire la destination, pas l'image (alt="Retour à l'accueil" plutôt que alt="Logo").
Focus management dans les modals. Quand on ouvre une modal : capturer le focus (cdkTrapFocus), focus initial sur le premier élément interactif ou le titre. Quand on ferme : restaurer le focus sur l'élément qui a ouvert la modal. cdkTrapFocusAutoCapture automatise ces deux étapes.
Skip links. En tête de page, un lien « Aller au contenu principal » qui apparaît au focus permet aux utilisateurs clavier de sauter la navigation répétée à chaque page.
Annonces vocales pour les changements de state. Une notification de succès, une erreur de formulaire, un chargement asynchrone : tous doivent être annoncés via LiveAnnouncer ou une zone aria-live. Sinon, le screen reader rate l'information.
Contraste de couleurs. Texte normal : ratio 4.5:1 minimum (WCAG AA). Texte large (>18pt ou >14pt bold) : 3:1. Cibler WCAG AAA pour un texte critique : 7:1. Outils : Chrome DevTools (Lighthouse, axe DevTools), Stark figma plugin, WebAIM Contrast Checker.
Pas d'information uniquement par la couleur. Un message d'erreur en rouge sans icône ni texte « Erreur : » est invisible pour les daltoniens. Toujours doubler la couleur par une icône, un texte, ou un motif.
Tester avec un screen reader réel. NVDA (Windows, gratuit), VoiceOver (macOS/iOS, intégré), TalkBack (Android, intégré), JAWS (Windows, payant, dominant en entreprise). Les screen readers ont des comportements légèrement différents — tester sur les plus populaires.
🔄 Versions — Angular 16 → 20
Angular 16 (mi-2023) : CDK a11y mature (FocusTrap, LiveAnnouncer, FocusMonitor stables). Material a11y bien documenté. aria-* bindings standard via [attr.aria-*].
Angular 17 (fin 2023) : nouveau control flow (@if, @for) sans impact direct sur a11y mais plus lisible. eslint-plugin-@angular-eslint/template gagne en règles a11y.
Angular 18 (mi-2024) : Material 18 améliore les contrastes par défaut. Apparition de directives standalone pour les patterns a11y courants (CdkAriaLive, CdkMonitorSubtreeFocus).
Angular 19 (fin 2024) : SSR + hydration améliore l'accessibilité initiale (le contenu est lisible par screen readers même avant l'hydratation). Documentation a11y enrichie dans angular.dev.
Angular 20 (mi-2025) : CDK a11y devient plus modulaire (tree-shakable par feature). Templates Material 100% conformes WCAG 2.2 AA par défaut. Schematics CLI pour audit a11y intégré.
Trajectoire 2026 : l'accessibilité est promue comme first-class concern dans la documentation Angular. Linting a11y intégré aux schematics de génération de composants. Tests a11y attendus dans toute review de code sérieuse.
⚠️ Pitfalls — 6-10
1. <div onclick> au lieu de <button>. Un div n'est pas focusable au clavier, n'a pas de rôle bouton pour les screen readers, et ne gère pas l'enter/space. Toujours utiliser <button type="button"> pour les actions, <a href> pour la navigation.
2. aria-label qui dupliquent le texte visible. <button aria-label="Soumettre">Soumettre</button> est lu deux fois par les screen readers. L'aria-label remplace le contenu, il ne s'y ajoute pas. À réserver aux éléments sans texte visible (icônes seules).
3. Modal sans focus trap. Une modal ouverte qui laisse le focus s'échapper vers le contenu derrière (via Tab) est inutilisable au clavier. Toujours utiliser cdkTrapFocus ou implémentation équivalente. Vérifier aussi qu'Échap ferme la modal.
4. Focus invisible. Désactiver l'outline (outline: none) sans en fournir d'alternative rend l'app inutilisable au clavier. Utiliser :focus-visible pour styliser uniquement le focus clavier (et pas le focus souris qui peut être moins visible).
5. Annonces qui ne se déclenchent pas. Insérer du texte dans une zone aria-live="polite" immédiatement après création ne déclenche pas l'annonce — la zone doit exister avant la modification. Le LiveAnnouncer du CDK gère ça correctement.
6. Tableaux pour la mise en page. Un <table> doit être utilisé pour des données tabulaires avec relations row/colonne. Pas pour layouter. Pour le layout, utiliser CSS Grid ou Flexbox. Sinon les screen readers annoncent « tableau de 12 lignes, 5 colonnes » au milieu d'une page de contenu.
7. Images SVG sans alt accessible. Un <svg> inline n'a pas d'attribut alt. Utiliser <svg role="img" aria-label="Description"> ou <svg><title>Description</title></svg>. Pour les SVG décoratifs, aria-hidden="true".
8. Form validation messages non associés. Un message d'erreur en dessous d'un input doit être référencé via aria-describedby="error-id" et avoir l'id="error-id". Sinon, le screen reader ne lit que le label, pas l'erreur.
9. Animations qui peuvent déclencher des malaises. Animations rapides, clignotements, scrolling automatique : respecter @media (prefers-reduced-motion: reduce) et désactiver/réduire ces effets pour les utilisateurs concernés.
10. Test seulement avec axe-core. Les outils automatisés détectent ~30% des problèmes a11y. Le reste nécessite des tests manuels avec screen readers et navigation clavier. Ne pas se contenter d'un score Lighthouse vert.
🧪 Testing
Tests automatisés avec @axe-core/playwright.
// e2e/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility', () => {
test('page accueil — pas de violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('formulaire — labels associés', async ({ page }) => {
await page.goto('/contact');
const results = await new AxeBuilder({ page }).include('form').analyze();
expect(results.violations.filter((v) => v.id === 'label')).toEqual([]);
});
});Tests unitaires avec Testing Library Angular.
// dropdown.component.spec.ts
import { render, screen, fireEvent } from '@testing-library/angular';
import { DropdownComponent } from './dropdown.component';
describe('DropdownComponent (a11y)', () => {
it('expose aria-expanded sur le bouton', async () => {
await render(DropdownComponent, {
componentInputs: { options: [{ id: '1', label: 'Option 1' }] },
});
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-expanded', 'false');
await fireEvent.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('se ferme avec Échap', async () => {
await render(DropdownComponent, {
componentInputs: { options: [{ id: '1', label: 'Option 1' }] },
});
await fireEvent.click(screen.getByRole('button'));
await fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' });
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});Linting avec @angular-eslint/template-accessibility-rules.
// eslint.config.js
{
"extends": [
"plugin:@angular-eslint/template/recommended",
"plugin:@angular-eslint/template/accessibility"
],
"rules": {
"@angular-eslint/template/alt-text": "error",
"@angular-eslint/template/elements-content": "error",
"@angular-eslint/template/label-has-associated-control": "error",
"@angular-eslint/template/no-positive-tabindex": "error",
"@angular-eslint/template/valid-aria": "error",
"@angular-eslint/template/click-events-have-key-events": "warn"
}
}Tests manuels — checklist minimale.
- Naviguer toute la page au clavier uniquement (Tab, Shift+Tab, Enter, Space, flèches, Échap)
- Activer NVDA (Windows) ou VoiceOver (Cmd+F5 sur Mac) et parcourir la page
- Vérifier le contraste avec Chrome DevTools (panneau a11y)
- Tester avec
prefers-reduced-motion: reduce(DevTools rendering tab) - Tester avec un zoom navigateur à 200% — le contenu doit rester lisible
- Désactiver les CSS — la structure HTML doit rester compréhensible
🎬 Cas d'usage concrets
Scénario 1 — SaaS RH, conformité RGAA
Contexte : l'éditeur SaaS RH vend à de grandes entreprises et au secteur public français, ce qui impose une conformité RGAA 4.1 niveau AA. L'audit annuel par un cabinet externe sanctionne tout écart. Approche : composants formulaires avec <label for> systématiques (jamais de placeholder seul), erreurs annoncées via LiveAnnouncer du CDK, navigation au clavier complète avec cdkTrapFocus sur les modals (annoncer une candidature, valider une offre), respect du contraste 4.5:1 audité via eslint-plugin-jsx-a11y adapté templates + tests axe-core dans Playwright qui bloquent les PR. Le sélecteur de pipeline ATS (drag-and-drop des candidatures entre colonnes) propose un mode clavier alternatif : flèches gauche/droite pour changer de colonne, Espace pour saisir/déposer, annonce vocale du déplacement via LiveAnnouncer. Cette implémentation seule a fait gagner 12 points sur l'audit RGAA et débloqué un appel d'offres ministère.
Scénario 2 — E-commerce mode, conformité WCAG AA
Contexte : le retailer mode opère en UE, soumis au European Accessibility Act entré en application en juin 2025 — sanctions financières pour les e-commerces non accessibles. Approche : refonte du parcours achat avec focus sur WCAG 2.2 AA. Les sélecteurs de taille/couleur sont des radio groups natifs (role="radiogroup", aria-label) plutôt que des <div> cliquables custom. Le mini-panier overlay est un dialog modal avec cdkTrapFocus, focus initial sur le bouton fermer, Escape qui ferme. Les photos produit ont des alt descriptifs générés par les marketeurs (process éditorial) plutôt que alt="produit" automatique. Les vidéos produit ont des sous-titres <track kind="captions">. La page paiement annonce les erreurs de carte via aria-live="assertive" sur la zone d'erreur Stripe. Tests : Playwright + @axe-core/playwright sur 8 parcours critiques, audit manuel avec NVDA + VoiceOver iOS chaque sprint, Lighthouse Accessibility ≥ 95 en CI.
Scénario 3 — Banque, accessibilité legal
Contexte : application banque retail dans un pays avec législation stricte (équivalent ADA, sanctions individuelles). Approche : équipe dédiée a11y, audits trimestriels externes, et architecture pensée a11y dès la conception. Les composants critiques (virement, signature, OTP) sont testés systématiquement avec lecteurs d'écran par un panel de testeurs aveugles rémunérés. Le saisie OTP utilise <input inputmode="numeric" autocomplete="one-time-code"> qui permet aux iPhones de proposer le code SMS automatiquement (et est lu correctement). Le tableau de transactions a <caption>, <thead> avec scope="col", et les montants ont <span class="visually-hidden">débit de</span> pour éviter l'ambigüité audio. Les graphiques de solde sont doublés d'une description textuelle (tendance + valeurs clés) et d'un tableau de données alternatif accessible via un bouton "Voir les données". La page se navigue intégralement au clavier en moins de 50 tabulations grâce à des skip links et un ordre DOM logique. Conformité documentée dans une déclaration d'accessibilité publique.
🛠️ Exemple end-to-end
Use case : modal d'annonce candidature SaaS RH avec focus trap, live announcer, navigation clavier complète, et tests axe.
// candidature-modal.component.ts
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { A11yModule, LiveAnnouncer } from '@angular/cdk/a11y';
import { Dialog, DIALOG_DATA } from '@angular/cdk/dialog';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
interface ModalData { readonly posteIntitule: string; }
@Component({
selector: 'app-candidature-modal',
imports: [A11yModule, ReactiveFormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="titleId"
[attr.aria-describedby]="descId"
cdkTrapFocus
cdkTrapFocusAutoCapture
class="modal"
>
<h2 [id]="titleId">Postuler à : {{ data.posteIntitule }}</h2>
<p [id]="descId">Renseignez vos informations puis envoyez votre candidature.</p>
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
<div class="field">
<label for="nom-input">Nom complet</label>
<input
id="nom-input"
type="text"
formControlName="nom"
autocomplete="name"
[attr.aria-invalid]="nomInvalid()"
[attr.aria-describedby]="nomInvalid() ? 'nom-error' : null"
/>
@if (nomInvalid()) {
<span id="nom-error" class="error">Le nom est requis (3 caractères minimum).</span>
}
</div>
<div class="field">
<label for="email-input">Adresse email</label>
<input
id="email-input"
type="email"
formControlName="email"
autocomplete="email"
inputmode="email"
[attr.aria-invalid]="emailInvalid()"
[attr.aria-describedby]="emailInvalid() ? 'email-error' : null"
/>
@if (emailInvalid()) {
<span id="email-error" class="error">Email invalide.</span>
}
</div>
<div role="status" aria-live="polite" class="visually-hidden">{{ statusMessage() }}</div>
<div class="actions">
<button type="button" (click)="cancel()">Annuler</button>
<button type="submit" [disabled]="form.invalid || submitting()">
{{ submitting() ? 'Envoi…' : 'Envoyer ma candidature' }}
</button>
</div>
</form>
</div>
`,
styles: [`
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.error { color: #b00020; font-size: 0.875rem; }
.modal:focus { outline: none; }
`],
})
export class CandidatureModalComponent {
protected readonly data = inject<ModalData>(DIALOG_DATA);
private readonly dialog = inject(Dialog);
private readonly announcer = inject(LiveAnnouncer);
private readonly fb = inject(FormBuilder);
protected readonly titleId = 'cand-modal-title';
protected readonly descId = 'cand-modal-desc';
protected readonly submitting = signal(false);
protected readonly statusMessage = signal('');
protected readonly form: FormGroup = this.fb.group({
nom: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]],
});
protected nomInvalid() {
const c = this.form.get('nom');
return !!(c && c.invalid && c.touched);
}
protected emailInvalid() {
const c = this.form.get('email');
return !!(c && c.invalid && c.touched);
}
protected async submit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
this.announcer.announce('Formulaire incomplet, veuillez corriger les erreurs.', 'assertive');
return;
}
this.submitting.set(true);
this.statusMessage.set('Envoi de votre candidature en cours');
try {
await new Promise((r) => setTimeout(r, 600)); // simulate API
this.announcer.announce('Candidature envoyée avec succès.', 'polite');
this.dialog.closeAll();
} catch {
this.announcer.announce('Erreur lors de l\'envoi, veuillez réessayer.', 'assertive');
} finally {
this.submitting.set(false);
}
}
protected cancel() { this.dialog.closeAll(); }
}// candidature-modal.a11y.spec.ts (Playwright + axe-core)
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('modal candidature sans violation a11y et navigable au clavier', async ({ page }) => {
await page.goto('/postes/p-1');
await page.getByRole('button', { name: 'Postuler' }).click();
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
await page.keyboard.press('Tab');
await expect(page.getByLabel('Nom complet')).toBeFocused();
await page.keyboard.type('Alice Martin');
await page.keyboard.press('Tab');
await expect(page.getByLabel('Adresse email')).toBeFocused();
await page.keyboard.type('[email protected]');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
await expect(page.getByText('Candidature envoyée')).toBeVisible();
});Modal conforme : focus trappé, labels associés, erreurs annoncées, statuts live, navigation clavier complète, tests axe en CI.
🤖 A11y des UI d'agents IA — streaming accessible
C'est le piège a11y le plus mal traité de 2026 : une UI de chat qui streame des tokens LLM met à jour le DOM des dizaines de fois par seconde. Implémentée naïvement (aria-live sur le bloc qui se remplit), elle noie le lecteur d'écran sous un flot d'annonces partielles incompréhensibles (« Bon… Bonjou… Bonjour com… »). Un staff engineer raisonne en séparant rendu visuel (haute fréquence, pour l'œil) et annonce vocale (basse fréquence, par unités de sens : phrase ou fin de message).
Token stream (SSE/ReadableStream)
│ ~40 tok/s
▼
┌─────────────────────────┐ écrit chaque token → rendu visuel (rAF-coalescé)
│ buffer signal │ ───────────────────────────────────────────────►
│ (append-only) │
└─────────────────────────┘ n'annonce QUE :
│ • à la fin d'une phrase (.!?\n)
│ • ou au "done" du message
▼ → aria-live="polite", debounced
LiveAnnouncer (par phrase)Règle a11y du streaming : la zone de rendu visuel est aria-live="off" (ou aria-hidden du point de vue annonce continue) ; une zone d'annonce dédiée, hors écran, reçoit le texte par unités de sens. On annonce le début (« L'assistant répond… »), les phrases complètes au fil de l'eau en polite, et l'état terminal (« Réponse terminée », « Réponse interrompue »).
// ai-chat.component.ts — chat IA streamé, accessible, zoneless
import {
ChangeDetectionStrategy, Component, DestroyRef, inject, signal,
} from '@angular/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { marked } from 'marked';
interface ChatMessage {
readonly id: string;
readonly role: 'user' | 'assistant';
text: string; // muté en append-only pendant le stream
readonly streaming: boolean;
}
@Component({
selector: 'app-ai-chat',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- Journal de conversation : role="log" annonce les AJOUTS, pas les modifs.
aria-live géré finement → on met polite mais on contrôle ce qui change. -->
<div
role="log"
aria-label="Conversation avec l'assistant"
aria-relevant="additions"
class="messages"
>
@for (msg of messages(); track msg.id) {
<article
class="msg"
[class.assistant]="msg.role === 'assistant'"
[attr.aria-label]="msg.role === 'assistant' ? 'Assistant' : 'Vous'"
>
<!-- Rendu visuel haute fréquence : aria-hidden pour ne pas
doubler l'annonce gérée par LiveAnnouncer. -->
<div class="bubble" [innerHTML]="render(msg.text)"></div>
@if (msg.streaming) {
<span class="cursor" aria-hidden="true">▌</span>
}
</article>
}
</div>
<form (submit)="$event.preventDefault(); send()">
<label for="chat-input" class="visually-hidden">Votre message</label>
<textarea
id="chat-input"
[value]="draft()"
(input)="draft.set($any($event.target).value)"
(keydown.enter)="$event.preventDefault(); send()"
[attr.aria-busy]="busy()"
></textarea>
@if (busy()) {
<button type="button" (click)="stop()">Stop</button>
} @else {
<button type="submit" [disabled]="!draft().trim()">Envoyer</button>
}
</form>
`,
styles: [`
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
`],
})
export class AiChatComponent {
private readonly announcer = inject(LiveAnnouncer);
private readonly sanitizer = inject(DomSanitizer);
private readonly destroyRef = inject(DestroyRef);
protected readonly messages = signal<ChatMessage[]>([]);
protected readonly draft = signal('');
protected readonly busy = signal(false);
private abort: AbortController | null = null;
private announceBuffer = ''; // accumulé entre deux frontières de phrase
private rafHandle = 0;
// Markdown + sanitize : NE JAMAIS bypassSecurityTrust sur de la sortie LLM.
protected render(text: string): SafeHtml {
const html = marked.parse(text, { async: false }) as string;
return this.sanitizer.sanitize(1 /* SecurityContext.HTML */, html) ?? '';
}
async send(): Promise<void> {
const content = this.draft().trim();
if (!content || this.busy()) return;
this.draft.set('');
this.pushMessage({ role: 'user', text: content, streaming: false });
const assistant = this.pushMessage({ role: 'assistant', text: '', streaming: true });
this.busy.set(true);
this.announcer.announce("L'assistant rédige une réponse", 'polite');
this.abort = new AbortController();
this.destroyRef.onDestroy(() => this.abort?.abort());
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message: content }),
signal: this.abort.signal, // annule AUSSI côté serveur (voir backend)
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
this.appendToken(assistant.id, chunk);
}
this.finishMessage(assistant.id);
this.flushAnnounce(true);
this.announcer.announce('Réponse terminée', 'polite');
} catch (e) {
if ((e as Error).name === 'AbortError') {
this.announcer.announce('Réponse interrompue', 'assertive');
} else {
this.announcer.announce("Erreur lors de la génération", 'assertive');
}
this.finishMessage(assistant.id);
} finally {
this.busy.set(false);
this.abort = null;
}
}
stop(): void {
this.abort?.abort(); // déclenche AbortError → annonce "interrompue"
}
// --- rendu visuel coalescé en rAF (1 repaint/frame, pas 1 par token) ---
private appendToken(id: string, token: string): void {
this.messages.update((list) =>
list.map((m) => (m.id === id ? { ...m, text: m.text + token } : m)),
);
// Annonce vocale par UNITÉ DE SENS, pas par token.
this.announceBuffer += token;
if (/[.!?\n]\s*$/.test(this.announceBuffer)) this.flushAnnounce(false);
}
private flushAnnounce(force: boolean): void {
const text = this.announceBuffer.trim();
if (!text) return;
if (force || text.length > 0) {
this.announcer.announce(text, 'polite'); // une phrase complète
this.announceBuffer = '';
}
}
private finishMessage(id: string): void {
this.messages.update((l) => l.map((m) => (m.id === id ? { ...m, streaming: false } : m)));
}
private pushMessage(p: Omit<ChatMessage, 'id'>): ChatMessage {
const msg = { ...p, id: crypto.randomUUID() };
this.messages.update((l) => [...l, msg]);
return msg;
}
}Pourquoi role="log" + aria-relevant="additions" plutôt qu'aria-live sur la bulle ? role="log" a une sémantique « journal chronologique » : les lecteurs d'écran annoncent les ajouts sans relire tout l'historique. Mais comme on mute text (modification, pas ajout d'un nœud), on ne s'appuie pas sur le live de la bulle pour le streaming — c'est LiveAnnouncer, débounce par phrase, qui porte l'annonce. La bulle visuelle peut clignoter à 40 fps sans gêner la voix.
Trace d'outils (tool-use) accessible. Pour une timeline d'étapes agentiques (pending | running | streaming | done | error), chaque étape est un <li> avec un aria-label qui inclut l'état textuel (pas seulement une couleur), et un changement d'état terminal (done/error) déclenche un announcer.announce('Outil recherche_web terminé', 'polite'). Le statut ne doit JAMAIS reposer sur la seule couleur (pitfall #8 du tableau ci-dessus appliqué aux agents).
Le bouton Stop, point a11y critique. Au clavier, le Stop doit être atteignable pendant le stream (il est dans l'ordre de tabulation, pas désactivé), Escape peut le déclencher, et après l'arrêt le focus revient sur le <textarea> pour enchaîner. L'annulation est double : AbortController côté client coupe le ReadableStream, et son signal passé au fetch propage la déconnexion jusqu'au handler NestJS — qui doit lui-même abort() l'appel SDK Anthropic pour arrêter la facturation des tokens. Côté serveur, écoutez req.on('close') (ou le signal du framework) et propagez-le au stream du SDK ; un stream IA qu'on continue de générer après déconnexion client est une fuite de coût.
Modèles Anthropic à jour (2026) pour ce backend :
claude-opus-4-8(flagship),claude-sonnet-4-6,claude-haiku-4-5. Utilisez le SDK en mode streaming avec retries, et un client injecté en DI (forRootAsync) côté NestJS — pas unnew Anthropic()dans un champ.
🔁 Quand utiliser / éviter
| Pattern | Utiliser quand | Éviter quand |
|---|---|---|
| Sémantique HTML native | Toujours en premier lieu | Jamais (c'est la fondation) |
cdkTrapFocus | Modals, drawers, menus contextuels | Contenus inline sans contexte modal |
LiveAnnouncer | Notifications, succès, erreurs, loading | Mises à jour visuelles évidentes |
FocusMonitor | Distinguer focus clavier (style fort) du focus souris | Si :focus-visible suffit en CSS |
aria-* custom | Quand HTML sémantique ne couvre pas le pattern | Si un élément HTML natif fait le même job |
| Skip links | Toute page avec navigation répétée | Pages très courtes sans navigation |
| Axe-core en CI | Toujours | Jamais (gratuit et préventif) |
| Material a11y | Pour des composants accessibles par défaut | Si on n'a besoin que d'un simple bouton |
| Tests screen reader réels | Validation finale, audit, certification | Itérations quotidiennes (trop lent) |
| WCAG 2.2 AAA | Sites publics, secteur public, santé | Apps internes avec audience contrôlée (AA suffit souvent) |
🔗 Liens
- WCAG 2.2 : https://www.w3.org/WAI/standards-guidelines/wcag/
- RGAA (France) : https://accessibilite.numerique.gouv.fr
- CDK a11y : https://material.angular.io/cdk/a11y/overview
- Angular Material accessibility : https://material.angular.io/guide/accessibility
- axe DevTools : https://www.deque.com/axe/devtools/
- Accessibility Insights : https://accessibilityinsights.io
- NVDA (gratuit Windows) : https://www.nvaccess.org
- @angular-eslint/template accessibility rules : https://github.com/angular-eslint/angular-eslint
- WebAIM (resources et formations) : https://webaim.org
- Inclusive Components (Heydon Pickering) : https://inclusive-components.design
🏋️ Exercices
Progression : implémenter → durcir en production → casser puis réparer. Chaque composant doit passer un audit @axe-core/playwright ET un test de navigation 100% clavier.
Exercice 1 — Combobox WAI-ARIA conforme (implémenter)
Objectif : transformer le DropdownComponent du cours en une vraie combobox éditable (pattern APG combobox + listbox) avec filtrage au clavier.
Contraintes : role="combobox" sur l'input, aria-expanded, aria-controls pointant vers le listbox, aria-activedescendant qui suit l'option active (le focus DOM reste sur l'input — pattern « focus virtuel »), flèches haut/bas qui déplacent aria-activedescendant, Home/End, Escape qui ferme sans valider, Enter qui valide. Le filtre met à jour les options sans casser l'activedescendant.
Indice/Solution : garder le focus DOM sur l'<input>, ne JAMAIS le déplacer sur les <li>. Chaque option a un id stable ; un activeId = computed(() => options()[activeIndex()]?.id) lié à [attr.aria-activedescendant]. Tester que NVDA annonce « option 3 sur 8 ».
Exercice 2 — Roving tabindex sur une toolbar (implémenter)
Objectif : une toolbar de boutons (gras, italique, lien…) navigable par flèches, un seul tabindex="0" à la fois (roving tabindex), le reste à -1.
Indice : signal activeButton, [attr.tabindex]="i === activeButton() ? 0 : -1", flèches gauche/droite qui incrémentent + effect() ou appel impératif qui .focus() le bouton actif. Comparer au CDK FocusKeyManager/ListKeyManager qui industrialise exactement ce pattern — réimplémentez-le, puis remplacez par ActiveDescendantKeyManager.
Exercice 3 — Drag-and-drop avec mode clavier alternatif (production-grade)
Objectif : un kanban (CDK DragDrop) entièrement utilisable au clavier, comme le pipeline ATS du scénario 1.
Contraintes : Space saisit/dépose une carte, flèches déplacent entre colonnes/positions, Escape annule, et CHAQUE déplacement est annoncé via LiveAnnouncer (« Carte Alice déplacée en colonne Entretien, position 2 sur 5 »). Le drag souris et le mode clavier partagent le même état.
Indice/Solution : le mode clavier ne touche pas au DOM drag natif ; il manipule directement le signal du modèle et émet l'annonce. Piège : sans annonce de position, l'utilisateur aveugle sait qu'il a bougé mais pas où. Mesurer : un déplacement complet doit être faisable sans souris en < 6 frappes.
Exercice 4 — Chat IA streamé accessible (production-grade)
Objectif : finaliser l'AiChatComponent pour qu'un utilisateur NVDA puisse suivre une réponse longue sans être noyé.
Contraintes : annonce du démarrage, annonces par phrase (pas par token), annonce terminale, bouton Stop atteignable au clavier pendant le stream + focus rendu au <textarea> après arrêt, markdown sanitizé. Bonus : timeline de tool-calls avec états textuels annoncés.
Indice : le débounce d'annonce se fait sur les frontières de phrase (/[.!?\n]\s*$/), pas sur un setTimeout aveugle. Vérifier que stop() → AbortError → annonce « interrompue » → focus textarea. Tester en coupant le réseau en plein stream : aucune annonce orpheline ne doit rester.
Exercice 5 — Casser puis réparer (régression a11y en CI)
Objectif : comprendre les angles morts des outils automatisés.
- Introduisez 4 bugs : (a)
<div (click)>au lieu de<button>, (b)outline: noneglobal, (c) image informative enalt="", (d) une bulle de chat enaria-live="assertive"qui spamme à chaque token. - Lancez
@axe-core/playwright. Constatez : axe attrape (a) et (c) mais rate (b) le focus invisible et (d) le spam d'annonces. - Écrivez les tests qui les attrapent : un test Playwright qui vérifie
getByRole('button')est focusable et a un:focus-visiblevisible (capture du style calculé) ; un test qui compte les annonces dans la zone live pendant 1 s de stream et échoue si > 3.
Indice/Solution : les outils statiques couvrent ~30% (pitfall #10). La régression d'annonces se teste en observant les mutations de la live region (MutationObserver dans le test) et en assertant un débit max. C'est ce genre de test qui sépare un audit Lighthouse vert d'une app réellement utilisable.
🎤 En entretien
Q : ARIA améliore-t-il toujours l'accessibilité ? Quand aria-label nuit-il ? Non — « la première règle d'ARIA, c'est de ne pas utiliser ARIA » : un élément HTML natif (<button>, <nav>, <input>) bat toujours un <div role>. Un aria-label remplace le contenu accessible ; mis sur un bouton qui a déjà du texte visible, il le masque (le visible et le vocal divergent) et casse la commande vocale qui cible le label visible. ARIA mal posé est pire que pas d'ARIA.
Q : Comment rends-tu une UI de chat LLM streamée accessible sans noyer le lecteur d'écran ? On sépare rendu visuel (haute fréquence, coalescé en rAF, aria-hidden pour l'annonce) et annonce vocale (LiveAnnouncer en polite, débouncée par unité de sens — phrase complète, pas token). On annonce le démarrage, les phrases au fil de l'eau, l'état terminal (terminé/interrompu/erreur), et le bouton Stop reste atteignable au clavier avec retour de focus au champ de saisie.
Q : Quelle est la limite des tests a11y automatisés et que mets-tu en CI vs en manuel ? axe-core/Lighthouse couvrent ~30% des critères WCAG (présence d'alt, labels, contraste statique, ARIA valide) — en CI, bloquant sur les PR. Le reste exige du manuel : ordre de focus logique, pertinence des annonces, gestion du focus à l'ouverture/fermeture de modal, sens des alt, navigation clavier complète, test avec NVDA/VoiceOver réels. Un score vert n'est jamais une preuve d'accessibilité.
Q : Modal accessible — quels sont les invariants non négociables ?role="dialog" + aria-modal="true" + aria-labelledby ; focus piégé à l'intérieur (cdkTrapFocus) ; focus initial sur le premier élément interactif (cdkTrapFocusAutoCapture) ; Escape ferme ; restauration du focus sur l'élément déclencheur à la fermeture ; et le contenu derrière est inerte (inert ou aria-hidden) pour que le lecteur d'écran ne le parcoure pas en mode virtuel. Piège : cdkTrapFocus ne borne que le Tab — il ne rend PAS l'arrière-plan inerte ; le curseur virtuel du screen reader le traverse toujours si on oublie inert.
Q : Qu'est-ce que l'arbre d'accessibilité et comment le « nom accessible » se calcule-t-il ? Le navigateur dérive du DOM un second arbre où chaque nœud porte role/name/state — c'est ce qu'exposent les lecteurs d'écran et l'automatisation, jamais les <div class>. Le nom accessible suit une priorité stricte : aria-labelledby > aria-label > contenu textuel / <label> associé > title/placeholder. Conséquence pratique : un aria-label écrase toujours le texte visible, ce qui casse la commande vocale (l'utilisateur cible le visible, pas le label) — d'où la règle « ne pose pas d'ARIA sans regarder l'arbre dans l'onglet Accessibility de DevTools ».
Q : getByRole de Testing Library / Playwright échoue mais l'élément est à l'écran — pourquoi est-ce un signal a11y ? Parce que ces sélecteurs interrogent l'arbre d'accessibilité, pas le DOM : si getByRole('button', { name: ... }) ne trouve rien, c'est que ton « bouton » est un <div onclick> sans rôle ni nom accessible. Le test qui « ne passe pas » est en réalité un détecteur de régression a11y gratuit — d'où l'intérêt de privilégier les requêtes par rôle/nom plutôt que par classe CSS ou data-testid.