Chapter 07

複雑なUIパターン

タブ、モーダル、検索フィルター、動的レンダリングなど実践的な UI パターンを DOM 操作で構築します。

40 min

このチャプターで学ぶこと

  • タブ切り替え UI を実装できる
  • モーダルダイアログを開閉できる
  • リアルタイム検索フィルターを作れる
  • 複数のUIパターンを組み合わせられる
  • DOM 操作コードの複雑化を体感する

はじめに

前のチャプターでは Todo アプリを作り、「データ配列を状態として管理し、render 関数で DOM を作り直す」というパターンを学びました。

今度はさらに複雑な UI パターンに挑戦しましょう。現実の Web アプリでよく見かけるパターンばかりです。

このチャプターで作るもの:

コードがどんどん複雑になっていく過程を体験することが、このチャプターの大きなテーマです。


タブ UI

タブ UI は「複数のコンテンツパネルを切り替えて表示する」インターフェースです。ニュースサイトのカテゴリー切り替え、設定画面のセクション切り替えなど、あらゆる場所で使われます。

HTML 構造

<!-- tabs-demo/index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>タブ UI デモ</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      max-width: 640px;
      margin: 2rem auto;
      padding: 0 1rem;
      color: #2d3748;
    }

    /* ---- タブボタン群 ---- */
    .tab-list {
      display: flex;
      border-bottom: 2px solid #e2e8f0;
      margin-bottom: 0;
      list-style: none;
      padding: 0;
      gap: 0.25rem;
    }

    .tab-btn {
      padding: 0.6rem 1.25rem;
      border: 2px solid transparent;
      border-bottom: none;
      background: transparent;
      font-size: 0.9375rem;
      font-weight: 500;
      cursor: pointer;
      color: #718096;
      border-radius: 6px 6px 0 0;
      transition: color 0.2s, background 0.2s;
      position: relative;
      bottom: -2px; /* ボーダーに重ねる */
    }

    .tab-btn:hover {
      color: #4a5568;
      background: #f7fafc;
    }

    /* アクティブなタブのスタイル */
    .tab-btn.active {
      color: #667eea;
      font-weight: 700;
      border-color: #e2e8f0;
      border-bottom-color: #fff; /* 下のボーダーを白で消す */
      background: #fff;
    }

    /* ---- タブコンテンツパネル ---- */
    .tab-panel {
      display: none; /* デフォルトは非表示 */
      padding: 1.5rem;
      border: 2px solid #e2e8f0;
      border-top: none;
      border-radius: 0 0 8px 8px;
    }

    .tab-panel.active {
      display: block; /* アクティブなパネルだけ表示 */
    }

    .tab-panel h2 {
      font-size: 1.125rem;
      margin-bottom: 0.75rem;
      color: #1a202c;
    }

    .tab-panel p {
      color: #4a5568;
      line-height: 1.7;
    }
  </style>
</head>
<body>

  <!-- タブボタン一覧 -->
  <ul class="tab-list">
    <li>
      <button class="tab-btn active" data-tab="overview">概要</button>
    </li>
    <li>
      <button class="tab-btn" data-tab="details">詳細</button>
    </li>
    <li>
      <button class="tab-btn" data-tab="reviews">レビュー</button>
    </li>
  </ul>

  <!-- タブコンテンツパネル群 -->
  <div class="tab-panel active" id="tab-overview">
    <h2>概要</h2>
    <p>このタブには製品の概要が表示されます。短い説明文と主要な特徴を紹介します。</p>
  </div>

  <div class="tab-panel" id="tab-details">
    <h2>詳細スペック</h2>
    <p>詳細なスペック情報がここに表示されます。素材、サイズ、重量などの情報です。</p>
  </div>

  <div class="tab-panel" id="tab-reviews">
    <h2>ユーザーレビュー</h2>
    <p>購入者からのレビューコメントがここに表示されます。星評価と感想が含まれます。</p>
  </div>

  <script src="tabs.js"></script>
</body>
</html>

JavaScript 実装

// tabs-demo/tabs.js

// ---- タブ切り替え関数 ----
function switchTab(targetTabId) {
  // ---- 1. すべてのタブパネルを非表示にする ----
  const allPanels = document.querySelectorAll(".tab-panel");
  allPanels.forEach((panel) => {
    panel.classList.remove("active");
  });

  // ---- 2. すべてのタブボタンを非アクティブにする ----
  const allButtons = document.querySelectorAll(".tab-btn");
  allButtons.forEach((btn) => {
    btn.classList.remove("active");
    btn.setAttribute("aria-selected", "false"); // アクセシビリティ
  });

  // ---- 3. クリックされたタブのパネルを表示する ----
  const targetPanel = document.getElementById(`tab-${targetTabId}`);
  if (targetPanel) {
    targetPanel.classList.add("active");
  }

  // ---- 4. クリックされたタブボタンをアクティブにする ----
  const targetButton = document.querySelector(`[data-tab="${targetTabId}"]`);
  if (targetButton) {
    targetButton.classList.add("active");
    targetButton.setAttribute("aria-selected", "true");
  }
}

