Skip to content

Acessibilidade (a11y)

Guia canônico de acessibilidade do Arqel. Linha de base: WCAG 2.1 nível AA. Este documento é a referência única para autores de componentes, plugins, themes e aplicações construídas com o Arqel. Pull requests que regridem a a11y serão recusados.

Acessibilidade não é "polimento final" no Arqel — é um requisito funcional. Um admin panel inacessível exclui usuários reais (operadores com baixa visão, mobilidade reduzida, deficiência cognitiva, ambientes ruidosos onde áudio é inviável, etc.). Como o Arqel é a camada de operação de muitos sistemas internos no Brasil e no mundo, ferimos diretamente o trabalho de pessoas reais quando a a11y falha.

1. Princípios

Seguimos os quatro princípios POUR do WCAG 2.1:

  1. Perceptível (Perceivable) — informação e UI devem ser apresentadas de forma que qualquer usuário consiga perceber. Imagens têm texto alternativo, vídeos têm legenda, contraste é suficiente, conteúdo não depende exclusivamente de cor.
  2. Operável (Operable) — toda interação acessível por teclado. Sem armadilhas de foco. Tempos de resposta configuráveis. Movimentos não disparam ataques (sem flashes >3Hz).
  3. Compreensível (Understandable) — texto legível, comportamento previsível, erros claramente identificados, instruções para correção fornecidas.
  4. Robusto (Robust) — markup válido, semântica correta, compatibilidade com tecnologias assistivas (screen readers, switches, eye-trackers, magnifiers).

Nível alvo

  • AA é o nosso baseline. Recursos que não atinjam AA não entram em release estável.
  • AAA é desejável onde possível (especialmente contraste 7:1 em modo "contrast-extra").

2. Convenções obrigatórias

2.1 Botões

  • Botões com texto — usem texto descritivo (Salvar, não OK).
  • Botões icon-onlyaria-label é obrigatório descrevendo a ação:
    tsx
    <button type="button" aria-label="Fechar diálogo">
      <XIcon aria-hidden="true" />
    </button>
  • Estado disabled — use o atributo HTML disabled (não apenas aria-disabled); evite esconder visualmente botões não-disponíveis. Quando o botão fica disabled após uma ação, anuncie o motivo via live region.

2.2 Forms

  • Todo <input> deve ter um <label> associado por htmlFor/id:
    tsx
    <label htmlFor="email">Email</label>
    <input id="email" type="email" name="email" required />
  • Para forms com layout compacto, use <VisuallyHidden as="label"> em vez de remover label.
  • Mensagens de erro devem ter aria-describedby apontando para o input:
    tsx
    <input id="email" aria-invalid={hasError} aria-describedby="email-error" />
    {hasError && <p id="email-error" role="alert">Email inválido</p>}
  • Campos obrigatórios marcados com required HTML + indicação visual (não confiar só em cor).
  • autoComplete apropriado em todos os campos (email, current-password, given-name, etc.).

2.3 Cores e contraste

ConteúdoContraste mínimo
Texto normal (<18.66px regular ou <24px bold)4.5:1
Texto large (≥18.66px regular ou ≥24px bold)3:1
Componentes UI e gráficos (bordas, ícones funcionais)3:1
  • Nunca transmita informação por cor (e.g., "campos vermelhos são obrigatórios" sem outro indicador visual e textual).
  • Estados de hover/focus/active devem ter mudança detectável além de cor (sublinhado, borda, ícone, etc.).

2.4 Teclado

  • Tudo focável por Tab em ordem lógica (DOM order = visual order; evite tabindex >0).
  • Enter/Space ativa botões e links.
  • Escape fecha modais, drawers, popovers, dialogs.
  • Setas (Arrow*) navegam dentro de listas, menus, comboboxes (segundo o role).
  • Home/End saltam para extremos em listas e tabelas.
  • Foco sempre visível. Não sobrescrever :focus-visible global do design system para removê-lo. Caso o design ache "feio", desenhe um focus ring próprio — nunca esconda.

2.5 Estrutura semântica

  • Use elementos nativos: <button>, <a href>, <form>, <nav>, <main>, <aside>, <header>, <footer>, <section>, <article>. Eles trazem semântica, foco e teclado de graça. Crie roles ARIA apenas quando o elemento nativo não existe.
  • Hierarquia de headings (h1...h6) sem pular níveis. Cada página tem um h1.
  • <main id="main-content"> para o conteúdo primário. SkipLink aponta para esse id.
  • Landmarks ARIA implícitos via tags semânticas — não duplicar com role="navigation" em <nav>.

2.6 Imagens e ícones

  • <img alt="..."> sempre. Imagens decorativas usam alt="" (string vazia, não omitir).
  • Ícones decorativos: aria-hidden="true". Ícones funcionais (botão icon-only) levam aria-label no botão pai e aria-hidden no ícone.
  • SVGs com <title> quando expressam significado isolado.

2.7 Live regions e anúncios

  • Resultado de ações async (salvar, deletar, importar) deve ser anunciado via useAnnounce() do @arqel-dev/a11y.
  • Erros críticos: priority assertive (interrompe SR).
  • Confirmações e progress: priority polite (espera SR ficar livre).

3. Helpers do @arqel-dev/a11y

O pacote @arqel-dev/a11y expõe primitivos reutilizáveis. Sempre prefira-os a reescrever focus trap ou live region por conta própria.

useFocusTrap

tsx
import { useFocusTrap } from '@arqel-dev/a11y';

const ref = useFocusTrap<HTMLDivElement>(open, { onEscape: onClose });
return <div ref={ref} role="dialog" aria-modal="true">...</div>;
  • Foca primeiro elemento ao ativar.
  • Cicla Tab/Shift+Tab dentro do container.
  • onEscape opcional — fecha o modal/drawer.
  • Restaura foco para o elemento que tinha foco antes (configurável).

useAnnounce

tsx
const { announce } = useAnnounce();
announce('Cadastro salvo com sucesso');
announce('Falha ao salvar — tente novamente', 'assertive');
  • Reutiliza live regions globais (uma por priority).
  • SSR-safe: silenciosamente ignora se document não existe.
tsx
<SkipLink targetId="main-content" />
<main id="main-content" tabIndex={-1}>...</main>
  • Renderiza link visível somente em focus.
  • Pular navegação repetitiva — primeiro elemento focável da página.

<VisuallyHidden>

tsx
<button>
  <SearchIcon aria-hidden="true" />
  <VisuallyHidden>Buscar</VisuallyHidden>
</button>
  • Esconde visualmente, mantém em screen reader.
  • Tag configurável via as.

<LiveRegion>

tsx
<LiveRegion message={status} priority="polite" />
  • Quando você precisa controlar a region via prop em vez do hook (e.g., conteúdo reativo a uma store).

4. Checklist para PRs

Use esta checklist em qualquer PR que toca UI:

  • [ ] Markup semântico (sem <div onClick> para botões).
  • [ ] Botões icon-only têm aria-label.
  • [ ] Inputs têm <label htmlFor> ou aria-label/aria-labelledby.
  • [ ] Erros de form têm aria-invalid + aria-describedby apontando para mensagem.
  • [ ] Tab percorre toda a página em ordem lógica.
  • [ ] Escape fecha qualquer overlay (modal, drawer, menu).
  • [ ] :focus-visible está visível em todos focáveis (sem outline: none global).
  • [ ] Contraste verificado (DevTools color picker ou plugin).
  • [ ] Imagens têm alt descritivo (ou vazio se decorativas).
  • [ ] Headings em ordem (sem pular h2 → h4).
  • [ ] Cores não são o único veículo de informação.
  • [ ] Anúncios via useAnnounce para ações assíncronas.
  • [ ] axe-core passa zero violations no componente.

5. Ferramentas

5.1 Manual

  • axe DevTools (Chrome/Firefox extension) — análise completa de página.
  • Lighthouse (Chrome DevTools) — score básico + sugestões.
  • VoiceOver (macOS, Cmd+F5) — testar narração e navegação real.
  • NVDA (Windows, gratuito) — testar narração e navegação real.
  • Tab through — desplugue o mouse e navegue só com teclado por 5 minutos.

5.2 Automatizado

  • vitest-axe em testes de componente:
    ts
    import { axe } from 'vitest-axe';
    expect(await axe(container)).toHaveNoViolations();
  • pnpm --filter @arqel-dev/a11y audit:scan — sweep heurístico de .tsx em apps/ procurando padrões suspeitos (img sem alt, botão sem aria-label, etc.).
  • CI — Playwright com @axe-core/playwright rodando em smoke E2E.

5.3 Screen readers

Plataformas que testamos antes de release:

SOScreen readerVersão
macOSVoiceOverlatest
WindowsNVDA2024+
iOSVoiceOver17+
AndroidTalkBack14+

6. Padrões comuns

6.1 Modal / Dialog

tsx
<div
  ref={trapRef}
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc"
>
  <h2 id="dialog-title">Confirmar</h2>
  <p id="dialog-desc">Tem certeza?</p>
  <button onClick={onCancel}>Cancelar</button>
  <button onClick={onConfirm}>Confirmar</button>
</div>

6.2 Tabela com sort

tsx
<th aria-sort={sortBy === 'name' ? sortDir : 'none'}>
  <button onClick={() => sort('name')}>Nome</button>
</th>

6.3 Combobox / Autocomplete

Siga WAI-ARIA Authoring Practices §3.5. role="combobox", aria-expanded, aria-controls, aria-activedescendant. Pacote @arqel-dev/ui já encapsula isso — prefira a primitiva pronta a reimplementar.

6.4 Loading state

Não esconda foco enquanto carrega. Anuncie via useAnnounce:

tsx
useEffect(() => {
  if (loading) announce('Carregando...');
}, [loading]);

7. Referências


Quando em dúvida: prefira excesso de a11y. Custo de adicionar aria-label é zero, custo de não tê-lo é um usuário excluído.

Licença MIT — construído com Inertia + React + Laravel.