Criar um menu suspenso

Criar um menu suspenso

Este guia aborda qualquer menu pop-up: suspenso, de contexto, flutuante e as barras de menu que os abrem. O exemplo trabalhado é um menu de comandos do aplicativo, em que os itens mudam a interface (criar, salvar, preferências).

Duas opções independentes. Todo o resto se aplica a ambos.

  • Elemento, por item. Um comando é um <button type="button">. Um destino é um <a href> que ainda abre em uma nova guia, é copiado como um URL e funciona sem script. Um menu suspenso pode misturá-los livremente: dois links e seis comandos, ou um comando e seis links, não muda nada abaixo.
  • Modelo de interação, por menu suspenso. As teclas de seta, Home/End e Esc o transformam em um menu: adicione o focusgroup, deixe que ele infira menuitem em cada item, seja botão ou link, e faça com que ele se pareça com um (uma barra contínua ou um acionador com um indicador de menu suspenso; consulte "Barra de menus ou botões de menu separados" abaixo). Uma lista curta de links que os usuários Tab usam para navegar: sem focusgroup, sem role="menu", role="menuitem" ou aria-haspopup="menu". Mantenha o popover, Esc e a restauração de foco e consulte o guia da barra de navegação.

O conteúdo nunca decide as funções. Um menu suspenso de links "Produtos" pode ser um menu, mas um menu suspenso de comandos "Conta" não pode ser uma navegação.

Use o padrão de menu apenas para um widget composto com uma parada de tabulação e navegação por teclas de seta. Use um <div focusgroup="menu nomemory">, não o elemento HTML <menu>. Não adicione role="menu" ou role="menuitem" na marcação compartilhada: o focusgroup nativo infere esses papéis, e os papéis explícitos bloqueiam essa inferência. Adicione-os apenas no substituto de navegador sem suporte abaixo. nomemory faz com que a reabertura comece no primeiro item. O Focusgroup move o foco, e o script lida com a visualização, a ativação e a restauração do foco.

Abrir e posicionar o menu

Faça com que cada gatilho seja um invocador de comando com commandfor e command="toggle-popover". O navegador abre e fecha o popover ao clicar, pressionar Enter e Espaço, e mantém o acionador dentro do grupo de fechamento leve do popover. Assim, clicar em um acionador aberto o fecha. Não use popovertarget ou popovertargetaction. Esse padrão usa commandfor.

Coloque cada popover imediatamente após o acionador. Os navegadores adicionam uma relação aria-details implícita apenas quando o popover não é o próximo irmão. Portanto, esse posicionamento evita isso sem atributos extras.

<!-- Command invoker: the browser toggles the popover on click, Enter, and Space -->
<button id="file-trigger" class="trigger" type="button" aria-haspopup="menu" aria-expanded="false"
  commandfor="file-menu" command="toggle-popover">
  File
</button>
<!-- Immediately after its trigger, so browsers add no implicit aria-details -->
<div id="file-menu" popover="auto">
  <!-- Nested, not on the popover: Chromium otherwise drops focusgroup keyboard behavior -->
  <div focusgroup="menu nomemory" aria-labelledby="file-trigger">
    <button type="button">New file</button>
    <button type="button">Save</button>
    <button id="export-trigger" type="button" aria-haspopup="menu" aria-expanded="false"
      commandfor="export-menu" command="toggle-popover">
      Export <span aria-hidden="true">›</span>
    </button>
    <!-- Submenu: popover="auto" directly after its trigger, inside the parent focusgroup -->
    <div id="export-menu" popover="auto">
      <div focusgroup="menu nomemory" aria-labelledby="export-trigger">
        <button type="button">PDF</button>
        <button type="button">Plain Text</button>
      </div>
    </div>
  </div>
</div>

Nomeie cada menu com aria-labelledby apontando para o gatilho. Mantenha os indicadores decorativos de submenu no CSS ou adicione aria-hidden="true". Dê aos acionadores com aria-expanded="true" um estilo ativo visível para que os usuários possam saber qual menu está aberto ou em pré-visualização.

