Programação Por Equipe DevBras

Capa do artigo: Menu fixo e WCAG 2.4.11: teste o foco escondido

Menu fixo e WCAG 2.4.11: teste o foco escondido

Menu fixo que esconde o foco reprova a WCAG 2.4.11. Veja onde a barra cobre o link de pular e as âncoras e rode um teste com Puppeteer que pega isso no CI.

Um menu fixo no topo pode reprovar uma página num critério de acessibilidade que entrou na WCAG 2.2: o 2.4.11 Foco não encoberto (mínimo), nível AA. Ele pede que, quando um componente recebe foco do teclado, ele não fique inteiramente escondido por conteúdo criado pelo autor, e cabeçalho fixo é o exemplo clássico. Este post explica o critério, mostra onde a barra costuma esconder o foco e traz um teste com Puppeteer que encontra o problema sozinho e pode rodar no CI.

O CSS do menu em si, a diferença entre sticky e fixed e os motivos de o sticky não grudar estão no par deste post, position: sticky não gruda? Menu fixo no topo em CSS.

O que os critérios 2.4.11 e 2.4.12 pedem

O texto do W3C é curto. No 2.4.11 (AA), o componente com foco precisa ficar pelo menos parcialmente visível. No 2.4.12 (AAA), ele precisa ficar inteiramente visível. Para um site comum, mirar o AA é o mínimo; ter o AAA de graça é um bom sinal de que o espaço da barra foi reservado direito.

Quem depende disso é quem não usa mouse: pessoas que navegam por teclado, por acionador ou por comando de voz precisam ver onde está o foco para saber o que o próximo Enter vai fazer. Se o link focado está atrás da barra, a pessoa aperta Enter às cegas.

O título de uma seção aberta por âncora não é foco, então não entra no 2.4.11. Mas o efeito para quem lê é parecido: a pessoa abre /manual#instalacao e cai num parágrafo solto, sem o título que diz onde ela está. O mesmo CSS resolve os dois casos.

Onde o foco some de verdade

No teste deste post, com Chrome 156, a navegação por Tab no meio da página não escondeu nenhum link: ao focar um link fora da área visível, o Chrome rolou a página e deixou o link longe da borda. O que falhou foram os pontos em que a página para exatamente no topo:

  • O link "Pular para o conteúdo". É o primeiro Tab da página e, se não tiver estilo próprio, fica embaixo de uma barra fixed.
  • O destino do pular e de qualquer âncora. Ao abrir #conteudo ou #banco, o navegador alinha o alvo no topo da janela, atrás da barra.
  • Elementos focados por script com focus() ou scrollIntoView() depois de validar um formulário, que também alinham pelo topo.

Outros navegadores rolam o foco de formas diferentes, e a regra pode mudar entre versões. Por isso o teste precisa rodar no navegador que o seu público usa, e não substituir uma passada manual com Tab e Shift+Tab em Firefox e Safari.

A correção, em resumo

Guarde a altura da barra numa variável e use a mesma variável no scroll-padding-top do html. O navegador passa a descontar essa faixa ao rolar até âncoras, destino de link de pular e, nos navegadores atuais, foco que entra pela borda:

:root { --altura-barra: 64px; }
.topo { position: sticky; top: 0; z-index: 10; min-height: var(--altura-barra); }
html { scroll-padding-top: calc(var(--altura-barra) + 12px); }
.pular { position: absolute; left: 1rem; top: -10rem; }
.pular:focus { top: 1rem; z-index: 20; }

A explicação de cada linha e o caso em que a altura muda no celular estão no post sobre o menu com sticky. Aqui o que interessa é provar que funcionou.

Um teste que acha o foco escondido

O script abaixo usa Puppeteer (puppeteer-core 25.16 com o Chrome instalado no sistema, Node 20). Ele faz duas coisas:

  • Aperta Tab e depois Shift+Tab pela página e, a cada passo, mede quanto do elemento focado está atrás de algum elemento fixed ou sticky. 100% coberto é falha no 2.4.11; cobertura parcial é aviso para o 2.4.12.
  • Abre cada âncora linkada na página e mede quanto da primeira linha do alvo ficou atrás da barra.
// foco-coberto.mjs: aperta Tab pela página toda e lista o foco escondido atrás de elementos fixos/sticky
import puppeteer from 'puppeteer-core';

