T-CREATOR

Web Components で作るモーダルダイアログ:フォーカス管理・閉じる動線まで実装

Web Components で作るモーダルダイアログ:フォーカス管理・閉じる動線まで実装

モーダルダイアログは、ユーザーの操作をいったん止めて重要な情報を表示したり、確認を求めたりするための UI パターンとして広く使われています。しかし、ただ見た目を作るだけでは不十分で、キーボード操作への対応やフォーカス管理、適切な閉じる動線の実装など、アクセシビリティとユーザビリティの観点で押さえるべきポイントがいくつもあるんです。

本記事では、Web Components を使ってモーダルダイアログを一から実装する方法を、フォーカストラップ(フォーカスの閉じ込め)や Esc キーでの閉じる動線、オーバーレイクリックでの閉じる処理まで、実践的なコード例とともに解説いたします。

背景

モーダルダイアログとは

モーダルダイアログは、ページの他の操作を一時的にブロックして、ユーザーに特定のアクションを促す UI コンポーネントです。ログイン画面、確認ダイアログ、画像のプレビュー表示など、さまざまな場面で利用されていますね。

Web Components を選ぶ理由

Web Components は、HTML・CSS・JavaScript を組み合わせて再利用可能なカスタム要素を作る仕組みです。フレームワークに依存せず、ネイティブの Web API だけで実装できるため、どんなプロジェクトでも使いまわせる汎用性の高いコンポーネントを作れます。

モーダルダイアログのように、複数のページやプロジェクトで頻繁に使う UI パターンは、Web Components で実装することで大きなメリットが得られるでしょう。

以下の図は、Web Components によるモーダルダイアログの基本構造を示しています。

mermaidflowchart TB
  shadow["Shadow DOM<br/>(内部スタイル・構造)"]
  template["&lt;template&gt;<br/>(再利用可能な HTML)"]
  custom["&lt;modal-dialog&gt;<br/>(カスタム要素)"]

  template -->|挿入| shadow
  shadow -->|包含| custom
  custom -->|公開| page["ページ HTML"]

図の要点:テンプレートで定義した HTML が Shadow DOM 内に挿入され、カスタム要素として外部に公開されます。

アクセシビリティの重要性

モーダルを開いたとき、キーボード操作だけでダイアログ内のボタンやフォームにアクセスできるか、スクリーンリーダーが適切に読み上げるかなど、アクセシビリティへの配慮が欠かせません。特にフォーカス管理は、視覚障害のあるユーザーや、マウスを使わないユーザーにとって非常に重要です。

課題

フォーカスが外に逃げてしまう問題

モーダルを表示したとき、Tab キーでフォーカスを移動すると、ダイアログの外(背景のページ)にフォーカスが移動してしまうことがあります。これでは、ユーザーがモーダル内の操作に集中できず、操作ミスやアクセシビリティの問題につながるでしょう。

閉じる動線が不明確

ユーザーがモーダルを閉じる方法が分かりにくいと、ストレスを感じてしまいますよね。一般的には以下の 3 つの方法でモーダルを閉じられるようにすることが推奨されます。

  • 閉じるボタン(× ボタンなど)のクリック
  • Esc キーの押下
  • オーバーレイ(背景の暗い部分)のクリック

スタイルの衝突

モーダルのスタイルがページ全体の CSS と干渉して、意図しない見た目になってしまう問題もあります。Web Components の Shadow DOM を使えば、スタイルをカプセル化して外部との衝突を防げますが、適切な設計が必要です。

以下の図は、モーダルダイアログで発生しがちな課題を整理したものです。

mermaidflowchart LR
  issue1["フォーカスが<br/>外に逃げる"]
  issue2["閉じる動線が<br/>不明確"]
  issue3["スタイル衝突"]

  issue1 -->|解決策| trap["フォーカストラップ"]
  issue2 -->|解決策| close["Esc・オーバーレイ<br/>クリック対応"]
  issue3 -->|解決策| shadow2["Shadow DOM<br/>スタイル分離"]