Escolha um formato para os acionadores de nível superior. A função, o modelo de teclado e o estilo precisam corresponder: botões visualmente separados indicam uma parada Tab cada.

  • Barra de menus. role="menubar" no contêiner (nunca em um popover), estilizado como uma barra contínua sem espaços entre os gatilhos. Uma parada de Tab; ArrowLeft/ArrowRight para alternar entre os acionadores; passe o cursor para abrir os menus. Use para uma barra de comandos do aplicativo.
  • Botões de menu separados. Cada gatilho é um ponto de parada Tab e se parece com um botão independente. Sem role="menubar", sem teclas de seta entre os acionadores, sem visualização em foco, sem alternância ao passar o cursor. Envolva em <nav> se eles navegarem. Caso contrário, não use nenhum papel. Usado para barras de ferramentas e cabeçalhos de página.

O restante deste guia descreve o formato da barra de menus. Para botões separados, mantenha tudo dentro de cada menu e descarte o comportamento no nível da barra de menus.

Posicionar o menu com o posicionamento de âncora do CSS

Use o posicionamento de âncora CSS, não as coordenadas do JavaScript. Tanto command="toggle-popover" quanto showPopover({ source: trigger }) definem uma âncora implícita. Portanto, não adicione regras anchor-name ou position-anchor por menu. O comportamento do menu não pode depender do posicionamento da âncora.

/* Top-level menus sit below their trigger. The implicit anchor comes from the invoker or source. */
[popover] {
  position-area: block-end span-inline-end;
  inset: auto;
}

/* A popover nested in another popover is a submenu: beside its parent item, flipping when it would overflow. */
[popover] [popover] {
  position-area: inline-end span-block-end;
  position-try-fallbacks: flip-inline, flip-block, --below;
}

/* Last resort for narrow viewports: directly below the trigger, indented, so the submenu never
   covers its trigger. */
@position-try --below {
  position-area: block-end span-inline-end;
  margin-inline-start: 1rem;
  /* Fit the area: the submenu's minimum width must not stop this fallback from fitting. */
  min-inline-size: 0;
  max-inline-size: calc(100% - 1rem);
}

Mantenha --below por último para que ele seja aplicado apenas quando nenhum dos lados se encaixar. Ele abre diretamente abaixo do acionador, então, em uma tela estreita, ele cobre os itens irmãos abaixo do acionador. Essa é a compensação aceita para ficar ao lado do acionador. Também permita que o submenu seja reduzido para a área (min-inline-size: 0, max-inline-size menos o recuo). Um substituto que não se ajusta é ignorado. Se nenhum se ajustar, o navegador vai manter o posicionamento de base, de modo que um submenu que seja alguns pixels largo demais vai cobrir o acionador sem que o usuário perceba.

Para saber como os substitutos de inversão funcionam em um popover simples, consulte Menus suspensos que consideram a janela de visualização. Esse guia posiciona um grupo de botões, não um menu. Para menus, o posicionamento do submenu e o substituto --below acima têm precedência.

Animar abertura e fechamento

Opcional. Menos de 150 ms, apenas opacity e um pequeno translate, o mesmo para submenus, dentro de @media (prefers-reduced-motion: no-preference). O foco se move em toggle, então a transição nunca atrasa os usuários do teclado.

@media (prefers-reduced-motion: no-preference) {
  /* Closing: this rule is in effect once :popover-open is removed. */
  [popover] {
    opacity: 0;
    translate: 0 -.25rem;
    transition: opacity .15s, translate .15s, display .15s allow-discrete, overlay .15s allow-discrete;
  }
  /* Opening: faster. */
  [popover]:popover-open {
    opacity: 1;
    translate: 0 0;
    transition-duration: .1s;
    @starting-style { opacity: 0; translate: 0 -.25rem; }
  }
  /* Switching between open menubar menus is instant, as in native menubars. */
  [role="menubar"]:has(> [popover]:popover-open) > [popover] {
    transition: none;
  }
}

@starting-style não é uma repetição de [popover]: um popover fechado é display: none, e as transições não são executadas de um estado não renderizado, então ele fornece os valores anteriores para a abertura. Sem ele, a abertura é instantânea, e apenas o fechamento é animado. display e overlay com allow-discrete mantêm o popover renderizado e na camada superior até que a transição de fechamento termine.

Teclado e foco

