Table 4 — テーブル 4 — リッチセル

データ表示 中級

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

テーブルのセルにはテキストだけでなく、リンク・ボタン・別のテーブルなどを埋め込むことができます。 このページでは「セル内のリンク」「セル内のネストテーブル」「ボタンクリックでダイアログを開く」という3つのリッチコンテンツパターンを1つの一覧表で確認できます。

デモでは「担当プロジェクト」列の各セルの中に、プロジェクト名と状態を並べた小テーブルが直接埋め込まれています。 また「詳細」ボタンをクリックするとモーダルダイアログが開き、その行の全データを確認できます。 ダイアログには標準HTML要素の <dialog> を使用しており、外部ライブラリ不要です。

  • セル内リンク — メール列を <a href="mailto:..."> として描画。クリックでメーラーが起動する
  • セル内ネストテーブル — 「担当プロジェクト」列の <td><table> を直接埋め込む。配列データを表形式でセル内に表示できる
  • アクションボタン — 各行に「詳細」ボタンを配置。クリックでその行のデータを使ってダイアログを開く
  • ダイアログ表示<dialog> 要素の showModal() でモーダル表示。背景クリックで閉じる
  • 動的生成 — ネストテーブルもダイアログもJavaScriptで都度生成するため、行ごとに異なる内容を表示できる

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" id="js-wrap"></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;
  white-space: nowrap;
}

.tbl th {
  background: #2B7FE8;
  color: #fff;
  padding: 10px 14px;
  text-align: left;
  font-weight: 600;
}

.tbl td {
  padding: 10px 14px;
  border-bottom: 1px solid #E5E9F2;
  color: #1A2332;
}

.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-link { color: #2B7FE8; text-decoration: none; }
.tbl-link:hover { text-decoration: underline; }

/* 状態バッジ */
.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; }

/* セル内ネストテーブルのラッパーセル */
.tbl-cell-projects {
  white-space: normal;
  vertical-align: top;
  padding: 8px 10px;
}

/* ネストテーブル */
.tbl-nest {
  border-collapse: collapse;
  width: 100%;
  font-size: 12px;
}

.tbl-nest th {
  background: #F4F7FF;
  color: #1A2332;
  padding: 4px 8px;
  text-align: left;
  font-weight: 600;
  border-bottom: 1.5px solid #C3D4EC;
}

.tbl-nest td {
  padding: 4px 8px;
  border-bottom: 1px solid #E5E9F2;
}

.tbl-nest tbody tr:last-child td { border-bottom: none; }

/* アクションセル */
.tbl-cell-action { text-align: center; }

/* 詳細ボタン */
.tbl-detail-btn {
  padding: 4px 14px;
  border-radius: 6px;
  border: 1.5px solid #2B7FE8;
  background: #fff;
  color: #2B7FE8;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  font-family: inherit;
  transition: background 0.15s, color 0.15s;
}