// ---- タブボタンへのイベントリスナー登録 ----
document.querySelectorAll(".tab-btn").forEach((btn) => {
  btn.addEventListener("click", () => {
    // data-tab 属性からどのタブが選ばれたか取得する
    const targetTab = btn.dataset.tab;
    switchTab(targetTab);
  });
});

// ---- キーボード操作のサポート(アクセシビリティ) ----
// 矢印キーでタブを切り替えられるようにする
document.querySelector(".tab-list").addEventListener("keydown", (e) => {
  const tabs = [...document.querySelectorAll(".tab-btn")];
  const currentIndex = tabs.indexOf(document.querySelector(".tab-btn.active"));

  let nextIndex;
  if (e.key === "ArrowRight") {
    // 右矢印:次のタブへ(最後なら最初に戻る)
    nextIndex = (currentIndex + 1) % tabs.length;
  } else if (e.key === "ArrowLeft") {
    // 左矢印:前のタブへ(最初なら最後に戻る)
    nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
  } else {
    return; // それ以外のキーは無視
  }

  tabs[nextIndex].click(); // 対象のタブボタンをクリック
  tabs[nextIndex].focus(); // フォーカスを移動
});

このタブ実装のポイントは「すべてを一度リセットして、対象だけをアクティブにする」という考え方です。タブが何個あっても同じロジックで動作します。


モーダルダイアログ

モーダルは「メインコンテンツの上にオーバーレイで情報を表示する UI」です。確認ダイアログ、画像のプレビュー、フォームの入力画面など、多くの場面で使われます。

📝 HTML の dialog 要素について

HTML にはネイティブの <dialog> 要素があり、一部の機能(フォーカストラップなど)はブラウザが自動で提供してくれます。現在はすべての主要ブラウザでサポートされています。

ただし、<dialog> のスタイリングには制約があり、カスタムデザインを実現するのが難しいケースもあります。カスタムモーダルの作り方を知っておくことは依然として重要です。また、React のようなライブラリでは、カスタムモーダルのパターンを学ぶことが後のポータル(createPortal)の理解に直結します。

<!-- modal-demo/index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>モーダル デモ</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      max-width: 640px;
      margin: 2rem auto;
      padding: 0 1rem;
    }

    .open-modal-btn {
      padding: 0.625rem 1.25rem;
      background: #667eea;
      color: #fff;
      border: none;
      border-radius: 8px;
      font-size: 1rem;
      font-weight: 600;
      cursor: pointer;
    }

    /* ---- オーバーレイ(背景の暗い部分) ---- */
    .modal-overlay {
      display: none; /* 初期状態は非表示 */
      position: fixed;
      inset: 0; /* top: 0; right: 0; bottom: 0; left: 0 の省略形 */
      background: rgba(0, 0, 0, 0.5);
      z-index: 100;
      /* フレックスで中央揃え */
      align-items: center;
      justify-content: center;
    }

    /* モーダルが開いているとき */
    .modal-overlay.open {
      display: flex;
    }

    /* ---- モーダル本体 ---- */
    .modal {
      background: #fff;
      border-radius: 12px;
      padding: 2rem;
      width: 90%;
      max-width: 480px;
      box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
      /* アニメーション */
      animation: fadeInUp 0.2s ease;
    }

    @keyframes fadeInUp {
      from { opacity: 0; transform: translateY(16px); }
      to   { opacity: 1; transform: translateY(0); }
    }

    .modal-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 1rem;
    }

    .modal-title {
      font-size: 1.25rem;
      font-weight: 700;
      color: #1a202c;
    }

    .modal-close-btn {
      background: none;
      border: none;
      font-size: 1.5rem;
      cursor: pointer;
      color: #718096;
      line-height: 1;
      padding: 0.125rem;
    }

    .modal-close-btn:hover {
      color: #1a202c;
    }

    .modal-body {
      color: #4a5568;
      line-height: 1.7;
      margin-bottom: 1.5rem;
    }

    .modal-footer {
      display: flex;
      gap: 0.75rem;
      justify-content: flex-end;
    }

    .btn {
      padding: 0.5rem 1rem;
      border-radius: 6px;
      font-size: 0.9375rem;
      font-weight: 600;
      cursor: pointer;
      border: 2px solid transparent;
    }

    .btn-primary {
      background: #667eea;
      color: #fff;
    }

    .btn-secondary {
      background: transparent;
      border-color: #e2e8f0;
      color: #4a5568;
    }
  </style>