Não adicione um segundo sistema de tabindex móvel dentro de um focusgroup. O script abrange apenas o que os invocadores e o grupo de foco não fazem:

  • Uma parada de tabulação para a barra de menus. Tab entra no primeiro acionador de nível superior. ArrowLeft e ArrowRight se movem entre os acionadores de nível superior e se ajustam em qualquer uma das extremidades. Home e End em um gatilho entram no menu no primeiro ou no último item.
    • Observação: para qual gatilho Tab retorna. O APG permite o primeiro ou o último acionador usado. O guia especifica o primeiro, mas a demonstração move a parada de tabulação com ArrowLeft e ArrowRight. Assim, após a navegação pelo teclado, Shift+Tab retorna ao último gatilho alcançado. O uso do ponteiro não o move, então a mesma sequência pode chegar a um acionador diferente. Os dois comportamentos são aceitáveis. Escolha um e mantenha a consistência.
  • Visualizar. Focar em um acionador de nível superior mostra o menu dele e define aria-expanded="true". O foco permanece no acionador, e nenhum item é focado. Desfocar o acionador fecha um menu somente para visualização.
  • Enter e Espaço em uma visualização. Eles fecham a prévia e mantêm o foco no acionador. A ação nativa toggle-popover lida com isso, então o script não pode interceptar essas chaves.
  • Seta para baixo em uma visualização. Converte em um menu aberto e foca no primeiro item.
  • Clique do ponteiro em um gatilho aberto ou visualizado. Fecha o menu de forma nativa, sem entrar nele.
    • Diferença conhecida nos acionadores de submenu. Um clique do ponteiro em um acionador de submenu também alterna nativamente, de modo que pode fechar um submenu que já foi visualizado ao passar o cursor ou abrir um que ainda não apareceu. Os menus nativos apenas abrem e nunca fecham. Isso é inofensivo, então o guia aceita.
  • Clique do ponteiro em um gatilho fechado. Abre o arquivo de forma nativa. O foco permanece no acionador. Nenhum item é focado. Somente as aberturas do teclado entram no menu.
  • Ponteiro sobre um item. Foca sem um anel, então o foco e o cursor são um item.
  • Ponteiro sobre outro acionador de nível superior enquanto um menu está aberto. Fecha o elemento e mostra uma prévia do que está sob o cursor, focando o acionador sem um anel. Nada aberto: passar o cursor não faz nada.
  • Ponteiro sobre um acionador de submenu. Mostra uma prévia do submenu após um breve atraso (o foco do ponteiro executa a mesma prévia ao focar). Um item irmão o fecha.
  • Em um menu. ArrowUp e ArrowDown movem entre os itens ativados. Home e End movem para o primeiro e o último item ativado, que são processados pelo script.
  • Seta para a esquerda ou Seta para a direita em um menu de nível superior aberto. Fecha o menu e mostra o menu adjacente com foco no acionador, quebrando em qualquer uma das extremidades. ArrowDown entra no local. As setas para a esquerda e para a direita nunca levam a um menu.
  • Seta para a direita em um acionador de submenu. Abre o submenu e foca no primeiro item.
  • Seta para a direita em um item de submenu sem um submenu filho. Fecha a hierarquia e mostra o próximo menu de nível superior, enquanto o foco permanece no acionador. ArrowDown entra no local.
  • Seta para a esquerda ou Esc em um submenu. Fecha apenas esse submenu e restaura o foco para o acionador. O menu da família permanece aberto.
  • Enter ou Espaço em um comando. Ativa o item e fecha todos os menus abertos.
  • Guia. Sai do sistema de menus. Não prenda o foco nem percorra todos os comandos. O fechamento não pode deixar o foco dentro do conteúdo oculto.

O evento command é acionado antes que toggle-popover abra o menu. Por isso, o foco não pode ser movido para o menu. Em vez disso, mova o foco no evento toggle. O HTML restaura o foco apenas para o primeiro popover="auto" em uma pilha. Portanto, os níveis aninhados precisam restaurar o foco para o acionador de forma explícita.

Implementar a interação

Implemente os comportamentos acima para sua própria marcação. O processamento de seta, Home/End, retorno e Tab para sair é um código keydown comum. Algumas partes são fáceis de errar, agrupadas abaixo por tipo de entrada.

