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:
- 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.
- 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).
- Compreensível (Understandable) — texto legível, comportamento previsível, erros claramente identificados, instruções para correção fornecidas.
- 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ãoOK). - Botões icon-only —
aria-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 apenasaria-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 porhtmlFor/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-describedbyapontando 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
requiredHTML + indicação visual (não confiar só em cor). autoCompleteapropriado em todos os campos (email, current-password, given-name, etc.).
2.3 Cores e contraste
| Conteúdo | Contraste 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 só 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
Tabem ordem lógica (DOM order = visual order; evitetabindex>0). Enter/Spaceativa botões e links.Escapefecha modais, drawers, popovers, dialogs.- Setas (
Arrow*) navegam dentro de listas, menus, comboboxes (segundo o role). Home/Endsaltam para extremos em listas e tabelas.- Foco sempre visível. Não sobrescrever
:focus-visibleglobal 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 umh1. <main id="main-content">para o conteúdo primário.SkipLinkaponta 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 usamalt=""(string vazia, não omitir).- Ícones decorativos:
aria-hidden="true". Ícones funcionais (botão icon-only) levamaria-labelno botão pai earia-hiddenno í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
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.
onEscapeopcional — fecha o modal/drawer.- Restaura foco para o elemento que tinha foco antes (configurável).
useAnnounce
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
documentnão existe.
<SkipLink targetId>
<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>
<button>
<SearchIcon aria-hidden="true" />
<VisuallyHidden>Buscar</VisuallyHidden>
</button>- Esconde visualmente, mantém em screen reader.
- Tag configurável via
as.
<LiveRegion>
<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>ouaria-label/aria-labelledby. - [ ] Erros de form têm
aria-invalid+aria-describedbyapontando para mensagem. - [ ]
Tabpercorre toda a página em ordem lógica. - [ ]
Escapefecha qualquer overlay (modal, drawer, menu). - [ ]
:focus-visibleestá visível em todos focáveis (semoutline: noneglobal). - [ ] Contraste verificado (DevTools color picker ou plugin).
- [ ] Imagens têm
altdescritivo (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
useAnnouncepara ações assíncronas. - [ ]
axe-corepassa 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.tsxemapps/procurando padrões suspeitos (img sem alt, botão sem aria-label, etc.).- CI — Playwright com
@axe-core/playwrightrodando em smoke E2E.
5.3 Screen readers
Plataformas que testamos antes de release:
| SO | Screen reader | Versão |
|---|---|---|
| macOS | VoiceOver | latest |
| Windows | NVDA | 2024+ |
| iOS | VoiceOver | 17+ |
| Android | TalkBack | 14+ |
6. Padrões comuns
6.1 Modal / Dialog
<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
<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:
useEffect(() => {
if (loading) announce('Carregando...');
}, [loading]);7. Referências
- WCAG 2.1: https://www.w3.org/TR/WCAG21/
- WAI-ARIA Authoring Practices: https://www.w3.org/WAI/ARIA/apg/
- A11y Project Checklist: https://www.a11yproject.com/checklist/
- MDN Accessibility: https://developer.mozilla.org/en-US/docs/Web/Accessibility
- Inclusive Components (Heydon Pickering): https://inclusive-components.design/
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.