import { type ReactNode, type RefObject, useEffect, useRef, useState } from 'react'; import type { ColumnProps } from 'react-aria-components'; import { Cell, Column, Row, Table as AriaTable, TableBody, TableHeader, TableLayout, Virtualizer, } from 'react-aria-components'; import { measures } from '../tokens/measures'; import styles from './Table.module.css'; export interface TableColumn { id: string; name: string; /** Share of what the fitted columns leave over. Only columns without `fit` divide it. */ share?: number; /** The column never shrinks below this. */ minWidth?: number; /** Sizes the column to the widest `text` it holds, and never past `max` itself. */ fit?: { text: (item: Item) => string; max: number }; /** The column that names the row for a screen reader. Exactly one per table. */ isRowHeader?: boolean; /** Extra class for the column's cells — a caller's device (a rule, a tone), not a layout knob. */ className?: string; render: (item: Item) => ReactNode; } interface Props { label: string; columns: TableColumn[]; items: Item[]; /** Rows the widths are measured over — the whole set, or a search resizes columns while typing. */ sample?: Item[]; empty: ReactNode; /** * Everything `render` takes from OUTSIDE the row: the library caches cells by row object and * drops that cache only on this list. Primitives only — these are compared as text. */ dependencies?: unknown[]; selectedId?: string; onSelect?: (id: string) => void; } /** * A virtualized table. A column with `fit` is measured off its own content and never grows past it; * columns without one take what is left over, and when nothing is left the fitted ones give way in * proportion to what they hold. * * ⚠ The column SET must never depend on the container width: changing it rebuilds the collection, * and doing that per drag frame measured 133–200 ms p90 (1200 terms). A filter may change it. */ export function Table({ label, columns, items, sample, empty, dependencies = [], selectedId, onSelect, }: Props) { const frame = useRef(null); const sizes = useFittedWidths(frame, columns, sample ?? items, dependencies, items.length > 0); // Names too: a header is a floor of its own, so a renamed column must be re-measured. const named = columns.map((column) => `${column.id}:${column.name}`).join(','); const layout = [named, ...dependencies]; // Widths reach the layout through the collection, which is cached by column object — and a // fitted width changes without the column changing, so it needs its own key. const head = [ ...layout, columns .map((column) => `${String(sizes[column.id]?.fit)}:${String(sizes[column.id]?.floor)}`) .join(','), ]; // Something must take what the others do not need, or the table stops short of its right edge. // Normally that is a column without a content width; if all have one, the last gives it up. const free = columns.some((column) => sizes[column.id]?.fit === undefined); const stretch = free ? undefined : columns.at(-1)?.id; // The head stays when the search finds nothing — the columns are what is searched through. The // message sits BESIDE the table: the library's own empty state announces itself as a table row. const isEmpty = items.length === 0; return (
{ if (keys !== 'all') for (const key of keys) onSelect?.(String(key)); }} > {(column) => ( {column.name} )} {(item) => ( {(column) => ( {column.render(item)} )} )} {isEmpty &&

{empty}

}
); } /** * A fitted column asks in TWO ways at once: its content is the ceiling AND the weight by which it * divides what there is. Both halves are needed — a ceiling alone never reaches its content when a * hungrier column is beside it; an exact width alone cannot give way and pushes a column off. */ function sizeOf( column: TableColumn, size: Fitted | undefined, stretch: boolean, ): Pick { // Spelled out even at zero: unsaid, the library puts a floor of 75px under every column. const minWidth = Math.max(column.minWidth ?? 0, size?.floor ?? 0); if (size?.fit !== undefined && !stretch) { const weight: `${number}fr` = `${size.fit}fr`; return { width: weight, minWidth, maxWidth: size.fit }; } const share: `${number}fr` = `${column.share ?? 1}fr`; return { width: share, minWidth }; } /** `fit` — as wide as the column's content, capped. `floor` — its own title, which always fits. */ interface Fitted { fit?: number; floor: number; } /** * Widths that fit the content, measured on a canvas with the font of the cells on screen: a hidden * DOM pass would cost a layout per row. Runs on a data change, never on a resize. */ function useFittedWidths( frame: RefObject, columns: TableColumn[], /** Every row the widths are measured over — the whole set, not what a search left on screen. */ sample: Item[], dependencies: unknown[], /** Not read here: fonts come off RENDERED rows, so losing them all and getting them back * is a reason to measure again. */ hasRows: boolean, ): Record { const [sizes, setSizes] = useState>({}); // Names too: the header is a floor of its own, so a retitled column must be re-measured. const named = columns.map((column) => `${column.id}:${column.name}`).join(','); const marks = dependencies.map(String).join('|'); // Columns are rebuilt every render, so the effect keys on what IDENTIFIES them and reads the // definitions from here. Effects run in order, so this one lands first. const latest = useRef(columns); useEffect(() => { latest.current = columns; }); useEffect(() => { let live = true; void measure(); return () => { live = false; }; async function measure() { const ruler = measurer(); if (ruler === null) return; const columns = latest.current; // The virtualizer fills its rows a frame after the table mounts, so the first look can land // on a head with no body under it. A few frames of patience, then keep the old widths. let lined = read(frame.current, columns); for (let frames = 0; lined === null && frames < 10; frames += 1) { await new Promise((done) => requestAnimationFrame(done)); if (!live) return; lined = read(frame.current, columns); } if (lined === null) return; await load(lined, sample); if (!live) return; const next: Record = {}; for (const { column, title, head, cell, around } of lined) { const floor = Math.ceil(textWidth(ruler, head, title) + around); let fit: number | undefined; if (column.fit) { let width = 0; for (const item of sample) width = Math.max(width, textWidth(ruler, cell, column.fit.text(item))); fit = Math.min(column.fit.max, Math.max(floor, Math.ceil(width + around))); } next[column.id] = { fit, floor }; } // Merged, not replaced: a column the caller has hidden is not in `lined`, and dropping its // width brings it back as a plain share for one frame — the neighbour jumped 13px. setSizes((current) => { const merged = { ...current, ...next }; return same(current, merged) ? current : merged; }); } }, [frame, named, sample, marks, hasRows]); return sizes; } /** * Asks for the faces the text will be measured against. A webfont split by unicode-range arrives * only once a glyph is DRAWN, and a canvas draws nothing — measured off the fallback, a Cyrillic * line comes out 4.7% wide. `document.fonts.ready` does not cover it: it promises only the faces * already asked for, not the subset whose glyph lives in an unrendered row. */ async function load(lined: Lined[], sample: Item[]): Promise { if (document.fonts === undefined) return; const wanted = new Map>(); const want = ({ font }: Face, text: string) => { const glyphs = wanted.get(font) ?? new Set(); for (const glyph of text) glyphs.add(glyph); wanted.set(font, glyphs); }; for (const { column, title, head, cell } of lined) { want(head, title); if (column.fit) for (const item of sample) want(cell, column.fit.text(item)); } await Promise.all( [...wanted].map(([font, glyphs]) => document.fonts.load(font, [...glyphs].join('')).catch(() => []), ), ); } /** * A column paired with what the browser actually draws it with. Copied out of the computed styles * rather than held as them: a `CSSStyleDeclaration` is LIVE, and the row it was read from can be * gone by the time the fonts finish loading — it then reads back blank, and every column measures * against the canvas default. That is what an emptied search did. */ interface Lined { column: TableColumn; title: string; head: Face; cell: Face; around: number; } interface Face { font: string; spacing: string; } const faceOf = (style: CSSStyleDeclaration): Face => ({ font: `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`, spacing: style.letterSpacing.endsWith('px') ? style.letterSpacing : '0px', }); /** Everything of the cell's width that is not text: its padding and its rules. */ const outside = (box: CSSStyleDeclaration) => parseFloat(box.paddingLeft) + parseFloat(box.paddingRight) + parseFloat(box.borderLeftWidth) + parseFloat(box.borderRightWidth); /** * Every column paired with the header and data cell standing in it, or null when the table is not * all there — pairing by position across a partial row hands a column its neighbour's font. */ function read(node: HTMLElement | null, columns: TableColumn[]): Lined[] | null { if (node === null) return null; const heads = byColumn(node.querySelectorAll('[role="columnheader"]')); const cells = byColumn(node.querySelectorAll('[role="gridcell"],[role="rowheader"]')); if (heads.length !== columns.length || cells.length !== columns.length) return null; const lined = columns.map((column, index) => { const head = getComputedStyle(heads[index] as Element); const cell = cells[index] as Element; return { column, // The title as it is SET, not as it is written: the stylesheet draws headers in capitals. title: head.textTransform === 'uppercase' ? column.name.toUpperCase() : column.name, head: faceOf(head), // The content font lives on what the cell RENDERS, not on the cell: the source side is set // one step larger, and measuring it at the cell's size loses about a glyph. cell: faceOf(getComputedStyle(cell.firstElementChild ?? cell)), // Padding AND rules: the box is border-box, so a column rule eats a pixel of the text. around: outside(getComputedStyle(cell)), }; }); // A blank computed style parses to NaN, and a NaN width loops the library's flex pass for ever: // it freezes items by the SIGN of their violation, and NaN has none. return lined.every((part) => Number.isFinite(part.around)) ? lined : null; } /** * One element per column, left to right. Cells of a column share a left edge; DOM order does not * follow column order, because the row header and any sticky column are kept out of turn. */ function byColumn(nodes: NodeListOf): Element[] { const found = new Map(); for (const node of nodes) { const left = Math.round(node.getBoundingClientRect().left); if (!found.has(left)) found.set(left, node); } return [...found].sort(([a], [b]) => a - b).map(([, node]) => node); } let ruler: CanvasRenderingContext2D | null | undefined; const measurer = () => (ruler ??= document.createElement('canvas').getContext('2d')); const measured = new Map(); function textWidth(ruler: CanvasRenderingContext2D, { font, spacing }: Face, text: string) { const key = `${font}|${spacing}|${text}`; const held = measured.get(key); if (held !== undefined) return held; ruler.font = font; ruler.letterSpacing = spacing; const width = ruler.measureText(text).width; // Only a width measured with the REAL face is remembered. `load()` asks for the face and gives up // quietly when it does not arrive; a fallback width cached here would then stand for the rest of // the session — the very poisoning (−4.7% on Cyrillic) that `load()` exists to prevent. if (drawnWith(font, text)) measured.set(key, width); return width; } function drawnWith(font: string, text: string): boolean { try { return document.fonts.check(font, text); } catch { // `check` throws on a font shorthand it cannot parse. Nothing is measurable about that, and // refusing to cache is the safe side. return false; } } const same = (a: Record, b: Record) => { const keys = Object.keys(b); return ( keys.length === Object.keys(a).length && keys.every((key) => a[key]?.fit === b[key]?.fit && a[key]?.floor === b[key]?.floor) ); };