カスタムボタン アクション

ウェブ機能: Invoker コマンド

Invoker Commands API を使用すると、ボタンが HTML 属性を使用してターゲット要素に対してアクションを宣言的にトリガーできます。このアプローチにより、手動のイベント リスナーの必要性が減り、UI が実装の詳細から切り離されます。カスタムのアプリケーション固有のアクションの場合は、独自のコマンド名を定義できます。カスタム コマンドには、将来の組み込みブラウザ コマンドとの競合を避けるために、先頭に 2 つのダッシュ(--)を付ける必要があります。

実装の手順

  1. ターゲット要素を定義する: アクションに応答する要素を特定します。一意の id がない場合は、追加します。
  2. 呼び出しボタンを構成する: commandfor 属性を使用してターゲットの id を指定し、command 属性を使用してカスタム コマンド名(接頭辞 --)を指定します。
  3. コマンド イベントを処理する: command イベント リスナーをターゲット要素に直接アタッチします。イベント オブジェクトには、command プロパティと target プロパティ(commandfor で識別される要素を参照)が含まれています。
  4. ARIA 状態を処理する: カスタム コマンドには固有のセマンティクスがないため、aria-pressed や aria-expanded などの状態を処理する必要があります。

例: カスタム アニメーション コントロール

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

主な制約

  • カスタム コマンドの接頭辞: 必須: すべてのカスタム コマンド名は -- で始まる必要があります(例: command="--my-action")。
  • ターゲティング: commandfor 属性は、同じドキュメント ツリー内の要素の id と一致する必要があります。
  • バブリングなし: command イベントはバブリングしません。ターゲットが複数ある場合は、イベント ハンドラに { capture: true } を追加し、祖先をリッスンします。
  • シャドウルート: ターゲットがシャドウルートにある可能性がある場合は、event.target ではなく event.composedPath()[0] を使用します。
  • ユーザー補助: カスタム コマンドには固有のセマンティクスがないため、状態を明示的に適用する必要があります。

ユーザー補助に関する考慮事項

  • ボタンに追加のコンテンツが表示される場合は、aria-expanded 属性を使用します。デフォルトでは aria-expanded="false" に設定し、付随するコンテンツが表示される場合は aria-expanded="true" に設定します。
  • ボタン自体がインタラクションを促進する場合(再生と一時停止を切り替えるボタン、ミュートとミュート解除を切り替える音量ボタンなど)は、aria-pressed 属性を使用し、無効な場合は aria-pressed="false"、有効な場合は aria-pressed="true" に設定します。
  • ボタンが menu、listbox、tree、grid、dialog のいずれかのロールを持つ要素を開く場合、コントロールには aria-haspopup 属性が必要であり、その値は開く要素のロールに設定されている必要があります。
  • 有効化時にボタンが無効になる場合は、disabled ではなく aria-disabled="true" を使用して、フォーカスが失われないようにします。

代替戦略

Invoker Commands API がサポートされていない場合、command イベントは発生しません。すべての最新のブラウザで完全なサポートを受けるには、npm install または CDN を介して https://github.com/keithamus/invokers-polyfill の invokers-polyfill を使用することをおすすめします。

このポリフィルは、カスタム アクション(-- で始まる)を完全にサポートし、ネイティブ API とまったく同じように command イベントをディスパッチします。

動的インポート(パフォーマンスの最適化)

最適なパフォーマンスを得るには、ブラウザが API をネイティブにサポートしていない場合にのみポリフィルを読み込む必要があります。これにより、最新のブラウザを使用しているユーザーの帯域幅を節約し、スクリプトの実行時間を短縮できます。

注: このポリフィルは、カスタム コマンドの ARIA 状態(aria-pressed や aria-expanded など)を管理しません。サイトにアクセスできるようにするには、イベント リスナーでこれらの状態を手動で同期する必要があります。

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

手動フォールバック(従来のパターン)

ポリフィルを使用しない場合は、イベント委任を組み合わせてイベントをディスパッチし、コマンド レジストリを使用してアクションを処理できます。これは、従来の JavaScript 開発でよく使用されるアーキテクチャ パターンであり、効率性とスケーラビリティに優れています。

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