Crear un cuadro de diálogo accesible desde cero
Publicado: 2022-03-10En primer lugar, no hagas esto en casa. No escriba sus propios diálogos o una biblioteca para hacerlo. Ya hay muchos de ellos que han sido probados, auditados, usados y reutilizados y debería preferir estos a los suyos. a11y-dialog es uno de ellos, pero hay más (enumerados al final de este artículo).
Permítanme tomar esta publicación como una oportunidad para recordarles a todos que sean cautelosos al usar cuadros de diálogo . Es tentador abordar todos los problemas de diseño con ellos, especialmente en dispositivos móviles, pero a menudo hay otras formas de superar los problemas de diseño. Tendemos a caer rápidamente en el uso de cuadros de diálogo no porque sean necesariamente la opción correcta, sino porque son fáciles. Dejan de lado los problemas de estado de la pantalla cambiándolos por un cambio de contexto, que no siempre es la compensación correcta. El punto es: considere si un cuadro de diálogo es el patrón de diseño correcto antes de usarlo.
En esta publicación, vamos a escribir una pequeña biblioteca de JavaScript para crear diálogos accesibles desde el principio (esencialmente recreando a11y-dialog). El objetivo es entender lo que entra. No vamos a tratar demasiado el estilo, solo la parte de JavaScript. Usaremos JavaScript moderno en aras de la simplicidad (como clases y funciones de flecha), pero tenga en cuenta que es posible que este código no funcione en navegadores heredados.
- Definición de la API
- Instanciando el diálogo
- Mostrar y ocultar
- Cierre con superposición
- Cerrando con escape
- Foco de reventado
- Mantener el enfoque
- Restaurando el enfoque
- Dar un nombre accesible
- Manejo de eventos personalizados
- Limpiar
- Reúnelo todo
- Terminando
Definición de la API
Primero, queremos definir cómo vamos a usar nuestro script de diálogo. Vamos a mantenerlo lo más simple posible para empezar. Le asignamos el elemento HTML raíz para nuestro diálogo, y la instancia que obtenemos tiene un .show(..)
y .hide(..)
.
class Dialog { constructor(element) {} show() {} hide() {} }
Instanciando el diálogo
Digamos que tenemos el siguiente HTML:
<div>This will be a dialog.</div>
E instanciamos nuestro diálogo así:
const element = document.querySelector('#my-dialog') const dialog = new Dialog(element)
Hay algunas cosas que debemos hacer bajo el capó al crear una instancia:
- Ocultarlo para que esté oculto de forma predeterminada (
hidden
). - Márquelo como un cuadro de diálogo para tecnologías de asistencia (
role="dialog"
). - Haga que el resto de la página sea inerte cuando esté abierta (
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) }
Tenga en cuenta que podríamos haber agregado estos 3 atributos en nuestro HTML inicial para no tener que agregarlos con JavaScript, pero de esta manera está fuera de la vista, fuera de la mente. Nuestro script puede asegurarse de que las cosas funcionen como deberían, independientemente de si hemos pensado en agregar todos nuestros atributos o no.
Mostrar y ocultar
Tenemos dos métodos: uno para mostrar el diálogo y otro para ocultarlo. Estos métodos no harán mucho (por ahora) además de alternar el atributo hidden
en el elemento raíz. También vamos a mantener un valor booleano en la instancia para poder evaluar rápidamente si se muestra o no el cuadro de diálogo. Esto será útil más adelante.
show() { this.isShown = true this.element.removeAttribute('hidden') } hide() { this.isShown = false this.element.setAttribute('hidden', true) }
Para evitar que el cuadro de diálogo sea visible antes de que JavaScript se active y lo oculte al agregar el atributo, podría ser interesante agregar hidden
al cuadro de diálogo directamente en el HTML desde el principio.
<div hidden>This will be a dialog.</div>
Cierre con superposición
Hacer clic fuera del cuadro de diálogo debería cerrarlo. Hay varias formas de hacerlo. Una forma podría ser escuchar todos los eventos de clic en la página y filtrar los que ocurren dentro del cuadro de diálogo, pero eso es relativamente complejo de hacer.
Otro enfoque sería escuchar los eventos de clic en la superposición (a veces llamado "telón de fondo"). La superposición en sí puede ser tan simple como un <div>
con algunos estilos.
Entonces, al abrir el cuadro de diálogo, debemos vincular eventos de clic en la superposición. Podríamos darle un ID o una clase determinada para poder consultarlo, o podríamos darle un atributo de datos. Tiendo a favorecer estos como ganchos de comportamiento. Modifiquemos nuestro HTML en consecuencia:
<div hidden> <div data-dialog-hide></div> <div>This will be a dialog.</div> </div>
Ahora, podemos consultar los elementos con el atributo data-dialog-hide
dentro del diálogo y darles un detector de clics que oculta el diálogo.
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)) }
Lo bueno de tener algo tan genérico como esto es que también podemos usar lo mismo para el botón de cerrar del cuadro de diálogo.
<div hidden> <div data-dialog-hide></div> <div> This will be a dialog. <button type="button" data-dialog-hide>Close</button> </div> </div>
Cerrar con escape
El cuadro de diálogo no solo debe ocultarse al hacer clic fuera de él, sino que también debe ocultarse al presionar Esc . Al abrir el cuadro de diálogo, podemos vincular un detector de teclado al documento y eliminarlo al cerrarlo. De esta manera, solo escucha las pulsaciones de teclas mientras el cuadro de diálogo está abierto en lugar de todo el tiempo.
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() }
Foco de captura
Eso es lo bueno. Atrapar el foco dentro del diálogo es la esencia de todo, y tiene que ser la parte más complicada (aunque probablemente no tan complicada como podrías pensar).
La idea es bastante simple: cuando el cuadro de diálogo está abierto, escuchamos las pulsaciones de Tabulador . Si presiona Tabulador en el último elemento enfocable del cuadro de diálogo, movemos el foco mediante programación al primero. Si presionamos Shift + Tab en el primer elemento enfocable del diálogo, lo movemos al último.
La función podría verse así:
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() } }
Lo siguiente que debemos averiguar es cómo obtener todos los elementos enfocables del diálogo ( getFocusableChildren
). Necesitamos consultar todos los elementos que teóricamente pueden ser enfocables, y luego debemos asegurarnos de que efectivamente lo sean.
La primera parte se puede hacer con selectores enfocables. Es un paquete diminuto que escribí que proporciona esta variedad de selectores:
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^="-"])', ]
Y esto es suficiente para llegar al 99% allí. Podemos usar estos selectores para encontrar todos los elementos enfocables, y luego podemos verificar cada uno de ellos para asegurarnos de que esté realmente visible en la pantalla (y no oculto o algo así).
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) }
Ahora podemos actualizar nuestro método handleKeyDown
:
handleKeyDown(event) { if (event.key === 'Escape') this.hide() else if (event.key === 'Tab') trapTabKey(this.element, event) }
Mantener el enfoque
Una cosa que a menudo se pasa por alto al crear cuadros de diálogo accesibles es asegurarse de que el foco permanezca dentro del cuadro de diálogo incluso después de que la página haya perdido el foco. Piénselo de esta manera: ¿qué sucede si una vez que se abre el cuadro de diálogo? Enfocamos la barra de URL del navegador y luego comenzamos a tabular nuevamente. Nuestra trampa de enfoque no funcionará, ya que solo conserva el enfoque dentro del diálogo cuando está dentro del diálogo para empezar.
Para solucionar ese problema, podemos vincular un oyente de enfoque al elemento <body>
cuando se muestra el cuadro de diálogo y mover el foco al primer elemento enfocable dentro del cuadro de diálogo.
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() }
No se aplica qué elemento enfocar al abrir el cuadro de diálogo y podría depender del tipo de contenido que muestra el cuadro de diálogo. En términos generales, hay un par de opciones:
- Enfoca el primer elemento.
Esto es lo que hacemos aquí, ya que se facilita por el hecho de que ya tenemos una funcióngetFocusableChildren
. - Enfoca el botón de cerrar.
Esta también es una buena solución, especialmente si el botón está absolutamente posicionado en relación con el cuadro de diálogo. Podemos hacer que esto suceda convenientemente colocando nuestro botón de cerrar como el primer elemento de nuestro diálogo. Si el botón de cerrar vive en el flujo del contenido del diálogo, al final, podría ser un problema si el diálogo tiene mucho contenido (y por lo tanto es desplazable), ya que desplazaría el contenido hasta el final al abrirlo. - Enfoca el diálogo en sí .
Esto no es muy común entre las bibliotecas de diálogo, pero también debería funcionar (aunque sería necesario agregarletabindex="-1"
para que sea posible ya que un elemento<div>
no se puede enfocar de forma predeterminada).
Tenga en cuenta que verificamos si hay un elemento con el atributo HTML de autofocus
dentro del cuadro de diálogo, en cuyo caso moveríamos el foco a él en lugar del primer elemento.
Restaurando el enfoque
Logramos atrapar con éxito el enfoque dentro del cuadro de diálogo, pero olvidamos mover el enfoque dentro del cuadro de diálogo una vez que se abre. De manera similar, debemos restaurar el foco al elemento que lo tenía antes de que se abriera el cuadro de diálogo.
Al mostrar el diálogo, podemos comenzar manteniendo una referencia al elemento que tiene el foco ( document.activeElement
). La mayoría de las veces, este será el botón con el que se interactuó para abrir el cuadro de diálogo, pero en casos excepcionales en los que se abre un cuadro de diálogo mediante programación, podría ser otra cosa.
show() { this.previouslyFocused = document.activeElement // … rest of the code this.moveFocusIn() }
Al ocultar el cuadro de diálogo, podemos mover el foco de nuevo a ese elemento. Lo protegemos con una condición para evitar un error de JavaScript si el elemento ya no existe (o si era un SVG):
hide() { // … rest of the code if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } }
Dar un nombre accesible
Es importante que nuestro cuadro de diálogo tenga un nombre accesible, que es como aparecerá en el árbol de accesibilidad. Hay un par de formas de abordarlo, una de las cuales es definir un nombre en el atributo aria-label
, pero aria-label
tiene problemas.
Otra forma es tener un título dentro de nuestro diálogo (ya sea oculto o no), y asociar nuestro diálogo con el atributo aria-labelledby
. Podría verse así:
<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>
Supongo que podríamos hacer que nuestra secuencia de comandos aplique este atributo dinámicamente en función de la presencia del título y otras cosas, pero diría que esto se resuelve fácilmente creando HTML adecuado, para empezar. No es necesario agregar JavaScript para eso.
Manejo de eventos personalizados
¿Qué pasa si queremos reaccionar cuando se abre el cuadro de diálogo? O cerrado? Actualmente no hay forma de hacerlo, pero agregar un sistema de eventos pequeños no debería ser demasiado difícil. Necesitamos una función para registrar eventos (llamémosla .on(..)
), y una función para cancelar su registro ( .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) } }
Luego, al mostrar y ocultar el método, llamaremos a todas las funciones que se han registrado para ese evento en particular.
class Dialog { show() { // … rest of the code this.events.show.forEach(event => event()) } hide() { // … rest of the code this.events.hide.forEach(event => event()) } }
Limpiar
Es posible que deseemos proporcionar un método para limpiar un cuadro de diálogo en caso de que hayamos terminado de usarlo. Se encargaría de dar de baja a los event listeners para que no duren más de lo debido.
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)) } }
Reuniéndolo todo
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) }
Terminando
Eso fue bastante, ¡pero finalmente llegamos allí! Una vez más, desaconsejaría implementar su propia biblioteca de diálogos, ya que no es la más sencilla y los errores podrían ser muy problemáticos para los usuarios de tecnología de asistencia. ¡Pero al menos ahora sabes cómo funciona debajo del capó!
Si necesita usar cuadros de diálogo en su proyecto, considere usar una de las siguientes soluciones (recuerde que también tenemos nuestra lista completa de componentes accesibles):
- Implementaciones de JavaScript Vanilla: a11y-dialog por su servidor o aria-modal-dialog por Scott O'Hara.
- Implementaciones de React: react-a11y-dialog de nuevo, reach/dialog del marco Reach, o @react-aria/dialog de Adobe. Puede que te interese esta comparación de las 3 bibliotecas.
- Implementaciones de Vue: vue-a11y-dialog de Moritz Kroger, a11y-vue-dialog de Renato de Leao.
Aquí hay más cosas que podrían agregarse pero no lo fueron por simplicidad:
- Compatibilidad con diálogos de alerta a través del rol
alertdialog
. Consulte la documentación de a11y-dialog sobre los diálogos de alerta. - Bloqueo de la capacidad de desplazarse mientras el cuadro de diálogo está abierto. Consulte la documentación de a11y-dialog sobre el bloqueo de desplazamiento.
- Compatibilidad con el elemento HTML nativo
<dialog>
porque es insatisfactorio e inconsistente. Consulte la documentación de a11y-dialog sobre el elemento de diálogo y este artículo de Scott O'hara para obtener más información sobre por qué no vale la pena. - Soporte para diálogos anidados porque es cuestionable. Consulte la documentación de a11y-dialog sobre diálogos anidados.
- Consideración para cerrar el cuadro de diálogo en la navegación del navegador. En algunos casos, puede tener sentido cerrar el cuadro de diálogo al presionar el botón Atrás del navegador.