import * as React from 'react'; import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Surface } from '@/components/ds/Surface'; import { EmptyState } from '@/components/ds/EmptyState'; import { SkeletonTable } from '@/components/ds/Skeleton'; import { LoadingOverlay } from '@/components/ds/LoadingState'; import { Pagination } from '@/components/ds/Pagination'; import { Checkbox } from '@/components/ui/checkbox'; import { usePreferences } from '@/lib/preferences'; /** * DataTable — the app's one table. * * Columns are declared as data: * * { key, header, accessor?, sortValue?, cell?, align?, width?, sortable?, hideBelow? } * * Sorting, selection and pagination are handled internally by default, but each * can be lifted by passing the corresponding value + handler — so the same * component serves a simple static list and a server-paginated view. * * `hideBelow: 'md'` drops a column on small screens, which is what keeps the * table usable on mobile without a horizontal scroll for every view. */ /** @param {any} props */ export function DataTable({ columns = [], rows = [], /** Stable row identity. Falls back to `row.id`, then the index. */ getRowId = (row, i) => row?.id ?? i, loading = false, /** Dims existing rows instead of replacing them — for refetches. */ refreshing = false, /* Sorting — omit `sort` to let the table own it */ sort: controlledSort, onSortChange, defaultSort, /* Selection — pass `selectedIds` to enable */ selectedIds, onSelectionChange, selectAllPages = true, /* Pagination — pass `pageSize` to enable */ pageSize: initialPageSize, page: controlledPage, onPageChange, totalItems, pageSizeOptions, onRowClick, emptyState, /** True when filters are active, so the empty copy can say so. */ isFiltered = false, onClearFilters, caption, className, rowClassName, stickyHeader = false, }) { /* ── Sorting ─────────────────────────────────────────────────────────── */ /* Row height follows the account's density preference. Only the vertical padding changes — same columns, same type, same behaviour. */ const { compactDensity: compact } = usePreferences(); const cellPad = compact ? 'py-1.5' : 'py-3'; const [internalSort, setInternalSort] = React.useState(defaultSort ?? null); const sort = controlledSort !== undefined ? controlledSort : internalSort; const setSort = (next) => { if (onSortChange) onSortChange(next); if (controlledSort === undefined) setInternalSort(next); }; const toggleSort = (column) => { if (sort?.key !== column.key) return setSort({ key: column.key, direction: 'asc' }); if (sort.direction === 'asc') return setSort({ key: column.key, direction: 'desc' }); // Third click clears sorting, so the original order is reachable. return setSort(null); }; const valueFor = (column, row) => { if (column.sortValue) return column.sortValue(row); if (column.accessor) return column.accessor(row); return row?.[column.key]; }; const sortedRows = React.useMemo(() => { // A controlled `sort` means the parent already ordered the rows. if (!sort || controlledSort !== undefined) return rows; const column = columns.find((c) => c.key === sort.key); if (!column) return rows; const factor = sort.direction === 'desc' ? -1 : 1; return [...rows].sort((a, b) => { const av = valueFor(column, a); const bv = valueFor(column, b); if (av == null && bv == null) return 0; if (av == null) return 1; if (bv == null) return -1; if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * factor; return String(av).localeCompare(String(bv)) * factor; }); }, [rows, sort, columns, controlledSort]); /* ── Pagination ──────────────────────────────────────────────────────── */ const paginated = initialPageSize != null; const [internalPage, setInternalPage] = React.useState(1); const [pageSize, setPageSize] = React.useState(initialPageSize ?? 25); const page = controlledPage !== undefined ? controlledPage : internalPage; const setPage = (next) => { if (onPageChange) onPageChange(next); if (controlledPage === undefined) setInternalPage(next); }; // Snap back to page 1 whenever the result set changes underneath us, // otherwise filtering to 3 rows while on page 4 shows an empty table. const rowCount = rows.length; React.useEffect(() => { if (controlledPage === undefined) setInternalPage(1); }, [rowCount, sort, controlledPage]); const serverPaginated = totalItems != null && onPageChange != null; const pageCount = paginated ? Math.max(1, Math.ceil((totalItems ?? sortedRows.length) / pageSize)) : 1; const visibleRows = React.useMemo(() => { if (!paginated || serverPaginated) return sortedRows; const start = (page - 1) * pageSize; return sortedRows.slice(start, start + pageSize); }, [sortedRows, paginated, serverPaginated, page, pageSize]); /* ── Selection ───────────────────────────────────────────────────────── */ const selectable = selectedIds !== undefined; const selected = React.useMemo(() => new Set(selectedIds || []), [selectedIds]); const allRowIds = React.useMemo( () => sortedRows.map((row, i) => getRowId(row, i)), [sortedRows, getRowId] ); const pageIds = visibleRows.map((row, i) => getRowId(row, i)); const targetIds = selectAllPages && !serverPaginated ? allRowIds : pageIds; const allTargetSelected = targetIds.length > 0 && targetIds.every((id) => selected.has(id)); const someTargetSelected = targetIds.some((id) => selected.has(id)); const toggleAll = () => { if (allTargetSelected) { onSelectionChange?.(selectedIds.filter((id) => !targetIds.includes(id))); } else { onSelectionChange?.([...new Set([...selectedIds, ...targetIds])]); } }; const toggleRow = (id) => { onSelectionChange?.( selected.has(id) ? selectedIds.filter((s) => s !== id) : [...selectedIds, id] ); }; /* ── Render ──────────────────────────────────────────────────────────── */ const hideBelowClass = { sm: 'hidden sm:table-cell', md: 'hidden md:table-cell', lg: 'hidden lg:table-cell' }; const alignClass = { right: 'text-right', center: 'text-center' }; if (loading) { return ( ); } if (sortedRows.length === 0) { return ( {emptyState || ( )} ); } return (
{/* radius="md" (12px), down from "xl". Surface's xl resolves to rounded-2xl — calc(var(--radius) + 8px) — which with --radius at 0.75rem is a 20px corner. On a small chip that reads as soft; on a full-width table it curves far enough in to cut visibly across the first and last cells of the header row. 12px is `--radius` itself, and matches radiusCard in themes/dt/tokens — the token meant for a large boxed region, which is what a table is. A value picked off the scale rather than an arbitrary px, so it stays in step if the base radius is ever retuned. Set on all three branches (loading, empty, populated) so a table does not change shape as its data arrives. */}
{caption && } {selectable && ( )} {columns.map((column) => { const active = sort?.key === column.key; const SortIcon = !active ? ChevronsUpDown : sort.direction === 'asc' ? ArrowUp : ArrowDown; return ( ); })} {visibleRows.map((row, i) => { const id = getRowId(row, i); const isSelected = selected.has(id); return ( onRowClick(row) : undefined} className={cn( 'border-b border-border last:border-0 transition-colors duration-fast', onRowClick && 'cursor-pointer', isSelected ? 'bg-brand-tint' : 'hover:bg-surface-subtle', typeof rowClassName === 'function' ? rowClassName(row) : rowClassName )} > {selectable && ( )} {columns.map((column) => ( ))} ); })}
{caption}
{column.sortable ? ( ) : ( column.header )}
e.stopPropagation()}> toggleRow(id)} aria-label={`Select row ${i + 1}`} /> {column.cell ? column.cell(row) : column.accessor ? column.accessor(row) : row?.[column.key] ?? '—'}
{paginated && ( { setPageSize(size); setPage(1); }} pageSizeOptions={pageSizeOptions} /> )}
); }