# キーボードで閉じられないポップアップ

Source: https://easeweb.dev/learn/closing-pop-ups
Topics: ダイアログとオーバーレイ > ダイアログと同意バナーを、いつでもキーボードで閉じられる (WCAG 2.1.2)
The fix: ポップアップを、キーボードでも閉じられるようにします。HTML標準のdialog要素にしてshowModal()で開けば、Escapeキーで閉じられます。独自のポップアップを残す場合は、フォーカスを留める処理はそのままに、Escapeキーで閉じる処理を加えます。どちらの場合も、×は名前のあるボタンにします。
Test with: keyboard, screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap.html https://www.w3.org/WAI/WCAG22/Techniques/general/G21 https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog

よくある問題を再現した学習用のサンプルです。実在のクライアントの診断結果ではありません。コードは出発点となる実装例のため、実際の製品でも確認してください。ここでは支援技術でのテスト結果は報告していません。

## 場面

陶器の店Juniper Ceramicsのサイトでは、ページを開いて5秒たつと、ポップアップが現れます。「初回のご注文が10%オフ」という見出し、メールアドレスの欄、「登録する」ボタン、右上の×です。マウスの利用者は×をクリックして、そのまま買い物を続けます。キーボードの利用者の場合、フォーカスはすでにメールアドレスの欄にあります。Tabキーで「登録する」へ移り、次のTabで欄に戻ります。Shift + Tabでも、逆向きに同じ二つを回るだけです。×には一度も届かず、Escapeキーも効きません。ポップアップからフォーカスを出せるのは、メールアドレスを入力し、Enterキーでフォームを送信したときだけです。アドレスを渡したくない人は、背後のページへ進めません。

## 確認できる問題

達成基準2.1.2は、キーボードでフォーカスを移せる部分からは、キーボードだけでフォーカスを外へ移せることを求めています。通常のキー以外が必要な場合は、その方法をページで知らせる必要があります。ダイアログで通常使うキーはEscapeキーです。解説書（Understanding）はEscapeキーをよく使われる標準的な抜け出し方として挙げ、ARIA Authoring Practicesもすべてのモーダルダイアログで、Escapeキーに閉じる操作を割り当てています。

ポップアップのスクリプトはTabキーを内側に留めています。モーダルとしては正しい動作で、オーバーレイの背後のページへフォーカスが迷い出るのを防いでいます。ただ、フォーカスを留めるなら抜け出す方法も必要なのに、このポップアップにはキーボードで使える出口がありません。×はクリック処理を付けた `span` で、`span` はフォーカスを受け取らないため、Tabキーは×に止まらずに入力欄と「登録する」を回ります。Escapeキーを受け取る処理もありません。残る出口は、登録することだけです。

2.1.2は、ページ上のすべての内容に適用される四つの達成基準の一つで、サイトが適合の根拠にしていない部分も対象になります（WCAGの適合要件にある「非干渉」）。マーケティング用のスクリプトが追加したポップアップも、コードの出どころにかかわらず、ページの一部です。

## 利用者への影響

マウスなら1回のクリックで閉じられるため、ポップアップの確認もたいていそれで済まされます。×はずっと画面に出ているので、画面を見ながらキーボードで操作する人には、出口が見えているのに届きません。

## 自動チェックとAIの限界

いくつかのコントロールを回り続けるTabキーは、ほとんどの場所ではキーボードトラップですが、開いたモーダルの中では本来の動作です。そのため、Tabキーでページを移動するだけのチェックでは、見逃すしかありません。ダイアログに出口があるかどうかは、Escapeキーを押し、閉じるボタンを調べて初めて分かります。また、多くのポップアップは、ページを開いて数秒後や、ポインターがアドレスバーへ向かったときに開くため、手早い自動チェックが終わった後に現れます。マーケティングツールの管理画面で設定され、サイト自身のコードと一緒に確認されないことも少なくありません。ポップアップの実装をAIアシスタントに頼むと、このパターンを書くことがあります。フォーカスを留める処理があるため、丁寧に作られたように見えるからです。

## 修正前：×にTabキーが届かないポップアップ

```html
<div class="popup-overlay" hidden>
  <div class="popup" role="dialog" aria-modal="true" aria-labelledby="popup-title">
    <span class="popup-close">×</span>
    <h2 id="popup-title">初回のご注文が10%オフ</h2>
    <p>新作やセールのお知らせをお届けします。</p>
    <form class="popup-form">
      <label for="popup-email">メールアドレス</label>
      <input id="popup-email" name="email" type="email" autocomplete="email" required>
      <button>登録する</button>
    </form>
  </div>
</div>
```

