Fazekit

Code

"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>
    </>
  );
}

More in Data Display

View all →