import { forwardRef, useCallback, useMemo, useState, type HTMLAttributes, type ReactNode, } from 'react' import { cn } from '@/lib/utils' // --- Types --- export interface DataTableColumn> { key: string header: string sortable?: boolean align?: 'left' | 'center' | 'right' render?: (value: unknown, row: T, index: number) => ReactNode } export interface DataTableProps> extends Omit, 'children'> { columns: DataTableColumn[] data: T[] selectable?: boolean pagination?: boolean pageSize?: number pageSizeOptions?: number[] loading?: boolean emptyMessage?: string onSelectionChange?: (selected: T[]) => void } type SortState = { key: string; dir: 'asc' | 'desc' } | null const ChevronUpIcon = () => ( ) const ChevronDownIcon = () => ( ) // --- DataTable --- function DataTableInner>( { columns, data, selectable = false, pagination = true, pageSize: initialPageSize = 5, pageSizeOptions = [5, 10, 25], loading = false, emptyMessage = 'No data available', onSelectionChange, className, ...props }: DataTableProps, ref: React.ForwardedRef, ) { const [sort, setSort] = useState(null) const [page, setPage] = useState(0) const [pageSize, setPageSize] = useState(initialPageSize) const [selected, setSelected] = useState>(new Set()) const sortedData = useMemo(() => { if (!sort) return data const { key, dir } = sort return [...data].sort((a, b) => { const va = a[key] const vb = b[key] if (va == null && vb == null) return 0 if (va == null) return 1 if (vb == null) return -1 const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true }) return dir === 'asc' ? cmp : -cmp }) }, [data, sort]) const pageCount = pagination ? Math.max(1, Math.ceil(sortedData.length / pageSize)) : 1 const pageData = pagination ? sortedData.slice(page * pageSize, (page + 1) * pageSize) : sortedData const rangeStart = page * pageSize + 1 const rangeEnd = Math.min((page + 1) * pageSize, sortedData.length) const toggleSort = useCallback((key: string) => { setSort((prev) => { if (prev?.key === key) { return prev.dir === 'asc' ? { key, dir: 'desc' } : null } return { key, dir: 'asc' } }) }, []) const toggleRow = useCallback( (index: number) => { setSelected((prev) => { const next = new Set(prev) if (next.has(index)) next.delete(index) else next.add(index) onSelectionChange?.( [...next].map((i) => sortedData[i]).filter(Boolean), ) return next }) }, [sortedData, onSelectionChange], ) const toggleAll = useCallback(() => { setSelected((prev) => { if (prev.size === sortedData.length) { onSelectionChange?.([]) return new Set() } const all = new Set(sortedData.map((_, i) => i)) onSelectionChange?.([...sortedData]) return all }) }, [sortedData, onSelectionChange]) return (
{selectable && ( )} {columns.map((col) => ( ))} {loading ? ( ) : pageData.length === 0 ? ( ) : ( pageData.map((row, rowIdx) => { const globalIdx = page * pageSize + rowIdx return ( {selectable && ( )} {columns.map((col) => ( ))} ) }) )}
0} onChange={toggleAll} className="accent-primary" /> toggleSort(col.key) : undefined} > {col.header} {col.sortable && sort?.key === col.key && ( sort.dir === 'asc' ? : )}
Loading…
{emptyMessage}
toggleRow(globalIdx)} className="accent-primary" /> {col.render ? col.render(row[col.key], row, globalIdx) : String(row[col.key] ?? '')}
{pagination && sortedData.length > 0 && (
{rangeStart}-{rangeEnd} of {sortedData.length}
)}
) } export const DataTable = forwardRef(DataTableInner) as >( props: DataTableProps & { ref?: React.Ref }, ) => React.ReactElement | null