"use client";
import * as React from "react";
import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from "motion/react";
import { ClipboardPaste, Copy, Redo2, Undo2 } from "lucide-react";
import { cn } from "@/lib/utils";
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
export type CellValue = string | number | null;
export type Row = Record<string, CellValue> & { id: string };
export type Column = {
key: string;
header: string;
type?: "text" | "number" | "currency" | "select";
options?: string[];
readOnly?: boolean;
/** Derived, read-only value computed from the row. */
compute?: (row: Row) => CellValue;
/** Return an error message to reject a value. */
validate?: (value: CellValue, row: Row) => string | null;
width?: number;
/** Include a sum in the footer. */
total?: boolean;
};
type Change = { row: string; col: string; prev: CellValue; next: CellValue };
type Pos = { r: number; c: number };
export interface EditableGridProps {
columns?: Column[];
rows?: Row[];
defaultRows?: Row[];
onRowsChange?: (rows: Row[]) => void;
currency?: string;
locale?: string;
label?: string;
className?: string;
}
/* ----------------------------------------------------------------------------
* Defaults
* ------------------------------------------------------------------------- */
const DEFAULT_COLUMNS: Column[] = [
{ key: "name", header: "Product", width: 200, validate: (v) => (String(v ?? "").trim() ? null : "Name is required") },
{ key: "sku", header: "SKU", readOnly: true, width: 96 },
{ key: "category", header: "Category", type: "select", options: ["Hardware", "Software", "Service"], width: 120 },
{
key: "qty",
header: "Qty",
type: "number",
width: 72,
total: true,
validate: (v) => (v === null || !Number.isInteger(v) || (v as number) < 0 ? "Whole number ≥ 0" : null),
},
{ key: "price", header: "Price", type: "currency", width: 104, validate: (v) => (v === null || (v as number) <= 0 ? "Must be above 0" : null) },
{ key: "total", header: "Total", type: "currency", readOnly: true, width: 112, total: true, compute: (r) => Number(r.qty ?? 0) * Number(r.price ?? 0) },
];
const DEFAULT_ROWS: Row[] = [
{ id: "r1", name: "Orbit Dock", sku: "OR-104", category: "Hardware", qty: 12, price: 89 },
{ id: "r2", name: "Lumen Pro licence", sku: "LU-220", category: "Software", qty: 40, price: 24 },
{ id: "r3", name: "Onboarding workshop", sku: "SV-310", category: "Service", qty: 2, price: 650 },
{ id: "r4", name: "Northwind Keyboard", sku: "NW-018", category: "Hardware", qty: 25, price: 59 },
{ id: "r5", name: "Acme Cloud seat", sku: "AC-501", category: "Software", qty: 18, price: 12.5 },
];
const colName = (c: number) => String.fromCharCode(65 + c);
/* ---------------------------------------------------------------------------- */
export function EditableGrid({
columns = DEFAULT_COLUMNS,
rows: rowsProp,
defaultRows = DEFAULT_ROWS,
onRowsChange,
currency = "USD",
locale = "en-US",
label = "Order lines",
className,
}: EditableGridProps) {
const uid = React.useId();
const reduce = useReducedMotion();
const [inner, setInner] = React.useState<Row[]>(defaultRows);
const rows = rowsProp ?? inner;
const [pos, setPos] = React.useState<Pos>({ r: 0, c: 0 });
const [edit, setEdit] = React.useState<{ draft: string; error: string | null; n: number } | null>(null);
const [past, setPast] = React.useState<Change[]>([]);
const [future, setFuture] = React.useState<Change[]>([]);
const [flash, setFlash] = React.useState<Record<string, number>>({});
const [toast, setToast] = React.useState<{ text: string; n: number } | null>(null);
const clip = React.useRef<string>("");
const cellRefs = React.useRef(new Map<string, HTMLTableCellElement>());
const wantFocus = React.useRef(false);
const editingRef = React.useRef(false);
const money = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency }), [locale, currency]);
const num = React.useMemo(() => new Intl.NumberFormat(locale), [locale]);
const valueOf = (row: Row, col: Column): CellValue => (col.compute ? col.compute(row) : (row[col.key] ?? null));
const display = (v: CellValue, col: Column) => {
if (v === null || v === "") return "";
if (col.type === "currency") return money.format(Number(v));
if (col.type === "number") return num.format(Number(v));
return String(v);
};
const editable = (col: Column) => !col.readOnly && !col.compute;
const key = (r: number, c: number) => `${r}:${c}`;
React.useEffect(() => {
if (!wantFocus.current || edit) return;
wantFocus.current = false;
cellRefs.current.get(key(pos.r, pos.c))?.focus({ preventScroll: false });
}, [pos, edit]);
const notify = (text: string) => setToast((t) => ({ text, n: (t?.n ?? 0) + 1 }));
const parse = (raw: string, col: Column): CellValue => {
const s = raw.trim();
if (col.type === "number" || col.type === "currency") {
if (!s) return null;
const n = Number(s.replace(/[^\d.,-]/g, "").replace(/,(?=\d{3}\b)/g, "").replace(",", "."));
return Number.isFinite(n) ? n : null;
}
if (col.type === "select") {
const hit = col.options?.find((o) => o.toLowerCase() === s.toLowerCase());
return hit ?? s;
}
return s;
};
const check = (v: CellValue, col: Column, row: Row) => {
if (col.type === "select" && v && col.options && !col.options.includes(String(v))) return `Pick one of: ${col.options.join(", ")}`;
if ((col.type === "number" || col.type === "currency") && v === null && col.validate) return col.validate(v, row) ?? "Enter a number";
return col.validate?.(v, row) ?? null;
};
const apply = (changes: Change[], direction: "do" | "undo") => {
const next = rows.map((row) => {
const mine = changes.filter((ch) => ch.row === row.id);
if (!mine.length) return row;
const copy: Row = { ...row };
for (const ch of mine) copy[ch.col] = direction === "do" ? ch.next : ch.prev;
return copy;
});
if (rowsProp === undefined) setInner(next);
onRowsChange?.(next);
setFlash((f) => {
const n = { ...f };
for (const ch of changes) n[`${ch.row}:${ch.col}`] = (n[`${ch.row}:${ch.col}`] ?? 0) + 1;
return n;
});
};
const write = (r: number, c: number, v: CellValue): string | null => {
const row = rows[r];
const col = columns[c];
if (!row || !col || !editable(col)) return "Read-only cell";
const err = check(v, col, row);
if (err) return err;
const prev = row[col.key] ?? null;
if (prev === v) return null;
const ch: Change = { row: row.id, col: col.key, prev, next: v };
apply([ch], "do");
setPast((p) => [...p.slice(-99), ch]);
setFuture([]);
return null;
};
const undo = () => {
const ch = past[past.length - 1];
if (!ch) return;
apply([ch], "undo");
setPast((p) => p.slice(0, -1));
setFuture((f) => [ch, ...f]);
focusChange(ch);
notify("Undone");
};
const redo = () => {
const ch = future[0];
if (!ch) return;
apply([ch], "do");
setFuture((f) => f.slice(1));
setPast((p) => [...p, ch]);
focusChange(ch);
notify("Redone");
};
const focusChange = (ch: Change) => {
const r = rows.findIndex((x) => x.id === ch.row);
const c = columns.findIndex((x) => x.key === ch.col);
if (r >= 0 && c >= 0) moveTo(r, c);
};
const moveTo = (r: number, c: number) => {
wantFocus.current = true;
setPos({ r: Math.max(0, Math.min(rows.length - 1, r)), c: Math.max(0, Math.min(columns.length - 1, c)) });
};
const startEdit = (initial?: string) => {
const col = columns[pos.c];
const row = rows[pos.r];
if (!col || !row) return;
if (!editable(col)) {
notify(`${col.header} is read-only`);
return;
}
const v = row[col.key] ?? null;
editingRef.current = true;
setEdit({ draft: initial ?? (v === null ? "" : String(v)), error: null, n: 0 });
};
const commit = (then: "down" | "right" | "left" | "stay") => {
if (!edit || !editingRef.current) return true;
const col = columns[pos.c];
const err = write(pos.r, pos.c, parse(edit.draft, col));
if (err) {
setEdit((e) => (e ? { ...e, error: err, n: e.n + 1 } : e));
return false;
}
editingRef.current = false;
setEdit(null);
wantFocus.current = true;
if (then === "down") moveTo(pos.r + 1, pos.c);
else if (then === "right") step(1);
else if (then === "left") step(-1);
else setPos({ ...pos });
return true;
};
/** Moves along the row, wrapping to the next/previous row. Returns false at the grid edge. */
const step = (d: 1 | -1) => {
let { r, c } = pos;
c += d;
if (c >= columns.length) {
if (r >= rows.length - 1) return false;
c = 0;
r += 1;
} else if (c < 0) {
if (r <= 0) return false;
c = columns.length - 1;
r -= 1;
}
moveTo(r, c);
return true;
};
const cellText = (r: number, c: number) => {
const row = rows[r];
const col = columns[c];
if (!row || !col) return "";
const v = valueOf(row, col);
return v === null ? "" : String(v);
};
const pasteText = (text: string) => {
const col = columns[pos.c];
const firstCell = text.split(/\t|\r?\n/)[0] ?? "";
const err = write(pos.r, pos.c, parse(firstCell, col));
notify(err ? `Paste rejected — ${err}` : "Pasted");
};
const onGridKeyDown = (e: React.KeyboardEvent) => {
if (edit) return;
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key.toLowerCase() === "z") {
e.preventDefault();
if (e.shiftKey) redo();
else undo();
return;
}
if (mod && e.key.toLowerCase() === "y") {
e.preventDefault();
redo();
return;
}
if (mod) return; // let copy / paste events fire
const { r, c } = pos;
switch (e.key) {
case "ArrowUp":
e.preventDefault();
moveTo(r - 1, c);
break;
case "ArrowDown":
e.preventDefault();
moveTo(r + 1, c);
break;
case "ArrowLeft":
e.preventDefault();
moveTo(r, c - 1);
break;
case "ArrowRight":
e.preventDefault();
moveTo(r, c + 1);
break;
case "Tab":
// at the grid edge Tab leaves the grid (no keyboard trap)
if (step(e.shiftKey ? -1 : 1)) e.preventDefault();
break;
case "Home":
e.preventDefault();
moveTo(r, 0);
break;
case "End":
e.preventDefault();
moveTo(r, columns.length - 1);
break;
case "Enter":
case "F2":
e.preventDefault();
startEdit();
break;
case "Delete":
case "Backspace": {
e.preventDefault();
const col = columns[c];
if (!editable(col)) return notify(`${col.header} is read-only`);
const err = write(r, c, col.type === "number" || col.type === "currency" ? null : "");
if (err) notify(err);
break;
}
default:
if (e.key.length === 1 && !e.altKey) {
e.preventDefault();
startEdit(e.key);
}
}
};
const totals = columns.map((col) =>
col.total ? rows.reduce((s, row) => s + Number(valueOf(row, col) ?? 0), 0) : null,
);
const activeCol = columns[pos.c];
const activeRow = rows[pos.r];
const tbBtn =
"grid size-8 place-items-center rounded-lg text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-35";
return (
<div className={cn("w-full max-w-3xl overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-sm", className)}>
{/* toolbar / formula bar */}
<div className="flex items-center gap-2 border-b px-2 py-1.5">
<span className="grid h-7 min-w-10 place-items-center rounded-md bg-muted px-2 font-mono text-xs font-semibold tabular-nums" aria-label="Selected cell">
{colName(pos.c)}
{pos.r + 1}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground" aria-hidden>
{activeRow && activeCol ? (activeCol.compute ? `= ${activeCol.header.toLowerCase()} (computed)` : cellText(pos.r, pos.c) || "—") : ""}
</span>
<button type="button" className={tbBtn} onClick={undo} disabled={!past.length} aria-label="Undo (Ctrl+Z)" title="Undo (Ctrl+Z)">
<Undo2 className="size-4" />
</button>
<button type="button" className={tbBtn} onClick={redo} disabled={!future.length} aria-label="Redo (Ctrl+Shift+Z)" title="Redo (Ctrl+Shift+Z)">
<Redo2 className="size-4" />
</button>
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden />
<button
type="button"
className={tbBtn}
aria-label="Copy cell"
title="Copy (Ctrl+C)"
onClick={() => {
clip.current = cellText(pos.r, pos.c);
navigator.clipboard?.writeText(clip.current).catch(() => {});
notify("Copied");
}}
>
<Copy className="size-4" />
</button>
<button
type="button"
className={tbBtn}
aria-label="Paste into cell"
title="Paste (Ctrl+V)"
onClick={async () => {
let text = clip.current;
try {
text = (await navigator.clipboard.readText()) || text;
} catch {
/* permission denied → internal clipboard */
}
pasteText(text);
}}
>
<ClipboardPaste className="size-4" />
</button>
</div>
<div className="relative overflow-x-auto">
<table
role="grid"
aria-label={label}
aria-rowcount={rows.length + 2}
aria-colcount={columns.length}
className="w-full min-w-[640px] border-collapse text-sm"
onKeyDown={onGridKeyDown}
onCopy={(e) => {
if (edit) return;
e.preventDefault();
clip.current = cellText(pos.r, pos.c);
e.clipboardData.setData("text/plain", clip.current);
notify("Copied");
}}
onCut={(e) => {
if (edit) return;
e.preventDefault();
const col = columns[pos.c];
clip.current = cellText(pos.r, pos.c);
e.clipboardData.setData("text/plain", clip.current);
const err = write(pos.r, pos.c, col.type === "number" || col.type === "currency" ? null : "");
notify(err ? `Copied — ${err}` : "Cut");
}}
onPaste={(e) => {
if (edit) return;
e.preventDefault();
pasteText(e.clipboardData.getData("text/plain") || clip.current);
}}
>
<thead>
<tr className="border-b bg-muted/40" aria-rowindex={1}>
{columns.map((col, c) => (
<th
key={col.key}
scope="col"
style={{ width: col.width }}
className={cn(
"h-9 border-r px-3 text-left text-xs font-medium text-muted-foreground last:border-r-0",
(col.type === "number" || col.type === "currency") && "text-right",
pos.c === c && "text-foreground",
)}
>
<span className="mr-1.5 font-mono text-[10px] opacity-50">{colName(c)}</span>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, r) => (
<tr key={row.id} aria-rowindex={r + 2} className={cn("border-b transition-colors", pos.r === r ? "bg-primary/[0.03]" : "hover:bg-muted/30")}>
{columns.map((col, c) => {
const active = pos.r === r && pos.c === c;
const isEditing = active && !!edit;
const v = valueOf(row, col);
const numeric = col.type === "number" || col.type === "currency";
const flashN = flash[`${row.id}:${col.key}`];
return (
<td
key={col.key}
ref={(el) => {
if (el) cellRefs.current.set(key(r, c), el);
else cellRefs.current.delete(key(r, c));
}}
role="gridcell"
aria-colindex={c + 1}
aria-selected={active}
aria-readonly={!editable(col) || undefined}
aria-invalid={isEditing && !!edit?.error ? true : undefined}
tabIndex={active && !edit ? 0 : -1}
onMouseDown={(e) => {
if (isEditing) return;
if (edit && !commit("stay")) {
e.preventDefault();
return;
}
e.preventDefault();
moveTo(r, c);
}}
onDoubleClick={() => {
if (!edit) startEdit();
}}
className={cn(
"relative h-10 border-r px-3 outline-none last:border-r-0",
numeric && "text-right tabular-nums",
!editable(col) && "text-muted-foreground",
col.compute && "font-medium text-foreground",
)}
>
{flashN && !reduce && (
<motion.span
key={flashN}
aria-hidden
className="pointer-events-none absolute inset-0 bg-primary/20"
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: 0.9, ease: "easeOut" }}
/>
)}
{active && (
<motion.span
layoutId={reduce ? undefined : `${uid}-sel`}
aria-hidden
className={cn(
"pointer-events-none absolute -inset-px z-10 rounded-[3px] border-2",
edit?.error ? "border-destructive" : "border-primary",
isEditing && !edit?.error && "shadow-[0_0_0_4px_color-mix(in_oklab,var(--color-primary)_18%,transparent)]",
)}
transition={{ type: "spring", stiffness: 700, damping: 45 }}
/>
)}
{isEditing ? (
<CellEditor
col={col}
draft={edit.draft}
error={edit.error}
shakeKey={edit.n}
reduce={!!reduce}
onDraft={(d) => setEdit((e) => (e ? { ...e, draft: d, error: null } : e))}
onCommit={commit}
onCancel={() => {
editingRef.current = false;
setEdit(null);
wantFocus.current = true;
setPos({ ...pos });
}}
/>
) : (
<span className={cn("relative block truncate", v === null || v === "" ? "text-muted-foreground/50" : "")}>
{v === null || v === "" ? (numeric ? "—" : "Empty") : col.type === "select" ? <Pill value={String(v)} /> : display(v, col)}
</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
<tfoot>
<tr className="bg-muted/40 text-xs" aria-rowindex={rows.length + 2}>
{columns.map((col, c) => (
<td key={col.key} className={cn("h-9 border-r px-3 font-semibold last:border-r-0", (col.type === "number" || col.type === "currency") && "text-right tabular-nums")}>
{c === 0 ? <span className="font-medium text-muted-foreground">Σ {rows.length} rows</span> : totals[c] !== null ? display(totals[c], col) : null}
</td>
))}
</tr>
</tfoot>
</table>
</div>
<div className="flex items-center justify-between gap-3 border-t px-3 py-2 text-[11px] text-muted-foreground">
<span className="hidden truncate sm:block">Enter / F2 edit · Tab / arrows move · Esc cancel · ⌘Z undo · ⌘C / ⌘V copy & paste</span>
<span className="truncate sm:hidden">Double-tap a cell to edit</span>
<span className="relative h-4 min-w-24 text-right" aria-live="polite">
<AnimatePresence mode="popLayout">
{toast && (
<motion.span
key={toast.n}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
className="absolute right-0 whitespace-nowrap font-medium text-foreground"
>
{toast.text}
</motion.span>
)}
</AnimatePresence>
</span>
</div>
</div>
);
}
function Pill({ value }: { value: string }) {
const tone =
value === "Hardware"
? "bg-sky-500/12 text-sky-700 dark:text-sky-300"
: value === "Software"
? "bg-violet-500/12 text-violet-700 dark:text-violet-300"
: value === "Service"
? "bg-amber-500/15 text-amber-700 dark:text-amber-300"
: "bg-muted text-foreground";
return <span className={cn("inline-flex rounded-md px-1.5 py-0.5 text-xs font-medium", tone)}>{value}</span>;
}
function CellEditor({
col,
draft,
error,
shakeKey,
reduce,
onDraft,
onCommit,
onCancel,
}: {
col: Column;
draft: string;
error: string | null;
shakeKey: number;
reduce: boolean;
onDraft: (d: string) => void;
onCommit: (then: "down" | "right" | "left" | "stay") => boolean;
onCancel: () => void;
}) {
const ref = React.useRef<HTMLInputElement & HTMLSelectElement>(null);
const shake = useAnimationControls();
React.useEffect(() => {
if (shakeKey && !reduce) void shake.start({ x: [0, -5, 5, -3, 3, 0], transition: { duration: 0.3 } });
}, [shakeKey, reduce, shake]);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
el.focus();
if (el instanceof HTMLInputElement) {
const len = el.value.length;
el.setSelectionRange(len, len);
}
}, []);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
onCommit("down");
} else if (e.key === "Tab") {
e.preventDefault();
onCommit(e.shiftKey ? "left" : "right");
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
e.stopPropagation();
};
const numeric = col.type === "number" || col.type === "currency";
const cls = cn(
"absolute inset-0 z-20 w-full bg-background px-3 text-sm outline-none",
numeric && "text-right tabular-nums",
error && "bg-destructive/5",
);
return (
<>
<motion.div className="absolute inset-0" animate={shake}>
{col.type === "select" ? (
<select
ref={ref}
aria-label={`Edit ${col.header}`}
value={col.options?.includes(draft) ? draft : ""}
onChange={(e) => onDraft(e.target.value)}
onKeyDown={onKeyDown}
onBlur={() => onCommit("stay")}
className={cls}
>
{!col.options?.includes(draft) && <option value="">Choose…</option>}
{col.options?.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
) : (
<input
ref={ref}
aria-label={`Edit ${col.header}`}
aria-invalid={!!error}
inputMode={numeric ? "decimal" : undefined}
value={draft}
onChange={(e) => onDraft(e.target.value)}
onKeyDown={onKeyDown}
onBlur={() => onCommit("stay")}
className={cls}
/>
)}
</motion.div>
<AnimatePresence>
{error && (
<motion.span
role="alert"
initial={{ opacity: 0, y: -4, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0 }}
className="absolute left-0 top-[calc(100%+4px)] z-30 whitespace-nowrap rounded-md bg-destructive px-2 py-1 text-left text-[11px] font-medium text-white shadow-lg"
>
{error}
</motion.span>
)}
</AnimatePresence>
</>
);
}