はじめに
最近の Web 開発では、モーダル UI のマークアップに <dialog> 要素を利用することが主流となってきました。
- 最小限の JavaScript でモーダルを実現可能
- フォーカス制御や Top layer などのアクセシビリティへの配慮が標準実装されている
- Esc キーで閉じる機能が標準搭載(本記事のテーマ)
- Chrome, Firefox, Safari, Edge など、主要なモダンブラウザでサポートされている
この記事では、<dialog> 要素に標準搭載されている Esc キーで閉じる機能が、とある条件下で制御できない問題についてまとめます。
結論
最初に結論まとめです:
-
<dialog>要素の Esc キーによる閉じる操作はcancelイベントを発火する - Chromium では、Esc キーを連打すると2回目以降の
cancelイベントはevent.cancelableがfalseであるためpreventDefault()できない - 完全にこの挙動をブロックする完璧な方法は現時点では存在しない
<dialog> 要素を使った基本的なモーダルコンポーネントの実装
まず、React を使った典型的な <dialog> 要素の実装例を見てみましょう(コアな部分のみ抜粋):
type Props = {
open: boolean;
onClose: () => void;
children: React.ReactNode;
}
export const Modal: React.FC<Props> = ({ open, onClose, children }) => {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const element = ref.current;
open ? element.showModal() : element.close();
}, [open]);
return (
<dialog ref={ref} onClose={onClose}>
<button onClick={onClose}>
x
</button>
{children}
</dialog>
)
}
利用側では以下のように open 状態を管理します:
import { Modal } from '@components/Modal';
export const PageComponent = () => {
const [open, setOpen] = useState(true);
return (
<Modal open={open} onClose={() => setOpen(false)}>
{/* モーダルの中身 */}
</Modal>
)
}
<dialog> 要素の open 属性に直接 open state を渡すことは推奨されないため、open state を参照して showModal() と close() メソッドを制御する形式にします。
発見した問題点
プロジェクトで「モーダルを閉じる際に『本当に閉じますか?』という確認ダイアログを表示してほしい」という要件があり、対応するため以下のような実装をしました:
export const PageComponent = () => {
const [open, setOpen] = useState(true);
return (
<Modal open={open} onClose={() => {
const confirm = window.confirm('本当に閉じますか?')
if (!confirm) return; // 確認ダイアログでキャンセルした場合は閉じない
setOpen(false)
}}>
{/* モーダルの中身 */}
</Modal>
)
}
onClose ハンドラー内で window.confirm を呼び出し、その応答に応じて setOpen(false) を実行することで要件を満たしています。
しかし動作確認中に次の問題が判明しました:
「Esc キーを押すと確認ダイアログは表示されるものの、モーダルがそのまま閉じてしまう」
閉じるボタンをクリックした場合は想定通り確認ダイアログが表示され、ユーザーの選択に応じた挙動となります。しかし、Esc キーを押した場合は確認プロセスをバイパスしてしまうため、要件を満たしていません。
原因の究明
調査の結果、Esc キー押下時には cancel イベントが発火し、モーダルが閉じられた後に close イベントが発火する ことがわかりました。
HTML 仕様書では、この動作を以下のように定義しています:
dialog 要素に対する close request(閉じる要求)が送信されると、まず cancel イベントが発火し、そのイベントが preventDefault() によってキャンセルされない場合、dialog 要素を閉じる処理が行われる
技術的な動作フローを HTML 仕様に基づいてより詳細に説明すると以下のようになります:
-
showModal()メソッドによって表示された<dialog>要素は、Esc キーによる閉じる操作が可能な状態になる - ユーザーが Esc キーを押すと、ブラウザは内部的に
requestClose()メソッドを呼び出す -
requestClose()メソッドは以下の順序で処理を行う:- まず
cancelイベントを発火する(この時点でevent.cancelableはtrue) -
cancelイベントがpreventDefault()によって阻止されなかった場合:- dialog 要素の
open属性を削除する -
closeイベントを発火させる
- dialog 要素の
- まず
-
closeイベントが発火する時点で、dialog は既に閉じられた状態になっている
現在の Modal コンポーネントの実装では:
- 外部で管理している
openstate によってshowModal()とclose()メソッドを制御している -
closeイベントハンドラー内で条件付きでsetOpen(false)を実行している - しかし、
cancelイベント発火後に dialog 要素は自動的に閉じられ、その後closeイベントが発火するため、closeイベントハンドラー内での制御では既に遅い状態になっている
preventDefault() を使った対応
対応は比較的単純です。cancel イベントの発火時に preventDefault() を適用して閉じる動作を阻止します:
type Props = {
open: boolean;
onClose: () => void;
onCancel: (e: Event) => void; // cancel イベント用のハンドラーを追加
children: React.ReactNode;
}
export const Modal: React.FC<Props> = ({ open, onClose, onCancel, children }) => {
useEffect(() => {
open ? element.showModal() : element.close();
}, [open]);
return (
<dialog onClose={onClose} onCancel={onCancel}>
<button onClick={onClose}>x</button>
{children}
</dialog>
)
}
利用側では、両方のイベントハンドラーに確認ロジックを実装します:
import { Modal } from '@components/Modal';
export const PageComponent = () => {
const [open, setOpen] = useState(true);
return (
<Modal
open={open}
onClose={() => {
// cancel イベント後に close イベントが発火するためこのままでは連続して window.confirm が呼ばれる
// 実際には別途条件を管理しておく必要があるが、ここでは省略
const confirm = window.confirm('本当に閉じますか?')
if (!confirm) return;
setOpen(false)
}}
onCancel={(e) => {
e.preventDefault() // Esc キーによる自動閉じを防止
const confirm = window.confirm('本当に閉じますか?')
if (!confirm) return;
setOpen(false)
}}
>
{/* モーダルの中身 */}
</Modal>
)
}
一見解決したように見えますが、ここで新たな問題が発生しました。
Chromium の仕様による予期せぬバグ
意気揚々と動作確認していると、意図せぬ挙動が確認されました。
Esc キーを連打すると 2 回目以降のキー押下でモーダルが閉じてしまう のです。
この問題は Chromium の issue トラッカーにも報告されており、詳細な議論が行われています:
issue の内容を簡単に要約すると以下のようになります:
-
当初の問題:
- Chromium において、Esc キーを1回押した後すぐに2回目を押すと、
cancelイベントが発火せず直接closeイベントが発火する - Firefox などの他のブラウザでは、Esc キーを押すたびに常に
cancelイベントが発火する(期待される動作)
- Chromium において、Esc キーを1回押した後すぐに2回目を押すと、
-
Google 開発チームの見解:
- 当初 Google の開発者はこの挙動を「意図的な動作」として説明
- 2024年5月のアップデートで一部改善され、「2回目の
cancelイベントは発火するようになったが、preventDefault()を呼んでも効果がない」状態に修正
-
最新の状況(2025年4月時点):
- 複数のユーザーから「依然として問題が解決していない」「この問題のために
<dialog>要素が使用できない」という報告がされている - 技術的には、2回目以降の
cancelイベントはevent.cancelable: falseという状態で発火するため、preventDefault()が効かない状況が続いている
- 複数のユーザーから「依然として問題が解決していない」「この問題のために
ということで 2025年4月現在でも、2回目以降の cancel イベントは event.cancelable: false で発火するため、preventDefault() を実行しても効果がありません。
暫定的な対応策
現時点では完全な解決策は存在しませんが、実用的な対応策として以下の方法が考えられます:
onCancel={(e) => {
// cancelable プロパティをチェックし false ならそのまま閉じる
if (!e.cancelable) {
setOpen(false);
return;
}
e.preventDefault();
const confirm = window.confirm('本当に閉じますか?')
if (!confirm) return;
setOpen(false);
}}
-
event.cancelableプロパティを確認し、trueの場合のみpreventDefault()を実行する -
event.cancelable: falseの場合(Esc キー連打時の2回目以降)は、やむを得ず閉じる処理を行う
Esc キー連打時の挙動を完全に制御することはできないため、cancelable: false の場合はそのまま閉じる処理を行うしかありません。
結論
-
<dialog>要素の Esc キーによる閉じる操作はcancelイベントを発火する - Chromium では、Esc キーを連打すると2回目以降の
cancelイベントはevent.cancelableがfalseであるためpreventDefault()できない - 完全にこの挙動をブロックする完璧な方法は現時点では存在しない
この制限を理解した上で、以下のような設計上の検討が必要です:
-
重要なデータを扱うコンテンツには
<dialog>要素を使用しない:
Esc キーによる意図しない閉じる操作を完全に防げないため、データ損失が深刻な問題となるような重要な情報やフォームは、別の方法(例:ページ内のフォーム)で実装することを検討する -
より堅牢な UI パターンを採用する:
重要な操作が必要な場合は、モーダルダイアログではなく専用の画面遷移を設計し、ブラウザバックや画面更新などの操作でも安全性を確保する