ツリービュー(Tree View)— チェックボックス連動

データ表示 中級

このコンポーネントについて

JSONファイルから階層データを読み込み、チェックボックス付きのツリービューをバニラJSで描画します。 チェックボックスは親子間で連動し、親をチェックすると子孫がすべてチェックされ、子の一部だけチェックすると親が中間状態(indeterminate)になります。

indeterminate はHTMLの属性では設定できずJavaScriptからしか操作できないプロパティで、チェックでも未チェックでもない「一部選択」を視覚的に示します。 選択済みのリーフノード数を常にカウント表示するため、ユーザーは現在何件選択しているかを把握できます。 商品カテゴリのフィルター選択・アクセス権限の設定・メニュー項目の表示/非表示切り替えなど、階層データの選択操作が必要なUIに適しています。

  • JSONフェッチ&再帰DOM生成fetch(){ tree: [...] } 形式のJSONを取得し、再帰関数でチェックボックス付きツリーを構築する
  • 親→子孫への伝播 — 親チェックボックスの状態変化時、子孫のチェックボックスをすべて同じ状態(checked / unchecked)に更新する
  • 子→親への伝播(祖先更新) — 子チェックボックスの変化後、updateAncestors() を再帰呼び出しして祖先ノードの状態を更新する
  • indeterminate(中間)状態 — 子の一部がチェック済みの場合、親を indeterminate = true に設定する。JS専用プロパティであり、CSSで「—」のような視覚表現がブラウザ標準で適用される
  • 選択カウント表示 — チェック済みリーフノード数 / 総リーフノード数をリアルタイムで表示する
  • 展開・折りたたみ — トグルボタン(▾ / ▸)で各ノードの開閉が可能

実装のポイント・注意点

indeterminate はJavaScript専用プロパティです。<input indeterminate> のようなHTML属性は存在しません。必ず checkbox.indeterminate = true でJSから設定してください。Reactで言えば checked={null} 相当で、ブラウザはこの状態を「—(ダッシュ)」などの視覚表現で示します。

祖先の状態更新には :scope セレクタが必要です。updateAncestors() 内で「直接の子ノードのチェックボックスのみ」を取得する際、:scope > .tv2-item > .tv2-row > .tv2-checkbox を使います。:scope を省略すると孫以下まで含まれてしまい、親の checked / indeterminate 判定が正しく機能しなくなります。

indeterminate 状態のチェックボックスをユーザーがクリックした場合、ブラウザは自動的に indeterminate を解除して checked = true に変化させます(Chrome・Firefoxとも共通)。その後 change イベントが発火するため handleCheck()updateAncestors() の連鎖が正常に動きます。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。

デモ

