JavaScript

HTML dialogでモーダルを作る|確認・入力・Esc・フォーカス復帰の実装

HTML dialogのshowModalとform method="dialog"で確認・入力モーダルを実装。returnValue、Escとキャンセル、外側クリック、入力検証、フォーカス復帰を動くコードで確認します。

この記事の目次
  1. showModalはモーダル、open属性とshowは非モーダル
  2. 確認と入力のHTMLを用意する
  3. 背景とサイズを整え、閉じたdialogをCSSで表示しない
  4. returnValueを初期化し、確定したときだけ内容を反映する
  5. 外側クリックは、入力を捨ててよい場面にだけ加える
  6. 動作を確かめてから、開閉アニメーションを足す

モーダルの箱を中央に置いたのに、Tabキーで背景のリンクへ移動できる。見た目をCSSで作っただけでは、背景の操作まで止まりません。

HTMLのdialog要素をshowModal()で開くと、モーダルとして表示され、同じ文書内の背景が操作対象から外れます。閉じるボタンや入力内容の反映は自分で用意し、Esc・確定・キャンセルを区別して扱います。確認用と入力用の二つを、同じページで動かしてみましょう。

showModalはモーダル、open属性とshowは非モーダル

開き方 表示・操作 選ぶ場面
dialog.showModal() top layerへ表示し、背景をinertにする 確認や入力を終えるまで背景操作を止める
dialog.show() 非モーダルで表示する 背景操作を続けるダイアログ
<dialog open> 最初から非モーダルで表示する 表示例には使えるが、モーダルを開く代用にはしない

inertは、その部分をクリックやフォーカス移動などの操作対象から外す状態です。大きなz-indexの指定とは役割が違います。iframe内で開いたdialogが、iframeの外側にある親ページまで止めるわけではありません。

補助メニューや追加説明を出したいだけなら、非モーダルのPopover APIも選べます。箱の配置だけを調整する場合はCSSの中央寄せを使い、モーダルの操作制限とは分けて考えます。

スポンサーリンク

確認と入力のHTMLを用意する

次のindex.html、style.css、main.jsを同じフォルダに保存し、index.htmlをブラウザで開きます。結果表示だけを変更するデモなので、アーカイブ処理やサーバーへの送信は行いません。

確認画面の最初のフォーカスはキャンセルへ、入力画面ではメールアドレスへ置きます。見出しとdialogをaria-labelledbyで結び付け、開いた画面の目的が分かるようにします。

<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>dialogの確認と入力</title>
<link rel="stylesheet" href="style.css">
<script src="main.js" defer></script>
<main>
  <h1>dialogの確認と入力</h1>
  <p>ここでは表示だけを変更します。通信や実データの削除は行いません。</p>
  <button id="open-confirm" type="button">アーカイブを確認</button>
  <button id="open-edit" type="button">メールアドレスを編集</button>
  <p id="result" role="status">まだ変更していません。</p>
  <p>メールアドレス:<span id="email-value">reader@example.com</span></p>
  <a href="#page-end" id="background-link">背景にあるリンク</a>
  <p id="page-end">ページの内容</p>
</main>

<dialog id="confirm-dialog" aria-labelledby="confirm-title" aria-describedby="confirm-description">
  <form method="dialog">
    <h2 id="confirm-title">この記事をアーカイブしますか?</h2>
    <p id="confirm-description">このデモでは結果の表示だけを変えます。</p>
    <div class="actions">
      <button value="cancel" autofocus>キャンセル</button>
      <button value="archive">アーカイブする</button>
    </div>
  </form>
</dialog>

<dialog id="edit-dialog" aria-labelledby="edit-title">
  <form method="dialog" id="edit-form">
    <h2 id="edit-title">メールアドレスを編集</h2>
    <label for="email">メールアドレス</label>
    <input id="email" name="email" type="email" required autofocus>
    <div class="actions">
      <button value="cancel" formnovalidate>キャンセル</button>
      <button value="save">この画面に反映</button>
    </div>
  </form>
</dialog>
</html>

form method="dialog"は、フォームの送信操作でdialogを閉じるための指定です。入力値をHTTPで送信する指定ではありません。どのボタンで閉じたかは、ボタンのvalueを通じてdialog.returnValueへ渡ります。

入力用のキャンセルにはformnovalidateを付けています。これがないと、requiredが付いたメール欄が空のとき、キャンセルの送信操作まで入力検証で止まります。確定側では検証を残し、メール形式として不正なら閉じません。dialogとフォームの関係はHTML Standardのdialogの仕様で確認できます。

