Claude Codeで自作モーダルをdialog要素に置き換え、フォーカスをPlaywrightで検証する
div製のモーダルをdialog要素とshowModal()に置き換える作業をClaude Codeに任せる手順です。置換前にPlaywrightで挙動を固定し、開いた直後のフォーカスとEscで閉じた後の戻り先を検証します。
divとJavaScriptで組んだ自作モーダルは、フォーカスの移動、Escでの閉じ方、背後の操作の遮断を自前で実装しています。HTMLの <dialog> 要素と showModal() に置き換えると、この3つの大半をブラウザが受け持ちます。Claude Codeには、置換の下ごしらえとして「どこに自作モーダルがあるか」の洗い出しと、置換前後でフォーカスの挙動が変わらないことのテストを任せられます。
進め方は4段です。洗い出し、置換前の挙動をPlaywrightで固定、<dialog> への置換、同じテストで検証。Tab順の監査そのものはキーボード操作の監査手順にあり、ここではモーダルの置換とその検証に絞ります。
どこが自作モーダルかを先に洗い出す
置換の対象は、見た目がモーダルで、中身が <dialog> ではない要素です。ソース検索で拾えるのは、次のような手がかりです。
# role="dialog" や aria-modal を手書きしている箇所
rg -n "role=[\"']dialog|aria-modal" src
# 「modal」「overlay」「backdrop」を名前に持つクラスとコンポーネント
rg -n -i "class(Name)?=[\"'{][^\"'}]*(modal|overlay|backdrop)" src
rg -n -i "(function|const)\s+\w*(Modal|Overlay|Popup)\w*" src
# フォーカス管理を自前で書いている箇所
rg -n "\.focus\(\)|focusin|keydown.*(Escape|Esc)|key === [\"']Escape" srcこれらはヒューリスティックで、ヒットが全部モーダルとは限りません。ドロップダウンやトーストも拾います。そこでClaudeには、ヒットを次の列で表にさせ、置換するもの・しないものを人間が選べる状態にします。
上の検索を実行し、モーダルとして使われているコンポーネントを表にして。
列は コンポーネント名 / ファイル / 開く契機(どのボタンか) / 閉じる手段
(×ボタン・Esc・背景クリック・フォーム送信) / 自前で書いているフォーカス処理 /
重ねて開かれる可能性。修正はまだしないこと。「重ねて開かれる可能性」の列は後で効きます。モーダルの上にモーダルを開く画面は、Escの挙動の検証項目が増えるためです。
置換の前に、今の挙動をPlaywrightで固定する
置換でいちばん怖いのは、見た目は同じなのにキーボードの挙動だけが変わることです。Claude Codeの公式ガイドでは、リファクタリングは小さく検証できる単位で進めること、変更後にテストを実行して確かめることが勧められています。モーダルでは、その「テスト」を先に作ります。
置換前のコードに対して、現状の挙動をそのまま記録するテストを書かせます。落ちる項目があっても、直さずに記録します。
src/components/ConfirmModal を使う画面(/settings の「アカウントを削除」)に対して、
Playwright のテストを書いて。まず現状の挙動を確認するだけで、実装は変えないこと。
確認すること:
1. ボタンをキーボードで押してモーダルを開いた直後、どの要素にフォーカスがあるか
2. Tab を10回押したとき、フォーカスが背後のページに出ないか
3. Esc で閉じたあと、フォーカスがどこにあるか
結果は pass/fail でなく、観測した事実を表にして報告して。3項目目は「期待する値」を先に決めず、観測として取るのがコツです。自作モーダルの多くは、閉じたあとにフォーカスが body に落ちるか、何も触っていない状態に戻ります。観測値を残しておけば、置換後に何が改善したかを数字でなく事実で言えます。
dialog要素とshowModal()に置き換える
置換前後の形を並べます。ここでは、確認用の削除モーダルを例にします。
div製モーダルをdialogに置き換える
置換前(div + 自前のフォーカス処理)
背景のオーバーレイ、role="dialog"、keydown のEsc監視、開閉時の focus() 呼び出しを、すべてコンポーネント側で持っています。
置換後(dialog + showModal)
<dialog> を1つ置き、showModal() と close() を呼ぶだけです。背景の遮断、Escでの閉じ、背景の ::backdrop はブラウザ側にあります。
<button id="open-delete">アカウントを削除</button>
<dialog id="delete-dialog" aria-labelledby="delete-title">
<form method="dialog">
<h2 id="delete-title">アカウントを削除しますか</h2>
<p>この操作は取り消せません。</p>
<button value="cancel" autofocus>キャンセル</button>
<button value="delete">削除する</button>
</form>
</dialog>const dialog = document.querySelector("#delete-dialog");
document.querySelector("#open-delete").addEventListener("click", () => {
dialog.showModal();
});
dialog.addEventListener("close", () => {
if (dialog.returnValue === "delete") deleteAccount();
});この形は、MDNの <dialog> のページに載る仕様に沿っています。ポイントは5つです。
showModal()で開いた<dialog>は、dialog以外がinert(操作を受け付けない状態)になります。背景の遮断をaria-hiddenや自前のオーバーレイで書く必要はありません。- 開いた直後のフォーカスは、ダイアログ内で最初にフォーカスできる要素に当たります。置きたい位置が別にあれば、その要素に
autofocusを付けます。上の例では、取り消せない操作なので「キャンセル」に付けています。 - ページは、迷ったらダイアログ自体を初期フォーカスにする方法も示しています。ただし
tabindexを<dialog>に付けてはいけません。ダイアログは対話型の要素ではなく、フォーカスを受けないためです。 <form method="dialog">の中のボタンを押すと、ダイアログが閉じ、押したボタンのvalueがreturnValueに入ります。送信はされません。上のcloseイベントは、この値で「削除」か「取り消し」かを分けています。showModal()で開いたダイアログは、Escで閉じられます。closedby属性が無効な値のときも、showModal()で開いたならcloserequestとして扱われ、Escと明示的な閉じる操作で閉じます。
古いコードに残る keydown でEscを見る処理、オーバーレイ要素、aria-modal の手書きは、置換後に削除対象です。Claudeには「削除してよいフォーカス処理」を挙げさせ、人間が残すものを選びます。
置換を頼むときの指示
置換は、1コンポーネントずつ頼みます。複数を一度に変えると、フォーカスのずれがどの変更で起きたか切り分けにくくなります。
ConfirmModal を <dialog> + showModal() に置き換えて。
- 開閉は showModal() / close() で行い、自前の keydown(Esc) と focus() 呼び出しは削除
- 初期フォーカスは「キャンセル」ボタンに autofocus で指定
- 閉じたときの結果は form method="dialog" と returnValue で受ける
- <dialog> に tabindex は付けない
- 見た目(CSS)は ::backdrop を使い、既存のオーバーレイ要素は消す
終わったら、先ほどの Playwright テストを再実行して、観測値の変化を表で示して。Playwrightでフォーカス位置を検証する
置換後は、観測だったテストを、期待を持つテストに書き換えます。Playwrightの toBeFocused() は、ロケーターが指す要素にフォーカスがあるかを確かめるアサーションです。キー入力は page.keyboard.press() で行え、Escape や Tab はキー名として使えます。
import { test, expect } from "@playwright/test";
test("確認モーダルの出入りでフォーカスが期待位置に来る", async ({ page }) => {
await page.goto("http://localhost:3000/settings");
const trigger = page.getByRole("button", { name: "アカウントを削除" });
await trigger.focus();
await page.keyboard.press("Enter");
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// 開いた直後: autofocus を付けた「キャンセル」にフォーカスがある
await expect(dialog.getByRole("button", { name: "キャンセル" })).toBeFocused();
// Tab を繰り返しても、背後のボタンにフォーカスが出ない
for (let i = 0; i < 6; i++) {
await page.keyboard.press("Tab");
await expect(trigger).not.toBeFocused();
}
// Esc で閉じたあと、開いた元のボタンにフォーカスが戻る
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
});3つの expect が、置換の狙いに1対1で対応します。
| 確かめたいこと | 使う検証 | 外れたときの原因の候補 |
|---|---|---|
| 開いた直後の位置 | 使う検証期待するボタンに toBeFocused() | 外れたときの原因の候補autofocus の付け忘れ、動的に描画する中身が遅れて入る |
| 背後へ漏れない | 使う検証Tabの繰り返しで not.toBeFocused() | 外れたときの原因の候補showModal() でなく show() や open 属性で開いている |
| 閉じた後の戻り先 | 使う検証Escの後に trigger が toBeFocused() | 外れたときの原因の候補閉じる前に開いた元の要素が消える、閉じ方の自前実装が残る |
2行目の落ちる原因は、見落としやすい点です。show() や open 属性で開いた <dialog> は非モーダルで、背後はそのまま操作できます。MDNも、open 属性での開閉は推奨しないとしています。
閉じた後の戻り先は、仕様の記述でなくテストで確かめる
開く側のフォーカスについては、MDNの <dialog> のページが「最初のフォーカス可能な要素に当たる」と説明しています。一方、閉じた後にフォーカスがどこへ戻るかについては、このページに記述がありません。ここは「ブラウザが戻してくれるはず」と書かずに、上のテストの最後の2行で確かめるのが確実です。
テストが落ちたときの直し方は、閉じるイベントで開いた元の要素に戻す処理を足すことです。
let opener = null;
document.querySelector("#open-delete").addEventListener("click", (e) => {
opener = e.currentTarget;
dialog.showModal();
});
dialog.addEventListener("close", () => {
opener?.focus();
});この処理は、落ちたときの保険です。テストが通るなら不要なので、足す前に必ず該当のアサーションだけを単独で再実行します。保険を先に入れると、後から本当の原因を見つけにくくなります。
Playwrightのテスト失敗の読み方はtraceを使った失敗の切り分けに、PRで落とすCIへの組み込みはaxeによるアクセシビリティ検査の記事にまとめています。
置換でつまずきやすい4つの挙動
<dialog> に変えたあとに、置換前には無かった挙動で戸惑う場面があります。MDNのページに書かれているものから挙げます。
必須入力があると閉じるボタンも止まる
<form method="dialog"> の中に required の入力があると、値を入れるまでダイアログを閉じられません。取り消しのボタンには formnovalidate を付けるか、close() を呼ぶ形にします。上のキャンセルのボタンが同じフォームにあるときは、特に注意です。
Escはcancelイベントを先に発火する
Escまたは requestClose() で閉じようとすると、cancel イベントが先に起き、コードで閉じるのを止める余地があります。「未保存の変更があるのでEscで閉じさせない」といった要件は、ここで preventDefault() します。ただし、閉じられないことが利用者に伝わる表示も要ります。
重ねて開くとEscは最後の1枚だけ閉じる
MDNは、複数のモーダルが開いているときのEscは最後に表示したものだけを閉じるべきで、<dialog> ならその動作がブラウザで提供されるとしています。前述の洗い出しで「重ねて開かれる」と書いた画面には、Escを2回押す検証を足します。
CSSでdisplayを上書きするとモーダルでなくなる
MDNは、<dialog> の既定の display を変えて表示した場合は、showModal() で開いたものと違い aria-modal がfalseとして公開されるとしています。アニメーションや配置のために dialog { display: flex } を書く前に、:modal や :open 擬似クラスで状態を分けることが、ページのCSSの節に示されています。
背景クリックで閉じる要件があるとき
自作モーダルは、背景のクリックで閉じる実装が多くあります。<dialog> では closedby 属性がこれを受け持ちます。値は3つで、挙動は次のとおりです。
| 値 | 閉じられる手段 |
|---|---|
any | 閉じられる手段閉じるボタン、Esc、ダイアログの外側のクリック(light dismiss) |
closerequest | 閉じられる手段閉じるボタンとEsc。showModal() で開いたときの既定 |
none | 閉じられる手段開発者が用意した手段だけ。Escでは閉じない |
確認ダイアログのように「うっかり閉じさせたくない」ものは closedby="none" にして、明示的なボタンだけで閉じる設計にできます。ただしMDNは、すべてのダイアログに利用者が確実に閉じられる手段を用意し、物理キーボードの無い端末でも動くようにすることを求めています。none にするなら、画面上に閉じるボタンが必ず見えていることを、テストの getByRole("button") で確かめます。
背景クリックのテストは、Escと同じ型で書けます。ダイアログの外側を page.mouse.click() で押し、閉じたあとに trigger へフォーカスが戻るかを、同じ toBeFocused() で見ます。マウスで閉じた場合とキーボードで閉じた場合で、戻り先が揃うかを見るのが目的です。閉じ方ごとに別の実装が残っていると、ここで差が出ます。
置換しないほうがよいモーダル
すべての自作ポップアップを showModal() に寄せる必要はありません。MDNは、<dialog> を非モーダルで使う方法として、show() と、popover 属性を付けて popovertarget で開く方法を挙げています。どちらも背後の操作は遮断しませんが、閉じ方が違います。show() で開いた非モーダルは、Escでは既定で閉じません。popover 属性の既定値(auto)で開いたものは、Escと外側のクリックで閉じます。外側のクリックで閉じさせたくなければ popover="manual" にします。
- チャット窓やヘルプのパネルのように、背後のページも触れてよいもの:
show()の非モーダル<dialog>、またはpopover - 確認や入力の完了を待つもの、背後を触らせたくないもの:
showModal()
洗い出しの表に「背後は触れてよいか」の列を足し、モーダルでないものを置換の対象から外します。外したものは、無理に showModal() にすると、背後が操作できなくなって画面が詰まります。
CLAUDE.mdに残しておく規約
置換が終わったら、次の新規モーダルも <dialog> で作られるよう、規約を1か所に残します。Claude Codeはプロジェクトの CLAUDE.md を読むため、コンポーネント作成時の前提にできます。
## モーダル
- 新規のモーダルは <dialog> と showModal() で作る。div + role="dialog" の自作は不可
- <dialog> に tabindex を付けない。初期フォーカスは autofocus で指定する
- 開閉のフォーカスは e2e/modal-focus.spec.ts のパターンで検証する
(開いた直後 / Tab の繰り返し / Esc で閉じた後の戻り先)テストファイル名まで書いておくと、Claudeは新しいモーダルを足すとき、同じ形のテストを一緒に作りやすくなります。
まとめ
自作モーダルの置換は、<dialog> に書き換える作業より、書き換えの前後で同じテストを走らせることのほうが重要です。観測のテストを先に作れば、開いた直後の位置、背後への漏れ、閉じた後の戻り先が、置換でどう変わったかを事実で示せます。
とくに戻り先は、MDNに記述が無いぶん、テストを省くと確認の機会そのものが無くなります。1コンポーネントずつ置換し、そのたびにテストを通す進め方が、フォーカスのずれの原因を狭くします。Playwrightを定期実行する構成はPlaywright E2Eテストをhookで自動実行する記事が扱っています。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。