Programação Por Equipe DevBras

Capa do artigo: Modal acessível: o foco entra, não escapa e volta para o botão que abriu

Modal acessível: o foco entra, não escapa e volta para o botão que abriu

Use dialog com showModal para prender o foco e bloquear o fundo, feche com Esc e devolva o foco ao botão de origem, ou a um ponto lógico se ele sumiu.

Na lista de pedidos, a pessoa aperta Tab até "Excluir pedido 4821", confirma no diálogo e ele fecha. Agora o foco está no começo da página. Para chegar ao pedido 4822, que estava logo abaixo, são mais 40 Tabs. Pior: enquanto o diálogo estava aberto, o Tab escapava para os links do fundo, escondidos atrás da camada escura. Com mouse, ninguém percebe. Com teclado, é uma página nova a cada diálogo. A resposta curta: ao abrir, o foco entra no diálogo e o resto da página fica inerte; ao fechar, o foco volta para o controle que abriu. O elemento <dialog> com showModal() resolve as duas primeiras partes. A terceira você garante com três linhas de JavaScript.

E quando o controle que abriu deixou de existir, como o botão do pedido que acabou de ser excluído, o foco vai para um ponto lógico próximo, nunca para o topo por acaso.

Por que o foco se perde

O navegador mantém um único elemento focado. Quando o diálogo é feito com uma div que aparece por cima de tudo, nada muda no foco: ele continua no botão que abriu, atrás da camada escura. O Tab segue a ordem do documento, então passa pelos links do fundo antes ou depois de chegar ao diálogo.

Ao fechar, o código remove ou esconde o elemento que tinha foco dentro do diálogo. Elemento escondido não pode manter o foco, e o navegador o devolve ao body. O próximo Tab começa do topo da página. Para quem usa leitor de tela, também não houve anúncio de que o diálogo abriu, porque nada indicou que aquilo era um diálogo nem qual era o título dele.

Esse custo se repete a cada diálogo. Numa tela de administração em que a pessoa exclui dez itens seguidos, são dez viagens desde o topo da página. É o tipo de defeito que não aparece em teste com mouse e que faz quem depende de teclado desistir da tarefa.

Como fazer o diálogo certo

  1. Use <dialog> e abra com showModal(). O navegador põe o diálogo na camada superior, torna o resto da página inerte, move o foco para dentro e fecha com Esc.
  2. Dê nome ao diálogo com aria-labelledby apontando para o título, para o leitor anunciar "Excluir o pedido 4821?, diálogo" na entrada.
  3. Escolha onde o foco entra. Em confirmação destrutiva, no botão seguro (Cancelar), com autofocus. Em formulário, no primeiro campo.
  4. Guarde quem abriu antes de chamar showModal().
  5. Devolva o foco no evento close, que dispara tanto pelo botão quanto pelo Esc.
  6. Se quem abriu sumiu, foque um ponto lógico: a linha seguinte, o título da lista ou a mensagem de resultado.
  7. Feche com Esc sempre que fechar for seguro. Se houver dados preenchidos, pergunte antes de descartar.

O HTML

<h2 id="titulo-pedidos" tabindex="-1">Pedidos</h2>
<ul>
  <li id="pedido-4821">
    Pedido 4821, R$ 159,90
    <button type="button" data-excluir="4821">Excluir pedido 4821</button>
  </li>
  <li id="pedido-4822">
    Pedido 4822, R$ 89,00
    <button type="button" data-excluir="4822">Excluir pedido 4822</button>
  </li>
</ul>

<dialog id="confirmar" aria-labelledby="titulo-confirmar">
  <h2 id="titulo-confirmar">Excluir o pedido?</h2>
  <p>Esta ação não pode ser desfeita.</p>
  <form method="dialog">
    <button value="cancelar" autofocus>Cancelar</button>
    <button value="excluir">Excluir</button>
  </form>
</dialog>

O JavaScript

