🐶

【挑戦】アクセシビリティ対応のモーダルウィンドウを実装する【a11y】

に公開

フロントエンドエンジニアの usako です。
今年のアドベントカレンダーに参加させていただくことになりました。
どうぞよろしくお願いします。

はじめに

モーダルウィンドウはよくある UI ですが、単純にボタンを押して表示するだけの実装をするとアクセシビリティの観点からよろしくない実装になってしまいます。

今回は dialog 要素を使って、アクセシビリティ対応のモーダルウィンドウを実装します。

https://developer.mozilla.org/ja/docs/Web/HTML/Reference/Elements/dialog

前提

今回は Vue や React などのフレームワークは使用せず、シンプルに HTML、CSS、JS のみで実装しています。

問題点

モーダルウィンドウを実装する上で問題となる点は以下です。

dialog 要素を使わない場合

dialog 要素を使わずにモーダルウィンドウを実装した場合、以下のような問題があります。

  • フォーカス管理の問題
  • スクリーンリーダー対応の問題
  • キーボード操作の問題
  • セマンティクスの問題

フォーカス管理の問題

  • モーダルウィンドウ外の要素にフォーカスが移動してしまう
  • キーボード操作でモーダルウィンドウの背後にある要素が操作可能になる
  • Tab キーでのフォーカス順序が適切に制御されない

スクリーンリーダー対応の問題

  • モーダルウィンドウが開いていることが適切に伝わらない
  • aria-modalrole="dialog" の設定が必要
  • モーダルウィンドウ外のコンテンツが読み上げられてしまう

キーボード操作の問題

  • Escape キーでの閉じる機能を手動で実装する必要がある
  • Enter キーや Space キーでの操作が適切に動作しない

セマンティクスの問題

  • HTML の意味的な構造が不適切
  • ブラウザの標準機能を活用できない
  • アクセシビリティツールでの認識が困難

dialog 要素を使った場合

dialog 要素を使ったとしてもグレーアウトした部分については、デフォルトではクリックしても閉じる機能が搭載されていません。
クリックできないとなると操作性が悪いです。

解決策

問題点を踏まえて、dialog 要素を使うことで、以下のような恩恵を得ることができます。
モーダルウィンドウに必要な挙動が自ら実装せずともほぼ標準でまかなえます。

  • dialog 要素を dialog.showModal() で開いて、dialog.close() で閉じるだけ
  • ESC キーで閉じる動作がデフォルトで組み込まれている
  • Tab キーのフォーカスの移動はフォーカストラップにより自動でモーダルウィンドウ内に閉じ込められる
  • role="dialog"aria-modal="true" などのアクセシビリティ属性が不要

あとは上記に加えて、モーダルウィンドウの外側をクリックした時にモーダルウィンドウを閉じる機能を実装するだけです。

実装

HTML
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>モーダルウィンドウのデモ</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>


  <button class="c-button js-modal-open-btn" data-id="modal1">モーダルウィンドウ1を開く</button>
  <button class="c-button js-modal-open-btn" data-id="modal2">モーダルウィンドウ2を開く</button>

  <!-- モーダルウィンドウ1 -->
  <dialog id="modal1" class="c-modal js-modal">
    <form method="dialog" class="c-modal__form js-modal-form">
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <button class="c-modal__close">閉じる</button>
    </form>
  </dialog>

  <!-- モーダルウィンドウ2 -->
  <dialog id="modal2" class="c-modal js-modal">
    <form method="dialog" class="c-modal__form js-modal-form">
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <p>モーダルウィンドウのコンテンツが入ります</p>
      <button class="c-modal__close">閉じる</button>
    </form>
  </dialog>

  <script src="script.js"></script>
  <script>
    const { initClickModalOpenButton } = useModal();
    document.addEventListener("DOMContentLoaded", () => {
      initClickModalOpenButton();
    });
  </script>
