自作ブラウザの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 #684とPR #761の話です。
autoとmanual
popover属性には、主にautoとmanualがあります。
値を省略した場合も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
表示状態が変わると、beforetoggleとtoggleが発生します。
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だけを呼んで終わりにはせず、実入力と描画を含めて確認しました。
autoとmanualの開閉- 入れ子と無関係な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というブラウザの一通りの処理を通ります。
小さく見える機能でも、ブラウザとして動かそうとするとだいたい広がります。