Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

HTMLDialogElement: showModal() はHTMLの長年の課題を解決する期待の星かと思ったら実際はだめな子

<dialog> 要素の showModal() は全てのモダンブラウザで実装され、そろそろ使っても良いかなという方も多いと思います。しかし実際に使ってみると(主にChromium系ブラウザで)重大な落とし穴があり、適用できるケースは限定的なことがわかりました。

背景

HTMLでモーダルダイアログを表示するとき、伝統的には以下のような実装をしていました。

<aside class="backdrop">
  <div class="dialog">
    ダイアログの中身
  </div>
</aside>
.backdrop {
    position: fixed;
    inset: 0;
    background: rgba(0,0,0,0.3);
    display: flex;
    justify-content: center; 
}
.dialog {
    position: absolute;
    width: 300px;
    height: 300px;
    background: white;
}

しかしこれだけでは以下のような問題があります。

  • Tabキーで裏にある <a> 要素等にフォーカスを移動できるので、想定外の操作ができてしまう
  • マウスホイールで裏がスクロールしてしまう

自前で解決しようとすると地味に面倒で、要素外に pointer-events: none を設定したところでキーボードフォーカスには関係ないし、DOM構造を工夫しないとそもそもうまくいきません。

これを解決するにはfocus-trapなどのライブラリを使用しますが、やっていることは結局「 keydown でTabキーを検知して preventDefault する」といった非常に泥臭く副作用のありそうな方法です。実際、テキストボックス中のIME操作などでたまに変なバグを誘発することもあります。実現したいことと複雑さが釣り合っておらず、これが数々のプロジェクトで当然必要というのはあまり健全な状態とは思えません。

HTMLDialogElement.showModal() の登場

showModal()というAPIがあります。これはモーダルダイアログを開くことができ、その間には裏の要素にフォーカスが移動しません。DOM構造もかなり自由が効きます。これこそ我々が10年以上求め続けていたものだ!と、登場時には小躍りしたものです。

※裏スクロールは防げません。

いつもネックになるのはiOS Safariですが、15.4以上で使用できるので、そろそろ実用できそうです。

なお、<dialog> 自体はもっと昔からありますが、これ単体ではアクセシビリティ等で一定の意味はあるものの、積極的に使うほどの魅力はありませんでした。

すでに多数の方が実用を試みていて、参考になります。

Reactの仮想DOMと相性悪い?

モーダルとして使うときは open 属性は使えず、必ず showModal() APIを呼び出す必要があります。Reactで直感的に使いづらそうな印象はありますが、refをうまく使えばそれほど問題ありません。上記のリンク先記事でもわかりやすく解説されていました。

dialog要素によるモーダルの問題

便利そうに思えた showModal() ですが、実際使ってみるとChromeブラウザで問題が見つかりました。特に記載のない限りChrome 127 Stableと129 Canaryで確認しています。

大問題: cancelをpreventDefaultできないケースがある

showModal() で開いたダイアログは ESC キーで閉じます。これ自体は便利ですが、ケースによっては閉じてほしくないことも多々あります。例えば...

  • 重要な処理中のプログレス表示(「ファイルを開いています」など)
    • これは完了まで操作をブロックすることが主題であり、閉じられては困ります
    • もちろんUIデザインとしてブロックしないほうが良いという意見はあるにせよ、それはUIの方針であって、ブロックが必要なケースは確実にあります
  • 「閉じて良いですか?」など確認ダイアログを出したいとき
    • 重要な操作や入力を行うダイアログでは多用しそうです

このような場合の常套手段は、 cancel イベントを preventDefault() することです。多くのWebサイトでも紹介されている方法です。

// お試し用: https://jsbin.com/raqucihusu/1/edit?html,js,output

document.querySelector('#dialog').addEventListener('cancel', (e) => {
  e.preventDefault()
})

しかし、Chrome 126以上ではESCキーを長押ししたり連打すると、問答無用で閉じるようになってしまいました。 preventDefault() は無視されます。

おかしいだろう、と同僚がバグレポしてくれたのですが、意図的だと言うことで却下されています。実際のユースケースのことをどう考えているのかという質問は無視されました。

この仕様に正義はあるか?

例えばフルスクリーン表示はESC長押しで強制的に脱出できます。これは詐欺サイトなどでの悪用を防ぐためであり、セキュリティ上の理由なので完全に妥当かつ必要です。

他にも window.open()はタイトルバー非表示の指定をしても環境によって無視されたりしますが、これもセキュリティやOSのUI都合から必要な制限です。

つまり、Webサイトの表示領域を超えて、ブラウザのUIやOSのウィンドウシステムに影響を与える動作は、JavaScript APIの動作に一定の制約を与えてでも、強制的な脱出方法を用意することにセキュリティ上の意義があります。History APIも同様でしょう。

他にも、バッテリーを節約するために setTimeout() に制限をかけるというのも必要な制限と考えられます。

しかし、showModal() は単にHTMLの中でのモーダルであり、これらには該当しません。もともとHTML全体でpointer-events:noneなどとやっているのと何ら変わりません。モーダルを使うまでもなく、Webサイトの実装者はWebサイトのDOM全体を好きに変更できます。よって、アプリケーションの内部の動作に変な制限を差し込む合理性は存在しません。完全に不当な制限で、合理性がありません。

preventDefaultすると戻るボタンまで塞いでしまう

CloseWatcherというAPIがあります。

現在表示しているものを「閉じる」ときに、各OSで一般的な操作が異なります。例えば

  • PCではESCキー
  • Android(3ボタンナビゲーション)では「戻る(back)」ボタン
  • Android(ジェスチャーナビゲーション)では画面左端からスワイプ