</head>
<body>

  <h1>モーダル デモ</h1>
  <p style="margin-bottom: 1.5rem; color: #4a5568;">ボタンをクリックするとモーダルが開きます。</p>
  <button class="open-modal-btn" id="openModalBtn">モーダルを開く</button>

  <!-- モーダルのオーバーレイ(背景) -->
  <div class="modal-overlay" id="modalOverlay">
    <!-- モーダル本体 -->
    <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
      <div class="modal-header">
        <h2 class="modal-title" id="modalTitle">確認</h2>
        <button class="modal-close-btn" id="modalCloseBtn" aria-label="閉じる">×</button>
      </div>
      <div class="modal-body">
        <p>この操作を実行しますか?この操作は取り消せません。</p>
      </div>
      <div class="modal-footer">
        <button class="btn btn-secondary" id="modalCancelBtn">キャンセル</button>
        <button class="btn btn-primary" id="modalConfirmBtn">実行する</button>
      </div>
    </div>
  </div>

  <script src="modal.js"></script>
</body>
</html>
// modal-demo/modal.js

const overlay = document.getElementById("modalOverlay");

// ---- モーダルを開く ----
function openModal() {
  overlay.classList.add("open");

  // スクロールを無効化(モーダル表示中に背景がスクロールしないように)
  document.body.style.overflow = "hidden";

  // 閉じるボタンにフォーカスを移動(キーボード操作のため)
  document.getElementById("modalCloseBtn").focus();
}

// ---- モーダルを閉じる ----
function closeModal() {
  overlay.classList.remove("open");

  // スクロールを元に戻す
  document.body.style.overflow = "";

  // モーダルを開いたボタンにフォーカスを戻す
  document.getElementById("openModalBtn").focus();
}

// ---- イベントリスナーの設定 ----

// 「モーダルを開く」ボタン
document.getElementById("openModalBtn").addEventListener("click", openModal);

// ✕ ボタン(閉じるボタン)
document.getElementById("modalCloseBtn").addEventListener("click", closeModal);

// 「キャンセル」ボタン
document.getElementById("modalCancelBtn").addEventListener("click", closeModal);

// 「実行する」ボタン
document.getElementById("modalConfirmBtn").addEventListener("click", () => {
  alert("操作を実行しました!");
  closeModal();
});

// ---- オーバーレイ(背景)クリックで閉じる ----
overlay.addEventListener("click", (e) => {
  // e.target がオーバーレイ自体のとき(モーダル本体以外をクリックしたとき)
  // e.target が .modal の中の要素なら何もしない
  if (e.target === overlay) {
    closeModal();
  }
});

// ---- Escape キーで閉じる ----
document.addEventListener("keydown", (e) => {
  if (e.key === "Escape" && overlay.classList.contains("open")) {
    closeModal();
  }
});

このモーダル実装で重要なポイントは 3 つです:

  1. 背景クリックで閉じるe.target === overlay のチェックが核心。e.target はクリックされた要素で、モーダル本体の内側をクリックしたときはモーダル本体の要素になります。オーバーレイそのものがクリックされたときだけ閉じます。
  2. Escape キー対応keydown イベントを document 全体で監視し、モーダルが開いているときだけ Escape キーに反応します。
  3. フォーカス管理 — 開いたときは閉じるボタンに、閉じたときは元のボタンにフォーカスを戻します。キーボードユーザーが迷子にならないための重要な配慮です。

リアルタイム検索フィルター

ユーザーが文字を入力するたびに即座にリストが絞り込まれる UI は、現代の Web アプリの定番パターンです。

// search-demo/app.js

// ---- 商品データ(本来は API から取得する) ----
const products = [
  { id: 1, name: "ノートPC 14インチ",    category: "PC",        price: 89800 },
  { id: 2, name: "ワイヤレスマウス",      category: "周辺機器",  price: 3480 },
  { id: 3, name: "メカニカルキーボード",  category: "周辺機器",  price: 12800 },
  { id: 4, name: "27インチ 4Kモニター",  category: "ディスプレイ", price: 54800 },
  { id: 5, name: "USBハブ 7ポート",      category: "周辺機器",  price: 2980 },
  { id: 6, name: "ゲーミングPC デスクトップ", category: "PC",   price: 148000 },
  { id: 7, name: "ウェブカメラ Full HD", category: "周辺機器",  price: 5980 },
  { id: 8, name: "32インチ ゲーミングモニター", category: "ディスプレイ", price: 42800 },
];

// ---- 状態変数 ----
let searchQuery = ""; // 検索キーワード
let selectedCategory = "all"; // 選択中のカテゴリー

// ---- DOM 要素の参照 ----
const searchInputEl = document.getElementById("searchInput");
const categorySelectEl = document.getElementById("categorySelect");
const productListEl = document.getElementById("productList");
const resultCountEl = document.getElementById("resultCount");

