"use client"; import * as React from "react"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; import { IconArrowDown, IconArrowUp, IconSelector } from "@tabler/icons-react"; import { cn } from "../lib/utils"; import { transitions } from "../lib/motion"; /** * DataGrid: the table for real record sets. Sortable columns, row * selection with a floating bulk-action bar, roving keyboard focus * (↑/↓ move, Space selects, Enter opens), a sticky header, skeleton and * empty states, and windowed rendering past a few hundred rows so 50k * records scroll smoothly. */ export interface DataGridColumn { key: string; header: React.ReactNode; cell: (row: T) => React.ReactNode; /** Enables sorting on this column. */ sortValue?: (row: T) => string | number | boolean | null | undefined; align?: "start" | "end" | "center"; /** CSS width, e.g. "12rem" or "30%". */ width?: string; /** Hide below the `sm` breakpoint. */ hideOnMobile?: boolean; } export interface DataGridSort { key: string; direction: "asc" | "desc"; } export interface DataGridProps { rows: readonly T[]; columns: ReadonlyArray>; getRowId: (row: T) => string; label: string; onRowOpen?: (row: T) => void; selectable?: boolean; selectedIds?: ReadonlySet; onSelectionChange?: (ids: Set) => void; /** Rendered in a floating bar while rows are selected. */ selectionActions?: (ids: Set, clear: () => void) => React.ReactNode; sort?: DataGridSort | null; onSortChange?: (sort: DataGridSort | null) => void; loading?: boolean; empty?: React.ReactNode; density?: "comfortable" | "compact"; /** Scroll container max height; the header stays pinned. */ maxHeight?: string; className?: string; } const WINDOW_THRESHOLD = 300; const OVERSCAN = 12; const INTERACTIVE = "button, a[href], input, select, textarea, label, [role='button'], [role='checkbox'], [role='menuitem'], [contenteditable='true']"; /** True when the event started on a control inside the row, not the row. */ function fromInteractiveChild(event: React.SyntheticEvent): boolean { const target = event.target as HTMLElement | null; const control = target?.closest(INTERACTIVE); return Boolean(control && control !== event.currentTarget && event.currentTarget.contains(control)); } function compare(a: unknown, b: unknown): number { if (a === b) return 0; if (a === null || a === undefined) return 1; if (b === null || b === undefined) return -1; if (typeof a === "number" && typeof b === "number") return a - b; if (typeof a === "boolean" && typeof b === "boolean") return Number(a) - Number(b); return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" }); } function Checkbox({ checked, indeterminate, onChange, label, }: { checked: boolean; indeterminate?: boolean; onChange: (checked: boolean) => void; label: string; }) { return ( ); } export function DataGrid({ rows, columns, getRowId, label, onRowOpen, selectable = false, selectedIds, onSelectionChange, selectionActions, sort: controlledSort, onSortChange, loading = false, empty, density = "comfortable", maxHeight = "70vh", className, }: DataGridProps) { const reduceMotion = useReducedMotion(); const [localSort, setLocalSort] = React.useState(null); const sort = controlledSort === undefined ? localSort : controlledSort; const [localSelection, setLocalSelection] = React.useState>(new Set()); const selection = selectedIds ? new Set(selectedIds) : localSelection; const [focusIndex, setFocusIndex] = React.useState(0); const scrollRef = React.useRef(null); const rowRefs = React.useRef(new Map()); const [scrollTop, setScrollTop] = React.useState(0); const [viewport, setViewport] = React.useState(600); const rowHeight = density === "compact" ? 36 : 46; const sorted = React.useMemo(() => { if (!sort) return rows; const column = columns.find((candidate) => candidate.key === sort.key); if (!column?.sortValue) return rows; const direction = sort.direction === "asc" ? 1 : -1; return [...rows].sort((a, b) => compare(column.sortValue!(a), column.sortValue!(b)) * direction); }, [rows, columns, sort]); const setSort = (next: DataGridSort | null) => { if (controlledSort === undefined) setLocalSort(next); onSortChange?.(next); }; const setSelection = (next: Set) => { if (!selectedIds) setLocalSelection(next); onSelectionChange?.(next); }; const toggleRow = (id: string) => { const next = new Set(selection); if (next.has(id)) next.delete(id); else next.add(id); setSelection(next); }; const allIds = sorted.map(getRowId); const selectedCount = allIds.filter((id) => selection.has(id)).length; const windowed = sorted.length > WINDOW_THRESHOLD; React.useEffect(() => { const element = scrollRef.current; if (!element || !windowed) return; setViewport(element.clientHeight); const observer = new ResizeObserver(() => setViewport(element.clientHeight)); observer.observe(element); return () => observer.disconnect(); }, [windowed]); const start = windowed ? Math.max(0, Math.floor(scrollTop / rowHeight) - OVERSCAN) : 0; const end = windowed ? Math.min(sorted.length, Math.ceil((scrollTop + viewport) / rowHeight) + OVERSCAN) : sorted.length; const visible = sorted.slice(start, end); const animateLayout = !reduceMotion && sorted.length <= 120; const focusRow = (index: number) => { const clamped = Math.max(0, Math.min(sorted.length - 1, index)); setFocusIndex(clamped); const element = scrollRef.current; if (element && windowed) { const top = clamped * rowHeight; if (top < element.scrollTop) element.scrollTop = top; else if (top + rowHeight > element.scrollTop + element.clientHeight - 40) { element.scrollTop = top + rowHeight - element.clientHeight + 40; } } requestAnimationFrame(() => rowRefs.current.get(clamped)?.focus()); }; const cellPadding = density === "compact" ? "px-3 py-1.5" : "px-4 py-2.5"; return (
setScrollTop(event.currentTarget.scrollTop)} className="relative overflow-auto rounded-2xl border border-border/60 bg-card" style={{ maxHeight }} > !column.hideOnMobile).length * 7 + (selectable ? 2.5 : 0)}rem` }} > {selectable ? ( ) : null} {columns.map((column) => { const active = sort?.key === column.key; const ariaSort = active ? (sort.direction === "asc" ? "ascending" : "descending") : "none"; return ( ); })} {loading ? Array.from({ length: 6 }, (_, index) => ( {selectable ? ))} )) : null} {!loading && windowed && start > 0 ? ( ) : null} {!loading ? visible.map((row, offset) => { const index = start + offset; const id = getRowId(row); const selected = selection.has(id); return ( { if (element) rowRefs.current.set(index, element); else rowRefs.current.delete(index); }} layout={animateLayout ? "position" : false} transition={transitions.enter} aria-rowindex={index + 2} aria-selected={selectable ? selected : undefined} tabIndex={index === focusIndex ? 0 : -1} onFocus={() => setFocusIndex(index)} onClick={(event) => { // A button, link, or checkbox inside a cell is its own action. if (fromInteractiveChild(event)) return; onRowOpen?.(row); }} onKeyDown={(event) => { // Row keys apply only while the row itself has focus. if (event.target !== event.currentTarget) return; if (event.key === "ArrowDown") { event.preventDefault(); focusRow(index + 1); } else if (event.key === "ArrowUp") { event.preventDefault(); focusRow(index - 1); } else if (event.key === "Home") { event.preventDefault(); focusRow(0); } else if (event.key === "End") { event.preventDefault(); focusRow(sorted.length - 1); } else if (event.key === " " && selectable) { event.preventDefault(); toggleRow(id); } else if (event.key === "Enter" && onRowOpen) { event.preventDefault(); onRowOpen(row); } }} className={cn( "group/row outline-none transition-colors duration-100", onRowOpen && "cursor-pointer", selected ? "bg-action/[0.06]" : "hover:bg-muted/40 focus-visible:bg-muted/40", )} {...(windowed ? { style: { height: rowHeight } } : {})} > {selectable ? ( ) : null} {columns.map((column, columnIndex) => ( ))} ); }) : null} {!loading && windowed && end < sorted.length ? ( ) : null}
0 && selectedCount === allIds.length} indeterminate={selectedCount > 0 && selectedCount < allIds.length} onChange={(checked) => setSelection(checked ? new Set(allIds) : new Set())} /> {column.sortValue ? ( ) : ( column.header )}
: null} {columns.map((column, columnIndex) => (
{!loading && sorted.length === 0 ? (
{empty ?? ( <>

Nothing to show

Records appear here as they are added.

)}
) : null}
{selectable && selectedCount > 0 && selectionActions ? ( {selectedCount} selected ) : null}
); }