詳細モーダル付きテーブル画面(一覧→詳細表示)

応用例 中級

この画面パターンについて

「一覧で探して、行をクリックして詳細を見る」という業務アプリの基本動線を、ページ遷移なしのモーダルで実現する画面パターンです。 テーブルには要約列だけを置き、詳細はモーダル内のタブ(基本情報・履歴)に整理して、一覧の見やすさと情報量を両立します。 行データとモーダルの紐付け方、開閉時のフォーカス制御が実装の核心です。

こんな場面で使えます

  • 案件・タスク管理 — 行をクリックして詳細と対応履歴を確認する
  • 問い合わせ管理 — 一覧から離れずに内容と経緯を確認する
  • 申請・承認一覧 — 承認前に申請内容の詳細をその場で確認する

この画面で使っているUIコンポーネント

#パーツこの画面での役割
1テーブル(クリック可能な行)ホバー強調+クリックで詳細表示へ
2ステータスバッジ対応中・完了・保留を色分け表示
3モーダルダイアログ行クリックで詳細を表示
4モーダル内タブ基本情報/履歴を切り替え
5定義リストラベル+値で詳細を整列表示
6タイムライン更新履歴を時系列表示
7キーボード・フォーカス制御ESCで閉じる・元の行へ復帰

実装のポイント・注意点

一覧と詳細をつなぐ核は、行に data-id を持たせてクリック時にIDからレコードを引き、モーダルへ流し込む紐付けです。 モーダルの開閉自体は単体事例と同じでも、一覧に組み込むと「どの行から開いたか」の記憶が必要になります。 lastFocusedRow に行要素を控えて閉じたときにフォーカスを戻すことで、キーボード操作でも一覧へ迷わず復帰できます。

もう1つの定番バグはタブの残留で、前回「履歴」タブのまま別の行を開くとユーザーの誤認を招くため、openModal() で毎回「基本情報」タブへリセットします。 オーバーレイクリックは e.target === overlay のときだけ閉じ、モーダル内クリックの貫通を防ぎます。

7個のUIコンポーネントをHTML・CSS・バニラJavaScriptのみで組み合わせており、 フレームワーク不要で画面ごとコピペして使えます。

動作サンプル

詳細モーダル付きテーブル画面(一覧→詳細表示)のデモ画面 動作サンプルを別ウィンドウで確認 ↗

試してみる:

  • 行をクリック(またはTabキーで選んでEnter)して詳細モーダルを開く
  • モーダル内の「履歴」タブでタイムライン表示を確認
  • ESCキーで閉じて、フォーカスが元の行へ戻ることを確認

そのほかの操作も自由に試してみてください。

サンプルソース

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

<!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="detail-screen">
  <h1 class="screen-title">案件一覧</h1>

  <!-- ===== テーブル ===== -->
  <div class="table-wrapper">
    <table class="data-table">
      <thead>
        <tr>
          <th>ID</th>
          <th>件名</th>
          <th>担当</th>
          <th>ステータス</th>
          <th>更新日</th>
        </tr>
      </thead>
      <tbody id="tableBody"><!-- JSで生成。各行に data-id と tabindex="0" --></tbody>
    </table>
  </div>

  <!-- ===== 詳細モーダル ===== -->
  <div class="modal-overlay" id="modalOverlay" hidden>
    <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
      <div class="modal-header">
        <h2 class="modal-title" id="modalTitle"></h2>
        <span class="badge" id="modalStatusBadge"></span>
        <button type="button" class="modal-close-btn" id="modalCloseBtn" aria-label="閉じる">×</button>
      </div>

      <!-- モーダル内タブ -->
      <div class="modal-tabs" role="tablist">
        <button type="button" class="modal-tab is-active" data-tab="info" role="tab" aria-selected="true">基本情報</button>
        <button type="button" class="modal-tab" data-tab="history" role="tab" aria-selected="false">履歴</button>
      </div>

      <!-- 基本情報タブ -->
      <div class="modal-panel" data-panel="info">
        <dl class="detail-list" id="detailList"><!-- JSで dt/dd を生成 --></dl>
      </div>

      <!-- 履歴タブ -->
      <div class="modal-panel" data-panel="history" hidden>
        <ol class="timeline" id="historyTimeline"><!-- JSで生成 --></ol>
      </div>
    </div>
  </div>
