import { useMemo, type ReactNode, type TableHTMLAttributes } from 'react'; import { cx } from '../utils/cx'; import { useControllableState } from '../hooks/useControllableState'; import { Icon } from './Icon'; export type SortDirection = 'asc' | 'desc'; export interface TableSort { columnId: string; direction: SortDirection; } export interface TableColumn { id: string; header: ReactNode; cell: (row: Row) => ReactNode; sortValue?: (row: Row) => string | number; align?: 'start' | 'center' | 'end'; width?: string; hideOnMobile?: boolean; } export interface TableProps extends Omit, 'children'> { columns: readonly TableColumn[]; rows: readonly Row[]; rowKey: (row: Row) => string; caption?: ReactNode; showCaption?: boolean; size?: 'sm' | 'md'; zebra?: boolean; stickyHeader?: boolean; sort?: TableSort | null; defaultSort?: TableSort | null; onSortChange?: (sort: TableSort | null) => void; emptyState?: ReactNode; } /** * Semantic data table with three-state column sorting (ascending → descending → * unsorted). Sorting is applied in-memory when a column exposes `sortValue`; * pass `sort` + `onSortChange` to sort on the server instead. */ export function Table({ columns, rows, rowKey, caption, showCaption = false, size = 'md', zebra = false, stickyHeader = false, sort, defaultSort = null, onSortChange, emptyState, className, ...rest }: TableProps) { const [activeSort, setActiveSort] = useControllableState(sort, defaultSort, onSortChange); const controlled = sort !== undefined; const sortedRows = useMemo(() => { if (controlled || !activeSort) return rows; const column = columns.find((entry) => entry.id === activeSort.columnId); if (!column?.sortValue) return rows; const factor = activeSort.direction === 'asc' ? 1 : -1; return rows.toSorted((a, b) => { const left = column.sortValue!(a); const right = column.sortValue!(b); if (typeof left === 'number' && typeof right === 'number') return (left - right) * factor; return String(left).localeCompare(String(right), undefined, { numeric: true }) * factor; }); }, [controlled, activeSort, rows, columns]); const toggleSort = (columnId: string) => { if (!activeSort || activeSort.columnId !== columnId) { setActiveSort({ columnId, direction: 'asc' }); } else if (activeSort.direction === 'asc') { setActiveSort({ columnId, direction: 'desc' }); } else { setActiveSort(null); } }; return (
{caption && ( )} {columns.map((column) => { const sorted = activeSort?.columnId === column.id ? activeSort.direction : null; return ( ); })} {sortedRows.length === 0 ? ( ) : ( sortedRows.map((row) => ( {columns.map((column) => ( ))} )) )}
{caption}
{column.sortValue ? ( ) : ( column.header )}
{emptyState ?? 'No results'}
{column.cell(row)}
); }