12k
All articles

JavaScriptなしでWeb Componentsを制御する

カスタムのinvoker commandsで、HTMLだけでWebコンポーネントを制御する方法を解説。commandとcommandforでボタンをJavaScriptなしで接続します。

OpenReplay Team
OpenReplay Team
JavaScriptなしでWeb Componentsを制御する

カスタムinvokerコマンドを使うと、カスタム要素は自身のアクションを宣言的に公開できます。コンポーネントの作者はJavaScriptでcommandイベントリスナーを1つ書くだけで、そのコンポーネントを使う人は誰でも、HTMLのcommand属性とcommandfor属性でボタンを紐づけられます。利用者側にスクリプトは一切不要です。

カスタム要素を配布するとなると、たいていREADMEのセクションが付いて回ります。どのメソッドを呼べばよいのか、あるいはボタンにどのdata-action属性を振りかければよいのかを説明する部分です。コンポーネント自体は動きます。摩擦を生んでいるのは、その配線作業です。

本記事は、Invoker Commands APIガイドが終わったところから始まります。あちらの記事では、ダイアログやポップオーバー向けの組み込みコマンド(show-modalclosetoggle-popoverなど)とCommandEventの基礎を扱っており、ここではその内容を繰り返しません。invokerコマンドは2025年12月12日よりBaseline Newly availableなので、ポリフィルのセクションもありません。代わりに、配布可能な要素<code-viewer>を1つ作ります。この要素の公開APIは、そのコマンドセットそのものです。

要点

  • カスタムコマンド名は--expandのようにダブルダッシュで始める必要があります。このプレフィックスは予約された名前空間なので、カスタムコマンドがブラウザに後から追加される組み込みコマンドと衝突することはありません。
  • commandイベントはcommandforで指名された要素上で直接発火し、バブリングせず、シャドウ境界も越えません。したがってリスナーはコンポーネント自身に置きます。
  • ハンドラ内では、event.commandがコマンド名を保持し、event.sourceが発火元のボタンを指します。aria-pressedaria-expandedはここに設定します。
  • コンポーネントは、自身のシャドウツリー内からはcommandforでターゲットにできません。そこではホストにidが存在しないためです。代わりにcommandForElementプロパティが要素への直接参照を受け付けます。
  • 仕様はカスタムコマンドに対する状態変更を定義していないため、ARIAの状態はハンドラ自身が管理しなければなりません。

作者はJavaScriptを書き、利用者はHTMLを書く

この役割分担こそが、カスタムinvokerコマンドの核心です。コンポーネント作者であるあなたは、要素の内部にcommandリスナーを一度だけ書きます。それ以降、すべての利用者はマークアップからコンポーネントを操作します。

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

コンポーネント本体以外のimportも、覚えるべきメソッド名も、自前で実装するイベント委譲も不要です。コマンドセットがそのまま要素の公開インターフェースとなり、<dialog>に対してcommand="show-modal"が文書化されているのとまったく同じ形でドキュメント化されます。

なぜメソッドやdata-*属性では不十分なのか

今日コンポーネント作者が配布している2つのパターンは、どちらも作業を利用者側に押し付けます。命令的なメソッドは、すべての利用者にJavaScriptを強制します。

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

独自のdata-action属性はマークアップを宣言的に保てますが、ディスパッチを自前で再実装する羽目になります。委譲されたclickリスナー、属性のパース規約、そして自分のコンポーネントだけが理解する語彙のためのドキュメントが必要です。どちらのアプローチも、プラットフォームから得られるものがありません。command/commandforなら、キーボードによるアクティベーションを含む本物のbuttonのセマンティクスを継承でき、ページ上の他のコマンド駆動要素すべてと共通の配線規約を得られます。

カスタムinvokerコマンドはどう定義するのか

カスタムコマンドの値はダブルダッシュで始まらなければならず、このプレフィックスは定義上予約されています。command属性の状態では、--で始まる任意の値はカスタムキーワードとして分類されます。組み込みコマンドがその形を取る余地は残されていないため、あなたのコマンドがブラウザに後から追加されるものと衝突することはありません。組み込みキーワードでもなく--プレフィックスも付かない値は無効であり、何もディスパッチされません。

<code-viewer>は3つのコマンドを公開します。--expand--toggle-wrap--copyです。ドキュメントが謳うのは、メソッドの一覧ではなく、このリストです。

コマンド動作ボタン上で更新すべきARIA状態
--expand要素のexpanded属性をトグルするaria-expanded
--toggle-wrap要素のwrap属性をトグルするaria-pressed
--copy要素のテキストコンテンツをクリップボードに書き込むなし

commandイベントはどこで発火するのか

commandイベントは、ボタン上ではなくcommandforで指名されたターゲット要素上で発火し、バブリングしません。したがって祖先要素でのバブルフェーズの委譲では捕捉できません。リスナーはコマンドを受け取る要素に付けてください。CommandEventインターフェースは、基底のイベントに加えて2つのプロパティをハンドラに提供します。event.commandはコマンド名を保持し、event.sourceは呼び出し元のボタンを指し返します。

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