const url = process.argv[2];
const browser = await puppeteer.launch({
  executablePath: process.env.CHROME ?? '/usr/bin/google-chrome',
  args: ['--no-sandbox'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720 });
await page.goto(url, { waitUntil: 'load' });

const problemas = [];
const voltas = Number(process.argv[3] ?? 40);
// ida com Tab e volta com Shift+Tab: na volta o navegador rola o foco pela borda de cima
const teclas = [...Array(voltas).fill('Tab'), ...Array(voltas).fill('Shift+Tab')];
for (const tecla of teclas) {
  if (tecla === 'Shift+Tab') {
    await page.keyboard.down('Shift');
    await page.keyboard.press('Tab');
    await page.keyboard.up('Shift');
  } else {
    await page.keyboard.press('Tab');
  }
  const r = await page.evaluate(() => {
    const el = document.activeElement;
    if (!el || el === document.body) return null;
    const f = el.getBoundingClientRect();
    // elementos fixos ou grudados que podem cobrir o foco
    const capas = [...document.querySelectorAll('body *')].filter((c) => {
      const p = getComputedStyle(c).position;
      return (p === 'fixed' || p === 'sticky') && !c.contains(el);
    });
    let pior = 0;
    for (const c of capas) {
      const b = c.getBoundingClientRect();
      const w = Math.max(0, Math.min(f.right, b.right) - Math.max(f.left, b.left));
      const h = Math.max(0, Math.min(f.bottom, b.bottom) - Math.max(f.top, b.top));
      pior = Math.max(pior, (w * h) / (f.width * f.height || 1));
    }
    return { texto: el.textContent.trim().slice(0, 30), coberto: Math.round(pior * 100) };
  });
  if (r && r.coberto > 0) problemas.push({ ...r, tecla });
}
// âncoras: abre cada #alvo linkado na página e mede quanto do alvo ficou atrás da barra
const ancoras = await page.evaluate(async () => {
  const ids = [...new Set([...document.querySelectorAll('a[href^="#"]')].map((a) => a.hash.slice(1)))];
  const res = [];
  for (const id of ids) {
    const alvo = document.getElementById(id);
    if (!alvo) continue;
    location.hash = id;
    await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
    const f = alvo.getBoundingClientRect();
    const topo = Math.max(0, ...[...document.querySelectorAll('body *')]
      .filter((c) => ['fixed', 'sticky'].includes(getComputedStyle(c).position) && !c.contains(alvo))
      .map((c) => c.getBoundingClientRect())
      .filter((b) => b.top <= 1)
      .map((b) => b.bottom));
    // mede só a primeira linha do alvo (um título); 1 linha = até 40 px
    const linha = Math.min(f.height, 40);
    const coberto = Math.max(0, Math.min(topo, f.top + linha) - f.top) / linha;
    if (coberto > 0) res.push({ texto: '#' + id, coberto: Math.round(coberto * 100), tecla: 'âncora' });
  }
  return res;
});
problemas.push(...ancoras);
await browser.close();

for (const p of problemas) {
  const nivel = p.tecla === 'âncora'
    ? 'título atrás da barra'
    : p.coberto === 100 ? 'FALHA 2.4.11 (foco inteiro escondido)' : 'aviso 2.4.12 (foco parcial)';
  console.log(`${p.tecla.padEnd(9)} ${String(p.coberto).padStart(3)}% coberto  ${nivel}  "${p.texto}"`);
}
console.log(problemas.length ? `${problemas.length} problema(s) encontrado(s)` : 'Nenhum foco coberto');
process.exitCode = problemas.some((p) => p.coberto === 100) ? 1 : 0; // falha o CI

Instale com npm i puppeteer-core e rode com node foco-coberto.mjs https://seu-site/manual 40, em que o último número é quantas vezes apertar Tab em cada sentido. Numa página de teste com barra fixed de 64 px e sem scroll-padding-top, a saída foi:

Tab       100% coberto  FALHA 2.4.11 (foco inteiro escondido)  "Pular para o conteúdo"
Shift+Tab 100% coberto  FALHA 2.4.11 (foco inteiro escondido)  "Pular para o conteúdo"
âncora    100% coberto  título atrás da barra  "#conteudo"
âncora    100% coberto  título atrás da barra  "#instalacao"
âncora    100% coberto  título atrás da barra  "#banco"
âncora    100% coberto  título atrás da barra  "#s1"
âncora    100% coberto  título atrás da barra  "#s3"
âncora    100% coberto  título atrás da barra  "#s5"
âncora    100% coberto  título atrás da barra  "#s6"
âncora    100% coberto  título atrás da barra  "#s7"
10 problema(s) encontrado(s)

A mesma página com sticky, scroll-padding-top e o estilo do link de pular imprimiu Nenhum foco coberto e saiu com código 0. A figura mostra as duas versões abertas no mesmo link #instalacao:

Como usar no CI

O script sai com código 1 quando algum foco fica 100% coberto, então basta rodá-lo como passo do pipeline contra o ambiente de homologação. Três cuidados para o teste não virar ruído:

  • Rode em duas larguras. Troque o setViewport para 390 x 844 numa segunda passada. É no celular que a barra quebra em duas linhas e a variável de altura fica mentindo.
  • Ignore o que a pessoa abriu. Pela nota do próprio critério, conteúdo que a pessoa abriu, como um menu suspenso, não conta como encobrimento se ela puder revelar o item focado sem mover o foco (fechando o menu com Esc, por exemplo). Feche menus antes de medir.
  • Banner de cookies conta. Um aviso fixo no rodapé é conteúdo do autor e esconde o foco que entra por baixo. Teste com e sem o banner aceito.

O teste não substitui o olhar humano para o anel de foco em si: se ele quase não aparece no tema escuro, o problema é outro critério, tratado em foco visível no tema escuro. E quando um modal fecha, o foco precisa voltar para o botão que o abriu, como em foco que volta.

Resumo

O 2.4.11 reprova foco inteiramente escondido por barra fixa; o 2.4.12 pede o foco inteiro visível. Os pontos frágeis são o link de pular, as âncoras e o foco mandado por script. Reserve o espaço com scroll-padding-top ligado à altura real da barra e deixe um teste automatizado conferindo isso a cada deploy.

Avalie este artigo

Referência: http://pt.stackoverflow.com/questions/4100/como-fixar-um-menu-horizontal-no-topo-da-janela-ao-rolar-a-p%C3%A1gina