command と commandfor の導入

公開日: 2025 年 3 月 7 日

ボタンは、動的なウェブ アプリケーションを作成するために不可欠です。ボタンは、メニューを開いたり、アクションを切り替えたり、フォームを送信したりします。これらは、ウェブ上のインタラクティビティの基盤となります。ボタンをシンプルでアクセスしやすいものにすると、予想外の課題に直面することがあります。マイクロフロントエンドやコンポーネント システムを構築するデベロッパーは、必要以上に複雑なソリューションに直面することがあります。フレームワークは役に立ちますが、ウェブではさらに多くのことができます。

Chrome 135 では、新しい command 属性と commandfor 属性を使用して宣言型の動作を提供する新しい機能が導入され、popovertargetaction 属性と popovertarget 属性が強化され、置き換えられます。これらの新しい属性をボタンに追加することで、ブラウザはシンプルさとユーザー補助に関するコアの問題に対処し、共通の機能を組み込むことができます。

従来のパターン

フレームワークなしでボタンの動作を構築すると、本番環境のコードが進化するにつれて、興味深い課題が生じる可能性があります。HTML にはボタンの onclick ハンドラが用意されていますが、コンテンツ セキュリティ ポリシー(CSP)ルールにより、デモやチュートリアル以外では使用できないことがよくあります。これらのイベントはボタン要素でディスパッチされますが、通常、ボタンはページに配置され、他の要素を制御するために、2 つの要素を同時に制御するコードが必要になります。また、このインタラクションに支援技術のユーザーがアクセスできるようにする必要があります。そのため、コードは次のような形になります。

<div class="menu-wrapper">
  <button class="menu-opener" aria-expanded="false">
    Open Menu
  </button>
  <div popover class="menu-content">
    <!-- ... -->
  </div>
</div>
<script type="module">
document.addEventListener('click', e => {  
  const button = e.target;
  if (button.matches('.menu-opener')) {
    const menu = button
      .closest('.menu-wrapper')
      .querySelector('.menu-content');
    if (menu) {
      button.setAttribute('aria-expanded', 'true');
      menu.showPopover();
      menu.addEventListener('toggle', e => {
        // reset back to aria-expanded=false on close
        if (e.newState == 'closed') {
          button.setAttribute('aria-expanded', 'false');
        }
      }, {once: true})
    }
  }
});
</script>

このアプローチは少し脆弱になる可能性があるため、フレームワークは人間工学を改善することを目的としています。React などのフレームワークの一般的なパターンでは、クリックを状態変化にマッピングします。

