~9 min de lecture

Ta modale Angular piège la souris, pas le clavier : 3 trous qu'axe ne verra jamais

TL;DR

Une modale maison bloque la souris, pas le clavier ni le lecteur d'écran : Tab s'échappe de la boîte, le focus se perd à la fermeture, rien n'est annoncé. Aucun de ces trois trous n'est vu par un scan axe sur la modale naïve : les deux premiers sont des interactions, le troisième n'accroche aucune règle tant que le div n'a pas de rôle. Le CDK a11y les ferme avec une directive (cdkTrapFocus), son input cdkTrapFocusAutoCapture, l'attribut marqueur cdkFocusInitial et un service (LiveAnnouncer) ; le <dialog> natif ferme les trous 1 et 2 gratuitement depuis mars 2022.

Le faux sentiment de sécurité

Ta modale de confirmation a tout ce qu'il faut, visuellement : un overlay sombre qui bloque les clics, une croix, Esc qui ferme, un bouton destructif bien rouge. Tu lances axe : zéro violation. Dossier clos.

Maintenant pose la souris. Ouvre la modale au clavier depuis un bouton "Delete account", appuie sur Tab : le focus ne va pas sur "Cancel", il file derrière l'overlay, sur le lien suivant de la page. Trois Tab plus loin, tu es dans le footer. L'overlay bloque les clics, pas la tabulation : pour le clavier, ta modale n'est qu'un div de plus.

Le focus qui ne va pas où il faut est une interaction : comme on l'a vu dans l'article sur axe-core, aucun analyseur d'instantané ne la provoquera. Audit vert et modale inutilisable au clavier ne sont pas contradictoires.

Voici la coupable, telle qu'on en a tous écrit une :

import { ChangeDetectionStrategy, Component, output } from '@angular/core';

@Component({
  selector: 'app-confirm-modal',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="overlay" (click)="cancelled.emit()"></div>
    <div class="panel" (keydown.escape)="cancelled.emit()">
      <h2>Delete this account?</h2>
      <p>This cannot be undone.</p>
      <button (click)="cancelled.emit()">Cancel</button>
      <button class="danger" (click)="confirmed.emit()">Delete</button>
    </div>
  `,
})
export class ConfirmModal {
  confirmed = output<void>();
  cancelled = output<void>();
}

Détail savoureux : même ton (keydown.escape) est cassé. Un événement clavier part de l'élément qui a le focus et remonte le DOM ; or rien n'a déplacé le focus dans la modale, il est resté sur le bouton "Delete account", derrière. Ton handler ne verra l'événement que si on a d'abord cliqué dans la boîte. À la souris, donc.

Trou 1 : le focus n'entre pas, et rien ne le retient

Ce qu'il faut : à l'ouverture le focus entre dans la modale, ensuite Tab et Shift+Tab bouclent à l'intérieur. C'est le contrat que l'ARIA Authoring Practices Guide décrit pour une boîte de dialogue, et il est pénible à écrire à la main : lister les éléments tabbables (ceux que Tab peut atteindre), intercepter Tab sur le dernier, Shift+Tab sur le premier, tenir la liste à jour.

Le CDK le fait en une directive et deux attributs. Le package @angular/cdk ne fait pas partie d'une app Angular par défaut, donc d'abord :

pnpm add @angular/cdk

Puis :

import { ChangeDetectionStrategy, Component, output } from '@angular/core';
import { A11yModule } from '@angular/cdk/a11y';

@Component({
  selector: 'app-confirm-modal',
  imports: [A11yModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="overlay" (click)="cancelled.emit()"></div>
    <div
      class="panel"
      role="dialog"
      aria-modal="true"
      aria-labelledby="confirm-title"
      cdkTrapFocus
      [cdkTrapFocusAutoCapture]="true"
      (keydown.escape)="cancelled.emit()"
    >
      <h2 id="confirm-title">Delete this account?</h2>
      <p>This cannot be undone.</p>
      <button cdkFocusInitial (click)="cancelled.emit()">Cancel</button>
      <button class="danger" (click)="confirmed.emit()">Delete</button>
    </div>
  `,
})
export class ConfirmModal {
  confirmed = output<void>();
  cancelled = output<void>();
}

Une directive et deux attributs travaillent ensemble ici :

  • cdkTrapFocus, la directive, pose le piège : Tab depuis le dernier élément tabbable revient au premier, Shift+Tab depuis le premier va au dernier - deux sentinelles invisibles encadrent la zone et rattrapent le focus qui tente de sortir.
  • [cdkTrapFocusAutoCapture]="true", son input, gère l'entrée : à l'initialisation de la directive, le focus est déplacé dans la zone piégée. Ça répare ton Esc au passage : le focus étant dans le panneau, le keydown remonte jusqu'à ton handler.
  • cdkFocusInitial est un attribut marqueur que le piège lit au moment de capturer : il désigne qui reçoit ce focus initial. Sans lui (ni autre marqueur de région du CDK), le focus va au premier élément tabbable de la zone. Ici on le pose sur "Cancel" : le focus initial va sur l'option sans danger, pour qu'un appui réflexe sur Entrée ne supprime pas un compte.

