Controle declarativo de caixas de diálogo e pop-overs

Use a API Invoker Commands para alternar a visibilidade dos elementos <dialog> e [popover] diretamente de botões HTML, eliminando a necessidade de listeners de eventos JavaScript personalizados.

Ao aplicar os atributos commandfor (ID de destino) e command (ação) a um <button>, o navegador processa automaticamente as mudanças de estado de abertura/fechamento, o gerenciamento de foco e as vinculações de acessibilidade (como aria-expanded). Essa abordagem declarativa é recomendada porque remove o código boilerplate frágil, garante que as interações sejam funcionais imediatamente após a análise do HTML e garante uma experiência do usuário robusta e nativamente acessível.

Como implementar popovers declarativos

Os popovers podem ser abertos e fechados com um único botão.

<!-- MANDATORY: The commandfor attribute links the invoker to the ID of the target element so the browser knows what to control. -->
<!-- MANDATORY: The command attribute specifies the action to perform. Use 'toggle-popover' to handle both open and close states automatically. -->
<button commandfor="my-popover" command="toggle-popover">
  Toggle Popover
</button>

<!-- MANDATORY: The target element must have the popover attribute to be controlled as a popover. -->
<div id="my-popover" popover>
  <p>Popover content goes here.</p>
</div>

Se você precisar controlar a abertura e o fechamento com botões separados, use os comandos show-popover e hide-popover.

<!-- MANDATORY: Use 'show-popover' to explicitly open the popover. It will not close the popover if clicked again. -->
<button commandfor="my-explicit-popover" command="show-popover">
  Show Popover
</button>

<div id="my-explicit-popover" popover="manual">
  <p>This popover is explicitly opened and closed by separate buttons.</p>

  <!-- MANDATORY: Use 'hide-popover' to explicitly close the targeted popover. -->
  <button commandfor="my-explicit-popover" command="hide-popover">
    Hide Popover
  </button>
</div>

Como implementar caixas de diálogo modais declarativas

Ao contrário dos popovers, as caixas de diálogo modais geralmente usam botões separados para abrir e fechar. Use o comando show-modal especificamente quando precisar abrir uma caixa de diálogo como modal.

<!-- MANDATORY: Use command="show-modal" to trigger the dialog as a modal, trapping focus and preventing interaction with the rest of the page. -->
<!-- MANDATORY: The commandfor attribute connects this button to the dialog ID. -->
<button commandfor="confirm-dialog" command="show-modal">
  Open Confirmation
</button>

<dialog id="confirm-dialog">
  <p>Are you sure you want to proceed?</p>

  <!-- MANDATORY: Use command="close" to dismiss the dialog safely. -->
  <button commandfor="confirm-dialog" command="close">
    Cancel
  </button>
</dialog>

Estratégias de fallback

Como os comandos do invocador e os popovers ainda não são universalmente compatíveis, você PRECISA usar polyfills como substitutos para navegadores mais antigos.

Comandos do invocador de polyfill

OBRIGATÓRIO: verifique se o recurso é compatível com a propriedade commandForElement no protótipo HTMLButtonElement. NÃO verifique o objeto de janela ou documento. Você PRECISA importar dinamicamente o polyfill somente quando o recurso nativo estiver ausente. NÃO carregue o polyfill incondicionalmente. Obrigatório: detectar o evento "command" diretamente no elemento de destino, porque o evento "command" nativo não é propagado.

Opção 1: usar um bundler Instale o polyfill pelo npm (npm install invokers-polyfill). Essa abordagem é para projetos que usam um bundler (como Vite ou Webpack) ou mapas de importação. Para todas as outras configurações, use a opção de CDN abaixo.

// MANDATORY: Feature detect 'commandForElement' on HTMLButtonElement.prototype.
// Conditionally load the invokers-polyfill only in browsers lacking native support.
if (!('commandForElement' in HTMLButtonElement.prototype)) {
  import('invokers-polyfill');
}

Opção 2: usar uma CDN Para projetos sem um bundler, importe dinamicamente o polyfill diretamente de uma CDN em um <script type="module">.

