Une fenêtre modale accessible

Dans ce second article sur l’accessibilité, nous allons nous intéresser à un composant incontournable dans une application Web : La fenêtre modale.

Nous n’aborderons pas les fenêtres modales d’un point de vue UX, mais d’un point de vue technique porté sur l’accessibilité.

Pour la plupart des développeurs, la solution rapide pour intégrer une fenêtre modale consiste à l’afficher ou à la cacher en utilisant la propriété CSS display. Comme vous vous en doutez certainement, ce n’est pas l’idéal en termes d’accessibilité ! 🤓

Dans une première partie, nous établirons quels sont les critères d’accessibilité d’une fenêtre modale et analyserons quelques exemples de ce qu’il ne faut pas faire…

Dans une seconde partie, nous implémenterons ce composant en respectant les critères définis précédemment. Allez, c’est parti !

Les mauvais élèves

Pour tester l’accessibilité des fenêtres modales, je me suis lancé un défi, celui de ne pas utiliser de souris pour naviguer, mais uniquement un clavier !

Le but du jeu est simple, il consiste à pouvoir lancer la fenêtre modale en pressant la touche « Entrée » sur le bouton d’appel, à naviguer au sein de celle-ci en utilisant les touches de tabulation, et à pouvoir fermer cette fenêtre modale en pressant la touche « Echap ».

Malheureusement, le constat est sans appel, 90% des sites testés proposant des fenêtres modales ne respectent pas ces critères. Dans les 10% restants, quelques sites sortent du lot notamment Airbnb qui implémente ses fenêtres modales de manière accessible :

Exemple d'une fenêtre modale accessible

Pour en revenir aux mauvais élèves, j’ai listé les deux exemples typiques de fenêtres modales inaccessibles :

leboncoin.fr

  • Étape 1 : Ouverture de la fenêtre modale de connexion En naviguant au clavier avec la touche de tabulation, il est impossible d’accéder au bouton de connexion dans la navigation principale (perte de focus). Cette première étape n’est donc pas validée.
  • Étape 2 : Navigation dans la fenêtre modale Pour pouvoir tester cette étape, j’ai donc dû utiliser la souris afin de lancer la fenêtre de connexion. Le constat est une fois de plus sans appel, le focus clavier ne se positionne pas dans la fenêtre ouverte mais reste dans le document principal qui est donc caché derrière cette fenêtre. Il est donc impossible de naviguer au clavier dans cette fenêtre modale.
  • Étape 3 : Fermeture de la fenêtre modale Cette dernière étape consistant à pouvoir fermer cette fenêtre de connexion en pressant la touche « Echap » est cette fois-ci validée.

Cet exemple cumule les mauvaises pratiques : peu de gestion d’événements clavier, problèmes de focus et de navigation interne.

Cette fenêtre modale est donc inaccessible pour une personne naviguant au clavier.

tf1.fr

  • Étape 1 : Ouverture de la fenêtre modale de connexion Cette fois-ci, je peux accéder au bouton de connexion en naviguant au clavier. En pressant la touche « Entrée » je parviens également à afficher la fenêtre modale. Cette étape est validée !
  • Étape 2 : Navigation dans la fenêtre modale Le focus clavier se positionne correctement dans la fenêtre, je peux parcourir les différents éléments de la fenêtre en pressant la touche de tabulation. Malheureusement, arrivé au dernier champs, je perds mon focus clavier. De nouveau, comme dans l’exemple précédent, le focus se repositionne dans le document principal. Cette étape est donc à 50% valide.
  • Étape 3 : Fermeture de la fenêtre modale Malheureusement cette dernière étape n’est pas validée, la touche « Echap » n’est pas fonctionnelle. Il est donc nécessaire d’utiliser la souris pour fermer cette fenêtre.

Cet exemple cumule moins de problèmes d’accessibilité mais il est loin d’être parfait.

Au final, ces deux exemples nous montre que l’accessibilité a été partiellement (ou pas du tout) prise en compte. Pourtant, j’ai volontairement pris deux sites à fort trafic, alors imaginez les sites de plus petites envergures… 😢

Nous allons maintenant entrer dans le vif du sujet, à savoir, concevoir une fenêtre modale accessible de manière simple à travers six étapes clés.

Commençons dès maintenant par le template HTML !

Étape 1 : Le template HTML (30 minutes)

La fenêtre modale (10 minutes)

