Skip to content

A11y/Teclado: o modal "configurar sala" não é um diálogo — sem role/aria-modal, o foco nunca entra e o Tab passeia pela home atrás (Modal.tsx:27-64) (§VII.4) #128

Open

Description

@caioross

Contexto

docs/UI-AAA-OVERHAUL.md §VII.4, bloco Respeito ao usuário (binário): "Teclado 100%; foco
gerenciado; WCAG AA"
. O único diálogo do produto não cumpre nenhuma das três coisas.

src/components/ui/Modal.tsx é usado num lugar só — HomeView.tsx:420, o modal // configurar sala, que é o caminho primário de criação de sala (linguagem via BannerCarousel, dificuldade,
máximo de jogadores, público/privado). Verificado em origin/main@31c1c2d:

  • Não se declara um diálogo. O card em Modal.tsx:37-43 é um motion.div cru: sem
    role="dialog", sem aria-modal="true", sem aria-labelledby. Para leitor de tela não existe
    diálogo nenhum — o conteúdo simplesmente aparece no meio da home.
  • O foco nunca entra. Não há nada que mova o foco para dentro ao abrir. Quem clica no
    StarBorder "Criar sala" (HomeView.tsx:325) e passa a navegar por teclado continua com o foco
    no botão atrás do backdrop.
  • O Tab passeia pela página de trás. Sem trap, Tab sai do modal e percorre header, ranking,
    login com Google, inputs de nick/código, salas públicas e footer — todos visualmente cobertos
    pelo bg-black/70 backdrop-blur (Modal.tsx:31). O usuário digita "às cegas" num formulário que
    não vê.
  • O foco não volta. Ao fechar (Escape em Modal.tsx:22, o X, o backdrop, "cancelar" ou
    "criar sala"), o foco cai para o <body> — a próxima tecla Tab recomeça do topo da página.

Não é teórico: o modal contém o BannerCarousel, cuja navegação é inteiramente por teclado
(BannerCarousel.tsx:499-513, roving focus com setas). O componente foi feito acessível por dentro
e vive dentro de um contêiner que não gerencia foco.

Acceptance criteria

  1. Com o modal aberto, document.activeElement está dentro do card (primeiro focável, ou o
    próprio card com tabIndex={-1}).
  2. Tab a partir do último focável volta ao primeiro; Shift+Tab a partir do primeiro vai ao
    último. Em nenhum momento o foco alcança um elemento fora do modal.
  3. Ao fechar por qualquer caminho (Escape, botão X, clique no backdrop, "cancelar", "criar
    sala"), o foco volta ao elemento que abriu o modal.
  4. O card tem role="dialog", aria-modal="true" e aria-labelledby apontando para o id do
    <h3> do título (Modal.tsx:45-47).
  5. Não regride o carousel: ArrowLeft/ArrowRight/Home/End continuam navegando as
    bandeiras dentro do modal — o trap intercepta apenas Tab.
  6. O comportamento vale para qualquer uso futuro do Modal (a correção é no componente, não no
    HomeView).
  7. Gate verde: pnpm typecheck · pnpm test · pnpm build.

Dica de abordagem

  • Mudança local e sem dependência nova (§7.3). Não instale focus-trap/radix-dialog: dep
    nova cai em §7.2 e pede quórum, para um problema de ~40 linhas.
  • Trap: onKeyDown no card, if (e.key !== "Tab") return, coletar focáveis com um
    querySelectorAll do seletor padrão (a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])), e ciclar com e.preventDefault() só nas bordas.
  • Retorno de foco: guarde document.activeElement num useRef no efeito que dispara quando open
    vira true, e restaure no cleanup — não no onClose, senão os 5 caminhos de fechamento
    precisam lembrar de chamar.
  • AnimatePresence mantém o nó montado durante a animação de saída (Modal.tsx:28): a restauração
    de foco não pode depender do nó ter sumido.
  • Considere aria-hidden/inert no conteúdo de fundo enquanto aberto. Se decidir não fazer,
    declare o porquê no PR (o trap de Tab já resolve o essencial; o modo de navegação por
    cursor virtual do leitor de tela é o que fica de fora).
  • Verificação — leia antes de tentar: o Browser pane não entrega keydown real
    (computer{key} não dispara; type usa insertText). Não tente provar o trap clicando no
    preview e não conclua que está quebrado por isso. Prove por lógica: extraia o cálculo do próximo
    foco numa função pura testável, e/ou dispare KeyboardEvent sintético via javascript_tool.

Escopo

§7.3 (UI/componentes). Não toca API, banco, engine nem a área sagrada — o modal só existe fora da
corrida. Fora de escopo: prefers-reduced-motion nas animações do modal (é a #108) e a11y da
tabela do ranking (é a #93).

Activity

  1. added
    P2Prioridade normal
    area:uiInterface, design system, animação, game feel
    on Aug 5, 2026
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

    P2Prioridade normalarea:uiInterface, design system, animação, game feel

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions