import * as React from "react"; import { ColumnDef, ColumnFiltersState, ColumnOrderState, ColumnPinningState, ColumnSizingState, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, Row, SortingState, Table as ReactTable, useReactTable, VisibilityState, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, ChevronUp, Columns, Inbox, Pin, PinOff, Save, Trash2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "./button"; import { EmptyState } from "./empty-state"; import { LoadingState } from "./loading-state"; /* ============================================================================ * DataTable v2 — TanStack Table + react-virtual + Radix dropdowns * * Features: * - Virtualized rows (auto-enables above `virtualizeAbove` row threshold) * - Sortable columns (asc → desc → unsorted) * - Column resize (drag handle) * - Column visibility toggle (DropdownMenu) * - Column pinning (left/right/unpin) via per-column dropdown * - Pagination footer (optional) * - Saved views (localStorage-backed) — keyed by `tableId` * * Re-export ColumnDef from @tanstack/react-table so consumers can type their * columns with full IntelliSense. * ========================================================================== */ export type { ColumnDef } from "@tanstack/react-table"; export type Density = "compact" | "cozy" | "comfortable"; const ROW_HEIGHT: Record = { compact: 32, cozy: 40, comfortable: 48, }; const CELL_PAD: Record = { compact: "px-3 py-1.5", cozy: "px-3 py-2", comfortable: "px-4 py-3", }; /* ============================================================ * Saved views — localStorage persistence * ========================================================== */ interface PersistedState { columnSizing: ColumnSizingState; columnVisibility: VisibilityState; columnPinning: ColumnPinningState; columnOrder: ColumnOrderState; sorting: SortingState; } interface SavedView { id: string; name: string; state: PersistedState; } interface SavedViewsConfig { views: SavedView[]; activeViewId: string | null; } const viewsStorageKey = (tableId: string) => `data-table:${tableId}:views`; const stateStorageKey = (tableId: string) => `data-table:${tableId}:state`; function readJSON(key: string): T | null { if (typeof window === "undefined") return null; try { const v = window.localStorage.getItem(key); return v ? (JSON.parse(v) as T) : null; } catch { return null; } } function writeJSON(key: string, value: unknown) { if (typeof window === "undefined") return; try { window.localStorage.setItem(key, JSON.stringify(value)); } catch { /* ignore quota */ } } /* ============================================================ * Main component * ========================================================== */ export interface DataTableProps { /** Unique id used to scope localStorage (saved views, column widths). */ tableId: string; data: T[] | undefined; columns: ColumnDef[]; loading?: boolean; loadingRows?: number; /** Loading variant — defaults to "table" */ loadingVariant?: "table" | "spinner"; /** Row stable id getter (default uses row index) */ getRowId?: (row: T, index: number) => string; density?: Density; onDensityChange?: (density: Density) => void; onRowClick?: (row: T, index: number) => void; /** Custom empty state */ empty?: React.ReactNode; /** Error state node (overrides empty when present) */ error?: React.ReactNode; /** Estimated container height — needed for virtualization. */ height?: number | string; /** Row count over which virtualization auto-enables. Default 50. */ virtualizeAbove?: number; /** Force virtualization on/off (overrides threshold) */ virtualize?: boolean; /** Enable pagination footer */ enablePagination?: boolean; pageSize?: number; pageSizeOptions?: number[]; /** * Server-side pagination — when true, the table will NOT slice rows itself. * Parent owns `pagination` state and supplies a fresh `data` page on change. * Must also pass `pageCount` (total pages from API) or `rowCount` (total rows). */ manualPagination?: boolean; pageCount?: number; rowCount?: number; /** Controlled pagination state — required when manualPagination is true */ paginationState?: PaginationState; onPaginationStateChange?: (state: PaginationState) => void; /** Column features (default all true) */ enableColumnResize?: boolean; enableColumnVisibility?: boolean; enableColumnPinning?: boolean; /** Enable saved views UI */ enableSavedViews?: boolean; /** Show density toggle in toolbar */ enableDensityToggle?: boolean; /** Custom toolbar nodes rendered on the left */ toolbarStart?: React.ReactNode; /** Custom toolbar nodes rendered on the right (before built-ins) */ toolbarEnd?: React.ReactNode; /** Row tone — applies a soft background tint */ rowToneAccessor?: ( row: T, ) => "success" | "warning" | "danger" | "info" | undefined; className?: string; } const TONE_BG: Record<"success" | "warning" | "danger" | "info", string> = { success: "bg-success-subtle/40", warning: "bg-warning-subtle/40", danger: "bg-danger-subtle/40", info: "bg-info-subtle/40", }; export function DataTable({ tableId, data, columns, loading, loadingRows = 8, loadingVariant = "table", getRowId, density: densityProp, onDensityChange, onRowClick, empty, error, height = 520, virtualizeAbove = 50, virtualize, enablePagination = false, pageSize: pageSizeProp = 50, pageSizeOptions = [25, 50, 100, 200], manualPagination = false, pageCount, rowCount, paginationState, onPaginationStateChange, enableColumnResize = true, enableColumnVisibility = true, enableColumnPinning = true, enableSavedViews = true, enableDensityToggle = true, toolbarStart, toolbarEnd, rowToneAccessor, className, }: DataTableProps) { /* ----------------------- Persisted state ----------------------- */ const persisted = React.useMemo>( () => readJSON(stateStorageKey(tableId)) ?? {}, [tableId], ); const [sorting, setSorting] = React.useState( persisted.sorting ?? [], ); const [columnVisibility, setColumnVisibility] = React.useState( persisted.columnVisibility ?? {}, ); const [columnPinning, setColumnPinning] = React.useState( persisted.columnPinning ?? { left: [], right: [] }, ); const [columnSizing, setColumnSizing] = React.useState( persisted.columnSizing ?? {}, ); const [columnOrder, setColumnOrder] = React.useState( persisted.columnOrder ?? [], ); const [columnFilters, setColumnFilters] = React.useState( [], ); const [internalPagination, setInternalPagination] = React.useState({ pageIndex: 0, pageSize: pageSizeProp, }); // Controlled when caller passes paginationState (typical for server-side) const pagination = paginationState ?? internalPagination; const setPagination = ( updater: PaginationState | ((prev: PaginationState) => PaginationState), ) => { const next = typeof updater === "function" ? updater(pagination) : updater; if (onPaginationStateChange) onPaginationStateChange(next); else setInternalPagination(next); }; // Uncontrolled internal density (when no parent control) const [internalDensity, setInternalDensity] = React.useState( densityProp ?? "cozy", ); const density = densityProp ?? internalDensity; const setDensity = (d: Density) => { if (onDensityChange) onDensityChange(d); else setInternalDensity(d); }; /* ----------------------- Persist on change ----------------------- */ React.useEffect(() => { writeJSON(stateStorageKey(tableId), { sorting, columnVisibility, columnPinning, columnSizing, columnOrder, }); }, [ tableId, sorting, columnVisibility, columnPinning, columnSizing, columnOrder, ]); /* ----------------------- Table instance ----------------------- */ const table = useReactTable({ data: data ?? [], columns, state: { sorting, columnVisibility, columnPinning, columnSizing, columnOrder, columnFilters, pagination: enablePagination ? pagination : undefined, }, getRowId, onSortingChange: setSorting, onColumnVisibilityChange: setColumnVisibility, onColumnPinningChange: setColumnPinning, onColumnSizingChange: setColumnSizing, onColumnOrderChange: setColumnOrder, onColumnFiltersChange: setColumnFilters, onPaginationChange: setPagination, columnResizeMode: "onChange", enableColumnResizing: enableColumnResize, enableHiding: enableColumnVisibility, enablePinning: enableColumnPinning, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: enablePagination && !manualPagination ? getPaginationRowModel() : undefined, manualPagination, pageCount: manualPagination && typeof pageCount === "number" ? pageCount : undefined, rowCount: manualPagination && typeof rowCount === "number" ? rowCount : undefined, }); const allRows = table.getRowModel().rows; const usingPaginationView = enablePagination && !manualPagination; const shouldVirtualize = virtualize ?? (!usingPaginationView && allRows.length > virtualizeAbove); /* ----------------------- Layout helpers ----------------------- */ const cellPadClass = CELL_PAD[density]; const rowH = ROW_HEIGHT[density]; /* ----------------------- Render ----------------------- */ return (
{error ? (
{error}
) : loading ? (
) : allRows.length === 0 ? ( empty ?? ( ) ) : ( )}
{enablePagination && !loading && allRows.length > 0 && ( )}
); } /* ============================================================ * Toolbar — saved views + column visibility + density * ========================================================== */ function Toolbar({ table, tableId, density, setDensity, enableSavedViews, enableColumnVisibility, enableDensityToggle, toolbarStart, toolbarEnd, sorting, setSorting, columnVisibility, setColumnVisibility, columnPinning, setColumnPinning, columnSizing, setColumnSizing, columnOrder, setColumnOrder, }: { table: ReactTable; tableId: string; density: Density; setDensity: (d: Density) => void; enableSavedViews: boolean; enableColumnVisibility: boolean; enableDensityToggle: boolean; toolbarStart?: React.ReactNode; toolbarEnd?: React.ReactNode; sorting: SortingState; setSorting: React.Dispatch>; columnVisibility: VisibilityState; setColumnVisibility: React.Dispatch>; columnPinning: ColumnPinningState; setColumnPinning: React.Dispatch>; columnSizing: ColumnSizingState; setColumnSizing: React.Dispatch>; columnOrder: ColumnOrderState; setColumnOrder: React.Dispatch>; }) { const currentState: PersistedState = { sorting, columnVisibility, columnPinning, columnSizing, columnOrder, }; return (
{toolbarStart}
{toolbarEnd} {enableSavedViews && ( { setSorting(s.sorting); setColumnVisibility(s.columnVisibility); setColumnPinning(s.columnPinning); setColumnSizing(s.columnSizing); setColumnOrder(s.columnOrder); }} /> )} {enableColumnVisibility && } {enableDensityToggle && ( )}
); } /* ============================================================ * ColumnsMenu — toggle visibility + pin * ========================================================== */ function ColumnsMenu({ table }: { table: ReactTable }) { const cols = table .getAllLeafColumns() .filter((c) => c.getCanHide() || c.getCanPin()); return (
Toggle columns
{cols.map((col) => { const isPinned = col.getIsPinned(); const headerLabel = typeof col.columnDef.header === "string" ? col.columnDef.header : col.id; return (
{col.getCanPin() && ( )}
); })}
); } /* ============================================================ * DensityMenu * ========================================================== */ function DensityMenu({ density, setDensity, }: { density: Density; setDensity: (d: Density) => void; }) { return ( {(["compact", "cozy", "comfortable"] as const).map((d) => ( setDensity(d)} className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent/60 data-[highlighted]:bg-accent/60" > {d} {density === d && } ))} ); } /* ============================================================ * ViewsMenu — saved views * ========================================================== */ function ViewsMenu({ tableId, currentState, applyState, }: { tableId: string; currentState: PersistedState; applyState: (s: PersistedState) => void; }) { const [config, setConfig] = React.useState(() => readJSON(viewsStorageKey(tableId)) ?? { views: [], activeViewId: null, }, ); const persist = (next: SavedViewsConfig) => { setConfig(next); writeJSON(viewsStorageKey(tableId), next); }; const activeView = config.views.find((v) => v.id === config.activeViewId) ?? null; const saveCurrent = () => { const name = window.prompt("Name for this view"); if (!name?.trim()) return; const id = `${Date.now().toString(36)}`; const next: SavedViewsConfig = { views: [...config.views, { id, name: name.trim(), state: currentState }], activeViewId: id, }; persist(next); }; const apply = (view: SavedView) => { applyState(view.state); persist({ ...config, activeViewId: view.id }); }; const remove = (id: string) => { if (!confirm("Delete this view?")) return; persist({ views: config.views.filter((v) => v.id !== id), activeViewId: config.activeViewId === id ? null : config.activeViewId, }); }; return (
Saved views
{config.views.length === 0 && (
No saved views yet
)} {config.views.map((view) => (
))} Save current as new view…
); } /* ============================================================ * TableShell — header + body with optional virtualization * ========================================================== */ function TableShell({ table, allRows, cellPadClass, rowHeight, height, shouldVirtualize, onRowClick, rowToneAccessor, enableColumnResize, }: { table: ReactTable; allRows: Row[]; cellPadClass: string; rowHeight: number; height: number | string; shouldVirtualize: boolean; onRowClick?: (row: T, index: number) => void; rowToneAccessor?: (row: T) => "success" | "warning" | "danger" | "info" | undefined; enableColumnResize: boolean; }) { const parentRef = React.useRef(null); const virtualizer = useVirtualizer({ count: allRows.length, getScrollElement: () => parentRef.current, estimateSize: () => rowHeight, overscan: 12, enabled: shouldVirtualize, }); const totalSize = shouldVirtualize ? virtualizer.getTotalSize() : "auto"; const items = shouldVirtualize ? virtualizer.getVirtualItems() : null; return (
{table.getVisibleLeafColumns().map((col) => ( ))} {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const col = header.column; const isSorted = col.getIsSorted(); const canSort = col.getCanSort(); const align = (col.columnDef.meta as { align?: "left" | "right" | "center" } | undefined) ?.align ?? "left"; const alignClass = align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left"; const pinned = col.getIsPinned(); return ( ); })} ))} {(shouldVirtualize ? items! : allRows.map((_, i) => ({ index: i, start: 0, size: 0, key: i }))).map( (virtualRow) => { const row = allRows[virtualRow.index]; if (!row) return null; const tone = rowToneAccessor?.(row.original); return ( onRowClick(row.original, virtualRow.index) : undefined} > {row.getVisibleCells().map((cell) => { const col = cell.column; const align = (col.columnDef.meta as { align?: "left" | "right" | "center" } | undefined) ?.align ?? "left"; const alignClass = align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left"; const pinned = col.getIsPinned(); return ( ); })} ); }, )}
{header.isPlaceholder ? null : ( {flexRender(col.columnDef.header, header.getContext())} {canSort && ( isSorted === "asc" ? ( ) : isSorted === "desc" ? ( ) : ( ) )} )} {enableColumnResize && col.getCanResize() && ( e.stopPropagation()} className={cn( "absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none", "bg-transparent hover:bg-brand-500/40", header.column.getIsResizing() && "bg-brand-500", )} role="separator" aria-orientation="vertical" /> )}
{flexRender(col.columnDef.cell, cell.getContext())}
); } /* ============================================================ * Pinning helpers — sticky positioning for pinned columns * ========================================================== */ function getPinStyle( column: ReactTable["getColumn"] extends (...a: any) => infer R ? R : never, kind: "header" | "cell", ): React.CSSProperties { if (!column) return {}; const pinned = column.getIsPinned(); if (!pinned) return {}; const offset = pinned === "left" ? column.getStart("left") : column.getAfter("right"); return { position: "sticky", [pinned === "left" ? "left" : "right"]: `${offset}px`, zIndex: kind === "header" ? 3 : 1, }; } function getPinnedWidth(table: ReactTable, side: "left" | "right"): number { return table .getAllLeafColumns() .filter((c) => c.getIsPinned() === side && c.getIsVisible()) .reduce((sum, c) => sum + c.getSize(), 0); } /* ============================================================ * Pagination * ========================================================== */ function Pagination({ table, pageSizeOptions, }: { table: ReactTable; pageSizeOptions: number[]; }) { const { pageIndex, pageSize } = table.getState().pagination; const pageCount = table.getPageCount(); const totalRows = table.getFilteredRowModel().rows.length; const start = pageIndex * pageSize + 1; const end = Math.min(start + pageSize - 1, totalRows); return (
{totalRows === 0 ? 0 : `${start}–${end}`} of {totalRows}
{pageIndex + 1} / {Math.max(pageCount, 1)}
); }