自作ブラウザのOmoikaneに、HTMLのPopover APIを実装しました。

Popover APIを使うと、JavaScriptで表示状態を管理しなくても、HTMLだけでポップオーバーを開閉できます。

<button popovertarget="menu">メニューを開く</button>

<div id="menu" popover>
  <button>項目1</button>
  <button>項目2</button>
</div>

これだけ見ると、要素のdisplayを切り替えるAPIを追加すれば終わりそうです。

実際には、popoverは通常の重なり順から外れたtop layerへ表示されます。外側を押したときのlight dismiss、Escapeでの終了、focusの移動と復元、::backdrop、dialogとの前後関係もあります。

DOM APIだけでは終わらず、CSS、layout、paint、hit test、実入力までつなぐことになりました。

Issue #684PR #761の話です。

autoとmanual

popover属性には、主にautomanualがあります。

値を省略した場合もautoです。

<div popover>auto popover</div>
<div popover="auto">auto popover</div>
<div popover="manual">manual popover</div>

autoは、一般的なメニューや補助表示に近い動作をします。

別の無関係なauto popoverを開くと、先に開いていたものは閉じます。ただし、popoverの中からさらに別のpopoverを開いた場合は、親子関係を保ったままstackへ積みます。

外側を押すlight dismissとEscapeでも閉じます。

manualは自動では閉じません。複数のmanual popoverを同時に開くこともできます。表示状態はページ側が明示的に管理します。

Omoikaneでは、Documentごとに開いているauto popoverのstackを持ち、開こうとしている要素と関係のないpopoverだけを上から閉じるようにしました。

JavaScriptから開閉する

JavaScriptからは、次の3つのAPIを使えます。

const menu = document.querySelector("#menu");

menu.showPopover();
menu.hidePopover();
menu.togglePopover();

togglePopover()forceも受け取ります。

menu.togglePopover({ force: true });
menu.togglePopover({ force: false });

要素にpopover属性がない場合や、Documentへ接続されていない場合は、対応するDOMExceptionを返します。

開閉中に同じ処理へ再入した場合も、状態を二重に変更しないようにしています。

buttonから宣言的に操作する

最初の例のように、buttonや一部のinputからpopovertargetで対象を指定できます。

<button popovertarget="menu" popovertargetaction="toggle">
  切り替える
</button>

<button popovertarget="menu" popovertargetaction="show">
  開く
</button>

<button popovertarget="menu" popovertargetaction="hide">
  閉じる
</button>

popovertargetactionを省略した場合はtoggleです。

対応するIDL propertyも追加しました。

button.popoverTargetElement = menu;
button.popoverTargetAction = "show";

Omoikaneのclick default actionからpopoverの開閉処理へ接続しています。属性が存在するだけではなく、実際にマウスでbuttonを押したときにも動くところまで実装しました。

beforetoggleとtoggle

表示状態が変わると、beforetoggletoggleが発生します。

menu.addEventListener("beforetoggle", event => {
  console.log(event.oldState, event.newState);
});

menu.addEventListener("toggle", event => {
  console.log(event.oldState, event.newState);
});

開く前のbeforetoggleはcancelできます。

menu.addEventListener("beforetoggle", event => {
  if (event.newState === "open" && !canOpen()) {
    event.preventDefault();
  }
});

toggleは非同期で通知します。同じtask内で開閉が続いた場合は通知をまとめ、最初の状態と最後の状態を返します。

buttonなどから開いた場合は、ToggleEvent.sourceから起点になった要素も取得できます。

top layerは大きなz-indexではない

popoverはtop layerへ表示されます。

top layerは、通常のstacking contextのさらに上にある表示領域です。ページ内の要素へどれだけ大きなz-indexを指定しても、top layerにあるpopoverより前には出ません。

<div class="normal">通常の要素</div>
<div id="menu" popover>popover</div>
.normal {
  position: fixed;
  z-index: 2147483647;
}

#menu::backdrop {
  background: rgb(0 0 0 / 40%);
}

この場合も、重なり順は次のようになります。

popover
::backdrop
通常の最大z-index要素
ページ

単にpopoverへ大きなz-indexを設定すると、祖先のstacking contextに閉じ込められる可能性があります。top layerでは、通常のbox treeにある重なり順とは別に扱う必要があります。

DOMにtop layerの状態を持たせる

