CRUD一覧画面(追加・編集・削除)
この画面パターンについて
一覧表示(Read)に追加・編集・削除を組み合わせた、業務アプリの核となるCRUD画面のパターンです。 追加と編集で1つのモーダルフォームを使い回し、削除確認 → 完了トーストまでの一連の流れを実装します。 データ配列を唯一の情報源として操作のたびに一覧を再描画する、単方向の状態管理が実装の核心です。
こんな場面で使えます
- 各種マスタの保守 — 商品・カテゴリなどを一覧から直接追加・修正する
- タグ・ラベル管理 — 小さなデータの追加と削除を繰り返す
- ユーザー管理 — 登録・情報修正・削除を1画面で完結する
この画面で使っているUIコンポーネント
| # | パーツ | この画面での役割 |
|---|---|---|
| 1 | テーブル一覧 | 全件の一覧+「全8件」の件数表示 |
| 2 | 新規追加ボタン | 空のモーダルフォームを開く |
| 3 | モーダルフォーム | 追加・編集で1つを共用 |
| 4 | インラインバリデーション | 必須・文字数をフィールド直下に表示 |
| 5 | 行操作ボタン | 行ごとの編集・削除の起点 |
| 6 | 削除確認ダイアログ | 対象名を明示して誤操作を防止 |
| 7 | トースト通知 | 追加・更新・削除の完了通知 |
| 8 | 空状態表示 | 全件削除時の案内+追加導線 |
実装のポイント・注意点
追加と編集でフォームを2つ作らず、editingId が null なら追加・IDが入っていれば編集と切り替えて、1つのモーダルを共用するのが実務の定石です。
データ操作は「配列を書き換えてから全再描画」に統一し、DOMの行だけを個別に消す近道をしないことで、件数表示や空状態との食い違いが起きなくなります。
新規IDの採番は length + 1 ではなく既存IDの最大値+1にします。途中の削除でIDが重複する事故を防ぐためです。
削除確認ダイアログには「『{名前}』を削除しますか」と対象名を必ず入れ、何を消そうとしているかをユーザーに見せます。
8個のUIコンポーネントをHTML・CSS・バニラJavaScriptのみで組み合わせており、 フレームワーク不要で画面ごとコピペして使えます。
動作サンプル
動作サンプルを別ウィンドウで確認 ↗
試してみる:
- 「+ 新規追加」から名前を空のまま保存して、バリデーションエラーを確認
- 「編集」を押すと同じモーダルがタイトル「編集」・値入りで開くことを確認
- 全行を削除して、空状態表示と「新規追加」導線を確認
そのほかの操作も自由に試してみてください。
サンプルソース
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>CRUD一覧画面 サンプル</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="crud-screen">
<!-- ===== 画面ヘッダー ===== -->
<div class="screen-header">
<h1 class="screen-title">アイテム管理</h1>
<button type="button" class="btn-primary" id="addBtn">+ 新規追加</button>
</div>
<p class="demo-note">このデモは保存されません。リロードすると初期状態に戻ります。</p>
<p class="result-count" id="resultCount">全8件</p>
<!-- ===== テーブル一覧 ===== -->
<div class="table-wrapper" id="tableWrapper">
<table class="data-table">
<thead>
<tr><th>ID</th><th>名前</th><th>カテゴリ</th><th>更新日</th><th>操作</th></tr>
</thead>
<tbody id="tableBody"><!-- JSで生成 --></tbody>
</table>
</div>
<!-- ===== 空状態(0件のとき表示) ===== -->
<div class="empty-state" id="emptyState" hidden>
<p class="empty-message">データがありません</p>
<button type="button" class="btn-primary" id="emptyAddBtn">+ 新規追加</button>
</div>
<!-- ===== 追加・編集モーダル(共用) ===== -->
<div class="modal-overlay" id="formOverlay" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="formTitle">
<button type="button" class="modal-close-btn" id="formCloseBtn" aria-label="閉じる">×</button>
<h2 class="modal-title" id="formTitle">新規追加</h2>
<div class="form-field">
<label for="nameInput">名前 <span class="required-mark">*</span></label>
<input type="text" id="nameInput">
<p class="field-error" id="nameError" hidden></p>
</div>
<div class="form-field">
<label for="categorySelect">カテゴリ <span class="required-mark">*</span></label>
<select id="categorySelect">
<option value="">選択してください</option>
<option value="タイプ1">タイプ1</option>
<option value="タイプ2">タイプ2</option>
<option value="タイプ3">タイプ3</option>
</select>
<p class="field-error" id="categoryError" hidden></p>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" id="formCancelBtn">キャンセル</button>
<button type="button" class="btn-primary" id="formSaveBtn">保存する</button>
</div>
</div>
</div>
<!-- ===== 削除確認ダイアログ ===== -->
<div class="modal-overlay" id="confirmOverlay" hidden>
<div class="modal modal-confirm" role="alertdialog" aria-modal="true" aria-labelledby="confirmMessage">
<p class="confirm-message" id="confirmMessage"></p>
<div class="modal-actions">
<button type="button" class="btn-secondary" id="confirmCancelBtn">キャンセル</button>
<button type="button" class="btn-danger" id="confirmOkBtn">削除する</button>
</div>
</div>
</div>
<!-- ===== トースト ===== -->
<div class="toast" id="toast" role="status" hidden></div>
</div>
<script src="./script.js"></script>
</body>
</html>
/* ===== CRUD一覧画面 — style.css ===== */
*, *::before, *::after { box-sizing: border-box; }
:root {
--color-primary: #2B7FE8;
--color-danger: #D64545;
--color-text: #1E293B;
--color-muted: #64748B;
--color-border: #D0D7E0;
--color-bg: #F4F6F9;
--color-card: #FFFFFF;
--color-th-bg: #F1F5F9;
}
body {
margin: 0;
font-family: "Hiragino Kaku Gothic ProN", "Hiragino Sans", Meiryo, sans-serif;
background: var(--color-bg);
color: var(--color-text);
}
/* ===== 画面レイアウト ===== */
.crud-screen {
max-width: 1000px;
margin: 0 auto;
padding: 24px 20px 48px;
}
.screen-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.screen-title {
font-size: 22px;
margin: 0;
}
.demo-note {
margin: 8px 0 0;
font-size: 12px;
color: var(--color-muted);
}
.result-count {
margin: 16px 0 8px;
font-size: 13px;
color: var(--color-muted);
}
/* ===== テーブル一覧 ===== */
/* スマホ幅では横スクロールで対応する */
.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; }
/* ===== 行操作ボタン(編集・削除) ===== */
.row-actions {
white-space: nowrap;
}
.row-actions button {
padding: 4px 12px;
font-size: 12px;
font-family: inherit;
background: var(--color-card);
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-row-edit {
color: var(--color-primary);
border: 1.5px solid var(--color-primary);
margin-right: 8px;
}
.btn-row-edit:hover {
background: var(--color-primary);
color: #fff;
}
/* 削除は赤系アウトライン(確認ダイアログ側だけ塗りにする) */
.btn-danger-outline {
color: var(--color-danger);
border: 1.5px solid var(--color-danger);
}
.btn-danger-outline:hover {
background: var(--color-danger);
color: #fff;
}
/* ===== 空状態(0件のとき表示) ===== */
.empty-state {
padding: 48px 20px;
text-align: center;
background: var(--color-card);
border: 1px dashed var(--color-border);
border-radius: 8px;
}
.empty-message {
margin: 0 0 16px;
font-size: 14px;
color: var(--color-muted);
}
/* ===== ボタン共通 ===== */
.btn-primary,
.btn-secondary,
.btn-danger {
padding: 10px 20px;
font-size: 14px;
font-family: inherit;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.btn-primary {
color: #fff;
background: var(--color-primary);
border: 1.5px solid var(--color-primary);
}
.btn-primary:hover { background: #1D6AD0; }
.btn-secondary {
color: var(--color-muted);
background: var(--color-card);
border: 1.5px solid var(--color-border);
}
.btn-secondary:hover {
background: var(--color-bg);
border-color: #9AA5B4;
}
/* 削除確認のOKボタンは赤の塗り(誤操作防止のためここだけ強調) */
.btn-danger {
color: #fff;
background: var(--color-danger);
border: 1.5px solid var(--color-danger);
}
.btn-danger:hover { background: #B93838; }
/* ===== モーダル(追加・編集/削除確認で共通) ===== */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 10;
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 {
position: relative;
width: calc(100% - 48px);
max-width: 420px;
padding: 24px 24px 20px;
background: var(--color-card);
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.modal-title {
margin: 0 0 18px;
font-size: 17px;
}
.modal-close-btn {
position: absolute;
top: 14px;
right: 14px;
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-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
.confirm-message {
margin: 0;
font-size: 15px;
line-height: 1.7;
}
/* ===== フォームフィールド ===== */
.form-field {
margin-bottom: 16px;
}
.form-field label {
display: block;
margin-bottom: 6px;
font-size: 14px;
font-weight: 700;
}
.required-mark { color: var(--color-danger); }
.form-field input[type="text"],
.form-field select {
width: 100%;
padding: 8px 10px;
font-size: 14px;
font-family: inherit;
color: var(--color-text);
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: 6px;
}
.form-field input:focus-visible,
.form-field select:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -1px;
}
/* バリデーションエラー時は入力枠を赤くする */
.form-field .is-invalid { border-color: var(--color-danger); }
.field-error {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-danger);
}
/* ===== トースト ===== */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 20;
padding: 12px 20px;
font-size: 14px;
color: #fff;
background: #1E293B;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
animation: toast-in 0.2s ease;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
/* hidden 属性を確実に効かせる(display 指定との競合対策) */
.crud-screen [hidden] { display: none !important; }
/* ===== レスポンシブ(768px以下) ===== */
@media (max-width: 768px) {
.screen-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
/* =====================================================
CRUD一覧画面のスクリプト
仕組み:データ配列 items を唯一の情報源にし、追加・更新・
削除はすべて「配列を書き換える → render() で全再描画」の
流れに統一する(件数表示・空状態との食い違いを防ぐ)。
追加と編集はモーダルフォームを1つ共用し、editingId が
null なら追加・IDが入っていれば編集として保存する。
行の生成は createElement + textContent(XSS対策)。
===================================================== */
// ===== 設定値 =====
var NAME_MAX_LENGTH = 50; // 名前の最大文字数
var TOAST_DURATION_MS = 3000; // トーストの表示時間
// ===== サンプルデータ(8件) =====
var items = [
{ id: 1, name: 'サンプルアイテムA', category: 'タイプ1', updatedAt: '2026-06-01' },
{ id: 2, name: 'サンプルアイテムB', category: 'タイプ2', updatedAt: '2026-05-28' },
{ id: 3, name: 'サンプルアイテムC', category: 'タイプ3', updatedAt: '2026-05-25' },
{ id: 4, name: 'サンプルアイテムD', category: 'タイプ1', updatedAt: '2026-05-20' },
{ id: 5, name: 'サンプルアイテムE', category: 'タイプ2', updatedAt: '2026-05-18' },
{ id: 6, name: 'サンプルアイテムF', category: 'タイプ1', updatedAt: '2026-05-12' },
{ id: 7, name: 'サンプルアイテムG', category: 'タイプ3', updatedAt: '2026-05-08' },
{ id: 8, name: 'サンプルアイテムH', category: 'タイプ2', updatedAt: '2026-05-02' }
];
// ===== DOM要素 =====
var addBtn = document.getElementById('addBtn');
var emptyAddBtn = document.getElementById('emptyAddBtn');
var resultCount = document.getElementById('resultCount');
var tableWrapper = document.getElementById('tableWrapper');
var tableBody = document.getElementById('tableBody');
var emptyState = document.getElementById('emptyState');
var formOverlay = document.getElementById('formOverlay');
var formTitle = document.getElementById('formTitle');
var formCloseBtn = document.getElementById('formCloseBtn');
var nameInput = document.getElementById('nameInput');
var nameError = document.getElementById('nameError');
var categorySelect = document.getElementById('categorySelect');
var categoryError = document.getElementById('categoryError');
var formCancelBtn = document.getElementById('formCancelBtn');
var formSaveBtn = document.getElementById('formSaveBtn');
var confirmOverlay = document.getElementById('confirmOverlay');
var confirmMessage = document.getElementById('confirmMessage');
var confirmCancelBtn = document.getElementById('confirmCancelBtn');
var confirmOkBtn = document.getElementById('confirmOkBtn');
var toastEl = document.getElementById('toast');
// 編集中のレコードID(null のときは新規追加モード)
var editingId = null;
// 削除確認中のレコードID
var deleteTargetId = null;
// ===== 描画 =====
// items の内容をそのまま画面に反映する(件数・空状態もここで切り替え)
function render() {
resultCount.textContent = '全' + items.length + '件';
var isEmpty = (items.length === 0);
tableWrapper.hidden = isEmpty;
resultCount.hidden = isEmpty;
emptyState.hidden = !isEmpty;
tableBody.textContent = '';
items.forEach(function (item) {
tableBody.appendChild(createRow(item));
});
}
// 1行分の tr を生成する
function createRow(item) {
var tr = document.createElement('tr');
tr.appendChild(createCell(item.id));
tr.appendChild(createCell(item.name));
tr.appendChild(createCell(item.category));
tr.appendChild(createCell(item.updatedAt));
var td = document.createElement('td');
td.className = 'row-actions';
td.appendChild(createActionButton('編集', 'btn-row-edit', function () { openForm(item.id); }));
td.appendChild(createActionButton('削除', 'btn-danger-outline', function () { openConfirm(item); }));
tr.appendChild(td);
return tr;
}
// テキストセルを生成する
function createCell(text) {
var td = document.createElement('td');
td.textContent = text;
return td;
}
// 行操作ボタン(編集・削除)を生成する
function createActionButton(label, className, onClick) {
var button = document.createElement('button');
button.type = 'button';
button.className = className;
button.textContent = label;
button.addEventListener('click', onClick);
return button;
}
// ===== 追加・編集モーダル(共用) =====
// 「+ 新規追加」 → 空のフォームを「新規追加」タイトルで開く
addBtn.addEventListener('click', function () { openForm(null); });
emptyAddBtn.addEventListener('click', function () { openForm(null); });
// id が null なら新規追加、IDが入っていれば該当レコードの編集として開く
function openForm(id) {
editingId = id;
if (editingId === null) {
formTitle.textContent = '新規追加';
nameInput.value = '';
categorySelect.value = '';
} else {
var item = findItem(editingId);
formTitle.textContent = '編集';
nameInput.value = item.name;
categorySelect.value = item.category;
}
clearFormErrors();
formOverlay.hidden = false;
document.body.style.overflow = 'hidden'; // 背景のスクロールを止める
nameInput.focus();
}
function closeForm() {
formOverlay.hidden = true;
document.body.style.overflow = '';
}
// 「保存する」 → バリデーション → editingId の有無で追加か更新かを切り替える
formSaveBtn.addEventListener('click', function () {
if (!validateForm()) { return; } // エラーあり:モーダルは開いたまま
if (editingId === null) {
// 新規追加:IDは既存IDの最大値+1で採番(length+1 だと削除後に重複する)
items.unshift({
id: nextId(),
name: nameInput.value.trim(),
category: categorySelect.value,
updatedAt: today()
});
showToast('追加しました');
} else {
var item = findItem(editingId);
item.name = nameInput.value.trim();
item.category = categorySelect.value;
item.updatedAt = today();
showToast('更新しました');
}
closeForm();
render();
});
formCancelBtn.addEventListener('click', closeForm);
formCloseBtn.addEventListener('click', closeForm);
// ===== バリデーション =====
// 名前(必須・50文字以内)とカテゴリ(必須)を検証し、結果を true/false で返す
function validateForm() {
clearFormErrors();
var valid = true;
if (nameInput.value.trim() === '') {
showFieldError(nameInput, nameError, '名前を入力してください');
valid = false;
} else if (nameInput.value.length > NAME_MAX_LENGTH) {
showFieldError(nameInput, nameError, NAME_MAX_LENGTH + '文字以内で入力してください');
valid = false;
}
if (categorySelect.value === '') {
showFieldError(categorySelect, categoryError, 'カテゴリを選択してください');
valid = false;
}
return valid;
}
// フィールド直下にエラーを表示し、入力枠を赤くする
function showFieldError(input, errorEl, message) {
input.classList.add('is-invalid');
errorEl.textContent = message;
errorEl.hidden = false;
}
// フォーム内のエラー表示をすべて消す
function clearFormErrors() {
nameInput.classList.remove('is-invalid');
categorySelect.classList.remove('is-invalid');
nameError.hidden = true;
categoryError.hidden = true;
}
// ===== 削除フロー =====
// 行の「削除」 → 対象名を明示した確認ダイアログを開く
function openConfirm(item) {
deleteTargetId = item.id;
confirmMessage.textContent = '『' + item.name + '』を削除しますか?この操作は取り消せません。';
confirmOverlay.hidden = false;
document.body.style.overflow = 'hidden';
confirmCancelBtn.focus(); // 誤操作防止のため初期フォーカスはキャンセル側
}
function closeConfirm() {
confirmOverlay.hidden = true;
document.body.style.overflow = '';
deleteTargetId = null;
}
// 「削除する」 → 配列から除去して再描画(0件になれば render() が空状態を出す)
confirmOkBtn.addEventListener('click', function () {
items = items.filter(function (item) { return item.id !== deleteTargetId; });
closeConfirm();
render();
showToast('削除しました');
});
confirmCancelBtn.addEventListener('click', closeConfirm);
// ===== モーダル共通の閉じる操作 =====
// オーバーレイ自体のクリックだけで閉じる(モーダル内クリックの貫通を防ぐ)
formOverlay.addEventListener('click', function (e) {
if (e.target === formOverlay) { closeForm(); }
});
confirmOverlay.addEventListener('click', function (e) {
if (e.target === confirmOverlay) { closeConfirm(); }
});
// ESCキー → 開いているモーダルを閉じる
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') { return; }
if (!formOverlay.hidden) { closeForm(); }
if (!confirmOverlay.hidden) { closeConfirm(); }
});
// ===== ヘルパー =====
// IDからレコードを探す
function findItem(id) {
for (var i = 0; i < items.length; i++) {
if (items[i].id === id) { return items[i]; }
}
return null;
}
// 新規IDを採番する(既存IDの最大値+1。配列が空でも 1 になる)
function nextId() {
return items.reduce(function (max, item) { return Math.max(max, item.id); }, 0) + 1;
}
// 当日の日付を YYYY-MM-DD 形式で返す
function today() {
var d = new Date();
var month = String(d.getMonth() + 1).padStart(2, '0');
var day = String(d.getDate()).padStart(2, '0');
return d.getFullYear() + '-' + month + '-' + day;
}
// ===== トースト =====
var toastTimerId = null;
// 3秒で自動的に消える。連続表示のときはタイマーを張り直す
function showToast(message) {
toastEl.textContent = message;
toastEl.hidden = false;
if (toastTimerId) { clearTimeout(toastTimerId); }
toastTimerId = setTimeout(function () {
toastEl.hidden = true;
toastTimerId = null;
}, TOAST_DURATION_MS);
}
// ===== 初期化 =====
render();
AI用プロンプト
以下のプロンプトをコピーしてAIに渡すと、同様の画面パターンを生成できます。
ChatGPTやClaudeにこのプロンプトを渡すと、同様の画面をゼロから生成・カスタマイズできます。列やフィールドの追加など、要件を追記して使うのがおすすめです。
※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。
💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。
# CRUD一覧画面 作成依頼
## 概要
テーブル一覧に対して追加・編集・削除ができるCRUD画面を作成してください。
追加と編集はモーダルフォームで行い、削除は確認ダイアログを挟みます。
## 要件
- テーブルは ID・名前・カテゴリ・更新日・操作(編集/削除ボタン)の5列、初期データ8件
- 「新規追加」ボタンで空のモーダルフォーム、行の「編集」でその行の値が入ったモーダルを開く
(モーダルは追加・編集で共用し、タイトルを「新規追加」「編集」に切り替える)
- フォームは 名前(必須・50文字以内)とカテゴリ(セレクト・必須)。
エラーはフィールド直下に赤字でインライン表示する
- 追加時のIDは既存IDの最大値+1で採番し、更新日は当日の日付を設定する
- 行の「削除」は「『(名前)』を削除しますか?この操作は取り消せません」の
確認ダイアログを表示し、OKで削除する
- 追加・更新・削除の完了時にそれぞれトースト通知(「追加しました」等)を3秒表示する
- 件数表示(「全8件」)を常時更新し、0件になったら空状態メッセージと
「新規追加」ボタンを表示する
- モーダルは ×・キャンセル・ESC・モーダル外クリックで閉じられる
## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- データはJavaScript内の配列で保持する(サーバー送信なし・リロードで初期状態に戻る)
- レスポンシブ対応:必要(テーブルは横スクロール)
## 動作詳細
- データ配列を唯一の情報源とし、すべての操作は「配列を書き換える → 一覧を再描画する」
の順で行う(DOMの行だけを個別に書き換えない)
- テーブル行・エラーメッセージの生成は createElement と textContent を使い、
innerHTML に変数を結合しない
## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。