DataGrid
A virtualized, editable grid backed by a row store, with sorting, selection, and per-cell flashes.
import { useState } from "react"
import { createInstrumentFormatter, formatNotional } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef } from "@/components/ui/data-grid"
interface Inquiry {
id: string
client: string
size: number
price: number
}
const rows: Inquiry[] = [
{ id: "Q-1", client: "ALPHA", size: 5_000_000, price: 99.5 },
{ id: "Q-2", client: "BETA", size: 10_000_000, price: 99.515625 },
{ id: "Q-3", client: "GAMMA", size: 15_000_000, price: 99.53125 },
]
const note = createInstrumentFormatter({ price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 })
const columns: ColumnDef<Inquiry>[] = [
{ key: "client", header: "Client", width: 140, accessor: (row) => row.client },
{ key: "size", header: "Size", width: 100, numeric: true, accessor: (row) => row.size, format: (value) => formatNotional(value as number, { unit: "mm" }) },
{ key: "price", header: "Price", width: 100, numeric: true, font: "mono", accessor: (row) => row.price, format: (value) => note.price(value as number) },
]
export default function DataGridDemo() {
const [store] = useState(() => {
const store = createRowStore<Inquiry>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: rows })
return store
})
return <div className="h-40 w-fit max-w-full"><DataGrid store={store} columns={columns} preset="rfq" label="Open inquiries" /></div>
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/data-grid#v2.0.0
npx shadcn@latest add tradecn/ui/data-grid#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/data-grid#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/data-grid#v2.0.0
-
Install the dependencies:
pnpm add cn @tanstack/react-virtualnpm install cn @tanstack/react-virtualyarn add cn @tanstack/react-virtualbun add cn @tanstack/react-virtual -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add checkbox context-menu dropdown-menunpx shadcn@latest add checkbox context-menu dropdown-menuyarn dlx shadcn@latest add checkbox context-menu dropdown-menubunx --bun shadcn@latest add checkbox context-menu dropdown-menu -
Copy the files into your project:
components/ui/ data-grid.tsx import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual" import { cn } from "cn" import { Fragment, memo, useCallback, useEffect, useId, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type MouseEvent, type PointerEvent, type ReactNode, type RefObject, type SyntheticEvent, type UIEvent, } from "react" import { Checkbox } from "@/components/ui/checkbox" import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "@/components/ui/context-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { createFlashMemory, playFlash, useFlash, type FlashMemory } from "@/hooks/use-flash" import { useRow, useRowIds, useStoreMeta, useView } from "@/hooks/use-row-store" import { MONO_NUMERIC_CLASS, NULL_TOKEN, NUMERIC_CLASS } from "@/lib/format" import { applyRules, columnName, compareDirected, compareValues, compileComparator, compileFilter, type AppliedRules, type ReadGridRules, type RuleDecoration } from "@/lib/grid-rules" import type { RowId, RowStore, RowView } from "@/lib/row-store" // A virtualized grid fed by a RowStore one row at a time. // // Rows subscribe to their own store entry, so a delta to one row re-renders one row; header, body, // and every other row stay put. Numeric cells flash by direction through a shared flash memory keyed // by row and column, so a row that scrolls out and back resumes its flash. Rows are positioned by // TanStack Virtual with a fixed height, which makes "keep the viewport pinned while rows arrive // above" exact arithmetic instead of measurement. // // Markup is a div grid with ARIA grid roles rather than a <table>: transform-positioned rows and // per-cell sticky frozen columns do not survive <tbody>. export interface ColumnDef<T> { key: string header: ReactNode /** * The column's name in words, for its header's name, its menu and resize handle, and the column chooser. By default * the header when that is a string; give one to a column whose header is an icon or other markup. */ title?: string width: number minWidth?: number align?: "left" | "right" | "center" frozen?: "left" sortable?: boolean hidden?: boolean /** The value the column shows; also what sorting and flash direction compare. */ accessor: (row: T) => unknown /** Text for the value. Defaults to String(value), NULL_TOKEN for null. */ format?: (value: unknown, row: T) => string /** Custom cell content. The cell still flashes by `accessor` value. `edit` is there when the column is editable and the grid has `onEdit`. */ cell?: (ctx: { row: T; value: unknown; rowId: RowId; edit?: CellEditHandle }) => ReactNode /** Flash on change. Defaults to the preset's variant for numeric columns and off otherwise. */ flash?: false | "fill" | "ring" /** Right-aligned, lining tabular figures in the numeric family, flashes by default. */ numeric?: boolean /** The family a numeric cell sets in: `numeric` (the sans, digits at one width) by default, or `mono` for fraction quotes, so digits and the dash take one width; a tailed quote still runs longer than an untailed one. */ font?: "numeric" | "mono" /** Reads a value typed into a rule in this column's own format (`99-16+` on a 32nds column). Default: a number for a numeric column, the text itself otherwise. */ parse?: (text: string) => unknown /** Lets the cell be edited in place, when the grid has `onEdit`. An edit is a command the server answers, never a local truth. */ edit?: CellEdit<T> } export interface ColumnState { order: string[] widths: Record<string, number> hidden: string[] } /** What stops an edit, in a sentence: what `parse` or `validate` return instead of a value. */ export interface EditProblem { problem: string } export function editProblem(problem: string): EditProblem { return { problem } } export function isEditProblem(value: unknown): value is EditProblem { return typeof value === "object" && value !== null && Object.keys(value).length === 1 && typeof (value as EditProblem).problem === "string" } /** How a commit came: the editor's Enter or Tab, leaving the editor, or a toggle or cell control committing a value; whether the key was held; and which opening of the editor it came from. */ export interface EditCommit { via: "enter" | "tab" | "blur" | "value" /** True for a held key's repeats: Enter or Tab in an editor, or a cell control's commit that passes `{ repeat: true }`. False for a blur, and for a toggle, whose held Space, Enter, or F2 commits once. */ repeat: boolean /** One number per opening of an editor, unique across every grid on the page, so a check that asks a question can keep its answer to that opening. Value commits share session 0. */ session: number /** The value another cell of this row has sent and not yet had back, by column key, so a check reads the row as it shows; undefined while nothing is out there. */ pending?: (key: string) => unknown } // Numbered across every grid, so answers kept per opening never cross from one grid to another, or from a // grid's openings before its store changed to those after. let editorOpenings = 0 /** How a column's cells are edited. Every function gets the row, because a step or a check can depend on it. */ export interface CellEdit<T> { /** Reads the typed text as a value, or says what is wrong with it. */ parse: (text: string, row: T) => unknown /** The text the editor opens with. Default: the column's `format`, else the value as text, blank for null. */ format?: (value: unknown, row: T) => string /** A check on the parsed value before it is committed. The grid also says how the commit came, so a check that asks a question can insist on a fresh Enter for the answer; a direct call may leave it out. */ validate?: (value: unknown, row: T, commit?: EditCommit) => EditProblem | null | undefined /** Up and Down in the editor: the value one step away, ten with Shift. Left out, the arrows do nothing. */ step?: (value: unknown, dir: 1 | -1, big: boolean, row: T) => unknown /** Enter or Space on the focused cell commits this in place of opening an editor: a checkbox column. */ toggle?: (value: unknown, row: T) => unknown /** False keeps this row's cell read-only. Default: editable. */ canEdit?: (row: T) => boolean } /** What `onEdit` is handed: one cell's committed value, with what it replaces. */ export interface EditChange<T> { rowId: RowId key: string value: unknown previous: unknown row: T } /** * Where an edit stands. `editing` while the editor is open; `pending` from the commit until a later batch * brings the row's value to the committed one or `onEdit`'s promise resolves; `rejected` when that promise * rejects, with the server's message and the value that stands. */ export type EditStatus = | { kind: "editing"; text: string; problem: string | null; selectAll: boolean; initial?: string | null; focused?: boolean; prior?: EditStatus; session?: number } | { kind: "pending"; value: unknown; text: string; tracked?: boolean } | { kind: "rejected"; value: unknown; message: string } /** What a `cell` renderer gets for an editable column: the edit's status, and a way to commit a value of its own (a checkbox's). */ export interface CellEditHandle { status: EditStatus | undefined /** Validates and sends a value. Pass `{ repeat: true }` for a commit a held key repeats, so a check that asks a question never takes it for an answer. */ commit: (value: unknown, how?: { repeat?: boolean }) => void open: () => void } export type SortState = { key: string; dir: "asc" | "desc" } | null export type SelectionMode = "none" | "single" | "multi" export type DataGridPreset = "blotter" | "watchlist" | "rfq" | "option-chain" | "tape" | "parameters" export interface RowEnterBehavior { /** Highlight rows as they arrive. */ highlight: boolean /** Keep the first visible row where it is when rows arrive above it. */ pinViewport: boolean /** * Follow the tail: the viewport goes to the end as rows arrive there, until a key, a pointer, or a * scroll away from the end stops it. A "N new" pill then counts the arrivals and returns to the end. * What the `tape` preset does; off elsewhere. */ followTail?: boolean } export interface DataGridPresetConfig { rowHeight: number fontClass: string reorderHoldMs: number flash: "fill" | "ring" selectionMode: SelectionMode rowEnter: RowEnterBehavior announceRowCount: "off" | "debounced" } export const DATA_GRID_PRESETS: Record<DataGridPreset, DataGridPresetConfig> = { blotter: { rowHeight: 24, fontClass: "text-xs", reorderHoldMs: 750, flash: "fill", selectionMode: "multi", rowEnter: { highlight: true, pinViewport: true }, announceRowCount: "debounced" }, watchlist: { rowHeight: 22, fontClass: "text-xs", reorderHoldMs: 0, flash: "fill", selectionMode: "single", rowEnter: { highlight: false, pinViewport: false }, announceRowCount: "off" }, rfq: { rowHeight: 26, fontClass: "text-xs", reorderHoldMs: 1000, flash: "ring", selectionMode: "single", rowEnter: { highlight: true, pinViewport: true }, announceRowCount: "debounced" }, "option-chain": { rowHeight: 20, fontClass: "text-xs", reorderHoldMs: 0, flash: "ring", selectionMode: "none", rowEnter: { highlight: false, pinViewport: false }, announceRowCount: "off" }, tape: { rowHeight: 22, fontClass: "text-xs", reorderHoldMs: 0, flash: "fill", selectionMode: "single", rowEnter: { highlight: true, pinViewport: false, followTail: true }, announceRowCount: "debounced" }, parameters: { rowHeight: 24, fontClass: "text-xs", reorderHoldMs: 0, flash: "ring", selectionMode: "single", rowEnter: { highlight: false, pinViewport: true }, announceRowCount: "off" }, } export const EMPTY_COLUMN_STATE: ColumnState = { order: [], widths: {}, hidden: [] } /** * The grid's own words. A template fills `{name}` with a column's title and `{n}` and `{m}` with counts; a label that * holds a count also takes a function of it, for a plural a template can't hold. */ export interface DataGridLabels { /** A row's selection box. */ selectRow: string /** The selection boxes' column header. */ selection: string /** A header's menu button, `{name}` the column's title. */ columnMenu: string /** A header's resize handle, `{name}` the column's title. */ resizeColumn: string sortAscending: string sortDescending: string clearSort: string moveLeft: string moveRight: string hideColumn: string /** The header menu's reset item, `{n}` the hidden columns. */ resetColumns: string | ((hidden: number) => string) /** What a screen reader hears once the row count settles, `{n}` the rows in view and `{m}` the rows that arrived since. */ rowCount: string | ((rows: number, arrived: number) => string) /** The button that takes a paused tape back to its end, `{n}` the rows that arrived since. */ newRows: string | ((n: number) => string) /** A refused edit whose error carries no message of its own. */ rejected: string /** An open editor's commit after the cell stopped taking edits: the text stays until Escape. */ notEditable: string /** What a screen reader hears on a cell whose edit is sent and not yet back. */ pending: string /** An empty view, unless `emptyState` says otherwise. */ noRows: string } export const DEFAULT_DATA_GRID_LABELS: DataGridLabels = { selectRow: "Select row", selection: "Selection", columnMenu: "{name} column menu", resizeColumn: "Resize {name}", sortAscending: "Sort ascending", sortDescending: "Sort descending", clearSort: "Clear sort", moveLeft: "Move left", moveRight: "Move right", hideColumn: "Hide column", resetColumns: (hidden) => (hidden ? `Reset columns (${hidden.toLocaleString()} hidden)` : "Reset columns"), rowCount: (rows, arrived) => `${rows.toLocaleString()} ${rows === 1 ? "row" : "rows"}${arrived ? `, ${arrived.toLocaleString()} new` : ""}`, newRows: (n) => `${n.toLocaleString()} new`, rejected: "Rejected", notEditable: "This cell can't be edited now.", pending: "Pending", noRows: "No rows", } /** A label's text: a template filled with names and counts, a count's in the reader's locale. */ function fillLabel(template: string, values: Record<string, string | number>): string { return template.replace(/\{(\w+)\}/g, (match, name: string) => { const value = values[name] return value === undefined ? match : typeof value === "number" ? value.toLocaleString() : value }) } export interface DataGridProps<T> { store: RowStore<T> /** A view you own (your comparator, your filter). Without it the grid makes one from `sort` and `filter`. */ view?: RowView<T> columns: ColumnDef<T>[] preset?: DataGridPreset rowHeight?: number overscan?: number /** Only used by the grid's own view. */ filter?: (row: T) => boolean reorderHoldMs?: number columnState?: ColumnState onColumnStateChange?: (state: ColumnState) => void /** Initial uncontrolled columns and the latest Reset columns target. Changes do not overwrite current settings. */ baseState?: ColumnState sort?: SortState onSortChange?: (sort: SortState) => void selection?: ReadonlySet<RowId> onSelectionChange?: (selection: ReadonlySet<RowId>) => void selectionMode?: SelectionMode /** Show a checkbox column (multi only). */ selectionColumn?: boolean focusedRowId?: RowId | null onFocusedRowChange?: (id: RowId | null) => void onRowActivate?: (row: T, id: RowId) => void /** Items for the right-click menu, given the rows it applies to (the selection, or the row under the pointer) and the row it opened on. */ renderContextMenu?: (rows: T[], ids: RowId[], target?: RowId | null) => ReactNode rowEnter?: Partial<RowEnterBehavior> announceRowCount?: "off" | "debounced" /** Accessible name for the grid. */ label: string /** The grid's own words, for another language or house style. */ labels?: Partial<DataGridLabels> emptyState?: ReactNode getRowProps?: (row: T, id: RowId) => RowDecoration | undefined /** * A row's name that holds while its cells tick, such as its symbol. Without it a row is named by its cells, * so a screen reader can read the focused row again at every update. With it, the grid reads the focused * row's cells once, politely, when focus rests on the row. `null` is the same as leaving it out, for a preset * that names its rows. Keep its identity stable between renders, as with `filter`. */ getRowLabel?: ((row: T, id: RowId) => string) | null /** * Rules as data: cells and rows to color, rows to show, and the order, from `grid-rules`. The grid * wires them itself; `cell`, `getRowProps`, `filter`, and `sort` stay yours for what code has to do. * With a `view` of your own the grid ignores `rules.filter` and `rules.sort`, as it ignores `filter`. * Keep the object's identity stable between renders, as with `filter`. */ rules?: ReadGridRules /** * Totals: a sticky row under the body with one value per column named here, given the view's rows * (filtered and ordered, what is on screen). Recomputed once per applied batch, never per frame, and * never flashed. Keep the object's identity stable between renders, as with `filter`. */ footer?: Record<string, (rows: T[]) => string> /** * A cell was edited: send the change on. Editing is on only when this is given, for the columns with * `edit`. The cell shows the committed value as pending until a later batch brings the row's value to it * or the promise you return resolves; a rejected promise keeps the previous value and prints the * message in the cell. Multi-cell paste is not part of this. */ onEdit?: (change: EditChange<T>) => void | Promise<unknown> flashWindowMs?: number className?: string /** Viewport size before layout is measured (tests, server rendering). */ initialRect?: { width: number; height: number } } /** What `getRowProps` may put on a row. A rule's decoration from `grid-rules` is one. */ export interface RowDecoration { className?: string "data-state"?: string "data-rule"?: string "data-tone"?: string "aria-description"?: string } type Resolved<T> = ColumnDef<T> & { width: number; minWidth: number } const EMPTY_SET: ReadonlySet<RowId> = new Set() const SELECT_WIDTH = 32 const ENTER_WINDOW_MS = 1500 // How long focus rests on a row before the grid reads a labelled row's cells. const ROW_READING_REST_MS = 400 // What a live region said stays only as long as a screen reader takes to say it: left in the grid, it would meet a // reader browsing the rows later with words the grid no longer stands by. const SPOKEN_MS = 5000 // ARIA roles that take no name from an author, and the elements that take one without a role. const NAMELESS_ROLES = new Set(["generic", "presentation", "none", "caption", "code", "deletion", "emphasis", "insertion", "paragraph", "strong", "subscript", "superscript", "time"]) const NAMED_TAGS = new Set(["A", "AREA", "BUTTON", "IFRAME", "IMG", "INPUT", "METER", "OUTPUT", "PROGRESS", "SELECT", "TEXTAREA", "svg"]) const TEXT_INPUTS = new Set(["", "text", "search", "tel", "url", "email", "number"]) const takesName = (element: Element) => { const role = element.getAttribute("role")?.trim().split(/\s+/)[0] return role ? !NAMELESS_ROLES.has(role) : NAMED_TAGS.has(element.tagName) } // What a screen reader says for a node inside a cell, after the accessible-name rules for content: a text field's or // a select's value, which is what the row holds; on an element that takes a name, the targets of `aria-labelledby`, // hidden ones included, else its `aria-label`; an image's `alt`; otherwise its content, with hidden parts left out. function spokenText(node: Node, referenced = false): string { if (node.nodeType === 3) return node.textContent ?? "" if (node.nodeType !== 1) return "" const element = node as Element if (!referenced && (element.getAttribute("aria-hidden") === "true" || element.hasAttribute("hidden"))) return "" const tag = element.tagName if (tag === "TEXTAREA" || (tag === "INPUT" && TEXT_INPUTS.has((element as HTMLInputElement).type))) return (element as HTMLInputElement).value if (tag === "SELECT") return Array.from((element as HTMLSelectElement).selectedOptions, (option) => option.text).join(" ") const named = takesName(element) const ids = referenced || !named ? null : element.getAttribute("aria-labelledby")?.trim() if (ids) { const root = element.getRootNode() as Document | ShadowRoot const text = ids.split(/\s+/).map((id) => root.getElementById(id)).map((target) => (target ? spokenText(target, true).trim() : "")).filter(Boolean).join(" ") if (text) return text } const label = element.getAttribute("aria-label")?.trim() if (label && named) return label if (tag === "IMG") return element.getAttribute("alt") ?? "" // Elements side by side read as separate words, as their layout shows them ("Client A", "Tier 1"); text beside // an element runs on, so a word split across inline elements stays one word. let text = "" let last: Node | null = null for (const child of element.childNodes) { const part = spokenText(child, referenced) if (!part) continue if (last?.nodeType === 1 && child.nodeType === 1) text += " " text += part last = child } return text } // A row's cells as one reading: each column's words in order, the selection box left out. function rowReading(row: Element): string { return Array.from(row.children) .filter((cell) => cell.hasAttribute("data-col")) .map((cell) => spokenText(cell).replace(/\s+/g, " ").trim()) .filter(Boolean) .join(", ") } // When a mark's flash window begins: at arrival, or, for a mark a reorder hold // parked, when that hold lapses — frozen at `now` while the hold is still running. interface EnterMark { at: number until: number | null } const enterStart = (mark: EnterMark, now: number) => (mark.until !== null ? Math.max(mark.at, Math.min(mark.until, now)) : mark.at) // Native controls and focus targets own interaction regardless of an authored role. const ROW_CONTROLS = 'a[href], button, input, select, textarea, label, summary, audio[controls], video[controls], iframe, object, embed, [contenteditable]:not([contenteditable="false"]), [tabindex], [data-grid-interaction="control"]' const CONTROL_ROLES = new Set("button link checkbox radio switch combobox listbox option textbox searchbox slider spinbutton scrollbar tab tablist toolbar menu menubar menuitem menuitemcheckbox menuitemradio tree treeitem radiogroup doc-backlink doc-biblioref doc-glossref doc-noteref".split(" ")) // Include noninteractive roles: in "status button", status wins. Abstract roles cannot win a fallback list. const ARIA_ROLES = new Set([ ..."alert alertdialog application article banner blockquote button caption cell checkbox code columnheader combobox comment complementary contentinfo definition deletion dialog directory document emphasis feed figure form generic grid gridcell group heading image img insertion link list listbox listitem log main mark marquee math menu menubar menuitem menuitemcheckbox menuitemradio meter navigation none note option paragraph presentation progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox sectionfooter sectionheader separator slider spinbutton status strong subscript suggestion superscript switch tab table tablist tabpanel term textbox time timer toolbar tooltip tree treegrid treeitem".split(" "), ..."document object symbol".split(" ").map(role => `graphics-${role}`), ..."abstract acknowledgments afterword appendix backlink biblioentry bibliography biblioref chapter colophon conclusion cover credit credits dedication endnote endnotes epigraph epilogue errata example footnote foreword glossary glossref index introduction noteref notice pagebreak pagefooter pageheader pagelist part preface prologue pullquote qna subtitle tip toc".split(" ").map(role => `doc-${role}`), ]) function roleOf(element: Element): string | undefined { const value = element.getAttribute("role") if (!value) return for (const token of value.split(/[\t\n\f\r ]+/)) { if (!/^[A-Za-z-]+$/.test(token)) continue const role = token.toLowerCase() if (ARIA_ROLES.has(role)) return role } } function independentGrid(element: Element): boolean { const role = roleOf(element) return role === "grid" || role === "treegrid" || element.getAttribute("data-grid-interaction") === "independent" } function gridTarget(root: HTMLElement | null, target: EventTarget, path?: EventTarget[]): Element | undefined { const element = target as Element // Portals can dispatch both an inner-target pass and a host-retargeted pass. if (!root?.contains(element)) return undefined if (path) { for (const entry of path) { if (entry === root) return element if ((entry as Node).nodeType === 1 && independentGrid(entry as Element)) return undefined } } else { for (let node: Element | null = element; node; node = node.parentElement) { if (node === root) return element if (independentGrid(node)) return undefined } } return undefined } function pathMatches(root: HTMLElement | null, path: EventTarget[], matches: (element: Element) => boolean): boolean { for (const entry of path) { if (entry === root) return false if ((entry as Node).nodeType === 1 && matches(entry as Element)) return true } return false } function rejectMenuStart(root: HTMLElement | null, event: SyntheticEvent) { const native = event.nativeEvent if (event.type === "contextmenu" && root && native.composedPath().includes(root)) { // Base's document listener also claims contained context menus, including at a hydrated document root. event.stopPropagation() if (native.currentTarget === root.ownerDocument) native.stopImmediatePropagation() return } // React's dispatcher checks this query before visiting an ancestor. This compatibility shim // skips the menu trigger without blocking native listeners used by dialog dismissal and touch tracking. event.isPropagationStopped = () => true } function useControllable<V>(value: V | undefined, onChange: ((v: V) => void) | undefined, initial: V): [V, (v: V) => void] { const [internal, setInternal] = useState(initial) const controlled = value !== undefined const current = controlled ? value : internal const set = useCallback( (v: V) => { if (!controlled) setInternal(v) onChange?.(v) }, [controlled, onChange], ) return [current, set] } /** Nulls last, numbers numerically, everything else as text. `compareValues` in `grid-rules`, kept here by name. */ export function compareForSort(a: unknown, b: unknown): number { return compareValues(a, b) } /** The header's sort as a comparator, a null last whichever way it runs. */ export function comparatorFor<T>(columns: ColumnDef<T>[], sort: SortState): ((a: T, b: T) => number) | undefined { if (!sort) return undefined const col = columns.find((c) => c.key === sort.key) if (!col) return undefined return (x, y) => compareDirected(col.accessor(x), col.accessor(y), sort.dir) } /** One comparator after another: the first that tells two rows apart decides. Undefined when there is none. */ function chainComparators<T>(...comparators: (((a: T, b: T) => number) | undefined)[]): ((a: T, b: T) => number) | undefined { const list = comparators.filter((c): c is (a: T, b: T) => number => c !== undefined) if (!list.length) return undefined if (list.length === 1) return list[0] return (a, b) => { for (const compare of list) { const c = compare(a, b) if (c !== 0) return c } return 0 } } /** Order, hide, and size columns by state; frozen columns lead. */ export function resolveColumns<T>(columns: ColumnDef<T>[], state: ColumnState): Resolved<T>[] { const rank = new Map(state.order.map((k, i) => [k, i])) const hidden = new Set(state.hidden) const ordered = columns .map((c, i) => ({ c, r: rank.get(c.key) ?? 1e6 + i })) .sort((a, b) => a.r - b.r) .map(({ c }) => c) .filter((c) => !c.hidden && !hidden.has(c.key)) const frozen = ordered.filter((c) => c.frozen === "left") const rest = ordered.filter((c) => c.frozen !== "left") return [...frozen, ...rest].map((c) => { const minWidth = c.minWidth ?? 48 return { ...c, minWidth, width: Math.max(minWidth, state.widths[c.key] ?? c.width) } }) } /** * CSV of the given rows and columns, RFC 4180 quoting, leaving out a column whose definition says `hidden`. Pass * `resolveColumns(columns, columnState)` to export the columns as the grid shows them, in its order and without the * ones the column state hides. */ export function exportCsv<T>(store: RowStore<T>, columns: ColumnDef<T>[], ids: readonly RowId[]): string { const cols = columns.filter((c) => !c.hidden) // Spreadsheets execute cells led by = + - @ or a tab or carriage return: free-text // fields — a message, an author — must not run as formulas when the file is opened. // The apostrophe prefix is the spreadsheet convention for "this is text". // A sign-led field is data only when the WHOLE field is a number: a leading digit // does not stop a spreadsheet from evaluating what follows it. const wholeNumber = /^[+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?$/i const neutral = (s: string) => (/^[=@\t\r]/.test(s) || (/^[+-]/.test(s) && !wholeNumber.test(s)) ? `'${s}` : s) // A finite numeric accessor usually formats to a number — +1,234.50 from a signed // formatter included — and number-shaped text is data. The sign rule still applies // to numeric text that does not read as one number, and the hard leads always // neutralize: a formatter may emit arbitrary text. const numberShaped = /^[+-]?(\d{1,3}(,\d{3})+|\d+)(\.\d*)?$|^[+-]?\.\d+$|^[+-]?\d+(\.\d*)?e[+-]?\d+$/i const esc = (s: string, numeric = false) => { const t = numeric ? (/^[=@\t\r]/.test(s) || (/^[+-]/.test(s) && !numberShaped.test(s)) ? `'${s}` : s) : neutral(s) return /[",\r\n]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t } const header = cols.map((c) => esc(columnName(c))).join(",") const lines: string[] = [] for (const id of ids) { const row = store.getRow(id) if (row === undefined) continue lines.push( cols .map((c) => { const v = c.accessor(row) const numeric = typeof v === "number" && Number.isFinite(v) const text = c.format ? c.format(v, row) : v === null || v === undefined ? "" : String(v) // The library's formatters print a typographic minus, which a spreadsheet reads as text: a number's sign goes // out as the hyphen-minus, so a sum over the column counts every negative. // Only where the cell then reads as one number: a unit after it, as in -2.5mm, would be text either way. const ascii = numeric ? text.replace(/\u2212/g, "-") : text return esc(numeric && numberShaped.test(ascii) ? ascii : text, numeric) }) .join(","), ) } return [header, ...lines].join("\r\n") + "\r\n" } // Editing. One editor at a time, its status kept in a tracker the cells subscribe to one key at a time, // so typing in or settling one cell re-renders that cell and nothing else; opening, moving, or closing the // editor also renders the grid once, which keeps the row being edited rendered. The controller is one // object for the grid's life; its callbacks read the latest props through a ref. const cellKey = (rowId: RowId, key: string) => `${rowId}\u0000${key}` interface EditTracker { get(key: string): EditStatus | undefined set(key: string, status: EditStatus | undefined): void subscribe(key: string, cb: () => void): () => void /** The cell whose editor is open. */ editing(): string | null /** Called whenever the open editor moves to another cell, or closes. */ subscribeOpen(cb: () => void): () => void } function createEditTracker(): EditTracker { const statuses = new Map<string, EditStatus>() const listeners = new Map<string, Set<() => void>>() const openListeners = new Set<() => void>() let open: string | null = null return { get: (key) => statuses.get(key), set(key, status) { const was = open if (status) statuses.set(key, status) else statuses.delete(key) if (status?.kind === "editing") open = key else if (open === key) open = null const set = listeners.get(key) if (set) for (const cb of set) cb() if (open !== was) for (const cb of openListeners) cb() }, subscribe(key, cb) { let set = listeners.get(key) if (!set) listeners.set(key, (set = new Set())) set.add(cb) return () => { set!.delete(cb) if (!set!.size) listeners.delete(key) } }, editing: () => open, subscribeOpen(cb) { openListeners.add(cb) return () => void openListeners.delete(cb) }, } } interface EditController { tracker: EditTracker reconcileColumns(keys: ReadonlySet<string>): void unmountEditor(key: string, input: HTMLInputElement): void editorFocusFell: { current: boolean } markFocused(rowId: RowId, key: string): void rowOf(k: string): RowId | undefined forget(k: string): void open(rowId: RowId, key: string, typed?: string): void type(rowId: RowId, key: string, text: string): void /** Parse, check, and send. `move` opens the next (1) or previous (-1) editable cell of the row after. */ commit(rowId: RowId, key: string, how: EditCommit, move?: 1 | -1): void /** A value a cell renderer settled itself (a checkbox): sent as it is. */ commitValue(rowId: RowId, key: string, value: unknown, repeat?: boolean): void cancel(rowId: RowId, key: string): void step(rowId: RowId, key: string, dir: 1 | -1, big: boolean): void /** Focus left the editor: commit what parses, drop what does not. */ blur(rowId: RowId, key: string): void /** A later batch brought the row's value to the committed one. */ settle(rowId: RowId, key: string): void settlePrior(rowId: RowId, key: string): void } function editText<T>(col: ColumnDef<T>, value: unknown, row: T): string { if (col.edit?.format) return col.edit.format(value, row) if (value === null || value === undefined) return "" return col.format ? col.format(value, row) : String(value) } // Alt with a letter: the letter typed, or the key's place where Option composes another character, as "ß" for Option+S on // a Mac. AltGr typing a character is typing, not a shortcut. function altLetter(e: KeyboardEvent<HTMLElement>): string | null { if (!e.altKey) return null // Windows reports AltGr as Ctrl and Alt, whatever letter its key leaves; Firefox on a Mac reports AltGraph for Option // alone, which is no typing key. if (e.getModifierState("AltGraph") && e.ctrlKey) return null if (/^[a-z]$/i.test(e.key)) return e.key.toLowerCase() return /^Key[A-Z]$/.test(e.code) ? e.code.slice(3).toLowerCase() : null } // A row id as an id attribute can carry it: letters, digits, and hyphens as they stand, anything else, an underscore // included, as an underscore and its four hex digits. No space splits an IDREF list, no two row ids meet, and no // string, a lone surrogate included, throws. function idPart(id: RowId): string { return id.replace(/[^A-Za-z0-9-]/g, (unit) => `_${unit.charCodeAt(0).toString(16).padStart(4, "0")}`) } /** A data cell's DOM id: the grid's, the column's place, and the row's. */ function cellDomId(uid: string, rowId: RowId, index: number): string { return `${uid}-c${index}-${idPart(rowId)}` } function messageOf(error: unknown, fallback: string): string { // An Error with no words, as `new Error(response.statusText)` is over HTTP/2, says the fallback too, and so does // one whose message can't become text: the cell leaves pending whatever the rejection carries. if (typeof error === "string") return error.trim() || fallback if (!(error instanceof Error)) return fallback try { return String(error.message ?? "").trim() || fallback } catch { return fallback } } /** The tree an element's focus is read in: its shadow root's, else its document's, whose active element is a shadow host while focus is inside one. */ function focusRoot(el: Node): Document | ShadowRoot { return el.getRootNode() as Document | ShadowRoot } /** Focus fell when the element that held it left: to the body, or to a shadow root's host with nothing in that root focused. */ function focusFell(root: Document | ShadowRoot, doc: Document, gone?: Element): boolean { const active = doc.activeElement if (active === null || active === doc.body || (gone !== undefined && active === gone)) return true return root !== doc && active === (root as ShadowRoot).host && (root.activeElement === null || root.activeElement === gone) } function canEditCell<T>(col: ColumnDef<T> | undefined, row: T | undefined): col is ColumnDef<T> & { edit: CellEdit<T> } { return Boolean(col?.edit) && row !== undefined && (col!.edit!.canEdit?.(row) ?? true) } const noopSubscribe = () => () => {} const undefinedStatus = () => undefined const FILL_CLASSES = "data-[direction=up]:bg-up-soft data-[direction=down]:bg-down-soft data-[direction=flat]:bg-flat-soft" const RING_CLASSES = "data-[direction=up]:shadow-[inset_0_0_0_1px_var(--up)] data-[direction=down]:shadow-[inset_0_0_0_1px_var(--down)] data-[direction=flat]:shadow-[inset_0_0_0_1px_var(--flat)]" // A selected row is marked by a bar at the start of its first cell, not filled: a fill behind a row's up and down text // can drop it below 4.5 to 1 in the light themes. The first cell is the selection box's when there is one, and a frozen // first column keeps the bar in view as the grid scrolls sideways. const SELECTED_BAR = "before:pointer-events-none before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary" function alignClass(col: { align?: "left" | "right" | "center"; numeric?: boolean }) { const a = col.align ?? (col.numeric ? "right" : "left") return a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left" } interface CellProps<T> { col: Resolved<T> row: T rowId: RowId id: string colIndex: number left: number | undefined memory: FlashMemory flashVariant: "fill" | "ring" flashWindowMs: number focusedCol: boolean /** The first cell of a selected row with no selection box draws the selection's bar. */ selectedBar: boolean rules: AppliedRules<T> | null /** The row's own rule, for a frozen cell to paint: its opaque background would otherwise cut a gap in the row's tint. */ rowRule: RuleDecoration | undefined rowClassName: string | undefined /** The grid's editing, or null when it has no `onEdit`. */ edits: EditController | null pendingLabel: string /** The focused row's cell in the focused column: the keyboard's cell. */ active: boolean } interface CellEditorProps { rowId: RowId colKey: string label: string status: Extract<EditStatus, { kind: "editing" }> numeric: boolean className: string edits: EditController } // The editor: a bare input the size of the cell. Enter commits, Escape reverts, Tab and Shift+Tab commit // and move along the row, bare Up and Down step. With a modifier held the arrows are not its business: // they belong to whoever listens above it, the way a ticket's mod+up reaches its registry from a field. function CellEditor({ rowId, colKey, label, status, numeric, className, edits }: CellEditorProps) { const problemId = useId() const ref = useRef<HTMLInputElement>(null) const selectAll = status.selectAll useLayoutEffect(() => { const input = ref.current return () => { if (input) edits.unmountEditor(colKey, input) } }, [edits, colKey]) const focused = status.focused ?? false useEffect(() => { const el = ref.current if (!el) return // Once per OPEN, not per mount: a remount of the open editor (a Suspense or Activity // reconnect, or a grid that had no height getting one) must not take focus a second time, // while a fresh open() of a mounted cell resets the flag and focuses again. StrictMode's // rehearsal runs this twice before the flag is read back, focusing twice, harmlessly. if (focused) return edits.markFocused(rowId, colKey) el.focus({ preventScroll: true }) if (selectAll) el.select() else el.setSelectionRange(el.value.length, el.value.length) // A keystroke must not reselect the text under the hand. // eslint-disable-next-line react-hooks/exhaustive-deps }, [focused]) const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { const mod = e.metaKey || e.ctrlKey || e.altKey switch (e.key) { case "Enter": e.preventDefault() edits.commit(rowId, colKey, { via: "enter", repeat: e.repeat, session: status.session ?? 0 }) return case "Escape": e.preventDefault() edits.cancel(rowId, colKey) return case "Tab": e.preventDefault() edits.commit(rowId, colKey, { via: "tab", repeat: e.repeat, session: status.session ?? 0 }, e.shiftKey ? -1 : 1) return case "ArrowUp": case "ArrowDown": if (mod) return e.preventDefault() edits.step(rowId, colKey, e.key === "ArrowUp" ? 1 : -1, e.shiftKey) return } } return ( <> <input ref={ref} data-cell-editor="" data-numeric={numeric ? "" : undefined} aria-label={label} aria-invalid={status.problem ? true : undefined} aria-describedby={status.problem ? problemId : undefined} title={status.problem ?? undefined} value={status.text} autoComplete="off" spellCheck={false} // The text keyboard, not the decimal pad: a phone's pad has no minus, and a numeric column can go below zero. className={cn("h-full w-full min-w-0 bg-background px-2 text-inherit outline-none ring-2 ring-inset ring-foreground aria-invalid:ring-destructive", numeric && "text-right", className)} onChange={(e) => edits.type(rowId, colKey, e.target.value)} onKeyDown={onKeyDown} onBlur={() => edits.blur(rowId, colKey)} /> {/* The problem in words, over the cell above, where a keyboard user sees it: the ring alone says only that something is wrong. */} {status.problem && ( <span id={problemId} data-cell-problem="" className="pointer-events-none absolute bottom-full left-0 z-10 mb-px w-max max-w-[min(24rem,80vw)] rounded-sm border border-destructive bg-background px-1.5 py-0.5 text-xs font-normal whitespace-normal text-destructive not-italic shadow-sm"> {status.problem} </span> )} </> ) } function Cell<T>({ col, row, rowId, id, colIndex, left, memory, flashVariant, flashWindowMs, focusedCol, selectedBar, rules, rowRule, rowClassName, edits, pendingLabel, active }: CellProps<T>) { const value = col.accessor(row) const ref = useRef<HTMLDivElement>(null) const flash = col.flash ?? (col.numeric ? flashVariant : false) const key = cellKey(rowId, col.key) useFlash(ref, value, { memory, cellKey: key, variant: flash || "fill", windowMs: flashWindowMs, disabled: !flash }) // Editable cells follow their own entry in the tracker; the rest subscribe to nothing. const editable = edits !== null && Boolean(col.edit) const tracker = editable ? edits.tracker : null const subscribe = useCallback((cb: () => void) => (tracker ? tracker.subscribe(key, cb) : noopSubscribe()), [tracker, key]) const get = useCallback(() => (tracker ? tracker.get(key) : undefined), [tracker, key]) const status = useSyncExternalStore(subscribe, get, undefinedStatus) // A later batch brought the row's value to the committed one: the edit is settled, and the cell reads the store again. // A covered pending settles the same way, or a close after the store moved on would restore a pending nothing can clear. const settled = status?.kind === "pending" && Object.is(status.value, value) const coveredSettled = status?.kind === "editing" && status.prior?.kind === "pending" && Object.is(status.prior.value, value) useEffect(() => { if (settled) edits?.settle(rowId, col.key) else if (coveredSettled) edits?.settlePrior(rowId, col.key) }, [settled, coveredSettled, edits, rowId, col.key]) const handle = useMemo<CellEditHandle | undefined>( () => (editable ? { status, commit: (next, how) => edits!.commitValue(rowId, col.key, next, how?.repeat), open: () => edits!.open(rowId, col.key) } : undefined), [editable, status, edits, rowId, col.key], ) const numericClass = col.numeric ? (col.font === "mono" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS) : "" const header = columnName(col) const editing = status?.kind === "editing" const pendingText = status?.kind === "pending" && !col.cell ? status.text : null const content = editing ? null : pendingText !== null ? pendingText : col.cell ? col.cell({ row, value, rowId, edit: handle }) : col.format ? col.format(value, row) : value === null || value === undefined ? NULL_TOKEN : String(value) // A matched rule names itself on the cell and says its words to a screen reader; the color is the hint. const rule = rules?.cell(col.key, row) const rejected = status?.kind === "rejected" ? status.message : null return ( <div ref={ref} id={id} role="gridcell" aria-colindex={colIndex + 1} // A pending edit says so beside a rule's words: the italics and dotted underline are what a sighted reader sees. aria-description={rejected ?? ([status?.kind === "pending" ? pendingLabel : undefined, rule?.["aria-description"]].filter(Boolean).join(", ") || undefined)} aria-readonly={editable ? !canEditCell(col, row) : undefined} data-col={col.key} data-numeric={col.numeric ? "" : undefined} data-focused-col={focusedCol || undefined} data-rule={rule?.["data-rule"]} data-tone={rule?.["data-tone"]} data-editable={editable && canEditCell(col, row) ? "" : undefined} data-editing={editing || undefined} data-pending={status?.kind === "pending" || undefined} data-rejected={rejected ?? undefined} title={rejected ?? (typeof content === "string" ? content : undefined)} className={cn( "flex h-full min-w-0 items-center", editing ? "relative overflow-visible" : "truncate px-2", alignClass(col), col.numeric && cn("justify-end", numericClass), col.align === "center" && "justify-center", flash === "ring" ? RING_CLASSES : flash === "fill" ? FILL_CLASSES : undefined, // Before the frozen position, so a frozen cell stays sticky under the bar's `relative`. selectedBar && cn("relative", SELECTED_BAR), left !== undefined && "sticky z-10 bg-background", // On a frozen cell the column's tint is a background image, so the cell keeps its opaque background under it. focusedCol && (left !== undefined ? "bg-[linear-gradient(color-mix(in_oklab,var(--muted)_50%,transparent),color-mix(in_oklab,var(--muted)_50%,transparent))]" : "bg-muted/50"), // The underline outlasts a rule's tint, which sets the text color. status?.kind === "pending" && "text-muted-foreground italic underline decoration-dotted underline-offset-2", rejected !== null && "text-destructive", // A frozen cell paints an opaque background over the row's, so it takes the row's own decoration too: a rule's // tint, and your class from getRowProps, a tint or a mark, or the row would show a hole at the frozen columns. // A cell rule's own decoration comes last, so it wins over the row's where both set the same thing. left !== undefined ? cn(rowRule?.className, rowClassName, rule?.className) : rule?.className, // The keyboard's cell carries its own mark: the column's tint is too faint to find it by. active && !editing && "outline-2 -outline-offset-2 outline-foreground", )} style={left !== undefined ? { left } : undefined} > {editing ? ( <CellEditor rowId={rowId} colKey={col.key} label={header} status={status} numeric={Boolean(col.numeric)} className={numericClass} edits={edits!} /> ) : ( // Beside a refusal the value keeps its digits and the message is what gives way. <span className={cn("truncate", rejected !== null && "shrink-0")}>{content}</span> )} {rejected !== null && <span data-edit-message="" className="ml-1 truncate">{rejected}</span>} </div> ) } interface RowProps<T> { store: RowStore<T> id: RowId index: number domId: string /** The grid's id, which its cells' ids start with. */ uid: string columns: Resolved<T>[] template: string lefts: (number | undefined)[] width: number height: number start: number selected: boolean focused: boolean focusedColKey: string | null selectionColumn: boolean selectable: boolean selectLabel: string onToggle: (id: RowId) => void memory: FlashMemory flashVariant: "fill" | "ring" flashWindowMs: number entered: Map<RowId, EnterMark> highlightEnter: boolean getRowProps?: (row: T, id: RowId) => RowDecoration | undefined getRowLabel?: ((row: T, id: RowId) => string) | null rules: AppliedRules<T> | null edits: EditController | null pendingLabel: string } function RowInner<T>(p: RowProps<T>) { const row = useRow(p.store, p.id) const ref = useRef<HTMLDivElement>(null) useLayoutEffect(() => { const mark = p.entered.get(p.id) if (p.highlightEnter && mark !== undefined && ref.current) { const now = Date.now() // A mark still parked by a running hold keeps waiting: its window opens at // the release, and the grid's release sweep plays it — overscan rows mount // while parked and must not burn their flash off screen. if (mark.until !== null && now < mark.until) return p.entered.delete(p.id) // A row that arrived off-view and scrolls in later flashes only the remainder // of its window — measured from the hold release when one parked it — and an // arrival from minutes ago is not news. const elapsed = now - enterStart(mark, now) if (elapsed < ENTER_WINDOW_MS) playFlash(ref.current, "flat", { windowMs: ENTER_WINDOW_MS, variant: "fill", elapsed }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) if (row === undefined) return null const extra = p.getRowProps?.(row, p.id) // A row rule paints under whatever your own props say: yours are read last, so they win a class. const rule = p.rules?.getRowProps(row) const rowLabel = p.getRowLabel?.(row, p.id) || undefined // Each cell's id once per render, for the row's label and the cell alike. const cellIds = p.columns.map((_, i) => cellDomId(p.uid, p.id, i)) return ( <div ref={ref} role="row" id={p.domId} data-row-id={p.id} data-state={extra?.["data-state"]} data-rule={extra?.["data-rule"] ?? rule?.["data-rule"]} data-tone={extra?.["data-tone"] ?? rule?.["data-tone"]} // Your words for the row, then a rule's, so neither hides the other. aria-description={[extra?.["aria-description"], rule?.["aria-description"]].filter(Boolean).join(", ") || undefined} aria-label={rowLabel} // Without a label of its own a row is named by its data cells, never by its selection box. aria-labelledby={rowLabel ? undefined : cellIds.join(" ") || undefined} data-focused={p.focused || undefined} aria-rowindex={p.index + 2} aria-selected={p.selectable ? p.selected : undefined} className={cn( // An editor's problem shows over the row above, the sticky header included. "absolute top-0 left-0 grid items-stretch border-b border-border/60 has-[[data-cell-problem]]:z-40", FILL_CLASSES, // Focus marks in the foreground, which clears 3 to 1 on every surface in every palette; shadcn's default ring // doesn't against white. p.focused && "outline-1 -outline-offset-1 outline-foreground", rule?.className, extra?.className, )} style={{ gridTemplateColumns: p.template, width: p.width, height: p.height, transform: `translateY(${p.start}px)` }} > {p.selectionColumn && ( <div role="gridcell" aria-colindex={1} className={cn("sticky left-0 z-10 flex items-center justify-center bg-background", p.selected && SELECTED_BAR)}> <Checkbox tabIndex={-1} checked={p.selected} onCheckedChange={() => p.onToggle(p.id)} aria-label={p.selectLabel} /> </div> )} {p.columns.map((col, i) => ( <Cell key={col.key} col={col} row={row} rowId={p.id} id={cellIds[i]!} colIndex={i + (p.selectionColumn ? 1 : 0)} left={p.lefts[i]} memory={p.memory} flashVariant={p.flashVariant} flashWindowMs={p.flashWindowMs} focusedCol={p.focusedColKey === col.key} selectedBar={p.selected && !p.selectionColumn && i === 0} rules={p.rules} rowRule={rule} rowClassName={extra?.className} edits={p.edits} pendingLabel={p.pendingLabel} active={p.focused && p.focusedColKey === col.key} /> ))} </div> ) } const Row = memo(RowInner) as typeof RowInner interface FooterProps<T> { store: RowStore<T> ids: readonly RowId[] columns: Resolved<T>[] template: string lefts: (number | undefined)[] width: number height: number footer: Record<string, (rows: T[]) => string> selectionColumn: boolean rowIndex: number } // The totals row. Its own component on the store's meta, which changes once per applied batch: the // view's rows are read once and each column's function runs once, at the feed's pace and never per // frame or per row. Nothing here flashes. function FooterInner<T>(p: FooterProps<T>) { const meta = useStoreMeta(p.store) const values = useMemo(() => { void meta.version const rows: T[] = [] for (const id of p.ids) { const row = p.store.getRow(id) if (row !== undefined) rows.push(row) } const out = new Map<string, string>() for (const col of p.columns) { const total = p.footer[col.key] if (total) out.set(col.key, total(rows)) } return out }, [meta.version, p.ids, p.columns, p.footer, p.store]) return ( <div role="row" aria-rowindex={p.rowIndex} data-grid-footer="" className="sticky bottom-0 z-20 mt-auto grid border-t border-border bg-background font-medium" style={{ gridTemplateColumns: p.template, width: p.width, height: p.height }}> {p.selectionColumn && <div role="gridcell" aria-colindex={1} className="sticky left-0 z-10 bg-background" />} {p.columns.map((col, i) => { const left = p.lefts[i] const text = values.get(col.key) return ( <div key={col.key} role="gridcell" aria-colindex={i + (p.selectionColumn ? 1 : 0) + 1} data-col={col.key} data-numeric={col.numeric ? "" : undefined} title={text} className={cn("flex h-full min-w-0 items-center truncate px-2", alignClass(col), col.numeric && cn("justify-end", col.font === "mono" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS), col.align === "center" && "justify-center", left !== undefined && "sticky z-10 bg-background")} style={left !== undefined ? { left } : undefined} > <span className="truncate">{text}</span> </div> ) })} </div> ) } const FooterRow = memo(FooterInner) as typeof FooterInner export function DataGrid<T>(props: DataGridProps<T>) { const { store, columns, label, className, emptyState, getRowProps, getRowLabel, onRowActivate, renderContextMenu, footer, onEdit, initialRect, overscan = 8, baseState = EMPTY_COLUMN_STATE, } = props const preset = DATA_GRID_PRESETS[props.preset ?? "blotter"] const rowHeight = props.rowHeight ?? preset.rowHeight const selectionMode = props.selectionMode ?? preset.selectionMode const selectionColumn = Boolean(props.selectionColumn) && selectionMode === "multi" const rowEnter = { ...preset.rowEnter, ...props.rowEnter } const announceRowCount = props.announceRowCount ?? preset.announceRowCount // A window that isn't a finite length of zero or more would throw from the flash animation: the default stands in. const flashWindowMs = Number.isFinite(props.flashWindowMs) && props.flashWindowMs! >= 0 ? props.flashWindowMs! : 900 // A word left undefined keeps its default. const labels = useMemo(() => ({ ...DEFAULT_DATA_GRID_LABELS, ...Object.fromEntries(Object.entries(props.labels ?? {}).filter(([, word]) => word !== undefined)) }) as DataGridLabels, [props.labels]) // The words a timer says later, read when it fires. const latestLabels = useRef(labels) useInsertionEffect(() => { latestLabels.current = labels }) const reorderHoldMs = props.reorderHoldMs ?? preset.reorderHoldMs const [sort, setSort] = useControllable(props.sort, props.onSortChange, null as SortState) const [columnState, setColumnState] = useControllable(props.columnState, props.onColumnStateChange, baseState) const [selection, setSelection] = useControllable(props.selection, props.onSelectionChange, EMPTY_SET) const [focusedRowId, setFocusedRowId] = useControllable<RowId | null>(props.focusedRowId, props.onFocusedRowChange, null) const [focusedColKey, setFocusedColKey] = useState<string | null>(null) const anchorRef = useRef<RowId | null>(null) // The view: yours, or one made from sort, filter, and the rules. A header sort comes first and the // rules' order breaks its ties; every filter rule has to hold, along with your own filter. const ruleSort = props.rules?.sort const ruleFilter = props.rules?.filter const ruleColumns = props.rules?.columns const comparator = useMemo(() => chainComparators(comparatorFor(columns, sort), ruleSort?.length ? compileComparator(ruleSort, columns) : undefined), [columns, sort, ruleSort]) const ownFilter = props.filter const filter = useMemo(() => { const byRules = ruleFilter?.length ? compileFilter(ruleFilter, columns) : undefined if (!byRules) return ownFilter if (!ownFilter) return byRules return (row: T) => ownFilter(row) && byRules(row) }, [ownFilter, ruleFilter, columns]) const rules = useMemo(() => (ruleColumns?.length ? applyRules(ruleColumns, columns) : null), [ruleColumns, columns]) const ownOptions = useMemo(() => (props.view ? null : { comparator, filter, reorderHoldMs }), [props.view, comparator, filter, reorderHoldMs]) const ownView = useView(store, ownOptions) const view = props.view ?? ownView! const ids = useRowIds(view) const indexOf = useMemo(() => new Map(ids.map((id, i) => [id, i] as const)), [ids]) // A focused row the view lets go of hands focus to the row now at its place, the last row if it was last, so the keys // go on from where the trader was and a screen reader hears where focus went. Only a row the view held: focus a parent // sets on a row that hasn't arrived yet waits for it. const focusedAt = useRef<{ id: RowId; index: number } | null>(null) useLayoutEffect(() => { if (focusedRowId === null) { focusedAt.current = null return } const at = indexOf.get(focusedRowId) if (at !== undefined) { focusedAt.current = { id: focusedRowId, index: at } return } const was = focusedAt.current if (was?.id !== focusedRowId || ids.length === 0) return const index = Math.min(was.index, ids.length - 1) // The row asked for is the one recorded now: a parent that keeps the departed id is asked once, and one that applies // the row asked for after it has left too gets another. focusedAt.current = { id: ids[index]!, index } setFocusedRowId(ids[index]!) }) const resolved = useMemo(() => resolveColumns(columns, columnState), [columns, columnState]) if (focusedColKey !== null && !resolved.some(column => column.key === focusedColKey)) setFocusedColKey(null) const lefts = useMemo(() => { let x = selectionColumn ? SELECT_WIDTH : 0 return resolved.map((c) => { if (c.frozen !== "left") return undefined const left = x x += c.width return left }) }, [resolved, selectionColumn]) const template = useMemo(() => (selectionColumn ? `${SELECT_WIDTH}px ` : "") + resolved.map((c) => `${c.width}px`).join(" "), [resolved, selectionColumn]) const totalWidth = useMemo(() => resolved.reduce((s, c) => s + c.width, selectionColumn ? SELECT_WIDTH : 0), [resolved, selectionColumn]) const memory = useMemo(() => createFlashMemory(), []) const scrollRef = useRef<HTMLDivElement>(null) const rootRef = useRef<HTMLDivElement>(null) const visibleEditColumns = useRef<ReadonlySet<string>>(EMPTY_SET) const editorFocusChecks = useRef(new Set<{ key: string; unavailable: boolean }>()) // Scrolls a row into view. Set once the virtualizer exists; the controller calls it when an editor opens. const revealRow = useRef<(rowId: RowId) => void>(() => {}) // The column layout as of this commit, published before any layout effect runs, so a reveal called from a cell's own // layout effect, in the commit that adds or moves its column, measures the new layout. const layout = useRef({ resolved, selectionColumn }) useInsertionEffect(() => { layout.current = { resolved, selectionColumn } }) // Sideways, a column comes clear of the frozen ones on the left and inside the viewport's right edge, its left edge // first when it is wider than the space between. A frozen column is always in view. const revealColumn = useRef((key: string) => { const el = scrollRef.current const { resolved, selectionColumn } = layout.current const i = resolved.findIndex((c) => c.key === key) if (!el || i < 0 || resolved[i]!.frozen === "left") return const lead = selectionColumn ? SELECT_WIDTH : 0 let x = lead let stuck = lead for (let j = 0; j < resolved.length; j++) { if (j < i) x += resolved[j]!.width if (resolved[j]!.frozen === "left") stuck += resolved[j]!.width } const start = x - stuck const end = x + resolved[i]!.width - el.clientWidth if (el.scrollLeft > start) el.scrollLeft = start else if (el.scrollLeft < end) el.scrollLeft = Math.min(end, start) }) // A column moved or resized by the keys or its header menu, with the order and width the request gives the columns // and the column focus it was asked under: the commit that lays the columns out exactly so, however late a controlled // parent applies the change, brings the column into view, and no other commit does. const columnToReveal = useRef<{ key: string; order: readonly string[]; width: number; focus: string | null } | null>(null) // Editing: one controller for the grid's life, reading the latest columns and onEdit through a ref, so the // memoized rows are handed one object and never re-render for it. Null without `onEdit`: nothing opens. const editLatest = useRef({ columns, resolved, onEdit, inView: (rowId: RowId) => indexOf.has(rowId), rejected: labels.rejected, notEditable: labels.notEditable }) useInsertionEffect(() => { editLatest.current = { columns, resolved, onEdit, inView: (rowId: RowId) => indexOf.has(rowId), rejected: labels.rejected, notEditable: labels.notEditable } }) const editable = Boolean(onEdit) const edits = useMemo<EditController | null>(() => { if (!editable) return null const tracker = createEditTracker() let activeColumn: string | null = null const cellsByKey = new Map<string, RowId>() const column = (key: string) => editLatest.current.columns.find((c) => c.key === key) const focusGrid = () => rootRef.current?.focus({ preventScroll: true }) // A check reads the row as it shows: a value another of its cells sent is the one in view until the row brings it back. const withPending = (rowId: RowId, how: EditCommit): EditCommit => ({ ...how, pending: (key) => { const now = tracker.get(cellKey(rowId, key)) const out = now?.kind === "editing" ? now.prior : now return out?.kind === "pending" ? out.value : undefined }, }) const send = (rowId: RowId, col: ColumnDef<T> & { edit: CellEdit<T> }, row: T, value: unknown) => { const previous = col.accessor(row) const k = cellKey(rowId, col.key) if (Object.is(value, previous)) { tracker.set(k, undefined) return } tracker.set(k, { kind: "pending", value, text: editText(col, value, row), tracked: false }) let result: void | Promise<unknown> try { result = editLatest.current.onEdit?.({ rowId, key: col.key, value, previous, row }) } catch (error) { tracker.set(k, { kind: "rejected", value: previous, message: messageOf(error, editLatest.current.rejected) }) return } if (result && typeof (result as Promise<unknown>).then === "function") { const started = tracker.get(k) if (started?.kind === "pending") tracker.set(k, { ...started, tracked: true }) ;(result as Promise<unknown>).then( () => { const now = tracker.get(k) if (now?.kind === "pending" && Object.is(now.value, value)) tracker.set(k, undefined) // A reopened untouched editor covers the pending status as prior: the // settle rewrites it there, or the close would restore a pending with // no live promise behind it. else if (now?.kind === "editing" && now.prior?.kind === "pending" && Object.is(now.prior.value, value)) tracker.set(k, { ...now, prior: undefined }) }, (error: unknown) => { const now = tracker.get(k) if (now?.kind === "pending" && Object.is(now.value, value)) tracker.set(k, { kind: "rejected", value: previous, message: messageOf(error, editLatest.current.rejected) }) else if (now?.kind === "editing" && now.prior?.kind === "pending" && Object.is(now.prior.value, value)) tracker.set(k, { ...now, prior: { kind: "rejected", value: previous, message: messageOf(error, editLatest.current.rejected) } }) }, ) } } const moveOn = (rowId: RowId, key: string, move: 1 | -1 | undefined) => { if (!move) return focusGrid() const row = store.getRow(rowId) const list = editLatest.current.resolved let i = list.findIndex((c) => c.key === key) + move for (; i >= 0 && i < list.length; i += move) { const next = list[i]! if (canEditCell(next, row) && !next.edit.toggle) { setFocusedColKey(next.key) return open(rowId, next.key) } } focusGrid() } function open(rowId: RowId, key: string, typed?: string) { if (!visibleEditColumns.current.has(key)) return // A retained handle must not open an editor on a row the view no longer holds: // it would mount and take focus whenever the row next returned. if (!editLatest.current.inView(rowId)) return const col = column(key) const row = store.getRow(rowId) if (!canEditCell(col, row) || col.edit.toggle) return cellsByKey.set(cellKey(rowId, key), rowId) // Sideways as the row is below: the editor opens with its column in sight. revealColumn.current(key) const k = cellKey(rowId, key) const before = tracker.editing() if (before && before !== k) { const covered = tracker.get(before) cellsByKey.delete(before) tracker.set(before, covered?.kind === "editing" ? covered.prior : undefined) } // A pending cell reopens on what it shows, the committed value, not on the value the store still holds. const now = tracker.get(k) // Reopening the cell already being edited is a request for focus, not a reset: // the draft, its problem, and what the editor covers all stay. A blur always // resolves the editor, so one open without focus comes from a remount (above). if (now?.kind === "editing" && typed === undefined) { activeColumn = key tracker.set(k, { ...now, selectAll: true, focused: false }) revealRow.current(rowId) return } const text = now?.kind === "pending" ? now.text : editText(col, col.accessor(row!), row!) activeColumn = key // The status this editor replaced comes back if it closes untouched: reopening a // pending cell and leaving must not erase a promise-backed pending or a later // rejection. A pending with no live promise can never settle, so reopening // dismisses it the way v1 did — any close then clears the cell. const covered = now?.kind === "editing" ? now.prior : now const prior = covered?.kind === "pending" && !covered.tracked ? undefined : covered tracker.set(k, { kind: "editing", text: typed ?? text, problem: null, selectAll: typed === undefined, initial: typed === undefined ? text : null, focused: false, prior, session: ++editorOpenings }) // The editing row is always rendered (see `keep` below), so its editor mounts and takes focus now; bring it // to where the keys are, which for a row scrolled out of the window is not where the viewport is. revealRow.current(rowId) } const controller: EditController = { tracker, editorFocusFell: { current: false }, reconcileColumns(keys) { const active = tracker.editing() if (active !== null && activeColumn !== null && !keys.has(activeColumn)) { const covered = tracker.get(active) cellsByKey.delete(active) tracker.set(active, covered?.kind === "editing" ? covered.prior : undefined) } }, unmountEditor(key, input) { const doc = input.ownerDocument // Asked of the input's own tree: inside a shadow root the document's active element is the host. const root = focusRoot(input) if (root.activeElement !== input) return controller.editorFocusFell.current = true const check = { key, unavailable: !visibleEditColumns.current.has(key) } editorFocusChecks.current.add(check) // Remember the removal commit even if the column returns before this focus check runs. queueMicrotask(() => { editorFocusChecks.current.delete(check) const grid = rootRef.current if (input.isConnected || !check.unavailable || !grid?.isConnected || grid.ownerDocument !== doc) return if (!focusFell(root, doc, input)) return focusGrid() }) }, open, rowOf: (k: string) => cellsByKey.get(k), forget: (k: string) => void cellsByKey.delete(k), markFocused(rowId, key) { const k = cellKey(rowId, key) const now = tracker.get(k) if (now?.kind === "editing" && !now.focused) tracker.set(k, { ...now, focused: true }) }, type(rowId, key, text) { const k = cellKey(rowId, key) const now = tracker.get(k) if (now?.kind === "editing") tracker.set(k, { ...now, text, problem: null }) }, commit(rowId, key, how, move) { const k = cellKey(rowId, key) const now = tracker.get(k) if (now?.kind !== "editing") return // Opened and left unchanged: a format that rounds must not turn looking into // an edit, so the untouched text sends nothing and the covered state comes // back — ahead of the permission check, so a revocation mid-look cannot // erase a pending or rejected state the way it refuses a real change. if (now.initial != null && now.text === now.initial) { cellsByKey.delete(k) tracker.set(k, now.prior) moveOn(rowId, key, move) return } const col = column(key) const row = store.getRow(rowId) if (!canEditCell(col, row)) { // The row is still here and the server stopped allowing the edit: the typed text stays, saying so, until // Escape or the edit is allowed again. A row or column that left takes the editor with it. if (row !== undefined && col?.edit) return tracker.set(k, { ...now, problem: editLatest.current.notEditable }) cellsByKey.delete(k) tracker.set(k, now.prior) return focusGrid() } const parsed = col.edit.parse(now.text, row!) const problem = isEditProblem(parsed) ? parsed : col.edit.validate?.(parsed, row!, withPending(rowId, how)) if (problem) { tracker.set(k, { ...now, problem: problem.problem }) return } cellsByKey.delete(k) send(rowId, col, row!, parsed) moveOn(rowId, key, move) }, commitValue(rowId, key, value, repeat = false) { const col = column(key) const row = store.getRow(rowId) if (!canEditCell(col, row)) return const problem = col.edit.validate?.(value, row!, withPending(rowId, { via: "value", repeat, session: 0 })) if (problem) { tracker.set(cellKey(rowId, key), { kind: "rejected", value: col.accessor(row!), message: problem.problem }) return } send(rowId, col, row!, value) }, cancel(rowId, key) { const k = cellKey(rowId, key) cellsByKey.delete(k) const now = tracker.get(k) if (now?.kind === "editing") tracker.set(k, now.prior) focusGrid() }, step(rowId, key, dir, big) { const k = cellKey(rowId, key) const now = tracker.get(k) const col = column(key) const row = store.getRow(rowId) if (now?.kind !== "editing" || !canEditCell(col, row) || !col.edit.step) return const typed = col.edit.parse(now.text, row!) const from = isEditProblem(typed) ? col.accessor(row!) : typed const next = col.edit.step(from, dir, big, row!) tracker.set(k, { ...now, text: editText(col, next, row!), problem: null }) }, blur(rowId, key) { const k = cellKey(rowId, key) cellsByKey.delete(k) const now = tracker.get(k) if (now?.kind !== "editing") return if (now.initial != null && now.text === now.initial) return tracker.set(k, now.prior) const col = column(key) const row = store.getRow(rowId) if (!canEditCell(col, row)) return tracker.set(k, now.prior) const parsed = col.edit.parse(now.text, row!) if (isEditProblem(parsed) || col.edit.validate?.(parsed, row!, withPending(rowId, { via: "blur", repeat: false, session: now.session ?? 0 }))) return tracker.set(k, now.prior) send(rowId, col, row!, parsed) }, settle(rowId, key) { const k = cellKey(rowId, key) if (tracker.get(k)?.kind === "pending") tracker.set(k, undefined) }, settlePrior(rowId, key) { const k = cellKey(rowId, key) const now = tracker.get(k) if (now?.kind === "editing" && now.prior?.kind === "pending") tracker.set(k, { ...now, prior: undefined }) }, } return controller }, [editable, store]) // Publish committed visibility before custom cells run layout effects; notify trackers in layout. useInsertionEffect(() => { visibleEditColumns.current = edits ? new Set(resolved.filter(col => col.edit && !col.edit.toggle).map(col => col.key)) : EMPTY_SET for (const check of editorFocusChecks.current) { if (!visibleEditColumns.current.has(check.key)) check.unavailable = true } }, [edits, resolved]) useLayoutEffect(() => { edits?.reconcileColumns(visibleEditColumns.current) }, [edits, resolved]) // A row that a filter or removal takes out of the view takes its open editor // with it; the editor cannot then grab focus back when the row returns, and focus // its unmount dropped on body comes home to the grid — only then: a feed commit // that closes a never-focused editor moves nothing. useLayoutEffect(() => { if (!edits) return // Read-and-clear up front: the flag answers for this commit's unmount only, // never for a Tab chain or scroll-out from some earlier one. const fell = edits.editorFocusFell.current edits.editorFocusFell.current = false const k = edits.tracker.editing() if (k === null) return const rowId = edits.rowOf(k) if (rowId !== undefined && !indexOf.has(rowId)) { const covered = edits.tracker.get(k) edits.forget(k) edits.tracker.set(k, covered?.kind === "editing" ? covered.prior : undefined) const grid = rootRef.current if (fell && grid && focusFell(focusRoot(grid), grid.ownerDocument)) grid.focus({ preventScroll: true }) } }, [edits, indexOf]) // The row whose editor is open, so the grid renders again when an editor opens on another row, or closes. const subscribeOpenEditor = useCallback((cb: () => void) => (edits ? edits.tracker.subscribeOpen(cb) : noopSubscribe()), [edits]) const openEditor = useSyncExternalStore(subscribeOpenEditor, () => edits?.tracker.editing() ?? null, () => null) const editingRowId = openEditor === null ? undefined : edits?.rowOf(openEditor) // The focused row and the row being edited are rendered wherever the viewport is: aria-activedescendant names an // element that exists, Shift+F10 opens on a row that is there, and an editor scrolled or re-sorted out of the window // keeps its input, its draft, and its focus instead of dropping them on the page. const keep = useMemo(() => { const kept: number[] = [] for (const id of [focusedRowId, editingRowId]) { const i = id === null || id === undefined ? undefined : indexOf.get(id) if (i !== undefined && !kept.includes(i)) kept.push(i) } return kept }, [focusedRowId, editingRowId, indexOf]) const rangeExtractor = useCallback((range: Range) => { const indexes = defaultRangeExtractor(range) const extra = keep.filter((i) => i < range.count && !indexes.includes(i)) return extra.length ? [...indexes, ...extra].sort((a, b) => a - b) : indexes }, [keep]) const virtualizer = useVirtualizer({ count: ids.length, getScrollElement: () => scrollRef.current, estimateSize: () => rowHeight, overscan, getItemKey: (i) => ids[i]!, rangeExtractor, initialRect, // The sticky header sits in normal flow above the rows, so row i really starts at // (i+1) x rowHeight inside the scroller: scrollMargin tells the virtualizer so, and // the paddings keep an aligned row clear of the sticky header above and the sticky // footer below. Rows keep rendering at v.start - scrollMargin inside the rowgroup, // which itself sits after the header, so nothing moves visually. scrollMargin: rowHeight, scrollPaddingStart: rowHeight, scrollPaddingEnd: footer ? rowHeight : 0, }) const items = virtualizer.getVirtualItems() // Published before any layout effect runs, so an editor a cell opens from its own layout effect, in the commit that // adds its row, scrolls to where the row now is. useInsertionEffect(() => { revealRow.current = (rowId) => { const i = indexOf.get(rowId) if (i !== undefined) virtualizer.scrollToIndex(i, { align: "auto" }) } }) // A row focus moves to, by the keys, a parent, or a hand-off, comes into view, as does a row focus waited for when it // arrives; a row already in view stays where it is. const focusedInView = focusedRowId !== null && indexOf.has(focusedRowId) useLayoutEffect(() => { if (focusedRowId !== null && focusedInView) revealRow.current(focusedRowId) }, [focusedRowId, focusedInView]) useLayoutEffect(() => { const pending = columnToReveal.current if (pending !== null) { const i = resolved.findIndex((c) => c.key === pending.key) if (pending.focus !== focusedColKey || i < 0) columnToReveal.current = null else if (resolved.length === pending.order.length && resolved.every((c, k) => c.key === pending.order[k]) && resolved[i]!.width === pending.width) { columnToReveal.current = null revealColumn.current(pending.key) } } }) const uid = useId() // Escaped, so any row id makes one IDREF, and prefixed apart from the cells' ids, so none collide. const domId = useCallback((id: RowId) => `${uid}-r-${idPart(id)}`, [uid]) // A tape follows its tail: new rows land at the end and the viewport goes there after every commit, // until a key, a pointer, or a scroll away from the end stops it. Then the arrivals count up on a // pill, and pressing it, or scrolling back to the end, follows again. Both are state set from // handlers, so the effect below only reads them. const followTail = Boolean(rowEnter.followTail) const [following, setFollowing] = useState(true) // The store's rows when following stopped: the button counts the rows in view since that the store didn't hold, so a // filter letting rows back in, or rows leaving, never reads as news. const [pausedStore, setPausedStore] = useState<ReadonlySet<RowId> | null>(null) const behind = useMemo(() => (followTail && !following && pausedStore ? ids.reduce((n, id) => (pausedStore.has(id) ? n : n + 1), 0) : 0), [followTail, following, pausedStore, ids]) useLayoutEffect(() => { if (followTail && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight }, [followTail]) // Arrival marks, in the insertion phase: all insertion effects for a commit run // before any layout effect, so a row mounting in the commit of its arrival finds // its mark — a mount-only layout effect in the row checks after this has written. // A mark parked by a reorder hold carries that hold's deadline, captured at // arrival: its window starts when the hold lapses, however late the release is // observed, and no later hold can touch it. The grid's own touches extend the // deadlines of marks still parked, synchronously, where an extension cannot be // mistaken for a new hold; extending a supplied view directly leaves them, so // that extension shortens the remainder rather than restarting it. const entered = useRef(new Map<RowId, EnterMark>()).current // After a touch extends the hold, move still-parked marks to the new deadline. // Only unexpired marks move: one whose hold already lapsed belongs to a finished // hold, and the touch that follows starts a new one that must not revive it. const refreshParkedMarks = () => { const deadline = view.isHeld() ? (view.holdExpiresAt?.() ?? null) : null if (deadline === null) return const now = Date.now() for (const [id, mark] of entered) if (mark.until !== null && mark.until > now && mark.until < deadline) entered.set(id, { at: mark.at, until: deadline }) scheduleReleaseSweep() } // A release that reorders nothing publishes nothing, so no commit observes it: // the deadline timer plays those flashes. Rendered commits sweep synchronously // and the timer re-arms for whatever is still parked. const sweepTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const highlightRef = useRef(rowEnter.highlight) useInsertionEffect(() => { highlightRef.current = rowEnter.highlight }, [rowEnter.highlight]) const sweepDueMarks = useCallback(() => { // The ref, not the closure: a timer armed before highlighting turned off must // not flash on a stale true while the stand-down effect is still queued. if (!highlightRef.current || !scrollRef.current) return const now = Date.now() let due: Map<RowId, number> | null = null for (const [id, mark] of entered) { if (mark.until !== null && now >= mark.until) { const elapsed = now - enterStart(mark, now) if (elapsed < ENTER_WINDOW_MS) (due ??= new Map()).set(id, elapsed) else entered.delete(id) } } if (due) { for (const el of scrollRef.current.querySelectorAll<HTMLElement>("[data-row-id]")) { const id = el.dataset.rowId const elapsed = id !== undefined ? due.get(id) : undefined // A nested grid's rows can share ids with this one: the DOM id says whose row this is. if (id !== undefined && elapsed !== undefined && el.id === domId(id)) { entered.delete(id) playFlash(el, "flat", { windowMs: ENTER_WINDOW_MS, variant: "fill", elapsed }) } } } }, [entered, domId]) const scheduleReleaseSweep = useCallback(() => { if (sweepTimerRef.current !== null) clearTimeout(sweepTimerRef.current) sweepTimerRef.current = null const now = Date.now() let next = Infinity for (const [, mark] of entered) if (mark.until !== null && mark.until > now) next = Math.min(next, mark.until) if (next === Infinity) return sweepTimerRef.current = setTimeout(() => { sweepTimerRef.current = null sweepDueMarks() scheduleReleaseSweep() }, Math.min(next - now, 2 ** 31 - 1)) }, [entered, sweepDueMarks]) useEffect(() => () => { if (sweepTimerRef.current !== null) clearTimeout(sweepTimerRef.current) }, []) useEffect(() => { if (rowEnter.highlight) return // Highlighting turned off: an armed timer would fire its old closure and // flash anyway, and unobserved marks would linger. Stand both down. if (sweepTimerRef.current !== null) clearTimeout(sweepTimerRef.current) sweepTimerRef.current = null entered.clear() }, [rowEnter.highlight, entered]) const prevIdsRef = useRef<readonly RowId[]>(ids) const markIdsRef = useRef<readonly RowId[]>(ids) const newSinceAnnounce = useRef(0) // One diff per commit: the insertion effect computes it and the layout effect // consumes it, so an order change builds two id sets, not four. const diffRef = useRef<{ prev: readonly RowId[]; entering: RowId[]; arrived: RowId[]; departed: RowId[] } | null>(null) // The rows the store has held, from mount on: a row entering the view is an arrival only when the store didn't hold // it, so a row a filter lets back in neither flashes nor counts as new. Every row entering still moves the viewport. const knownIds = useRef<{ store: RowStore<T>; ids: Set<RowId> } | null>(null) useInsertionEffect(() => { const prev = markIdsRef.current // Another store starts known as it stands, so its rows don't all read as arrivals. if (knownIds.current?.store !== store) knownIds.current = { store, ids: new Set(store.getIds()) } const known = knownIds.current.ids if (prev === ids) return markIdsRef.current = ids const prevSet = new Set(prev) const nowSet = new Set(ids) const entering: RowId[] = [] const arrived: RowId[] = [] for (const id of ids) { if (prevSet.has(id)) continue entering.push(id) if (!known.has(id)) arrived.push(id) known.add(id) } const departed: RowId[] = [] for (const id of prev) { if (nowSet.has(id)) continue departed.push(id) if (store.getRow(id) === undefined) known.delete(id) } // A row the store let go of while out of view stays known: past twice the store, start again from what it holds. if (known.size > 2 * store.getIds().length + 64) knownIds.current = { store, ids: new Set(store.getIds()) } diffRef.current = { prev, entering, arrived, departed } if (!rowEnter.highlight) return const at = Date.now() const until = view.isHeld() ? (view.holdExpiresAt?.() ?? null) : null for (const id of arrived) entered.set(id, { at, until }) for (const [id, mark] of entered) { if (!nowSet.has(id)) entered.delete(id) else if (at - enterStart(mark, at) >= ENTER_WINDOW_MS) entered.delete(id) } if (until !== null) scheduleReleaseSweep() }, [ids, rowEnter.highlight, entered, view, store]) // The scroll work stays in the layout phase, where refs belong to this commit: // pin the viewport, follow the tail, count arrivals, forget departed flashes. useLayoutEffect(() => { const prev = prevIdsRef.current if (prev === ids) return // The insertion effect stashed this commit's diff; a StrictMode replay reaches // the early return above, so a stale stash is never consumed twice. const stash = diffRef.current const fresh = stash !== null && stash.prev === prev const entering = fresh ? stash.entering : [] // Without this commit's diff there's no telling arrivals from rows let back in, so none is counted as new. const arrived = fresh ? stash.arrived : [] const departed = fresh ? stash.departed : [] if (!fresh) { const prevSet = new Set(prev) const nowSet = new Set(ids) for (const id of ids) if (!prevSet.has(id)) (entering as RowId[]).push(id) for (const id of prev) if (!nowSet.has(id)) (departed as RowId[]).push(id) } diffRef.current = null for (const id of departed) memory.forget(`${id}\u0000`) if ((entering.length || departed.length) && rowEnter.pinViewport && scrollRef.current) { const el = scrollRef.current const firstIndex = Math.floor(el.scrollTop / rowHeight) const firstId = prev[firstIndex] const nextIndex = firstId !== undefined ? indexOf.get(firstId) : undefined if (nextIndex !== undefined && nextIndex !== firstIndex) el.scrollTop += (nextIndex - firstIndex) * rowHeight } if (entering.length && followTail && following && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight newSinceAnnounce.current += arrived.length prevIdsRef.current = ids }, [ids, indexOf, rowHeight, rowEnter.pinViewport, followTail, following, memory]) const stopFollowing = () => { if (!followTail || !following) return setFollowing(false) setPausedStore(new Set(store.getIds())) } const toTail = (e: MouseEvent<HTMLButtonElement>) => { // The button goes once following resumes: focus on it moves to the grid first, rather than fall to the page. // Inside a shadow root the document's active element is the host, so ask the root the button is in. if ((e.currentTarget.getRootNode() as Document | ShadowRoot).activeElement === e.currentTarget) rootRef.current?.focus({ preventScroll: true }) const el = scrollRef.current if (el) el.scrollTop = el.scrollHeight setFollowing(true) } const onScroll = followTail ? (e: UIEvent<HTMLDivElement>) => { const el = e.currentTarget const atTail = el.scrollTop + el.clientHeight >= el.scrollHeight - 1 if (atTail === following) return if (atTail) setFollowing(true) else stopFollowing() } : undefined const [announcement, setAnnouncement] = useState("") useEffect(() => { if (announceRowCount === "off") return const t = setTimeout(() => { const fresh = newSinceAnnounce.current newSinceAnnounce.current = 0 const { rowCount } = latestLabels.current setAnnouncement(typeof rowCount === "function" ? rowCount(ids.length, fresh) : fillLabel(rowCount, { n: ids.length, m: fresh })) }, 1000) return () => clearTimeout(t) }, [ids.length, announceRowCount]) useEffect(() => { if (!announcement) return const t = setTimeout(() => setAnnouncement(""), SPOKEN_MS) return () => clearTimeout(t) }, [announcement]) // A row named by `getRowLabel` keeps its name while its cells tick, so its cells are read here instead: once, when // focus rests on a row (another row, or the grid taking focus), and never because a cell changed. const readsRows = typeof getRowLabel === "function" const [reading, setReading] = useState("") const [arrivals, setArrivals] = useState(0) useEffect(() => { if (!readsRows || focusedRowId === null) return const t = setTimeout(() => { const root = rootRef.current // Inside a shadow root the document's active element is the host, so ask the root the grid is in. if (!root || (root.getRootNode() as Document | ShadowRoot).activeElement !== root) return const id = domId(focusedRowId) const row = Array.from(root.querySelectorAll<HTMLElement>('[role="row"][data-row-id]')).find((el) => el.id === id) const text = row ? rowReading(row) : "" // The same words again would change nothing a live region hears, so a repeat differs by an invisible character. if (text) setReading((was) => (was === text ? `${text}\u200b` : text)) }, ROW_READING_REST_MS) return () => clearTimeout(t) }, [readsRows, focusedRowId, arrivals, domId]) useEffect(() => { if (!reading) return const t = setTimeout(() => setReading(""), SPOKEN_MS) return () => clearTimeout(t) }, [reading]) // Selection and focus, by id. const select = useCallback( (targets: RowId[], mode: "replace" | "toggle" | "range") => { if (selectionMode === "none") return if (selectionMode === "single" || mode === "replace") { setSelection(new Set(targets.slice(-1))) anchorRef.current = targets[targets.length - 1] ?? null return } const next = new Set(selection) if (mode === "toggle") { for (const id of targets) if (next.has(id)) next.delete(id) else next.add(id) anchorRef.current = targets[targets.length - 1] ?? anchorRef.current } else { const to = targets[targets.length - 1]! const from = anchorRef.current ?? to // Capture and focus callbacks can publish a new order before this render commits. const currentIds = view.getIds() const a = currentIds === ids ? indexOf.get(from) ?? 0 : Math.max(0, currentIds.indexOf(from)) const b = currentIds === ids ? indexOf.get(to) ?? -1 : currentIds.indexOf(to) if (b < 0) return for (let i = Math.min(a, b); i <= Math.max(a, b); i++) next.add(currentIds[i]!) } setSelection(next) }, [ids, indexOf, selection, selectionMode, setSelection, view], ) const focusIndex = useCallback( (index: number, extend = false) => { if (!ids.length) return const i = Math.max(0, Math.min(ids.length - 1, index)) const id = ids[i]! setFocusedRowId(id) virtualizer.scrollToIndex(i, { align: "auto" }) if (extend && selectionMode === "multi") select([id], "range") else if (selectionMode === "single") select([id], "replace") }, [ids, select, selectionMode, setFocusedRowId, virtualizer], ) const cycleSort = useCallback( (key: string) => { const col = columns.find((c) => c.key === key) if (!col?.sortable) return setSort(!sort || sort.key !== key ? { key, dir: "asc" } : sort.dir === "asc" ? { key, dir: "desc" } : null) }, [columns, sort, setSort], ) const updateColumns = useCallback( (fn: (s: ColumnState) => ColumnState) => setColumnState(fn(columnState)), [columnState, setColumnState], ) const moveColumn = useCallback( (key: string, delta: -1 | 1) => { const order = resolved.map((c) => c.key) const i = order.indexOf(key) const j = i + delta if (i < 0 || j < 0 || j >= order.length) return ;[order[i], order[j]] = [order[j]!, order[i]!] updateColumns((s) => ({ ...s, order })) }, [resolved, updateColumns], ) const resizeColumn = useCallback( (key: string, width: number) => { const col = resolved.find((c) => c.key === key) if (!col) return // A drag or a key that leaves the width as it is, at the minimum, changes nothing. const next = Math.max(col.minWidth, Math.round(width)) if (next === col.width) return updateColumns((s) => ({ ...s, widths: { ...s.widths, [key]: next } })) }, [resolved, updateColumns], ) const hideColumn = useCallback((key: string) => updateColumns((s) => ({ ...s, hidden: [...new Set([...s.hidden, key])] })), [updateColumns]) const resetColumns = useCallback(() => setColumnState(baseState), [baseState, setColumnState]) // A move or resize the keys or a header menu ask for. A request that can't change the layout changes nothing: past // either end, across the frozen boundary (frozen columns lead whatever the order says), or under the minimum width. // Otherwise it's recorded before the state changes, so a commit the change flushes at once still finds it. const requestColumn = (key: string, change: { move: -1 | 1 } | { widen: number }) => { const i = resolved.findIndex((c) => c.key === key) const at = resolved[i] if (!at) return const order = resolved.map((c) => c.key) if ("move" in change) { const beside = resolved[i + change.move] if (!beside || (beside.frozen === "left") !== (at.frozen === "left")) return ;[order[i], order[i + change.move]] = [beside.key, at.key] columnToReveal.current = { key, order, width: at.width, focus: focusedColKey } moveColumn(key, change.move) } else { const width = Math.max(at.minWidth, Math.round(at.width + change.widen)) if (width === at.width) return columnToReveal.current = { key, order, width, focus: focusedColKey } resizeColumn(key, width) } } // A key that moves row focus brings the focused column back too, should a wheel have scrolled it away. const revealFocusedColumn = () => { if (focusedColKey !== null) revealColumn.current(focusedColKey) } // The sticky header and footer each cover one row of the viewport. const visibleCount = Math.max(1, Math.floor((scrollRef.current?.clientHeight ?? initialRect?.height ?? rowHeight * 10) / rowHeight) - 1 - (footer ? 1 : 0)) // The focused cell's column when it can be edited now, else undefined. const editableFocus = () => { if (!edits || focusedRowId === null || focusedColKey === null) return undefined const col = resolved.find((c) => c.key === focusedColKey) return canEditCell(col, store.getRow(focusedRowId)) ? col : undefined } const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { // Nested grids and portals own their keys, including reorder holds and following. const path = e.nativeEvent.composedPath() if (!gridTarget(e.currentTarget, e.target, path)) return // A key is interaction wherever in the grid it lands, the editor included: it holds the order, so a batch cannot // re-sort the row being typed into away from the hand while a hold lasts, and it stops a tape following its tail. view.touch() refreshParkedMarks() stopFollowing() // The editor owns its keys; what it lets through (a modifier-held arrow) is for the listeners above the grid. if (pathMatches(e.currentTarget, path, element => element.hasAttribute("data-cell-editor"))) return // Focused controls own their keys; application handlers can also claim a grid key in capture. if (e.defaultPrevented || e.target !== e.currentTarget) return const fi = focusedRowId !== null ? (indexOf.get(focusedRowId) ?? -1) : -1 const ci = focusedColKey !== null ? resolved.findIndex((c) => c.key === focusedColKey) : -1 const mod = e.metaKey || e.ctrlKey // Commands that act on the focused row require it to be in the current view, the // same bar aria-activedescendant holds: a row a filter or removal took out is not // silently activated, edited, selected, or menued. const focusedInView = fi >= 0 // Typing on an editable cell opens its editor with the character typed. if (focusedInView && !e.repeat && e.key.length === 1 && e.key !== " " && !mod && !e.altKey) { const col = editableFocus() if (col && !col.edit.toggle) { e.preventDefault() edits!.open(focusedRowId!, col.key, e.key) return } } const letter = altLetter(e) if ((letter === "s" || letter === "h") && focusedColKey) { e.preventDefault() // Once per press: a held Alt+S would cycle the sort on every repeat, and a parent that hasn't applied a hide yet // would be asked again on every repeat. if (e.repeat) return if (letter === "s") cycleSort(focusedColKey) else hideColumn(focusedColKey) return } switch (e.key) { case "ArrowDown": e.preventDefault() focusIndex(fi + 1, e.shiftKey) revealFocusedColumn() return case "ArrowUp": e.preventDefault() focusIndex(fi <= 0 ? 0 : fi - 1, e.shiftKey) revealFocusedColumn() return case "PageDown": e.preventDefault() focusIndex(fi + visibleCount, e.shiftKey) revealFocusedColumn() return case "PageUp": e.preventDefault() focusIndex(fi - visibleCount, e.shiftKey) revealFocusedColumn() return case "Home": e.preventDefault() focusIndex(0, e.shiftKey) revealFocusedColumn() return case "End": e.preventDefault() focusIndex(ids.length - 1, e.shiftKey) revealFocusedColumn() return case "ArrowLeft": case "ArrowRight": { e.preventDefault() const delta = e.key === "ArrowLeft" ? -1 : 1 if (e.altKey && focusedColKey) { // The commit that lays the columns out as asked brings the column into view; focus or a pointer moving on // first drops the request, and a layout the parent changes or never applies doesn't scroll the grid for it. requestColumn(focusedColKey, e.shiftKey ? { widen: delta * 8 } : { move: delta }) return } const next = resolved[Math.max(0, Math.min(resolved.length - 1, ci < 0 ? (delta > 0 ? 0 : resolved.length - 1) : ci + delta))] setFocusedColKey(next?.key ?? null) // Focus moved into a column whose place hasn't changed: bring it into view now, so a key typed next edits a // column in sight. if (next) revealColumn.current(next.key) return } case " ": { e.preventDefault() // A held Space acts once, as a native checkbox's does: each repeat would toggle the value the server // just acknowledged, or the row's selection, back again. if (!focusedInView || e.repeat) return const col = editableFocus() if (col?.edit.toggle) { const row = store.getRow(focusedRowId!)! // A toggle commits where it can be seen, as an editor opens. revealColumn.current(col.key) edits!.commitValue(focusedRowId!, col.key, col.edit.toggle(col.accessor(row), row), false) return } if (focusedRowId !== null) select([focusedRowId], selectionMode === "multi" ? "toggle" : "replace") return } case "Enter": case "F2": { if (!focusedInView) return const col = editableFocus() if (col) { e.preventDefault() // Once per press: a held key would commit a toggle again, or close and reopen an editor on each repeat. if (e.repeat) return const row = store.getRow(focusedRowId!)! if (col.edit.toggle) { revealColumn.current(col.key) edits!.commitValue(focusedRowId!, col.key, col.edit.toggle(col.accessor(row), row), false) } else edits!.open(focusedRowId!, col.key) return } if (e.key === "F2") return if (focusedRowId !== null) { const row = store.getRow(focusedRowId) // A held Enter activates the row once, as a held key commits a toggle once. if (row !== undefined && !e.repeat) onRowActivate?.(row, focusedRowId) } return } case "Escape": // Once per press: the repeats of an Escape that closed an editor land here and would clear the selection. if (!e.repeat && selectionMode !== "none" && selection.size) setSelection(EMPTY_SET) return case "a": case "A": if (mod && selectionMode === "multi") { e.preventDefault() if (!e.repeat) setSelection(new Set(ids)) } return case "ContextMenu": case "F10": if (e.key === "F10" && !e.shiftKey) return e.preventDefault() if (!e.repeat) openContextMenuAtFocus() return } } const openContextMenuAtFocus = () => { if (focusedRowId === null) return const root = rootRef.current const id = domId(focusedRowId) // The focused row renders even outside the window: bring it into view, so the menu opens where it can be seen. const index = indexOf.get(focusedRowId) if (index !== undefined) virtualizer.scrollToIndex(index, { align: "auto" }) const el = Array.from(root?.querySelectorAll<HTMLElement>('[role="row"][data-row-id]') ?? []).find(row => row.id === id) const ownerWindow = el?.ownerDocument.defaultView if (!el || !ownerWindow || !gridTarget(root, el)) return // At the focused cell, brought into view first, as a wheel may have scrolled it away, else the row's visible left // edge: scrolled sideways, the row's own left edge sits off to the left of the grid. if (focusedColKey !== null) revealColumn.current(focusedColKey) const r = el.getBoundingClientRect() const cell = focusedColKey === null ? undefined : Array.from(el.children).find((child) => child.getAttribute("data-col") === focusedColKey) const port = scrollRef.current?.getBoundingClientRect() const left = cell ? cell.getBoundingClientRect().left : Math.max(r.left, port?.left ?? r.left) el.dispatchEvent(new ownerWindow.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: left + 8, clientY: r.top + r.height / 2, button: 2 })) } const rowTarget = (event: { target: EventTarget; nativeEvent: Event }, includeShadowTarget = false) => { const path = event.nativeEvent.composedPath() const root = rootRef.current // Menu starts can reach the primitive before a shadow portal's host-targeted pass. const target = includeShadowTarget && !root?.contains(event.target as Node) ? path.find(entry => (entry as Node).nodeType === 1 && root?.contains(entry as Node)) ?? event.target : event.target const element = gridTarget(root, target, path) if (!element || pathMatches(root, path, element => element.matches(ROW_CONTROLS) || CONTROL_ROLES.has(roleOf(element) ?? ""))) return undefined const row = element.closest<HTMLElement>('[role="row"][data-row-id]') const id = row?.dataset.rowId if (id === undefined || row?.id !== domId(id) || !indexOf.has(id) || store.getRow(id) === undefined) return undefined // A capture handler may change membership before React commits the new rows. const currentIds = view.getIds() if (currentIds !== ids && !currentIds.includes(id)) return undefined return { element, id } } // The row the menu opens on, taken from the event that opens it, since a parent that controls focus may not // have moved focus there yet: a pointer down starts a touch's long press, and a contextmenu event — the // pointer's, or the one the keyboard sends to the focused row — opens it at once. const [menuRow, setMenuRow] = useState<RowId | null>(null) // Right-click targets the row under the pointer: focus it, and make it the selection unless it is already selected. const onContextMenu = (e: MouseEvent<HTMLDivElement>) => { const target = rowTarget(e, true) if (e.defaultPrevented || !target) { rejectMenuStart(rootRef.current, e) return } const { id } = target setMenuRow(id) setFocusedRowId(id) if (!selection.has(id)) select([id], "replace") } const onPointerDownCapture = (e: PointerEvent<HTMLDivElement>) => { if (!gridTarget(rootRef.current, e.target, e.nativeEvent.composedPath())) return columnToReveal.current = null view.touch() refreshParkedMarks() stopFollowing() } const onRowPointerDown = (e: PointerEvent<HTMLDivElement>) => { const target = rowTarget(e) if (e.defaultPrevented || !target) { // Radix starts long-press menus from pointerdown; Base UI uses touchstart below. if (renderContextMenu && e.pointerType !== "mouse" && (e.defaultPrevented || !rowTarget(e, true))) rejectMenuStart(rootRef.current, e) return } const { id } = target setMenuRow(id) setFocusedRowId(id) if (e.button !== 0) return if (e.shiftKey) select([id], "range") else if (e.metaKey || e.ctrlKey) select([id], "toggle") else select([id], "replace") } // A double click on an editable cell opens it; anywhere else on the row it activates the row. const onRowDoubleClick = (e: MouseEvent<HTMLDivElement>) => { if (e.defaultPrevented) return const target = rowTarget(e) if (!target) return const { element, id } = target const row = store.getRow(id) if (row === undefined) return const key = element.closest<HTMLElement>("[data-col]")?.dataset.col const col = key !== undefined ? resolved.find((c) => c.key === key) : undefined if (edits && canEditCell(col, row) && !col.edit.toggle) { setFocusedRowId(id) setFocusedColKey(col.key) edits.open(id, col.key) return } onRowActivate?.(row, id) } const toggle = useCallback((id: RowId) => select([id], "toggle"), [select]) // A rapid reopen must get fresh content even while the previous popup is still exiting. const [menuOpening, setMenuOpening] = useState(0) const contextRows = useMemo(() => { // With nothing selected the menu acts on the row it opened on, even when focus has since moved off a row that left. const opened = menuRow ?? focusedRowId const targets = selection.size ? [...selection] : opened !== null ? [opened] : [] return { opening: menuOpening, ids: targets, target: menuRow ?? focusedRowId, rows: targets.map((id) => store.getRow(id)).filter((r): r is T => r !== undefined) } }, [selection, focusedRowId, store, menuOpening, menuRow]) const body = ( <div ref={scrollRef} className="relative h-full min-h-0 flex-1 overflow-auto" onPointerDownCapture={onPointerDownCapture} onPointerDown={onRowPointerDown} onDoubleClick={onRowDoubleClick} onContextMenu={renderContextMenu ? onContextMenu : undefined} onTouchStart={renderContextMenu ? event => { // Multiple touches must reach the primitive so it can cancel a pending long press. if (event.touches.length === 1 && (event.defaultPrevented || !rowTarget(event, true))) rejectMenuStart(rootRef.current, event) } : undefined} onScroll={onScroll} > {/* At least the viewport tall, so a footer sits at the bottom edge when the rows do not reach it. */} <div className="flex min-h-full flex-col"> <div role="row" aria-rowindex={1} className="sticky top-0 z-20 grid border-b border-border bg-background text-muted-foreground" style={{ gridTemplateColumns: template, width: totalWidth, height: rowHeight }} > {selectionColumn && <div role="columnheader" aria-colindex={1} aria-label={labels.selection} className="sticky left-0 z-30 bg-background" />} {resolved.map((col, i) => ( <HeaderCell key={col.key} gridRef={rootRef} col={col} colIndex={i + (selectionColumn ? 1 : 0)} left={lefts[i]} sort={sort?.key === col.key ? sort.dir : null} focused={focusedColKey === col.key} canMoveLeft={i > 0 && (resolved[i - 1]!.frozen === "left") === (col.frozen === "left")} canMoveRight={i < resolved.length - 1 && (resolved[i + 1]!.frozen === "left") === (col.frozen === "left")} onSort={() => cycleSort(col.key)} onFocus={() => setFocusedColKey(col.key)} onHide={() => hideColumn(col.key)} onMove={(d) => requestColumn(col.key, { move: d })} onReset={resetColumns} onResize={(w) => resizeColumn(col.key, w)} hiddenCount={columnState.hidden.length} labels={labels} /> ))} </div> {ids.length === 0 ? ( <div id={`${uid}-empty`} className="p-4 text-muted-foreground">{emptyState ?? labels.noRows}</div> ) : ( <div role="rowgroup" className="relative shrink-0" style={{ height: virtualizer.getTotalSize(), width: totalWidth }}> {items.map((v) => { const id = ids[v.index]! return ( <Row key={v.key} store={store} id={id} index={v.index} domId={domId(id)} uid={uid} columns={resolved} template={template} lefts={lefts} width={totalWidth} height={rowHeight} start={v.start - rowHeight} selected={selection.has(id)} focused={focusedRowId === id} focusedColKey={focusedColKey} selectionColumn={selectionColumn} selectable={selectionMode !== "none"} selectLabel={labels.selectRow} pendingLabel={labels.pending} onToggle={toggle} memory={memory} flashVariant={preset.flash} flashWindowMs={flashWindowMs} entered={entered} highlightEnter={rowEnter.highlight} getRowProps={getRowProps} getRowLabel={getRowLabel} rules={rules} edits={edits} /> ) })} </div> )} {footer && <FooterRow store={store} ids={ids} columns={resolved} template={template} lefts={lefts} width={totalWidth} height={rowHeight} footer={footer} selectionColumn={selectionColumn} rowIndex={ids.length + 2} />} </div> </div> ) return ( <div ref={rootRef} role="grid" data-slot="tradecn-data-grid" data-preset={props.preset ?? "blotter"} data-editable={edits ? "" : undefined} tabIndex={0} aria-label={label} aria-rowcount={ids.length + (footer ? 2 : 1)} aria-colcount={resolved.length + (selectionColumn ? 1 : 0)} aria-multiselectable={selectionMode === "multi" || undefined} aria-activedescendant={focusedRowId !== null && indexOf.has(focusedRowId) ? domId(focusedRowId) : undefined} aria-describedby={ids.length === 0 ? `${uid}-empty` : undefined} onKeyDown={onKeyDown} onFocus={readsRows ? (e) => { if (e.target === e.currentTarget) setArrivals((n) => n + 1) } : undefined} // Focus leaving the grid takes the row's reading with it. onBlur={readsRows ? (e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setReading("") } : undefined} className={cn("relative flex h-full min-h-0 flex-col overflow-hidden rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-2 focus-visible:ring-foreground/60 lining-nums tabular-nums", preset.fontClass, className)} style={{ lineHeight: `${rowHeight}px` } as CSSProperties} > {renderContextMenu ? ( <ContextMenu onOpenChange={open => { if (open) setMenuOpening(value => value + 1) }}> <ContextMenuTrigger className="contents">{body}</ContextMenuTrigger> <ContextMenuContent><Fragment key={contextRows.opening}>{renderContextMenu(contextRows.rows, contextRows.ids, contextRows.target)}</Fragment></ContextMenuContent> </ContextMenu> ) : ( body )} {behind > 0 && ( <button type="button" data-grid-behind={behind} onClick={toTail} className="absolute left-1/2 z-30 -translate-x-1/2 rounded-full bg-primary px-2.5 py-0.5 text-primary-foreground shadow-sm hover:bg-primary/90" style={{ bottom: (footer ? rowHeight : 0) + 8 }} > {typeof labels.newRows === "function" ? labels.newRows(behind) : fillLabel(labels.newRows, { n: behind })} </button> )} {announceRowCount !== "off" && ( <div aria-live="polite" className="sr-only"> {announcement} </div> )} {readsRows && ( <div aria-live="polite" aria-atomic="true" className="sr-only" data-grid-row-reading=""> {reading} </div> )} </div> ) } interface HeaderCellProps<T> { gridRef: RefObject<HTMLDivElement | null> col: Resolved<T> colIndex: number left: number | undefined sort: "asc" | "desc" | null focused: boolean canMoveLeft: boolean canMoveRight: boolean hiddenCount: number labels: DataGridLabels onSort: () => void onFocus: () => void onHide: () => void onMove: (delta: -1 | 1) => void onReset: () => void onResize: (width: number) => void } function HeaderCell<T>(p: HeaderCellProps<T>) { const { col, gridRef } = p const triggerRef = useRef<HTMLButtonElement>(null) const menuRef = useRef<HTMLDivElement>(null) const resizeRef = useRef(p.onResize) const resizeGesture = useRef<{ pointerId: number; owner: Window; end: () => void } | null>(null) useInsertionEffect(() => { resizeRef.current = p.onResize }) useLayoutEffect(() => () => resizeGesture.current?.end(), []) useLayoutEffect(() => () => { const trigger = triggerRef.current const menu = menuRef.current const grid = gridRef.current const doc = trigger?.ownerDocument if (!trigger || !grid || !doc) return // Focus read in each tree it can be in: the trigger's, which inside a shadow root isn't the document's, and the // menu's, wherever it was portaled. const root = focusRoot(trigger) const inMenu = menu ? focusRoot(menu).activeElement : null const active = root.activeElement === trigger ? trigger : inMenu && menu?.contains(inMenu) ? inMenu : null if (!active) return // Wait for removal and the menu's cleanup. A surviving trigger (including effect replay) owns its focus. queueMicrotask(() => { if (trigger.isConnected || !grid.isConnected) return const current = menu ? focusRoot(menu).activeElement : null if (focusFell(root, doc, trigger) || (current !== null && (current === active || menu?.contains(current)))) grid.focus({ preventScroll: true }) }) }, [gridRef]) const startResize = (e: PointerEvent<HTMLDivElement>) => { if (e.defaultPrevented || e.button !== 0 || !e.isPrimary) return const handle = e.currentTarget const owner = handle.ownerDocument.defaultView if (!owner) return if (resizeGesture.current?.owner !== owner) resizeGesture.current?.end() if (resizeGesture.current && resizeGesture.current.pointerId !== e.pointerId) return e.preventDefault() e.stopPropagation() resizeGesture.current?.end() const pointerId = e.pointerId const startX = e.clientX const startW = col.width const move = (ev: globalThis.PointerEvent) => { if (ev.pointerId !== pointerId) return if (!(ev.buttons & 1) || !handle.isConnected || handle.ownerDocument.defaultView !== owner) { end() return } resizeRef.current(startW + ev.clientX - startX) } const finish = (ev: globalThis.PointerEvent) => { if (ev.pointerId === pointerId) end() } const end = () => { if (resizeGesture.current?.end !== end) return observer.disconnect() owner.removeEventListener("pointermove", move, true) owner.removeEventListener("pointerup", finish, true) owner.removeEventListener("pointercancel", finish, true) owner.removeEventListener("lostpointercapture", finish, true) owner.removeEventListener("blur", end) handle.removeEventListener("lostpointercapture", finish) resizeGesture.current = null if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId) } // Watch ancestor removal only while dragging; row updates need no subtree observation. const observer = new owner.MutationObserver(() => { if (!handle.isConnected || handle.ownerDocument.defaultView !== owner) end() else observeAncestors() }) const observeAncestors = () => { observer.disconnect() for (let node: Node | null = handle.parentNode; node; node = node.parentNode ?? (node.nodeType === 11 ? (node as ShadowRoot).host : null)) { observer.observe(node, { childList: true }) } } observeAncestors() resizeGesture.current = { pointerId, owner, end } owner.addEventListener("pointermove", move, true) owner.addEventListener("pointerup", finish, true) owner.addEventListener("pointercancel", finish, true) owner.addEventListener("lostpointercapture", finish, true) owner.addEventListener("blur", end) // Adoption can deliver capture loss through the handle's new document. handle.addEventListener("lostpointercapture", finish) // Synthetic events may not have an active browser pointer to capture. try { handle.setPointerCapture?.(pointerId) } catch { /* Window listeners still end the gesture. */ } } // The column's name in words: its title, its header when that is words, else its key. A header is named by it alone, // never by the menu button and resize handle inside, which have names of their own; give a header of markup a title. const name = columnName(col) const words = col.title?.trim() || (typeof col.header === "string" ? col.header.trim() : "") return ( <div role="columnheader" aria-label={name} aria-colindex={p.colIndex + 1} aria-sort={p.sort === "asc" ? "ascending" : p.sort === "desc" ? "descending" : col.sortable ? "none" : undefined} data-col={col.key} data-focused-col={p.focused || undefined} className={cn("group relative flex h-full min-w-0 items-center gap-1 px-2 font-medium select-none", alignClass(col), p.left !== undefined && "sticky z-30 bg-background", p.focused && "text-foreground")} style={p.left !== undefined ? { left: p.left } : undefined} onPointerDown={p.onFocus} > <span className={cn("min-w-0 flex-1 truncate", col.sortable && "cursor-pointer hover:text-foreground")} title={words || undefined} onClick={col.sortable ? p.onSort : undefined} > {col.header} {p.sort && <span aria-hidden className="ml-1">{p.sort === "asc" ? "\u25B2" : "\u25BC"}</span>} </span> <DropdownMenu> <DropdownMenuTrigger ref={triggerRef} aria-label={fillLabel(p.labels.columnMenu, { name })} className="rounded px-1 text-muted-foreground opacity-0 hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 data-[popup-open]:opacity-100 data-[state=open]:opacity-100" > <svg aria-hidden width="10" height="10" viewBox="0 0 10 10" fill="currentColor"> <circle cx="5" cy="1.5" r="1.2" /> <circle cx="5" cy="5" r="1.2" /> <circle cx="5" cy="8.5" r="1.2" /> </svg> </DropdownMenuTrigger> <DropdownMenuContent ref={menuRef} align="end"> {col.sortable && <DropdownMenuItem onClick={p.onSort}>{p.sort === "asc" ? p.labels.sortDescending : p.sort === "desc" ? p.labels.clearSort : p.labels.sortAscending}</DropdownMenuItem>} <DropdownMenuItem disabled={!p.canMoveLeft} onClick={() => p.onMove(-1)}> {p.labels.moveLeft} </DropdownMenuItem> <DropdownMenuItem disabled={!p.canMoveRight} onClick={() => p.onMove(1)}> {p.labels.moveRight} </DropdownMenuItem> <DropdownMenuItem onClick={p.onHide}>{p.labels.hideColumn}</DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem onClick={p.onReset}>{typeof p.labels.resetColumns === "function" ? p.labels.resetColumns(p.hiddenCount) : fillLabel(p.labels.resetColumns, { n: p.hiddenCount })}</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> <div role="separator" aria-orientation="vertical" aria-label={fillLabel(p.labels.resizeColumn, { name })} onPointerDown={startResize} className="absolute top-0 right-0 h-full w-1.5 touch-none cursor-col-resize hover:bg-ring/40" /> </div> ) }hooks/use-flash.ts import { useLayoutEffect, useRef, type RefObject } from "react" // Flash a cell when its value changes, colored by direction, without a React re-render per tick. // // The previous value lives outside React (per cell, or in a shared FlashMemory keyed by cell so a // virtualized row that scrolls back within the window resumes its flash instead of losing it). // The flash itself is a Web Animations API animation on the existing element: retriggering cancels // the previous one for free, and nothing is remounted. export type Direction = "up" | "down" | "flat" export interface FlashRecord { value: unknown /** Clock time of the last flash, or -Infinity when the cell has never flashed. */ at: number dir: Direction } export interface FlashMemory { get(key: string): FlashRecord | undefined set(key: string, record: FlashRecord): void /** Drop every record whose key starts with `prefix` (a grid calls it with the row id on remove). */ forget(prefix: string): void readonly size: number } /** Bounded, least-recently-set memory. `max` records; oldest are evicted first. */ export function createFlashMemory(max = 50_000): FlashMemory { const map = new Map<string, FlashRecord>() return { get: (key) => map.get(key), set(key, record) { if (map.has(key)) map.delete(key) map.set(key, record) if (map.size > max) map.delete(map.keys().next().value!) }, forget(prefix) { for (const key of map.keys()) if (key.startsWith(prefix)) map.delete(key) }, get size() { return map.size }, } } export type Compare = (prev: unknown, next: unknown) => number | null /** Numeric difference when both sides are finite numbers; 0 when identical; null for any other change. */ export const compareValues: Compare = (prev, next) => { if (typeof prev === "number" && typeof next === "number" && Number.isFinite(prev) && Number.isFinite(next)) return next - prev return Object.is(prev, next) ? 0 : null } /** Direction of a change. Zero is flat, never up. A non-numeric change is flat. */ export function directionOf(prev: unknown, next: unknown, compare: Compare = compareValues): Direction { const d = compare(prev, next) if (d === null || d === 0) return "flat" return d > 0 ? "up" : "down" } export interface FlashOptions { /** How long a flash lasts, and how long a scrolled-away cell remembers it. Default 900. */ windowMs?: number /** `fill` tints the background; `ring` draws an inset outline, for cells that already carry a bar. */ variant?: "fill" | "ring" /** Paint with this instead of the direction's token: `var(--primary)` for an acknowledgement that has no direction. */ color?: string compare?: Compare /** * Flash flat when the same value arrives again. Needs `revision` to know it arrived: pass the row object * or the batch version, since an equal `value` alone looks unchanged to React. Default false. */ flashOnEqual?: boolean /** Anything that changes identity when a new reading arrives (the row object, a store version). */ revision?: unknown /** Shared memory across mounts; requires `cellKey`. */ memory?: FlashMemory cellKey?: string disabled?: boolean /** Injectable clock in ms; defaults to performance.now. */ now?: () => number } export const FILL_COLORS: Record<Direction, string> = { up: "var(--up-soft)", down: "var(--down-soft)", flat: "var(--flat-soft)", } export const RING_COLORS: Record<Direction, string> = { up: "var(--up)", down: "var(--down)", flat: "var(--flat)", } /** Tailwind classes for coloring a value by direction: text-up, text-down, text-flat. */ export function directionClass(dir: Direction | null | undefined): string { return dir === "up" ? "text-up" : dir === "down" ? "text-down" : "text-flat" } const animations = new WeakMap<Element, Animation>() const clearTimers = new WeakMap<Element, ReturnType<typeof setTimeout>>() let reducedMotion: boolean | null = null function prefersReducedMotion(): boolean { if (reducedMotion === null) { reducedMotion = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)").matches : false } return reducedMotion } /** Test hook: forget the cached media query. */ export function resetReducedMotionCache() { reducedMotion = null } function keyframes(dir: Direction, variant: "fill" | "ring", color?: string): Keyframe[] { if (variant === "ring") return [{ boxShadow: `inset 0 0 0 1px ${color ?? RING_COLORS[dir]}` }, { boxShadow: "inset 0 0 0 1px transparent" }] return [{ backgroundColor: color ?? FILL_COLORS[dir] }, { backgroundColor: "transparent" }] } /** Start (or resume, `elapsed` ms in) a flash on an element. */ export function playFlash(el: HTMLElement, dir: Direction, opts: { windowMs: number; variant: "fill" | "ring"; color?: string; elapsed?: number }) { const elapsed = Math.max(0, Math.min(opts.elapsed ?? 0, opts.windowMs)) animations.get(el)?.cancel() const pending = clearTimers.get(el) if (pending) clearTimeout(pending) el.dataset.direction = dir const done = () => { if (el.dataset.direction === dir) delete el.dataset.direction } if (prefersReducedMotion() || typeof el.animate !== "function") { // Static color from the data-direction styles, cleared when the window ends. clearTimers.set(el, setTimeout(done, opts.windowMs - elapsed)) return } const anim = el.animate(keyframes(dir, opts.variant, opts.color), { duration: opts.windowMs, easing: "ease-out", fill: "none" }) anim.currentTime = elapsed anim.onfinish = () => { if (animations.get(el) === anim) animations.delete(el) done() } animations.set(el, anim) } const NEVER: FlashRecord = { value: undefined, at: -Infinity, dir: "flat" } export function useFlash<E extends HTMLElement>(ref: RefObject<E | null>, value: unknown, opts: FlashOptions = {}): void { const { windowMs = 900, variant = "fill", color, compare = compareValues, flashOnEqual = false, memory, cellKey, disabled = false, revision } = opts const now = opts.now ?? (() => performance.now()) const local = useRef<FlashRecord | undefined>(undefined) const key = memory ? cellKey : undefined if (memory && !cellKey) throw new Error("useFlash: a shared memory needs a cellKey") const read = (): FlashRecord | undefined => (key !== undefined ? memory!.get(key) : local.current) const write = (record: FlashRecord) => { if (key !== undefined) memory!.set(key, record) else local.current = record } useLayoutEffect(() => { const el = ref.current if (!el || disabled) return const t = now() const prev = read() if (!prev) { // First sight of this cell: remember, do not flash. write({ ...NEVER, value }) return } const changed = !Object.is(prev.value, value) if (changed || flashOnEqual) { const dir = changed ? directionOf(prev.value, value, compare) : "flat" write({ value, at: t, dir }) playFlash(el, dir, { windowMs, variant, color }) return } // Same value on a fresh mount (a virtualized row scrolled back): resume a flash still in its window. const elapsed = t - prev.at if (elapsed < windowMs) playFlash(el, prev.dir, { windowMs, variant, color, elapsed }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, revision, ref, disabled, windowMs, variant, color, flashOnEqual, compare, memory, key]) useLayoutEffect(() => { const el = ref.current return () => { if (!el) return animations.get(el)?.cancel() animations.delete(el) const pending = clearTimers.get(el) if (pending) clearTimeout(pending) } }, [ref]) }hooks/use-row-store.ts import { useCallback, useLayoutEffect, useState, useSyncExternalStore } from "react" import type { RowId, RowStore, RowView, StoreMeta, ViewOptions } from "@/lib/row-store" /** One row. The component re-renders when this row is replaced or patched, and for nothing else. */ export function useRow<T>(store: RowStore<T>, id: RowId): T | undefined { const subscribe = useCallback((cb: () => void) => store.subscribeRow(id, cb), [store, id]) const get = useCallback(() => store.getRow(id), [store, id]) return useSyncExternalStore(subscribe, get, get) } /** The ids of a store (insertion or authoritative order) or of a view (filtered, sorted, held). */ export function useRowIds<T>(source: RowStore<T> | RowView<T>): readonly RowId[] { const subscribe = useCallback((cb: () => void) => ("subscribeOrder" in source ? source.subscribeOrder(cb) : source.subscribe(cb)), [source]) const get = useCallback(() => source.getIds(), [source]) return useSyncExternalStore(subscribe, get, get) } /** Version, size, lane, drop count, sequence, gap, and timestamps. Changes once per batch. */ export function useStoreMeta<T>(store: RowStore<T>): StoreMeta { const subscribe = useCallback((cb: () => void) => store.subscribeMeta(cb), [store]) const get = useCallback(() => store.getMeta(), [store]) return useSyncExternalStore(subscribe, get, get) } /** * A view whose connection belongs to this component. Preparation is pure, so abandoned renders and * server rendering leave no live resources. Cleanup disconnects; effect replay reconnects the same view. * Keep options stable (a `useMemo`): new store/options identities select a new view synchronously. * Null options give null, for a component that borrows a caller-owned view instead. */ export function useView<T>(store: RowStore<T>, options: ViewOptions<T> | null): RowView<T> | null { const [prepared, setPrepared] = useState(() => ({ store, options, view: options ? store.prepareView(options) : null })) let current = prepared if (prepared.store !== store || prepared.options !== options) { current = { store, options, view: options ? store.prepareView(options) : null } setPrepared(current) } const view = current.view useLayoutEffect(() => view?.connect(), [view]) return view }lib/row-store.ts // A row store that React components subscribe to one row at a time. // // The consumer owns the feed. It calls `applyDeltas` once per frame with everything that changed // (a Tauri consumer straight from its event listener; a WebSocket consumer through `createFrameBatcher`). // A delta to one row wakes that row's subscribers and nobody else, and React batches every // notification fired inside one `applyDeltas` into one commit, so a batch of two thousand row // changes is one render pass over the rows that changed. // // Views sit on top: a filtered, sorted list of ids with an optional reorder hold so rows do not // move under a trader's cursor while they work. // setTimeout's longest delay; a longer one fires at once. const MAX_TIMER_MS = 2 ** 31 - 1 // How long a batch waits for a frame before a timer flushes it: a hidden tab runs no frames. const FRAME_FALLBACK_MS = 250 export type RowId = string /** How the lane loses data. `coalesced` drops stale ticks and counts them; `ordered` never drops. */ export type Lane = "coalesced" | "ordered" export interface DeltaBatch<T> { /** Whole rows. Replaces the row object, so every subscriber of that row wakes. */ upsert?: readonly T[] /** Partial rows merged into a new object. Unknown ids are ignored. */ patch?: readonly { id: RowId; fields: Partial<T> }[] remove?: readonly RowId[] /** Authoritative order for an ordered lane. Ids not present keep their previous relative order at the end. */ order?: readonly RowId[] meta?: { lane?: Lane /** Messages the producer dropped since the last batch (coalesced lane). Accumulated. */ dropped?: number /** Last sequence number applied (ordered lane). */ seq?: number /** A gap was detected and replay is in progress. */ gap?: boolean /** Producer timestamp of the newest message in the batch, ms since epoch. */ producedAt?: number } } export interface StoreMeta { /** Increments per batch or clear, before any subscriber is notified. */ version: number size: number lane: Lane dropped: number seq: number | null gap: boolean /** Wall clock of the last applied batch, ms since epoch. */ lastBatchAt: number | null producedAt: number | null } export interface ViewOptions<T> { comparator?: (a: T, b: T) => number filter?: (row: T) => boolean /** * After `touch()` (the grid calls it on every key and pointer interaction) the order is frozen for this * long: new rows append, removed rows vanish, nothing moves. 0 disables the hold. */ reorderHoldMs?: number /** Injectable clock, ms. Defaults to Date.now. */ now?: () => number } export interface RowView<T> { /** The store this view reads from. */ readonly store: RowStore<T> /** Stable array reference until the order actually changes. */ getIds(): readonly RowId[] subscribe(cb: () => void): () => void /** The user interacted; start or extend the reorder hold. */ touch(): void /** True while a hold is in force. */ isHeld(): boolean /** When the current or most recent reorder hold lapses or lapsed, in Date.now() milliseconds; null before any hold. Optional: without it, parked arrival marks age from their arrival, so a hold longer than the highlight window swallows those flashes. */ holdExpiresAt?(): number | null dispose(): void /** True after `dispose`: the view no longer follows the store. */ isDisposed(): boolean } /** A render-safe snapshot with an explicitly owned, reversible connection to its store. */ export interface PreparedRowView<T> extends RowView<T> { /** Follow batches until the returned release runs. Multiple owners release independently. */ connect(): () => void } /** Publish current row, order and metadata snapshots before notifying any subscriber. */ export interface RowStore<T> { /** Stable object reference until the row is replaced or patched. */ getRow(id: RowId): T | undefined /** Stable array reference until the order changes. Insertion order unless a batch carries `order`. */ getIds(): readonly RowId[] /** Stable object reference until the next batch. */ getMeta(): StoreMeta subscribeRow(id: RowId, cb: () => void): () => void subscribeOrder(cb: () => void): () => void subscribeMeta(cb: () => void): () => void /** Apply one batch synchronously. Call it once per frame. */ applyDeltas(batch: DeltaBatch<T>): void /** Prepare a current snapshot without subscriptions, registration or timers. Safe during render. */ prepareView(opts?: ViewOptions<T>): PreparedRowView<T> /** Follow the store immediately, even without subscribers. The caller must dispose the view. */ createView(opts?: ViewOptions<T>): RowView<T> /** Drop every row (a re-snapshot is coming). Wakes every subscriber. */ clear(): void readonly getRowId: (row: T) => RowId } export interface RowStoreOptions<T> { getRowId: (row: T) => RowId lane?: Lane now?: () => number } type Listener = () => void export function createRowStore<T>(options: RowStoreOptions<T>): RowStore<T> { const { getRowId } = options const now = options.now ?? Date.now const rows = new Map<RowId, T>() let ids: RowId[] = [] // Snapshots never alias the live array: the first batch appends to ids in place. let idsSnapshot: readonly RowId[] = ids.slice() let meta: StoreMeta = { version: 0, size: 0, lane: options.lane ?? "coalesced", dropped: 0, seq: null, gap: false, lastBatchAt: null, producedAt: null } const rowListeners = new Map<RowId, Set<Listener>>() const orderListeners = new Set<Listener>() const metaListeners = new Set<Listener>() const views = new Set<ViewImpl<T>>() function notifyRow(id: RowId) { const set = rowListeners.get(id) if (set) for (const cb of set) cb() } function applyDeltas(batch: DeltaBatch<T>) { const touched = new Set<RowId>() const removed = new Set<RowId>() let orderChanged = false if (batch.upsert) { for (const row of batch.upsert) { const id = getRowId(row) if (!rows.has(id)) { ids.push(id) orderChanged = true } rows.set(id, row) touched.add(id) } } if (batch.patch) { for (const { id, fields } of batch.patch) { const existing = rows.get(id) if (existing === undefined) continue rows.set(id, { ...existing, ...fields }) touched.add(id) } } if (batch.remove?.length) { const drop = new Set<RowId>() for (const id of batch.remove) { if (rows.delete(id)) { drop.add(id) removed.add(id) touched.add(id) } } if (drop.size) { ids = ids.filter((id) => !drop.has(id)) orderChanged = true } } if (batch.order) { const next: RowId[] = [] const seen = new Set<RowId>() for (const id of batch.order) { if (rows.has(id) && !seen.has(id)) { next.push(id) seen.add(id) } } for (const id of ids) if (!seen.has(id)) next.push(id) if (!sameOrder(ids, next)) { ids = next orderChanged = true } } const m = batch.meta meta = { version: meta.version + 1, size: rows.size, lane: m?.lane ?? meta.lane, dropped: meta.dropped + (m?.dropped ?? 0), seq: m?.seq ?? meta.seq, gap: m?.gap ?? meta.gap, lastBatchAt: now(), producedAt: m?.producedAt ?? meta.producedAt, } if (orderChanged) idsSnapshot = ids.slice() for (const view of views) view.onBatch(touched, removed, orderChanged, meta.version) for (const id of touched) notifyRow(id) if (orderChanged) for (const cb of orderListeners) cb() for (const cb of metaListeners) cb() } const store: RowStore<T> = { getRowId, getRow: (id) => rows.get(id), getIds: () => idsSnapshot, getMeta: () => meta, subscribeRow(id, cb) { let set = rowListeners.get(id) if (!set) rowListeners.set(id, (set = new Set())) set.add(cb) return () => { set!.delete(cb) if (!set!.size) rowListeners.delete(id) } }, subscribeOrder(cb) { orderListeners.add(cb) return () => orderListeners.delete(cb) }, subscribeMeta(cb) { metaListeners.add(cb) return () => metaListeners.delete(cb) }, applyDeltas, prepareView(opts = {}) { const view = new ViewImpl<T>(store, opts, () => views.add(view), () => views.delete(view)) return view }, createView(opts) { const view = store.prepareView(opts) view.connect() return view }, clear() { const all = ids rows.clear() ids = [] idsSnapshot = [] meta = { ...meta, version: meta.version + 1, size: 0, lastBatchAt: now() } for (const view of views) view.onBatch(new Set(all), new Set(all), true, meta.version) for (const id of all) notifyRow(id) for (const cb of orderListeners) cb() for (const cb of metaListeners) cb() }, } return store } function sameOrder(a: readonly RowId[], b: readonly RowId[]): boolean { if (a === b) return true if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false return true } class ViewImpl<T> implements PreparedRowView<T> { private ids: readonly RowId[] = [] private published: readonly RowId[] = [] private version: number private readonly listeners = new Set<Listener>() private holdUntil = 0 private settlePending = false private timer: ReturnType<typeof setTimeout> | null = null private disposed = false private connections = 0 private readonly now: () => number readonly store: RowStore<T> private readonly opts: ViewOptions<T> private readonly attach: () => void private readonly detach: () => void constructor(store: RowStore<T>, opts: ViewOptions<T>, attach: () => void, detach: () => void) { this.store = store this.opts = opts this.attach = attach this.detach = detach this.now = opts.now ?? Date.now this.version = store.getMeta().version this.ids = this.compute() this.published = this.ids } getIds() { if (this.disposed) return this.ids const version = this.store.getMeta().version const expired = !this.connections && this.settlePending && !this.isHeld() if (version !== this.version || expired) { this.cache(this.isHeld() ? this.computeHeld() : this.compute()) this.version = version if (expired) this.settlePending = false } return this.ids } subscribe(cb: Listener) { if (this.disposed) return () => {} this.listeners.add(cb) return () => { this.listeners.delete(cb) } } connect() { if (this.disposed) return () => {} let released = false const release = () => { if (released || this.disposed) return released = true if (--this.connections === 0) { this.detach() this.clearTimer() } } if (this.connections === 0) { // Refresh while disconnected, including a hold that expired while effects were hidden. this.getIds() this.attach() } this.connections++ try { this.publish() this.armTimer() } catch (error) { release() throw error } return release } isHeld() { return this.now() < this.holdUntil } holdExpiresAt() { // The hold is kept in the view's own clock; the reported deadline is wall // time, so a grid can compare it with Date.now whatever clock was injected. return this.holdUntil > 0 ? Date.now() + (this.holdUntil - this.now()) : null } touch() { if (this.disposed) return // A hold that isn't a positive number holds nothing, and one longer than a timer can wait, Infinity included, holds // as long as one can, about 24.8 days: past that a timer fires at once. const asked = this.opts.reorderHoldMs ?? 0 const hold = asked > 0 ? Math.min(asked, MAX_TIMER_MS) : 0 if (hold <= 0) return if (!this.connections) this.getIds() this.holdUntil = this.now() + hold this.settlePending = true this.clearTimer() this.armTimer() } private armTimer() { if (this.disposed || !this.connections || !this.settlePending || this.timer !== null) return // A quiet feed still settles: recompute when the hold lapses. this.timer = setTimeout(() => { this.timer = null this.settlePending = false this.cache(this.compute()) this.publish() }, Math.max(0, this.holdUntil - this.now())) } private clearTimer() { if (this.timer !== null) clearTimeout(this.timer) this.timer = null } dispose() { if (this.disposed) return this.disposed = true this.clearTimer() this.connections = 0 this.listeners.clear() this.detach() } isDisposed() { return this.disposed } /** Called by the store inside applyDeltas, before row listeners fire. */ onBatch(touched: Set<RowId>, removed: Set<RowId>, orderChanged: boolean, version: number) { // Another view's listener may already have read our current snapshot. It cannot publish it. if (this.version === version) { this.publish() return } this.version = version if (!touched.size && !orderChanged) return if (this.isHeld()) { this.cache(this.computeHeld(removed)) this.publish() return } // Only recompute when the batch could have changed this view. let relevant = orderChanged if (!relevant) for (const id of touched) if (this.passes(id) || this.ids.includes(id)) { relevant = true; break } if (relevant) { this.cache(this.compute()) this.publish() } } private computeHeld(removed?: Set<RowId>): readonly RowId[] { // Frozen order: drop removals and rows outside the filter, then append sorted newcomers. const present = new Set(this.ids) const kept = this.ids.filter((id) => !removed?.has(id) && this.passes(id)) const newcomers = this.store.getIds().filter((id) => !present.has(id) && this.passes(id)) if (this.opts.comparator) newcomers.sort(this.compare) return kept.length === this.ids.length && !newcomers.length ? this.ids : [...kept, ...newcomers] } private passes(id: RowId): boolean { const row = this.store.getRow(id) if (row === undefined) return false return this.opts.filter ? this.opts.filter(row) : true } private readonly compare = (a: RowId, b: RowId) => this.opts.comparator!(this.store.getRow(a)!, this.store.getRow(b)!) private compute(): readonly RowId[] { const all = this.store.getIds() const list = this.opts.filter ? all.filter((id) => this.passes(id)) : all.slice() if (this.opts.comparator) list.sort(this.compare) return list } private cache(next: readonly RowId[]) { if (!sameOrder(this.ids, next)) this.ids = next } private publish() { if (sameOrder(this.published, this.ids)) return this.published = this.ids for (const cb of this.listeners) cb() } } export interface FrameBatcher<T> { /** Queue a delta; the merged batch applies on the next animation frame, or after 250 ms when no frame comes. */ push(delta: DeltaBatch<T>): void /** Apply now, without waiting for the frame. */ flush(): void /** Drop everything queued. */ cancel(): void pending(): boolean } /** * Coalesce many small deltas into one batch per frame, last write wins per row. For consumers whose * feed delivers messages one at a time. A consumer whose core already batches per frame calls * `applyDeltas` directly and skips this: another frame of latency buys nothing. */ export function createFrameBatcher<T>( apply: (batch: DeltaBatch<T>) => void, options: { getRowId: (row: T) => RowId; raf?: (cb: () => void) => number; caf?: (handle: number) => void }, ): FrameBatcher<T> { const raf = options.raf ?? ((cb) => requestAnimationFrame(cb)) const caf = options.caf ?? ((h) => (typeof cancelAnimationFrame === "function" ? cancelAnimationFrame(h) : undefined)) const upserts = new Map<RowId, T>() const patches = new Map<RowId, Partial<T>>() const removes = new Set<RowId>() let order: readonly RowId[] | undefined let meta: NonNullable<DeltaBatch<T>["meta"]> | undefined let handle: number | null = null // A hidden document runs no frames, so a timer stands in: whichever comes first flushes, and the other is cancelled. let timer: ReturnType<typeof setTimeout> | null = null // The frame scheduled now. A frame the flush cancelled can still run where it can't be cancelled, as with a `raf` // passed without its `caf`, and it finds another frame scheduled, or none, and flushes nothing. let scheduled: object | null = null function reset() { upserts.clear() patches.clear() removes.clear() order = undefined meta = undefined } function flush() { scheduled = null if (handle !== null) { caf(handle) handle = null } if (timer !== null) { clearTimeout(timer) timer = null } if (!upserts.size && !patches.size && !removes.size && !order && !meta) return const batch: DeltaBatch<T> = {} if (upserts.size) batch.upsert = [...upserts.values()] if (patches.size) batch.patch = [...patches].map(([id, fields]) => ({ id, fields })) if (removes.size) batch.remove = [...removes] if (order) batch.order = order if (meta) batch.meta = meta reset() apply(batch) } return { push(delta) { for (const row of delta.upsert ?? []) { const id = options.getRowId(row) removes.delete(id) patches.delete(id) upserts.set(id, row) } for (const { id, fields } of delta.patch ?? []) { if (removes.has(id)) continue const pendingUpsert = upserts.get(id) if (pendingUpsert !== undefined) upserts.set(id, { ...pendingUpsert, ...fields }) else patches.set(id, { ...(patches.get(id) ?? {}), ...fields }) } for (const id of delta.remove ?? []) { upserts.delete(id) patches.delete(id) removes.add(id) } if (delta.order) order = delta.order if (delta.meta) { // Omitted fields keep their previous values all the way through: a frame that // never mentions gap must not write one, or a replay's recorded gap is // silenced mid-flight. Mentions apply in order, so a gap that opens and // closes inside one frame ends closed, exactly as two frames would. const gapMentioned = delta.meta.gap !== undefined || meta?.gap !== undefined meta = { ...meta, ...delta.meta, dropped: (meta?.dropped ?? 0) + (delta.meta.dropped ?? 0), ...(gapMentioned ? { gap: delta.meta.gap ?? meta?.gap } : {}), } if (!gapMentioned) delete (meta as { gap?: boolean }).gap } if (handle === null && timer === null) { const frame = {} scheduled = frame const requested = raf(() => { if (scheduled !== frame) return handle = null flush() }) // A frame that ran as it was requested has flushed already, and leaves nothing to hold. if (scheduled === frame) { handle = requested timer = setTimeout(() => { timer = null flush() }, FRAME_FALLBACK_MS) } } }, flush, cancel() { scheduled = null if (handle !== null) caf(handle) if (timer !== null) clearTimeout(timer) handle = null timer = null reset() }, pending: () => handle !== null || timer !== null, } }lib/format.ts // Number formatting for trading screens. Pure functions, no React, no dependencies. // Every formatter returns NULL_TOKEN for null, undefined, NaN, and infinities, so a grid // never prints "NaN" or "undefined" while a feed is warming up. export const NULL_TOKEN = "–" const MINUS = "−" export type Nullable = number | null | undefined export interface Locale { /** BCP 47 tag; defaults to en-US. */ locale?: string } export type PriceConvention = /** Fixed decimals, e.g. equities at 2. */ | { kind: "decimal"; decimals: number } /** Decimals derived from the instrument's tick size, e.g. 0.001 gives 3. */ | { kind: "tick"; tick: number } /** * Fractional quoting. `denominator` 32 or 64; `half` renders a half tick as "+" (99-16+) or "5" (99-165); * `eighths` renders eighths of a tick as a trailing digit (99-162 is 99 and 16 2/8 thirty-seconds). */ | { kind: "fraction"; denominator: 32 | 64; half: "+" | "5"; eighths?: boolean } /** What a quote is typed and read in. Bills quote on discount, most bonds on price, some on yield, credit often on a spread in basis points. */ export type QuoteBasis = "price" | "yield" | "discount" | "spread" export interface InstrumentConvention { price: PriceConvention /** Smallest price increment, used for stepping and rounding. */ tick: number yieldDecimals?: number quantityUnit?: "notional" | "contracts" /** Default price. */ quoteBasis?: QuoteBasis /** What a quote steps by in its basis. Default: the tick for price, 0.001 for yield and discount, 0.1 for spread. */ quoteStep?: number /** Decimals for a yield, discount, or spread quote. Default 3, 3, and 1. Price quotes follow their convention. */ quoteDecimals?: number /** Whether a higher quote means a lower price, so a normal market quotes the bid above the offer. Defaults by basis: yield and discount invert; price and spread do not, since a CDS spread quotes bid below offer while cash credit quotes the other way — declare it for the instrument when the default reads wrong. */ quoteInverted?: boolean } function isNil(v: Nullable): v is null | undefined { return v === null || v === undefined || !Number.isFinite(v) } // One Intl.NumberFormat per locale and option set; construction is the expensive part. const formatters = new Map<string, Intl.NumberFormat>() /** True for a time a `Date` can hold: a finite number of milliseconds within 8.64e15 of the epoch either way. */ export const isInstant = (ms: unknown): ms is number => Number.isFinite(ms) && Math.abs(ms as number) <= 8.64e15 export function numberFormat(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat { const key = `${locale ?? "en-US"}|${JSON.stringify(options)}` let nf = formatters.get(key) if (!nf) { nf = new Intl.NumberFormat(locale ?? "en-US", options) formatters.set(key, nf) } return nf } function fixed(v: number, decimals: number, locale?: string, extra: Intl.NumberFormatOptions = {}): string { return numberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, ...extra }).format(v) } function typographicMinus(s: string): string { return s.replace(/-/g, MINUS) } /** Decimal places needed to show a price on this tick grid. 0.001 gives 3, 1/32 gives 5, 1 gives 0. Capped at `max`. */ export function decimalsFromTick(tick: number, max = 8): number { if (!Number.isFinite(tick) || tick <= 0) return 0 for (let d = 0; d <= max; d++) { const scaled = tick * 10 ** d if (Math.abs(scaled - Math.round(scaled)) < 1e-9 * Math.max(1, scaled)) return d } return max } /** Nearest price on the tick grid, cleaned of float noise. */ export function roundToTick(v: number, tick: number): number { if (!Number.isFinite(v) || !Number.isFinite(tick) || tick <= 0) return v return Number((Math.round(v / tick) * tick).toFixed(decimalsFromTick(tick))) } /** Move `steps` ticks (negative allowed) from the nearest grid price. */ export function stepByTick(v: number, tick: number, steps: number): number { return roundToTick(roundToTick(v, tick) + steps * tick, tick) } /** Whole-number and sub-tick decomposition on an integer grid so 32nds never drift. */ function decomposeFraction(v: number, c: Extract<PriceConvention, { kind: "fraction" }>) { const sub = c.eighths ? 8 : 2 const unitsPerWhole = c.denominator * sub const total = Math.round(Math.abs(v) * unitsPerWhole) const whole = Math.floor(total / unitsPerWhole) const rem = total - whole * unitsPerWhole const ticks = Math.floor(rem / sub) const subticks = rem - ticks * sub return { negative: v < 0 && total > 0, whole, ticks, subticks, sub } } /** 99.515625 as "99-16+" (32nds, half "+"), "99-165" (half "5"), or "99-164" with eighths. */ export function formatFraction(v: Nullable, c: Extract<PriceConvention, { kind: "fraction" }>): string { if (isNil(v)) return NULL_TOKEN const { negative, whole, ticks, subticks, sub } = decomposeFraction(v, c) // From 1e21 a whole part prints in exponent form, and further up the scaling overflows: neither is the notation, // and parsePrice refuses the same range. if (!(whole < 1e21)) return NULL_TOKEN let tail = "" if (subticks !== 0) { if (c.eighths) tail = String(subticks) else tail = c.half } void sub return `${negative ? MINUS : ""}${whole}-${String(ticks).padStart(2, "0")}${tail}` } export function formatPrice(v: Nullable, c: PriceConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN switch (c.kind) { case "decimal": return typographicMinus(fixed(v, c.decimals, l?.locale)) case "tick": return typographicMinus(fixed(roundToTick(v, c.tick), decimalsFromTick(c.tick), l?.locale)) case "fraction": return formatFraction(v, c) } } /** * Numeric text, trimmed and with either minus sign read as "-", with its thousands separators removed, or null when a * comma in it isn't one. A comma groups * thousands in the whole part only: a first group of one to three digits with no leading zero, then groups of three, * and none after the point or among the 32nds, so "99,5" from a decimal-comma keyboard is no number, never 995. With * `decimals`, for text whose notation prints digits after the point, one comma with nothing after the whole part is * no number either, since "4,253" reads as 4253 grouped or as 4.253 typed with a decimal comma; two or more groups, * or a point or a fraction's dash after them, can only be grouping. Text without a comma comes back trimmed with the minus * read, and the rest of any text is left to the caller's own pattern, which decides what is a number. */ export function stripGrouping(input: string, options: { decimals: boolean }): string | null { const text = input.trim().replace(/−/g, "-") if (!text.includes(",")) return text const m = /^([+-]?)([1-9]\d{0,2}(?:,\d{3})+)((?:[.-].*)?)$/.exec(text) if (!m || m[3]!.includes(",")) return null const whole = m[2]! if (options.decimals && !m[3] && whole.indexOf(",") === whole.lastIndexOf(",")) return null return m[1]! + whole.replace(/,/g, "") + m[3]! } // Whether a convention's own text carries digits after the point, or a fraction's ticks. const printsDecimals = (c: PriceConvention) => (c.kind === "fraction" ? true : c.kind === "decimal" ? c.decimals > 0 : decimalsFromTick(c.tick) > 0) /** * Inverse of formatPrice for ticket entry. Accepts the convention's own notation and a plain decimal. * Returns null when the text is not a price. Fraction input: "99-16+", "99-165", "99-162", "99-16", "99". */ export function parsePrice(s: string, c: PriceConvention): number | null { const text = stripGrouping(s, { decimals: printsDecimals(c) }) if (!text) return null if (c.kind === "fraction") { const m = /^([+-]?)(\d+)(?:-(\d{1,2})([+0-9])?)?$/.exec(text) if (m) { const [, sign, wholeText, ticksText, tail] = m const ticks = ticksText ? Number(ticksText) : 0 if (ticks >= c.denominator) return null let subFraction = 0 if (tail === "+") subFraction = 0.5 else if (tail !== undefined) { const digit = Number(tail) if (c.eighths) { if (digit > 7) return null subFraction = digit / 8 } else if (c.half === "5" && digit === 5) subFraction = 0.5 else if (digit === 0) subFraction = 0 else return null } const value = Number(wholeText) + (ticks + subFraction) / c.denominator // What cannot print back in the notation is refused: unit scaling must stay finite, the // whole part must stay out of exponent form (1e21 up), and "-0" stays plain zero. if (value >= 1e21 || !Number.isFinite(value * c.denominator * (c.eighths ? 8 : 2))) return null return value === 0 ? 0 : sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Every convention keeps the promise the reference row makes: what parses can print back. // Intl prints tick and decimal prices in full digits at any finite size, so only fraction // notation carries the 1e21 bound; tick scaling must stay finite, and zero stays plain zero. if (c.kind === "tick") { const rounded = roundToTick(n, c.tick) return Number.isFinite(rounded) ? (rounded === 0 ? 0 : rounded) : null } if (c.kind === "decimal") { const fixed = Number(n.toFixed(c.decimals)) return fixed === 0 ? 0 : fixed } // A decimal typed into a fraction convention snaps to the printable grid, so the value a field // reports is the price its formatted text shows. The magnitude rounds, as the formatter rounds, // so a negative tie snaps away from zero the way it prints. const unitsPerWhole = c.denominator * (c.eighths ? 8 : 2) const units = Math.round(Math.abs(n) * unitsPerWhole) // Scaling can overflow a finite input, a magnitude from 1e21 up prints in exponent form the // notation cannot parse back, and a sub-half-unit negative would make negative zero. if (!Number.isFinite(units) || units / unitsPerWhole >= 1e21) return null return units === 0 ? 0 : (n < 0 ? -units : units) / unitsPerWhole } /** 4.2531 as "4.253%". */ export function formatYield(v: Nullable, o: { decimals?: number; suffix?: "%" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + (o.suffix ?? "%") } /** 12.5 as "12.5 bp"; signed gives "+12.5 bp". */ export function formatBps(v: Nullable, o: { decimals?: number; signed?: boolean; unit?: "bp" | "bps" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const unit = o.unit ?? "bp" const body = o.signed ? formatSigned(v, { decimals: o.decimals ?? 1, locale: o.locale }) : typographicMinus(fixed(v, o.decimals ?? 1, o.locale)) return unit ? `${body} ${unit}` : body } /** Dollar value of a basis point as whole currency; compact gives "$1.23K". */ export function formatDv01(v: Nullable, o: { currency?: string; compact?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const currency = o.currency ?? "USD" if (o.compact) { const symbol = numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }) .formatToParts(0) .find((p) => p.type === "currency")?.value ?? "" return `${v < 0 ? MINUS : ""}${symbol}${formatNotional(Math.abs(v), { compact: true, locale: o.locale })}` } return typographicMinus(numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }).format(v)) } /** 1250000 as "1.25M" (compact), "1.25mm" (unit "mm", the desk's word for millions, never scaled further), or "1,250,000.00". Compact suffixes K, M, B, T. */ export function formatNotional(v: Nullable, o: { compact?: boolean; decimals?: number; unit?: "mm" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 if (o.unit === "mm") return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(v / 1e6)) + "mm" if (!o.compact) return typographicMinus(fixed(v, decimals, o.locale)) const abs = Math.abs(v) const [divisor, suffix] = abs >= 1e12 ? [1e12, "T"] : abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : abs >= 1e3 ? [1e3, "K"] : [1, ""] return typographicMinus(fixed(v / divisor, suffix ? decimals : 0, o.locale)) + suffix } /** "+0.12", "−0.12" (U+2212), and "0.00" for zero: flat carries no sign. */ export function formatSigned(v: Nullable, o: { decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 2, o.locale, { signDisplay: "exceptZero" })) } /** 1.234 as "1.23%"; signed gives "+1.23%". */ export function formatPercent(v: Nullable, o: { decimals?: number; signed?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 return (o.signed ? formatSigned(v, { decimals, locale: o.locale }) : typographicMinus(fixed(v, decimals, o.locale))) + "%" } /** Whole quantities with grouping: 1000000 as "1,000,000". */ export function formatQuantity(v: Nullable, l?: Locale): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(Math.round(v), 0, l?.locale)) } /** * A coupon the way a run prints it: 4.125 as "4 1/8", 4.5 as "4 1/2", 4 as "4", 0 as "0" (coupons step in eighths). * One off the eighths grid prints as a plain decimal, "4.1". `style: "decimal"` prints "4.125%". */ export function formatCoupon(v: Nullable, o: { style?: "fraction" | "decimal"; decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN if (o.style === "decimal") return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + "%" const eighths = Math.round(Math.abs(v) * 8) if (Math.abs(eighths / 8 - Math.abs(v)) > 1e-9) return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: o.decimals ?? 3 }).format(v)) const whole = Math.floor(eighths / 8) const rem = eighths % 8 const fraction = rem === 0 ? "" : rem % 4 === 0 ? "1/2" : rem % 2 === 0 ? `${rem / 2}/4` : `${rem}/8` const body = fraction ? (whole ? `${whole} ${fraction}` : fraction) : String(whole) return `${v < 0 && eighths > 0 ? MINUS : ""}${body}` } /** A date as an instant, a millisecond count, or a string `Date` can read. A date-only string ("2034-05-15") is that day in UTC. */ export type DateLike = Date | number | string function toDate(d: DateLike | null | undefined): Date | null { if (d === null || d === undefined) return null const date = d instanceof Date ? d : new Date(d) return Number.isNaN(date.getTime()) ? null : date } const dateFormatters = new Map<string, Intl.DateTimeFormat>() /** * A maturity as a run prints it: "05/15/34", or "05/15/2034" with `year: "numeric"`. Read in UTC, so a * date-only string prints as that day wherever the screen is. */ export function formatMaturity(d: DateLike | null | undefined, o: { year?: "2-digit" | "numeric" } & Locale = {}): string { const date = toDate(d) if (!date) return NULL_TOKEN const key = `${o.locale ?? "en-US"}|${o.year ?? "2-digit"}` let f = dateFormatters.get(key) if (!f) { f = new Intl.DateTimeFormat(o.locale ?? "en-US", { month: "2-digit", day: "2-digit", year: o.year ?? "2-digit", timeZone: "UTC" }) dateFormatters.set(key, f) } return f.format(date) } /** Whole UTC days from `now` (the moment, by default) to a maturity. Negative once it has passed; null when the date does not read. */ export function daysToMaturity(d: DateLike | null | undefined, now: DateLike = Date.now()): number | null { const maturity = toDate(d) const from = toDate(now) if (!maturity || !from) return null const day = (x: Date) => Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate()) return Math.round((day(maturity) - day(from)) / 86_400_000) } /** How many ticks `a` is from `b`, signed, to the nearest eighth of a tick: 99-17 against 99-16+ on a 1/64 tick is 1. NaN when a side is not a number or the tick is not positive. */ export function ticksBetween(a: number, b: number, tick: number): number { if (!Number.isFinite(a) || !Number.isFinite(b) || !(tick > 0)) return NaN return Math.round(((a - b) / tick) * 8) / 8 } /** A count of ticks: "+1", "−0.5", "0", up to three decimals and no trailing zeros. Signed unless told otherwise; `unit` adds a word; `grouping: false` drops the thousands separators, for an editor's text. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string; grouping?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero", ...(o.grouping === false ? { useGrouping: false } : {}) }).format(v)) return o.unit ? `${body} ${o.unit}` : body } /** * The class a number wears (contract rule 14): lining, tabular figures whatever the font, in the numeric family. * `--tradecn-font-numeric` is the sans by default, so letters stay proportional and only the digits take one * width, which is what a column of prices wants. docs/typography.md has the reasoning and the tokens. */ export const NUMERIC_CLASS = "font-(family-name:--tradecn-font-numeric) lining-nums tabular-nums" /** The same figures in the mono stack, `--tradecn-font-mono`: for a fraction quote, so digits and the dash take one width; tails still lengthen a quote. */ export const MONO_NUMERIC_CLASS = "font-(family-name:--tradecn-font-mono) lining-nums tabular-nums" /** * The class for a number printed under a convention: a fraction price (99-16+) sets in the mono stack, so * digits and the dash take one width down a column; a tailed quote still runs longer than an untailed one. * Every other price, and every quote in another basis, keeps the numeric family. */ export function numericFontClass(c?: PriceConvention | InstrumentConvention | null): string { const price = c && "price" in c ? (quoteBasisOf(c) === "price" ? c.price : null) : c return price?.kind === "fraction" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS } /** The word for a quote field's label, per basis. */ export const QUOTE_BASIS_LABELS: Record<QuoteBasis, string> = { price: "Price", yield: "Yield", discount: "Discount", spread: "Spread" } const QUOTE_DEFAULTS: Record<Exclude<QuoteBasis, "price">, { step: number; decimals: number }> = { yield: { step: 0.001, decimals: 3 }, discount: { step: 0.001, decimals: 3 }, spread: { step: 0.1, decimals: 1 }, } export function quoteBasisOf(c: InstrumentConvention): QuoteBasis { return c.quoteBasis ?? "price" } /** Whether a higher quote means a lower price for this instrument: declared, else by basis — yield and discount invert, price and spread do not. */ export function quoteInvertedOf(c: InstrumentConvention): boolean { if (c.quoteInverted !== undefined) return c.quoteInverted const basis = quoteBasisOf(c) return basis === "yield" || basis === "discount" } /** What a quote steps by in its basis: the tick for a price, else `quoteStep` or the basis default. */ export function quoteStepOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? c.tick : (c.quoteStep ?? QUOTE_DEFAULTS[basis].step) } function quoteDecimalsOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? 0 : (c.quoteDecimals ?? QUOTE_DEFAULTS[basis].decimals) } /** A quote in the instrument's basis: a price by its convention, else a fixed decimal with the basis's decimals and no unit, since the field's label says the basis. */ export function formatQuote(v: Nullable, c: InstrumentConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN if (quoteBasisOf(c) === "price") return formatPrice(v, c.price, l) return typographicMinus(fixed(roundToTick(v, quoteStepOf(c)), quoteDecimalsOf(c), l?.locale)) } /** Inverse of formatQuote for a quote field. A price takes its notation or a decimal; the others take a decimal and snap to the quote step. Null when the text is not a quote. */ export function parseQuote(s: string, c: InstrumentConvention): number | null { if (quoteBasisOf(c) === "price") return parsePrice(s, c.price) const text = stripGrouping(s, { decimals: quoteDecimalsOf(c) > 0 }) if (text === null || !/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Snapping divides by the step, which can overflow where the raw number does not; what // cannot print back is not a quote. const snapped = roundToTick(n, quoteStepOf(c)) return Number.isFinite(snapped) ? snapped : null } /** Move a quote by `steps` of its step, from the nearest grid value. */ export function stepQuote(v: number, c: InstrumentConvention, steps: number): number { return stepByTick(v, quoteStepOf(c), steps) } /** Bind a convention once per instrument; a grid column calls `formatters[row.instrumentId].price(v)`, a quote field its `quote`, `parseQuote`, and `stepQuote`. */ export function createInstrumentFormatter(c: InstrumentConvention, l?: Locale) { return { tick: c.tick, basis: quoteBasisOf(c), quoteStep: quoteStepOf(c), price: (v: Nullable) => formatPrice(v, c.price, l), yield: (v: Nullable) => formatYield(v, { decimals: c.yieldDecimals ?? 3, locale: l?.locale }), step: (v: number, steps: number) => stepByTick(v, c.tick, steps), parsePrice: (s: string) => parsePrice(s, c.price), quantity: (v: Nullable) => formatQuantity(v, l), quote: (v: Nullable) => formatQuote(v, c, l), parseQuote: (s: string) => parseQuote(s, c), stepQuote: (v: number, steps: number) => stepQuote(v, c, steps), } }lib/grid-rules.ts // Rules as data for a grid: which cells to color, which rows to show, and the order, as plain objects // a desk's support staff can write without a build, compiled here into the functions the grid runs. // // A rule names a column by its key and reads a row through that column's own accessor. A value typed // into a rule is read in the column's own format through its `parse`, so a price rule on a 32nds // column is written the way the column prints, "99-16+"; a numeric column without one reads a plain // number with commas only between thousands. Tones are token names, never a literal // color, and an applied rule carries its meaning in a channel besides the color: `data-rule` names // the rule on the element and the rule's words go in the element's accessible description (contract // rule 15). Nothing here decides a value; it only compares what the row says with what the rule says. import { stripGrouping } from "@/lib/format" export type RuleOp = "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "between" | "in" | "contains" | "startsWith" | "isNull" | "notNull" /** What a rule compares against, as typed. A string is read in the column's format; a number or boolean is used as it is. */ export type RuleValue = string | number | boolean | null /** A token name. The soft variant, or a 12% tint for primary and destructive, tints the background; the text on it is the foreground. */ export type RuleTone = "up" | "down" | "flat" | "stale" | "expiring" | "primary" | "destructive" export interface RuleCondition { op: RuleOp /** For eq, ne, gt, gte, lt, lte, contains, and startsWith. */ value?: RuleValue /** For between (two: low, then high, both inclusive) and in (any number). Each is read as `value` is. */ values?: RuleValue[] } export interface ColumnRule { id: string /** The column whose value the rule reads, by key. */ column: string when: RuleCondition tone: RuleTone /** The rule in words: the accessible description of what it paints. `describeRule` writes one when it is left out. */ label?: string /** What the tone paints: the cell in `column` (default), or the whole row. */ target?: "cell" | "row" } export interface FilterRule extends RuleCondition { column: string } export interface SortRule { key: string dir: "asc" | "desc" } export interface GridRules { /** Cells and rows to color. The first rule in the list that matches a cell decides it. */ columns?: ColumnRule[] /** Every rule has to hold for a row to show. */ filter?: FilterRule[] /** The first rule that tells two rows apart decides their order. */ sort?: SortRule[] } // The types above are rules as you write them. Rules read from saved data are looser: any field can be missing but // a sort key's direction, which reads as ascending unless it says descending, and an op or a tone can be text this version doesn't know, for `ruleProblem` to name. The readers return these, // and every helper, the grid, and the editor take them, so code that reads a rule's fields checks them first. /** A condition as read from saved data: its op can be missing or unknown text. */ export interface ReadCondition extends Omit<RuleCondition, "op"> { op?: string } /** A highlight as read from saved data: any field can be missing, and its tone can be unknown text. */ export interface ReadColumnRule extends Omit<ColumnRule, "id" | "column" | "when" | "tone"> { id?: string column?: string when?: ReadCondition tone?: string } /** A filter rule as read from saved data: its column and op can be missing, and its op unknown text. */ export interface ReadFilterRule extends ReadCondition { column?: string } /** A sort key as read from saved data: its key can be missing. */ export interface ReadSortRule extends Omit<SortRule, "key"> { key?: string } /** Rules as read from saved data. A set of rules you wrote is one of these too. */ export interface ReadGridRules { columns?: ReadColumnRule[] filter?: ReadFilterRule[] sort?: ReadSortRule[] } /** Which list a rule comes from, for the helpers that judge or describe one rule. */ export type RuleKind = "highlight" | "filter" /** What a rule needs of a column. The grid's `ColumnDef` satisfies it. */ export interface RuleColumn<T> { key: string /** Named in a rule's words when it is a string, unless `title` names it; the key otherwise. */ header?: unknown /** The column's name in words, before its header. */ title?: string accessor: (row: T) => unknown numeric?: boolean /** Reads a value typed into a rule in this column's own format. Default: a number for a numeric column, the text itself for the rest. */ parse?: (text: string) => unknown /** Prints a parsed rule value in the rule's words, so a description says what the rule compares — a data-grid column's `format` fits when it reads only the value, since no row exists here. */ format?: (value: unknown, ...rest: never[]) => string } export const RULE_OPS: readonly RuleOp[] = ["eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "contains", "startsWith", "isNull", "notNull"] export const NUMBER_OPS: readonly RuleOp[] = ["eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "isNull", "notNull"] export const TEXT_OPS: readonly RuleOp[] = ["eq", "ne", "contains", "startsWith", "in", "isNull", "notNull"] /** The word for each op, as a rule reads aloud: "Price above 99-16+". */ export const RULE_OP_LABELS: Record<RuleOp, string> = { eq: "is", ne: "is not", gt: "above", gte: "at or above", lt: "below", lte: "at or below", between: "between", in: "one of", contains: "contains", startsWith: "starts with", isNull: "is empty", notNull: "is not empty", } /** The words a rule and its problems are written in. Templates fill `{name}`, `{op}`, `{value}`, `{column}`, `{tone}`, `{low}`, and `{high}`. */ export interface RuleWords { ops: Record<RuleOp, string> tones: Record<RuleTone, string> /** The two ends of a range, `{low}` and `{high}`. */ range: string needsColumn: string noColumn: string needsComparison: string noComparison: string needsValue: string unreadable: string rangeNeedsEnds: string rangeOrder: string setNeedsValue: string needsTone: string noTone: string } /** Words to replace: any of them, a partial map of comparisons or tones included. */ export type RuleWordsInput = Partial<Omit<RuleWords, "ops" | "tones">> & { ops?: Partial<Record<RuleOp, string>>; tones?: Partial<Record<RuleTone, string>> } export const DEFAULT_RULE_WORDS: RuleWords = { ops: RULE_OP_LABELS, tones: { up: "up", down: "down", flat: "flat", stale: "stale", expiring: "expiring", primary: "primary", destructive: "destructive" }, range: "{low} and {high}", needsColumn: "The rule needs a column.", noColumn: 'No column is named "{name}".', needsComparison: "The rule needs a comparison.", noComparison: 'No comparison is named "{op}".', needsValue: "{op} needs a value.", unreadable: '"{value}" is not a value {column} reads.', rangeNeedsEnds: "{op} needs a low value and a high value.", rangeOrder: "{op} needs a low value at or below the high value.", setNeedsValue: "{op} needs at least one value.", needsTone: "The rule needs a tone.", noTone: 'No tone is named "{tone}".', } /** The defaults with yours over them, maps merged key by key. */ // An undefined word keeps its default, as DataGrid's labels do. const defined = <W extends object>(words: W | undefined): Partial<W> => Object.fromEntries(Object.entries(words ?? {}).filter(([, word]) => word !== undefined)) as Partial<W> export function ruleWords(words?: RuleWordsInput): RuleWords { return { ...DEFAULT_RULE_WORDS, ...defined(words), ops: { ...DEFAULT_RULE_WORDS.ops, ...defined(words?.ops) }, tones: { ...DEFAULT_RULE_WORDS.tones, ...defined(words?.tones) } } } const fillWords = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match) export const RULE_TONES: readonly RuleTone[] = ["up", "down", "flat", "stale", "expiring", "primary", "destructive"] /** * The text on a tint: the foreground, with any up, down, flat, stale, expiring, destructive, or primary text inside * taking it too. A state color on a tint can drop below 4.5 to 1 in the light themes, and every state the library * colors also says itself another way, by a sign, a word, or a description. Put it beside every tint behind text. */ export const ON_TINT_CLASS = "text-foreground [&_.text-up]:text-inherit [&_.text-down]:text-inherit [&_.text-flat]:text-inherit [&_.text-stale]:text-inherit [&_.text-expiring]:text-inherit [&_.text-destructive]:text-inherit [&_.text-primary]:text-inherit [&_.text-muted-foreground]:text-inherit" // A tone is a tint of the token's soft variant, painted as a background image rather than a background // color so it layers over whatever color the element already has: a frozen cell keeps its opaque // `bg-background` under the tint and stays opaque as rows scroll beneath it. Literal strings, so Tailwind finds them. const ON_TINT = ON_TINT_CLASS export const RULE_TONE_CLASS: Record<RuleTone, string> = { up: `${ON_TINT} bg-[linear-gradient(var(--up-soft),var(--up-soft))]`, down: `${ON_TINT} bg-[linear-gradient(var(--down-soft),var(--down-soft))]`, flat: `${ON_TINT} bg-[linear-gradient(var(--flat-soft),var(--flat-soft))]`, stale: `${ON_TINT} bg-[linear-gradient(var(--stale-soft),var(--stale-soft))]`, expiring: `${ON_TINT} bg-[linear-gradient(var(--expiring-soft),var(--expiring-soft))]`, primary: `${ON_TINT} bg-[linear-gradient(color-mix(in_oklab,var(--primary)_12%,transparent),color-mix(in_oklab,var(--primary)_12%,transparent))]`, destructive: `${ON_TINT} bg-[linear-gradient(color-mix(in_oklab,var(--destructive)_12%,transparent),color-mix(in_oklab,var(--destructive)_12%,transparent))]`, } /** What an applied rule puts on a cell or a row. */ export interface RuleDecoration { "data-rule": string /** The rule's tone as text, absent when the rule has none: one outside RULE_TONES paints nothing. */ "data-tone"?: string "aria-description": string className: string } /** The ops a column offers: comparisons and ranges for a numeric column, text matching for the rest. */ export function opsFor(column: { numeric?: boolean } | undefined): readonly RuleOp[] { return column?.numeric ? NUMBER_OPS : TEXT_OPS } /** The column's name in words: its title, else its header when that is a string, else its key. */ export function columnName<T>(column: RuleColumn<T> | undefined, key?: string): string { if (column && typeof column.title === "string" && column.title.trim()) return column.title if (column && typeof column.header === "string" && column.header.trim()) return column.header return column?.key ?? textOf(key) ?? "" } /** Null for null, undefined, NaN, and the infinities; the value otherwise. */ export function normalizeValue(value: unknown): unknown { if (value === null || value === undefined) return null if (typeof value === "number" && !Number.isFinite(value)) return null return value } /** Nulls last, numbers numerically, everything else as text. */ export function compareValues(a: unknown, b: unknown): number { const an = normalizeValue(a) === null const bn = normalizeValue(b) === null if (an && bn) return 0 if (an) return 1 if (bn) return -1 if (typeof a === "number" && typeof b === "number") return a - b return String(a).localeCompare(String(b)) } /** `compareValues` in a direction, with a null last whichever way the list runs: a missing price never leads a list sorted high to low. */ export function compareDirected(a: unknown, b: unknown, dir: "asc" | "desc"): number { const an = normalizeValue(a) === null const bn = normalizeValue(b) === null if (an || bn) return compareValues(a, b) const c = compareValues(a, b) return dir === "desc" ? -c : c } /** * A value as typed into a rule, read for a column: a string goes through the column's `parse`, or for * a numeric column through `stripGrouping` and `Number`, and stays text otherwise; a number or boolean is used as it is. * Null when there is nothing to read, and null never matches anything but `isNull`. */ export function readRuleValue<T>(column: RuleColumn<T> | undefined, raw: RuleValue | undefined): unknown { if (raw === undefined || raw === null) return null if (typeof raw === "number" || typeof raw === "boolean") return normalizeValue(raw) // Anything else that is not text (an object or a list in a rule's JSON) has nothing to read. if (typeof raw !== "string") return null if (column?.parse) return normalizeValue(column.parse(raw)) if (column?.numeric) { // The column's notation is unknown here, so a comma reads only as a thousands separator and one comma with no // point after it, which reads either way, is no number: "1,5" is never 15, and "4,253" never 4253. if (raw.trim() === "") return null const text = stripGrouping(raw, { decimals: true }) return text === null ? null : normalizeValue(Number(text)) } return raw } function same(a: unknown, b: unknown): boolean { if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase() if (typeof a === "number" && typeof b === "string") return a === Number(b) if (typeof a === "string" && typeof b === "number") return Number(a) === b return a === b } const needsValue = new Set<RuleOp>(["eq", "ne", "gt", "gte", "lt", "lte", "contains", "startsWith"]) /** True for an op this module compares with. Other text, inherited names such as `toString` included, is not one. */ function isRuleOp(op: unknown): op is RuleOp { return typeof op === "string" && (RULE_OPS as readonly string[]).includes(op) } /** True for a tone this module paints with. */ function isRuleTone(tone: unknown): tone is RuleTone { return typeof tone === "string" && (RULE_TONES as readonly string[]).includes(tone) } function isCondition(condition: unknown): condition is RuleCondition { return typeof condition === "object" && condition !== null && isRuleOp((condition as RuleCondition).op) } // A value that is not text is quoted as its JSON, so an object in a rule reads as `{}`, not [object Object]. One JSON // cannot print reads through `String`, so a bigint prints its digits, and by its tag when even that throws (an object // whose own `toString` is not a function), so this never throws. function shown(raw: unknown): string { if (typeof raw === "string") return raw if (typeof raw === "number") return String(raw) try { return JSON.stringify(raw) ?? String(raw) } catch { try { return String(raw) } catch { return Object.prototype.toString.call(raw) } } } // A name or a word from saved data: text as it is, anything else as its JSON, nothing for null or undefined. const textOf = (value: unknown): string | undefined => (value === undefined || value === null ? undefined : shown(value)) // A value to compare with: text, a number, a boolean, or null as they are; anything else as its JSON text, so the // value the editor shows is the value compared and `ruleProblem` can name it. const valueOf = (value: unknown): RuleValue | undefined => value === undefined ? undefined : value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : shown(value) // An object that is not a list: what a rule, a condition, or a set of rules is saved as. const isEntry = (value: unknown): value is object => typeof value === "object" && value !== null && !Array.isArray(value) // The saved object with the fields this module reads replaced by their readings, or removed when a reading is // nothing. Every other field an app keeps on a rule (an owner, a flag, an id on a filter) passes through, so an edit // made from a read rule never loses it. function keep<R>(saved: Record<string, unknown>, read: Record<string, unknown>): R { const rule: Record<string, unknown> = { ...saved } for (const [key, value] of Object.entries(read)) { if (value === undefined) delete rule[key] else rule[key] = value } return rule as R } /** * A condition as a desk saved it, read: its op as text (one this module does not know stays as its text, for * `ruleProblem` to name), its value as `valueOf` reads it, and its values only from a list; any other field passes * through. Undefined when the condition is not an object. */ export function readCondition(value: unknown): ReadCondition | undefined { if (!isEntry(value)) return undefined const saved = value as Record<string, unknown> return keep<ReadCondition>(saved, { op: textOf(saved.op), value: valueOf(saved.value), values: Array.isArray(saved.values) ? saved.values.flatMap((item) => (item === undefined ? [] : [valueOf(item)!])) : undefined, }) } /** * A highlight as a desk saved it, read: names and words as text, its condition read, a label only when it is text, * and the row as its target only when it says so; a field it reads to nothing is left out, and any other field * passes through. Null when it is not an object. */ export function readColumnRule(value: unknown): ReadColumnRule | null { if (!isEntry(value)) return null const saved = value as Record<string, unknown> return keep<ReadColumnRule>(saved, { id: textOf(saved.id), column: textOf(saved.column), when: readCondition(saved.when), tone: textOf(saved.tone), label: typeof saved.label === "string" ? saved.label : undefined, target: saved.target === "row" || saved.target === "cell" ? saved.target : undefined, }) } /** A filter rule as a desk saved it, read as a condition on its column; any other field passes through. Null when it is not an object. */ export function readFilterRule(value: unknown): ReadFilterRule | null { if (!isEntry(value)) return null return keep<ReadFilterRule>(readCondition(value) as unknown as Record<string, unknown>, { column: textOf((value as Record<string, unknown>).column) }) } /** A sort key as a desk saved it, read: its key as text, descending only when it says so; any other field passes through. Null when it is not an object. */ export function readSortRule(value: unknown): ReadSortRule | null { if (!isEntry(value)) return null const saved = value as Record<string, unknown> return keep<ReadSortRule>(saved, { key: textOf(saved.key), dir: saved.dir === "desc" ? "desc" : "asc" }) } /** * Rules a desk saved or shared, read into the shape the grid and the editor take: a list that is not a list is * left out, an entry that is not an object is dropped, and each rule is read as `readColumnRule`, `readFilterRule`, * and `readSortRule` read it; any other field passes through. Nothing is decided here: a rule that cannot apply * still reads, for `ruleProblem` to name. */ export function readRules(value: unknown): ReadGridRules { if (!isEntry(value)) return {} const saved = value as Record<string, unknown> const list = <R,>(items: unknown, read: (item: unknown) => R | null): R[] | undefined => Array.isArray(items) ? items.flatMap((item) => { const rule = read(item); return rule ? [rule] : [] }) : undefined return keep<ReadGridRules>(saved, { columns: list(saved.columns, readColumnRule), filter: list(saved.filter, readFilterRule), sort: list(saved.sort, readSortRule) }) } // A highlight carries its condition in `when` and has a tone; a filter rule carries its condition flat and has no tone. const isHighlightShape = (rule: object) => "when" in rule || "tone" in rule // A list that is not a list reads as none. const listOf = (value: unknown): readonly unknown[] => (Array.isArray(value) ? value : []) // A list of values that is not a list reads as none. const valuesOf = (condition: { values?: unknown }): readonly RuleValue[] => (Array.isArray(condition.values) ? condition.values : []) /** * One condition compiled against one column: the typed values are read once, and the function that * comes back only compares. A row whose value is null answers only `isNull` and `notNull`. Rules are * data a desk edits, so a condition that is missing or names no op this module knows matches nothing * rather than throwing, as do `in` and `between` when their `values` are not a list; `ruleProblem` says * what is wrong with it. */ export function compileCondition<T>(saved: ReadCondition | undefined, column: RuleColumn<T>): (row: T) => boolean { const condition = readCondition(saved) if (!isCondition(condition)) return () => false const { op } = condition const want = readRuleValue(column, condition.value) const list = valuesOf(condition).map((v) => readRuleValue(column, v)).filter((v) => v !== null) const read = (row: T) => normalizeValue(column.accessor(row)) switch (op) { case "isNull": return (row) => read(row) === null case "notNull": return (row) => read(row) !== null case "eq": return want === null ? () => false : (row) => same(read(row), want) case "ne": return want === null ? () => false : (row) => { const v = read(row) return v !== null && !same(v, want) } case "gt": case "gte": case "lt": case "lte": { if (want === null) return () => false return (row) => { const v = read(row) if (v === null) return false const c = compareValues(v, want) return op === "gt" ? c > 0 : op === "gte" ? c >= 0 : op === "lt" ? c < 0 : c <= 0 } } case "between": { const [lo, hi] = list if (lo === undefined || hi === undefined) return () => false return (row) => { const v = read(row) return v !== null && compareValues(v, lo) >= 0 && compareValues(v, hi) <= 0 } } case "in": return list.length === 0 ? () => false : (row) => list.some((item) => same(read(row), item)) case "contains": case "startsWith": { if (want === null) return () => false const needle = String(want).toLowerCase() return (row) => { const v = read(row) if (v === null) return false const text = String(v).toLowerCase() return op === "contains" ? text.includes(needle) : text.startsWith(needle) } } } } function findColumn<T>(columns: readonly RuleColumn<T>[], key: string | undefined): RuleColumn<T> | undefined { return key === undefined ? undefined : columns.find((column) => column.key === key) } /** * Why a rule cannot apply, in a sentence, or null when it can: a column the grid does not have, a * value the column cannot read, a range without its two ends, a set with nothing in it, a missing or * unknown comparison or tone. `kind` says which list the rule is in; without it, a `when` or a `tone` * key makes the rule a highlight. */ export function ruleProblem<T>(rule: ReadColumnRule & ReadFilterRule, columns: readonly RuleColumn<T>[], kind?: RuleKind, words?: RuleWordsInput): string | null { const w = ruleWords(words) if (typeof rule !== "object" || rule === null) return w.needsColumn const named = textOf(rule.column) if (named === undefined) return w.needsColumn const column = findColumn(columns, named) if (!column) return fillWords(w.noColumn, { name: named }) const highlight = kind ? kind === "highlight" : isHighlightShape(rule) // The condition as the grid reads it: one saved as anything but an object that is not a list is none. const read = readCondition(highlight ? rule.when : rule) const op = read?.op if (op === undefined || op === "") return w.needsComparison if (!isRuleOp(op)) return fillWords(w.noComparison, { op }) // The op is one this module knows, from here on. const condition = read as RuleCondition const name = columnName(column) const unreadable = (raw: RuleValue | undefined) => raw !== undefined && raw !== null && raw !== "" && readRuleValue(column, raw) === null if (needsValue.has(condition.op)) { if (condition.value === undefined || condition.value === null || condition.value === "") return fillWords(w.needsValue, { op: w.ops[condition.op] }) if (unreadable(condition.value)) return fillWords(w.unreadable, { value: shown(condition.value), column: name }) } if (condition.op === "between") { const values = valuesOf(condition) if (values.length !== 2) return fillWords(w.rangeNeedsEnds, { op: w.ops.between }) for (const raw of values) if (unreadable(raw) || raw === "" || raw === null) return fillWords(w.unreadable, { value: raw === null ? "" : shown(raw), column: name }) if (compareValues(readRuleValue(column, values[0]), readRuleValue(column, values[1])) > 0) return fillWords(w.rangeOrder, { op: w.ops.between }) } if (condition.op === "in") { const values = valuesOf(condition).filter((raw) => raw !== null && raw !== "") if (!values.length) return fillWords(w.setNeedsValue, { op: w.ops.in }) for (const raw of values) if (unreadable(raw)) return fillWords(w.unreadable, { value: shown(raw), column: name }) } // A highlight named as one always needs a tone; one only inferred from its keys is judged by the tone it carries. const tone = textOf(rule.tone) if (highlight && (kind || "tone" in rule) && !isRuleTone(tone)) return tone === undefined || tone === "" ? w.needsTone : fillWords(w.noTone, { tone }) return null } /** The rule in words: "Price above 99-16+", "Client one of ALPHA, BETA", "Status is empty". `kind` reads as for `ruleProblem`. */ export function describeRule<T>(rule: ReadColumnRule & ReadFilterRule, columns: readonly RuleColumn<T>[], kind?: RuleKind, words?: RuleWordsInput): string { if (typeof rule !== "object" || rule === null) return "" const w = ruleWords(words) const column = findColumn(columns, textOf(rule.column)) const highlight = kind ? kind === "highlight" : isHighlightShape(rule) const condition: Partial<ReadCondition> = readCondition(highlight ? rule.when : rule) ?? {} const name = columnName(column, rule.column) const word = isRuleOp(condition.op) ? w.ops[condition.op] : (condition.op ?? "") // A string threshold reads through the column's parse, so the words print what the rule // compares: a decimal typed into a fraction column describes, as it matches, on the grid. const text = (raw: RuleValue | undefined) => { if (raw === undefined || raw === null) return "" if (typeof raw === "string" && column?.parse && column.format) { // Normalized like the comparison: a parser's NaN matches nothing, so its words must not // print a formatted non-value. A parser's throw propagates here as it does everywhere. const parsed = normalizeValue(column.parse(raw)) if (parsed !== null && parsed !== undefined) { try { const printed = column.format(parsed) if (typeof printed === "string" && printed !== "") return printed } catch { // A format that needs its row has no row here; the typed text stands. } } } return shown(raw) } if (condition.op === "isNull" || condition.op === "notNull") return `${name} ${word}` if (condition.op === "between") { const [lo, hi] = valuesOf(condition) return `${name} ${word} ${fillWords(w.range, { low: text(lo), high: text(hi) })}` } if (condition.op === "in") return `${name} ${word} ${valuesOf(condition).map(text).join(", ")}` return `${name} ${word} ${text(condition.value)}`.trim() } /** * Every rule has to hold. A rule that can't apply is skipped, so it hides nothing: one on a column the grid does not * have, and one `ruleProblem` names, with a comparison it doesn't know or a value its column can't read. */ export function compileFilter<T>(rules: readonly ReadFilterRule[], columns: readonly RuleColumn<T>[]): (row: T) => boolean { const tests: ((row: T) => boolean)[] = [] for (const entry of listOf(rules)) { const rule = readFilterRule(entry) const column = rule ? findColumn(columns, rule.column) : undefined if (rule && column && ruleProblem(rule as ReadColumnRule & ReadFilterRule, columns, "filter") === null) tests.push(compileCondition(rule, column)) } if (!tests.length) return () => true if (tests.length === 1) return tests[0]! return (row) => tests.every((test) => test(row)) } /** The first rule that tells two rows apart decides, nulls last either way. Undefined when no rule names a column the grid has. */ export function compileComparator<T>(rules: readonly ReadSortRule[], columns: readonly RuleColumn<T>[]): ((a: T, b: T) => number) | undefined { const keys: { accessor: (row: T) => unknown; dir: "asc" | "desc" }[] = [] for (const entry of listOf(rules)) { const rule = readSortRule(entry) const column = rule ? findColumn(columns, rule.key) : undefined if (rule && column) keys.push({ accessor: column.accessor, dir: rule.dir }) } if (!keys.length) return undefined return (a, b) => { for (const { accessor, dir } of keys) { const c = compareDirected(accessor(a), accessor(b), dir) if (c !== 0) return c } return 0 } } /** The decoration a matched rule puts on its cell or row. */ export function ruleDecoration<T>(saved: ReadColumnRule, columns: readonly RuleColumn<T>[]): RuleDecoration { const rule = readColumnRule(saved) ?? {} return { "data-rule": rule.id ?? "", "data-tone": rule.tone, "aria-description": (typeof rule.label === "string" ? rule.label.trim() : "") || describeRule(rule, columns, "highlight"), className: isRuleTone(rule.tone) ? RULE_TONE_CLASS[rule.tone] : "", } } export interface AppliedRules<T> { /** The first cell rule on this column that the row matches, or undefined. */ cell: (columnKey: string, row: T) => RuleDecoration | undefined /** The first row rule the row matches, or undefined. Shaped for the grid's `getRowProps`. */ getRowProps: (row: T) => RuleDecoration | undefined /** The rules that name a column the grid has, by column key, in the order they apply. */ byColumn: ReadonlyMap<string, readonly ReadColumnRule[]> } /** * Column rules compiled once: each against its column, its decoration made ahead of time, and the * grid's two hooks, `cell` and `getRowProps`, answering per row with a matched rule's decoration. A * rule naming a column the grid does not have is skipped. */ export function applyRules<T>(rules: readonly ReadColumnRule[], columns: readonly RuleColumn<T>[]): AppliedRules<T> { const cells = new Map<string, { test: (row: T) => boolean; decoration: RuleDecoration }[]>() const rows: { test: (row: T) => boolean; decoration: RuleDecoration }[] = [] const byColumn = new Map<string, ReadColumnRule[]>() for (const entry of listOf(rules)) { const rule = readColumnRule(entry) const column = rule ? findColumn(columns, rule.column) : undefined if (!rule || !column) continue const compiled = { test: compileCondition(rule.when, column), decoration: ruleDecoration(rule, columns) } byColumn.set(column.key, [...(byColumn.get(column.key) ?? []), rule]) if (rule.target === "row") rows.push(compiled) else cells.set(column.key, [...(cells.get(column.key) ?? []), compiled]) } return { cell(columnKey, row) { const list = cells.get(columnKey) if (!list) return undefined for (const { test, decoration } of list) if (test(row)) return decoration return undefined }, getRowProps(row) { for (const { test, decoration } of rows) if (test(row)) return decoration return undefined }, byColumn, } } -
Add the tokens to your stylesheet:
:root { --down: oklch(0.46 0.19 40); --down-soft: oklch(0.46 0.19 40 / 18%); --expiring-soft: oklch(0.53 0.2 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.55 0 0 / 14%); --stale-soft: oklch(0.52 0.13 75 / 16%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --up: oklch(0.52 0.13 165); --up-soft: oklch(0.52 0.13 165 / 18%); } .dark { --down: oklch(0.66 0.19 40); --down-soft: oklch(0.66 0.19 40 / 18%); --expiring-soft: oklch(0.74 0.17 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.7 0 0 / 14%); --stale-soft: oklch(0.8 0.15 80 / 16%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --up: oklch(0.74 0.15 165); --up-soft: oklch(0.74 0.15 165 / 18%); } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
Seed a stable store, define its columns, and give the grid an accessible name and a container with a height. Sizes below are notionals; prices print in 32nds.
import { useState } from "react"
import { createInstrumentFormatter, formatNotional } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef } from "@/components/ui/data-grid"
interface Inquiry {
id: string
client: string
size: number
price: number
}
const rows: Inquiry[] = [
{ id: "Q-1", client: "ALPHA", size: 5_000_000, price: 99.5 },
{ id: "Q-2", client: "BETA", size: 10_000_000, price: 99.515625 },
{ id: "Q-3", client: "GAMMA", size: 15_000_000, price: 99.53125 },
]
const note = createInstrumentFormatter({ price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 })
const columns: ColumnDef<Inquiry>[] = [
{ key: "client", header: "Client", width: 140, accessor: (row) => row.client },
{ key: "size", header: "Size", width: 100, numeric: true, accessor: (row) => row.size, format: (value) => formatNotional(value as number, { unit: "mm" }) },
{ key: "price", header: "Price", width: 100, numeric: true, font: "mono", accessor: (row) => row.price, format: (value) => note.price(value as number) },
]
export default function DataGridDemo() {
const [store] = useState(() => {
const store = createRowStore<Inquiry>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: rows })
return store
})
return <div className="h-40 w-fit max-w-full"><DataGrid store={store} columns={columns} preset="rfq" label="Open inquiries" /></div>
}
Controlled state
Control sort and columnState when your application needs to keep or restore the view. Click a header to sort; drag its edge to resize, or open its menu to move or hide a column. Client stays frozen on the left. Reset view restores both state values.
Choose left alignment in the preview toolbar to keep the grid's left edge fixed while resizing. The choice applies to previews across the site and is saved in this browser.
import { useState } from "react"
import { formatNotional } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef, type ColumnState, type SortState } from "@/components/ui/data-grid"
interface Inquiry { id: string; client: string; side: "Buy" | "Sell"; size: number }
const columns: ColumnDef<Inquiry>[] = [
{ key: "client", header: "Client", width: 160, frozen: "left", sortable: true, accessor: (row) => row.client },
{ key: "side", header: "Side", width: 100, sortable: true, accessor: (row) => row.side, cell: ({ row }) => <span className={row.side === "Buy" ? "text-up" : "text-down"}>{row.side}</span> },
{ key: "size", header: "Size", width: 120, numeric: true, sortable: true, accessor: (row) => row.size, format: (value) => formatNotional(value as number, { unit: "mm" }) },
]
const initialColumns: ColumnState = { order: [], widths: {}, hidden: [] }
const initialSort: SortState = { key: "size", dir: "desc" }
export default function DataGridControlledDemo() {
const [store] = useState(() => {
const store = createRowStore<Inquiry>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: [
{ id: "Q-1", client: "ALPHA", side: "Buy", size: 5_000_000 },
{ id: "Q-2", client: "BETA", side: "Sell", size: 15_000_000 },
{ id: "Q-3", client: "GAMMA", side: "Buy", size: 10_000_000 },
] })
return store
})
const [sort, setSort] = useState<SortState>(initialSort)
const [columnState, setColumnState] = useState<ColumnState>(initialColumns)
return (
<>
<div data-demo-controls className="flex flex-wrap items-center gap-3 text-xs lining-nums tabular-nums">
<button type="button" className="rounded border px-2 py-1" onClick={() => { setSort(initialSort); setColumnState(initialColumns) }}>Reset view</button>
<span>{sort ? `Sort: ${sort.key} ${sort.dir}` : "No sort"} · {columnState.hidden.length} hidden</span>
</div>
<div className="h-48 w-fit max-w-full">
<DataGrid store={store} columns={columns} preset="rfq" label="Inquiries with controlled columns" sort={sort} onSortChange={setSort} columnState={columnState} onColumnStateChange={setColumnState} />
</div>
</>
)
}
Shared defaults
Pass the same baseState and controlled columnState to the grid and ColumnChooser. Install ColumnChooser for this example; its installation includes DataGrid.
Uncontrolled defaults
Use baseState to initialize an uncontrolled grid and choose its reset defaults. Later changes apply on the next Reset columns action.
import { useState } from "react"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef, type ColumnState } from "@/components/ui/data-grid"
type Quote = { id: string; price: number; size: number }
const columns: ColumnDef<Quote>[] = [
{ key: "id", header: "Quote", width: 96, frozen: "left", accessor: row => row.id },
{ key: "price", header: "Price", width: 100, numeric: true, accessor: row => row.price, format: value => Number(value).toFixed(2) },
{ key: "size", header: "Size", width: 90, numeric: true, accessor: row => row.size },
]
const compact: ColumnState = { order: [], widths: { price: 144 }, hidden: ["size"] }
const expanded: ColumnState = { order: ["id", "size", "price"], widths: { price: 120 }, hidden: [] }
export default function DataGridDefaultsDemo() {
const [store] = useState(() => {
const store = createRowStore<Quote>({ getRowId: row => row.id })
store.applyDeltas({ upsert: [
{ id: "Q-1", price: 100.25, size: 500 },
{ id: "Q-2", price: 99.75, size: 250 },
] })
return store
})
const [expandedDefaults, setExpandedDefaults] = useState(false)
return (
<>
<div data-demo-controls className="flex max-w-sm flex-col gap-2 text-xs">
<label className="flex items-center gap-2">
<input type="checkbox" checked={expandedDefaults} onChange={event => setExpandedDefaults(event.target.checked)} />
Show size on reset
</label>
<p className="text-muted-foreground">Change the defaults, then choose Reset columns from a column menu.</p>
</div>
<div className="h-40 w-fit max-w-full">
<DataGrid store={store} columns={columns} baseState={expandedDefaults ? expanded : compact} label="Quotes with local settings" />
</div>
</>
)
}
Selection and actions
Set selectionMode="multi" to extend the RFQ preset's single selection. Use the checkboxes, Shift for a range, or Command/Ctrl to toggle rows. A selected row is marked by a bar at the start of its first cell, the selection box's when there is one, rather than filled, so its up and down text keeps its contrast. In a grid that scrolls sideways, freeze the first column or show the selection box, so the bar stays in view; Watchlist, Positions, RfqStack, AuditTrail, and AlertHistory freeze theirs. Right-click plain content in a selected row, or press Shift+F10 with the grid focused, to act on the selection; an unselected row targets just itself.
Enter on the grid or a double-click on plain cell content activates a row. The Inspect button acts on its own row and preserves selection. This example prints the requested action below the grid; your handler opens a ticket or sends a command.
import { useMemo, useState } from "react"
import { ContextMenuItem } from "@/components/ui/context-menu"
import { formatNotional } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef } from "@/components/ui/data-grid"
interface Inquiry { id: string; client: string; size: number }
const inquiryColumns: ColumnDef<Inquiry>[] = [
{ key: "client", header: "Client", width: 160, accessor: (row) => row.client },
{ key: "size", header: "Size", width: 120, numeric: true, accessor: (row) => row.size, format: (value) => formatNotional(value as number, { unit: "mm" }) },
]
export default function DataGridSelectionDemo() {
const [store] = useState(() => {
const store = createRowStore<Inquiry>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: [
{ id: "Q-1", client: "ALPHA", size: 5_000_000 },
{ id: "Q-2", client: "BETA", size: 10_000_000 },
{ id: "Q-3", client: "GAMMA", size: 15_000_000 },
] })
return store
})
const [selection, setSelection] = useState<ReadonlySet<string>>(new Set())
const [action, setAction] = useState("No action yet.")
const columns = useMemo<ColumnDef<Inquiry>[]>(() => [...inquiryColumns, {
key: "inspect", header: "Action", width: 100, accessor: () => null,
cell: ({ row }) => <button type="button" className="rounded border border-border px-2 focus-visible:outline-2 focus-visible:outline-ring" aria-label={`Inspect ${row.client}`} onClick={() => setAction(`Inspect: ${row.client}`)}>Inspect</button>,
}], [])
return (
<div className="w-fit max-w-full space-y-2 text-xs lining-nums tabular-nums">
<div className="h-48">
<DataGrid
store={store} columns={columns} preset="rfq" label="Select inquiries"
selectionMode="multi" selectionColumn selection={selection} onSelectionChange={setSelection}
onRowActivate={(row) => setAction(`Open ticket: ${row.client}`)}
renderContextMenu={(rows) => (
<>
<ContextMenuItem onClick={() => setAction(`Quote: ${rows.map((row) => row.client).join(", ")}`)}>Quote inquiries</ContextMenuItem>
<ContextMenuItem onClick={() => setAction(`Pass: ${rows.map((row) => row.client).join(", ")}`)}>Pass inquiries</ContextMenuItem>
</>
)}
/>
</div>
<p>{selection.size} selected</p>
<p role="status">{action}</p>
</div>
)
}
Totals for the view
Each footer function receives all rows in the filtered view, including rows outside the viewport. Filter out the smallest inquiry to see the count fall from three to two and the size total from 30mm to 25mm. Keep the footer functions and filter stable between renders.
import { useState } from "react"
import { formatNotional } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef } from "@/components/ui/data-grid"
interface Inquiry { id: string; client: string; size: number }
const columns: ColumnDef<Inquiry>[] = [
{ key: "client", header: "Client", width: 160, accessor: (row) => row.client },
{ key: "size", header: "Size", width: 120, numeric: true, accessor: (row) => row.size, format: (value) => formatNotional(value as number, { unit: "mm" }) },
]
const largeInquiry = (row: Inquiry) => row.size >= 10_000_000
const footer = {
client: (rows: Inquiry[]) => `${rows.length} inquiries`,
size: (rows: Inquiry[]) => formatNotional(rows.reduce((total, row) => total + row.size, 0), { unit: "mm" }),
}
export default function DataGridTotalsDemo() {
const [store] = useState(() => {
const store = createRowStore<Inquiry>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: [
{ id: "Q-1", client: "ALPHA", size: 5_000_000 },
{ id: "Q-2", client: "BETA", size: 10_000_000 },
{ id: "Q-3", client: "GAMMA", size: 15_000_000 },
] })
return store
})
const [largeOnly, setLargeOnly] = useState(false)
return (
<>
<div data-demo-controls className="text-xs lining-nums tabular-nums">
<label className="flex items-center gap-2"><input type="checkbox" checked={largeOnly} onChange={(event) => setLargeOnly(event.target.checked)} />Only sizes of at least 10mm</label>
</div>
<div className="h-48 w-fit max-w-full">
<DataGrid store={store} columns={columns} preset="rfq" label="Inquiry totals" filter={largeOnly ? largeInquiry : undefined} footer={footer} />
</div>
</>
)
}
Store updates
Pass feed batches to store.applyDeltas: patch changes existing rows, remove drops expired inquiries, and upsert adds arrivals. Receive a batch below to step through these changes with twelve rows. The button supplies sample feed data; no clock or connection runs in the background. Prices move by one tick, with the signed change beside them.
Scroll down before receiving a batch to see the RFQ preset keep the first visible row in place when an earlier row leaves. A feed that already batches can call applyDeltas directly; use createFrameBatcher when messages arrive individually, and cancel it when the feed disconnects.
import { useRef, useState } from "react"
import { createInstrumentFormatter, formatSigned } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef } from "@/components/ui/data-grid"
interface Inquiry { id: string; price: number; change: number }
const note = createInstrumentFormatter({ price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 })
const columns: ColumnDef<Inquiry>[] = [
{ key: "id", header: "Inquiry", width: 120, frozen: "left", accessor: (row) => row.id },
{ key: "price", header: "Price", width: 100, numeric: true, font: "mono", accessor: (row) => row.price, format: (value) => note.price(value as number) },
{ key: "change", header: "Change", width: 100, numeric: true, accessor: (row) => row.change, format: (value) => formatSigned(value as number, { decimals: 6 }) },
]
export default function DataGridUpdatesDemo() {
const [store] = useState(() => {
const store = createRowStore<Inquiry>({ getRowId: (row) => row.id, lane: "ordered" })
store.applyDeltas({ upsert: Array.from({ length: 12 }, (_, i) => ({ id: `Q-${i + 1}`, price: 99.5, change: 0 })) })
return store
})
const nextId = useRef(13)
const [received, setReceived] = useState(0)
const receive = () => {
const ids = store.getIds()
const patches = ids.slice(1).map((id, i) => {
const row = store.getRow(id)!
const change = (i % 2 === 0 ? 1 : -1) / 64
return { id, fields: { price: row.price + change, change } }
})
// A feed supplies one batch: changed quotes, an expired inquiry, and a new arrival.
store.applyDeltas({ patch: patches, remove: [ids[0]!], upsert: [{ id: `Q-${nextId.current++}`, price: 99.5, change: 0 }] })
setReceived((count) => count + 1)
}
return (
<>
<div data-demo-controls className="flex items-center gap-3 text-xs lining-nums tabular-nums">
<button type="button" className="rounded border px-2 py-1" onClick={receive}>Receive a batch</button>
<span>Batches received: {received}</span>
</div>
<div className="h-56 w-fit max-w-full">
<DataGrid store={store} columns={columns} preset="rfq" label="Updating inquiries" />
</div>
</>
)
}
API Reference
Props
T is your row type; RowId is a string. Give the grid a container with a height. Keep unchanged columns, filter, sort, and rule lists stable between renders to avoid rebuilding the grid's view.
| Prop | Type | Default | Purpose |
|---|---|---|---|
store |
RowStore<T> |
Required | Rows from row-store. |
columns |
ColumnDef<T>[] |
Required | Column definitions; see below. |
label |
string |
Required | Accessible name of the grid. |
preset |
DataGridPreset |
"blotter" |
Defaults from the preset table. |
view |
RowView<T> |
Internally owned view | Supply your own membership, order, and reorder hold. |
filter |
(row: T) => boolean |
None | Filter the internally owned view. |
rules |
ReadGridRules |
None | Cell and row tones, filters, and sort rules, written or read with readRules. |
rowHeight |
number |
Preset | Fixed row height in px. |
overscan |
number |
8 |
Extra rows rendered beyond the viewport. |
rowEnter |
Partial<RowEnterBehavior> |
Preset | Override highlight, pinViewport, or followTail booleans. |
reorderHoldMs |
number |
Preset | Delay in ms before the internally owned view reorders after interaction. |
announceRowCount |
"off" | "debounced" |
Preset | Enable the polite row-count announcement, cleared 5 seconds after it is said. |
selectionMode |
"none" | "single" | "multi" |
Preset | Selection behavior. |
selectionColumn |
boolean |
false |
Show checkboxes in multi-select mode. They take a pointer, not Tab: they add no Tab stops, and Space on the grid toggles the focused row. The header menu buttons keep theirs. |
flashWindowMs |
number |
900 |
Cell flash duration in ms. A value that isn't a finite length of zero or more uses the default. |
footer |
Record<string, (rows: T[]) => string> |
None | Totals keyed by column key. |
onEdit |
(change: EditChange<T>) => void | Promise<unknown> |
None | Handle commits; enables columns with edit. |
onRowActivate |
(row: T, id: RowId) => void |
None | Handle grid Enter or an unhandled double-click on plain row content, unless it opens an editor. |
renderContextMenu |
(rows: T[], ids: RowId[], target?: RowId | null) => ReactNode |
None | Menu items opened from an owned row, for the selection or targeted row. target is the row the menu opened on, which can sit inside a larger selection, taken from the event that opened it. |
getRowProps |
(row: T, id: RowId) => RowDecoration | undefined |
None | Row classes, state, tone, and accessible description. The class lands on the row's frozen cells too, over their opaque background, so keep it to colors and marks. |
getRowLabel |
((row: T, id: RowId) => string) | null |
None | A row's name that holds while its cells tick, such as its symbol. null is the same as none. Keep its identity stable. |
emptyState |
ReactNode |
"No rows" |
Empty-view content. |
labels |
Partial<DataGridLabels> |
DEFAULT_DATA_GRID_LABELS |
The grid's own words; see Labels. |
className |
string |
None | Classes on the grid root. |
initialRect |
{ width: number; height: number } |
None | Viewport size in px before measurement, for tests or server rendering. |
Each context-menu opening remounts its content and supplies a fresh snapshot of target rows, including reopening during a closing animation. Ordinary rerenders within an opening preserve content state. Subscribe inside custom content for values that must update while it stays open. ids is the raw target set while rows holds only the targets still in the store, so after a removal the arrays differ in length and do not line up by index: act on ids, and read each row through the store when you need its fields.
RowDecoration accepts optional string fields: className, data-state, data-rule, data-tone, and aria-description. A row's description is yours, then a row rule's words, joined, so neither hides the other.
State and callbacks
Omit a state prop to let the grid manage it. Pass it to control that state, and apply changes from its callback. Callbacks also work with internal state.
| Prop | Type | Default | Purpose |
|---|---|---|---|
columnState |
ColumnState |
Initial baseState |
Complete snapshot of order, width overrides in px, and hidden keys. |
onColumnStateChange |
(state: ColumnState) => void |
None | Receive column changes and resets. |
baseState |
ColumnState |
{ order: [], widths: {}, hidden: [] } |
Initial uncontrolled settings and the latest reset target. |
sort |
SortState |
null |
{ key: string, dir: "asc" | "desc" } or no header sort. |
onSortChange |
(sort: SortState) => void |
None | Receive header sort changes. |
selection |
ReadonlySet<RowId> |
Empty set | Selected rows. |
onSelectionChange |
(selection: ReadonlySet<RowId>) => void |
None | Receive selection changes. |
focusedRowId |
RowId | null |
null |
Focused row. |
onFocusedRowChange |
(id: RowId | null) => void |
None | Receive row focus changes. |
The grid initializes uncontrolled columns from baseState once. Replacing it later leaves current settings untouched until Reset columns is chosen.
Controlled columnState always supplies the complete snapshot; defaults are never merged into it. Initialize controlled state from your defaults or a saved snapshot.
Reset emits the configured baseState through onColumnStateChange, including when it already matches. It preserves sort, selection and row focus. The grid applies its usual column ordering, visibility and minimum widths without normalizing the emitted snapshot.
ColumnChooser normalizes its edits and suppresses effective no-ops; share defaults for the same rendered columns, not identical callback payloads or counts.
Keep an external chooser or application reset control when a snapshot can hide every column: an empty header has no menu to reopen columns.
A move from the header or Alt+arrows rebuilds the order from visible columns, so a hidden column loses its place and returns at the end when shown; ColumnChooser keeps hidden columns in place when the same state matters. A move across the frozen boundary changes nothing, and the header menu disables it.
Drag a column's right edge to resize it. Movement is measured from its starting width, rounded to pixels, and clamped to minWidth. Other column settings and the change callback come from the latest committed props.
Resizing starts on an unhandled primary-button press from a primary pointer. A press already prevented by an application handler does not start a drag.
Releasing or canceling the pointer, losing pointer capture or window focus, or removing the column ends the drag. Accepted widths remain in place. The edge handle is pointer-only; focus the grid, choose a column with Left or Right, then use Alt+Shift+Left / Right to resize it by 8 px.
Columns
Each ColumnDef<T> describes one column. Frozen columns stay on the left, before the other columns regardless of columnState.order.
| Field | Type | Default | Purpose |
|---|---|---|---|
key |
string |
Required | Column identity for state, rules, and edits. |
header |
ReactNode |
Required | Header content. |
title |
string |
The header, when it is a nonblank string | The column's name in words: the header's name to a screen reader, its menu button and resize handle, the editor, the column chooser, and the CSV header. Without one, a header of markup or a blank one is named by the key, as menus and the CSV are. |
width |
number |
Required | Initial width in px. |
accessor |
(row: T) => unknown |
Required | Value used for display, sorting, rules, and flash direction. |
minWidth |
number |
48 |
Minimum width in px. |
align |
"left" | "right" | "center" |
Right if numeric, otherwise left | Text alignment. |
frozen |
"left" |
None | Keep the column visible during horizontal scrolling. |
sortable |
boolean |
false |
Enable header sort controls. |
hidden |
boolean |
false |
Hide the column regardless of column state. |
format |
(value: unknown, row: T) => string |
String(value); nullish values use NULL_TOKEN |
Display text. Carries into rule descriptions on a column that also has parse, when it reads just the value. |
cell |
(ctx: { row: T; value: unknown; rowId: RowId; edit?: CellEditHandle }) => ReactNode |
Formatted text | Custom content; flashes still follow the accessor. |
numeric |
boolean |
false |
Numeric typography, alignment, and default flashing. |
font |
"numeric" | "mono" |
"numeric" |
Font family for numeric cells. |
flash |
false | "fill" | "ring" |
Preset for numeric columns; otherwise false |
Override or disable cell flashes. |
parse |
(text: string) => unknown |
Number if numeric; otherwise text | Read values in grid rules. Separate from edit.parse. |
edit |
CellEdit<T> |
None | Editing behavior; also requires the grid's onEdit. |
Numeric cells carry data-numeric and use lining, tabular figures in --tradecn-font-numeric. Use font: "mono" for fraction quotes such as 99-16+, giving digits and dashes equal widths; quotes with and without a tail still differ in length, so a right-aligned column lines tails up only against padding you add. typography explains the choice; numericFontClass in format selects it from an instrument convention.
Presets
| Preset | Height (px) | Selection | Flash | Hold (ms) | Highlight arrivals | Viewport | Announce count |
|---|---|---|---|---|---|---|---|
blotter |
24 | Multi | Fill | 750 | Yes | Pin | Yes |
watchlist |
22 | Single | Fill | 0 | No | Unpinned | No |
rfq |
26 | Single | Ring | 1,000 | Yes | Pin | Yes |
option-chain |
20 | None | Ring | 0 | No | Unpinned | No |
tape |
22 | Single | Fill | 0 | Yes | Follow tail | Yes |
parameters |
24 | Single | Ring | 0 | No | Pin | No |
Override behavior with the corresponding props and each column's flash; override the presets' text-xs size through className. watchlist, blotter, and parameter-grid build on these presets.
How it stays inside the frame
Rows subscribe individually through useRow. A value update that leaves the view's membership and order unchanged re-renders the affected visible row without re-rendering the other rows. React batches notifications from one applyDeltas call. Sorting, filtering, and footer totals can also do work for that batch.
TanStack Virtual positions fixed-height rows. The focused row and the row being edited render even outside the window, so whenever the grid has a height aria-activedescendant names an element that exists, and Shift+F10 brings the focused row into view and opens its menu there. With pinViewport, arrivals or removals adjust scrollTop by the first visible row's change in index times rowHeight, provided that row remains in the view.
Flash memory is keyed by row and column. A cell returning with the same value resumes a flash only while its window remains open. Reduced motion uses a static mark instead of animation. The install adds the up, down, and flat tokens, and the soft variants of those three and of stale and expiring, if absent; flashes use the first three, and rules tint with all five soft variants.
The reorder hold
Key events delivered to the grid from its own element or contained controls call view.touch(), even when already handled. That includes a cell editor's keys, so a batch can't re-sort the row being typed into while the hold lasts. Nested grids and portaled content outside the grid are excluded.
Pointer presses in the scroll area call touch() during capture, including presses on controls and editors. This keeps rows steady while a control handles its press. Nested grids and content portaled outside the grid have their own interaction ownership.
During the hold, existing rows keep their relative order, new rows append, and removed or filtered-out rows leave. The view sorts again when the hold expires, even without another feed update.
A row the view held before, or one the store held when the view last changed, doesn't arrive: a row a filter lets back in, or one that comes back into the view by any other road, neither flashes nor counts in the row count's new. A row the store took in while a filter hid it counts as an arrival when the filter lets it in. An arrival the hold parks starts its highlight window when the hold lapses, not when it arrived — and no later hold revives it. A parked row that is already rendered, at a visible tail or in the overscan band, waits with the others and flashes in place on the grid's deadline timer when the hold lapses, whether or not the release publishes an update. One scrolled to later flashes whatever remains of its window. The grid's own keys and presses carry extensions to still-parked marks; calling touch() on a supplied view directly shortens those remainders instead of restarting them. This release accounting needs the view's holdExpiresAt, which the internally owned view and row-store views provide; on a custom view without it, a hold longer than the highlight window swallows the parked flash.
The reorderHoldMs prop configures the internally owned view. With a supplied view, configure its hold yourself; the grid still calls touch() on it.
Following the tail
Use tape for append-only feeds, or set rowEnter={{ followTail: true, pinViewport: false }} on another preset. Row-entry overrides merge with the preset, so turn pinning off explicitly when switching from it.
The viewport moves to the end when following is enabled and as rows arrive. Interactions that touch this grid's reorder hold, or scrolling away, pause following. An N new button counts the rows in view that the store didn't hold when following paused, so rows that left, or rows a filter lets back in, never count; click it or scroll to the end to resume.
Footer totals
footer={{ size: (rows) => formatQuantity(sum(rows)) }} adds a sticky totals row. Each function receives all rows in the filtered, ordered view, including rows outside the viewport. Only displayed columns with a footer function get a value.
Totals calculate on mount and subscribe to store batches. Changes to the view's ids, resolved columns, footer object, or store also recompute them. Keep footer stable between renders. Totals have no per-frame timer or flash and retain the column's numeric typography and alignment.
Rules as data
grid-rules, installed alongside the grid, supplies GridRules: plain data for tones, filtering, and ordering.
| Rule | Internally owned view | Supplied view |
|---|---|---|
rules.columns |
Color cells and rows | Color cells and rows |
rules.filter |
Every rule must pass, along with filter |
Ignored, as is filter |
rules.sort |
Break ties after the header sort; provide the order when no header sort is set | Ignored; the view owns the order |
A supplied view also ignores sort for ordering. Header controls can still report changes through onSortChange for you to apply.
String rule values use the column's parse when provided, for example parse: (text) => parsePrice(text, convention), and a parsed decimal threshold snaps to the convention's grid, as a cell editor that parses with parsePrice does. The column's format carries into rule descriptions only when it reads just the value — descriptions call it without a row, so a row-reading format keeps the typed words while the threshold still snaps. Numeric and boolean values bypass it. Matched cells and rows carry data-rule, data-tone, and an accessible description. getRowProps takes precedence over row decorations; a cell's rejection message takes precedence over its rule description.
Editing in place
Give the grid onEdit and the column a CellEdit<T>:
| Field | Type | Default | Purpose |
|---|---|---|---|
parse |
(text: string, row: T) => unknown |
Required | Return a value or editProblem("…"). |
format |
(value: unknown, row: T) => string |
Column formatter or String(value); blank for nullish values |
Editor and pending text. |
validate |
(value: unknown, row: T, commit?: EditCommit) => EditProblem | null | undefined |
None | Return a problem to refuse the value. The grid passes commit, { via, repeat, session }: via is "enter", "tab", "blur", or "value" for a toggle or a cell control's commit; repeat is true for the repeats of a held Enter or Tab in an editor, and for a cell control's commit that says so, and false for a blur and for a toggle, whose held Space, Enter, or F2 commits once; session numbers each opening of an editor, uniquely across every grid on the page, and value commits share 0; pending(key) gives the value another cell of the row has sent and not yet had back, by column key, or undefined, so a check reads the row as it shows. A check that asks a question can insist on a fresh Enter in the same opening for the answer. |
step |
(value: unknown, dir: 1 | -1, big: boolean, row: T) => unknown |
None | Return the next value for Up or Down; Shift sets big. |
toggle |
(value: unknown, row: T) => unknown |
None | Return a value to commit without opening an editor. |
canEdit |
(row: T) => boolean |
Returns true |
Make individual cells read-only with false. |
Use big to implement a larger step, such as ten ticks with Shift; the grid does not multiply it. A cell refused by canEdit carries aria-readonly="true". An editor whose cell canEdit refuses after it opened keeps the typed text: Enter says labels.notEditable over the cell instead of closing it, and sends once the cell takes edits again; Escape discards the text. The editor opens a phone's text keyboard, since the decimal pad has no minus for a value below zero. parameter-grid builds a parameter sheet on this API.
Only one text editor opens at a time. A failed parse or validation leaves it open with its problem in words over the cell, the editor's description, and a destructive ring; leaving the editor instead discards invalid input and commits valid changes. An editor opened and left unchanged sends nothing, whatever rounding its format applies, and every close of an untouched editor — Enter, Tab, blur, Escape, or the teardowns below — restores the promise-backed pending or rejected state it covered, with a promise that settled meanwhile applied to it. A pending from a void onEdit has no promise to settle it, so reopening dismisses it the way v1 did. A value equal to the store's current value sends nothing.
Hiding or removing a column while its text editor is still open discards the draft without calling onEdit. A row that a filter or removal takes out of the view does the same: its open editor closes, the draft is discarded, and the returning row does not reopen it or take focus. A re-sort that only moves the row keeps the editor open with its draft. The row being edited stays rendered wherever the viewport goes, so an editor scrolled or re-sorted out of the window keeps its input and its focus. Removing its edit configuration or switching to toggle also discards it.
Restoring the column does not reopen the draft. These column changes preserve pending and rejected edits.
A commit calls onEdit with { rowId, key, value, previous, row }. The grid never writes the store. Its default renderer shows the committed text muted, in italics with a dotted underline that holds under a rule's tint, with data-pending and a description that says Pending (labels.pending), until the store value matches it or the returned promise resolves. Resolution clears pending state and displays the current store value, which may still be the old value. Returning nothing leaves the edit pending until the store matches.
A thrown error or rejection of a still-pending promise displays the store value with the error message, data-rejected, and destructive styling, except inside a rule's tint, where its text is the foreground. Pending state matches replies by value, not by request, even with promises: commit 105, then 106, then 105 again, and the first request's success settles the third's pending mark, whose own later rejection is dropped. Returning the promise from onEdit lets a rejection reach the cell, but the grid cannot tell two requests for the same value apart — keep one request in flight per cell by refusing the cell through canEdit while a reply is pending, as ParameterGrid's server demo does by withdrawing the permission. A custom cell that merely disables its control blocks the pointer only: on a toggle column, the grid's Space, Enter, and F2 still commit. Reopening the editor clears the error while it is open; closing it untouched brings the error back, and committing a change replaces it. A pending cell can also be reopened, starting from its committed text.
A custom cell receives edit: { status, commit(value, how?), open() } when editing is enabled for its column. It sees status as absent or an object whose kind is pending or rejected. While a text editor is open, the grid renders its built-in editor instead of calling cell. The renderer chooses its content and can disable its control while pending; commit(value) validates and sends the value without parsing text. Pass { repeat: true } as how for a commit a held key repeats, so validate sees it as one.
For an available text-editable cell, open() opens and focuses the text editor and scrolls its row and its column into view. It does nothing for a row outside the view, and on the cell already being edited it keeps the draft and asks for focus again. It does not select the row or change the grid's logical row or chosen column. To return grid navigation to that row after editing, control focusedRowId and update it in the action handler before calling open(). Column shortcuts still use the previously chosen column; choose a column with grid navigation or its header.
Identity
Selection, focus, and context-menu targets use row ids, so a reorder preserves their identity. The grid never prunes selection or a menu's targets: a removed or filtered-out row stays selected and counted until your application drops it, and a Shift range whose anchor has left the view starts from the top of the view. Prune selection as rows retire, as the watchlist layout demo does against the symbols its remove handler receives. aria-activedescendant names the focused row while its id is in the view; each data row's aria-rowindex is its zero-based view index plus two, accounting for the header.
With announcements enabled, a 1,000 ms timer reports the row count through a polite live region, for example "1,024 rows, 12 new". It starts on mount and restarts when the count changes. Continuous count changes delay the announcement.
Without getRowLabel, or with null, a row is named by its data cells, never its selection box, so a screen reader can read the focused row again at every update. With it, each row is named by what it returns, and the grid reads the focused row's cells once through a polite live region of its own, 400 ms after focus rests on the row: on a new row, or when the grid takes focus. A cell that changes never reads the row, and an empty name leaves the row named by its cells, still read. The reading clears 5 seconds after it is said and when focus leaves the grid, so a reader browsing the rows later meets no stale words. Return a name that tells rows apart, and keep the function's identity stable, since a new one re-renders every row.
The reading is each column's cell in order, joined with commas, and approximates the accessible-name rules for content: a text field's value and a select's chosen option, labelled or not and whatever their role, so an <input role="combobox"> reads its value; on an element that takes a name, the targets of aria-labelledby, each read whole, a hidden target and hidden content inside a target included, or else its aria-label; an image's alt; otherwise the text, with elements side by side read as separate words. An element takes a name when the first word of its role is anything but generic, presentation, none, caption, code, deletion, emphasis, insertion, paragraph, strong, subscript, superscript, or time, or, with no role, when it is an a, area, button, iframe, img, input, meter, output, progress, select, textarea, or svg element; any other element but an image, a span, a heading, or a table included, is read by its content. So any other labelled control, such as a slider or a combobox built on a button, reads its label rather than its value. Outside those targets it leaves out content marked aria-hidden or hidden, and it leaves out the selection checkbox. It reads content hidden only by CSS, such as display: none or visibility: hidden, which a screen reader skips, so mark such content aria-hidden or hidden as well. It doesn't read content inside a shadow root, a title attribute, a cell's rule description, a control's state, or the column names.
Pointer interactions
A primary-button press on plain cell content requests row focus and selection. Other buttons request focus only. selectionMode="none" leaves selection unchanged, and controlled state changes only when your callback applies the request.
In multi-select mode, Shift adds a range from the current view without clearing the existing selection. Changes made in capture or focus callbacks are included. Ctrl or Cmd toggles a row.
A double-click opens an editable text cell or activates the row.
Buttons, links, inputs, editors, focusable content and interactive ARIA widgets keep their own pointer behavior. Their presses and double-clicks do not implicitly select, focus, edit or activate the row. Selection checkboxes and custom edit.commit controls still run their explicit actions.
Fallback ARIA role lists use the first recognized role: role="unsupported button" owns its interaction, while role="status button" remains plain content.
Use native controls with accessible names in custom cells. A custom handler can also claim a press or double-click with preventDefault() or stopPropagation(). Row actions run during bubbling, after the child handler. To claim one from a parent, use its capture handler.
With renderContextMenu, right-clicking plain content in a selected row keeps the selection; another row becomes the target. Either way the row under the pointer is the menu's target, even while a parent that controls focusedRowId has not moved focus there; a touch's long press opens it on the touched row, and Shift+F10 or the Menu key on the focused row, the menu opening at the focused cell, which it first scrolls into view, or 8 px into the row's visible left edge before a column is chosen. Controls, nested grids, content portaled outside this grid, headers, footers and empty space do not open its row menu. Controls retain their own context menus, including the browser's default when the application leaves it available.
When a row menu is installed, rejected contextmenu, non-mouse pointerdown, and single-touch touchstart events stop React bubbling after child handlers run. Use capture handlers on ancestors to observe them. Multiple-touch touchstart events reach the menu so it can cancel a pending long press.
Rejected pointer and touch starts retain native propagation for dialog dismissal and touch tracking. Rejected context-menu events from contained targets also stop native bubbling, including later listeners on a hydrated document root. Events from content portaled outside the grid retain native delivery.
A document-level bubbling handler cannot suppress the browser menu for those contained targets. Call preventDefault() in the custom control's onContextMenu handler to suppress it there. A capture handler that prevents every context-menu event also claims the grid's row menus.
Open shadow roots use the browser's event path. Closed roots expose only their hosts. Set data-grid-interaction on the host or a containing element outside that root:
| Value | Row actions | Reorder hold and tail following |
|---|---|---|
control |
Skip row actions and row menu. | Retain hold and pause on presses and keys. |
independent |
Skip row actions and row menu. | Skip hold and pause. Use for a separate widget. |
Markers add no roles or tab stops. Child handlers still run. Custom content owns accessibility, focus and styles.
Mark interactive closed-root content explicitly. It can otherwise trigger row actions. Plain text in an open shadow root still selects or activates its row.
CSS such as pointer-events: none can send a disabled control's press to the cell beneath it. Wrap that control to keep those interactions within the custom content:
import type { ReactNode } from "react"
export function CellControl({ children }: { children: ReactNode }) {
return <span className="inline-flex leading-normal" data-grid-interaction="control">{children}</span>
}
export function DisabledCellAction() {
return <CellControl><button type="button" disabled className="pointer-events-none">Inspect</button></CellControl>
}
Keyboard
These DataGrid shortcuts run with focus on the grid itself. Header controls, selection checkboxes, and custom cell controls keep their own key behavior.
After using a control, press Shift+Tab until the grid itself has focus, or click a cell without a control, to resume grid navigation.
To handle a grid shortcut in a parent, call preventDefault() from onKeyDownCapture. A parent's bubbling onKeyDown runs after the grid. The grid skips commands for events already prevented.
| Key | Action |
|---|---|
| Up / Down | Move row focus, bringing the focused column back into view sideways. |
| PageUp / PageDown | Move row focus by a viewport, bringing the focused column back into view. |
| Home / End | Focus the first / last row, bringing the focused column back into view. |
| Shift + a row navigation key | Extend selection in multi-select mode. |
| Left / Right | Move column focus, scrolling the column into view sideways, clear of the frozen columns. |
| Space | Toggle selection in multi mode; select in single mode. On an editable toggle cell, commit its toggle instead. A held Space acts once, as a checkbox's does. Does nothing while the focused row is outside the view. |
| Enter | Edit the focused editable cell, including toggles, once per press; otherwise activate the row, once per press. An editor opens, and a toggle commits, with its column scrolled into view. Does nothing while the focused row is outside the view. |
| F2 | Edit the focused editable cell, including toggles, once per press. An editor opens, and a toggle commits, with its column scrolled into view. Does nothing while the focused row is outside the view. |
| Type a character other than Space | Open an editable text cell with that character, once per press; the editor takes the repeats. Toggle cells and Ctrl, Cmd, or Alt combinations do not open an editor, and nothing opens while the focused row is outside the view. |
| Escape | Clear selection, once per press. |
| Ctrl or Cmd+A | Select all rows in the view in multi mode, once per press. |
| Alt+Left / Right | Move the focused column, keeping it in view, even when a controlled columnState takes the move later. Does nothing past either end or across the frozen columns, which lead whatever the order says. |
| Alt+Shift+Left / Right | Resize the focused column by 8 px, keeping it in view. Alt+Shift+Left does nothing at the column's minimum width. |
| Alt+S | Cycle a sortable column: ascending, descending, off, one step per press. Option+S on a Mac, by the key's place, as Alt+H is. |
| Alt+H | Hide the focused column, once per press. |
| Shift+F10 / Menu | Open the context menu on the focused row, once per press: at the focused cell, scrolled into view, or 8 px into the row's visible left edge before a column is chosen. |
Row navigation also selects the focused row in single-select mode. A row focus moves to, by the keys, a parent's focusedRowId, or a hand-off, comes into view, and a row already in view stays where it is. When the focused row leaves the view, focus goes to the row now at its place, or to the last row, and the keys go on from there; focus set on a row that hasn't arrived yet waits for it, and an open row menu keeps the row it opened on; a parent that controls focusedRowId is asked once, and while it keeps the departed row, the keys that act on a row do nothing. Each column header has move, hide, and reset controls, plus a resize handle. Sort controls appear only when the column has sortable: true. Move left and Move right are disabled at either end and beside the frozen columns, and a move brings the column into view, as Alt+Left and Right do.
When a column disappears while its menu trigger or menu owns focus, focus returns to the grid. A surviving menu trigger retains the menu's normal close-focus behavior.
If a column disappears or no longer supports a text editor, its focused editor returns focus to the grid when removed. Focus moved elsewhere is left alone.
Hiding or removing the focused column clears column focus. Press Left or Right on the grid to choose a visible column before using its shortcuts. The chosen column is shown visually and is not reported to assistive technology, which hears the row as one unit: a screen-reader workflow drives column actions from the header controls, not the grid shortcuts. The header menu carries move, hide, sort, and reset but no resize — Alt+Shift+arrows is the only keyboard resize, and it relies on that unannounced column focus.
Existing sort, row focus, and selection remain unchanged. A controlled hide request leaves column focus in place until the caller accepts it.
Inside a text editor:
| Key | Action |
|---|---|
| Enter | Commit and return focus to the grid. |
| Escape | Discard the edit and return focus to the grid. |
| Tab / Shift+Tab | Commit and open the next / previous editable text cell in the row, skipping toggles. At either end, return focus to the grid. |
| Up / Down | Call step with direction 1 / -1. Shift sets big: true. |
| Ctrl, Cmd, or Alt + Up / Down | Leave the event to listeners above the grid, such as a hotkey registry. |
Opening an editor selects its text unless you opened it by typing a character.
Labels
labels merges partial overrides into DEFAULT_DATA_GRID_LABELS. A template fills {name} with a column's title and {n} and {m} with counts in the reader's locale; a label that holds a count also takes a function of the count, for a language whose plurals a template can't hold.
| Label | Default | Where |
|---|---|---|
selectRow |
Select row |
A row's selection box |
selection |
Selection |
The selection boxes' column header |
columnMenu / resizeColumn |
{name} column menu / Resize {name} |
A header's menu button and resize handle |
sortAscending / sortDescending / clearSort |
Sort ascending / Sort descending / Clear sort |
The header menu's sort item |
moveLeft / moveRight / hideColumn |
Move left / Move right / Hide column |
The header menu |
resetColumns |
Reset columns, then (2 hidden) while columns are hidden |
The header menu's reset item; {n} the hidden columns |
rowCount |
1 row, 12 rows, then , 3 new when rows arrived |
What a screen reader hears once the count settles; {n} the rows, {m} the arrivals |
newRows |
3 new |
The button that takes a paused tape back to its end; {n} the arrivals |
rejected |
Rejected |
A refused edit whose error is an Error with no message, a blank string, or anything else |
notEditable |
This cell can't be edited now. |
An open editor's commit after its cell stopped taking edits |
pending |
Pending |
A cell whose edit is sent and not yet back, said beside a rule's words |
noRows |
No rows |
An empty view, unless emptyState says otherwise; the grid's description while it is empty |
Watchlist, Blotter, Positions, RfqStack, and AuditTrailGrid pass labels through to the grid, and ParameterGrid's and QuotePanel's labels take these beside their own.
Exporting CSV
exportCsv(store, columns, ids) returns the rows ids names as CSV text, quoted as RFC 4180 has it, with a header row of each column's title and a column for each definition except one whose definition says hidden. Pass resolveColumns(columns, columnState) for the columns as the grid shows them, in its order and without the ones the column state hides. A cell is the column's format of its value, else the value as text, and nothing for null. A number's typographic minus goes out as the hyphen-minus where the cell then reads as one number, so a spreadsheet sums the negatives; one with a unit, as in −2.5mm, keeps its own. A cell a spreadsheet would run as a formula, one led by =, +, -, @, a tab, or a carriage return, goes out behind an apostrophe, unless a sign leads a cell that is wholly one plain number, digits with an optional point and exponent, which in a column whose value is a number may also group its thousands with commas.
What it does not do
The grid does not fetch data, group rows, render trees, paste multiple cells, support variable row heights, or persist state. Feed the store and save state through the callbacks; edits remain commands for your application to handle.
It lays out left to right only: frozen columns and the selection box stick to the left, and the keys scroll sideways as in a left-to-right document. Under dir="rtl" the frozen columns sit on the wrong side.
Built on shadcn's checkbox, context-menu, and dropdown-menu.
This page is docs/data-grid.md at v2.0.0.