// ---- フィルタリングロジック ----
function getFilteredProducts() {
  return products.filter((product) => {
    // カテゴリーフィルター
    const categoryMatch =
      selectedCategory === "all" || product.category === selectedCategory;

    // テキスト検索(大文字小文字を区別しない)
    const searchMatch =
      product.name.toLowerCase().includes(searchQuery.toLowerCase());

    // 両方の条件を満たす商品だけ返す
    return categoryMatch && searchMatch;
  });
}

// ---- 金額のフォーマット ----
function formatPrice(price) {
  // Intl.NumberFormat を使って日本円の表示形式に変換
  return new Intl.NumberFormat("ja-JP", {
    style: "currency",
    currency: "JPY",
  }).format(price);
}

// ---- 商品リストの render 関数 ----
function renderProducts() {
  const filtered = getFilteredProducts();

  // 件数表示を更新
  resultCountEl.textContent = `${filtered.length} 件の商品`;

  if (filtered.length === 0) {
    productListEl.innerHTML = `
      <p class="empty-state">
${searchQuery}」に一致する商品はありませんでした。
      </p>
    `;
    return;
  }

  // 商品カードのリストを生成
  productListEl.innerHTML = filtered
    .map(
      (product) => `
      <div class="product-card" data-id="${product.id}">
        <div class="product-name">${product.name}</div>
        <div class="product-meta">
          <span class="product-category">${product.category}</span>
          <span class="product-price">${formatPrice(product.price)}</span>
        </div>
      </div>
    `
    )
    .join(""); // 配列の文字列を結合して HTML 文字列にする
}

// ---- イベントリスナー ----

// 検索ボックスへの入力(input イベント = 1文字入力するたびに発火)
searchInputEl.addEventListener("input", (e) => {
  searchQuery = e.target.value;
  renderProducts(); // 即座に再描画
});

// カテゴリーの選択変更
categorySelectEl.addEventListener("change", (e) => {
  selectedCategory = e.target.value;
  renderProducts();
});

// ---- 初期表示 ----
renderProducts();

input イベントは change イベントと異なり、1 文字入力するたびに発火します。これによってリアルタイムの絞り込みが実現できます。


ソート機能

検索フィルターに加えて、「価格が安い順」「価格が高い順」「名前順」でソートする機能を追加します。

// search-demo/app.js(ソート機能を追加)

// ---- 状態変数にソートを追加 ----
let searchQuery = "";
let selectedCategory = "all";
let sortKey = "default"; // "default" | "price-asc" | "price-desc" | "name-asc"

// ---- ソート済みの商品を返すヘルパー ----
function getSortedProducts(productArray) {
  // 元の配列を変更しないよう [...productArray] でコピーを作ってからソート
  const sorted = [...productArray];

  switch (sortKey) {
    case "price-asc":
      // 価格の昇順(安い順)
      return sorted.sort((a, b) => a.price - b.price);
    case "price-desc":
      // 価格の降順(高い順)
      return sorted.sort((a, b) => b.price - a.price);
    case "name-asc":
      // 名前のアルファベット順(五十音順)
      return sorted.sort((a, b) => a.name.localeCompare(b.name, "ja"));
    default:
      // ソートなし(元の順序)
      return sorted;
  }
}

// ---- フィルタリング + ソートをまとめた関数 ----
function getProcessedProducts() {
  const filtered = getFilteredProducts(); // まずフィルタリング
  return getSortedProducts(filtered);    // 次にソート
}

// ---- render 関数を更新 ----
function renderProducts() {
  const processed = getProcessedProducts(); // フィルタリング + ソート済み

  resultCountEl.textContent = `${processed.length} 件の商品`;

  if (processed.length === 0) {
    productListEl.innerHTML = `<p class="empty-state">商品が見つかりません</p>`;
    return;
  }

  productListEl.innerHTML = processed
    .map(
      (product) => `
      <div class="product-card" data-id="${product.id}">
        <div class="product-name">${product.name}</div>
        <div class="product-meta">
          <span class="product-category">${product.category}</span>
          <span class="product-price">${formatPrice(product.price)}</span>
        </div>
      </div>
    `
    )
    .join("");
}

// ---- ソートセレクトのイベントリスナー ----
document.getElementById("sortSelect").addEventListener("change", (e) => {
  sortKey = e.target.value;
  renderProducts();
});

ソートで Array.sort() を使うときは 元の配列を変更しない(非破壊的な操作にする)ことが重要です。[...productArray] でコピーを作ってからソートすることで、元の products 配列の順序を保持します。


全部を組み合わせる:商品カタログページ

ここまで学んだタブ、モーダル、検索フィルター、ソートを組み合わせて、本格的な商品カタログページを作ります。

HTML