Tu peux importer A11yModule en entier ou seulement CdkTrapFocus (standalone depuis @angular/cdk 17.1 ; avant, le module était le seul chemin). Les deux attributs voyagent avec la directive : il n'y a rien d'autre à importer.

Les attributs role="dialog", aria-modal="true" et aria-labelledby posés au passage ne sont pas décoratifs : on y revient au trou 3.

Trou 2 : à la fermeture, le focus tombe dans le vide

L'utilisateur annule. Ta modale sort du DOM... et l'élément focusé disparaît avec elle : le navigateur rabat le focus sur <body>. L'utilisateur clavier re-tabule depuis le haut de la page ; l'utilisateur de lecteur d'écran, lui, repart typiquement du début du document. La règle : celui qui prend le focus le rend à l'élément qui l'avait avant l'ouverture - typiquement le bouton déclencheur.

Bonne nouvelle : c'est déjà réglé. cdkTrapFocusAutoCapture mémorise l'élément focusé à la capture et le restaure à la destruction de la directive. Ta modale vivant dans un @if, sa fermeture détruit la directive : Entrée pour ouvrir, Esc pour fermer, et le focus revient tout seul sur "Delete account". Ça tient tant que la fermeture détruit réellement la directive - la modale sort du DOM, elle n'est pas masquée par un [hidden] ou un display: none.

Trou 3 : le lecteur d'écran n'a rien entendu

Ouvre la version naïve avec VoiceOver ou NVDA : un div apparaît, rien n'est dit. Trois attributs changent ça, déjà posés dans le code du trou 1 :

  • role="dialog" déclare la nature de la boîte : le lecteur d'écran l'annonce comme un dialogue au lieu de la traverser comme un bloc anonyme.
  • aria-labelledby="confirm-title" donne son nom accessible à la boîte : l'utilisateur entend "Delete this account?, dialog" au lieu de "dialog" tout court, qui n'aide personne.
  • aria-modal="true" déclare hors jeu le contenu extérieur pour les technologies d'assistance. Attention au contresens : il n'empêche pas Tab de sortir, il ne pilote que la couche d'assistance ; le piège clavier du trou 1 reste indispensable.

Ironie : ce trou-là est fait d'attributs, et axe ne le voyait quand même pas - un div sans rôle n'accroche aucune règle de dialogue. Pose role="dialog" sans nom accessible, et là axe se réveille (règle aria-dialog-name) : le scan ne te protège qu'une fois le travail commencé.

Reste le résultat de l'action. L'utilisateur confirme, le compte est supprimé, un toast s'affiche deux secondes... visuellement. Un changement de DOM n'est pas une annonce : sans région aria-live, le lecteur d'écran n'en dira pas un mot. D'où le LiveAnnouncer du CDK :

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { AccountsApi } from './accounts-api';

@Component({
  selector: 'app-account-settings',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `...`,
})
export class AccountSettings {
  private readonly announcer = inject(LiveAnnouncer);
  private readonly accounts = inject(AccountsApi);

  protected async onConfirmDelete(): Promise<void> {
    await this.accounts.deleteCurrent();
    await this.announcer.announce('Account deleted', 'polite');
  }
}

announce() maintient une région aria-live visuellement masquée dans le <body> et y pousse ton message ; le lecteur d'écran le lit sans déplacer le focus. Le deuxième argument choisit la politesse : 'polite' (le défaut) attend la fin de la phrase en cours, 'assertive' l'interrompt - à réserver aux erreurs. La méthode rend une Promise, résolue quand le message est posé.

Tu pourrais poser la région toi-même dans le template. Le service vaut le détour parce que sa région vit en permanence dans le body : une région live doit exister avant le message pour être annoncée de façon fiable ; créée dans un @if au moment du message, elle arrive trop tard sur certains couples navigateur / lecteur d'écran.

Le twist : <dialog> natif ferme les trous 1 et 2 gratuitement

Tout ce qui précède répare un div déguisé en modale. Mais le web a un élément pour ça, pris en charge partout depuis mars 2022 (Chrome 37, Firefox 98, Safari 15.4) : <dialog>, et surtout sa méthode showModal().

import {
  ChangeDetectionStrategy,
  Component,
  ElementRef,
  output,
  viewChild,
} from '@angular/core';

