12k
All articles

Piloter des Web Components sans JavaScript

Utilisez des commandes invocatrices personnalisées pour piloter des composants web en HTML seul. command et commandfor relient boutons et élément sans JavaScript.

OpenReplay Team
OpenReplay Team
Piloter des Web Components sans JavaScript

Les commandes d’invocation personnalisées (custom invoker commands) permettent à un élément personnalisé d’exposer ses actions de manière déclarative : l’auteur du composant écrit un unique écouteur d’événement command en JavaScript, et tous ceux qui utilisent le composant y raccordent des boutons via les attributs HTML command et commandfor, sans la moindre ligne de script côté consommateur.

Publier un élément personnalisé s’accompagne généralement d’une section de README : celle qui explique quelles méthodes appeler, ou quel attribut data-action saupoudrer sur les boutons. Le composant fonctionne ; c’est le raccordement qui crée la friction.

Cet article commence là où s’arrête notre guide de l’API Invoker Commands. Ce dernier couvre les commandes natives pour les dialogues et les popovers (show-modal, close, toggle-popover et consorts) ainsi que les bases du CommandEvent ; rien de tout cela n’est repris ici. Les commandes d’invocation sont Baseline « Newly available » depuis le 12 décembre 2025, il n’y a donc pas non plus de section polyfill. À la place, nous construisons un élément distribuable, <code-viewer>, dont l’API publique est son jeu de commandes.

Points clés à retenir

  • Les noms de commandes personnalisées doivent commencer par un double tiret, par exemple --expand ; ce préfixe constitue un espace de noms réservé, si bien qu’une commande personnalisée ne pourra jamais entrer en collision avec une commande native ajoutée ultérieurement par le navigateur.
  • L’événement command est déclenché directement sur l’élément désigné par commandfor, ne remonte pas (pas de bubbling) et ne franchit pas les frontières du shadow DOM : l’écouteur doit donc être placé sur le composant lui-même.
  • À l’intérieur du gestionnaire, event.command contient le nom de la commande et event.source désigne le bouton qui l’a déclenchée : c’est là que doivent être appliqués aria-pressed et aria-expanded.
  • Un composant ne peut pas être ciblé par commandfor depuis l’intérieur de son propre shadow tree, car l’hôte n’y possède pas d’id ; la propriété commandForElement accepte en revanche une référence directe vers un élément.
  • La spécification ne définit aucun changement d’état pour les commandes personnalisées : le gestionnaire doit donc maintenir lui-même les états ARIA.

L’auteur écrit du JavaScript, le consommateur écrit du HTML

Cette répartition des rôles constitue toute l’idée des commandes d’invocation personnalisées. Vous, l’auteur du composant, écrivez l’écouteur command une seule fois, à l’intérieur de l’élément. Tous les consommateurs pilotent ensuite le composant depuis le balisage :

<button command="--expand" commandfor="snippet">Expand</button>

Aucun import au-delà du composant lui-même, aucun nom de méthode à mémoriser, aucune délégation d’événements à écrire à la main. Le jeu de commandes devient l’interface publique de l’élément, documentée de la même manière que command="show-modal" l’est pour <dialog>.

Pourquoi les méthodes et les attributs data-* ne suffisent-ils pas ?

Les deux approches livrées aujourd’hui par les auteurs de composants reportent toutes deux le travail sur le consommateur. Une méthode impérative contraint chaque consommateur à écrire du JavaScript :

document.querySelector('#snippet').expand();
// plus a click listener on every button that should call it

Un attribut data-action sur mesure conserve un balisage déclaratif, mais vous oblige à réimplémenter la distribution : un écouteur de clic délégué, une convention d’analyse d’attributs et une documentation pour un vocabulaire que seul votre composant comprend. Aucune de ces approches ne vous fait bénéficier de quoi que ce soit venant de la plateforme. Avec command/commandfor, vous héritez de la sémantique d’un véritable bouton, activation au clavier comprise, ainsi que d’une convention de raccordement partagée avec tous les autres éléments pilotés par commandes présents sur la page.

Comment définir une commande d’invocation personnalisée ?

Les valeurs de commandes personnalisées doivent commencer par un double tiret, et ce préfixe est réservé par définition. Dans les états de l’attribut command, toute valeur débutant par -- est classée comme mot-clé personnalisé, ce qui interdit à toute commande native de prendre un jour cette forme : vos commandes ne peuvent donc entrer en collision avec rien de ce que le navigateur ajoutera par la suite. Une valeur qui n’est ni un mot-clé natif ni préfixée par -- est invalide et ne déclenche rien.

Notre <code-viewer> expose trois commandes : --expand, --toggle-wrap et --copy. C’est cette liste, et non un ensemble de méthodes, que met en avant sa documentation.

CommandeRôleÉtat ARIA à mettre à jour sur le bouton
--expandBascule l’attribut expanded sur l’élémentaria-expanded
--toggle-wrapBascule l’attribut wrap sur l’élémentaria-pressed
--copyÉcrit le contenu textuel de l’élément dans le presse-papiersaucun

Où l’événement command est-il déclenché ?

L’événement command est déclenché sur l’élément cible désigné par commandfor, et non sur le bouton, et il ne remonte pas : une délégation en phase de bouillonnement sur un ancêtre ne le verra donc jamais. Attachez l’écouteur à l’élément qui reçoit la commande ; l’interface CommandEvent fournit au gestionnaire deux propriétés supplémentaires par rapport à l’événement de base : event.command contient le nom de la commande, et event.source pointe vers le bouton invocateur.

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

