Claude Media
Claude Codeで自作モーダルをdialog要素に置き換え、フォーカスをPlaywrightで検証する

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に置き換える

BEFORE

置換前(div + 自前のフォーカス処理)

背景のオーバーレイ、role="dialog"、keydown のEsc監視、開閉時の focus() 呼び出しを、すべてコンポーネント側で持っています。

AFTER

置換後(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で自動実行する記事が扱っています。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/common-workflows
  2. developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog
  3. playwright.dev/docs/api/class-locatorassertions
  4. playwright.dev/docs/api/class-keyboard
この記事を共有:XはてブLinkedIn