La première étape consiste à créer le conteneur de la fenêtre modale. La méthode la plus simple (et compatible*) est d’ajouter une balise <div> avec l’attribut role="dialog".

*Nous n’utiliserons volontairement pas la balise <dialog> qui est à l’heure actuelle (Janvier 2018) encore mal supportée :

Support navigateur de l'élément dialog

<div role="dialog"> indique aux technologies d’assistance que le contenu se présente sous forme d’une boîte de dialogue d’application séparée du reste du document Web. Au focus sur cette boîte de dialogue, les technologies d’assistance doivent être capables de restituer son nom via l’attribut aria-labelledby et sa description (si renseignée) via l’attribut aria-describedby.

Une des particularités du role="dialog" est de changer le mode de navigation « document » (par défaut) en mode « application ». Ce changement de mode peut perturber la navigation d’un utilisateur de lecteurs d’écran (désactivation des touches « Flèche haut » et « Flèche bas »).

Une solution consiste à ajouter à la suite une balise <div role="document"> afin de repasser dans un mode de navigation par défaut d’un document Web.

Pour informer les technologies d’assistance que le document principal n’est plus accessible lorsque la fenêtre modale est affichée, il est possible d’ajouter l’attribut aria-modal="true". Cette technique n’étant pas encore correctement supportée, il sera nécessaire d’utiliser une technique complémentaire que nous verrons par la suite.

La fenêtre modale doit également être focalisable (nécessaire à la gestion des événements clavier). Pour cela, il nous suffit d’ajouter l’attribut tabindex="-1".

La dernière étape consiste à masquer (par défaut) la fenêtre modale aux technologies d’assistance, la solution est d’ajouter l’attribut aria-hidden="true" :

<div
  id="dialog"
  role="dialog"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc"
  aria-modal="true"
  aria-hidden="true"
  tabindex="-1"
  class="c-dialog">
  <div role="document" class="c-dialog__box">
    <h2 id="dialog-title">Ma fenêtre modale</h2>
    <p id="dialog-desc">Je suis une fenêtre modale accessible.</p>
  </div>
</div>

Le formulaire (5 minutes)

Pour l’exemple, nous allons mettre en place un simple formulaire de connexion (non fonctionnel) en ajoutant des champs de type email, password et un bouton de validation.

<form action="" method="post">
  <p>
    <label for="email">Email</label><br />
    <input type="email" id="email" />
  </p>
  <p>
    <label for="password">Mot de passe</label><br />
    <input type="password" id="password" />
  </p>
  <p>
    <button type="submit">Valider</button>
  </p>
</form>

Le(s) bouton(s) de fermeture (5 minutes)

Une fenêtre modale doit avoir au minimum un élément focalisable, en général il s’agit du bouton de fermeture :

<button type="button">X</button>

Afin d’éviter que le caractère « X » soit lu par un lecteur d’écran, il est nécessaire de labelliser correctement le bouton de fermeture grâce à l’attribut aria-label. Je vous conseille également d’ajouter un attribut title afin d’apporter une information textuelle au survol de la souris.

Pour finir, nous ajoutons un attribut de données data-dismiss="dialog" qui nous permettra par la suite de pouvoir y accéder en JavaScript afin de fermer la fenêtre modale :

<button 
  type="button" 
  aria-label="Fermer"
  title="Fermer cette fenêtre modale"
  data-dismiss="dialog">X
</button>

Le(s) bouton(s) d’appel (5 minutes)

Pour ouvrir la fenêtre modale, il est nécessaire de définir un bouton d’appel. Attention, il s’agit d’un élément de type <button> et non d’un lien hypertexte <a>. (Si vous devez néanmoins utiliser une balise <a>, ajoutez-y le role="button").

Afin d’indiquer que ce bouton déclenche l’ouverture d’une fenêtre modale, il est nécessaire d’ajouter l’attribut aria-haspopup avec pour valeur le rôle dialog.

Enfin, pour lier le bouton d’appel à sa fenêtre modale, il est possible d’ajouter l’attribut aria-controls, avec pour valeur, l’identifiant de la fenêtre modale.

<button 
  type="button" 
  aria-haspopup="dialog"
  aria-controls="dialog">Ouvrir ma fenêtre modale
</button>

Le document HTML (5 minutes)

Pour rappel, le document principal doit être désactivé tant que la fenêtre modale est visible.

Pour satisfaire cette condition et en complément de l’utilisation de l’attribut aria-modal="true", la fenêtre modale doit se situer en dehors du document principal.

