Describe the tables as TanStack columns

Add ui/DataTable, which builds ReUI's DataGrid from `columns` and `data`, and move
all 23 tables onto it. What the grid does not take (a class on a row or cell,
`aria-sort`, `style`, `colSpan`, `role`, `hidden`) is put on the elements it drew
after every render. The column definitions keep their identity between renders, so
cells are not mounted again, and the grid's own paging is off.

The stacked rows on a phone, the column labels and the sort order of the original
work as before. In Blocking > Lists a group heading is a row whose first cell spans
the four columns. docs/reui-migration-plan.md records it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 00:40:53 +07:00
1 parent f3531c64f9
commit 4c0a5de7e7
24 files changed
+1611 -1175

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
import { createContext, useContext, useLayoutEffect, useMemo, useRef, type HTMLAttributes, type ReactNode, type TdHTMLAttributes, type ThHTMLAttributes } from 'react'
import { useTable } from '@tanstack/react-table'
import { DataGrid, dataGridFeatures } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { useStacking } from './Table'
import styles from './Table.module.css'
/*
The console's table on ReUI's DataGrid, with its columns as TanStack columns and its
rows as data.
<DataTable
data={users}
getRowId={(u) => u.username}
columns={[
{ id: 'name', ...head({ field: 'name', sort, onSort: toggle }, 'Username'), cell: (u) => u.username },
{ id: 'actions', th: { className: tbl.actionsCell }, cell: (u) => <Menu …/>, td: () => ({ className: tbl.actionsCell }) },
]}
emptyText="No User Found"
/>
What the grid does not take, the table asks of the DOM afterwards: the grid draws every
`<th>`, `<tr>` and `<td>` itself and has no door for a class on one row, an `aria-sort`
on one header or a `colSpan` on one cell. `th`, `td` and `rowProps` carry those, and
`apply` puts them on the elements the grid drew, after every render, as `Table` does
for the labels of the stacked rows. Sorting stays the screen's (`useSort`): the data
arrives already in order, so the grid sorts nothing.
*/
export interface Col<T> {
id: string
/** What goes inside the `<th>`: normally the result of `head(…)`. */
header?: ReactNode
/** What the `<th>` itself carries: `aria-sort`, a class. */
th?: ThHTMLAttributes<HTMLTableCellElement>
cell: (row: T, index: number) => ReactNode
/** What a row's `<td>` carries: a class. */
td?: (row: T, index: number) => TdHTMLAttributes<HTMLTableCellElement> | undefined
}
const ColumnsContext = createContext<Col<object>[]>([])
function HeaderCell({ column }: { column: { id: string } }) {
const columns = useContext(ColumnsContext)
return <>{columns.find((c) => c.id === column.id)?.header ?? null}</>
}
function BodyCell({ row, column }: { row: { original: object; index: number }; column: { id: string } }) {
const columns = useContext(ColumnsContext)
return <>{columns.find((c) => c.id === column.id)?.cell(row.original, row.index) ?? null}</>
}
type Attrs = Record<string, unknown>
/* React writes a number as pixels, except for the properties that take none. */
const UNITLESS = new Set(['opacity', 'flex', 'flexGrow', 'flexShrink', 'order', 'zIndex', 'lineHeight', 'fontWeight', 'zoom'])
const cssText = (style: Record<string, unknown>) =>
Object.entries(style)
.filter(([, v]) => v != null && v !== false)
.map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())}: ${typeof v === 'number' && v !== 0 && !UNITLESS.has(k) ? `${v}px` : v}`)
.join('; ')
/* What was put on an element last time, so a class or an attribute that no longer
applies is taken off again. */
const applied = new WeakMap<Element, { classes: string[]; names: string[] }>()
function apply(el: Element, attrs: Attrs | undefined) {
const before = applied.get(el)
if (before != null) {
el.classList.remove(...before.classes)
for (const name of before.names) el.removeAttribute(name)
}
const classes = String(attrs?.className ?? '').split(/\s+/).filter(Boolean)
const names: string[] = []
for (const [key, value] of Object.entries(attrs ?? {})) {
if (key === 'className' || key === 'children' || value == null || value === false || typeof value === 'function') continue
const name = key === 'colSpan' ? 'colspan' : key === 'rowSpan' ? 'rowspan' : key
el.setAttribute(name, value === true ? '' : key === 'style' && typeof value === 'object' ? cssText(value as Record<string, unknown>) : String(value))
names.push(name)
}
el.classList.add(...classes)
applied.set(el, { classes, names })
}
export function DataTable<T extends object>({
data,
columns,
getRowId,
rowProps,
emptyText,
className,
tableClass,
footer,
stack = false,
}: {
data: T[]
columns: Col<T>[]
getRowId: (row: T, index: number) => string
/** What a row's `<tr>` carries: a class, `data-*`. */
rowProps?: (row: T, index: number) => HTMLAttributes<HTMLTableRowElement> | undefined
/** What to say when there are no rows. Without it the body is blank. */
emptyText?: ReactNode
className?: string
tableClass?: string
/** The cells of a footer row. */
footer?: ReactNode
stack?: boolean
}) {
const wrap = useRef<HTMLDivElement>(null)
useStacking(wrap, stack)
/* The column definitions must keep their identity from one render to the next: the
grid renders a function in `header`/`cell` as a COMPONENT, and a new function every
render would unmount and mount every cell again (a menu closes, a field loses its
focus). So the definitions are built once per set of ids, from two components that
read the current columns from the context below. */
const ids = columns.map((c) => c.id).join('|')
// eslint-disable-next-line react-hooks/exhaustive-deps
const defs = useMemo(() => columns.map((c) => ({ id: c.id, header: HeaderCell, cell: BodyCell })), [ids])
const grid = useTable({ features: dataGridFeatures, columns: defs, data, getRowId, manualPagination: true })
/* After every render: the grid has drawn its elements, now they get what the screen
asked of them. */
useLayoutEffect(() => {
const root = wrap.current
const table = root?.querySelector('table')
if (root == null || table == null) return
for (const c of columns) {
const th = table.tHead?.querySelector(`th[data-col-id="${CSS.escape(c.id)}"]`)
if (th != null) apply(th, c.th as Attrs | undefined)
}
const byId = new Map(data.map((row, i) => [getRowId(row, i), { row, i }]))
for (const tr of Array.from(table.tBodies[0]?.rows ?? [])) {
const found = byId.get(tr.dataset.rowId ?? '')
if (found == null) {
/* The "nothing here" row. */
const cell = tr.cells[0]
if (cell != null && tr.querySelector('[data-slot="data-grid-table-empty"]') != null) cell.classList.add(styles.noRows)
continue
}
apply(tr, rowProps?.(found.row, found.i) as Attrs | undefined)
columns.forEach((c, n) => {
const td = tr.cells[n]
if (td != null) apply(td, c.td?.(found.row, found.i) as Attrs | undefined)
})
}
})
const base = [styles.table, stack && styles.stack, tableClass].filter(Boolean).join(' ')
return (
<div ref={wrap} className={[styles.wrap, stack && styles.stackWrap, className].filter(Boolean).join(' ')}>
<ColumnsContext.Provider value={columns as Col<object>[]}>
<DataGrid
table={grid}
recordCount={data.length}
emptyMessage={emptyText ?? ' '}
tableLayout={{ width: 'auto' }}
tableClassNames={{ base }}
>
<DataGridTable footerContent={footer != null ? <tr>{footer}</tr> : undefined} />
</DataGrid>
</ColumnsContext.Provider>
</div>
)
}
+40 -1
View File
@@ -249,7 +249,7 @@ function place(wrap: HTMLElement, table: HTMLTableElement) {
/* The table is ReUI's, so it is found inside the wrapper rather than held by a ref. */
const tableIn = (wrap: HTMLElement | null) => wrap?.querySelector('table') ?? null
function useStacking(ref: React.RefObject<HTMLDivElement | null>, stack: boolean) {
export function useStacking(ref: React.RefObject<HTMLDivElement | null>, stack: boolean) {
// After every render, because the rows are the screen's: a sort, a page or a
// filter hands back new cells, and React leaves alone attributes it did not set.
useLayoutEffect(() => {
@@ -412,6 +412,45 @@ export function Th({
)
}
/**
* The column-definition twin of `Th`, for `DataTable`: the header's content (the
* sort button, or the plain text) and the attributes its `<th>` carries
* (`aria-sort`). `DataTable` draws the `<th>` itself.
*/
export function head({
field,
sort,
onSort,
name,
children,
...th
}: {
field?: string
sort?: Sort | null
onSort?: (field: string) => void
children?: ReactNode
name?: string
} & React.ThHTMLAttributes<HTMLTableCellElement>): { header: ReactNode; th: React.ThHTMLAttributes<HTMLTableCellElement> } {
if (field == null || onSort == null) return { header: children, th }
const active = sort?.field === field
return {
th: { 'aria-sort': active ? (sort!.desc ? 'descending' : 'ascending') : 'none', ...th },
header: (
<button
type="button"
className={styles.sort}
aria-label={children == null ? name : undefined}
onClick={() => onSort(field)}
>
{children}
<span className={styles.arrow}>
<Icon name={active ? 'chevronDown' : 'sort'} size={12} data-desc={active && !sort!.desc} />
</span>
</button>
),
}
}
/*
The button for a row action. It carries an icon and not a label (see the measured
reasoning in `Table.module.css`) but keeps its name in `aria-label` and in