List 4 — リスト 4 — ページング

データ表示 中級

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

外部JSONファイルからデータを読み込み、<ul><li> のシンプルなリスト形式で10件ずつページ表示します。 ページ数・ページボタンの数は読み込んだJSONのデータ件数から自動で計算するため、データが増減しても変更は不要です。

テーブルほど複雑な列管理が不要なため、メンバー一覧・検索結果・通知履歴など「スッキリ読めるリスト」が必要な場面に向いています。 ページング処理の基本的な仕組みを学ぶ出発点としても最適なパターンです。 フィルタリング付きのより発展的なバージョンはテーブル版(テーブル 5 — ページング+フィルター)で確認できます。

  • JSONフェッチfetch() でJSONファイルを1回だけ取得し、メモリに保持する。ページ移動のたびに再通信しない
  • ページ数の動的計算 — 総ページ数はJSONの件数から Math.ceil(件数 / PAGE_SIZE) で計算。データが増えてもJS・HTMLを修正しなくてよい
  • ページボタンの動的生成 — ページ番号ボタンは計算された総ページ数をもとにJSで生成。ページ数が変わっても自動で増減する
  • ul/liレイアウト — 各アイテムに氏名・役職/部署・ステータスバッジを横並びで配置するシンプルな構造
  • 件数・ページ数表示 — 「30件 / 3ページ」のように件数と総ページ数をリアルタイム表示

実装のポイント・注意点

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

slice() でページ分だけ切り出す。 全データを持っていても表示するのは allRows.slice(start, start + PAGE_SIZE) の範囲分だけです。 start = (currentPage - 1) * PAGE_SIZE がそのページの開始位置です。 たとえば2ページ目なら start = 10 で、10〜19件目を表示します。

再描画前に textContent = '' でリストをクリアするul.textContent = ''ul 内のすべての li を一括削除します。 これをしないとページを移動するたびに前のページのアイテムが残ったまま追記されてしまいます。

DOM挿入は textContent / createElement を使うinnerHTML に変数を直接書くとデータに含まれる特殊文字がHTMLとして解釈されてレイアウトが崩れることがあります。

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

デモ

ページ番号は読み込んだJSONの件数から自動で計算・生成されます。

    サンプルソース

    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="lst-wrap">
      <!-- 件数・ページ数表示 -->
      <p class="count-text" id="js-count"></p>
    
      <!-- リスト(アイテムはJSで動的生成) -->
      <ul class="lst" id="js-list"></ul>
    
      <!-- ページング(ボタンは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:  #E2E8F0;
      --color-text:    #1A2332;
      --color-muted:   #64748B;
    }
    
    body {
      font-family: sans-serif;
      padding: 24px;
      max-width: 560px;
      background: #F8FAFC;
      color: var(--color-text);
    }
    
    /* ---- 件数表示 ---- */
    .count-text {
      font-size: 13px;
      color: var(--color-muted);
      margin: 0 0 10px;
    }
    
    /* ---- リスト ---- */
    .lst {
      list-style: none;
      padding: 0;
      margin: 0 0 16px;
    }
    
    .lst-item {
      display: flex;
      align-items: center;
      gap: 12px;
      padding: 12px 4px;
      border-bottom: 1px solid var(--color-border);
      flex-wrap: wrap;
    }
    
    .lst-item:last-child { border-bottom: none; }
    
    .lst-name {
      font-size: 15px;
      font-weight: 600;
      min-width: 100px;
    }
    
    .lst-meta {
      font-size: 13px;
      color: var(--color-muted);
      flex: 1;
    }
    
    /* ---- ステータスバッジ ---- */
    .status-badge {
      display: inline-block;
      padding: 2px 10px;
      border-radius: 9999px;
      font-size: 12px;
      font-weight: 600;
      white-space: nowrap;
    }
    
    .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;
      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 currentPage = 1; // 現在のページ番号
    var PAGE_SIZE = 10;  // 1ページの表示件数
    
    // --- DOM要素を取得 ---
    var countEl = document.getElementById('js-count');
    var listEl  = document.getElementById('js-list');
    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; // 全件をメモリに保持
        render();
      })
      .catch(function(err) {
        console.error('読み込みエラー:', err);
        var li = document.createElement('li');
        li.style.cssText = 'color:#9B1C1C; padding:16px; font-size:14px;';
        li.textContent = 'データを読み込めませんでした。ローカルサーバーで開いているか確認してください。';
        listEl.appendChild(li);
      });
    
    // --- リストとページングをまとめて更新 ---
    function render() {
      // ページ数をデータ件数から動的に計算(データが増えても変更不要)
      var totalPages = Math.ceil(allRows.length / PAGE_SIZE);
    
      // このページに表示するデータを切り出す
      var start = (currentPage - 1) * PAGE_SIZE;
      var pageRows = allRows.slice(start, start + PAGE_SIZE);
    
      renderList(pageRows);
      renderPager(totalPages);
      updateCount(totalPages);
    }
    
    // --- リスト本体を描画 ---
    function renderList(rows) {
      listEl.textContent = ''; // 前回のアイテムを一括クリア
    
      rows.forEach(function(row) {
        var li = document.createElement('li');
        li.className = 'lst-item';
    
        // 氏名
        var name = document.createElement('span');
        name.className = 'lst-name';
        name.textContent = row.name != null ? row.name : '';
    
        // 役職 / 部署
        var meta = document.createElement('span');
        meta.className = 'lst-meta';
        meta.textContent = (row.role || '') + ' / ' + (row.department || '');
    
        // ステータスバッジ
        var badge = document.createElement('span');
        badge.className = 'status-badge status-badge--' + getStatusClass(row.status);
        badge.textContent = row.status != null ? row.status : '';
    
        li.appendChild(name);
        li.appendChild(meta);
        li.appendChild(badge);
        listEl.appendChild(li);
      });
    }
    
    // --- ページングを描画 ---
    function renderPager(totalPages) {
      pagerEl.textContent = ''; // 前回のボタンをクリア
    
      if (totalPages <= 1) { return; } // 1ページ以下はページングを表示しない
    
      // 「前へ」ボタン
      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);
    }
    
    // --- 件数・ページ数を更新 ---
    function updateCount(totalPages) {
      // 件数と総ページ数はどちらもJSONの件数から計算した値
      countEl.textContent = allRows.length + '件 / ' + totalPages + 'ページ';
    }
    
    // --- ステータス文字列を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ファイルから社員データを取得し、ul/liのリスト形式で10件ずつページ表示するUIを実装してください。
    
    ## 要件
    - fetch() でJSONファイル(rows配列: id/name/department/role/email/status)を取得する
    - 取得したデータはメモリに保持し、ページ移動のたびに再通信しない
    - 1ページあたり10件表示。前へ・次へボタンとページ番号ボタンで移動できる
    - ページ数・ページボタンの数はJSONのデータ件数から Math.ceil(件数 / 10) で動的に計算する
    - 件数と総ページ数をリアルタイム表示する
    - status(在籍/休職/退職)を色付きバッジで表示する
    - 各リストアイテムに氏名・役職/部署・ステータスバッジを横並びで表示する
    
    ## 技術仕様
    - HTML / CSS / バニラJavaScript で実装
    - 外部ライブラリ:なし
    - レスポンシブ対応:必要
    
    ## 動作詳細
    fetch().then() でJSONを取得して allRows に保存し、初回描画する。
    総ページ数は Math.ceil(allRows.length / PAGE_SIZE) で常に動的に求める。
    ページ番号ボタンはこの totalPages の数だけループで動的生成する。
    表示データは allRows.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE) で切り出す。
    ページ番号ボタンには data-page 属性でページ番号を持たせ parseInt(this.dataset.page, 10) で取得する。
    すべてのDOM操作は createElement / textContent を使い、innerHTML に変数を渡さない。
    再描画前は ul.textContent = '' で前回のアイテムを一括クリアする。
    
    ## 出力形式
    HTML・CSS・JavaScriptを分けて出力してください。
    各ファイルは単独でコピー&ペーストして使えるよう記述してください。