Crear un cuadro de diálogo accesible desde cero

 

 

 

  • Patrones de diseño de interfaces inteligentes, vídeo de 10h + formación UX
  • ¡Registro!

  • Índice
    1. Definiendo la API
    2. Creación de instancias del diálogo
    3. Mostrar y ocultar
    4. Cerrando con superposición
    5. Cerrando con escape
    6. Enfoque de captura
    7. Mantener el enfoque
    8. Restaurando el enfoque
    9. Dar un nombre accesible
    10. Manejo de eventos personalizados
    11. Limpiar
    12. Reuniéndolo todo
    13. Terminando

    Los diálogos están en todas partes en el diseño de interfaces moderno (para bien o para mal) y, sin embargo, muchos de ellos no son accesibles a las tecnologías de asistencia. En este artículo, profundizaremos en cómo crear un script breve para crear cuadros de diálogo accesibles.

     

    En primer lugar, no hagas esto en casa. No escriba sus propios diálogos ni una biblioteca para hacerlo. Ya existen muchos que han sido probados, auditados, usados ​​y reutilizados y deberías preferir estos a los tuyos. a11y-dialog es uno de ellos, pero hay más (enumerados al final de este artículo).

    Permítanme aprovechar esta publicación como una oportunidad para recordarles a todos que tengan cuidado al utilizar los 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 intercambiándolos por cambio de contexto, lo que no siempre es la compensación correcta. El punto es: considere si un 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 cuadros de diálogo accesibles desde el principio (esencialmente recreando a11y-dialog). El objetivo es entender lo que implica. No nos ocuparemos demasiado del estilo, solo de la parte de JavaScript. Usaremos JavaScript moderno para simplificar (como clases y funciones de flecha), pero tenga en cuenta que es posible que este código no funcione en navegadores heredados.

    1. Definiendo la API
    2. Crear instancias del diálogo
    3. Mostrar y ocultar
    4. Cerrando con superposición
    5. Cerrando con escape
    6. Enfoque de captura
    7. Mantener el enfoque
    8. Restaurando el enfoque
    9. Dar un nombre accesible
    10. Manejo de eventos personalizados
    11. Limpiar
    12. Reúnelo todo
    13. Terminando

    Definiendo la API

    Primero, queremos definir cómo vamos a usar nuestro script de diálogo. Para empezar, lo mantendremos lo más simple posible. Le damos el elemento HTML raíz de nuestro diálogo y la instancia que obtenemos tiene un método .show(..)y un .hide(..)método.

    class Dialog { constructor(element) {} show() {} hide() {}}

    Digamos que tenemos el siguiente HTML:

    divThis will be a dialog./div

    Y creamos una instancia de nuestro diálogo así:

    const element = document.querySelector('#my-dialog')const dialog = new Dialog(element)

    Hay algunas cosas que debemos hacer internamente al crear una instancia:

    • Ocultarlo para que esté oculto de forma predeterminada ( hidden).
    • Márquelo como un diálogo para tecnologías de asistencia ( role="dialog").
    • Haga que el resto de la página esté 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 garantizar 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 hiddenatributo en el elemento raíz. También mantendremos un valor booleano en la instancia para poder evaluar rápidamente si el diálogo se muestra o no. 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 agregando el atributo, podría ser interesante agregarlo hiddenal cuadro de diálogo directamente en HTML desde el principio.

    div hiddenThis will be a dialog./div

    Cerrando con superposición

    Al hacer clic fuera del cuadro de diálogo debería cerrarse. 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 divcon algunos estilos.

    Entonces, al abrir el cuadro de diálogo, debemos vincular los 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 preferirlos como ganchos de comportamiento. Modifiquemos nuestro HTML en consecuencia:

    div hidden div data-dialog-hide/div divThis will be a dialog./div/div

    Ahora, podemos consultar los elementos con el data-dialog-hideatributo dentro del cuadro de diálogo y darles un detector de clic que oculta el cuadro de 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 bastante genérico como este 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-hideClose/button /div/div

    Cerrando 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 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()}

    Enfoque 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ía pensar).

    La idea es bastante simple: cuando el diálogo está abierto, escuchamos las Tabpulsaciones. Si presionamos Tabel último elemento enfocable del diálogo, movemos el foco mediante programación al primero. Si presionamos Shift+ Taben 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 descubrir 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í y 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%. 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í). Todo sobre Apple, Mac e Iphone

    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 handleKeyDownmétodo:

     

    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 abierto el diálogo? Enfocamos la barra de URL del navegador y luego comenzamos a tabular nuevamente. Nuestra trampa de enfoque no va a funcionar, ya que solo conserva el enfoque dentro del diálogo cuando, para empezar, está dentro del diálogo.

    Para solucionar ese problema, podemos vincular un detector de enfoque al bodyelemento cuando se muestra el diálogo y mover el foco al primer elemento enfocable dentro del 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 exige qué elemento centrarse al abrir el cuadro de diálogo y podría depender del tipo de contenido que muestre 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 es más fácil porque ya tenemos una getFocusableChildrenfunción.
    • 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 se encuentra 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.
    • Enfoque 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 agregarlotabindex="-1"para que sea posible, ya que undivelemento no se puede enfocar de forma predeterminada).

    Tenga en cuenta que verificamos si hay un elemento con el autofocusatributo HTML 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 foco dentro del cuadro de diálogo, pero olvidamos mover el foco dentro del cuadro de diálogo una vez que se abre. De manera similar, necesitamos restaurar el foco al elemento que lo tenía antes de que se abriera el 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 un cuadro de diálogo se abre 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 volver a mover el foco a ese elemento. Lo protegemos con una condición para evitar un error de JavaScript si el elemento de alguna manera 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 solucionarlo, una de las cuales es definir un nombre en el aria-labelatributo, pero aria-labeltiene problemas .

    Otra forma es tener un título dentro de nuestro diálogo (ya sea oculto o no) y asociarle nuestro diálogo con el aria-labelledbyatributo. Podría verse así:

    div hidden aria-labelledby="my-dialog-title" div data-dialog-hide/div div h1My dialog title/h1 This will be a dialog. button type="button" data-dialog-hideClose/button /div/div

    Supongo que podríamos hacer que nuestro script aplique este atributo dinámicamente en función de la presencia del título y demás, pero yo diría que, para empezar, esto se resuelve con la misma facilidad creando HTML adecuado. No es necesario agregar JavaScript para eso.

    Manejo de eventos personalizados

    ¿Qué pasa si queremos reaccionar ante la apertura del 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 el registro de ellos ( .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 hayan 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 queramos proporcionar un método para limpiar un cuadro de diálogo en caso de que hayamos terminado de usarlo. Sería responsable de dar de baja los oyentes de eventos para que no duren más de lo que deberían.

     

    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 lo logramos! 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 bajo el capó!

    Si necesita utilizar 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 Vanilla JavaScript: a11y-dialog de un servidor o aria-modal-dialog de Scott O'Hara.
    • Implementaciones de React: reaccione-a11y-dialog por su servidor nuevamente, alcance/diálogo desde el marco Reach o @react-aria/dialog de Adobe. Quizás te interese esta comparativa de las 3 bibliotecas .
    • Implementaciones de Vue: vue-a11y-dialog de Moritz Kröger, a11y-vue-dialog de Renato de Leão.

    Aquí hay más cosas que podrían agregarse pero no por simplicidad:

    • Soporte para cuadros de diálogo de alerta a través del alertdialogrol. Consulte la documentación de a11y-dialog sobre cuadros de diálogo de alerta .
    • Bloquear la capacidad de desplazarse mientras el cuadro de diálogo está abierto. Consulte la documentación del diálogo a11y sobre el bloqueo de desplazamiento .
    • Soporte para el dialogelemento HTML nativo porque es deficiente 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 cuadros de diálogo 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.

    (vf, il)Explora más en

    • Navegadores
    • Interfaces
    • javascript
    • Accesibilidad





    Tal vez te puede interesar:

    1. ¿Deberían abrirse los enlaces en ventanas nuevas?
    2. 24 excelentes tutoriales de AJAX
    3. 70 técnicas nuevas y útiles de AJAX y JavaScript
    4. Más de 45 excelentes recursos y repositorios de fragmentos de código

    Crear un cuadro de diálogo accesible desde cero

    Crear un cuadro de diálogo accesible desde cero

    Definiendo la APICreación de instancias del diálogoMostrar y ocultarCerrando con superposiciónCerrando con escapeEnfoque de capturaMantener el enfoqueRestau

    programar

    es

    https://aprendeprogramando.es/static/images/programar-crear-un-cuadro-de-dialogo-accesible-desde-cero-1113-0.jpg

    2024-12-03

     

    Crear un cuadro de diálogo accesible desde cero
    Crear un cuadro de diálogo accesible desde cero

    Si crees que alguno de los contenidos (texto, imagenes o multimedia) en esta página infringe tus derechos relativos a propiedad intelectual, marcas registradas o cualquier otro de tus derechos, por favor ponte en contacto con nosotros en el mail [email protected] y retiraremos este contenido inmediatamente

     

     

    Update cookies preferences