'use client'; import * as React from 'react'; import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon } from '@/icons'; import { cn } from '@/lib/utils'; import { focusRing } from '@/lib/cva-presets'; export type SortDirection = 'asc' | 'desc'; export interface DataTableSort { key: React.Key; direction: SortDirection; } export interface DataTableColumn { /** Required — it identifies the column for sorting and for React keys. */ key: React.Key; title: React.ReactNode; /** Path to the value in the record. Omit it and supply `render` instead. */ dataIndex?: string | number | (string | number)[]; render?: (value: unknown, record: RecordType, index: number) => React.ReactNode; width?: number | string; minWidth?: number; align?: 'left' | 'center' | 'right'; /** Pin the column while the rest scrolls horizontally. Needs `scroll.x`. */ fixed?: 'left' | 'right'; /** Truncate overflowing text instead of wrapping. */ ellipsis?: boolean; className?: string; /** Adds a sort control to this column's header. */ sortable?: boolean; /** Comparator for local sorting. Defaults to a numeric-aware compare. */ sorter?: (a: RecordType, b: RecordType) => number; } export interface DataTableExpandable { expandedRowRender?: (record: RecordType, index: number) => React.ReactNode; rowExpandable?: (record: RecordType) => boolean; defaultExpandAllRows?: boolean; expandedRowKeys?: readonly React.Key[]; onExpand?: (expanded: boolean, record: RecordType) => void; } export interface DataTableProps { columns: DataTableColumn[]; data: readonly RecordType[]; /** Field name or function producing a stable key per row. */ rowKey?: string | ((record: RecordType, index?: number) => React.Key); className?: string; rowClassName?: string | ((record: RecordType, index: number) => string); /** `{ y }` makes the header sticky; `{ x }` enables horizontal scrolling. */ scroll?: { x?: number | string | true; y?: number | string }; /** Keep the header visible while the table's container scrolls. */ sticky?: boolean; expandable?: DataTableExpandable; emptyText?: React.ReactNode; /** * Controlled sort. Pass it together with `onSortChange` when something else * sorts the data (a server, a query hook). Leave both off and the table * sorts its own rows. */ sort?: DataTableSort | null; onSortChange?: (sort: DataTableSort | null) => void; onRow?: (record: RecordType, index?: number) => React.HTMLAttributes; id?: string; 'aria-label'?: string; } /** Reads a column's value out of a record for the default comparator. */ const readCell = (record: RecordType, column: DataTableColumn) => { if (column.dataIndex === undefined) return undefined; const segments = Array.isArray(column.dataIndex) ? column.dataIndex : [column.dataIndex]; return segments.reduce( (value, segment) => value == null ? value : (value as Record)[segment], record ); }; const defaultCompare = (a: unknown, b: unknown) => { if (a == null && b == null) return 0; if (a == null) return -1; if (b == null) return 1; if (typeof a === 'number' && typeof b === 'number') return a - b; return String(a).localeCompare(String(b), undefined, { numeric: true }); }; const alignClass = { left: 'text-left', center: 'text-center', right: 'text-right' } as const; /** Marker for the extra column that carries the expand toggle. */ const EXPAND_KEY = '__expand__'; /** * Table for structured data. * * A plain `` covers most of it; what this adds is the part that makes a * data grid usable at scale — a header that stays put, columns pinned while the * rest scrolls sideways, and expandable rows. * * Sorting works either way round. Omit `sort`/`onSortChange` and the table * reorders its own rows; pass both and the header becomes a controlled reporter * of intent, which is what you want when a server does the sorting. * * ```tsx * * ``` */ function DataTable({ columns, data, rowKey = 'key', className, rowClassName, scroll, sticky, expandable, emptyText = 'No results.', sort: controlledSort, onSortChange, onRow, id, ...props }: DataTableProps) { const scrollRef = React.useRef(null); const tableRef = React.useRef(null); const [uncontrolledSort, setUncontrolledSort] = React.useState(null); const isSortControlled = controlledSort !== undefined; const sort = isSortControlled ? controlledSort : uncontrolledSort; const keyOf = React.useCallback( (record: RecordType, index: number): React.Key => typeof rowKey === 'function' ? rowKey(record, index) : ((record as Record)[rowKey] ?? index), [rowKey] ); const [uncontrolledExpanded, setUncontrolledExpanded] = React.useState>(() => expandable?.defaultExpandAllRows ? new Set(data.map((record, index) => keyOf(record, index))) : new Set() ); const expandedKeys = expandable?.expandedRowKeys ? new Set(expandable.expandedRowKeys) : uncontrolledExpanded; const toggleSort = (key: React.Key) => { // asc → desc → unsorted, so a mis-click is always one more click from neutral. const next: DataTableSort | null = sort?.key !== key ? { key, direction: 'asc' } : sort.direction === 'asc' ? { key, direction: 'desc' } : null; if (!isSortControlled) setUncontrolledSort(next); onSortChange?.(next); }; const toggleExpanded = (key: React.Key, record: RecordType) => { const willExpand = !expandedKeys.has(key); if (!expandable?.expandedRowKeys) { setUncontrolledExpanded((current) => { const next = new Set(current); if (willExpand) next.add(key); else next.delete(key); return next; }); } expandable?.onExpand?.(willExpand, record); }; /* Only sort locally when the caller has not taken control of it. */ const rows = React.useMemo(() => { if (isSortControlled || !sort) return data; const column = columns.find((entry) => entry.key === sort.key); if (!column) return data; const compare = column.sorter ?? ((a: RecordType, b: RecordType) => defaultCompare(readCell(a, column), readCell(b, column))); const sorted = [...data].sort(compare); return sort.direction === 'desc' ? sorted.reverse() : sorted; }, [columns, data, isSortControlled, sort]); /* The expand toggle rides in its own leading column, pinned alongside the first column when that one is pinned — otherwise it would scroll away from the row it belongs to. */ const leafColumns: DataTableColumn[] = expandable?.expandedRowRender ? [ { key: EXPAND_KEY, title: '', width: 48, align: 'center', fixed: columns.some((column) => column.fixed === 'left') ? 'left' : undefined, }, ...columns, ] : columns; const lastFixedLeft = leafColumns.reduce( (last, column, index) => (column.fixed === 'left' ? index : last), -1 ); const firstFixedRight = leafColumns.findIndex((column) => column.fixed === 'right'); /** * Pinned columns need a pixel offset that only exists once the browser has * laid the table out. Writing it as a custom property straight onto the table * keeps it out of React state — there is nothing to re-render, and measuring * into state would cost a second pass on every resize and scroll. */ const measure = React.useCallback(() => { const table = tableRef.current; const viewport = scrollRef.current; if (!table || !viewport) return; const headers = Array.from(table.querySelectorAll('thead th')); let left = 0; headers.forEach((header, index) => { if (header.dataset.fixed !== 'left') return; table.style.setProperty(`--ui-table-fixed-${index}`, `${left}px`); left += header.offsetWidth; }); let right = 0; for (let index = headers.length - 1; index >= 0; index -= 1) { if (headers[index].dataset.fixed !== 'right') continue; table.style.setProperty(`--ui-table-fixed-${index}`, `${right}px`); right += headers[index].offsetWidth; } /* The shadows say "there is more this way", so they only appear while there is somewhere left to scroll. */ viewport.toggleAttribute('data-ping-left', viewport.scrollLeft > 0); viewport.toggleAttribute( 'data-ping-right', Math.ceil(viewport.scrollLeft + viewport.clientWidth) < viewport.scrollWidth ); }, []); React.useEffect(() => { measure(); const table = tableRef.current; if (!table || typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver(measure); observer.observe(table); return () => observer.disconnect(); }, [measure, leafColumns.length, rows]); const headerSticky = Boolean(scroll?.y || sticky); const pinClasses = (column: DataTableColumn, index: number) => column.fixed ? cn( 'sticky z-20', /* The pinned edge fades the content sliding under it — a gradient rather than a shadow, so the colour stays a semantic token. */ 'after:pointer-events-none after:absolute after:inset-y-0 after:w-5 after:opacity-0', 'after:transition-opacity after:duration-(--ui-duration-base)', index === lastFixedLeft && 'after:left-full after:bg-linear-to-r after:from-overlay/20 after:to-transparent group-data-ping-left/table:after:opacity-100', index === firstFixedRight && 'after:right-full after:bg-linear-to-l after:from-overlay/20 after:to-transparent group-data-ping-right/table:after:opacity-100' ) : undefined; const pinStyle = (column: DataTableColumn, index: number) => column.fixed ? { [column.fixed]: `var(--ui-table-fixed-${index})` } : undefined; const cellPadding = 'px-3 py-2.5 align-middle'; return (
{leafColumns.map((column) => ( ))} {leafColumns.map((column, index) => { const active = sort?.key === column.key; const SortIcon = !active ? ChevronsUpDownIcon : sort.direction === 'asc' ? ArrowUpIcon : ArrowDownIcon; return ( ); })} {rows.length === 0 ? ( ) : ( rows.map((record, index) => { const key = keyOf(record, index); const renderExpanded = expandable?.expandedRowRender; const canExpand = renderExpanded ? (expandable.rowExpandable?.(record) ?? true) : false; const expanded = expandedKeys.has(key); const rowProps = onRow?.(record, index); return ( {leafColumns.map((column, columnIndex) => { const isExpandCell = column.key === EXPAND_KEY; const value = readCell(record, column); return ( ); })} {renderExpanded && expanded && canExpand ? ( ) : null} ); }) )}
{column.sortable ? ( ) : ( column.title )}
{emptyText}
{isExpandCell ? ( canExpand ? ( ) : null ) : column.render ? ( column.render(value, record, index) ) : ( (value as React.ReactNode) )}
{renderExpanded(record, index)}
); } export { DataTable };