Table
registry/pouf/table.tsx
import { useMemo, useState, type ReactNode } from 'react'
import { Text } from './text'
/* ------------------------------------------------------------------ */
/* Table */
/* ------------------------------------------------------------------ */
interface TableColumn<T> {
key: string
header: string
render: (row: T) => ReactNode
align?: 'left' | 'right'
mono?: boolean
truncate?: boolean
/** Supply a comparator to make this column's header a sort control.
* Opt-in on purpose: a column without `sort` renders the same inert header
* it always has, so adding sorting to the system changed no existing
* snapshot. Sorting is internal state — `rows` stays the source order. */
sort?: (a: T, b: T) => number
}
interface TableBaseProps<T> {
columns: TableColumn<T>[]
rows: T[]
getKey: (row: T) => string
}
type TableProps<T> = TableBaseProps<T> &
(
| { onRowClick?: undefined; getRowLabel?: never }
| {
/** Makes the row clickable while keeping keyboard semantics on a real
* button in its first cell. Use for opening a record, not deletion. */
onRowClick: (row: T) => void
/** Names that first-cell button, e.g. "Open Apple". */
getRowLabel: (row: T) => string
}
)
export function Table<T>({ columns, rows, getKey, onRowClick, getRowLabel }: TableProps<T>) {
const [sortKey, setSortKey] = useState<string | null>(null)
const [desc, setDesc] = useState(false)
const active = sortKey ? columns.find((c) => c.key === sortKey) : undefined
/* Sort a copy: mutating `rows` would reorder the caller's array, and a caller
* that memoised it would see its own data silently rearranged. */
const sorted = useMemo(() => {
if (!active?.sort) return rows
const out = [...rows].sort(active.sort)
return desc ? out.reverse() : out
}, [rows, active, desc])
function toggle(col: TableColumn<T>) {
if (col.key === sortKey) setDesc((d) => !d)
else {
setSortKey(col.key)
setDesc(false)
}
}
return (
<div className="pouf-table__wrap">
<table className="pouf-table">
<thead>
<tr>
{columns.map((col) => (
<th
key={col.key}
className={col.align === 'right' ? 'pouf-table__h--right' : ''}
aria-sort={
col.sort
? col.key === sortKey
? desc
? 'descending'
: 'ascending'
: 'none'
: undefined
}
>
{col.sort ? (
<button
type="button"
className="pouf-table__sort"
onClick={() => toggle(col)}
>
<Text size="sm" muted>{col.header}</Text>
{/* Both arrows always render, the inactive one faded, so the
* header never changes width when you sort it. */}
<span aria-hidden="true" className="pouf-table__caret">
{col.key === sortKey ? (desc ? '▾' : '▴') : '⇅'}
</span>
</button>
) : (
<Text size="sm" muted>{col.header}</Text>
)}
</th>
))}
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr>
<td colSpan={columns.length} className="pouf-table__empty">
<Text size="sm" muted>No data.</Text>
</td>
</tr>
) : (
sorted.map((row) => (
<tr
key={getKey(row)}
className={onRowClick ? 'pouf-table__row pouf-table__row--click' : 'pouf-table__row'}
onClick={
onRowClick
? (event) => {
const target = event.target as Element
const interactive = target.closest('a, button, input, select, textarea')
if (interactive && !interactive.classList.contains('pouf-table__rowaction')) return
onRowClick(row)
}
: undefined
}
>
{columns.map((col, index) => {
const cell = (
<Text
size="sm"
mono={col.mono}
num={col.align === 'right'}
truncate={col.truncate}
>
{col.render(row)}
</Text>
)
return (
<td
key={col.key}
className={col.align === 'right' ? 'pouf-table__cell--right' : ''}
>
{index === 0 && onRowClick ? (
<button
type="button"
className="pouf-table__rowaction"
aria-label={getRowLabel(row)}
>
{cell}
</button>
) : cell}
</td>
)
})}
</tr>
))
)}
</tbody>
</table>
</div>
)
}
- Where it goes
- data-table
- Use cases
- dashboard