</div>

<script src="./script.js"></script>
</body>
</html>
/* ===== 詳細モーダル付きテーブル画面 — style.css ===== */
*, *::before, *::after { box-sizing: border-box; }

:root {
  --color-primary:   #2B7FE8;
  --color-text:      #1E293B;
  --color-muted:     #64748B;
  --color-border:    #D0D7E0;
  --color-bg:        #F4F6F9;
  --color-card:      #FFFFFF;
  --color-th-bg:     #F1F5F9;
  --color-row-hover: #F0F6FF;
}

body {
  margin: 0;
  font-family: "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo, sans-serif;
  background: var(--color-bg);
  color: var(--color-text);
}

/* ===== 画面レイアウト ===== */
.detail-screen {
  max-width: 1160px;
  margin: 0 auto;
  padding: 24px 20px 48px;
}

.screen-title {
  font-size: 22px;
  margin: 0 0 16px;
}

/* ===== テーブル ===== */
/* スマホ幅では横スクロールで対応する */
.table-wrapper {
  overflow-x: auto;
  background: var(--color-card);
  border: 1px solid var(--color-border);
  border-radius: 8px;
}

.data-table {
  width: 100%;
  min-width: 640px;
  border-collapse: collapse;
  font-size: 14px;
}

.data-table th,
.data-table td {
  padding: 10px 14px;
  text-align: left;
  border-bottom: 1px solid #E6EBF1;
}

.data-table thead th {
  background: var(--color-th-bg);
  font-size: 13px;
  color: var(--color-muted);
  white-space: nowrap;
}

.data-table tbody tr:last-child td { border-bottom: none; }

/* 行はクリック可能 — ホバーとフォーカスで分かるようにする */
.data-table tbody tr { cursor: pointer; }
.data-table tbody tr:hover { background: var(--color-row-hover); }

.data-table tbody tr:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: -2px;
}

/* ===== ステータスバッジ ===== */
.badge {
  display: inline-block;
  padding: 3px 12px;
  font-size: 12px;
  border-radius: 9999px;
  white-space: nowrap;
}

