La API de Invoker Commands permite que los botones activen acciones en los elementos de destino de forma declarativa con atributos HTML.
Este enfoque reduce la necesidad de objetos de escucha de eventos manuales y desvincula la IU de los detalles de implementación.
Para las acciones personalizadas y específicas de la aplicación, puedes definir tus propios nombres de comandos. Los comandos personalizados deben tener el prefijo de un guion doble (--) para evitar colisiones con futuros comandos integrados del navegador.
Pasos de implementación
- Define el elemento objetivo: Identifica el elemento que responderá a la acción. Si no tiene un
idúnico, agrégale uno. - Configura el botón de invocación: Usa el atributo
commandforpara apuntar aliddel destino y el atributocommandpara especificar el nombre del comando personalizado (con el prefijo--). - Controla el evento de comando: Adjunta un objeto de escucha de eventos
commanddirectamente en el elemento de destino. El objeto de evento contiene una propiedadcommandy una propiedadtarget(que hace referencia al elemento identificado porcommandfor). - Controla los estados de ARIA: Los comandos personalizados no tienen una semántica inherente, y debes controlar estados como
aria-pressedoaria-expanded.
Ejemplo: Controles de animación 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>
Restricciones clave
- Prefijo de los comandos personalizados: OBLIGATORIO: Todos los nombres de los comandos personalizados deben comenzar con
--(p.ej.,command="--my-action"). - Segmentación: El atributo
commandfordebe coincidir con elidde un elemento en el mismo árbol de documentos. - Sin burbujeo: El evento
commandno se propaga. Si hay varios destinos posibles, agrega{ capture: true }al controlador de eventos y escucha en un elemento superior. - Raíces de sombra: Si el objetivo puede estar en una raíz de sombra, usa
event.composedPath()[0]en lugar deevent.target. - Accesibilidad: Los comandos personalizados no tienen una semántica inherente, por lo que debes aplicar cualquier estado de forma explícita.
Consideraciones de accesibilidad
- Si el botón muestra contenido adicional, usa el atributo
aria-expanded, establecido enaria-expanded="false"de forma predeterminada y enaria-expanded="true"cuando se muestra el contenido complementario. - Si el botón facilita una interacción en sí (por ejemplo, botones de activación que pueden reproducir y pausar, botones de volumen que pueden silenciar y activar el sonido), usa el atributo
aria-pressed, establecido enaria-pressed="false"cuando está inactivo y enaria-pressed="true"cuando está activo. - Si el botón abre un elemento con un rol de
menu,listbox,tree,gridodialog, el control debe tener un atributoaria-haspopup, con un valor establecido en el rol del elemento que abre. - Si el botón se inhabilita cuando se activa, en lugar de usar
disabled, usaaria-disabled="true"para que no se pierda el enfoque.
Estrategias de resguardo
Si no se admite la API de Invoker Commands, no se activará el evento command. Para obtener compatibilidad total con todos los navegadores actualizados, se recomienda usar el polyfill de invocadores de https://github.com/keithamus/invokers-polyfill a través de npm install o CDN.
Este polyfill admite por completo las acciones personalizadas (que comienzan con --) y despacha el evento command exactamente como la API nativa.
Importación dinámica (optimización del rendimiento)
Para obtener el mejor rendimiento, solo debes cargar el polyfill si el navegador no admite la API de forma nativa. Esto ahorra ancho de banda y reduce el tiempo de ejecución de la secuencia de comandos para los usuarios de navegadores modernos.
NOTA: Este polyfill no administra los estados de ARIA (como aria-pressed o aria-expanded) para los comandos personalizados. Debes sincronizar manualmente estos estados en tu objeto de escucha de eventos para garantizar que se pueda acceder a tu sitio.
// 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);
}
});
Resguardo manual (patrón tradicional)
Si prefieres no usar un polyfill, puedes usar una combinación de delegación de eventos para enviar eventos y un registro de comandos para controlar las acciones. Este es un patrón de arquitectura común en el desarrollo tradicional de JavaScript que sigue siendo altamente eficiente y escalable.
// 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);
}
});