```js
const overlay = document.querySelector('.popup-overlay');
const popup = overlay.querySelector('.popup');
const email = document.getElementById('popup-email');

function openPopup() {
  overlay.hidden = false;
  email.focus();
}
function closePopup() {
  overlay.hidden = true;
}

// ページを開いて5秒後にポップアップを開く。
setTimeout(openPopup, 5000);
popup.querySelector('.popup-close').addEventListener('click', closePopup);
popup.querySelector('.popup-form').addEventListener('submit', (event) => {
  event.preventDefault();
  subscribe(email.value);
  closePopup();
});
// 開いている間、Tabキーをポップアップの内側に留める。
popup.addEventListener('keydown', (event) => {
  if (event.key !== 'Tab') return;
  const items = popup.querySelectorAll('button, input');
  const first = items[0];
  const last = items[items.length - 1];
  if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault();
    first.focus();
  }
  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault();
    last.focus();
  }
});
```

フォーカスを留める処理は、「登録する」からのTabを入力欄へ戻します。`span` はこの処理が集める一覧にも、Tabキーの順序にも入っていないため、×はポインターでしか使えません。Escapeキーを扱う処理はなく、ポップアップを閉じるコードは、×のクリックのほかにはフォームの送信だけです。

## 修正後：HTML標準のdialog要素

```html
<dialog class="popup" aria-labelledby="popup-title">
  <button type="button" class="popup-close" aria-label="閉じる">×</button>
  <h2 id="popup-title">初回のご注文が10%オフ</h2>
  <p>新作やセールのお知らせをお届けします。</p>
  <form class="popup-form">
    <label for="popup-email">メールアドレス</label>
    <input id="popup-email" name="email" type="email" autocomplete="email" required>
    <button>登録する</button>
  </form>
</dialog>
```

```js
const popup = document.querySelector('.popup');
const email = document.getElementById('popup-email');

// showModal()は、背後のページを操作できなくし、Escapeキーで閉じ、閉じたらフォーカスを戻す。
function openPopup() {
  popup.showModal();
  email.focus();
}
function closePopup() {
  popup.close();
}

// ページを開いて5秒後にポップアップを開く。
setTimeout(openPopup, 5000);
popup.querySelector('.popup-close').addEventListener('click', closePopup);
popup.querySelector('.popup-form').addEventListener('submit', (event) => {
  event.preventDefault();
  subscribe(email.value);
  closePopup();
});
// 最後のコントロールからのTabを、ブラウザーへ出さずに最初のコントロールへ戻す。
popup.addEventListener('keydown', (event) => {
  if (event.key !== 'Tab') return;
  const items = popup.querySelectorAll('button, input');
  const first = items[0];
  const last = items[items.length - 1];
  if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault();
    first.focus();
  }
  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault();
    last.focus();
  }
});
```

`showModal()` は背後のページを操作できない状態にするため、Tabキーが背後のページに届くことはありません。ただ、ブラウザーはそのままだと、ダイアログの最後のコントロールからのTabを、アドレスバーなどブラウザー自身のコントロールへ出してしまいます。そこで短い処理を加えて×へ戻し、ARIA Authoring Practicesのモーダルダイアログのパターンと同じように、フォーカスをポップアップの中に留めます。Escapeキーで閉じ、閉じるとフォーカスは、ポップアップが開いたときにフォーカスのあった要素へ戻ります。×は `button` になったので、Tabキーで届き、EnterキーとSpaceキーで押せます。`aria-label` で付けた「閉じる」という名前が、記号の代わりにスクリーンリーダーで読まれます。背景を暗くする表示はブラウザーが `::backdrop` 擬似要素で描くため、オーバーレイの `div` は不要になります。

## 修正後：同じポップアップに、閉じるボタンとEscapeキーを加える

```html
<div class="popup-overlay" hidden>
  <div class="popup" role="dialog" aria-modal="true" aria-labelledby="popup-title">
    <button type="button" class="popup-close" aria-label="閉じる">×</button>
    <h2 id="popup-title">初回のご注文が10%オフ</h2>
    <p>新作やセールのお知らせをお届けします。</p>
    <form class="popup-form">
      <label for="popup-email">メールアドレス</label>
      <input id="popup-email" name="email" type="email" autocomplete="email" required>
      <button>登録する</button>
    </form>
  </div>
</div>
```

