Criando uma caixa de diálogo acessível do zero

Publicados: 2022-03-10
Resumo rápido ↬ Diálogos estão em toda parte no design de interface moderno (para o bem ou para o mal), e ainda muitos deles não são acessíveis às tecnologias assistivas. Neste artigo, vamos nos aprofundar em como criar um script curto para criar diálogos acessíveis.

Em primeiro lugar, não faça isso em casa. Não escreva seus próprios diálogos ou uma biblioteca para fazer isso. Existem muitos deles por aí que já foram testados, auditados, usados ​​e reutilizados e você deve preferir esses aos seus. a11y-dialog é um deles, mas há mais (listados no final deste artigo).

Deixe-me aproveitar este post como uma oportunidade para lembrá-los de serem cautelosos ao usar diálogos . Está tentando resolver todos os problemas de design com eles, especialmente em dispositivos móveis, mas geralmente há outras maneiras de superar problemas de design. Nós tendemos a cair rapidamente no uso de diálogos não porque eles são necessariamente a escolha certa, mas porque eles são fáceis. Eles deixam de lado os problemas de propriedade da tela trocando-os por troca de contexto, o que nem sempre é a troca certa. O ponto é: considere se um diálogo é o padrão de design correto antes de usá-lo.

Neste post, vamos escrever uma pequena biblioteca JavaScript para criar diálogos acessíveis desde o início (essencialmente recriando a11y-dialog). O objetivo é entender o que acontece nele. Não vamos lidar muito com estilo, apenas com a parte do JavaScript. Usaremos JavaScript moderno para simplificar (como classes e funções de seta), mas lembre-se de que esse código pode não funcionar em navegadores legados.

  1. Definindo a API
  2. Instanciando o diálogo
  3. Mostrando e escondendo
  4. Fechando com sobreposição
  5. Fechando com fuga
  6. Captura de foco
  7. Mantendo o foco
  8. Restaurando o foco
  9. Dando um nome acessível
  10. Manipulação de eventos personalizados
  11. Limpando
  12. Junte tudo
  13. Empacotando

Definindo a API

Primeiro, queremos definir como usaremos nosso script de diálogo. Vamos mantê-lo o mais simples possível para começar. Damos a ele o elemento HTML raiz para nosso diálogo, e a instância que obtemos tem um .show(..) e um .hide(..) .

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

Instanciando o diálogo

Digamos que temos o seguinte HTML:

 <div>This will be a dialog.</div>

E instanciamos nosso diálogo assim:

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

Existem algumas coisas que precisamos fazer sob o capô ao instanciar:

  • Oculte-o para que fique oculto por padrão ( hidden ).
  • Marque-o como um diálogo para tecnologias assistivas ( role="dialog" ).
  • Torna o resto da página inerte quando aberta ( 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) }

Observe que poderíamos ter adicionado esses 3 atributos em nosso HTML inicial para não ter que adicioná-los com JavaScript, mas desta forma está fora da vista, fora da mente. Nosso script pode garantir que as coisas funcionem como deveriam, independentemente de termos pensado em adicionar todos os nossos atributos ou não.

Mostrando e escondendo

Temos dois métodos: um para mostrar a caixa de diálogo e outro para ocultá-la. Esses métodos não farão muito (por enquanto) além de alternar o atributo hidden no elemento raiz. Também vamos manter um booleano na instância para poder avaliar rapidamente se a caixa de diálogo é mostrada ou não. Isso será útil mais tarde.

 show() { this.isShown = true this.element.removeAttribute('hidden') } hide() { this.isShown = false this.element.setAttribute('hidden', true) }

Para evitar que a caixa de diálogo fique visível antes que o JavaScript entre em ação e a esconda adicionando o atributo, pode ser interessante adicionar hidden à caixa de diálogo diretamente no HTML desde o início.

 <div hidden>This will be a dialog.</div>

Fechando com sobreposição

Clicar fora da caixa de diálogo deve fechá-la. Existem várias maneiras de fazê-lo. Uma maneira pode ser ouvir todos os eventos de clique na página e filtrar aqueles que acontecem na caixa de diálogo, mas isso é relativamente complexo de fazer.

Outra abordagem seria ouvir os eventos de clique na sobreposição (às vezes chamado de “pano de fundo”). A sobreposição em si pode ser tão simples quanto um <div> com alguns estilos.