Effectuez la distribution sur event.command avec une liste de cas explicite et sans default permissif. Toute valeur préfixée par -- déclenche l’événement, que vous la traitiez ou non, et rien n’est levé lorsque ce n’est pas le cas. Les session replays de composants raccordés de manière déclarative font apparaître ce mode de défaillance sous la forme d’un bouton mort : le clic aboutit, rien ne change à l’écran, et aucune erreur de console n’est émise — ce qui correspond exactement à l’aspect, côté utilisateur, d’une valeur de commande mal orthographiée ou d’un écouteur placé sur le mauvais nœud.

Shadow DOM : cibler l’hôte avec commandForElement

Un composant ne peut pas être ciblé par commandfor depuis l’intérieur de son propre shadow tree. L’attribut commandfor ne résout que les id présents dans l’arbre du bouton lui-même, or l’hôte, situé dans le light DOM, ne possède aucun id à l’intérieur de son propre shadow root. La propriété commandForElement comble cette lacune : elle accepte une référence directe vers un élément, y compris à travers les shadow roots, plutôt qu’un id. Un bouton interne peut ainsi passer par le même gestionnaire de commandes :

connectedCallback() {
  const copyBtn = document.createElement('button');
  copyBtn.textContent = 'Copy';
  copyBtn.setAttribute('command', '--copy');
  copyBtn.commandForElement = this; // no id needed
  this.shadowRoot.append(copyBtn);
}

Deux faits relatifs à la propagation sont ici déterminants. L’événement est déclenché directement sur la cible, sans que bubbles ni composed ne soient définis : il ne franchit donc jamais une frontière de shadow DOM, et event.target est toujours l’élément qui l’a reçu ; aucune contorsion avec composedPath() n’est nécessaire. Par ailleurs, event.source est reciblé par rapport à l’arbre de l’écouteur : pour ce bouton interne, un écouteur placé sur l’hôte voit l’hôte, et non le bouton. Conservez donc une référence directe vers les boutons internes si vous devez les mettre à jour.

L’état et l’ARIA sont à votre charge

La spécification ne définit aucun changement d’état pour les valeurs de commandes personnalisées ; le seul comportement du navigateur consiste à déclencher l’événement. Rien ne définit aria-pressed ou aria-expanded à votre place : mettez-les donc à jour sur event.source dans la même branche que celle qui modifie l’état :

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

Pour les boutons du light DOM du consommateur, cette approche est sûre : le bouton et l’écouteur partagent le même arbre, donc event.source correspond bien au bouton réel.

L’élément finalisé et le HTML qui le pilote

Une fois assemblé, le composant tient en une classe dotée d’un seul écouteur :

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

Et voici tout ce que doit écrire un consommateur :

<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>

Zéro JavaScript côté consommateur. Les boutons peuvent se trouver n’importe où dans le document, dans n’importe quel ordre, être ajoutés ou supprimés à volonté.

Conclusion

Un jeu de commandes constitue une API publique plus réduite et plus durable qu’une surface de méthodes : il est inspectable dans le balisage, accessible au clavier par défaut, et son espace de noms garantit que la plateforme ne pourra jamais le casser. Choisissez un composant que vous pilotez actuellement via des méthodes ou des conventions data-*, déplacez ses actions derrière des commandes préfixées par --, et laissez son prochain consommateur le raccorder sans ouvrir la moindre balise script.

FAQ

Quelle est la différence entre commandfor et popovertarget ?

Les attributs command et commandfor remplacent et généralisent popovertarget et popovertargetaction. L'ancienne paire se limite à afficher, masquer ou basculer des popovers, tandis que command et commandfor pilotent également les dialogues avec des valeurs telles que show-modal et close, et déclenchent des commandes personnalisées à double tiret sur n'importe quel élément. Les nouveaux attributs prennent en charge tout ce que permettaient les anciens : le nouveau code devrait donc privilégier command et commandfor.

command et commandfor fonctionnent-ils sur d'autres éléments que button ?

Non. La spécification HTML définit command et commandfor uniquement sur l'élément button, et les propriétés IDL correspondantes, command et commandForElement, appartiennent à HTMLButtonElement. Les liens, les champs de saisie et les autres éléments ne peuvent pas jouer le rôle d'invocateurs. Un élément personnalisé qui encapsule un bouton natif n'hérite pas non plus gratuitement du comportement d'invocateur : c'est le bouton natif interne qui doit porter lui-même les attributs.

Un bouton doté de command soumet-il son formulaire parent ?

Non, et cela fonctionne dans les deux sens. Un bouton portant command ou commandfor sans type explicite n'est pas un bouton de soumission : il ne soumettra donc pas le formulaire. Mais il n'exécutera pas non plus la commande : en présence d'un formulaire propriétaire, le type se trouve à l'état Auto et l'activation retourne avant que la commande ne soit déclenchée, de sorte que le bouton ne fait absolument rien. Définissez type='button' sur les boutons invocateurs situés à l'intérieur d'un formulaire. La spécification présente cette restriction comme une mesure de compatibilité qu'elle prévoit de lever.

Puis-je traiter les événements command avec un écouteur délégué sur un ancêtre ?

Uniquement en phase de capture. L'événement command ne remonte pas, si bien qu'un écouteur délégué classique placé sur un ancêtre ne se déclenche jamais. Les écouteurs en phase de capture s'exécutent lors de la descente vers la cible : addEventListener('command', handler, { capture: true }) sur un conteneur intercepte donc les événements command déclenchés sur ses descendants au sein du même arbre. L'événement ne franchissant jamais les frontières du shadow DOM, la délégation s'arrête aux shadow roots.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.