図の要点:各課題に対して、フォーカストラップ、複数の閉じる動線、Shadow DOM によるスタイル分離が有効です。

解決策

フォーカストラップの実装

モーダルを開いたら、フォーカスをダイアログ内のフォーカス可能な要素(ボタン、入力欄など)に限定します。Tab キーや Shift+Tab キーでフォーカスを移動しても、ダイアログ内をループするようにすることで、ユーザーの操作がダイアログの外に出ないようにするのです。

複数の閉じる動線を用意

閉じるボタン、Esc キー、オーバーレイクリックの 3 つの動線を実装します。これにより、マウス操作派の方もキーボード操作派の方も、直感的にモーダルを閉じられますね。

Shadow DOM によるスタイルのカプセル化

Shadow DOM を使えば、モーダルのスタイルを完全に独立させることができます。外部の CSS がモーダル内に影響せず、逆にモーダルのスタイルが外部に漏れることもありません。

ARIA 属性でアクセシビリティを強化

role="dialog"aria-modal="true"aria-labelledby などの ARIA 属性を適切に設定することで、スクリーンリーダーがモーダルを正しく認識して読み上げられるようにします。

以下の図は、解決策の全体像を示したものです。

mermaidflowchart TB
  open["モーダルを開く"] --> focus["フォーカスを<br/>ダイアログ内へ"]
  focus --> trap2["フォーカストラップ<br/>有効化"]
  trap2 --> wait["ユーザー操作待ち"]

  wait -->|閉じるボタン| closeAction["モーダルを閉じる"]
  wait -->|Esc キー| closeAction
  wait -->|オーバーレイクリック| closeAction

  closeAction --> restore["元の要素へ<br/>フォーカス復帰"]

図の要点:モーダルを開いたらフォーカスを移動し、複数の閉じる動線を経て元の要素にフォーカスを戻します。

具体例

プロジェクトの準備

まずは、プロジェクトディレクトリを作成し、必要なファイルを用意しましょう。

bashmkdir web-components-modal
cd web-components-modal
touch index.html modal-dialog.js

HTML ファイルの作成

index.html では、作成したカスタム要素 <modal-dialog> を読み込み、開くボタンを配置します。

html<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>Web Components モーダルダイアログ</title>
  </head>
  <body>
    <h1>Web Components モーダルサンプル</h1>
    <button id="openModalBtn">モーダルを開く</button>

    <!-- カスタム要素のモーダルダイアログ -->
    <modal-dialog id="myModal">
      <h2 slot="title">確認ダイアログ</h2>
      <p slot="content">
        この操作を実行してもよろしいですか?
      </p>
    </modal-dialog>

    <script type="module" src="./modal-dialog.js"></script>
    <script>
      // モーダルを開くボタンのイベント
      document
        .getElementById('openModalBtn')
        .addEventListener('click', () => {
          document.getElementById('myModal').open();
        });
    </script>
  </body>
</html>

この HTML では、<modal-dialog> タグの中に slot 属性を使ってタイトルと本文を挿入しています。slot を使うことで、モーダルの内容を外部から柔軟に指定できるんですね。

カスタム要素の定義

modal-dialog.js で Web Components としてモーダルダイアログを定義します。まず、テンプレートとスタイルを作成しましょう。

