Skip to content

[a11y] O role="progressbar" do DS entra no nome acessível dos botões do dashboard — categoria em 150% se anuncia como "Transporte 100 R$ 450,00 / R$ 300,00" #138

Description

@Guiroos

Onde

components/ui/progress.tsx:20-25 — a origem. Os 4 pontos de render:

# Arquivo value / max Dentro de <button>? value > max possível?
1 components/dashboard/CategoryGroupProgress.tsx:194-197 group.totalSpent / groupMax sim (:172) simgroupMax = totalBudget || totalSpent || 1 (:157), então grupo estourado dá now > max
2 components/dashboard/CategoryGroupProgress.tsx:244-246 catPct / 100 sim (:223) simcatPct = (cat.spent / cat.budget) * 100 (:220), sem teto
3 components/metas/MetasList.tsx:85-89 goal.currentBalance / goal.targetAmount não sim — meta superada
4 components/parcelas/InstallmentGroupCard.tsx:102-106 paidInstallments / totalInstallments não não

Nenhum dos 4 passa aria-label, aria-labelledby ou aria-valuetext:
grep -n "aria-" CategoryGroupProgress.tsx MetasList.tsx InstallmentGroupCard.tsx0 ocorrências.

Os três consumidores são renderizados (exigência 7): CategoryGroupProgressapp/(app)/dashboard/page.tsx:19,178; MetasListapp/(app)/metas/page.tsx:5,44; InstallmentGroupCardcomponents/parcelas/ParcelasToolbar.tsx:12,135.

Evidência

// components/ui/progress.tsx:20-25
<div
  role="progressbar"
  aria-valuemin={0}
  aria-valuemax={max}
  aria-valuenow={value}     // ← cru, sem clamp; e sem nome acessível nenhum

Duas falhas distintas saem daí, e as duas foram medidas na árvore de acessibilidade real do Chromium (Accessibility.getFullAXTree via CDP, /opt/pw-browsers/chromium-1194), não deduzidas da spec.

(a) O número cru vaza para o nome do botão que o contém. Os sites 1 e 2 ficam dentro de um <button>, cujo nome é computado por name from content — e o passo 2E do accname usa o valor de widget de faixa embutido. Réplica exata da ordem do JSX da linha de categoria (:223-271), medida:

button  name="Alimentação 45.6667 R$ 450,00 / R$ 985,00"   nameFrom=[contents]
button  name="Transporte 100 R$ 450,00 / R$ 300,00"        nameFrom=[contents]

Duas coisas nessa saída:

  • No caso normal entra um float de 4 casas (45.6667) entre o nome da categoria e os valores — ruído sem unidade, lido em voz alta em todo item da lista.
  • No caso estourado o Chromium clampa aria-valuenow em aria-valuemax: 150 vira 100. O botão anuncia Transporte 100 R$ 450,00 / R$ 300,00 — o 100 contradiz os próprios R$ 450,00 / R$ 300,00 ao lado dele, e é indistinguível de uma categoria exatamente no orçamento.

O clamp foi medido nos quatro pares de valor dos call sites:

{"name":"","valuenow":1000, "min":0,"max":1000, "ignored":false}   // grupo: 1500 → 1000
{"name":"","valuenow":100,  "min":0,"max":100,  "ignored":false}   // categoria: 150 → 100
{"name":"","valuenow":10000,"min":0,"max":10000,"ignored":false}   // meta: 12000 → 10000
{"name":"","valuenow":3,    "min":0,"max":12,   "ignored":false}   // parcela: dentro da faixa

(b) Nos sites 3 e 4 sobra uma progressbar sem nome. name é "" e ignored é false — o nó está exposto, navegável, e se anuncia como "barra de progresso, 100%" sem dizer de quê. role="progressbar" é role de widget: a ARIA exige nome acessível.

WCAG 4.1.2 Name, Role, Value e 1.3.1 Info and Relationships.