function MyMenu({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  const open = useCallback(() => setIsOpen(true), []);
  const handleToggle = useCallback((e) => {
      // popovers have light dismiss which influences our state
     setIsOpen(e.newState === 'open')
  }, []);
  const popoverRef = useRef(null);
  useEffect(() => {
    if (popoverRef.current) {
      if (isOpen) {
        popoverRef.current.showPopover();
      } else {
        popoverRef.current.hidePopover();
      }
    }
  }, [popoverRef, isOpen]);
  return (
    <>
      <button onClick={open} aria-expanded={isOpen}>
        Open Menu
      </button>
      <div popover onToggle={handleToggle} ref={popoverRef}>
        {children}
      </div>
    </>
  );
}

他の多くのフレームワークも同様の人間工学を実現することを目標としています。たとえば、これは AlpineJS で次のように記述できます。

<div x-data="{open: false}">
  <button @click="open = !open; $refs.popover.showPopover()" :aria-expanded="open">
    Open Menu
  </button>
  <div popover x-ref="popover" @toggle="open = $event.newState === 'open'">
    <!-- ... -->
  </div>
</div>

Svelte で記述すると次のようになります。

<script>
  let popover;
  let open = false;
  function togglePopover() {
    open ? popover.hidePopover() : popover.showPopover();
    open = !open;
  }
</script>
<button on:click={togglePopover} aria-expanded={open}>
  Open Menu
</button>
<div bind:this={popover} popover>
  <!-- ... -->
</div>

一部のデザインシステムやライブラリでは、状態の変化をカプセル化するボタン要素のラッパーを用意することで、さらに一歩進んでいます。これにより、トリガー コンポーネントの背後で状態の変更が抽象化され、柔軟性が少し犠牲になりますが、人間工学が改善されます。

import {MenuTrigger, MenuContent} from 'my-design-system';
function MyMenu({children}) {
  return (
    <MenuTrigger>
      <button>Open Menu</button>
    </MenuTrigger>
    <MenuContent>{children}</MenuContent>
  );
}

command パターンと commandfor パターン

command 属性と commandfor 属性を使用すると、ボタンで他の要素に対して宣言的にアクションを実行できるため、柔軟性を犠牲にすることなくフレームワークの人間工学を実現できます。commandfor ボタンは for 属性と同様に ID を受け取りますが、command は組み込み値を受け取るため、よりポータブルで直感的なアプローチが可能になります。

例: command と commandfor を含む開くメニューボタン

次の HTML は、ボタンとメニューの間に宣言型の関係を設定し、ブラウザがロジックとユーザー補助を処理できるようにします。aria-expanded を管理したり、追加の JavaScript を追加したりする必要はありません。

<button commandfor="my-menu" command="show-popover">
Open Menu
</button>
<div popover id="my-menu">
  <!-- ... -->
</div>

commandcommandforpopovertargetactionpopovertarget の比較

popover を使用したことがある場合は、popovertarget 属性と popovertargetaction 属性に精通しているかもしれません。これらは、ポップオーバーに固有であるという点を除き、それぞれ commandforcommand と同様に機能します。command 属性と commandfor 属性は、これらの古い属性に完全に置き換わります。新しい属性は、古い属性がサポートしていたすべての機能をサポートし、新しい機能を追加します。

組み込みコマンド

command 属性には、インタラクティブな要素のさまざまな API にマッピングされる一連の組み込み動作があります。

  • show-popover: el.showPopover() にマッピングされます。
  • hide-popover: el.hidePopover() にマッピングされます。
  • toggle-popover: el.togglePopover() にマッピングされます。
  • show-modal: dialogEl.showModal() にマッピングされます。
  • close: dialogEl.close() にマッピングされます。

これらのコマンドは JavaScript の対応するコマンドにマッピングされ、(aria-detailsaria-expanded の同等関係の提供など)ユーザー補助やフォーカス管理を効率化します。

例: commandcommandfor を含む確認ダイアログ

<button commandfor="confirm-dialog" command="show-modal">
  Delete Record
</button>
<dialog id="confirm-dialog">
  <header>
    <h1>Delete Record?</h1>
    <button commandfor="confirm-dialog" command="close" aria-label="Close" value="close">
      <img role="none" src="/close-icon.svg">
    </button>
  </header>
  <p>Are you sure? This action cannot be undone</p>
  <footer>
    <button commandfor="confirm-dialog" command="close" value="cancel">
      Cancel
    </button>
    <button commandfor="confirm-dialog" command="close" value="delete">
      Delete
    </button>
  </footer>
</dialog>

[Delete Record] ボタンをクリックすると、ダイアログがモーダルとして開きます。[Close]、[Cancel]、[Delete] ボタンをクリックすると、ダイアログが閉じられ、ダイアログに "close" イベントがディスパッチされます。このイベントには、ボタンの値に一致する returnValue プロパティがあります。これにより、次に何をすべきかを判断するために、ダイアログ上の 1 つのイベント リスナー以外に JavaScript を使用する必要がなくなります。

dialog.addEventListener("close", (event) => {
  if (event.target.returnValue == "cancel") {
    console.log("cancel was clicked");
  } else if (event.target.returnValue == "close") {
    console.log("close was clicked");
  } else if (event.target.returnValue == "delete") {
    console.log("delete was clicked");
  }
});

カスタム コマンド

組み込みコマンドに加えて、-- 接頭辞を使用してカスタムコマンドを定義できます。カスタム コマンドは、(組み込みコマンドと同様に)ターゲット要素に対して "command" イベントをディスパッチしますが、組み込み値のように追加のロジックを実行することはありません。これにより、ラッパー コンポーネントの提供、ターゲット要素の DOM の走査、ボタンクリックの状態変化へのマッピングを行うことなく、さまざまな方法でボタンに反応できるコンポーネントを柔軟に構築できます。これにより、コンポーネントの HTML に API を指定できます。

<button commandfor="the-image" command="--rotate-landscape">
 Landscape
</button>
<button commandfor="the-image" command="--rotate-portrait">
 Portrait
</button>

<img id="the-image" src="photo.jpg">

<script type="module">
  const image = document.getElementById("the-image");
  image.addEventListener("command", (event) => {
   if ( event.command == "--rotate-landscape" ) {
    image.style.rotate = "-90deg"
   } else if ( event.command == "--rotate-portrait" ) {
    image.style.rotate = "0deg"
   }
  });
</script>

ShadowDOM のコマンド

commandfor 属性は ID を受け取るため、Shadow DOM をまたぐことに制限があります。このような場合は、JavaScript API を使用して .commandForElement プロパティを設定できます。このプロパティは、シャドウルート全体で任意の要素を設定できます。

<my-element>
  <template shadowrootmode=open>
    <button command="show-popover">Show popover</button>
    <slot></slot>
  </template>
  <div popover><!-- ... --></div>
</my-element>
<script>
customElements.define("my-element", class extends HTMLElement {
  connectedCallback() {
    const popover = this.querySelector('[popover]');
    // The commandForElement can set cross-shadow root elements.
    this.shadowRoot.querySelector('button').commandForElement = popover;
  }
});
</script>

今後の提案では、参照ターゲット プロポーザルなど、シャドウ境界間で参照を共有する宣言型の方法が提供される可能性があります。

次のステップ

Google は、ウェブサイトで使用される一般的な機能を網羅できるよう、新しい組み込みコマンドの可能性を継続的に模索しています。提案されたアイデアについては、Open UI の提案をご覧ください。すでに検討されているアイデアの例:

  • <details> 要素の開閉。
  • <input> 要素と <select> 要素の "show-picker" コマンド。showPicker() にマッピングされます。
  • <video> 要素と <audio> 要素の再生コマンド。
  • 要素からテキスト コンテンツをコピーする。

コミュニティからのフィードバックをお待ちしております。ご提案がございましたら、Open UI Issue Tracker で問題を報告してください。

その他の情報

commandcommandfor の詳細については、仕様MDN をご覧ください。