Popover API et `<dialog>` natif : la fin des modales construites à la main depuis zéro
Popover API et <dialog> natif : la fin des modales construites à la main depuis zéro
Dans l'article sur l'accessibilité des composants, je montrais une modale qui disparaissait visuellement via display: none, mais restait présente dans l'arbre d'accessibilité, parce que quelqu'un l'avait masquée de façon incorrecte. Ce n'était que l'un des cinq problèmes qu'une modale construite à la main sur un <div> doit résoudre elle-même pour seulement prétendre à l'accessibilité : le piège à focus (Tab ne doit pas pouvoir faire sortir l'utilisateur de la modale), la fermeture par la touche Échap, la fermeture par clic en dehors du contenu, le retour du focus vers l'élément qui a ouvert la modale, et le rendu réellement au-dessus du reste de la page, quel que soit le nombre de composants en chemin ayant un overflow: hidden ou leur propre z-index.
jsx
// Ressemble à une modale – ne résout en réalité aucun des cinq problèmes ci-dessusconst NaiveModal = ({ isOpen, onClose, children }) => {if (!isOpen) return null;return (<div className="modal-backdrop" onClick={onClose}><div className="modal">{children}</div></div>);};
Ce code n'a pas de piège à focus – Tab ramène librement l'utilisateur au clavier vers le reste de la page, alors que la modale masque visuellement tout. Il ne se ferme pas avec Échap. Il ne rend pas le focus au déclencheur après fermeture. Et il dépend du fait qu'aucun parent dans l'arbre DOM n'ait de overflow: hidden ni un z-index inférieur à celui d'un autre élément de la page – une hypothèse qui, dans une grande application, s'avère régulièrement fausse. Des bibliothèques comme Radix ou Headless UI existent précisément pour résoudre ce problème une fois pour toutes, correctement, en JavaScript. Depuis quelques années, une partie de ce problème est résolue par le navigateur lui-même, sans une seule ligne de JS.
Le top layer : une couche que z-index n'atteint pas
Le mécanisme clé sur lequel reposent à la fois <dialog> et l'attribut popover s'appelle le top layer – une couche de rendu interne du navigateur, physiquement au-dessus de tout le reste du document, totalement indépendante de z-index, de overflow ou des contextes d'empilement. Un élément promu dans le top layer n'a pas simplement « un z-index très élevé » – il ne fait plus partie de la pile de couches normale de la page, donc aucun parent avec overflow: hidden ne peut le rogner, et aucun élément voisin avec un z-index: 9999 absurde ne peut le recouvrir. C'est le même mécanisme que le navigateur utilisait déjà en interne depuis longtemps pour des éléments natifs comme <video> en mode plein écran – la Popover API et <dialog> le mettent simplement à disposition des développeurs.
jsx
const ConfirmDialog = ({ onConfirm }) => {const dialogRef = useRef(null);return (<dialog ref={dialogRef} className="confirm-dialog"><p>Voulez-vous vraiment supprimer cet élément ?</p><form method="dialog" className="confirm-dialog__actions"><button type="submit" value="cancel">Annuler</button><button type="submit" value="confirm" onClick={onConfirm}>Supprimer</button></form></dialog>);};// ailleurs dans le composant :// dialogRef.current.showModal();
Appeler .showModal() (pas .show() – c'est une erreur fréquente, .show() ouvre une boîte de dialogue non modale, sans fond assombri et sans piège à focus) déclenche simultanément quatre choses gratuitement : la promotion dans le top layer, le rendu du pseudo-élément ::backdrop assombrissant le reste de la page, un piège à focus (Tab boucle uniquement à l'intérieur de la boîte de dialogue) et le passage du reste du document en inert – les éléments hors de la boîte de dialogue cessent d'être focalisables et disparaissent de l'interaction pour un lecteur d'écran, bien qu'ils restent visibles sous le fond assombri. La fermeture via Échap ou via <form method="dialog"> (un mécanisme natif – cliquer sur un bouton submit à l'intérieur d'un tel formulaire ferme la boîte de dialogue et fixe dialog.returnValue à la value du bouton cliqué, sans preventDefault ni gestionnaire en JS) rend automatiquement le focus à l'élément qui a ouvert la boîte de dialogue. Aucune de ces quatre choses ne demande d'être écrite soi-même.
La Popover API : la même chose sans modalité
<dialog> suppose que le reste de la page doit être bloqué. C'est pertinent pour une confirmation de suppression, mais trop lourd pour un menu déroulant, une infobulle ou un toast – des éléments censés s'afficher au-dessus du reste du contenu, se fermer d'eux-mêmes au clic ailleurs, mais ne pas bloquer l'interaction avec le reste de la page. C'est le rôle de l'attribut popover, de façon déclarative, sans une ligne de JavaScript :
html
<button popovertarget="user-menu">Compte</button><div id="user-menu" popover><a href="/profile">Profil</a><a href="/settings">Paramètres</a><button popovertarget="user-menu" popovertargetaction="hide">Se déconnecter</button></div>
La simple relation popovertarget/id suffit pour que le navigateur gère tout le reste : la promotion dans le top layer, le light dismiss (cliquer n'importe où en dehors du popover ou appuyer sur Échap le ferme automatiquement, sans écouter click sur document), et les bonnes relations ARIA entre le déclencheur et le contenu (le navigateur associe lui-même aria-expanded et aria-controls en fonction de cette paire d'attributs). C'est exactement ce qui exigeait auparavant une bibliothèque comme Floating UI ou une écoute manuelle des clics en dehors de l'élément, avec en plus une vérification via event.target.closest().
La différence fondamentale par rapport à <dialog> en mode modal : un popover ne rend pas le reste de la page inert et n'a pas de piège à focus. C'est une décision délibérée de la spécification, pas une fonctionnalité manquante – un menu utilisateur ne devrait pas empêcher de faire défiler ou de cliquer ailleurs sur la page, contrairement à une vraie modale. Confondre ces deux outils dans le mauvais sens – utiliser popover là où le contenu doit réellement bloquer le reste de l'interface (par exemple, confirmer une opération irréversible) – donne une interface où l'utilisateur au clavier peut à tout moment sortir en tabulant d'une « modale » qui ne le bloquait pas du tout.
Le piège dont la documentation reste parfois silencieuse : cliquer sur le fond de <dialog> ne le ferme pas automatiquement
Bien que ::backdrop se rende gratuitement, cliquer dessus ne ferme pas la boîte de dialogue sans code supplémentaire – c'est l'une des rares choses à ajouter manuellement. Le mécanisme qui le permet repose sur un fait précis concernant le hit-testing : l'élément <dialog> lui-même, en mode modal, remplit toute la zone visible pour le hit-testing, indépendamment de la petitesse visuelle de sa boîte de contenu – donc un clic sur le fond assombri touche bien event.target === dialogElement, tandis qu'un clic sur le contenu à l'intérieur touche un élément enfant précis.
jsx
const handleBackdropClick = (event) => {// event.target est le <dialog> lui-même seulement si le clic a touché// le fond, pas un élément du contenu à l'intérieurif (event.target === dialogRef.current) {dialogRef.current.close();}};// <dialog ref={dialogRef} onClick={handleBackdropClick}>
Cela vaut la peine de retenir cette astuce à part, car l'intuition dit « puisque le fond se rend tout seul, il doit se fermer tout seul aussi » – ce n'est pas le cas, et c'est le seul élément de cet ensemble qui exige d'écrire soi-même une ligne de JS.
Pièges et bonnes pratiques
.show()n'est pas la même chose que.showModal()..show()ouvre une boîte de dialogue non modale – sans::backdrop, sans piège à focus, sansinertsur le reste de la page. Pour une vraie modale, il faut toujours.showModal().- Les styles par défaut de
<dialog>doivent être réinitialisés consciemment, tout comme pour chaque élément natif évoqué dans l'article sur l'accessibilité (<button>,<ul>) – le navigateur lui donne par défaut uneborder, unpaddinget un centrage que vous écrasez presque toujours en pratique avec votre propre CSS. - Le popover ne remplace pas
<dialog>là où la modalité est requise. L'absence de piège à focus et d'inertest une limitation volontaire du popover, pas une faille à contourner – si le contenu doit bloquer le reste de la page, l'outil approprié reste toujours<dialog>.showModal(). - Compatibilité :
<dialog>est sûr depuis longtemps,popoverest plus récent.<dialog>(y comprisshowModal()) bénéficie d'un support solide depuis 2022. L'attributpopovera atteint le statut Baseline (largement disponible) plus tard, en 2024 – il reste utile de vérifier la version minimale de Safari prise en charge dans les projets avec une longue traîne d'appareils anciens, avant de renoncer totalement à une bibliothèque JS comme solution de repli.
Conclusion
Une modale construite à la main sur un <div> n'est pas mauvaise parce que le développeur ne s'est pas donné de peine – elle est mauvaise parce qu'elle tente de recréer en JavaScript un mécanisme que le navigateur offre désormais nativement et gratuitement : un top layer indépendant de z-index, un piège à focus, un inert sur le reste de la page, un ::backdrop, le retour du focus au déclencheur. <dialog> et popover ne sont pas deux variantes d'une même chose – ce sont deux outils situés de part et d'autre d'une même frontière : la modalité, qui bloque consciemment le reste de l'interface, et le contenu léger et temporaire, qui ne le bloque consciemment pas. Choisir entre les deux n'est pas une question de style, mais de réponse à la question de savoir si l'utilisateur devrait, à ce moment précis, pouvoir faire autre chose sur la page – une question qu'il vaut mieux se poser avant d'écrire la première ligne de code, pas après coup, quand on découvre qu'un menu bloque le défilement, ou qu'une modale de confirmation de suppression de compte ne bloque pas du tout Tab.