JavaScript | 1 日 120 分 × 7 日アプリ学習:モーダルウィンドウ管理

Web APP JavaScript
スポンサーリンク

7日目のゴールと全体像

7日目は、ここまで育ててきたモーダルを
「アプリでそのまま使えるコンポーネント」として
ひとつの完成形にまとめる日です。

クラス設計、UI 状態制御、イベント伝播。
開く / 閉じる、背景クリック制御、ESC キー対応。
この全部を「一つのコード」に収めて、
自分で読んで理解できるレベルまで落とし込むのがゴールです。

今日は、最終的な Modal クラスの完成版を書きながら、
一行一行の意味をかみ砕いて確認していきます。


完成版 Modal クラスの全体コード

まずは、完成形を一気に見てください。
そのあとで、重要なところを分解して解説します。

class Modal {
  constructor(root, manager, options = {}) {
    this.root = root;
    this.manager = manager;

    this.content = root.querySelector(".modal__content");
    this.closeButton = root.querySelector(".modal__close");

    this.state = "closed"; // "opening" | "opened" | "closing" | "closed"
    this.disableEsc = options.disableEsc || false;

    this.events = {};
    this.safeAreas = [this.content];

    this.handleRootClick = this.handleRootClick.bind(this);
    this.handleKeydown = this.handleKeydown.bind(this);
    this.trapFocus = this.trapFocus.bind(this);

    this.bindBaseEvents();
  }

  get isOpened() {
    return this.state === "opened";
  }

  on(eventName, handler) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }
    this.events[eventName].push(handler);
  }

  emit(eventName) {
    const handlers = this.events[eventName] || [];
    handlers.forEach(fn => fn());
  }

  addSafeArea(el) {
    this.safeAreas.push(el);
  }

  bindBaseEvents() {
    this.root.addEventListener("click", this.handleRootClick);

    if (this.closeButton) {
      this.closeButton.addEventListener("click", () => this.close());
    }
  }

  handleRootClick(event) {
    const top = this.manager.getTopModal();
    if (top !== this) return;

    const isInsideSafeArea = this.safeAreas.some(el => el && el.contains(event.target));
    if (!isInsideSafeArea) {
      this.close();
    }
  }

  handleKeydown(event) {
    if (event.key === "Escape") {
      if (this.disableEsc) return;
      if (this.state !== "opened") return;

      const top = this.manager.getTopModal();
      if (top === this) {
        this.close();
      }
    }

    this.trapFocus(event);
  }

  trapFocus(event) {
    if (event.key !== "Tab") return;

    const focusable = this.root.querySelectorAll(
      'button, a, input, textarea, select, [tabindex]:not([tabindex="-1"])'
    );
    if (focusable.length === 0) return;

    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (event.shiftKey && document.activeElement === first) {
      event.preventDefault();
      last.focus();
    } else if (!event.shiftKey && document.activeElement === last) {
      event.preventDefault();
      first.focus();
    }
  }

  lockScroll() {
    document.body.style.overflow = "hidden";
  }

  unlockScroll() {
    document.body.style.overflow = "";
  }

  open() {
    if (this.state !== "closed") return;

    this.state = "opening";
    this.root.classList.add("is-open");
    this.lockScroll();
    this.manager.push(this);

    document.addEventListener("keydown", this.handleKeydown);

    const firstFocusable = this.root.querySelector(
      'button, a, input, textarea, select, [tabindex]:not([tabindex="-1"])'
    );
    if (firstFocusable) {
      firstFocusable.focus();
    }

    this.root.addEventListener(
      "transitionend",
      () => {
        if (this.state === "opening") {
          this.state = "opened";
          this.emit("open");
        }
      },
      { once: true }
    );
  }

  close() {
    if (this.state !== "opened") return;

    this.state = "closing";
    this.root.classList.remove("is-open");
    this.manager.pop(this);
    this.unlockScroll();
    document.removeEventListener("keydown", this.handleKeydown);

    this.root.addEventListener(
      "transitionend",
      () => {
        if (this.state === "closing") {
          this.state = "closed";
          this.emit("close");
        }
      },
      { once: true }
    );
  }
}
JavaScript

クラス設計の肝:責務をどう分けているか

このクラスがやっていることを、ざっくり言葉で整理するとこうなります。

モーダルが「開いているか / 閉じているか」を知っている。
開くときと閉じるときに、UI と状態とイベントをまとめて切り替える。
背景クリックが「安全かどうか」を判定して閉じる。
ESC キーとフォーカスを制御して、キーボード操作にも強くする。
外部から on(“open”) / on(“close”) で動きを差し込めるようにしている。

ポイントは、「全部をバラバラの関数で書かない」ことです。
モーダルに関することは Modal クラスに閉じ込める。
複数モーダルの調整は ModalManager に任せる。
この分離が、中級レベルのクラス設計の感覚です。


UI 状態制御の肝:state と isOpened の意味

state は内部用の「細かい状態」です。
“opening” / “opened” / “closing” / “closed” の 4 つを持っています。

isOpened は外部用の「ざっくりした状態」です。
「今開いているかどうか」だけを知りたいときに使います。

内部では細かく、外部にはシンプルに。
この二段構えが、UI 状態制御を分かりやすくします。

例えば open() の最初の行。

if (this.state !== "closed") return;
JavaScript

