Fermer

juillet 28, 2021

Créer une boîte de dialogue accessible à partir de zéro —


Résumé rapide ↬

Les boîtes de dialogue sont omniprésentes dans la conception d'interfaces modernes (pour le meilleur ou pour le pire), et pourtant beaucoup d'entre elles ne sont pas accessibles aux technologies d'assistance. Dans cet article, nous verrons comment créer un court script pour créer des boîtes de dialogue accessibles.

Tout d'abord, ne faites pas cela à la maison. N'écrivez pas vos propres boîtes de dialogue 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 préférer ceux-ci aux vôtres. a11y-dialog est l'un d'entre eux, mais il y en a d'autres (listés à la fin de cet article).

Permettez-moi de profiter de ce post pour vous rappeler à tous de être prudent lorsque en utilisant des dialogues. 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 mettent 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 : 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 (essentiellement recréer une boîte de dialogue a11y). Le but est de comprendre ce qui se passe dedans. 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 de flèche), mais gardez à l'esprit que ce code peut ne pas fonctionner dans les navigateurs hérités.

  1. Définition de l'API
  2. Instanciation de la boîte de dialogue
  3. Afficher et masquer
  4. Fermeture avec overlay
  5. Fermeture avec escape
  6. Piégeage du focus
  7. Maintien du focus
  8. Restauration du focus
  9. Donner un nom accessible
  10. Gestion des événements personnalisés
  11. Nettoyage
  12. Apporter tous ensemble
  13. Conclusion

Définition de 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 pour notre boîte de dialogue, et l'instance que nous obtenons a une méthode .show(..) et une méthode .hide(..).

class Dialogue {
  constructeur(élément) {}
  spectacle() {}
  cacher() {}
}

Instanciation de la boîte de dialogue

Disons que nous avons le code HTML suivant :

Ce sera une boîte de dialogue.

Et nous instancions notre boîte de dialogue comme ceci :

const element = document.querySelector('#my -dialogue')
const dialog = new Dialog (élément)

Il y a quelques choses que nous devons faire sous le capot lors de l'instanciation :

  • Le masquer afin 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) {
  // Stocke une référence à l'élément HTML sur l'instance afin qu'il puisse être utilisé
  // entre les méthodes.
  this.élément = élément
  this.element.setAttribute('caché', vrai)
  this.element.setAttribute('rôle', 'dialogue')
  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 de l'esprit. Notre script peut garantir que les choses fonctionneront comme elles le devraient, que nous ayons pensé ou non à ajouter tous nos attributs.

Afficher et masquer

Nous avons deux méthodes : une pour afficher la boîte de dialogue et une pour masquer il. 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 rapidement évaluer si le dialogue est affiché ou non. Cela vous sera utile plus tard.

show() {
  this.isMontré = vrai
  this.element.removeAttribute('caché')
}

cacher() {
  this.isShown = false
  this.element.setAttribute('caché', vrai)
}

Pour éviter que la boîte de dialogue ne soit visible avant que JavaScript ne démarre et la masque en ajoutant l'attribut, il peut être intéressant d'ajouter hidden à la boîte de dialogue directement dans le code HTML dès le départ.


Fermeture avec superposition

Cliquer en dehors de la boîte de dialogue devrait la fermer. Il y a 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 serait d'é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

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 :


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) {
  // … reste du code
  // Lier nos méthodes afin qu'elles puissent être utilisées dans les écouteurs d'événement sans perdre le
  // référence à l'instance de dialogue
  this._show = this.show.bind(this)
  this._hide = this.hide.bind(this)

  fermes const = [...this.element.querySelectorAll('[data-dialog-hide]')]
  closes.forEach(closer => close.addEventListener('click', this._hide))
}

L'avantage 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.


Plus après le saut ! Continuez à lire ci-dessous ↓

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 Echap. 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 pendant que la boîte de dialogue est ouverte au lieu de tout le temps.

show() {
  // … reste du code
  // Remarque : `_handleKeyDown` est la méthode liée, comme nous l'avons fait pour `_show`/`_hide`
  document.addEventListener('keydown', this._handleKeyDown)
}

cacher() {
  // … reste du code
  // Remarque : `_handleKeyDown` est la méthode liée, comme nous l'avons fait pour `_show`/`_hide`
  document.removeEventListener('keydown', this._handleKeyDown)
}

handleKeyDown(événement) {
  if (event.key === 'Escape') this.hide()
}

Trapping Focus

Maintenant, c'est la bonne chose. Le piégeage du focus dans le dialogue est en quelque sorte l'essence de l'ensemble, 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 le dialogue est ouvert, nous écoutons les pressions Tab. Si vous appuyez sur Tab sur le dernier élément focalisable de la boîte de dialogue, nous déplaçons par programme le focus sur le premier. Si vous appuyez sur Shift + Tab sur le premier élément focalisable de la boîte de dialogue, nous le déplaçons vers le dernier.

