RulesEditor
Edit one grid's highlights, filters, and sort order, with live match counts and an optional Columns tab for the column chooser.
import { useState } from "react"
import { createInstrumentFormatter, formatNotional } from "@/lib/format"
import type { GridRules } from "@/lib/grid-rules"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef, type ColumnState } from "@/components/ui/data-grid"
import { RulesEditor } from "@/components/ui/rules-editor"
interface Rfq {
id: string
client: string
size: number
px: number | null
}
const ust = createInstrumentFormatter({ price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 })
const columns: ColumnDef<Rfq>[] = [
{ key: "client", header: "Client", width: 180, sortable: true, accessor: (r) => r.client },
{ key: "size", header: "Size", width: 160, numeric: true, sortable: true, accessor: (r) => r.size, format: (v) => formatNotional(v as number, { unit: "mm" }) },
{ key: "px", header: "Price", width: 170, numeric: true, font: "mono", sortable: true, accessor: (r) => r.px, format: (v) => ust.price(v as number | null), parse: ust.parsePrice },
]
const rows: Rfq[] = [
{ id: "Q-1", client: "ALPHA", size: 5_000_000, px: 99.5 },
{ id: "Q-2", client: "BETA", size: 25_000_000, px: 100.015625 },
{ id: "Q-3", client: "GAMMA", size: 10_000_000, px: null },
]
// The editor and the grid share one rules object and one column state. Edit a rule and the grid follows the keystroke.
export default function RulesEditorDemo() {
const [store] = useState(() => {
const s = createRowStore<Rfq>({ getRowId: (r) => r.id })
s.applyDeltas({ upsert: rows })
return s
})
const [rules, setRules] = useState<GridRules>({
columns: [{ id: "threshold", column: "px", when: { op: "gte", value: "100-00" }, tone: "primary", label: "Price threshold" }],
filter: [],
sort: [],
})
const [columnState, setColumnState] = useState<ColumnState>({ order: [], widths: {}, hidden: [] })
return (
<div className="w-lg max-w-full space-y-3">
<RulesEditor className="overflow-x-auto" columns={columns} rules={rules} onRulesChange={setRules} store={store} columnState={columnState} onColumnStateChange={setColumnState} />
<div className="h-48">
<DataGrid store={store} columns={columns} preset="watchlist" label="Quotes with rules" rules={rules} columnState={columnState} onColumnStateChange={setColumnState} />
</div>
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/rules-editor#v1.4.13
npx shadcn@latest add tradecn/ui/rules-editor#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/rules-editor#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/rules-editor#v1.4.13
-
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 badge button checkbox context-menu dialog dropdown-menu input native-selectnpx shadcn@latest add badge button checkbox context-menu dialog dropdown-menu input native-selectyarn dlx shadcn@latest add badge button checkbox context-menu dialog dropdown-menu input native-selectbunx --bun shadcn@latest add badge button checkbox context-menu dialog dropdown-menu input native-select -
Copy the files into your project:
components/ui/ rules-editor.tsx import { cn } from "cn" import { useEffect, useMemo, useState, type DragEvent, type KeyboardEvent, type ReactNode } from "react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select" import { NUMERIC_CLASS } from "@/lib/format" import { RULE_OP_LABELS, RULE_TONES, RULE_TONE_CLASS, columnName, compileCondition, compileFilter, opsFor, ruleProblem, type ColumnRule, type FilterRule, type GridRules, type RuleCondition, type RuleOp, type RuleTone, type RuleValue, type SortRule, } from "@/lib/grid-rules" import type { RowStore } from "@/lib/row-store" import { ColumnChooserPanel } from "@/components/ui/column-chooser" import type { ColumnDef, ColumnState } from "@/components/ui/data-grid" // The editor over one grid's rules: highlights, filters, and the sort stack, each a list to add to, // edit, reorder, and remove, plus the column chooser in a fourth tab when the grid's column state is // given. A rule is built from a column picker, an op picker that narrows by the column's kind, a value // field in the column's own format, and, for a highlight, a tone shown in its token and what it paints. // Beside each rule a live count says how many rows in the store it matches right now, so a rule is // checked as it is typed. It produces `GridRules` objects through `onRulesChange` and stores nothing. export type RulesEditorTab = "highlights" | "filters" | "sort" | "columns" export interface RulesEditorLabels { title: string highlights: string filters: string sort: string columns: string addHighlight: string addFilter: string addSort: string column: string condition: string value: string low: string high: string /** The one field for `in`: comma separated. */ values: string tone: string /** What a highlight paints: the cell or the row. */ paints: string cell: string row: string label: string direction: string asc: string desc: string remove: string moveUp: string moveDown: string /** `{n}` is the count. */ matches: string /** `{n}` pass of `{m}`. */ shown: string noHighlights: string noFilters: string noSort: string dragHint: string } export const DEFAULT_RULES_EDITOR_LABELS: RulesEditorLabels = { title: "Rules", highlights: "Highlights", filters: "Filters", sort: "Sort", columns: "Columns", addHighlight: "Add highlight", addFilter: "Add filter", addSort: "Add sort key", column: "Column", condition: "Condition", value: "Value", low: "Low", high: "High", values: "Values, comma separated", tone: "Tone", paints: "Paints", cell: "the cell", row: "the row", label: "Label", direction: "Direction", asc: "ascending", desc: "descending", remove: "Remove", moveUp: "Move up", moveDown: "Move down", matches: "{n} rows match", shown: "{n} of {m} rows show", noHighlights: "No highlights. Add one to color a cell or a row when a value crosses a line.", noFilters: "No filters. Every row shows.", noSort: "No sort keys. Rows keep their arrival order, or the order a header sets.", dragHint: "Drag a rule, or hold Alt with an arrow key, to reorder. The first rule that applies wins.", } export interface RulesEditorProps<T> { columns: ColumnDef<T>[] rules: GridRules onRulesChange: (rules: GridRules) => void /** For the live counts: how many rows each rule matches right now. */ store?: RowStore<T> /** With both, a Columns tab holds the column chooser over the same grid. */ columnState?: ColumnState onColumnStateChange?: (state: ColumnState) => void defaultTab?: RulesEditorTab labels?: Partial<RulesEditorLabels> className?: string } const OPS_WITH_VALUE = new Set<RuleOp>(["eq", "ne", "gt", "gte", "lt", "lte", "contains", "startsWith"]) /** What a condition's op wants typed: one value, two (a range), many (a set), or nothing. */ export function valueShape(op: RuleOp): "one" | "two" | "many" | "none" { if (OPS_WITH_VALUE.has(op)) return "one" if (op === "between") return "two" if (op === "in") return "many" return "none" } /** A list of values from one field, comma separated, trimmed, empties dropped. */ export function parseValues(text: string): RuleValue[] { return text .split(",") .map((part) => part.trim()) .filter((part) => part !== "") } /** The one field's text for a list of values. */ export function valuesText(values: readonly RuleValue[] | undefined): string { return (values ?? []).map((value) => (value === null ? "" : String(value))).join(", ") } /** The condition with a new op: the typed values are kept when the new op wants the same shape and dropped otherwise. */ export function withOp(condition: RuleCondition, op: RuleOp): RuleCondition { if (valueShape(condition.op) === valueShape(op)) return { ...condition, op } return { op } } /** The condition for a new column: its op if the column offers it, else the column's first, and the values dropped either way when the op changed. */ export function withColumn<T>(condition: RuleCondition, column: ColumnDef<T> | undefined): RuleCondition { const ops = opsFor(column) return ops.includes(condition.op) ? condition : { op: ops[0]! } } /** A list with one item moved to another's index, the rest shifting to make room. */ export function moveItem<X>(list: readonly X[], from: number, to: number): X[] { if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) return [...list] const next = [...list] const [item] = next.splice(from, 1) next.splice(to, 0, item!) return next } let ruleCounter = 0 /** An id for a new highlight, unique on this page. */ export function newRuleId(): string { ruleCounter += 1 return `rule-${Date.now().toString(36)}-${ruleCounter}` } /** A new highlight on the first column, its first op, in `up`. */ export function newHighlight<T>(columns: readonly ColumnDef<T>[]): ColumnRule { const column = columns[0] return { id: newRuleId(), column: column?.key ?? "", when: { op: opsFor(column)[0]! }, tone: "up" } } /** A new filter on the first column, its first op. */ export function newFilter<T>(columns: readonly ColumnDef<T>[]): FilterRule { const column = columns[0] return { column: column?.key ?? "", op: opsFor(column)[0]! } } /** A new sort key on the first column not yet in the list, ascending. */ export function newSort<T>(columns: readonly ColumnDef<T>[], existing: readonly SortRule[] = []): SortRule { const used = new Set(existing.map((rule) => rule.key)) const column = columns.find((c) => !used.has(c.key)) ?? columns[0] return { key: column?.key ?? "", dir: "asc" } } function fill(template: string, values: Record<string, number>): string { return template.replace(/\{(\w+)\}/g, (_, name: string) => (values[name] ?? 0).toLocaleString()) } /** The store's version, at most once per `ms`, so a busy feed does not redraw the editor every frame. */ function useThrottledVersion<T>(store: RowStore<T> | undefined, ms: number): number { const [version, setVersion] = useState(() => store?.getMeta().version ?? 0) useEffect(() => { if (!store) return let timer: ReturnType<typeof setTimeout> | null = null const unsubscribe = store.subscribeMeta(() => { if (timer !== null) return timer = setTimeout(() => { timer = null setVersion(store.getMeta().version) }, ms) }) return () => { unsubscribe() if (timer !== null) clearTimeout(timer) } }, [store, ms]) return version } // One empty list per kind, so a rules object without a list does not hand the memos a fresh array every render. const NO_HIGHLIGHTS: ColumnRule[] = [] const NO_FILTERS: FilterRule[] = [] const NO_SORTS: SortRule[] = [] function countMatching<T>(store: RowStore<T>, test: (row: T) => boolean): number { let n = 0 for (const id of store.getIds()) { const row = store.getRow(id) if (row !== undefined && test(row)) n++ } return n } interface ListProps { count: number labels: RulesEditorLabels empty: string onMove: (from: number, to: number) => void children: (row: RowFrame) => ReactNode } interface RowFrame { /** Wrap one item's content in the list's row: drag, keyboard, and the move and remove controls. */ item: (index: number, key: string | number, name: string, content: ReactNode, onRemove: () => void) => ReactNode } /** The shared frame of the three lists: a row per item, each draggable, movable by Alt+arrow or by button, removable. */ function ReorderableList({ count, labels, empty, onMove, children }: ListProps) { const [dragging, setDragging] = useState<number | null>(null) if (count === 0) return <p className="text-muted-foreground">{empty}</p> const item: RowFrame["item"] = (index, key, name, content, onRemove) => { const onKeyDown = (event: KeyboardEvent<HTMLLIElement>) => { if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return event.preventDefault() event.stopPropagation() const to = index + (event.key === "ArrowUp" ? -1 : 1) if (to >= 0 && to < count) onMove(index, to) } return ( <li key={key} tabIndex={0} draggable data-rule-row={index} data-dragging={dragging === index || undefined} aria-label={name} className="flex flex-wrap items-start gap-1.5 rounded-sm border border-border/60 p-1.5 outline-none focus-visible:border-ring data-[dragging]:opacity-50" onKeyDown={onKeyDown} onDragStart={(event: DragEvent<HTMLLIElement>) => { event.dataTransfer?.setData?.("text/plain", String(index)) if (event.dataTransfer) event.dataTransfer.effectAllowed = "move" setDragging(index) }} onDragOver={(event: DragEvent<HTMLLIElement>) => { if (dragging === null || dragging === index) return event.preventDefault() if (event.dataTransfer) event.dataTransfer.dropEffect = "move" }} onDrop={(event: DragEvent<HTMLLIElement>) => { event.preventDefault() const from = dragging ?? Number(event.dataTransfer?.getData?.("text/plain") ?? NaN) setDragging(null) if (Number.isInteger(from) && from !== index) onMove(from, index) }} onDragEnd={() => setDragging(null)} > <span aria-hidden className="mt-1.5 cursor-grab select-none text-muted-foreground" title={labels.dragHint}> <svg width="8" height="12" viewBox="0 0 8 12" fill="currentColor"> <circle cx="2" cy="2" r="1.2" /> <circle cx="6" cy="2" r="1.2" /> <circle cx="2" cy="6" r="1.2" /> <circle cx="6" cy="6" r="1.2" /> <circle cx="2" cy="10" r="1.2" /> <circle cx="6" cy="10" r="1.2" /> </svg> </span> <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">{content}</div> <span className="flex shrink-0 items-center"> <Button type="button" variant="ghost" size="sm" className="h-6 w-6 px-0 text-xs" aria-label={`${labels.moveUp}: ${name}`} disabled={index === 0} onClick={() => onMove(index, index - 1)}> <span aria-hidden>▲</span> </Button> <Button type="button" variant="ghost" size="sm" className="h-6 w-6 px-0 text-xs" aria-label={`${labels.moveDown}: ${name}`} disabled={index === count - 1} onClick={() => onMove(index, index + 1)}> <span aria-hidden>▼</span> </Button> <Button type="button" variant="ghost" size="sm" className="h-6 px-1.5 text-xs" aria-label={`${labels.remove}: ${name}`} onClick={onRemove}> <span aria-hidden>×</span> </Button> </span> </li> ) } return <ul className="flex flex-col gap-1">{children({ item })}</ul> } interface ConditionFieldsProps<T> { name: string column: ColumnDef<T> | undefined columns: ColumnDef<T>[] condition: RuleCondition columnKey: string labels: RulesEditorLabels onColumn: (key: string) => void onCondition: (condition: RuleCondition) => void } /** The column picker, the op picker narrowed by the column, and the value fields the op wants. */ function ConditionFields<T>({ name, column, columns, condition, columnKey, labels, onColumn, onCondition }: ConditionFieldsProps<T>) { const ops = opsFor(column) const shape = valueShape(condition.op) const text = (value: RuleValue | undefined) => (value === undefined || value === null ? "" : String(value)) return ( <> <NativeSelect size="sm" value={columnKey} aria-label={`${labels.column}: ${name}`} data-rule-field="column" onChange={(event) => onColumn(event.target.value)}> {!columns.some((c) => c.key === columnKey) && <NativeSelectOption value={columnKey}>{columnKey}</NativeSelectOption>} {columns.map((c) => ( <NativeSelectOption key={c.key} value={c.key}> {columnName(c)} </NativeSelectOption> ))} </NativeSelect> <NativeSelect size="sm" value={condition.op} aria-label={`${labels.condition}: ${name}`} data-rule-field="op" onChange={(event) => onCondition(withOp(condition, event.target.value as RuleOp))}> {ops.map((op) => ( <NativeSelectOption key={op} value={op}> {RULE_OP_LABELS[op]} </NativeSelectOption> ))} </NativeSelect> {shape === "one" && ( <Input value={text(condition.value)} aria-label={`${labels.value}: ${name}`} placeholder={labels.value} spellCheck={false} autoComplete="off" data-rule-field="value" className="h-6 w-28 px-1.5 text-xs md:text-xs" onChange={(event) => onCondition({ ...condition, value: event.target.value })} /> )} {shape === "two" && ( <> <Input value={text(condition.values?.[0])} aria-label={`${labels.low}: ${name}`} placeholder={labels.low} spellCheck={false} autoComplete="off" data-rule-field="low" className="h-6 w-24 px-1.5 text-xs md:text-xs" onChange={(event) => onCondition({ ...condition, values: [event.target.value, condition.values?.[1] ?? ""] })} /> <Input value={text(condition.values?.[1])} aria-label={`${labels.high}: ${name}`} placeholder={labels.high} spellCheck={false} autoComplete="off" data-rule-field="high" className="h-6 w-24 px-1.5 text-xs md:text-xs" onChange={(event) => onCondition({ ...condition, values: [condition.values?.[0] ?? "", event.target.value] })} /> </> )} {shape === "many" && <ValuesField name={name} values={condition.values} labels={labels} onChange={(values) => onCondition({ ...condition, values })} />} </> ) } // The comma field keeps its own text while it is typed, so "a," is not read back as "a" under the hand. function ValuesField({ name, values, labels, onChange }: { name: string; values: RuleValue[] | undefined; labels: RulesEditorLabels; onChange: (values: RuleValue[]) => void }) { const [text, setText] = useState(() => valuesText(values)) const [known, setKnown] = useState(values) if (values !== known) { setKnown(values) if (valuesText(parseValues(text)) !== valuesText(values)) setText(valuesText(values)) } return ( <Input value={text} aria-label={`${labels.values}: ${name}`} placeholder={labels.values} spellCheck={false} autoComplete="off" data-rule-field="values" className="h-6 w-40 px-1.5 text-xs md:text-xs" onChange={(event) => { setText(event.target.value) onChange(parseValues(event.target.value)) }} /> ) } function Problem({ text }: { text: string | null }) { return text ? ( <p className="basis-full text-destructive" data-rule-problem> {text} </p> ) : null } function Count({ n, template }: { n: number | null; template: string }) { if (n === null) return null return ( <span className={cn("text-muted-foreground", NUMERIC_CLASS)} data-rule-count={n}> {fill(template, { n })} </span> ) } export function RulesEditor<T>({ columns, rules, onRulesChange, store, columnState, onColumnStateChange, defaultTab, labels: labelsProp, className }: RulesEditorProps<T>) { const labels = { ...DEFAULT_RULES_EDITOR_LABELS, ...labelsProp } const hasColumns = columnState !== undefined && onColumnStateChange !== undefined const tabs: { id: RulesEditorTab; label: string; count: number | null }[] = [ { id: "highlights", label: labels.highlights, count: rules.columns?.length ?? 0 }, { id: "filters", label: labels.filters, count: rules.filter?.length ?? 0 }, { id: "sort", label: labels.sort, count: rules.sort?.length ?? 0 }, ...(hasColumns ? [{ id: "columns" as const, label: labels.columns, count: columnState.hidden.length }] : []), ] const [tab, setTab] = useState<RulesEditorTab>(defaultTab ?? "highlights") const shownTab = tabs.some((t) => t.id === tab) ? tab : "highlights" const highlights = rules.columns ?? NO_HIGHLIGHTS const filters = rules.filter ?? NO_FILTERS const sorts = rules.sort ?? NO_SORTS const byKey = useMemo(() => new Map(columns.map((c) => [c.key, c])), [columns]) // Live counts, on a throttled beat, from the store the grid reads. const version = useThrottledVersion(store, 250) const highlightCounts = useMemo(() => { void version if (!store) return null return highlights.map((rule) => { const column = byKey.get(rule.column) return column ? countMatching(store, compileCondition(rule.when, column)) : 0 }) }, [store, highlights, byKey, version]) const filterCounts = useMemo(() => { void version if (!store) return null return filters.map((rule) => { const column = byKey.get(rule.column) return column ? countMatching(store, compileCondition(rule, column)) : 0 }) }, [store, filters, byKey, version]) const shown = useMemo(() => { void version if (!store) return null return { n: countMatching(store, compileFilter(filters, columns)), m: store.getIds().length } }, [store, filters, columns, version]) const change = (next: Partial<GridRules>) => onRulesChange({ ...rules, ...next }) const setHighlights = (list: ColumnRule[]) => change({ columns: list }) const setFilters = (list: FilterRule[]) => change({ filter: list }) const setSorts = (list: SortRule[]) => change({ sort: list }) const replaceAt = <X,>(list: readonly X[], index: number, item: X) => list.map((x, i) => (i === index ? item : x)) const removeAt = <X,>(list: readonly X[], index: number) => list.filter((_, i) => i !== index) const onTabKey = (event: KeyboardEvent<HTMLButtonElement>, index: number) => { const delta = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : event.key === "Home" ? -index : event.key === "End" ? tabs.length - 1 - index : 0 if (!delta) return event.preventDefault() const next = tabs[(index + delta + tabs.length) % tabs.length]! setTab(next.id) ;(event.currentTarget.parentElement?.querySelector<HTMLButtonElement>(`[data-rules-tab="${next.id}"]`) ?? null)?.focus() } return ( <div role="region" aria-label={labels.title} data-slot="tradecn-rules-editor" data-tab={shownTab} className={cn("flex flex-col gap-2 text-xs lining-nums tabular-nums", className)}> <div role="tablist" aria-label={labels.title} className="flex flex-wrap items-center gap-1"> {tabs.map((t, index) => ( <Button key={t.id} type="button" role="tab" id={`rules-tab-${t.id}`} aria-selected={shownTab === t.id} aria-controls={`rules-panel-${t.id}`} tabIndex={shownTab === t.id ? 0 : -1} data-rules-tab={t.id} variant={shownTab === t.id ? "secondary" : "ghost"} size="sm" className="h-7 px-2 text-xs" onClick={() => setTab(t.id)} onKeyDown={(event) => onTabKey(event, index)} > {t.label} {t.count !== null && t.count > 0 && <span className={cn("text-muted-foreground", NUMERIC_CLASS)}>{t.count}</span>} </Button> ))} </div> <div role="tabpanel" id={`rules-panel-${shownTab}`} aria-labelledby={`rules-tab-${shownTab}`} className="flex flex-col gap-2"> {shownTab === "highlights" && ( <> <ReorderableList count={highlights.length} labels={labels} empty={labels.noHighlights} onMove={(from, to) => setHighlights(moveItem(highlights, from, to))}> {({ item }) => highlights.map((rule, index) => { const column = byKey.get(rule.column) const name = rule.label?.trim() || `${labels.highlights} ${index + 1}` const update = (next: ColumnRule) => setHighlights(replaceAt(highlights, index, next)) return item( index, rule.id, name, <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5" data-rule-id={rule.id}> <ConditionFields name={name} column={column} columns={columns} columnKey={rule.column} condition={rule.when} labels={labels} onColumn={(key) => update({ ...rule, column: key, when: withColumn(rule.when, byKey.get(key)) })} onCondition={(when) => update({ ...rule, when })} /> <NativeSelect size="sm" value={rule.tone} aria-label={`${labels.tone}: ${name}`} data-rule-field="tone" onChange={(event) => update({ ...rule, tone: event.target.value as RuleTone })}> {RULE_TONES.map((tone) => ( <NativeSelectOption key={tone} value={tone}> {tone} </NativeSelectOption> ))} </NativeSelect> <span className={cn("rounded-sm px-1.5 py-0.5", RULE_TONE_CLASS[rule.tone])} data-rule-swatch={rule.tone} aria-hidden> {rule.tone} </span> <NativeSelect size="sm" value={rule.target ?? "cell"} aria-label={`${labels.paints}: ${name}`} data-rule-field="target" onChange={(event) => update({ ...rule, target: event.target.value === "row" ? "row" : "cell" })}> <NativeSelectOption value="cell">{labels.cell}</NativeSelectOption> <NativeSelectOption value="row">{labels.row}</NativeSelectOption> </NativeSelect> <Input value={rule.label ?? ""} aria-label={`${labels.label}: ${name}`} placeholder={labels.label} spellCheck={false} autoComplete="off" data-rule-field="label" className="h-6 w-36 px-1.5 text-xs md:text-xs" onChange={(event) => update({ ...rule, label: event.target.value })} /> <Count n={highlightCounts ? (highlightCounts[index] ?? 0) : null} template={labels.matches} /> <Problem text={ruleProblem(rule, columns)} /> </div>, () => setHighlights(removeAt(highlights, index)), ) }) } </ReorderableList> <Button type="button" variant="outline" size="sm" className="h-7 self-start px-2 text-xs" onClick={() => setHighlights([...highlights, newHighlight(columns)])}> {labels.addHighlight} </Button> </> )} {shownTab === "filters" && ( <> <ReorderableList count={filters.length} labels={labels} empty={labels.noFilters} onMove={(from, to) => setFilters(moveItem(filters, from, to))}> {({ item }) => filters.map((rule, index) => { const column = byKey.get(rule.column) const name = `${labels.filters} ${index + 1}` const update = (next: FilterRule) => setFilters(replaceAt(filters, index, next)) const { column: key, ...condition } = rule return item( index, index, name, <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5" data-filter-index={index}> <ConditionFields name={name} column={column} columns={columns} columnKey={key} condition={condition} labels={labels} onColumn={(next) => update({ column: next, ...withColumn(condition, byKey.get(next)) })} onCondition={(next) => update({ column: key, ...next })} /> <Count n={filterCounts ? (filterCounts[index] ?? 0) : null} template={labels.matches} /> <Problem text={ruleProblem(rule, columns)} /> </div>, () => setFilters(removeAt(filters, index)), ) }) } </ReorderableList> <div className="flex flex-wrap items-center gap-2"> <Button type="button" variant="outline" size="sm" className="h-7 px-2 text-xs" onClick={() => setFilters([...filters, newFilter(columns)])}> {labels.addFilter} </Button> {shown && ( <span className={cn("text-muted-foreground", NUMERIC_CLASS)} data-rules-shown={shown.n}> {fill(labels.shown, shown)} </span> )} </div> </> )} {shownTab === "sort" && ( <> <ReorderableList count={sorts.length} labels={labels} empty={labels.noSort} onMove={(from, to) => setSorts(moveItem(sorts, from, to))}> {({ item }) => sorts.map((rule, index) => { const name = `${labels.sort} ${index + 1}` const update = (next: SortRule) => setSorts(replaceAt(sorts, index, next)) return item( index, index, name, <div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5" data-sort-index={index}> <span className={cn("w-4 text-right text-muted-foreground", NUMERIC_CLASS)} aria-hidden> {index + 1} </span> <NativeSelect size="sm" value={rule.key} aria-label={`${labels.column}: ${name}`} data-rule-field="column" onChange={(event) => update({ ...rule, key: event.target.value })}> {!byKey.has(rule.key) && <NativeSelectOption value={rule.key}>{rule.key}</NativeSelectOption>} {columns.map((c) => ( <NativeSelectOption key={c.key} value={c.key}> {columnName(c)} </NativeSelectOption> ))} </NativeSelect> <NativeSelect size="sm" value={rule.dir} aria-label={`${labels.direction}: ${name}`} data-rule-field="dir" onChange={(event) => update({ ...rule, dir: event.target.value === "desc" ? "desc" : "asc" })}> <NativeSelectOption value="asc">{labels.asc}</NativeSelectOption> <NativeSelectOption value="desc">{labels.desc}</NativeSelectOption> </NativeSelect> {!byKey.has(rule.key) && <Problem text={`No column is named "${rule.key}".`} />} </div>, () => setSorts(removeAt(sorts, index)), ) }) } </ReorderableList> <Button type="button" variant="outline" size="sm" className="h-7 self-start px-2 text-xs" onClick={() => setSorts([...sorts, newSort(columns, sorts)])}> {labels.addSort} </Button> </> )} {shownTab === "columns" && hasColumns && <ColumnChooserPanel columns={columns} columnState={columnState} onColumnStateChange={onColumnStateChange} rules={highlights} />} </div> {shownTab !== "columns" && <p className="text-muted-foreground">{labels.dragHint}</p>} </div> ) }components/ui/ column-chooser.tsx import { cn } from "cn" import { useMemo, useState, type DragEvent, type KeyboardEvent } from "react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { NUMERIC_CLASS } from "@/lib/format" import { RULE_TONE_CLASS, columnName, describeRule, type ColumnRule } from "@/lib/grid-rules" import { EMPTY_COLUMN_STATE, type ColumnDef, type ColumnState } from "@/components/ui/data-grid" // The surface over one grid's columns: every column with show and hide, reorder by drag or by // keyboard, a width reset where a column was resized, a search box, frozen columns marked, and // reset all. It reads and writes the grid's own `ColumnState` through `columnState` and // `onColumnStateChange` and stores nothing, so what it shows is what the grid shows, and where the // state is kept (a panel's state, a preferences envelope) stays the consumer's. A rule that names a // column is said in words beside it, so a highlight can be read without the color. // // `ColumnChooser` is the dialog, a hotkey wall like any dialog: with focus inside it only its own // scopes run, so a grid's single-key bindings do not fire while a column is being found by typing. // `ColumnChooserPanel` is the same list inline, for a sheet, a tab, or a settings page. export interface ColumnChooserLabels { title: string description: string search: string /** Prefix of a checkbox's name: "Show Price". */ show: string frozen: string /** After a count: "2 hidden". */ hidden: string width: string resetWidth: string moveUp: string moveDown: string resetAll: string empty: string dragHint: string } export const DEFAULT_COLUMN_CHOOSER_LABELS: ColumnChooserLabels = { title: "Columns", description: "Show, hide, reorder, and size the columns of this grid.", search: "Find a column", show: "Show", frozen: "frozen", hidden: "hidden", width: "Width", resetWidth: "Reset width", moveUp: "Move up", moveDown: "Move down", resetAll: "Reset all", empty: "No column matches.", dragHint: "Drag a column, or hold Alt with an arrow key, to reorder. Frozen columns stay first.", } export interface ColumnChooserPanelProps<T> { columns: ColumnDef<T>[] columnState: ColumnState onColumnStateChange: (state: ColumnState) => void /** Rules that name columns, said in words beside the columns they touch. */ rules?: ColumnRule[] labels?: Partial<ColumnChooserLabels> className?: string } export interface ColumnChooserProps<T> extends ColumnChooserPanelProps<T> { open: boolean onOpenChange: (open: boolean) => void } /** One column as the chooser lists it. */ export interface ChooserRow<T> { column: ColumnDef<T> key: string name: string visible: boolean frozen: boolean /** The width in force: the state's, else the column's own. */ width: number /** The state holds a width for it. */ resized: boolean rules: ColumnRule[] } /** * The columns in the order the grid shows them, frozen ones first, hidden ones in their place, with * the width in force and the rules that name each. A column hidden in its definition is not listed: * that is a decision in code, not one for this surface. */ export function chooserRows<T>(columns: ColumnDef<T>[], state: ColumnState, rules: readonly ColumnRule[] = []): ChooserRow<T>[] { const rank = new Map(state.order.map((key, index) => [key, index])) const hidden = new Set(state.hidden) const ordered = columns .filter((column) => !column.hidden) .map((column, index) => ({ column, rank: rank.get(column.key) ?? 1e6 + index })) .sort((a, b) => a.rank - b.rank) .map(({ column }) => column) const frozen = ordered.filter((column) => column.frozen === "left") const rest = ordered.filter((column) => column.frozen !== "left") return [...frozen, ...rest].map((column) => ({ column, key: column.key, name: columnName(column), visible: !hidden.has(column.key), frozen: column.frozen === "left", width: state.widths[column.key] ?? column.width, resized: column.key in state.widths, rules: rules.filter((rule) => rule.column === column.key), })) } /** True when the state changes nothing: no order, no widths, nothing hidden. */ export function isDefaultColumnState(state: ColumnState): boolean { return state.order.length === 0 && Object.keys(state.widths).length === 0 && state.hidden.length === 0 } /** The state with one column shown or hidden. */ export function setColumnVisible(state: ColumnState, key: string, visible: boolean): ColumnState { const hidden = state.hidden.filter((k) => k !== key) return { ...state, hidden: visible ? hidden : [...hidden, key] } } /** The state with one column's width forgotten, so the column's own width is in force again. */ export function resetColumnWidth(state: ColumnState, key: string): ColumnState { if (!(key in state.widths)) return state const widths = { ...state.widths } delete widths[key] return { ...state, widths } } /** * The state with one column moved to another's place, the rest shifting to make room. A column stays * on its own side of the frozen line: the grid leads with frozen columns whatever the order says, so a * move across the line would change nothing and is refused. The full order is written, hidden columns * in their places, so a column shown again comes back where it was. */ export function moveColumnTo<T>(rows: readonly ChooserRow<T>[], state: ColumnState, key: string, targetKey: string): ColumnState { if (key === targetKey) return state const from = rows.findIndex((row) => row.key === key) const to = rows.findIndex((row) => row.key === targetKey) if (from < 0 || to < 0 || rows[from]!.frozen !== rows[to]!.frozen) return state const keys = rows.map((row) => row.key) keys.splice(from, 1) keys.splice(to, 0, key) return { ...state, order: keys } } /** The state with one column moved one place up (-1) or down (1), on its own side of the frozen line. */ export function moveColumnBy<T>(rows: readonly ChooserRow<T>[], state: ColumnState, key: string, delta: -1 | 1): ColumnState { const index = rows.findIndex((row) => row.key === key) const target = rows[index + delta] if (index < 0 || !target) return state return moveColumnTo(rows, state, key, target.key) } interface RowProps<T> { row: ChooserRow<T> canMoveUp: boolean canMoveDown: boolean dragging: boolean labels: ColumnChooserLabels onVisible: (visible: boolean) => void onMove: (delta: -1 | 1) => void onResetWidth: () => void onDragStart: (event: DragEvent<HTMLLIElement>) => void onDragOver: (event: DragEvent<HTMLLIElement>) => void onDrop: (event: DragEvent<HTMLLIElement>) => void onDragEnd: () => void } function Row<T>({ row, canMoveUp, canMoveDown, dragging, labels, onVisible, onMove, onResetWidth, onDragStart, onDragOver, onDrop, onDragEnd }: RowProps<T>) { const onKeyDown = (event: KeyboardEvent<HTMLLIElement>) => { if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return event.preventDefault() event.stopPropagation() onMove(event.key === "ArrowUp" ? -1 : 1) } return ( <li tabIndex={0} draggable data-column={row.key} data-visible={row.visible ? "true" : "false"} data-frozen={row.frozen || undefined} data-dragging={dragging || undefined} aria-label={row.name} className={cn( "group flex items-center gap-2 rounded-sm border border-transparent px-1.5 py-1 outline-none focus-visible:border-ring data-[dragging]:opacity-50", !row.visible && "text-muted-foreground", )} onKeyDown={onKeyDown} onDragStart={onDragStart} onDragOver={onDragOver} onDrop={onDrop} onDragEnd={onDragEnd} > <span aria-hidden className="cursor-grab select-none text-muted-foreground" title={labels.dragHint}> <svg width="8" height="12" viewBox="0 0 8 12" fill="currentColor"> <circle cx="2" cy="2" r="1.2" /> <circle cx="6" cy="2" r="1.2" /> <circle cx="2" cy="6" r="1.2" /> <circle cx="6" cy="6" r="1.2" /> <circle cx="2" cy="10" r="1.2" /> <circle cx="6" cy="10" r="1.2" /> </svg> </span> <Checkbox checked={row.visible} onCheckedChange={() => onVisible(!row.visible)} aria-label={`${labels.show} ${row.name}`} /> <span className="min-w-20 flex-1 truncate font-medium" title={row.name}> {row.name} </span> {row.frozen && ( <Badge variant="outline" className="h-4 px-1.5 text-xs" data-column-frozen> {labels.frozen} </Badge> )} {/* A rule's words give way before the column's name does: the badge shrinks and its text ellipsizes (the badge is a flex box, so the text needs its own span to truncate), the name keeps its minimum. */} {row.rules.map((rule) => ( <Badge key={rule.id} variant="outline" className={cn("h-4 min-w-0 shrink px-1.5 text-xs", RULE_TONE_CLASS[rule.tone])} data-column-rule={rule.id} title={describeRule(rule, [row.column])}> <span className="min-w-0 truncate">{rule.label?.trim() || describeRule(rule, [row.column])}</span> </Badge> ))} <span className={cn("w-14 shrink-0 text-right text-muted-foreground", NUMERIC_CLASS)} aria-label={`${labels.width} ${row.width}`} data-column-width={row.width}> {row.width} px </span> <Button type="button" variant="ghost" size="sm" className={cn("h-6 px-1.5 text-xs", !row.resized && "invisible")} aria-label={`${labels.resetWidth}: ${row.name}`} aria-hidden={!row.resized || undefined} tabIndex={row.resized ? undefined : -1} onClick={onResetWidth}> {labels.resetWidth} </Button> <span className="flex shrink-0 items-center"> <Button type="button" variant="ghost" size="sm" className="h-6 w-6 px-0 text-xs" aria-label={`${labels.moveUp}: ${row.name}`} disabled={!canMoveUp} onClick={() => onMove(-1)}> <span aria-hidden>▲</span> </Button> <Button type="button" variant="ghost" size="sm" className="h-6 w-6 px-0 text-xs" aria-label={`${labels.moveDown}: ${row.name}`} disabled={!canMoveDown} onClick={() => onMove(1)}> <span aria-hidden>▼</span> </Button> </span> </li> ) } /** The list inline: for a sheet, a tab, or a settings page of your own. */ export function ColumnChooserPanel<T>({ columns, columnState, onColumnStateChange, rules, labels: labelsProp, className }: ColumnChooserPanelProps<T>) { const labels = { ...DEFAULT_COLUMN_CHOOSER_LABELS, ...labelsProp } const rows = useMemo(() => chooserRows(columns, columnState, rules), [columns, columnState, rules]) const [query, setQuery] = useState("") const [dragging, setDragging] = useState<string | null>(null) const q = query.trim().toLowerCase() const shown = q ? rows.filter((row) => row.name.toLowerCase().includes(q) || row.key.toLowerCase().includes(q)) : rows const hiddenCount = rows.filter((row) => !row.visible).length const sideOf = (key: string) => rows.find((row) => row.key === key)?.frozen const change = (next: ColumnState) => { if (next !== columnState) onColumnStateChange(next) } return ( <div role="group" aria-label={labels.title} data-slot="tradecn-column-chooser" data-hidden={hiddenCount} className={cn("flex flex-col gap-2 text-xs lining-nums tabular-nums", className)}> <div className="flex flex-wrap items-center gap-2"> <Input value={query} aria-label={labels.search} placeholder={labels.search} spellCheck={false} autoComplete="off" className="h-7 max-w-56 text-xs md:text-xs" onChange={(event) => setQuery(event.target.value)} /> <span className={cn("text-muted-foreground", NUMERIC_CLASS)} data-column-hidden-count={hiddenCount}> {hiddenCount} {labels.hidden} </span> <Button type="button" variant="outline" size="sm" className="ml-auto h-7 px-2 text-xs" disabled={isDefaultColumnState(columnState)} onClick={() => change(EMPTY_COLUMN_STATE)}> {labels.resetAll} </Button> </div> {shown.length === 0 ? ( <p className="text-muted-foreground">{labels.empty}</p> ) : ( <ul className="flex flex-col" aria-label={labels.title}> {shown.map((row) => { const index = rows.indexOf(row) const up = rows[index - 1] const down = rows[index + 1] return ( <Row key={row.key} row={row} canMoveUp={up !== undefined && up.frozen === row.frozen} canMoveDown={down !== undefined && down.frozen === row.frozen} dragging={dragging === row.key} labels={labels} onVisible={(visible) => change(setColumnVisible(columnState, row.key, visible))} onMove={(delta) => change(moveColumnBy(rows, columnState, row.key, delta))} onResetWidth={() => change(resetColumnWidth(columnState, row.key))} onDragStart={(event) => { event.dataTransfer?.setData?.("text/plain", row.key) if (event.dataTransfer) event.dataTransfer.effectAllowed = "move" setDragging(row.key) }} onDragOver={(event) => { const from = dragging if (from === null || from === row.key || sideOf(from) !== row.frozen) return event.preventDefault() if (event.dataTransfer) event.dataTransfer.dropEffect = "move" }} onDrop={(event) => { event.preventDefault() const from = dragging ?? event.dataTransfer?.getData?.("text/plain") ?? null setDragging(null) if (from) change(moveColumnTo(rows, columnState, from, row.key)) }} onDragEnd={() => setDragging(null)} /> ) })} </ul> )} <p className="text-muted-foreground">{labels.dragHint}</p> </div> ) } /** The dialog: the panel under a title, over the page, a hotkey wall while it is open. */ export function ColumnChooser<T>({ open, onOpenChange, labels: labelsProp, className, ...panel }: ColumnChooserProps<T>) { const labels = { ...DEFAULT_COLUMN_CHOOSER_LABELS, ...labelsProp } return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className={cn("sm:max-w-lg", className)}> <DialogHeader> <DialogTitle>{labels.title}</DialogTitle> <DialogDescription>{labels.description}</DialogDescription> </DialogHeader> <ColumnChooserPanel {...panel} labels={labels} /> </DialogContent> </Dialog> ) }components/ui/ data-grid.tsx import { useVirtualizer } from "@tanstack/react-virtual" import { cn } from "cn" import { memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type MouseEvent, type PointerEvent, type ReactNode, 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, compareDirected, compareValues, compileComparator, compileFilter, type AppliedRules, type GridRules, 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 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 a column of fraction quotes whose ticks must line up. */ 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 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. */ validate?: (value: unknown, row: T) => 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 } | { kind: "pending"; value: unknown; text: string } | { 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 commit: (value: unknown) => 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: [] } 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 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). */ renderContextMenu?: (rows: T[], ids: RowId[]) => ReactNode rowEnter?: Partial<RowEnterBehavior> announceRowCount?: "off" | "debounced" /** Accessible name for the grid. */ label: string emptyState?: ReactNode getRowProps?: (row: T, id: RowId) => RowDecoration | undefined /** * 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?: GridRules /** * 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 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 visible columns, RFC 4180 quoting. */ export function exportCsv<T>(store: RowStore<T>, columns: ColumnDef<T>[], ids: readonly RowId[]): string { const cols = columns.filter((c) => !c.hidden) const esc = (s: string) => (/[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s) const header = cols.map((c) => esc(typeof c.header === "string" ? c.header : c.key)).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) return esc(c.format ? c.format(v, row) : v === null || v === undefined ? "" : String(v)) }) .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 opening, typing in, or settling one cell re-renders that cell and nothing else. 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 } function createEditTracker(): EditTracker { const statuses = new Map<string, EditStatus>() const listeners = new Map<string, Set<() => void>>() let open: string | null = null return { get: (key) => statuses.get(key), set(key, status) { 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() }, 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, } } interface EditController { tracker: EditTracker 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, move?: 1 | -1): void /** A value a cell renderer settled itself (a checkbox): sent as it is. */ commitValue(rowId: RowId, key: string, value: unknown): 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 } 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) } function messageOf(error: unknown): string { if (error instanceof Error) return error.message if (typeof error === "string") return error return "Rejected" } 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)]" 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 colIndex: number left: number | undefined memory: FlashMemory flashVariant: "fill" | "ring" flashWindowMs: number focusedCol: 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 /** The grid's editing, or null when it has no `onEdit`. */ edits: EditController | null } 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 ref = useRef<HTMLInputElement>(null) const selectAll = status.selectAll useEffect(() => { const el = ref.current if (!el) return el.focus({ preventScroll: true }) if (selectAll) el.select() else el.setSelectionRange(el.value.length, el.value.length) // On mount only: a keystroke must not reselect the text under the hand. // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { const mod = e.metaKey || e.ctrlKey || e.altKey switch (e.key) { case "Enter": e.preventDefault() edits.commit(rowId, colKey) return case "Escape": e.preventDefault() edits.cancel(rowId, colKey) return case "Tab": e.preventDefault() edits.commit(rowId, colKey, 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-description={status.problem ?? undefined} title={status.problem ?? undefined} value={status.text} autoComplete="off" spellCheck={false} inputMode={numeric ? "decimal" : undefined} className={cn("h-full w-full min-w-0 bg-background px-2 text-inherit outline-none ring-1 ring-inset ring-ring aria-invalid:ring-destructive", numeric && "text-right", className)} onChange={(e) => edits.type(rowId, colKey, e.target.value)} onKeyDown={onKeyDown} onBlur={() => edits.blur(rowId, colKey)} /> ) } function Cell<T>({ col, row, rowId, colIndex, left, memory, flashVariant, flashWindowMs, focusedCol, rules, rowRule, edits }: 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. const settled = status?.kind === "pending" && Object.is(status.value, value) useEffect(() => { if (settled) edits?.settle(rowId, col.key) }, [settled, edits, rowId, col.key]) const handle = useMemo<CellEditHandle | undefined>( () => (editable ? { status, commit: (next) => edits!.commitValue(rowId, col.key, next), 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 = typeof col.header === "string" ? col.header : col.key 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} role="gridcell" aria-colindex={colIndex + 1} aria-description={rejected ?? rule?.["aria-description"]} 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 truncate", !editing && "px-2", alignClass(col), col.numeric && cn("justify-end", numericClass), col.align === "center" && "justify-center", flash === "ring" ? RING_CLASSES : flash === "fill" ? FILL_CLASSES : undefined, left !== undefined && "sticky z-10 bg-background", focusedCol && "bg-muted/50", status?.kind === "pending" && "text-muted-foreground italic", rejected !== null && "text-destructive", rule?.className ?? (left !== undefined ? rowRule?.className : undefined), )} 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 columns: Resolved<T>[] template: string lefts: (number | undefined)[] width: number height: number start: number selected: boolean focused: boolean focusedColKey: string | null selectionColumn: boolean onToggle: (id: RowId) => void memory: FlashMemory flashVariant: "fill" | "ring" flashWindowMs: number entered: Set<RowId> highlightEnter: boolean getRowProps?: (row: T, id: RowId) => RowDecoration | undefined rules: AppliedRules<T> | null edits: EditController | null } function RowInner<T>(p: RowProps<T>) { const row = useRow(p.store, p.id) const ref = useRef<HTMLDivElement>(null) useLayoutEffect(() => { if (p.highlightEnter && p.entered.has(p.id) && ref.current) { p.entered.delete(p.id) playFlash(ref.current, "flat", { windowMs: ENTER_WINDOW_MS, variant: "fill" }) } // 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) 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"]} aria-description={extra?.["aria-description"] ?? rule?.["aria-description"]} data-focused={p.focused || undefined} aria-rowindex={p.index + 2} aria-selected={p.selected || undefined} className={cn( "absolute top-0 left-0 grid items-stretch border-b border-border/60", FILL_CLASSES, p.selected && "bg-accent", p.focused && "outline-1 -outline-offset-1 outline-ring", 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="sticky left-0 z-10 flex items-center justify-center bg-background"> <Checkbox checked={p.selected} onCheckedChange={() => p.onToggle(p.id)} aria-label="Select row" /> </div> )} {p.columns.map((col, i) => ( <Cell key={col.key} col={col} row={row} rowId={p.id} colIndex={i + (p.selectionColumn ? 1 : 0)} left={p.lefts[i]} memory={p.memory} flashVariant={p.flashVariant} flashWindowMs={p.flashWindowMs} focusedCol={p.focusedColKey === col.key} rules={p.rules} rowRule={rule} edits={p.edits} /> ))} </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, onRowActivate, renderContextMenu, footer, onEdit, initialRect, overscan = 8, } = 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 const flashWindowMs = props.flashWindowMs ?? 900 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, EMPTY_COLUMN_STATE) 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]) const resolved = useMemo(() => resolveColumns(columns, columnState), [columns, columnState]) 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) // 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 }) useEffect(() => { editLatest.current = { columns, resolved, onEdit } }) const editable = Boolean(onEdit) const edits = useMemo<EditController | null>(() => { if (!editable) return null const tracker = createEditTracker() const column = (key: string) => editLatest.current.columns.find((c) => c.key === key) const focusGrid = () => rootRef.current?.focus({ preventScroll: true }) 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) }) 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) }) return } if (result && typeof (result as Promise<unknown>).then === "function") { ;(result as Promise<unknown>).then( () => { const now = tracker.get(k) if (now?.kind === "pending" && Object.is(now.value, value)) tracker.set(k, 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) }) }, ) } } 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) { const col = column(key) const row = store.getRow(rowId) if (!canEditCell(col, row) || col.edit.toggle) return const k = cellKey(rowId, key) const before = tracker.editing() if (before && before !== k) tracker.set(before, 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) const text = now?.kind === "pending" ? now.text : editText(col, col.accessor(row!), row!) tracker.set(k, { kind: "editing", text: typed ?? text, problem: null, selectAll: typed === undefined }) } const controller: EditController = { tracker, open, 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, move) { const k = cellKey(rowId, key) const now = tracker.get(k) if (now?.kind !== "editing") return const col = column(key) const row = store.getRow(rowId) if (!canEditCell(col, row)) { tracker.set(k, undefined) return focusGrid() } const parsed = col.edit.parse(now.text, row!) const problem = isEditProblem(parsed) ? parsed : col.edit.validate?.(parsed, row!) if (problem) { tracker.set(k, { ...now, problem: problem.problem }) return } send(rowId, col, row!, parsed) moveOn(rowId, key, move) }, commitValue(rowId, key, value) { const col = column(key) const row = store.getRow(rowId) if (!canEditCell(col, row)) return const problem = col.edit.validate?.(value, row!) 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) if (tracker.get(k)?.kind === "editing") tracker.set(k, undefined) 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) const now = tracker.get(k) if (now?.kind !== "editing") return const col = column(key) const row = store.getRow(rowId) if (!canEditCell(col, row)) return tracker.set(k, undefined) const parsed = col.edit.parse(now.text, row!) if (isEditProblem(parsed) || col.edit.validate?.(parsed, row!)) return tracker.set(k, undefined) send(rowId, col, row!, parsed) }, settle(rowId, key) { const k = cellKey(rowId, key) if (tracker.get(k)?.kind === "pending") tracker.set(k, undefined) }, } return controller }, [editable, store]) const virtualizer = useVirtualizer({ count: ids.length, getScrollElement: () => scrollRef.current, estimateSize: () => rowHeight, overscan, getItemKey: (i) => ids[i]!, initialRect, }) const items = virtualizer.getVirtualItems() const uid = useId() const domId = (id: RowId) => `${uid}-${id}` // 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) const [anchor, setAnchor] = useState(0) const behind = followTail && !following ? Math.max(0, ids.length - anchor) : 0 useLayoutEffect(() => { if (followTail && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight }, [followTail]) // Rows arriving: highlight, pin the viewport or follow the tail, announce. Rows leaving: forget their flashes. const entered = useRef(new Set<RowId>()).current const prevIdsRef = useRef<readonly RowId[]>(ids) const newSinceAnnounce = useRef(0) useLayoutEffect(() => { const prev = prevIdsRef.current if (prev === ids) return const prevSet = new Set(prev) const nowSet = new Set(ids) const arrived: RowId[] = [] for (const id of ids) if (!prevSet.has(id)) arrived.push(id) let departed = 0 for (const id of prev) { if (!nowSet.has(id)) { departed++ memory.forget(`${id}\u0000`) } } if ((arrived.length || departed) && 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 (arrived.length && followTail && following && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight if (arrived.length && rowEnter.highlight) for (const id of arrived) entered.add(id) newSinceAnnounce.current += arrived.length prevIdsRef.current = ids }, [ids, indexOf, rowHeight, rowEnter.pinViewport, rowEnter.highlight, followTail, following, entered, memory]) const stopFollowing = () => { if (!followTail || !following) return setFollowing(false) setAnchor(ids.length) } const toTail = () => { 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 setAnnouncement(`${ids.length.toLocaleString()} rows${fresh ? `, ${fresh.toLocaleString()} new` : ""}`) }, 1000) return () => clearTimeout(t) }, [ids.length, announceRowCount]) // 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 const a = indexOf.get(from) ?? 0 const b = indexOf.get(to) ?? 0 for (let i = Math.min(a, b); i <= Math.max(a, b); i++) next.add(ids[i]!) } setSelection(next) }, [ids, indexOf, selection, selectionMode, setSelection], ) 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 updateColumns((s) => ({ ...s, widths: { ...s.widths, [key]: Math.max(col.minWidth, Math.round(width)) } })) }, [resolved, updateColumns], ) const hideColumn = useCallback((key: string) => updateColumns((s) => ({ ...s, hidden: [...new Set([...s.hidden, key])] })), [updateColumns]) const resetColumns = useCallback(() => setColumnState(EMPTY_COLUMN_STATE), [setColumnState]) const visibleCount = Math.max(1, Math.floor((scrollRef.current?.clientHeight ?? initialRect?.height ?? rowHeight * 10) / rowHeight)) // 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>) => { // The editor owns its keys; what it lets through (a modifier-held arrow) is for the listeners above the grid. if ((e.target as HTMLElement).closest?.("[data-cell-editor]")) return view.touch() stopFollowing() 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 // Typing on an editable cell opens its editor with the character typed. if (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 } } switch (e.key) { case "ArrowDown": e.preventDefault() focusIndex(fi + 1, e.shiftKey) return case "ArrowUp": e.preventDefault() focusIndex(fi <= 0 ? 0 : fi - 1, e.shiftKey) return case "PageDown": e.preventDefault() focusIndex(fi + visibleCount, e.shiftKey) return case "PageUp": e.preventDefault() focusIndex(fi - visibleCount, e.shiftKey) return case "Home": e.preventDefault() focusIndex(0, e.shiftKey) return case "End": e.preventDefault() focusIndex(ids.length - 1, e.shiftKey) return case "ArrowLeft": case "ArrowRight": { e.preventDefault() const delta = e.key === "ArrowLeft" ? -1 : 1 if (e.altKey && focusedColKey) { if (e.shiftKey) resizeColumn(focusedColKey, (resolved[ci]?.width ?? 0) + delta * 8) else moveColumn(focusedColKey, 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) return } case " ": { e.preventDefault() const col = editableFocus() if (col?.edit.toggle) { const row = store.getRow(focusedRowId!)! edits!.commitValue(focusedRowId!, col.key, col.edit.toggle(col.accessor(row), row)) return } if (focusedRowId !== null) select([focusedRowId], selectionMode === "multi" ? "toggle" : "replace") return } case "Enter": case "F2": { const col = editableFocus() if (col) { e.preventDefault() const row = store.getRow(focusedRowId!)! if (col.edit.toggle) edits!.commitValue(focusedRowId!, col.key, col.edit.toggle(col.accessor(row), row)) else edits!.open(focusedRowId!, col.key) return } if (e.key === "F2") return if (focusedRowId !== null) { const row = store.getRow(focusedRowId) if (row !== undefined) onRowActivate?.(row, focusedRowId) } return } case "Escape": setSelection(EMPTY_SET) return case "a": case "A": if (mod && selectionMode === "multi") { e.preventDefault() setSelection(new Set(ids)) } return case "s": case "S": if (e.altKey && focusedColKey) { e.preventDefault() cycleSort(focusedColKey) } return case "h": case "H": if (e.altKey && focusedColKey) { e.preventDefault() hideColumn(focusedColKey) } return case "ContextMenu": case "F10": if (e.key === "F10" && !e.shiftKey) return e.preventDefault() openContextMenuAtFocus() return } } const openContextMenuAtFocus = () => { if (focusedRowId === null) return const el = scrollRef.current?.querySelector<HTMLElement>(`[data-row-id="${cssEscape(focusedRowId)}"]`) if (!el) return const r = el.getBoundingClientRect() el.dispatchEvent(new window.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: r.left + 8, clientY: r.top + r.height / 2, button: 2 })) } // Right-click targets the row under the pointer: focus it, and make it the selection unless it is already selected. const onContextMenuCapture = (e: MouseEvent<HTMLDivElement>) => { const rowEl = (e.target as HTMLElement).closest<HTMLElement>("[data-row-id]") const id = rowEl?.dataset.rowId if (!id) return setFocusedRowId(id) if (!selection.has(id)) select([id], "replace") } const onRowPointerDown = (e: PointerEvent<HTMLDivElement>) => { view.touch() stopFollowing() const rowEl = (e.target as HTMLElement).closest<HTMLElement>("[data-row-id]") const id = rowEl?.dataset.rowId if (!id) return if ((e.target as HTMLElement).closest('[data-slot="checkbox"]')) return 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>) => { const target = e.target as HTMLElement const id = target.closest<HTMLElement>("[data-row-id]")?.dataset.rowId const row = id !== undefined ? store.getRow(id) : undefined if (id === undefined || row === undefined) return const key = target.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]) const contextRows = useMemo(() => { const targets = selection.size ? [...selection] : focusedRowId !== null ? [focusedRowId] : [] return { ids: targets, rows: targets.map((id) => store.getRow(id)).filter((r): r is T => r !== undefined) } }, [selection, focusedRowId, store]) const body = ( <div ref={scrollRef} className="relative h-full min-h-0 flex-1 overflow-auto" onPointerDownCapture={onRowPointerDown} onDoubleClick={onRowDoubleClick} onContextMenuCapture={renderContextMenu ? onContextMenuCapture : 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} className="sticky left-0 z-30 bg-background" />} {resolved.map((col, i) => ( <HeaderCell key={col.key} 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} canMoveRight={i < resolved.length - 1} onSort={() => cycleSort(col.key)} onFocus={() => setFocusedColKey(col.key)} onHide={() => hideColumn(col.key)} onMove={(d) => moveColumn(col.key, d)} onReset={resetColumns} onResize={(w) => resizeColumn(col.key, w)} hiddenCount={columnState.hidden.length} /> ))} </div> {ids.length === 0 ? ( <div className="p-4 text-muted-foreground">{emptyState ?? "No rows"}</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)} columns={resolved} template={template} lefts={lefts} width={totalWidth} height={rowHeight} start={v.start} selected={selection.has(id)} focused={focusedRowId === id} focusedColKey={focusedColKey} selectionColumn={selectionColumn} onToggle={toggle} memory={memory} flashVariant={preset.flash} flashWindowMs={flashWindowMs} entered={entered} highlightEnter={rowEnter.highlight} getRowProps={getRowProps} 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} onKeyDown={onKeyDown} 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-ring/40 lining-nums tabular-nums", preset.fontClass, className)} style={{ lineHeight: `${rowHeight}px` } as CSSProperties} > {renderContextMenu ? ( <ContextMenu> <ContextMenuTrigger className="contents">{body}</ContextMenuTrigger> <ContextMenuContent>{renderContextMenu(contextRows.rows, contextRows.ids)}</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 }} > {behind.toLocaleString()} new </button> )} {announceRowCount !== "off" && ( <div aria-live="polite" className="sr-only"> {announcement} </div> )} </div> ) } interface HeaderCellProps<T> { col: Resolved<T> colIndex: number left: number | undefined sort: "asc" | "desc" | null focused: boolean canMoveLeft: boolean canMoveRight: boolean hiddenCount: number 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 } = p const startResize = (e: PointerEvent<HTMLDivElement>) => { e.preventDefault() e.stopPropagation() const startX = e.clientX const startW = col.width const move = (ev: globalThis.PointerEvent) => p.onResize(startW + ev.clientX - startX) const up = () => { window.removeEventListener("pointermove", move) window.removeEventListener("pointerup", up) } window.addEventListener("pointermove", move) window.addEventListener("pointerup", up) } const title = typeof col.header === "string" ? col.header : undefined return ( <div role="columnheader" 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={title} 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 aria-label={`${title ?? col.key} column menu`} 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 align="end"> {col.sortable && <DropdownMenuItem onClick={p.onSort}>{p.sort === "asc" ? "Sort descending" : p.sort === "desc" ? "Clear sort" : "Sort ascending"}</DropdownMenuItem>} <DropdownMenuItem disabled={!p.canMoveLeft} onClick={() => p.onMove(-1)}> Move left </DropdownMenuItem> <DropdownMenuItem disabled={!p.canMoveRight} onClick={() => p.onMove(1)}> Move right </DropdownMenuItem> <DropdownMenuItem onClick={p.onHide}>Hide column</DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem onClick={p.onReset}>Reset columns{p.hiddenCount ? ` (${p.hiddenCount} hidden)` : ""}</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> <div role="separator" aria-orientation="vertical" aria-label={`Resize ${title ?? col.key}`} onPointerDown={startResize} className="absolute top-0 right-0 h-full w-1.5 cursor-col-resize hover:bg-ring/40" /> </div> ) } function cssEscape(s: string): string { return typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(s) : s.replace(/["\\]/g, "\\$&") }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, useEffect, useRef, 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 this component owns: made for the options given, disposed on the way out, and made again for * new options. Keep the options object's identity stable (a `useMemo`), since a new object is a new view. * Null options give null, for a component that takes a view from its props instead. * * Why a hook and not a memo with a dispose effect: React's StrictMode mounts, unmounts, and mounts a * component again in development, and the cleanup of that rehearsal disposes the memo's view while the * memo keeps handing it out, so the grid stopped following its store until something remade the view. * The effect here notices a disposed view and makes another. */ export function useView<T>(store: RowStore<T>, options: ViewOptions<T> | null): RowView<T> | null { const [view, setView] = useState<RowView<T> | null>(() => (options ? store.createView(options) : null)) const made = useRef({ store, options, view }) useEffect(() => { let current = made.current.view if (made.current.store !== store || made.current.options !== options || (current !== null && current.isDisposed())) { current = options ? store.createView(options) : null made.current = { store, options, view: current } // A view the rehearsal disposed, or new options: the replacement is state so the render that reads it re-runs. setView(current) } return () => current?.dispose() }, [store, options]) 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. 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 once per applied batch. */ 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 dispose(): void /** True after `dispose`: the view no longer follows the store. */ isDisposed(): boolean } 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 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[] = [] let idsSnapshot: readonly RowId[] = ids 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) 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, createView(opts = {}) { const view = new ViewImpl<T>(store, opts, () => views.delete(view)) views.add(view) return view }, clear() { const all = ids rows.clear() ids = [] idsSnapshot = ids meta = { ...meta, version: meta.version + 1, size: 0, lastBatchAt: now() } for (const view of views) view.onBatch(new Set(all), new Set(all), true) 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.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 RowView<T> { private ids: readonly RowId[] = [] private readonly listeners = new Set<Listener>() private holdUntil = 0 private timer: ReturnType<typeof setTimeout> | null = null private disposed = false private readonly now: () => number readonly store: RowStore<T> private readonly opts: ViewOptions<T> private readonly onDispose: () => void constructor(store: RowStore<T>, opts: ViewOptions<T>, onDispose: () => void) { this.store = store this.opts = opts this.onDispose = onDispose this.now = opts.now ?? Date.now this.ids = this.compute() } getIds() { return this.ids } subscribe(cb: Listener) { this.listeners.add(cb) return () => { this.listeners.delete(cb) } } isHeld() { return this.now() < this.holdUntil } touch() { const hold = this.opts.reorderHoldMs ?? 0 if (hold <= 0) return this.holdUntil = this.now() + hold if (this.timer) clearTimeout(this.timer) // A quiet feed still settles: recompute when the hold lapses. this.timer = setTimeout(() => { this.timer = null this.set(this.compute()) }, hold) } dispose() { if (this.disposed) return this.disposed = true if (this.timer) clearTimeout(this.timer) this.listeners.clear() this.onDispose() } isDisposed() { return this.disposed } /** Called by the store inside applyDeltas, before row listeners fire. */ onBatch(touched: Set<RowId>, removed: Set<RowId>, orderChanged: boolean) { if (!touched.size && !orderChanged) return if (this.isHeld()) { // Frozen order: keep what is there, drop removals and rows that no longer pass the filter, append 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) const next = kept.length === this.ids.length && !newcomers.length ? this.ids : [...kept, ...newcomers] this.set(next) 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.set(this.compute()) } 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 set(next: readonly RowId[]) { if (sameOrder(this.ids, next)) return this.ids = next for (const cb of this.listeners) cb() } } export interface FrameBatcher<T> { /** Queue a delta; the merged batch applies on the next animation frame. */ 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 function reset() { upserts.clear() patches.clear() removes.clear() order = undefined meta = undefined } function flush() { if (handle !== null) { caf(handle) handle = 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) { meta = { ...meta, ...delta.meta, dropped: (meta?.dropped ?? 0) + (delta.meta.dropped ?? 0), gap: Boolean(meta?.gap) || Boolean(delta.meta.gap), } } if (handle === null) { handle = raf(() => { handle = null flush() }) } }, flush, cancel() { if (handle !== null) caf(handle) handle = null reset() }, pending: () => handle !== 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 } 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>() 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) 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) } } /** * 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 = s.trim().replace(/−/g, "-").replace(/,/g, "") 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 return sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null if (c.kind === "tick") return roundToTick(n, c.tick) if (c.kind === "decimal") return Number(n.toFixed(c.decimals)) return n } /** 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. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero" }).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, whose dash, ticks, and tail must line up down a column. */ 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 * 99-16+ over 99-17 keeps its dash and its tail in the same place; 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" } /** 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 = s.trim().replace(/−/g, "-").replace(/,/g, "") if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) return Number.isFinite(n) ? roundToTick(n, quoteStepOf(c)) : 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+". 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. 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 tints the background; the token itself colors the text. */ 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[] } /** 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; the key otherwise. */ header?: unknown 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 } 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", } export const RULE_TONES: readonly RuleTone[] = ["up", "down", "flat", "stale", "expiring", "primary", "destructive"] // The text takes the token and the background a tint of its 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, and a selected row's `bg-accent` shows through. Literal strings, so Tailwind finds them. export const RULE_TONE_CLASS: Record<RuleTone, string> = { up: "text-up bg-[linear-gradient(var(--up-soft),var(--up-soft))]", down: "text-down bg-[linear-gradient(var(--down-soft),var(--down-soft))]", flat: "text-flat bg-[linear-gradient(var(--flat-soft),var(--flat-soft))]", stale: "text-stale bg-[linear-gradient(var(--stale-soft),var(--stale-soft))]", expiring: "text-expiring bg-[linear-gradient(var(--expiring-soft),var(--expiring-soft))]", primary: "text-primary bg-[linear-gradient(color-mix(in_oklab,var(--primary)_12%,transparent),color-mix(in_oklab,var(--primary)_12%,transparent))]", destructive: "text-destructive 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 "data-tone": RuleTone "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 header when that is a string, else its key. */ export function columnName<T>(column: RuleColumn<T> | undefined, key?: string): string { if (column && typeof column.header === "string" && column.header.trim()) return column.header return column?.key ?? 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 * `Number` for a numeric column, 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 !== "string") return normalizeValue(raw) if (column?.parse) return normalizeValue(column.parse(raw)) if (column?.numeric) { const text = raw.trim().replace(/−/g, "-").replace(/,/g, "") return text === "" ? 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"]) /** * 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`. */ export function compileCondition<T>(condition: RuleCondition, column: RuleColumn<T>): (row: T) => boolean { const { op } = condition const want = readRuleValue(column, condition.value) const list = (condition.values ?? []).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): RuleColumn<T> | undefined { return 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. */ export function ruleProblem<T>(rule: { column: string; when?: RuleCondition } & Partial<RuleCondition>, columns: readonly RuleColumn<T>[]): string | null { const column = findColumn(columns, rule.column) if (!column) return `No column is named "${rule.column}".` const condition: RuleCondition = rule.when ?? { op: rule.op!, value: rule.value, values: rule.values } 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 `${RULE_OP_LABELS[condition.op]} needs a value.` if (unreadable(condition.value)) return `"${condition.value}" is not a value ${name} reads.` } if (condition.op === "between") { const values = condition.values ?? [] if (values.length !== 2) return "between needs a low value and a high value." for (const raw of values) if (unreadable(raw) || raw === "" || raw === null) return `"${raw ?? ""}" is not a value ${name} reads.` } if (condition.op === "in") { const values = (condition.values ?? []).filter((raw) => raw !== null && raw !== "") if (!values.length) return "one of needs at least one value." for (const raw of values) if (unreadable(raw)) return `"${raw}" is not a value ${name} reads.` } return null } /** The rule in words: "Price above 99-16+", "Client one of ALPHA, BETA", "Status is empty". */ export function describeRule<T>(rule: { column: string; when?: RuleCondition } & Partial<RuleCondition>, columns: readonly RuleColumn<T>[]): string { const column = findColumn(columns, rule.column) const condition: RuleCondition = rule.when ?? { op: rule.op!, value: rule.value, values: rule.values } const name = columnName(column, rule.column) const word = RULE_OP_LABELS[condition.op] const text = (raw: RuleValue | undefined) => (raw === undefined || raw === null ? "" : String(raw)) if (condition.op === "isNull" || condition.op === "notNull") return `${name} ${word}` if (condition.op === "between") { const [lo, hi] = condition.values ?? [] return `${name} ${word} ${text(lo)} and ${text(hi)}` } if (condition.op === "in") return `${name} ${word} ${(condition.values ?? []).map(text).join(", ")}` return `${name} ${word} ${text(condition.value)}` } /** Every rule has to hold. A rule on a column the grid does not have is skipped, so a stale rule hides nothing. */ export function compileFilter<T>(rules: readonly FilterRule[], columns: readonly RuleColumn<T>[]): (row: T) => boolean { const tests: ((row: T) => boolean)[] = [] for (const rule of rules) { const column = findColumn(columns, rule.column) if (column) 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 SortRule[], columns: readonly RuleColumn<T>[]): ((a: T, b: T) => number) | undefined { const keys: { accessor: (row: T) => unknown; dir: "asc" | "desc" }[] = [] for (const rule of rules) { const column = findColumn(columns, rule.key) if (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>(rule: ColumnRule, columns: readonly RuleColumn<T>[]): RuleDecoration { return { "data-rule": rule.id, "data-tone": rule.tone, "aria-description": rule.label?.trim() || describeRule(rule, columns), className: 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 ColumnRule[]> } /** * 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 ColumnRule[], 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, ColumnRule[]>() for (const rule of rules) { const column = findColumn(columns, rule.column) if (!column) continue const compiled = { test: compileCondition(rule.when, column), decoration: ruleDecoration(rule, columns) } byColumn.set(rule.column, [...(byColumn.get(rule.column) ?? []), rule]) if (rule.target === "row") rows.push(compiled) else cells.set(rule.column, [...(cells.get(rule.column) ?? []), compiled]) } return { cell(columnKey, row) { const list = cells.get(columnKey) if (!list) return undefined for (const { test, decoration } of list) if (test(row)) return decoration return undefined }, getRowProps(row) { for (const { test, decoration } of rows) if (test(row)) return decoration return undefined }, byColumn, } } -
Add the tokens to your stylesheet:
:root { --down: oklch(0.46 0.19 40); --down-soft: oklch(0.46 0.19 40 / 18%); --expiring: oklch(0.53 0.2 350); --expiring-soft: oklch(0.53 0.2 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.55 0 0 / 14%); --stale: oklch(0.52 0.13 75); --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: oklch(0.74 0.17 350); --expiring-soft: oklch(0.74 0.17 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.7 0 0 / 14%); --stale: oklch(0.8 0.15 80); --stale-soft: oklch(0.8 0.15 80 / 16%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --up: oklch(0.74 0.15 165); --up-soft: oklch(0.74 0.15 165 / 18%); } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { useState } from "react"
import { createInstrumentFormatter, formatNotional } from "@/lib/format"
import type { GridRules } from "@/lib/grid-rules"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef, type ColumnState } from "@/components/ui/data-grid"
import { RulesEditor } from "@/components/ui/rules-editor"
interface Rfq {
id: string
client: string
size: number
px: number | null
}
const ust = createInstrumentFormatter({ price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 })
const columns: ColumnDef<Rfq>[] = [
{ key: "client", header: "Client", width: 180, sortable: true, accessor: (r) => r.client },
{ key: "size", header: "Size", width: 160, numeric: true, sortable: true, accessor: (r) => r.size, format: (v) => formatNotional(v as number, { unit: "mm" }) },
{ key: "px", header: "Price", width: 170, numeric: true, font: "mono", sortable: true, accessor: (r) => r.px, format: (v) => ust.price(v as number | null), parse: ust.parsePrice },
]
const rows: Rfq[] = [
{ id: "Q-1", client: "ALPHA", size: 5_000_000, px: 99.5 },
{ id: "Q-2", client: "BETA", size: 25_000_000, px: 100.015625 },
{ id: "Q-3", client: "GAMMA", size: 10_000_000, px: null },
]
// The editor and the grid share one rules object and one column state. Edit a rule and the grid follows the keystroke.
function QuoteRules() {
const [store] = useState(() => {
const s = createRowStore<Rfq>({ getRowId: (r) => r.id })
s.applyDeltas({ upsert: rows })
return s
})
const [rules, setRules] = useState<GridRules>({
columns: [{ id: "threshold", column: "px", when: { op: "gte", value: "100-00" }, tone: "primary", label: "Price threshold" }],
filter: [],
sort: [],
})
const [columnState, setColumnState] = useState<ColumnState>({ order: [], widths: {}, hidden: [] })
return (
<div className="w-lg max-w-full space-y-3">
<RulesEditor className="overflow-x-auto" columns={columns} rules={rules} onRulesChange={setRules} store={store} columnState={columnState} onColumnStateChange={setColumnState} />
<div className="h-48">
<DataGrid store={store} columns={columns} preset="watchlist" label="Quotes with rules" rules={rules} columnState={columnState} onColumnStateChange={setColumnState} />
</div>
</div>
)
}
The editor and grid share one controlled rules object. Change the price threshold from 100-00 to 99-00: the match count grows from one to two, and both price cells highlight. The blank price stays unmatched. Filters and Sort start empty; add rules in those tabs to narrow or reorder the same three rows.
Composition
The first three tabs edit GridRules: Highlights edits columns, Filters edits filter, and Sort edits sort. Columns embeds ColumnChooserPanel when both columnState and onColumnStateChange are supplied. The example shares that state with the grid too; omit both props from the editor when you do not need the Columns tab. The RulesEditor installation includes the grid, chooser, formatter, and store used here.
Left/Right wrap through tabs; Home/End select the first/last. defaultTab chooses the initial tab only. If Columns is selected but unavailable, Highlights is shown; Columns returns when both props return unless another tab was selected.
Each rule list has draggable rows, Move up, Move down, Remove, and an add button. Drag onto another row to take its place, or use Alt+Up/Down on a focused row. Move buttons are disabled at the ends.
API Reference
Props
RulesEditorProps<T> uses the grid's row type. RulesEditorTab is "highlights" | "filters" | "sort" | "columns".
| Prop | Type | Default | Purpose |
|---|---|---|---|
columns |
ColumnDef<T>[] |
Required | Column definitions shared with the grid. |
rules |
GridRules |
Required | Controlled rules; omitted lists appear empty. |
onRulesChange |
(rules: GridRules) => void |
Required | Receives each rule edit. |
store |
RowStore<T> |
Omitted | Rows for match counts. |
columnState |
ColumnState |
Omitted | Chooser state. |
onColumnStateChange |
(state: ColumnState) => void |
Omitted | Chooser changes; enables Columns with columnState. |
defaultTab |
RulesEditorTab |
"highlights" |
Initial tab. |
labels |
Partial<RulesEditorLabels> |
DEFAULT_RULES_EDITOR_LABELS |
Label overrides. |
className |
string |
Omitted | Outer region classes. |
It produces rules and keeps nothing
Each edit emits a new GridRules and edited list; untouched lists and unchanged rules retain their references. Accept the result into rules and share it with the grid for immediate updates. For a save step, keep a draft separate from the applied rules. The JSON shape can live in a preferences envelope, file, or server.
Replace changed arrays and objects: the editor and grid memoize by reference. The editor retains transient tab, drag, and comma-field state, preserving unfinished text such as ALPHA, while emitting parsed values.
A rule as it is typed
Each highlight or filter has column, operator, and value controls. opsFor offers comparisons/ranges for column.numeric, text matching otherwise, and equality, sets, and null checks for both.
| Operators | Fields |
|---|---|
eq, ne, gt, gte, lt, lte, contains, startsWith |
One value |
between |
Two values: inclusive low/high |
in |
Comma-separated values |
isNull, notNull |
None |
withOp preserves values for the same field shape (gt to lt) and clears them otherwise (gt to between). withColumn keeps the condition if the new column offers its operator; otherwise it selects the first operator and drops values, even if the shape is unchanged.
Values stay as typed strings, read through the column's parse or numerically for numeric columns without a parser. A 32nds parser accepts 100-00. The set field trims items and drops empties, with no quoting or escaping: enter 1000000, not 1,000,000, for one numeric member.
Errors
ruleProblem reports missing columns/values and unreadable values below highlights and filters without blocking edits or evaluation. An unreadable single numeric value matches nothing; a mixed valid/invalid in set can match valid members. Empty text can match despite a missing-value message.
A missing column gives an individual match count of zero, but combined filtering skips that rule. Sort rows also report missing columns, which the comparator skips.
Highlights
Highlights add a tone swatch, a target ("cell" by default or "row"), and a label for the grid's accessible description and chooser badge. Blank labels fall back to describeRule. Tones are up, down, flat, stale, expiring, primary, and destructive.
Add highlight appends newHighlight(columns). The grid evaluates cell and row rules separately: the first matching cell rule for a column wins for that cell; the first matching row rule wins for the row. Reorder to change precedence.
Filters and the count
Add filter appends newFilter(columns). Every filter on a known column must match; filter order does not affect that requirement.
With store, highlights and filters show independent match counts across all store rows, including hidden rows and highlights that lose precedence. The Filters total reports combined rule matches out of all store ids, excluding any separate grid filter or custom view.
Counts recompute on mount and changes to their rule lists, columns, or store. Store notifications schedule updates at most once per 250 ms; edits are not throttled. Without store, match counts and the total disappear. Tab badges still show nonzero rule counts or columnState.hidden.length.
Sort
Each sort key has a column and direction. The first key distinguishing two rows decides their order; reorder to change precedence. Add sort key appends newSort(columns, rules.sort). Duplicates are allowed.
For the grid's own view, the rule stack breaks header-sort ties. A caller-supplied view owns filtering and sorting instead.
Helpers
Import these from @/components/ui/rules-editor. Rule types, opsFor, ruleProblem, and describeRule come from grid-rules. Here columns means readonly ColumnDef<T>[], condition means RuleCondition, and op means RuleOp.
| Helper | Return type | Behavior |
|---|---|---|
valueShape(op) |
"one" | "two" | "many" | "none" |
Shape in the operator table. |
parseValues(text: string) |
RuleValue[] |
Comma-separated, trimmed, nonempty strings. |
valuesText(values: readonly RuleValue[] | undefined) |
string |
Joins with ", "; null becomes empty text, undefined gives "". |
withOp(condition, op) |
RuleCondition |
Copies for the same shape; otherwise returns { op }. |
withColumn(condition, column: ColumnDef<T> | undefined) |
RuleCondition |
Same condition if supported; otherwise only the first operator. |
moveItem<X>(list: readonly X[], from: number, to: number) |
X[] |
Moves by index; equal/out-of-bounds indices return an unchanged copy. |
newRuleId() |
string |
Timestamp plus module-local counter. |
newHighlight(columns) |
ColumnRule |
First column/operator, fresh id, tone "up". |
newFilter(columns) |
FilterRule |
First column/operator. |
newSort(columns, existing: readonly SortRule[] = []) |
SortRule |
First unused column, or first column if exhausted; dir: "asc". |
With no columns, add buttons remain enabled and helpers use an empty key; highlights/filters start with eq.
Labels
labels covers the title, tabs, add/move/remove buttons, fields, target/direction choices, empty messages, drag hint, and counts ({n} matches; {m} total ids). The title names the outer region and tablist.
Fields use <field>: <rule>, such as Value: Rich to the market. Highlights use their nonblank label or tab name and number; filters/sorts use tab name and number.
Operator words use RULE_OP_LABELS. Tone names, errors, and the embedded chooser's labels are not overridden by labels.
What it does not do
The editor emits rules and evaluates counts; the grid applies the rules to its display. The caller supplies row values, accepts changes, and decides where to persist them.
Tokens
The install adds the grid's up, down, flat, stale, and expiring tokens with their soft variants if missing. Tone swatches and chooser badges use them; primary and destructive use the host theme's tokens.
Built on shadcn's badge, button, checkbox, context-menu, dialog, dropdown-menu, input, and native-select.
This page is docs/rules-editor.md at v1.4.13.