javascript// テンプレート HTML を定義
const template = document.createElement('template');
template.innerHTML = `
  <style>
    /* オーバーレイ(背景の暗い部分) */
    .overlay {
      display: none;
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-color: rgba(0, 0, 0, 0.5);
      z-index: 1000;
      justify-content: center;
      align-items: center;
    }

    .overlay.show {
      display: flex;
    }

ここでは、オーバーレイのスタイルを定義しています。display: none で非表示にし、show クラスが追加されたときに display: flex で中央揃えになるようにしました。

次に、ダイアログ本体のスタイルを追加します。

javascript    /* ダイアログ本体 */
    .dialog {
      background-color: white;
      border-radius: 8px;
      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
      max-width: 500px;
      width: 90%;
      padding: 24px;
      position: relative;
    }

    /* 閉じるボタン */
    .close-btn {
      position: absolute;
      top: 12px;
      right: 12px;
      background: none;
      border: none;
      font-size: 24px;
      cursor: pointer;
      color: #666;
    }

    .close-btn:hover {
      color: #000;
    }
  </style>

ダイアログは白い背景に角丸、影をつけて立体感を出しています。閉じるボタンは右上に配置し、ホバー時に色が変わるようにしました。

続いて、テンプレートの HTML 部分を定義します。

javascript  <div class="overlay" part="overlay">
    <div class="dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title" part="dialog">
      <button class="close-btn" aria-label="閉じる">×</button>
      <div id="dialog-title">
        <slot name="title">タイトル</slot>
      </div>
      <div>
        <slot name="content">本文</slot>
      </div>
      <div>
        <slot name="actions"></slot>
      </div>
    </div>
  </div>
`;

role="dialog"aria-modal="true" を設定して、スクリーンリーダーにモーダルであることを伝えます。slot を使って、外部から title、content、actions を挿入できるようにしました。

クラスの定義とライフサイクル

次に、カスタム要素のクラスを定義します。まずはコンストラクタと Shadow DOM の作成から始めます。