「閉じているとき以外は open しない」というガードです。
これがあるから、「開き途中にもう一度 open()」みたいな事故が起きません。

close() も同じです。

if (this.state !== "opened") return;
JavaScript

「開いているときだけ閉じる」。
状態遷移をきちんと制御しているから、
アニメーション中に ESC を押しても壊れません。


イベント伝播と背景クリック制御の肝

背景クリックの判定は handleRootClick に集約されています。

handleRootClick(event) {
  const top = this.manager.getTopModal();
  if (top !== this) return;

  const isInsideSafeArea = this.safeAreas.some(el => el && el.contains(event.target));
  if (!isInsideSafeArea) {
    this.close();
  }
}
JavaScript

ここでやっていることは三段階です。

まず、「今クリックされたモーダルが最前面か?」を確認しています。
最前面でなければ、何もしません。
これで「下のモーダルが誤って閉じる」事故を防いでいます。

次に、safeAreas の中に event.target が含まれているかを判定しています。
safeAreas は「クリックしても閉じたくない領域」のリストです。
content(中身の白い部分)は最初から safeAreas に入っています。
必要なら外部から addSafeArea で増やせます。

最後に、「どの safeArea にも含まれていないなら背景クリック」とみなして close()。
contains() を使っているので、
中身の構造が変わっても壊れません。

イベント伝播を stopPropagation で無理やり止めるのではなく、
「どこをクリックしたか」を冷静に判定しているのがポイントです。


ESC キー対応とフォーカス制御の肝

handleKeydown は、ESC とフォーカス制御を両方見ています。

handleKeydown(event) {
  if (event.key === "Escape") {
    if (this.disableEsc) return;
    if (this.state !== "opened") return;

    const top = this.manager.getTopModal();
    if (top === this) {
      this.close();
    }
  }

  this.trapFocus(event);
}
JavaScript

ESC については、
disableEsc が true なら何もしない。
state が “opened” でなければ何もしない。
最前面のモーダルでなければ何もしない。
この三重チェックで、「意図しない閉じ」を徹底的に防いでいます。

trapFocus は Tab キーのときだけ動きます。
モーダル内のフォーカス可能要素を全部集めて、
先頭と末尾でループするようにしています。

これによって、
Tab を押してもフォーカスが背景に逃げません。
キーボードだけで操作しても、モーダルの中に閉じ込められます。


開く / 閉じるの一連の流れを言葉でなぞる

open() が呼ばれたとき、何が起きているかを順番に追ってみます。

閉じている状態でなければ何もしない。
state を “opening” にする。
is-open クラスを付けて、CSS 側のアニメーションを発火させる。
スクロールをロックする(背景が動かないようにする)。
ModalManager に自分を登録する(スタックに積まれる)。
keydown イベントを登録して、ESC とフォーカス制御を有効にする。
最初にフォーカスを当てる要素を探して focus() する。
transitionend が来たら state を “opened” にして、”open” イベントを emit する。

close() も同じように追えます。

opened でなければ何もしない。
state を “closing” にする。
is-open クラスを外して、閉じるアニメーションを走らせる。
ModalManager から自分を外す(スタックから抜ける)。
スクロールロックを解除する。
keydown イベントを解除する。
transitionend が来たら state を “closed” にして、”close” イベントを emit する。

ここまで言葉で追えるようになっていれば、
あなたはもう「なんとなく動くモーダル」ではなく、
“設計して動かしているモーダル” を扱えている状態です。


ModalManager の最小実装と使い方

最後に、ModalManager のシンプルな実装と、
実際の使い方をまとめておきます。

class ModalManager {
  constructor() {
    this.stack = [];
  }

  push(modal) {
    this.stack.push(modal);
  }

  pop(modal) {
    this.stack = this.stack.filter(m => m !== modal);
  }

  getTopModal() {
    return this.stack[this.stack.length - 1] || null;
  }
}
JavaScript

使い方のイメージはこうです。

const manager = new ModalManager();

const modalElement = document.getElementById("myModal");
const modal = new Modal(modalElement, manager);

const openButton = document.getElementById("openModal");
openButton.addEventListener("click", () => {
  modal.open();
});

modal.on("open", () => {
  console.log("開いたよ");
});

modal.on("close", () => {
  console.log("閉じたよ");
});
JavaScript

これで、
ボタンで開く。
背景クリックで閉じる。
ESC で閉じる。
フォーカスはモーダル内に閉じ込められる。
スクロールは止まる。
複数モーダルにも対応できる。

という「中級者レベルのモーダル管理」が完成です。


7日目の本質と、あなたがもう持っているもの

7日間かけてやってきたことを、一言でまとめるとこうです。

モーダルという UI を、
「クラス設計」「状態」「イベント」という視点で
ちゃんと“設計して”作れるようになった。

開く / 閉じるは、ただの show / hide ではなく、
状態遷移とイベントと UI をセットで考えるものだ、
という感覚がもう身についています。

ここまで来たあなたなら、
モーダルだけじゃなく、
ドロップダウン、タブ、サイドバー、トースト通知。
どんな UI でも「クラス + 状態 + イベント」で
同じように設計していけます。

もし次にやるなら、
この Modal を実際の小さなアプリに組み込んで、
「削除確認」「フォーム送信」「設定変更」など、
具体的なシーンと結びつけていくのがいいですね。

タイトルとURLをコピーしました