Processamento de teclado e foco

  1. Um caminho aberto. A abertura pelo teclado chama trigger.click(), então o toggle-popover do invocador é a única maneira de abrir um menu. Marque o menu para que o gerenciador toggle o insira, apenas o teclado é aberto. Em uma abertura de ponteiro, toggle foca o acionador sem um anel, porque pointerdown impediu o foco, e as teclas de seta ainda precisam funcionar.
  2. Visualização em foco. Focar um acionador chama showPopover({ source: trigger }) para que o menu seja fixado nele. Ignorar o foco que retorna de dentro do próprio menu do acionador ou de outra janela (sinalizador na janela blur, limpa um tique após a janela focus ou a troca de apps reabre o último menu). Ocultar um menu visualizado em blur, a menos que o foco seja movido para ele.
  3. Mova o foco em toggle, não em command. command é acionado antes de o menu abrir.
  4. Restaure o foco para o acionador por conta própria. Foque o acionador antes de hidePopover(). Após uma ativação do ponteiro, restaure o foco sem um anel (focus({ focusVisible: false })) ou o acionador vai mostrar o contorno do foco do teclado após um clique do mouse. As ativações do teclado mantêm o anel. Um clique com event.detail === 0 veio do teclado.
  5. Feche um nível de cada vez. Chame stopPropagation() em Escape e ArrowLeft em um submenu, ou o listener pai também o fechará.

Processamento de ponteiros

  1. Alternância de cliques do ponteiro. Chame preventDefault() em pointerdown para que o clique não mude o foco e inicie uma prévia no meio do clique. Lembre-se de qual gatilho foi pressionado. Clicar em um gatilho fechado ou visualizado abre ou fecha o gatilho de forma nativa e nunca entra no menu. Somente as aberturas pelo teclado entram nele. Não intercepte o clique.
  2. O cursor segue o foco. Estilize :hover e :focus-visible separadamente. Em pointermove sobre um item, chame focus({ focusVisible: false }). Os navegadores que ignoram a opção não pintam nenhum anel para o foco do ponteiro.
  3. Passar o cursor abre os menus. No pointerenter de um acionador de nível superior enquanto outro menu de nível superior está aberto: oculte-o, foque no acionador que está sendo focalizado sem um anel e visualize no foco (veja acima). Nada aberto: não fazer nada.
  4. Triângulo seguro. Uma ponte CSS fixa é muito estreita: ela só funciona quando o ponteiro começa perto da borda do elemento pai. Deixe o CSS desenhar a ponte e use o script apenas para informar o ponteiro:
    • Execute isso apenas quando matchMedia('(hover: hover)').matches. É irrelevante para toque e teclado.
    • Começa quando o ponteiro sai de um acionador de submenu que está aberto. Pular um submenu --below, que não tem borda lateral.
    • Defina --bridge-x e --bridge-y (o ponteiro, em px em relação ao submenu), --bridge-edge (o x da borda próxima do submenu: 0 quando o ponteiro está à esquerda, a largura quando ele é invertido) e --bridge-height no submenu, e data-bridge para ativar a ponte.
    • Pare após cerca de 300 ms, quando o ponteiro alcançar um item no submenu ou quando o submenu for fechado. Remova o listener e o atributo. Nada é executado depois disso.
    • O CSS dá ao submenu um ::before recortado em um triângulo do ponteiro até os cantos próximos à borda. Ele fica sobre os itens irmãos no caminho do ponteiro, então eles nunca recebem o cursor que fecharia o submenu. clip-path também limita o teste de hit, para que o restante do menu da família permaneça interativo.
    • O submenu precisa de overflow: visible (um popover é cortado por padrão), e o pseudoelemento se estende além dele. Portanto, desloque os pontos da forma de acordo com essa extensão.
    • Complemento: um caminho curvo (comandos de curva shape()) segue arcos de ponteiro reais melhor do que um triângulo reto.
[popover] [popover] {
  overflow: visible;

  &[data-bridge]::before {
    content: "";
    position: absolute;
    inset: -100vh -100vw; /* Room for the triangle, which can extend far outside the submenu. */
    clip-path: shape(
      from calc(100vw + var(--bridge-x)) calc(100vh + var(--bridge-y)),
      line to calc(100vw + var(--bridge-edge)) 100vh,
      line to calc(100vw + var(--bridge-edge)) calc(100vh + var(--bridge-height)),
      close
    );
  }
}
const canHover = matchMedia('(hover: hover)');

