diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index 3dc72f8..c80162a 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -1,5 +1,5 @@ import { - Children, cloneElement, Fragment, isValidElement, useId, + Children, cloneElement, Fragment, isValidElement, useId, useState, type MouseEvent, type ReactElement, type ReactNode, } from 'react' @@ -77,6 +77,34 @@ export interface Column { label: string numeric?: boolean mono?: boolean + /** Set false to make this column non-sortable (e.g. an action column). Default: sortable. */ + sortable?: boolean +} + +/** Recursively pull the plain text out of a rendered cell so it can be compared/sorted. */ +function cellText(node: ReactNode): string { + if (node === null || node === undefined || typeof node === 'boolean') return '' + if (typeof node === 'string' || typeof node === 'number') return String(node) + if (Array.isArray(node)) return node.map(cellText).join(' ') + if (typeof node === 'object' && 'props' in node) { + const p = (node as { props?: { children?: ReactNode } }).props + if (p !== undefined && 'children' in p) return cellText(p.children) + } + return '' +} + +/** Compare two cells; numeric columns compare by their leading number, else natural text. */ +function compareCells(a: ReactNode, b: ReactNode, numeric: boolean): number { + const ta = cellText(a).trim(), tb = cellText(b).trim() + if (numeric) { + const na = parseFloat(ta.replace(/[^0-9.-]/g, '')), nb = parseFloat(tb.replace(/[^0-9.-]/g, '')) + const aok = !Number.isNaN(na), bok = !Number.isNaN(nb) + if (aok && bok) return na - nb + if (aok) return -1 // numbers before blanks + if (bok) return 1 + return 0 + } + return ta.localeCompare(tb, undefined, { numeric: true, sensitivity: 'base' }) } export function DataTable(props: { @@ -85,21 +113,47 @@ export function DataTable(props: { onRowClick?: (row: Record, index: number) => void rowTone?: (index: number) => 'ok' | 'warn' | 'err' | undefined }) { + // Client-side column sort (sorts the rows currently shown; index into the caller's data + // is preserved so onRowClick/rowTone keep pointing at the right record). asc → desc → off. + const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' } | null>(null) const cellClass = (c: Column): string | undefined => { const cls = [c.numeric === true ? 'num' : '', c.mono === true ? 'mono' : ''].filter((x) => x !== '').join(' ') return cls !== '' ? cls : undefined } + const canSort = (c: Column): boolean => c.sortable !== false && c.label !== '' + const toggle = (key: string) => + setSort((s) => (s?.key !== key ? { key, dir: 'asc' } : s.dir === 'asc' ? { key, dir: 'desc' } : null)) + + const ordered = props.rows.map((row, i) => ({ row, i })) + if (sort !== null) { + const col = props.columns.find((c) => c.key === sort.key) + ordered.sort((x, y) => { + const r = compareCells(x.row[sort.key], y.row[sort.key], col?.numeric === true) + return sort.dir === 'asc' ? r : -r + }) + } + return ( - {props.columns.map((c) => ( - - ))} + {props.columns.map((c) => { + const sortable = canSort(c) + const arrow = sort?.key === c.key ? (sort.dir === 'asc' ? ' ▲' : ' ▼') : '' + return ( + + ) + })} - {props.rows.map((row, i) => { + {ordered.map(({ row, i }) => { const tone = props.rowTone?.(i) const clickable = props.onRowClick !== undefined return (
{c.label} toggle(c.key) : undefined} + style={sortable ? { cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' } : undefined} + title={sortable ? 'Sort' : undefined}> + {c.label}{arrow} +