Table 2 — テーブル 2 — 表示制御

データ表示 初級

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

テーブルにデータを動的に流し込むと、文字の長さによって列幅がバラバラになりがちです。 長い文章が入ったセルが列全体を押し広げ、レイアウトが崩れることもあります。 このページでは「列幅を固定して安定させる」「長い文字列を省略(…)表示にする」「ヘッダークリックで並び替える」という3つの表示制御テクニックを紹介します。

デモでは「メモ」列に長い文章データが入っていますが、省略表示によって列幅が一定に保たれています。 省略されたセルにマウスを乗せると title 属性のツールチップで全文が確認できます。

  • 列幅固定table-layout: fixedthwidth 指定で、データ内容に関わらず列幅を確定させる
  • 長文省略(…)white-space: nowrapoverflow: hiddentext-overflow: ellipsis の3セットでセルをはみ出した文字を省略する
  • ツールチップ — 省略セルに title 属性を付与。マウスオーバーで全文を確認できる
  • 列ソート — ヘッダーをクリックすると昇順・降順を切り替え。▲▼アイコンで現在のソート方向を表示

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="tbl-scroll">
  <table class="tbl" id="js-table">
    <thead><tr id="js-thead-row"></tr></thead>
    <tbody id="js-tbody"></tbody>
  </table>
</div>

<script src="./script.js"></script>
</body>
</html>
/* テーブル(表示制御)— style.css */
*, *::before, *::after { box-sizing: border-box; }

body {
  font-family: sans-serif;
  padding: 24px;
  background: #f8f9fa;
}

.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;
  /* 列幅を th の width 指定どおりに固定する */
  table-layout: fixed;
}

.tbl th {
  background: #2B7FE8;
  color: #fff;
  padding: 10px 12px;
  text-align: left;
  font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
  user-select: none;
}

.tbl th:hover { background: #2268c5; }

/* ソートアイコン */
.tbl-sort-icon {
  display: inline-block;
  margin-left: 6px;
  font-size: 11px;
  opacity: 0.6;
  vertical-align: middle;
}

.tbl-sort-icon.is-active { opacity: 1; }

.tbl td {
  padding: 10px 12px;
  border-bottom: 1px solid #E5E9F2;
  color: #1A2332;
  /* overflow: hidden は省略表示に必要。table-layout: fixed と組み合わせて使う */
  overflow: hidden;
}

.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; }

/* 省略表示セル: 3つのプロパティをセットで指定する */
.tbl-cell-note {
  white-space: nowrap;   /* 折り返しを禁止する */
  text-overflow: ellipsis; /* はみ出た部分を「...」にする */
  color: #5A6A7A;
}

/* 状態バッジ */
.tbl-status {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 9999px;
  font-size: 12px;
  font-weight: 600;
}

.tbl-status[data-status="在籍"] { background: #D1FAE5; color: #065F46; }
.tbl-status[data-status="休職"] { background: #FEF3C7; color: #92400E; }
.tbl-status[data-status="退職"] { background: #F3F4F6; color: #6B7280; }
// ソート状態を管理する
var currentSort = { key: null, asc: true };
var tableData = null;

// JSONファイルを読み込んでテーブルを描画する
fetch('./data/data.json')
  .then(function (res) {
    if (!res.ok) { throw new Error('HTTP ' + res.status); }
    return res.json();
  })
  .then(function (data) {
    tableData = data;
    renderTable(data, data.rows);
  })
  .catch(function (err) {
    console.error('データ読み込みエラー:', err);
    var wrap = document.getElementById('js-wrap');
    var msg = document.createElement('p');
    msg.style.cssText = 'color:#9B1C1C; font-size:14px;';
    msg.textContent = 'データを読み込めませんでした。';
    wrap.appendChild(msg);
  });

function sortRows(rows, key, asc) {
  return rows.slice().sort(function (a, b) {
    var va = a[key], vb = b[key];
    if (typeof va === 'number' && typeof vb === 'number') {
      return asc ? va - vb : vb - va;
    }
    var sa = String(va != null ? va : '');
    var sb = String(vb != null ? vb : '');
    return asc ? sa.localeCompare(sb, 'ja') : sb.localeCompare(sa, 'ja');
  });
}

function renderTable(data, rows) {
  var theadRow = document.getElementById('js-thead-row');
  var tbody    = document.getElementById('js-tbody');
  theadRow.innerHTML = '';
  tbody.innerHTML = '';

  // ヘッダー行を生成する
  data.columns.forEach(function (col) {
    var th = document.createElement('th');
    if (col.width) { th.style.width = col.width; }

    var labelSpan = document.createElement('span');
    labelSpan.textContent = col.label;

    var iconSpan = document.createElement('span');
    iconSpan.className = 'tbl-sort-icon';
    if (currentSort.key === col.key) {
      iconSpan.textContent = currentSort.asc ? '▲' : '▼';
      iconSpan.classList.add('is-active');
    } else {
      iconSpan.textContent = '↕';
    }

    th.appendChild(labelSpan);
    th.appendChild(iconSpan);

    // クロージャで c を正しくキャプチャする
    (function (c) {
      th.addEventListener('click', function () {
        if (currentSort.key === c.key) {
          currentSort.asc = !currentSort.asc;
        } else {
          currentSort.key = c.key;
          currentSort.asc = true;
        }
        renderTable(data, sortRows(tableData.rows, currentSort.key, currentSort.asc));
      });
    }(col));

    theadRow.appendChild(th);
  });

  // データ行を生成する
  rows.forEach(function (row) {
    var tr = document.createElement('tr');
    data.columns.forEach(function (col) {
      var td = document.createElement('td');
      var val = row[col.key];
      if (col.key === 'note') {
        // title 属性で全文をツールチップ表示する
        td.className = 'tbl-cell-note';
        td.setAttribute('title', val != null ? val : '');
        td.textContent = val != null ? val : '';
      } else if (col.key === 'status') {
        var span = document.createElement('span');
        span.className = 'tbl-status';
        span.setAttribute('data-status', val != null ? val : '');
        span.textContent = val != null ? val : '';
        td.appendChild(span);
      } else {
        td.textContent = val != null ? val : '';
      }
      tr.appendChild(td);
    });
    tbody.appendChild(tr);
  });
}
{
  "columns": [
    { "key": "id",     "label": "ID",   "width": "56px"  },
    { "key": "name",   "label": "氏名", "width": "140px" },
    { "key": "role",   "label": "役職", "width": "150px" },
    { "key": "note",   "label": "メモ", "width": "220px" },
    { "key": "status", "label": "状態", "width": "80px"  }
  ],
  "rows": [
    { "id": 1, "name": "田中 一郎", "role": "リードエンジニア",       "status": "在籍", "note": "フロントエンド担当。ReactとTypeScriptの経験5年。チームのコードレビューを主導し、技術的な意思決定を行っている。" },
    { "id": 2, "name": "鈴木 花子", "role": "UIデザイナー",           "status": "在籍", "note": "Figmaを使ったUIデザイン・プロトタイプ作成を担当。ユーザーインタビューの経験もあり、UXリサーチにも携わっている。" },
    { "id": 3, "name": "佐藤 次郎", "role": "バックエンドエンジニア", "status": "在籍", "note": "Node.jsとPostgreSQLが得意。APIの設計・実装を担当。インフラ周りの知識もあり、CI/CDパイプラインの整備も行った。" },
    { "id": 4, "name": "山田 三枝", "role": "セールスマネージャー",   "status": "在籍", "note": "新規開拓と既存顧客のフォローを担当。昨年度は売上目標120%達成。チームのKPI管理とメンバーの育成も行っている。" },
    { "id": 5, "name": "伊藤 健太", "role": "エンジニア",             "status": "休職", "note": "モバイルアプリ開発(iOS/Android)担当。SwiftとKotlinの経験3年。現在育児休業中。" },
    { "id": 6, "name": "渡辺 美咲", "role": "コンテンツディレクター", "status": "在籍", "note": "SEO対策・SNS運用・ブログ記事のディレクションを担当。月間PV50万達成に貢献。GA4とSearchConsoleを使った分析も実施。" }
  ]
}

AI用プロンプト

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

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

💡 ソート対象の列・省略表示する列は要件に合わせて書き換えてください。

# テーブル(列幅固定・省略表示・ソート)作成依頼

## 概要
列幅を固定し、長い文字列を省略(…)表示し、ヘッダークリックで列ソートができるテーブルを実装してください。

## 要件
- table-layout: fixed と th の width 指定で列幅を固定する
- 指定した列(メモ列など)では white-space: nowrap / overflow: hidden / text-overflow: ellipsis の3セットで長文を省略する
- 省略されたセルにはマウスオーバーで全文が確認できる title 属性を付与する
- ヘッダーをクリックすると昇順・降順が切り替わる
- ソート中の列には ▲▼ アイコンを表示する
- ソートしていない列には ↕ アイコンを表示する

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

## 動作詳細
currentSort = { key: null, asc: true } でソート状態を管理する。
ヘッダークリック時、同じ列なら asc を反転し、別の列なら key を変えて asc を true にする。
ソート後は rows 配列をソートして renderTable() を再実行する。
文字列ソートは localeCompare('ja') で日本語に対応する。
数値列はそのまま大小比較する。

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