La fonction peut ressembler à ceci :

function trapTabKey( nœud, événement) {
  const focusableChildren = getFocusableChildren(noeud)
  const focusItemIndex = focusableChildren.indexOf(document.activeElement)
  const lastIndex = focusableChildren.length - 1
  const withShift = event.shiftKey

  if (withShift && focusItemIndex === 0) {
    focusableChildren[lastIndex].focus()
    event.preventDefault()
  } else if (!withShift && focusItemIndex === lastIndex) {
    focusableChildren[0].focus()
    event.preventDefault()
  }
}

La prochaine chose que nous devons déterminer est de savoir comment obtenir tous les éléments focalisables du 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 effectuée avec focusable-selectors. C'est un tout petit paquet que j'ai écrit qui fournit ce tableau de sélecteurs :

module.exports = [
  'a[href]:not([tabindex^="-"])',
  'zone[href]:not([tabindex^="-"])',
  'entrée:pas([type="hidden"]):pas([type="radio"]):pas([disabled]):pas([tabindex^="-"])',
  'input[type="radio"]:not([disabled]):not([tabindex^="-"]):checked',
  'select:not([disabled]):not([tabindex^="-"])',
  'textarea:not([disabled]):not([tabindex^="-"])',
  'bouton:pas([disabled]):pas([tabindex^="-"])',
  'iframe:not([tabindex^="-"])',
  'audio[controls]:pas([tabindex^="-"])',
  'vidéo[controls]:pas([tabindex^="-"])',
  '[contenteditable]:pas([tabindex^="-"])',
  '[tabindex]:pas([tabindex^="-"])',
]

Et c'est suffisant pour vous y rendre à 99%. Nous pouvons utiliser ces sélecteurs pour trouver tous les éléments pouvant être focalisés, puis nous pouvons vérifier chacun d'entre eux pour nous assurer qu'il est réellement visible à l'écran (et non caché ou quelque chose du genre).

fonction estVisible(élément) {
élément de retour =>
element.offsetWidth ||
element.offsetHeight ||
element.getClientRects().length
}

fonction getFocusableChildren (racine) {
éléments const = […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)
}

Maintien du focus

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 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 nous concentrons sur la barre d'URL du navigateur, puis nous recommençons à tabuler. Notre piège de focus ne fonctionnera pas, car il ne conserve le focus dans le dialogue que lorsqu'il se trouve à l'intérieur du dialogue pour commencer.

Pour résoudre ce problème, nous pouvons lier un écouteur de focus à l'élément lorsque le dialogue s'affiche et déplacez le focus sur le premier élément pouvant être focalisé dans la boîte de dialogue.

show () {
  // … reste du code
  // Remarque : `_maintainFocus` est la méthode liée, comme nous l'avons fait pour `_show`/`_hide`
  document.body.addEventListener('focus', this._maintainFocus, true)
}

cacher () {
  // … reste du code
  // Remarque : `_maintainFocus` est la méthode liée, comme nous l'avons fait pour `_show`/`_hide`
  document.body.removeEventListener('focus', this._maintainFocus, true)
}

maintenirFocus(événement) {
  const isInDialog = event.target.closest('[aria-modal="true"]')
  si (!isInDialog) this.moveFocusIn()
}

