Skip to content
This page has been auto-translated and may contain errors.View in English

React Acessível

Todo app React renderiza para o mesmo HTML que o navegador sempre distribuiu, e toda ferramenta assistiva funciona a partir do DOM que seus componentes produzem. Acessibilidade em React é basicamente uma série de pequenas escolhas sobre esse DOM: qual elemento você renderiza, como ele recebe seu nome e o que você faz quando a tela muda para alguém que não consegue ver essa mudança.

Um detalhe JSX antes do resto. React renomeia class para className e for para htmlFor, mas atributos ARIA mantêm seus hífens: aria-live, aria-label, e um simples role.

Elementos semânticos em primeiro lugar

Um <button> chega com um monte de comportamento já anexado. Ele fica na ordem de tabulação, então o teclado consegue alcançá-lo. Ele ativa seu handler de clique em Enter e em Space. Um leitor de tela o anuncia como botão e lê seu texto como o nome, que também é o nome que o software de controle por voz alvo. O navegador cuida do estado desabilitado, do anel de foco e do estilo ativo.

jsx
// o navegador oferece foco, ativação por teclado e o anúncio "botão"
<button className="die" onClick={hold}>{value}</button>

Uma <div> com um handler onClick recebe um item dessa lista: o clique. Tab pula por cima dela, Enter e Space não fazem nada, e um leitor de tela a lê como um fluxo de texto sem nenhuma dica de que algo vai acontecer se você interagir com ela.

O patch usual é role="button" mais tabIndex={0}, que coloca o elemento na ordem de tabulação e muda o que é anunciado. O comportamento continua faltando. Você adicionaria um handler onKeyDown, verificaria Enter e Space, chamaria preventDefault() em Space para que a página parasse de rolar, e então manteria um estado desabilitado feito à mão em sincronia com o estilo. É bem código a mais para reconstruir algo que o navegador já oferece. Usar o verdadeiro <button> é o caminho mais curto, e ele permanece correto conforme os navegadores mudam.

A mesma lógica percorre o resto da marcação: <a href> para navegação, <nav> e <main> como marcos que um leitor de tela pode pular entre eles, títulos em ordem para o contorno que as pessoas navegam. A maioria do trabalho de acessibilidade em uma base de código React é escolher o elemento que já faz o trabalho.

Anunciando o que mudou

Um app de página única se atualiza no lugar. Não há carregamento de página para dizer a um leitor de tela que algo aconteceu, então uma mudança renderizada no meio da tela pode ser completamente silenciosa. Uma região ativa passa essa informação adiante: um container que o leitor de tela vigia e anuncia sempre que seu conteúdo muda. A classe sr-only abaixo a esconde visualmente, usando um padrão CSS coberto mais tarde neste capítulo.

jsx
<div aria-live="polite" className="sr-only">
  {isGameWon && <p>Você venceu! Pressione Novo Jogo para começar novamente.</p>}
</div>

O wrapper renderiza toda vez, vazio no início, e React coloca um parágrafo nele quando isGameWon muda. Essa ordem é a parte que as pessoas erram. O elemento que carrega aria-live tem que estar no DOM antes do conteúdo chegar, porque leitores de tela registram regiões ativas quando as encontram e depois vigiam por mutações. Montar a região e seu texto juntos em uma única renderização e muitos leitores de tela não anunciam nada: o todo parece conteúdo novo ordinário. Manter uma região vazia na árvore não custa nada e torna o anúncio confiável.

aria-live="polite" coloca o anúncio em uma fila. O leitor de tela termina o que está lendo no momento, então entrega sua mensagem no próximo pausa natural, que pode chegar um tempo depois da mudança visual. Esse atraso é intencional, e polite é a configuração correta para quase tudo.

Interação pelo teclado

Tab move para frente pelos elementos focáveis, Shift+Tab move para trás, Enter ativa links e botões, e Space ativa botões e alterna checkboxes.

A ordem de tabulação segue a ordem do DOM, então a sequência que seu JSX renderiza é a sequência que as pessoas movem. Reordenar visualmente com CSS deixa uma ordem de tabulação que salta pela tela, e valores positivos de tabIndex causam a mesma confusão propositalmente. tabIndex={-1} é o útil: torna um elemento focável do JavaScript enquanto o mantém fora da sequência de tabulação, que é o que um alvo de foco como um título de diálogo precisa.

Mais duas regras. Mantenha o foco visível: evite outline: none a menos que um estilo :focus-visible seu próprio o substitua. E mantenha uma saída disponível: um modal que deliberadamente prende o foco dentro de si precisa de Escape para fechar e precisa devolver o foco ao seu gatilho.

