Table 3 — テーブル 3 — インライン編集

データ表示 中級

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

業務アプリでデータを素早く修正したいとき、モーダルやフォームを開くのは操作コストが高くなります。 インライン編集は「セルをクリックしてその場で直接入力する」UXで、マスターデータ管理や設定画面でよく使われます。

このページでは、セルクリックでテキスト入力欄に切り替わり、Enter/Tabで確定・Escでキャンセルできるテーブルを実装します。 状態列はテキスト入力ではなくドロップダウン(select)に切り替わるため、正しい値しか入力できません。 ヘッダーの ✎ マークが付いた列が編集可能列です。

  • クリックで編集開始 — 編集可能セルをクリックすると input または select に切り替わる
  • Enter / Tab で確定 — 入力内容をデータに反映してテキスト表示に戻る
  • Esc でキャンセル — 編集前の値に戻す
  • フォーカスアウトで確定 — セル外をクリックしたときも確定される
  • 状態列はセレクト — 「在籍 / 休職 / 退職」の選択肢から選ぶドロップダウンに切り替わる
  • ID列は読み取り専用 — 編集不可のセルはポインタカーソルにならず、クリックしても何も起きない

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">
  <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;
  table-layout: fixed;
}

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

/* 編集可能マーク */
.tbl-edit-hint {
  margin-left: 6px;
  font-size: 12px;
  opacity: 0.7;
}

/* 読み取り専用セル */
.tbl-cell-readonly {
  color: #9AA5B4;
  cursor: default;
}

/* 編集可能セル */
.tbl-cell-editable { cursor: pointer; }
.tbl-cell-editable:hover { background: #EBF2FF !important; }

/* 編集中セル */
.tbl-cell-editable.is-editing {
  padding: 0;
  cursor: default;
  background: #fff !important;
  box-shadow: inset 0 0 0 2px #2B7FE8;
}

.tbl td {
  padding: 8px 12px;
  border-bottom: 1px solid #E5E9F2;
  color: #1A2332;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

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

/* インライン編集 input */
.tbl-input {
  display: block;
  width: 100%;
  padding: 8px 12px;
  border: none;
  outline: none;
  background: transparent;
  font-size: 14px;
  font-family: inherit;
  color: #1A2332;
}

/* インライン編集 select(状態列) */
.tbl-select {
  display: block;
  width: 100%;
  padding: 8px 12px;
  border: none;
  outline: none;
  background: transparent;
  font-size: 14px;
  font-family: inherit;
  cursor: pointer;
}

/* 状態バッジ */
.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 STATUS_OPTS = ['在籍', '休職', '退職'];
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);
  })
  .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 renderCell(td, row, col) {
  td.textContent = '';
  var val = row[col.key];
  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 : '';
  }
}

// セルをインライン編集モードにする
function makeEditable(td, row, col) {
  if (!col.editable) return;
  if (td.classList.contains('is-editing')) return;

  td.classList.add('is-editing');
  td.textContent = '';
  var savedVal = String(row[col.key] != null ? row[col.key] : '');

  var input;
  if (col.type === 'select') {
    // 状態列はドロップダウンに切り替える
    input = document.createElement('select');
    input.className = 'tbl-select';
    STATUS_OPTS.forEach(function (opt) {
      var option = document.createElement('option');
      option.value = opt;
      option.textContent = opt;
      if (opt === savedVal) { option.selected = true; }
      input.appendChild(option);
    });
  } else {
    // テキスト列は input に切り替える
    input = document.createElement('input');
    input.type = 'text';
    input.className = 'tbl-input';
    input.value = savedVal;
  }

  td.appendChild(input);
  input.focus();
  if (input.tagName === 'INPUT') { input.select(); }

  var committed = false;

  function confirmEdit() {
    if (committed) return;
    committed = true;
    row[col.key] = input.value; // データを更新する
    td.classList.remove('is-editing');
    renderCell(td, row, col);
  }

  function cancelEdit() {
    if (committed) return;
    committed = true;
    td.classList.remove('is-editing');
    renderCell(td, row, col); // 元の値で再描画する
  }

  input.addEventListener('keydown', function (e) {
    if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); confirmEdit(); }
    else if (e.key === 'Escape') { cancelEdit(); }
  });

  // フォーカスが外れたら確定する
  input.addEventListener('blur', confirmEdit);
}