背景とサイズを整え、閉じたdialogをCSSで表示しない

style.cssには次を保存します。閉じたdialogはブラウザが非表示にするので、dialog { display: flex; }のように無条件でdisplayを上書きしません。ボタンを並べたい場合は、内側の.actionsへflexを指定できます。

* { box-sizing: border-box; }
body { margin: 0; font: 1rem/1.7 system-ui, sans-serif; color: #172b40; background: #f2f6fa; }
main { max-width: 52rem; margin: auto; padding: 1.5rem; }
button, input { font: inherit; }
button { padding: .6rem .9rem; cursor: pointer; }
button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #1768bb; outline-offset: 3px; }
dialog { width: min(30rem, calc(100% - 2rem)); max-height: calc(100dvh - 2rem); overflow: auto; border: 1px solid #9eb1c4; border-radius: .75rem; padding: 1.5rem; }
dialog h2 { margin-top: 0; font-size: 1.25rem; }
dialog::backdrop { background: rgb(10 25 40 / 65%); }
.actions { display: flex; flex-wrap: wrap; gap: .75rem; margin-top: 1.25rem; }
label, input { display: block; }
input { width: 100%; min-width: 0; padding: .5rem; }
html.modal-open { overflow: hidden; }
#page-end { margin-top: 80vh; }

::backdropは、showModal()で開いたdialogの背後を装飾します。幅は画面より少し小さく、高さは表示領域を超えないようにし、内容が長い場合はdialog内でスクロールさせます。文字を拡大しても閉じる操作へたどり着けるか確認してください。

背景のスクロールを抑えるため、この例では開閉に合わせてhtmlへmodal-openクラスを付けます。背景をinertにすることと、スクロールの見え方を調整することは別です。特にスマートフォンの画面内キーボードやスクロール挙動は、対象端末でも確認します。

returnValueを初期化し、確定したときだけ内容を反映する

main.jsでは、開く直前にreturnValueを空へ戻し、closeイベントで結果を処理します。入力内容は、saveで閉じた場合だけ画面へ反映します。

const confirmDialog = document.querySelector('#confirm-dialog');
const editDialog = document.querySelector('#edit-dialog');
const confirmButton = document.querySelector('#open-confirm');
const editButton = document.querySelector('#open-edit');
const email = document.querySelector('#email');
const emailValue = document.querySelector('#email-value');
const result = document.querySelector('#result');

function openDialog(dialog) {
  if (dialog.open) return;
  dialog.returnValue = ''; // 前回の確定結果を持ち越さない
  dialog.showModal();
  document.documentElement.classList.add('modal-open');
}

confirmButton.addEventListener('click', () => openDialog(confirmDialog));
editButton.addEventListener('click', () => {
  document.querySelector('#edit-form').reset();
  email.value = emailValue.textContent;
  openDialog(editDialog);
});

for (const dialog of [confirmDialog, editDialog]) {
  dialog.addEventListener('cancel', () => {
    dialog.returnValue = 'cancel'; // Escは確定として扱わない
  });
  dialog.addEventListener('close', () => {
    document.documentElement.classList.remove('modal-open');
  });
}

confirmDialog.addEventListener('close', () => {
  result.textContent = confirmDialog.returnValue === 'archive'
    ? 'アーカイブを選択しました(デモ)。'
    : 'アーカイブをキャンセルしました。';
});
editDialog.addEventListener('close', () => {
  if (editDialog.returnValue === 'save') {
    emailValue.textContent = email.value; // HTMLとして挿入しない
    result.textContent = 'メールアドレスをこの画面に反映しました。';
  } else {
    result.textContent = '編集をキャンセルしました。';
  }
});

// 外側で押し始め、外側で離したときだけ閉じる。確認デモにのみ適用。
let startedOutside = false;
function isOutside(event) {
  const rect = confirmDialog.getBoundingClientRect();
  return event.target === confirmDialog && (
    event.clientX < rect.left || event.clientX > rect.right ||
    event.clientY < rect.top || event.clientY > rect.bottom
  );
}
confirmDialog.addEventListener('pointerdown', (event) => {
  startedOutside = event.isPrimary && event.button === 0 && isOutside(event);
});
confirmDialog.addEventListener('click', (event) => {
  const dismiss = startedOutside && event.detail > 0 && isOutside(event);
  startedOutside = false;
  if (dismiss) confirmDialog.close('cancel');
});
confirmDialog.addEventListener('pointercancel', () => { startedOutside = false; });
confirmDialog.addEventListener('close', () => { startedOutside = false; });

重要なのは、閉じたことだけで保存処理を実行しないことです。Esc、キャンセルボタン、外側クリックでも閉じるので、returnValueがsaveやarchiveである場合にだけ対応する処理へ進めます。

returnValueの説明にもあるとおり、Escで閉じるだけでは戻り値が新しく設定されません。前回のsaveが残っていると誤判定につながるため、開くときに初期化し、この例ではcancelイベントでもcancelを入れています。

Escによるcancelイベントの既定動作を止めなければ、dialogは閉じます。一方、close('cancel')はプログラムから閉じる操作で、cancelイベントを経由する前提にはできません。この例は閉じた後の共通処理をcloseへ置いています。使い分けはclose()の仕様で確認できます。

外側クリックは、入力を捨ててよい場面にだけ加える

このコードでは、外側クリックで閉じる処理を確認用にだけ付けています。編集用は、外側を押しても閉じません。入力途中の内容を、意図しないクリックで捨てにくくするためです。

外側の判定は、押し始めとクリック完了の両方を見ます。内側からドラッグして外側で離しただけなら閉じません。event.targetがdialogという条件だけでは、dialog自身の余白を押した場合も含まれるため、座標とgetBoundingClientRect()を合わせて判定しています。

新しいclosedby="any"属性を使うと、対応ブラウザでは外側操作による閉じ方を指定できます。ただし、基本のdialogと同じ対応範囲とは限りません。採用する場合はdialogの互換性表でclosedbyの対応も確認し、独自の外側クリック処理を重ねず、どちらを使うか選びます。

動作を確かめてから、開閉アニメーションを足す

まずはアニメーションなしで、次の操作を順に試します。フォーカスは通常、開く前にいた要素へ戻ります。このデモでは開くボタンがページに残るため、そのボタンへ戻ることを確認します。確定後にそのボタンを削除するUIでは、次に操作する見出しやボタンなどへ移す設計が必要です。

操作 確かめる結果
キーボードで確認画面を開く 最初はキャンセルにフォーカス。Tabで背景のリンクへ移らない
確定後、もう一度開いてEsc 前の確定結果を使わずキャンセルになる。開くボタンへ戻る
確認画面の内側から外側へドラッグ 閉じない。外側を押して離す操作なら閉じる
メール欄を空・不正な形式にして確定 入力検証で止まり、dialogが残る
空欄のままキャンセル 閉じる。表示済みのアドレスは変わらない
有効なアドレスで確定し、次の編集をEscで取消 確定した値だけ残り、途中の編集は反映されない

上のコードはChrome 152で、幅390px・1440pxの両方についてこれらの操作を確認しています。スクリーンリーダーの実際の読み上げと、Safari・Firefox・モバイル実機での操作は、利用対象に合わせて追加確認してください。dialog要素を使うだけで、内容・フォーカス先・ラベルを含む全体の使いやすさが完成するわけではありません。

基本動作が通ったら、次をstyle.cssの末尾へ任意で加えます。動きを減らす設定では適用しません。

/* 基本CSSの後へ任意で追加。非対応なら即時開閉のまま使う。 */
@media (prefers-reduced-motion: no-preference) {
  @supports (transition-behavior: allow-discrete) {
    dialog {
      opacity: 0;
      transform: translateY(.5rem);
      transition: opacity .18s, transform .18s,
        display .18s allow-discrete, overlay .18s allow-discrete;
    }
    dialog[open] { opacity: 1; transform: translateY(0); }
    @starting-style {
      dialog[open] { opacity: 0; transform: translateY(.5rem); }
    }
  }
}

@starting-styleは、表示開始時のスタイルを与えます。displayとoverlayには離散的な切り替えを扱う指定を加え、閉じる途中も描画を維持します。仕組みは@starting-styleの説明で確認できます。対応していない環境では、即時の開閉でも操作できる状態を残します。

実際の保存をAPIへつなぐ場合は、method=”dialog”で先に閉じてしまう形から、通信完了を待つ設計へ変える必要があります。保存ボタンの連打、通信失敗、エラー表示、成功後に閉じるタイミングを決めてから組み込みます。まずはこのデモで、「開く・入力する・確定する・取り消す」の結果が混ざらないことを確かめてください。

スポンサーリンク