Invoker Commands API を使用すると、ボタンが HTML 属性を使用してターゲット要素に対してアクションを宣言的にトリガーできます。このアプローチにより、手動のイベント リスナーの必要性が減り、UI が実装の詳細から切り離されます。カスタムのアプリケーション固有のアクションの場合は、独自のコマンド名を定義できます。カスタム コマンドには、将来の組み込みブラウザ コマンドとの競合を避けるために、先頭に 2 つのダッシュ(--)を付ける必要があります。
実装の手順
- ターゲット要素を定義する: アクションに応答する要素を特定します。一意の
idがない場合は、追加します。 - 呼び出しボタンを構成する:
commandfor属性を使用してターゲットのidを指定し、command属性を使用してカスタム コマンド名(接頭辞--)を指定します。 - コマンド イベントを処理する:
commandイベント リスナーをターゲット要素に直接アタッチします。イベント オブジェクトには、commandプロパティとtargetプロパティ(commandforで識別される要素を参照)が含まれています。 - 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);
}
});