La structure HTML finale devrait ressembler à ça :

<!doctype html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
    <title>Article : Une fenêtre modale accessible</title>
  </head>
  <body class="js-page">
    <main class="js-document">
      <button 
        type="button" 
        aria-haspopup="dialog"
        aria-controls="dialog">Ouvrir ma fenêtre modale
      </button>
    </main>
    <div 
      id="dialog"
      role="dialog" 
      aria-labelledby="dialog-title" 
      aria-describedby="dialog-desc"
      aria-modal="true"
      aria-hidden="true"
      tabindex="-1" 
      class="c-dialog">
      <div role="document" class="c-dialog__box">
        <button 
          type="button" 
          aria-label="Fermer" 
          title="Fermer cette fenêtre modale"
          data-dismiss="dialog">X
        </button>
        <h2 id="dialog-title">Ma fenêtre modale</h2>
        <p id="dialog-desc">Je suis une fenêtre modale accessible.</p>
        <form action="" method="post">
          <p>
            <label for="email">Email</label><br />
            <input type="email" id="email" />
          </p>
          <p>
            <label for="password">Mot de passe</label><br />
            <input type="password" id="password" />
          </p>
          <p>
            <button type="submit">Valider</button>
          </p>
        </form>
      </div>
    </div>
  </body>
</html>

Étape 2 : La mise en forme CSS (10 minutes)

Nous allons volontairement survoler cette partie. Le but de cet article n’étant pas de mettre en forme une fenêtre modale, nous nous contenterons simplement de mettre en place les styles basiques destinés à son affichage :

// dialog overlay
.c-dialog {
  position: fixed;
  z-index: 100;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  display: flex;
  padding: 2.4rem;
  overflow-y: scroll;
  -webkit-overflow-scrolling: touch;
  background-color: rgba(black, .75);
  transition: .2s;
}

// dialog box
.c-dialog__box {
  flex: 1;
  max-width: 48rem;
  margin: auto;
  padding: 2.4rem;
  background-color: white;
}

// hidden dialog
.c-dialog[aria-hidden="true"] {
  visibility: hidden;
  opacity: 0;
}

Étape 3 : La gestion de l’affichage en JavaScript (25 minutes)

Commençons par gérer les deux événements basiques en JavaScript, à savoir, l’ouverture et la fermeture d’une fenêtre modale.

C’est parti ! 😛

L’ouverture de la fenêtre modale (15 minutes)

Implémentons le JavaScript permettant de récupérer tous les composants de type aria-haspopup="dialog" d’un document ainsi que les fenêtres modales liées :

document.addEventListener('DOMContentLoaded', () => { 
  const triggers = document.querySelectorAll('[aria-haspopup="dialog"]');

  triggers.forEach((trigger) => {
    const dialog = document.getElementById(trigger.getAttribute('aria-controls'));
  });
});

Nous pouvons maintenant créer une fonction destinée à l’ouverture de la fenêtre modale en passant simplement la valeur de son attribut aria-hidden à false :

document.addEventListener('DOMContentLoaded', () => { 
  const triggers = document.querySelectorAll('[aria-haspopup="dialog"]');

  const open = function (dialog) {
    dialog.setAttribute('aria-hidden', false);
  };

  triggers.forEach((trigger) => {
    const dialog = document.getElementById(trigger.getAttribute('aria-controls'));

    // open dialog
    trigger.addEventListener('click', (event) => {
      event.preventDefault();

      open(dialog);
    });
  });
});

La dernière étape consiste à désactiver le document principal lorsque la fenêtre modale est active :

document.addEventListener('DOMContentLoaded', () => { 
  const triggers = document.querySelectorAll('[aria-haspopup="dialog"]');
  const doc = document.querySelector('.js-document');

  const open = function (dialog) {
    dialog.setAttribute('aria-hidden', false);
    doc.setAttribute('aria-hidden', true);
  };

  triggers.forEach((trigger) => {
    const dialog = document.getElementById(trigger.getAttribute('aria-controls'));

    // open dialog
    trigger.addEventListener('click', (event) => {
      event.preventDefault();

      open(dialog);
    });
  });
});

La fermeture de la fenêtre modale (10 minutes)

Il est possible de fermer une fenêtre modale de deux façons* :

  • Au clic sur un(des) bouton(s) de fermeture dans la fenêtre modale
  • Au clic en dehors de la fenêtre modale (dans l’arrière-plan)

