371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
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<Item> {
|
||
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<Item extends { id: string }> {
|
||
label: string;
|
||
columns: TableColumn<Item>[];
|
||
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<Item extends { id: string }>({
|
||
label,
|
||
columns,
|
||
items,
|
||
sample,
|
||
empty,
|
||
dependencies = [],
|
||
selectedId,
|
||
onSelect,
|
||
}: Props<Item>) {
|
||
const frame = useRef<HTMLDivElement>(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 (
|
||
<div className={`${styles.frame} ${isEmpty ? styles.frameEmpty : ''}`} ref={frame}>
|
||
<Virtualizer
|
||
layout={TableLayout}
|
||
layoutOptions={{
|
||
rowHeight: measures['--row-height-table'],
|
||
headingHeight: measures['--row-height-table'],
|
||
}}
|
||
>
|
||
<AriaTable
|
||
className={styles.table}
|
||
aria-label={label}
|
||
selectionMode={onSelect ? 'single' : 'none'}
|
||
selectedKeys={selectedId === undefined ? [] : [selectedId]}
|
||
onSelectionChange={(keys) => {
|
||
if (keys !== 'all') for (const key of keys) onSelect?.(String(key));
|
||
}}
|
||
>
|
||
<TableHeader columns={columns} dependencies={head}>
|
||
{(column) => (
|
||
<Column
|
||
className={`${styles.column} ${column.className ?? ''}`}
|
||
id={column.id}
|
||
isRowHeader={column.isRowHeader}
|
||
{...sizeOf(column, sizes[column.id], column.id === stretch)}
|
||
>
|
||
{column.name}
|
||
</Column>
|
||
)}
|
||
</TableHeader>
|
||
<TableBody items={items} dependencies={layout}>
|
||
{(item) => (
|
||
<Row className={styles.row} columns={columns} dependencies={layout}>
|
||
{(column) => (
|
||
<Cell className={`${styles.cell} ${column.className ?? ''}`}>
|
||
{column.render(item)}
|
||
</Cell>
|
||
)}
|
||
</Row>
|
||
)}
|
||
</TableBody>
|
||
</AriaTable>
|
||
</Virtualizer>
|
||
{isEmpty && <p className={styles.empty}>{empty}</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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<Item>(
|
||
column: TableColumn<Item>,
|
||
size: Fitted | undefined,
|
||
stretch: boolean,
|
||
): Pick<ColumnProps, 'width' | 'minWidth' | 'maxWidth'> {
|
||
// 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<Item>(
|
||
frame: RefObject<HTMLElement | null>,
|
||
columns: TableColumn<Item>[],
|
||
/** 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<string, Fitted> {
|
||
const [sizes, setSizes] = useState<Record<string, Fitted>>({});
|
||
// 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<string, Fitted> = {};
|
||
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<Item>(lined: Lined<Item>[], sample: Item[]): Promise<void> {
|
||
if (document.fonts === undefined) return;
|
||
const wanted = new Map<string, Set<string>>();
|
||
const want = ({ font }: Face, text: string) => {
|
||
const glyphs = wanted.get(font) ?? new Set<string>();
|
||
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<Item> {
|
||
column: TableColumn<Item>;
|
||
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<Item>(node: HTMLElement | null, columns: TableColumn<Item>[]): Lined<Item>[] | 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>): Element[] {
|
||
const found = new Map<number, Element>();
|
||
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<string, number>();
|
||
|
||
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<string, Fitted>, b: Record<string, Fitted>) => {
|
||
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)
|
||
);
|
||
};
|