ツリービュー — 検索・絞り込み

データ表示中級

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

権限設定のような階層データを、ノード名または管理コードでリアルタイムに絞り込むツリービューです。一致したノードだけでなく、そこへたどり着くための親階層も残し、検索中は該当する経路を自動展開します。名称で「請求管理」、管理コードで「BIL」のように、利用者の手元にある情報から検索できます。権限設定、組織選択、フォルダ管理、商品カテゴリ管理など、深い階層から目的の項目を探す管理画面で使えます。

  • ノード名・管理コードの部分一致検索 — 入力ごとに検索結果を更新
  • 親階層を残す絞り込み — 一致ノードへ至る経路を保持
  • 安全なハイライト — textContent と createTextNode() で一致箇所を強調

実装のポイント・注意点

絞り込みは、DOMをその場で隠すのではなく、元データから結果ツリーを作り直します。filterTree() で「自分が一致する」または「一致する子を持つ」ノードだけを再帰的に残すと、検索結果への親階層を保てます。親ノード自身が一致しただけの場合に、子孫をすべて表示しないのもこの処理で制御します。

ハイライトには、検索語やデータを innerHTML へ結合しません。通常部分は document.createTextNode()、一致部分は mark 要素と textContent で追加します。JSONの表示名や利用者の検索語にHTMLに見える文字が含まれても、意図しない要素として実行されません。

入力ごとの即時検索は、小さなローカルデータ向けです。数百〜数千件を超えるデータやAPI検索では、Enter/検索ボタンでの実行、またはデバウンスを使います。日本語入力を扱う場合は、IME変換中に検索しないよう compositionend のタイミングも考慮してください。

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

デモ

「請求管理」「BIL」「SYS」で検索してみてください。

