Actions des boutons personnalisés

Fonctionnalités Web : commandes d'appel

L'API Invoker Commands permet aux boutons de déclencher des actions sur des éléments cibles de manière déclarative à l'aide d'attributs HTML. Cette approche réduit le besoin d'écouteurs d'événements manuels et dissocie l'UI des détails d'implémentation. Pour les actions personnalisées spécifiques à l'application, vous pouvez définir vos propres noms de commande. Les commandes personnalisées doivent être précédées d'un double tiret (--) pour éviter les conflits avec les futures commandes intégrées du navigateur.

Procédure d'implémentation

  1. Définir l'élément cible : identifiez l'élément qui répondra à l'action. Si ce n'est pas le cas, ajoutez-en unid.
  2. Configurez le bouton d'appel : utilisez l'attribut commandfor pour pointer vers l'id de la cible et l'attribut command pour spécifier le nom de la commande personnalisée (préfixé par --).
  3. Gérer l'événement de commande : associez un écouteur d'événements command directement à l'élément cible. L'objet d'événement contient une propriété command et une propriété target (faisant référence à l'élément identifié par commandfor).
  4. Gérer les états ARIA : les commandes personnalisées n'ont pas de sémantique inhérente. Vous devez donc gérer les états tels que aria-pressed ou aria-expanded.

Exemple : Commandes d'animation personnalisées

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

Principales contraintes

  • Préfixe des commandes personnalisées : OBLIGATOIRE : tous les noms de commandes personnalisées doivent commencer par -- (par exemple, command="--my-action").
  • Ciblage : l'attribut commandfor doit correspondre à l'attribut id d'un élément de la même arborescence de document.
  • Pas de propagation : l'événement command ne se propage pas. S'il existe plusieurs cibles possibles, ajoutez { capture: true } au gestionnaire d'événements et écoutez un ancêtre.
  • Racines fantômes : si la cible peut se trouver dans une racine fantôme, utilisez event.composedPath()[0] au lieu de event.target.
  • Accessibilité : les commandes personnalisées n'ont pas de sémantique inhérente. Vous devez appliquer explicitement tous les états.

Considérations sur l'accessibilité

  • Si le bouton affiche du contenu supplémentaire, utilisez l'attribut aria-expanded, défini sur aria-expanded="false" par défaut et sur aria-expanded="true" lorsque le contenu associé est affiché.
  • Si le bouton facilite une interaction (par exemple, les boutons d'activation qui permettent de lire et de mettre en pause, ou les boutons de volume qui permettent de couper et de réactiver le son), utilisez l'attribut aria-pressed, défini sur aria-pressed="false" lorsqu'il est inactif et sur aria-pressed="true" lorsqu'il est actif.
  • Si le bouton ouvre un élément avec un rôle menu, listbox, tree, grid ou dialog, la commande doit avoir un attribut aria-haspopup, avec une valeur définie sur le rôle de l'élément qu'elle ouvre.
  • Si le bouton est désactivé lors de l'activation, utilisez aria-disabled="true" au lieu de disabled pour ne pas perdre le focus.

Stratégies de remplacement

Si l'API Invoker Commands n'est pas compatible, l'événement command ne se déclenchera pas. Pour une compatibilité totale avec tous les navigateurs récents, nous vous recommandons d'utiliser le polyfill invokers-polyfill de https://github.com/keithamus/invokers-polyfill via npm install ou un CDN.

Ce polyfill est entièrement compatible avec les actions personnalisées (à partir de --) et distribue l'événement command exactement comme l'API native.

Importation dynamique (optimisation des performances)

Pour des performances optimales, ne chargez le polyfill que si le navigateur n'est pas compatible avec l'API de manière native. Cela permet d'économiser de la bande passante et de réduire le temps d'exécution des scripts pour les utilisateurs de navigateurs récents.

REMARQUE : Ce polyfill ne gère pas les états ARIA (comme aria-pressed ou aria-expanded) pour les commandes personnalisées. Vous devez synchroniser manuellement ces états dans votre écouteur d'événements pour vous assurer que votre site est accessible.

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

Création de remplacement manuelle (modèle traditionnel)

Si vous préférez ne pas utiliser de polyfill, vous pouvez combiner la délégation d'événements pour distribuer les événements et un registre de commandes pour gérer les actions. Il s'agit d'un schéma architectural communément utilisé dans le développement JavaScript traditionnel, qui reste très efficace et évolutif.

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