"use client"; import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, CheckIcon, Redo2Icon, Undo2Icon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ClipboardEvent, type KeyboardEvent, type PointerEvent, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { Button } from "@/components/ui/button"; import { Kbd } from "@/components/ui/kbd"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { cn } from "@/lib/utils"; export type CellValue = string | number | null; export interface SheetColumn { key: string; label: string; type?: "text" | "number"; format?: Intl.NumberFormatOptions; width?: number; } export interface SheetRow { id: string; [key: string]: CellValue; } export interface MiniSheetProps { columns?: SheetColumn[]; rows?: SheetRow[]; onRowsChange?: (rows: SheetRow[]) => void; locale?: string; className?: string; } type Cell = { r: number; c: number }; type Sort = { key: string; dir: "asc" | "desc" } | null; type Editing = { r: number; c: number; value: string; replace: boolean; session: number } | null; const MotionRow = motion.create(TableRow); const defaultColumns: SheetColumn[] = [ { key: "company", label: "Company", width: 152 }, { key: "owner", label: "Owner", width: 112 }, { key: "plan", label: "Plan", width: 92 }, { key: "seats", label: "Seats", type: "number", width: 72 }, { key: "mrr", label: "MRR", type: "number", width: 96, format: { style: "currency", currency: "USD", maximumFractionDigits: 0 } }, ]; const defaultRows: SheetRow[] = [ { id: "1", company: "Bluebird Labs", owner: "Maya Ortiz", plan: "Team", seats: 24, mrr: 1920 }, { id: "2", company: "Kestrel Freight", owner: "Jon Bakker", plan: "Enterprise", seats: 140, mrr: 11200 }, { id: "3", company: "Pinecone Studio", owner: "Ava Lindqvist", plan: "Pro", seats: 6, mrr: 294 }, { id: "4", company: "Harbor Analytics", owner: "Theo Nakamura", plan: "Team", seats: 38, mrr: 3040 }, { id: "5", company: "Orchard Health", owner: "Priya Raman", plan: "Enterprise", seats: 92, mrr: 7360 }, { id: "6", company: "Lumen & Co", owner: "Sam Okafor", plan: "Starter", seats: 3, mrr: 57 }, { id: "7", company: "Atlas Robotics", owner: "Lena Fischer", plan: "Pro", seats: 12, mrr: 588 }, ]; const colName = (c: number) => String.fromCharCode(65 + c); function offsetWithin(el: HTMLElement, root: HTMLElement) { let x = 0; let y = 0; let node: HTMLElement | null = el; while (node && node !== root) { x += node.offsetLeft; y += node.offsetTop; node = node.offsetParent as HTMLElement | null; } return { x, y }; } function parseCell(raw: string, column: SheetColumn): CellValue { const text = raw.trim(); if (column.type !== "number") return text; if (text === "") return null; const n = Number(text.replace(/[^0-9.\-]/g, "")); return Number.isFinite(n) ? n : Number.NaN; } export function MiniSheet({ columns = defaultColumns, rows: initialRows = defaultRows, onRowsChange, locale = "en-US", className, }: MiniSheetProps) { const reduce = useReducedMotion(); const uid = useId().replace(/:/g, ""); const wrapRef = useRef(null); const scrollRef = useRef(null); const gridRef = useRef(null); const dragging = useRef(false); const session = useRef(0); const finished = useRef(-1); const [rows, setRows] = useState(initialRows); const [past, setPast] = useState([]); const [future, setFuture] = useState([]); const [sort, setSort] = useState(null); const [anchor, setAnchor] = useState({ r: 0, c: 0 }); const [focus, setFocus] = useState({ r: 0, c: 0 }); const [editing, setEditing] = useState(null); const [hasFocus, setHasFocus] = useState(false); const [notice, setNotice] = useState<{ id: number; text: string } | null>(null); const [frame, setFrame] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const [glide, setGlide] = useState(true); const formatters = useMemo( () => columns.map((col) => (col.type === "number" ? new Intl.NumberFormat(locale, col.format) : null)), [columns, locale], ); const view = useMemo(() => { if (!sort) return rows; const col = columns.find((c) => c.key === sort.key); const sign = sort.dir === "asc" ? 1 : -1; return [...rows].sort((a, b) => { const x = a[sort.key]; const y = b[sort.key]; if (x === null || x === "") return 1; if (y === null || y === "") return -1; if (col?.type === "number") return (Number(x) - Number(y)) * sign; return String(x).localeCompare(String(y), locale) * sign; }); }, [rows, sort, columns, locale]); const maxR = view.length - 1; const maxC = columns.length - 1; const top = Math.min(anchor.r, focus.r); const bottom = Math.max(anchor.r, focus.r); const left = Math.min(anchor.c, focus.c); const right = Math.max(anchor.c, focus.c); const inRange = (r: number, c: number) => r >= top && r <= bottom && c >= left && c <= right; const cellId = (r: number, c: number) => `${uid}-${r}-${c}`; useEffect(() => { if (!notice) return; const t = setTimeout(() => setNotice(null), 1600); return () => clearTimeout(t); }, [notice]); // Measured with offsets so row layout animations don't skew the frame. useLayoutEffect(() => { const wrap = wrapRef.current; const grid = gridRef.current; if (!wrap || !grid) return; const a = grid.querySelector(`#${CSS.escape(cellId(top, left))}`); const b = grid.querySelector(`#${CSS.escape(cellId(bottom, right))}`); if (!a || !b) { setFrame(null); return; } const pa = offsetWithin(a, wrap); const pb = offsetWithin(b, wrap); setFrame({ x: pa.x, y: pa.y, w: pb.x + b.offsetWidth - pa.x, h: pb.y + b.offsetHeight - pa.y }); }, [top, left, bottom, right, view, columns]); useEffect(() => { const scroller = scrollRef.current; const wrap = wrapRef.current; const cell = gridRef.current?.querySelector(`#${CSS.escape(cellId(focus.r, focus.c))}`); if (!hasFocus || !scroller || !wrap || !cell) return; const x = offsetWithin(cell, wrap).x; const rowHead = 36; if (x - rowHead < scroller.scrollLeft) scroller.scrollLeft = x - rowHead; else if (x + cell.offsetWidth > scroller.scrollLeft + scroller.clientWidth) scroller.scrollLeft = x + cell.offsetWidth - scroller.clientWidth; }, [focus, hasFocus]); function display(value: CellValue, c: number) { if (value === null || value === "") return ""; const f = formatters[c]; return f && typeof value === "number" ? f.format(value) : String(value); } function commitRows(next: SheetRow[]) { setPast((p) => [...p.slice(-49), rows]); setFuture([]); setRows(next); onRowsChange?.(next); } function writeCells(cells: { r: number; c: number; value: CellValue }[]) { const byId = new Map(rows.map((row) => [row.id, { ...row }])); let changed = false; for (const { r, c, value } of cells) { const viewRow = view[r]; const col = columns[c]; if (!viewRow || !col) continue; if (typeof value === "number" && Number.isNaN(value)) continue; const target = byId.get(viewRow.id); if (target && target[col.key] !== value) { target[col.key] = value; changed = true; } } if (changed) commitRows(rows.map((row) => byId.get(row.id) ?? row)); return changed; } function undo() { const prev = past[past.length - 1]; if (!prev) return; setPast((p) => p.slice(0, -1)); setFuture((f) => [rows, ...f]); setRows(prev); onRowsChange?.(prev); setNotice({ id: Date.now(), text: "Undone" }); } function redo() { const next = future[0]; if (!next) return; setFuture((f) => f.slice(1)); setPast((p) => [...p, rows]); setRows(next); onRowsChange?.(next); setNotice({ id: Date.now(), text: "Redone" }); } function select(cell: Cell, extend: boolean) { const clamped = { r: Math.max(0, Math.min(maxR, cell.r)), c: Math.max(0, Math.min(maxC, cell.c)) }; if (extend) setFocus(clamped); else { setAnchor(clamped); setFocus(clamped); } } function startEdit(replaceWith?: string) { const value = view[anchor.r]?.[columns[anchor.c].key] ?? null; setFocus(anchor); setEditing({ r: anchor.r, c: anchor.c, value: replaceWith ?? (value === null ? "" : String(value)), replace: replaceWith !== undefined, session: ++session.current, }); } function finishEdit(move: "down" | "right" | "left" | "none", cancel = false) { if (!editing || finished.current === editing.session) return; finished.current = editing.session; if (gridRef.current?.contains(document.activeElement)) gridRef.current.focus(); if (!cancel) { const value = parseCell(editing.value, columns[editing.c]); writeCells([{ r: editing.r, c: editing.c, value }]); } setEditing(null); if (move === "down") select({ r: editing.r + 1, c: editing.c }, false); if (move === "right") select({ r: editing.r, c: editing.c + 1 }, false); if (move === "left") select({ r: editing.r, c: editing.c - 1 }, false); } function cycleSort(key: string) { setGlide(true); setSort((s) => (!s || s.key !== key ? { key, dir: "asc" } : s.dir === "asc" ? { key, dir: "desc" } : null)); } function rangeText() { const lines: string[] = []; for (let r = top; r <= bottom; r++) { const cells: string[] = []; for (let c = left; c <= right; c++) { const v = view[r]?.[columns[c].key]; cells.push(v === null || v === undefined ? "" : String(v)); } lines.push(cells.join("\t")); } return lines.join("\n"); } const cellCount = (bottom - top + 1) * (right - left + 1); function onCopy(e: ClipboardEvent) { if (editing) return; e.preventDefault(); e.clipboardData.setData("text/plain", rangeText()); setNotice({ id: Date.now(), text: `Copied ${cellCount} ${cellCount === 1 ? "cell" : "cells"}` }); } function clearRange() { const cells = []; for (let r = top; r <= bottom; r++) for (let c = left; c <= right; c++) cells.push({ r, c, value: columns[c].type === "number" ? null : "" }); return writeCells(cells); } function onCut(e: ClipboardEvent) { if (editing) return; e.preventDefault(); e.clipboardData.setData("text/plain", rangeText()); clearRange(); setNotice({ id: Date.now(), text: `Cut ${cellCount} ${cellCount === 1 ? "cell" : "cells"}` }); } function onPaste(e: ClipboardEvent) { if (editing) return; e.preventDefault(); const text = e.clipboardData.getData("text/plain").replace(/\r/g, "").replace(/\n$/, ""); if (!text) return; const grid = text.split("\n").map((line) => line.split("\t")); const cells: { r: number; c: number; value: CellValue }[] = []; const single = grid.length === 1 && grid[0].length === 1; if (single && cellCount > 1) { for (let r = top; r <= bottom; r++) for (let c = left; c <= right; c++) cells.push({ r, c, value: parseCell(grid[0][0], columns[c]) }); } else { grid.forEach((line, dr) => line.forEach((raw, dc) => { const r = top + dr; const c = left + dc; if (r <= maxR && c <= maxC) cells.push({ r, c, value: parseCell(raw, columns[c]) }); }), ); const lastR = Math.min(maxR, top + grid.length - 1); const lastC = Math.min(maxC, left + Math.max(...grid.map((l) => l.length)) - 1); setAnchor({ r: top, c: left }); setFocus({ r: lastR, c: lastC }); } if (writeCells(cells)) setNotice({ id: Date.now(), text: `Pasted ${cells.length} ${cells.length === 1 ? "cell" : "cells"}` }); } function onKeyDown(e: KeyboardEvent) { if (editing || view.length === 0) return; setGlide(false); const mod = e.metaKey || e.ctrlKey; const key = e.key; if (mod && key.toLowerCase() === "z") { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return; } if (mod && key.toLowerCase() === "y") { e.preventDefault(); redo(); return; } if (mod && key.toLowerCase() === "a") { e.preventDefault(); setAnchor({ r: 0, c: 0 }); setFocus({ r: maxR, c: maxC }); return; } const moves: Record = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1], }; if (moves[key]) { e.preventDefault(); const [dr, dc] = moves[key]; const from = e.shiftKey ? focus : anchor; const jump = mod ? { r: dr ? (dr > 0 ? maxR : 0) : from.r, c: dc ? (dc > 0 ? maxC : 0) : from.c } : null; select(jump ?? { r: from.r + dr, c: from.c + dc }, e.shiftKey); return; } if (key === "Enter" || key === "F2") { e.preventDefault(); startEdit(); return; } if (key === "Backspace" || key === "Delete") { e.preventDefault(); clearRange(); return; } if (key === "Escape") { setFocus(anchor); return; } if (key.length === 1 && !mod && !e.altKey) { e.preventDefault(); startEdit(key); } } function cellFromPoint(x: number, y: number): Cell | null { const el = document.elementFromPoint(x, y)?.closest("[data-cell]"); if (!el || !gridRef.current?.contains(el)) return null; return { r: Number(el.dataset.r), c: Number(el.dataset.c) }; } function onPointerDown(e: PointerEvent) { if (e.button !== 0) return; setGlide(true); const target = e.target as HTMLElement; if (target.closest("input, button")) return; const rowHead = target.closest("[data-row-head]"); if (rowHead) { const r = Number(rowHead.dataset.rowHead); if (e.shiftKey) setFocus({ r, c: maxC }); else { setAnchor({ r, c: 0 }); setFocus({ r, c: maxC }); } e.preventDefault(); gridRef.current?.focus(); return; } const cell = cellFromPoint(e.clientX, e.clientY); if (!cell) return; e.preventDefault(); gridRef.current?.focus(); select(cell, e.shiftKey); dragging.current = true; e.currentTarget.setPointerCapture(e.pointerId); } function onPointerMove(e: PointerEvent) { if (!dragging.current) return; const cell = cellFromPoint(e.clientX, e.clientY); if (cell && (cell.r !== focus.r || cell.c !== focus.c)) setFocus(cell); } function onPointerUp(e: PointerEvent) { dragging.current = false; if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); } let count = 0; let sum = 0; let numeric = 0; const numericCols = new Set(); for (let r = top; r <= bottom; r++) { for (let c = left; c <= right; c++) { const v = view[r]?.[columns[c].key]; if (v === null || v === "" || v === undefined) continue; count++; if (typeof v === "number") { sum += v; numeric++; numericCols.add(c); } } } const statFmt = numericCols.size === 1 ? formatters[[...numericCols][0]] : new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }); const ref = cellCount === 1 ? `${colName(left)}${top + 1}` : `${colName(left)}${top + 1}:${colName(right)}${bottom + 1}`; const frameMotion = reduce || !glide ? { duration: 0 } : { type: "spring" as const, stiffness: 700, damping: 50 }; return (
{ref} {editing ? "Editing · Enter to save, Esc to cancel" : "Double-click or type to edit"}
sum + (col.width ?? 120), 0) }}> { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHasFocus(false); }} onCopy={onCopy} onCut={onCut} onDoubleClick={(e) => { // Pointer capture retargets clicks to the table, so find the cell from the pointer position. const cell = cellFromPoint(e.clientX, e.clientY); if (!cell || editing) return; const value = view[cell.r]?.[columns[cell.c].key] ?? null; select(cell, false); setEditing({ r: cell.r, c: cell.c, value: value === null ? "" : String(value), replace: false, session: ++session.current, }); }} onFocus={() => setHasFocus(true)} onKeyDown={onKeyDown} onPaste={onPaste} onPointerCancel={onPointerUp} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} ref={gridRef} role="grid" tabIndex={0} > {columns.map((col) => ( ))} {columns.map((col, c) => { const dir = sort?.key === col.key ? sort.dir : null; const SortIcon = dir === "asc" ? ArrowUpIcon : dir === "desc" ? ArrowDownIcon : ArrowUpDownIcon; return ( = left && c <= right && "text-foreground")} key={col.key} > ); })} {view.length === 0 && ( No rows yet. Paste from a spreadsheet to fill it. )} {view.map((row, r) => ( = top && r <= bottom && "bg-muted text-foreground", )} data-row-head={r} > {r + 1} {columns.map((col, c) => { const value = row[col.key] ?? null; const selected = inRange(r, c); const isEditing = editing?.r === r && editing.c === c; return ( 1 && "bg-info/8 dark:bg-info/12", )} data-c={c} data-cell="" data-r={r} id={cellId(r, c)} key={col.key} role="gridcell" > {display(value, c)} {isEditing && editing && ( finishEdit("none")} onChange={(e) => setEditing({ ...editing, value: e.target.value })} onFocus={(e) => { const input = e.currentTarget; if (editing.replace) input.setSelectionRange(input.value.length, input.value.length); else input.select(); }} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); finishEdit("down"); } else if (e.key === "Tab") { e.preventDefault(); finishEdit(e.shiftKey ? "left" : "right"); } else if (e.key === "Escape") { e.preventDefault(); finishEdit("none", true); } }} value={editing.value} /> )} ); })} ))}
{frame && ( )}
Count {count} {numeric > 0 && ( <> Sum {statFmt?.format(sum) ?? String(sum)} Avg{" "} {statFmt?.format(sum / numeric) ?? ""} )}
⌘C ⌘V ⌘Z
{notice && ( {notice.text} )}
); }