@Component({
  selector: 'app-confirm-dialog',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <dialog #dlg aria-labelledby="confirm-title" (close)="onClose(dlg.returnValue)">
      <h2 id="confirm-title">Delete this account?</h2>
      <p>This cannot be undone.</p>
      <button autofocus (click)="dlg.close('cancel')">Cancel</button>
      <button class="danger" (click)="dlg.close('confirm')">Delete</button>
    </dialog>
  `,
})
export class ConfirmDialog {
  confirmed = output<void>();
  cancelled = output<void>();

  private readonly dialog =
    viewChild.required<ElementRef<HTMLDialogElement>>('dlg');

  open(): void {
    const dlg = this.dialog().nativeElement;
    dlg.returnValue = '';
    dlg.showModal();
  }

  protected onClose(returnValue: string): void {
    if (returnValue === 'confirm') {
      this.confirmed.emit();
    } else {
      this.cancelled.emit();
    }
  }
}

Inventaire de ce que showModal() donne sans une ligne de code :

  • Trou 1, fermé : le reste du document devient inerte - plus focusable, plus cliquable, ignoré des technologies d'assistance. Tab ne peut physiquement pas sortir. Le focus entre à l'ouverture, sur l'élément autofocus si tu en poses un (ici "Cancel", même logique qu'au trou 1).
  • Trou 2, fermé : à la fermeture, le navigateur rend le focus à l'élément qui l'avait avant showModal(). Spécifié, pas bricolé.
  • Esc fonctionne : les événements cancel puis close sont émis, la boîte se ferme. Pas de handler à écrire.
  • Le rôle est déjà là : dialog est le rôle implicite de l'élément. Donne-lui quand même son nom accessible, comme dans l'extrait : axe ne te rattrapera pas ici, sa règle aria-dialog-name ne vise que le rôle explicite.
  • Bonus : le pseudo-élément ::backdrop stylise le voile, sans div overlay.

Un piège quand même : returnValue est persistant, et Esc ferme la boîte sans y toucher. Sans le returnValue = '' de open(), un "confirm" d'une ouverture précédente serait relu au prochain Esc... et ton annulation supprimerait le compte.

Deux limites. Le clic sur le backdrop ne ferme rien par défaut (closedby="any" arrive, mais Safari ne l'a pas encore). Et showModal() ne verrouille pas le scroll derrière ; une règle de CSS moderne s'en charge, dans l'esprit de ce qu'on faisait avec :has(), posée dans le styles.css global puisqu'elle traverse la frontière du composant :

body:has(dialog[open]) {
  overflow: hidden;
}

Son périmètre : elle est sans effet si html porte son propre overflow, ignorée par iOS Safari au toucher, et son sélecteur dialog[open] attrape aussi un dialogue non modal ouvert par show(). Sur desktop classique elle suffit ; ailleurs, teste avant de promettre.

Le trou 3 reste le tien dans les deux mondes : nommer la boîte et annoncer les résultats d'action. LiveAnnouncer se marie très bien avec un <dialog> natif.

Alors, CDK ou natif ?

  • Modale de dialogue classique (confirmation, formulaire, paywall) : <dialog> + showModal(). C'est la plateforme qui porte le contrat, et elle le portera encore quand ta lib de composants aura changé trois fois.
  • Surface piégeante non modale : panneau latéral qui garde la page visible, palette de commandes, tiroir de filtres. showModal() rendrait inerte ce que tu veux laisser vivant : cdkTrapFocus te donne le piège sans le reste.
  • Annonces asynchrones (résultats chargés, action confirmée, erreur réseau) : LiveAnnouncer, quel que soit le choix au-dessus.
  • Tu utilises Angular Material : MatDialog embarque piège, focus initial et restauration, construits sur les mêmes briques du CDK a11y (côté service, via FocusTrap) ; LiveAnnouncer reste à ta charge. Cet article sert quand tu n'as pas Material - la plupart des design systems maison.

Récap actionnable

  1. Rejoue chaque modale au clavier, sans souris : ouvre, Tab x5, Esc, regarde où est le focus à chaque étape. Aucun scan automatique ne fera ce test pour toi.
  2. Modale de dialogue : <dialog> natif et showModal(), autofocus sur l'action sans danger, aria-labelledby vers le titre, returnValue remis à vide avant chaque ouverture.
  3. Modale custom que tu ne peux pas passer en <dialog> : pnpm add @angular/cdk, puis cdkTrapFocus + [cdkTrapFocusAutoCapture]="true" + cdkFocusInitial, avec role="dialog", aria-modal="true" et un nom accessible.
  4. Surface piégeante non modale (panneau latéral, palette de commandes) : le même attirail CDK, un nom accessible et le rôle qui colle vraiment à la surface, mais pas d'aria-modal="true" : il déclarerait hors jeu un contenu que tu veux laisser vivant.
  5. aria-modal="true" ne piège pas le clavier, et un overlay ne bloque que la souris : les deux réunis, sans piège à focus, ça reste une modale ouverte aux quatre vents.
  6. Le résultat qui n'est que visuel (toast, compteur, suppression) passe par LiveAnnouncer.announce(), 'polite' par défaut, 'assertive' pour les erreurs.
  7. Le focus se rend à la fermeture : vérifie qu'il revient sur le déclencheur. cdkTrapFocusAutoCapture et <dialog> le font pour toi.

Ta modale piégeait la souris. Maintenant elle piège aussi le clavier, elle parle au lecteur d'écran, et elle rend ce qu'elle a pris en partant. C'est ça, une modale finie.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.