Verificações feitas (tentativa de falsificar o achado)

  1. git log -S 'role="progressbar"' -- components/ui/progress.tsx → só o commit de importação inicial (98c85f4, que trouxe o repo inteiro). Não é escolha recente e deliberada; é omissão de origem.
  2. .claude/ds-components.md não tem regra sobre nome acessível de Progress. A única linha sobre o componente é visual ("Progress implica progresso em direção a algo… se o usuário precisar de legenda para entender a barra, ela não está comunicando sozinha") — não cobre isto, e o precedente de escopo já está firmado: [a11y] Button apaga o outline nativo e não devolve indicador de foco — e o token de ring do DS mede 1.19:1, abaixo dos 3:1 da WCAG #52, [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, [a11y] Combobox não expõe papel nem estado — o seletor de categoria de todo lançamento é um campo de texto solto para leitor de tela #75 e [a11y] Chip e Segment não expõem qual opção está selecionada — o seletor de tipo do formulário de lançamento é um grupo de botões sem estado para leitor de tela #107 foram todos achados de a11y dentro de components/ui/ e foram aceitos.
  3. Cheguei a supor que <button> achatasse o descendente e o número não aparecesse no nome. Falso — a medição acima mostra o contrário. Era a hipótese que mataria o achado.
  4. Cheguei a supor que a correção óbvia (aria-label no Progress) resolvesse. Também falso, e é o resultado mais importante daqui — ver a proposta.
  5. Nenhum teste toca progress.tsx. grep -rl "[Pp]rogress" __tests__/ devolve 3 arquivos, e os três são sobre goal.progress/dados de investimento, não sobre o componente. Não há asserção afirmando o comportamento atual, então a correção não deixa a suíte vermelha.

Script de reprodução (roda sem node_modules): sobe o Chromium com --headless=new --remote-debugging-port=N, navega para um file:// com o markup acima e imprime os nós de role button/progressbar de Accessibility.getFullAXTree.

Impacto

Usuário de leitor de tela no /dashboard, que é a tela inicial do app. Ao percorrer a lista de gastos por grupo, cada linha de grupo e cada linha de categoria carrega um número solto no próprio nome do botão. No caso que mais importa — categoria ou grupo acima do orçamento, exatamente o que a lista existe para sinalizar — esse número é 100, quando o gasto está em 150%. A informação visual correta (a barra vermelha, os valores) está na tela; a auditiva está errada.

Em /metas e /parcelas o efeito é menor: uma barra anônima a mais na leitura linear, e em /metas uma meta superada é anunciada como exatamente 100%.

Cobertura

Não existe teste sobre components/ui/progress.tsx (item 5 acima) — não há nada afirmando o bug, e nada que proteja contra a recaída.

Não há @testing-library/react no projeto, então o gate viável é sobre o texto-fonte, no mesmo molde de __tests__/unit/a11y-estado-selecao.test.ts (precedente versionado, criado para o #107, que documenta por que ancorar a asserção na linha inteira com ^\s*…$ e flag m).

O gate precisa reprovar hoje e continuar reprovando com a correção errada mais provável (só acrescentar aria-label), então duas asserções, não uma:

const src = readFileSync(join(process.cwd(), 'components/ui/progress.tsx'), 'utf-8')

// 1. o role não pode ser incondicional — só é exposto quando há nome
expect(src).not.toMatch(/^\s*role="progressbar"$/m)

// 2. aria-valuenow não pode ser o `value` cru
expect(src).not.toMatch(/^\s*aria-valuenow=\{value\}$/m)

Uma asserção genérica do tipo expect(src).toMatch(/aria-label/) passaria com a correção errada e não cobriria nada.

Proposta

Tornar a barra presentacional por padrão e só expor o widget quando o chamador der um nome. Em components/ui/progress.tsx:

  • sem aria-label/aria-labelledby: não emitir role="progressbar" nem os aria-value* (a barra vira decoração);
  • com nome: emitir o role, com aria-valuenow={Math.min(Math.max(value, 0), max)} e um aria-valuetext legível (ex.: "150% do orçamento"), que é o que o accname usa em vez do número quando presente.

Por que essa e não aria-label nos 4 call sites (exigência 6) — é o caminho mais curto, o repo já usa aria-label em botão de ícone, e é o que a memória do projeto sugere primeiro. Medido, ele não fecha o achado:

// só aria-label no progressbar aninhado:
button  name="Transporte 100 R$ 450,00 / R$ 300,00"          ← número errado continua no nome
// aria-valuetext humano:
button  name="Transporte 150% do orçamento R$ 450,00 / R$ 300,00"
// barra presentacional:
button  name="Transporte R$ 450,00 / R$ 300,00"              ← limpo

Nomear o widget conserta a metade (b) e deixa a metade (a) intacta, porque o passo 2E do accname contribui o valor, não o nome, do descendente. Só aria-valuetext ou a saída presentacional mexem no nome do botão.

Nos 4 call sites os mesmos números já estão em texto adjacente (R$ 1.500,00 / R$ 1.000,00, Parcela 3 de 12, 120.0%), então a saída presentacional não perde informação — o que ela remove é a duplicata errada. Se em algum site a barra for considerada informação própria, o caminho é passar aria-label e deixar o aria-valuetext fazer o trabalho; a assinatura acomoda os dois sem tocar nos outros três.

Custo estimado

M (components/ui/progress.tsx + o arquivo de gate; os 4 call sites não precisam mudar na saída padrão)

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions