QuotePanel
Edit a market maker's bid, ask, skew, width, and sizes beside the market's two-way. Each instrument has one row, with the server's status and allowed actions.
import { useState } from "react"
import { QuotePanel, type QuoteRow } from "@/components/quote-panel"
import type { InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
const convention: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const quotes: QuoteRow[] = [
{ id: "2Y", instrument: "2Y Treasury", status: "Quoting", marketBid: 100.234375, marketAsk: 100.25, bid: 100.21875, ask: 100.265625, skew: 0, width: 3, bidSize: 5_000_000, askSize: 5_000_000, allowedActions: ["edit"] },
{ id: "10Y", instrument: "10Y Treasury", status: "Quoting", marketBid: 99.515625, marketAsk: 99.53125, bid: 99.5, ask: 99.5625, skew: 0.5, width: 4, bidSize: 10_000_000, askSize: 10_000_000, allowedActions: ["edit"] },
]
export default function QuotePanelDemo() {
const [store] = useState(() => {
const store = createRowStore<QuoteRow>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: quotes })
return store
})
return (
<div className="h-40 w-fit max-w-full">
<QuotePanel store={store} convention={convention} onEdit={({ rowId, key, value }) => {
store.applyDeltas({ patch: [{ id: rowId, fields: { [key]: value } }] })
}} />
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/quote-panel#v2.0.0
npx shadcn@latest add tradecn/ui/quote-panel#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/quote-panel#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/quote-panel#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 button checkbox context-menu dropdown-menunpx shadcn@latest add button checkbox context-menu dropdown-menuyarn dlx shadcn@latest add button checkbox context-menu dropdown-menubunx --bun shadcn@latest add button checkbox context-menu dropdown-menu -
Copy the files into your project:
components/quote-panel.tsx import { cn } from "cn" import { createContext, useCallback, useContext, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ChangeEvent, type FocusEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from "react" import { Button } from "@/components/ui/button" import { ContextMenuGroup, ContextMenuItem, ContextMenuLabel } from "@/components/ui/context-menu" import { useRowIds, useStoreMeta } from "@/hooks/use-row-store" import { NULL_TOKEN, NUMERIC_CLASS, formatQuantity, formatQuote, quoteInvertedOf, formatTicks, numericFontClass, parseQuote, stepQuote, stripGrouping, type InstrumentConvention } from "@/lib/format" import { blocks, checkLimits, confirms, type Limits, type LimitsDraft, type LimitsLabels } from "@/lib/limits" import type { RowId } from "@/lib/row-store" import { DataGrid, editProblem, type CellEdit, type ColumnDef, type DataGridLabels, type DataGridProps, type EditChange, type EditCommit, type EditProblem } from "@/components/ui/data-grid" // 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> // A market maker's two-way panel: one row per instrument with the market's bid and ask, the desk's bid and // ask, the skew and the width, a size per side, the server's status word, and the actions the server allows // on the row. The levels and sizes are typed in place through the grid's editing contract, so every edit is // a command the server answers and the cell shows it as pending until the row comes back with it. The // limits table runs on every edit: a block refuses the value in the editor, a confirm asks in words over the // grid and only a fresh Enter on the same value sends it. Pull all asks again. Everything the panel shows is the server's; nothing here decides a quote. export interface QuoteRow { id: string /** The instrument's name, the frozen first column. */ instrument: string /** The server's word on the quote: Quoting, Paused, Pulled, whatever it says. Printed as is. */ status: string /** The market's two-way. */ marketBid?: number | null marketAsk?: number | null /** The desk's two-way. Null on a side is no level there. */ bid?: number | null ask?: number | null /** How far the desk's mid sits off the market's, in quote steps (ticks for a price basis). Signed. */ skew?: number | null /** From the desk's bid to its ask, in quote steps. */ width?: number | null bidSize?: number | null askSize?: number | null /** What the server allows on this row, by id. The edit action lets a value be typed; the rest are the row's buttons. Nothing without a list. */ allowedActions?: readonly string[] updatedAt?: number | null } /** A button on a row: pause, resume, pull, or whatever the desk calls them. Shown when the row's `allowedActions` names the id. */ export interface QuoteAction<T extends QuoteRow = QuoteRow> { id: string label: string run: (row: T) => void | Promise<unknown> destructive?: boolean } /** The fields typed in place. */ export type QuoteField = "bid" | "ask" | "skew" | "width" | "bidSize" | "askSize" export interface QuotePanelLabels { /** Column headers. */ instrument: string status: string marketBid: string marketAsk: string bid: string ask: string skew: string width: string bidSize: string askSize: string actions: string /** The button over the grid, and what it says while it asks again. */ pullAll: string pullAllAnyway: string /** Editor problems. */ notAQuote: string notANumber: string notASize: string notAWidth: string bidCrosses: string askCrosses: string /** A limit that asks: `{message}` is the limit's own sentence. */ askAgain: string /** The same, asked of a value a cell control committed: the control answers by committing it again. */ askAgainControl: string /** The row menu when the server allows nothing on the row. */ noActions: string } export const DEFAULT_QUOTE_PANEL_LABELS: QuotePanelLabels = { instrument: "Instrument", status: "Status", marketBid: "Mkt bid", marketAsk: "Mkt ask", bid: "Bid", ask: "Ask", skew: "Skew", width: "Width", bidSize: "Bid size", askSize: "Ask size", actions: "Actions", pullAll: "Pull all", pullAllAnyway: "Pull all anyway?", notAQuote: "Not a quote in this instrument's notation.", notANumber: "Not a number.", notASize: "A size is a whole number, zero or more.", notAWidth: "A width is zero or more.", bidCrosses: "The bid would cross the ask.", askCrosses: "The ask would cross the bid.", askAgain: "{message} Press Enter again to send it, or Escape to discard it.", askAgainControl: "{message} Do it again to send it.", noActions: "No actions for this row right now.", } const fill = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (_, key: string) => values[key] ?? "") /** True when the server's list for the row names the action. */ export function allowsQuoteAction(row: QuoteRow, action: string): boolean { return row.allowedActions?.includes(action) === true } function isNumber(v: unknown): v is number { return typeof v === "number" && Number.isFinite(v) } // A size prints whole and grouped, "5,000", so one group reads as thousands; skew and width print decimals, so one // comma with no point after it reads either way and is refused, and their editor opens on text without separators. // Any other comma is refused: "2,5" is never 25. function readNumber(text: string, decimals: boolean): number | null | "bad" { if (text.trim() === "") return null const clean = stripGrouping(text, { decimals }) if (clean === null) return "bad" // Plain decimals only: Number would also read 0x10 and 1e3, which no cell prints, and a run of digits too // long to hold reads as Infinity. if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(clean)) return "bad" const n = Number(clean) if (!Number.isFinite(n)) return "bad" return n === 0 ? 0 : n } export interface QuoteColumnOptions<T extends QuoteRow> { /** One convention for every row, or one per row. Prices print, parse, and step through it. */ convention: InstrumentConvention | ((row: T) => InstrumentConvention) labels?: Partial<QuotePanelLabels> /** The row's buttons, shown where `allowedActions` names them. */ actions?: readonly QuoteAction<T>[] /** The id the server allows for typing a value. Default `edit`. */ editAction?: string /** The fat-finger lines every edit is checked against, for every row or per row. */ limits?: Limits | ((row: T) => Limits | undefined) /** The limit sentences' words, over `DEFAULT_LIMITS_LABELS`. */ limitsLabels?: Partial<LimitsLabels> /** The family the price columns set in. Default: the mono stack for a fraction convention, the numeric one otherwise. */ font?: "numeric" | "mono" /** * The question standing, one per set to match the one line that shows it: the first commit of a value a limit * asks about is refused with the question, and a fresh Enter on that value in the same opening of the editor * sends it. Asking anything else replaces it; any other outcome of a check against the set withdraws it. The * panel owns one and hands it to a `columns` function; outside the panel, pass your own, one per line you show. */ asked?: Set<string> /** * Told the question a confirm limit asks, and null when a later check answers it, blocks or crosses the value, * finds nothing to ask (a blank value included), or comes from leaving the editor; text that does not parse * never reaches a check. It is not told when an editor closes without a commit, or when its text changes by * typing or a step: the panel withdraws on those itself. Outside the panel, clear what you show, and `asked`, * when the asking editor's text changes or focus leaves it. */ onQuestion?: (question: string | null) => void } function conventionOf<T extends QuoteRow>(c: InstrumentConvention | ((row: T) => InstrumentConvention), row: T): InstrumentConvention { return typeof c === "function" ? c(row) : c } function limitsOf<T extends QuoteRow>(l: Limits | ((row: T) => Limits | undefined) | undefined, row: T): Limits | undefined { return typeof l === "function" ? l(row) : l } // The question a commit would answer: its cell, the opening of the cell's editor, and the value. Calls without // the grid's commit share an opening of their own, and never answer. const questionToken = (row: QuoteRow, key: string, value: unknown, commit?: EditCommit) => `${row.id}\u0000${key}\u0000${commit?.session ?? "none"}\u0000${String(value)}` // Withdraws the question standing, from the memory and from the line. function withdraw<T extends QuoteRow>(options: QuoteColumnOptions<T>): null { options.asked?.clear() options.onQuestion?.(null) return null } /** * The limits, as the value is committed: a block refuses it in the editor with the limit's sentence; a confirm * refuses it with the question, and only a fresh Enter on that value in the same opening of the editor answers * it. One question stands at a time, as one line shows it: asking replaces it, and any other outcome withdraws * it. Tab and a held key ask again rather than send; leaving the editor refuses without asking, since the draft * goes with it; a control committing a value answers by committing it again. A call without `commit` never * answers: a wrapper that drops it fails closed. */ function limitProblem<T extends QuoteRow>(draft: LimitsDraft, row: T, key: string, value: unknown, options: QuoteColumnOptions<T>, labels: QuotePanelLabels, commit?: EditCommit): EditProblem | null { const limits = limitsOf(options.limits, row) if (!limits) return withdraw(options) const problems = checkLimits(draft, limits, { market: { bid: row.marketBid, ask: row.marketAsk }, convention: conventionOf(options.convention, row), labels: options.limitsLabels }) const stop = blocks(problems)[0] if (stop) { withdraw(options) return editProblem(stop.message) } const ask = confirms(problems)[0] if (!ask) return withdraw(options) const token = questionToken(row, key, value, commit) const fresh = commit !== undefined && !commit.repeat && (commit.via === "enter" || commit.via === "value") if (fresh && options.asked?.has(token)) return withdraw(options) const question = fill(commit?.via === "value" ? labels.askAgainControl : labels.askAgain, { message: ask.message }) if (commit?.via === "blur") { withdraw(options) return editProblem(question) } options.asked?.clear() options.asked?.add(token) options.onQuestion?.(question) return editProblem(question) } /** The grid's `edit` for one field: its notation, its step, the crossed check, and the limits. */ export function quoteEdit<T extends QuoteRow>(field: QuoteField, options: QuoteColumnOptions<T>): CellEdit<T> { const labels = { ...DEFAULT_QUOTE_PANEL_LABELS, ...defined(options.labels) } const editAction = options.editAction ?? "edit" const canEdit = (row: T) => allowsQuoteAction(row, editAction) if (field === "bid" || field === "ask") { return { parse: (text, row) => { if (text.trim() === "") return null const v = parseQuote(text, conventionOf(options.convention, row)) return v === null ? editProblem(labels.notAQuote) : v }, format: (value, row) => (isNumber(value) ? formatQuote(value, conventionOf(options.convention, row)) : ""), validate: (value, row, commit) => { if (!isNumber(value)) return withdraw(options) // Crossing reads the instrument's quote direction, as the RFQ ticket does: where a // higher quote means a lower price, the bid sits above the offer in a normal market. // The other side is the one in view: a level sent and not yet back counts over the row's. const inverted = quoteInvertedOf(conventionOf(options.convention, row)) const otherKey = field === "bid" ? "ask" : "bid" const sent = commit?.pending?.(otherKey) const other = sent !== undefined ? sent : row[otherKey] const crossed = isNumber(other) && (field === "bid" ? (inverted ? value <= other : value >= other) : inverted ? value >= other : value <= other) if (crossed) { withdraw(options) return editProblem(field === "bid" ? labels.bidCrosses : labels.askCrosses) } return limitProblem(field === "bid" ? { bid: value } : { ask: value }, row, field, value, options, labels, commit) }, // A step from an empty side starts at the market's same side. step: (value, dir, big, row) => { const from = isNumber(value) ? value : field === "bid" ? row.marketBid : row.marketAsk return isNumber(from) ? stepQuote(from, conventionOf(options.convention, row), dir * (big ? 10 : 1)) : value }, canEdit, } } if (field === "bidSize" || field === "askSize") { return { parse: (text) => { const n = readNumber(text, false) if (n === "bad") return editProblem(labels.notANumber) // A fraction is refused from the text, before Number can round it away near the safe-integer limit. if (n !== null && (!Number.isSafeInteger(n) || n < 0 || /\.\d*[1-9]/.test(text))) return editProblem(labels.notASize) return n }, format: (value) => (isNumber(value) ? formatQuantity(value) : ""), validate: (value, row, commit) => (isNumber(value) ? limitProblem({ quantity: value }, row, field, value, options, labels, commit) : withdraw(options)), step: (value, dir, big) => Math.max(0, (isNumber(value) ? value : 0) + dir * (big ? 10 : 1)), canEdit, } } return { parse: (text) => { const n = readNumber(text, true) if (n === "bad") return editProblem(labels.notANumber) if (field === "width" && n !== null && n < 0) return editProblem(labels.notAWidth) return n }, format: (value) => (isNumber(value) ? formatTicks(value, { signed: field === "skew", grouping: false }) : ""), // Skew and width count in quote steps; half steps are common, so the arrows move by one and ten with Shift. A // width stops at zero, as a size does, so a step never lands on a value the parse refuses. step: (value, dir, big) => { const next = Number(((isNumber(value) ? value : 0) + dir * (big ? 10 : 1)).toFixed(10)) return field === "width" ? Math.max(0, next) : next }, canEdit, } } // Which row has a run out, kept by the panel rather than the row's cell: a virtualized row that scrolls away // and back remounts its cell, and the buttons must stay held while the promise is out. interface PendingRuns { get(id: RowId): string | null set(id: RowId, action: string | null): void subscribe(listener: () => void): () => void } function createPendingRuns(): PendingRuns { const runs = new Map<RowId, string>() const listeners = new Set<() => void>() return { get: (id) => runs.get(id) ?? null, set(id, action) { if (action === null) runs.delete(id) else runs.set(id, action) for (const listener of listeners) listener() }, subscribe(listener) { listeners.add(listener) return () => { listeners.delete(listener) } }, } } const PendingRunsContext = createContext<PendingRuns | null>(null) // A row's name is its instrument: named by its cells, a focused row would be read again at every market tick. const instrumentOf = (row: QuoteRow) => row.instrument // A popout's elements come from another window, where instanceof against this one's classes fails. const isElement = (node: unknown): node is Element => typeof node === "object" && node !== null && (node as Node).nodeType === 1 const isEditor = (node: unknown): node is HTMLInputElement => isElement(node) && node.hasAttribute("data-cell-editor") const subscribeNothing = () => () => {} // A press counts once: the second click of a double-click, and the clicks a held Enter repeats on a focused // button, run nothing and answer no question the first press asked. The hold belongs to the button it repeats // on, and ends when the key is let go or focus leaves that button. function useFreshPress() { const held = useRef<EventTarget | null>(null) const release = () => { held.current = null } return { onKeyDown: (event: KeyboardEvent<HTMLElement>) => { held.current = event.key === "Enter" && event.repeat ? event.currentTarget : null }, onKeyUp: release, onBlur: release, fresh: (event: MouseEvent<HTMLElement>) => event.detail <= 1 && !(event.detail === 0 && held.current === event.currentTarget), } } // Focus leaving a control on purpose. A window switch also blurs with no destination, but the document loses // focus with it, so the record stays and a control withdrawn while the trader is away still hands focus to the // grid on return. function deliberateBlur(e: FocusEvent<Element>): boolean { return e.relatedTarget !== null || e.target.ownerDocument.hasFocus() } /** Runs a row action as a click or the row menu lands: checked against the row as it is now, held while a promise is out. */ function runQuoteAction<T extends QuoteRow>(row: T, action: QuoteAction<T>, busy: boolean, hold: (action: string | null) => void) { if (busy || !allowsQuoteAction(row, action.id)) return const out = action.run(row) if (out && typeof out === "object" && "then" in out) { hold(action.id) Promise.resolve(out).then( () => hold(null), () => hold(null), ) } } /** The row's run, from the panel when there is one, else kept by the cell itself. */ function useRowRun(id: RowId): [string | null, (action: string | null) => void] { const shared = useContext(PendingRunsContext) const fromPanel = useSyncExternalStore(shared ? shared.subscribe : subscribeNothing, () => shared?.get(id) ?? null, () => null) const [own, setOwn] = useState<string | null>(null) return shared ? [fromPanel, (action) => shared.set(id, action)] : [own, setOwn] } interface RowActionsProps<T extends QuoteRow> { row: T rowId: RowId actions: readonly QuoteAction<T>[] } // The row's buttons: the actions the server allows on it, checked again against the row as the click lands, // held while a run's promise is out. The status word never moves on a click; only the row's next batch moves it. function RowActions<T extends QuoteRow>({ row, rowId, actions }: RowActionsProps<T>) { const [pending, hold] = useRowRun(rowId) const press = useFreshPress() const box = useRef<HTMLSpanElement>(null) // When the button under focus leaves — the server's reply swaps Pause for Resume, or holds the row — focus // falls to body and the grid's keys go dead. The grid takes it instead. Leaving on purpose clears the record. const focused = useRef<HTMLElement | null>(null) useLayoutEffect(() => { const previous = focused.current if (!previous) return const doc = previous.ownerDocument const gone = !previous.isConnected || previous.matches(":disabled") if (gone && (doc.activeElement === previous || doc.activeElement === doc.body)) { focused.current = null box.current?.closest<HTMLElement>("[role='grid']")?.focus({ preventScroll: true }) } }) // A row that leaves takes its buttons with it — removed, filtered out, or scrolled out of the virtual window — // and focus on one would fall to body. The grid takes it as they go, while they are still in the page. useLayoutEffect(() => { const el = box.current return () => { const doc = el?.ownerDocument if (el && doc && el.contains(doc.activeElement)) el.closest<HTMLElement>("[role='grid']")?.focus({ preventScroll: true }) } }, []) const allowed = actions.filter((action) => allowsQuoteAction(row, action.id)) return ( <span ref={box} className="flex items-center gap-1" data-quote-actions={allowed.length} // Only the buttons are controls: the null token stays plain row content, so it selects and opens the menu. data-grid-interaction={allowed.length > 0 ? "control" : undefined} onFocus={(e) => { focused.current = e.target as HTMLElement }} onBlur={(e) => { if (deliberateBlur(e)) focused.current = null }} > {/* A destructive action says so in the destructive color on the ghost button, which holds 4.5 to 1 at rest and turns to the foreground under the pointer: the destructive variant puts that color on its own tint, below it. */} {allowed.length === 0 ? <span className="text-muted-foreground">{NULL_TOKEN}</span> : allowed.map((action) => ( <Button key={action.id} type="button" size="sm" variant="ghost" className={cn("h-5 px-1.5 text-xs", action.destructive && "text-destructive")} tabIndex={-1} data-destructive={action.destructive || undefined} disabled={pending !== null} data-action={action.id} data-pending={pending === action.id || undefined} onKeyDown={press.onKeyDown} onKeyUp={press.onKeyUp} onBlur={press.onBlur} onClick={(event) => { if (press.fresh(event)) runQuoteAction(row, action, pending !== null, hold) }}> {action.label} </Button> ))} </span> ) } interface RowMenuProps<T extends QuoteRow> { id: RowId store: QuotePanelProps<T>["store"] actions: readonly QuoteAction<T>[] noActions: string } // The same actions on the row menu, so the keyboard reaches them: Shift+F10 or the Menu key on the focused row. // They act on the row the menu opened on, named at the top, whatever else is selected. function RowMenu<T extends QuoteRow>({ id, store, actions, noActions }: RowMenuProps<T>) { const [pending, hold] = useRowRun(id) // The store's row, not the menu's: the grid hands the menu its rows as it opens, and the panel renders the // menu again with every batch while it stays open. const row = store.getRow(id) const allowed = row ? actions.filter((action) => allowsQuoteAction(row, action.id)) : [] return ( <ContextMenuGroup aria-label={row?.instrument}> {row && <ContextMenuLabel>{row.instrument}</ContextMenuLabel>} {allowed.length === 0 && <ContextMenuItem disabled>{noActions}</ContextMenuItem>} {row && allowed.map((action) => ( <ContextMenuItem key={action.id} data-action={action.id} // The destructive color at rest; highlighted, the item takes the menu's accent, as the destructive // variant's tint behind the same color reads below 4.5 to 1. data-destructive={action.destructive || undefined} className={action.destructive ? "text-destructive" : undefined} disabled={pending !== null} onClick={(event) => { if (!event.defaultPrevented) runQuoteAction(row, action, pending !== null, hold) }} > {action.label} </ContextMenuItem> ))} </ContextMenuGroup> ) } /** Instrument, status, the market's two-way, the desk's, skew, width, the sizes, and the actions. Spread them into your own list to add, drop, or reorder. */ export function quotePanelColumns<T extends QuoteRow>(options: QuoteColumnOptions<T>): ColumnDef<T>[] { const labels = { ...DEFAULT_QUOTE_PANEL_LABELS, ...defined(options.labels) } const font = options.font ?? (typeof options.convention === "function" ? "numeric" : numericFontClass(options.convention) === NUMERIC_CLASS ? "numeric" : "mono") const quote = (value: unknown, row: T) => (isNumber(value) ? formatQuote(value, conventionOf(options.convention, row)) : NULL_TOKEN) const price = (key: "marketBid" | "marketAsk", header: string): ColumnDef<T> => ({ key, header, width: 88, numeric: true, font, sortable: true, flash: "fill", accessor: (r) => r[key] ?? null, format: quote }) const own = (key: "bid" | "ask", header: string): ColumnDef<T> => ({ key, header, width: 88, numeric: true, font, sortable: true, accessor: (r) => r[key] ?? null, format: quote, edit: quoteEdit(key, options) }) const steps = (key: "skew" | "width", header: string): ColumnDef<T> => ({ key, header, width: 72, numeric: true, sortable: true, accessor: (r) => r[key] ?? null, format: (v) => (isNumber(v) ? formatTicks(v, { signed: key === "skew" }) : NULL_TOKEN), edit: quoteEdit(key, options) }) const size = (key: "bidSize" | "askSize", header: string): ColumnDef<T> => ({ key, header, width: 80, numeric: true, sortable: true, accessor: (r) => r[key] ?? null, format: (v) => (isNumber(v) ? formatQuantity(v) : NULL_TOKEN), edit: quoteEdit(key, options) }) const actions = options.actions ?? [] return [ { key: "instrument", header: labels.instrument, width: 128, frozen: "left", sortable: true, flash: false, accessor: (r) => r.instrument, cell: ({ row }) => <span className="truncate font-medium">{row.instrument}</span> }, { key: "status", header: labels.status, width: 88, sortable: true, flash: false, accessor: (r) => r.status, cell: ({ row }) => <span data-quote-status={row.status} className="truncate">{row.status}</span> }, price("marketBid", labels.marketBid), price("marketAsk", labels.marketAsk), own("bid", labels.bid), own("ask", labels.ask), steps("skew", labels.skew), steps("width", labels.width), size("bidSize", labels.bidSize), size("askSize", labels.askSize), { key: "actions", header: labels.actions, width: 40 + actions.length * 64, flash: false, accessor: (r) => (r.allowedActions ?? []).join(" "), cell: ({ row, rowId }) => <RowActions row={row} rowId={rowId} actions={actions} /> }, ] } /** What the panel hands a `columns` function: its memory of the question standing, and its question line. */ export interface QuotePanelQuestion { asked: Set<string> onQuestion: (question: string | null) => void } export interface QuotePanelProps<T extends QuoteRow = QuoteRow> extends Omit<DataGridProps<T>, "columns" | "preset" | "label" | "onEdit" | "labels">, Omit<QuoteColumnOptions<T>, "asked" | "onQuestion"> { /** The panel's words beside its own: `QuotePanelLabels` and DataGrid's `DataGridLabels`. */ labels?: Partial<QuotePanelLabels & DataGridLabels> /** * `quotePanelColumns(options)` by default. A list replaces the columns; a function receives the panel's * question wiring to spread into `quotePanelColumns` or `quoteEdit`, and keeps the panel's question line. */ columns?: ColumnDef<T>[] | ((question: QuotePanelQuestion) => ColumnDef<T>[]) label?: string /** A level, a size, the skew, or the width was typed: send it to the server. The cell stays pending until the row comes back with the value, or the promise resolves. */ onEdit: (change: EditChange<T>) => void | Promise<unknown> /** Pull all asks again, then hands you every row the server allows the pull action on. Without it there is no button. */ onPullAll?: (rows: T[]) => void | Promise<unknown> /** The id the server allows for pulling a quote. Default `pull`. */ pullAction?: string } export function QuotePanel<T extends QuoteRow = QuoteRow>({ store, convention, labels: labelsProp, actions, editAction = "edit", limits, limitsLabels, font, columns, label = "Quotes", onEdit, onPullAll, pullAction = "pull", className, renderContextMenu, getRowLabel = instrumentOf, ...grid }: QuotePanelProps<T>) { const labels = useMemo(() => ({ ...DEFAULT_QUOTE_PANEL_LABELS, ...defined(labelsProp) }), [labelsProp]) const [asked] = useState(() => new Set<string>()) const [question, setQuestion] = useState<string | null>(null) const [runs] = useState(createPendingRuns) const root = useRef<HTMLDivElement>(null) // Where focus was as the question was asked: the editor whose commit asked, or the control that committed. // Focus landing anywhere else in the panel, its text changing, or its blur withdraws the question and the // memory with it; a control's blur counts when focus leaves it on purpose, not for a window switch. const asking = useRef<Element | null>(null) useLayoutEffect(() => { asking.current = question !== null ? (root.current?.ownerDocument.activeElement ?? null) : null }, [question]) const withdrawQuestion = () => { asked.clear() asking.current = null setQuestion(null) } // The grid's rows are memoized, so what it is handed keeps its identity from one render to the next; your // callbacks are read through a ref, updated before any layout effect, so a commit from one reads this render's. const latest = useRef({ onEdit, onPullAll }) useInsertionEffect(() => { latest.current = { onEdit, onPullAll } }) // A columns function rebuilds only when it changes; the generated columns, when one of their options does. const columnsFrom = typeof columns === "function" ? columns : undefined const generate = columns === undefined const fromFunction = useMemo(() => columnsFrom?.({ asked, onQuestion: setQuestion }), [columnsFrom, asked]) const generated = useMemo( () => (generate ? quotePanelColumns<T>({ convention, labels, actions, editAction, limits, limitsLabels, font, asked, onQuestion: setQuestion }) : undefined), [generate, convention, labels, actions, editAction, limits, limitsLabels, font, asked], ) const all = fromFunction ?? (Array.isArray(columns) ? columns : generated) ?? [] const edit = useCallback((change: EditChange<T>) => latest.current.onEdit(change), []) // The row menu carries the actions of the row it opened on, for the keyboard, then any items of your own. Its // presence follows whether actions are passed at all, so emptying the list never remounts the grid's body. const hasMenu = actions !== undefined || renderContextMenu !== undefined const menu = useCallback( (rows: T[], ids: RowId[], target?: RowId | null): ReactNode => { const id = target ?? ids[0] return ( <> {actions !== undefined && id !== undefined && <RowMenu<T> id={id} store={store} actions={actions} noActions={labels.noActions} />} {renderContextMenu?.(rows, ids, target)} </> ) }, [actions, store, labels.noActions, renderContextMenu], ) // Pull all: the rows the server allows it on, counted once per applied batch; the first press asks, the second sends. const ids = useRowIds(store) const meta = useStoreMeta(store) const pullable = useMemo(() => { void meta.version const rows: T[] = [] for (const id of ids) { const row = store.getRow(id) if (row && allowsQuoteAction(row, pullAction)) rows.push(row) } return rows }, [store, ids, meta.version, pullAction]) const [confirming, setConfirming] = useState(false) // A question about nothing pullable no longer stands: when a row becomes pullable again, the button asks again. if (confirming && pullable.length === 0) setConfirming(false) const pullPress = useFreshPress() const [pulling, setPulling] = useState(false) const pullAll = () => { if (pulling) return if (!confirming) { setConfirming(true) return } setConfirming(false) const rows: T[] = [] for (const id of ids) { const row = store.getRow(id) if (row && allowsQuoteAction(row, pullAction)) rows.push(row) } if (rows.length === 0) return const out = latest.current.onPullAll?.(rows) if (out && typeof out === "object" && "then" in out) { setPulling(true) Promise.resolve(out).then( () => setPulling(false), () => setPulling(false), ) } } // Any other press in the panel, Escape, or focus leaving the panel withdraws the question. const withdraw = (e: MouseEvent<HTMLDivElement>) => { if (!confirming) return if (isElement(e.target) && e.target.closest("[data-quote-pull-all]")) return setConfirming(false) } const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { if (e.key === "Escape" && confirming) setConfirming(false) // A step rewrites the asking editor's text without an input event; it withdraws the question as typing does. if ((e.key === "ArrowUp" || e.key === "ArrowDown") && asking.current && e.target === asking.current) withdrawQuestion() } // When Pull all disables under focus — its promise out, or no row left to pull — focus falls to body and the // grid's keys go dead. The grid takes it instead. A row's buttons see to their own. const focusedControl = useRef<HTMLElement | null>(null) useLayoutEffect(() => { const previous = focusedControl.current if (!previous) return const doc = previous.ownerDocument const gone = !previous.isConnected || previous.matches(":disabled") if (gone && (doc.activeElement === previous || doc.activeElement === doc.body)) { focusedControl.current = null root.current?.querySelector<HTMLElement>("[role='grid']")?.focus({ preventScroll: true }) } }) const onFocus = (e: FocusEvent<HTMLDivElement>) => { const target = e.target focusedControl.current = isElement(target) && target.closest("[data-quote-pull-all]") ? (target as HTMLElement) : null // Tab into the next editor, or the grid taking focus from an editor its row took away. if (asking.current && target !== asking.current) withdrawQuestion() } const onChange = (e: ChangeEvent<HTMLDivElement>) => { if (asking.current && e.target === asking.current) withdrawQuestion() } const onBlur = (e: FocusEvent<HTMLDivElement>) => { // An editor closing withdraws the question, whether or not leaving committed anything; so does focus leaving // the control that asked, straight out of the panel included. if (isEditor(e.target) || (asking.current && e.target === asking.current && deliberateBlur(e))) withdrawQuestion() if (focusedControl.current === e.target && deliberateBlur(e)) focusedControl.current = null const next = e.relatedTarget if (confirming && !(isElement(next) && e.currentTarget.contains(next))) setConfirming(false) } // A line for the question only where one can stand — the generated columns with limits, or a columns function // given the panel's wiring — beside Pull all when there is one. const header = onPullAll !== undefined || (columns === undefined ? limits !== undefined : typeof columns === "function") return ( <PendingRunsContext.Provider value={runs}> <div ref={root} data-slot="tradecn-quote-panel" className={cn("flex h-full min-h-0 flex-col gap-1 text-xs lining-nums tabular-nums", className)} onClickCapture={withdraw} onKeyDownCapture={onKeyDown} onFocus={onFocus} onBlur={onBlur} onChange={onChange}> {header && ( <div className="flex min-h-7 shrink-0 items-center justify-end gap-2"> <p role="status" data-quote-question={question === null ? undefined : ""} title={question ?? undefined} className="mr-auto min-w-0 truncate text-destructive"> {question} </p> {onPullAll && ( <Button type="button" size="sm" variant="outline" className={cn("h-7", confirming && "text-destructive")} disabled={pulling || pullable.length === 0} data-quote-pull-all={pullable.length} data-confirming={confirming || undefined} onKeyDown={pullPress.onKeyDown} onKeyUp={pullPress.onKeyUp} onBlur={pullPress.onBlur} onClick={(event) => { if (pullPress.fresh(event)) pullAll() }}> {confirming ? labels.pullAllAnyway : labels.pullAll} </Button> )} </div> )} <div className="min-h-0 flex-1"> <DataGrid<T> {...grid} store={store} preset="parameters" label={label} labels={labelsProp} getRowLabel={getRowLabel} columns={all} onEdit={edit} renderContextMenu={hasMenu ? menu : undefined} /> </div> </div> </PendingRunsContext.Provider> ) }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, } }lib/limits.ts import { formatQuantity, formatTicks, quoteBasisOf, ticksBetween, type InstrumentConvention } from "@/lib/format" // 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> // Fat-finger checks are universal: a size above a line, a level too far from the market, a side the // book may not take. The tickets already stop a missing level and a crossed market; this gives them a // limits table, as data, and returns problems in the shape they already print. A `block` stops the // action; a `confirm` turns it into a two-step, the blotter's ask-again. Nothing here decides a // limit: the numbers are the desk's, the words are the consumer's, and the check runs where the // click lands, against the draft as it is then. export type ProblemLevel = "block" | "confirm" export interface Problem { /** The field it is about: "quantity", "price", "bid", "ask", or "side". */ field: string level: ProblemLevel /** A sentence, in the consumer's words. */ message: string /** Which limit raised it: "maxQuantity", "minQuantity", "maxDistance", "sides", or a custom rule's own name. */ rule: string } /** What the check reads of a draft: a ticket's side, quantity, and price, or a quote's bid and ask. */ export interface LimitsDraft { side?: "buy" | "sell" quantity?: number | null price?: number | null bid?: number | null ask?: number | null } export interface LimitsMarket { bid?: number | null ask?: number | null last?: number | null mid?: number | null } export interface LimitsLabels { /** `{n}` the quantity, `{max}` the limit. */ quantityAbove: string quantityAboveConfirm: string /** `{n}` the quantity, `{min}` the limit. */ quantityBelow: string quantityBelowConfirm: string /** `{field}` the level's field, `{distance}` how far, `{max}` the limit, both with their unit. */ tooFar: string tooFarConfirm: string /** `{side}` the side. */ sideNotAllowed: string buy: string sell: string /** The level's field in `{field}`: a ticket's price, a two-sided quote's bid and ask. */ price: string bid: string ask: string /** The unit after a distance in ticks: a word, or a function of the count, for a plural a word can't hold. */ ticks: string | ((n: number) => string) /** The unit after a distance in basis points: a word, or a function of the count. */ bps: string | ((n: number) => string) } export const DEFAULT_LIMITS_LABELS: LimitsLabels = { quantityAbove: "{n} is above the size limit of {max}.", quantityAboveConfirm: "{n} is above {max}. Send it anyway?", quantityBelow: "{n} is below the minimum of {min}.", quantityBelowConfirm: "{n} is below {min}. Send it anyway?", tooFar: "The {field} is {distance} from the market; the limit is {max}.", tooFarConfirm: "The {field} is {distance} from the market, past {max}. Send it anyway?", sideNotAllowed: "The book does not take a {side}.", buy: "buy", sell: "sell", price: "price", bid: "bid", ask: "ask", ticks: (n) => (n === 1 ? "tick" : "ticks"), bps: "bp", } export interface LimitsContext { market?: LimitsMarket convention?: InstrumentConvention labels?: Partial<LimitsLabels> } /** A line, or two: a size to ask again past, and a size to stop at. One number is a stop. */ export type Threshold = number | { confirm?: number; block?: number } export interface Limits { maxQuantity?: Threshold minQuantity?: Threshold /** How far a level may sit from the market's same side (the mid when there is none), in ticks for a price basis or basis points for the rest. */ maxDistance?: ({ ticks: number } | { bps: number }) & { level?: ProblemLevel } /** The sides the book may take. A ticket's side, or, for a quote, a bid is a buy and an offer a sell. */ sides?: readonly ("buy" | "sell")[] /** Your own rules, in the same shape. */ custom?: (draft: LimitsDraft, context: LimitsContext) => Problem[] } function fill(template: string, values: Record<string, string | number>): string { return template.replace(/\{(\w+)\}/g, (_, name: string) => String(values[name] ?? "")) } function thresholds(t: Threshold | undefined): { confirm?: number; block?: number } { if (t === undefined) return {} return typeof t === "number" ? { block: t } : t } /** The market's side a level is measured against: the same side as the level, else the mid, else the last. */ export function marketSideFor(field: "price" | "bid" | "ask", side: "buy" | "sell" | undefined, market: LimitsMarket | undefined): number | null { if (!market) return null const pick = (...values: (number | null | undefined)[]) => values.find((v): v is number => typeof v === "number" && Number.isFinite(v)) ?? null const mid = pick(market.mid) ?? (typeof market.bid === "number" && typeof market.ask === "number" ? (market.bid + market.ask) / 2 : null) if (field === "bid") return pick(market.bid, mid, market.last) if (field === "ask") return pick(market.ask, mid, market.last) // A price on a ticket: a buyer measures against the offer, a seller against the bid. if (side === "buy") return pick(market.ask, mid, market.last) if (side === "sell") return pick(market.bid, mid, market.last) return pick(mid, market.last) } /** How far a level is from a market level in the convention's unit: ticks for a price basis, basis points for the rest. */ export function distanceFromMarket(level: number, market: number, convention: InstrumentConvention | undefined): { value: number; unit: "ticks" | "bps" } { if (!convention || quoteBasisOf(convention) === "price") return { value: Math.abs(ticksBetween(level, market, convention?.tick ?? 1)), unit: "ticks" } // A yield or a discount is quoted in percent, so a hundredth of a point is a basis point; a spread is quoted in basis points already. const perPoint = quoteBasisOf(convention) === "spread" ? 1 : 100 // To a thousandth of a basis point, so 4.30 against 4.25 is 5 and not 4.999999999999982. return { value: Math.round(Math.abs(level - market) * perPoint * 1000) / 1000, unit: "bps" } } /** * What the limits say about a draft, in the tickets' shape, each with its level and a sentence. Empty * when nothing does. Quantity against the lines, each level against the market in the instrument's * unit, the side against the sides the book takes, then your own rules. */ export function checkLimits(draft: LimitsDraft, limits: Limits | undefined, context: LimitsContext = {}): Problem[] { if (!limits) return [] const labels = { ...DEFAULT_LIMITS_LABELS, ...defined(context.labels) } const problems: Problem[] = [] const quantity = draft.quantity // A fractional size prints exactly, or the prompt would round the trader's own number and // the limit into a lie such as "10 is above the size limit of 10". const size = (v: number) => (Number.isInteger(v) ? formatQuantity(v) : String(v).replace(/-/g, "−")) if (typeof quantity === "number" && Number.isFinite(quantity)) { const max = thresholds(limits.maxQuantity) if (max.block !== undefined && quantity > max.block) problems.push({ field: "quantity", level: "block", rule: "maxQuantity", message: fill(labels.quantityAbove, { n: size(quantity), max: size(max.block) }) }) else if (max.confirm !== undefined && quantity > max.confirm) problems.push({ field: "quantity", level: "confirm", rule: "maxQuantity", message: fill(labels.quantityAboveConfirm, { n: size(quantity), max: size(max.confirm) }) }) const min = thresholds(limits.minQuantity) if (min.block !== undefined && quantity < min.block) problems.push({ field: "quantity", level: "block", rule: "minQuantity", message: fill(labels.quantityBelow, { n: size(quantity), min: size(min.block) }) }) else if (min.confirm !== undefined && quantity < min.confirm) problems.push({ field: "quantity", level: "confirm", rule: "minQuantity", message: fill(labels.quantityBelowConfirm, { n: size(quantity), min: size(min.confirm) }) }) } if (limits.maxDistance) { const rule = limits.maxDistance const level = rule.level ?? "block" const max = "ticks" in rule ? { value: rule.ticks, unit: "ticks" as const } : { value: rule.bps, unit: "bps" as const } for (const field of ["price", "bid", "ask"] as const) { const value = draft[field] if (typeof value !== "number" || !Number.isFinite(value)) continue const against = marketSideFor(field, draft.side, context.market) if (against === null) continue const distance = distanceFromMarket(value, against, context.convention) if (distance.unit !== max.unit || !(distance.value > max.value)) continue const unit = (n: number) => { const word = labels[max.unit] return typeof word === "function" ? word(n) : word } const words = { field: labels[field], distance: `${formatTicks(distance.value, { signed: false })} ${unit(distance.value)}`, max: `${formatTicks(max.value, { signed: false })} ${unit(max.value)}` } problems.push({ field, level, rule: "maxDistance", message: fill(level === "block" ? labels.tooFar : labels.tooFarConfirm, words) }) } } if (limits.sides) { const taken = new Set(limits.sides) const refuse = (side: "buy" | "sell", field: string) => problems.push({ field, level: "block", rule: "sides", message: fill(labels.sideNotAllowed, { side: labels[side] }) }) if (draft.side && !taken.has(draft.side)) refuse(draft.side, "side") if (typeof draft.bid === "number" && !taken.has("buy")) refuse("buy", "bid") if (typeof draft.ask === "number" && !taken.has("sell")) refuse("sell", "ask") } if (limits.custom) problems.push(...limits.custom(draft, context)) return problems } /** The problems that stop the action. */ export function blocks(problems: readonly Problem[]): Problem[] { return problems.filter((p) => p.level === "block") } /** The problems that ask again. */ export function confirms(problems: readonly Problem[]): Problem[] { return problems.filter((p) => p.level === "confirm") } /** The first message per field, for printing under the fields the way the tickets do. */ export function problemsByField(problems: readonly Problem[]): Record<string, string> { const messages = new Map<string, string>() for (const p of problems) if (!messages.has(p.field)) messages.set(p.field, p.message) return Object.fromEntries(messages) } -
Add the tokens to your stylesheet:
:root { --down: oklch(0.46 0.19 40); --down-soft: oklch(0.46 0.19 40 / 18%); --expiring-soft: oklch(0.53 0.2 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.55 0 0 / 14%); --stale-soft: oklch(0.52 0.13 75 / 16%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --up: oklch(0.52 0.13 165); --up-soft: oklch(0.52 0.13 165 / 18%); } .dark { --down: oklch(0.66 0.19 40); --down-soft: oklch(0.66 0.19 40 / 18%); --expiring-soft: oklch(0.74 0.17 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.7 0 0 / 14%); --stale-soft: oklch(0.8 0.15 80 / 16%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --up: oklch(0.74 0.15 165); --up-soft: oklch(0.74 0.15 165 / 18%); } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
Seed a stable store and include "edit" in each editable row's allowedActions. Double-click a value or press Enter on its cell to edit; Enter commits and Escape cancels. Prices use 32nds, skew and width use quote steps, and sizes use whole units. Scroll horizontally to reach the remaining columns.
This example writes each edit straight back to the store. In an application, send the command to your server and apply its response; the server decides any related changes to bid, ask, skew, or width. The panel never derives those values or writes the store itself.
import { useState } from "react"
import { QuotePanel, type QuoteRow } from "@/components/quote-panel"
import type { InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
const convention: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const quotes: QuoteRow[] = [
{ id: "2Y", instrument: "2Y Treasury", status: "Quoting", marketBid: 100.234375, marketAsk: 100.25, bid: 100.21875, ask: 100.265625, skew: 0, width: 3, bidSize: 5_000_000, askSize: 5_000_000, allowedActions: ["edit"] },
{ id: "10Y", instrument: "10Y Treasury", status: "Quoting", marketBid: 99.515625, marketAsk: 99.53125, bid: 99.5, ask: 99.5625, skew: 0.5, width: 4, bidSize: 10_000_000, askSize: 10_000_000, allowedActions: ["edit"] },
]
export default function QuotePanelDemo() {
const [store] = useState(() => {
const store = createRowStore<QuoteRow>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: quotes })
return store
})
return (
<div className="h-40 w-fit max-w-full">
<QuotePanel store={store} convention={convention} onEdit={({ rowId, key, value }) => {
store.applyDeltas({ patch: [{ id: rowId, fields: { [key]: value } }] })
}} />
</div>
)
}
Pending and rejected edits
Return a promise from onEdit when the server can reject a command. This example waits one second before writing the accepted value and resolving. Set Width to 5 to see it pending, then to 9 to see a rejection: the cell returns to the stored value and shows the error. Reopen it and commit a change to replace the error. The delay is sample server behavior; outstanding timers are cleared on unmount.
import { useEffect, useRef, useState } from "react"
import { QuotePanel, type QuoteRow } from "@/components/quote-panel"
import type { InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import type { ColumnState, EditChange } from "@/components/ui/data-grid"
const convention: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
export default function QuotePanelPendingDemo() {
const [columnState, setColumnState] = useState<ColumnState>({ order: [], widths: { width: 260 }, hidden: ["marketBid", "marketAsk", "bid", "ask", "bidSize", "askSize", "actions"] })
const [store] = useState(() => {
const store = createRowStore<QuoteRow>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: [
{ id: "2Y", instrument: "2Y Treasury", status: "Quoting", marketBid: 100.234375, marketAsk: 100.25, bid: 100.21875, ask: 100.265625, skew: 0, width: 3, bidSize: 5_000_000, askSize: 5_000_000, allowedActions: ["edit"] },
] })
return store
})
const timers = useRef(new Map<ReturnType<typeof setTimeout>, () => void>())
useEffect(() => {
const pending = timers.current
return () => {
for (const [timer, resolve] of pending) { clearTimeout(timer); resolve() }
pending.clear()
}
}, [])
// Stand in for a server response: accept the edit after one second, or reject a width over 8.
const onEdit = ({ rowId, key, value }: EditChange<QuoteRow>) => new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
timers.current.delete(timer)
if (key === "width" && typeof value === "number" && value > 8) {
reject(new Error("Risk declined a width over 8."))
return
}
store.applyDeltas({ patch: [{ id: rowId, fields: { [key]: value } }] })
resolve()
}, 1000)
timers.current.set(timer, resolve)
})
return <div className="h-40 w-fit max-w-full"><QuotePanel store={store} convention={convention} columnState={columnState} onColumnStateChange={setColumnState} onEdit={onEdit} /></div>
}
Allowed actions
The server's allowedActions controls both editing and which row buttons appear. Pause keeps the levels, Pull clears them and removes edit permission, and Resume restores a two-way from the market. These handlers write the status and permissions back to the store so the result stays visible. The unrelated columns start hidden so the row actions stay in view.
Pull all asks for a second, separate press, then receives every row that allows "pull", including rows outside a filter or selection. After all rows are pulled, it is disabled; Resume makes a row eligible again. Escape or another click inside the panel cancels the question.
import { useState } from "react"
import { QuotePanel, type QuoteAction, type QuoteRow } from "@/components/quote-panel"
import type { InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import type { ColumnState } from "@/components/ui/data-grid"
const convention: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const quotes: QuoteRow[] = [
{ id: "2Y", instrument: "2Y Treasury", status: "Quoting", marketBid: 100.234375, marketAsk: 100.25, bid: 100.21875, ask: 100.265625, allowedActions: ["edit", "pause", "pull"] },
{ id: "5Y", instrument: "5Y Treasury", status: "Paused", marketBid: 99.734375, marketAsk: 99.75, bid: 99.71875, ask: 99.765625, allowedActions: ["edit", "resume", "pull"] },
{ id: "10Y", instrument: "10Y Treasury", status: "Pulled", marketBid: 99.515625, marketAsk: 99.53125, bid: null, ask: null, allowedActions: ["resume"] },
]
export default function QuotePanelActionsDemo() {
const [columnState, setColumnState] = useState<ColumnState>({ order: [], widths: {}, hidden: ["marketBid", "marketAsk", "skew", "width", "bidSize", "askSize"] })
const [store] = useState(() => {
const store = createRowStore<QuoteRow>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: quotes })
return store
})
const pull = (rows: QuoteRow[]) => {
store.applyDeltas({ patch: rows.map((row) => ({ id: row.id, fields: { status: "Pulled", bid: null, ask: null, allowedActions: ["resume"] } })) })
}
const [actions] = useState<QuoteAction[]>(() => [
{ id: "pause", label: "Pause", run: (row) => {
store.applyDeltas({ patch: [{ id: row.id, fields: { status: "Paused", allowedActions: ["edit", "resume", "pull"] } }] })
} },
{ id: "resume", label: "Resume", run: (row) => {
store.applyDeltas({ patch: [{ id: row.id, fields: { status: "Quoting", bid: row.marketBid, ask: row.marketAsk, allowedActions: ["edit", "pause", "pull"] } }] })
} },
{ id: "pull", label: "Pull", destructive: true, run: (row) => pull([row]) },
])
return (
<div className="h-48 w-fit max-w-full">
<QuotePanel store={store} convention={convention} columnState={columnState} onColumnStateChange={setColumnState} actions={actions} onPullAll={pull} onEdit={({ rowId, key, value }) => {
store.applyDeltas({ patch: [{ id: rowId, fields: { [key]: value } }] })
}} />
</div>
)
}
Confirming and blocking edits
Pass limits to check a value before onEdit runs. Try Bid 100-04: it is seven ticks below the market bid, past the four-tick limit, so the editor asks for confirmation. Press Enter again to send it. A crossed quote is refused before limits are checked.
In Bid size, 60000000 asks for confirmation above 50 million; 150000000 is blocked above 100 million. Skew, width, and blank values bypass these limits. The Limits reference explains when a question stands and what withdraws it.
import { useState } from "react"
import { QuotePanel, type QuoteRow } from "@/components/quote-panel"
import type { InstrumentConvention } from "@/lib/format"
import type { Limits } from "@/lib/limits"
import { createRowStore } from "@/lib/row-store"
import type { ColumnState } from "@/components/ui/data-grid"
const convention: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const limits: Limits = { maxDistance: { ticks: 4, level: "confirm" }, maxQuantity: { confirm: 50_000_000, block: 100_000_000 } }
export default function QuotePanelLimitsDemo() {
const [columnState, setColumnState] = useState<ColumnState>({ order: [], widths: { bidSize: 120 }, hidden: ["status", "skew", "width", "askSize", "actions"] })
const [store] = useState(() => {
const store = createRowStore<QuoteRow>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: [
{ id: "2Y", instrument: "2Y Treasury", status: "Quoting", marketBid: 100.234375, marketAsk: 100.25, bid: 100.21875, ask: 100.265625, skew: 0, width: 3, bidSize: 5_000_000, askSize: 5_000_000, allowedActions: ["edit"] },
] })
return store
})
return (
<div className="h-40 w-fit max-w-full">
<QuotePanel store={store} convention={convention} columnState={columnState} onColumnStateChange={setColumnState} limits={limits} onEdit={({ rowId, key, value }) => {
store.applyDeltas({ patch: [{ id: rowId, fields: { [key]: value } }] })
}} />
</div>
)
}
API Reference
QuotePanel uses data-grid in its parameters preset with generated quote columns and an optional Pull all button. Shared files are byte-identical to the ones data-grid and parameter-grid install. Though it installs as a block in your components alias, compose it through quotePanelColumns and its options rather than editing the file: unlike the ticket blocks, its behavior lives in the generated columns, and an edited file takes later registry fixes by hand. A two-way edit is refused when it crosses the desk's other side, read through the instrument's quote direction — a bid at or above the ask normally, the reverse where a higher quote means a lower price: yield and discount by default, any basis the convention declares with quoteInverted, and quoteInverted: false keeps the plain reading on a desk that quotes bid below offer in those bases.
Props
QuotePanelProps<T> requires T extends QuoteRow. It inherits DataGridProps<T> except preset, columns, label, and onEdit; the latter three have the definitions below. Other inherited props, including views, sorting, selection, column state, and preset overrides such as rowHeight, pass to the grid. className styles the panel's outer wrapper.
| Prop | Type | Default | Purpose |
|---|---|---|---|
store |
RowStore<T> |
Required | Quotes keyed by their id. |
convention |
InstrumentConvention | ((row: T) => InstrumentConvention) |
Required | How levels print, parse, and step: one convention for every row, or one per row. |
onEdit |
(change: EditChange<T>) => void | Promise<unknown> |
Required | Send a typed level, size, skew, or width to the server. |
actions |
readonly QuoteAction<T>[] |
None | The row's buttons, shown where allowedActions names them. |
onPullAll |
(rows: T[]) => void | Promise<unknown> |
None | Show Pull all; receive every row the server allows the pull action on. |
pullAction |
string |
"pull" |
The id Pull all looks for in allowedActions. |
editAction |
string |
"edit" |
The id that lets a row's values be typed. |
limits |
Limits | ((row: T) => Limits | undefined) |
None | Checks for bid, ask, and size edits. |
limitsLabels |
Partial<LimitsLabels> |
DEFAULT_LIMITS_LABELS |
The words of the limits' sentences, inside a block or a question. |
getRowLabel |
((row: T, id: RowId) => string) | null |
The row's instrument |
A row's name that holds while its prices tick, so a focused row is read once, not at every update; null names a row by its cells, as DataGrid does. |
font |
"numeric" | "mono" |
See below | Font family for the market and desk bid/ask columns. |
columns |
ColumnDef<T>[] | ((question: QuotePanelQuestion) => ColumnDef<T>[]) |
quotePanelColumns(options) |
Replace the generated column list. A function keeps the panel's question line; see Columns. |
label |
string |
"Quotes" |
Accessible name for the grid. |
labels |
Partial<QuotePanelLabels & DataGridLabels> |
DEFAULT_QUOTE_PANEL_LABELS |
Override the words listed below, and the grid's own, DataGrid's labels, among them the column menus, the resize handles, and notEditable. A word given as undefined keeps its default. |
className |
string |
None | Classes on the outer wrapper. |
Keep labels, actions, limits, and a fixed convention stable between renders: a new object rebuilds every generated column.
With a fixed convention, font defaults to "mono" for fractional quotes in the price basis and "numeric" otherwise. A function-valued convention always defaults to "numeric", even when it returns fractional price conventions; pass font="mono" to override it.
Rows
Extend QuoteRow with whatever else your server sends:
| Field | Type | Required | Purpose |
|---|---|---|---|
id |
string |
Yes | Row identity; use it in the store's getRowId. |
instrument |
string |
Yes | The frozen first column. |
status |
string |
Yes | The server's word: Quoting, Paused, Pulled, whatever it says. Printed as is, on data-quote-status. |
marketBid / marketAsk |
number | null |
No | The market's two-way. These flash as they move. |
bid / ask |
number | null |
No | The desk's two-way. Null on a side is no level there. |
skew |
number | null |
No | How far the desk's mid sits off the market's, in quote steps, signed. |
width |
number | null |
No | From the desk's bid to its ask, in quote steps. |
bidSize / askSize |
number | null |
No | The size shown on each side. |
allowedActions |
readonly string[] |
No | The ids the server allows now. An absent or empty list permits nothing. |
updatedAt |
number | null |
No | When the server last changed the row; unused by the generated columns. |
A quote step is tick for a price basis. For yield, discount, or spread, it is quoteStep or the basis default from format: 0.001, 0.001, or 0.1, respectively.
Every edit is a command
With the generated columns, Enter, F2, a double click, or typing opens a cell when the row's allowedActions includes editAction. Enter commits; Escape cancels; Tab and Shift+Tab commit and move to the next or previous editable cell in the row. Up and Down step, with Shift for ten steps. Leaving the editor also commits a valid value, or discards a value that fails parsing or validation.
Bid and ask read the instrument's notation through format's parseQuote. Sizes read whole numbers with commas only between thousands, as they print (5,000,000). Skew and width read decimals, so one comma with no point after it, as in 1,234, could be a decimal comma and is not a number; neither is 1,5. Their editor opens on text without separators, 1234 for a width and +1234 for a skew, and a pending value shows that text until the store settles.
A valid change calls onEdit with { rowId, key, value, previous, row }. The generated keys are bid, ask, skew, width, bidSize, and askSize; previous and row come from the current store. Committing the same value sends nothing. The panel never writes the store.
The cell shows the committed value as pending until the store matches it (Object.is) or your promise resolves. Resolution displays the current store value, which may still be the old value. Returning nothing leaves the cell pending until the store matches. Reopening starts from the committed text. A promise-backed pending is covered: every close of the untouched editor — Enter, Tab, blur, or Escape — restores it, a promise that settled meanwhile applied, and committing a change replaces it. A pending from returning nothing has no promise to settle it, so reopening dismisses it. A thrown error or rejection of a still-pending promise displays the current store value and the error message; reopening clears the error while the editor is open, and closing it untouched brings the error back.
| Field | Typed as | Steps by | Blank means |
|---|---|---|---|
bid, ask |
The instrument's notation (99-16+, 4.125) through parseQuote; a typed decimal snaps to the printable grid |
One quote step, ten with Shift; from the market's same side when the side is empty | No level on that side |
skew, width |
A plain decimal number of quote steps, including fractional steps; a width is zero or more | One step, ten with Shift; from zero when empty, a width never below zero | Null |
bidSize, askSize |
A whole number from zero to Number.MAX_SAFE_INTEGER |
One, ten with Shift; from zero when empty, never below zero | Null |
Numbers are plain decimals: 1e3 and 0x10 are refused. A crossed edit is refused in the editor before it is sent, read through the instrument's quote direction: a bid at or above the desk's ask, or an ask at or below its bid, normally — the reverse where the direction inverts. The other side is the one the row shows: a level sent and not yet back counts over the stored one, through the grid's commit.pending, so lowering the ask and then raising the bid past it is refused before the server has answered either.
A row the server stops allowing editAction on while its editor is open keeps the typed text: Enter says labels.notEditable on the editor rather than closing it, and sends once the action is allowed again; Escape discards the text.
Limits
The generated editors call checkLimits for non-null bids, asks, and sizes. A bid is passed as { bid }, an ask as { ask }, and a size as { quantity }, with the row's market bid/ask and convention as context. Skew, width, and blank values bypass limits. The crossed-quote check runs before limits.
The first block refuses the value with its message. Otherwise, the first confirm refuses it with a question, {message} Press Enter again to send it, or Escape to discard it., shown on the editor and in words above the grid, on a role="status" line carrying data-quote-question. The panel shows that line where a question can stand: with limits on the generated columns, with a columns function, and beside Pull all.
Only a fresh Enter on the same value, in the same opening of the editor, answers the question, provided no block or crossed quote now prevents it. Tab and the repeats of a held Enter ask again rather than send. One question stands at a time, as the line shows one: asking about another value, in any cell, replaces it, and a blank, crossed, or blocked value, or one inside the limits, withdraws it. Changing the text withdraws the question too, by typing or by a step with Up or Down, and so does every way out of the editor: Escape, a click away, Tab on to another cell, or the row leaving the view. A value typed again later is asked about again, and an answered value asks again the next time it is committed.
Actions
Each QuoteAction<T> defines a row button:
| Field | Type | Default | Purpose |
|---|---|---|---|
id |
string |
Required | Permission id in allowedActions. |
label |
string |
Required | Button text. |
run |
(row: T) => void | Promise<unknown> |
Required | Handle the action for the rendered row. |
destructive |
boolean |
false |
Draws the action in the destructive color, on the row's ghost button and in the row menu, where a highlighted item takes the menu's accent; adds no confirmation. |
Buttons follow your actions order and appear only when allowedActions includes their ids. A click rechecks permission against the rendered row, and a press counts once: a double-click's second click and a held Enter's repeats run nothing. While a returned promise is pending, every action button on that row is disabled. Fulfillment or rejection re-enables them; rejection shows no built-in error. The status changes only when the store changes.
The same actions are on the row menu, so the keyboard reaches them: right-click the row, or press Shift+F10 or the Menu key on the focused row. With actions passed, a right-click on a row opens this menu instead of the browser's own. The buttons themselves stay out of the Tab order, keeping the grid one tab stop. The menu names the row it opened on and acts on that row alone, whatever else is selected. While it stays open, its items follow the row as the server changes it. A run started from either the menu or a button holds both until its promise settles; the hold is the panel's, so it survives the row scrolling out of view and back. A row the server allows no action on, and every row when actions is an empty list, shows No actions for this row right now. in its menu. Items your own renderContextMenu returns follow the row's actions. Pass actions always or never: switching between a list and undefined adds or removes the menu, which remounts the grid's rows.
When the control under focus leaves, focus moves to the grid so its keys keep working: a row button the server's reply removes, its row leaving the view or scrolling out of it, or Pull all disabling while its promise is out.
Action buttons and the gaps between them preserve row selection and logical focus, including while a command is pending.
Pull all appears when onPullAll is given. The first press changes its label to Pull all anyway? in the destructive color on the outline button; a click elsewhere inside the panel, Escape within it, focus leaving the panel, or no row left to pull cancels the question, so a row that becomes pullable again is asked about again. The second press — a fresh one, not a double-click's second click or a held Enter's repeat — rereads the store and passes every row whose allowedActions includes pullAction, regardless of the grid's filter or selection. The button is disabled when no row allows the action or its returned promise is pending. Fulfillment or rejection clears the pending state without an error message. data-quote-pull-all carries the eligible row count.
Columns
quotePanelColumns(options) returns eleven columns in this order: instrument, status, marketBid, marketAsk, bid, ask, skew, width, bidSize, askSize, and actions. Spread them into your own list to add, drop, or reorder. Supplying columns replaces the generated list, including its editors, formatting, permission checks, and row actions; panel props do not retrofit those features onto custom columns, except the row menu, which follows the panel's actions.
Pass columns as a function to keep the panel's question line. The panel calls it with QuotePanelQuestion, { asked, onQuestion }: its memory of the question standing and its line. Spread that into quotePanelColumns or quoteEdit, and the panel withdraws the question for your columns as it does for its own. Keep the function stable, as you would a list: a new function rebuilds every column.
| Helper | Returns | Inputs |
|---|---|---|
quotePanelColumns<T>(options) |
ColumnDef<T>[] |
QuoteColumnOptions<T> for the generated columns. |
quoteEdit<T>(field, options) |
CellEdit<T> |
A QuoteField and QuoteColumnOptions<T> for its parser, stepper, permission check, and validation. |
allowsQuoteAction(row, id) |
boolean |
A QuoteRow and action id (string); false for an absent or empty list. |
For example, inside your component, with a stable convention, limits, actions, and labels, the ones you pass the panel, as in Confirming and blocking edits:
import { useCallback } from "react"
import { quotePanelColumns, type QuotePanelQuestion, type QuoteRow } from "@/components/quote-panel"
// Inside the component: every generated column but skew, with the panel's question line.
const columns = useCallback(
(question: QuotePanelQuestion) =>
quotePanelColumns<QuoteRow>({ convention, limits, actions, labels, ...question }).filter((column) => column.key !== "skew"),
[convention, limits, actions, labels],
)
// Pass columns={columns} to QuotePanel, with the same actions, so the row's buttons and its menu agree.
QuoteColumnOptions<T> requires convention and accepts labels, editAction, and limits with the types and defaults in the props table. It also accepts actions and font, which only quotePanelColumns uses, asked?: Set<string> for the question standing, and onQuestion?: (question: string | null) => void, told each question a confirm limit asks, and null when a later check answers it, blocks or crosses the value, finds nothing to ask, a blank value included, or comes from leaving the editor. Text that does not parse never reaches a check. It is not told when an editor closes without a commit or its text changes. In the panel, focus leaving the control that asked withdraws its question too, unless the window lost focus. QuoteField is "bid" | "ask" | "skew" | "width" | "bidSize" | "askSize".
Outside the panel, in a grid of your own, create a stable asked set and pass it, with your own onQuestion, to every helper whose questions share one line; a set holds one question, so give each line you show its own. Clear the set, and the question you show, when the asking editor's text changes or focus leaves it, as the panel does; otherwise a value stepped or typed away and back in the same opening passes on the old question. Without a set, every confirming validation asks again.
A wrapper around the generated validate must pass the grid's third argument through: a call without it never answers a question. A cell control that commits through edit.commit(value) is asked in the words of askAgainControl, and answers by committing the same value again. A control that commits on a held key passes { repeat: true } with those commits, which never answer.
Labels
labels merges partial overrides into DEFAULT_QUOTE_PANEL_LABELS:
| Label | Default | Where |
|---|---|---|
instrument / status / marketBid / marketAsk / bid / ask / skew / width / bidSize / askSize / actions |
Instrument / Status / Mkt bid / Mkt ask / Bid / Ask / Skew / Width / Bid size / Ask size / Actions |
Column headers |
pullAll / pullAllAnyway |
Pull all / Pull all anyway? |
The button, and what it says while it asks |
notAQuote / notANumber / notASize / notAWidth |
Not a quote in this instrument's notation. / Not a number. / A size is a whole number, zero or more. / A width is zero or more. |
Editor problems |
bidCrosses / askCrosses |
The bid would cross the ask. / The ask would cross the bid. |
Editor problems |
askAgain |
{message} Press Enter again to send it, or Escape to discard it. |
A limit that asks; {message} is its sentence |
askAgainControl |
{message} Do it again to send it. |
A limit that asks about a value a cell control committed |
noActions |
No actions for this row right now. |
The row menu when the server allows no action on the row |
What it does not do
It does not quote, hedge, decide a status, or send anything on its own. The rows are the server's, an edit is a request, and a button is one of the actions the server said it would take.
Tokens
The install adds the grid's tokens, up, down, and flat, and the soft variants of those and of stale and expiring, if you do not have them.
Built on shadcn's button, checkbox, context-menu, and dropdown-menu.
This page is docs/quote-panel.md at v2.0.0.