Omoikaneでは、要素に次の状態を持たせました。

  • popoverとして開いているか
  • modal dialogとして開いているか
  • top layerへ入った順番

popoverとmodal dialogは同じtop layerを使います。

要素がtop layerへ入るたびに単調増加する番号を割り当て、その順番で前後関係を決めます。Escapeが押された場合も、popoverとdialogのうち一番手前にあるdismiss可能な要素を選びます。

要素をDOMから取り外したときは、その子孫を含めてtop layerの状態を消します。切断されたpopoverが描画だけ残る状態にならないようにしました。

通常のlayoutから一度外す

layoutでは、top layerにある要素を通常のDOM位置では配置しません。

まず通常のDocumentをlayoutし、そのあとでtop layerにある要素を収集します。挿入順に並べ、viewportを基準にしたpositioned elementとしてDocument直下のlayout結果へ追加します。

通常のDocumentをlayout
  -> top layer要素は読み飛ばす
  -> top layer要素を収集
  -> 挿入順にlayout
  -> 通常の要素より後ろへ追加

これによって、popoverがDOM上では深い位置にあっても、祖先のoverflowやstacking contextに閉じ込められません。

paintとhit testも同じ順序のlayout treeを使います。見た目だけ一番上に描いたものの、クリックは背後の要素へ届く、という状態を避けています。

backdropをpopoverの直前に描く

::backdropも通常の子要素ではありません。

top layer要素を描画するときに、その要素の::backdropのcomputed styleを取得し、viewport全体を覆うgenerated boxとして先に描きます。

top layer item 1 のbackdrop
top layer item 1
top layer item 2 のbackdrop
top layer item 2

複数のtop layer要素がある場合も、それぞれのbackdropと本体が挿入順に対応します。

CSS側には、::backdropのほかに:popover-open:modalも追加しました。popoverが閉じている間は表示せず、開いた状態だけUA styleと著者styleを適用します。

focusとdismiss

popoverを開いたとき、autofocusを持つ要素があればそこへfocusを移します。

閉じる前には元のfocus位置を保存しておき、popover内にfocusがある状態で閉じた場合は、開く前の要素へ戻します。

light dismissでは、pointerを押した場所と離した場所の両方を確認します。

押下と解放が同じpopoverの内側なら閉じません。外側で完結した入力なら、その位置より上にあるauto popoverを閉じます。pointerを押したままpopoverの内外へ移動しただけで誤って閉じないための処理です。

Escapeについては、開いているauto popoverとmodal dialogのtop layer順を比較し、一番手前の対象を閉じます。

このあたりまで来ると、Popover APIの実装というより、ブラウザの入力と表示状態をまとめて実装する話になってきます。

確認

DOM APIだけを呼んで終わりにはせず、実入力と描画を含めて確認しました。

  • automanualの開閉
  • 入れ子と無関係なpopoverの排他
  • beforetoggleのcancelとtoggleの非同期通知
  • buttonとinputからの宣言的な起動
  • focus移動と復元
  • Escapeとlight dismiss
  • dialogとpopoverのtop layer順
  • DOMから切断したsubtreeの後片付け
  • :popover-open:modal::backdrop
  • layout、paint、hit test

固定したWPTでは、追加したPopover関連2ファイル、42 subtestがすべて通りました。WPT全体は119件でregression 0です。

描画確認には160×120の固定fixtureを使いました。通常の最大z-index要素をbackdropが覆い、その上にpopoverが表示されることを画像で確認しています。保存したbaselineとの差分も0でした。

通常テストは35個のtest binaryとlibrary test 2,496件が成功し、3環境のCIも失敗0でした。

まとめ

今回はOmoikaneにPopover APIを実装しました。

最初はshowPopover()hidePopover()を追加する話に見えましたが、それだけではブラウザ上のpopoverにはなりません。

表示状態をDOMへ持たせ、通常のlayoutから外してtop layerへ配置し、backdropを描画します。さらにfocus、Escape、light dismiss、宣言的なbutton操作、dialogとの順序、hit testまで同じ状態へ接続しました。

Popover APIは比較的新しいHTML APIですが、内部ではDOM、CSS、layout、paint、inputというブラウザの一通りの処理を通ります。

小さく見える機能でも、ブラウザとして動かそうとするとだいたい広がります。