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:
1 parent
f3531c64f9
commit
4c0a5de7e7
24 files changed
+1611
-1175
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user