ツリービュー(Tree View)— 展開・折りたたみ

データ表示 中級

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

JSONファイルから階層構造データを読み込み、折りたたみ可能なツリービューをバニラJSで描画します。 再帰関数でDOMを構築するため、データの深さに制限がなく3階層・4階層のカテゴリ構造にも対応できます。

各ノードのクリックで展開・折りたたみをトグルでき、「全展開」「全折りたたみ」ボタンで一括操作も可能です。 ノードをクリックすると選択状態(ハイライト)になり、選択中のノード名を画面に表示するため、 選択したカテゴリを後続の処理に渡すUIの起点として使えます。 商品カテゴリ管理・組織図・ファイルツリー・サイドメニューなど、階層データを扱うあらゆる業務アプリに適用できます。

  • JSONフェッチ&再帰DOM生成fetch(){ tree: [...] } 形式のJSONを取得し、buildNode() を再帰呼び出しして <li>/<ul> のツリーを構築する
  • 展開・折りたたみトグル — 子を持つノードにトグルボタン(▾ / ▸)を設置し、クリックで <ul> の表示を切り替える。aria-expanded で支援技術にも通知する
  • 全展開・全折りたたみ — ツールバーのボタン1つで全ノードを一括操作できる
  • ノード選択状態 — ノードクリックで .is-selected クラスを付け外しし、選択中のノード名をラベルに表示する
  • リーフノード識別 — 子を持たない末端ノードはトグルボタンなし(スペーサー表示)で視覚的に区別する
  • インデント表示 — 深さに応じて padding-left で字下げし、階層を視覚的に表現する

実装のポイント・注意点

再帰関数でDOMを構築するのがツリービュー実装の核心です。buildNode(node, depth) は1つのノードと現在の深さを受け取り、<li> 要素を返します。子ノードがあれば <ul class="tv1-children"> を作り、各子ノードに対して buildNode(child, depth + 1) を再帰呼び出しします。JSON のネストが何階層あっても、この関数1つですべて対応できます。

トグルボタンのクリックと行全体のクリックを分離するために e.stopPropagation() が必要です。トグルボタンは .tv1-row(行)の子要素なので、ボタンをクリックするとイベントが親の行にも伝播し、展開操作と選択操作が同時に起きてしまいます。stopPropagation() で親への伝播を止めることで、ボタン=開閉・行=選択とはっきり分けられます。

リーフノードにはスペーサーを置くことでラベルのインデントが揃います。トグルボタンと同じ幅(20px)の <span class="tv1-toggle-spacer"> を設置することで、親ノードと末端ノードのラベル開始位置が一致します。

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

デモ