Movendo foco deliberadamente

Quando a interface muda de forma, o foco pode acabar em lugar nenhum. Alguém ativa um botão, o botão é removido ou substituído, e o foco cai de volta para <body>. O próximo Tab começa no topo da página, e o leitor perdeu seu lugar.

O fix é mover o foco para algum lugar sensato, que é um dos usos legítimos de uma ref:

jsx
function NewGameButton({ isGameWon, onNewGame }) {
  const buttonRef = useRef(null)

  useEffect(() => {
    if (isGameWon) {
      buttonRef.current.focus()
    }
  }, [isGameWon])

  return <button ref={buttonRef} onClick={onNewGame}>Novo Jogo</button>
}

O effect roda depois que React confirmou aquele nó na tela, então o elemento está lá para receber foco. Guardar em isGameWon evita que ele roube foco em cada renderização.

O mesmo padrão cobre os outros momentos comuns: um diálogo leva foco ao abrir e o devolve ao gatilho ao fechar, uma falha de validação envia o foco para o primeiro campo inválido, deletar uma linha move foco para a linha que a substituiu. A regra por baixo é uma linha: se seu código removeu a coisa que tinha foco, seu código decide onde o foco vai em seguida.

Texto visualmente oculto

Bastante estado é óbvio a partir do layout e silencioso para um leitor de tela: um check verde ao lado de um campo, um dado que parece pressionado, um número que fica claro de onde está. Texto visualmente oculto explica isso para qualquer um que escute a página.

A convenção é uma classe chamada sr-only. Ela não tem significado para React ou para o navegador: é um nome de classe simples, e essas regras CSS são o que fazem o trabalho.

css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

O elemento permanece na árvore de acessibilidade enquanto não ocupa espaço visual. display: none e visibility: hidden o retirariam daquela árvore também, escondendo-o de todos.

Um botão apenas com ícone é o caso do dia a dia. Ou dê a ele um aria-label, ou coloque texto real dentro e esconda-o visualmente:

jsx
<button onClick={onClose}>
  <XIcon aria-hidden="true" />
  <span className="sr-only">Fechar</span>
</button>

aria-hidden="true" mantém o SVG decorativo fora do anúncio, e o span oculto fornece o nome. Uma ressalva sobre aria-label: ele define um nome acessível em elementos interativos e em qualquer coisa que carregue um role explícito, e navegadores frequentemente o ignoram em uma <div> ou <span> simples sem role. Mantenha-o em botões, links, inputs e marcos rotulados.

Ler o código só vai levá-lo tão longe com qualquer um desses. Ligue VoiceOver com Cmd+F5 e ouça seu próprio app, e execute axe DevTools no navegador para pegar labels faltando e controles sem nome automaticamente.

Todo controle de formulário precisa de um label, e formulários são onde a lacuna mais aparece. Um <label> vinculado a um input dá ao campo seu nome acessível, então um leitor de tela lê "Endereço de email, editar texto" quando o foco chega lá, e o texto do label se torna um alvo clicável para o campo.

Duas fiações funcionam. Aponte o label para o input por id, usando htmlFor de React para o atributo HTML for:

jsx
<label htmlFor="email">Endereço de email</label>
<input id="email" type="email" name="email" />

Ou envolva o input no label e pule o id inteiramente:

jsx
<label>
  Endereço de email
  <input type="email" name="email" />
</label>

Envolver faz sentido para um checkbox ou um radio, onde o texto já fica ao lado do controle. A versão htmlFor oferece mais liberdade sobre layout.

Um id pré-codificado como email funciona para um formulário em uma página. Eleve aquela marcação para um <TextField> reutilizável e duas instâncias na mesma página emitem o mesmo id, então htmlFor vincula a qual renderizou primeiro e o label silenciosamente para de funcionar para cada campo depois dele. useId gera um id que é único por instância de componente, que é o trabalho que React o adicionou para:

jsx
function TextField({ label, ...props }) {
  const id = useId()

  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} {...props} />
    </>
  )
}

Sufixe aquele valor para ids relacionados, ${id}-hint para um elemento de descrição, então uma chamada cobre o controle inteiro.

Texto placeholder faz um trabalho diferente. Um placeholder desaparece no instante em que alguém digita um caractere, então quando ele carrega a única descrição do campo, aquela descrição desaparece no momento em que é necessária para verificar a resposta. O estilo padrão de placeholder é cinza claro, que tipicamente falha em requisitos de contraste, e o suporte do leitor de tela para o atributo é inconsistente. Use-o para um exemplo do formato esperado, joao@example.com abaixo de um label lendo "Endereço de email".

