Table 5 — テーブル 5 — ページング+フィルター

データ表示 上級

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

外部JSONファイルからデータを読み込み、テーブルに10件ずつ表示するページング機能を実装します。 ページ数・ページボタンの数は読み込んだJSONのデータ件数から自動で計算するため、データが増減しても変更は不要です。

ドロップダウンで部署を選ぶと絞り込み後の件数に応じてページ数が再計算され、ページも自動的に1ページ目に戻ります。 選択肢もJSONデータの値から自動で生成するため、データに新しい部署が増えれば選択肢も自動で追加されます。 APIやJSONファイルからデータを取得して一覧表示する実務でよく使われるパターンです。

  • JSONフェッチfetch() でJSONファイルを取得。取得後はメモリに保持し、ページ移動・フィルター操作のたびに再通信しない
  • ページ数の動的計算 — 総ページ数はJSONの件数から Math.ceil(件数 / PAGE_SIZE) で計算。データが増えてもJS・HTMLを修正しなくてよい
  • ページボタンの動的生成 — ページ番号ボタンは計算された総ページ数をもとにJSで生成。ページ数が変わっても自動で増減する
  • フィルタリング — 部署ドロップダウンで絞り込み。フィルター変更時は絞り込み後の件数でページ数を再計算し、ページを1に戻す
  • 選択肢の自動生成 — ドロップダウンの選択肢もJSONデータの department 値から自動で作る
  • 件数・ページ数表示 — 現在の絞り込み件数と総ページ数をリアルタイム表示
  • ステータスバッジ — 「在籍 / 休職 / 退職」を色付きバッジで識別しやすく表示

実装のポイント・注意点

ページ数はデータ件数から計算するMath.ceil(filteredRows.length / PAGE_SIZE) が基本式です。 JSONのデータが30件なら3ページ、31件なら4ページと自動で変わります。ページ数をHTMLやJSに直接書かず、必ずこの式で求めましょう。

フィルター変更時は「ページ数の再計算」と「ページのリセット」をセットで行う。 絞り込むとデータ件数が変わり、ページ数も変わります。たとえば全30件(3ページ)で3ページ目を見ているときに1ページ分しかないデータに絞り込んでも、 ページリセットなしだと3ページ目のままになりテーブルが空になります。フィルター変更処理の最初に currentPage = 1 を書くのが確実です。

全データとフィルター後データを別の変数で持つallRows(全件)と filteredRows(絞り込み後)を分けておくことで、 フィルターを「すべて」に戻したとき全件データに戻せます。フィルター後のデータで allRows を上書きしてしまうと元データが消えてしまいます。

DOM挿入は textContent / createElement を使うinnerHTML に変数を直接埋め込むと、データに <> が含まれているときに画面が崩れたり、 悪意あるコードが実行されるXSSというセキュリティリスクがあります。textContent で挿入すれば文字として安全に扱われます。

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

デモ

部署ドロップダウンで絞り込むと、ページ数がデータ件数から自動で再計算されます。

ID 氏名 部署 役職 状態

サンプルソース

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="tbl-wrap">
  <!-- フィルター行 -->
  <div class="toolbar">
    <label for="js-filter" class="filter-label">部署:</label>
    <select id="js-filter" class="filter-select">
      <option value="">すべて</option>
    </select>
    <span class="count-text" id="js-count"></span>
  </div>

  <!-- テーブル -->
  <div class="tbl-scroll">
    <table class="tbl">
      <thead>
        <tr>
          <th scope="col">ID</th>
          <th scope="col">氏名</th>
          <th scope="col">部署</th>
          <th scope="col">役職</th>
          <th scope="col">状態</th>
        </tr>
      </thead>
      <tbody id="js-tbody"></tbody>
    </table>
  </div>

  <!-- ページング(ボタンはJSで動的生成) -->
  <nav class="pager" id="js-pager" aria-label="ページ移動"></nav>
</div>

