テーブル(Table)— 選択行の一括削除
このコンポーネントについて
テーブルで選択した複数行を、確認モーダル→即時削除→取り消し(Undo)トーストという一連のフローでまとめて削除する「一括削除(bulk delete)」の実装例です。選択・全選択チェックボックスの管理は data-table-6 の設計(選択状態を Set で保持し、ヘッダー状態を1つの関数に集約する)をそのまま引き継いでいます。削除確認は overlay-confirm-1 のダイアログパターンを、取り消しは overlay-toast-1 のトーストパターンをそれぞれ踏襲し、両事例と実装・見た目の語彙を揃えています。連続して一括削除を行った場合にUndoトーストが1件ずつ独立してスタックする挙動まで、実際に触って確認できます。
- 選択行のみ対象にした一括削除 — ツールバーの「削除」ボタンは1件以上選択時のみ有効になり、押すと選択中の行だけが削除対象になる
- 確認モーダル(overlay-confirm-1準拠) — 削除実行前に必ず確認ダイアログを挟む。件数による分岐はせず、1件でも複数件でも同じフローを通す
- 即時削除+Undoトースト — 確認後は行をすぐに画面・データから取り除き、「取り消せる」ことをUndoトースト(overlay-toast-1準拠)で示す
- Undoトーストのスタック表示 — 連続で一括削除すると、削除ごとに独立したUndoトーストが積み上がる。1つを「元に戻す」しても他のUndo枠・タイマーには影響しない
- 元の並び順への復元 — 「元に戻す」を押すと、削除した行を元にあった位置へ戻す(末尾追加ではない)
- 選択状態はデータ(Set)で保持 —
data-table-6と同じ設計を踏襲し、選択・ハイライト・ヘッダー状態・カウンターをすべてこの1つの真実から導出する - createElement による安全な行生成 — 行の描画は
createElement+textContentで行い、innerHTMLに値を結合しない(XSS対策)
実装のポイント・注意点
確認モーダルは選択件数で分岐させません。1件削除でも8件削除でも同じダイアログ・同じ文言テンプレートを通すことで、「一括」と「単発」で挙動が変わる複雑さを避けています。また文言も「この操作は取り消せません」とは書かず、「実行後3秒以内なら元に戻せます」と案内します。Undo手段がある操作にあえて強い警告文を出すと、ユーザーに誤った不安を与えてしまうためです。
Undoトーストはoverlay-toast-1が複数トーストの同時スタック表示にすでに対応しているため、その語彙をそのまま流用しています。1回の削除=1つのトースト=1つの退避データ+1つの独立したタイマーとして扱うことで、「前の削除のUndo枠が後の削除操作で消えてしまう」事故を避けられます。
削除した行を元の位置に戻す処理は、各タスクに削除後も変わらないorderIndex(元の並び順)を持たせ、復元時に現在のタスク配列とマージしてからorderIndexの昇順で並べ替え、テーブル全体を再描画する方式にしています。DOMを部分的に挿入・移動する実装は複数のUndoが絡むと状態管理が複雑になるため、8行規模のデモでは全再描画で十分と判断しました。
HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。
デモ
| ID | タスク名 | 担当者 | 期限 | 状態 |
|---|
サンプルソース
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="dt8-wrap">
<!-- ツールバー(選択件数 + 一括操作) -->
<div class="dt8-toolbar">
<span class="dt8-count" id="dt8-count" aria-live="polite">0件選択中</span>
<div class="dt8-toolbar-actions">
<button class="dt8-clear-btn" id="dt8-clear" disabled>選択を解除</button>
<button class="dt8-delete-btn" id="dt8-delete" disabled>削除</button>
</div>
</div>
<div class="dt8-table-scroll">
<table class="dt8-table">
<thead>
<tr>
<th class="dt8-check-col">
<input type="checkbox" id="dt8-select-all" aria-label="すべて選択">
</th>
<th class="dt8-id-col">ID</th>
<th>タスク名</th>
<th>担当者</th>
<th>期限</th>
<th>状態</th>
</tr>
</thead>
<tbody id="dt8-tbody"><!-- 行はJSで動的生成 --></tbody>
</table>
</div>
</div>
<!-- 削除確認モーダル -->
<div class="dt8-overlay" id="dt8-overlay" hidden>
<div class="dt8-dialog dt8-dialog--danger" role="alertdialog" aria-modal="true" aria-labelledby="dt8-dialog-title">
<h3 class="dt8-dialog-title" id="dt8-dialog-title">選択した行を削除しますか?</h3>
<p class="dt8-dialog-msg" id="dt8-dialog-msg"></p>
<div class="dt8-dialog-actions">
<button class="dt8-dialog-cancel" id="dt8-dialog-cancel">キャンセル</button>
<button class="dt8-dialog-confirm" id="dt8-dialog-confirm">削除する</button>
</div>
</div>
</div>
<!-- Undoトースト コンテナ -->
<div class="dt8-toast-container" id="dt8-toast-container" aria-live="polite"></div>
<script src="./script.js"></script>
</body>
</html>
:root {
--dt8-primary: #2B7FE8;
--dt8-border: #E2E8F0;
--dt8-text: #1A2332;
--dt8-text-sub: #5A6A7A;
--dt8-head-bg: #F4F7FF;
--dt8-selected-bg: #EBF2FF;
--dt8-red: #EF4444;
--dt8-red-dark: #DC2626;
--dt8-red-light: #FEE2E2;
}
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: sans-serif;
padding: 24px;
background: #F0F2F5;
color: var(--dt8-text);
}
.dt8-wrap {
width: 100%;
max-width: 720px;
margin: 0 auto;
}
/* ===== ツールバー ===== */
.dt8-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.dt8-count {
font-size: 13px;
font-weight: 600;
color: var(--dt8-text-sub);
}
.dt8-toolbar-actions { display: flex; gap: 8px; }
.dt8-clear-btn {
padding: 6px 14px;
font-size: 13px;
color: var(--dt8-primary);
background: #fff;
border: 1.5px solid #C7D9F5;
border-radius: 6px;
cursor: pointer;
font-family: sans-serif;
transition: background 0.15s, border-color 0.15s;
}
.dt8-clear-btn:hover:not(:disabled) { background: #F0F6FF; border-color: var(--dt8-primary); }
.dt8-clear-btn:disabled { color: #A9B4C2; border-color: #E2E8F0; background: #F7F9FC; cursor: not-allowed; }
/* 危険操作の強調色(赤系) */
.dt8-delete-btn {
padding: 6px 14px;
font-size: 13px;
font-weight: 600;
color: #fff;
background: var(--dt8-red);
border: none;
border-radius: 6px;
cursor: pointer;
font-family: sans-serif;
transition: background 0.15s;
}
.dt8-delete-btn:hover:not(:disabled) { background: var(--dt8-red-dark); }
.dt8-delete-btn:disabled { background: #E2E8F0; color: #A9B4C2; cursor: not-allowed; }
/* ===== テーブル ===== */
.dt8-table-scroll { overflow-x: auto; }
.dt8-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
color: var(--dt8-text);
background: #fff;
}
.dt8-table th,
.dt8-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--dt8-border);
white-space: nowrap;
}
.dt8-table thead th {
background: var(--dt8-head-bg);
font-weight: 600;
color: var(--dt8-text-sub);
}
.dt8-check-col { width: 44px; text-align: center; }
.dt8-id-col { width: 56px; }
.dt8-table input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--dt8-primary);
}
.dt8-table tbody tr:hover td {
background: #F4F7FF;
transition: background 0.1s;
}
.dt8-table tbody tr.is-selected td {
background: var(--dt8-selected-bg);
}
/* 削除アニメーション中の行 */
.dt8-table tbody tr.is-removing td {
opacity: 0;
transition: opacity 0.2s;
}
/* ===== 状態バッジ ===== */
.dt8-status {
display: inline-block;
padding: 2px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 600;
}
.dt8-status[data-status="未着手"] { background: #F3F4F6; color: #6B7280; }
.dt8-status[data-status="対応中"] { background: #DBEAFE; color: #1E40AF; }
.dt8-status[data-status="完了"] { background: #D1FAE5; color: #065F46; }
/* ===== 削除確認モーダル(overlay-confirm-1 を踏襲) ===== */
.dt8-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 16px;
}
.dt8-overlay[hidden] { display: none; }
.dt8-dialog {
background: #fff;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 400px;
padding: 20px;
animation: dt8-pop 0.15s ease-out;
}
@keyframes dt8-pop {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.dt8-dialog--danger { border-top: 4px solid var(--dt8-red); }
.dt8-dialog-title {
font-size: 16px;
font-weight: 700;
color: var(--dt8-red-dark);
margin: 0 0 10px;
}
.dt8-dialog-msg { font-size: 14px; color: #374151; line-height: 1.65; margin: 0 0 20px; }
.dt8-dialog-actions { display: flex; justify-content: flex-end; gap: 10px; }
.dt8-dialog-cancel {
padding: 8px 18px;
font-size: 14px;
font-weight: 500;
color: #374151;
background: #fff;
border: 1.5px solid var(--dt8-border);
border-radius: 6px;
cursor: pointer;
font-family: sans-serif;
transition: background 0.15s;
}
.dt8-dialog-cancel:hover { background: #F9FAFB; }
.dt8-dialog-confirm {
padding: 8px 18px;
font-size: 14px;
font-weight: 600;
color: #fff;
background: var(--dt8-red-dark);
border: none;
border-radius: 6px;
cursor: pointer;
font-family: sans-serif;
transition: background 0.15s;
}
.dt8-dialog-confirm:hover { background: #B91C1C; }
/* ===== Undoトースト(overlay-toast-1 を踏襲) ===== */
.dt8-toast-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column-reverse;
gap: 10px;
pointer-events: none;
max-width: 340px;
width: calc(100vw - 40px);
}
.dt8-toast {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 13px 14px;
background: #fff;
border-radius: 10px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12), 0 1px 4px rgba(0, 0, 0, 0.08);
border-left: 4px solid #22C55E;
pointer-events: auto;
position: relative;
overflow: hidden;
opacity: 0;
transform: translateX(20px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.dt8-toast--show { opacity: 1; transform: translateX(0); }
.dt8-toast--hide { opacity: 0; transform: translateX(20px); }
.dt8-icon {
width: 24px; height: 24px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 700; flex-shrink: 0;
background: #DCFCE7; color: #16A34A;
}
.dt8-body { flex: 1; min-width: 0; }
.dt8-title {
font-size: 13px; font-weight: 700; color: #1A2332;
margin: 0 0 4px; line-height: 1.4;
}
.dt8-undo-btn {
border: none;
background: none;
padding: 0;
font-size: 12px;
font-weight: 600;
color: var(--dt8-primary);
text-decoration: underline;
cursor: pointer;
font-family: sans-serif;
}
.dt8-undo-btn:hover { color: #1E63C4; }
.dt8-close {
width: 20px; height: 20px;
background: none; border: none;
font-size: 12px; color: #9AA5B4;
cursor: pointer; padding: 0;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; border-radius: 4px;
transition: color 0.15s, background 0.15s;
font-family: sans-serif;
}
.dt8-close:hover { color: #5A6A7A; background: #F1F5F9; }
.dt8-progress {
position: absolute;
bottom: 0; left: 0;
height: 3px;
border-radius: 0 0 0 10px;
background: #22C55E;
animation: dt8-countdown 3s linear forwards;
}
@keyframes dt8-countdown {
from { width: 100%; }
to { width: 0; }
}
/* ===== リセットボタン ===== */
.demo-controls { margin-top: 12px; }
.reset-btn {
padding: 6px 16px;
font-size: 13px;
color: #5A6A7A;
background: #fff;
border: 1.5px solid #D0D7E0;
border-radius: 6px;
cursor: pointer;
font-family: sans-serif;
transition: background 0.15s, border-color 0.15s;
}
.reset-btn:hover { background: #F4F6F9; border-color: #9AA5B4; }
@media (max-width: 600px) {
.dt8-table th,
.dt8-table td { padding: 8px 12px; }
.dt8-toast-container { right: 10px; bottom: 10px; }
}
// タスク一覧の初期データ(data-table-6 と同一データ)
const TASKS_INITIAL = [
{ id: 1, name: '見積書テンプレートの改訂', assignee: '田中 一郎', due: '2026-07-15', status: '対応中' },
{ id: 2, name: '請求データの月次集計', assignee: '鈴木 花子', due: '2026-07-10', status: '未着手' },
{ id: 3, name: '顧客マスタの重複チェック', assignee: '佐藤 次郎', due: '2026-07-08', status: '完了' },
{ id: 4, name: 'リリースノートの作成', assignee: '山田 三枝', due: '2026-07-20', status: '未着手' },
{ id: 5, name: '問い合わせの一次対応', assignee: '田中 一郎', due: '2026-07-09', status: '対応中' },
{ id: 6, name: 'サーバー証明書の更新', assignee: '高橋 健', due: '2026-07-12', status: '未着手' },
{ id: 7, name: '議事録の共有', assignee: '鈴木 花子', due: '2026-07-07', status: '完了' },
{ id: 8, name: '予算申請の承認依頼', assignee: '伊藤 美咲', due: '2026-07-18', status: '対応中' }
];
// 描画・操作対象の可変配列。各タスクに元の並び順 orderIndex を付与する(削除後も値は変えない)
let tasks = TASKS_INITIAL.map(function (t, i) {
return Object.assign({ orderIndex: i }, t);
});
// 選択中の行IDを Set で保持する(DOMのcheckedを状態の正としない)
const selected = new Set();
const tbody = document.getElementById('dt8-tbody');
const selectAll = document.getElementById('dt8-select-all');
const countEl = document.getElementById('dt8-count');
const clearBtn = document.getElementById('dt8-clear');
const deleteBtn = document.getElementById('dt8-delete');
const overlay = document.getElementById('dt8-overlay');
const dialogMsg = document.getElementById('dt8-dialog-msg');
const dialogCancel = document.getElementById('dt8-dialog-cancel');
const dialogConfirm = document.getElementById('dt8-dialog-confirm');
const toastContainer = document.getElementById('dt8-toast-container');
// 確認待ちの確定コールバック
let pendingConfirm = null;
// テキストセルを作る(textContent で安全に表示)
function makeCell(value, className) {
const td = document.createElement('td');
if (className) td.className = className;
td.textContent = value;
return td;
}
// 行を生成する(createElement で組み立て、innerHTML は使わない)
function renderRows() {
tbody.textContent = '';
tasks.forEach(function (task) {
const tr = document.createElement('tr');
tr.dataset.id = task.id;
const tdCheck = document.createElement('td');
tdCheck.className = 'dt8-check-col';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'dt8-row-check';
cb.dataset.id = task.id;
cb.setAttribute('aria-label', task.name + ' を選択');
cb.addEventListener('change', onRowChange);
tdCheck.appendChild(cb);
tr.appendChild(tdCheck);
tr.appendChild(makeCell(task.id, 'dt8-id-col'));
tr.appendChild(makeCell(task.name));
tr.appendChild(makeCell(task.assignee));
tr.appendChild(makeCell(task.due));
const tdStatus = document.createElement('td');
const badge = document.createElement('span');
badge.className = 'dt8-status';
badge.dataset.status = task.status;
badge.textContent = task.status;
tdStatus.appendChild(badge);
tr.appendChild(tdStatus);
tbody.appendChild(tr);
});
}
// 個別チェックの変更 → Set を更新して各表示を再導出
function onRowChange(e) {
const id = Number(e.target.dataset.id);
if (e.target.checked) {
selected.add(id);
} else {
selected.delete(id);
}
syncChecks();
syncHighlight();
updateSelectAllState();
}
// ヘッダーの全選択 → 全ID追加 or 全クリア
function onSelectAll() {
if (selectAll.checked) {
tasks.forEach(function (t) { selected.add(t.id); });
} else {
selected.clear();
}
syncChecks();
syncHighlight();
updateSelectAllState();
}
// Set をもとに各行の checked を再同期する
function syncChecks() {
tbody.querySelectorAll('.dt8-row-check').forEach(function (cb) {
cb.checked = selected.has(Number(cb.dataset.id));
});
}
// Set をもとに行ハイライトを再同期する
function syncHighlight() {
tbody.querySelectorAll('tr').forEach(function (tr) {
tr.classList.toggle('is-selected', selected.has(Number(tr.dataset.id)));
});
}
// ヘッダー状態・カウンター・ボタンを1関数で更新する(状態更新の集約)
function updateSelectAllState() {
const size = selected.size;
const total = tasks.length;
if (size === 0) {
selectAll.checked = false;
selectAll.indeterminate = false;
} else if (size === total) {
selectAll.checked = true;
selectAll.indeterminate = false;
} else {
selectAll.checked = false;
selectAll.indeterminate = true;
}
countEl.textContent = size + '件選択中';
clearBtn.disabled = (size === 0);
deleteBtn.disabled = (size === 0);
}
// 選択のみ解除する(データは変えない)
function clearSelection() {
selected.clear();
syncChecks();
syncHighlight();
updateSelectAllState();
}
// ===== 削除確認モーダル =====
function openConfirmDialog(count, onConfirm) {
dialogMsg.textContent = '選択した' + count + '件を削除します。実行後3秒以内なら元に戻せます。';
pendingConfirm = onConfirm;
overlay.hidden = false;
dialogCancel.focus();
}
function closeConfirmDialog() {
overlay.hidden = true;
pendingConfirm = null;
}
dialogCancel.addEventListener('click', closeConfirmDialog);
dialogConfirm.addEventListener('click', function () {
const callback = pendingConfirm;
closeConfirmDialog();
if (callback) callback();
});
// ESCキーでモーダルを閉じる
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !overlay.hidden) closeConfirmDialog();
});
// 削除ボタン → 対象タスクを確定して確認モーダルを開く
deleteBtn.addEventListener('click', function () {
const targets = tasks.filter(function (t) { return selected.has(t.id); });
if (targets.length === 0) return;
openConfirmDialog(targets.length, function () { executeDelete(targets); });
});
// ===== 削除の実行 =====
function executeDelete(targets) {
const targetIds = targets.map(function (t) { return t.id; });
tasks = tasks.filter(function (t) { return targetIds.indexOf(t.id) === -1; });
selected.clear();
renderRows();
updateSelectAllState();
showUndoToast(targets);
}
// ===== Undoトースト(スタック表示・個別タイマー管理) =====
function showUndoToast(removedTasks) {
const toast = document.createElement('div');
toast.className = 'dt8-toast';
const icon = document.createElement('span');
icon.className = 'dt8-icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = '✓';
const body = document.createElement('div');
body.className = 'dt8-body';
const title = document.createElement('p');
title.className = 'dt8-title';
title.textContent = removedTasks.length + '件を削除しました';
const undoBtn = document.createElement('button');
undoBtn.type = 'button';
undoBtn.className = 'dt8-undo-btn';
undoBtn.textContent = '元に戻す';
body.appendChild(title);
body.appendChild(undoBtn);
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'dt8-close';
closeBtn.setAttribute('aria-label', '閉じる');
closeBtn.textContent = '✕';
const progress = document.createElement('div');
progress.className = 'dt8-progress';
toast.appendChild(icon);
toast.appendChild(body);
toast.appendChild(closeBtn);
toast.appendChild(progress);
toastContainer.appendChild(toast);
requestAnimationFrame(function () {
requestAnimationFrame(function () {
toast.classList.add('dt8-toast--show');
});
});
// このトースト専用のタイマー(他のトーストには影響しない)
const timer = setTimeout(function () {
dismissToast(toast, false);
}, 3000);
toast.dataset.timer = String(timer);
undoBtn.addEventListener('click', function () {
clearTimeout(Number(toast.dataset.timer));
restoreTasks(removedTasks);
dismissToast(toast, true);
});
closeBtn.addEventListener('click', function () {
dismissToast(toast, false);
});
}
function dismissToast(toast, alreadyRestored) {
clearTimeout(Number(toast.dataset.timer));
toast.classList.remove('dt8-toast--show');
toast.classList.add('dt8-toast--hide');
toast.addEventListener('transitionend', function () {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, { once: true });
}
// 削除した行を元の並び順の位置に復元する
function restoreTasks(removedTasks) {
tasks = tasks.concat(removedTasks).sort(function (a, b) {
return a.orderIndex - b.orderIndex;
});
renderRows();
updateSelectAllState();
}
// ===== リセット =====
function resetDemo() {
// 表示中の全Undoトーストをタイマーごと即座に消去する
toastContainer.querySelectorAll('.dt8-toast').forEach(function (t) {
clearTimeout(Number(t.dataset.timer));
if (t.parentNode) t.parentNode.removeChild(t);
});
closeConfirmDialog();
tasks = TASKS_INITIAL.map(function (t, i) {
return Object.assign({ orderIndex: i }, t);
});
selected.clear();
renderRows();
updateSelectAllState();
}
selectAll.addEventListener('change', onSelectAll);
clearBtn.addEventListener('click', clearSelection);
renderRows();
updateSelectAllState();
AI用プロンプト
以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。
ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。ライブラリ指定や列数変更など、要件を追記して使うのがおすすめです。
※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。
💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。
# テーブルの選択行一括削除(確認モーダル+Undoトースト) 作成依頼
## 概要
一覧テーブルでチェックボックスにより選択した複数行を、確認モーダル→即時削除→取り消し(Undo)トーストという流れでまとめて削除できるようにしてください。題材はタスク一覧です。
## 要件
- 列は「選択(チェックボックス)/ID/タスク名/担当者/期限/状態」。状態は未着手・対応中・完了の色付きバッジで表示する
- 行データはJavaScriptの配列で持ち、行はDOMで動的生成する
- ヘッダーに全選択チェックボックスを置き、個別チェックの結果に応じて自動更新する(0件=未チェック、全件=チェック、一部=indeterminate)
- ツールバーに「N件選択中」のカウンターと「削除」ボタンを表示する。「削除」ボタンは1件以上選択時のみ有効にする
- 「削除」ボタンを押すと確認モーダルを表示し、選択件数を文言に含める(件数によってフローを分岐させない)
- 確認後は選択行を即座に削除し、選択状態を初期化したうえで「N件を削除しました」「元に戻す」ボタン付きのトーストを表示する
- トーストは3秒で自動的に消え、消えた後は元に戻せなくする。「元に戻す」を押した場合は、削除した行を元の並び順の位置に復元する
- 連続して削除操作を行った場合、Undoトーストは1件ずつ独立してスタック表示し、片方のUndo・タイムアウトが他方に影響しないようにする
- 確認モーダルはキャンセルボタンとESCキーで閉じられるようにする
- リセットボタンで、削除・Undo待ちの状態も含めてすべて初期状態(8件・未選択)に戻す
## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要
## 動作詳細
選択状態はDOMのcheckedではなく、選択中の行IDを保持するSetで管理してください。
各タスクには元の並び順を表す番号(orderIndex)を持たせ、削除後もこの番号は変えないでください。行を元に戻すときは、この番号順に並べ直してから再描画することで、元の位置に復元してください。
Undoトーストは複数同時に表示できるようにし、トーストごとに個別のタイマーと、削除した行のデータを保持してください。1つのトーストの「元に戻す」や自動消滅が、他のトーストに影響しないようにしてください。
削除・復元のたびにテーブル全体を再描画する実装で構いません(数十行規模のデモのため、部分的なDOM挿入は不要です)。
行・モーダル・トーストの描画は createElement と textContent で行い、innerHTML に値を結合しないでください。
## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。