</body>
</html>
JavaScript
const useModal = () => {
  /**
   * モーダルウィンドウを開くボタンのクリックイベントを設定する
   */
  const initClickModalOpenButton = () => {
    // ページ内のすべてのモーダルウィンドウを開くボタンを探す
    const btns = document.querySelectorAll('.js-modal-open-btn');

    // ボタンが見つからなかったら何もしない
    if (btns.length === 0) return;

    // 見つかったボタンそれぞれにクリックイベントを設定する
    btns.forEach((btn) => {
      btn.addEventListener('click', () => {
        // ボタンに設定されているモーダルウィンドウのIDを取得する
        const id = btn.getAttribute('data-id');

        // IDが設定されていなかったら何もしない
        if (id === null) return;

        // 指定されたIDのモーダルウィンドウを開く
        openModal(id);
      });
    });

    // モーダルウィンドウ閉じるボタンのクリックイベントを設定する
    initClickModalCloseButton();
  };

  /**
   * 指定されたIDのモーダルウィンドウを開く
   * @param id - 開くモーダルウィンドウのID
   */
  const openModal = (id) => {
    // IDでモーダルウィンドウ要素を取得する
    const dialog = document.getElementById(id);

    // モーダルウィンドウが見つからなかったら何もしない
    if (dialog === null) return;

    // モーダルウィンドウを表示する(ブラウザのネイティブ機能を使用)
    dialog.showModal();
  };

  /**
   * モーダルウィンドウ閉じるボタンのクリックイベントを設定する
   */
  const initClickModalCloseButton = () => {
    // ページ内のすべてのモーダルウィンドウ要素を探す
    const dialogs = document.querySelectorAll('.js-modal');

    // モーダルウィンドウが見つからなかったら何もしない
    if (dialogs.length === 0) return;

    // 見つかったモーダルウィンドウそれぞれにクリックイベントを設定する
    dialogs.forEach((dialog) => {
      dialog.addEventListener('click', (event) => {
        // クリックされたモーダルウィンドウのIDを取得する
        const id = dialog.getAttribute('id');

        // IDが設定されていなかったら何もしない
        if (id === null) return;

        // モーダルウィンドウを閉じる処理を実行する
        closeModal(id, event);
      });
    });
  };

  /**
   * 指定されたIDのモーダルウィンドウを閉じる
   * @param id - 閉じるモーダルウィンドウのID
   * @param event - クリックイベント
   */
  const closeModal = (id, event) => {
    // クリックされた要素を取得する
    const target = event.target;
    // フォームの外側(背景部分)をクリックしたかどうかを判定する
    const isClickOutsideForm = target && !target.closest('.js-modal-form');

    // フォームの外側をクリックした場合のみ、モーダルウィンドウを閉じる
    // (フォーム内をクリックした場合は閉じない)
    if (isClickOutsideForm) {
      // IDでモーダルウィンドウ要素を取得する
      const dialog = document.getElementById(id);

      // モーダルウィンドウが見つからなかったら何もしない
      if (dialog === null) return;

      // モーダルウィンドウを閉じる
      dialog.close();
    }
  };

  // 外部から使える関数を返す
  return {
    initClickModalOpenButton,
  };
};
CSS
.c-modal {
  width: 100%;
  max-width: calc(100dvw - 16px * 2);
  max-height: calc(100dvh - 16px * 2);
  padding: 0;
  overflow: visible;
  border: none;
  border-radius: 10px;
}
.c-modal::backdrop {
  background: rgba(36, 36, 36, 0.8);
}
.c-modal__form {
  max-height: calc(100dvh - 16px * 2);
  padding: 32px 24px;
  overflow-y: auto;
  width: 100%;
}
html:has(dialog[open]) {
  overflow: hidden;
}

おわりに

今回は dialog 要素を使ってモーダルウィンドウを実装しました。
dialog 要素に加えて、ほんの少しの実装でモーダルウィンドウに必要な挙動が実装できました。

本記事が皆さまのお役に立てれば幸いです。
ここまで読んでいただき、ありがとうございました!

参考

https://fastcoding.jp/blog/all/frontend/modal-css/
https://zenn.dev/de_teiu_tkg/articles/96a46374655e56

株式会社ソニックムーブ

Discussion