Menus suspensos que se adaptam à janela de visualização

Um painel de ações revelado ou um grupo de botões popover é um padrão útil para os usuários acessarem outras funcionalidades sem ocupar muito espaço. Esse padrão de sobreposição tem uma complexidade de layout, já que o painel precisa permanecer vinculado a um elemento acionador enquanto se adapta às restrições da janela de visualização. Tradicionalmente, isso exigia bibliotecas JavaScript complexas (como Popper.js ou Floating UI) para calcular posições e lidar com colisões.

O posicionamento de âncora do CSS oferece uma maneira declarativa e otimizada para desempenho de lidar com essas relações inteiramente no CSS, permitindo que os navegadores gerenciem a lógica de posicionamento e overflow de forma nativa.

1. Definir a relação entre o botão e o painel

A primeira etapa é criar um botão de acionamento que abre o contêiner de sobreposição usando a API Popover.

Diferença de acessibilidade OBRIGATÓRIA: esse padrão modela explicitamente um grupo de botões revelado dentro de um popover, em vez de um menu ARIA verdadeiro. Não aplique role="menu" ou role="menuitem", a menos que você implemente totalmente o contrato de navegação por teclado correspondente (como o processamento da navegação espacial com teclas de seta entre itens). Pelo mesmo motivo, não adicione aria-haspopup ao acionador. O valor dele (menu, listbox, tree, grid ou o legado true) é uma promessa de que o destino expõe um papel correspondente, o que esse padrão não faz.

<button popovertarget="action-panel">
  Open Actions
</button>
<!-- Use the Popover API (`popover="auto"`) for the overlay to ensure it is placed in the top layer and handled accessibly by the browser. -->
<div id="action-panel" popover="auto" class="panel">
  <button class="action-item" type="button">Edit</button>
</div>

Isso cria uma associação de âncora implícita entre o botão e o painel, para que o painel possa ser posicionado em relação ao botão de forma nativa, sem precisar de anchor-name no botão ou position-anchor no painel.

2. Posicionamento com position-area

Em vez de deslocamentos manuais de top/left, use position-area para colocar o destino em uma grade de 3x3 em relação à âncora.

.panel {
  /* 
     Position the panel below the anchor (block-end), 
     aligned to the start of the anchor and spanning to its end (span-inline-end).
  */
  position-area: block-end span-inline-end;
  
  /* Reset insets to allow the grid to take control */
  inset: auto;
}

Dê preferência a palavras-chave lógicas (span-inline-end, block-start) em vez de físicas (left, top) para oferecer suporte automático a RTL e diferentes modos de escrita.

OBRIGATÓRIO: não misture palavras-chave físicas e lógicas em position-area.

3. Implementar resiliência de borda (fallbacks)

Para evitar que o painel seja cortado na borda da tela, defina "táticas de tentativa" que o navegador deve usar se a posição padrão transbordar.

.panel {
  /* 
     If the panel overflows the bottom, flip it to the top (flip-block).
     If it overflows the inline edges, flip it horizontally (flip-inline).
     If it overflows both (e.g., in a corner), flip on both axes (flip-block flip-inline).
  */
  position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
}

Se o menu suspenso ou o submenu incluir uma seta direcional ou uma borda que precise mudar de estilo dependendo da posição de fallback escolhida, consulte Dicas de ferramentas que reconhecem a posição. Para invocação declarativa de caixas de diálogo e pop-overs sem JavaScript, consulte Controle declarativo de caixas de diálogo e pop-overs.

Estratégias de fallback

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.

Para oferecer suporte a navegadores sem posicionamento de âncora, é necessário definir uma posição razoável. Por padrão, os popovers são centralizados no meio da tela, o que pode funcionar para seu caso de uso.

Em alguns casos de uso, é possível usar o polyfill @oddbird/css-anchor-positioning, que adiciona suporte a alguns casos de uso de posicionamento de âncora. Ele não é compatível com âncoras implícitas. Portanto, é OBRIGATÓRIO adicionar nomes de âncoras ao acionador. Além disso, o position-area não é compatível com popovers pelo polyfill. Portanto, você PRECISA usar anchor() nas inserções desejadas.

<!-- MANDATORY: Conditionally install the anchor positioning polyfill -->
<script type="module">
  if (!("anchorName" in document.documentElement.style)) {
    await import("https://unpkg.com/@oddbird/css-anchor-positioning");
  }
</script>
.panel {
  /* Mandatory: use explicit anchor name */
  position-anchor: --kebab-anchor;
  /* Mandatory: use insets rather than position-area for positioning */
  bottom: auto;
  right: auto;
  top: anchor(bottom);
  left: anchor(left);
  margin: 0;
}