テーブル(Table)— インクリメンタル検索

データ表示 初級

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

検索ボックスへの入力に応じて社員名簿テーブルをリアルタイムに絞り込む、インクリメンタル検索(incremental search)の実装例です。入力が続いている間は絞り込みを実行せず、入力が止まってから300ms後に検索を実行するdebounce処理により、大量データでも検索のたびに再描画が発生して重くなることを防ぎます。日本語入力(IME)中はisComposingで変換途中の文字列を無視し、変換確定時にだけ即座に絞り込みを実行するため、変換中に意図しない検索結果がちらついたり日本語入力時だけ挙動が違ったりする問題を避けられます。ヒットした文字列は<mark>要素でハイライト表示し、該当0件のときは空状態表示に切り替えます。

  • debounceによる絞り込み — 入力停止から300ms後に検索を実行する。入力の都度検索する実装に比べ、行数が多いテーブルでの再描画負荷を抑える
  • IME変換中の誤発火防止inputイベントではe.isComposingを確認して変換中は無視し、compositionendで変換確定時に即座に検索を実行する
  • 複数列の横断OR一致検索 — 氏名・ふりがな・部署・役職のいずれかに部分一致すれば表示する。漢字でもかな読みでも同じ行に辿り着ける
  • マッチ箇所のハイライト — ヒットした文字列を<mark>要素で強調表示する
  • ヒット件数のリアルタイム表示 — 「◯件ヒット」を検索のたびに更新する
  • 空状態表示への切り替え — 該当0件のとき「"〇〇"に一致する社員が見つかりません」のメッセージ表示に切り替える

実装のポイント・注意点

debounceの待ち時間は300msにしています。人はキー入力を続けている間は「まだ入力中」と感じ、200〜400ms止まると「入力が一段落した」と感じやすいとされる目安から採用した数値で、日本語入力の変換確定タイミングとも体感的に馴染みます。

日本語入力(IME)対応が本ページの核です。inputイベントの先頭でe.isComposingtrueのときは処理を中断し、変換途中の文字列で検索が走らないようにします。変換が確定するとcompositionendイベントが発火するので、そこでdebounceの待ち時間なしに即座に検索を実行します。この実装は検索ボックスがIME変換中に発火する原因と直し方の記事で解説している「正解実装」と同じパターンです。

ハイライト表示はinnerHTMLに文字列を結合せず、document.createTextNode()createElement('mark')を組み合わせて安全に組み立てています。検索キーワードに正規表現の特殊文字(. * ( など)が含まれてもエラーにならないよう、事前にエスケープしてからRegExpを作る点も同じ考え方です。

クライアントサイドの配列絞り込みをAPI経由の検索(fetch)に置き換える場合は、リクエストの競合に注意してください。入力のたびにリクエストを送ると、後から入力した検索より前のリクエストのレスポンスが遅れて返ってくることがあります。AbortControllerで前のリクエストを中断するか、リクエストに連番を振って最新のものだけを描画に反映する実装が必要です。

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

デモ

社員番号 氏名 ふりがな 部署 役職

サンプルソース

3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js — 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。

<!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="dt10-wrap">
  <div class="dt10-search-bar">
    <input class="dt10-input" id="dt10-input" type="text" placeholder="氏名・部署・役職で検索...">
    <span class="dt10-count" id="dt10-count" aria-live="polite" hidden></span>
  </div>

  <div class="dt10-table-scroll" id="dt10-table-scroll">
    <table class="dt10-table">
      <thead>
        <tr>
          <th>社員番号</th>
          <th>氏名</th>
          <th>ふりがな</th>
          <th>部署</th>
          <th>役職</th>
        </tr>
      </thead>
      <tbody id="dt10-tbody"><!-- 行はJSで動的生成 --></tbody>
    </table>
  </div>

  <div class="dt10-empty" id="dt10-empty" hidden>
    <svg class="dt10-empty-icon" viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
      <circle cx="11" cy="11" r="7"></circle>
      <line x1="16.5" y1="16.5" x2="21" y2="21"></line>
    </svg>
    <p class="dt10-empty-text" id="dt10-empty-text">一致する社員が見つかりません</p>
  </div>
</div>

<script src="./script.js"></script>
</body>
</html>
:root {
  --dt10-primary: #2B7FE8;
  --dt10-border: #E2E8F0;
  --dt10-text: #1A2332;
  --dt10-text-sub: #5A6A7A;
  --dt10-head-bg: #F4F7FF;
}

*, *::before, *::after { box-sizing: border-box; }

body {
  font-family: sans-serif;
  padding: 24px;
  background: #F0F2F5;
  color: var(--dt10-text);
}

.dt10-wrap {
  width: 100%;
  max-width: 720px;
  margin: 0 auto;
}

/* ===== 検索ボックス ===== */
.dt10-search-bar {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 12px;
}

.dt10-input {
  flex: 1;
  padding: 8px 12px;
  font-size: 14px;
  font-family: inherit;
  color: var(--dt10-text);
  background: #fff;
  border: 1.5px solid var(--dt10-border);
  border-radius: 6px;
}
.dt10-input:focus {
  outline: none;
  border-color: var(--dt10-primary);
}

.dt10-count {
  font-size: 13px;
  color: var(--dt10-text-sub);
  white-space: nowrap;
}

/* ===== テーブル ===== */
.dt10-table-scroll { overflow-x: auto; }

.dt10-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
  color: var(--dt10-text);
  background: #fff;
}