<!-- catalog/index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>商品カタログ</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: #f0f2f5;
      color: #2d3748;
    }

    .app {
      max-width: 900px;
      margin: 0 auto;
      padding: 2rem 1rem;
    }

    h1 {
      font-size: 1.75rem;
      font-weight: 700;
      margin-bottom: 1.5rem;
      color: #1a202c;
    }

    /* ---- コントロール(検索・ソート) ---- */
    .controls {
      display: flex;
      gap: 0.75rem;
      margin-bottom: 1.25rem;
      flex-wrap: wrap;
    }

    .search-input {
      flex: 1;
      min-width: 200px;
      padding: 0.6rem 0.875rem;
      border: 2px solid #e2e8f0;
      border-radius: 8px;
      font-size: 1rem;
      outline: none;
    }

    .search-input:focus { border-color: #667eea; }

    .sort-select {
      padding: 0.6rem 0.875rem;
      border: 2px solid #e2e8f0;
      border-radius: 8px;
      font-size: 0.9375rem;
      background: #fff;
      cursor: pointer;
    }

    /* ---- タブ ---- */
    .tab-list {
      display: flex;
      gap: 0.25rem;
      list-style: none;
      border-bottom: 2px solid #e2e8f0;
      margin-bottom: 0;
    }

    .tab-btn {
      padding: 0.5rem 1rem;
      border: 2px solid transparent;
      border-bottom: none;
      background: transparent;
      font-size: 0.875rem;
      font-weight: 500;
      cursor: pointer;
      color: #718096;
      border-radius: 6px 6px 0 0;
      position: relative;
      bottom: -2px;
      transition: color 0.2s;
    }

    .tab-btn.active {
      color: #667eea;
      font-weight: 700;
      border-color: #e2e8f0;
      border-bottom-color: #fff;
      background: #fff;
    }

    /* ---- 商品グリッド ---- */
    .product-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
      gap: 1rem;
      padding: 1.25rem;
      background: #fff;
      border: 2px solid #e2e8f0;
      border-top: none;
      border-radius: 0 0 8px 8px;
      min-height: 200px;
    }

    .product-card {
      background: #fff;
      border: 1px solid #e2e8f0;
      border-radius: 8px;
      padding: 1rem;
      cursor: pointer;
      transition: box-shadow 0.2s, transform 0.2s;
    }

    .product-card:hover {
      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
      transform: translateY(-2px);
    }

    .product-name {
      font-weight: 600;
      margin-bottom: 0.5rem;
      color: #1a202c;
    }

    .product-category {
      font-size: 0.75rem;
      background: #ebf4ff;
      color: #4299e1;
      padding: 0.125rem 0.5rem;
      border-radius: 999px;
      display: inline-block;
      margin-bottom: 0.5rem;
    }

    .product-price {
      font-weight: 700;
      font-size: 1.125rem;
      color: #667eea;
      display: block;
    }

    .result-count {
      font-size: 0.875rem;
      color: #718096;
      margin-bottom: 0.5rem;
    }

    .empty-state {
      grid-column: 1 / -1;
      text-align: center;
      padding: 2rem;
      color: #a0aec0;
    }

    /* ---- モーダル ---- */
    .modal-overlay {
      display: none;
      position: fixed;
      inset: 0;
      background: rgba(0, 0, 0, 0.5);
      z-index: 100;
      align-items: center;
      justify-content: center;
    }

    .modal-overlay.open { display: flex; }

    .modal {
      background: #fff;
      border-radius: 12px;
      padding: 2rem;
      width: 90%;
      max-width: 480px;
      box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
      animation: fadeInUp 0.2s ease;
    }

    @keyframes fadeInUp {
      from { opacity: 0; transform: translateY(16px); }
      to   { opacity: 1; transform: translateY(0); }
    }

    .modal-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 1rem;
      padding-bottom: 1rem;
      border-bottom: 1px solid #e2e8f0;
    }

    .modal-title { font-size: 1.25rem; font-weight: 700; }

    .modal-close-btn {
      background: none;
      border: none;
      font-size: 1.5rem;
      cursor: pointer;
      color: #718096;
    }

    .modal-price {
      font-size: 1.75rem;
      font-weight: 700;
      color: #667eea;
      margin: 1rem 0;
    }

    .modal-description {
      color: #4a5568;
      line-height: 1.7;
    }
  </style>