<script type="module">
  // MANDATORY: Feature detect 'commandForElement' on HTMLButtonElement.prototype.
  // Conditionally load the invokers-polyfill from a CDN only in browsers lacking native support.
  if (!('commandForElement' in HTMLButtonElement.prototype)) {
    import('https://esm.run/invokers-polyfill');
  }
</script>

Limitações do polyfill de invocadores OBRIGATÓRIO: esse polyfill não processa os estados ARIA (por exemplo, aria-expanded) do botão de comando da mesma forma que os navegadores nativos. Recomendamos que você processe esses estados por conta própria para garantir que seu site seja totalmente acessível.

Se a API Invoker Commands não for compatível, o evento command não será disparado. Para ter suporte total em todos os navegadores mais recentes, recomendamos usar o invokers-polyfill de https://github.com/keithamus/invokers-polyfill via npm install ou CDN.

Esse polyfill oferece suporte total a ações personalizadas (começando com --) e envia o evento command exatamente como a API nativa.

Importação dinâmica (otimização de performance)

Para ter o melhor desempenho, carregue o polyfill apenas se o navegador não for compatível com a API de forma nativa. Isso economiza largura de banda e reduz o tempo de execução do script para usuários em navegadores mais recentes.

OBSERVAÇÃO: esse polyfill não gerencia estados ARIA (como aria-pressed ou aria-expanded) para comandos personalizados. É necessário sincronizar manualmente esses estados no listener de eventos para garantir que o site seja acessível.

// 1. Conditionally load the polyfill
const hasNativeSupport = 'commandForElement' in HTMLButtonElement.prototype;

if (!hasNativeSupport) {
  // Wrap in an async IIFE to avoid top-level await issues in older browsers
  (async () => {
    try {
      await import('https://esm.run/invokers-polyfill');
    } catch (err) {
      console.error('Error loading fallback:', err);
    }
  })();
}

// 2. Manually manage ARIA states in your listener
document.getElementById('action-target').addEventListener('command', (event) => {
  const command = event.command;
  const target = event.target;
  const source = event.source; // The button that triggered the command

  if (command === '--spin') {
    const isSpun = target.classList.toggle('is-spun');
    
    // Polyfill tip: Manually update ARIA to match the new state
    source?.setAttribute('aria-pressed', isSpun);
  }
});

Substituição manual (padrão tradicional)

Se você preferir não usar um polyfill, use uma combinação de delegação de eventos para enviar eventos e um registro de comandos para processar as ações. Esse é um padrão arquitetônico comum no desenvolvimento tradicional de JavaScript que continua sendo altamente eficiente e escalonável.

// 1. **Optional:** Define a registry of requested actions for cleaner logic
const commandRegistry = {
  '--spin': (target) => target.classList.toggle('is-spun'),
  '--grow': (target) => target.classList.toggle('is-grown'),
  '--reset': (target) => target.classList.remove('is-spun', 'is-grown'),
};

// 2. If CommandEvent doesn't exist, we assume no native support and provide the fallback
if (!globalThis.CommandEvent) {
  globalThis.CommandEvent = class CommandEvent extends Event {
    constructor(type, { source, command, ...options } = {}) {
      super(type, options);
      this.source = source;
      this.command = command;
    }
  }
}

// 3. The fallback: Dispatch events manually if native support is missing
  document.addEventListener('click', (event) => {
    const button = event.composedPath().find((el) => el.matches?.("button[commandfor]"));
    if (!button) return;

    const target = document.getElementById(button.getAttribute('commandfor'));
    const command = button.getAttribute('command');

    if (target && command) {
      target.dispatchEvent(new CommandEvent('command', { 
        command, 
        source: button,
      }));
    }
  });

// 4. **Mandatory:** Register the unified listener directly on the target element
document.getElementById('action-target').addEventListener('command', (event) => {
  const command = event.command;
  const target = event.target;
  const action = commandRegistry[command];

  if (action) {
    action(target);
  }
 });

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.