const dialogo = document.getElementById('confirmar');
const tituloDialogo = document.getElementById('titulo-confirmar');
const tituloLista = document.getElementById('titulo-pedidos');
let origem = null;
let pedidoId = null;

document.addEventListener('click', (evento) => {
  const botao = evento.target.closest('[data-excluir]');
  if (!botao) return;
  origem = botao;
  pedidoId = botao.dataset.excluir;
  tituloDialogo.textContent = 'Excluir o pedido ' + pedidoId + '?';
  dialogo.returnValue = '';
  dialogo.showModal();
});

dialogo.addEventListener('close', async () => {
  if (dialogo.returnValue === 'excluir') {
    const item = document.getElementById('pedido-' + pedidoId);
    const proximo = item.nextElementSibling;
    await excluirPedido(pedidoId);
    item.remove();
    const alvo = proximo ? proximo.querySelector('[data-excluir]') : tituloLista;
    alvo.focus();
    return;
  }
  if (origem && origem.isConnected) origem.focus();
});

O form method="dialog" fecha o diálogo ao clicar num dos botões e grava o value dele em returnValue. O Esc também fecha e dispara close, mas não grava valor; por isso o código zera returnValue antes de abrir, senão um "excluir" de uma vez anterior poderia ser lido de novo. Quando a exclusão acontece, o foco vai para o botão do pedido seguinte ou, se era o último, para o título da lista, que tem tabindex="-1" para poder receber foco por script sem entrar na ordem do Tab.

Os navegadores atuais já tentam devolver o foco ao elemento anterior quando um diálogo modal fecha. O código explícito garante o comportamento em qualquer caso e cobre a situação que o navegador não resolve: o elemento de origem que deixou de existir. A função excluirPedido precisa tratar erro; se a exclusão falhar, mostre a mensagem e devolva o foco à origem.

Se você não pode usar o dialog nativo

Em componentes próprios, os mesmos resultados exigem trabalho manual: role="dialog" e aria-modal="true" no contêiner, o atributo inert no conteúdo principal enquanto o diálogo está aberto, foco movido para dentro na abertura, tecla Esc tratada e o foco devolvido no fechamento. O inert é o que impede o Tab e o leitor de tela de chegarem ao fundo; sem ele, você precisaria interceptar o Tab no primeiro e no último elemento do diálogo. Se usa biblioteca de componentes, teste esses pontos antes de confiar.

Erros comuns

  • Diálogo feito com div sem bloquear o fundo. O Tab percorre links invisíveis atrás da camada escura.
  • Não devolver o foco ao fechar. A pessoa volta ao topo da página e precisa refazer todo o caminho.
  • Focar o botão "Excluir" ao abrir uma confirmação. Um Enter apressado apaga o pedido.
  • Diálogo sem nome acessível. O leitor anuncia "diálogo" sem dizer qual, e a pessoa não sabe o que está confirmando.
  • Esc que não fecha, ou que fecha descartando um formulário preenchido. Os dois quebram a expectativa de quem usa teclado.
  • Esquecer o caso do elemento removido. O foco volta para um botão que não existe e cai no body.

Checklist

  • O diálogo abre com showModal() ou tem inert no fundo.
  • Ao abrir, o foco está dentro do diálogo, no ponto certo.
  • O Tab não alcança nada fora do diálogo.
  • O diálogo tem nome via aria-labelledby.
  • Esc fecha quando fechar é seguro.
  • Ao fechar, o foco volta ao controle que abriu.
  • Se esse controle sumiu, o foco vai para um ponto próximo e lógico.

Os casos de quando confirmar e quando oferecer desfazer estão em confirmação clara ou desfazer de verdade, e o contorno que mostra onde está o foco, em anel de foco no tema escuro. Abra e feche cada diálogo do seu sistema só com teclado: depois de fechar, o próximo Tab leva você ao item seguinte ou ao topo da página?

Avalie este artigo

Referência: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/