</head>
<body>
  <div class="app">
    <h1>商品カタログ</h1>

    <!-- コントロール -->
    <div class="controls">
      <input
        type="text"
        class="search-input"
        id="searchInput"
        placeholder="商品名で検索..."
      />
      <select class="sort-select" id="sortSelect">
        <option value="default">並び替え: デフォルト</option>
        <option value="price-asc">価格: 安い順</option>
        <option value="price-desc">価格: 高い順</option>
        <option value="name-asc">名前: 五十音順</option>
      </select>
    </div>

    <!-- カテゴリータブ -->
    <ul class="tab-list" id="tabList">
      <li><button class="tab-btn active" data-category="all">すべて</button></li>
      <li><button class="tab-btn" data-category="PC">PC</button></li>
      <li><button class="tab-btn" data-category="周辺機器">周辺機器</button></li>
      <li><button class="tab-btn" data-category="ディスプレイ">ディスプレイ</button></li>
    </ul>

    <!-- 件数表示 -->
    <p class="result-count" id="resultCount"></p>

    <!-- 商品グリッド -->
    <div class="product-grid" id="productGrid"></div>
  </div>

  <!-- 商品詳細モーダル -->
  <div class="modal-overlay" id="modalOverlay">
    <div class="modal" role="dialog" aria-modal="true">
      <div class="modal-header">
        <h2 class="modal-title" id="modalProductName"></h2>
        <button class="modal-close-btn" id="modalCloseBtn">×</button>
      </div>
      <p class="product-category" id="modalProductCategory"></p>
      <p class="modal-price" id="modalProductPrice"></p>
      <p class="modal-description" id="modalProductDescription"></p>
    </div>
  </div>

  <script src="catalog.js"></script>
</body>
</html>

JavaScript(全部の組み合わせ)

// catalog/catalog.js — 商品カタログ 完成版

// ============================================================
// 1. データ
// ============================================================

const products = [
  {
    id: 1,
    name: "ノートPC 14インチ",
    category: "PC",
    price: 89800,
    description: "軽量で持ち運びやすい 14 インチノートPC。Intel Core i5 搭載、メモリ 16GB、SSD 512GB。",
  },
  {
    id: 2,
    name: "ワイヤレスマウス",
    category: "周辺機器",
    price: 3480,
    description: "人間工学に基づいたデザインのワイヤレスマウス。2.4GHz 接続、乾電池 1 本で約 18 ヶ月使用可能。",
  },
  {
    id: 3,
    name: "メカニカルキーボード",
    category: "周辺機器",
    price: 12800,
    description: "テンキーレスのコンパクトなメカニカルキーボード。青軸採用、バックライト搭載。",
  },
  {
    id: 4,
    name: "27インチ 4Kモニター",
    category: "ディスプレイ",
    price: 54800,
    description: "3840x2160 の 4K 解像度。USB-C 給電対応、IPSパネルで色再現性が高い。",
  },
  {
    id: 5,
    name: "USBハブ 7ポート",
    category: "周辺機器",
    price: 2980,
    description: "USB 3.0 × 4 ポート + USB 2.0 × 3 ポートの 7 ポートハブ。電源アダプター付き。",
  },
  {
    id: 6,
    name: "ゲーミングPC デスクトップ",
    category: "PC",
    price: 148000,
    description: "Core i7、RTX 4070 搭載のゲーミングデスクトップPC。32GB メモリ、1TB NVMe SSD。",
  },
  {
    id: 7,
    name: "ウェブカメラ Full HD",
    category: "周辺機器",
    price: 5980,
    description: "1080p / 30fps のウェブカメラ。オートフォーカス・内蔵マイク搭載。プラグアンドプレイ対応。",
  },
  {
    id: 8,
    name: "32インチ ゲーミングモニター",
    category: "ディスプレイ",
    price: 42800,
    description: "2560x1440 の QHD 解像度。165Hz リフレッシュレート、1ms 応答速度。ゲームに最適。",
  },
];

// ============================================================
// 2. 状態変数(State)— 複数の状態が絡み合っている
// ============================================================

let searchQuery = "";         // 検索キーワード
let selectedCategory = "all"; // 選択中のカテゴリー(タブ)
let sortKey = "default";      // ソートの種類
let selectedProductId = null; // 詳細モーダルに表示する商品 ID

// ============================================================
// 3. DOM 要素への参照
// ============================================================

const searchInputEl = document.getElementById("searchInput");
const sortSelectEl = document.getElementById("sortSelect");
const tabListEl = document.getElementById("tabList");
const productGridEl = document.getElementById("productGrid");
const resultCountEl = document.getElementById("resultCount");
const modalOverlayEl = document.getElementById("modalOverlay");
const modalCloseBtnEl = document.getElementById("modalCloseBtn");

// ============================================================
// 4. データ処理(フィルタリング + ソート)
// ============================================================

function getProcessedProducts() {
  // フィルタリング
  const filtered = products.filter((p) => {
    const categoryMatch = selectedCategory === "all" || p.category === selectedCategory;
    const searchMatch = p.name.toLowerCase().includes(searchQuery.toLowerCase());
    return categoryMatch && searchMatch;
  });

  // ソート(元のデータを変更しないようにコピーしてから)
  const sorted = [...filtered];
  switch (sortKey) {
    case "price-asc":
      sorted.sort((a, b) => a.price - b.price);
      break;
    case "price-desc":
      sorted.sort((a, b) => b.price - a.price);
      break;
    case "name-asc":
      sorted.sort((a, b) => a.name.localeCompare(b.name, "ja"));
      break;
  }
  return sorted;
}

