Componente Tailwind copiado do preview: teste de acessibilidade em 15 minutos
Pegue um componente copiado de um catálogo Tailwind e verifique teclado, nome dos botões, contraste e foco do modal, com um teste automatizado que impede a regressão.
O card de pedido ficou idêntico ao preview: sombra suave, ícone de lixeira no canto, modal de confirmação elegante. Aí alguém tenta usar só com o teclado. O Tab pula a lixeira, que é um div com clique. O leitor de tela anuncia "botão" sem dizer qual. O modal abre e o foco continua na página de trás. O cinza do texto secundário mal aparece no monitor do balcão.
A resposta curta: o preview mostra o estado bonito, não o uso real. Antes de colar um componente no produto, faça quatro testes: navegue só com Tab, Enter, Espaço e Esc; confira se todo botão tem nome; meça o contraste do texto; e veja se o modal prende e devolve o foco. Depois, transforme isso num teste automatizado para o próximo ajuste visual não desfazer o trabalho.
Por que o preview esconde os problemas
Catálogos de componentes são vitrines. O frame do preview é visto com mouse, com texto curto de exemplo e num monitor bom. Ninguém aperta Tab ali. Em muitos kits, a versão em HTML puro traz só a marcação e as classes; o comportamento de teclado e de foco fica por sua conta, enquanto as versões para React ou Vue usam bibliotecas que já cuidam disso. Quem copia o HTML leva o visual e deixa o comportamento para trás.
Além disso, o componente não conhece o seu conteúdo. O exemplo tem "Pedido #12"; o seu tem nome de cliente com 40 caracteres. O exemplo tem um ícone decorativo; no seu, o ícone é a única indicação do que o botão faz. Acessibilidade depende do contexto, e o contexto é seu.
Como testar o componente copiado, passo a passo
O exemplo: o card de pedido do painel da Loja Exemplo, com um botão de lixeira que abre um modal "Excluir o pedido 1042?".
- Teclado: coloque o cursor na barra de endereço e use só Tab e Shift+Tab. Todo elemento clicável recebe foco, na ordem visual, com indicação visível.
- Nomes: com as ferramentas do navegador (painel de acessibilidade) ou um leitor de tela, confira o nome de cada botão e link. "Botão" sozinho é falha.
- Contraste: meça o texto secundário. Texto normal precisa de pelo menos 4,5:1 contra o fundo pelas WCAG. Em geral,
text-gray-400sobre branco não passa;text-gray-600passa com folga. - Modal: ao abrir, o foco entra no modal; Tab não escapa para a página de trás; Esc fecha; ao fechar, o foco volta para o botão que abriu.
- Hover: se ações só aparecem ao passar o mouse, elas precisam aparecer também com foco de teclado.
- Conteúdo real: troque o texto de exemplo pelo mais longo que o seu sistema produz e aumente o zoom para 200%.
- Automatize com Playwright e axe.
O componente como veio e como fica
Como veio do preview, resumido:
<div class="group relative rounded-lg bg-white p-4 shadow">
<p class="font-medium">Pedido 1042</p>
<p class="text-sm text-gray-400">Ana Souza · R$ 389,90</p>
<div class="absolute right-2 top-2 opacity-0 group-hover:opacity-100">
<div onclick="abrirModal()" class="cursor-pointer p-2">
<svg class="h-5 w-5">...</svg>
</div>
</div>
</div>
Corrigido, com elemento nativo para o modal:
<div class="group relative rounded-lg bg-white p-4 shadow">
<p class="font-medium">Pedido 1042</p>
<p class="text-sm text-gray-600">Ana Souza · R$ 389,90</p>
<div class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100">
<button type="button" id="abrir-excluir-1042"
class="rounded p-2 text-gray-600 hover:bg-gray-100 focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2">
<svg class="h-5 w-5" aria-hidden="true">...</svg>
<span class="sr-only">Excluir pedido 1042</span>
</button>
</div>
</div>
<dialog id="dlg-excluir-1042" aria-labelledby="dlg-excluir-1042-titulo"
class="rounded-lg p-6 shadow-xl backdrop:bg-black/40">
<h2 id="dlg-excluir-1042-titulo" class="text-lg font-semibold">Excluir o pedido 1042?</h2>
<p class="mt-2 text-gray-700">O pedido sai da lista e não pode ser recuperado.</p>
<form method="dialog" class="mt-4 flex justify-end gap-2">
<button value="cancelar" class="rounded px-4 py-2 hover:bg-gray-100">Cancelar</button>
<button value="confirmar" class="rounded bg-red-700 px-4 py-2 text-white">Excluir</button>
</form>
</dialog>
<script>
const dlg = document.getElementById('dlg-excluir-1042');
document.getElementById('abrir-excluir-1042').addEventListener('click', () => dlg.showModal());
dlg.addEventListener('close', () => {
if (dlg.returnValue === 'confirmar') excluirPedido(1042);
});
</script>
O que mudou e por quê: button no lugar de div traz foco, Enter e Espaço de graça. O sr-only dá nome ao ícone sem mudar o visual, e o aria-hidden tira o SVG da leitura. O group-focus-within mostra a lixeira quando ela recebe foco. O focus-visible:ring substitui o contorno que muitos componentes removem com outline-none. O dialog aberto com showModal() torna o resto da página inerte e fecha com Esc; navegadores atuais devolvem o foco ao botão que abriu, e o teste abaixo garante isso. O vermelho mais escuro mantém o contraste do texto branco.
O teste que impede a regressão
// tests/card-pedido.spec.js
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('card de pedido funciona só com teclado', async ({ page }) => {
await page.goto('/pedidos');
const excluir = page.getByRole('button', { name: 'Excluir pedido 1042' });
await excluir.focus();
await expect(excluir).toBeVisible();
await page.keyboard.press('Enter');
const dialogo = page.getByRole('dialog', { name: 'Excluir o pedido 1042?' });
await expect(dialogo).toBeVisible();
await page.keyboard.press('Escape');
await expect(dialogo).toBeHidden();
await expect(excluir).toBeFocused();
const resultado = await new AxeBuilder({ page }).analyze();
expect(resultado.violations).toEqual([]);
});
O axe pega contraste, botão sem nome e atributos ARIA errados. Ele não pega ordem de foco ilógica nem texto que corta com conteúdo longo; esses continuam exigindo os testes manuais acima. O page.goto('/pedidos') assume um baseURL configurado no playwright.config.
Antes de colar no produto
Confira a licença do kit: componentes pagos costumam ter regras sobre uso em produtos de clientes, templates revendidos e projetos de código aberto. E importe só o que usa, sem arrastar dependências inteiras por causa de um bloco. Se o mesmo problema aparece em páginas geradas por construtores visuais, veja construtor visual de Bootstrap gera a casca, não a página.
Erros comuns
- Usar
divcomonclickcomo botão. O elemento não recebe foco nem responde a Enter, e quem usa teclado não consegue executar a ação. - Botão só com ícone e sem nome. O leitor de tela anuncia "botão", e a pessoa não sabe se vai excluir ou editar.
- Remover o contorno de foco sem substituir. Quem navega por teclado perde a noção de onde está na página.
- Ações visíveis só no hover. Em tela de toque e no teclado elas nunca aparecem.
- Modal feito com
divsem gestão de foco. O Tab vai para a página de trás e o leitor de tela continua lendo o conteúdo escondido. - Confiar só no axe. Ele passa enquanto a ordem de foco e o texto longo continuam quebrados.
Checklist
- Todo elemento clicável é
buttonouae recebe foco com Tab. - Todo botão de ícone tem nome acessível.
- O foco é visível em todos os elementos interativos.
- Texto normal tem contraste de pelo menos 4,5:1.
- O modal fecha com Esc e devolve o foco ao botão que abriu.
- Ações que aparecem no hover aparecem também no foco.
- O teste com Playwright e axe roda na CI.
Desconecte o mouse e abra a tela onde está o último componente que você copiou de um catálogo: dá para concluir a ação principal dele só com o teclado?
Referência: https://tailwindui.com/components/preview