import * as React from 'react'; import { clsx } from 'clsx'; import { flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, type Column, type ColumnDef, type ColumnOrderState, type ColumnPinningState, type Row, type SortingState, } from '@tanstack/react-table'; import { useVirtualizer } from '@tanstack/react-virtual'; import type { DataTableProps } from './types.js'; const SELECT_COL_ID = '__select__'; const densityRowCls: Record['density']>, string> = { compact: 'px-3 py-1 text-xs', comfortable: 'px-4 py-2.5 text-sm', }; /** Sticky-left offsets for pinned columns. */ function pinnedStyle(column: Column): React.CSSProperties { const isPinned = column.getIsPinned(); if (isPinned !== 'left') return {}; return { position: 'sticky', left: column.getStart('left'), zIndex: 1, background: 'var(--bl-surface-card, #fff)', }; } /** * `` — a token-themed table powered by TanStack Table v8 (the * current stable; the roadmap's "v9" predates a stable v9 release) and * TanStack Virtual. Opt into features via props; everything is keyboard- and * screen-reader-friendly. */ export function DataTable({ columns, data, enableSorting = true, enableFilter = true, filterPlaceholder = 'Filter…', enablePagination = true, pageSize = 10, enableRowSelection = false, renderBulkActions, enableColumnResizing = false, enableColumnReorder = false, enableColumnPinning = false, density = 'comfortable', getRowId, emptyState, ariaLabel = 'Data table', className, virtualized = false, rowHeight = 40, maxBodyHeight = 480, }: DataTableProps) { const [sorting, setSorting] = React.useState([]); const [globalFilter, setGlobalFilter] = React.useState(''); const [columnOrder, setColumnOrder] = React.useState([]); const [columnPinning, setColumnPinning] = React.useState({}); const dragColRef = React.useRef(null); const resolvedColumns = React.useMemo[]>(() => { if (!enableRowSelection) return columns; const selectionColumn: ColumnDef = { id: SELECT_COL_ID, size: 44, enableSorting: false, enableResizing: false, header: ({ table }) => ( { if (el) el.indeterminate = table.getIsSomeRowsSelected(); }} onChange={table.getToggleAllRowsSelectedHandler()} /> ), cell: ({ row }) => ( ), }; return [selectionColumn, ...columns]; }, [columns, enableRowSelection]); const table = useReactTable({ data, columns: resolvedColumns, state: { sorting, globalFilter, columnOrder, columnPinning }, onSortingChange: setSorting, onGlobalFilterChange: setGlobalFilter, onColumnOrderChange: setColumnOrder, onColumnPinningChange: setColumnPinning, getRowId, enableSorting, enableRowSelection, enableColumnResizing, columnResizeMode: 'onChange', getCoreRowModel: getCoreRowModel(), getSortedRowModel: enableSorting ? getSortedRowModel() : undefined, getFilteredRowModel: enableFilter ? getFilteredRowModel() : undefined, getPaginationRowModel: enablePagination && !virtualized ? getPaginationRowModel() : undefined, initialState: { pagination: { pageSize } }, }); const rows = table.getRowModel().rows; const selectedRows = table.getSelectedRowModel().rows.map((r: Row) => r.original); const rowCellCls = densityRowCls[density]; // ── Virtualiser (only when virtualized) ── const scrollRef = React.useRef(null); const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollRef.current, estimateSize: () => rowHeight, overscan: 8, enabled: virtualized, // Seed the viewport so rows render before the scroll element is measured // (SSR + headless environments report a zero-size rect otherwise). initialRect: { width: 0, height: maxBodyHeight }, }); const onHeaderDragStart = (colId: string) => { dragColRef.current = colId; }; const onHeaderDrop = (targetId: string) => { const source = dragColRef.current; dragColRef.current = null; if (!source || source === targetId) return; const order = table.getAllLeafColumns().map(c => c.id); const current = columnOrder.length ? columnOrder : order; const from = current.indexOf(source); const to = current.indexOf(targetId); if (from === -1 || to === -1) return; const next = [...current]; next.splice(to, 0, next.splice(from, 1)[0]!); setColumnOrder(next); }; function renderHeaderCell( header: ReturnType[number]['headers'][number] ) { const column = header.column; const canSort = enableSorting && column.getCanSort(); const sortDir = column.getIsSorted(); const isSelectCol = column.id === SELECT_COL_ID; const reorderable = enableColumnReorder && !isSelectCol; return ( onHeaderDragStart(column.id) : undefined} onDragOver={reorderable ? e => e.preventDefault() : undefined} onDrop={reorderable ? () => onHeaderDrop(column.id) : undefined} style={{ width: enableColumnResizing ? header.getSize() : undefined, position: 'relative', ...pinnedStyle(column), }} className={clsx( rowCellCls, 'border-b border-[var(--bl-border,rgba(0,0,0,0.12))] text-left font-semibold text-[var(--bl-text-secondary,#444)]' )} > {header.isPlaceholder ? null : ( )} {enableColumnPinning && !isSelectCol && ( )} {enableColumnResizing && column.getCanResize() && ( )} ); } const colCount = table.getAllLeafColumns().length; return (
{enableFilter && ( setGlobalFilter(e.target.value)} placeholder={filterPlaceholder} className="h-9 w-full max-w-xs rounded-lg border border-[var(--bl-border,rgba(0,0,0,0.12))] bg-[var(--bl-surface-card,#fff)] px-3 text-sm outline-none focus-visible:border-[var(--bl-accent,#6366f1)]" /> )} {enableRowSelection && selectedRows.length > 0 && (
{selectedRows.length} selected {renderBulkActions?.(selectedRows, () => table.resetRowSelection())}
)}
{table.getHeaderGroups().map(hg => ( {hg.headers.map(h => renderHeaderCell(h))} ))} {rows.length === 0 ? ( ) : virtualized ? ( virtualizer.getVirtualItems().map(vi => { const row = rows[vi.index]!; return ( {row.getVisibleCells().map(cell => ( ))} ); }) ) : ( rows.map(row => ( {row.getVisibleCells().map(cell => ( ))} )) )}
{emptyState ?? 'No rows.'}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{enablePagination && !virtualized && rows.length > 0 && (
Page {table.getState().pagination.pageIndex + 1} of {Math.max(1, table.getPageCount())}
)}
); }