Programação Por Equipe DevBras

Capa do artigo: Componente Tailwind copiado do preview: teste de acessibilidade em 15 minutos

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?".

  1. 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.
  2. 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.
  3. 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-400 sobre branco não passa; text-gray-600 passa com folga.
  4. 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.
  5. Hover: se ações só aparecem ao passar o mouse, elas precisam aparecer também com foco de teclado.
  6. Conteúdo real: troque o texto de exemplo pelo mais longo que o seu sistema produz e aumente o zoom para 200%.
  7. 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 div com onclick como 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 div sem 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 é button ou a e 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?

Avalie este artigo

Referência: https://tailwindui.com/components/preview