Skip to content

[a11y] DeleteButton do DS não tem nome acessível — 6 pontos de render anunciam o botão destrutivo como "botão" #126

Description

@Guiroos

Fatia (a) de #106. Site 1 dos 9. As demais: #127 e #128 (os seis lápis de editar), #129 (SplitSection), #131 (gate repo-wide, bloqueado por estas quatro).

Onde

1 site, 6 pontos de render. components/ui/delete-button.tsx:49-56 — o gatilho do DeleteButton:

// components/ui/delete-button.tsx:49-56  (linhas conferidas em 2026-08-29)
const trigger = (
  <Button
    size="icon"
    variant="ghost"
    className="h-7 w-7 text-text-secondary hover:text-negative"
    onClick={() => setOpen(true)}
  >
    <Trash2 className="h-3.5 w-3.5" />
  </Button>
)

Sem aria-label, aria-labelledby, title ou sr-only. grep -n 'aria-label' components/ui/delete-button.tsx devolve zero.

Pontos de render, confirmados por grep (exigência 7 — nenhum é código morto):

arquivo:linha tela
app/(app)/categorias/page.tsx:71 /categorias (grupo)
app/(app)/categorias/page.tsx:110 /categorias (categoria)
app/(app)/contas/page.tsx:79 /contas
components/metas/MetasList.tsx:75 /metas
components/metas/MetasList.tsx:141 /metas
components/investimentos/WithdrawalTable.tsx:74 /investimentos

Evidência

O <svg> do ícone não pode servir de nome, e isso é comportamento da versão fixada, não suposição. lucide-react está pinado em 1.8.0 (package.json:45), e o Icon dessa versão marca o <svg> como escondido quando não recebe filho nem prop de a11y:

// lucide-react@1.8.0 — dist/esm/Icon.js:36  (lido do tarball do registry, não de memória)
...!children && !hasA11yProp(rest) && { "aria-hidden": "true" },

Logo o <svg> sai da árvore de acessibilidade por decisão da própria biblioteca, e a computação do nome do role=button fica sem nenhuma fonte. Medido na árvore real via CDP (Accessibility.getFullAXTree) na auditoria original da #106: role=button name="", contra name="Editar" / name="Fechar" nos controles do mesmo repo que rotulam.

WCAG 4.1.2 Name, Role, Value (nível A).

Falsificação (exigência 2)

Impacto

É o botão destrutivo, e é o de maior alcance dos 9 sites — 6 pontos de render em 4 telas. Quem usa leitor de tela em /categorias, /contas, /metas e /investimentos percorre listas em que cada linha termina em dois controles anunciados como "botão", "botão", sem saber qual edita e qual exclui. O próprio diálogo diz "Essa ação não pode ser desfeita" (delete-button.tsx:28): o custo de errar é perder um grupo de categorias, uma conta, uma meta ou um resgate, sem desfazer.

Atinge também comando de voz — "clicar em Excluir" não encontra alvo quando o alvo não tem nome.

Proposta

Uma linha, aditiva, sem mudança visual:

   <Button
     size="icon"
     variant="ghost"
+    aria-label="Excluir"
     className="h-7 w-7 text-text-secondary hover:text-negative"
     onClick={() => setOpen(true)}
   >

Rotular o botão, não o ícone. <Trash2 aria-label="Excluir" /> também funcionaria — o hasA11yProp do lucide desliga o aria-hidden nesse caso — mas põe o nome no <svg> em vez do controle: o alvo de clique e o nó nomeado passam a ser diferentes, e o padrão diverge dos 20 controles do repo que já rotulam o <Button>.

Cobertura (exigência 3)

Nenhum teste pega hoje e o lint está verde — é o caso em que a suíte verde é o sinal errado.

Não há @testing-library/react nas devDependencies, então o gate proporcional é varredura de texto-fonte, padrão já usado em __tests__/unit/row-actions.test.ts (#54) e __tests__/unit/a11y-estado-selecao.test.ts (#107). Arquivo novo: __tests__/unit/a11y-nomes-acessiveis.test.ts — e não extensão do focus-ring-contrast.test.ts, que concentra os gates dependentes da maquinaria OKLCH→sRGB, ausente aqui.

A asserção precisa ancorar no botão que contém o ícone, não no arquivo. Um expect(source).toMatch(/aria-label/) passaria com o bug intacto assim que qualquer outro botão do arquivo fosse rotulado — é o mesmo modo de falha que row-actions.test.ts documenta para a className do gatilho. O recorte que só a correção certa satisfaz:

// fatia o bloco do <Button> mais próximo que envolve o <Trash2>:
// (?:(?!<Button)[\s\S])*? impede que a fatia comece num <Button> anterior do arquivo
const gatilho = source.match(/<Button\b(?:(?!<Button)[\s\S])*?<Trash2\b/)?.[0]
expect(gatilho).toBeDefined()
expect(gatilho).toMatch(/^\s*aria-label="[^"]+"$/m)

A âncora ^\s*...$ com flag m é a mesma de a11y-estado-selecao.test.ts e existe pelo mesmo motivo: sem ela, o atributo comentado (// aria-label=...) deixa o teste verde com o bug de volta.

Custo estimado

P — 2 arquivos: components/ui/delete-button.tsx + __tests__/unit/a11y-nomes-acessiveis.test.ts.

Nota de ordenação

Esta fatia cria o arquivo de teste. As fatias #127, #128 e #129 acrescentam blocos describe a ele. Se duas fatias estiverem em voo ao mesmo tempo o conflito é trivial (blocos independentes no fim do arquivo), mas mergear esta primeira evita que duas PRs criem o mesmo arquivo do zero.

Fecha parcialmente #106 — usar refs #106, não closes (exigência 8).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    a11yclaude-auditAchado da auditoria automáticaclaude-wipJá tem PR aberto, não pegar de novo

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions