Programação Por Equipe DevBras

Capa do artigo: Menu hambúrguer que abre, fecha no Esc e devolve o foco

Menu hambúrguer que abre, fecha no Esc e devolve o foco

Monte o menu retrátil com um botão que diz se está aberto, feche no Esc e no clique fora, devolva o foco ao botão e mostre o menu inteiro na tela larga.

No celular, a pessoa toca no ícone de três linhas, a lista de links desce por cima do conteúdo e ela desiste de navegar. Procura um "X", não acha, toca no ícone de novo e nada muda porque o script só sabia abrir. Quem usa teclado tem um problema pior: o menu fechou visualmente, mas o Tab continua passando pelos links invisíveis antes de chegar ao conteúdo.

A resposta curta: o menu retrátil é um botão com estado e uma lista que aparece e some de verdade. O botão tem nome e aria-expanded; a lista usa o atributo hidden quando está fechada; Esc fecha e devolve o foco ao botão; clicar fora fecha também. Na tela larga, se há espaço, o menu fica visível e o botão some.

Por que o menu "só visual" prende a navegação

A maioria dos menus retráteis antigos anima a altura ou a posição da lista com CSS ou jQuery (slideToggle, left: -300px). A lista sai do campo de visão, mas continua no documento. Para o leitor de tela e para o Tab, ela está lá: links que ninguém enxerga, recebendo foco, na frente do conteúdo.

O segundo problema é o estado. Se o botão não diz se o menu está aberto, quem não enxerga a animação não sabe o que o toque fez. O atributo aria-expanded no botão resolve isso: o leitor de tela anuncia "Menu, botão, recolhido" ou "expandido", e você ganha de brinde um seletor para estilizar o ícone.

O terceiro é a saída. Um painel que abre por cima do conteúdo precisa de pelo menos dois jeitos de fechar: o próprio botão e o Esc. Clicar fora é um terceiro jeito, bem-vindo, mas não pode ser o único, porque no teclado não existe "fora".

Como montar o menu passo a passo

  1. Use um <button type="button"> com texto visível ou nome acessível ("Menu"), nunca um <div> com ícone.
  2. Ligue o botão à lista com aria-controls e comece com aria-expanded="false".
  3. Deixe a lista visível no HTML e esconda pelo JavaScript. Se o script falhar, o menu continua usável.
  4. Ao abrir, tire o hidden da lista e mude aria-expanded para true. Ao fechar, o contrário.
  5. No Esc, feche e mova o foco de volta para o botão.
  6. No clique fora do menu e do botão, feche sem mexer no foco.
  7. Na tela larga, mostre a lista sempre e esconda o botão.

O HTML

<header>
  <a href="/">Loja Exemplo</a>
  <button type="button" aria-expanded="false" aria-controls="menu-principal" data-menu-botao>
    Menu
  </button>
  <nav aria-label="Principal">
    <ul id="menu-principal">
      <li><a href="/produtos">Produtos</a></li>
      <li><a href="/ofertas">Ofertas</a></li>
      <li><a href="/atendimento">Atendimento</a></li>
    </ul>
  </nav>
</header>

Os itens são links comuns, dentro de um <nav> com rótulo. Não use role="menu" aqui: esse papel é para menus de aplicativo, como os de um editor de texto, e promete ao leitor de tela navegação por setas que você não implementou. Navegação de site é lista de links.

O JavaScript

const botao = document.querySelector('[data-menu-botao]');
const menu = document.getElementById(botao.getAttribute('aria-controls'));
const telaLarga = window.matchMedia('(min-width: 900px)');

function abrir() {
  menu.hidden = false;
  botao.setAttribute('aria-expanded', 'true');
}

function fechar({ devolverFoco = false } = {}) {
  menu.hidden = true;
  botao.setAttribute('aria-expanded', 'false');
  if (devolverFoco) botao.focus();
}

botao.addEventListener('click', () => {
  if (menu.hidden) abrir();
  else fechar();
});

document.addEventListener('keydown', (evento) => {
  if (evento.key === 'Escape' && !menu.hidden && !telaLarga.matches) {
    fechar({ devolverFoco: true });
  }
});

document.addEventListener('click', (evento) => {
  if (menu.hidden || telaLarga.matches) return;
  if (menu.contains(evento.target) || botao.contains(evento.target)) return;
  fechar();
});

function ajustarAoTamanho() {
  if (telaLarga.matches) {
    menu.hidden = false;
  } else {
    fechar();
  }
}

telaLarga.addEventListener('change', ajustarAoTamanho);
ajustarAoTamanho();

A chamada final de ajustarAoTamanho é o que esconde a lista no celular quando o script carrega. Antes disso, ela está visível, então quem fica sem JavaScript por qualquer motivo ainda vê os links.

O matchMedia com o evento change evita escutar resize a cada pixel. Quando a pessoa gira o tablet e passa de 900 pixels, o menu aparece inteiro; quando volta, ele fecha. Use o mesmo número no CSS:

@media (min-width: 900px) {
  [data-menu-botao] { display: none; }
  #menu-principal { display: flex; gap: 1.5rem; }
}

Detalhes que separam o menu bom do que só funciona

Devolver o foco só quando faz sentido

No Esc, o foco volta ao botão, porque a pessoa estava dentro do menu e precisa de um ponto de partida. No clique fora, não: ela clicou em outro lugar de propósito, e puxar o foco de volta para o topo da página atrapalharia. Quando a pessoa escolhe um link, a página muda e o foco segue a navegação normal.

Menu que cobre a tela inteira vira modal

Se o seu menu ocupa a tela toda, com fundo escuro e o conteúdo atrás inacessível, ele deixou de ser um painel retrátil e virou um diálogo. Nesse caso, o foco precisa ficar preso dentro dele enquanto estiver aberto, e o elemento <dialog> com showModal() resolve boa parte disso. O cuidado com o retorno do foco está em modal que devolve o foco. Para um menu que desce e empurra ou cobre só parte do conteúdo, não prenda o foco.

Animação sem esconder o estado

Animar a abertura é possível, mas a animação não pode substituir o hidden. Se quiser transição, anime a altura ou a opacidade e aplique o hidden ao fim da animação de fechamento. Respeite prefers-reduced-motion para quem desligou animações no sistema.

Menu fixo no topo

Se o cabeçalho com o botão fica fixo durante a rolagem, ele não pode cobrir o título da seção nem o elemento com foco. As medidas para isso estão em menu fixo que não cobre o título, e o comportamento de acompanhar a rolagem em menu que acompanha a rolagem.

Erros comuns

  • Esconder a lista só com posição ou altura. Os links continuam no Tab e no leitor de tela, invisíveis, na frente do conteúdo.
  • Botão sem aria-expanded. Quem não vê a animação não sabe se o toque abriu ou fechou o menu.
  • Ícone de três linhas sem nome. O leitor de tela anuncia "botão" e nada mais.
  • Clicar fora como única saída. No teclado não existe "fora", e o menu fica aberto até a pessoa desistir.
  • Esc que fecha e deixa o foco num link escondido. O próximo Tab some em lugar nenhum e a pessoa perde a posição.
  • role="menu" numa lista de links. O leitor de tela espera setas e a navegação real não corresponde ao anunciado.

Checklist

  • O controle é um <button> com nome acessível.
  • aria-expanded no botão muda junto com a lista.
  • A lista fechada tem hidden e sai da ordem do Tab.
  • Esc fecha o menu e devolve o foco ao botão.
  • Clicar fora fecha, sem roubar o foco.
  • Sem JavaScript, os links continuam visíveis.
  • Na tela larga, a lista aparece inteira e o botão some.

Abra o menu do seu site no celular ou com a janela estreita, aperte Tab até entrar nele e depois Esc. O foco voltou para o botão que abriu, ou ficou perdido num link que você não consegue mais ver?

Avalie este artigo

Referência: http://blog.thiagobelem.net/criando-um-menu-retratil-com-css-e-jquery/