// Call on pointerleave of a submenu trigger, with that trigger's submenu.
function startBridge(submenu, event) {
  if (!canHover.matches || !submenu.matches(':popover-open')) return;
  const rect = submenu.getBoundingClientRect();
  if (event.clientX >= rect.left && event.clientX <= rect.right) return; // --below: no side edge.
  const style = submenu.style;
  style.setProperty('--bridge-edge', `${event.clientX < rect.left ? 0 : rect.width}px`);
  style.setProperty('--bridge-height', `${rect.height}px`);
  const update = ({ clientX, clientY, target }) => {
    if (target !== submenu && submenu.contains(target)) return stop(); // Reached an item.
    style.setProperty('--bridge-x', `${clientX - rect.left}px`);
    style.setProperty('--bridge-y', `${clientY - rect.top}px`);
  };
  const timer = setTimeout(() => stop(), 300);
  const stop = () => {
    clearTimeout(timer);
    document.removeEventListener('pointermove', update);
    submenu.removeEventListener('toggle', stop);
    submenu.removeAttribute('data-bridge');
  };
  update(event);
  submenu.dataset.bridge = '';
  document.addEventListener('pointermove', update);
  submenu.addEventListener('toggle', stop, { once: true });
}

O foco segue o cursor (item 2) permanece um gerenciador pointermove simples: ele não precisa de lógica de triângulo.

Além disso:

  • Fora de foco. Feche todos os menus abertos quando o foco estiver fora do sistema de menus (um listener document focusin).
  • Ativação de comandos. Anuncie-o, oculte o menu raiz e restaure o foco para o acionador, sem um anel se a ativação veio de um ponteiro. Para itens que podem ser marcados (menuitemcheckbox, menuitemradio), aguarde cerca de 300 ms antes de fechar para que a marca de seleção alterada fique visível. Estilize um gatilho fechado como ativo apenas em :focus-visible. Caso contrário, o foco restaurado (sem anel) o deixará destacado após uma seleção de ponteiro.
  • aria-expanded. Defina-o apenas no listener toggle. Ele abrange os estados de visualização e aberto, e o aria-expanded do acionador permanece "true" enquanto o menu é visualizado ou aberto.
  • Auxiliares. Os substitutos abaixo pressupõem os auxiliares que você define para sua marcação: triggers (todos os botões [commandfor]), menuFor(trigger), isOpen(menu), closeAndRestore(menu, { fromPointer }) (foca o acionador, sem um anel quando fromPointer, depois menu.hidePopover()), enterOnOpen (um WeakSet de menus para entrar quando aberto) e pointerTrigger (o acionador pressionado por último por um ponteiro).
  • Use popover="auto" para submenus, colocados após o acionador do submenu dentro do focusgroup do pai. Nunca popover="manual".
  • O aninhamento permite que o navegador mantenha o pai aberto, feche os submenus irmãos, feche os submenus filhos quando o menu raiz for fechado e processe a dispensa leve de cliques externos.
  • O gatilho do submenu mantém aria-haspopup="menu" e aria-expanded. O listener toggle mantém aria-expanded em sincronia.
  • O script do teclado processa Escape e ArrowLeft aninhados, conforme descrito acima.
  • Um acionador de submenu nunca executa um comando: um controle não pode ativar e abrir ao mesmo tempo, e Enter não pode significar as duas coisas. Coloque a ação padrão como o primeiro item do submenu ou adicione um item de comando separado ao lado do acionador. Se um controle combinado for forçado, documente-o como uma compensação de acessibilidade.

Separadores e itens marcáveis

  • Separe os grupos de itens com <hr> dentro do focusgroup: um separator nativo, não focalizável, ignorado pelo focusgroup e pelo substituto. Nunca um <div> estilizado.
  • Uma opção booleana é um <button type="button"> com aria-checked e um role="menuitemcheckbox" explícito. O Focusgroup não infere esses papéis, então esse é o único lugar em que a marcação compartilhada define um papel de item.
  • Um dos conjuntos é um <button type="button"> com role="menuitemradio" e aria-checked, dentro de um role="group" com um aria-label, no mesmo focusgroup.
  • O script alterna aria-checked.
  • Enter em uma caixa de seleção alterna e fecha o menu; Space alterna e permanece aberto (APG). Os rádios fecham com qualquer uma das teclas.
  • Renderize a marca de seleção de [aria-checked="true"]::before, não um elemento de ícone separado (consulte o CSS abaixo).