// 金額のフォーマット
function formatPrice(price) {
  return new Intl.NumberFormat("ja-JP", {
    style: "currency",
    currency: "JPY",
  }).format(price);
}

// ============================================================
// 5. 商品グリッドの render 関数
// ============================================================

function renderProducts() {
  const processed = getProcessedProducts();

  // 件数を更新
  resultCountEl.textContent = `${processed.length} 件の商品が見つかりました`;

  if (processed.length === 0) {
    productGridEl.innerHTML = `
      <p class="empty-state">条件に一致する商品はありません。</p>
    `;
    return;
  }

  // 商品カードを生成
  productGridEl.innerHTML = processed
    .map(
      (p) => `
      <div class="product-card" data-id="${p.id}">
        <p class="product-name">${p.name}</p>
        <span class="product-category">${p.category}</span>
        <span class="product-price">${formatPrice(p.price)}</span>
      </div>
    `
    )
    .join("");
}

// ============================================================
// 6. タブ切り替え
// ============================================================

function switchCategory(category) {
  selectedCategory = category;

  // タブボタンのアクティブ状態を更新
  tabListEl.querySelectorAll(".tab-btn").forEach((btn) => {
    btn.classList.toggle("active", btn.dataset.category === category);
  });

  // 商品グリッドを再描画
  renderProducts();
}

tabListEl.addEventListener("click", (e) => {
  const btn = e.target.closest(".tab-btn");
  if (btn) switchCategory(btn.dataset.category);
});

// ============================================================
// 7. モーダル(商品詳細)
// ============================================================

function openProductModal(productId) {
  const product = products.find((p) => p.id === productId);
  if (!product) return;

  selectedProductId = productId;

  // モーダルの中身を更新
  document.getElementById("modalProductName").textContent = product.name;
  document.getElementById("modalProductCategory").textContent = product.category;
  document.getElementById("modalProductPrice").textContent = formatPrice(product.price);
  document.getElementById("modalProductDescription").textContent = product.description;

  // モーダルを表示
  modalOverlayEl.classList.add("open");
  document.body.style.overflow = "hidden";
  modalCloseBtnEl.focus();
}

function closeModal() {
  modalOverlayEl.classList.remove("open");
  document.body.style.overflow = "";
  selectedProductId = null;
}

// ✕ ボタンで閉じる
modalCloseBtnEl.addEventListener("click", closeModal);

// 背景クリックで閉じる
modalOverlayEl.addEventListener("click", (e) => {
  if (e.target === modalOverlayEl) closeModal();
});

// Escape キーで閉じる
document.addEventListener("keydown", (e) => {
  if (e.key === "Escape" && modalOverlayEl.classList.contains("open")) {
    closeModal();
  }
});

// 商品カードのクリックでモーダルを開く(イベントデリゲーション)
productGridEl.addEventListener("click", (e) => {
  const card = e.target.closest(".product-card");
  if (card) {
    openProductModal(Number(card.dataset.id));
  }
});

// ============================================================
// 8. 検索 & ソートのイベントリスナー
// ============================================================

searchInputEl.addEventListener("input", (e) => {
  searchQuery = e.target.value;
  renderProducts();
});

sortSelectEl.addEventListener("change", (e) => {
  sortKey = e.target.value;
  renderProducts();
});

// ============================================================
// 9. 初期表示
// ============================================================

renderProducts();
⚠️ コードがどんどん複雑になっていることに気づきましたか?

catalog.js は約 180 行になりました。機能を増やすごとにコードが増えていきます。

現在のコードには次のような問題があります:

  1. 状態変数が 4 つ散在しているsearchQuery, selectedCategory, sortKey, selectedProductId が別々の変数で、どれが変わったら何を再描画すべきか追跡が難しい

  2. renderProducts() を何箇所からも呼んでいる — 検索ボックス、ソート選択、タブ切り替えのそれぞれで renderProducts() を呼んでいる。モーダルは別の関数が DOM を直接更新している。「どの状態の変化がどの DOM の更新をトリガーするか」 が一目でわからない

  3. 2 種類の DOM 更新戦略が混在している — 商品グリッドは renderProducts() で全部作り直すが、モーダルの中身は個別に textContent を書き換えている。統一されていない

  4. コンポーネントの境界がない — タブ、検索ボックス、商品グリッド、モーダルが全部フラットな関数の集合になっている。どの関数がどの UI の責任を持つのか、わかりにくい

これが「バニラ JS の限界」です。React や Vue はこれらの問題を解決するために生まれました。


複雑さの分析

カタログページを作り終えて、コードがどれだけ複雑になったかを振り返りましょう。

(a) 複数の render 関数が異なる DOM セクションを管理している

// 商品グリッドは renderProducts() が管理
function renderProducts() { /* ... */ }

