List 2 — リスト 2 — フィルター絞り込み

データ表示 初級

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

データ件数が増えると、目的のアイテムをスクロールで探すのは非効率です。 キーワード入力やカテゴリー選択で即時に絞り込むフィルター機能を加えるだけで、操作性が大きく向上します。

このページでは、テキスト検索ボックスと部署セレクトを組み合わせたリアルタイムフィルターを紹介します。 入力のたびにDOMを再生成するのではなく、表示・非表示をCSSクラスで制御するため動作が軽快です。

  • キーワード検索 — テキストボックスへの入力に応じて、氏名・役職を含む行のみ表示する
  • 部署セレクト — JSONデータから部署名を自動抽出してセレクトを生成。選択した部署で絞り込む
  • AND絞り込み — キーワード条件と部署条件を同時に適用する(両方の条件を満たす行のみ表示)
  • 件数表示 — 絞り込み結果の件数をリアルタイムで更新する(「3件 / 10件」形式)
  • クリアボタン — キーワードと部署選択をまとめてリセットし全件表示に戻す

実装のポイント・注意点

フィルター処理はDOMを再生成せず、各 liis-hidden クラスをトグルして display: none で非表示にしています。 毎回 innerHTML を書き換えるとブラウザがDOMを作り直すコストが発生しますが、クラストグル方式なら変更が最小限で済みます。

フィルター比較に使うデータは lidata-name / data-role / data-dept 属性に事前に格納しています。 テキストノードを読み取るより属性を参照するほうがシンプルで読みやすいコードになります。