<script src="./script.js"></script>
</body>
</html>
/* テーブル(ページング+フィルター)— style.css */
*, *::before, *::after { box-sizing: border-box; }

:root {
  --color-primary: #2B7FE8;
  --color-border:  #E5E9F2;
  --color-text:    #1A2332;
  --color-muted:   #64748B;
}

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

/* ---- フィルター行 ---- */
.toolbar {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
  flex-wrap: wrap;
}

.filter-label { font-size: 14px; font-weight: 600; }

.filter-select {
  padding: 6px 10px;
  border: 1px solid #CBD5E1;
  border-radius: 6px;
  font-size: 14px;
  background: #fff;
  cursor: pointer;
  font-family: inherit;
}

.count-text { font-size: 13px; color: var(--color-muted); }

/* ---- テーブル ---- */
.tbl-scroll {
  overflow-x: auto;
  border-radius: 8px;
  box-shadow: 0 1px 6px rgba(0, 0, 0, 0.10);
}

.tbl {
  border-collapse: collapse;
  width: 100%;
  font-size: 14px;
  background: #fff;
  white-space: nowrap;
}

.tbl th {
  background: var(--color-primary);
  color: #fff;
  padding: 10px 14px;
  text-align: left;
  font-weight: 600;
}

.tbl td {
  padding: 10px 14px;
  border-bottom: 1px solid var(--color-border);
}

