Positions
A positions grid with signed quantities, average and mark prices, day and total P&L, a risk column you name, and totals for the displayed book.
import { useState } from "react"
import { createRowStore } from "@/lib/row-store"
import { Positions, type PositionRow } from "@/components/ui/positions"
const rows: PositionRow[] = [
{ id: "T2", book: "Cash", instrument: "T 2Y", position: 50_000_000, average: 100.25, mark: 100.28125, dayPnl: 15_625, totalPnl: 62_000, risk: 21_500 },
{ id: "T10", book: "Cash", instrument: "T 10Y", position: -25_000_000, average: 99.5, mark: 99.515625, dayPnl: -3_906, totalPnl: -12_000, risk: -21_000 },
{ id: "T30", book: "Cash", instrument: "T 30Y", position: 0, average: null, mark: 98.75, dayPnl: 0, totalPnl: null, risk: null },
]
export default function PositionsDemo() {
const [store] = useState(() => {
const store = createRowStore<PositionRow>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: rows })
return store
})
return <div className="h-48 w-fit max-w-full"><Positions store={store} label="Cash positions" riskHeader="DV01" /></div>
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/positions#v2.0.0
npx shadcn@latest add tradecn/ui/positions#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/positions#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/positions#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/ positions.tsx import { cn } from "cn" import { useCallback, useMemo } from "react" import { directionClass, directionOf } from "@/hooks/use-flash" import { NULL_TOKEN, formatNotional, formatPrice, formatQuantity, formatSigned } from "@/lib/format" import type { RowId } from "@/lib/row-store" import { DataGrid, type ColumnDef, type DataGridProps } from "@/components/ui/data-grid" // A positions grid: the data grid with a book's columns, the position signed in the instrument's // unit, the average and the mark, the day's and the total P&L colored by their sign with the sign // printed, a risk number the consumer formats, and totals under the body. Every number is the // server's; the grid prints and adds, and works nothing out. // // One grid per book. Grouping and tree rows would cost the grid its fixed row height, which is what // makes an arrival above the first visible row move the viewport by exactly one row. export interface PositionRow { id: string book?: string instrument: string /** Signed: long above zero, short below, in the row's unit. */ position: number /** Notional by default; `contracts` prints a count. */ quantityUnit?: "notional" | "contracts" /** The average price of the position, printed through `price`. */ average?: number | null /** The mark it is carried at, printed through `price`; flashes by direction. */ mark?: number | null /** In the account's currency, as the server figures it. */ dayPnl?: number | null totalPnl?: number | null /** The desk's risk number for the row: a DV01, a delta, a beta-weighted exposure. Printed through `risk`. */ risk?: number | null } export interface PositionsColumnOptions<T extends PositionRow> { /** How to print a price for this row. Instruments differ, so it gets the row. Two decimals by default. */ price?: (value: number, row: T) => string /** How to print a P&L. Signed, no decimals, by default. */ pnl?: (value: number, row: T) => string /** How to print the risk column. Signed, no decimals, by default. */ risk?: (value: number, row: T) => string /** The risk column's header. `Risk` by default: name it what the desk calls it. */ riskHeader?: string } const twoDecimals = (value: number) => formatPrice(value, { kind: "decimal", decimals: 2 }) const wholeSigned = (value: number) => formatSigned(value, { decimals: 0 }) /** A gain printed by a formatter that drops the plus gets it back: the sign is the channel the color is not (contract rule 15). */ export function withSign(value: number, text: string): string { return value > 0 && !/^[+\u2212-]/.test(text) ? `+${text}` : text } /** The position the way the desk says it, with its sign: `+5mm`, `−2.5mm`, or `+120` contracts. Zero is flat and carries no sign. */ export function formatPosition(row: PositionRow): string { const v = row.position if (!Number.isFinite(v)) return NULL_TOKEN const sign = v > 0 ? "+" : "" if (row.quantityUnit === "contracts") return sign + formatQuantity(v) return sign + formatNotional(v, { unit: "mm" }) } /** Long, short, or flat, as a word for the row and the `data-side` a stylesheet reads. */ export function positionSide(position: number): "long" | "short" | "flat" { return position > 0 ? "long" : position < 0 ? "short" : "flat" } /** Book, instrument, position, average, mark, day P&L, total P&L, risk. Spread them into your own list to add, drop, or reorder. */ export function positionsColumns<T extends PositionRow>(options: PositionsColumnOptions<T> = {}): ColumnDef<T>[] { const price = options.price ?? twoDecimals const pnl = options.pnl ?? wholeSigned const risk = options.risk ?? wholeSigned const px = (value: unknown, row: T) => (typeof value === "number" ? price(value, row) : NULL_TOKEN) // The sign is printed and the side is named, so the color is not the only thing saying which way. const signed = (format: (value: number, row: T) => string) => (value: unknown, row: T) => (typeof value === "number" ? withSign(value, format(value, row)) : NULL_TOKEN) const colored = (format: (value: number, row: T) => string) => ({ value, row }: { value: unknown; row: T }) => <span className={directionClass(typeof value === "number" ? directionOf(0, value) : "flat")}>{signed(format)(value, row)}</span> return [ { key: "book", header: "Book", width: 84, sortable: true, flash: false, accessor: (r) => r.book ?? null }, { key: "instrument", header: "Instrument", width: 140, frozen: "left", sortable: true, flash: false, accessor: (r) => r.instrument, cell: ({ row }) => <span className="font-semibold">{row.instrument}</span> }, { key: "position", header: "Position", width: 96, numeric: true, sortable: true, accessor: (r) => r.position, format: (_, row) => formatPosition(row), cell: ({ row }) => ( <span data-side={positionSide(row.position)} className={directionClass(directionOf(0, row.position))}> {formatPosition(row)} </span> ), }, { key: "average", header: "Average", width: 96, numeric: true, sortable: true, flash: false, accessor: (r) => r.average ?? null, format: px }, { key: "mark", header: "Mark", width: 96, numeric: true, sortable: true, accessor: (r) => r.mark ?? null, format: px }, { key: "dayPnl", header: "Day P&L", width: 96, numeric: true, sortable: true, accessor: (r) => r.dayPnl ?? null, format: signed(pnl), cell: colored(pnl) }, { key: "totalPnl", header: "Total P&L", width: 104, numeric: true, sortable: true, accessor: (r) => r.totalPnl ?? null, format: signed(pnl), cell: colored(pnl) }, { key: "risk", header: options.riskHeader ?? "Risk", width: 88, numeric: true, sortable: true, accessor: (r) => r.risk ?? null, format: signed(risk), cell: colored(risk) }, ] } /** Totals of the day's P&L, the total P&L, and the risk, for the grid's footer; a book's position has no total across instruments. */ export function positionsTotals<T extends PositionRow>(options: PositionsColumnOptions<T> = {}): Record<string, (rows: T[]) => string> { const pnl = options.pnl ?? wholeSigned const risk = options.risk ?? wholeSigned const sum = (rows: T[], read: (row: T) => number | null | undefined, format: (value: number, row: T) => string) => { let total = 0 let any = false for (const row of rows) { const v = read(row) if (typeof v === "number" && Number.isFinite(v)) { total += v any = true } } return any && rows[0] ? withSign(total, format(total, rows[0])) : NULL_TOKEN } return { instrument: (rows) => `${rows.length} ${rows.length === 1 ? "position" : "positions"}`, dayPnl: (rows) => sum(rows, (r) => r.dayPnl, pnl), totalPnl: (rows) => sum(rows, (r) => r.totalPnl, pnl), risk: (rows) => sum(rows, (r) => r.risk, risk), } } export interface PositionsProps<T extends PositionRow = PositionRow> extends Omit<DataGridProps<T>, "columns" | "preset" | "label" | "footer" | "renderContextMenu">, PositionsColumnOptions<T> { /** `positionsColumns(options)` by default. */ columns?: ColumnDef<T>[] label?: string /** The totals row. `positionsTotals(options)` by default; `false` for none; your own record for other sums. */ totals?: false | Record<string, (rows: T[]) => string> /** Your items for the right-click menu, with the row it opened on as `target`. */ renderContextMenu?: DataGridProps<T>["renderContextMenu"] } // A row's name is its instrument, which holds while its mark and P&L tick. One grid per book keeps it unique. const positionLabel = (row: PositionRow) => row.instrument export function Positions<T extends PositionRow = PositionRow>({ columns, price, pnl, risk, riskHeader, label = "Positions", totals, renderContextMenu, className, store, getRowProps, getRowLabel = positionLabel, selectionMode = "single", ...grid }: PositionsProps<T>) { const options = useMemo<PositionsColumnOptions<T>>(() => ({ price, pnl, risk, riskHeader }), [price, pnl, risk, riskHeader]) const all = useMemo(() => columns ?? positionsColumns<T>(options), [columns, options]) const footer = useMemo(() => (totals === false ? undefined : (totals ?? positionsTotals<T>(options))), [totals, options]) // The row says its side in words a screen reader hears, under whatever your own // props say. Keyed on the caller's function: a new decoration reaches rows // already on screen, and a memoized one keeps row identity across unrelated // re-renders. The menu passes straight through, as blotter's does. const rowProps = useCallback((row: T, id: RowId) => { const own = getRowProps?.(row, id) const side = positionSide(row.position) return { ...own, "data-state": own?.["data-state"] ?? side, "aria-description": own?.["aria-description"] ?? (side === "flat" ? undefined : side) } }, [getRowProps]) return ( <div data-slot="tradecn-positions" className={cn("flex h-full min-h-0 flex-col gap-1 lining-nums tabular-nums", className)}> <div className="min-h-0 flex-1"> <DataGrid<T> {...grid} store={store} preset="blotter" selectionMode={selectionMode} label={label} columns={all} footer={footer} getRowProps={rowProps} getRowLabel={getRowLabel} renderContextMenu={renderContextMenu} /> </div> </div> ) }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
import { useState } from "react"
import { createRowStore } from "@/lib/row-store"
import { Positions, type PositionRow } from "@/components/ui/positions"
const rows: PositionRow[] = [
{ id: "T2", book: "Cash", instrument: "T 2Y", position: 50_000_000, average: 100.25, mark: 100.28125, dayPnl: 15_625, totalPnl: 62_000, risk: 21_500 },
{ id: "T10", book: "Cash", instrument: "T 10Y", position: -25_000_000, average: 99.5, mark: 99.515625, dayPnl: -3_906, totalPnl: -12_000, risk: -21_000 },
{ id: "T30", book: "Cash", instrument: "T 30Y", position: 0, average: null, mark: 98.75, dayPnl: 0, totalPnl: null, risk: null },
]
function CashPositions() {
const [store] = useState(() => {
const store = createRowStore<PositionRow>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: rows })
return store
})
return <div className="h-48 w-fit max-w-full"><Positions store={store} label="Cash positions" riskHeader="DV01" /></div>
}
The three rows show long, short and flat positions in one book. Without quantityUnit, quantities print as notional amounts in millions. Missing optional values use –. The footer counts the positions and sums their supplied P&L and risk, omitting missing values; it does not sum quantities across instruments.
Every value is a fixed server snapshot. The grid displays these numbers and totals them; it does not calculate P&L from average prices, marks or quantities.
Formats and units
Set quantityUnit: "contracts" on a row for a contract count and leave it off for notional amounts. This example prints TY as +120 and the cash position as −25mm. A stable price callback chooses fractional prices for T 10Y and three decimal places for TY. The shared money formatter prints compact P&L and DV01 for both cells and totals; it retains negative signs.
import { useState } from "react"
import { createInstrumentFormatter, formatDv01 } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { Positions, type PositionRow } from "@/components/ui/positions"
const note = createInstrumentFormatter({ price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 })
const price = (value: number, row: PositionRow) => row.instrument === "T 10Y" ? note.price(value) : value.toFixed(3)
const money = (value: number) => formatDv01(value, { compact: true })
export default function PositionsFormatsDemo() {
const [store] = useState(() => {
const store = createRowStore<PositionRow>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: [
{ id: "TY", book: "Rates", instrument: "TY", position: 120, quantityUnit: "contracts", average: 110.281, mark: 110.5, dayPnl: 26_250, totalPnl: 41_000, risk: 8_400 },
{ id: "T10", book: "Rates", instrument: "T 10Y", position: -25_000_000, average: 99.5, mark: 99.515625, dayPnl: -3_906, totalPnl: -12_000, risk: -21_000 },
] })
return store
})
return <div className="h-40 w-fit max-w-full"><Positions store={store} label="Formatted rates positions" riskHeader="DV01" price={price} pnl={money} risk={money} /></div>
}
Separate books
Give each book its own store and named grid to keep selection, sorting and totals separate. These two books each contain one position, so each footer belongs only to that book. Book seeds its store once from its initial rows; subsequent data belongs in store batches.
import { useState } from "react"
import { createRowStore } from "@/lib/row-store"
import { Positions, type PositionRow } from "@/components/ui/positions"
const futures: PositionRow[] = [
{ id: "TY", book: "Futures", instrument: "TY", position: 120, quantityUnit: "contracts", average: 110.25, mark: 110.5, dayPnl: 26_250, totalPnl: 41_000, risk: 8_400 },
]
const cash: PositionRow[] = [
{ id: "T10", book: "Cash", instrument: "T 10Y", position: -25_000_000, average: 99.5, mark: 99.515625, dayPnl: -3_906, totalPnl: -12_000, risk: -21_000 },
]
function Book({ name, rows }: { name: string; rows: PositionRow[] }) {
const [store] = useState(() => {
const store = createRowStore<PositionRow>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: rows })
return store
})
return (
<section aria-label={`${name} book`} className="space-y-1">
<h3 className="text-xs font-semibold">{name}</h3>
<div className="h-32"><Positions store={store} label={`${name} positions`} riskHeader="DV01" /></div>
</section>
)
}
export default function PositionsBooksDemo() {
return <div className="w-fit max-w-full space-y-4"><Book name="Futures" rows={futures} /><Book name="Cash" rows={cash} /></div>
}
Server updates
The Apply next server batch button first changes only the cash note's mark. P&L and risk stay unchanged. The second batch supplies new P&L and risk values; those cells and their totals update. Changed cells flash, while totals do not. The sample values are explicit server outputs, not a valuation model.
The final state stays visible until Restore book replaces the original rows. Selection, sorting and column settings stay as you left them. There is no background publisher. Scroll the grid horizontally on a narrow screen to inspect the mark, P&L and risk columns.
import { useState } from "react"
import { createRowStore, type DeltaBatch } from "@/lib/row-store"
import { Positions, type PositionRow } from "@/components/ui/positions"
const rows: PositionRow[] = [
{ id: "T2", book: "Cash", instrument: "T 2Y", position: 50_000_000, average: 100.25, mark: 100.28125, dayPnl: 15_625, totalPnl: 62_000, risk: 21_500 },
{ id: "T10", book: "Cash", instrument: "T 10Y", position: -25_000_000, average: 99.5, mark: 99.515625, dayPnl: -3_906, totalPnl: -12_000, risk: -21_000 },
]
const updates: { message: string; batch: DeltaBatch<PositionRow> }[] = [
{ message: "Mark updated; P&L and risk unchanged.", batch: { patch: [{ id: "T10", fields: { mark: 99.53125 } }] } },
{ message: "Server P&L and risk applied.", batch: { patch: [{ id: "T10", fields: { dayPnl: -3_000, totalPnl: -11_000, risk: -20_500 } }] } },
]
export default function PositionsUpdatesDemo() {
const [store] = useState(() => {
const store = createRowStore<PositionRow>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: rows })
return store
})
const [step, setStep] = useState(0)
const advance = () => {
const update = updates[step]
if (!update) return
store.applyDeltas(update.batch)
setStep(step + 1)
}
const restore = () => {
store.applyDeltas({ upsert: rows })
setStep(0)
}
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={step === updates.length} onClick={advance}>Apply next server batch</button>
<button type="button" className="rounded border px-2 py-1" onClick={restore}>Restore book</button>
</div>
<div className="w-fit max-w-full space-y-2 text-xs lining-nums tabular-nums">
<div className="h-40"><Positions store={store} label="Updated cash positions" riskHeader="DV01" /></div>
<p role="status" className="text-muted-foreground">{updates[step - 1]?.message ?? "Initial book."}</p>
</div>
</>
)
}
API Reference
Positions uses data-grid's blotter preset: 24 px rows, fill flashes, arrival highlights, and viewport pinning when rows arrive above the visible area. Selection defaults to single with no checkboxes. Shared files are byte-identical to those installed by data-grid, watchlist, and blotter.
Props
PositionsProps<T> accepts rows extending PositionRow. Give the grid a container with a height.
| Prop | Type | Default | Purpose |
|---|---|---|---|
store |
RowStore<T> |
Required | Position rows. |
columns |
ColumnDef<T>[] |
positionsColumns(options) |
Replace or extend the book's columns. |
price |
(value: number, row: T) => string |
Two decimals | Format average and mark prices by instrument. |
pnl |
(value: number, row: T) => string |
Signed whole number with separators | Format both P&L columns and their default totals. |
risk |
(value: number, row: T) => string |
Signed whole number with separators | Format risk cells and the default risk total. |
riskHeader |
string |
"Risk" |
Name the default risk column, such as DV01 or Delta. |
totals |
false | Record<string, (rows: T[]) => string> |
positionsTotals(options) |
Replace the footer values by column key, or hide the footer. |
label |
string |
"Positions" |
Accessible name of the grid. |
selectionMode |
"none" | "single" | "multi" |
"single" |
Choose selection behavior. |
selectionColumn |
boolean |
false |
Show checkboxes when selection is "multi". |
renderContextMenu |
(rows: T[], ids: RowId[], target?: RowId | null) => ReactNode |
None | Menu items for the selection or targeted row; target is the row the menu opened on. |
getRowProps |
(row: T, id: RowId) => RowDecoration | undefined |
Side decorations | Add row classes or override the side's state and accessible description. Memoize it: the wrapper follows your function, so a new one re-renders every row and a memoized one is free. |
getRowLabel |
((row: T, id: RowId) => string) | null |
The instrument | A row's name that holds while its mark and P&L tick; add the book when one grid holds several books. The grid reads the focused row's cells once when focus rests on it, and null names rows by their cells and reads nothing. Keep its identity stable. |
className |
string |
None | Classes on the outer tradecn-positions container, not the grid. |
Sorting, filtering, a supplied view, column state, row callbacks, and keyboard behavior follow DataGrid. preset and footer are excluded; use totals for the footer. PositionsColumnOptions<T> is the shared options type for price, pnl, risk, and riskHeader.
Every number is the server's
The server supplies positions, prices, P&L, and risk. The grid displays them and adds totals; it does not calculate P&L from positions and marks. Fills, funding, and corrections belong to the server.
PositionRow field |
Type | Meaning |
|---|---|---|
id |
string |
Position ID; the example uses it as the store's row key. |
instrument |
string |
Instrument name or identifier. |
position |
number |
Signed notional amount or contract count: positive is long, negative is short, zero is flat. |
quantityUnit |
"notional" | "contracts", optional |
Defaults to notional, displayed in millions: -25_000_000 becomes −25mm. Contracts display as a whole count, such as +120. |
book |
string, optional |
Book name. |
average, mark |
number | null, optional |
Average price and current mark, printed through price. |
dayPnl, totalPnl |
number | null, optional |
P&L in the account's currency, printed through pnl. |
risk |
number | null, optional |
The desk's risk measure, such as DV01, delta, or beta-weighted exposure. |
Missing optional cell values print the null token. formatPosition also returns it for a non-finite position.
The sign says which way
Default position, P&L, and risk cells use up for positive finite values, down for negative finite values, and flat for zero. Position text uses signed quantities such as +120 and −25mm.
P&L and risk round to whole numbers by default. A small negative value such as -0.1 prints 0, while 0.1 prints +0; the color still follows the unrounded value. Supply a formatter with enough precision when those small values need to remain distinguishable without color. Zero itself prints without a sign.
The position span carries data-side="long", "short", or "flat". The row defaults to the same data-state, with an aria-description of long or short, which the grid joins with a row rule's words; flat rows have no default description. Your getRowProps can override either row attribute.
| Helper | Behavior |
|---|---|
formatPosition(row) |
Format a signed notional amount in millions or a whole contract count. |
positionSide(position) |
Return "long", "short", or "flat" from the position's sign. |
withSign(value, text) |
Prefix positive values with + unless the text already starts with +, −, or -. Used for P&L and risk cells and default totals. |
Custom pnl and risk formatters must retain negative signs themselves; withSign only adds a missing positive sign.
Columns
positionsColumns({ price, pnl, risk, riskHeader }) returns definitions in this order: book, instrument, position, average, mark, day P&L, total P&L, and risk. Instrument is frozen on the left; the grid places frozen columns before the others. Spread the list to add, remove, or reorder columns.
Position, mark, both P&Ls, and risk flash on value changes. Book, instrument, and average have flashing disabled. Override a column's flash setting in a custom list to change that behavior.
Keep columns and the formatters stable with module constants or memoization. Formatters receive the current row for cells; totals use the calling convention below. Supplying columns replaces the default definitions, so their cell formatting and riskHeader are then yours to set.
Totals
positionsTotals(options) produces the default footer values:
| Column key | Total |
|---|---|
instrument |
Row count, such as 3 positions. |
dayPnl, totalPnl |
Sum of finite numeric values, through pnl. |
risk |
Sum of finite numeric values, through risk. |
Totals read all rows in the grid's current view, including those outside the viewport. They update on store batches and when the view or footer inputs change. Null, missing, and non-finite values are omitted from sums; a sum with no finite values prints the null token. Totals do not flash. There is no position total because instruments can have different units.
The default total formatters receive (sum, rows[0]): the second argument is the first row in the current view, even if that row has no value for the summed field. It does not describe the aggregate. Use formatters appropriate to the whole book, or supply totals when aggregation needs more context.
Custom totals replaces the default map. Each function receives the view's rows in order and returns display text for its column key; only visible columns render totals. totals={false} hides the footer.
One grid per book
For several books, render one Positions per book with a separate store or filtered view and its own totals, as the separate-books example does. The grid has fixed-height rows for viewport pinning and does not group or nest positions.
What it does not do
It does not fetch, price, or net positions, or derive a position from fills. Use renderContextMenu or onRowActivate to open your ticket for a hedge, close, or transfer.
Tokens
The install adds the grid's up, down, and flat tokens, and the soft variants of those and of stale and expiring, where missing, plus the shared font tokens and the hyperlegible remap. Positive, negative, and flat cells use the first three.
Built on shadcn's checkbox, context-menu, and dropdown-menu.
This page is docs/positions.md at v2.0.0.