Ações de botão personalizadas

Recursos da Web: Comandos do Invoker

A API Invoker Commands permite que os botões acionem ações em elementos de destino de forma declarativa usando atributos HTML. Essa abordagem reduz a necessidade de listeners de eventos manuais e separa a interface dos detalhes de implementação. Para ações personalizadas e específicas do aplicativo, é possível definir seus próprios nomes de comando. Os comandos personalizados precisam ter um prefixo de traço duplo (--) para evitar colisões com futuros comandos integrados do navegador.

Etapas de implementação

  1. Defina o elemento de destino: identifique o elemento que vai responder à ação. Se não houver um id exclusivo, adicione um.
  2. Configure o botão de invocação: use o atributo commandfor para apontar para o id do destino e o atributo command para especificar o nome do comando personalizado (com o prefixo --).
  3. Processar o evento de comando: anexe um listener de eventos command diretamente ao elemento de destino. O objeto de evento contém uma propriedade command e uma propriedade target (referente ao elemento identificado por commandfor).
  4. Processar estados de ARIA: os comandos personalizados não têm semântica inerente, e você precisa processar estados como aria-pressed ou aria-expanded.

Exemplo: controles de animação personalizados

<!-- The target element that will respond to custom commands -->
<div id="action-target" class="target">
  Action Target
</div>

<!-- Buttons declaratively linked to the target element -->
<!-- Each button sends a unique custom command starting with '--' -->
<button commandfor="action-target" command="--spin">
  Spin
</button>

<button commandfor="action-target" command="--grow">
  Grow
</button>

<button commandfor="action-target" command="--reset">
  Reset All
</button>

<script>
  // 1. **Optional:** Define a registry of requested actions for cleaner logic
const commandRegistry = {
  '--spin': (target, source) => {
    const isSpun = target.classList.toggle('is-spun');
    // Set ARIA states, as custom commands have no inherent semantics.
    source?.setAttribute('aria-pressed', isSpun);
  },
  '--grow': (target, source) => {
    const isGrown = target.classList.toggle('is-grown');
    source?.setAttribute('aria-pressed', isGrown);
  },
  '--reset': (target) => {
    target.classList.remove('is-spun', 'is-grown');
    // Reset all associated buttons' ARIA states
    document.querySelectorAll(`button[commandfor="${target.id}"]`).forEach(btn => {
      btn.setAttribute('aria-pressed', 'false');
    });
  },
};

  // 2. **Mandatory:** Listen for the 'command' event directly on the target element
  // (This is necessary because the native 'command' event does not bubble)
  document.getElementById('action-target').addEventListener('command', (event) => {
    const command = event.command;
    const target = event.target;
    const source = event.source; // event.source refers to the triggering button
    const action = commandRegistry[command];

    if (action) {
      action(target, source);
    }
  });
</script>

Principais limitações

  • Prefixo de comandos personalizados: OBRIGATÓRIO: todos os nomes de comandos personalizados precisam começar com -- (por exemplo, command="--my-action").
  • Segmentação: o atributo commandfor precisa corresponder ao id de um elemento na mesma árvore de documentos.
  • Sem propagação: o evento command não é propagado. Se houver vários destinos possíveis, adicione { capture: true } ao manipulador de eventos e detecte um ancestral.
  • Raízes de sombra: se o destino estiver em uma raiz de sombra, use event.composedPath()[0] em vez de event.target.
  • Acessibilidade: os comandos personalizados não têm semântica inerente, e é necessário aplicar explicitamente todos os estados.

Considerações sobre acessibilidade

  • Se o botão exibir conteúdo adicional, use o atributo aria-expanded, definido como aria-expanded="false" por padrão e aria-expanded="true" quando o conteúdo complementar for exibido.
  • Se o botão facilitar uma interação (por exemplo, botões ativar/desativar que podem reproduzir e pausar, botões de volume que podem ativar e desativar o som), use o atributo aria-pressed, definido como aria-pressed="false" quando inativo e aria-pressed="true" quando ativo.
  • Se o botão abrir um elemento com uma função menu, listbox, tree, grid ou dialog, o controle precisará ter um atributo aria-haspopup, com um valor definido para a função do elemento que ele abre.
  • Se o botão for desativado na ativação, use aria-disabled="true" em vez de disabled para que não haja perda de foco.

Estratégias de fallback

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);
  }
 });