🎈

Popover API - JavaScript不要、HTMLのみでポップオーバーUI

2023/02/19に公開
4
更新履歴

Git履歴

HTML Standardにpopover属性をはじめとしたPopover APIが正式にマージされました。Open UIによって提案されていた[1]APIで、名前がPopoverなのかPopupなのか紆余曲折の末、やっとHTML Standardとなります。

現段階で実装されているブラウザは少ないですが、簡易サンプルを作ったので体験しながら読んでいただくといいかもしれません。

CodeSandboxの簡易サンプル

3つの属性

まずはHTMLの属性が新しく追加されているところに注目しましょう。

以上の3つが追加されました。popover属性はポップオーバーする要素自体に。popovertarget属性とpopovertargetaction属性はそれを展開するボタン[2]に対して設定します。つまり、a要素やdiv要素には設定できません。これはアクセシビリティの観点から革新的と言えます。ついにdivボタンが消える…!

<button type="button" popovertarget="p1">開閉ボタン</button>
<div popover id="p1">ポップオーバーUI</div>

たったこれだけで、ポップオーバーUIが動作します。JavaScriptは不要です。popovertarget属性のidがマッチしていれば、<details><summary>と同様にJavaScriptからメソッドを呼び出すことなく実装できます。

popover属性が付与された要素は、デフォルトで非表示(display: none状態)となり、展開された場合にはトップレイヤーに表示されます。トップレイヤーとは、簡単に言うと、z-indexをどんなに大きな数にしても超えられない表示レイヤーで、<dialog>もここに表示されます。popover属性の値は「空」かautomanualで、デフォルトはautoです。「空」はautoと同等なので、値なしで<div popover>と書くこともできます。

ポップオーバーUIを制御するトリガー側の属性は2種類あります。

  • popovertarget属性: 対象のポップオーバーUIのIDを指定します。
  • popovertargetaction属性: アクションを種類を指定します。toggleshowhideのいずれかです。デフォルトはtoggleなので、この属性は省略することが出来ます。

筆者の感想としては「本気でJavaScript不要にしたかったんだな[3]」ということが伝わってきます。

簡易非表示(Light Dismiss)

今回新たに簡易非表示(英語原版ではLight Dismiss[4]という概念が登場しました。

そんなに難しいものではなく

  • ESCキーで閉じる
  • ポップオーバーUIの外側をクリックすると閉じる

という単純な機能ですね。外側をクリックは一般的によく採用されてきたパターンですし、ESCキーも同様にキーボード操作でも容易に閉じることができるでアクセシビリティも担保できています。

この簡易非表示(Light Dismiss) は、popover属性がautoのときに有効になります(つまり値なしでもOK)。これもJavaScriptを使わずに有効になるのはとても有用です。

auto/manual

popover属性の値はautomanualを受け取りますが、この2つの違いは先に説明した簡易非表示以外もあります。

auto開いたときに他のポップオーバーUIを閉じます。一方、manual他のポップオーバーを閉じません。表にまとめると、

複数表示 他のポップオーバーUIを 簡易非表示(Light Dismiss)
auto できない 閉じる 有効
manual できる 閉じない 無効

ということになります。目的合わせて使い分けるといいでしょう。

JavaScriptで利用するには

ここまでJavaScript不要と紹介していますが、使えないわけではありません。DOM APIとしてもきちんと操作可能なAPIを提供しているので、これを利用してより目的にあった実装をすることも可能です。

メソッド

3つのメソッドを使うことができます。

<div popover id="p1">ポップオーバーUI</div>
const element = document.querySelector("#p1");

// ポップオーバーUIを表示する
element.showPopover();

// ポップオーバーUIを非表示にする
element.hidePopover();

// 表示・非表示を切り替える(戻り値は開閉の結果)
const isShow = element.togglePopover();

// 強制的に表示・非表示を切り替える
element.togglePopover(true);

popover属性を持たない要素をshowPopoverなどで呼び出すと例外(NotSupportedError)を投げます。

イベント

イベントはbeforetoggletoggleの2種類です。イベントオブジェクトはToggleEventインターフェイスでnewStateoldStateというプロパティを持ちます。

element.addEventListener("beforeToggle", (ev) => {
  console.log(ev.newState); // "open" もしくは "closed"
  console.log(ev.oldState); // "open" もしくは "closed"

  // beforeToggleイベント内ではpreventDefaultで開閉を抑制することができます。
  ev.preventDefault();
});

element.addEventListener("toggle", (ev) => {
  console.log(ev.newState); // "open" もしくは "closed"
  console.log(ev.oldState); // "open" もしくは "closed"
});

popover="manual"は簡易非表示を無効にしていることなどから、JavaScriptで独自の実装をするために設けられたものとも考えることができます。うまく扱っていきたいところです。

アクセシビリティ

アクセシビリティオブジェクトのマッピング

気になるアクセシビリティオブジェクトのマッピングですが、以下のような変化が起こります[5]

要素 ロール ステート
popover属性をもつ要素 group
popovertarget属性をもつ要素 button(変化なし[6] expanded=false/true

またpopover属性を付与した時点で非表示(display:none)になりアクセシビリティツリーから消えることを意味するので、その点は留意が必要です。

読み上げに関する工夫

以下のように言及されています。

Whenever possible ensure the popover element is placed immediately after its triggering element in the DOM. Doing so will help ensure that the popover is exposed in a logical programmatic reading order for users of assistive technology, such as screen readers.

これは、可能な限りポップオーバー要素を、それをトリガーする要素の直後にDOM内に配置するようにすることを推奨しています。このようにすることで、スクリーンリーダーなどの支援技術を使用するユーザーにとって、論理的なプログラム読み取り順序で公開されることを確保するのに役立ちます。

<button type="button" popovertarget="p1">開閉ボタン</button>
<div popover id="p1">ポップオーバーUI</div>

フォーカスの移動

基本的にトリガー要素からポップオーバー要素にフォーカスは移動しません。しかしポップオーバーの要素内にautofocus属性をもつフォーカス可能要素があれば移動します。

<button type="button" popovertarget="p1">開閉ボタン</button>
<div popover id="p1">
  <button autofocus>開くと同時にフォーカスが当たる要素</button>
</div>

この場合、ポップオーバーが閉じられたとき、フォーカスがポップオーバー要素の内側にある場合はトリガー要素にフォーカスが戻ります(ポップオーバー要素の外側の場合はそのままフォーカスは移動しません)。

ただしpopover="manual"の場合は、ポップオーバーが閉じてもフォーカスは戻りません[7]。これは、JavaScriptで独自の実装をすることを想定しているためと考えられます。

CSS擬似クラス

Popover APIのページには擬似クラスについて言及はありませんが、デフォルトCSSの定義と、擬似クラスのページで仕様を確認することができます。

CSSでは擬似クラスが1つ適用されます。

  • :popover-open: 表示時に適用されるクラス

dialog要素での利用

結論から言うと、モーダルダイアログに利用することはできませんpopovertarget属性はshowModalメソッドに相当する呼び出しができないからです。

<!-- showModalが呼び出されるわけではない -->
<button type="button" popovertarget="m1">開閉ボタン</button>
<dialog popover id="m1">ダイアログ</dialog>

またpopover属性をもち、既にdialog要素がポップオーバー要素として開いている状態でshowModalを呼び出すとDOMExceptionエラーとなります[8]

Popover APIは簡易非表示(Light Dismiss)が便利なため、モーダルダイアログでも利用したいところですがshowModalメソッドが呼び出せないため、簡易非表示(Light Dismiss)が有効になりません。実装の際は注意が必要です。

id属性を使わない実装

Element.popoverTargetElementにポップオーバー要素のノードを渡すことで、id属性を使わずに実装することができます。

<button type="button">開閉ボタン</button>
<div popover>ポップオーバーUI</div>
const button = document.querySelector("button");
button.popoverTargetElement = button.nextElementSibling;

これにより、不要なid値を生成することなく実装することができます。Reactなどの実装でもuseRefを使って同様のことができるでしょう。

ブラウザ実装状況

2023年9月現在でChromeとEdgeが対応しています。Safariはv17から対応予定です。(HTMLElement API: popover | Can I use)

ポリフィル

Popover Attribute Polyfillが有志によって提供されています。SafariとFirefox向けに利用してみるとよいでしょう。

npm install @oddbird/popover-polyfill
脚注
  1. Popover API (Explainer) ↩︎

  2. button要素と、type=submittype=buttontype=resettype=imageをもつinput要素 ↩︎

  3. 実際、提案のゴールには “Avoid the need for any Javascript for most common cases.”(ほとんどの場合、Javascriptは必要ありません。)と記載されています ↩︎

  4. 簡易非表示という言葉は、日本語翻訳のページに合わせています ↩︎

  5. 2023年9月現在Chromeにて観測 ↩︎

  6. 暗黙的にbuttonロールになるbutton要素とinput要素にしかpopovertarget属性は許可されないため ↩︎

  7. ただし、ポップオーバーがアクセシビリティツリー上から消えるのでフォーカスは浮いた状態となります。 ↩︎

  8. The showModal() method stepsのステップ4参照。2023年9月現在Chromeでは未実装 ↩︎

GitHubで編集を提案

Discussion

かがんかがん

2023年4月27日現在、 chrome://flags から 「Experimental Web Platform features」のフラグをオンにすることで動作確認できました!
参考: https://utilitybend.com/blog/open-ui-and-the-popover-api

上記記事では「Canaryで」と書いてありましたが、安定版のChrome 112で確認できました。

ゆうてんゆうてん

おお!情報ありがとうございます。
MDNやCan I useも更新されはじめたっぽいですね。
助かります🙏