Skip to content

[a11y] Nenhum item de navegação declara ser a página atual — os 22 links da sidebar, da bottom nav e do menu mobile se anunciam idênticos #118

Description

@Guiroos

Onde

São 3 sites (exigência 8), enumerados por conteúdo — todo <Link> cujo estado "esta é a página
em que você está" existe como active/isActive(href) e alimenta className:

# arquivo:linha quantos links renderiza onde aparece
1 components/layout/Sidebar.tsx:66 (NavItem) 11 (8 de mainNav:30-39 + 3 de configNav:41-45), 12 quando isAdmin toda rota de (app) em lg+app/(app)/layout.tsx monta <Sidebar> no shell
2 components/layout/BottomNav.tsx:63 (NavItem) 3 (primaryNav.slice(0,2) em :121 + /investimentos em :149) toda rota de (app) abaixo de lg
3 components/layout/BottomNav.tsx:194 (links do dialog "Menu") 7 (menuItems:39-47) menu mobile, única via para 7 das 12 rotas

Os 3 têm consumidor confirmado: app/(app)/layout.tsx monta <Sidebar> e <BottomNav> no shell
autenticado (exigência 7).

Evidência

grep -rn "aria-current" components app devolve zero ocorrências no repo inteiro.

Nos três sites o estado nasce do pathname, vira classe e para aí:

// components/layout/Sidebar.tsx:66-79
<Link
  href={href}
  onClick={onClick}
  className={cn(
    'relative flex items-center gap-3 rounded-md px-3 py-2 …',
    active
      ? 'bg-accent-subtle font-semibold text-accent-text'   // <- única saída do estado
      : 'text-text-secondary hover:bg-bg-subtle …'
  )}
>
  {active && <span className="absolute -left-2.5 bottom-2 top-2 w-1 … bg-accent" />}
  <Icon className={cn('h-4 w-4 shrink-0', active ? 'stroke-2' : 'stroke-1')} />
  {label}
</Link>

isActive (Sidebar.tsx:92-95, BottomNav.tsx:107-110) calcula corretamente qual item é o atual —
inclusive cobrindo sub-rotas via pathname.startsWith(href + '/'), o que faz /devedores/[id]
marcar "Devedores". A informação existe; ela só não sai do CSS.

Medido. Reproduzi o DOM que o NavItem emite (item ativo e itens inativos) e li a árvore via
CDP (Accessibility.getFullAXTree, Chrome/141.0.7390.37). O nó do item ativo é indistinguível dos
inativos:

{"name":"Dashboard",  "props":["focusable=true","url=\"…/dashboard\""]}   <- o ATIVO
{"name":"Histórico",  "props":["focusable=true","url=\"…/historico\""]}
{"name":"Lançamento", "props":["focusable=true","url=\"…/registro\""]}

Nome igual em forma, focusable igual, url é o único campo que difere — e ele diz para onde o
link vai, não onde o usuário está.

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

Impacto

