@ -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 (