Não abordado aqui

  • Feedback de ativação. Os menus nativos piscam o item escolhido antes de fechar. Se quiser, mostre uma confirmação visual de aproximadamente 100 ms antes de hidePopover(). Não atrase a restauração do foco ou o anúncio.
  • Preenchimento automático. O Focusgroup não tem nenhum. Se necessário, em keydown imprimível, faça a correspondência com os rótulos de itens ativados do nível atual e foque na próxima correspondência após o item atual, com quebra de linha. Não intercepte Space.

Verificar antes de concluir

Uma instrução não pode garantir o comportamento, então teste o resultado. Abra a página e verifique cada um deles com o teclado e o mouse reais. Corrija e teste novamente os que falharem:

  • Teclado. Tab chega ao primeiro acionador uma vez; ArrowDown entra; Escape fecha um nível e retorna o foco para o acionador; Tab fecha todos os menus.
  • Ponteiro aberto. Clicar em um acionador abre o menu dele com foco no acionador e sem anel. Clicar novamente fecha o menu.
  • Comando de ponteiro. Clicar em um comando fecha todos os menus e não mostra nenhum anel de foco no acionador.
  • Submenu. Mover diagonalmente do meio de um acionador de submenu para um item de submenu o mantém aberto. Mover para um irmão não relacionado o fecha.
  • Tela estreita. Em 375 px, nenhum submenu cobre o próprio acionador.
  • Marcas de seleção. Os itens marcados mostram uma marca de seleção.
  • Sem sobras. Não há erros no console nem popovertarget, popovertargetaction ou aria-details nos acionadores.

Alinhar o conteúdo do item de menu

Quando uma linha tiver um ícone, um rótulo e um atalho ou indicador de submenu, reserve um slot inicial de tamanho fixo para o ícone ou a marca de seleção e envie o conteúdo final para o fim inline. Os ícones têm um tamanho conhecido, então um slot fixo mantém os rótulos alinhados, independentemente de um item ter um ícone ou não. Não use subgrid para linhas: qualquer filho inline extra (um selo, por exemplo) se torna um item de grade próprio e quebra o layout. Ocultar o texto do atalho da tecnologia adaptativa quando ele apenas repete uma tecla que já opera o item.

[focusgroup~="menu"] {
  --menu-icon-size: 1.25rem;
}

/* Target items only. A closed submenu popover inside the focusgroup must keep display: none. */
[focusgroup~="menu"] > button,
[focusgroup~="menu"] > [role="group"] > button {
  display: flex;
  align-items: center;
  gap: .5rem;
}

/* The leading slot holds the icon or check mark. */
[focusgroup~="menu"] button::before {
  content: "";
  flex: none;
  inline-size: var(--menu-icon-size);
}

/* Check mark for checked items. This selector must be at least as specific as the base
   ::before rule above, or the empty content wins and the check mark never shows. */
[focusgroup~="menu"] button[aria-checked="true"]::before {
  content: "✓";
}

/* Shortcut or submenu indicator at the inline end. */
[focusgroup~="menu"] .hint {
  margin-inline-start: auto;
}

Coloque um indicador final ou um atalho em um <span class="hint" aria-hidden="true"> dentro do botão. O acionador do submenu já anuncia o pop-up.

Aprimoramento progressivo e substitutos

Alternativas e suporte do navegador para focusgroup

Mantenha focusgroup="menu nomemory" na marcação. Se 'focusGroup' in HTMLElement.prototype for falso, instale as teclas de seta para cima, seta para baixo, Home e End, defina role="menu" em cada contêiner de menu e role="menuitem" em botões sem função. Deixe os itens marcáveis, <hr> e role="group" em paz. Nunca o instale quando o focusgroup nativo estiver presente. Esquerda, direita, pré-visualização, abrir e fechar são scripts do autor de qualquer maneira.

