テーブル(Table)— ドラッグ&ドロップ並べ替え
このコンポーネントについて
テーブルの行をドラッグ&ドロップで並べ替える実装例です。左端のドラッグハンドル(⠿)をつかんだときだけドラッグが始まる「ハンドル起点」方式のため、セル内のクリックやテキスト選択とドラッグが干渉しません。メニューの表示順・帳票の項目順・優先度リストなど、業務アプリで「行の並び順そのものがデータになる」画面を想定し、並べ替えるたびにNo.(順序)列が自動で振り直されます。「並び順を保存」ボタンでは、実務でサーバーへ送信する順序データ(メニューIDの配列)のイメージを表示します。外部ライブラリは使わず、HTML5標準のDrag & Drop APIとバニラJavaScriptだけで動きます。
- ハンドル起点のドラッグ — 行全体ではなく左端のハンドル(⠿)をつかんだときだけドラッグを開始する。ハンドルの
mousedownで対象行をdraggableにし、dragendで戻すことで、セルのクリックやテキスト選択と両立させる - ドロップ位置インジケーター — ドラッグ中、カーソルが対象行の上半分か下半分かを判定し、行の上端/下端に青いラインを表示してドロップ後の位置を予告する
- No.列の自動振り直し — 並べ替えるたびに先頭から1・2・3…とNo.列を振り直す。行の並び順が順序番号として即座に反映される
- 並び順の保存(サーバー送信イメージ) — 「並び順を保存」で現在の並び順をメニューIDの配列として表示する。実務でサーバーへPOSTする順序データの形を示す
- リセット — 初期の並び順(No.昇順)に戻し、保存結果パネルを閉じる
実装のポイント・注意点
行の並べ替えはdraggable属性とドラッグイベント(dragstart/dragover/drop/dragend)だけで実装できます。ポイントは3つです。1つ目はハンドル起点のドラッグ。<tr>全体をdraggableにするとセルのクリックや文字選択までドラッグ扱いになるため、ハンドルのmousedownで一時的にdraggable="true"にし、dragendで戻します。2つ目はdragoverで必ずe.preventDefault()を呼ぶこと。これを忘れるとdropが発火しません。3つ目はドロップ位置のライン表示。border-collapseのテーブルでは<tr>にborderが引けないため、box-shadow: insetで行の上端・下端に1本線を描きます。なお、No.列は並び順の見た目表現で、行の識別はdata-idで行います。保存時にサーバーへ送るのはIDの並び(配列)です。HTML5 Drag & Drop APIはスマホのタッチ操作では発火しない点にも注意してください(対策はページ下部のメモを参照)。
HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。
デモ
| No. | メニュー名 | 区分 |
|---|
保存しました
サーバーへ送信する並び順(イメージ):
サンプルソース
3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js
— 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。
<!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="dt11-wrap">
<table class="dt11-table" id="dt11-table">
<thead>
<tr>
<th class="dt11-th-handle" aria-label="並び替え"></th>
<th class="dt11-th-no">No.</th>
<th>メニュー名</th>
<th>区分</th>
</tr>
</thead>
<tbody id="dt11-tbody"><!-- 行は script.js が生成 --></tbody>
</table>
<div class="dt11-controls">
<button class="dt11-save-btn" id="dt11-save" type="button">並び順を保存</button>
<button class="dt11-reset-btn" id="dt11-reset" type="button">リセット</button>
</div>
<div class="dt11-result" id="dt11-result" hidden>
<p class="dt11-result-status">保存しました</p>
<p class="dt11-result-label">サーバーへ送信する並び順(イメージ):</p>
<pre class="dt11-result-code" id="dt11-result-code"></pre>
</div>
</div>
<script src="./script.js"></script>
</body>
</html>
:root {
--dt11-primary: #2B7FE8;
--dt11-border: #e2e8f0;
--dt11-drop-line: #2B7FE8;
}
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: sans-serif;
padding: 24px;
background: #f0f2f5;
color: #1a2332;
}
.dt11-wrap {
max-width: 640px;
margin: 0 auto;
background: #fff;
border-radius: 8px;
padding: 16px;
overflow-x: auto;
}
/* ===== テーブル本体 ===== */
.dt11-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.dt11-table th,
.dt11-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--dt11-border);
text-align: left;
}
.dt11-table thead th {
background: #f4f6f9;
color: #5a6a7a;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
}
.dt11-th-handle { width: 40px; }
.dt11-th-no { width: 56px; white-space: nowrap; }
/* ===== ドラッグハンドル ===== */
.dt11-handle-cell {
text-align: center;
padding: 0;
}
.dt11-handle {
display: inline-block;
color: #cbd5e1;
font-size: 18px;
line-height: 1;
padding: 10px 8px;
cursor: grab;
user-select: none; /* ドラッグ中に文字が選択されるのを防ぐ */
}
.dt11-handle:active { cursor: grabbing; }
/* No.列は等幅数字で桁を揃える */
.dt11-no {
color: #8a96a6;
font-variant-numeric: tabular-nums;
}
/* ドラッグ中の行 */
.dt11-table tr.is-dragging { opacity: 0.4; }
/* ドロップ位置インジケーター
border-collapse では tr に border が引けないため box-shadow で1辺だけ描く */
.dt11-table tbody tr.is-over-top td {
box-shadow: inset 0 2px 0 0 var(--dt11-drop-line);
}
.dt11-table tbody tr.is-over-bottom td {
box-shadow: inset 0 -2px 0 0 var(--dt11-drop-line);
}
/* ===== 操作ボタン ===== */
.dt11-controls {
margin-top: 12px;
display: flex;
gap: 8px;
}
.dt11-save-btn {
padding: 6px 16px;
font-size: 13px;
color: #fff;
background: var(--dt11-primary);
border: none;
border-radius: 6px;
cursor: pointer;
}
.dt11-save-btn:hover { background: #1e6fd9; }
.dt11-reset-btn {
padding: 6px 16px;
font-size: 13px;
color: #5a6a7a;
background: #fff;
border: 1.5px solid #d0d7e0;
border-radius: 6px;
cursor: pointer;
}
.dt11-reset-btn:hover { background: #f4f6f9; border-color: #9aa5b4; }
/* ===== 保存結果パネル ===== */
.dt11-result {
margin-top: 16px;
padding: 12px 14px;
background: #f4f8ff;
border: 1px solid #d6e4fb;
border-radius: 8px;
}
.dt11-result[hidden] { display: none; }
.dt11-result-status { margin: 0 0 6px; font-weight: 700; color: #1e6fd9; font-size: 13px; }
.dt11-result-label { margin: 0 0 4px; font-size: 12px; color: #5a6a7a; }
.dt11-result-code {
margin: 0;
font-size: 12px;
color: #333;
white-space: pre-wrap;
word-break: break-all;
font-family: monospace;
}
// メニュー表示順マスタ(並び順そのものがデータになる画面を想定)
var MENU_ITEMS = [
{ id: 'M01', name: 'ダッシュボード', category: '標準' },
{ id: 'M02', name: '受注管理', category: '業務' },
{ id: 'M03', name: '在庫管理', category: '業務' },
{ id: 'M04', name: '売上レポート', category: '分析' },
{ id: 'M05', name: '顧客マスタ', category: 'マスタ' },
{ id: 'M06', name: '商品マスタ', category: 'マスタ' },
{ id: 'M07', name: '権限設定', category: '設定' },
{ id: 'M08', name: '通知設定', category: '設定' }
];
var tbody = document.getElementById('dt11-tbody');
var dragRow = null; // 現在ドラッグ中の行
// データからテーブルの行を生成する(innerHTMLは使わず createElement で組み立てる)
function renderRows(list) {
tbody.textContent = '';
list.forEach(function (item) {
var tr = document.createElement('tr');
tr.setAttribute('data-id', item.id);
tr.setAttribute('draggable', 'false'); // 既定ではドラッグ不可
// ハンドル列:ここをつかんだときだけ行をドラッグ可能にする
var handleTd = document.createElement('td');
handleTd.className = 'dt11-handle-cell';
var handle = document.createElement('span');
handle.className = 'dt11-handle';
handle.setAttribute('aria-hidden', 'true');
handle.textContent = '⠿';
handle.addEventListener('mousedown', function () { tr.setAttribute('draggable', 'true'); });
handle.addEventListener('mouseup', function () { tr.setAttribute('draggable', 'false'); });
handleTd.appendChild(handle);
tr.appendChild(handleTd);
// No.列(renumber で振り直すので中身は後から入れる)
var noTd = document.createElement('td');
noTd.className = 'dt11-no';
tr.appendChild(noTd);
// メニュー名
var nameTd = document.createElement('td');
nameTd.textContent = item.name;
tr.appendChild(nameTd);
// 区分
var catTd = document.createElement('td');
catTd.textContent = item.category;
tr.appendChild(catTd);
tbody.appendChild(tr);
});
renumber();
}
// No.列を先頭から 1, 2, 3... に振り直す
function renumber() {
var rows = tbody.querySelectorAll('tr');
rows.forEach(function (row, i) {
var noCell = row.querySelector('.dt11-no');
if (noCell) { noCell.textContent = i + 1; }
});
}
// ドロップ位置インジケーターを全行から消す
function clearOverClasses() {
tbody.querySelectorAll('.is-over-top, .is-over-bottom').forEach(function (row) {
row.classList.remove('is-over-top', 'is-over-bottom');
});
}
// ドラッグ開始
tbody.addEventListener('dragstart', function (e) {
var tr = e.target.closest('tr');
if (!tr) { return; }
dragRow = tr;
tr.classList.add('is-dragging');
e.dataTransfer.effectAllowed = 'move';
});
// ドラッグ中:カーソルが対象行の上半分か下半分かでインジケーター位置を決める
tbody.addEventListener('dragover', function (e) {
e.preventDefault(); // これを呼ばないと drop が発火しない
var tr = e.target.closest('tr');
if (!tr || tr === dragRow) { return; }
clearOverClasses();
var rect = tr.getBoundingClientRect();
var isTop = e.clientY < rect.top + rect.height / 2;
tr.classList.add(isTop ? 'is-over-top' : 'is-over-bottom');
});
// 対象行から外れたらインジケーターを消す
tbody.addEventListener('dragleave', function (e) {
var tr = e.target.closest('tr');
if (tr) { tr.classList.remove('is-over-top', 'is-over-bottom'); }
});
// ドロップ:上半分なら対象行の前、下半分なら後ろへ移動
tbody.addEventListener('drop', function (e) {
e.preventDefault();
var tr = e.target.closest('tr');
if (!tr || tr === dragRow) { return; }
var rect = tr.getBoundingClientRect();
var isTop = e.clientY < rect.top + rect.height / 2;
if (isTop) {
tbody.insertBefore(dragRow, tr);
} else {
tbody.insertBefore(dragRow, tr.nextSibling);
}
clearOverClasses();
});
// ドラッグ終了:状態をリセットして No. を振り直す
tbody.addEventListener('dragend', function () {
if (dragRow) { dragRow.setAttribute('draggable', 'false'); }
tbody.querySelectorAll('tr').forEach(function (row) {
row.classList.remove('is-dragging', 'is-over-top', 'is-over-bottom');
});
dragRow = null;
renumber();
});
// 並び順を保存:現在の並び順を ID の配列として表示(サーバー送信のイメージ)
document.getElementById('dt11-save').addEventListener('click', function () {
var ids = [];
tbody.querySelectorAll('tr').forEach(function (row) {
ids.push(row.getAttribute('data-id'));
});
document.getElementById('dt11-result-code').textContent = JSON.stringify(ids);
document.getElementById('dt11-result').hidden = false;
});
// リセット:初期の並び順に戻す
function resetDemo() {
renderRows(MENU_ITEMS);
document.getElementById('dt11-result').hidden = true;
}
document.getElementById('dt11-reset').addEventListener('click', resetDemo);
// 初期描画
renderRows(MENU_ITEMS);
AI用プロンプト
以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。
ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。列の追加や題材の変更など、要件を追記して使うのがおすすめです。
※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。
💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。
# テーブル(Table)— ドラッグ&ドロップ並べ替え 作成依頼
## 概要
テーブルの行を、左端のドラッグハンドルをつかんでドラッグ&ドロップで並べ替えられるUIを実装してください。
並べ替えると順序(No.)列が自動で振り直され、「並び順を保存」でサーバー送信用の順序データ(IDの配列)を表示します。
外部ライブラリは使わず、HTML5標準のDrag & Drop APIとバニラJSで実装します。
## 要件
- テーブルの列は「ドラッグハンドル(⠿)/No./メニュー名/区分」の4列にする
- 行全体ではなく、左端のドラッグハンドルをつかんだときだけドラッグを開始する(ハンドル以外のセルからはドラッグしない)
- ドラッグ中はつかんでいる行を半透明(opacity: 0.4)にする
- ドラッグ中、カーソルが対象行の上半分か下半分かを判定し、対象行の上端または下端に青いラインインジケーターを表示する
- ドロップ後は行をその位置に移動し、No.列を先頭から1・2・3…に振り直す
- 「並び順を保存」ボタンで、現在の並び順をIDの配列(例: ["M03","M01",...])として画面に表示する(サーバー送信のイメージ。永続化は不要)
- 「リセット」ボタンで初期の並び順(No.昇順)に戻す
## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要(max-width: 640px程度で中央寄せ、スマホ幅では横スクロールで操作できること)
## データ管理
- メニューデータは JavaScript の定数 `MENU_ITEMS` の配列で管理する(id・メニュー名・区分を持つオブジェクトの配列)
- テーブルの行は `<tbody>` を空にしておき、JSの `renderRows(list)` 関数でDOM生成する。各 `<tr>` に `data-id` を設定する
## ハンドル起点ドラッグの実装仕様
- `<tr>` は既定で draggable にしない
- ドラッグハンドルの `mousedown` で親 `<tr>` の `draggable` を `true` にし、`dragend`(およびハンドルの `mouseup`)で `false` に戻す
## ドラッグ&ドロップの実装仕様
- `dragstart` でドラッグ中の行を変数に記録し `is-dragging` クラス(opacity: 0.4)を付与する
- `dragover` では必ず `e.preventDefault()` を呼ぶ(呼ばないと `drop` が発火しない)。対象行の `getBoundingClientRect()` と `e.clientY` から上半分/下半分を判定し、`is-over-top` / `is-over-bottom` クラスでインジケーターを表示する
- `drop` で、上半分なら `insertBefore(dragRow, targetRow)`、下半分なら `insertBefore(dragRow, targetRow.nextSibling)` により行を移動する
- `dragend` で `is-dragging` / `is-over-top` / `is-over-bottom` を全行から除去し、No.列を振り直す
## ドロップ位置インジケーターのCSS仕様
- `border-collapse: collapse` のテーブルでは `<tr>` のborderが引けないため、インジケーターは `box-shadow` で描く
- 上端: `tr.is-over-top td { box-shadow: inset 0 2px 0 0 #2B7FE8; }`
- 下端: `tr.is-over-bottom td { box-shadow: inset 0 -2px 0 0 #2B7FE8; }`
## XSSセキュリティ要件(重要)
- `innerHTML` に変数(メニューデータ)を結合しない
- 行・セルは `createElement()` + `appendChild()`、テキストは `textContent` で組み立てる
- 保存結果のID配列表示も `textContent` を使う
## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。