Texto de ajuda extra e mensagens de erro se anexam com aria-describedby, que aponta para o id do elemento segurando o texto:

jsx
<label htmlFor="password">Senha</label>
<input
  id="password"
  type="password"
  aria-describedby="password-hint"
  aria-invalid={error ? true : undefined}
/>
<p id="password-hint">{error || 'Pelo menos 12 caracteres.'}</p>

A descrição é lida depois do label e do tipo de campo, então chega como contexto em vez de como o nome. aria-invalid marca o campo como falhando validação, e trocar o texto de erro no elemento que aria-describedby já aponta mantém o anúncio em um nó que o leitor de tela está rastreando. Um nível acima, um conjunto de botões radio pertence dentro de um <fieldset> com uma <legend> carregando a pergunta.

aria-live toma três valores, e a escolha decide se a região ajuda ou prejudica. off é o padrão, significando mudanças vão sem anúncio. polite coloca o anúncio em fila e o entrega quando o leitor de tela alcança uma pausa em o que já está dizendo. assertive interrompe, cortando o anúncio atual para entregar o seu. Assertive é quase sempre a escolha errada: reserve-o para algo que genuinamente bloqueia o progresso da pessoa, como uma sessão expirando em dez segundos. Uma confirmação de save, uma contagem de resultado de busca, uma mudança de estado do jogo todos pertencem a uma região polite.

Dois roles carregam educação implícita e tendem a ser anunciados mais consistentemente que um atributo aria-live puro: role="status" se comporta como polite, role="alert" como assertive, e role="status" mais aria-live="polite" é um sólido padrão para uma região de status. aria-atomic="true" então lê o conteúdo inteiro da região em qualquer mudança, que faz sentido para uma frase curta que só faz sentido inteira; o padrão lê apenas o que mudou, que faz sentido para um log onde cada linha se sustenta sozinha.

O modo de falha que vale nomear é a região que anuncia demais. Ligue uma a um valor que atualiza a cada keystroke, digamos uma contagem de resultado abaixo de uma caixa de busca, e cada caractere coloca outro anúncio na fila. Entrega polite adiciona à fila em vez de substituí-la, então a pessoa ouve um fluxo de números obsoletos sobre o campo que ainda está digitando, e seu próprio eco de digitação fica enterrado. Uma flag de loading piscante ou três regiões competindo causam o mesmo amontoado.

Então mantenha regiões ativas poucas, debounce qualquer coisa movida por digitação até o valor se estabilizar, e anuncie apenas os momentos que fariam um usuário olhando para cima. Um app que não diz nada é pelo menos explorável: a pessoa pode navegá-lo com seus próprios comandos de leitor de tela ao seu próprio ritmo. Um app que fala constantemente é um que eles deixam.

JunoO elemento certo faz a maior parte do trabalho Use um <button> real quando algo é clicável, e um <label> real ao lado de cada input. Esses elementos vêm com suporte a teclado e um nome que um leitor de tela consegue ler, tudo de graça.

Quando algo muda na tela que uma pessoa escutando a página de outra forma perderia, coloque uma frase curta dentro de uma <div> com aria-live="polite", e mantenha aquela div na página desde o início para que a mudança seja notada.

JunoO elemento certo faz a maior parte do trabalho Elementos semânticos te dão foco, ativação por teclado e anúncios sem nenhum código, é por isso que corrigir uma <div> com role e tabIndex te deixa escrevendo seu próprio tratamento de tecla.

Rotule cada controle com htmlFor ou um <label> envolvente, e trate um placeholder como uma dica de formato, já que ele desaparece no instante em que alguém digita.

Mantenha uma região aria-live="polite" montada e troque seu texto, e mova foco com uma ref sempre que seu código remove a coisa que o tinha.

JunoO elemento certo faz a maior parte do trabalho Regiões ativas são registradas quando o leitor de tela as encontra, então a região deve estar no DOM antes do conteúdo mudar, e polite entrega na próxima pausa na fala enquanto assertive interrompe e é quase sempre a escolha errada.

role="status" e role="alert" carregam a mesma educação com melhor consistência, e aria-atomic decide se a região inteira ou apenas o delta fica lido.

Uma região excessivamente ansiosa movida por keystrokes coloca anúncios na fila mais rápido do que podem ser falados, que é pior para o usuário que silêncio.

Próximo: Além do básico, um mapa do que vem depois dos fundamentos.