if (!('focusGroup' in HTMLElement.prototype)) {
  document.querySelectorAll('[focusgroup~="menu"]').forEach(group => {
    group.setAttribute('role', 'menu');
    group.querySelectorAll('button:not([role])').forEach(item => {
      item.setAttribute('role', 'menuitem');
    });
    group.addEventListener('keydown', event => {
      if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
      // Own items only: a nested submenu has its own focusgroup, a role="group" wrapper does not.
      const items = [...group.querySelectorAll('button')]
        .filter(item => item.closest('[focusgroup]') === group)
        .filter(item => !item.disabled && item.getAttribute('aria-disabled') !== 'true');
      const current = items.indexOf(document.activeElement);
      if (current < 0) return;
      const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 :
        (current + (event.key === 'ArrowUp' ? -1 : 1) + items.length) % items.length;
      event.preventDefault();
      event.stopImmediatePropagation();
      items[next].focus({ preventScroll: true });
    }, true);
  });
}

Alternativas e suporte do navegador para Popover

A API Popover é, em grande parte, um aprimoramento progressivo, mas os comportamentos que a definem (promoção de camada superior, fechamento leve e invocação de popovertarget) não têm equivalente somente em CSS. Os navegadores mais antigos precisam de um polyfill ou de um substituto manual, se você preferir não enviar um.

Polyfill: para oferecer suporte ao atributo popover em navegadores mais antigos, carregue condicionalmente @oddbird/popover-polyfill. OBRIGATÓRIO: detecte o recurso verificando a propriedade popover em HTMLElement.prototype e carregue o polyfill somente quando não houver suporte nativo. NÃO o carregue incondicionalmente.

Com um bundler ou mapa de importação:

// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
  import("@oddbird/popover-polyfill");
}

Sem um bundler, importe de uma CDN dentro de um <script type="module">:

<script type="module">
  if (!("popover" in HTMLElement.prototype)) {
    import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
  }
</script>

Observação sobre o estilo: o polyfill não pode definir a pseudoclasse :popover-open real. Por isso, ele aplica uma classe .\:popover-open. OBRIGATÓRIO: combine os dois com :is() ou :where(). Caso contrário, os navegadores que não têm :popover-open vão descartar toda a regra:

[popover]:is(:popover-open, .\:popover-open) {
  display: block;
}

Como alternativa, para um substituto legado sem um polyfill, use position: fixed e calcule manualmente as coordenadas usando getBoundingClientRect() ou use o posicionamento padrão com inset: auto, se isso for aceitável para o caso de uso.

Alternativas e suporte do navegador para posicionamento de âncora

Sem o posicionamento de âncora CSS, um popover volta para o posicionamento centralizado da janela de visualização do user agent. Nunca use isso em um menu: ele se desvincula do acionador, e um submenu centralizado cobre o pai. Proteja as regras ancoradas atrás de @supports (position-area: block-end) e posicione o menu do script quando ele falhar:

  • Coloque o menu abaixo do acionador (nível superior) ou ao lado dele (submenu), virando para o outro lado e fixando na janela de visualização quando houver transbordamento.
  • Execute-o no gerenciador toggle quando o menu for aberto e aplique o resultado como left e top no menu. Use position: fixed com inset: auto e margin: 0 para que as coordenadas sejam relativas à janela de visualização.
  • Mantenha essas substituições dentro de @supports not (position-area: block-end) para que os navegadores com posicionamento de âncora não sejam afetados.

Se você estiver segmentando implementações anteriores em que showPopover({ source }) não estabelece uma âncora implícita (sem source em ToggleEvent.prototype), atribua a cada acionador um anchor-name exclusivo e a cada menu um position-anchor correspondente do script, em vez de escrever regras CSS por menu.

Para navegadores sem posicionamento de âncora, consulte Menus suspensos que consideram a janela de visualização para a rota de polyfill.

Alternativas e suporte do navegador para comandos do invocador

Sem invocadores, command="toggle-popover" não faz nada, então o script precisa alternar o menu.

  • Detecte a interface, não uma instância: 'commandForElement' in HTMLButtonElement.prototype.
  • Em cada acionador click, chame preventDefault() para que um navegador com invocadores não possa alternar duas vezes.
  • Se o menu estiver aberto, feche-o com closeAndRestore(menu). Caso contrário, chame menu.showPopover({ source: trigger }).
  • Marque o menu em enterOnOpen, a menos que o clique tenha vindo do acionador pressionado (pointerTrigger). Assim, apenas as aberturas pelo teclado vão entrar no menu.
  • Enter e Space ainda funcionam porque geram um clique nos botões.