// モーダルの中身は openProductModal() が直接 DOM を変更
function openProductModal(id) {
  document.getElementById("modalProductName").textContent = product.name;
  // ...
}

// タブのアクティブ状態は switchCategory() が管理
function switchCategory(category) {
  tabListEl.querySelectorAll(".tab-btn").forEach(/* ... */);
}

(b) 状態が複数の変数にバラバラに分散している

let searchQuery = "";
let selectedCategory = "all";
let sortKey = "default";
let selectedProductId = null;
// ↑ これら 4 つの変数が連動して UI に影響するが、
//   どれが変わったときに何を再描画すべきか、
//   コードを全部読まないとわからない

(c) イベントリスナーが積み重なっている

searchInputEl.addEventListener("input", /* ... */);
sortSelectEl.addEventListener("change", /* ... */);
tabListEl.addEventListener("click", /* ... */);
productGridEl.addEventListener("click", /* ... */);
modalCloseBtnEl.addEventListener("click", /* ... */);
modalOverlayEl.addEventListener("click", /* ... */);
document.addEventListener("keydown", /* ... */);
// ↑ 7 つのリスナーが登録されている。
//   アプリが大きくなるほど増え続ける

(d) 状態が変わったとき、何を再描画すべきかが不明確

sortKey = e.target.value;
renderProducts(); // ← 商品グリッドだけ再描画すればいい

selectedCategory = category;
// ← タブのアクティブ状態も更新しなければならない
//   renderProducts() だけでは不足

(e) コンポーネントの境界がない

タブ UI、検索ボックス、商品グリッド、モーダルは、概念的には別々の「コンポーネント」です。しかし、バニラ JS のコードでは、これらがすべて同じスコープの関数として並んでいます。あるコンポーネントのロジックを変更すると、別のコンポーネントに影響することがあります。

次のチャプターでは、この問題がさらに深刻になるケースを見ていきます。 そして、React がどのようにこれらの問題を根本から解決するかを学びます。


✍ チャレンジ:お気に入り機能を追加する

商品カタログに「お気に入り」機能を追加してみましょう。これはバニラ JS のコードに新しい機能を追加することの難しさを体験するためのチャレンジです。

仕様:

  • 各商品カードに「お気に入り」ボタン(ハートアイコン)を表示する
  • ボタンをクリックするとお気に入り状態がトグルする(追加 / 解除)
  • お気に入りの商品はカードのスタイルが変わる(例: ボーダーを黄色にする)
  • フィルターに「お気に入りだけ表示」チェックボックスを追加する
  • お気に入りの状態は localStorage に保存して、リロードしても保持する

考えるべきこと:

  1. 状態変数の追加 — お気に入りの状態をどこに保存するか?Set<number> で ID を管理する方法が便利です

    let favoritedIds = new Set(); // Set は重複を自動で除外する
  2. フィルタリングロジックの変更getProcessedProducts() に「お気に入りのみ」のフィルターを追加する

  3. render 関数の変更 — 各商品カードにハートボタンを追加し、favoritedIds に含まれているかどうかでスタイルを変える

  4. イベントデリゲーションの変更productGridEl のクリックハンドラーで、商品カードのクリックとハートボタンのクリックを区別する

    productGridEl.addEventListener("click", (e) => {
      if (e.target.closest(".favorite-btn")) {
        // お気に入りトグル
      } else if (e.target.closest(".product-card")) {
        // 商品詳細モーダルを開く
      }
    });
  5. localStorage との同期 — お気に入りが変わるたびに localStorage.setItem("favorites", JSON.stringify([...favoritedIds])) で保存する

この機能を実装してみると、既存のコードのあちこちを変更しなければならない ことに気づくでしょう。これが「バニラ JS での機能追加の難しさ」です。React では、新しい機能を追加するために既存のコンポーネントを変更する範囲が明確に限定されます。


まとめ

このチャプターでは、タブ UI、モーダルダイアログ、リアルタイム検索フィルター、ソートといった実践的な UI パターンを実装し、最終的にそれらすべてを組み合わせた商品カタログページを作りました。

学んだ UI パターンと技法

パターン核心のテクニック
タブ UI全パネルを非表示にしてから、対象だけ表示する
モーダルe.target === overlay で背景クリックを検出する
検索フィルターinput イベント + Array.filter() でリアルタイム絞り込み
ソート[...array].sort() でコピーを作ってから並び替える
イベントデリゲーション動的な要素には親要素でイベントをまとめて処理する

バニラ JS の限界

コードが大きくなるほど、以下の問題が顕在化します:

次のステップ

ここまでの 7 つのチャプターで、バニラ JavaScript の DOM 操作をしっかり学びました。これからは React を学びます。React がどのように設計されているか、なぜこのような問題を解決できるのかを、自分の手でバニラ JS を書いた経験をベースに理解することができます。

バニラ JS での苦労があるからこそ、React の価値が本当に理解できます。