feat(ui): click-to-sort on every DataTable (asc/desc/off), index-safe

Column headers are now sortable across every table in the app (one shared component).
Sort compares cell text — numeric columns by their leading number, else natural text.
Sorting preserves the original row index so onRowClick/rowTone keep pointing at the
right record; action columns (empty label) aren't sortable. asc → desc → off cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 days ago
parent f0bba301e9
commit 5679a94d3c

@ -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<string, ReactNode>, 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 (
<table className="wf">
<thead>
<tr>
{props.columns.map((c) => (
<th key={c.key} className={c.numeric === true ? 'num' : undefined}>{c.label}</th>
))}
{props.columns.map((c) => {
const sortable = canSort(c)
const arrow = sort?.key === c.key ? (sort.dir === 'asc' ? ' ▲' : ' ▼') : ''
return (
<th key={c.key} className={c.numeric === true ? 'num' : undefined}
aria-sort={sort?.key === c.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined}
onClick={sortable ? () => toggle(c.key) : undefined}
style={sortable ? { cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' } : undefined}
title={sortable ? 'Sort' : undefined}>
{c.label}<span style={{ opacity: 0.7 }}>{arrow}</span>
</th>
)
})}
</tr>
</thead>
<tbody>
{props.rows.map((row, i) => {
{ordered.map(({ row, i }) => {
const tone = props.rowTone?.(i)
const clickable = props.onRowClick !== undefined
return (

Loading…
Cancel
Save