0 / 0 カテゴリー選択中

    サンプルソース

    4つのファイルを同じフォルダに保存し、ローカルサーバー(VS Code Live Server等)経由で index.html を開くと動作確認できます。
    ファイル名:index.html / style.css / script.js + data/ フォルダに data.jsonfetch を使用しているため file:// での直接表示は動作しません(CORSエラー)。 保存時の文字コードは UTF-8 を指定してください。

    <!DOCTYPE html>
    <html lang="ja">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>ツリービュー チェックボックス連動 サンプル</title>
      <link rel="stylesheet" href="./style.css">
    </head>
    <body>
    
    <div class="tv2-wrap">
      <!-- 選択カウント表示 -->
      <p class="tv2-count" id="tv2-count">0 / 0 カテゴリー選択中</p>
    
      <!-- ツリー本体(JSで動的生成) -->
      <ul class="tv2-tree" id="tv2-tree" role="tree"></ul>
    </div>
    
    <script src="./script.js"></script>
    </body>
    </html>
    /* ツリービュー チェックボックス連動 — style.css */
    *, *::before, *::after { box-sizing: border-box; }
    
    :root {
      --color-primary:  #2B7FE8;
      --color-hover:    #F4F6F9;
      --color-border:   #D0D7E0;
      --color-muted:    #5A6A7A;
      --color-text:     #1A2533;
    }
    
    body {
      font-family: sans-serif;
      padding: 24px;
      max-width: 500px;
      margin: 0 auto;
      background: #F8FAFC;
      color: var(--color-text);
    }
    
    /* ---- ラッパー ---- */
    .tv2-wrap {
      background: #fff;
      border: 1px solid var(--color-border);
      border-radius: 10px;
      padding: 16px;
    }
    
    /* ---- 選択カウント ---- */
    .tv2-count {
      font-size: 13px;
      color: var(--color-muted);
      margin: 0 0 12px;
    }
    
    /* ---- ツリー本体 ---- */
    .tv2-tree,
    .tv2-children {
      list-style: none;
      padding: 0;
      margin: 0;
    }
    
    .tv2-item { list-style: none; }
    
    /* ---- ノード行 ---- */
    .tv2-row {
      display: flex;
      align-items: center;
      gap: 6px;
      padding: 5px 8px;
      border-radius: 6px;
      cursor: default;
      user-select: none;
    }
    
    .tv2-row:hover { background: var(--color-hover); }
    
    /* ---- チェックボックス ---- */
    .tv2-checkbox {
      width: 16px;
      height: 16px;
      cursor: pointer;
      flex-shrink: 0;
      accent-color: var(--color-primary);
    }
    
    /* ---- トグルボタン ---- */
    .tv2-toggle {
      width: 20px;
      height: 20px;
      display: flex;
      align-items: center;
      justify-content: center;
      background: none;
      border: none;
      cursor: pointer;
      font-size: 18px;
      font-weight: bold;
      color: #2B7FE8;
      flex-shrink: 0;
      padding: 0;
    }
    
    /* ---- ラベル ---- */
    .tv2-label {
      font-size: 14px;
      color: var(--color-text);
      cursor: pointer;
      line-height: 1.4;
    }
    // ============================================================
    // ツリービュー(チェックボックス連動)— script.js
    // ============================================================
    
    // --- JSONを読み込んでツリーを生成 ---
    fetch('./data/data.json')
      .then(function(res) { return res.json(); })
      .then(function(data) {
        var container = document.getElementById('tv2-tree');
        data.tree.forEach(function(node) {
          container.appendChild(buildNode(node, 0));
        });
        updateCount(); // 初期カウント表示(0件)
      });
    
    // --- チェックボックス付きノードをDOMとして再帰的に構築 ---
    function buildNode(node, depth) {
      var hasChildren = node.children && node.children.length > 0;
    
      var item = document.createElement('li');
      item.className = 'tv2-item';
      item.dataset.id = node.id;
      // 子を持たないリーフノードを識別するクラス(カウント用)
      if (!hasChildren) item.classList.add('is-leaf');
    
      var row = document.createElement('div');
      row.className = 'tv2-row';
      // 階層ごとに20px字下げ(基本8px + 深さ × 20px)
      row.style.paddingLeft = (depth * 20 + 8) + 'px';
    
      // チェックボックス
      var cb = document.createElement('input');
      cb.type = 'checkbox';
      cb.className = 'tv2-checkbox';
      cb.id = 'cb-' + node.id;
      cb.addEventListener('change', function() {
        handleCheck(cb, item, hasChildren);
      });
      row.appendChild(cb);
    
      // トグルボタン(親ノードのみ)
      if (hasChildren) {
        var toggle = document.createElement('button');
        toggle.className = 'tv2-toggle';
        toggle.textContent = '▾';
        toggle.setAttribute('aria-expanded', 'true');
        toggle.setAttribute('aria-label', '折りたたむ');
        toggle.addEventListener('click', function(e) {
          e.stopPropagation();
          toggleNode(this, item);
        });
        row.appendChild(toggle);
      }
    
      // ラベル(label要素でチェックボックスと連動)
      var label = document.createElement('label');
      label.className = 'tv2-label';
      label.htmlFor = 'cb-' + node.id;
      label.textContent = node.label;
      row.appendChild(label);
    
      item.appendChild(row);
    
      // 子リストを再帰的に生成
      if (hasChildren) {
        var childList = document.createElement('ul');
        childList.className = 'tv2-children';
        node.children.forEach(function(child) {
          childList.appendChild(buildNode(child, depth + 1));
        });
        item.appendChild(childList);
      }
    
      return item;
    }
    
    // --- チェック変化のメインハンドラ ---
    function handleCheck(cb, item, hasChildren) {
      if (hasChildren) {
        // 親チェック → 子孫のチェックボックスをすべて同じ状態に伝播
        var childCbs = item.querySelectorAll('.tv2-children .tv2-checkbox');
        childCbs.forEach(function(childCb) {
          childCb.checked = cb.checked;
          childCb.indeterminate = false;
        });
      }
      // 祖先ノードの状態を更新
      updateAncestors(item);
      updateCount();
    }
    
    // --- 祖先チェックボックスの checked / indeterminate を再帰更新 ---
    function updateAncestors(item) {
      var parentList = item.parentElement;
      // .tv2-children 内でなければルートノードのため終了
      if (!parentList || !parentList.classList.contains('tv2-children')) return;
    
      var parentItem = parentList.parentElement;
      if (!parentItem || !parentItem.classList.contains('tv2-item')) return;
    
      var parentCb = parentItem.querySelector(':scope > .tv2-row > .tv2-checkbox');
    
      // :scope で直接の子のチェックボックスのみ取得(孫を含めない)
      var siblingCbs = Array.from(
        parentList.querySelectorAll(':scope > .tv2-item > .tv2-row > .tv2-checkbox')
      );
    
      var checkedCount = siblingCbs.filter(function(c) { return c.checked; }).length;
      var indeterminateCount = siblingCbs.filter(function(c) { return c.indeterminate; }).length;
    
      if (checkedCount === siblingCbs.length) {
        // 全子チェック → 親を checked
        parentCb.checked = true;
        parentCb.indeterminate = false;
      } else if (checkedCount === 0 && indeterminateCount === 0) {
        // 全子未チェック → 親を未チェック
        parentCb.checked = false;
        parentCb.indeterminate = false;
      } else {
        // 一部チェック or 子に indeterminate あり → 親を indeterminate
        parentCb.checked = false;
        parentCb.indeterminate = true;
      }
    
      // さらに上の祖先に伝播
      updateAncestors(parentItem);
    }
    
    // --- チェック済みリーフノード数 / 総リーフ数を表示 ---
    function updateCount() {
      var leafCbs = document.querySelectorAll('.tv2-item.is-leaf .tv2-checkbox');
      var checkedCount = Array.from(leafCbs).filter(function(cb) { return cb.checked; }).length;
      document.getElementById('tv2-count').textContent =
        checkedCount + ' / ' + leafCbs.length + ' カテゴリー選択中';
    }
    
    // --- 展開・折りたたみを切り替える ---
    function toggleNode(btn, item) {
      var children = item.querySelector('.tv2-children');
      var isOpen = btn.getAttribute('aria-expanded') === 'true';
      if (isOpen) {
        btn.textContent = '▸';
        btn.setAttribute('aria-expanded', 'false');
        btn.setAttribute('aria-label', '展開する');
        children.style.display = 'none';
      } else {
        btn.textContent = '▾';
        btn.setAttribute('aria-expanded', 'true');
        btn.setAttribute('aria-label', '折りたたむ');
        children.style.display = '';
      }
    }
    {
      "tree": [
        {
          "id": "cat-1",
          "label": "ファッション",
          "children": [
            {
              "id": "cat-1-1",
              "label": "メンズ",
              "children": [
                { "id": "cat-1-1-1", "label": "トップス", "children": [] },
                { "id": "cat-1-1-2", "label": "ボトムス", "children": [] },
                { "id": "cat-1-1-3", "label": "アウター", "children": [] }
              ]
            },
            {
              "id": "cat-1-2",
              "label": "レディース",
              "children": [
                { "id": "cat-1-2-1", "label": "トップス", "children": [] },
                { "id": "cat-1-2-2", "label": "スカート", "children": [] },
                { "id": "cat-1-2-3", "label": "アウター", "children": [] }
              ]
            },
            {
              "id": "cat-1-3",
              "label": "キッズ",
              "children": [
                { "id": "cat-1-3-1", "label": "トップス", "children": [] },
                { "id": "cat-1-3-2", "label": "ボトムス", "children": [] }
              ]
            }
          ]
        },
        {
          "id": "cat-2",
          "label": "家電・PC",
          "children": [
            {
              "id": "cat-2-1",
              "label": "スマートフォン・タブレット",
              "children": [
                { "id": "cat-2-1-1", "label": "スマートフォン", "children": [] },
                { "id": "cat-2-1-2", "label": "タブレット", "children": [] }
              ]
            },
            {
              "id": "cat-2-2",
              "label": "PC・周辺機器",
              "children": [
                { "id": "cat-2-2-1", "label": "ノートPC", "children": [] },
                { "id": "cat-2-2-2", "label": "デスクトップPC", "children": [] },
                { "id": "cat-2-2-3", "label": "マウス・キーボード", "children": [] }
              ]
            }
          ]
        },
        {
          "id": "cat-3",
          "label": "食品・飲料",
          "children": [
            {
              "id": "cat-3-1",
              "label": "お菓子・スナック",
              "children": [
                { "id": "cat-3-1-1", "label": "チョコレート", "children": [] },
                { "id": "cat-3-1-2", "label": "スナック菓子", "children": [] }
              ]
            },
            {
              "id": "cat-3-2",
              "label": "飲料",
              "children": [
                { "id": "cat-3-2-1", "label": "コーヒー・お茶", "children": [] },
                { "id": "cat-3-2-2", "label": "ジュース・水", "children": [] }
              ]
            }
          ]
        }
      ]
    }

    AI用プロンプト

    以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

    ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。ライブラリ指定や列数変更など、要件を追記して使うのがおすすめです。

    ※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

    💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

    # ツリービュー(チェックボックス連動)作成依頼
    
    ## 概要
    JSONファイルから階層データを読み込み、チェックボックス付きのツリービューUIを実装してください。
    親と子のチェックボックスが以下のルールで連動します。
    
    ## 要件
    - fetch() でJSONファイル(tree配列: id / label / children の再帰構造)を取得する
    - 各ノードにチェックボックスを設置し、ラベル要素(<label for="">)で連動させる
    - 親チェック → 子孫のチェックボックスをすべて同じ状態(checked / unchecked)に更新する
    - 子をすべてチェック → 親を checked にする
    - 子を一部チェック → 親を indeterminate(中間)状態にする
    - 子をすべて解除 → 親を unchecked に戻す
    - indeterminate 状態は checkbox.indeterminate = true/false でJSから設定する(HTML属性では不可)
    - 状態変化は祖先チェーンを遡って再帰的に伝播する
    - チェック済みリーフノード数 / 総リーフノード数 をリアルタイム表示する
    - 子を持つノードには展開・折りたたみのトグルボタン(▾ / ▸)を設置する
    
    ## 技術仕様
    - HTML / CSS / バニラJavaScript で実装
    - 外部ライブラリ:なし
    - レスポンシブ対応:必要
    
    ## 動作詳細
    handleCheck(cb, item, hasChildren) がチェック変化の起点。
    hasChildren が true のとき querySelectorAll('.tv2-children .tv2-checkbox') で全子孫を取得して一括更新する。
    updateAncestors(item) を再帰呼び出しして祖先ノードの状態を更新する。
    祖先更新では :scope > .tv2-item > .tv2-row > .tv2-checkbox で直接の子チェックボックスのみを取得する。
    全子checked → checked, 全子unchecked(indeterminate=falseも含む) → unchecked, それ以外 → indeterminate。
    リーフノードには .is-leaf クラスを付与し、updateCount() でこのクラスを持つチェックボックスを数える。
    すべてのDOM操作は createElement / textContent を使い、innerHTML に変数を渡さない。
    
    ## 出力形式
    HTML・CSS・JavaScriptを分けて出力してください。
    各ファイルは単独でコピー&ペーストして使えるよう記述してください。