読み込み中です。

    サンプルソース

    4つのファイルを同じフォルダに保存し、ローカルサーバー(VS Code Live Server等)経由でindex.html を開くと動作確認できます。
    ファイル名:index.html / style.css / script.js + data/ フォルダに data.json — fetch を使用するため、file:// での直接表示は動作しません。保存時の文字コードは 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="tv3-wrap">
      <!-- 入力のたびにJSがツリーを絞り込む検索欄 -->
      <div class="tv3-search">
        <input
          class="tv3-input"
          id="tv3-search"
          type="search"
          placeholder="名称または管理コードで検索"
          aria-label="権限を検索"
          aria-controls="tv3-tree"
        >
      </div>
    
      <!-- JSが「○件一致」などの状態を表示する領域 -->
      <p class="tv3-result" id="tv3-result" aria-live="polite">読み込み中です。</p>
    
      <!-- JSONからJSがツリーのli要素を組み立てる -->
      <ul class="tv3-tree" id="tv3-tree" role="tree"></ul>
    
      <!-- 検索結果が0件のときだけ表示するメッセージ -->
      <div class="tv3-empty" id="tv3-empty" hidden>
        <p>該当する権限が見つかりません。名称または管理コードを変えてください。</p>
      </div>
    </div>
    
    <!-- 初期状態(検索なし)へ戻すボタン -->
    <div class="demo-controls">
      <button class="reset-btn" type="button" onclick="resetDemo()">リセット</button>
    </div>
    
    <script src="./script.js"></script>
    </body>
    </html>
    /* ツリービュー 検索・絞り込み — style.css */
    *, *::before, *::after {
      box-sizing: border-box;
    }
    
    :root {
      --tv3-blue: #2B7FE8;
      --tv3-border: #D0D7E0;
      --tv3-text: #1A2332;
      --tv3-sub: #5A6A7A;
    }
    
    body {
      max-width: 600px;
      margin: 0 auto;
      padding: 24px;
      color: var(--tv3-text);
      background: #F8FAFC;
      font-family: sans-serif;
    }
    
    /* ---- デモ全体 ---- */
    .tv3-wrap {
      width: 100%;
      max-width: 520px;
      padding: 16px;
      color: var(--tv3-text);
      background: #fff;
      border: 1px solid var(--tv3-border);
      border-radius: 10px;
    }
    
    /* ---- 検索欄 ---- */
    .tv3-search {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-bottom: 10px;
    }
    
    .tv3-input {
      width: 100%;
      min-height: 44px;
      padding: 9px 12px;
      color: var(--tv3-text);
      background: #fff;
      border: 1.5px solid var(--tv3-border);
      border-radius: 7px;
      font: inherit;
    }
    
    .tv3-input:focus {
      outline: 3px solid #E8F1FD;
      border-color: var(--tv3-blue);
    }
    
    /* ---- 検索結果・ツリー ---- */
    .tv3-result {
      min-height: 20px;
      margin: 0 0 10px;
      color: var(--tv3-sub);
      font-size: 13px;
    }
    
    .tv3-tree,
    .tv3-children {
      margin: 0;
      padding: 0;
      list-style: none;
    }
    
    .tv3-item {
      list-style: none;
    }
    
    /* ---- ノード行 ---- */
    .tv3-row {
      display: flex;
      align-items: center;
      gap: 6px;
      min-height: 44px;
      padding: 5px 8px;
      border-radius: 6px;
    }
    
    .tv3-row:hover {
      background: #F4F6F9;
    }
    
    /* ---- 展開ボタンとラベル ---- */
    .tv3-toggle,
    .tv3-spacer {
      display: inline-flex;
      flex: 0 0 28px;
      align-items: center;
      justify-content: center;
      width: 28px;
      height: 28px;
    }
    
    .tv3-toggle {
      color: var(--tv3-sub);
      background: transparent;
      border: 0;
      border-radius: 4px;
      cursor: pointer;
      font-size: 14px;
    }
    
    .tv3-toggle:hover {
      color: var(--tv3-blue);
      background: #E8F1FD;
    }
    
    .tv3-toggle[aria-disabled="true"] {
      cursor: default;
      opacity: .7;
    }
    
    .tv3-label {
      overflow-wrap: anywhere;
      font-size: 14px;
      font-weight: 600;
    }
    
    .tv3-code {
      margin-left: auto;
      overflow-wrap: anywhere;
      color: var(--tv3-sub);
      font: 12px/1.3 ui-monospace, SFMono-Regular, Consolas, monospace;
    }
    
    /* ---- 検索一致箇所 ---- */
    .tv3-row mark {
      padding: 0 2px;
      color: inherit;
      background: #FFEC8A;
      border-radius: 2px;
    }
    
    /* ---- 0件表示・リセット ---- */
    .tv3-empty {
      display: flex;
      align-items: center;
      justify-content: center;
      min-height: 130px;
      padding: 20px;
      color: var(--tv3-sub);
      background: #fff;
      border: 1px dashed var(--tv3-border);
      border-radius: 8px;
      text-align: center;
    }
    
    .tv3-empty[hidden],
    .tv3-children[hidden] {
      display: none;
    }
    
    .demo-controls {
      margin-top: 12px;
    }
    
    .reset-btn {
      padding: 6px 16px;
      color: var(--tv3-sub);
      background: #fff;
      border: 1.5px solid var(--tv3-border);
      border-radius: 6px;
      cursor: pointer;
      font: 13px sans-serif;
      transition: background .15s, border-color .15s;
    }
    
    .reset-btn:hover {
      background: #F4F6F9;
      border-color: #9AA5B4;
    }
    
    /* ---- 画面幅が狭いときはコードを次行に送る ---- */
    @media (max-width: 480px) {
      .tv3-row {
        align-items: flex-start;
        flex-wrap: wrap;
      }
    
      .tv3-code {
        width: calc(100% - 34px);
        margin-left: 34px;
      }
    }
    // ============================================================
    // ツリービュー 検索・絞り込み — script.js
    // ============================================================
    
    // --- JSONの読み込み先と、操作するHTML要素 ---
    var JSON_PATH = './data/data.json';
    var treeData = [];
    var searchInput = document.getElementById('tv3-search');
    var treeElement = document.getElementById('tv3-tree');
    var resultElement = document.getElementById('tv3-result');
    var emptyElement = document.getElementById('tv3-empty');
    
    // --- JSONを読み込んだ後、検索なしのツリーを最初に描画する ---
    fetch(JSON_PATH)
      .then(function(response) {
        if (!response.ok) throw new Error('データを取得できませんでした');
        return response.json();
      })
      .then(function(data) {
        treeData = data.tree;
        renderTree('');
      })
      .catch(function() {
        resultElement.textContent = 'データを読み込めませんでした。';
      });
    
    // 小さなローカルデータ向けの即時検索。大量データやAPI検索では、
    // デバウンスやEnter/検索ボタンでの実行を検討する。
    // 入力のたびに、検索語でツリーを描画し直す
    searchInput.addEventListener('input', function() {
      renderTree(searchInput.value.trim());
    });
    
    // --- 日本語・英数字を同じ条件で比較するために小文字化する ---
    function normalize(value) {
      return String(value).toLocaleLowerCase('ja-JP');
    }
    
    // ノード名または管理コードに検索語が含まれるかを判定する
    function isMatched(node, query) {
      var keyword = normalize(query);
      return normalize(node.label).includes(keyword) ||
        normalize(node.code).includes(keyword);
    }
    
    // --- 一致ノードと、その親経路だけを残した新しいツリーを作る ---
    function filterTree(nodes, query) {
      return nodes.reduce(function(result, node) {
        // 先に子を絞り込む。子が残れば親も表示する。
        var children = filterTree(node.children || [], query);
    
        if (isMatched(node, query) || children.length > 0) {
          result.push({
            id: node.id,
            label: node.label,
            code: node.code,
            children: children
          });
        }
    
        return result;
      }, []);
    }
    
    // 検索結果メッセージ用に、元データ内の一致数を数える
    function countMatches(nodes, query) {
      return nodes.reduce(function(total, node) {
        var ownMatch = isMatched(node, query) ? 1 : 0;
        return total + ownMatch + countMatches(node.children || [], query);
      }, 0);
    }
    
    // --- 一致箇所をmark要素で強調する(innerHTMLは使わない) ---
    function appendHighlightedText(element, text, query) {
      if (!query) {
        element.textContent = text;
        return;
      }
    
      var value = String(text);
      var lowerValue = normalize(value);
      var lowerQuery = normalize(query);
      var start = 0;
      var index = lowerValue.indexOf(lowerQuery, start);
    
      while (index !== -1) {
        // 一致する前の通常テキスト
        if (index > start) {
          element.appendChild(document.createTextNode(value.slice(start, index)));
        }
    
        // 一致部分だけをmark要素として追加
        var mark = document.createElement('mark');
        mark.textContent = value.slice(index, index + query.length);
        element.appendChild(mark);
    
        start = index + query.length;
        index = lowerValue.indexOf(lowerQuery, start);
      }
    
      // 最後の一致より後ろに残ったテキスト
      if (start < value.length) {
        element.appendChild(document.createTextNode(value.slice(start)));
      }
    }
    
    // --- 展開ボタンと子リストの状態をそろえる ---
    function setExpanded(button, children, expanded) {
      children.hidden = !expanded;
      button.textContent = expanded ? '▾' : '▸';
      button.setAttribute('aria-expanded', String(expanded));
      button.setAttribute('aria-label', expanded ? '折りたたむ' : '展開する');
    }
    
    // --- 1ノード分のDOMを再帰的に組み立てる ---
    function buildNode(node, depth, query) {
      var hasChildren = node.children && node.children.length > 0;
    
      var item = document.createElement('li');
      item.className = 'tv3-item';
      item.setAttribute('role', 'treeitem');
    
      var row = document.createElement('div');
      row.className = 'tv3-row';
      // 深い階層ほど20pxずつ字下げする
      row.style.paddingLeft = (depth * 20 + 8) + 'px';
    
      if (hasChildren) {
        var button = document.createElement('button');
        button.type = 'button';
        button.className = 'tv3-toggle';
    
        var childList = document.createElement('ul');
        childList.className = 'tv3-children';
        childList.setAttribute('role', 'group');
    
        // 検索中は親経路を開く。通常時は第1階層だけ開く。
        var expanded = query !== '' || depth === 0;
        setExpanded(button, childList, expanded);
    
        if (query) {
          // 検索中は結果の経路を閉じないよう操作を無効化する
          button.setAttribute('aria-disabled', 'true');
        } else {
          button.addEventListener('click', function() {
            var isClosed = button.getAttribute('aria-expanded') !== 'true';
            setExpanded(button, childList, isClosed);
          });
        }
    
        row.appendChild(button);
        node.children.forEach(function(child) {
          childList.appendChild(buildNode(child, depth + 1, query));
        });
        item.appendChild(row);
        item.appendChild(childList);
      } else {
        // 子を持たない行にも、展開ボタンと同じ幅の余白を置いてそろえる
        var spacer = document.createElement('span');
        spacer.className = 'tv3-spacer';
        spacer.setAttribute('aria-hidden', 'true');
        row.appendChild(spacer);
        item.appendChild(row);
      }
    
      var label = document.createElement('span');
      label.className = 'tv3-label';
      appendHighlightedText(label, node.label, query);
      row.appendChild(label);
    
      var code = document.createElement('span');
      code.className = 'tv3-code';
      appendHighlightedText(code, node.code, query);
      row.appendChild(code);
    
      return item;
    }
    
    // --- 検索語に応じてツリー全体を描画する ---
    function renderTree(query) {
      if (!treeData.length) return;
    
      var visibleTree = query ? filterTree(treeData, query) : treeData;
      treeElement.replaceChildren();
    
      if (!visibleTree.length) {
        treeElement.hidden = true;
        emptyElement.hidden = false;
        resultElement.textContent = '0件一致';
        return;
      }
    
      treeElement.hidden = false;
      emptyElement.hidden = true;
    
      visibleTree.forEach(function(node) {
        treeElement.appendChild(buildNode(node, 0, query));
      });
    
      resultElement.textContent = query
        ? countMatches(treeData, query) + '件一致'
        : '全18件を表示中';
    }
    
    // --- リセットボタンから呼び出す関数 ---
    function resetDemo() {
      searchInput.value = '';
      renderTree('');
    }
    {
      "tree": [
        {
          "id": "system",
          "label": "システム管理",
          "code": "SYS",
          "children": [
            {
              "id": "user-management",
              "label": "ユーザー管理",
              "code": "SYS-USR",
              "children": [
                {
                  "id": "user-list",
                  "label": "ユーザー一覧",
                  "code": "SYS-USR-LST",
                  "children": []
                },
                {
                  "id": "role-management",
                  "label": "ロール管理",
                  "code": "SYS-USR-ROL",
                  "children": []
                }
              ]
            },
            {
              "id": "permission-management",
              "label": "権限管理",
              "code": "SYS-AUTH",
              "children": [
                {
                  "id": "billing-permission",
                  "label": "請求管理",
                  "code": "SYS-AUTH-BIL",
                  "children": []
                },
                {
                  "id": "report-permission",
                  "label": "レポート閲覧",
                  "code": "SYS-AUTH-RPT",
                  "children": []
                }
              ]
            }
          ]
        },
        {
          "id": "business",
          "label": "業務管理",
          "code": "BIZ",
          "children": [
            {
              "id": "sales",
              "label": "営業管理",
              "code": "BIZ-SAL",
              "children": [
                {
                  "id": "customer",
                  "label": "顧客管理",
                  "code": "BIZ-SAL-CUS",
                  "children": []
                },
                {
                  "id": "quotation",
                  "label": "見積管理",
                  "code": "BIZ-SAL-QUO",
                  "children": []
                }
              ]
            },
            {
              "id": "accounting",
              "label": "経理管理",
              "code": "BIZ-ACC",
              "children": [
                {
                  "id": "invoice",
                  "label": "請求書発行",
                  "code": "BIZ-ACC-INV",
                  "children": []
                },
                {
                  "id": "expense",
                  "label": "経費精算",
                  "code": "BIZ-ACC-EXP",
                  "children": []
                }
              ]
            }
          ]
        },
        {
          "id": "common",
          "label": "共通設定",
          "code": "COM",
          "children": [
            {
              "id": "notification",
              "label": "通知設定",
              "code": "COM-NTF",
              "children": [
                {
                  "id": "mail-notification",
                  "label": "メール通知",
                  "code": "COM-NTF-ML",
                  "children": []
                },
                {
                  "id": "inapp-notification",
                  "label": "アプリ内通知",
                  "code": "COM-NTF-APP",
                  "children": []
                }
              ]
            }
          ]
        }
      ]
    }

    AI用プロンプト

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

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

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

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

    # ツリービュー検索・絞り込み 作成依頼
    
    ## 概要
    ノード名・管理コードでリアルタイム検索できる、権限設定用ツリービューを作成してください。
    
    ## 要件
    - JSONファイルから id / label / code / children を持つ再帰的なツリーデータを fetch() で読み込む
    - 入力ごとに、名称・管理コードを大文字・小文字を区別しない部分一致で検索する
    - 一致ノードと、そこへ至る親階層だけを表示する。親自身が一致しただけの場合に、子孫を無条件で全表示しない
    - 検索中は結果に含まれる親階層をすべて展開し、トグルボタンにはクリック処理を付けず閉じられない状態にする
    - 名称・管理コードの一致箇所だけを mark 要素でハイライトする
    - 一致したノード数だけを表示し、0件時は空状態メッセージを表示する
    - 検索語を消すかリセットボタンを押すと、ルートノードの子リストだけを展開した初期状態へ戻す
    - 検索語・データを innerHTML に連結せず、createElement / textContent / createTextNode でDOMを組み立てる
    
    ## 技術仕様
    - HTML / CSS / バニラJavaScript
    - 外部ライブラリ:なし
    - レスポンシブ対応:必要
    - fetch() を使うため、動作確認は file:// ではなくローカルサーバー経由で行う
    - 入力欄・トグルボタン・検索結果の状態が、キーボード操作とスクリーンリーダーでも分かるようにする
    - 小規模なローカルデータは即時検索にし、大量データやAPI検索では検索ボタン・Enter実行・デバウンスを検討する注意をコードコメントで示す
    
    ## 出力形式
    HTML・CSS・JavaScript・JSONを分けて出力してください。