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
- Use
<dialog>e abra comshowModal(). 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. - Dê nome ao diálogo com
aria-labelledbyapontando para o título, para o leitor anunciar "Excluir o pedido 4821?, diálogo" na entrada. - Escolha onde o foco entra. Em confirmação destrutiva, no botão seguro (Cancelar), com
autofocus. Em formulário, no primeiro campo. - Guarde quem abriu antes de chamar
showModal(). - Devolva o foco no evento
close, que dispara tanto pelo botão quanto pelo Esc. - Se quem abriu sumiu, foque um ponto lógico: a linha seguinte, o título da lista ou a mensagem de resultado.
- 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
divsem 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 teminertno 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?
Referência: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/