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
- Defina o elemento de destino: identifique o elemento que vai responder à ação. Se não houver um
idexclusivo, adicione um. - Configure o botão de invocação: use o atributo
commandforpara apontar para oiddo destino e o atributocommandpara especificar o nome do comando personalizado (com o prefixo--). - Processar o evento de comando: anexe um listener de eventos
commanddiretamente ao elemento de destino. O objeto de evento contém uma propriedadecommande uma propriedadetarget(referente ao elemento identificado porcommandfor). - Processar estados de ARIA: os comandos personalizados não têm semântica inerente, e você precisa processar estados como
aria-pressedouaria-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
commandforprecisa corresponder aoidde um elemento na mesma árvore de documentos. - Sem propagação: o evento
commandnã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 deevent.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 comoaria-expanded="false"por padrão earia-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 comoaria-pressed="false"quando inativo earia-pressed="true"quando ativo. - Se o botão abrir um elemento com uma função
menu,listbox,tree,gridoudialog, o controle precisará ter um atributoaria-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 dedisabledpara 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);
}
});