function renderTable(data) {
  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');
    th.textContent = col.label;
    if (col.width) { th.style.width = col.width; }
    if (col.editable) {
      var hint = document.createElement('span');
      hint.className = 'tbl-edit-hint';
      hint.textContent = '✎';
      th.appendChild(hint);
    }
    theadRow.appendChild(th);
  });

  // データ行を生成する
  data.rows.forEach(function (row, rowIdx) {
    var tr = document.createElement('tr');
    data.columns.forEach(function (col) {
      var td = document.createElement('td');
      td.className = col.editable ? 'tbl-cell-editable' : 'tbl-cell-readonly';
      if (col.editable) {
        td.setAttribute('title', 'クリックして編集');
        td.setAttribute('data-row-idx', rowIdx);
        td.setAttribute('data-col-key', col.key);
      }
      renderCell(td, row, col);
      tr.appendChild(td);
    });
    tbody.appendChild(tr);
  });
}

// イベント委任: 1つのリスナーで全セルのクリックを処理する
document.getElementById('js-wrap').addEventListener('click', function (e) {
  var td = e.target.closest('td.tbl-cell-editable');
  if (!td || td.classList.contains('is-editing')) return;
  var rowIdx = parseInt(td.getAttribute('data-row-idx'), 10);
  var colKey = td.getAttribute('data-col-key');
  var col = null;
  for (var i = 0; i < tableData.columns.length; i++) {
    if (tableData.columns[i].key === colKey) { col = tableData.columns[i]; break; }
  }
  if (col) { makeEditable(td, tableData.rows[rowIdx], col); }
});
{
  "columns": [
    { "key": "id",         "label": "ID",   "width": "56px",  "editable": false },
    { "key": "name",       "label": "氏名", "width": "130px", "editable": true  },
    { "key": "department", "label": "部署", "width": "110px", "editable": true  },
    { "key": "role",       "label": "役職", "width": "150px", "editable": true  },
    { "key": "status",     "label": "状態", "width": "80px",  "editable": true, "type": "select" }
  ],
  "rows": [
    { "id": 1, "name": "田中 一郎", "department": "開発部",           "role": "リードエンジニア",       "status": "在籍" },
    { "id": 2, "name": "鈴木 花子", "department": "デザイン部",       "role": "UIデザイナー",           "status": "在籍" },
    { "id": 3, "name": "佐藤 次郎", "department": "開発部",           "role": "バックエンドエンジニア", "status": "在籍" },
    { "id": 4, "name": "山田 三枝", "department": "営業部",           "role": "セールスマネージャー",   "status": "在籍" },
    { "id": 5, "name": "伊藤 健太", "department": "開発部",           "role": "エンジニア",             "status": "休職" },
    { "id": 6, "name": "渡辺 美咲", "department": "マーケティング部", "role": "コンテンツディレクター", "status": "在籍" }
  ]
}

AI用プロンプト

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

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

💡 編集可能にする列・ドロップダウンにする列は要件に合わせて変更してください。保存APIへの送信処理も「確定時に fetch でPATCHリクエストを送る」と追記すれば対応できます。

# テーブル(インライン編集)作成依頼

## 概要
セルをクリックしてその場でテキスト編集できるインライン編集テーブルを実装してください。

## 要件
- 編集可能セルをクリックすると input タグに切り替わる
- Enter または Tab キーで入力を確定してテキスト表示に戻る
- Escape キーで編集をキャンセルして元の値に戻る
- セル外にフォーカスが移動したときも確定する
- 「状態」列はテキスト入力ではなく select(ドロップダウン)で選択する
- ID 列など編集不可の列はクリックしても入力モードにならない
- 編集中のセルは枠線で視覚的に強調する

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要

## 動作詳細
columns 配列に editable フラグと type('select' / 未指定)を持たせる。
イベント委任でテーブル全体のクリックを1つのリスナーで処理する。
makeEditable() 内で committed フラグを使い、confirm と cancel の二重実行を防ぐ。
行インデックスは rowIndex - 1(thead の1行を除く)で取得する。
動的データの DOM 挿入は textContent を使い innerHTML に変数を直接渡さない。

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