.badge-open { color: #1D4ED8; background: #DBEAFE; }
.badge-done { color: #166534; background: #DCFCE7; }
.badge-hold { color: #854D0E; background: #FEF9C3; }

/* ===== モーダルオーバーレイ ===== */
.modal-overlay {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.4);
  animation: fade-in 0.15s ease;
}

@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* ===== モーダル本体 ===== */
.modal {
  width: calc(100% - 32px);
  max-width: 560px;
  max-height: 80vh;
  overflow-y: auto;
  background: var(--color-card);
  border-radius: 10px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}

.modal-header {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 18px 20px 14px;
}

.modal-title {
  flex: 1;
  margin: 0;
  font-size: 17px;
  line-height: 1.4;
}

.modal-close-btn {
  width: 32px;
  height: 32px;
  font-size: 20px;
  line-height: 1;
  color: var(--color-muted);
  background: transparent;
  border: none;
  border-radius: 6px;
  cursor: pointer;
}

.modal-close-btn:hover { background: var(--color-bg); }

.modal-close-btn:focus-visible {
  outline: 2px solid var(--color-primary);
}

/* ===== モーダル内タブ ===== */
.modal-tabs {
  display: flex;
  gap: 4px;
  padding: 0 20px;
  border-bottom: 1px solid var(--color-border);
}

.modal-tab {
  padding: 10px 14px;
  margin-bottom: -1px;
  font-size: 14px;
  color: var(--color-muted);
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  cursor: pointer;
}

.modal-tab:hover { color: var(--color-primary); }

/* 選択中タブは下線+アクティブ色 */
.modal-tab.is-active {
  color: var(--color-primary);
  font-weight: 700;
  border-bottom-color: var(--color-primary);
}

.modal-panel {
  padding: 16px 20px 20px;
}

/* ===== 定義リスト(基本情報タブ) ===== */
.detail-list {
  display: grid;
  grid-template-columns: 120px 1fr;
  row-gap: 10px;
  margin: 0;
}

.detail-list dt {
  font-size: 13px;
  color: var(--color-muted);
  padding-top: 1px;
}

.detail-list dd {
  margin: 0;
  font-size: 14px;
  line-height: 1.6;
}

/* ===== タイムライン(履歴タブ) ===== */
.timeline {
  list-style: none;
  margin: 4px 0 0 6px;
  padding: 0 0 0 18px;
  border-left: 2px solid var(--color-border);
}

.timeline-item {
  position: relative;
  padding-bottom: 16px;
}

.timeline-item:last-child { padding-bottom: 4px; }

/* 左ラインの上に乗せるドット */
.timeline-item::before {
  content: "";
  position: absolute;
  left: -24px;
  top: 3px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--color-primary);
}

.timeline-meta {
  display: flex;
  gap: 10px;
  margin: 0 0 2px;
  font-size: 12px;
  color: var(--color-muted);
}

.timeline-date { font-weight: 700; }

.timeline-label {
  margin: 0;
  font-size: 14px;
  line-height: 1.6;
}

/* hidden 属性を確実に効かせる(display 指定との競合対策) */
.detail-screen [hidden] { display: none !important; }

/* ===== レスポンシブ(768px以下) ===== */
@media (max-width: 768px) {
  .detail-list { grid-template-columns: 90px 1fr; }
  .modal { max-height: 85vh; }
}
/* =====================================================
   詳細モーダル付きテーブル画面のスクリプト

   仕組み:テーブル行は RECORDS から生成し、行の data-id に
   レコードIDを持たせる。行クリック(またはEnter)でIDから
   レコードを探し、openModal() がモーダルへ流し込んで表示する。
   開くたびにタブは「基本情報」へリセットし、閉じるときは
   lastFocusedRow に控えた行へフォーカスを戻す。

   DOM生成は createElement + textContent(XSS対策)。
   ===================================================== */

// ===== 設定値 =====
// ステータス値と表示名・バッジ色クラスの対応表
var STATUS_MAP = {
  open: { label: '対応中', className: 'badge-open' },
  done: { label: '完了',   className: 'badge-done' },
  hold: { label: '保留',   className: 'badge-hold' }
};

// 基本情報タブに表示する項目(key はレコードのフィールド名・表示順)
var DETAIL_FIELDS = [
  { key: 'id',        label: 'ID' },
  { key: 'assignee',  label: '担当' },
  { key: 'type',      label: '区分' },
  { key: 'createdAt', label: '登録日' },
  { key: 'note',      label: '備考' }
];

// ===== サンプルデータ(8件) =====
var RECORDS = [
  {
    id: 'CASE-001',
    title: 'サンプル案件A',
    assignee: '担当者A',
    type: 'タイプ1',
    status: 'open',
    createdAt: '2026-05-20',
    updatedAt: '2026-06-05',
    note: '備考のサンプルテキストです。補足事項をここに記載します。',
    history: [
      { date: '2026-06-05', label: 'ステータスを「対応中」に変更', by: '担当者A' },
      { date: '2026-05-28', label: '担当者を割り当て', by: '管理者' },
      { date: '2026-05-20', label: '新規登録', by: '管理者' }
    ]
  },
  {
    id: 'CASE-002',
    title: 'サンプル案件B',
    assignee: '担当者B',
    type: 'タイプ2',
    status: 'done',
    createdAt: '2026-05-12',
    updatedAt: '2026-06-02',
    note: '対応完了済みの案件です。',
    history: [
      { date: '2026-06-02', label: 'ステータスを「完了」に変更', by: '担当者B' },
      { date: '2026-05-25', label: 'ステータスを「対応中」に変更', by: '担当者B' },
      { date: '2026-05-15', label: '担当者を割り当て', by: '管理者' },
      { date: '2026-05-12', label: '新規登録', by: '管理者' }
    ]
  },
  {
    id: 'CASE-003',
    title: 'サンプル案件C',
    assignee: '担当者C',
    type: 'タイプ3',
    status: 'hold',
    createdAt: '2026-05-25',
    updatedAt: '2026-06-01',
    note: '確認待ちのため保留中です。',
    history: [
      { date: '2026-06-01', label: 'ステータスを「保留」に変更', by: '担当者C' },
      { date: '2026-05-27', label: '担当者を割り当て', by: '管理者' },
      { date: '2026-05-25', label: '新規登録', by: '管理者' }
    ]
  },
  {
    id: 'CASE-004',
    title: 'サンプル案件D',
    assignee: '担当者A',
    type: 'タイプ2',
    status: 'open',
    createdAt: '2026-05-08',
    updatedAt: '2026-06-08',
    note: '追加の資料を確認中です。',
    history: [
      { date: '2026-06-08', label: '備考を更新', by: '担当者A' },
      { date: '2026-05-18', label: 'ステータスを「対応中」に変更', by: '担当者A' },
      { date: '2026-05-08', label: '新規登録', by: '管理者' }
    ]
  },
  {
    id: 'CASE-005',
    title: 'サンプル案件E',
    assignee: '担当者B',
    type: 'タイプ1',
    status: 'done',
    createdAt: '2026-04-28',
    updatedAt: '2026-05-30',
    note: '完了報告済みです。',
    history: [
      { date: '2026-05-30', label: 'ステータスを「完了」に変更', by: '担当者B' },
      { date: '2026-05-20', label: '備考を更新', by: '担当者B' },
      { date: '2026-05-02', label: 'ステータスを「対応中」に変更', by: '担当者B' },
      { date: '2026-04-28', label: '新規登録', by: '管理者' }
    ]
  },
  {
    id: 'CASE-006',
    title: 'サンプル案件F',
    assignee: '担当者C',
    type: 'タイプ3',
    status: 'open',
    createdAt: '2026-05-30',
    updatedAt: '2026-06-09',
    note: '初回ヒアリングが完了しました。',
    history: [
      { date: '2026-06-09', label: 'ステータスを「対応中」に変更', by: '担当者C' },
      { date: '2026-06-02', label: '担当者を割り当て', by: '管理者' },
      { date: '2026-05-30', label: '新規登録', by: '管理者' }
    ]
  },
  {
    id: 'CASE-007',
    title: 'サンプル案件G',
    assignee: '担当者A',
    type: 'タイプ1',
    status: 'hold',
    createdAt: '2026-05-15',
    updatedAt: '2026-05-28',
    note: '先方からの回答待ちです。',
    history: [
      { date: '2026-05-28', label: 'ステータスを「保留」に変更', by: '担当者A' },
      { date: '2026-05-22', label: 'ステータスを「対応中」に変更', by: '担当者A' },
      { date: '2026-05-15', label: '新規登録', by: '管理者' }
    ]
  },
  {
    id: 'CASE-008',
    title: 'サンプル案件H',
    assignee: '担当者B',
    type: 'タイプ2',
    status: 'open',
    createdAt: '2026-06-01',
    updatedAt: '2026-06-10',
    note: '今週中に対応予定です。',
    history: [
      { date: '2026-06-10', label: 'ステータスを「対応中」に変更', by: '担当者B' },
      { date: '2026-06-04', label: '担当者を割り当て', by: '管理者' },
      { date: '2026-06-01', label: '新規登録', by: '管理者' }
    ]
  }
];

// ===== DOM要素 =====
var tableBody       = document.getElementById('tableBody');
var overlay         = document.getElementById('modalOverlay');
var modalTitle      = document.getElementById('modalTitle');
var statusBadge     = document.getElementById('modalStatusBadge');
var closeBtn        = document.getElementById('modalCloseBtn');
var detailList      = document.getElementById('detailList');
var historyTimeline = document.getElementById('historyTimeline');
var modalTabs       = document.querySelectorAll('.modal-tab');
var modalPanels     = document.querySelectorAll('.modal-panel');

// 閉じたときにフォーカスを戻す行(どの行から開いたかの記憶)
var lastFocusedRow = null;

// ===== テーブル描画 =====
RECORDS.forEach(function (record) {
  var tr = document.createElement('tr');
  tr.dataset.id = record.id;
  tr.tabIndex = 0; // Tabキーで行を選んでEnterで開けるようにする
  tr.appendChild(createCell(record.id));
  tr.appendChild(createCell(record.title));
  tr.appendChild(createCell(record.assignee));
  tr.appendChild(createStatusCell(record.status));
  tr.appendChild(createCell(record.updatedAt));
  tableBody.appendChild(tr);
});

// テキストセルを生成する
function createCell(text) {
  var td = document.createElement('td');
  td.textContent = text;
  return td;
}

// ステータスバッジ入りのセルを生成する
function createStatusCell(status) {
  var td = document.createElement('td');
  var badge = document.createElement('span');
  applyStatusBadge(badge, status);
  td.appendChild(badge);
  return td;
}

// span にステータスの表示名とバッジ色を反映する(テーブル・モーダルで共用)
function applyStatusBadge(span, status) {
  var info = STATUS_MAP[status];
  span.className = 'badge ' + info.className;
  span.textContent = info.label;
}

// ===== モーダルを開く =====
// 行クリック → data-id から対象レコードを探してモーダルに詰めて開く
tableBody.addEventListener('click', function (e) {
  var row = e.target.closest('tr');
  if (row) { openModal(row); }
});

// 行でEnter → クリックと同じ動作(キーボード操作対応)
tableBody.addEventListener('keydown', function (e) {
  var row = e.target.closest('tr');
  if (row && e.key === 'Enter') { openModal(row); }
});

function openModal(row) {
  var record = findRecord(row.dataset.id);
  if (!record) { return; }

  // 閉じたときにフォーカスを戻せるよう、開いた行を控えておく
  lastFocusedRow = row;

  // ヘッダー(件名+ステータスバッジ)
  modalTitle.textContent = record.title;
  applyStatusBadge(statusBadge, record.status);

  renderDetail(record);
  renderHistory(record);

  // 前回開いたタブが残らないよう、開くたびに「基本情報」へ戻す
  selectTab('info');

  overlay.hidden = false;
  document.body.style.overflow = 'hidden'; // 背景のスクロールを止める
  closeBtn.focus();
}

// IDからレコードを探す
function findRecord(id) {
  for (var i = 0; i < RECORDS.length; i++) {
    if (RECORDS[i].id === id) { return RECORDS[i]; }
  }
  return null;
}

// 基本情報タブの定義リスト(dt/dd)を描き直す
function renderDetail(record) {
  detailList.textContent = '';
  DETAIL_FIELDS.forEach(function (field) {
    var dt = document.createElement('dt');
    dt.textContent = field.label;
    var dd = document.createElement('dd');
    dd.textContent = record[field.key];
    detailList.appendChild(dt);
    detailList.appendChild(dd);
  });
}

// 履歴タブのタイムラインを描き直す
function renderHistory(record) {
  historyTimeline.textContent = '';
  record.history.forEach(function (entry) {
    var li = document.createElement('li');
    li.className = 'timeline-item';

    var meta = document.createElement('div');
    meta.className = 'timeline-meta';
    var date = document.createElement('span');
    date.className = 'timeline-date';
    date.textContent = entry.date;
    var by = document.createElement('span');
    by.className = 'timeline-by';
    by.textContent = entry.by;
    meta.appendChild(date);
    meta.appendChild(by);

    var label = document.createElement('p');
    label.className = 'timeline-label';
    label.textContent = entry.label;

    li.appendChild(meta);
    li.appendChild(label);
    historyTimeline.appendChild(li);
  });
}

// ===== タブ切り替え =====
// タブクリック → 対応するパネルだけを表示する
modalTabs.forEach(function (tab) {
  tab.addEventListener('click', function () {
    selectTab(tab.dataset.tab);
  });
});

// 指定した名前のタブをアクティブにし、対応するパネルだけを表示する
function selectTab(name) {
  modalTabs.forEach(function (tab) {
    var isActive = (tab.dataset.tab === name);
    tab.classList.toggle('is-active', isActive);
    tab.setAttribute('aria-selected', String(isActive));
  });
  modalPanels.forEach(function (panel) {
    panel.hidden = (panel.dataset.panel !== name);
  });
}

// ===== モーダルを閉じる =====
closeBtn.addEventListener('click', closeModal);

// オーバーレイ自体のクリックだけで閉じる
// (e.target === overlay の判定でモーダル内クリックの貫通を防ぐ)
overlay.addEventListener('click', function (e) {
  if (e.target === overlay) { closeModal(); }
});

// ESCキー → モーダル表示中のみ閉じる
document.addEventListener('keydown', function (e) {
  if (e.key === 'Escape' && !overlay.hidden) { closeModal(); }
});

function closeModal() {
  overlay.hidden = true;
  document.body.style.overflow = ''; // 背景のスクロールを復帰

  // 開いた行へフォーカスを戻す(キーボード操作でも一覧へ迷わず復帰できる)
  if (lastFocusedRow) { lastFocusedRow.focus(); }
}

AI用プロンプト

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

ChatGPTやClaudeにこのプロンプトを渡すと、同様の画面をゼロから生成・カスタマイズできます。表示項目の追加やタブの増設など、要件を追記して使うのがおすすめです。

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

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

# 詳細モーダル付きテーブル画面 作成依頼

## 概要
テーブルの行をクリックすると、その行の詳細情報をモーダルダイアログで表示する
「一覧→詳細」パターンの画面を作成してください。

## 要件
- テーブルは ID・件名・担当・ステータス・更新日 の5列、8行程度
- ステータスは「対応中(青)・完了(緑)・保留(黄)」のバッジで色分け表示する
- 行はホバーで背景色が変わり、クリックまたはEnterキーでモーダルが開く
- モーダルには件名+ステータスバッジのヘッダーと「基本情報」「履歴」の2タブを設ける
- 基本情報タブは ID・担当・区分・登録日・備考 をラベル+値の定義リストで表示する
- 履歴タブはそのレコードの更新履歴(日付・内容・操作者)をタイムライン形式で表示する
- モーダルは ×ボタン・モーダル外クリック・ESCキー のいずれでも閉じられる
- モーダルを閉じたらクリック元の行にフォーカスを戻す
- モーダル表示中は背景ページをスクロールさせない

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- データはJavaScript内の定数配列(8件、各レコードに履歴3〜4件)で保持する
- レスポンシブ対応:必要(モーダルはスマホ幅で画面端に余白を残して全幅表示)

## 動作詳細
- 行に data-id を持たせ、クリック時にIDからレコードを検索してモーダルに反映する
- モーダルを開くたびにタブは「基本情報」へリセットする
- DOM生成は createElement と textContent を使い、innerHTML に変数を結合しない

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