Créer une boîte de dialogue accessible à partir de zéro
Publié: 2022-03-10Tout d'abord, ne faites pas cela à la maison. N'écrivez pas vos propres dialogues ou une bibliothèque pour le faire. Il y en a déjà beaucoup qui ont été testés, audités, utilisés et réutilisés et vous devriez les préférer aux vôtres. a11y-dialog est l'un d'entre eux, mais il y en a d'autres (énumérés à la fin de cet article).
Permettez-moi de profiter de cet article pour vous rappeler à tous d' être prudent lorsque vous utilisez des boîtes de dialogue . Il est tentant de résoudre tous les problèmes de conception avec eux, en particulier sur mobile, mais il existe souvent d'autres moyens de surmonter les problèmes de conception. Nous avons tendance à tomber rapidement dans l'utilisation des dialogues non pas parce qu'ils sont nécessairement le bon choix mais parce qu'ils sont faciles. Ils ont mis de côté les problèmes de propriété d'écran en les échangeant contre un changement de contexte, ce qui n'est pas toujours le bon compromis. Le point est le suivant : déterminez si une boîte de dialogue est le bon modèle de conception avant de l'utiliser.
Dans cet article, nous allons écrire une petite bibliothèque JavaScript pour créer des boîtes de dialogue accessibles dès le début (en recréant essentiellement a11y-dialog). Le but est de comprendre ce qui s'y passe. Nous n'allons pas trop nous occuper du style, juste de la partie JavaScript. Nous utiliserons du JavaScript moderne par souci de simplicité (comme les classes et les fonctions fléchées), mais gardez à l'esprit que ce code peut ne pas fonctionner dans les anciens navigateurs.
- Définir l'API
- Instanciation de la boîte de dialogue
- Afficher et masquer
- Fermeture avec superposition
- Fermeture avec évasion
- Mise au point de piégeage
- Rester concentré
- Restaurer la concentration
- Donner un nom accessible
- Gestion des événements personnalisés
- Nettoyer
- Rassemblez tout
- Emballer
Définir l'API
Tout d'abord, nous voulons définir comment nous allons utiliser notre script de dialogue. Nous allons le garder aussi simple que possible pour commencer. Nous lui donnons l'élément HTML racine de notre boîte de dialogue, et l'instance que nous obtenons a une .show(..)
et une .hide(..)
.
class Dialog { constructor(element) {} show() {} hide() {} }
Instanciation du dialogue
Disons que nous avons le HTML suivant :
<div>This will be a dialog.</div>
Et nous instancions notre dialogue comme ceci :
const element = document.querySelector('#my-dialog') const dialog = new Dialog(element)
Il y a quelques choses que nous devons faire sous le capot lors de son instanciation :
- Cachez-le pour qu'il soit masqué par défaut (
hidden
). - Marquez-le comme une boîte de dialogue pour les technologies d'assistance (
role="dialog"
). - Rendre le reste de la page inerte lorsqu'elle est ouverte (
aria-modal="true"
).
constructor (element) { // Store a reference to the HTML element on the instance so it can be used // across methods. this.element = element this.element.setAttribute('hidden', true) this.element.setAttribute('role', 'dialog') this.element.setAttribute('aria-modal', true) }
Notez que nous aurions pu ajouter ces 3 attributs dans notre HTML initial pour ne pas avoir à les ajouter avec JavaScript, mais de cette façon c'est hors de vue, hors d'esprit. Notre script peut s'assurer que les choses fonctionneront comme elles le devraient, que nous ayons pensé à ajouter tous nos attributs ou non.
Afficher et masquer
Nous avons deux méthodes : une pour afficher la boîte de dialogue et une pour la masquer. Ces méthodes ne feront pas grand-chose (pour l'instant) à part basculer l'attribut hidden
sur l'élément racine. Nous allons également maintenir un booléen sur l'instance pour pouvoir évaluer rapidement si la boîte de dialogue s'affiche ou non. Cela vous sera utile plus tard.
show() { this.isShown = true this.element.removeAttribute('hidden') } hide() { this.isShown = false this.element.setAttribute('hidden', true) }
Pour éviter que la boîte de dialogue ne soit visible avant que JavaScript ne se déclenche et ne la masque en ajoutant l'attribut, il peut être intéressant d'ajouter hidden
à la boîte de dialogue directement dans le HTML dès le départ.
<div hidden>This will be a dialog.</div>
Fermeture avec superposition
Cliquer en dehors de la boîte de dialogue devrait la fermer. Il existe plusieurs façons de le faire. Une façon pourrait être d'écouter tous les événements de clic sur la page et de filtrer ceux qui se produisent dans la boîte de dialogue, mais c'est relativement complexe à faire.
Une autre approche consisterait à écouter les événements de clic sur la superposition (parfois appelée « toile de fond »). La superposition elle-même peut être aussi simple qu'un <div>
avec certains styles.
Ainsi, lors de l'ouverture de la boîte de dialogue, nous devons lier les événements de clic sur la superposition. Nous pourrions lui donner un ID ou une certaine classe pour pouvoir l'interroger, ou nous pourrions lui donner un attribut de données. J'ai tendance à les privilégier pour les crochets de comportement. Modifions notre HTML en conséquence :
<div hidden> <div data-dialog-hide></div> <div>This will be a dialog.</div> </div>
Maintenant, nous pouvons interroger les éléments avec l'attribut data-dialog-hide
dans la boîte de dialogue et leur donner un écouteur de clic qui masque la boîte de dialogue.
constructor (element) { // … rest of the code // Bind our methods so they can be used in event listeners without losing the // reference to the dialog instance this._show = this.show.bind(this) this._hide = this.hide.bind(this) const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.addEventListener('click', this._hide)) }
La bonne chose à propos d'avoir quelque chose d'assez générique comme celui-ci est que nous pouvons également utiliser la même chose pour le bouton de fermeture de la boîte de dialogue.
<div hidden> <div data-dialog-hide></div> <div> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
Fermeture avec échappement
Non seulement la boîte de dialogue doit être masquée lorsque vous cliquez en dehors de celle-ci, mais elle doit également être masquée lorsque vous appuyez sur Échap . Lors de l'ouverture de la boîte de dialogue, nous pouvons lier un écouteur de clavier au document et le supprimer lors de sa fermeture. De cette façon, il n'écoute que les pressions sur les touches lorsque la boîte de dialogue est ouverte au lieu de tout le temps.
show() { // … rest of the code // Note: `_handleKeyDown` is the bound method, like we did for `_show`/`_hide` document.addEventListener('keydown', this._handleKeyDown) } hide() { // … rest of the code // Note: `_handleKeyDown` is the bound method, like we did for `_show`/`_hide` document.removeEventListener('keydown', this._handleKeyDown) } handleKeyDown(event) { if (event.key === 'Escape') this.hide() }
Mise au point de piégeage
Voilà les bonnes choses. Le piégeage du focus dans la boîte de dialogue est en quelque sorte à l'essence de tout, et doit être la partie la plus compliquée (bien que probablement pas aussi compliquée que vous pourriez le penser).
L'idée est assez simple : lorsque la boîte de dialogue est ouverte, nous écoutons les pressions de tabulation . Si vous appuyez sur Tab sur le dernier élément pouvant être sélectionné dans la boîte de dialogue, nous déplaçons par programme le focus sur le premier. Si vous appuyez sur Maj + Tab sur le premier élément focalisable de la boîte de dialogue, nous le déplaçons vers le dernier.
La fonction pourrait ressembler à ceci :
function trapTabKey(node, event) { const focusableChildren = getFocusableChildren(node) const focusedItemIndex = focusableChildren.indexOf(document.activeElement) const lastIndex = focusableChildren.length - 1 const withShift = event.shiftKey if (withShift && focusedItemIndex === 0) { focusableChildren[lastIndex].focus() event.preventDefault() } else if (!withShift && focusedItemIndex === lastIndex) { focusableChildren[0].focus() event.preventDefault() } }
La prochaine chose que nous devons comprendre est comment obtenir tous les éléments focalisables de la boîte de dialogue ( getFocusableChildren
). Nous devons interroger tous les éléments qui peuvent théoriquement être focalisables, puis nous devons nous assurer qu'ils le sont effectivement.
La première partie peut être réalisée avec des sélecteurs focalisables. C'est un tout petit paquet que j'ai écrit qui fournit ce tableau de sélecteurs :
module.exports = [ 'a[href]:not([tabindex^="-"])', 'area[href]:not([tabindex^="-"])', 'input:not([type="hidden"]):not([type="radio"]):not([disabled]):not([tabindex^="-"])', 'input[type="radio"]:not([disabled]):not([tabindex^="-"]):checked', 'select:not([disabled]):not([tabindex^="-"])', 'textarea:not([disabled]):not([tabindex^="-"])', 'button:not([disabled]):not([tabindex^="-"])', 'iframe:not([tabindex^="-"])', 'audio[controls]:not([tabindex^="-"])', 'video[controls]:not([tabindex^="-"])', '[contenteditable]:not([tabindex^="-"])', '[tabindex]:not([tabindex^="-"])', ]
Et cela suffit pour vous y rendre à 99%. Nous pouvons utiliser ces sélecteurs pour trouver tous les éléments focalisables, puis nous pouvons vérifier chacun d'eux pour nous assurer qu'il est réellement visible à l'écran (et non caché ou quelque chose).
import focusableSelectors from 'focusable-selectors' function isVisible(element) { return element => element.offsetWidth || element.offsetHeight || element.getClientRects().length } function getFocusableChildren(root) { const elements = [...root.querySelectorAll(focusableSelectors.join(','))] return elements.filter(isVisible) }
Nous pouvons maintenant mettre à jour notre méthode handleKeyDown
:
handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) }
Maintenir la concentration
Une chose qui est souvent négligée lors de la création de boîtes de dialogue accessibles est de s'assurer que le focus reste dans la boîte de dialogue même après que la page a perdu le focus. Pensez-y de cette façon : que se passe-t-il si une fois la boîte de dialogue ouverte ? Nous concentrons la barre d'URL du navigateur, puis recommençons à tabuler. Notre piège de focus ne fonctionnera pas, car il ne conserve le focus dans la boîte de dialogue que lorsqu'il se trouve à l'intérieur de la boîte de dialogue pour commencer.
Pour résoudre ce problème, nous pouvons lier un écouteur de focus à l'élément <body>
lorsque la boîte de dialogue est affichée, et déplacer le focus sur le premier élément pouvant être focalisé dans la boîte de dialogue.
show () { // … rest of the code // Note: `_maintainFocus` is the bound method, like we did for `_show`/`_hide` document.body.addEventListener('focus', this._maintainFocus, true) } hide () { // … rest of the code // Note: `_maintainFocus` is the bound method, like we did for `_show`/`_hide` document.body.removeEventListener('focus', this._maintainFocus, true) } maintainFocus(event) { const isInDialog = event.target.closest('[aria-modal="true"]') if (!isInDialog) this.moveFocusIn() } moveFocusIn () { const target = this.element.querySelector('[autofocus]') || getFocusableChildren(this.element)[0] if (target) target.focus() }
L'élément sur lequel se concentrer lors de l'ouverture de la boîte de dialogue n'est pas appliqué, et cela peut dépendre du type de contenu affiché par la boîte de dialogue. De manière générale, il existe plusieurs options :
- Concentrez le premier élément.
C'est ce que nous faisons ici, car cela est facilité par le fait que nous avons déjà une fonctiongetFocusableChildren
. - Concentrez le bouton de fermeture.
C'est aussi une bonne solution, surtout si le bouton est absolument positionné par rapport à la boîte de dialogue. Nous pouvons facilement y arriver en plaçant notre bouton de fermeture comme premier élément de notre boîte de dialogue. Si le bouton de fermeture vit dans le flux du contenu de la boîte de dialogue, à la toute fin, cela pourrait être un problème si la boîte de dialogue a beaucoup de contenu (et est donc défilable), car elle ferait défiler le contenu jusqu'à la fin à l'ouverture. - Concentrez-vous sur la boîte de dialogue elle-même .
Ce n'est pas très courant parmi les bibliothèques de dialogue, mais cela devrait également fonctionner (bien que cela nécessiterait d'y ajoutertabindex="-1"
pour que ce soit possible car un élément<div>
n'est pas focalisable par défaut).
Notez que nous vérifions s'il existe un élément avec l'attribut HTML autofocus
dans la boîte de dialogue, auquel cas nous déplacerions le focus sur celui-ci au lieu du premier élément.
Restaurer la concentration
Nous avons réussi à piéger le focus dans la boîte de dialogue, mais nous avons oublié de déplacer le focus à l'intérieur de la boîte de dialogue une fois qu'elle s'ouvre. De même, nous devons restaurer le focus sur l'élément qui l'avait avant l'ouverture de la boîte de dialogue.
Lors de l'affichage de la boîte de dialogue, nous pouvons commencer par conserver une référence à l'élément qui a le focus ( document.activeElement
). La plupart du temps, ce sera le bouton avec lequel on a interagi pour ouvrir la boîte de dialogue, mais dans de rares cas où une boîte de dialogue est ouverte par programme, il pourrait s'agir d'autre chose.
show() { this.previouslyFocused = document.activeElement // … rest of the code this.moveFocusIn() }
En masquant la boîte de dialogue, nous pouvons déplacer le focus vers cet élément. Nous le gardons avec une condition pour éviter une erreur JavaScript si l'élément n'existe plus (ou s'il s'agissait d'un SVG) :
hide() { // … rest of the code if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } }
Donner un nom accessible
Il est important que notre boîte de dialogue ait un nom accessible, c'est ainsi qu'elle sera répertoriée dans l'arborescence d'accessibilité. Il existe plusieurs façons de résoudre ce problème, dont l'une consiste à définir un nom dans l'attribut aria-label
, mais aria-label
a des problèmes.
Une autre façon est d'avoir un titre dans notre boîte de dialogue (caché ou non), et de lui associer notre boîte de dialogue avec l'attribut aria-labelledby
. Cela pourrait ressembler à ceci :
<div hidden aria-labelledby="my-dialog-title"> <div data-dialog-hide></div> <div> <h1>My dialog title</h1> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
Je suppose que nous pourrions faire en sorte que notre script applique cet attribut dynamiquement en fonction de la présence du titre et ainsi de suite, mais je dirais que cela est tout aussi facilement résolu en créant du code HTML approprié, pour commencer. Pas besoin d'ajouter JavaScript pour cela.
Gestion des événements personnalisés
Que se passe-t-il si nous voulons réagir à l'ouverture de la boîte de dialogue ? Ou fermé ? Il n'y a actuellement aucun moyen de le faire, mais l'ajout d'un petit système d'événements ne devrait pas être trop difficile. Nous avons besoin d'une fonction pour enregistrer les événements (appelons-la .on(..)
) et d'une fonction pour les désenregistrer ( .off(..)
).
class Dialog { constructor(element) { this.events = { show: [], hide: [] } } on(type, fn) { this.events[type].push(fn) } off(type, fn) { const index = this.events[type].indexOf(fn) if (index > -1) this.events[type].splice(index, 1) } }
Ensuite, lors de l'affichage et du masquage de la méthode, nous appellerons toutes les fonctions qui ont été enregistrées pour cet événement particulier.
class Dialog { show() { // … rest of the code this.events.show.forEach(event => event()) } hide() { // … rest of the code this.events.hide.forEach(event => event()) } }
Nettoyer
Nous pourrions vouloir fournir une méthode pour nettoyer une boîte de dialogue au cas où nous aurions fini de l'utiliser. Il serait responsable de la désinscription des auditeurs d'événements afin qu'ils ne durent pas plus qu'ils ne le devraient.
class Dialog { destroy() { const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.removeEventListener('click', this._hide)) this.events.show.forEach(event => this.off('show', event)) this.events.hide.forEach(event => this.off('hide', event)) } }
Rassembler le tout
import focusableSelectors from 'focusable-selectors' class Dialog { constructor(element) { this.element = element this.events = { show: [], hide: [] } this._show = this.show.bind(this) this._hide = this.hide.bind(this) this._maintainFocus = this.maintainFocus.bind(this) this._handleKeyDown = this.handleKeyDown.bind(this) element.setAttribute('hidden', true) element.setAttribute('role', 'dialog') element.setAttribute('aria-modal', true) const closers = [...element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.addEventListener('click', this._hide)) } show() { this.isShown = true this.previouslyFocused = document.activeElement this.element.removeAttribute('hidden') this.moveFocusIn() document.addEventListener('keydown', this._handleKeyDown) document.body.addEventListener('focus', this._maintainFocus, true) this.events.show.forEach(event => event()) } hide() { if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } this.isShown = false this.element.setAttribute('hidden', true) document.removeEventListener('keydown', this._handleKeyDown) document.body.removeEventListener('focus', this._maintainFocus, true) this.events.hide.forEach(event => event()) } destroy() { const closers = [...this.element.querySelectorAll('[data-dialog-hide]')] closers.forEach(closer => closer.removeEventListener('click', this._hide)) this.events.show.forEach(event => this.off('show', event)) this.events.hide.forEach(event => this.off('hide', event)) } on(type, fn) { this.events[type].push(fn) } off(type, fn) { const index = this.events[type].indexOf(fn) if (index > -1) this.events[type].splice(index, 1) } handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) } moveFocusIn() { const target = this.element.querySelector('[autofocus]') || getFocusableChildren(this.element)[0] if (target) target.focus() } maintainFocus(event) { const isInDialog = event.target.closest('[aria-modal="true"]') if (!isInDialog) this.moveFocusIn() } } function trapTabKey(node, event) { const focusableChildren = getFocusableChildren(node) const focusedItemIndex = focusableChildren.indexOf(document.activeElement) const lastIndex = focusableChildren.length - 1 const withShift = event.shiftKey if (withShift && focusedItemIndex === 0) { focusableChildren[lastIndex].focus() event.preventDefault() } else if (!withShift && focusedItemIndex === lastIndex) { focusableChildren[0].focus() event.preventDefault() } } function isVisible(element) { return element => element.offsetWidth || element.offsetHeight || element.getClientRects().length } function getFocusableChildren(root) { const elements = [...root.querySelectorAll(focusableSelectors.join(','))] return elements.filter(isVisible) }
Emballer
C'était quelque chose, mais nous y sommes finalement arrivés! Encore une fois, je déconseille de déployer votre propre bibliothèque de dialogues car ce n'est pas la plus simple et les erreurs pourraient être très problématiques pour les utilisateurs de technologies d'assistance. Mais au moins maintenant vous savez comment ça marche sous le capot !
Si vous avez besoin d'utiliser des boîtes de dialogue dans votre projet, envisagez d'utiliser l'une des solutions suivantes (n'oubliez pas que nous avons également notre liste complète de composants accessibles) :
- Implémentations JavaScript Vanilla : a11y-dialog par votre serviteur ou aria-modal-dialog par Scott O'Hara.
- Implémentations de React : react-a11y-dialog by yours really again, reach/dialog du framework Reach, ou @react-aria/dialog d'Adobe. Vous pourriez être intéressé par cette comparaison des 3 bibliothèques.
- Implémentations Vue : vue-a11y-dialog par Moritz Kroger, a11y-vue-dialog par Renato de Leao.
Voici d'autres choses qui pourraient être ajoutées mais qui ne l'ont pas été par souci de simplicité :
- Prise en charge des boîtes de dialogue d'alerte via le rôle
alertdialog
. Reportez-vous à la documentation a11y-dialog sur les boîtes de dialogue d'alerte. - Verrouillage de la possibilité de faire défiler lorsque la boîte de dialogue est ouverte. Reportez-vous à la documentation a11y-dialog sur le verrouillage du défilement.
- Prise en charge de l'élément HTML
<dialog>
natif car il est inférieur à la normale et incohérent. Reportez-vous à la documentation a11y-dialog sur l'élément dialog et cet article de Scott O'hara pour plus d'informations sur les raisons pour lesquelles cela n'en vaut pas la peine. - Prise en charge des boîtes de dialogue imbriquées car c'est discutable. Reportez-vous à la documentation a11y-dialog sur les boîtes de dialogue imbriquées.
- Considération pour la fermeture de la boîte de dialogue sur la navigation du navigateur. Dans certains cas, il peut être judicieux de fermer la boîte de dialogue en appuyant sur le bouton de retour du navigateur.