.tbl tbody tr:last-child td { border-bottom: none; }
.tbl tbody tr:nth-child(even) td { background: #F4F7FF; }
.tbl tbody tr:hover td { background: #EBF2FF; transition: background 0.1s; }

.tbl-empty {
  text-align: center;
  color: var(--color-muted);
  padding: 24px !important;
}

/* ---- ステータスバッジ ---- */
.status-badge {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 9999px;
  font-size: 12px;
  font-weight: 600;
}

.status-badge--active  { background: #D1FAE5; color: #065F46; }
.status-badge--leave   { background: #FEF3C7; color: #92400E; }
.status-badge--retired { background: #F3F4F6; color: #6B7280; }

/* ---- ページング ---- */
.pager {
  display: flex;
  align-items: center;
  gap: 4px;
  margin-top: 16px;
  flex-wrap: wrap;
}

.pager-btn {
  min-width: 36px;
  height: 36px;
  padding: 0 10px;
  border-radius: 6px;
  border: 1px solid #CBD5E1;
  background: #fff;
  color: var(--color-text);
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  font-family: inherit;
  transition: background 0.1s;
}

.pager-btn:hover:not(:disabled):not(.is-active) { background: #F1F5F9; }

.pager-btn.is-active {
  background: var(--color-primary);
  color: #fff;
  border-color: var(--color-primary);
  font-weight: 700;
}

.pager-btn:disabled {
  color: #CBD5E1;
  cursor: not-allowed;
}
// ============================================================
// テーブル(ページング+フィルター)— script.js
// ============================================================

// --- 状態管理変数 ---
var allRows = [];      // 読み込んだ全件データ(上書きしない)
var filteredRows = []; // フィルター後のデータ。ページングはこちらを参照する
var currentPage = 1;   // 現在のページ番号
var PAGE_SIZE = 10;    // 1ページの表示件数

// --- DOM要素を取得 ---
var filterEl = document.getElementById('js-filter');
var countEl  = document.getElementById('js-count');
var tbodyEl  = document.getElementById('js-tbody');
var pagerEl  = document.getElementById('js-pager');

// --- JSONを読み込んで初期化(最初の1回だけ通信する) ---
fetch('./data/data.json')
  .then(function(res) {
    if (!res.ok) { throw new Error('HTTP ' + res.status); }
    return res.json();
  })
  .then(function(data) {
    allRows = data.rows;         // 全件をメモリに保持
    buildFilterOptions(allRows); // 部署ドロップダウンを生成
    filteredRows = allRows;      // 初期はフィルターなし=全件
    render();
  })
  .catch(function(err) {
    console.error('読み込みエラー:', err);
    var tr = document.createElement('tr');
    var td = document.createElement('td');
    td.colSpan = 5;
    td.style.cssText = 'color:#9B1C1C; text-align:center; padding:24px;';
    td.textContent = 'データを読み込めませんでした。ローカルサーバーで開いているか確認してください。';
    tr.appendChild(td);
    tbodyEl.appendChild(tr);
  });

// --- フィルター変更:部署で絞り込む ---
filterEl.addEventListener('change', function() {
  var dept = this.value;
  currentPage = 1;           // 絞り込み件数が変わるので必ずページを1に戻す
  filteredRows = dept === ''
    ? allRows                // 「すべて」は全件
    : allRows.filter(function(r) { return r.department === dept; });
  render();
});

// --- 部署ドロップダウンの選択肢をJSONデータから自動生成 ---
function buildFilterOptions(rows) {
  // 重複なし・五十音順で部署一覧を作る
  var seen = {};
  var depts = [];
  rows.forEach(function(r) {
    if (!seen[r.department]) {
      seen[r.department] = true;
      depts.push(r.department);
    }
  });
  depts.sort();

  depts.forEach(function(dept) {
    var opt = document.createElement('option');
    opt.value = dept;
    opt.textContent = dept;
    filterEl.appendChild(opt);
  });
}

// --- テーブルとページングをまとめて更新 ---
function render() {
  renderTable();
  renderPager();
}

// --- テーブル本体を描画 ---
function renderTable() {
  tbodyEl.textContent = ''; // 前回の行を一括クリア

  // 0件のときは「該当なし」メッセージを表示して終了
  if (filteredRows.length === 0) {
    var tr = document.createElement('tr');
    var td = document.createElement('td');
    td.colSpan = 5;
    td.className = 'tbl-empty';
    td.textContent = '該当する社員が見つかりませんでした。';
    tr.appendChild(td);
    tbodyEl.appendChild(tr);
    return;
  }

  // このページに表示するデータを切り出す
  var start = (currentPage - 1) * PAGE_SIZE;
  var pageRows = filteredRows.slice(start, start + PAGE_SIZE);

  pageRows.forEach(function(row) {
    var tr = document.createElement('tr');

    // ID・氏名・部署・役職列
    ['id', 'name', 'department', 'role'].forEach(function(key) {
      var td = document.createElement('td');
      td.textContent = row[key] != null ? row[key] : '';
      tr.appendChild(td);
    });

    // 状態列:色付きバッジで表示
    var tdStatus = document.createElement('td');
    var badge = document.createElement('span');
    badge.className = 'status-badge status-badge--' + getStatusClass(row.status);
    badge.textContent = row.status != null ? row.status : '';
    tdStatus.appendChild(badge);
    tr.appendChild(tdStatus);

    tbodyEl.appendChild(tr);
  });
}

// --- ページングを描画 ---
function renderPager() {
  // ページ数をデータ件数から動的に計算(データが増えても変更不要)
  var totalPages = Math.ceil(filteredRows.length / PAGE_SIZE);

  // 件数・ページ数を更新
  countEl.textContent = filteredRows.length + '件 / ' + (totalPages || 0) + 'ページ';

  pagerEl.textContent = ''; // 前回のボタンをクリア

  if (totalPages === 0) { return; }

  // 「前へ」ボタン
  var prevBtn = document.createElement('button');
  prevBtn.textContent = '前へ';
  prevBtn.className = 'pager-btn';
  prevBtn.disabled = (currentPage === 1);
  prevBtn.addEventListener('click', function() { currentPage--; render(); });
  pagerEl.appendChild(prevBtn);

  // ページ番号ボタン(totalPagesの数だけ動的に生成する)
  for (var i = 1; i <= totalPages; i++) {
    var btn = document.createElement('button');
    btn.textContent = i;
    btn.className = 'pager-btn' + (i === currentPage ? ' is-active' : '');
    btn.dataset.page = i; // ページ番号をdata属性に保存
    btn.addEventListener('click', function() {
      // this.dataset.pageで取得することでクロージャの問題を回避する
      currentPage = parseInt(this.dataset.page, 10);
      render();
    });
    pagerEl.appendChild(btn);
  }

  // 「次へ」ボタン
  var nextBtn = document.createElement('button');
  nextBtn.textContent = '次へ';
  nextBtn.className = 'pager-btn';
  nextBtn.disabled = (currentPage === totalPages);
  nextBtn.addEventListener('click', function() { currentPage++; render(); });
  pagerEl.appendChild(nextBtn);
}

// --- ステータス文字列をCSSクラス名に変換 ---
function getStatusClass(status) {
  if (status === '在籍') { return 'active'; }
  if (status === '休職') { return 'leave'; }
  if (status === '退職') { return 'retired'; }
  return '';
}
{
  "rows": [
    { "id": 1,  "name": "田中 一郎",   "department": "開発部",            "role": "リードエンジニア",         "email": "[email protected]",    "status": "在籍" },
    { "id": 2,  "name": "鈴木 花子",   "department": "デザイン部",         "role": "UIデザイナー",             "email": "[email protected]",    "status": "在籍" },
    { "id": 3,  "name": "佐藤 次郎",   "department": "開発部",            "role": "バックエンドエンジニア",    "email": "[email protected]",      "status": "在籍" },
    { "id": 4,  "name": "山田 三枝",   "department": "営業部",            "role": "セールスマネージャー",     "email": "[email protected]",    "status": "在籍" },
    { "id": 5,  "name": "伊藤 健太",   "department": "開発部",            "role": "モバイルエンジニア",       "email": "[email protected]",       "status": "休職" },
    { "id": 6,  "name": "渡辺 美咲",   "department": "マーケティング部",   "role": "コンテンツディレクター",   "email": "[email protected]",  "status": "在籍" },
    { "id": 7,  "name": "中村 剛",     "department": "インフラ部",         "role": "インフラエンジニア",       "email": "[email protected]",  "status": "在籍" },
    { "id": 8,  "name": "小林 奈々",   "department": "人事部",            "role": "HRマネージャー",           "email": "[email protected]", "status": "退職" },
    { "id": 9,  "name": "加藤 大輔",   "department": "開発部",            "role": "データエンジニア",         "email": "[email protected]",      "status": "在籍" },
    { "id": 10, "name": "吉田 さくら", "department": "カスタマーサポート", "role": "チームリーダー",           "email": "[email protected]",   "status": "在籍" },
    { "id": 11, "name": "松本 拓哉",   "department": "開発部",            "role": "フロントエンドエンジニア", "email": "[email protected]", "status": "在籍" },
    { "id": 12, "name": "井上 彩",     "department": "デザイン部",         "role": "グラフィックデザイナー",   "email": "[email protected]",     "status": "在籍" },
    { "id": 13, "name": "木村 誠",     "department": "営業部",            "role": "セールスエンジニア",       "email": "[email protected]",    "status": "在籍" },
    { "id": 14, "name": "林 真由子",   "department": "マーケティング部",   "role": "デジタルマーケター",       "email": "[email protected]",   "status": "在籍" },
    { "id": 15, "name": "清水 龍之介", "department": "インフラ部",         "role": "セキュリティエンジニア",   "email": "[email protected]",   "status": "在籍" },
    { "id": 16, "name": "池田 裕子",   "department": "人事部",            "role": "採用担当",                 "email": "[email protected]",     "status": "在籍" },
    { "id": 17, "name": "橋本 隆",     "department": "カスタマーサポート", "role": "サポートエンジニア",       "email": "[email protected]", "status": "在籍" },
    { "id": 18, "name": "前田 麻衣",   "department": "営業部",            "role": "アカウントエグゼクティブ", "email": "[email protected]",     "status": "在籍" },
    { "id": 19, "name": "藤田 宏",     "department": "開発部",            "role": "テックリード",             "email": "[email protected]",    "status": "在籍" },
    { "id": 20, "name": "坂本 由美",   "department": "デザイン部",         "role": "UXリサーチャー",           "email": "[email protected]",  "status": "在籍" },
    { "id": 21, "name": "遠藤 毅",     "department": "営業部",            "role": "営業部長",                 "email": "[email protected]",      "status": "在籍" },
    { "id": 22, "name": "西村 亮",     "department": "マーケティング部",   "role": "ブランドマネージャー",     "email": "[email protected]", "status": "在籍" },
    { "id": 23, "name": "菊地 美穂",   "department": "人事部",            "role": "労務担当",                 "email": "[email protected]",   "status": "在籍" },
    { "id": 24, "name": "原田 翔",     "department": "開発部",            "role": "エンジニア",               "email": "[email protected]",    "status": "退職" },
    { "id": 25, "name": "土屋 幸子",   "department": "カスタマーサポート", "role": "QAエンジニア",             "email": "[email protected]",  "status": "在籍" },
    { "id": 26, "name": "岡田 浩二",   "department": "インフラ部",         "role": "クラウドアーキテクト",     "email": "[email protected]",     "status": "在籍" },
    { "id": 27, "name": "長谷川 舞",   "department": "デザイン部",         "role": "モーションデザイナー",     "email": "[email protected]",  "status": "在籍" },
    { "id": 28, "name": "福田 剛",     "department": "開発部",            "role": "エンジニア",               "email": "[email protected]",    "status": "在籍" },
    { "id": 29, "name": "河野 千尋",   "department": "マーケティング部",   "role": "SNSマーケター",            "email": "[email protected]",    "status": "在籍" },
    { "id": 30, "name": "石川 健",     "department": "開発部",            "role": "SREエンジニア",            "email": "[email protected]",  "status": "在籍" }
  ]
}

AI用プロンプト

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

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

💡 ページ数・フィルターのフィールド名はデータに合わせて変更してください。jQuery・Vue・Reactで実装したい場合はプロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# テーブル(ページング+フィルター)作成依頼

## 概要
JSONファイルから社員データを取得し、10件ずつページ表示するテーブルを実装してください。
部署のドロップダウンでフィルタリングする機能も含みます。

## 要件
- fetch() でJSONファイル(rows配列: id/name/department/role/email/status)を取得する
- 取得したデータはメモリに保持し、ページ移動・フィルターのたびに再通信しない
- 1ページあたり10件表示。前へ・次へボタンとページ番号ボタンで移動できる
- ページ数・ページボタンの数はJSONのデータ件数から Math.ceil(件数 / 10) で動的に計算する
- フィルター後も絞り込み件数から再計算し、ページボタンを動的に再生成する
- 部署ドロップダウンで絞り込み。選択肢はJSONのdepartment値から自動生成する
- フィルター変更時はページを1に戻してページ数を再計算する
- 絞り込み件数と総ページ数をリアルタイム表示する
- status(在籍/休職/退職)を色付きバッジで表示する
- フィルター結果が0件のときは「該当なし」メッセージを表示する

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要(テーブルは横スクロール対応)

## 動作詳細
グローバル変数 allRows(全件)と filteredRows(フィルター後)を分けて管理する。
総ページ数は Math.ceil(filteredRows.length / PAGE_SIZE) で常に動的に求める。
ページ番号ボタンはこの totalPages の数だけループで動的生成する。
フィルター変更時は currentPage = 1 にセット→filteredRows 更新→render() の順で処理する。
ページ番号ボタンには data-page 属性でページ番号を持たせ parseInt(this.dataset.page, 10) で取得する。
すべてのDOM操作は createElement / textContent を使い、innerHTML に変数を渡さない。

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