部署セレクトの選択肢はJSONデータから動的に生成しています。 データを変えれば選択肢も自動で更新されるため、ハードコードした選択肢のメンテナンスが不要になります。

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="filter-bar">
      <input class="filter-input" type="text" id="js-keyword" placeholder="氏名・役職で検索" autocomplete="off">
      <select class="filter-select" id="js-dept">
        <option value="">すべての部署</option>
      </select>
      <span class="filter-count" id="js-count"></span>
      <button class="filter-clear" id="js-clear" type="button">クリア</button>
    </div>
    <ul class="lst" id="js-list"></ul>
    
    <script src="./script.js"></script>
    </body>
    </html>
    /* リスト(フィルター絞り込み)— style.css */
    :root {
      --av1: #2B7FE8;
      --av2: #10B981;
      --av3: #F59E0B;
      --av4: #8B5CF6;
      --av5: #EF4444;
    }
    
    *, *::before, *::after { box-sizing: border-box; }
    
    body {
      font-family: sans-serif;
      padding: 24px;
      background: #f8f9fa;
    }
    
    /* フィルターバー */
    .filter-bar {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: 8px;
      margin-bottom: 16px;
      max-width: 560px;
    }
    
    .filter-input,
    .filter-select {
      padding: 7px 12px;
      font-size: 13px;
      font-family: sans-serif;
      color: #1A2332;
      background: #fff;
      border: 1.5px solid #D0D7E0;
      border-radius: 6px;
      outline: none;
      transition: border-color 0.15s;
    }
    
    .filter-input {
      flex: 1;
      min-width: 160px;
    }
    
    .filter-input:focus,
    .filter-select:focus {
      border-color: #2B7FE8;
    }
    
    .filter-count {
      font-size: 13px;
      color: #6B7280;
      white-space: nowrap;
      margin-left: auto;
    }
    
    .filter-clear {
      padding: 6px 14px;
      font-size: 13px;
      font-family: sans-serif;
      color: #5A6A7A;
      background: #fff;
      border: 1.5px solid #D0D7E0;
      border-radius: 6px;
      cursor: pointer;
      transition: background 0.15s;
    }
    
    .filter-clear:hover { background: #F4F6F9; }
    
    /* リスト本体 */
    .lst {
      list-style: none;
      margin: 0;
      padding: 0;
      background: #fff;
      border-radius: 8px;
      box-shadow: 0 1px 6px rgba(0, 0, 0, 0.10);
      overflow: hidden;
      max-width: 560px;
    }
    
    /* フィルターで非表示にするクラス */
    .lst-item.is-hidden { display: none; }
    
    .lst-item {
      display: flex;
      align-items: center;
      gap: 12px;
      padding: 12px 16px;
      border-bottom: 1px solid #E5E9F2;
      transition: background 0.1s;
    }
    
    .lst-item:last-child { border-bottom: none; }
    .lst-item:hover { background: #F4F7FF; }
    
    /* アバター */
    .lst-avatar {
      flex-shrink: 0;
      width: 40px;
      height: 40px;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 16px;
      font-weight: 700;
      color: #fff;
    }
    
    .lst-avatar[data-color="1"] { background: var(--av1); }
    .lst-avatar[data-color="2"] { background: var(--av2); }
    .lst-avatar[data-color="3"] { background: var(--av3); }
    .lst-avatar[data-color="4"] { background: var(--av4); }
    .lst-avatar[data-color="5"] { background: var(--av5); }
    
    /* 本文エリア */
    .lst-body { flex: 1; min-width: 0; }
    
    .lst-name {
      font-size: 14px;
      font-weight: 700;
      color: #1A2332;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    
    .lst-meta {
      font-size: 12px;
      color: #6B7280;
      margin-top: 2px;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    
    /* 状態バッジ */
    .lst-status {
      flex-shrink: 0;
      display: inline-block;
      padding: 2px 10px;
      border-radius: 9999px;
      font-size: 12px;
      font-weight: 600;
    }
    
    .lst-status[data-status="在籍"] { background: #D1FAE5; color: #065F46; }
    .lst-status[data-status="休職"] { background: #FEF3C7; color: #92400E; }
    .lst-status[data-status="退職"] { background: #F3F4F6; color: #6B7280; }
    
    /* 0件メッセージ */
    .lst-empty {
      padding: 24px 16px;
      text-align: center;
      font-size: 14px;
      color: #9AA5B4;
    }
    
    @media (max-width: 480px) {
      body { padding: 12px; }
      .filter-bar, .lst { max-width: 100%; }
      .filter-input { min-width: 120px; }
    }
    var AVATAR_COLORS = ['1', '2', '3', '4', '5'];
    
    // JSONを読み込んでリストとフィルターを初期化する
    fetch('./data/data.json')
      .then(function (res) {
        if (!res.ok) { throw new Error('HTTP ' + res.status); }
        return res.json();
      })
      .then(function (data) {
        renderList(data.rows);
      })
      .catch(function (err) {
        console.error('データ読み込みエラー:', err);
        var ul = document.getElementById('js-list');
        var msg = document.createElement('p');
        msg.style.cssText = 'color:#9B1C1C; font-size:14px;';
        msg.textContent = 'データを読み込めませんでした。';
        ul.parentNode.appendChild(msg);
      });
    
    // リストを全件描画する(初回のみ呼ぶ)
    function renderList(rows) {
      var ul = document.getElementById('js-list');
      ul.innerHTML = '';
    
      rows.forEach(function (row, index) {
        var li = document.createElement('li');
        li.className = 'lst-item';
        // フィルター用にデータ属性を付与する
        li.setAttribute('data-name', (row.name || '').toLowerCase());
        li.setAttribute('data-role', (row.role || '').toLowerCase());
        li.setAttribute('data-dept', row.department || '');
    
        // アバター
        var avatar = document.createElement('div');
        avatar.className = 'lst-avatar';
        avatar.setAttribute('data-color', AVATAR_COLORS[index % AVATAR_COLORS.length]);
        avatar.textContent = row.name ? row.name.charAt(0) : '?';
    
        // 本文エリア
        var body = document.createElement('div');
        body.className = 'lst-body';
    
        var nameEl = document.createElement('div');
        nameEl.className = 'lst-name';
        nameEl.textContent = row.name || '';
    
        var meta = document.createElement('div');
        meta.className = 'lst-meta';
        meta.textContent = (row.role || '') + ' · ' + (row.department || '');
    
        body.appendChild(nameEl);
        body.appendChild(meta);
    
        // 状態バッジ
        var status = document.createElement('span');
        status.className = 'lst-status';
        status.setAttribute('data-status', row.status || '');
        status.textContent = row.status || '';
    
        li.appendChild(avatar);
        li.appendChild(body);
        li.appendChild(status);
        ul.appendChild(li);
      });
    
      // 部署セレクトの選択肢を生成する
      buildDeptSelect(rows);
      updateCount();
    }
    
    // 部署セレクトの選択肢をデータから自動生成する
    function buildDeptSelect(rows) {
      var select = document.getElementById('js-dept');
      // 既存の選択肢(「すべての部署」以外)を削除する
      while (select.options.length > 1) { select.remove(1); }
    
      var seen = {};
      rows.forEach(function (row) {
        var d = row.department || '';
        if (d && !seen[d]) {
          seen[d] = true;
          var option = document.createElement('option');
          option.value = d;
          option.textContent = d;
          select.appendChild(option);
        }
      });
    }
    
    // フィルターを適用する(DOMを再生成せずクラスのトグルで制御する)
    function applyFilter() {
      var keyword = document.getElementById('js-keyword').value.toLowerCase();
      var dept = document.getElementById('js-dept').value;
    
      var items = document.querySelectorAll('#js-list .lst-item');
      items.forEach(function (li) {
        var matchKeyword = keyword === '' ||
          li.getAttribute('data-name').indexOf(keyword) !== -1 ||
          li.getAttribute('data-role').indexOf(keyword) !== -1;
        var matchDept = dept === '' || li.getAttribute('data-dept') === dept;
    
        if (matchKeyword && matchDept) {
          li.classList.remove('is-hidden');
        } else {
          li.classList.add('is-hidden');
        }
      });
    
      updateCount();
      updateEmpty();
    }
    
    // 件数表示を更新する
    function updateCount() {
      var total = document.querySelectorAll('#js-list .lst-item').length;
      var shown = document.querySelectorAll('#js-list .lst-item:not(.is-hidden)').length;
      document.getElementById('js-count').textContent = shown + '件 / ' + total + '件';
    }
    
    // 0件のときにメッセージを表示する
    function updateEmpty() {
      var shown = document.querySelectorAll('#js-list .lst-item:not(.is-hidden)').length;
      var existing = document.getElementById('js-empty');
      if (shown === 0) {
        if (!existing) {
          var msg = document.createElement('li');
          msg.id = 'js-empty';
          msg.className = 'lst-empty';
          msg.textContent = '該当するメンバーが見つかりません';
          document.getElementById('js-list').appendChild(msg);
        }
      } else {
        if (existing) { existing.parentNode.removeChild(existing); }
      }
    }
    
    // フィルターイベントを登録する
    document.getElementById('js-keyword').addEventListener('input', applyFilter);
    document.getElementById('js-dept').addEventListener('change', applyFilter);
    
    // クリアボタン処理
    document.getElementById('js-clear').addEventListener('click', function () {
      document.getElementById('js-keyword').value = '';
      document.getElementById('js-dept').value = '';
      applyFilter();
    });
    
    {
      "rows": [
        { "name": "田中 一郎", "department": "開発部",           "role": "リードエンジニア",       "status": "在籍" },
        { "name": "鈴木 花子", "department": "デザイン部",       "role": "UIデザイナー",           "status": "在籍" },
        { "name": "佐藤 次郎", "department": "開発部",           "role": "バックエンドエンジニア", "status": "在籍" },
        { "name": "山田 三枝", "department": "営業部",           "role": "セールスマネージャー",   "status": "在籍" },
        { "name": "伊藤 健太", "department": "開発部",           "role": "エンジニア",             "status": "休職" },
        { "name": "渡辺 美咲", "department": "マーケティング部", "role": "コンテンツディレクター", "status": "在籍" },
        { "name": "中村 剛",   "department": "インフラ部",       "role": "インフラエンジニア",     "status": "在籍" },
        { "name": "小林 奈々", "department": "人事部",           "role": "HRマネージャー",         "status": "退職" }
      ]
    }

    AI用プロンプト

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

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

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

    # リスト(フィルター絞り込み)作成依頼
    
    ## 概要
    キーワード検索と部署セレクトでリアルタイム絞り込みができるメンバーリストを実装してください。
    
    ## 要件
    - テキストボックスに入力するたびに氏名・役職フィールドで部分一致フィルターをかける
    - 部署セレクトの選択肢はJSONデータから自動生成する(重複排除)
    - キーワードと部署の条件をANDで同時適用する
    - 表示件数を「{表示件数}件 / {全件数}件」形式でリアルタイム更新する
    - クリアボタンで全条件をリセットして全件表示に戻す
    - フィルター時はDOMを再生成せず、li要素に is-hidden クラスをトグルして表示制御する
    - 0件のときは「該当するメンバーが見つかりません」と表示する
    
    ## 技術仕様
    - HTML / CSS / バニラJavaScript で実装
    - 外部ライブラリ:なし
    - レスポンシブ対応:必要
    
    ## 動作詳細
    rows 配列は { name, department, role, status } のプロパティを持つオブジェクトの配列とする。
    初回 renderList(rows) で全件を ul に描画し、各 li に data-name / data-role / data-dept 属性を付与する。
    applyFilter() は keyword と dept の両条件で li.is-hidden をトグルする。
    件数更新は querySelectorAll('.lst-item:not(.is-hidden)').length で算出する。
    動的データのDOM挿入は textContent を使い innerHTML に変数を直接渡さない。
    
    ## 出力形式
    HTML・CSS・JavaScriptを分けて出力してください。
    各ファイルは単独でコピー&ペーストして使えるよう記述してください。