textmachine/frontend/src/ui/Table.tsx

371 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 133200 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)
);
};