import React from 'react'; export type DataTableAlign = 'left' | 'right' | 'center'; export interface DataTableColumn { id: string; header: React.ReactNode; /** `string` reads `row[accessor]`; function maps the row to a cell value. */ accessor: keyof TRow | ((row: TRow) => React.ReactNode); sortable?: boolean; align?: DataTableAlign; width?: number | string; } export interface DataTableSort { columnId: string; direction: 'asc' | 'desc'; } export interface DataTableProps { columns: readonly DataTableColumn[]; rows: readonly TRow[]; /** Row id accessor. Defaults to `row.id`. */ rowId?: (row: TRow) => string; sortBy?: DataTableSort | null; onSortChange?: (next: DataTableSort | null) => void; selection?: ReadonlySet; onSelectionChange?: (next: Set) => void; loading?: boolean; emptyState?: React.ReactNode; className?: string; caption?: React.ReactNode; /** Number of skeleton rows to emit while `loading`. */ skeletonRows?: number; } const defaultRowId = (row: TRow): string => String((row as unknown as { id?: unknown }).id ?? ''); const readCell = ( row: TRow, column: DataTableColumn ): React.ReactNode => { if (typeof column.accessor === 'function') return column.accessor(row); return (row as unknown as Record)[ column.accessor as string ]; }; const nextDirection = ( current: DataTableSort | null | undefined, columnId: string ): DataTableSort | null => { if (!current || current.columnId !== columnId) { return { columnId, direction: 'asc' }; } if (current.direction === 'asc') { return { columnId, direction: 'desc' }; } // Third click clears the sort - matches common table UX. return null; }; const toWidth = (v: number | string | undefined): string | undefined => typeof v === 'number' ? `${v}px` : v; export const DataTable = ({ columns, rows, rowId = defaultRowId, sortBy, onSortChange, selection, onSelectionChange, loading = false, emptyState, className = '', caption, skeletonRows = 3 }: DataTableProps): React.ReactElement => { const classes = ['data-table', className].filter(Boolean).join(' '); const hasSelection = selection !== undefined && onSelectionChange !== undefined; const totalCols = columns.length + (hasSelection ? 1 : 0); const allIds = rows.map(row => rowId(row)); const allSelected = hasSelection && allIds.length > 0 && allIds.every(id => selection.has(id)); const someSelected = hasSelection && !allSelected && allIds.some(id => selection.has(id)); const toggleAll = (): void => { if (!hasSelection) return; const next = new Set(selection); if (allSelected) { allIds.forEach(id => next.delete(id)); } else { allIds.forEach(id => next.add(id)); } onSelectionChange(next); }; const toggleRow = (id: string): void => { if (!hasSelection) return; const next = new Set(selection); if (next.has(id)) next.delete(id); else next.add(id); onSelectionChange(next); }; const renderCell = ( row: TRow, column: DataTableColumn ): React.ReactElement => { const cellClasses = [ 'data-table__cell', column.align && column.align !== 'left' ? `data-table__cell--${column.align}` : '' ] .filter(Boolean) .join(' '); return ( {readCell(row, column)} ); }; return (
{caption !== undefined && } {hasSelection && ( )} {columns.map(column => { const sortable = column.sortable === true; const active = sortBy?.columnId === column.id; const ariaSort: 'ascending' | 'descending' | 'none' | undefined = sortable ? active ? sortBy.direction === 'asc' ? 'ascending' : 'descending' : 'none' : undefined; const headerClasses = [ 'data-table__header', column.align && column.align !== 'left' ? `data-table__header--${column.align}` : '' ] .filter(Boolean) .join(' '); return ( ); })} {loading ? Array.from({ length: skeletonRows }, (_, i) => ( {hasSelection && ( )} {columns.map(column => ( ))} )) : rows.length === 0 ? [ ] : rows.map(row => { const id = rowId(row); const selected = hasSelection && selection.has(id); return ( {hasSelection && ( )} {columns.map(column => renderCell(row, column))} ); })}
{caption}
{ if (el) el.indeterminate = someSelected; }} onChange={toggleAll} /> {sortable && onSortChange !== undefined ? ( ) : ( column.header )}
{emptyState}
toggleRow(id)} />
); }; DataTable.displayName = 'DataTable';