Patterns Hard
CRUD Table
Full CRUD table pattern with create, inline edit, delete actions, sorting, pagination, and form validation.
Open in Lab
MCP
vanilla-js css react vue svelte
Targets: TS JS HTML React Vue Svelte
Code
* {
box-sizing: border-box;
}
:root {
--bg: #080b11;
--panel: #101622;
--panel-soft: #171f2f;
--border: rgba(255, 255, 255, 0.12);
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #38bdf8;
--danger: #f87171;
--ok: #22c55e;
}
body {
margin: 0;
font-family: "Sora", system-ui, sans-serif;
background: radial-gradient(circle at top, #131d31, var(--bg));
color: var(--text);
min-height: 100vh;
}
.shell {
width: min(1100px, calc(100% - 2rem));
margin: 2rem auto;
display: grid;
gap: 1rem;
}
.header h1 {
margin: 0;
}
.header p {
margin: 0.35rem 0 0;
color: var(--muted);
}
.panel {
background: linear-gradient(160deg, rgba(255, 255, 255, 0.02), transparent), var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem;
}
.form-panel h2 {
margin-top: 0;
}
.editor {
display: grid;
grid-template-columns: repeat(2, minmax(140px, 1fr));
gap: 0.8rem;
}
.editor label {
font-size: 0.85rem;
color: var(--muted);
display: grid;
gap: 0.3rem;
}
.editor input,
.editor select {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel-soft);
color: var(--text);
padding: 0.6rem;
}
.actions {
grid-column: 1 / -1;
display: flex;
gap: 0.6rem;
}
button {
background: var(--accent);
border: 0;
border-radius: 8px;
color: #031019;
font-weight: 700;
padding: 0.55rem 0.9rem;
cursor: pointer;
}
button.ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
}
.error {
grid-column: 1 / -1;
min-height: 1.2rem;
margin: 0;
color: var(--danger);
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border-bottom: 1px solid var(--border);
padding: 0.65rem;
font-size: 0.9rem;
text-align: left;
}
th button {
all: unset;
cursor: pointer;
color: var(--muted);
font-size: 0.75rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.status {
display: inline-block;
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.75rem;
text-transform: capitalize;
border: 1px solid currentColor;
}
.status.active {
color: var(--ok);
}
.status.pending {
color: #fbbf24;
}
.status.inactive {
color: #94a3b8;
}
.row-actions {
display: flex;
gap: 0.35rem;
}
.row-actions button {
padding: 0.35rem 0.55rem;
font-size: 0.75rem;
}
.row-actions .danger {
background: rgba(248, 113, 113, 0.12);
color: #fecaca;
border: 1px solid rgba(248, 113, 113, 0.4);
}
.table-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
margin-top: 0.8rem;
}
#summary {
margin: 0;
color: var(--muted);
font-size: 0.85rem;
}
.pager {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pager button {
padding: 0.35rem 0.6rem;
font-size: 0.8rem;
}
@media (max-width: 760px) {
.editor {
grid-template-columns: 1fr;
}
.table-panel {
overflow: auto;
}
table {
min-width: 720px;
}
}(() => {
const seedRows = [
{ id: 1, name: "Lia Stone", role: "Engineer", status: "active", email: "lia@example.com" },
{ id: 2, name: "Milo Park", role: "Designer", status: "pending", email: "milo@example.com" },
{ id: 3, name: "Aya Reed", role: "Product", status: "active", email: "aya@example.com" },
{ id: 4, name: "Noah Cruz", role: "Engineer", status: "inactive", email: "noah@example.com" },
{ id: 5, name: "Zoe Hart", role: "Designer", status: "active", email: "zoe@example.com" },
{ id: 6, name: "Eli Frost", role: "Product", status: "pending", email: "eli@example.com" },
{ id: 7, name: "Ira Blake", role: "Engineer", status: "active", email: "ira@example.com" },
];
let rows = seedRows.slice();
let sortKey = "id";
let sortDir = "asc";
let page = 1;
const pageSize = 5;
let editingId = null;
const body = document.getElementById("table-body");
const summary = document.getElementById("summary");
const pageIndicator = document.getElementById("page-indicator");
const prevBtn = document.getElementById("prev-page");
const nextBtn = document.getElementById("next-page");
const form = document.getElementById("record-form");
const title = document.getElementById("form-title");
const nameInput = document.getElementById("name");
const roleInput = document.getElementById("role");
const statusInput = document.getElementById("status");
const emailInput = document.getElementById("email");
const saveBtn = document.getElementById("save-btn");
const cancelBtn = document.getElementById("cancel-btn");
const error = document.getElementById("form-error");
const getSortedRows = () => {
const next = rows.slice();
next.sort((a, b) => {
const left = a[sortKey];
const right = b[sortKey];
if (left === right) return 0;
if (typeof left === "number" && typeof right === "number") {
return sortDir === "asc" ? left - right : right - left;
}
return sortDir === "asc"
? String(left).localeCompare(String(right))
: String(right).localeCompare(String(left));
});
return next;
};
const getPageRows = () => {
const sorted = getSortedRows();
const start = (page - 1) * pageSize;
return sorted.slice(start, start + pageSize);
};
const render = () => {
const sorted = getSortedRows();
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
if (page > totalPages) page = totalPages;
body.innerHTML = "";
const pageRows = getPageRows();
for (const row of pageRows) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${row.id}</td>
<td>${row.name}</td>
<td>${row.role}</td>
<td><span class="status ${row.status}">${row.status}</span></td>
<td>${row.email}</td>
<td>
<div class="row-actions">
<button type="button" data-edit="${row.id}">Edit</button>
<button type="button" class="danger" data-delete="${row.id}">Delete</button>
</div>
</td>
`;
body.appendChild(tr);
}
summary.textContent = `Showing ${pageRows.length} of ${rows.length} rows`;
pageIndicator.textContent = `Page ${page} / ${totalPages}`;
prevBtn.disabled = page <= 1;
nextBtn.disabled = page >= totalPages;
};
const resetForm = () => {
form.reset();
editingId = null;
title.textContent = "Add user";
saveBtn.textContent = "Add record";
cancelBtn.hidden = true;
error.textContent = "";
};
const fillForm = (row) => {
editingId = row.id;
title.textContent = `Edit user #${row.id}`;
saveBtn.textContent = "Save changes";
cancelBtn.hidden = false;
nameInput.value = row.name;
roleInput.value = row.role;
statusInput.value = row.status;
emailInput.value = row.email;
error.textContent = "";
};
const sortButtons = document.querySelectorAll("th button[data-sort]");
for (const btn of sortButtons) {
btn.addEventListener("click", () => {
const key = btn.getAttribute("data-sort");
if (!key) return;
if (sortKey === key) {
sortDir = sortDir === "asc" ? "desc" : "asc";
} else {
sortKey = key;
sortDir = "asc";
}
page = 1;
render();
});
}
body.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const editId = target.getAttribute("data-edit");
if (editId) {
const row = rows.find((item) => item.id === Number(editId));
if (row) fillForm(row);
return;
}
const deleteId = target.getAttribute("data-delete");
if (deleteId) {
rows = rows.filter((item) => item.id !== Number(deleteId));
if (editingId === Number(deleteId)) resetForm();
render();
}
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const name = nameInput.value.trim();
const email = emailInput.value.trim();
const role = roleInput.value;
const status = statusInput.value;
if (name.length < 2) {
error.textContent = "Name must contain at least 2 characters.";
return;
}
if (!email.includes("@") || !email.includes(".")) {
error.textContent = "Please provide a valid email address.";
return;
}
if (editingId === null) {
const nextId = rows.reduce((max, row) => Math.max(max, row.id), 0) + 1;
rows.unshift({ id: nextId, name, role, status, email });
} else {
rows = rows.map((row) => {
if (row.id !== editingId) return row;
return { ...row, name, role, status, email };
});
}
page = 1;
resetForm();
render();
});
cancelBtn.addEventListener("click", resetForm);
prevBtn.addEventListener("click", () => {
page -= 1;
render();
});
nextBtn.addEventListener("click", () => {
page += 1;
render();
});
render();
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CRUD Table</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="shell">
<header class="header">
<h1>CRUD Table</h1>
<p>Create, edit, sort, paginate, and delete records in one pattern.</p>
</header>
<section class="panel form-panel" aria-label="Editor">
<h2 id="form-title">Add user</h2>
<form id="record-form" class="editor">
<label>
Name
<input id="name" name="name" type="text" required />
</label>
<label>
Role
<select id="role" name="role">
<option value="Engineer">Engineer</option>
<option value="Designer">Designer</option>
<option value="Product">Product</option>
</select>
</label>
<label>
Status
<select id="status" name="status">
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="inactive">Inactive</option>
</select>
</label>
<label>
Email
<input id="email" name="email" type="email" required />
</label>
<div class="actions">
<button type="submit" id="save-btn">Add record</button>
<button type="button" id="cancel-btn" class="ghost" hidden>Cancel edit</button>
</div>
<p id="form-error" class="error" role="alert"></p>
</form>
</section>
<section class="panel table-panel" aria-label="Users table">
<table>
<thead>
<tr>
<th><button data-sort="id" type="button">ID</button></th>
<th><button data-sort="name" type="button">Name</button></th>
<th><button data-sort="role" type="button">Role</button></th>
<th><button data-sort="status" type="button">Status</button></th>
<th><button data-sort="email" type="button">Email</button></th>
<th>Actions</th>
</tr>
</thead>
<tbody id="table-body"></tbody>
</table>
<div class="table-footer">
<p id="summary"></p>
<div class="pager">
<button id="prev-page" type="button">Prev</button>
<span id="page-indicator"></span>
<button id="next-page" type="button">Next</button>
</div>
</div>
</section>
</main>
<script src="script.js"></script>
</body>
</html>import { type FormEvent, useEffect, useMemo, useState } from "react";
type Role = "Engineer" | "Designer" | "Product";
type Status = "active" | "pending" | "inactive";
type Row = {
id: number;
name: string;
role: Role;
status: Status;
email: string;
};
const seedRows: Row[] = [
{ id: 1, name: "Lia Stone", role: "Engineer", status: "active", email: "lia@example.com" },
{ id: 2, name: "Milo Park", role: "Designer", status: "pending", email: "milo@example.com" },
{ id: 3, name: "Aya Reed", role: "Product", status: "active", email: "aya@example.com" },
{ id: 4, name: "Noah Cruz", role: "Engineer", status: "inactive", email: "noah@example.com" },
{ id: 5, name: "Zoe Hart", role: "Designer", status: "active", email: "zoe@example.com" },
{ id: 6, name: "Eli Frost", role: "Product", status: "pending", email: "eli@example.com" },
{ id: 7, name: "Ira Blake", role: "Engineer", status: "active", email: "ira@example.com" },
];
const PAGE_SIZE = 5;
const ROLE_OPTIONS: Role[] = ["Engineer", "Designer", "Product"];
const STATUS_OPTIONS: Status[] = ["active", "pending", "inactive"];
export default function CrudTablePattern() {
const [rows, setRows] = useState<Row[]>(seedRows);
const [sortKey, setSortKey] = useState<keyof Row>("id");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [page, setPage] = useState(1);
const [editingId, setEditingId] = useState<number | null>(null);
const [error, setError] = useState("");
const [form, setForm] = useState({
name: "",
role: "Engineer" as Role,
status: "active" as Status,
email: "",
});
const sorted = useMemo(() => {
const next = rows.slice();
next.sort((a, b) => {
const left = a[sortKey];
const right = b[sortKey];
if (left === right) return 0;
if (typeof left === "number" && typeof right === "number") {
return sortDir === "asc" ? left - right : right - left;
}
return sortDir === "asc"
? String(left).localeCompare(String(right))
: String(right).localeCompare(String(left));
});
return next;
}, [rows, sortKey, sortDir]);
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const visible = sorted.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const onSort = (key: keyof Row) => {
if (sortKey === key) {
setSortDir((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
setPage(1);
};
const resetForm = () => {
setEditingId(null);
setForm({ name: "", role: "Engineer", status: "active", email: "" });
setError("");
};
const sortIndicator = (key: keyof Row) => {
if (sortKey !== key) return "↕";
return sortDir === "asc" ? "↑" : "↓";
};
const statusBadgeClass = (status: Status) => {
if (status === "active") return "bg-emerald-500/15 text-emerald-300 border-emerald-400/25";
if (status === "pending") return "bg-amber-500/15 text-amber-300 border-amber-400/25";
return "bg-slate-500/20 text-slate-300 border-slate-400/25";
};
const submit = (event: FormEvent) => {
event.preventDefault();
if (form.name.trim().length < 2) {
setError("Name must contain at least 2 characters.");
return;
}
if (!form.email.includes("@") || !form.email.includes(".")) {
setError("Please provide a valid email address.");
return;
}
if (editingId === null) {
const nextId = rows.reduce((max, row) => Math.max(max, row.id), 0) + 1;
setRows((prev) => [{ ...form, id: nextId }, ...prev]);
} else {
setRows((prev) => prev.map((row) => (row.id === editingId ? { ...row, ...form } : row)));
}
setPage(1);
resetForm();
};
return (
<section className="min-h-screen bg-[#0d1117] px-4 py-6 text-slate-100">
<div className="mx-auto max-w-6xl space-y-4">
<header className="rounded-2xl border border-[#30363d] bg-[#161b22] p-4">
<p className="text-xs font-bold uppercase tracking-wider text-[#8b949e]">Pattern</p>
<h1 className="mt-1 text-lg font-bold text-[#e6edf3]">CRUD Table</h1>
<p className="mt-1 text-sm text-[#8b949e]">
Create, edit, delete, sort, and paginate rows in a single table flow.
</p>
</header>
<form onSubmit={submit} className="rounded-2xl border border-[#30363d] bg-[#161b22] p-4">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-[1.3fr_1.3fr_1fr_1fr_auto_auto]">
<input
value={form.name}
placeholder="Full name"
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
className="rounded-lg border border-[#30363d] bg-[#0d1117] px-3 py-2 text-sm text-[#e6edf3] placeholder-[#6b7280] outline-none transition-colors focus:border-[#58a6ff]"
/>
<input
value={form.email}
placeholder="work@email.com"
onChange={(event) => setForm((prev) => ({ ...prev, email: event.target.value }))}
className="rounded-lg border border-[#30363d] bg-[#0d1117] px-3 py-2 text-sm text-[#e6edf3] placeholder-[#6b7280] outline-none transition-colors focus:border-[#58a6ff]"
/>
<select
value={form.role}
onChange={(event) =>
setForm((prev) => ({ ...prev, role: event.target.value as Role }))
}
className="rounded-lg border border-[#30363d] bg-[#0d1117] px-3 py-2 text-sm text-[#e6edf3] outline-none transition-colors focus:border-[#58a6ff]"
>
{ROLE_OPTIONS.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
<select
value={form.status}
onChange={(event) =>
setForm((prev) => ({ ...prev, status: event.target.value as Status }))
}
className="rounded-lg border border-[#30363d] bg-[#0d1117] px-3 py-2 text-sm text-[#e6edf3] outline-none transition-colors focus:border-[#58a6ff]"
>
{STATUS_OPTIONS.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
<button
type="submit"
className="rounded-lg border border-[#58a6ff]/40 bg-[#58a6ff]/15 px-4 py-2 text-sm font-semibold text-[#c9e6ff] transition-colors hover:bg-[#58a6ff]/25"
>
{editingId === null ? "Add row" : "Save"}
</button>
{editingId !== null && (
<button
type="button"
onClick={resetForm}
className="rounded-lg border border-[#30363d] px-4 py-2 text-sm font-semibold text-[#9ca3af] transition-colors hover:bg-[#0d1117] hover:text-[#e6edf3]"
>
Cancel
</button>
)}
</div>
<p className="mt-2 min-h-5 text-xs text-rose-400">{error}</p>
</form>
<div className="overflow-hidden rounded-2xl border border-[#30363d] bg-[#161b22]">
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="bg-[#21262d] text-[#c9d1d9]">
<tr>
{(
[
["id", "ID"],
["name", "Name"],
["role", "Role"],
["status", "Status"],
["email", "Email"],
] as const
).map(([key, label]) => (
<th key={key} className="px-4 py-3 font-semibold">
<button
type="button"
onClick={() => onSort(key)}
className="inline-flex items-center gap-1 text-left transition-colors hover:text-white"
>
{label}
<span className="text-xs text-[#8b949e]">{sortIndicator(key)}</span>
</button>
</th>
))}
<th className="px-4 py-3 font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{visible.map((row) => (
<tr
key={row.id}
className="border-t border-[#21262d] text-[#e6edf3] transition-colors hover:bg-white/[0.02]"
>
<td className="px-4 py-3 font-mono text-xs text-[#9ca3af]">{row.id}</td>
<td className="px-4 py-3 font-medium">{row.name}</td>
<td className="px-4 py-3 text-[#c9d1d9]">{row.role}</td>
<td className="px-4 py-3">
<span
className={`inline-flex rounded-md border px-2 py-1 text-xs font-semibold ${statusBadgeClass(row.status)}`}
>
{row.status}
</span>
</td>
<td className="px-4 py-3 text-[#9fb3c8]">{row.email}</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setEditingId(row.id);
setForm({
name: row.name,
role: row.role,
status: row.status,
email: row.email,
});
setError("");
}}
className="rounded-md border border-[#3d5d8a]/40 bg-[#58a6ff]/10 px-2.5 py-1 text-xs font-semibold text-[#9ed0ff] transition-colors hover:bg-[#58a6ff]/20"
>
Edit
</button>
<button
type="button"
onClick={() =>
setRows((prev) => prev.filter((item) => item.id !== row.id))
}
className="rounded-md border border-rose-500/35 bg-rose-500/10 px-2.5 py-1 text-xs font-semibold text-rose-300 transition-colors hover:bg-rose-500/20"
>
Delete
</button>
</div>
</td>
</tr>
))}
{visible.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-[#8b949e]">
No rows available.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between border-t border-[#21262d] px-4 py-3 text-sm">
<p className="text-[#8b949e]">
Page <span className="text-[#e6edf3]">{safePage}</span> of{" "}
<span className="text-[#e6edf3]">{totalPages}</span>
</p>
<div className="flex gap-2">
<button
type="button"
disabled={safePage <= 1}
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
className="rounded-md border border-[#30363d] px-3 py-1.5 text-xs font-semibold text-[#c9d1d9] transition-colors enabled:hover:bg-[#0d1117] disabled:cursor-not-allowed disabled:opacity-40"
>
Prev
</button>
<button
type="button"
disabled={safePage >= totalPages}
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
className="rounded-md border border-[#30363d] px-3 py-1.5 text-xs font-semibold text-[#c9d1d9] transition-colors enabled:hover:bg-[#0d1117] disabled:cursor-not-allowed disabled:opacity-40"
>
Next
</button>
</div>
</div>
</div>
</div>
</section>
);
}<script setup>
import { ref, computed, watch } from "vue";
const seedRows = [
{ id: 1, name: "Lia Stone", role: "Engineer", status: "active", email: "lia@example.com" },
{ id: 2, name: "Milo Park", role: "Designer", status: "pending", email: "milo@example.com" },
{ id: 3, name: "Aya Reed", role: "Product", status: "active", email: "aya@example.com" },
{ id: 4, name: "Noah Cruz", role: "Engineer", status: "inactive", email: "noah@example.com" },
{ id: 5, name: "Zoe Hart", role: "Designer", status: "active", email: "zoe@example.com" },
{ id: 6, name: "Eli Frost", role: "Product", status: "pending", email: "eli@example.com" },
{ id: 7, name: "Ira Blake", role: "Engineer", status: "active", email: "ira@example.com" },
];
const PAGE_SIZE = 5;
const ROLE_OPTIONS = ["Engineer", "Designer", "Product"];
const STATUS_OPTIONS = ["active", "pending", "inactive"];
const rows = ref([...seedRows]);
const sortKey = ref("id");
const sortDir = ref("asc");
const page = ref(1);
const editingId = ref(null);
const error = ref("");
const formName = ref("");
const formEmail = ref("");
const formRole = ref("Engineer");
const formStatus = ref("active");
const columns = [
{ key: "id", label: "ID" },
{ key: "name", label: "Name" },
{ key: "role", label: "Role" },
{ key: "status", label: "Status" },
{ key: "email", label: "Email" },
];
const sorted = computed(() => {
const arr = rows.value.slice();
arr.sort((a, b) => {
const left = a[sortKey.value];
const right = b[sortKey.value];
if (left === right) return 0;
if (typeof left === "number" && typeof right === "number") {
return sortDir.value === "asc" ? left - right : right - left;
}
return sortDir.value === "asc"
? String(left).localeCompare(String(right))
: String(right).localeCompare(String(left));
});
return arr;
});
const totalPages = computed(() => Math.max(1, Math.ceil(sorted.value.length / PAGE_SIZE)));
const safePage = computed(() => Math.min(page.value, totalPages.value));
const visible = computed(() =>
sorted.value.slice((safePage.value - 1) * PAGE_SIZE, safePage.value * PAGE_SIZE)
);
watch(totalPages, (tp) => {
if (page.value > tp) page.value = tp;
});
function onSort(key) {
if (sortKey.value === key) {
sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
} else {
sortKey.value = key;
sortDir.value = "asc";
}
page.value = 1;
}
function sortIndicator(key) {
if (sortKey.value !== key) return "\u21D5";
return sortDir.value === "asc" ? "\u2191" : "\u2193";
}
function resetForm() {
editingId.value = null;
formName.value = "";
formEmail.value = "";
formRole.value = "Engineer";
formStatus.value = "active";
error.value = "";
}
function submit() {
if (formName.value.trim().length < 2) {
error.value = "Name must contain at least 2 characters.";
return;
}
if (!formEmail.value.includes("@") || !formEmail.value.includes(".")) {
error.value = "Please provide a valid email address.";
return;
}
if (editingId.value === null) {
const nextId = rows.value.reduce((max, r) => Math.max(max, r.id), 0) + 1;
rows.value = [
{
id: nextId,
name: formName.value,
role: formRole.value,
status: formStatus.value,
email: formEmail.value,
},
...rows.value,
];
} else {
rows.value = rows.value.map((r) =>
r.id === editingId.value
? {
...r,
name: formName.value,
role: formRole.value,
status: formStatus.value,
email: formEmail.value,
}
: r
);
}
page.value = 1;
resetForm();
}
function editRow(row) {
editingId.value = row.id;
formName.value = row.name;
formRole.value = row.role;
formStatus.value = row.status;
formEmail.value = row.email;
error.value = "";
}
function deleteRow(id) {
rows.value = rows.value.filter((r) => r.id !== id);
}
function statusStyle(status) {
if (status === "active")
return "background:rgba(16,185,129,0.15);color:#6ee7b7;border:1px solid rgba(52,211,153,0.25)";
if (status === "pending")
return "background:rgba(245,158,11,0.15);color:#fcd34d;border:1px solid rgba(251,191,36,0.25)";
return "background:rgba(100,116,139,0.2);color:#cbd5e1;border:1px solid rgba(148,163,184,0.25)";
}
</script>
<template>
<section style="min-height:100vh;background:#0d1117;padding:1.5rem 1rem;font-family:system-ui,-apple-system,sans-serif;color:#e6edf3">
<div style="max-width:960px;margin:0 auto;display:flex;flex-direction:column;gap:1rem">
<!-- Header -->
<header style="border-radius:1rem;border:1px solid #30363d;background:#161b22;padding:1rem">
<p style="font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:#8b949e;margin:0">Pattern</p>
<h1 style="margin:0.25rem 0 0;font-size:1.125rem;font-weight:700;color:#e6edf3">CRUD Table</h1>
<p style="margin:0.25rem 0 0;font-size:0.875rem;color:#8b949e">Create, edit, delete, sort, and paginate rows in a single table flow.</p>
</header>
<!-- Form -->
<form @submit.prevent="submit" style="border-radius:1rem;border:1px solid #30363d;background:#161b22;padding:1rem">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:0.5rem">
<input v-model="formName" placeholder="Full name" style="border-radius:0.5rem;border:1px solid #30363d;background:#0d1117;padding:0.5rem 0.75rem;font-size:0.875rem;color:#e6edf3;outline:none" />
<input v-model="formEmail" placeholder="work@email.com" style="border-radius:0.5rem;border:1px solid #30363d;background:#0d1117;padding:0.5rem 0.75rem;font-size:0.875rem;color:#e6edf3;outline:none" />
<select v-model="formRole" style="border-radius:0.5rem;border:1px solid #30363d;background:#0d1117;padding:0.5rem 0.75rem;font-size:0.875rem;color:#e6edf3;outline:none">
<option v-for="r in ROLE_OPTIONS" :key="r" :value="r">{{ r }}</option>
</select>
<select v-model="formStatus" style="border-radius:0.5rem;border:1px solid #30363d;background:#0d1117;padding:0.5rem 0.75rem;font-size:0.875rem;color:#e6edf3;outline:none">
<option v-for="s in STATUS_OPTIONS" :key="s" :value="s">{{ s }}</option>
</select>
<button type="submit" style="border-radius:0.5rem;border:1px solid rgba(88,166,255,0.4);background:rgba(88,166,255,0.15);padding:0.5rem 1rem;font-size:0.875rem;font-weight:600;color:#c9e6ff;cursor:pointer">
{{ editingId === null ? 'Add row' : 'Save' }}
</button>
<button v-if="editingId !== null" type="button" @click="resetForm" style="border-radius:0.5rem;border:1px solid #30363d;background:transparent;padding:0.5rem 1rem;font-size:0.875rem;font-weight:600;color:#9ca3af;cursor:pointer">
Cancel
</button>
</div>
<p style="margin-top:0.5rem;min-height:1.25rem;font-size:0.75rem;color:#f87171">{{ error }}</p>
</form>
<!-- Table -->
<div style="border-radius:1rem;border:1px solid #30363d;background:#161b22;overflow:hidden">
<div style="overflow-x:auto">
<table style="width:100%;min-width:700px;text-align:left;font-size:0.875rem;border-collapse:collapse">
<thead style="background:#21262d;color:#c9d1d9">
<tr>
<th v-for="col in columns" :key="col.key" style="padding:0.75rem 1rem;font-weight:600">
<button type="button" @click="onSort(col.key)" style="background:none;border:none;color:inherit;cursor:pointer;display:inline-flex;align-items:center;gap:0.25rem;font-weight:600;font-size:0.875rem">
{{ col.label }}
<span style="font-size:0.75rem;color:#8b949e">{{ sortIndicator(col.key) }}</span>
</button>
</th>
<th style="padding:0.75rem 1rem;font-weight:600">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visible" :key="row.id" style="border-top:1px solid #21262d;color:#e6edf3">
<td style="padding:0.75rem 1rem;font-family:monospace;font-size:0.75rem;color:#9ca3af">{{ row.id }}</td>
<td style="padding:0.75rem 1rem;font-weight:500">{{ row.name }}</td>
<td style="padding:0.75rem 1rem;color:#c9d1d9">{{ row.role }}</td>
<td style="padding:0.75rem 1rem">
<span :style="statusStyle(row.status) + ';display:inline-flex;border-radius:0.375rem;padding:0.25rem 0.5rem;font-size:0.75rem;font-weight:600'">{{ row.status }}</span>
</td>
<td style="padding:0.75rem 1rem;color:#9fb3c8">{{ row.email }}</td>
<td style="padding:0.75rem 1rem">
<div style="display:flex;gap:0.5rem">
<button type="button" @click="editRow(row)" style="border-radius:0.375rem;border:1px solid rgba(61,93,138,0.4);background:rgba(88,166,255,0.1);padding:0.25rem 0.625rem;font-size:0.75rem;font-weight:600;color:#9ed0ff;cursor:pointer">Edit</button>
<button type="button" @click="deleteRow(row.id)" style="border-radius:0.375rem;border:1px solid rgba(244,63,94,0.35);background:rgba(244,63,94,0.1);padding:0.25rem 0.625rem;font-size:0.75rem;font-weight:600;color:#fda4af;cursor:pointer">Delete</button>
</div>
</td>
</tr>
<tr v-if="visible.length === 0">
<td colspan="6" style="padding:2rem 1rem;text-align:center;font-size:0.875rem;color:#8b949e">No rows available.</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #21262d;padding:0.75rem 1rem;font-size:0.875rem">
<p style="color:#8b949e;margin:0">Page <span style="color:#e6edf3">{{ safePage }}</span> of <span style="color:#e6edf3">{{ totalPages }}</span></p>
<div style="display:flex;gap:0.5rem">
<button type="button" :disabled="safePage <= 1" @click="page = Math.max(1, page - 1)" style="border-radius:0.375rem;border:1px solid #30363d;background:transparent;padding:0.375rem 0.75rem;font-size:0.75rem;font-weight:600;color:#c9d1d9;cursor:pointer" :style="{ opacity: safePage <= 1 ? 0.4 : 1 }">Prev</button>
<button type="button" :disabled="safePage >= totalPages" @click="page = Math.min(totalPages, page + 1)" style="border-radius:0.375rem;border:1px solid #30363d;background:transparent;padding:0.375rem 0.75rem;font-size:0.75rem;font-weight:600;color:#c9d1d9;cursor:pointer" :style="{ opacity: safePage >= totalPages ? 0.4 : 1 }">Next</button>
</div>
</div>
</div>
</div>
</section>
</template><script>
const seedRows = [
{ id: 1, name: "Lia Stone", role: "Engineer", status: "active", email: "lia@example.com" },
{ id: 2, name: "Milo Park", role: "Designer", status: "pending", email: "milo@example.com" },
{ id: 3, name: "Aya Reed", role: "Product", status: "active", email: "aya@example.com" },
{ id: 4, name: "Noah Cruz", role: "Engineer", status: "inactive", email: "noah@example.com" },
{ id: 5, name: "Zoe Hart", role: "Designer", status: "active", email: "zoe@example.com" },
{ id: 6, name: "Eli Frost", role: "Product", status: "pending", email: "eli@example.com" },
{ id: 7, name: "Ira Blake", role: "Engineer", status: "active", email: "ira@example.com" },
];
const PAGE_SIZE = 5;
const ROLE_OPTIONS = ["Engineer", "Designer", "Product"];
const STATUS_OPTIONS = ["active", "pending", "inactive"];
let rows = [...seedRows];
let sortKey = "id";
let sortDir = "asc";
let page = 1;
let editingId = null;
let error = "";
let formName = "";
let formEmail = "";
let formRole = "Engineer";
let formStatus = "active";
const columns = [
{ key: "id", label: "ID" },
{ key: "name", label: "Name" },
{ key: "role", label: "Role" },
{ key: "status", label: "Status" },
{ key: "email", label: "Email" },
];
$: sorted = (() => {
const arr = rows.slice();
arr.sort((a, b) => {
const left = a[sortKey];
const right = b[sortKey];
if (left === right) return 0;
if (typeof left === "number" && typeof right === "number") {
return sortDir === "asc" ? left - right : right - left;
}
return sortDir === "asc"
? String(left).localeCompare(String(right))
: String(right).localeCompare(String(left));
});
return arr;
})();
$: totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
$: safePage = Math.min(page, totalPages);
$: visible = sorted.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
$: if (page > totalPages) page = totalPages;
function onSort(key) {
if (sortKey === key) {
sortDir = sortDir === "asc" ? "desc" : "asc";
} else {
sortKey = key;
sortDir = "asc";
}
page = 1;
}
function sortIndicator(key) {
if (sortKey !== key) return "\u21D5";
return sortDir === "asc" ? "\u2191" : "\u2193";
}
function resetForm() {
editingId = null;
formName = "";
formEmail = "";
formRole = "Engineer";
formStatus = "active";
error = "";
}
function submit() {
if (formName.trim().length < 2) {
error = "Name must contain at least 2 characters.";
return;
}
if (!formEmail.includes("@") || !formEmail.includes(".")) {
error = "Please provide a valid email address.";
return;
}
if (editingId === null) {
const nextId = rows.reduce((max, r) => Math.max(max, r.id), 0) + 1;
rows = [
{ id: nextId, name: formName, role: formRole, status: formStatus, email: formEmail },
...rows,
];
} else {
rows = rows.map((r) =>
r.id === editingId
? { ...r, name: formName, role: formRole, status: formStatus, email: formEmail }
: r
);
}
page = 1;
resetForm();
}
function editRow(row) {
editingId = row.id;
formName = row.name;
formRole = row.role;
formStatus = row.status;
formEmail = row.email;
error = "";
}
function deleteRow(id) {
rows = rows.filter((r) => r.id !== id);
}
function statusStyle(status) {
if (status === "active")
return "background:rgba(16,185,129,0.15);color:#6ee7b7;border:1px solid rgba(52,211,153,0.25)";
if (status === "pending")
return "background:rgba(245,158,11,0.15);color:#fcd34d;border:1px solid rgba(251,191,36,0.25)";
return "background:rgba(100,116,139,0.2);color:#cbd5e1;border:1px solid rgba(148,163,184,0.25)";
}
</script>
<section style="min-height:100vh;background:#0d1117;padding:1.5rem 1rem;font-family:system-ui,-apple-system,sans-serif;color:#e6edf3">
<div style="max-width:960px;margin:0 auto;display:flex;flex-direction:column;gap:1rem">
<!-- Header -->
<header style="border-radius:1rem;border:1px solid #30363d;background:#161b22;padding:1rem">
<p style="font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.08em;color:#8b949e;margin:0">Pattern</p>
<h1 style="margin:0.25rem 0 0;font-size:1.125rem;font-weight:700;color:#e6edf3">CRUD Table</h1>
<p style="margin:0.25rem 0 0;font-size:0.875rem;color:#8b949e">Create, edit, delete, sort, and paginate rows in a single table flow.</p>
</header>
<!-- Form -->
<form on:submit|preventDefault={submit} style="border-radius:1rem;border:1px solid #30363d;background:#161b22;padding:1rem">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:0.5rem">
<input bind:value={formName} placeholder="Full name" style="border-radius:0.5rem;border:1px solid #30363d;background:#0d1117;padding:0.5rem 0.75rem;font-size:0.875rem;color:#e6edf3;outline:none" />
<input bind:value={formEmail} placeholder="work@email.com" style="border-radius:0.5rem;border:1px solid #30363d;background:#0d1117;padding:0.5rem 0.75rem;font-size:0.875rem;color:#e6edf3;outline:none" />
<select bind:value={formRole} style="border-radius:0.5rem;border:1px solid #30363d;background:#0d1117;padding:0.5rem 0.75rem;font-size:0.875rem;color:#e6edf3;outline:none">
{#each ROLE_OPTIONS as r}
<option value={r}>{r}</option>
{/each}
</select>
<select bind:value={formStatus} style="border-radius:0.5rem;border:1px solid #30363d;background:#0d1117;padding:0.5rem 0.75rem;font-size:0.875rem;color:#e6edf3;outline:none">
{#each STATUS_OPTIONS as s}
<option value={s}>{s}</option>
{/each}
</select>
<button type="submit" style="border-radius:0.5rem;border:1px solid rgba(88,166,255,0.4);background:rgba(88,166,255,0.15);padding:0.5rem 1rem;font-size:0.875rem;font-weight:600;color:#c9e6ff;cursor:pointer">
{editingId === null ? 'Add row' : 'Save'}
</button>
{#if editingId !== null}
<button type="button" on:click={resetForm} style="border-radius:0.5rem;border:1px solid #30363d;background:transparent;padding:0.5rem 1rem;font-size:0.875rem;font-weight:600;color:#9ca3af;cursor:pointer">
Cancel
</button>
{/if}
</div>
<p style="margin-top:0.5rem;min-height:1.25rem;font-size:0.75rem;color:#f87171">{error}</p>
</form>
<!-- Table -->
<div style="border-radius:1rem;border:1px solid #30363d;background:#161b22;overflow:hidden">
<div style="overflow-x:auto">
<table style="width:100%;min-width:700px;text-align:left;font-size:0.875rem;border-collapse:collapse">
<thead style="background:#21262d;color:#c9d1d9">
<tr>
{#each columns as col}
<th style="padding:0.75rem 1rem;font-weight:600">
<button type="button" on:click={() => onSort(col.key)} style="background:none;border:none;color:inherit;cursor:pointer;display:inline-flex;align-items:center;gap:0.25rem;font-weight:600;font-size:0.875rem">
{col.label}
<span style="font-size:0.75rem;color:#8b949e">{sortIndicator(col.key)}</span>
</button>
</th>
{/each}
<th style="padding:0.75rem 1rem;font-weight:600">Actions</th>
</tr>
</thead>
<tbody>
{#each visible as row (row.id)}
<tr style="border-top:1px solid #21262d;color:#e6edf3">
<td style="padding:0.75rem 1rem;font-family:monospace;font-size:0.75rem;color:#9ca3af">{row.id}</td>
<td style="padding:0.75rem 1rem;font-weight:500">{row.name}</td>
<td style="padding:0.75rem 1rem;color:#c9d1d9">{row.role}</td>
<td style="padding:0.75rem 1rem">
<span style="{statusStyle(row.status)};display:inline-flex;border-radius:0.375rem;padding:0.25rem 0.5rem;font-size:0.75rem;font-weight:600">{row.status}</span>
</td>
<td style="padding:0.75rem 1rem;color:#9fb3c8">{row.email}</td>
<td style="padding:0.75rem 1rem">
<div style="display:flex;gap:0.5rem">
<button type="button" on:click={() => editRow(row)} style="border-radius:0.375rem;border:1px solid rgba(61,93,138,0.4);background:rgba(88,166,255,0.1);padding:0.25rem 0.625rem;font-size:0.75rem;font-weight:600;color:#9ed0ff;cursor:pointer">Edit</button>
<button type="button" on:click={() => deleteRow(row.id)} style="border-radius:0.375rem;border:1px solid rgba(244,63,94,0.35);background:rgba(244,63,94,0.1);padding:0.25rem 0.625rem;font-size:0.75rem;font-weight:600;color:#fda4af;cursor:pointer">Delete</button>
</div>
</td>
</tr>
{/each}
{#if visible.length === 0}
<tr>
<td colspan="6" style="padding:2rem 1rem;text-align:center;font-size:0.875rem;color:#8b949e">No rows available.</td>
</tr>
{/if}
</tbody>
</table>
</div>
<!-- Pagination -->
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #21262d;padding:0.75rem 1rem;font-size:0.875rem">
<p style="color:#8b949e;margin:0">Page <span style="color:#e6edf3">{safePage}</span> of <span style="color:#e6edf3">{totalPages}</span></p>
<div style="display:flex;gap:0.5rem">
<button type="button" disabled={safePage <= 1} on:click={() => page = Math.max(1, page - 1)} style="border-radius:0.375rem;border:1px solid #30363d;background:transparent;padding:0.375rem 0.75rem;font-size:0.75rem;font-weight:600;color:#c9d1d9;cursor:pointer;opacity:{safePage <= 1 ? 0.4 : 1}">Prev</button>
<button type="button" disabled={safePage >= totalPages} on:click={() => page = Math.min(totalPages, page + 1)} style="border-radius:0.375rem;border:1px solid #30363d;background:transparent;padding:0.375rem 0.75rem;font-size:0.75rem;font-weight:600;color:#c9d1d9;cursor:pointer;opacity:{safePage >= totalPages ? 0.4 : 1}">Next</button>
</div>
</div>
</div>
</div>
</section>CRUD Table
A practical integration pattern that combines table rendering, form state, validation, and row actions in one flow.
Features
- Create and edit rows from a shared form
- Delete actions per row
- Click-to-sort columns
- Pagination for larger datasets
- Inline validation for required fields
Notes
This pattern focuses on CRUD orchestration and differs from data-table, which emphasizes selection, bulk tools, and column visibility.