.tbl-detail-btn:hover { background: #2B7FE8; color: #fff; }

/* ====== ダイアログ ====== */
.detail-dialog {
  border: none;
  border-radius: 12px;
  padding: 28px 28px 24px;
  max-width: 520px;
  width: 90%;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}

.detail-dialog::backdrop { background: rgba(0, 0, 0, 0.4); }

.dialog-close {
  position: absolute;
  top: 12px;
  right: 14px;
  border: none;
  background: none;
  font-size: 18px;
  color: #5A6A7A;
  cursor: pointer;
  padding: 4px 8px;
  border-radius: 4px;
  font-family: inherit;
}

.dialog-close:hover { background: #F3F4F6; }

.dialog-name {
  margin: 0 0 16px;
  font-size: 18px;
  color: #1A2332;
}

/* 詳細リスト */
.dialog-details {
  display: grid;
  grid-template-columns: 72px 1fr;
  gap: 6px 12px;
  margin: 0 0 20px;
  font-size: 14px;
}

.dialog-details dt { color: #5A6A7A; font-weight: 600; }
.dialog-details dd { margin: 0; color: #1A2332; word-break: break-all; }

/* ネストテーブル */
.dialog-nest-heading { margin: 0 0 8px; font-size: 14px; font-weight: 700; }

.dialog-projects {
  border-collapse: collapse;
  width: 100%;
  font-size: 13px;
}

.dialog-projects th {
  background: #F4F7FF;
  color: #1A2332;
  padding: 6px 10px;
  text-align: left;
  font-weight: 600;
  border-bottom: 2px solid #C3D4EC;
}

.dialog-projects td {
  padding: 6px 10px;
  border-bottom: 1px solid #E5E9F2;
}

.dialog-projects tbody tr:last-child td { border-bottom: none; }
// ダイアログ要素を1つ作成して再利用する
var dialog = document.createElement('dialog');
dialog.className = 'detail-dialog';
document.body.appendChild(dialog);

// 背景クリックで閉じる
dialog.addEventListener('click', function (e) {
  if (e.target === dialog) { dialog.close(); }
});

// JSONファイルを読み込んでテーブルを描画する
// columnsにprojectsがないため、status列の直前に動的挿入する
fetch('./data/data.json')
  .then(function (res) {
    if (!res.ok) { throw new Error('HTTP ' + res.status); }
    return res.json();
  })
  .then(function (data) {
    var insertIdx = data.columns.length;
    for (var i = 0; i < data.columns.length; i++) {
      if (data.columns[i].key === 'status') { insertIdx = i; break; }
    }
    data.columns.splice(insertIdx, 0, { key: 'projects', label: '担当プロジェクト', width: '260px' });
    renderTable(data);
  })
  .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 openDialog(row) {
  dialog.textContent = '';

  // 閉じるボタン
  var closeBtn = document.createElement('button');
  closeBtn.className = 'dialog-close';
  closeBtn.textContent = '✕';
  closeBtn.addEventListener('click', function () { dialog.close(); });
  dialog.appendChild(closeBtn);

  // 名前
  var title = document.createElement('h3');
  title.className = 'dialog-name';
  title.textContent = row.name != null ? row.name : '';
  dialog.appendChild(title);

  // 詳細リスト
  var dl = document.createElement('dl');
  dl.className = 'dialog-details';
  [
    { label: '部署',   key: 'department' },
    { label: '役職',   key: 'role'       },
    { label: 'メール', key: 'email'      },
    { label: '状態',   key: 'status'     },
    { label: 'メモ',   key: 'note'       }
  ].forEach(function (f) {
    var dt = document.createElement('dt');
    dt.textContent = f.label;
    var dd = document.createElement('dd');
    dd.textContent = row[f.key] != null ? row[f.key] : '—';
    dl.appendChild(dt);
    dl.appendChild(dd);
  });
  dialog.appendChild(dl);

  // 担当プロジェクト(ネストテーブル)
  if (row.projects && row.projects.length > 0) {
    var h4 = document.createElement('h4');
    h4.className = 'dialog-nest-heading';
    h4.textContent = '担当プロジェクト';
    dialog.appendChild(h4);

    var nestTable = document.createElement('table');
    nestTable.className = 'dialog-projects';

    var thead = document.createElement('thead');
    var headRow = document.createElement('tr');
    ['プロジェクト名', '役割', 'ステータス'].forEach(function (label) {
      var th = document.createElement('th');
      th.textContent = label;
      headRow.appendChild(th);
    });
    thead.appendChild(headRow);
    nestTable.appendChild(thead);

    var tbody = document.createElement('tbody');
    row.projects.forEach(function (p) {
      var tr = document.createElement('tr');
      [p.name, p.role, p.status].forEach(function (val) {
        var td = document.createElement('td');
        td.textContent = val != null ? val : '—';
        tr.appendChild(td);
      });
      tbody.appendChild(tr);
    });
    nestTable.appendChild(tbody);
    dialog.appendChild(nestTable);
  }

  dialog.showModal();
}

function renderTable(data) {
  var wrap = document.getElementById('js-wrap');
  wrap.innerHTML = '';

  var table = document.createElement('table');
  table.className = 'tbl';

  // thead(表示列 + アクション列)
  var thead = document.createElement('thead');
  var headRow = document.createElement('tr');
  data.columns.forEach(function (col) {
    var th = document.createElement('th');
    th.textContent = col.label;
    if (col.width) { th.style.width = col.width; }
    headRow.appendChild(th);
  });
  var thAct = document.createElement('th');
  thAct.textContent = '詳細';
  thAct.style.width = '80px';
  headRow.appendChild(thAct);
  thead.appendChild(headRow);
  table.appendChild(thead);

  // tbody
  var tbody = document.createElement('tbody');
  data.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 === 'projects') {
        // プロジェクト列: td の中にネストテーブルを描画する
        td.className = 'tbl-cell-projects';
        var projects = row.projects;
        if (projects && projects.length > 0) {
          var nestTable = document.createElement('table');
          nestTable.className = 'tbl-nest';
          var ntHead = document.createElement('thead');
          var ntHeadRow = document.createElement('tr');
          ['プロジェクト名', '状態'].forEach(function (label) {
            var th = document.createElement('th');
            th.textContent = label;
            ntHeadRow.appendChild(th);
          });
          ntHead.appendChild(ntHeadRow);
          nestTable.appendChild(ntHead);
          var ntBody = document.createElement('tbody');
          projects.forEach(function (p) {
            var ntr = document.createElement('tr');
            [p.name, p.status].forEach(function (v) {
              var ntd = document.createElement('td');
              ntd.textContent = v != null ? v : '—';
              ntr.appendChild(ntd);
            });
            ntBody.appendChild(ntr);
          });
          nestTable.appendChild(ntBody);
          td.appendChild(nestTable);
        } else {
          td.textContent = '—';
        }
      } else if (col.key === 'email') {
        // メール列はリンクとして描画する
        var a = document.createElement('a');
        var emailStr = String(val != null ? val : '');
        a.href = (emailStr.indexOf('@') !== -1) ? 'mailto:' + emailStr : '#';
        a.textContent = emailStr;
        a.className = 'tbl-link';
        td.appendChild(a);
      } 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);
    });

    // アクションセル: 詳細ボタン
    var tdAct = document.createElement('td');
    tdAct.className = 'tbl-cell-action';
    var btn = document.createElement('button');
    btn.className = 'tbl-detail-btn';
    btn.textContent = '詳細';
    btn.type = 'button';
    btn.addEventListener('click', function () { openDialog(row); });
    tdAct.appendChild(btn);
    tr.appendChild(tdAct);

    tbody.appendChild(tr);
  });
  table.appendChild(tbody);
  wrap.appendChild(table);
}
{
  "columns": [
    { "key": "id",     "label": "ID",     "width": "56px"  },
    { "key": "name",   "label": "氏名",   "width": "130px" },
    { "key": "email",  "label": "メール", "width": "180px" },
    { "key": "status", "label": "状態",   "width": "80px"  }
  ],
  "rows": [
    {
      "id": 1, "name": "田中 一郎", "email": "[email protected]", "status": "在籍",
      "department": "開発部", "role": "リードエンジニア", "note": "フロントエンド担当。ReactとTypeScriptの経験5年。",
      "projects": [
        { "name": "ダッシュボードUI改修", "role": "リーダー", "status": "進行中" },
        { "name": "認証システム移行",     "role": "メンバー", "status": "完了"   }
      ]
    },
    {
      "id": 2, "name": "鈴木 花子", "email": "[email protected]", "status": "在籍",
      "department": "デザイン部", "role": "UIデザイナー", "note": "Figmaを使ったUIデザイン・プロトタイプ作成を担当。",
      "projects": [
        { "name": "モバイルアプリリニューアル", "role": "リーダー", "status": "進行中" },
        { "name": "デザインシステム整備",       "role": "リーダー", "status": "進行中" }
      ]
    },
    {
      "id": 3, "name": "佐藤 次郎", "email": "[email protected]", "status": "在籍",
      "department": "開発部", "role": "バックエンドエンジニア", "note": "Node.jsとPostgreSQLが得意。API設計・実装を担当。",
      "projects": [
        { "name": "API v2 開発",  "role": "リーダー", "status": "進行中" },
        { "name": "DB最適化対応", "role": "リーダー", "status": "完了"   }
      ]
    },
    {
      "id": 4, "name": "山田 三枝", "email": "[email protected]", "status": "在籍",
      "department": "営業部", "role": "セールスマネージャー", "note": "新規開拓と既存顧客フォローを担当。目標120%達成。",
      "projects": [
        { "name": "Q3営業キャンペーン", "role": "リーダー", "status": "完了"   },
        { "name": "新規パートナー開拓", "role": "リーダー", "status": "進行中" }
      ]
    },
    {
      "id": 5, "name": "伊藤 健太", "email": "[email protected]", "status": "休職",
      "department": "開発部", "role": "エンジニア", "note": "モバイルアプリ開発担当。現在育児休業中。",
      "projects": [
        { "name": "モバイルアプリリニューアル", "role": "メンバー", "status": "進行中" }
      ]
    }
  ]
}

