TableGrid.tsx29.6 KBView on GitHub 'use client';
/**
* The grid. Hand-rolled over `@tanstack/react-virtual`, deliberately NOT
* `@tanstack/react-table`.
*
* The design doc reached for react-table because it is already a dependency, but its
* headless model is built around a `data` array it derives a row model from — and this
* grid has no such array. Cell values live in per-row Y.Maps that each row subscribes to
* itself, which is the whole granularity story (see `useYTable`); handing react-table a
* materialized `data` array would rebuild the row model, and re-render every row, on every
* keystroke. Nothing else it offers is wanted here either: filter/group/paginate are absent,
* the ORDER is `schema.sort` applied to the handle list by `useYTable` (a view over document
* order, never a rewrite of it), and column order and sizing are persisted in the Y schema,
* which is the source of truth rather than table state. So this follows the in-repo precedent —
* `modules/crm/components/crm-table.tsx` is also hand-rolled over the same virtualizer.
*
* ── Selection, and where the keyboard lives ──
*
* Focus lives on the scroll container and never moves into a cell; the selection is a
* `CellSelectionStore` that cells subscribe to individually, so a range drag never re-renders
* the grid (see `cell-selection.ts`). What each key and each paste MEANS is
* `use-grid-commands.ts` — a different job from laying the grid out, meeting this file at
* exactly the store and the row list.
*
* ── Row heights are measured, not declared ──
*
* A wrapping column makes a row as tall as its tallest cell, so the virtualizer estimates
* `ROW_HEIGHT` and then measures what it actually rendered. `MAX_ROW_HEIGHT` bounds that
* from the cell side, so no measurement can be pathological.
*/
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { AlertTriangle, ChevronDown, GripVertical, Link2, Loader2, Plus } from 'lucide-react';
import type { TableColumn, TableSortDirection } from '@zero/server/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { cn } from '@/lib/utils';
import { resolveBoundWrite } from './bound-writes';
import { useBoundCellWrite } from './use-bound-cell-write';
import {
CellSelectionProvider,
CellSelectionStore,
useActiveDescendant,
type CellPosition,
} from './cell-selection';
import {
ADD_COLUMN_WIDTH,
DEFAULT_COLUMN_WIDTH,
EXTEND_BLANK_RUN_PX,
GUTTER_WIDTH,
MIN_COLUMN_WIDTH,
ROW_HEIGHT,
SORT_DIRECTION_ICONS,
sortStateLabel,
TRAILING_BLANK_ROWS,
} from './constants';
import {
dropColumn,
insertColumn,
moveColumn,
patchColumn,
setSort,
unbindColumn,
withoutSortOn,
} from './schema-edits';
import { TableColumnMenu } from './TableColumnMenu';
import { BlankRowView, TableRowView, type CreateTaskForRowInput } from './TableRow';
import { useGridCommands } from './use-grid-commands';
import type { UseYTableResult } from './useYTable';
export interface TableGridProps {
table: UseYTableResult;
/**
* Per-row "Create task for this row". Owned by the caller because it is a mutation (and a
* table opened read-only, e.g. on the share page, simply omits it).
*/
onCreateTaskForRow?: (input: CreateTaskForRowInput) => void;
className?: string;
}
interface ResizeDraft {
key=[redacted];
width: number;
}
export function TableGrid({ table, onCreateTaskForRow, className }: TableGridProps) {
// Destructured because every callback below must depend on the individual (stable)
// mutators rather than on `table`, which is a new object each render — depending on the
// object would hand the memoized rows a fresh callback identity on every render.
const {
schema,
schemaError,
rows,
isLoading,
setCell,
setCells,
addRows,
deleteRow,
moveRow,
setSchema,
removeColumn,
insertSection,
undo,
redo,
} = table;
const scrollRef = useRef<HTMLDivElement>(null);
const gridId = useId();
const [blankRun, setBlankRun] = useState(TRAILING_BLANK_ROWS);
const [resize, setResize] = useState<ResizeDraft | null>(null);
const [draggedColumn, setDraggedColumn] = useState<string | null>(null);
/**
* Header the pointer is currently over mid-drag — the only feedback that a drop will land.
* NOT `dropColumn`: that is the schema-edit helper this file imports, and shadowing it made
* `handleDeleteColumn` call a string.
*/
const [dropTargetColumn, setDropTargetColumn] = useState<string | null>(null);
/** The column whose link the user is being asked to confirm severing. */
const [severColumn, setSeverColumn] = useState<string | null>(null);
const draggedRowRef = useRef<number | null>(null);
/**
* Whether the view is sorted, readable from a callback that must not change identity.
*
* `handleBlankInput` is handed to every rendered blank row, and taking `schema.sort` as a
* dependency would give all of them a fresh prop on any schema write — which is the row
* memoization the whole grid rests on, defeated to answer a question asked once per keystroke
* in a trailing blank.
*/
const sortedRef = useRef(false);
/**
* The current row list, for a callback that runs AFTER the Y observer has re-synced it.
* `handleInsertSection` needs the new row's index, which does not exist at the moment it
* asks for the insert.
*/
const orderedRowsRef = useRef(rows);
orderedRowsRef.current = rows;
const writeBoundCell = useBoundCellWrite();
/**
* Where a bound cell's edit goes, for one row. Depends on the schema — which is what knows
* the ref columns a binding reads through — and is therefore as stable as the schema is:
* it changes when a column does, which is the same moment `columns` changes anyway.
*/
const boundWriteFor = useCallback(
(column: TableColumn, cells: Record<string, string>) =>
resolveBoundWrite(column, schema, cells),
[schema],
);
// One store per mounted grid. Not React state — see the module comment.
const selection = useMemo(() => new CellSelectionStore(), []);
const activeDescendant = useActiveDescendant(gridId, selection);
const columns = useMemo(() => {
const visible = (schema?.columns ?? []).filter((column) => !column.hidden);
if (!resize) return visible;
// Only allocates a new array mid-drag, so the row memoization holds the rest of the
// time — a fresh `columns` array on every render would defeat it permanently.
return visible.map((column) =>
column.key === resize.key ? { ...column, width: resize.width } : column,
);
}, [schema?.columns, resize]);
const bodyWidth =
GUTTER_WIDTH + columns.reduce((sum, c) => sum + (c.width ?? DEFAULT_COLUMN_WIDTH), 0);
const rowCount = rows.length + blankRun;
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT,
// A wrapping cell makes its row taller, so the row is measured rather than told. The
// `max` is not defensive padding: jsdom reports every rect as zero-height, and without
// it every row in a test would collapse to nothing and the virtual window would be the
// whole table.
measureElement: (element) => Math.max(element.getBoundingClientRect().height, ROW_HEIGHT),
overscan: 8,
});
/**
* Which rendered rows are headings, and the number each RECORD shows in the gutter.
*
* Both come off the same walk because both are answers about the rendered ORDER rather than
* about a row: a heading occupies an index but is not a row of data, so numbering by index
* would make the third record "row 4" and the toolbar's count disagree with the last number
* in the gutter. Recomputed only when the row list changes identity, which is the same moment
* the order itself can have changed.
*/
const rowShape = useMemo(() => {
const isSection: boolean[] = [];
const numbers: number[] = [];
let records = 0;
for (const handle of rows) {
isSection.push(handle.isSection);
numbers.push(handle.isSection ? 0 : (records += 1));
}
return { isSection, numbers, recordCount: records };
}, [rows]);
useEffect(() => {
selection.setBounds({
rowCount,
colCount: columns.length,
// A trailing blank is never a heading, so anything past the materialized rows is false.
isSpanRow: (row) => rowShape.isSection[row] ?? false,
});
}, [selection, rowCount, columns.length, rowShape]);
// A drag-select that ends anywhere — over the header, outside the window — still ends.
useEffect(() => {
const stop = () => selection.endDrag();
window.addEventListener('mouseup', stop);
return () => window.removeEventListener('mouseup', stop);
}, [selection]);
// ── Row writes ──
const handleCommitCell = useCallback(
(rowId: string, columnKey=[redacted], value: string) => setCell(rowId, columnKey, value),
[setCell],
);
const handleDeleteRow = useCallback((rowId: string) => deleteRow(rowId), [deleteRow]);
/**
* Insert a heading, then open it for editing — a section with the placeholder name still on
* it is a section nobody has named, and making the user find it and click again is the step
* that gets skipped.
*/
const handleInsertSection = useCallback(
(anchorRowId: string, where: 'above' | 'below') => {
const created = insertSection(anchorRowId, where);
if (!created) return;
// The row list re-syncs from the Y observer, so the new index is only knowable after it.
queueMicrotask(() => {
const at = orderedRowsRef.current.findIndex((handle) => handle.rowId === created);
if (at >= 0) selection.beginEdit({ row: at, col: 0 });
});
},
[insertSection, selection],
);
// Stable even when the caller supplies nothing, so the memoized rows never see a changing
// prop identity — the whole granularity story depends on that.
const handleCreateTaskForRow = useCallback(
(input: CreateTaskForRowInput) => onCreateTaskForRow?.(input),
[onCreateTaskForRow],
);
const handleDragStartRow = useCallback((index: number) => {
draggedRowRef.current = index;
}, []);
const handleDropRow = useCallback(
(index: number) => {
const from = draggedRowRef.current;
draggedRowRef.current = null;
if (from === null) return;
moveRow(from, index);
},
[moveRow],
);
/**
* A trailing blank committed a value. Materializes the blanks above it as empty rows so the
* typed one keeps the position it was typed in — the Sheets behaviour of typing into a row a
* few below the data. The selection needs no repair: the materialized row lands at exactly
* the index the blank occupied.
*/
const handleBlankInput = useCallback(
(offset: number, columnKey=[redacted], value: string) => {
// Under a sort the gap is not a position: the typed row lands wherever the comparator
// puts it, and the empties meant to hold its place would all sort to the bottom as N
// blank rows nobody asked for. So a sorted table materializes exactly the row typed.
const empties = sortedRef.current ? [] : Array.from({ length: offset }, () => ({}));
addRows([...empties, { [columnKey]: value }]);
},
[addRows],
);
/** Bring the active cell into view. Vertical is the virtualizer's job; horizontal is ours. */
const revealActive = useCallback(() => {
const active = selection.getActive();
const element = scrollRef.current;
if (!active || !element) return;
virtualizer.scrollToIndex(active.row, { align: 'auto' });
let left = GUTTER_WIDTH;
for (let col = 0; col < active.col; col++) left += columns[col]?.width ?? DEFAULT_COLUMN_WIDTH;
const width = columns[active.col]?.width ?? DEFAULT_COLUMN_WIDTH;
if (left < element.scrollLeft + GUTTER_WIDTH) element.scrollLeft = left - GUTTER_WIDTH;
else if (left + width > element.scrollLeft + element.clientWidth) {
element.scrollLeft = left + width - element.clientWidth;
}
}, [selection, virtualizer, columns]);
const { onKeyDown, onCopy, onCut, onPaste } = useGridCommands({
selection,
columns,
rows,
setCells,
addRows,
undo,
redo,
revealActive,
resolveBoundWrite: boundWriteFor,
onWriteBound: writeBoundCell,
});
// ── Column writes ──
const patch = useCallback(
(key=[redacted], columnPatch: Partial<Omit<TableColumn, 'key'>>) => {
if (!schema) return;
const patched = patchColumn(schema, key, columnPatch);
// Hiding the column the table is ordered by leaves the rows in an order with nothing on
// screen to explain it — the same reason deleting one clears the sort (see `dropColumn`).
setSchema(columnPatch.hidden === true ? withoutSortOn(patched, key) : patched);
},
[schema, setSchema],
);
/**
* Order the table by a column, or clear the order.
*
* A schema write and nothing else: `tableRows` keeps document order, so `_id` addressing and
* every row's CRDT identity survive a sort — `useYTable` reorders the HANDLES it hands back.
*/
const handleSort = useCallback(
(key=[redacted], direction: TableSortDirection | null) => {
if (!schema) return;
setSchema(setSort(schema, key, direction));
},
[schema, setSchema],
);
/**
* The rows on screen are not in document order, so a row drag cannot mean what it looks like.
*
* `moveRow` writes an INDEX into the Y.Array, and under a sort the index the user dropped on
* is a position in the sorted view — so the row would land somewhere else entirely and then
* snap back to wherever the comparator puts it, which reads as the drag being ignored. The
* handle is therefore withdrawn while a sort is on, rather than left there to lie.
*/
const sorted = schema?.sort ?? null;
sortedRef.current = !!sorted;
const sortDirectionFor = useCallback(
(key=[redacted] TableSortDirection | null => (sorted?.columnKey === key ? sorted.direction : null),
[sorted],
);
const handleInsertColumn = useCallback(
(atIndex: number) => {
if (!schema) return;
setSchema(insertColumn(schema, atIndex));
},
[schema, setSchema],
);
const handleDeleteColumn = useCallback(
(key=[redacted] => {
if (!schema) return;
removeColumn(key, dropColumn(schema, key));
},
[schema, removeColumn],
);
/**
* Sever a column's link — a schema write only. The cells are deliberately untouched, so the
* last values Cedar computed stay put and become ordinary editable ones: the column stops
* recomputing, and editing it stops reaching the deal.
*
* Confirmed rather than immediate, because it is the one column action with no way back
* from the grid — re-binding takes an agent, and the header's link glyph is a small target
* beside the column's own name.
*/
const handleUnbindColumn = useCallback(
(key=[redacted] => {
if (!schema) return;
setSchema(unbindColumn(schema, key));
},
[schema, setSchema],
);
/** The column the sever dialog is about, so it can name what it is about to change. */
const severTarget = severColumn
? (schema?.columns.find((column) => column.key === severColumn) ?? null)
: null;
const handleDropColumn = useCallback(
(targetKey=[redacted] => {
setDropTargetColumn(null);
setDraggedColumn(null);
if (!schema || !draggedColumn || draggedColumn === targetKey) return;
setSchema(moveColumn(schema, draggedColumn, targetKey));
},
[schema, draggedColumn, setSchema],
);
/** A drag that ends anywhere — a drop, an Escape, a release off-target — clears the state. */
const handleDragEndColumn = useCallback(() => {
setDraggedColumn(null);
setDropTargetColumn(null);
}, []);
const startResize = useCallback(
(column: TableColumn, event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = column.width ?? DEFAULT_COLUMN_WIDTH;
let width = startWidth;
const onMove = (moveEvent: PointerEvent) => {
width = Math.max(MIN_COLUMN_WIDTH, startWidth + moveEvent.clientX - startX);
setResize({ key=[redacted], width });
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
setResize(null);
// The draft only ever lived in React state; the committed width goes into the
// schema so it survives a reload and reaches every other client.
if (width !== startWidth) patch(column.key, { width });
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
},
[patch],
);
/** Select a whole column by clicking its header — the other half of Cmd+A. */
const selectColumn = useCallback(
(colIndex: number) => {
const top: CellPosition = { row: 0, col: colIndex };
selection.select(top);
selection.select({ row: rowCount - 1, col: colIndex }, { extend: true });
},
[selection, rowCount],
);
// ── Scroll: extend the blank run rather than preallocating rows ──
const handleScroll = useCallback(() => {
const element = scrollRef.current;
if (!element) return;
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
if (distanceFromBottom < EXTEND_BLANK_RUN_PX) {
setBlankRun((run) => run + TRAILING_BLANK_ROWS);
}
}, []);
if (schemaError) {
return (
<div className={cn('p-6', className)}>
<div className="rounded-lg border border-border bg-background p-4 text-xs text-destructive">
<p className="flex items-center gap-2 font-semibold">
<AlertTriangle className="size-4 shrink-0" />
This table could not be rendered
</p>
<p className="mt-1 text-muted-foreground">{schemaError}</p>
<p className="mt-2 text-muted-foreground">
The rows are untouched — the markdown mirror still holds them, so nothing has
been lost.
</p>
</div>
</div>
);
}
if (isLoading && rows.length === 0) {
return (
<div className={cn('flex h-40 items-center justify-center text-muted-foreground', className)}>
<Loader2 className="size-4 animate-spin" />
</div>
);
}
const virtualItems = virtualizer.getVirtualItems();
return (
<CellSelectionProvider value={selection}>
<div
ref={scrollRef}
tabIndex={0}
role="grid"
aria-label="Table"
// Focus stays here and never roves into a cell — that is what lets a cell be a plain
// element a click can SELECT rather than a tab stop a click can only focus.
aria-activedescendant={activeDescendant}
// The TRUE totals, not the virtualized window's. Only a handful of rows are in the DOM at
// any time, so without these a screen reader reports whatever happens to be rendered as
// the whole table. Both are 1-based and count the header row / gutter column.
aria-rowcount={rows.length + 1}
aria-colcount={columns.length + 1}
onScroll={handleScroll}
onKeyDown={onKeyDown}
onCopy={onCopy}
onCut={onCut}
onPaste={onPaste}
className={cn('min-h-0 flex-1 overflow-auto outline-none', className)}
>
<div style={{ width: bodyWidth + ADD_COLUMN_WIDTH }}>
<div role="row" aria-rowindex={1} className="sticky top-0 z-10 flex border-b border-border bg-background">
<div
role="columnheader"
aria-colindex={1}
className="shrink-0 border-r border-border"
style={{ width: GUTTER_WIDTH }}
/>
{columns.map((column, index) => (
<div
key=[redacted]
role="columnheader"
aria-colindex={index + 2}
onDragOver={(event) => {
event.preventDefault();
if (draggedColumn && draggedColumn !== column.key) setDropTargetColumn(column.key);
}}
onDragLeave={() => setDropTargetColumn((key) => (key === column.key ? null : key))}
onDrop={() => handleDropColumn(column.key)}
className={cn(
'group/header relative flex shrink-0 items-center border-r border-border',
draggedColumn === column.key && 'opacity-50',
// Where the dragged column will land, drawn on the target's leading edge.
dropTargetColumn === column.key &&
'before:absolute before:inset-y-0 before:left-0 before:z-20 before:w-0.5 before:bg-action',
)}
style={{ width: column.width ?? DEFAULT_COLUMN_WIDTH, height: ROW_HEIGHT }}
title={column.description ?? column.label}
>
{/*
The reorder handle, and why it is a separate element rather than the whole header.
The header IS a Radix menu trigger, and Radix opens on `pointerdown` and calls
`preventDefault()` — which is exactly what suppresses the native `dragstart`. With
`draggable` on the header itself the gesture was therefore dead everywhere the
trigger covered, which was all of it. A handle outside the trigger is the one
surface where the pointer is still free.
*/}
<div
draggable
role="button"
aria-label={`Reorder ${column.label}`}
onDragStart={() => setDraggedColumn(column.key)}
onDragEnd={handleDragEndColumn}
className="flex h-full w-3 shrink-0 cursor-grab items-center justify-center text-muted-foreground opacity-0 transition-opacity group-hover/header:opacity-70"
>
<GripVertical className="size-3" />
</div>
{column.binding && (
<button
type="button"
aria-label={`Sever the link on ${column.label}`}
title={
`Linked to ${column.binding}. Edits in this column update the deal, ` +
'everywhere. Click to sever the link and make it this table only.'
}
onClick={() => setSeverColumn(column.key)}
className="flex shrink-0 cursor-pointer items-center rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Link2 className="size-3" />
</button>
)}
<TableColumnMenu
column={column}
// The whole set, for the fill-instruction panel: one column's instruction to
// its agent is written against the ones beside it.
columns={columns}
onPatch={(columnPatch) => patch(column.key, columnPatch)}
onPatchColumn={patch}
onInsertBefore={() => handleInsertColumn(index)}
onInsertAfter={() => handleInsertColumn(index + 1)}
onDelete={() => handleDeleteColumn(column.key)}
onUnbind={() => setSeverColumn(column.key)}
onSelectColumn={() => selectColumn(index)}
onSort={(direction) => handleSort(column.key, direction)}
sortDirection={sortDirectionFor(column.key)}
>
<button
type="button"
className="flex h-full min-w-0 flex-1 cursor-pointer items-center gap-1 pl-0.5 pr-2 text-left text-sm font-medium hover:bg-muted/60"
>
<span className="truncate">{column.label}</span>
{/*
The arrow is the only thing on screen that explains the row order, so it
is NOT hover-revealed and it sits beside the label rather than at the far
edge of a 400px column, where it would be nowhere near the name it
qualifies. `title` says which way, in the column's own words.
*/}
<SortGlyph column={column} direction={sortDirectionFor(column.key)} />
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
</button>
</TableColumnMenu>
<div
role="separator"
aria-label={`Resize ${column.label}`}
onPointerDown={(event) => startResize(column, event)}
className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary"
/>
</div>
))}
<button
type="button"
aria-label="Add column"
onClick={() => handleInsertColumn(columns.length)}
className="flex shrink-0 cursor-pointer items-center justify-center text-muted-foreground hover:bg-muted/60 hover:text-foreground"
style={{ width: ADD_COLUMN_WIDTH, height: ROW_HEIGHT }}
>
<Plus className="size-4" />
</button>
</div>
<div
className="relative"
style={{ height: virtualizer.getTotalSize(), width: bodyWidth }}
>
{virtualItems.map((item) => {
const handle = rows[item.index];
if (handle) {
return (
<TableRowView
key=[redacted]
rowId={handle.rowId}
rowMap={handle.map}
columns={columns}
index={item.index}
rowNumber={rowShape.numbers[item.index] ?? item.index + 1}
top={item.start}
gridId={gridId}
measureRef={virtualizer.measureElement}
titleColumn={schema?.titleColumn ?? ''}
onCommitCell={handleCommitCell}
onDeleteRow={handleDeleteRow}
onDragStartRow={handleDragStartRow}
onDropRow={handleDropRow}
reorderable={!sorted}
// The STABLE callback, not a per-row closure over `handle.rowId` — a fresh
// function identity on every render would break the row memoization the
// whole grid rests on, which is exactly what TableGrid.test.tsx caught.
onInsertSection={sorted ? undefined : handleInsertSection}
onCreateTaskForRow={handleCreateTaskForRow}
resolveBoundWrite={boundWriteFor}
onWriteBound={writeBoundCell}
/>
);
}
return (
<BlankRowView
key=[redacted]
offset={item.index - rows.length}
index={item.index}
columns={columns}
top={item.start}
gridId={gridId}
measureRef={virtualizer.measureElement}
onFirstInput={handleBlankInput}
/>
);
})}
</div>
</div>
</div>
<AlertDialog
open={!!severColumn}
onOpenChange={(next) => !next && setSeverColumn(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Sever the link on {severTarget?.label ?? 'this column'}?
</AlertDialogTitle>
<AlertDialogDescription>
{severTarget?.binding ? (
<>
The column stops reading{' '}
<span className="font-mono">{severTarget.binding}</span>. Every value Cedar has
computed stays exactly as it is and becomes this table’s own — it
will no longer update when the deal changes, and editing it will no longer
update the deal.
</>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer"
onClick={() => {
if (severColumn) handleUnbindColumn(severColumn);
setSeverColumn(null);
}}
>
Sever link
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CellSelectionProvider>
);
}
/**
* The arrow on a sorted column's header, and nothing at all on every other one.
*
* Same glyph the menu row that set it uses, so the two read as one control rather than as an
* action and an unrelated indicator. The tooltip lives on this span and not on the header,
* whose own `title` is the column DESCRIPTION — overwriting that would trade "what this column
* holds" for "which way it points".
*/
function SortGlyph({
column,
direction,
}: {
column: TableColumn;
direction: TableSortDirection | null;
}) {
if (!direction) return null;
const Icon = SORT_DIRECTION_ICONS[direction];
const label = sortStateLabel(column, direction);
return (
<span title={label} className="flex shrink-0 items-center">
<Icon aria-label={label} role="img" className="size-3 text-foreground" />
</span>
);
}