Skip to content

[a11y] Dois "X" idênticos e sem nome na divisão de despesa do /registro — um fecha tudo, o outro remove uma pessoa #129

Description

@Guiroos

Fatia (c) de #106. Sites 8 e 9 dos 9. As outras: #126 (DS), #127 e #128 (lápis).

Onde

2 sites, no mesmo arquivo. Ambos <Button size="icon"> cujo único filho é um <X> do lucide-react, sem aria-label, aria-labelledby, title ou sr-only:

# (na #106) arquivo:linha o que faz
8 components/forms/transaction/SplitSection.tsx:146-154 fecha a divisão de despesa inteira (handleClose)
9 components/forms/transaction/SplitSection.tsx:183-191 remove uma pessoa da divisão (removePerson(entry.uid))

Linhas reconferidas em 2026-08-29 — deslocaram ~24 em relação à tabela da #106 (que dizia :122 e :159). Reconfirmar por conteúdo, não por linha (exigência 8): o site 8 é o <Button> do cabeçalho, irmão do <span>Dividir com</span>, com onClick={handleClose}; o site 9 é o <Button> dentro do .map(...) de resolved, com onClick={() => removePerson(entry.uid)}.

Renderizado por components/forms/TransactionForm.tsx (grep confirma o import — exigência 7), que é o formulário central do /registro. Não é código morto.

Evidência

// site 8 — components/forms/transaction/SplitSection.tsx:146-154
<Button type="button" variant="ghost" size="icon" onClick={handleClose}
  className="text-text-tertiary hover:text-text-primary">
  <X className="h-4 w-4" />
</Button>

// site 9 — components/forms/transaction/SplitSection.tsx:183-191
<Button type="button" variant="ghost" size="icon" onClick={() => removePerson(entry.uid)}
  className="mb-0.5 flex-shrink-0 text-text-tertiary hover:text-negative">
  <X className="h-4 w-4" />
</Button>

O <svg> não pode servir de nome: lucide-react está pinado em 1.8.0 (package.json:45) e o Icon dessa versão esconde o <svg> quando não recebe filho nem prop de a11y —

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

— então a computação do nome do role=button fica sem fonte alguma. Medido na árvore real via CDP (Accessibility.getFullAXTree) na auditoria da #106: role=button name="".

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

Falsificação (exigência 2)

  • O lint não pega. eslint.config.mjs:25-32 escopa jsx-a11y/label-has-associated-control a components/ui/field.tsx e nada mais. CI verde com os dois sites presentes.
  • Não é decisão deliberada. git log de SplitSection.tsx: 277c172 é repaginação visual; nada sobre rotulagem.
  • O Field vizinho não resolve. As linhas do .map usam <Field label={idx === 0 ? 'Pessoa' : undefined}> para o Combobox e o CurrencyInput, mas o Field nomeia campos de formulário e não envolve o <Button> do site 9 — é o escopo da [a11y] Field nunca liga o label ao controle — 94 campos sem associação; três campos de dinheiro no mesmo dialog se anunciam todos como "R$ 0,00" #53, que não alcança botões. Confirmado no arquivo: o <Button> é irmão dos dois <div> de Field, não filho.

Impacto

Este é o par mais confusível dos 9 sites, porque os dois botões são visualmente e semanticamente idênticos na árvore: mesmo ícone X, mesmo variant/size, e ambos anunciados apenas como "botão". Quem usa leitor de tela e abriu "Dividir com alguém" encontra, por linha, um "botão" que remove aquela pessoa e, acima, um "botão" que descarta a divisão inteira — sem nada que os distinga. O destino de errar não é simétrico: handleClose joga fora todas as linhas já preenchidas.

Acontece no /registro, o formulário central do produto. Atinge também comando de voz, que não tem alvo nomeado para mirar.

Proposta

Uma linha por site, aditiva, sem mudança visual. Rótulos distintos, que é o ponto inteiro desta fatia:

   // site 8
   <Button type="button" variant="ghost" size="icon" onClick={handleClose}
+    aria-label="Fechar divisão"
     className="text-text-tertiary hover:text-text-primary">

   // site 9
   <Button type="button" variant="ghost" size="icon" onClick={() => removePerson(entry.uid)}
+    aria-label="Remover pessoa da divisão"
     className="mb-0.5 flex-shrink-0 text-text-tertiary hover:text-negative">

Rótulo estático e não interpolado com o nome da pessoa (Remover ${entry.personId}): o personId pode estar vazio (linha recém-adicionada, placeholder="Selecionar...") e o nome vem do Combobox, cujo options já são texto decriptado — montar a string ali acopla o rótulo ao estado de seleção sem ganho, e um nome vazio produziria "Remover " truncado. O Combobox irmão, esse sim, já anuncia de quem é a linha.

Rotular o botão, não o ícone. <X aria-label="..." /> desliga o aria-hidden via hasA11yProp, mas põe o nome no <svg> em vez do controle — alvo de clique e nó nomeado divergem, contra os 20 controles do repo que rotulam o <Button>.

Cobertura (exigência 3)

Nenhum teste pega hoje e o lint está verde. Acrescentar um bloco describe a __tests__/unit/a11y-nomes-acessiveis.test.ts, criado pela #126 (se ela ainda não tiver mergeado, criar o arquivo com o mesmo cabeçalho). Varredura de texto-fonte, padrão de row-actions.test.ts (#54) e a11y-estado-selecao.test.ts (#107) — não há @testing-library/react no projeto.

Aqui a asserção genérica falha duas vezes, não uma. SplitSection.tsx tem cinco <Button> (o "Dividir com alguém", os dois X, e mais dois no rodapé), então toMatch(/aria-label/) passa com qualquer um rotulado. E como os dois sites usam <X>, um recorte que pegue só a primeira ocorrência deixa o site 9 descoberto. O gate precisa iterar sobre todas as ocorrências e exigir rótulos distintos:

// todos os <Button> que envolvem um <X>; (?:(?!<Button)[\s\S])*? garante que
// cada fatia comece no <Button> mais próximo do ícone, não num anterior.
const gatilhos = [...source.matchAll(/<Button\b(?:(?!<Button)[\s\S])*?<X\b/g)].map((m) => m[0])
expect(gatilhos).toHaveLength(2)

const rotulos = gatilhos.map((g) => g.match(/^\s*aria-label="([^"]+)"$/m)?.[1])
expect(rotulos.every(Boolean)).toBe(true)
expect(new Set(rotulos).size).toBe(2) // rótulos distintos — o defeito é a ambiguidade

toHaveLength(2) é parte do gate, não cerimônia: se o .map ganhar outro X no futuro, o teste falha em vez de cobrir só dois dos três.

Nota sobre a "armadilha da regex" descrita na #106. O corpo da #106 alerta que <(button|Button)\b([^>]*?)> não encontra o site 9, porque o > da arrow function em onClick={() => removePerson(...)} fecha a tag cedo — e conclui que o parser precisa andar caractere a caractere respeitando {} e aspas. Esse alerta vale para o scanner repo-wide (issue separada), não para este gate. O recorte acima não tenta delimitar a tag de abertura: vai do <Button até o <X, atravessando a arrow function sem precisar interpretá-la, e busca o aria-label ancorado em linha própria dentro desse trecho. Não há parser a escrever aqui.

A âncora ^\s*...$ com flag m é a mesma de a11y-estado-selecao.test.ts: sem ela o atributo comentado deixa o teste verde com o bug de volta.

Custo estimado

P — 2 arquivos: components/forms/transaction/SplitSection.tsx + __tests__/unit/a11y-nomes-acessiveis.test.ts.

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