AI用プロンプト

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

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

💡 ネストテーブルのカラム名やダイアログに表示するフィールドは要件に合わせて変更してください。

# テーブル(リッチセル)作成依頼

## 概要
セル内リンク・セル内ネストテーブル・ボタンクリックでダイアログを開くリッチなテーブルを実装してください。

## 要件
- メール列を <a href="mailto:..."> として描画する
- 担当プロジェクト列の <td> 内に <table> を直接埋め込む(セル内ネストテーブル)
- 各行に「詳細」ボタンを配置し、クリックで <dialog> 要素のモーダルを表示する
- ダイアログ内にその行の全フィールドをキーバリュー形式で表示する
- ダイアログ内に担当プロジェクト一覧をネストしたテーブルで表示する
- ダイアログの外側(backdrop)をクリックすると閉じる
- 「✕」ボタンでもダイアログを閉じる

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要
- dialog 要素の showModal() / close() を使用する

## 動作詳細
ダイアログ要素は1つ作成して再利用する(都度 textContent = '' でクリアして内容を差し替える)。
背景クリック検知は dialog の click イベントで e.target === dialog 判定で実装する。
ボタンのクリックイベントはクロージャで row を正しくキャプチャする。
動的データの DOM 挿入は textContent を使い innerHTML に変数を直接渡さない。
メールの href は indexOf('@') でバリデーションしてから mailto: を付与する。

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