*Pour le moment, je ne parle pas des événements claviers que nous verrons un peu plus loin

Commençons par écrire la fonction de fermeture, qui est à peu de chose près identique à la fonction d’ouverture de la fenêtre modale :

const close = function (dialog) {
  dialog.setAttribute('aria-hidden', true);
  doc.setAttribute('aria-hidden', false);
};

Maintenant, il nous reste à récupérer la valeur des attributs de données data-dismiss des boutons de fermeture et d’appeler la fonction close() en passant cette valeur :

const dismissTriggers = dialog.querySelectorAll('[data-dismiss]');

// close dialog
dismissTriggers.forEach((dismissTrigger) => {
  const dismissDialog = document.getElementById(dismissTrigger.dataset.dismiss);

  dismissTrigger.addEventListener('click', (event) => {
    event.preventDefault();

    close(dismissDialog);
    });
});    

La gestion de l’événement clic sur l’arrière-plan peut se faire de manière très simple :

window.addEventListener('click', (event) => {
  if (event.target === dialog) {
    close(dialog);
  }
});

La gestion de l’affichage de la fenêtre modale est maintenant terminée, le code JavaScript devrait ressembler à ça :

document.addEventListener('DOMContentLoaded', () => { 
  const triggers = document.querySelectorAll('[aria-haspopup="dialog"]');
  const doc = document.querySelector('.js-document');

  const open = function (dialog) {
    dialog.setAttribute('aria-hidden', false);
    doc.setAttribute('aria-hidden', true);
  };

  const close = function (dialog) {
    dialog.setAttribute('aria-hidden', true);
    doc.setAttribute('aria-hidden', false);
  };

  triggers.forEach((trigger) => {
    const dialog = document.getElementById(trigger.getAttribute('aria-controls'));
    const dismissTriggers = dialog.querySelectorAll('[data-dismiss]');

    // open dialog
    trigger.addEventListener('click', (event) => {
      event.preventDefault();

      open(dialog);
    });

    // close dialog
    dismissTriggers.forEach((dismissTrigger) => {
      const dismissDialog = document.getElementById(dismissTrigger.dataset.dismiss);

      dismissTrigger.addEventListener('click', (event) => {
        event.preventDefault();

        close(dismissDialog);
      });
    });

    window.addEventListener('click', (event) => {
      if (event.target === dialog) {
        close(dialog);
      }
    });
  });
});

Les prochaines étapes vont permettre d’améliorer l’accessibilité de notre fenêtre modale. Elles correspondent précisément aux trois étapes définies en début d’article :

  • La gestion du focus clavier
  • La gestion des événements « Entrée » et « Echap »
  • La gestion de la tabulation

Le Design Pattern détaillé se trouve à cette adresse : https://www.w3.org/TR/wai-aria-practices-1.1/#dialog_modal.

Commençons donc par la gestion du focus clavier ! 😛

Étape 4 : La gestion du focus clavier en JavaScript (15 minutes)

Dans cette partie, nous allons nous intéresser à deux comportements :

  • À l’ouverture d’une fenêtre modale, le focus clavier doit se positionner sur le premier élément focalisable contenu dans la fenêtre
  • À la fermeture d’une fenêtre modale, le focus clavier doit se repositionner sur le bouton d’appel de la fenêtre

Le focus clavier à l’ouverture de la fenêtre modale (10 minutes)

La première étape est de sélectionner tous les éléments focalisables contenus dans la fenêtre modale :

const focusableElementsArray = [
  '[href]',
  'button:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
];

const open = function (dialog) {
  const focusableElements = dialog.querySelectorAll(focusableElementsArray);

  dialog.setAttribute('aria-hidden', false);
  doc.setAttribute('aria-hidden', true);
};

Il nous reste maintenant à appliquer le focus clavier sur le premier élément focalisable contenu dans la fenêtre modale :

const open = function (dialog) {
  const focusableElements = dialog.querySelectorAll(focusableElementsArray);
  const firstFocusableElement = focusableElements[0];

  dialog.setAttribute('aria-hidden', false);
  doc.setAttribute('aria-hidden', true);

  // return if no focusable element
  if (!firstFocusableElement) {
    return;
  }

  window.setTimeout(() => {
    firstFocusableElement.focus();
  }, 100);
}

La subtilité réside dans l’utilisation de la fonction setTimeout(). La raison est simple, dans notre exemple CSS, nous affichons la fenêtre modale avec une transition de quelques millisecondes, il est donc nécessaire d’appliquer le focus clavier après celle-ci.

