Assinar
Voltar
Frontend

O Chrome bloqueia o seu aria-hidden, e o tabindex=-1 só cala o aviso

O aviso de aria-hidden bloqueado no console do Chrome não é falso positivo: é o navegador recusando esconder um elemento com o foco dentro.

</>

Abra o console em qualquer aplicação com modal e a mensagem está lá: “Blocked aria-hidden on an element because its descendant retained focus.” Procure a frase e você vai cair em dezenas de discussões abertas em repositório de biblioteca de componente, quase sempre com a mesma resposta mais votada: coloque tabindex="-1" nos elementos de dentro e o aviso some.

Some mesmo. E o defeito continua exatamente onde estava.

A posição deste post é direta: esse aviso não é ruído de ferramenta. Ele é o navegador avisando que recusou obedecer ao seu aria-hidden, porque obedecer produziria um estado que ninguém consegue descrever para o usuário. O problema não está no atributo. Está na ordem em que o seu código esconde a região.

O que aria-hidden faz, e o que ele nunca fez

A definição do MDN é curta e vale ler devagar: adicionar aria-hidden="true" a um elemento remove aquele elemento e todos os seus filhos da árvore de acessibilidade. E, na frase seguinte, a parte que a maioria pula: o atributo esconde conteúdo da tecnologia assistiva, mas não esconde nada visualmente.

Duas consequências saem daí. A primeira é que o elemento continua desenhado na tela, clicável com o mouse e alcançável pelo Tab. A segunda é que a herança é total e irreversível para baixo: a regra do axe registra que, uma vez que aria-hidden="true" está num ancestral, colocar aria-hidden="false" num descendente não devolve aquele conteúdo. Não existe furo no cobertor.

Por isso o próprio MDN afirma que aria-hidden="true" não deve ser usado em elementos que podem receber foco e, como o atributo é herdado pelos filhos, também não deve ser colocado no pai ou em qualquer ancestral de um elemento que possa receber foco.

Foco escondido é um estado que ninguém sabe narrar

Pense no que acontece quando as duas coisas coincidem. O foco do teclado está num botão. Esse botão, ou algum ancestral dele, ganha aria-hidden="true". Agora existe um elemento que detém o foco e que, ao mesmo tempo, não existe na árvore que o leitor de tela consulta.

Não há saída boa. Se a tecnologia assistiva respeitar o atributo, ela precisa anunciar o foco atual e não tem nada para anunciar: o usuário fica com o cursor parado num vazio, sem nome, sem papel e sem valor. Se ela ignorar o atributo, ela contradiz o que o autor pediu. É exatamente esse impasse que a regra aria-hidden-focus do axe descreve, e que ela mapeia para o critério 4.1.2 da WCAG, Name, Role, Value. O elemento focado precisa ter nome, papel e valor expostos. Escondido, ele não tem nenhum dos três.

O navegador escolheu o lado menos ruim: ignora o aria-hidden na cadeia de ancestrais do elemento focado e conta para você no console. O aviso é o registro de uma decisão tomada contra a sua instrução, não uma sugestão de estilo.

Por que tabindex=”-1″ cala o aviso e mantém o problema

A remediação que circula nas threads vem da própria página da regra: colocar tabindex="-1" nos elementos que recebem foco, ou display:none, ou disabled nos campos de formulário. As três estão corretas para o caso que a regra descreve, que é conteúdo estaticamente marcado como escondido contendo algo que recebe foco.

O caso do modal é outro. Ali o foco já está dentro da região no instante em que você a esconde. E tabindex="-1" não tira o foco de ninguém: ele apenas retira o elemento da ordem de tabulação para o próximo Tab. Um elemento com tabindex="-1" continua perfeitamente capaz de receber foco por script, e continua focado se já estava. Você removeu o sintoma que a checagem estática enxerga e manteve o foco preso num subárvore invisível.

O display:none resolve de verdade, porque remove o elemento da renderização e o foco cai fora dele. O custo é que ele é incompatível com qualquer animação de saída: não dá para desaparecer com opacity em 200 ms um elemento que já não é desenhado.

inert age no foco, e é por isso que ele resolve

O atributo inert é a ferramenta que faz as duas coisas ao mesmo tempo. Segundo o MDN, o elemento e todos os descendentes na árvore plana ficam inertes, o que significa que não podem ser focados e não disparam eventos de focus, não disparam click quando clicados, não permitem seleção do texto que contêm, não são encontrados pela busca na página do navegador e ficam de fora da árvore de acessibilidade.

Repare que a exclusão da árvore de acessibilidade, que é tudo o que aria-hidden entrega, aparece por último numa lista que começa pelo foco. Essa é a diferença inteira. aria-hidden descreve uma intenção para a tecnologia assistiva. inert muda o comportamento do documento, e a descrição vira consequência de um estado que passou a ser verdade.

Disponibilidade não é mais argumento contra: o MDN classifica inert como Baseline widely available, disponível nos navegadores desde abril de 2023.

O contrato de fechamento

A correção do aviso não é trocar um atributo por outro na mesma linha. É garantir que o foco saia antes de a região sumir. Em ordem:

function fecharModal(modal, gatilho) {
  // 1. devolve o foco primeiro, de forma sincrona
  gatilho.focus();

  // 2. so entao torna o modal inerte
  modal.inert = true;
  modal.setAttribute('aria-hidden', 'true');

  // 3. o fundo volta a ser interativo
  document.querySelector('#app').inert = false;

  // 4. desmonta quando a animacao de saida terminar
  modal.addEventListener('transitionend', () => modal.remove(), { once: true });
}

O passo 1 antes do passo 2 é o post inteiro. Invertido, você recria o mesmo estado com nome diferente, e o navegador vai reclamar de novo. Vale guardar a referência do elemento que abriu o modal na abertura, porque na hora de fechar ele já não está em document.activeElement.

Fica um aviso do próprio MDN que costuma virar bug de regressão: por padrão não existe nenhuma indicação visual de que um elemento ou sua subárvore estão inertes. Marcar o fundo como inerte e esquecer de desmarcar produz uma página que parece normal e não responde a nada.

A exceção do dialog modal

Se você usa <dialog> aberto com showModal(), boa parte disso já vem pronto e o MDN registra a regra especial: elementos <dialog> modais escapam da inércia, ou seja, não herdam inércia dos ancestrais, mas podem ser tornados inertes com o atributo posto neles mesmos. Nenhum outro elemento escapa.

É uma exceção pequena com efeito grande. Ela permite marcar o container da aplicação inteiro como inerte sem apagar junto o diálogo que você acabou de abrir, que é justamente o caso que qualquer implementação manual erra na primeira tentativa.

Perguntas frequentes

Posso simplesmente silenciar o aviso no console?

Pode, e o foco continua preso. O aviso é a única sinalização barata que você tem de um defeito que, no uso real, aparece como usuário de leitor de tela com o cursor num lugar sem nome.

Preciso dos dois, inert e aria-hidden?

Para navegador atual, inert sozinho já retira da árvore de acessibilidade. Manter aria-hidden junto não atrapalha e ajuda em combinações antigas de navegador com leitor de tela, desde que o foco já tenha saído.

E se a região escondida não tem nada que receba foco?

aria-hidden é o uso correto do atributo. Ícone decorativo, texto duplicado que existe só para o olho, separador visual: nada disso recebe foco e nada disso precisa de inert.

Fontes

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *