Controlar Web Components sin JavaScript
Usa comandos invocadores personalizados para controlar componentes web solo con HTML. command y commandfor conectan botones y elemento sin JavaScript.
Los comandos de invocador personalizados permiten que un custom element exponga sus acciones de forma declarativa: quien escribe el componente define un único listener del evento command en JavaScript, y todos los que usan el componente conectan botones a él con los atributos HTML command y commandfor, sin necesidad de script del lado del consumidor.
Distribuir un custom element suele arrastrar consigo una sección del README: esa parte que explica qué métodos hay que llamar, o qué atributo data-action hay que esparcir por los botones. El componente funciona; el cableado es la fricción.
Este artículo empieza donde termina nuestra guía sobre la Invoker Commands API. Aquel texto cubre los comandos integrados para diálogos y popovers (show-modal, close, toggle-popover y compañía) y los fundamentos del CommandEvent; nada de eso se repite aquí. Los comandos de invocador son Baseline Newly available desde el 12 de diciembre de 2025, así que tampoco hay sección de polyfill. En su lugar, construimos un elemento distribuible, <code-viewer>, cuya API pública es su conjunto de comandos.
Puntos clave
- Los nombres de comandos personalizados deben empezar con doble guion, como
--expand; el prefijo es un espacio de nombres reservado, así que un comando personalizado nunca podrá colisionar con uno integrado que el navegador añada más adelante. - El evento
commandse dispara directamente sobre el elemento indicado porcommandfor, no hace bubbling y no cruza límites de shadow DOM, por lo que el listener corresponde al propio componente. - Dentro del manejador,
event.commandcontiene el nombre del comando yevent.sourcees el botón que lo disparó, que es donde correspondenaria-pressedyaria-expanded. - Un componente no puede ser referenciado por
commandfordesde dentro de su propio shadow tree, porque allí el host no tiene id; en su lugar, la propiedadcommandForElementacepta una referencia directa al elemento. - La especificación no define ningún cambio de estado para los comandos personalizados, así que el manejador debe mantener por sí mismo el estado ARIA.
El autor escribe JavaScript, el consumidor escribe HTML
Esta división del trabajo es la idea central de los comandos de invocador personalizados. Tú, como autor del componente, escribes el listener de command una sola vez, dentro del elemento. A partir de ahí, cada consumidor maneja el componente desde el marcado:
<button command="--expand" commandfor="snippet">Expand</button>
Ninguna importación más allá del propio componente, ningún nombre de método que memorizar, ninguna delegación de eventos que implementar a mano. El conjunto de comandos se convierte en la interfaz pública del elemento, documentada del mismo modo en que se documenta command="show-modal" para <dialog>.
¿Por qué se quedan cortos los métodos y los atributos data-*?
Los dos patrones que los autores de componentes distribuyen hoy trasladan trabajo al consumidor. Un método imperativo obliga a todo consumidor a usar JavaScript:
document.querySelector('#snippet').expand();
// plus a click listener on every button that should call it
Un atributo data-action a medida mantiene el marcado declarativo, pero te obliga a reimplementar el despacho: un listener de click delegado, una convención para parsear atributos y documentación de un vocabulario que solo tu componente entiende. Ninguno de los dos enfoques te aporta nada desde la plataforma. Con command/commandfor heredas la semántica de un botón real, activación por teclado incluida, y una convención de cableado compartida con cualquier otro elemento de la página impulsado por comandos.
¿Cómo se define un comando de invocador personalizado?
Los valores de comandos personalizados deben empezar con doble guion, y ese prefijo está reservado por definición. En los estados del atributo command, cualquier valor que empiece por -- se clasifica como palabra clave personalizada, lo que impide que un comando integrado adopte alguna vez esa forma, de modo que tus comandos no pueden colisionar con nada que el navegador añada después. Un valor que no sea ni una palabra clave integrada ni tenga el prefijo -- es inválido y no despacha nada.
Nuestro <code-viewer> expone tres comandos: --expand, --toggle-wrap y --copy. Esa lista, y no un conjunto de métodos, es lo que anuncia su documentación.
| Comando | Qué hace | Estado ARIA que actualizar en el botón |
|---|---|---|
--expand | Alterna el atributo expanded en el elemento | aria-expanded |
--toggle-wrap | Alterna el atributo wrap en el elemento | aria-pressed |
--copy | Escribe el contenido de texto del elemento en el portapapeles | ninguno |
¿Dónde se dispara el evento command?
El evento command se dispara sobre el elemento destino indicado por commandfor, no sobre el botón, y no hace bubbling, por lo que una delegación en fase de burbujeo sobre un ancestro no lo verá. Adjunta el listener al elemento que recibe el comando; la interfaz CommandEvent proporciona al manejador dos propiedades adicionales respecto al evento base: event.command contiene el nombre del comando y event.source apunta de vuelta al botón invocador.
class CodeViewer extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<pre><slot></slot></pre>`;
this.addEventListener('command', this.#onCommand);
}
#onCommand = (event) => {
switch (event.command) {
case '--expand':
// state change goes here
break;
case '--toggle-wrap':
break;
case '--copy':
navigator.clipboard.writeText(this.textContent);
break;
}
};
}
Despacha sobre event.command con una lista explícita de casos y sin un default permisivo. Cualquier valor con prefijo -- despacha el evento, lo manejes o no, y nada lanza un error cuando no lo haces. Las repeticiones de sesión de componentes cableados declarativamente muestran ese modo de fallo como un botón muerto: el click se registra, nada cambia en pantalla y no aparece ningún error en consola, que es exactamente el aspecto que tiene, desde el lado del usuario, un valor de comando mal escrito o un listener en el nodo equivocado.
Shadow DOM: apuntar al host con commandForElement
Un componente no puede ser referenciado por commandfor desde dentro de su propio shadow tree. El atributo commandfor solo resuelve ids que viven en el propio árbol del botón, y el host, que está fuera en el light DOM, no tiene id dentro de su propio shadow root. La propiedad commandForElement cubre ese hueco: acepta una referencia directa al elemento, a través de shadow roots, en lugar de un id. Así, un botón interno puede enrutarse por el mismo manejador de comandos:
connectedCallback() {
const copyBtn = document.createElement('button');
copyBtn.textContent = 'Copy';
copyBtn.setAttribute('command', '--copy');
copyBtn.commandForElement = this; // no id needed
this.shadowRoot.append(copyBtn);
}
Aquí importan dos hechos sobre la propagación. El evento se dispara directamente sobre el destino sin bubbles ni composed activados, por lo que nunca cruza un límite de shadow DOM y event.target es siempre el elemento que lo recibió; no hacen falta malabares con composedPath(). Y event.source se re-apunta (retargeting) respecto al árbol del listener: para ese botón interno, un listener en el host ve al host, no al botón, así que conserva una referencia directa a los botones internos si necesitas actualizarlos.
El estado y ARIA son cosa tuya
La especificación no define cambios de estado para valores de comandos personalizados; el único comportamiento del navegador es despachar el evento. Nada establece aria-pressed ni aria-expanded por ti, así que actualízalos en event.source dentro de la misma rama que muta el estado:
case '--expand': {
const expanded = this.toggleAttribute('expanded');
event.source.setAttribute('aria-expanded', expanded);
break;
}
case '--toggle-wrap': {
const wrapped = this.toggleAttribute('wrap');
event.source.setAttribute('aria-pressed', wrapped);
break;
}
Para los botones del light DOM del consumidor esto es seguro: botón y listener comparten árbol, así que event.source es el botón real.
El elemento terminado y el HTML que lo maneja
Una vez ensamblado, el componente es una clase con un único listener:
class CodeViewer extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `<pre><slot></slot></pre>`;
this.addEventListener('command', this.#onCommand);
}
#onCommand = (event) => {
switch (event.command) {
case '--expand': {
const expanded = this.toggleAttribute('expanded');
event.source.setAttribute('aria-expanded', expanded);
break;
}
case '--toggle-wrap': {
const wrapped = this.toggleAttribute('wrap');
event.source.setAttribute('aria-pressed', wrapped);
break;
}
case '--copy':
navigator.clipboard.writeText(this.textContent);
break;
}
};
}
customElements.define('code-viewer', CodeViewer);
Y esto es todo lo que escribe un consumidor:
<code-viewer id="snippet">const answer = 42;</code-viewer>
<button command="--expand" commandfor="snippet" aria-expanded="false">Expand</button>
<button command="--toggle-wrap" commandfor="snippet" aria-pressed="false">Wrap</button>
<button command="--copy" commandfor="snippet">Copy</button>
Cero JavaScript del consumidor. Los botones pueden estar en cualquier parte del documento, en cualquier orden, y añadirse o eliminarse a voluntad.
Conclusión
Un conjunto de comandos es una API pública más pequeña y más duradera que una superficie de métodos: es inspeccionable en el marcado, accesible por teclado de forma predeterminada y está en un espacio de nombres que la plataforma nunca podrá romper. Elige un componente que hoy manejes mediante métodos o convenciones data-*, mueve sus acciones detrás de comandos con prefijo -- y deja que su próximo consumidor lo conecte sin abrir una etiqueta de script.
Preguntas frecuentes
¿Cuál es la diferencia entre commandfor y popovertarget?
Los atributos command y commandfor reemplazan y generalizan a popovertarget y popovertargetaction. El par más antiguo solo muestra, oculta o alterna popovers, mientras que command y commandfor también controlan diálogos con valores como show-modal y close, y despachan comandos personalizados con doble guion a cualquier elemento. Los atributos más nuevos admiten todo lo que hacían los anteriores, así que el código nuevo debería preferir command y commandfor.
¿Funcionan command y commandfor en elementos distintos de button?
No. La especificación HTML define command y commandfor únicamente en el elemento button, y las propiedades IDL correspondientes, command y commandForElement, viven en HTMLButtonElement. Los enlaces, los inputs y otros elementos no pueden actuar como invocadores. Un custom element que envuelve un button nativo tampoco obtiene comportamiento de invocador gratis: el button nativo interno debe llevar los atributos él mismo.
¿Un botón con command envía su formulario padre?
No, y eso tiene doble filo. Un botón que lleva command o commandfor sin un type explícito no es un botón de envío, así que no enviará el formulario. Tampoco ejecutará el comando: si hay un formulario propietario, el type está en el estado Auto y la activación retorna antes de que se dispare el comando, por lo que el botón no hace absolutamente nada. Establece type='button' en los botones invocadores dentro de un formulario. La especificación señala esta restricción como una medida de compatibilidad que planea eliminar.
¿Puedo manejar eventos command con un listener delegado en un ancestro?
Solo en la fase de captura. El evento command no hace bubbling, así que un listener delegado normal en un ancestro nunca se dispara. Los listeners en fase de captura se ejecutan en el descenso hacia el destino, de modo que addEventListener('command', handler, { capture: true }) en un contenedor captura los eventos command despachados a sus descendientes dentro del mismo árbol. El evento nunca cruza límites de shadow DOM, así que la delegación se detiene en los shadow roots.