Portanto, ao abrir a caixa de diálogo, precisamos vincular eventos de clique na sobreposição. Poderíamos dar-lhe um ID ou uma determinada classe para poder consultá-lo, ou poderíamos dar-lhe um atributo de dados. Eu tendo a favorecer estes para ganchos de comportamento. Vamos modificar nosso HTML de acordo:

 <div hidden> <div data-dialog-hide></div> <div>This will be a dialog.</div> </div>

Agora, podemos consultar os elementos com o atributo data-dialog-hide dentro da caixa de diálogo e dar a eles um ouvinte de clique que oculta a caixa 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)) }

O bom de ter algo bem genérico como este é que podemos usar a mesma coisa para o botão fechar da caixa 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>
Mais depois do salto! Continue lendo abaixo ↓

Fechando com Fuga

Não apenas a caixa de diálogo deve ser ocultada ao clicar fora dela, mas também deve ser ocultada ao pressionar Esc . Ao abrir a caixa de diálogo, podemos vincular um ouvinte de teclado ao documento e removê-lo ao fechá-lo. Dessa forma, ele só ouve as teclas pressionadas enquanto a caixa de diálogo está aberta, e não o tempo todo.

 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

Agora essa é a coisa boa. Aprisionar o foco no diálogo é meio que a essência da coisa toda e deve ser a parte mais complicada (embora provavelmente não seja tão complicada quanto você possa pensar).

A ideia é bem simples: quando a caixa de diálogo está aberta, ouvimos as pressões de Tab . Se pressionar Tab no último elemento focalizável da caixa de diálogo, movemos programaticamente o foco para o primeiro. Se pressionar Shift + Tab no primeiro elemento focalizável da caixa de diálogo, o movemos para o último.

A função pode ficar assim:

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

A próxima coisa que precisamos descobrir é como obter todos os elementos focalizáveis ​​da caixa de diálogo ( getFocusableChildren ). Precisamos consultar todos os elementos que teoricamente podem ser focalizados e, em seguida, precisamos ter certeza de que eles são efetivamente.

A primeira parte pode ser feita com seletores focalizáveis. É um pequeno pacote que escrevi que fornece este conjunto de seletores:

 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^="-"])', ]

E isso é o suficiente para você chegar a 99%. Podemos usar esses seletores para encontrar todos os elementos focalizáveis ​​e, em seguida, podemos verificar cada um deles para garantir que estejam realmente visíveis na tela (e não ocultos ou algo assim).

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

Agora podemos atualizar nosso método handleKeyDown :

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

Mantendo o foco

Uma coisa que muitas vezes é negligenciada ao criar caixas de diálogo acessíveis é garantir que o foco permaneça dentro da caixa de diálogo mesmo depois que a página perder o foco. Pense desta forma: o que acontece se a caixa de diálogo for aberta? Focamos a barra de URL do navegador e começamos a tabular novamente. Nossa armadilha de foco não funcionará, pois ela apenas preserva o foco dentro do diálogo quando está dentro do diálogo para começar.

Para corrigir esse problema, podemos vincular um ouvinte de foco ao elemento <body> quando a caixa de diálogo é exibida e mover o foco para o primeiro elemento focalizável na caixa 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() }

Qual elemento focar ao abrir a caixa de diálogo não é aplicado e pode depender do tipo de conteúdo que a caixa de diálogo exibe. De um modo geral, existem algumas opções:

  • Foque o primeiro elemento.
    Isto é o que fazemos aqui, pois é facilitado pelo fato de já termos uma função getFocusableChildren .
  • Foque o botão fechar.
    Esta também é uma boa solução, especialmente se o botão estiver absolutamente posicionado em relação à caixa de diálogo. Podemos convenientemente fazer isso acontecer colocando nosso botão Fechar como o primeiro elemento de nossa caixa de diálogo. Se o botão fechar estiver no fluxo do conteúdo da caixa de diálogo, no final, pode ser um problema se a caixa de diálogo tiver muito conteúdo (e, portanto, for rolável), pois rolaria o conteúdo até o final ao abrir.
  • Foque o próprio diálogo .
    Isso não é muito comum entre as bibliotecas de diálogo, mas também deve funcionar (embora seja necessário adicionar tabindex="-1" a ele para que seja possível, pois um elemento <div> não é focalizável por padrão).

Observe que verificamos se há um elemento com o atributo HTML autofocus dentro da caixa de diálogo, nesse caso, moveríamos o foco para ele em vez do primeiro item.

Restaurando o foco