```js
const overlay = document.querySelector('.popup-overlay');
const popup = overlay.querySelector('.popup');
const email = document.getElementById('popup-email');
let previous;

function openPopup() {
  previous = document.activeElement;
  overlay.hidden = false;
  email.focus();
}
function closePopup() {
  overlay.hidden = true;
  previous?.focus();
}

// ページを開いて5秒後にポップアップを開く。
setTimeout(openPopup, 5000);
popup.querySelector('.popup-close').addEventListener('click', closePopup);
popup.querySelector('.popup-form').addEventListener('submit', (event) => {
  event.preventDefault();
  subscribe(email.value);
  closePopup();
});
// 開いている間、Tabキーをポップアップの内側に留める。
popup.addEventListener('keydown', (event) => {
  if (event.key !== 'Tab') return;
  const items = popup.querySelectorAll('button, input');
  const first = items[0];
  const last = items[items.length - 1];
  if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault();
    first.focus();
  }
  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault();
    last.focus();
  }
});
// Escapeキーでポップアップを閉じる。IMEは変換の取り消しにEscapeキーを使うため、そのキーは見送る。
popup.addEventListener('keydown', (event) => {
  if (event.isComposing || event.keyCode === 229) return;
  if (event.key === 'Escape') closePopup();
});
```

×が `button` になったので、フォーカスを留める処理の `querySelectorAll('button, input')` が最初に×を見つけ、Tabキーは×、入力欄、「登録する」を回ります。二つ目の処理がEscapeキーでポップアップを閉じ、閉じるとフォーカスはポップアップが開く前の場所に戻ります。ほかは変わりません。オーバーレイ、フォーカスを留める処理、残りのマークアップはそのままです。

## どの方法を選ぶか

どちらも、キーボードに二つの出口（Escapeキーと、Tabキーで届く×）を用意し、閉じた後にフォーカスを戻します。HTML標準の `dialog` を選んでください。ブラウザーがEscapeキーで閉じ、背後のページを操作できない状態にするため、コードが少なく、後からスクリプトを変えてもEscapeキーという出口が失われません。独自のポップアップを残すのは、そのマークアップがコンポーネントやツールから出力されていて、`dialog` 要素に置き換えられない場合です。その場合は、変更のたびにEscapeキーと×を確認し直してください。

## ポップアップが外部サービスのものである場合

会員登録のポップアップ、離脱時のクーポン、Cookieの同意ダイアログは、マーケティングツールや同意管理ツールで設定されることが多く、コードは提供元のものです。できることは次のとおりです。

- 公開する前に、ツールが出すポップアップを一つずつ「修正の確認」の手順で試す。ツールが許せば、テスト用のページで行います。
- ツールの設定を確認する。アクセシブルなテンプレートや「Escapeキーで閉じる」設定があるものもあります。閉じるボタンが見えるものを選び、ページごとに開き直すポップアップは無効にします。
- ポップアップがEscapeキーで閉じるか、閉じるボタンが名前のあるボタンか、閉じた後にフォーカスがどこへ移るかを、提供元に尋ねる。
- うまくいかない点を、再現手順を添えて提供元に報告する。直るまでは、[アクセシビリティ方針](/ja/learn/accessibility-statement)に既知の問題として記載し、キーボードで閉じられないポップアップは止めます。どのような経路でページに入ったものでも、2.1.2の対象です。

提供元のスクリプトは、自社のリリースと関係なく変わります。提供元の更新やポップアップの設定の変更のたびに、確認を繰り返してください。

## 実装の判断

**Cookieの同意ダイアログ。** ページの操作を止めるCookieの同意ダイアログにも、同じ原則が当てはまります。「すべて許可」「すべて拒否」「Cookieを選ぶ」のどの選択肢も、Tabキーで届く `button` にし、Escapeキーでは何も保存せずに閉じて、次の訪問でもう一度尋ねます。バナーをページのどこに置くか、フォーカスを覆わないかは、[フォーカスを隠し、最後にしか届かないCookieバナー](/ja/learn/cookie-banners)で扱っています。

**Escapeキーを打ち消さない。** HTML標準のダイアログは、Escapeキーで閉じる前に `cancel` イベントを発生させます。選択を強制するために、そこで `preventDefault()` を呼ぶと、閉じるための通常のキーが使えなくなります。どうしても答えが必要なら、Escapeキーを止めるのではなく、答えごとにTabキーで届くボタンを用意します。