Le focus clavier à fermeture de la fenêtre modale (5 minutes)

Cette étape est très rapide à mettre en place, il nous faut restaurer le focus clavier sur le bouton d’appel lors de la fermeture de la fenêtre modale. Nous allons donc ajouter un second paramètre à notre fonction close(), ce fameux bouton d’appel :

close(dialog, trigger);

Notre fonction close() mise à jour :

const close = function (dialog, trigger) {
  dialog.setAttribute('aria-hidden', true);
  doc.setAttribute('aria-hidden', false);

  // restoring focus
  trigger.focus();
};

Étape 5 : La gestion des événements « Entrée » et « Echap » en JavaScript (5 minutes)

Pour rappel, deux conditions doivent être remplies :

  • Presser la touche « Entrée » sur un bouton d’appel permet d’afficher la fenêtre modale qui lui est liée
  • Presser la touche « Echap » permet de fermer cette fenêtre modale.

Cette étape est particulièrement rapide à mettre en place.

En effet, le but du jeu sera simplement de réutiliser nos fonctions open() et close() définies précédemment en ajoutant nos deux nouveaux événements clavier : « Entrée » et « Echap ».

Dans un premier temps définissons un objet contenant les codes clavier JavaScript « Entrée » et « Echap » (nous pourrons rajouter par la suite d’autres codes clavier selon nos besoins) :

const keyCodes = {
  enter: 13,
  escape: 27,
};

Il ne nous reste plus qu’à ajouter nos deux événements clavier et à appeler nos fonctions open() et close() correspondant.

Ouverture de la fenêtre modale (2 minutes 30)

trigger.addEventListener('keydown', (event) => {
  if (event.which === keyCodes.enter) {
    event.preventDefault();

    open(dialog);
  }  
});

Fermeture de la fenêtre modale (2 minutes 30)

dialog.addEventListener('keydown', (event) => {
  if (event.which === keyCodes.escape) {
    close(dialog, trigger);
  }      
});

Étape 6 : La gestion de la tabulation en JavaScript (20 minutes)

Le document principal étant désactivé au moment où la fenêtre modale s’affiche, le principe est de contenir l’ordre de tabulation dans celle-ci. Deux règles en découlent :

  • Lorsque le dernier élément focalisable est atteint, le prochain élément à être focalisé via la touche de tabulation sera le premier élément focalisable
  • Lorsque le premier élément focalisable est atteint, le prochain élément à être focalisé via la touche Maj + tabulation sera le dernier élément focalisable

Dans un premier temps, il est nécessaire de sauvegarder le dernier élément focalisable :

const lastFocusableElement = focusableElements[focusableElements.length - 1];

Mettons à jour notre objet contenant les codes clavier JavaScript, en ajoutant la touche de tabulation :

const keyCodes = {
  tab: 9,
  enter: 13,
  escape: 27,  
};

Nous pouvons maintenant mettre à jour notre fonction open() :

window.setTimeout(() => {
  firstFocusableElement.focus();

  // trapping focus inside the dialog
  focusableElements.forEach((focusableElement) => {
    if (focusableElement.addEventListener) {
      focusableElement.addEventListener('keydown', (event) => {
        const tab = event.which === keyCodes.tab;

        if (!tab) {
          return;
        }

        if (event.shiftKey) {
          if (event.target === firstFocusableElement) { // shift + tab
            event.preventDefault();

            lastFocusableElement.focus();
          }
        } else if (event.target === lastFocusableElement) { // tab
          event.preventDefault();

          firstFocusableElement.focus();
        }
      });
    }
  });
}, 100);

Notre fenêtre modale est maintenant parfaitement accessible ! 🤗

Une fenêtre modale accessible

Notre code final devrait ressembler à ça :

Conclusion

Nous venons de voir à travers ces six étapes comment implémenter une fenêtre modale de façon optimale afin de répondre à un maximum de critères d’accessibilité (et tout ça, en moins de deux heures !).

Concevoir un composant Web, c’est avant tout penser à l’utilisateur. Il est nécessaire de réfléchir à tous les moyens qu’a celui-ci d’accéder à ce composant. La navigation au clavier en fait partie.

Dans un prochain article, nous nous intéresserons à rendre ce composant facilement réutilisable sous la forme d’un plugin JavaScript.

Quelques liens de documentation :

blog comments powered by Disqus