uicapsule
blockspreadsheetsource: ok · refreshed
Spreadsheet
npx shadcn add "https://uicapsule.com/r/spreadsheet.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightui-primitives@base-ui/react
iconslucide-react
formszod
Files14 · source cached 10/7/2026, 7:48:23 PM
/components/editable-cell.tsx
"use client";
import type { ChangeEvent, KeyboardEvent } from "react";
import { Input } from "./ui";
import type { SpreadsheetRow } from "../lib/spreadsheet-store";
import { useSpreadsheetStore } from "../lib/spreadsheet-store";
interface EditableCellProps {
row: { original: SpreadsheetRow };
column: { id: string };
}
export const EditableCell = ({ row, column }: EditableCellProps) => {
const editingCell = useSpreadsheetStore((state) => state.editingCell);
const setEditingCell = useSpreadsheetStore((state) => state.setEditingCell);
const updateData = useSpreadsheetStore((state) => state.updateData);
// Cells are cleared to `undefined` by Delete/Backspace; coerce so <Input> stays controlled.
const value = row.original[column.id] ?? "";
const isEditing = editingCell?.rowId === row.original.id && editingCell?.columnId === column.id;
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
updateData(row.original.id, column.id, e.target.value);
};
const onBlur = () => {
setEditingCell(null);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === "Escape") {
setEditingCell(null);
}
e.stopPropagation();
};
if (isEditing) {
return (
<Input
value={value}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
className="h-8 w-full rounded-none border-0 bg-transparent p-1 text-sm focus-visible:ring-blue-600/20 dark:focus-visible:ring-blue-400/30"
autoFocus
/>
);
}
return (
<div className="h-8 w-full p-1">
<span className="text-(--foreground) block truncate text-sm" title={value}>
{value}
</span>
</div>
);
};
/components/resize-handle.tsx
"use client";
import { useRef, useState } from "react";
import type { MouseEvent as ReactMouseEvent, RefObject, Ref } from "react";
import { cn } from "cn";
import { MIN_COLUMN_WIDTH } from "../lib/spreadsheet-utils";
/** Half the handle's hit width, so the drag line sits centred on it. */
const DRAG_LINE_OFFSET = 2;
interface ResizeHandleProps {
columnId: string;
width: number;
handleColumnResize: (columnId: string, width: number) => void;
tableContainerRef: RefObject<HTMLDivElement | null>;
dragLineRef: RefObject<HTMLDivElement | null>;
setDragLineVisible: (visible: boolean) => void;
className?: string;
ref?: Ref<HTMLDivElement>;
}
export const ResizeHandle = ({
columnId,
width,
handleColumnResize,
tableContainerRef,
dragLineRef,
setDragLineVisible,
className,
ref,
}: ResizeHandleProps) => {
const [isResizing, setIsResizing] = useState(false);
const startXRef = useRef(0);
const startWidthRef = useRef(0);
const columnStartXRef = useRef(0);
const currentMouseXRef = useRef(0);
const handleMouseDown = (e: ReactMouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setDragLineVisible(true);
startXRef.current = e.clientX;
// Seed with the press position so a click with no drag commits the current width.
currentMouseXRef.current = e.clientX;
startWidthRef.current = width;
const headerElement = e.currentTarget.closest("[data-column-id]");
if (headerElement instanceof HTMLElement) {
const rect = headerElement.getBoundingClientRect();
const tableRect = tableContainerRef.current?.getBoundingClientRect();
if (tableRect) {
columnStartXRef.current = rect.left - tableRect.left;
const initialDragX = columnStartXRef.current + startWidthRef.current - DRAG_LINE_OFFSET;
if (dragLineRef.current) {
dragLineRef.current.style.left = `${initialDragX}px`;
}
}
}
const widthForClientX = (clientX: number) =>
Math.max(MIN_COLUMN_WIDTH, startWidthRef.current + (clientX - startXRef.current));
const handleMouseMove = (event: MouseEvent) => {
currentMouseXRef.current = event.clientX;
const dragX = columnStartXRef.current + widthForClientX(event.clientX) - DRAG_LINE_OFFSET;
if (dragLineRef.current) {
dragLineRef.current.style.left = `${dragX}px`;
}
};
const handleMouseUp = () => {
setIsResizing(false);
setDragLineVisible(false);
handleColumnResize(columnId, widthForClientX(currentMouseXRef.current));
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
return (
<div
ref={ref}
role="presentation"
className={cn(
"hover:bg-(--primary)/50 dark:hover:bg-(--primary)/60 absolute top-0 right-0 h-full w-1 cursor-col-resize transition-colors",
isResizing && "bg-(--primary) dark:bg-(--primary)/80",
className,
)}
onMouseDown={handleMouseDown}
/>
);
};
/components/spreadsheet.tsx
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { HTMLAttributes, ReactNode, Ref } from "react";
import { cn } from "cn";
import { flexRender, useTable } from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { SpreadsheetRow } from "../lib/spreadsheet-store";
import type { ColumnInfo, NavigationMap, SpreadsheetFeatures } from "../lib/spreadsheet-utils";
import type { ColumnDef } from "@tanstack/react-table";
import { useSpreadsheetStore } from "../lib/spreadsheet-store";
import {
createNavigationMap,
getColumnSizeVars,
getRowCells,
MIN_COLUMN_WIDTH,
spreadsheetFeatures,
} from "../lib/spreadsheet-utils";
import { useSpreadsheetHandlers } from "../lib/use-spreadsheet-handlers";
import { TableBody } from "./table-body";
import { ResizeHandle } from "./resize-handle";
export interface SpreadsheetProps extends HTMLAttributes<HTMLDivElement> {
columns: ColumnDef<SpreadsheetFeatures, SpreadsheetRow>[];
showRowNumbers?: boolean;
renderRowNumber?: (rowIndex: number) => ReactNode;
renderRowActions?: (row: SpreadsheetRow, rowIndex: number) => ReactNode;
ref?: Ref<HTMLDivElement>;
}
export const Spreadsheet = ({
className,
style,
columns,
showRowNumbers = true,
renderRowNumber,
renderRowActions,
ref,
...props
}: SpreadsheetProps) => {
"use no memo";
// TanStack Virtual exposes mutable measurements that React Compiler cannot memoize.
const data = useSpreadsheetStore((state) => state.data);
const selectedCells = useSpreadsheetStore((state) => state.selectedCells);
const isDragging = useSpreadsheetStore((state) => state.dragStartCell !== null);
const columnWidths = useSpreadsheetStore((state) => state.columnWidths);
const setColumnWidths = useSpreadsheetStore((state) => state.setColumnWidths);
const dragLineVisible = useSpreadsheetStore((state) => state.dragLineVisible);
const setDragLineVisible = useSpreadsheetStore((state) => state.setDragLineVisible);
const tableContainerRef = useRef<HTMLDivElement>(null);
const dragLineRef = useRef<HTMLDivElement>(null);
const table = useTable({
columns,
data,
features: spreadsheetFeatures,
});
const leafColumns = table.getAllLeafColumns();
const columnMeta = useMemo<ColumnInfo[]>(
() => leafColumns.map((column) => ({ id: column.id })),
[leafColumns],
);
const navigationMap = useMemo<Map<string, NavigationMap>>(
() => createNavigationMap(data, columnMeta),
[data, columnMeta],
);
const columnSizeVars = useMemo(() => getColumnSizeVars(columnWidths), [columnWidths]);
const { handleMouseDown, handleMouseMove, handleMouseUp, handleKeyDown } = useSpreadsheetHandlers(
{
columns: columnMeta,
navigationMap,
},
);
const handleColumnResize = useCallback(
(columnId: string, width: number) => {
setColumnWidths((prev) => ({
...prev,
[columnId]: Math.max(MIN_COLUMN_WIDTH, width),
}));
},
[setColumnWidths],
);
const getRowCellsHelper = useCallback(
(rowId: string) => getRowCells(rowId, columnMeta),
[columnMeta],
);
// Mouseup should be global to handle drag ending outside the spreadsheet
useEffect(() => {
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mouseup", handleMouseUp);
};
}, [handleMouseUp]);
// oxlint-disable-next-line react/incompatible-library -- This component and its table body opt out of memoization for mutable virtualizer measurements.
const rowVirtualizer = useVirtualizer({
count: table.getRowModel().rows.length,
estimateSize: () => 36,
getScrollElement: () => tableContainerRef.current,
overscan: 10,
});
return (
<div
ref={ref}
role="grid"
className={cn("relative flex flex-col", isDragging && "select-none", className)}
style={{ ...columnSizeVars, ...style }}
tabIndex={0}
onKeyDown={handleKeyDown}
{...props}
>
<div
ref={dragLineRef}
className={cn(
"bg-(--primary) pointer-events-none absolute top-0 bottom-0 z-50 w-0.5 transition-opacity",
dragLineVisible ? "opacity-100" : "opacity-0",
)}
/>
<div className="bg-(--muted) border-(--border) sticky top-0 z-1 flex border-y">
{showRowNumbers && (
<div className="border-(--border) bg-(--muted) text-(--muted-foreground) flex h-10 w-12 shrink-0 items-center justify-center border-r text-xs font-medium">
#
</div>
)}
{(table.getHeaderGroups()[0]?.headers ?? []).map((header) => (
<div
key={header.id}
role="presentation"
data-column-id={header.column.id}
data-column-header
className="border-(--border) bg-(--muted) text-(--muted-foreground) relative flex h-10 shrink-0 cursor-default items-center border-r pl-1 text-left text-xs font-medium transition-colors"
style={{
width: `calc(var(--col-${header.column.id}-size) * 1px)`,
}}
onMouseDown={(e) => handleMouseDown(e, "", header.column.id)}
onMouseMove={(e) => handleMouseMove(e, "", header.column.id)}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
<ResizeHandle
columnId={header.column.id}
width={columnWidths[header.column.id] ?? 0}
handleColumnResize={handleColumnResize}
tableContainerRef={tableContainerRef}
dragLineRef={dragLineRef}
setDragLineVisible={setDragLineVisible}
/>
</div>
))}
{showRowNumbers && (
<div className="border-(--border) bg-(--muted) text-(--muted-foreground) flex h-10 w-12 shrink-0 items-center justify-center border-r text-xs font-medium" />
)}
</div>
<div ref={tableContainerRef} className="flex-1">
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}}
>
<TableBody
virtualItems={rowVirtualizer.getVirtualItems()}
table={table}
selectedCells={selectedCells}
getRowCells={getRowCellsHelper}
handleMouseDown={handleMouseDown}
handleMouseMove={handleMouseMove}
showRowNumbers={showRowNumbers}
renderRowNumber={renderRowNumber}
renderRowActions={renderRowActions}
/>
</div>
</div>
</div>
);
};
/components/status-bar.tsx
"use client";
import type { ComponentProps } from "react";
import { cn } from "cn";
import { useSpreadsheetStore } from "../lib/spreadsheet-store";
export type StatusBarProps = ComponentProps<"div">;
export const StatusBar = ({ className, ...props }: StatusBarProps) => (
<div
className={cn(
"text-(--muted-foreground) bg-(--background) dark:bg-(--background)/95 sticky bottom-0 flex items-center justify-between border-t p-2 text-xs",
className,
)}
{...props}
/>
);
export type StatusBarSectionProps = ComponentProps<"div">;
export const StatusBarSection = ({ className, ...props }: StatusBarSectionProps) => (
<div className={cn("flex flex-1 items-center gap-2", className)} {...props} />
);
export const StatusBarMessage = () => {
const editingCell = useSpreadsheetStore((state) => state.editingCell);
const selectedCells = useSpreadsheetStore((state) => state.selectedCells);
const data = useSpreadsheetStore((state) => state.data);
const selectedCellsCount = selectedCells.size;
if (editingCell) {
const rowIndex = data.findIndex((row) => row.id === editingCell.rowId);
const displayRowNumber = rowIndex === -1 ? "?" : rowIndex + 1;
return (
<span>
Editing row {displayRowNumber}, column {editingCell.columnId}
</span>
);
}
if (selectedCellsCount > 0) {
return (
<span>
Selected: {selectedCellsCount} cell
{selectedCellsCount > 1 ? "s" : ""}
</span>
);
}
return <span>Click a cell to select</span>;
};
export const StatusBarSummary = () => {
const data = useSpreadsheetStore((state) => state.data);
// Rows carry an `id` alongside their cell values; only the latter are columns.
const columnCount = Object.keys(data[0] ?? {}).filter((key) => key !== "id").length;
return (
<span>
{data.length} rows × {columnCount} columns
</span>
);
};
/components/table-body.tsx
"use client";
import type { MouseEvent, ReactNode } from "react";
import { cn } from "cn";
import { flexRender } from "@tanstack/react-table";
import type { SpreadsheetRow } from "../lib/spreadsheet-store";
import type { SpreadsheetFeatures } from "../lib/spreadsheet-utils";
import type { Table } from "@tanstack/react-table";
import type { VirtualItem } from "@tanstack/react-virtual";
interface TableBodyProps {
virtualItems: VirtualItem[];
table: Table<SpreadsheetFeatures, SpreadsheetRow>;
selectedCells: Set<string>;
getRowCells: (rowId: string) => string[];
handleMouseDown: (e: MouseEvent, rowId: string, columnId: string) => void;
handleMouseMove: (e: MouseEvent, rowId: string, columnId: string) => void;
showRowNumbers?: boolean;
renderRowNumber?: (rowIndex: number) => ReactNode;
renderRowActions?: (row: SpreadsheetRow, rowIndex: number) => ReactNode;
}
export const TableBody = ({
virtualItems,
table,
selectedCells,
getRowCells,
handleMouseDown,
handleMouseMove,
showRowNumbers = true,
renderRowNumber,
renderRowActions,
}: TableBodyProps) => (
<>
{virtualItems.map((virtualRow) => {
const row = table.getRowModel().rows[virtualRow.index];
const rowIndex = virtualRow.index;
const rowId = row?.original?.id;
if (!rowId) {
return null;
}
const rowCells = getRowCells(rowId);
const isRowSelected = rowCells.every((cell) => selectedCells.has(cell));
return (
<div
key={row.id}
className={cn(
"hover:bg-(--muted)/30 dark:hover:bg-(--muted)/20 absolute top-0 left-0 w-full transition-colors",
isRowSelected && "bg-(--muted)/50 dark:bg-(--muted)/40",
)}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div className="flex h-full">
{showRowNumbers && (
<div
role="presentation"
data-row-number
className={cn(
"border-(--border) bg-(--muted)/30 text-(--muted-foreground) hover:bg-(--muted)/50 dark:hover:bg-(--muted)/40 flex h-9 w-12 shrink-0 cursor-default items-center justify-center border-r border-b font-mono text-xs transition-colors",
isRowSelected && "bg-(--muted) dark:bg-(--muted)/80",
)}
onMouseDown={(e) => handleMouseDown(e, rowId, "")}
onMouseMove={(e) => handleMouseMove(e, rowId, "")}
>
{renderRowNumber ? renderRowNumber(rowIndex) : rowIndex + 1}
</div>
)}
{row.getVisibleCells().map((cell) => {
const cellKey = `${rowId}:${cell.column.id}`;
const isCellSelected = selectedCells.has(cellKey);
return (
<div
key={cell.id}
role="presentation"
className={cn(
"border-(--border) relative flex h-9 shrink-0 cursor-default items-center border-r border-b transition-colors",
isCellSelected && "bg-blue-50 dark:bg-blue-950/50",
)}
style={{
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
}}
onMouseDown={(e) => handleMouseDown(e, rowId, cell.column.id)}
onMouseMove={(e) => handleMouseMove(e, rowId, cell.column.id)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
);
})}
{renderRowActions && (
<div className="border-(--border) flex h-9 shrink-0 items-center justify-center border-b">
{renderRowActions(row.original, rowIndex)}
</div>
)}
</div>
</div>
);
})}
</>
);
/components/toolbar.tsx
"use client";
import type { ComponentProps } from "react";
import { Button } from "./ui";
import { cn } from "cn";
export type ToolbarProps = ComponentProps<"div">;
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
<div className={cn("flex items-center gap-2 p-2", className)} {...props} />
);
export type ToolbarButtonProps = ComponentProps<typeof Button>;
export const ToolbarButton = (props: ToolbarButtonProps) => (
<Button size="sm" variant="outline" {...props} />
);
/components/ui.tsx
"use client";
import type { ComponentProps } from "react";
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { Menu } from "@base-ui/react/menu";
import { cn } from "cn";
import "../spreadsheet.css";
type Styled<Props> = Omit<Props, "className"> & { className?: string };
export const Button = ({
className,
variant = "outline",
size = "sm",
...props
}: Styled<ButtonPrimitive.Props> & {
variant?: "outline" | "ghost";
size?: "sm" | "icon";
}) => (
<ButtonPrimitive
className={cn(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-full text-sm font-medium outline-none transition-colors hover:bg-(--muted) focus-visible:ring-2 focus-visible:ring-(--ring) disabled:opacity-50",
variant === "outline" && "border border-(--border) bg-(--background) shadow-xs",
size === "sm" ? "h-8 px-2.5" : "size-9",
className,
)}
{...props}
/>
);
export const Input = ({ className, ...props }: ComponentProps<"input">) => (
<input
className={cn(
"h-9 w-full rounded-md border border-(--input) bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-(--ring)",
className,
)}
{...props}
/>
);
export const DropdownMenu = Menu.Root;
export const DropdownMenuTrigger = Menu.Trigger;
export const DropdownMenuContent = ({
className,
align = "start",
...props
}: Styled<Menu.Popup.Props> & Pick<Menu.Positioner.Props, "align">) => (
<Menu.Portal>
<Menu.Positioner align={align} sideOffset={4} className="spreadsheet isolate z-50">
<Menu.Popup
className={cn(
"min-w-32 rounded-md bg-(--popover) p-1 text-(--popover-foreground) shadow-md ring-1 ring-(--foreground)/10 outline-none",
className,
)}
{...props}
/>
</Menu.Positioner>
</Menu.Portal>
);
export const DropdownMenuItem = ({ className, ...props }: Styled<Menu.Item.Props>) => (
<Menu.Item
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-highlighted:bg-(--accent) data-disabled:opacity-50",
className,
)}
{...props}
/>
);
/lib/spreadsheet-store.tsx
"use client";
import { createContext, use, useMemo } from "react";
import type { ReactNode } from "react";
import { createStore, useStore } from "zustand";
import "../spreadsheet.css";
/** A cell holds the text typed into it, or `undefined` once cleared by Delete/Backspace. */
export type CellValue = string | undefined;
export interface SpreadsheetRow {
id: string;
[key: string]: CellValue;
}
export interface SpreadsheetStore {
data: SpreadsheetRow[];
selectedCells: Set<string>;
setSelectedCells: (update: (prev: Set<string>) => Set<string>) => void;
editingCell: { rowId: string; columnId: string } | null;
setEditingCell: (cell: { rowId: string; columnId: string } | null) => void;
dragStartCell: { rowId: string; columnId: string } | null;
setDragStartCell: (cell: { rowId: string; columnId: string } | null) => void;
columnWidths: Record<string, number>;
setColumnWidths: (update: (prev: Record<string, number>) => Record<string, number>) => void;
dragLineVisible: boolean;
setDragLineVisible: (visible: boolean) => void;
updateData: (rowId: string, columnId: string, value: CellValue) => void;
updateSelectedCellsData: (value?: CellValue) => void;
addRow: (onCreateRow?: (rowIndex: number) => SpreadsheetRow) => void;
deleteRow: (rowId: string) => void;
}
const createSpreadsheetStore = (
initialData: SpreadsheetRow[],
initialColumnWidths: Record<string, number>,
) =>
createStore<SpreadsheetStore>((set, get) => ({
addRow: (onCreateRow) => {
set((state) => {
const nextRow = onCreateRow?.(state.data.length) ?? { id: crypto.randomUUID() };
return { data: [...state.data, nextRow] };
});
},
columnWidths: initialColumnWidths,
data: initialData,
deleteRow: (rowId) => {
set((state) => ({
data: state.data.filter((row) => row.id !== rowId),
dragStartCell: state.dragStartCell?.rowId === rowId ? null : state.dragStartCell,
editingCell: state.editingCell?.rowId === rowId ? null : state.editingCell,
selectedCells: new Set(
[...state.selectedCells].filter((key) => !key.startsWith(`${rowId}:`)),
),
}));
},
dragLineVisible: false,
dragStartCell: null,
editingCell: null,
selectedCells: new Set(),
setColumnWidths: (widths) => {
set((state) => ({
columnWidths: widths(state.columnWidths),
}));
},
setDragLineVisible: (visible) => set({ dragLineVisible: visible }),
setDragStartCell: (cell) => set({ dragStartCell: cell }),
setEditingCell: (cell) => set({ editingCell: cell }),
setSelectedCells: (cells) => {
set((state) => ({
selectedCells: cells(state.selectedCells),
}));
},
updateData: (rowId, columnId, value) => {
set((state) => ({
data: state.data.map((row) => {
if (row.id === rowId) {
return { ...row, [columnId]: value };
}
return row;
}),
}));
},
updateSelectedCellsData: (value) => {
if (get().selectedCells.size === 0) {
return;
}
set((state) => {
const newData = [...state.data];
for (const cellKey of state.selectedCells) {
const [rowId, columnId] = cellKey.split(":");
if (!rowId || !columnId) {
continue;
}
const rowIndex = newData.findIndex((row) => row.id === rowId);
const currentRow = newData[rowIndex];
if (!currentRow) {
continue;
}
newData[rowIndex] = { ...currentRow, [columnId]: value };
}
return { data: newData };
});
},
}));
type SpreadsheetStoreApi = ReturnType<typeof createSpreadsheetStore>;
const SpreadsheetContext = createContext<SpreadsheetStoreApi | null>(null);
export const SpreadsheetProvider = ({
initialData,
initialColumnWidths,
children,
}: {
initialData: SpreadsheetRow[];
initialColumnWidths: Record<string, number>;
children: ReactNode;
}) => {
const store = useMemo(
() => createSpreadsheetStore(initialData, initialColumnWidths),
[initialData, initialColumnWidths],
);
return (
<SpreadsheetContext value={store}>
<div className="spreadsheet">{children}</div>
</SpreadsheetContext>
);
};
export const useSpreadsheetApi = () => {
const store = use(SpreadsheetContext);
if (!store) {
throw new Error("Spreadsheet components require SpreadsheetProvider");
}
return store;
};
export const useSpreadsheetStore = <T,>(selector: (state: SpreadsheetStore) => T): T =>
useStore(useSpreadsheetApi(), selector);
/lib/spreadsheet-utils.ts
import { columnVisibilityFeature, tableFeatures } from "@tanstack/react-table";
import type { SpreadsheetRow } from "./spreadsheet-store";
/**
* Features every spreadsheet table is built on. Features are opt-in in
* react-table v9, and the body renders through `row.getVisibleCells()`, which
* column visibility provides. Declared here rather than in `spreadsheet.tsx` so
* the body and cell components can type against it without an import cycle.
*/
export const spreadsheetFeatures = tableFeatures({ columnVisibilityFeature });
export type SpreadsheetFeatures = typeof spreadsheetFeatures;
/** Columns never resize below this, both while dragging and once committed. */
export const MIN_COLUMN_WIDTH = 60;
export interface CellPosition {
rowId: string;
columnId: string;
}
export interface ColumnInfo {
id: string;
}
export interface NavigationMap {
up: string | null;
down: string | null;
left: string | null;
right: string | null;
tab: string | null;
}
export type NavigationDirection = keyof NavigationMap;
export const getRowCells = (rowId: string, columns: ColumnInfo[]): string[] =>
columns.map((col) => `${rowId}:${col.id}`);
export const getColumnCells = (columnId: string, data: SpreadsheetRow[]): string[] =>
data.map((row) => `${row.id}:${columnId}`);
export const getRangeCells = (
startRowId: string,
startColId: string,
endRowId: string,
endColId: string,
columns: ColumnInfo[],
data: SpreadsheetRow[],
): string[] => {
const columnIds = columns.map((col) => col.id);
const startColIndex = columnIds.indexOf(startColId);
const endColIndex = columnIds.indexOf(endColId);
const minColIndex = Math.min(startColIndex, endColIndex);
const maxColIndex = Math.max(startColIndex, endColIndex);
const startRowIndex = data.findIndex((row) => row.id === startRowId);
const endRowIndex = data.findIndex((row) => row.id === endRowId);
if (startRowIndex === -1 || endRowIndex === -1 || startColIndex === -1 || endColIndex === -1) {
return [];
}
const minRowIndex = Math.min(startRowIndex, endRowIndex);
const maxRowIndex = Math.max(startRowIndex, endRowIndex);
const cells: string[] = [];
for (let rowIndex = minRowIndex; rowIndex <= maxRowIndex; rowIndex += 1) {
const row = data[rowIndex];
if (!row) {
continue;
}
for (let colIndex = minColIndex; colIndex <= maxColIndex; colIndex += 1) {
const colId = columnIds[colIndex];
if (!colId) {
continue;
}
cells.push(`${row.id}:${colId}`);
}
}
return cells;
};
export const toggleRowSelection = (
rowId: string,
selectedCells: Set<string>,
columns: ColumnInfo[],
): Set<string> => {
const rowCells = getRowCells(rowId, columns);
const isRowFullySelected = rowCells.every((cell) => selectedCells.has(cell));
const newSelectedCells = new Set(selectedCells);
for (const cell of rowCells) {
if (isRowFullySelected) {
newSelectedCells.delete(cell);
} else {
newSelectedCells.add(cell);
}
}
return newSelectedCells;
};
export const toggleColumnSelection = (
columnId: string,
selectedCells: Set<string>,
data: SpreadsheetRow[],
): Set<string> => {
const columnCells = getColumnCells(columnId, data);
const isColumnFullySelected = columnCells.every((cell) => selectedCells.has(cell));
const newSelectedCells = new Set(selectedCells);
for (const cell of columnCells) {
if (isColumnFullySelected) {
newSelectedCells.delete(cell);
} else {
newSelectedCells.add(cell);
}
}
return newSelectedCells;
};
export const toggleCellSelection = (cellKey: string, selectedCells: Set<string>): Set<string> => {
const newSelectedCells = new Set(selectedCells);
if (newSelectedCells.has(cellKey)) {
newSelectedCells.delete(cellKey);
} else {
newSelectedCells.add(cellKey);
}
return newSelectedCells;
};
export const getFirstSelectedCell = (selectedCells: Set<string>): CellPosition | null => {
const [firstSelectedCell] = [...selectedCells];
if (!firstSelectedCell) {
return null;
}
const [rowId, columnId] = firstSelectedCell.split(":");
if (!rowId || !columnId) {
return null;
}
return { columnId, rowId };
};
export const getColumnSizeVars = (columnWidths: Record<string, number>) => {
const colSizes: { [key: string]: number } = {};
for (const [columnId, width] of Object.entries(columnWidths)) {
colSizes[`--col-${columnId}-size`] = width;
}
return colSizes;
};
export const isWithinDataAttribute = (target: EventTarget | null, attribute: string): boolean =>
target instanceof HTMLElement && !!target.closest(`[data-${attribute}]`);
export const shouldAllowEditing = (selectedCells: Set<string>, cellKey: string): boolean =>
selectedCells.has(cellKey) && selectedCells.size === 1;
export const createNavigationMap = (
data: SpreadsheetRow[],
columns: ColumnInfo[],
): Map<string, NavigationMap> => {
const navigationMap = new Map<string, NavigationMap>();
const columnIds = columns.map((col) => col.id);
for (const [rowIndex, row] of data.entries()) {
for (const [colIndex, columnId] of columnIds.entries()) {
const cellKey = `${row.id}:${columnId}`;
const prevRowId = data[rowIndex - 1]?.id;
const nextRowId = data[rowIndex + 1]?.id;
const prevColId = columnIds[colIndex - 1];
const nextColId = columnIds[colIndex + 1];
const [firstColId] = columnIds;
const navigation: NavigationMap = {
down: nextRowId ? `${nextRowId}:${columnId}` : null,
left: prevColId ? `${row.id}:${prevColId}` : null,
right: nextColId ? `${row.id}:${nextColId}` : null,
tab: null,
up: prevRowId ? `${prevRowId}:${columnId}` : null,
};
if (nextColId) {
navigation.tab = `${row.id}:${nextColId}`;
} else if (nextRowId && firstColId) {
navigation.tab = `${nextRowId}:${firstColId}`;
}
navigationMap.set(cellKey, navigation);
}
}
return navigationMap;
};
export const getNextCellPositionFromMap = (
cellKey: string,
direction: NavigationDirection,
navigationMap: Map<string, NavigationMap>,
): CellPosition | null => {
const navigation = navigationMap.get(cellKey);
if (!navigation) {
return null;
}
const nextCellKey = navigation[direction];
if (!nextCellKey) {
return null;
}
const [rowId, columnId] = nextCellKey.split(":");
if (!rowId || !columnId) {
return null;
}
return { columnId, rowId };
};
/lib/use-ai-enrichment.ts
import { useCallback, useMemo } from "react";
import { useChat } from "@ai-sdk/react";
import { StaticChatTransport } from "@loremllm/transport";
import type { StoreApi } from "zustand";
import type { SpreadsheetStore } from "./spreadsheet-store";
import type { UIMessage } from "@ai-sdk/react";
import { useSpreadsheetApi } from "./spreadsheet-store";
type CustomUIMessage = UIMessage<
unknown,
{
updateCell: {
rowId: string;
columnId: string;
value: string;
};
}
>;
// The random index is always in range; the `?? list[0]` only satisfies
// `noUncheckedIndexedAccess`, which the non-empty tuple keeps well-typed.
const pick = <T>(list: readonly [T, ...T[]]): T =>
list[Math.floor(Math.random() * list.length)] ?? list[0];
const fakeData = new Map<string, () => string>([
[
"firstName",
() => pick(["John", "Jane", "Michael", "Sarah", "David", "Emily", "James", "Emma"]),
],
[
"lastName",
() => pick(["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis"]),
],
[
"email",
() => {
const domain = pick(["example.com", "demo.org", "test.io"]);
return `${Math.random().toString(36).slice(2, 15)}@${domain}`;
},
],
[
"company",
() =>
pick([
"Acme Corp",
"Tech Solutions",
"Global Industries",
"Digital Ventures",
"Innovation Labs",
"Future Systems",
]),
],
[
"role",
() =>
pick([
"Software Engineer",
"Product Manager",
"Data Scientist",
"Designer",
"Marketing Director",
"Sales Executive",
]),
],
]);
const createTransport = (storeApi: StoreApi<SpreadsheetStore>) =>
new StaticChatTransport<CustomUIMessage>({
chunkDelayMs: [400, 1400],
async *mockResponse() {
const store = storeApi.getState();
const { selectedCells } = store;
const cells = [...selectedCells];
for (const cellKey of cells) {
const [rowId, columnId] = cellKey.split(":");
if (!rowId || !columnId) {
continue;
}
const generateValue = fakeData.get(columnId);
if (!generateValue) {
continue;
}
const value = generateValue();
store.updateData(rowId, columnId, "Generating...");
yield {
data: { columnId, rowId, value },
type: "data-updateCell",
};
}
},
});
export const useAiEnrichment = () => {
const storeApi = useSpreadsheetApi();
const transport = useMemo(() => createTransport(storeApi), [storeApi]);
const { sendMessage, status } = useChat<CustomUIMessage>({
onData: (dataPart) => {
if (dataPart.type === "data-updateCell") {
const { rowId, columnId, value } = dataPart.data;
const store = storeApi.getState();
store.updateData(rowId, columnId, value);
}
},
transport,
});
const handleEnrich = useCallback(() => {
void sendMessage({ text: "enrich" });
}, [sendMessage]);
const aiGenerating = status === "streaming" || status === "submitted";
return {
aiGenerating,
handleEnrich,
};
};
/lib/use-spreadsheet-handlers.ts
import { useCallback } from "react";
import type { KeyboardEvent, MouseEvent } from "react";
import type { ColumnInfo, NavigationDirection, NavigationMap } from "./spreadsheet-utils";
import { useSpreadsheetStore } from "./spreadsheet-store";
import {
getColumnCells,
getFirstSelectedCell,
getNextCellPositionFromMap,
getRangeCells,
getRowCells,
isWithinDataAttribute,
shouldAllowEditing,
toggleCellSelection,
toggleColumnSelection,
toggleRowSelection,
} from "./spreadsheet-utils";
interface UseSpreadsheetHandlersProps {
columns: ColumnInfo[];
navigationMap: Map<string, NavigationMap>;
}
/** Keys that move the single-cell selection, mapped to their direction in the navigation map. */
const NAVIGATION_KEYS = new Map<string, NavigationDirection>([
["ArrowUp", "up"],
["ArrowDown", "down"],
["ArrowLeft", "left"],
["ArrowRight", "right"],
["Tab", "tab"],
]);
export const useSpreadsheetHandlers = ({ columns, navigationMap }: UseSpreadsheetHandlersProps) => {
const data = useSpreadsheetStore((state) => state.data);
const selectedCells = useSpreadsheetStore((state) => state.selectedCells);
const setSelectedCells = useSpreadsheetStore((state) => state.setSelectedCells);
const editingCell = useSpreadsheetStore((state) => state.editingCell);
const setEditingCell = useSpreadsheetStore((state) => state.setEditingCell);
const dragStartCell = useSpreadsheetStore((state) => state.dragStartCell);
const setDragStartCell = useSpreadsheetStore((state) => state.setDragStartCell);
const updateSelectedCellsData = useSpreadsheetStore((state) => state.updateSelectedCellsData);
/** A multi-cell selection has no single edit target, so editing is cancelled. Returns `selection` for chaining. */
const exitEditIfMultiple = useCallback(
(selection: Set<string>) => {
if (selection.size > 1 && editingCell) {
setEditingCell(null);
}
return selection;
},
[editingCell, setEditingCell],
);
const handleMouseDown = useCallback(
(e: MouseEvent, rowId: string, columnId: string) => {
if (e.button !== 0) {
return;
}
const cellKey = `${rowId}:${columnId}`;
if (isWithinDataAttribute(e.target, "row-number")) {
if (e.ctrlKey || e.metaKey) {
setSelectedCells((currentSelectedCells) =>
exitEditIfMultiple(toggleRowSelection(rowId, currentSelectedCells, columns)),
);
} else {
setSelectedCells(() => exitEditIfMultiple(new Set(getRowCells(rowId, columns))));
}
return;
}
if (isWithinDataAttribute(e.target, "column-header")) {
if (e.ctrlKey || e.metaKey) {
setSelectedCells((currentSelectedCells) =>
exitEditIfMultiple(toggleColumnSelection(columnId, currentSelectedCells, data)),
);
} else {
setSelectedCells(() => exitEditIfMultiple(new Set(getColumnCells(columnId, data))));
}
return;
}
if (e.ctrlKey || e.metaKey) {
setSelectedCells((currentSelectedCells) =>
exitEditIfMultiple(toggleCellSelection(cellKey, currentSelectedCells)),
);
} else if (e.shiftKey) {
setSelectedCells((currentSelectedCells) => {
if (currentSelectedCells.size === 0) {
return new Set([cellKey]);
}
const [firstSelectedCell] = [...currentSelectedCells];
if (!firstSelectedCell) {
return new Set([cellKey]);
}
const [firstRowId, firstCol] = firstSelectedCell.split(":");
if (!firstRowId || !firstCol) {
return new Set([cellKey]);
}
const rangeCells = getRangeCells(firstRowId, firstCol, rowId, columnId, columns, data);
return exitEditIfMultiple(new Set(rangeCells));
});
} else {
const isCurrentlyEditing =
editingCell?.rowId === rowId && editingCell?.columnId === columnId;
const isCurrentlySelected = selectedCells.has(cellKey) && selectedCells.size === 1;
if (isCurrentlyEditing) {
setTimeout(() => setEditingCell(null), 0);
} else if (isCurrentlySelected) {
setTimeout(() => setEditingCell({ columnId, rowId }), 0);
} else {
setSelectedCells(() => new Set([cellKey]));
}
}
setDragStartCell({ columnId, rowId });
},
[
selectedCells,
columns,
data,
editingCell,
exitEditIfMultiple,
setSelectedCells,
setEditingCell,
setDragStartCell,
],
);
const handleMouseMove = useCallback(
(e: MouseEvent, rowId: string, columnId: string) => {
if (!dragStartCell) {
return;
}
const rangeCells = getRangeCells(
dragStartCell.rowId,
dragStartCell.columnId,
rowId,
columnId,
columns,
data,
);
setSelectedCells(() => exitEditIfMultiple(new Set(rangeCells)));
},
[dragStartCell, columns, data, exitEditIfMultiple, setSelectedCells],
);
const handleMouseUp = useCallback(() => {
setDragStartCell(null);
}, [setDragStartCell]);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const firstSelectedCell = getFirstSelectedCell(selectedCells);
if (!firstSelectedCell) {
return;
}
const { rowId, columnId } = firstSelectedCell;
const { activeElement } = document;
if (activeElement && activeElement.tagName === "INPUT") {
return;
}
const direction = NAVIGATION_KEYS.get(e.key);
if (direction) {
e.preventDefault();
const nextPosition = getNextCellPositionFromMap(
`${rowId}:${columnId}`,
direction,
navigationMap,
);
if (nextPosition) {
setSelectedCells(() => new Set([`${nextPosition.rowId}:${nextPosition.columnId}`]));
}
return;
}
switch (e.key) {
case "Enter": {
e.preventDefault();
if (editingCell?.rowId === rowId && editingCell?.columnId === columnId) {
setEditingCell(null);
} else if (shouldAllowEditing(selectedCells, `${rowId}:${columnId}`)) {
setEditingCell({ columnId, rowId });
}
break;
}
case "Escape": {
e.preventDefault();
if (editingCell) {
setEditingCell(null);
} else {
setSelectedCells(() => new Set());
}
break;
}
case "Delete":
case "Backspace": {
e.preventDefault();
if (selectedCells.size > 0) {
updateSelectedCellsData();
}
break;
}
default: {
break;
}
}
},
[
selectedCells,
editingCell,
setEditingCell,
setSelectedCells,
updateSelectedCellsData,
navigationMap,
],
);
return {
handleKeyDown,
handleMouseDown,
handleMouseMove,
handleMouseUp,
};
};
/preview.tsx
"use client";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./components/ui";
import { MoreVerticalIcon, Plus, Sparkles, TrashIcon } from "lucide-react";
import type { SpreadsheetProps } from "./components/spreadsheet";
import { EditableCell } from "./components/editable-cell";
import { Spreadsheet } from "./components/spreadsheet";
import {
StatusBar,
StatusBarMessage,
StatusBarSection,
StatusBarSummary,
} from "./components/status-bar";
import { Toolbar, ToolbarButton } from "./components/toolbar";
import type { SpreadsheetRow } from "./lib/spreadsheet-store";
import { SpreadsheetProvider, useSpreadsheetStore } from "./lib/spreadsheet-store";
import { useAiEnrichment } from "./lib/use-ai-enrichment";
interface Person extends SpreadsheetRow {
id: string;
linkedinUrl: string;
firstName: string;
lastName: string;
email: string;
company: string;
role: string;
}
const generateSamplePeople = (count: number): Person[] =>
Array.from({ length: count }, (_, i) => ({
company: "",
email: "",
firstName: "",
id: `${i + 1}`,
lastName: "",
linkedinUrl: `https://linkedin.com/in/user-${i + 1}`,
role: "",
}));
const initialData = generateSamplePeople(30);
const initialColumnWidths = {
company: 150,
email: 180,
firstName: 120,
lastName: 120,
linkedinUrl: 250,
role: 150,
};
const RowActions = ({ row }: { row: SpreadsheetRow }) => {
const deleteRow = useSpreadsheetStore((state) => state.deleteRow);
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Row actions"
render={<Button size="icon" variant="ghost" className="h-8 w-8" />}
>
<MoreVerticalIcon className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => deleteRow(row.id)} className="text-(--destructive)">
<TrashIcon className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
const ToolbarButtons = () => {
const { handleEnrich } = useAiEnrichment();
const addRow = useSpreadsheetStore((state) => state.addRow);
return (
<>
<ToolbarButton
onClick={() =>
addRow(() => ({
company: "",
email: "",
firstName: "",
id: crypto.randomUUID(),
lastName: "",
linkedinUrl: "",
role: "",
}))
}
>
<Plus className="size-4" />
Add Row
</ToolbarButton>
<ToolbarButton onClick={handleEnrich}>
<Sparkles className="size-4" />
Enrich
</ToolbarButton>
</>
);
};
const columns: SpreadsheetProps["columns"] = [
{ accessorKey: "linkedinUrl", cell: EditableCell, header: "LinkedIn URL" },
{ accessorKey: "firstName", cell: EditableCell, header: "First Name" },
{ accessorKey: "lastName", cell: EditableCell, header: "Last Name" },
{ accessorKey: "email", cell: EditableCell, header: "Email" },
{ accessorKey: "company", cell: EditableCell, header: "Company" },
{ accessorKey: "role", cell: EditableCell, header: "Role" },
];
const Preview = () => (
<SpreadsheetProvider initialData={initialData} initialColumnWidths={initialColumnWidths}>
<Toolbar>
<ToolbarButtons />
</Toolbar>
<Spreadsheet
columns={columns}
showRowNumbers
renderRowActions={(row) => <RowActions row={row} />}
/>
<StatusBar>
<StatusBarSection>
<StatusBarMessage />
</StatusBarSection>
<StatusBarSection className="justify-end">
<StatusBarSummary />
</StatusBarSection>
</StatusBar>
</SpreadsheetProvider>
);
export default Preview;
/spreadsheet.css
.spreadsheet {
--background: #fff;
--foreground: #18181b;
--primary: #f66b2c;
--primary-foreground: #fff;
--secondary: #f4f4f5;
--secondary-foreground: #27272a;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f4f4f5;
--accent-foreground: #27272a;
--popover: #fff;
--popover-foreground: #18181b;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #a1a1aa;
--destructive: #dc2626;
color: var(--foreground);
}
.dark .spreadsheet {
--background: #18181b;
--foreground: #fafafa;
--primary-foreground: #27272a;
--secondary: #27272a;
--secondary-foreground: #fafafa;
--muted: #27272a;
--muted-foreground: #a1a1aa;
--accent: #27272a;
--accent-foreground: #fafafa;
--popover: #27272a;
--popover-foreground: #fafafa;
--border: #ffffff1a;
--input: #ffffff26;
--ring: #71717a;
--destructive: #f87171;
}
@layer base {
:where(.spreadsheet, .spreadsheet *) {
border-color: var(--border);
}
}
/styles.d.ts
declare module "*.css" {}