選択中:

    サンプルソース

    4つのファイルを同じフォルダに保存し、簡易サーバーで index.html を開くと動作確認できます。
    ファイル構成:index.html / style.css / script.js / data/data.jsondata フォルダを作って中に配置)
    保存時の文字コードは UTF-8 を指定してください。fetch()file:// では動作しないため、VS Code の Live Server 等で開いてください。

    <!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="tv1-wrap">
      <div class="tv1-toolbar">
        <button class="tv1-btn" onclick="expandAll()">全展開</button>
        <button class="tv1-btn" onclick="collapseAll()">全折りたたみ</button>
        <span class="tv1-selected-label">選択中:<span id="tv1-selected">—</span></span>
      </div>
      <!-- ツリー本体(JSで動的生成) -->
      <ul class="tv1-tree" id="tv1-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-selected-bg:   #E8F0FB;
      --color-selected-text: #1A5EC7;
      --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);
    }
    
    /* ---- ラッパー ---- */
    .tv1-wrap {
      background: #fff;
      border: 1px solid var(--color-border);
      border-radius: 10px;
      padding: 16px;
    }
    
    /* ---- ツールバー ---- */
    .tv1-toolbar {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-bottom: 12px;
      flex-wrap: wrap;
    }
    
    .tv1-btn {
      padding: 5px 12px;
      font-size: 13px;
      border: 1.5px solid var(--color-border);
      border-radius: 6px;
      background: #fff;
      cursor: pointer;
      font-family: sans-serif;
      transition: background 0.15s;
    }
    
    .tv1-btn:hover { background: var(--color-hover); }
    
    .tv1-selected-label {
      font-size: 13px;
      color: var(--color-muted);
      margin-left: auto;
    }
    
    /* ---- ツリー本体 ---- */
    .tv1-tree,
    .tv1-children {
      list-style: none;
      padding: 0;
      margin: 0;
    }
    
    .tv1-item { list-style: none; }
    
    /* ---- ノード行 ---- */
    .tv1-row {
      display: flex;
      align-items: center;
      gap: 4px;
      padding: 5px 8px;
      border-radius: 6px;
      cursor: pointer;
      user-select: none;
      transition: background 0.1s;
    }
    
    .tv1-row:hover { background: var(--color-hover); }
    
    .tv1-row.is-selected { background: var(--color-selected-bg); }
    
    .tv1-row.is-selected .tv1-label {
      color: var(--color-selected-text);
      font-weight: 600;
    }
    
    /* ---- トグルボタン ---- */
    .tv1-toggle {
      width: 20px;
      height: 20px;
      display: flex;
      align-items: center;
      justify-content: center;
      background: none;
      border: none;
      cursor: pointer;
      font-size: 16px;
      color: #374151;
      flex-shrink: 0;
      padding: 0;
    }
    
    /* ---- リーフノード用スペーサー ---- */
    .tv1-toggle-spacer {
      width: 20px;
      flex-shrink: 0;
      display: inline-block;
    }
    
    /* ---- ラベル ---- */
    .tv1-label {
      font-size: 14px;
      color: var(--color-text);
      line-height: 1.4;
    }
    // ============================================================
    // ツリービュー(展開・折りたたみ)— script.js
    // ============================================================
    
    var selectedId = null;
    
    // --- JSONを読み込んでツリーを生成 ---
    fetch('./data/data.json')
      .then(function(res) { return res.json(); })
      .then(function(data) {
        var container = document.getElementById('tv1-tree');
        data.tree.forEach(function(node) {
          container.appendChild(buildNode(node, 0));
        });
      });
    
    // --- ノードをDOMとして再帰的に構築 ---
    function buildNode(node, depth) {
      var hasChildren = node.children && node.children.length > 0;
    
      var item = document.createElement('li');
      item.className = 'tv1-item';
    
      var row = document.createElement('div');
      row.className = 'tv1-row';
      row.dataset.id = node.id;
      // 階層ごとに20px字下げ(基本8px + 深さ × 20px)
      row.style.paddingLeft = (depth * 20 + 8) + 'px';
    
      if (hasChildren) {
        // 子を持つノード:展開・折りたたみトグルボタン
        var toggle = document.createElement('button');
        toggle.className = 'tv1-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);
      } else {
        // リーフノード:インデントを揃えるスペーサー
        var spacer = document.createElement('span');
        spacer.className = 'tv1-toggle-spacer';
        row.appendChild(spacer);
      }
    
      // ノード名ラベル
      var label = document.createElement('span');
      label.className = 'tv1-label';
      label.textContent = node.label;
      row.appendChild(label);
    
      // 行クリックで選択状態にする
      row.addEventListener('click', function() {
        selectNode(node.id, label.textContent);
      });
    
      item.appendChild(row);
    
      // 子リストを再帰的に生成
      if (hasChildren) {
        var childList = document.createElement('ul');
        childList.className = 'tv1-children';
        node.children.forEach(function(child) {
          childList.appendChild(buildNode(child, depth + 1));
        });
        item.appendChild(childList);
      }
    
      return item;
    }
    
    // --- 展開・折りたたみを切り替える ---
    function toggleNode(btn, item) {
      var children = item.querySelector('.tv1-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 = '';
      }
    }
    
    // --- ノードを選択状態にする ---
    function selectNode(id, labelText) {
      var prev = document.querySelector('.tv1-row.is-selected');
      if (prev) prev.classList.remove('is-selected');
    
      var row = document.querySelector('.tv1-row[data-id="' + id + '"]');
      if (row) row.classList.add('is-selected');
    
      selectedId = id;
      document.getElementById('tv1-selected').textContent = labelText;
    }
    
    // --- 全展開 ---
    function expandAll() {
      document.querySelectorAll('.tv1-toggle').forEach(function(btn) {
        btn.textContent = '▾';
        btn.setAttribute('aria-expanded', 'true');
        btn.setAttribute('aria-label', '折りたたむ');
        btn.closest('.tv1-item').querySelector('.tv1-children').style.display = '';
      });
    }
    
    // --- 全折りたたみ ---
    function collapseAll() {
      document.querySelectorAll('.tv1-toggle').forEach(function(btn) {
        btn.textContent = '▸';
        btn.setAttribute('aria-expanded', 'false');
        btn.setAttribute('aria-label', '展開する');
        btn.closest('.tv1-item').querySelector('.tv1-children').style.display = 'none';
      });
    }
    {
      "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用プロンプト

    このプロンプトをChatGPTやClaudeに渡すと、同様のツリービューコンポーネントをゼロから生成・カスタマイズできます。

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

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

    # ツリービュー(展開・折りたたみ)作成依頼
    
    ## 概要
    JSONファイルから階層データを読み込み、クリックで展開・折りたたみができるツリービューUIを実装してください。
    
    ## 要件
    - fetch() でJSONファイル(tree配列: id / label / children の再帰構造)を取得する
    - 再帰関数でDOMを構築し、3階層以上の深さに対応する
    - 子を持つノードにはトグルボタン(▾ / ▸)を設置し、クリックで展開・折りたたみをトグルする
    - aria-expanded 属性をトグルに応じて true / false で更新する
    - ノードをクリックすると選択状態(ハイライト)になり、選択中のノード名を画面に表示する
    - 「全展開」「全折りたたみ」ボタンで全ノードを一括操作できる
    - 子を持たないリーフノードはトグルボタンなし(同幅スペーサー表示)
    - 深さに応じて padding-left で字下げする(1階層ごとに20pxずつ増やす)
    
    ## 技術仕様
    - HTML / CSS / バニラJavaScript で実装
    - 外部ライブラリ:なし
    - レスポンシブ対応:必要
    
    ## 動作詳細
    fetch().then() でJSONを取得し data.tree 配列を再帰的にDOM生成する。
    buildNode(node, depth) を再帰呼び出しして <li class="tv1-item"> を構築する。
    子リストは <ul class="tv1-children"> に入れ、折りたたみ時は style.display = 'none' で非表示にする。
    トグルボタンのクリックイベントは e.stopPropagation() で行クリック(選択)と分離する。
    選択状態は .is-selected クラスの付け外しで管理し、同時に1ノードだけ選択される。
    すべてのDOM操作は createElement / textContent を使い、innerHTML に変数を渡さない。
    
    ## 出力形式
    HTML・CSS・JavaScriptを分けて出力してください。
    各ファイルは単独でコピー&ペーストして使えるよう記述してください。