Decouvrez @angular/aria v21 : directives headless WAI-ARIA pour tabs, menu, combobox, dialog, tree et accordion accessibles WCAG 2.1 AA sans imposer de style.
L'accessibilité avant @angular/aria
Pendant des années, rendre un composant Angular réellement accessible représentait un travail colossal. Pour un simple menu déroulant, vous deviez gérer manuellement :
- Les attributs ARIA (
role,aria-expanded,aria-activedescendant...) - La navigation clavier (flèches, Home, End, Echap, lettre rapide)
- La gestion du focus (trap, return, visible)
- Le bon ordre de tabulation (
tabindexroving ou non) - Les annonces lecteur d'écran (
aria-live,aria-busy) - La compatibilité multi-screen-reader (NVDA, JAWS, VoiceOver, TalkBack)
Résultat : 90% des composants custom dans la wild sont inaccessibles. Les développeurs se rabattent sur Angular Material, qui impose son look Material Design, ou copient-collent du code ARIA approximatif depuis Stack Overflow.
Pourquoi un package officiel maintenant ?
L'équipe Angular a constaté que même les développeurs expérimentés font des erreurs sur l'accessibilité. La complexité des spécifications WAI-ARIA (40+ rôles, 50+ attributs, des dizaines de patterns) dépasse ce qu'on peut raisonnablement implémenter à la main. @angular/aria répond à ce besoin en encapsulant le travail ARIA dans des directives officielles, maintenues par l'équipe core.
@angular/aria : promesse et philosophie
Le package @angular/aria (introduit en Angular 21, novembre 2025) implémente les patterns du WAI-ARIA Authoring Practices Guide sous forme de directives Angular pures. Aucun style n'est imposé : ce sont des headless components au sens où Tailwind UI, Radix UI ou Headless UI les définissent.
Les 3 principes fondateurs
- Headless : zéro CSS imposé. Vous gardez votre design system existant (Bootstrap, Tailwind, Material, perso).
- Patterns standardisés : chaque directive suit à la lettre les WAI-ARIA Authoring Practices. Conformité garantie.
- Signal-first : intégration native avec les Signals Angular pour la réactivité (état ouvert/fermé, focus, sélection).
a11y offrait déjà des helpers bas-niveau (FocusTrap, LiveAnnouncer, FocusMonitor). @angular/aria va plus haut en proposant des directives composites prêtes à l'emploi qui combinent ces helpers pour des patterns complets.
Patterns disponibles en v21
| Pattern | Directive racine | Cas d'usage |
|---|---|---|
| Tabs | [ariaTabs] |
Onglets dans une fiche produit, paramètres |
| Menu | [ariaMenu] |
Menu contextuel, menu déroulant |
| Listbox | [ariaListbox] |
Sélection multiple stylée |
| Combobox | [ariaCombobox] |
Autocomplétion, recherche live |
| Dialog | [ariaDialog] |
Modales, panneaux d'action |
| Tree | [ariaTree] |
Explorateur de fichiers, arbo |
| Accordion | [ariaAccordion] |
FAQ, paramètres dépliables |
| Radio-group | [ariaRadioGroup] |
Sélection unique custom stylée |
| Toolbar | [ariaToolbar] |
Éditeur WYSIWYG, barres d'actions |
| Tooltip | [ariaTooltip] |
Info-bulles accessibles clavier |
Installation et premiers pas
// Installer le package (Angular 21+ requis)
npm install @angular/aria
// Aucune configuration supplémentaire. Les directives sont standalone — importez-les directement dans vos composants
Exemple simple : un bouton qui ouvre un menu
// menu-button.component.ts — menu déroulant entièrement accessible en 20 lignes
import { Component, signal } from '@angular/core';
import { AriaMenu, AriaMenuTrigger, AriaMenuItem } from '@angular/aria/menu';
@Component({
selector: 'app-menu-button',
standalone: true,
imports: [AriaMenu, AriaMenuTrigger, AriaMenuItem],
template: `
<!-- Le bouton trigger : aria-haspopup, aria-expanded, gestion clavier auto -->
<button ariaMenuTrigger #trigger="ariaMenuTrigger">
Actions
</button>
<!-- Le menu : role="menu", flèches haut/bas, Echap pour fermer -->
<ul ariaMenu [trigger]="trigger" class="menu-list">
<li ariaMenuItem (click)="rename()">Renommer</li>
<li ariaMenuItem (click)="duplicate()">Dupliquer</li>
<li ariaMenuItem disabled>Archiver (Pro)</li>
<li ariaMenuItem (click)="remove()">Supprimer</li>
</ul>
`,
})
export class MenuButtonComponent {
rename() { /* ... */ }
duplicate() { /* ... */ }
remove() { /* ... */ }
}
En une vingtaine de lignes, vous obtenez :
- Ouverture/fermeture clavier (Entrée, Espace, flèche bas)
- Navigation entre items via flèches haut/bas
- Saut au premier/dernier via Home/End
- Fermeture sur Echap, click outside, perte de focus
- Saut rapide par lettre tapée (« r » sélectionne « Renommer »)
- Retour du focus sur le trigger après fermeture
- Attributs ARIA conformes à WAI-ARIA Authoring Practices
Pattern Tabs : exemple détaillé
Les tabs (onglets) sont l'un des composants les plus mal implémentés du web. La norme WAI-ARIA impose plus de 30 règles de comportement. @angular/aria les encapsule en trois directives.
// settings-tabs.component.ts — tabs Settings d'un dashboard, complets
import { Component, signal } from '@angular/core';
import { AriaTabList, AriaTab, AriaTabPanel } from '@angular/aria/tabs';
@Component({
selector: 'app-settings-tabs',
standalone: true,
imports: [AriaTabList, AriaTab, AriaTabPanel],
template: `
<!-- Container des onglets : role="tablist", roving tabindex auto -->
<div ariaTabList [(selectedTab)]="active" class="tab-bar">
<button ariaTab value="profile">Profil</button>
<button ariaTab value="security">Sécurité</button>
<button ariaTab value="billing">Facturation</button>
<button ariaTab value="api" disabled>API (Pro)</button>
</div>
<!-- Panneaux : role="tabpanel", aria-labelledby auto-lié au tab -->
<div ariaTabPanel value="profile" class="tab-content">
<h2>Mon profil</h2>
... formulaire profil ...
</div>
<div ariaTabPanel value="security" class="tab-content">
<h2>Sécurité</h2>
... 2FA, mot de passe ...
</div>
<div ariaTabPanel value="billing" class="tab-content">
<h2>Facturation</h2>
... abonnement, factures ...
</div>
`,
styles: [`
.tab-bar { display: flex; gap: 8px; border-bottom: 2px solid #e5e7eb; }
.tab-bar [ariaTab][aria-selected="true"] {
border-bottom: 2px solid #3b82f6;
color: #3b82f6;
}
.tab-content { padding: 1.5rem 0; }
`],
})
export class SettingsTabsComponent {
// selectedTab est un Signal<string> en two-way binding
active = signal('profile');
}
Mode manuel vs automatique
// Par défaut, la sélection suit le focus (automatic) — recommandé
<div ariaTabList [activationMode]="'automatic'" ...>...</div>
// Si chaque tab charge des données coûteuses, préférer "manual" :
// l'utilisateur navigue les tabs avec flèches mais doit appuyer Entrée pour activer
<div ariaTabList [activationMode]="'manual'" ...>...</div>
Dialog avec focus trap natif
Les modales custom sont l'un des écueils majeurs d'accessibilité. @angular/aria/dialog gère le focus trap, le scroll lock, le retour de focus et l'annonce du dialog par les lecteurs d'écran.
// confirm-dialog.component.ts — modale de confirmation accessible
import { Component, inject, output } from '@angular/core';
import { AriaDialog, AriaDialogClose, ARIA_DIALOG_REF } from '@angular/aria/dialog';
@Component({
selector: 'app-confirm-dialog',
standalone: true,
imports: [AriaDialog, AriaDialogClose],
template: `
<!-- ariaDialog gère : role="dialog", aria-modal="true",
focus trap dans le dialog, scroll body bloqué,
focus initial sur le premier focusable,
retour focus sur l'élément d'origine à la fermeture -->
<div ariaDialog aria-labelledby="confirm-title" class="modal-content">
<h2 id="confirm-title">Confirmer la suppression</h2>
<p>Cette action est irréversible. Voulez-vous vraiment continuer ?</p>
<div class="actions">
<button ariaDialogClose>Annuler</button>
<button (click)="confirm()" class="btn-danger">Supprimer</button>
</div>
</div>
`,
})
export class ConfirmDialogComponent {
private ref = inject(ARIA_DIALOG_REF);
confirmed = output<void>();
confirm() {
this.confirmed.emit();
this.ref.close('confirmed');
}
}
// usage.component.ts — ouvrir le dialog
import { Component, inject } from '@angular/core';
import { AriaDialogService } from '@angular/aria/dialog';
import { ConfirmDialogComponent } from './confirm-dialog.component';
@Component({ /* ... */ })
export class UsageComponent {
private dialog = inject(AriaDialogService);
async deleteItem(itemId: string) {
const ref = this.dialog.open(ConfirmDialogComponent, {
ariaLabel: 'Confirmation de suppression',
closeOnEscape: true, // par défaut true
closeOnBackdropClick: false, // par défaut true
});
const result = await ref.afterClosed();
if (result === 'confirmed') {
await this.api.delete(itemId);
}
}
}
Tree et Accordion : ARIA composé
Accordion (FAQ, paramètres)
// faq.component.ts — accordion FAQ entièrement accessible
import { Component } from '@angular/core';
import { AriaAccordion, AriaAccordionItem, AriaAccordionHeader, AriaAccordionPanel } from '@angular/aria/accordion';
@Component({
selector: 'app-faq',
standalone: true,
imports: [AriaAccordion, AriaAccordionItem, AriaAccordionHeader, AriaAccordionPanel],
template: `
<div ariaAccordion [multiple]="true">
<!-- Item 1 -->
<div ariaAccordionItem value="q1">
<h3>
<!-- ariaAccordionHeader gère button + aria-expanded + Entrée/Espace -->
<button ariaAccordionHeader>
Comment annuler mon abonnement ?
</button>
</h3>
<!-- ariaAccordionPanel gère region + aria-labelledby + hidden -->
<div ariaAccordionPanel>
Rendez-vous dans Paramètres → Facturation → Résilier...
</div>
</div>
<!-- Item 2, 3... même pattern -->
</div>
`,
})
export class FaqComponent {}
Tree (explorateur de fichiers)
// file-tree.component.ts — arbo de fichiers accessible
import { Component, signal } from '@angular/core';
import { AriaTree, AriaTreeItem } from '@angular/aria/tree';
interface Node { id: string; label: string; children?: Node[]; }
@Component({
selector: 'app-file-tree',
standalone: true,
imports: [AriaTree, AriaTreeItem],
template: `
<ul ariaTree [(selected)]="selected" class="tree">
@for (node of nodes; track node.id) {
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: node, level: 1 }"></ng-container>
}
</ul>
<ng-template #treeNode let-node let-level="level">
<li ariaTreeItem [value]="node.id" [level]="level">
<span>{{ node.label }}</span>
@if (node.children) {
<ul>
@for (child of node.children; track child.id) {
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></ng-container>
}
</ul>
}
</li>
</ng-template>
`,
})
export class FileTreeComponent {
selected = signal<string | null>(null);
nodes: Node[] = [
{ id: '1', label: 'src', children: [
{ id: '1.1', label: 'app', children: [
{ id: '1.1.1', label: 'home.component.ts' },
]},
{ id: '1.2', label: 'assets' },
]},
{ id: '2', label: 'package.json' },
];
}
Comportements clavier ARIA Tree obtenus :
- Flèche droite/gauche : ouvre/ferme le nœud
- Flèche haut/bas : navigation entre nœuds visibles
- Home/End : aller au premier/dernier nœud
- Lettre tapée : saut au prochain nœud commençant par cette lettre
aria-level,aria-expanded,aria-selectedgérés automatiquement
Tester l'accessibilité automatiquement
Utiliser @angular/aria garantit la conformité ARIA, mais ne dispense pas de tester. Voici les outils de validation à intégrer au CI.
1. axe-core dans les tests unitaires
// product-card.spec.ts — test a11y unitaire avec axe-core
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import axe, { type AxeResults } from 'axe-core';
import { ProductCardComponent } from './product-card.component';
describe('ProductCardComponent a11y', () => {
it('n\'a aucune violation WCAG 2.1 AA', async () => {
const fixture = TestBed.createComponent(ProductCardComponent);
fixture.componentRef.setInput('name', 'Test');
fixture.componentRef.setInput('priceCents', 100);
fixture.detectChanges();
// axe-core scanne le DOM du composant et retourne les violations
const results: AxeResults = await axe.run(fixture.nativeElement, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21aa'] },
});
// Echec si une seule violation : le PR n'est pas mergeable
expect(results.violations).toEqual([]);
});
});
2. Playwright e2e avec @axe-core/playwright
// e2e/a11y.spec.ts — test a11y de bout en bout
import { test, expect } from '@playwright/test';
import { injectAxe, checkA11y } from 'axe-playwright';
test.describe('Accessibilité Settings', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/settings');
await injectAxe(page);
});
test('aucune violation sur l\'onglet Profil', async ({ page }) => {
await checkA11y(page, undefined, {
detailedReport: true,
detailedReportOptions: { html: true },
});
});
test('navigation clavier complète dans les tabs', async ({ page }) => {
await page.keyboard.press('Tab');
await expect(page.getByRole('tab', { name: 'Profil' })).toBeFocused();
// Flèche droite passe à l'onglet suivant
await page.keyboard.press('ArrowRight');
await expect(page.getByRole('tab', { name: 'Sécurité', selected: true })).toBeVisible();
});
});
- Test axe-core unitaire ou e2e passant — zéro violation
- Navigation 100% clavier vérifiée à la main (Tab, flèches, Echap)
- Test NVDA (Windows gratuit) ou VoiceOver (Mac natif)
- Contraste WCAG AA : 4,5:1 texte normal, 3:1 texte gros
- Focus visible (pas
outline: none) - Pas de
div onClickà la place d'unbutton - Toutes les images ont
alt(oualt=""si décoratives) - Formulaires : chaque
inputa unlabelouaria-label
Conclusion et ressources
Avec @angular/aria, l'accessibilité Angular passe d'une discipline d'expert à une compétence atteignable par toute l'équipe. Le package supprime 80% du code ARIA fragile que vous écriviez à la main, tout en garantissant la conformité WAI-ARIA Authoring Practices.
Le package va continuer d'évoluer : la roadmap 2026 mentionne l'ajout de patterns grid, switch, slider et color picker. À terme, l'équipe Angular vise une couverture exhaustive des patterns ARIA stables.
@angular/aria en est une implémentation fidèle.