event.commandによるディスパッチは、明示的なcaseリストで行い、寛容なdefaultは置かないでください。--で始まる値は、あなたが処理するかどうかに関わらずイベントをディスパッチしますし、処理しなくても例外は投げられません。宣言的に配線されたコンポーネントのセッションリプレイを見ると、この失敗モードは「反応しないボタン」として現れます。クリックは届いているのに画面上では何も変わらず、コンソールエラーも出ません。これはまさに、コマンド値のタイプミスや、誤ったノードに付けられたリスナーが、ユーザー側からどう見えるかを示しています。

Shadow DOM: commandForElementでホストをターゲットにする

コンポーネントは、自身のシャドウツリー内からはcommandforでターゲットにできません。commandfor属性は、ボタン自身のツリー内に存在するidしか解決できず、ライトDOM側に位置するホストは、自身のシャドウルート内にidを持たないからです。commandForElementプロパティがこのギャップを埋めます。idではなく、シャドウルートを跨いだ要素への直接参照を受け付けるのです。これにより、内部のボタンも同じコマンドハンドラを経由できます。

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

ここで重要な伝播上の事実が2つあります。イベントはbubblescomposedも設定されないままターゲットに直接発火されるため、シャドウ境界を越えることは決してなく、event.targetは常にそれを受け取った要素です。composedPath()を使った小細工は不要です。そしてevent.sourceはリスナーのツリーに対してリターゲットされます。前述の内部ボタンの場合、ホスト上のリスナーが見るのはボタンではなくホストです。したがって、内部ボタンを更新する必要があるなら、そのボタンへの直接参照を保持しておいてください。

状態管理とARIAはあなたの仕事

仕様はカスタムコマンド値に対する状態変更を一切定義していません。ブラウザの振る舞いはイベントのディスパッチだけです。aria-pressedaria-expandedを代わりに設定してくれるものは何もないので、状態を変更するのと同じ分岐の中でevent.source上のそれらを更新してください。

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

利用者のライトDOM上のボタンについては、これは安全です。ボタンとリスナーが同じツリーを共有しているため、event.sourceは実際のボタンになります。

完成した要素と、それを駆動するHTML

組み上がったコンポーネントは、リスナー1つを持つクラス1つです。

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

そして、利用者が書くのはこれだけです。

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

利用者側のJavaScriptはゼロです。ボタンはドキュメント内のどこにでも、どんな順序でも配置でき、自由に追加・削除できます。

まとめ

コマンドセットは、メソッド群という表面積よりも小さく、より長持ちする公開APIです。マークアップ上で検査でき、デフォルトでキーボードアクセシブルで、名前空間が確保されているのでプラットフォーム側がそれを壊すことはありません。現在メソッドやdata-*規約で駆動しているコンポーネントを1つ選び、そのアクションを--プレフィックス付きのコマンドの背後に移してみてください。次にそれを使う人は、script タグを開くことなく配線できるようになります。

FAQ

commandforとpopovertargetの違いは何ですか?

command属性とcommandfor属性は、popovertargetとpopovertargetactionを置き換え、より一般化したものです。古い方のペアはポップオーバーの表示・非表示・トグルしかできませんが、commandとcommandforはshow-modalやcloseといった値でダイアログも駆動でき、任意の要素にダブルダッシュのカスタムコマンドをディスパッチできます。新しい属性は古い属性ができたことをすべてサポートするので、新規コードではcommandとcommandforを優先すべきです。

commandとcommandforはbutton以外の要素でも動きますか?

いいえ。HTML仕様はcommandとcommandforをbutton要素上でのみ定義しており、対応するIDLプロパティであるcommandとcommandForElementはHTMLButtonElementに存在します。リンクやinput、その他の要素はinvokerとして機能できません。ネイティブのbuttonをラップするカスタム要素も、それだけでinvokerの振る舞いを得られるわけではありません。内部のネイティブbuttonが自ら属性を持つ必要があります。

commandを持つボタンは親のフォームを送信しますか?

いいえ、そしてそれは両刃の剣です。commandまたはcommandforを持ち、明示的なtypeがないボタンはsubmitボタンではないため、フォームを送信しません。かといってコマンドが実行されるわけでもありません。フォームオーナーが存在する場合、typeはAuto状態となり、コマンドが発火する前にアクティベーションが返ってしまうため、ボタンは何もしません。フォーム内のinvokerボタンにはtype='button'を設定してください。仕様はこの制限を互換性のための措置と位置づけており、将来的に解除する予定としています。

祖先要素の委譲リスナーでcommandイベントを処理できますか?

キャプチャフェーズでのみ可能です。commandイベントはバブリングしないため、祖先要素上の通常の委譲リスナーは発火しません。キャプチャフェーズのリスナーはターゲットへ下っていく途中で実行されるので、コンテナ上でaddEventListener('command', handler, { capture: true })とすれば、同じツリー内の子孫要素にディスパッチされたcommandイベントを捕捉できます。イベントはシャドウ境界を越えないため、委譲はシャドウルートで止まります。

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.