と言った具合です。Webサイトの実装時にOSを判別して自然な動作を実装するのは骨が折れるので、それを抽象化してくれるのがこのAPIです。便利ですね。

これ自体は便利で大変良いのですが、showModal()はこれを使用したのと同じ挙動が強制されます。つまり、Android(3ボタンナビゲーション)で言えば戻るボタンで閉じるのが強制されます。つまり、ダイアログが閉じるまでは戻るボタンで前のページに戻れません

そして、先述のような諸事情により cancel イベントを preventDefault() すると、戻るボタンを押しても戻れないお行儀の悪いサイトが出来上がってしまいます。

戻るボタンでダイアログが閉じる動作、確かにありがたいケースも多いですが、ダイアログは忘れて戻って欲しいんだよということもあり、強制はしないで欲しい。

一度に複数のダイアログを開くと、1回のESCキーで同時に両方閉じる

こんなコードがあったとします。

// お試し用: https://jsbin.com/yaxenuyabu/edit?html,css,js,output

document.querySelector('#open-all').addEventListener('click', () => {
  document.querySelector('#dialog1').showModal()
  document.querySelector('#dialog2').showModal()
  document.querySelector('#dialog3').showModal()
})

この場合、ESCキーを押すと2つのダイアログが一度に閉じます

「一度に3つのダイアログを開く」ことではなく、「ユーザ操作を起点にダイアログを1個開く以外の呼び出し」が再現条件なので、状況次第では1個ずつ開いても発生します。

こんなことやる?

先述の通り cancelpreventDefault() できないことに気づいた人類は、アドウェアに着想を得て「ダイアログが閉じた瞬間にもう1回開き直せば良いのでは💡」という方法を発明するわけです。

実際 close イベントで開き直すのをやってみると、一見、意外とうまく動くように見えます。しかし、ユーザ操作ではなく close イベントを起点に開いたダイアログは、同じようにESCキーでまとめて閉じてしまうことがあります。結果として、モーダルの重なり順が想定と違う順序になるなど、ややこしいバグを誘発します。

それ以外にも、例えばUIの状態を保存しておいてまるっと復元するようなときは、ネストした入力フォームを復元するときに複数まとめて開きたいかもしれません。

仕様らしい

これも同僚にバグレポしてもらったのですが、仕様だとしてリジェクトされました。

CloseWatcherの挙動があるので、Androidで戻るボタンを連打しても閉じられないのがうざいというのは理解できないこともないですが、それならCloseWatcher強制的に接続するのをやめるとか、閉じるんじゃなくてブラウザのHistory backを強制すれば良いのに、なぜアプリの挙動を壊すようなことをするのか...

なにか我々には想像もつかない高度で高尚な理由でもあるのかと思ったら、最後に

It's possible this shows up more often in your automated tests, but in our measurements, this occurs on between 0.000000% and 0.000001% of page views in the wild, so it is likely not a problem for your users.

と書かれてずっこけました。

これだけ落とし穴だらけで使いづらい <dialog> だから全然普及していないのに、「使われていないから問題ない」は本末転倒でしょう。

どうすべきか

こんなケースでなら使っても良いかも?

モーダルダイアログを showModal() で実装するときは、以下をすべて満たすことを確認してからのほうが良さそうです。

  • ユーザがなにかタップしたときにだけ、必ず1個だけ表示すること。それ以外のタイミングでは表示せず、複数同時に開くパターンもないこと。
  • どんな場合でも一切の例外なく、JS上の状態がどうであろうと、ユーザがESCキーを押したら即座に閉じて良いこと。
  • 「閉じていいですか」などの確認は不要で、将来的に必要になる可能性も絶対にないこと
  • 見た目はいかにもOSのダイアログ風味であり、Androidの戻るボタン操作や画面エッジスワイプが「ページを戻る」ではなく「ダイアログを閉じる」であることが明確に伝わること。

このようなUIが存在することは間違いないですし、それがベストプラクティスと主張するのも良いと思います。しかしHTMLとJavaScriptはWebアプリケーションのプラットフォームであり、世の中にそれ以外のモーダルの存在は許さない、というのは使いやすいとは言えないですね。

仕様はどうなの?

執筆時点で、WhatWGのspecには「連打したらpreventDefaultは無視すべし」とも「無視してはならぬ」とも、明確な記述は見つけられませんでした。また、Firefox 131時点ではここで挙げたような挙動は発生しないようです。

しかし、基本的にこの手のAPIはChromeの実装を追認する形で仕様化されますし、最近わざわざ強引に変更されたこの挙動を元に戻すとは考えにくいです。担当者が変わらない限り、おそらくそのまま行くか、さらにFirefoxなども追従していくか、になるのではないでしょうか。

Googleが譲らない時点で仕様を盾に戦うのは分が悪く、そこまでして showModal() にする思い入れはありません。

みんなどうしてるんだろう?

Google Documentsなど複雑なWebアプリをいくつか調べてみましたが、モーダルに <dialog> を使っているものは見つけられませんでした。みんなことごとく <div> でした。

誰も使ってない疑惑。

じゃあどうする?

幸いなことに、TABキーフォーカスに反応しないという一番やりたい部分はinertプロパティで実現できるようになりました。iOSの対応バージョンも概ね同じくらいです。

DOMの構造に工夫は必要になりますが、モーダル以外を inert 指定して、それ以外は従来通り手動で実装するのが一番良いと思います。

そして、 HTMLDialogElement: showModal() は普及しないまま埋もれていくのだと思います。

結論: みんなで inert を使おう!


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。