**×を常に表示する。** ポインターが乗ったときだけ現れる閉じるボタンは、それまで隠されています。`visibility: hidden` や `display: none` で隠していると、フォーカスを受け取れないため、Tabキーが届きません。ポップアップが表示されている間は、×も表示しておきます。「登録する」の横に置く「今回はやめておく」のボタンは、誰にでも見える二つ目の出口で、×が暗に示すだけのことをはっきり言葉にします。

**背景のクリック。** ポップアップの周りの暗くなった部分をクリックして閉じる仕組みは、ポインターのための便利な機能です。キーボードの出口にはならないため、ボタンやEscapeキーの代わりにはなりません。

**最後のコントロールの次のTab。** フォーカスを留める処理がないと、HTML標準のダイアログは「登録する」の次のTabでフォーカスをアドレスバーなどブラウザー自身のコントロールへ移し、もう一度Tabを押すとダイアログの最初のコントロールに戻します。WCAGの達成方法H102が説明している動作で、キーボードトラップではありませんが、フォーカスはポップアップからもページからも出てしまいます。フォーカスを留める処理は、ARIA Authoring Practicesのモーダルダイアログのパターンと同じように、フォーカスをポップアップの中に保ちます。処理が集める一覧は、ダイアログの中身と合わせておきます。一覧に当てはまらないコントロールを加えると、Tabが1周するときに飛ばされます。

**フォーカスを留める処理だけを外さない。** 背後のページを操作できない状態にしないまま、Tabキーを内側に留める処理だけを外しても、問題は解決しません。Tabキーはオーバーレイの下の、フォーカスが見えにくいページを移動するようになり、×には相変わらず届きません。モーダルの中のフォーカスについては、[背後にフォーカスが残るモーダル](/ja/learn/modal-focus-management)で扱っています。

**閉じた後のフォーカスの位置。** 自動で開くポップアップは、利用者がそのときいた場所、たとえばフォームの途中からフォーカスを奪います。フォーカスはそこへ戻し、利用者が続きから操作できるようにします。`showModal()` なら自動で戻り、独自の修正では、そのための処理を書いています。

**チャットのパネル。** サポートチャットの閉じるボタンと起動ボタンには、別の問題があります。[キーボードで開けず、閉じるボタンにも名前がないチャット](/ja/learn/chat-launchers)を参照してください。

## 修正の確認

1. マウスを使わずにページを開き、ポップアップが開くのを待つ。フォーカスがポップアップに移ることを確認する。
2. TabキーとShift + Tabで、ポップアップの中を2周する。×を含むすべてのコントロールに届き、フォーカスが背後のページにもブラウザー自身のコントロールにも出ないことを確認する。
3. Tabキーで×へ移動し、Enterキーを押す。ポップアップが閉じ、ポップアップが開く前の場所にフォーカスがあることを確認する。
4. もう一度ページを開き、ポップアップが開いたらEscapeキーを押す。同じように閉じることを確認する。
5. スクリーンリーダーを起動して、もう一度ページを開く。ポップアップが開くときに見出しが読まれ、×が「閉じる、ボタン」と読まれることを確認する。

OS、ブラウザーと支援技術のバージョン、ビルド、実施日、各手順の実際の結果を記録してください。ポップアップ、それを出すツール、またはその設定を変更した際は、再度確認します。

## 制限

このサンプルは一つのポップアップのための出発点で、あらゆるオーバーレイで同じ結果になることを保証するものではありません。一度閉じたポップアップをページごとに開き直さないための記録と、`subscribe()` がアドレスをどう扱うかは省略しています。ポップアップが開く前にどこにもフォーカスがなかった場合、閉じた後にフォーカスをどこへ移すかはページによります。ブラウザーに任せるのも妥当ですが、その後のTabがどこから進むかはブラウザーによって異なるため、対象のブラウザーで確認してください。実際に利用者が使う支援技術で、操作全体を確認してください。

## スクリーンリーダーでの動き（EaseWebが実機で確認）

`<dialog>`: showModal() で開くダイアログです。フォーカスがダイアログの中へ移ります。

| スクリーンリーダー | ブラウザーとOS | 開く |
| --- | --- | --- |
| NVDA | Chrome, Windows 11 | 未確認 |
| JAWS | Chrome, Windows 11 | 未確認 |
| VoiceOver | Safari, iOS | 未確認 |
| TalkBack | Chrome, Android | 未確認 |

https://easeweb.dev/ja/learn/screen-reader-support

---

このガイドをコーディングエージェントで使う: MCP https://easeweb.dev/mcp（設定は https://easeweb.dev/ja/services/developers）
