👨‍🔧

button 要素の command & commandfor 属性のフォールバックを自力で書いてみた(ポップオーバー関連には目を背けつつ)

に公開

こなさんみんばんわ。
まずは何でも自力で書いてみようのコーナーです。

さて、最近のブラウザには Invoker Commands API というのが実装されておりまして、button 要素に commandcommandfor といった属性を付けることで、ダイアログやポップオーバー要素の操作ができるようになっています。

  • command 属性は実行するアクションを指定します。次のような値を指定できます
    • show-modal, close, request-close: それぞれ HTMLDialogElementshowModal(), close(), requestClose() メソッドに相当
    • show-popover, hide-popover, toggle-popover: それぞれ HTMLElementshowPopover(), hidePopover(), togglePopocer() メソッドに相当
    • カスタム値: -- で始まる任意の識別子 (<dashed-ident>) です。実行するアクションは開発者が定義します
  • commandfor 属性command で指定したアクションの対象となる要素を id 値で指定します

https://bcrikko.github.io/til/posts/2025-03-24/invoker-commands-api/

これを使えば JavaScript を一切書かずとも、HTML を書くだけでモーダルダイアログなどを開いたり閉じたりできる訳ですね。べんり〜
なのですが、この機能まだ Safari では(普通に使ってる限りは)動作しません(´・_・`)[1]

公開ベータ版ともいえる Technology Preview 版では有効になっているので、そう遠くないうちに正式版でも普通に使えるようになるとは思いますが(10/5追記: Safari 26.2 Beta でのサポート追加を確認しましたので次期リリース版からは使えそう)、とりあえず現時点でも多くのブラウザでは使える訳ですから、今後はもう HTML はこの方法で書いておいて、非対応ブラウザ向けには JavaScript によるフォールバックを加えておく、というのがよろしいんじゃないかと思います。

という訳で、じゃあ実際にやってみよう! というのが今回のお題です。

前提

  • 普通の静的 HTML に <script> で JavaScript を追加する、というケースを想定しています。これは単に自分の普段制作するものがほぼそんな感じだからです(React などの JS フレームワークでの動作は想定していませんし試してもないです、多分動作しない気がします)
  • とりあえず今回はダイアログ関連の属性値のみを実装して、ポップオーバー関連のものには対応しないこととします。ここでがんばって対応するよりも、すでに全ブラウザで普通に実装されている Popover API を使うのが賢明でしょう…という判断です

HTML

index.html
<!-- body の中身のみ -->
<p><button command="show-modal" commandfor="testDialog" id="openBtn" type="button">ダイアログを開く</button></p>
<dialog id="testDialog" closedby="any"><!-- もしくは `none` `closerequest` -->
  <p>このダイアログはモーダルです</p>
  <p><button command="close" commandfor="testDialog" id="closeBtn" type="button">ダイアログを閉じる</button></p>
</dialog>

ベースとなる HTML コードはこんな感じ。dialog 要素についてる closedby 属性については別の記事でそのフォールバックも含めて説明しておりますが、簡単にいえばそのダイアログの「閉じられ方」(簡単に閉じられるようにする、ボタン操作のみに制限する、など)を指定するためのものです。

https://zenn.dev/jforg/articles/291d668e4c95dd

JavaScript

HTML が準備できたので、次は JavaScript を書いていきましょう。ファイル名はとりあえず invoker.js にしておきます。HTML での読み込みはお好みの方法でいいと思いますが、最近の僕は <script type="module"> として head 内で読み込むことが多いですかね。中でモジュールが使えるのはもちろん、defer を指定したのと同じ扱いになるので非同期読み込みになる・実行タイミングや順序が保証される、などといった利点もあります。

index.html
<!-- head 内に -->
<script type="module" src="invoker.js"></script>

まずは手順を考える

毎回いちいち getElementById() を使って要素を取得していては汎用性に欠けるので、もう少し効率的なスクリプトを考えます。1ページ内に複数のダイアログとボタンのセットがある場合も考慮に入れると、次のような手順でやるのがよさそうです。

  1. ページ内すべての id 付き dialog 要素を querySelectorAll() で取得してループで回す
  2. 各ダイアログの id 値を取得、その値を commandfor 属性に持つ button 要素を同じく querySelectorAll() で探す
  3. button が持つ command 属性値に応じて click イベントに与える処理を変える

実装していく

大枠はそれほど難しくないので一気に行きます。外側の繰り返し処理に forEach() ではなく for…of ループを使っている理由は後述。

invoker.js
// 1. ページ内すべての `id` 付き `dialog` 要素を `querySelectorAll()` で取得して
const dialogs = document.querySelectorAll('dialog[id]')
//    ループを回す
for (const dialog of dialogs) {
  // 2. 各ダイアログの `id` 値を取得
  const id = dialog.id
  //    その値を `commandfor` 属性に持つ `button` 要素を
  //    同じく `querySelectorAll()` で探す
  const buttons = document.querySelectorAll(`button[commandfor="${id}"]`)
  buttons.forEach(button => {
    // 3. 各 `button` が持つ `command` 属性値に応じて
    const command = button.getAttribute('command')
    //    `click` イベントに与える処理を変える
    switch (command) {
      case 'show-modal':
        button.addEventListener('click', () => dialog.showModal())
        break
      case 'close':
        button.addEventListener('click', () => dialog.close())
        break
      case 'request-close':
        button.addEventListener('click', () => dialog.requestClose())
        break
      //(とりあえず、これ以外のコマンドは無視ということで…)
      default:
        console.log(`Sorry, command "${command}" is not supported by this script.`)
    }
  })
}

手順 1. 〜 2. の時点で各ダイアログとボタンの関係は明白なので、あとはボタンの command 属性値を見て switch 文で処理を分岐するだけですし、click イベントに与えるコールバックもただ対応する dialog のインスタンスメソッドを呼び出すだけなので簡単ですね。特につまずくような箇所もないと思います。

対応ブラウザにはスクリプトを適用しない処理を加える

ただ、これだけだとすでに Invoker Command API をサポートしているブラウザにもボタンにイベントがくっついてしまいます。各ブラウザで検証してみたところ動作そのものに支障はなさそうですが、Google Chrome はコンソールに毎回「すでにダイアログが開いてる(閉じてる)よ」という警告を表示しますね。

まぁ計算リソースの無駄遣いでもあるので、対応ブラウザに対しては何も行わないようにする処理を加えておきましょう。button.forEach() の繰り返し処理に入る前の行に、次の一行を加えます。

invoker.js
  if (typeof buttons[0]?.commandForElement !== 'undefined') break

Invoker Commands API 対応ブラウザであれば、各 button 要素の commandForElement プロパティには commandfor 属性で指定した id 値を持つ要素、つまり対になる dialog 要素が必ず入っています[2]ので、それが取得できるようであればその時点でループ処理を抜けてスクリプトを終了します。先ほど少し触れた forEach() を使わなかった理由は、要するにあとでこの処理を追加するためです[3]

?. ってのはなに?🤔

ちなみに ?. というのはオプショナルチェーン演算子というそうです(実は最近知りました😅)。単に buttons[0].commandForElement だとボタンがひとつも存在しない場合には TypeError となってしまいますが、buttons[0]?.commandForElement だとボタンが存在しない場合にはその時点で undefined として評価し、そこで式は終了するんですって。へー。

でもそれだとボタンが存在しない場合には対応ブラウザでも undefined になるから break しないんじゃないの? と一瞬なりますが、その場合は次の forEach() も実行されないので、どっちみち結果は一緒ですね。気にする必要なかったわ。涙

できたー

という訳で、全部まとめると次のようになります。コメントは全削除しました。

invoker.js
const dialogs = document.querySelectorAll('dialog[id]')
for (const dialog of dialogs) {
  const id = dialog.id
  const buttons = document.querySelectorAll(`button[commandfor="${id}"]`)
  if (typeof buttons[0]?.commandForElement !== 'undefined') break
  buttons.forEach(button => {
    const command = button.getAttribute('command')
    switch (command) {
      case 'show-modal':
        button.addEventListener('click', () => dialog.showModal())
        break
      case 'close':
        button.addEventListener('click', () => dialog.close())
        break
      case 'request-close':
        button.addEventListener('click', () => dialog.requestClose())
        break
      default:
        console.log(`Sorry, command "${command}" is not supported by this script.`)
    }
  })
}

そんな訳で

最近はモーダルダイアログやポップオーバー要素の開閉が button 要素に commandcommandfor といった属性を指定するだけで実装できるようになっててめっちゃ便利なんだけど、まだ Safari が非対応なのでフォールバックのスクリプトを書いてみたよ、というお話でした。

脚注
  1. Safari v26 であれば機能フラグで “HTML command & commandfor attributes” を有効にすれば今でも動作させることができます。macOS 版は設定 > 詳細で「Web デベロッパ用の機能を表示」にチェックを入れると、設定画面一番右端のアイコンからアクセス可能になります。iOS・iPadOS の場合は OS の設定アイコンを開いて、アプリ > Safari > 詳細の中に該当メニューがあります。ちなみに iOS 18 にも同じ機能フラグが存在しますが、そちらは有効にしても動作しないようでした(´・_・`) ↩︎

  2. というよりそうなるように指定して querySelectorAll() をしています。 ↩︎

  3. forEach() は繰り返し処理ではあるけどループ文ではないので break で抜けることはできません。 ↩︎

Discussion