javascriptclass ModalDialog extends HTMLElement {
  constructor() {
    super();

    // Shadow DOM を作成してテンプレートを挿入
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.appendChild(template.content.cloneNode(true));

    // DOM 要素への参照を保持
    this.overlay = this.shadowRoot.querySelector('.overlay');
    this.dialog = this.shadowRoot.querySelector('.dialog');
    this.closeBtn = this.shadowRoot.querySelector('.close-btn');

    // 元々フォーカスがあった要素を記憶するための変数
    this.previouslyFocusedElement = null;
  }

コンストラクタでは、Shadow DOM を作成し、先ほど定義したテンプレートを挿入します。また、後で使う DOM 要素への参照を保持しておくことで、パフォーマンスを向上させています。

次に、要素が DOM に追加されたときの処理を定義します。

javascript  connectedCallback() {
    // イベントリスナーを設定
    this.closeBtn.addEventListener('click', () => this.close());
    this.overlay.addEventListener('click', (e) => {
      // オーバーレイ自体がクリックされた場合のみ閉じる(ダイアログ内は除外)
      if (e.target === this.overlay) {
        this.close();
      }
    });

    // Esc キーでモーダルを閉じる
    this.handleKeydown = (e) => {
      if (e.key === 'Escape' && this.overlay.classList.contains('show')) {
        this.close();
      }
    };
    document.addEventListener('keydown', this.handleKeydown);
  }

connectedCallback は、要素が DOM に追加されたときに自動的に呼ばれるライフサイクルメソッドです。ここで、閉じるボタンのクリック、オーバーレイのクリック、Esc キーの押下に対するイベントリスナーを設定しています。

また、要素が DOM から削除されたときにイベントリスナーを解除する処理も追加しましょう。

javascript  disconnectedCallback() {
    // メモリリークを防ぐためにイベントリスナーを削除
    document.removeEventListener('keydown', this.handleKeydown);
  }

モーダルを開く処理

モーダルを開く open() メソッドでは、フォーカスの保存とフォーカストラップの有効化を行います。

javascript  open() {
    // 現在フォーカスされている要素を記憶
    this.previouslyFocusedElement = document.activeElement;

    // モーダルを表示
    this.overlay.classList.add('show');

    // フォーカストラップを有効化
    this.enableFocusTrap();

    // 最初のフォーカス可能な要素にフォーカスを移動
    const firstFocusable = this.getFocusableElements()[0];
    if (firstFocusable) {
      firstFocusable.focus();
    }
  }

モーダルを開く前に、現在フォーカスされている要素を previouslyFocusedElement に保存しておきます。これにより、モーダルを閉じたときに元の場所にフォーカスを戻せますね。

フォーカストラップの実装

フォーカストラップでは、Tab キーでのフォーカス移動を監視し、ダイアログ内のフォーカス可能な要素をループさせます。

javascript  enableFocusTrap() {
    this.focusTrapHandler = (e) => {
      if (e.key !== 'Tab') return;

      const focusableElements = this.getFocusableElements();
      const firstElement = focusableElements[0];
      const lastElement = focusableElements[focusableElements.length - 1];

      // Shift+Tab で最初の要素から戻ろうとした場合、最後の要素へ
      if (e.shiftKey && document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      }
      // Tab で最後の要素から進もうとした場合、最初の要素へ
      else if (!e.shiftKey && document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    };

    document.addEventListener('keydown', this.focusTrapHandler);
  }

Tab キーが押されたとき、現在のフォーカス位置が最初または最後の要素であれば、ループするように制御しています。これにより、ユーザーがダイアログの外にフォーカスを移動できなくなるんですね。

次に、フォーカス可能な要素を取得するヘルパーメソッドを定義します。

javascript  getFocusableElements() {
    // Shadow DOM 内のフォーカス可能な要素を取得
    const focusableSelectors = [
      'button:not([disabled])',
      'a[href]',
      'input:not([disabled])',
      'select:not([disabled])',
      'textarea:not([disabled])',
      '[tabindex]:not([tabindex="-1"])'
    ];

    return Array.from(
      this.shadowRoot.querySelectorAll(focusableSelectors.join(','))
    ).filter(el => el.offsetParent !== null); // 非表示要素を除外
  }

このメソッドでは、ボタン、リンク、入力欄など、フォーカス可能な要素をすべて取得しています。offsetParent をチェックすることで、非表示になっている要素を除外しているのがポイントです。

モーダルを閉じる処理

モーダルを閉じる close() メソッドでは、フォーカストラップを解除し、元の要素にフォーカスを戻します。

javascript  close() {
    // モーダルを非表示
    this.overlay.classList.remove('show');

    // フォーカストラップを解除
    this.disableFocusTrap();

    // 元々フォーカスがあった要素にフォーカスを戻す
    if (this.previouslyFocusedElement) {
      this.previouslyFocusedElement.focus();
    }

    // イベントを発火(外部で閉じた後の処理を実行できるように)
    this.dispatchEvent(new CustomEvent('modal-closed'));
  }

  disableFocusTrap() {
    // フォーカストラップのイベントリスナーを削除
    if (this.focusTrapHandler) {
      document.removeEventListener('keydown', this.focusTrapHandler);
      this.focusTrapHandler = null;
    }
  }
}

モーダルを閉じたら、previouslyFocusedElement に保存しておいた要素にフォーカスを戻します。これにより、ユーザーは元の操作位置から続けられるようになりますね。また、modal-closed というカスタムイベントを発火させることで、外部から閉じた後の処理をフックできるようにしています。

カスタム要素の登録

最後に、定義したクラスをカスタム要素として登録します。

javascript// カスタム要素を登録
customElements.define('modal-dialog', ModalDialog);

これで、HTML 内で <modal-dialog> タグを使えるようになりました。

動作確認

ブラウザで index.html を開いて、「モーダルを開く」ボタンをクリックしてみましょう。モーダルが表示され、Tab キーでフォーカスがダイアログ内をループすること、Esc キーやオーバーレイクリックでモーダルが閉じることを確認できます。

以下の図は、ユーザーがモーダルダイアログを操作する際の状態遷移を示したものです。

mermaidstateDiagram-v2
  [*] --> ModalClosed: 初期状態
  ModalClosed --> ModalOpen: open() 呼び出し
  ModalOpen --> FocusTrapped: フォーカストラップ有効化

  FocusTrapped --> FocusTrapped: Tab キーでループ
  FocusTrapped --> ModalClosing: ×ボタンクリック
  FocusTrapped --> ModalClosing: Esc キー
  FocusTrapped --> ModalClosing: オーバーレイクリック

  ModalClosing --> ModalClosed: close() 実行<br/>フォーカス復帰
  ModalClosed --> [*]

図の要点:モーダルは開く・フォーカストラップ・閉じるというライフサイクルを持ち、複数の閉じる動線から元の状態に戻ります。

カスタマイズ例

モーダルの内容は、スロットを使って自由にカスタマイズできます。たとえば、アクションボタンを追加する場合は以下のようにします。

html<modal-dialog id="myModal">
  <h2 slot="title">ログアウト確認</h2>
  <p slot="content">本当にログアウトしますか?</p>
  <div slot="actions">
    <button id="confirmBtn">ログアウト</button>
    <button id="cancelBtn">キャンセル</button>
  </div>
</modal-dialog>

スロットを使うことで、モーダルの見た目や機能を柔軟に変更できるのが Web Components の魅力ですね。

エラー対処例

エラー: Uncaught TypeError: Cannot read properties of null (reading 'focus')

発生条件:フォーカス可能な要素が見つからない場合に発生します。

解決方法

  1. getFocusableElements() が空配列を返していないか確認する
  2. open() メソッドで以下のようにガードを追加する
javascriptconst firstFocusable = this.getFocusableElements()[0];
if (firstFocusable) {
  firstFocusable.focus();
} else {
  console.warn(
    'モーダル内にフォーカス可能な要素が見つかりません'
  );
}
  1. モーダル内に少なくとも 1 つのボタンやリンクを配置する

エラー: DOMException: Failed to execute 'define' on 'CustomElementRegistry'

エラーコード: DOMException

発生条件:同じ名前のカスタム要素を複数回登録しようとした場合に発生します。

解決方法

  1. 登録前に既に定義されていないかチェックする
javascriptif (!customElements.get('modal-dialog')) {
  customElements.define('modal-dialog', ModalDialog);
}
  1. スクリプトが複数回読み込まれていないか確認する
  2. モジュールスクリプト(type="module")を使って重複を防ぐ

まとめ

本記事では、Web Components を使ってモーダルダイアログを実装する方法を、フォーカス管理や複数の閉じる動線を含めて詳しく解説いたしました。

実装のポイント

#項目内容
1フォーカストラップTab キーでのフォーカス移動をダイアログ内に限定
2複数の閉じる動線閉じるボタン・Esc キー・オーバーレイクリックの 3 通り
3フォーカスの復帰モーダルを閉じたら元の要素にフォーカスを戻す
4ARIA 属性role="dialog"aria-modal="true" でアクセシビリティ対応
5Shadow DOMスタイルをカプセル化して外部との衝突を防ぐ
6スロットモーダルの内容を外部から柔軟にカスタマイズ可能

これらのポイントを押さえることで、ユーザビリティとアクセシビリティに優れたモーダルダイアログを実装できます。

今後の拡張案

さらに機能を充実させたい場合は、以下のような拡張も検討できるでしょう。

  • アニメーション: CSS トランジションでフェードイン・フェードアウトを追加
  • 多段モーダル: モーダルの上にさらにモーダルを開けるようにする
  • サイズバリエーション: 小・中・大のサイズオプションを属性で指定
  • モバイル対応: スワイプダウンで閉じる動作を追加

Web Components は、一度作れば他のプロジェクトでも使いまわせる汎用性の高い資産になります。ぜひ、本記事のコードをベースに、自分のプロジェクトに合わせてカスタマイズしてみてくださいね。

関連リンク