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);
}
JavaScriptESC については、
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 を実際の小さなアプリに組み込んで、
「削除確認」「フォーム送信」「設定変更」など、
具体的なシーンと結びつけていくのがいいですね。