Quem usa leitor de tela e abre a lista de links de qualquer tela de (app) recebe 11 entradas
("Dashboard, Histórico, Lançamento, Parcelas Futuras, Investimentos, Metas, Panorama Anual,
Devedores, Categorias e Grupos, Contas e Cartões, Configuração do Mês") sem nenhuma marca de onde
está. A consequência prática não é se perder — é não conseguir usar a navegação como navegação:
não dá para saber que um link é o próprio lugar, então cada tentativa de "voltar para onde eu
estava" ou de conferir se o clique surtiu efeito custa uma recarga completa da página.

O caso que fecha sozinho é o site 3, no mobile. O dialog "Menu" é a única via para /historico,
/metas, /panorama, /devedores, /categorias, /contas e /configuracao-mes; com o dialog
aberto o Radix marca o restante da página como aria-hidden, e o <h1> da página some da árvore.
Os 7 links ficam sendo a informação inteira disponível, e são idênticos entre si.

Atinge também navegação por comando de voz e quem usa magnificação de tela em nível alto, que vê
uma fração da sidebar por vez e depende do anúncio, não do destaque visual.

Cobertura

Nenhum teste pega hoje: grep -rn "aria-current" __tests__/ devolve zero, e nem Sidebar.tsx nem
BottomNav.tsx são tocados por teste algum.

Não há @testing-library/react nas devDependencies, então o gate proporcional é varredura do
texto-fonte — formato já versionado em __tests__/unit/row-actions.test.ts (adotado para a #54).
Cabe no mesmo arquivo que a #117 propõe se ela vier antes
(__tests__/unit/a11y-estado-expansao.test.ts → renomear para a11y-estado-navegacao.test.ts só se
as duas caírem juntas); se vier isolada, arquivo próprio
__tests__/unit/a11y-pagina-atual.test.ts. Em nenhum caso estender
focus-ring-contrast.test.ts, que existe para concentrar os gates que compartilham a maquinaria
OKLCH→sRGB, inexistente aqui.

As asserções precisam ancorar na expressão de estado:

const sidebar = readFileSync(join(process.cwd(), 'components/layout/Sidebar.tsx'), 'utf-8')
const bottom  = readFileSync(join(process.cwd(), 'components/layout/BottomNav.tsx'), 'utf-8')

// sites 1 e 2 — o NavItem de cada arquivo recebe `active: boolean`
expect(sidebar).toMatch(/aria-current=\{active \? 'page' : undefined\}/)
expect(bottom).toMatch(/aria-current=\{active \? 'page' : undefined\}/)
// site 3 — os links do dialog calculam na hora
expect(bottom).toMatch(/aria-current=\{isActive\(href\) \? 'page' : undefined\}/)

Por que ancorar na expressão, e por que ? 'page' : undefined e não aria-current={active}:
são as duas correções erradas mais prováveis, e ambas passariam num
expect(src).toMatch(/aria-current/) genérico. aria-current="page" fixo marca todos os 11
links como a página atual — resultado pior que o bug. E aria-current={active} renderiza
aria-current="false" no HTML, que a especificação de ARIA trata como o estado false, não
como ausência — o React só omite o atributo para undefined/null, não para false, em atributos
aria-*. Isso deixaria a marcação sintaticamente correta e semanticamente inerte, que é o pior
resultado possível: verde no gate genérico, sem correção. As três asserções falham hoje.

Como os sites 1 e 2 passam pelos respectivos NavItem, um caso por arquivo cobre os 14 links; o
site 3 precisa da terceira asserção porque os links do dialog são escritos inline
(BottomNav.tsx:194-210), fora do NavItem.

Proposta

Uma linha por site, aditiva, sem mudança visual:

   // components/layout/Sidebar.tsx:66  e  components/layout/BottomNav.tsx:63
   <Link
     href={href}
     onClick={onClick}
+    aria-current={active ? 'page' : undefined}
     className={cn(…)}
   >
   // components/layout/BottomNav.tsx:194
   <Link
     key={href}
     href={href}
+    aria-current={isActive(href) ? 'page' : undefined}
     onClick={() => { setPendingHref(href); setMenuOpen(false) }}

'page' e não 'true' (exigência 6): os dois são válidos, mas aria-current="page" é o token
específico para "este link aponta para a página em que você está" e é o que os leitores anunciam
como "página atual"; 'true' é o genérico de fallback, para quando não há token que descreva a
relação. Aqui há.

Não trocar <Link> por <a> nem mexer em isActive: o cálculo já está certo, inclusive o
pendingHref que antecipa o destaque durante a navegação (Sidebar.tsx:93). A correção é só
publicar o booleano que já existe.

Adjacente, deliberadamente fora deste escopo

As quatro <nav> do shell (Sidebar.tsx:140 "Principal", :157 "Configuração",
BottomNav.tsx:115 e :192) não têm aria-label, então a lista de landmarks lê "navigation"
quatro vezes. Mesmo critério (1.3.1) e mesmos dois arquivos, mas é outro achado com outro gate —
fica registrado aqui para não se perder, e não deve entrar num PR que escreva closes nesta
issue.

Custo estimado

P (2 arquivos: components/layout/Sidebar.tsx e components/layout/BottomNav.tsx) + 1 de teste.
Três linhas aditivas no total; nenhuma altera comportamento visual, roteamento ou fluxo de dados.

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