Front-end angularforall.com

- Angular v21 ARIA : directives accessibles

Angular Angular-Aria Accessibilite Wcag Wai-Aria Angular-21 Headless-Components Directives Focus-Trap Axe-Core Tabs Combobox Dialog A11Y
Angular v21 ARIA : directives accessibles

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 (tabindex roving 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.

Le constat WCAG 2026 : en France, depuis le décret du 27 juillet 2025, toute application web professionnelle servant le public doit atteindre WCAG 2.1 niveau AA. Les amendes vont jusqu'à 50 000 € par site non conforme. L'accessibilité n'est plus optionnelle.

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

  1. Headless : zéro CSS imposé. Vous gardez votre design system existant (Bootstrap, Tailwind, Material, perso).
  2. Patterns standardisés : chaque directive suit à la lettre les WAI-ARIA Authoring Practices. Conformité garantie.
  3. Signal-first : intégration native avec les Signals Angular pour la réactivité (état ouvert/fermé, focus, sélection).
À ne pas confondre : @angular/aria ≠ @angular/cdk/a11y. Le CDK 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');
}
Ce que vous obtenez gratuitement : flèches gauche/droite pour naviguer entre onglets, Home/End pour aller au premier/dernier, activation automatique (manual ou automatic selectable via input), focus visible correct, annonces lecteur d'écran (« Profil, onglet sélectionné, 1 sur 4 »), bon comportement RTL (langues arabe/hébreu).

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>

Menu et Combobox accessibles

Combobox avec autocomplétion

Le combobox est le pattern le plus complexe d'ARIA. @angular/aria le résout en deux directives.

// city-search.component.ts — autocomplete de villes
import { Component, signal, computed } from '@angular/core';
import { AriaCombobox, AriaComboboxOption } from '@angular/aria/combobox';

@Component({
    selector: 'app-city-search',
    standalone: true,
    imports: [AriaCombobox, AriaComboboxOption],
    template: `
        <label for="city">Ville de départ</label>

        <!-- Input combobox : aria-expanded, aria-controls, aria-activedescendant auto -->
        <input
            id="city"
            ariaCombobox
            [(value)]="query"
            [options]="filtered()"
            (select)="onSelect($event)"
            placeholder="Tapez une ville...">

        <!-- Liste : role="listbox" injecté par la directive -->
        <ul ariaComboboxList>
            @for (city of filtered(); track city.id) {
                <li ariaComboboxOption [value]="city">
                    {{ city.name }} ({{ city.region }})
                </li>
            }
        </ul>
    `,
})
export class CitySearchComponent {
    allCities = [
        { id: 1, name: 'Paris',     region: 'IDF' },
        { id: 2, name: 'Marseille', region: 'PACA' },
        { id: 3, name: 'Lyon',      region: 'AURA' },
        { id: 4, name: 'Toulouse',  region: 'Occitanie' },
    ];

    query = signal('');

    // Filtrage live — re-calculé à chaque changement de query()
    filtered = computed(() =>
        this.allCities.filter(c =>
            c.name.toLowerCase().includes(this.query().toLowerCase())
        )
    );

    onSelect(city: { id: number; name: string }) {
        console.log('Sélectionné :', city.name);
    }
}

Comportements clavier obtenus :

  • Flèche bas ouvre la liste si fermée, sinon navigue
  • Entrée sélectionne l'option mise en évidence
  • Echap ferme la liste sans modifier l'input
  • Tab sélectionne l'option en évidence puis sort du combobox
  • aria-activedescendant tenu à jour pour les lecteurs d'écran
  • Le focus visuel reste sur l'input même quand la « sélection clavier » descend dans la liste — comportement WAI-ARIA correct

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);
        }
    }
}
Pourquoi le focus trap est si critique : sans focus trap, un utilisateur clavier qui ouvre votre modale et appuie sur Tab finit par tabuler dans la page de fond. Désorienté, il ne peut plus revenir dans la modale. Le focus trap, c'est 2 secondes de code mais c'est la frontière entre une UI accessible et une UI cassée pour 13% des utilisateurs (handicap, mobilité réduite, vision).

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-selected gé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();
    });
});
Checklist a11y obligatoire avant un PR :
  • 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'un button
  • Toutes les images ont alt (ou alt="" si décoratives)
  • Formulaires : chaque input a un label ou aria-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.

Pour aller plus loin : consultez l'ARIA Authoring Practices Guide officiel du W3C, la documentation de référence pour tous les patterns interactifs. Le code de @angular/aria en est une implémentation fidèle.

Partager