moveFocusIn () {
  const cible =
    this.element.querySelector('[autofocus]') ||
    getFocusableChildren(this.element)[0]

  if (cible) 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 :

  • Concentrer le premier élément.
    C'est ce que nous faisons ici, car cela est facilité par le fait que nous avons déjà un getFocusableChildren.
  • Concentrez-vous sur le bouton de fermeture.
    C'est aussi une bonne solution, surtout si le bouton est absolument positionné par rapport au dialogue. Nous pouvons facilement y arriver en plaçant notre bouton de fermeture comme premier élément de notre dialogue. Si le bouton de fermeture vit dans le flux du contenu de la boîte de dialogue, à la toute fin, cela pourrait poser problème si la boîte de dialogue a beaucoup de contenu (et est donc défilante), 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 boîtes de dialogue, mais cela devrait également fonctionner (même si cela nécessiterait l'ajout de tabindex="-1" afin que cela soit possible car un élément
    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 vers celui-ci au lieu du premier élément.

Restaurer le focus

Nous avons réussi à piéger le focus dans la boîte de dialogue, mais nous avons oublié de déplacer le focus dans 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 programmation, il peut s'agir d'autre chose.

show() {
  this.previouslyFocused = document.activeElement
  // … reste du code
  this.moveFocusIn()
}

Lorsque la boîte de dialogue est masquée, nous pouvons ramener le focus sur 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() {
  // … reste du 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'il sera répertorié dans l'arborescence d'accessibilité. Il existe plusieurs façons de le résoudre, dont l'une consiste à définir un nom dans l'attribut aria-labelmais aria-label a des problèmes.

Une autre façon est d'avoir un titre dans notre dialogue (qu'il soit caché ou non), et de lui associer notre dialogue avec l'attribut aria-labelledby. Cela pourrait ressembler à ceci :


Je suppose que nous pourrions faire en sorte que notre script applique cet attribut de manière dynamique 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

Et 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 Dialogue {
  constructeur(élément) {
    this.events = { afficher : []masquer : [] }
  }
  sur (tapez, fn) {
    this.events[type].push(fn)
  }
  off(tapez, fn) {
    index const = 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 {
  spectacle() {
    // … reste du code
    this.events.show.forEach(événement => événement())
  }

  cacher() {
    // … reste du code
    this.events.hide.forEach(événement => événement())
  }
}

Nettoyage

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 écouteurs d'événements afin qu'ils ne durent pas plus qu'ils ne le devraient.

class Dialog {
  détruire () {
    fermes const = [...this.element.querySelectorAll('[data-dialog-hide]')]
    closes.forEach(closer => close.removeEventListener('click', this._hide))

    this.events.show.forEach(événement => this.off('show', événement))
    this.events.hide.forEach(événement => this.off('cacher', événement))
  }
}

Tout rassembler

 importer des sélecteurs focusable à partir de « sélecteurs focusables »

boîte de dialogue de classe {
  constructeur(élément) {
    this.élément = élément
    this.events = { afficher : []masquer : [] }

    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('caché', vrai)
    element.setAttribute('rôle', 'dialogue')
    element.setAttribute('aria-modal', true)

    fermes const = [...element.querySelectorAll('[data-dialog-hide]')]
    closes.forEach(closer => close.addEventListener('click', this._hide))
  }

  spectacle() {
    this.isMontré = vrai
    this.previouslyFocused = document.activeElement
    this.element.removeAttribute('caché')

    this.moveFocusIn()

    document.addEventListener('keydown', this._handleKeyDown)
    document.body.addEventListener('focus', this._maintainFocus, true)

    this.events.show.forEach(événement => événement())
  }

  cacher() {
    if (this.previouslyFocused && this.previouslyFocused.focus) {
      this.previouslyFocused.focus()
    }

    this.isShown = false
    this.element.setAttribute('caché', vrai)

    document.removeEventListener('keydown', this._handleKeyDown)
    document.body.removeEventListener('focus', this._maintainFocus, true)

    this.events.hide.forEach(événement => événement())
  }

  détruire () {
    fermes const = [...this.element.querySelectorAll('[data-dialog-hide]')]
    closes.forEach(closer => close.removeEventListener('click', this._hide))

    this.events.show.forEach(événement => this.off('show', événement))
    this.events.hide.forEach(événement => this.off('cacher', événement))
  }

  sur (tapez, fn) {
    this.events[type].push(fn)
  }

  off(tapez, fn) {
    index const = this.events[type].indexOf(fn)
    if (index > -1) this.events[type].splice(index, 1)
  }

  handleKeyDown(événement) {
    if (event.key === 'Escape') this.hide()
    else if (event.key === 'Tab') trapTabKey(this.element, event)
  }

  moveFocusIn() {
    const cible =
      this.element.querySelector('[autofocus]') ||
      getFocusableChildren(this.element)[0]

    if (cible) target.focus()
  }

  maintenirFocus(événement) {
    const isInDialog = event.target.closest('[aria-modal="true"]')
    si (!isInDialog) this.moveFocusIn()
  }
}

function trapTabKey(nœud, événement) {
  const focusableChildren = getFocusableChildren(noeud)
  const focusItemIndex = focusableChildren.indexOf(document.activeElement)
  const lastIndex = focusableChildren.length - 1
  const withShift = event.shiftKey

  if (withShift && focusItemIndex === 0) {
    focusableChildren[lastIndex].focus()
    event.preventDefault()
  } else if (!withShift && focusItemIndex === lastIndex) {
    focusableChildren[0].focus()
    event.preventDefault()
  }
}

fonction estVisible(élément) {
  élément de retour =>
    element.offsetWidth ||
    element.offsetHeight ||
    element.getClientRects().length
}

fonction getFocusableChildren (racine) {
  éléments const = [...root.querySelectorAll(focusableSelectors.join(','))]

  return elements.filter(isVisible)
}

Conclure

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 le 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 cela fonctionne sous le capot !

Si vous devez utiliser des boîtes de dialogue dans votre projet, envisagez d'utiliser l'une des solutions suivantes (rappelons que nous avons notre liste complète de composants accessibles également) :

Voici d'autres éléments qui pourraient être ajoutés, mais qui ne l'étaient pas par souci de simplicité :

Smashing Editorial(vf, il)




Source link