Conseguimos capturar com sucesso o foco na caixa de diálogo, mas esquecemos de mover o foco para dentro da caixa de diálogo quando ela é aberta. Da mesma forma, precisamos restaurar o foco de volta para o elemento que o tinha antes da caixa de diálogo ser aberta.

Ao mostrar a caixa de diálogo, podemos começar mantendo uma referência ao elemento que tem o foco ( document.activeElement ). Na maioria das vezes, este será o botão com o qual interagiu para abrir a caixa de diálogo, mas em casos raros em que uma caixa de diálogo é aberta programaticamente, pode ser outra coisa.

 show() { this.previouslyFocused = document.activeElement // … rest of the code this.moveFocusIn() }

Ao ocultar a caixa de diálogo, podemos mover o foco de volta para esse elemento. Nós o guardamos com uma condição para evitar um erro de JavaScript se o elemento de alguma forma não existir mais (ou se for um SVG):

 hide() { // … rest of the code if (this.previouslyFocused && this.previouslyFocused.focus) { this.previouslyFocused.focus() } }

Dando um nome acessível

É importante que nosso diálogo tenha um nome acessível, que é como ele será listado na árvore de acessibilidade. Existem algumas maneiras de resolver isso, uma das quais é definir um nome no atributo aria-label , mas aria-label tem problemas.

Outra maneira é ter um título dentro do nosso diálogo (escondido ou não), e associar nosso diálogo a ele com o atributo aria-labelledby . Pode parecer assim:

 <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>

Acho que poderíamos fazer com que nosso script aplicasse esse atributo dinamicamente com base na presença do título e outros enfeites, mas eu diria que isso é facilmente resolvido criando o HTML adequado, para começar. Não há necessidade de adicionar JavaScript para isso.

Manipulação de eventos personalizados

E se quisermos reagir ao diálogo sendo aberto? Ou fechado? Atualmente, não há como fazer isso, mas adicionar um sistema de pequenos eventos não deve ser muito difícil. Precisamos de uma função para registrar eventos (vamos chamá-la de .on(..) ), e uma função para desregistrá-los ( .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) } }

Então, ao mostrar e ocultar o método, chamaremos todas as funções que foram registradas para esse evento específico.

 class Dialog { show() { // … rest of the code this.events.show.forEach(event => event()) } hide() { // … rest of the code this.events.hide.forEach(event => event()) } }

Limpando

Podemos querer fornecer um método para limpar uma caixa de diálogo caso tenhamos terminado de usá-lo. Seria responsável por cancelar o registro de listeners de eventos para que não durem mais do que deveriam.

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

Juntando tudo

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

Empacotando

Isso foi bastante coisa, mas finalmente chegamos lá! Mais uma vez, desaconselho a implantação de sua própria biblioteca de diálogos, pois não é a mais direta e os erros podem ser altamente problemáticos para usuários de tecnologia assistiva. Mas pelo menos agora você sabe como funciona sob o capô!

Se você precisar usar diálogos em seu projeto, considere usar uma das seguintes soluções (lembre-se de que também temos nossa lista abrangente de componentes acessíveis):

  • Implementações de Vanilla JavaScript: a11y-dialog por sua conta ou aria-modal-dialog por Scott O'Hara.
  • Implementações do React: react-a11y-dialog por sua conta novamente, alcance/diálogo do framework Reach, ou @react-aria/dialog da Adobe. Você pode estar interessado nesta comparação das 3 bibliotecas.
  • Implementações Vue: vue-a11y-dialog de Moritz Kroger, a11y-vue-dialog de Renato de Leão.

Aqui estão mais coisas que poderiam ser adicionadas, mas não foram por questão de simplicidade:

  • Suporte para diálogos de alerta por meio da função alertdialog . Consulte a documentação do a11y-dialog sobre os diálogos de alerta.
  • Bloqueando a capacidade de rolar enquanto a caixa de diálogo está aberta. Consulte a documentação da caixa de diálogo a11y sobre bloqueio de rolagem.
  • Suporte para o elemento HTML <dialog> nativo porque é inferior e inconsistente. Consulte a documentação a11y-dialog sobre o elemento dialog e este artigo de Scott O'hara para obter mais informações sobre por que não vale a pena.
  • Suporte para diálogos aninhados porque é questionável. Consulte a documentação a11y-dialog sobre diálogos aninhados.
  • Consideração para fechar a caixa de diálogo na navegação do navegador. Em alguns casos, pode fazer sentido fechar a caixa de diálogo ao pressionar o botão Voltar do navegador.