.dt10-table th,
.dt10-table td {
  padding: 10px 14px;
  text-align: left;
  border-bottom: 1px solid var(--dt10-border);
  white-space: nowrap;
}

.dt10-table thead th {
  background: var(--dt10-head-bg);
  font-weight: 600;
  color: var(--dt10-text-sub);
}

.dt10-table tbody tr:hover td {
  background: #F4F7FF;
  transition: background 0.1s;
}

.dt10-table mark {
  background: #FFEC6E;
  color: inherit;
  border-radius: 2px;
  padding: 0 1px;
}

/* ===== 空状態表示 ===== */
.dt10-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 48px 16px;
  color: #9AA5B4;
  background: #fff;
  border: 1px solid var(--dt10-border);
  border-radius: 6px;
  text-align: center;
}
/* display: flex を持つ要素を hidden 属性で制御するため、明示的に上書きする */
.dt10-empty[hidden] {
  display: none;
}

.dt10-empty-icon { color: #C7D0DB; }

.dt10-empty-text {
  margin: 0;
  font-size: 14px;
}

/* ===== リセットボタン ===== */
.demo-controls { margin-top: 12px; }

.reset-btn {
  padding: 6px 16px;
  font-size: 13px;
  color: #5A6A7A;
  background: #fff;
  border: 1.5px solid #D0D7E0;
  border-radius: 6px;
  cursor: pointer;
  font-family: sans-serif;
  transition: background 0.15s, border-color 0.15s;
}
.reset-btn:hover { background: #F4F6F9; border-color: #9AA5B4; }

@media (max-width: 600px) {
  .dt10-table th,
  .dt10-table td { padding: 8px 10px; }
}
// 社員名簿の初期データ(20件)。氏名・ふりがな・部署・役職を検索対象にする
const EMPLOYEES = [
  { id: 'E001', name: '佐藤 陽菜',   kana: 'さとう はるな',     dept: '営業部',           role: '主任' },
  { id: 'E002', name: '鈴木 大輔',   kana: 'すずき だいすけ',   dept: '開発部',           role: '課長' },
  { id: 'E003', name: '高橋 美咲',   kana: 'たかはし みさき',   dept: '人事部',           role: '一般' },
  { id: 'E004', name: '田中 一郎',   kana: 'たなか いちろう',   dept: '経理部',           role: '部長' },
  { id: 'E005', name: '伊藤 健太',   kana: 'いとう けんた',     dept: 'マーケティング部', role: 'リーダー' },
  { id: 'E006', name: '渡辺 さくら', kana: 'わたなべ さくら',   dept: '営業部',           role: '一般' },
  { id: 'E007', name: '山本 修',     kana: 'やまもと おさむ',   dept: '開発部',           role: '主任' },
  { id: 'E008', name: '中村 優子',   kana: 'なかむら ゆうこ',   dept: '人事部',           role: '課長' },
  { id: 'E009', name: '小林 拓也',   kana: 'こばやし たくや',   dept: '経理部',           role: '一般' },
  { id: 'E010', name: '加藤 美穂',   kana: 'かとう みほ',       dept: 'マーケティング部', role: '主任' },
  { id: 'E011', name: '吉田 隆',     kana: 'よしだ たかし',     dept: '営業部',           role: '課長' },
  { id: 'E012', name: '山田 直樹',   kana: 'やまだ なおき',     dept: '開発部',           role: '一般' },
  { id: 'E013', name: '佐々木 陽子', kana: 'ささき ようこ',     dept: '人事部',           role: '主任' },
  { id: 'E014', name: '松本 健一',   kana: 'まつもと けんいち', dept: '経理部',           role: '課長' },
  { id: 'E015', name: '井上 麻衣',   kana: 'いのうえ まい',     dept: 'マーケティング部', role: '一般' },
  { id: 'E016', name: '木村 拓真',   kana: 'きむら たくま',     dept: '営業部',           role: 'リーダー' },
  { id: 'E017', name: '林 香織',     kana: 'はやし かおり',     dept: '開発部',           role: '主任' },
  { id: 'E018', name: '斎藤 隆之',   kana: 'さいとう たかゆき', dept: '人事部',           role: '部長' },
  { id: 'E019', name: '清水 彩',     kana: 'しみず あや',       dept: '経理部',           role: '一般' },
  { id: 'E020', name: '山口 大地',   kana: 'やまぐち だいち',   dept: 'マーケティング部', role: '課長' }
];

const input       = document.getElementById('dt10-input');
const countEl     = document.getElementById('dt10-count');
const tbody       = document.getElementById('dt10-tbody');
const tableScroll = document.getElementById('dt10-table-scroll');
const emptyEl     = document.getElementById('dt10-empty');
const emptyText   = document.getElementById('dt10-empty-text');

let debounceTimer = null;

// 正規表現の特殊文字をエスケープする
function escapeRegExp(str) {
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// 1セル分のハイライト付きテキストを組み立てる(innerHTMLは使わずcreateElementで安全に)
function buildHighlightedCell(text, regex) {
  const td = document.createElement('td');

  if (!regex) {
    td.textContent = text;
    return td;
  }

  regex.lastIndex = 0;
  let lastIndex = 0;
  let match;
  while ((match = regex.exec(text)) !== null) {
    if (match.index > lastIndex) {
      td.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
    }
    const mark = document.createElement('mark');
    mark.textContent = match[0];
    td.appendChild(mark);
    lastIndex = match.index + match[0].length;
  }
  if (lastIndex < text.length) {
    td.appendChild(document.createTextNode(text.slice(lastIndex)));
  }

  return td;
}

// 絞り込み結果を描画する(0件のときはテーブルの代わりに空状態表示へ切り替える)
function renderRows(list, keyword) {
  tbody.textContent = '';

  if (list.length === 0) {
    tableScroll.hidden = true;
    emptyEl.hidden = false;
    emptyText.textContent = '"' + keyword + '"に一致する社員が見つかりません';
    return;
  }

  tableScroll.hidden = false;
  emptyEl.hidden = true;

  const regex = keyword ? new RegExp(escapeRegExp(keyword), 'gi') : null;

  list.forEach(function (employee) {
    const tr = document.createElement('tr');

    const tdId = document.createElement('td');
    tdId.textContent = employee.id;
    tr.appendChild(tdId);

    tr.appendChild(buildHighlightedCell(employee.name, regex));
    tr.appendChild(buildHighlightedCell(employee.kana, regex));
    tr.appendChild(buildHighlightedCell(employee.dept, regex));
    tr.appendChild(buildHighlightedCell(employee.role, regex));

    tbody.appendChild(tr);
  });
}

// キーワードで社員データを絞り込む(氏名・ふりがな・部署・役職を横断してOR一致)
function applyFilter() {
  const keyword = input.value.trim();

  if (!keyword) {
    countEl.hidden = true;
    renderRows(EMPLOYEES, '');
    return;
  }

  const lowerKeyword = keyword.toLowerCase();
  const filtered = EMPLOYEES.filter(function (employee) {
    return [employee.name, employee.kana, employee.dept, employee.role].some(function (field) {
      return field.toLowerCase().includes(lowerKeyword);
    });
  });

  countEl.hidden = false;
  countEl.textContent = filtered.length + '件ヒット';
  renderRows(filtered, keyword);
}

// input: IME変換中(isComposing)は無視し、変換確定後だけdebounce(300ms)で絞り込みを実行する
input.addEventListener('input', function (e) {
  if (e.isComposing) return;
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(applyFilter, 300);
});

// compositionend: 変換確定の瞬間はdebounceを待たず即座に絞り込みを実行する
input.addEventListener('compositionend', function () {
  clearTimeout(debounceTimer);
  applyFilter();
});

// ===== リセット =====
function resetDemo() {
  clearTimeout(debounceTimer);
  input.value = '';
  countEl.hidden = true;
  renderRows(EMPLOYEES, '');
}

renderRows(EMPLOYEES, '');

AI用プロンプト

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

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

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

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

# テーブル(Table)— インクリメンタル検索 作成依頼

## 概要
検索ボックスへの入力に応じて社員名簿テーブルをリアルタイムに絞り込む、インクリメンタル検索UIを実装してください。
入力停止後にdebounceで検索を実行し、日本語入力(IME)変換中の誤発火を防ぐ実装を含みます。

## 要件
- 検索ボックスに入力すると、入力が止まってから300ms後に一覧を絞り込む(debounce)。入力し続けている間は絞り込みを実行しない
- 絞り込みは「氏名・ふりがな・部署・役職」の4項目を対象に、いずれか1項目でも部分一致すれば表示するOR検索にする
- マッチした文字列をテーブル内で `<mark>` 要素によりハイライト表示する
- ヒット件数を「◯件ヒット」の形式でリアルタイム表示する。検索ボックスが空のときは非表示にする
- 日本語入力(IME)変換中は絞り込みを実行せず、変換確定(compositionend)のタイミングで即座に絞り込みを実行する(debounceの待ち時間を挟まない)
- 該当行が0件のとき、テーブルの代わりに「"〇〇"に一致する社員が見つかりません」という空状態メッセージを表示する
- 「リセット」ボタンで検索ボックスを空にし、全行表示・初期状態に戻す

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要(max-width: 720px程度で中央寄せ、スマホ幅でも列が崩れないこと)

## データ管理
- 社員データは JavaScript の定数 `const EMPLOYEES` の配列で管理する(社員番号・氏名・ふりがな・部署・役職を持つオブジェクトの配列)
- テーブルの行は `` を空にしておき、JSの `renderRows(list)` 関数でDOM生成する

## debounce実装仕様
- `input` イベントで `clearTimeout` → `setTimeout(applyFilter, 300)` を行う定石のdebounceパターンを使う
- 連続入力中はタイマーをリセットし続け、入力が止まってから300ms後にだけ絞り込みを実行する

## IME対応仕様(重要)
- `input` イベントハンドラの先頭で `e.isComposing` が `true` の場合は処理を中断する(`if (e.isComposing) return;`)
- `compositionend` イベントでは debounce を待たず、絞り込み処理を即座に呼び出す

## ハイライト実装仕様
- 検索キーワードを正規表現の特殊文字に対してエスケープしてから `RegExp` に渡す
  - エスケープ: `keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`
- `'gi'` フラグ(大文字小文字を区別しない)で `RegExp.exec()` ループと `lastIndex` を使って文字列を走査する
- テキスト部分は `document.createTextNode()` で生成する
- マッチ部分は `createElement('mark')` + `textContent` で生成する
- すべてのノードを `appendChild` でセルに追加する(`innerHTML` に変数を代入しない)

## XSSセキュリティ要件(重要)
- `innerHTML` にユーザー入力や社員データを直接代入しない
- テキスト部分は `document.createTextNode()`、行・セルは `createElement()` + `appendChild()` で組み立てる
- 空状態メッセージへのキーワード反映は `textContent` を使う

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。