RulesEditor
Edit one grid's highlights, filters, and sort order, with live match counts.
import { useState } from "react"
import type { ReadGridRules } from "@/lib/grid-rules"
import type { ColumnDef } from "@/components/ui/data-grid"
import {
RulesEditor,
RulesEditorAdd,
RulesEditorColumn,
RulesEditorItem,
RulesEditorOperator,
RulesEditorProblem,
RulesEditorRemove,
RulesEditorTone,
RulesEditorValue,
} from "@/components/ui/rules-editor"
interface Quote { px: number }
const columns: ColumnDef<Quote>[] = [
{ key: "px", header: "Price", width: 100, numeric: true, accessor: (row) => row.px },
]
export default function RulesEditorDemo() {
const [rules, setRules] = useState<ReadGridRules>({
columns: [{ id: "price", column: "px", when: { op: "gte", value: "100" }, tone: "up" }],
})
return (
<RulesEditor columns={columns} rules={rules} onRulesChange={setRules} className="w-lg max-w-full">
{rules.columns?.length ? (
<ul className="space-y-2">
{rules.columns?.map((rule, index) => (
<li key={rule.id ?? index}>
<RulesEditorItem kind="highlights" index={index}>
<RulesEditorColumn />
<RulesEditorOperator />
<RulesEditorValue />
<RulesEditorValue field="low" />
<RulesEditorValue field="high" />
<RulesEditorValue field="values" />
<RulesEditorTone />
<RulesEditorRemove>Remove</RulesEditorRemove>
<RulesEditorProblem />
</RulesEditorItem>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground">No highlights.</p>
)}
<RulesEditorAdd kind="highlights">Add highlight</RulesEditorAdd>
</RulesEditor>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/rules-editor#v2.0.0
npx shadcn@latest add tradecn/ui/rules-editor#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/rules-editor#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/rules-editor#v2.0.0
-
Install the dependencies:
pnpm add cn @tanstack/react-virtualnpm install cn @tanstack/react-virtualyarn add cn @tanstack/react-virtualbun add cn @tanstack/react-virtual -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add badge button checkbox context-menu dialog dropdown-menu input native-select tabsnpx shadcn@latest add badge button checkbox context-menu dialog dropdown-menu input native-select tabsyarn dlx shadcn@latest add badge button checkbox context-menu dialog dropdown-menu input native-select tabsbunx --bun shadcn@latest add badge button checkbox context-menu dialog dropdown-menu input native-select tabs -
Copy the files into your project:
components/ui/ rules-editor.tsx import { cn } from "cn" import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode, type Ref } 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_OPS, RULE_TONES, RULE_TONE_CLASS, columnName, compileCondition, compileFilter, opsFor, readColumnRule, readFilterRule, readSortRule, ruleProblem, ruleWords, type ColumnRule, type FilterRule, type ReadColumnRule, type ReadCondition, type ReadFilterRule, type ReadGridRules, type ReadSortRule, type RuleKind, type RuleOp, type RuleTone, type RuleValue, type RuleWords, type RuleWordsInput, type SortRule, } from "@/lib/grid-rules" import type { RowStore } from "@/lib/row-store" import type { ColumnDef } from "@/components/ui/data-grid" // An undefined word keeps its default, as DataGrid's labels do. const defined = <W extends object>(words: W | undefined): Partial<W> => Object.fromEntries(Object.entries(words ?? {}).filter(([, word]) => word !== undefined)) as Partial<W> // Callers own the sections and row markup. Items coordinate editing and reordering; // the counts provider isolates feed updates from the editable fields. 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 /** The rows a highlight or filter matches: a template with `{n}`, or a function of the count, for a plural a template can't hold. */ matches: string | ((n: number) => string) /** The rows the filters pass, `{n}`, of all `{m}`: a template, or a function of both counts. */ shown: string | ((n: number, m: number) => string) noHighlights: string noFilters: string noSort: string dragHint: string /** An unnamed rule's name: `{kind}` the section's word, `{n}` its place. */ itemName: string /** The comparisons', tones', and problems' words, as grid-rules writes them; a partial map keeps the rest. */ ruleWords: RuleWordsInput } 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) => `${n.toLocaleString()} ${n === 1 ? "row matches" : "rows match"}`, shown: (n, m) => `${n.toLocaleString()} of ${m.toLocaleString()} ${m === 1 ? "row" : "rows"} ${n === 1 ? "shows" : "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.", itemName: "{kind} {n}", ruleWords: {}, } export type RulesEditorKind = "highlights" | "filters" | "sort" export interface RulesEditorProps<T> extends ComponentProps<"div"> { columns: ColumnDef<T>[] /** Rules as written, or read from saved data with `readRules`. */ rules: ReadGridRules /** Each edit. An edit can leave a rule incomplete, as typing does, so it comes back in the read type the grid takes. */ onRulesChange: (rules: ReadGridRules) => void /** Rows for independent match counts and the combined filter total. */ store?: RowStore<T> labels?: Partial<RulesEditorLabels> children: ReactNode } 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 | string | undefined): "one" | "two" | "many" | "none" { if (OPS_WITH_VALUE.has(op as RuleOp)) 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: ReadCondition, op: RuleOp): ReadCondition & { op: RuleOp } { // An op no version knows says nothing about the value's shape: keep what was typed, for the new op to read. if (valueShape(condition.op) === valueShape(op) || !isKnownOp(condition.op)) return { ...condition, op } return { ...withoutValues(condition), 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: ReadCondition, column: ColumnDef<T> | undefined): ReadCondition & { op: RuleOp } { const ops = opsFor(column) return isKnownOp(condition.op) && ops.includes(condition.op) ? (condition as ReadCondition & { op: RuleOp }) : { ...withoutValues(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 rule list that is not a list reads as none; its entries are read through grid-rules' readers where they are used, // so an entry that is not an object renders and counts nothing and keeps the source indices of the rest. const listOf = <R,>(value: readonly R[] | undefined): readonly R[] => (Array.isArray(value) ? value : []) const NO_CONDITION: ReadCondition = Object.freeze({}) // A tone this version paints with, as opposed to text saved by another or by hand. const isKnownTone = (tone: string | undefined): tone is RuleTone => tone !== undefined && (RULE_TONES as readonly string[]).includes(tone) // An op this version knows, as opposed to text saved by another or by hand. const isKnownOp = (op: string | undefined): op is RuleOp => op !== undefined && (RULE_OPS as readonly string[]).includes(op) // A condition without its typed values, every other field it carries kept. function withoutValues(condition: ReadCondition): ReadCondition { const rest = { ...condition } delete rest.value delete rest.values return rest } /** A new sort key on the first column not yet in the list, ascending. */ export function newSort<T>(columns: readonly ColumnDef<T>[], existing: readonly ReadSortRule[] = []): SortRule { const used = new Set(listOf(existing).flatMap((entry) => { const rule = readSortRule(entry); return 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_RULES: ReadGridRules = {} const NO_HIGHLIGHTS: ReadColumnRule[] = [] const NO_FILTERS: ReadFilterRule[] = [] // Any one entry of a rule list, as the editor reads it. type AnyRule = ReadColumnRule | ReadFilterRule | ReadSortRule 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 } export interface RulesEditorState { rules: ReadGridRules labels: RulesEditorLabels columns: { key: string; name: string; ops: readonly RuleOp[] }[] /** Why a rule cannot apply, judged as the kind of rule it is, or null. */ problem: (rule: ReadColumnRule | ReadFilterRule, kind: RuleKind) => string | null change: (next: Partial<ReadGridRules>) => void add: (kind: RulesEditorKind) => void move: (kind: RulesEditorKind, from: number, to: number) => void remove: (kind: RulesEditorKind, index: number) => void } const EditorContext = createContext<RulesEditorState | null>(null) /** Read the controlled rules and edit operations for custom lists and controls. */ export function useRulesEditor(): RulesEditorState { const value = useContext(EditorContext) if (!value) throw new Error("RulesEditor parts must be inside RulesEditor") return value } interface Counts { highlights: number[] | null filters: number[] | null shown: { n: number; m: number } | null } const CountsContext = createContext<Counts>({ highlights: null, filters: null, shown: null }) // A feed notification updates only count consumers, even when the fields are not memoized. function CountsProvider<T>({ store, columns, rules, children }: Pick<RulesEditorProps<T>, "store" | "columns" | "children"> & { rules: ReadGridRules }) { const highlights = Array.isArray(rules.columns) ? rules.columns : NO_HIGHLIGHTS const filters = Array.isArray(rules.filter) ? rules.filter : NO_FILTERS const byKey = useMemo(() => new Map(columns.map((c) => [c.key, c])), [columns]) const version = useThrottledVersion(store, 250) const highlightCounts = useMemo(() => { void version return store ? highlights.map((entry) => { const rule = readColumnRule(entry) const column = rule?.column === undefined ? undefined : byKey.get(rule.column) return rule && column ? countMatching(store, compileCondition(rule.when, column)) : 0 }) : null }, [store, highlights, byKey, version]) const filterCounts = useMemo(() => { void version return store ? filters.map((entry) => { const rule = readFilterRule(entry) const column = rule?.column === undefined ? undefined : byKey.get(rule.column) return rule && column ? countMatching(store, compileCondition(rule, column)) : 0 }) : null }, [store, filters, byKey, version]) const shown = useMemo(() => { void version return store ? { n: countMatching(store, compileFilter(filters, columns)), m: store.getIds().length } : null }, [store, filters, columns, version]) return <CountsContext value={{ highlights: highlightCounts, filters: filterCounts, shown }}>{children}</CountsContext> } const LIST_KEY = { highlights: "columns", filters: "filter", sort: "sort" } as const function isElement(target: EventTarget): target is Element { // Popouts can contain nodes created in either window, even after adoption back to the page. return "nodeType" in target && target.nodeType === 1 } // Widget roles whose keyboard model uses the arrows, per the ARIA authoring practices, as the column chooser reads them. const ARROW_OWNING_ROLES = new Set("application columnheader combobox grid gridcell listbox menu menubar menuitem menuitemcheckbox menuitemradio option radio radiogroup row rowheader scrollbar searchbox separator slider spinbutton tab tablist textbox toolbar tree treegrid treeitem".split(" ")) // Controls whose own keys matter: carets, selects, and ARIA widgets built on generic elements. A button owns no // arrows. The walk checks the item itself last and stays inside it, so an item given an arrow-owning role keeps its keys. // An item given an arrow-owning role keeps Alt with its arrows, so it names no keys for moving. const roleOwnsArrows = (role: string | undefined) => Boolean(role && role.toLowerCase().split(/[\t\n\f\r ]+/).some((token) => ARROW_OWNING_ROLES.has(token))) function arrowOwningTarget(target: EventTarget, item: HTMLElement) { if (!isElement(target)) return false for (let node: Element | null = target; node; node = node.parentElement) { if (node.matches('input:not([type="checkbox"]):not([type="button"]):not([type="submit"]):not([type="reset"]), textarea, select, [contenteditable]:not([contenteditable="false"])')) return true const role = node.getAttribute("role") if (role && role.toLowerCase().split(/[\t\n\f\r ]+/).some((token) => ARROW_OWNING_ROLES.has(token))) return true if (node === item) break } return false } const DragContext = createContext<{ current: { kind: RulesEditorKind; index: number; list: readonly AnyRule[]; type: string; clear: () => void } | null }>({ current: null }) // Controlled callers may copy rules. Compare their data while dragging or awaiting focus // restoration; property order and extra application fields do not matter. function sameRuleList(left: readonly AnyRule[] | undefined, right: readonly AnyRule[]) { const fields = ["id", "column", "when", "op", "value", "values", "tone", "target", "label", "key", "dir"] if (left === right) return true // Data JSON cannot print (a bigint a caller put in a rule) is never the same list. try { return JSON.stringify(left, fields) === JSON.stringify(right, fields) } catch { return false } } function assignEditorRef<T>(ref: Ref<T> | undefined, node: T | null) { if (typeof ref === "function") return ref(node) if (ref) ref.current = node } function useEditorRef<T>(localRef: { current: T | null }, forwardedRef: Ref<T> | undefined) { return useCallback((node: T | null) => { localRef.current = node const cleanup = assignEditorRef(forwardedRef, node) return () => { localRef.current = null if (typeof cleanup === "function") cleanup() else assignEditorRef(forwardedRef, null) } }, [localRef, forwardedRef]) } export function RulesEditor<T>({ columns, rules: given, onRulesChange, store, labels: labelsProp, children, className, ref, ...props }: RulesEditorProps<T>) { // Rules that are not an object (a null from storage) are no rules; read saved rules with readRules first. const rules: ReadGridRules = typeof given === "object" && given !== null ? given : NO_RULES const labels = { ...DEFAULT_RULES_EDITOR_LABELS, ...defined(labelsProp) } const root = useRef<HTMLDivElement>(null) const rootRef = useEditorRef(root, ref) const dragging = useRef<{ kind: RulesEditorKind; index: number; list: readonly AnyRule[]; type: string; clear: () => void } | null>(null) useLayoutEffect(() => { const drag = dragging.current if (!drag || sameRuleList(rules[LIST_KEY[drag.kind]], drag.list)) return drag.clear() dragging.current = null }, [rules]) const focusAfter = useRef<{ rules: ReadGridRules; kind: RulesEditorKind; index: number; list: readonly AnyRule[]; active: Element; field?: string } | null>(null) useLayoutEffect(() => { const pending = focusAfter.current if (!pending || pending.rules === rules) return focusAfter.current = null const list = rules[LIST_KEY[pending.kind]] if (list === pending.rules[LIST_KEY[pending.kind]] || !sameRuleList(list, pending.list)) return const active = root.current?.ownerDocument.activeElement if (active !== pending.active && active !== root.current?.ownerDocument.body) return const item = root.current?.querySelector<HTMLElement>(`[data-rule-kind="${pending.kind}"][data-rule-row="${pending.index}"]`) const field = pending.field ? item?.querySelector<HTMLElement>(`[data-rule-field="${pending.field}"]`) : null const target = field && !field.matches(":disabled") ? field : item ?? root.current?.querySelector<HTMLElement>(`[data-rules-add="${pending.kind}"]`) target?.focus() }, [rules]) const rememberFocus = (kind: RulesEditorKind, index: number, list: readonly AnyRule[]) => { const active = root.current?.ownerDocument.activeElement if (!active || !root.current?.contains(active)) return focusAfter.current = { rules, kind, index, list, active, field: active.getAttribute("data-rule-field") ?? undefined } } const change = (next: Partial<ReadGridRules>) => onRulesChange({ ...rules, ...next }) const value: RulesEditorState = { rules, labels, columns: columns.map((column) => ({ key: column.key, name: columnName(column), ops: opsFor(column) })), problem: (rule, kind) => ruleProblem(rule, columns, kind, labels.ruleWords), change, add: (kind) => { if (kind === "highlights") change({ columns: [...listOf(rules.columns), newHighlight(columns)] }) else if (kind === "filters") change({ filter: [...listOf(rules.filter), newFilter(columns)] }) else change({ sort: [...listOf(rules.sort), newSort(columns, rules.sort)] }) }, move: (kind, from, to) => { const key = LIST_KEY[kind] const list = listOf<AnyRule>(rules[key]) if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) return const next = moveItem<AnyRule>(list, from, to) rememberFocus(kind, to, next) // The key and list originate from the same kind; spread preserves the other lists. change({ [key]: next }) }, remove: (kind, index) => { const key = LIST_KEY[kind] const list = listOf<AnyRule>(rules[key]) if (index < 0 || index >= list.length) return const next = list.filter((_, i) => i !== index) rememberFocus(kind, Math.min(index, list.length - 2), next) change({ [key]: next }) }, } return ( <EditorContext value={value}> <DragContext value={dragging}> <div role="region" aria-label={props["aria-labelledby"] ? undefined : labels.title} data-slot="tradecn-rules-editor" className={cn("flex min-w-0 flex-col gap-2 text-xs lining-nums tabular-nums", className)} {...props} ref={rootRef}> <CountsProvider store={store} columns={columns} rules={rules}>{children}</CountsProvider> </div> </DragContext> </EditorContext> ) } type ActionProps = Omit<ComponentProps<typeof Button>, "children"> & { children: ReactNode } export interface RulesEditorItemState { kind: RulesEditorKind index: number name: string columnKey: string /** The condition as read: its op can be missing, or text this version doesn't know. */ condition: ReadCondition | null /** The highlight as read: any field can be missing. */ highlight: ReadColumnRule | null /** The filter as read: any field can be missing. */ filter: ReadFilterRule | null /** The sort key as read: its key can be missing. */ sort: ReadSortRule | null problem: string | null /** The problem line's id, which the item and its fields point to while there's a problem. */ problemId: string /** The editor's rule words, with yours over the defaults. */ words: RuleWords setColumn: (key: string) => void setCondition: (condition: ReadCondition) => void updateHighlight: (patch: Partial<ReadColumnRule>) => void setDirection: (direction: "asc" | "desc") => void } const ItemContext = createContext<RulesEditorItemState | null>(null) export function useRulesEditorItem(): RulesEditorItemState { const value = useContext(ItemContext) if (!value) throw new Error("Rule fields and actions must be inside RulesEditorItem") return value } export interface RulesEditorItemProps extends ComponentProps<"div"> { kind: RulesEditorKind index: number children: ReactNode } export function RulesEditorItem({ kind, index, className, onKeyDown, onDragStart, onDragOver, onDrop, onDragEnd, ...props }: RulesEditorItemProps) { const editor = useRulesEditor() const dragRef = useContext(DragContext) const dragType = `application/x-tradecn-rule-${useId()}`.toLowerCase() const [dragging, setDragging] = useState(false) useEffect(() => () => { if (dragRef.current?.type === dragType) dragRef.current = null }, [dragRef, dragType]) // The entry as the grid reads it: names and words as text, and nothing for an entry that is not an object. const entry = listOf<unknown>(editor.rules[LIST_KEY[kind]])[index] const read = useMemo(() => (kind === "highlights" ? readColumnRule(entry) : kind === "filters" ? readFilterRule(entry) : readSortRule(entry)), [kind, entry]) const highlight = kind === "highlights" ? (read as ReadColumnRule | null) : null const filter = kind === "filters" ? (read as ReadFilterRule | null) : null const sort = kind === "sort" ? (read as ReadSortRule | null) : null const rule = highlight ?? filter ?? sort const problemId = useId() if (!rule) return null let columnKey = highlight?.column ?? sort?.key ?? "" // A highlight whose condition is missing or not an object edits from a blank one, as a filter with no op does; an op // the column does not offer is shown as it is. let condition = highlight ? (highlight.when ?? NO_CONDITION) : null if (filter) { const { column, ...rest } = filter // A filter with no column edits from a blank one, so its select stays controlled. columnKey = column ?? "" condition = rest } const name = highlight?.label?.trim() || editor.labels.itemName.replace("{kind}", editor.labels[kind]).replace("{n}", (index + 1).toLocaleString()) const words = ruleWords(editor.labels.ruleWords) const replace = (next: AnyRule) => { const key = LIST_KEY[kind] editor.change({ [key]: listOf<AnyRule>(editor.rules[key]).map((item, i) => i === index ? next : item) }) } const state: RulesEditorItemState = { kind, index, name, columnKey, condition, highlight: highlight ?? null, filter: filter ?? null, sort: sort ?? null, problem: highlight ? editor.problem(highlight, "highlight") : filter ? editor.problem(filter, "filter") : sort && sort.key === undefined ? words.needsColumn : editor.columns.some((c) => c.key === columnKey) ? null : words.noColumn.replace("{name}", columnKey), problemId, words, setColumn: (key) => { if (sort) return replace({ ...sort, key }) const ops = editor.columns.find((c) => c.key === key)?.ops ?? opsFor(undefined) const next = condition!.op !== undefined && (ops as readonly string[]).includes(condition!.op) ? condition! : { ...withoutValues(condition!), op: ops[0]! } replace(highlight ? { ...highlight, column: key, when: next } : { column: key, ...next }) }, setCondition: (next) => { if (highlight) replace({ ...highlight, when: next }) // A filter with no column keeps none: the editor's blank is not a key. else if (filter) replace(filter.column === undefined ? { ...next } : { column: filter.column, ...next }) }, updateHighlight: (patch) => { if (highlight) replace({ ...highlight, ...patch }) }, setDirection: (dir) => { if (sort) replace({ ...sort, dir }) }, } return <ItemContext value={state}><div role="group" tabIndex={0} draggable aria-label={props["aria-labelledby"] ? undefined : name} aria-invalid={state.problem ? true : undefined} aria-describedby={state.problem ? state.problemId : undefined} aria-keyshortcuts={roleOwnsArrows(props.role) || (props.contentEditable !== undefined && props.contentEditable !== false && props.contentEditable !== "false") ? undefined : "Alt+ArrowUp Alt+ArrowDown"} data-rule-kind={kind} data-rule-row={index} data-rule-id={highlight?.id} data-filter-index={filter ? index : undefined} data-sort-index={sort ? index : undefined} data-dragging={dragging || undefined} className={cn("flex min-w-0 flex-wrap items-center gap-1.5 rounded-sm border border-border/60 p-1.5 outline-none focus-visible:border-foreground data-[dragging]:border-dashed data-[dragging]:border-foreground", className)} {...props} onKeyDown={(event) => { onKeyDown?.(event) // Plain Alt with an arrow, from this item's own elements: a portaled popover's controls bubble here through React // without being the item's, and a held Shift, Ctrl, or Meta makes another chord. if (event.defaultPrevented || event.nativeEvent.isComposing || !event.altKey || event.shiftKey || event.ctrlKey || event.metaKey || !["ArrowUp", "ArrowDown"].includes(event.key)) return if (!isElement(event.target) || !event.currentTarget.contains(event.target)) return // A select, a text field, or another control that uses the arrows keeps Alt with them: Alt+Down opens a select. if (arrowOwningTarget(event.target, event.currentTarget)) return event.preventDefault() event.stopPropagation() editor.move(kind, index, index + (event.key === "ArrowUp" ? -1 : 1)) }} onDragStart={(event) => { onDragStart?.(event) if (event.defaultPrevented) return dragRef.current = { kind, index, list: listOf<AnyRule>(editor.rules[LIST_KEY[kind]]), type: dragType, clear: () => setDragging(false) } event.dataTransfer?.setData?.("text/plain", String(index)) event.dataTransfer?.setData?.(dragType, "") if (event.dataTransfer) event.dataTransfer.effectAllowed = "move" setDragging(true) }} onDragOver={(event) => { onDragOver?.(event) if (event.defaultPrevented || dragRef.current?.kind !== kind || dragRef.current.index === index || !event.dataTransfer?.types.includes(dragRef.current.type)) return event.preventDefault() if (event.dataTransfer) event.dataTransfer.dropEffect = "move" }} onDrop={(event) => { onDrop?.(event) if (event.defaultPrevented || dragRef.current?.kind !== kind || !event.dataTransfer?.types.includes(dragRef.current.type)) return event.preventDefault() const from = dragRef.current.index dragRef.current.clear() dragRef.current = null editor.move(kind, from, index) }} onDragEnd={(event) => { onDragEnd?.(event) dragRef.current = null setDragging(false) }} /></ItemContext> } type SelectProps = Omit<ComponentProps<typeof NativeSelect>, "value" | "defaultValue" | "children"> // base-mira draws a small select at 10 px through `data-[size=sm]` on the select itself, which outranks a plain // `[&_select]` on the wrapper. Naming the same attribute here outranks it back, so the fields hold the floor in every style. const SELECT_SIZE = "[&_select[data-size=sm]]:text-xs" export function RulesEditorColumn({ onChange, className, ...props }: SelectProps) { const { columns, labels } = useRulesEditor() const item = useRulesEditorItem() // A rule with no column shows a blank, first, so it is the one selected even beside a column keyed "", which the // grid would not use for it; a key no column has shows as it is. const missing = (item.highlight?.column ?? item.filter?.column ?? item.sort?.key) === undefined return <NativeSelect size="sm" aria-label={`${labels.column}: ${item.name}`} data-rule-field="column" className={cn(SELECT_SIZE, className)} {...props} value={item.columnKey} onChange={(event) => { onChange?.(event) if (!event.defaultPrevented) item.setColumn(event.target.value) }}> {(missing || !columns.some((c) => c.key === item.columnKey)) && <NativeSelectOption value={item.columnKey}>{item.columnKey}</NativeSelectOption>} {columns.map((c) => <NativeSelectOption key={c.key} value={c.key}>{c.name}</NativeSelectOption>)} </NativeSelect> } export function RulesEditorOperator({ onChange, className, ...props }: SelectProps) { const { columns, labels } = useRulesEditor() const item = useRulesEditorItem() const { words } = item if (!item.condition) return null const condition = item.condition const ops = columns.find((c) => c.key === item.columnKey)?.ops ?? opsFor(undefined) // A stored op the column does not offer (above on a text column, or one no version knows) stays the selected // option under its own word, so the select says what the grid applies. const current = condition.op ?? "" return <NativeSelect size="sm" aria-label={`${labels.condition}: ${item.name}`} data-rule-field="op" className={cn(SELECT_SIZE, className)} {...props} value={current} onChange={(event) => { onChange?.(event) if (!event.defaultPrevented) item.setCondition(withOp(condition, event.target.value as RuleOp)) }}> {/* An op the column does not offer reads as its word; one no version knows reads quoted, apart from every word here. */} {!(ops as readonly string[]).includes(current) && <NativeSelectOption value={current}>{isKnownOp(current) ? words.ops[current] : current && `"${current}"`}</NativeSelectOption>} {ops.map((op) => <NativeSelectOption key={op} value={op}>{words.ops[op]}</NativeSelectOption>)} </NativeSelect> } type InputProps = Omit<ComponentProps<typeof Input>, "value" | "defaultValue"> export type RulesEditorValueProps = InputProps & { field?: "value" | "low" | "high" | "values" } export function RulesEditorValue({ field = "value", ...props }: RulesEditorValueProps) { const { condition } = useRulesEditorItem() const shape = condition ? valueShape(condition.op) : "none" if (shape === "none" || (shape === "one" && field !== "value") || (shape === "two" && field !== "low" && field !== "high") || (shape === "many" && field !== "values")) return null return <ValueInput key={field} field={field} {...props} /> } function ValueInput({ field, onChange, className, ...props }: InputProps & { field: NonNullable<RulesEditorValueProps["field"]> }) { const { labels } = useRulesEditor() const { condition, name, setCondition, problem, problemId } = useRulesEditorItem() const values = condition!.values // Keep a trailing comma under the hand, but accept a different controlled value immediately. const [draft, setDraft] = useState(() => valuesText(values)) const [known, setKnown] = useState(values) if (values !== known) { setKnown(values) if (valuesText(parseValues(draft)) !== valuesText(values)) setDraft(valuesText(values)) } const value = field === "values" ? draft : field === "value" ? condition!.value : values?.[field === "low" ? 0 : 1] return <Input aria-label={`${labels[field]}: ${name}`} aria-describedby={problem ? problemId : undefined} placeholder={labels[field]} spellCheck={false} autoComplete="off" data-rule-field={field} className={cn("h-6 px-1.5 text-xs md:text-xs", field === "values" ? "w-40" : field === "value" ? "w-28" : "w-24", className)} {...props} value={value == null ? "" : String(value)} onChange={(event) => { onChange?.(event) if (event.defaultPrevented) return const text = event.target.value if (field === "values") { setDraft(text) setCondition({ ...condition!, values: parseValues(text) }) } else if (field === "value") setCondition({ ...condition!, value: text }) else setCondition({ ...condition!, values: field === "low" ? [text, values?.[1] ?? ""] : [values?.[0] ?? "", text] }) }} /> } export function RulesEditorTone({ onChange, className, ...props }: SelectProps) { const { labels } = useRulesEditor() const { highlight, name, updateHighlight, words } = useRulesEditorItem() if (!highlight) return null const current = highlight.tone ?? "" return <NativeSelect size="sm" aria-label={`${labels.tone}: ${name}`} data-rule-field="tone" className={cn(SELECT_SIZE, className)} {...props} value={current} onChange={(event) => { onChange?.(event) if (!event.defaultPrevented) updateHighlight({ tone: event.target.value as RuleTone }) }}> {!isKnownTone(current) && <NativeSelectOption value={current}>{current && `"${current}"`}</NativeSelectOption>} {RULE_TONES.map((tone) => <NativeSelectOption key={tone} value={tone}>{words.tones[tone]}</NativeSelectOption>)} </NativeSelect> } export function RulesEditorToneSwatch({ className, ...props }: ComponentProps<"span">) { const { highlight, words } = useRulesEditorItem() if (!highlight) return null return <span aria-hidden data-rule-swatch={highlight.tone} className={cn("rounded-sm px-1.5 py-0.5", isKnownTone(highlight.tone) && RULE_TONE_CLASS[highlight.tone], className)} {...props}>{isKnownTone(highlight.tone) ? words.tones[highlight.tone] : highlight.tone}</span> } export function RulesEditorTarget({ onChange, className, ...props }: SelectProps) { const { labels } = useRulesEditor() const { highlight, name, updateHighlight } = useRulesEditorItem() if (!highlight) return null return <NativeSelect size="sm" aria-label={`${labels.paints}: ${name}`} data-rule-field="target" className={cn(SELECT_SIZE, className)} {...props} value={highlight.target ?? "cell"} onChange={(event) => { onChange?.(event) if (!event.defaultPrevented) updateHighlight({ target: event.target.value === "row" ? "row" : "cell" }) }}><NativeSelectOption value="cell">{labels.cell}</NativeSelectOption><NativeSelectOption value="row">{labels.row}</NativeSelectOption></NativeSelect> } export function RulesEditorLabel({ onChange, className, ...props }: InputProps) { const { labels } = useRulesEditor() const { highlight, name, updateHighlight } = useRulesEditorItem() if (!highlight) return null return <Input aria-label={`${labels.label}: ${name}`} placeholder={labels.label} spellCheck={false} autoComplete="off" data-rule-field="label" className={cn("h-6 w-36 px-1.5 text-xs md:text-xs", className)} {...props} value={highlight.label ?? ""} onChange={(event) => { onChange?.(event) if (!event.defaultPrevented) updateHighlight({ label: event.target.value }) }} /> } export function RulesEditorDirection({ onChange, className, ...props }: SelectProps) { const { labels } = useRulesEditor() const { sort, name, setDirection } = useRulesEditorItem() if (!sort) return null return <NativeSelect size="sm" aria-label={`${labels.direction}: ${name}`} data-rule-field="dir" className={cn(SELECT_SIZE, className)} {...props} value={sort.dir} onChange={(event) => { onChange?.(event) if (!event.defaultPrevented) setDirection(event.target.value === "desc" ? "desc" : "asc") }}><NativeSelectOption value="asc">{labels.asc}</NativeSelectOption><NativeSelectOption value="desc">{labels.desc}</NativeSelectOption></NativeSelect> } export function RulesEditorProblem({ className, ...props }: ComponentProps<"p">) { const { problem, problemId } = useRulesEditorItem() // The generated id comes last: the item and its fields point to it, so a caller's id can't take it away. return problem ? <p data-rule-problem className={cn("basis-full text-destructive", className)} {...props} id={problemId}>{problem}</p> : null } export function RulesEditorMatchCount({ className, ...props }: ComponentProps<"span">) { const { kind, index } = useRulesEditorItem() const { labels } = useRulesEditor() const counts = useContext(CountsContext) const n = kind === "sort" ? null : counts[kind]?.[index] ?? null return n === null ? null : <span data-rule-count={n} className={cn("text-muted-foreground", NUMERIC_CLASS, className)} {...props}>{typeof labels.matches === "function" ? labels.matches(n) : fill(labels.matches, { n })}</span> } export function RulesEditorFilterCount({ className, ...props }: ComponentProps<"span">) { const { labels } = useRulesEditor() const { shown } = useContext(CountsContext) return shown ? <span data-rules-shown={shown.n} className={cn("text-muted-foreground", NUMERIC_CLASS, className)} {...props}>{typeof labels.shown === "function" ? labels.shown(shown.n, shown.m) : fill(labels.shown, shown)}</span> : null } export function RulesEditorRuleCount({ kind, className, ...props }: ComponentProps<"span"> & { kind: RulesEditorKind }) { const { rules } = useRulesEditor() const n = listOf<unknown>(rules[LIST_KEY[kind]]).filter((entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry)).length return n ? <span className={cn("text-muted-foreground", NUMERIC_CLASS, className)} {...props}>{n}</span> : null } export type RulesEditorAddProps = ActionProps & { kind: RulesEditorKind } export function RulesEditorAdd({ kind, onClick, className, ...props }: RulesEditorAddProps) { const { add } = useRulesEditor() return <Button type="button" variant="outline" size="sm" data-rules-add={kind} className={cn("h-7 self-start px-2 text-xs", className)} {...props} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) add(kind) }} /> } export type RulesEditorMoveProps = ActionProps & { direction: "up" | "down" } export function RulesEditorMove({ direction, disabled, onClick, className, ...props }: RulesEditorMoveProps) { const { rules, labels, move } = useRulesEditor() const { kind, index, name } = useRulesEditorItem() const end = direction === "up" ? index === 0 : index === listOf<unknown>(rules[LIST_KEY[kind]]).length - 1 return <Button type="button" variant="ghost" size="sm" aria-label={`${labels[direction === "up" ? "moveUp" : "moveDown"]}: ${name}`} data-rule-field={`move-${direction}`} className={cn("h-6 px-1.5 text-xs", className)} {...props} disabled={disabled || end} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) move(kind, index, index + (direction === "up" ? -1 : 1)) }} /> } export function RulesEditorRemove({ onClick, className, ...props }: ActionProps) { const { labels, remove } = useRulesEditor() const { kind, index, name } = useRulesEditorItem() return <Button type="button" variant="ghost" size="sm" aria-label={`${labels.remove}: ${name}`} data-rule-field="remove" className={cn("h-6 px-1.5 text-xs", className)} {...props} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) remove(kind, index) }} /> }components/ui/ column-chooser.tsx import { cn } from "cn" import { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentProps, type DragEvent, type ReactNode, type Ref } from "react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" import { NUMERIC_CLASS } from "@/lib/format" import { RULE_TONES, RULE_TONE_CLASS, columnName, describeRule, readColumnRule, type ReadColumnRule, type RuleTone, type RuleWordsInput } from "@/lib/grid-rules" import { EMPTY_COLUMN_STATE, type ColumnDef, type ColumnState } from "@/components/ui/data-grid" // An undefined word keeps its default, as DataGrid's labels do. const defined = <W extends object>(words: W | undefined): Partial<W> => Object.fromEntries(Object.entries(words ?? {}).filter(([, word]) => word !== undefined)) as Partial<W> // The root coordinates controlled grid edits, search, drag ownership and focus. // Callers own the collection, row contents and any surrounding dialog. export interface ColumnChooserLabels { title: string description: string search: string /** Prefix of a checkbox's name: "Show Price". */ show: string frozen: string /** A hidden item's spoken state. */ hidden: string /** How many columns are hidden: a template with `{n}`, or a function of the count, for a plural a template can't hold. */ hiddenCount: string | ((n: number) => string) /** Said before an item's width, which it shows in px. */ width: string /** The rule badges' words, as grid-rules writes them; a partial map keeps the rest. */ ruleWords: RuleWordsInput resetWidth: string moveUp: string moveDown: string resetAll: string empty: string dragHint: string /** Announcement templates accept {name}, and moves also accept {n} and {m}. */ announceMove?: string announceReorder?: string announceShow?: string announceHide?: string announceResetWidth?: string announceReset?: string } export const DEFAULT_COLUMN_CHOOSER_LABELS: Required<ColumnChooserLabels> = { title: "Columns", description: "Show, hide, reorder, and size the columns of this grid.", search: "Find a column", show: "Show", frozen: "frozen", hidden: "hidden", hiddenCount: (n) => `${n.toLocaleString()} hidden`, width: "Width", ruleWords: {}, resetWidth: "Reset width", moveUp: "Move up", moveDown: "Move down", resetAll: "Reset all", empty: "No column matches.", dragHint: "Up and Down move between columns, and Home and End jump to the ends. Drag a column, or hold Alt with an arrow key, to reorder; Alt+Home and Alt+End move to the edge. Space shows or hides a focused column, and Delete resets its width. Frozen columns stay first.", announceMove: "{name} moved to {n} of {m}.", announceReorder: "{name} reordered.", announceShow: "{name} shown.", announceHide: "{name} hidden.", announceResetWidth: "{name} width reset.", announceReset: "Column settings reset.", } export interface ColumnChooserProps<T> extends ComponentProps<"div"> { columns: ColumnDef<T>[] columnState: ColumnState onColumnStateChange: (state: ColumnState) => void /** The shared defaults restored by reset. Column state remains a complete snapshot. */ baseState?: ColumnState /** Column keys in the collection's render order. Defaults to the search results. */ presented?: readonly string[] /** Rules that name columns, said in words beside the columns they touch: written, or read from saved data. */ rules?: readonly ReadColumnRule[] labels?: Partial<ColumnChooserLabels> children: ReactNode } /** 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 /** The rules on this column, each as the grid reads it. */ rules: ReadColumnRule[] } /** * 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 ReadColumnRule[] = []): 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") // Each entry as the grid reads it, once: one that is not an object is no rule, and its names and words are text. const read = (Array.isArray(rules) ? rules : []).flatMap((entry) => { const rule = readColumnRule(entry) return rule ? [rule] : [] }) 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: read.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) } /** Readings for a column, without accessors tied to the grid's row type. */ export interface ColumnChooserEntry extends Omit<ChooserRow<unknown>, "column" | "rules"> { /** The effective width differs from the root's reset baseline. */ resized: boolean rules: { rule: ReadColumnRule; description: string }[] } export interface ColumnChooserState { rows: readonly ColumnChooserEntry[] shown: readonly ColumnChooserEntry[] presented: readonly ColumnChooserEntry[] query: string setQuery: (query: string) => void labels: ColumnChooserLabels hiddenCount: number isDefault: boolean reset: () => void } interface ChooserContextValue extends ColumnChooserState { announcements: ReturnType<typeof createAnnouncements> byKey: ReadonlyMap<string, ColumnChooserItemState> startDrag: (key: string, event: DragEvent<HTMLDivElement>) => void dragOver: (key: string, event: DragEvent<HTMLDivElement>) => void drop: (key: string, event: DragEvent<HTMLDivElement>) => void endDrag: (key?: string) => void focusFallback: () => void /** Each item subscribes for its own ownership, so focus moves re-render two items, not the list. */ registrations: ChooserRegistrations registerItem: (key: string, node: HTMLElement | null, optedOut?: boolean, keylessItem?: boolean) => void focusStep: (from: string, step: -1 | 1 | "first" | "last") => void } interface ChooserRegistrations { nodes: Map<string, HTMLElement> optedOut: Set<string> /** Items that keep every native key: no roving stop, no arrow visits — they own a Tab stop. */ keyless: Set<string> subscribe: (listener: () => void) => () => void /** The one presented, registered, coordinated key currently in the tab order. */ owner: () => string | null set: (key: string, node: HTMLElement | null, opted: boolean, keylessItem: boolean) => void present: (keys: readonly string[]) => void focus: (key: string) => void /** Re-resolves against live availability; publishes only when the owner moves. */ refresh: () => void /** The collection's root, the boundary for judging an item's own availability. */ setRoot: (node: HTMLElement | null) => void } const ChooserContext = createContext<ChooserContextValue | null>(null) const ItemContext = createContext<ColumnChooserItemState | null>(null) const ControlsContext = createContext<((kind: "visibility" | "resetWidth", element: () => HTMLElement | null) => () => void) | null>(null) const NO_CONTROL_ELEMENT = () => null function useChooserContext() { const value = useContext(ChooserContext) if (!value) throw new Error("ColumnChooser parts must be inside ColumnChooser") return value } export function useColumnChooser(): ColumnChooserState { return useChooserContext() } export interface ColumnChooserItemState { row: ColumnChooserEntry canMoveUp: boolean canMoveDown: boolean dragging: boolean setVisible: (visible: boolean) => void move: (delta: -1 | 1) => void /** Move to the first or last presented place on the column's side of the frozen line. */ moveToEdge: (edge: "start" | "end") => void resetWidth: () => void } export function useColumnChooserItem(): ColumnChooserItemState { const value = useContext(ItemContext) if (!value) throw new Error("Column readings and controls must be inside ColumnChooserItem") return value } function assignRef<T>(ref: Ref<T> | undefined, node: T | null) { if (typeof ref === "function") return ref(node) if (ref) ref.current = node } function useChooserRef<T>(localRef: { current: T | null }, forwarded: Ref<T> | undefined) { return useCallback((node: T | null) => { localRef.current = node const cleanup = assignRef(forwarded, node) return () => { localRef.current = null if (typeof cleanup === "function") cleanup() else assignRef(forwarded, null) } }, [localRef, forwarded]) } /** * Unavailable for reasons of its own, judged no higher than the collection's root: a shared * ancestor mask hides every item equally and is not the item's fault. Display does not inherit, * so each step's own computed display is positional; visibility does inherit, so the item is * self-hidden only when the root itself still reads visible. */ function unavailableWithin(node: HTMLElement, root: HTMLElement | null) { if (!root || !root.contains(node)) return unavailable(node) if (node.matches(":disabled") || (node.matches("[aria-disabled=true]") && node.tabIndex < 0)) return true const mask = node.closest("[hidden], [aria-hidden=true], [inert]") if (mask && mask !== root && root.contains(mask)) return true const view = node.ownerDocument.defaultView const visibility = view?.getComputedStyle(node).visibility const rootVisibility = view?.getComputedStyle(root).visibility if ((visibility === "hidden" || visibility === "collapse") && rootVisibility !== visibility) return true for (let ancestor: HTMLElement | null = node; ancestor && ancestor !== root; ancestor = ancestor.parentElement) { const style = view?.getComputedStyle(ancestor) if (style?.display === "none" || style?.contentVisibility === "hidden") return true } // A closed details hides through the browser's own slotting, which no light ancestor's // attribute or style carries, so it is checked by element: content below the root inside a // closed details is the item's own fault, except the details' first summary, which stays // rendered. if (insideClosedDetails(node, root)) return true return false } /** * A disabled fieldset suspends a control unless the control lives in that fieldset's first * legend, which HTML leaves enabled; an outer disabled fieldset can still suspend it. */ function fieldsetSuspended(el: HTMLElement) { for (let fieldset = el.closest("fieldset[disabled]"); fieldset; fieldset = fieldset.parentElement ? fieldset.parentElement.closest("fieldset[disabled]") : null) { const legend = Array.prototype.find.call(fieldset.children, (child: Element) => child.tagName === "LEGEND") as Element | undefined if (!legend || !legend.contains(el)) return true } return false } function insideClosedDetails(node: HTMLElement, boundary: HTMLElement | null) { for (let details = node.closest("details:not([open])"); details && details !== boundary && (!boundary || boundary.contains(details)); details = details.parentElement ? details.parentElement.closest("details:not([open])") : null) { const summary = Array.prototype.find.call(details.children, (child: Element) => child.tagName === "SUMMARY") as Element | undefined if (!summary || !summary.contains(node)) return true } return false } function unavailable(node: HTMLElement) { if (!node.isConnected || node.matches(":disabled") || (node.matches("[aria-disabled=true]") && node.tabIndex < 0) || node.closest("[hidden], [aria-hidden=true], [inert]")) return true // Where the platform has it, checkVisibility also sees hiding no ancestor attribute carries, // a closed details element's slotted content among it. if (typeof node.checkVisibility === "function" && !node.checkVisibility()) return true const view = node.ownerDocument.defaultView const visibility = view?.getComputedStyle(node).visibility if (visibility === "hidden" || visibility === "collapse") return true for (let ancestor: HTMLElement | null = node; ancestor; ancestor = ancestor.parentElement) { const style = view?.getComputedStyle(ancestor) if (style?.display === "none" || style?.contentVisibility === "hidden") return true } return false } function sameSettings<T>(left: readonly ChooserRow<T>[], right: readonly ChooserRow<T>[]) { return left.length === right.length && left.every((row, index) => { const other = right[index]! return row.key === other.key && row.visible === other.visible && Object.is(row.width, other.width) }) } function normalizeSettings<T>(rows: readonly ChooserRow<T>[], state: ColumnState, baseRows: readonly ChooserRow<T>[], baseState: ColumnState): ColumnState { const keys = new Set(rows.map((row) => row.key)) const baseByKey = new Map(baseRows.map((row) => [row.key, row])) const order = rows.every((row, index) => row.key === baseRows[index]?.key) ? [...new Set([...baseState.order].reverse())].reverse().filter((key) => keys.has(key)) : rows.map((row) => row.key) const hidden = rows.every((row) => row.visible === baseByKey.get(row.key)?.visible) ? [...new Set(baseState.hidden)].filter((key) => keys.has(key)) : [...new Set(state.hidden)].filter((key) => keys.has(key)) const widths = Object.fromEntries(rows.flatMap((row) => { const source = Object.is(row.width, baseByKey.get(row.key)?.width) ? baseState : state const width = source.widths[row.key] return width === undefined ? [] : [[row.key, width]] })) return { order, widths, hidden } } type ChooserEdit = { kind: "move" | "show" | "hide" | "width"; key: string } | { kind: "reset" } function createAnnouncements() { const empty = { text: "", revision: 0 } let snapshot = empty const listeners = new Set<() => void>() return { getSnapshot: () => snapshot, getServerSnapshot: () => empty, subscribe: (listener: () => void) => { listeners.add(listener) return () => { listeners.delete(listener) if (!listeners.size) snapshot = empty } }, publish: (text: string) => { if (!listeners.size) return snapshot = { text, revision: snapshot.revision + 1 } listeners.forEach((listener) => listener()) }, } } function editAnnouncement(edit: ChooserEdit, rows: readonly ColumnChooserEntry[], presented: readonly ColumnChooserEntry[], labels: ColumnChooserLabels) { if (edit.kind === "reset") return labels.announceReset ?? DEFAULT_COLUMN_CHOOSER_LABELS.announceReset const row = rows.find((row) => row.key === edit.key) if (!row) return "" const index = presented.findIndex((row) => row.key === edit.key) const label = edit.kind === "move" ? (index < 0 ? "announceReorder" : "announceMove") : edit.kind === "show" ? "announceShow" : edit.kind === "hide" ? "announceHide" : "announceResetWidth" const template = labels[label] ?? DEFAULT_COLUMN_CHOOSER_LABELS[label] return template.replace(/\{(name|n|m)\}/g, (_, token: string) => token === "name" ? row.name : String(token === "n" ? index + 1 : presented.length)) } export function ColumnChooser<T>({ columns, columnState, onColumnStateChange, baseState = EMPTY_COLUMN_STATE, presented, rules, labels: labelsProp, children, className, ref, role = "group", tabIndex = -1, "aria-label": ariaLabel, onFocusCapture, onBlurCapture, ...props }: ColumnChooserProps<T>) { const labels = { ...DEFAULT_COLUMN_CHOOSER_LABELS, ...defined(labelsProp) } const ruleWords = labels.ruleWords // Definition-hidden columns keep their settings if application policy exposes them later. const knownColumns = useMemo(() => columns.map((column) => column.hidden ? { ...column, hidden: false } : column), [columns]) const stateRows = useMemo(() => chooserRows(knownColumns, columnState, rules), [knownColumns, columnState, rules]) const baseRows = useMemo(() => chooserRows(knownColumns, baseState), [knownColumns, baseState]) const rows = useMemo(() => { const editable = new Set(columns.filter((column) => !column.hidden).map((column) => column.key)) const baseByKey = new Map(baseRows.map((row) => [row.key, row])) return stateRows.filter((row) => editable.has(row.key)).map(({ column, rules, ...row }) => ({ ...row, resized: !Object.is(row.width, baseByKey.get(row.key)?.width), rules: rules.map((rule) => ({ rule, description: describeRule(rule, [column], "highlight", ruleWords) })) })) }, [columns, stateRows, baseRows, ruleWords]) const [query, setQuery] = useState("") const [dragging, setDragging] = useState<string | null>(null) const drag = useRef<{ key: string; frozen: boolean } | null>(null) const id = useId() const dragType = `application/x-tradecn-column-${id.replace(/[^a-z0-9]/gi, "").toLowerCase()}` const root = useRef<HTMLDivElement>(null) const rootRef = useChooserRef(root, ref) const focused = useRef<HTMLElement | 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 rowByKey = new Map(rows.map((row) => [row.key, row])) const presentedRows = [...new Set(presented ?? shown.map((row) => row.key))].flatMap((key) => { const row = rowByKey.get(key) return row ? [row] : [] }) const neighbors = new Map<string, { up?: string; down?: string }>() for (const frozen of [true, false]) { const group = presentedRows.filter((row) => row.frozen === frozen) group.forEach((row, index) => neighbors.set(row.key, { up: group[index - 1]?.key, down: group[index + 1]?.key })) } const hiddenCount = rows.filter((row) => !row.visible).length const isDefault = sameSettings(stateRows, baseRows) // One presented item carries the tab stop; arrows move between items (roving focus). Items // register their nodes, and whether an explicit tabIndex opted them out, through a store each // item subscribes to for its own ownership alone, so registration, search, and focus moves // re-render the items they touch instead of the collection. const [registrations] = useState<ChooserRegistrations>(() => { const nodes = new Map<string, HTMLElement>() const optedOut = new Set<string>() const keyless = new Set<string>() const listeners = new Set<() => void>() let presented: readonly string[] = [] let active: string | null = null let owner: string | null = null let rootEl: HTMLElement | null = null // The stop stays on the focused coordinated item while it is presented and registered, else // it falls to the first presented key with a usable registered item. const resolve = () => { const usable = (key: string) => { const node = nodes.get(key) return node !== undefined && !optedOut.has(key) && !unavailable(node) } // When nothing is usable — an ancestor masks every item — the stop falls back to an item // that is clean by its own fault, judged no higher than the collection's root: a standing // owner first, so a shared mask never moves the stop, then the focused item, then the // first presented one. Unreachable while hidden, tabbable the moment the mask lifts. const clean = (key: string) => { const node = nodes.get(key) return node !== undefined && !optedOut.has(key) && !unavailableWithin(node, rootEl) } // A registered coordinated item always leaves one stop standing: usable first, then clean // by its own fault, then — when a mask inside the root hides everything — any registered // item at all, the standing owner preferred at every tier. const registered = (key: string) => nodes.has(key) && !optedOut.has(key) const next = (active !== null && presented.includes(active) && usable(active) ? active : presented.find(usable)) ?? (owner !== null && presented.includes(owner) && clean(owner) ? owner : undefined) ?? (active !== null && presented.includes(active) && clean(active) ? active : presented.find(clean)) ?? (owner !== null && presented.includes(owner) && registered(owner) ? owner : undefined) ?? presented.find(registered) ?? null if (next === owner) return false owner = next return true } const publish = () => { for (const listener of listeners) listener() } return { nodes, optedOut, keyless, subscribe: (listener: () => void) => { listeners.add(listener) return () => void listeners.delete(listener) }, owner: () => owner, set(key, node, opted, keylessItem) { if (node) { if (nodes.get(key) === node && optedOut.has(key) === opted && keyless.has(key) === keylessItem) return nodes.set(key, node) if (opted) optedOut.add(key) else optedOut.delete(key) if (keylessItem) keyless.add(key) else keyless.delete(key) } else { if (!nodes.has(key)) return nodes.delete(key) optedOut.delete(key) keyless.delete(key) } if (resolve()) publish() }, present(keys) { presented = keys if (resolve()) publish() }, focus(key) { active = key if (resolve()) publish() }, refresh() { if (resolve()) publish() }, setRoot(node) { rootEl = node if (resolve()) publish() }, } }) // The root is the boundary for judging an item's own availability. useLayoutEffect(() => { registrations.setRoot(root.current) return () => registrations.setRoot(null) }, [registrations]) // A change inside the root can hide the owner without any commit — a collapsible kept // mounted, a class toggle, a details element. The root watches its own subtree and asks the // store to re-resolve; publish-on-change keeps it from looping, and the cost is scoped to // this collection's markup. useEffect(() => { const node = root.current if (!node) return const observer = new MutationObserver(() => registrations.refresh()) observer.observe(node, { attributes: true, subtree: true, attributeFilter: ["hidden", "inert", "aria-hidden", "open", "class", "style"] }) return () => observer.disconnect() }, [registrations]) // Presentation order reaches the store after each commit; the owner moves only when it must. useLayoutEffect(() => { registrations.present(presentedRows.map((row) => row.key)) }) // Again after paint: a modal elsewhere can hold the page aria-hidden through the mount commit, // which reads every item as unusable; by now the transient is gone and the items subscribe. useEffect(() => { registrations.present(presentedRows.map((row) => row.key)) }) const registerItem = useCallback((key: string, node: HTMLElement | null, optedOut = false, keylessItem = false) => registrations.set(key, node, optedOut, keylessItem), [registrations]) const focusStep = (from: string, step: -1 | 1 | "first" | "last") => { const keys = presentedRows.map((row) => row.key) const index = keys.indexOf(from) // Scan past unregistered or unavailable items: a hidden column between two live ones must // not wall off arrows, and Home and End walk inward from their edge to the first usable item. const usable = (key: string | undefined) => { if (key === undefined || registrations.keyless.has(key)) return null const node = registrations.nodes.get(key) return node && !unavailable(node) ? { key, node } : null } let found: { key: string; node: HTMLElement } | null = null if (step === "first" || step === "last") { for (const key of step === "first" ? keys : [...keys].reverse()) if ((found = usable(key))) break } else if (index >= 0) { for (let i = index + step; i >= 0 && i < keys.length; i += step) if ((found = usable(keys[i]))) break } if (!found || found.key === from) return // Navigation can visit an opted-out item, but the roving stop stays with a coordinated one. if (!registrations.optedOut.has(found.key)) registrations.focus(found.key) found.node.focus() } const [announcements] = useState(createAnnouncements) const committed = useRef({ state: columnState, rows: stateRows }) const pending = useRef<{ state: ColumnState; before: ChooserRow<T>[]; rows: ChooserRow<T>[]; edit: ChooserEdit } | null>(null) useLayoutEffect(() => { const previous = committed.current committed.current = { state: columnState, rows: stateRows } if (sameSettings(previous.rows, stateRows)) return const proposed = pending.current // A child layout effect can submit the next edit before this root processes the current commit. if (proposed?.state === columnState && sameSettings(proposed.before, stateRows)) return pending.current = null if (previous.state !== columnState && proposed && sameSettings(proposed.rows, stateRows)) { const message = editAnnouncement(proposed.edit, rows, presentedRows, labels) if (message) announcements.publish(message) } }) const focusFallback = useCallback(() => { const node = root.current if (!node?.isConnected) return const search = [...node.querySelectorAll<HTMLInputElement>("[data-column-search]:not(:disabled):not([hidden])")].find(input => input.closest("[data-slot=tradecn-column-chooser]") === node) const target = search && !unavailable(search) ? search : node target.focus() }, []) const endDrag = useCallback((key?: string) => { if (!drag.current || (key !== undefined && drag.current.key !== key)) return drag.current = null setDragging(null) }, [setDragging]) useLayoutEffect(() => { const current = drag.current if (current && !rows.some((row) => row.key === current.key && row.frozen === current.frozen)) endDrag(current.key) const node = root.current const previous = focused.current if (node && previous && unavailable(previous) && (node.ownerDocument.activeElement === previous || node.ownerDocument.activeElement === node.ownerDocument.body)) focusFallback() }) const change = (next: ColumnState, edit: ChooserEdit) => { if (next === columnState) return const nextRows = chooserRows(knownColumns, next) if (sameSettings(stateRows, nextRows)) return pending.current = { state: columnState, before: stateRows, rows: nextRows, edit } onColumnStateChange(normalizeSettings(nextRows, next, baseRows, baseState)) } const byKey = new Map(rows.map((row): [string, ColumnChooserItemState] => [row.key, { row, canMoveUp: neighbors.get(row.key)?.up !== undefined, canMoveDown: neighbors.get(row.key)?.down !== undefined, dragging: dragging === row.key, setVisible: (visible) => change(setColumnVisible(columnState, row.key, visible), { kind: visible ? "show" : "hide", key: row.key }), move: (delta) => { const target = neighbors.get(row.key)?.[delta === -1 ? "up" : "down"] if (target !== undefined) change(moveColumnTo(stateRows, columnState, row.key, target), { kind: "move", key: row.key }) }, moveToEdge: (edge) => { if (!presentedRows.some((presentedRow) => presentedRow.key === row.key)) return const side = presentedRows.filter((presentedRow) => presentedRow.frozen === row.frozen) const target = edge === "start" ? side[0] : side[side.length - 1] if (target && target.key !== row.key) change(moveColumnTo(stateRows, columnState, row.key, target.key), { kind: "move", key: row.key }) }, resetWidth: () => { const width = baseState.widths[row.key] change(width === undefined ? resetColumnWidth(columnState, row.key) : { ...columnState, widths: { ...columnState.widths, [row.key]: width } }, { kind: "width", key: row.key }) }, }])) const accepts = (key: string, event: DragEvent<HTMLDivElement>) => { const current = drag.current const from = current && byKey.get(current.key)?.row const target = byKey.get(key)?.row return !!(current && from && target && current.key !== key && from.frozen === current.frozen && from.frozen === target.frozen && event.dataTransfer.types.includes(dragType)) } return <ChooserContext value={{ rows, shown, presented: presentedRows, query, setQuery, labels, hiddenCount, isDefault, byKey, focusFallback, endDrag, announcements, registrations, registerItem, focusStep, reset: () => change(baseState, { kind: "reset" }), startDrag: (key, event) => { const row = byKey.get(key)?.row if (!row) return event.dataTransfer.setData(dragType, key) event.dataTransfer.setData("text/plain", key) event.dataTransfer.effectAllowed = "move" drag.current = { key, frozen: row.frozen } setDragging(key) }, dragOver: (key, event) => { if (!accepts(key, event)) return event.preventDefault() event.dataTransfer.dropEffect = "move" }, drop: (key, event) => { const current = drag.current const accepted = accepts(key, event) if (current) endDrag(current.key) if (!accepted || !current) return event.preventDefault() change(moveColumnTo(stateRows, columnState, current.key, key), { kind: "move", key: current.key }) }, }}> <div role={role} tabIndex={tabIndex} aria-label={ariaLabel ?? (props["aria-labelledby"] ? undefined : labels.title)} data-hidden={hiddenCount} className={cn("flex min-w-0 flex-col gap-2 text-xs lining-nums tabular-nums", className)} {...props} data-slot="tradecn-column-chooser" ref={rootRef} onFocusCapture={(event) => { onFocusCapture?.(event) if (event.currentTarget.contains(event.target) && event.target.closest("[data-slot=tradecn-column-chooser]") === event.currentTarget) focused.current = event.target }} onBlurCapture={(event) => { onBlurCapture?.(event) if (!event.currentTarget.contains(event.relatedTarget) && (event.relatedTarget || !unavailable(event.target))) focused.current = null }}>{children}</div> </ChooserContext> } /** Mount once per chooser. Announces committed states matching pending edits; unrelated updates stay silent. */ export function ColumnChooserAnnouncer({ className, ...props }: Omit<ComponentProps<"span">, "children">) { const { announcements } = useChooserContext() const message = useSyncExternalStore(announcements.subscribe, announcements.getSnapshot, announcements.getServerSnapshot) return <span role="status" aria-live="polite" aria-atomic="true" className={cn("sr-only", className)} {...props}>{message.text && <span key={message.revision}>{message.text}</span>}</span> } export function ColumnChooserSearch({ onChange, className, "aria-label": ariaLabel, ...props }: Omit<ComponentProps<typeof Input>, "value" | "defaultValue">) { const { query, setQuery, labels } = useColumnChooser() return <Input aria-label={ariaLabel ?? (props["aria-labelledby"] ? undefined : labels.search)} placeholder={labels.search} spellCheck={false} autoComplete="off" data-column-search="" className={cn("h-7 max-w-56 text-xs md:text-xs", className)} {...props} value={query} onChange={(event) => { onChange?.(event) if (!event.defaultPrevented) setQuery(event.target.value) }} /> } export function ColumnChooserHiddenCount({ className, ...props }: Omit<ComponentProps<"span">, "children">) { const { hiddenCount, labels } = useColumnChooser() return <span data-column-hidden-count={hiddenCount} className={cn("text-muted-foreground", NUMERIC_CLASS, className)} {...props}>{typeof labels.hiddenCount === "function" ? labels.hiddenCount(hiddenCount) : labels.hiddenCount.replace("{n}", hiddenCount.toLocaleString())}</span> } type ActionProps = Omit<ComponentProps<typeof Button>, "children"> & { children: ReactNode } export function ColumnChooserResetAll({ type = "button", variant = "outline", size, disabled, onClick, className, ...props }: ActionProps) { const { isDefault, reset } = useColumnChooser() return <Button type={type} variant={variant} size={size === undefined ? "sm" : size} className={cn(size === undefined && "h-7 px-2 text-xs", className)} {...props} disabled={disabled || isDefault} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) reset() }} /> } export interface ColumnChooserItemProps extends ComponentProps<"div"> { columnKey: string children: ReactNode } export function ColumnChooserItem({ columnKey, ...props }: ColumnChooserItemProps) { const { byKey } = useChooserContext() const item = byKey.get(columnKey) return item ? <ChooserItem key={columnKey} item={item} {...props} /> : null } function isElement(target: EventTarget): target is Element { // Popouts can contain nodes created in either window, even after adoption back to the page. return "nodeType" in target && target.nodeType === 1 } function ownsItemEvent(event: { target: EventTarget; currentTarget: HTMLDivElement }) { return isElement(event.target) && event.currentTarget.contains(event.target) && event.target.closest("[data-slot=tradecn-column-chooser-item]") === event.currentTarget && event.target.closest("[data-slot=tradecn-column-chooser]") === event.currentTarget.closest("[data-slot=tradecn-column-chooser]") } // Widget roles whose keyboard model uses the arrows, per the ARIA authoring practices. A separator // only receives keys when it is focusable, and a focusable separator is a splitter that owns them. const ARROW_OWNING_ROLES = new Set("application columnheader combobox grid gridcell listbox menu menubar menuitem menuitemcheckbox menuitemradio option radio radiogroup row rowheader scrollbar searchbox separator slider spinbutton tab tablist textbox toolbar tree treegrid treeitem".split(" ")) // Controls whose own keys matter: carets, selects, radios, sliders, and ARIA widgets built on // generic elements. Any recognized arrow-owning token counts, a conservative reading of fallback // role lists. A checkbox or plain button owns no arrows. The walk checks the item itself last and // stays inside it, so an editable item, or one given an arrow-owning role, keeps its own keys. function arrowOwningTarget(target: EventTarget, item: HTMLElement) { if (!isElement(target)) return false for (let node: Element | null = target; node; node = node.parentElement) { if (node.matches('input:not([type="checkbox"]):not([type="button"]):not([type="submit"]):not([type="reset"]), textarea, select, [contenteditable]:not([contenteditable="false"])')) return true const role = node.getAttribute("role") if (role && role.toLowerCase().split(/[\t\n\f\r ]+/).some((token) => ARROW_OWNING_ROLES.has(token))) return true if (node === item) break } return false } function ChooserItem({ item, className, ref, role = "group", tabIndex, draggable = true, "aria-label": ariaLabel, onKeyDown, onDragStart, onDragOver, onDrop, onDragEnd, onFocusCapture, onBlurCapture, ...props }: ComponentProps<"div"> & { item: ColumnChooserItemState }) { const { row, dragging, move, moveToEdge, setVisible, resetWidth } = item const { labels, focusFallback, startDrag, dragOver, drop, endDrag, registrations, registerItem, focusStep } = useChooserContext() const isOwner = useSyncExternalStore(registrations.subscribe, () => registrations.owner() === row.key, () => false) // An item made editable, or given an arrow-owning role of its own, keeps every native key: // the handler bows out, the metadata advertises nothing, the roving model opts it out, and // it owns a Tab stop of its own, since no arrow can reach it. const itemKeyless = (props.contentEditable !== undefined && props.contentEditable !== false && props.contentEditable !== "false") || (role ?? "").toLowerCase().split(/[\t\n\f\r ]+/).some((token) => ARROW_OWNING_ROLES.has(token)) const [controls, setControls] = useState<{ visibility: { element: () => HTMLElement | null }[]; resetWidth: { element: () => HTMLElement | null }[] }>({ visibility: [], resetWidth: [] }) const registerControl = useCallback((kind: "visibility" | "resetWidth", element: () => HTMLElement | null) => { // Each registration is its own token: two getter-less declarations share one element // function, and removing one must never remove the other. const token = { element } setControls((current) => ({ ...current, [kind]: [...current[kind], token] })) return () => setControls((current) => ({ ...current, [kind]: current[kind].filter((entry) => entry !== token) })) }, []) // Effectively enabled at press time: a declared control can still sit in a disabled fieldset, // where HTML leaves the first legend's controls enabled; a getter-less declaration counts as // live. const liveControl = (kind: "visibility" | "resetWidth") => controls[kind].some((entry) => { const el = entry.element() return !el || !(el.matches(":disabled") || fieldsetSuspended(el)) }) // The command runs through the live control itself when one is in the DOM: a click carries // every veto — onClick preventDefault, a checkbox's cancel, readOnly — exactly as a pointer // does. Only a getter-less public declaration falls back to the direct call. const runControl = (kind: "visibility" | "resetWidth", direct: () => void) => { for (const entry of controls[kind]) { const el = entry.element() if (el && !(el.matches(":disabled") || fieldsetSuspended(el))) { el.click() return } } direct() } const root = useRef<HTMLDivElement>(null) const rootRef = useChooserRef(root, ref) // Registration is a layout effect, not part of the ref: an inline caller ref changes identity // every render, and re-registering on each change would invalidate the provider's subscription // in a loop. useLayoutEffect(() => { registerItem(row.key, root.current, tabIndex !== undefined || itemKeyless, itemKeyless) return () => registerItem(row.key, null) }, [row.key, registerItem, tabIndex, itemKeyless]) // Any commit of an item can change its availability — hidden, inert, aria-hidden, a class or // style that removes it — so every one re-resolves; the store publishes only when the owner // actually moves, which keeps re-renders from looping. useLayoutEffect(() => { registrations.refresh() }) const focused = useRef<HTMLElement | null>(null) useLayoutEffect(() => { const node = root.current const previous = focused.current if (node && previous && unavailable(previous) && (node.ownerDocument.activeElement === previous || node.ownerDocument.activeElement === node.ownerDocument.body)) node.focus() // Keyed DOM moves can return focus to body without removing the focused control. else if (node && previous && node.contains(previous) && node.ownerDocument.activeElement === node.ownerDocument.body) previous.focus() }) useLayoutEffect(() => { const node = root.current return () => { endDrag(row.key) if (node?.contains(node.ownerDocument.activeElement)) { const ownerDocument = node.ownerDocument queueMicrotask(() => { if (!node.isConnected && ownerDocument.activeElement === ownerDocument.body) focusFallback() }) } } }, [row.key, endDrag, focusFallback]) return <ItemContext value={item}><ControlsContext value={registerControl}><div role={role} tabIndex={tabIndex ?? (itemKeyless || isOwner ? 0 : -1)} draggable={draggable} aria-label={ariaLabel ?? (props["aria-labelledby"] ? undefined : row.name)} aria-description={row.visible ? undefined : labels.hidden} aria-keyshortcuts={itemKeyless ? undefined : `${controls.visibility.length ? "Space " : ""}Alt+ArrowUp Alt+ArrowDown Alt+Home Alt+End${controls.resetWidth.length ? " Delete Backspace" : ""}`} data-column={row.key} data-visible={row.visible ? "true" : "false"} data-frozen={row.frozen || undefined} data-dragging={dragging || undefined} className={cn("group flex min-w-0 items-center gap-2 rounded-sm border border-transparent px-1.5 py-1 outline-none focus-visible:border-foreground data-[dragging]:border-dashed data-[dragging]:border-foreground", !row.visible && "text-muted-foreground", className)} {...props} data-slot="tradecn-column-chooser-item" ref={rootRef} onFocusCapture={(event) => { onFocusCapture?.(event) if (ownsItemEvent(event)) { focused.current = event.target // An explicitly opted-out item never takes the roving stop, even by pointer focus. if (tabIndex === undefined && !itemKeyless) registrations.focus(row.key) } }} onBlurCapture={(event) => { onBlurCapture?.(event) if (!event.currentTarget.contains(event.relatedTarget) && (event.relatedTarget || !unavailable(event.target))) focused.current = null }} onKeyDown={(event) => { onKeyDown?.(event) if (!ownsItemEvent(event) || event.defaultPrevented || event.nativeEvent.isComposing || event.ctrlKey || event.metaKey) return // A control that owns its arrows keeps every arrow chord: word movement, combobox opening, slider steps. if (arrowOwningTarget(event.target, event.currentTarget)) return if (event.altKey) { if (event.shiftKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown" && event.key !== "Home" && event.key !== "End")) return event.preventDefault() event.stopPropagation() if (event.key === "ArrowUp" || event.key === "ArrowDown") move(event.key === "ArrowUp" ? -1 : 1) else moveToEdge(event.key === "Home" ? "start" : "end") return } if (event.shiftKey) return if (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Home" || event.key === "End") { event.preventDefault() event.stopPropagation() focusStep(row.key, event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : event.key === "Home" ? "first" : "last") return } if (event.target !== event.currentTarget) return // Space and Delete run only where the layout renders the matching control, so a visible-only // list cannot hide a column it offers no way to bring back, and a held key toggles once. if (event.key === " " && controls.visibility.length && liveControl("visibility")) { // Every handled Space cancels, or held repeats scroll the page; the toggle runs once. // A control that is effectively disabled where it stands never claims the key at all. event.preventDefault() event.stopPropagation() if (!event.repeat) runControl("visibility", () => setVisible(!row.visible)) } else if ((event.key === "Delete" || event.key === "Backspace") && controls.resetWidth.length && !event.repeat && row.resized && liveControl("resetWidth")) { event.preventDefault() event.stopPropagation() runControl("resetWidth", resetWidth) } }} onDragStart={(event) => { onDragStart?.(event) if (ownsItemEvent(event) && !event.defaultPrevented) startDrag(row.key, event) }} onDragOver={(event) => { onDragOver?.(event) if (ownsItemEvent(event) && !event.defaultPrevented) dragOver(row.key, event) }} onDrop={(event) => { onDrop?.(event) if (!ownsItemEvent(event)) return if (!event.defaultPrevented) drop(row.key, event) else endDrag() }} onDragEnd={(event) => { onDragEnd?.(event) if (ownsItemEvent(event)) endDrag(row.key) }} /></ControlsContext></ItemContext> } /** * Declares that this item renders a control for one of the item-level keys, so Space or Delete * stays live where a caller-owned control covers the command. A disabled control declares * nothing. Pass `element` so a state only the DOM knows — a disabled enclosing fieldset — * suspends the key while the declaration stands; without one the command counts as enabled. */ export function useColumnChooserCommand(kind: "visibility" | "resetWidth", enabled = true, element?: () => HTMLElement | null) { const registerControl = useContext(ControlsContext) useLayoutEffect(() => (enabled ? registerControl?.(kind, element ?? NO_CONTROL_ELEMENT) : undefined), [registerControl, kind, enabled, element]) } export function ColumnChooserVisibility({ onClick, onCheckedChange, tabIndex = -1, ref, "aria-label": ariaLabel, ...props }: Omit<ComponentProps<typeof Checkbox>, "checked" | "defaultChecked" | "indeterminate">) { const { row, setVisible } = useColumnChooserItem() const { labels } = useColumnChooser() const local = useRef<HTMLElement | null>(null) const mergedRef = useChooserRef(local, ref) const element = useCallback(() => local.current, []) // Presence tells the item Space has a rendered counterpart, so the key runs only here — a // read-only checkbox rejects changes from everywhere, the keyboard included. Read untyped: // one base's checkbox types carry readOnly and the other's do not. const readOnly = "readOnly" in props && Boolean((props as { readOnly?: unknown }).readOnly) useColumnChooserCommand("visibility", !props.disabled && !readOnly, element) return <Checkbox ref={mergedRef} tabIndex={tabIndex} aria-label={ariaLabel ?? (props["aria-labelledby"] ? undefined : `${labels.show} ${row.name}`)} {...props} checked={row.visible} onClick={(event) => { onClick?.(event) // Some built-ins separate browser cancellation from their own click handler. if (event.defaultPrevented && "preventBaseUIHandler" in event && typeof event.preventBaseUIHandler === "function") event.preventBaseUIHandler() }} onCheckedChange={(...args) => { onCheckedChange?.(...args) const details: unknown = args.slice(1)[0] if (details && typeof details === "object" && "isCanceled" in details && details.isCanceled) return setVisible(args[0] === true) }} /> } export function ColumnChooserName({ className, ...props }: Omit<ComponentProps<"span">, "children">) { const { row } = useColumnChooserItem() return <span title={row.name} className={cn("min-w-20 flex-1 truncate font-medium", className)} {...props}>{row.name}</span> } export function ColumnChooserFrozen({ className, ...props }: Omit<ComponentProps<typeof Badge>, "children">) { const { row } = useColumnChooserItem() const { labels } = useColumnChooser() return row.frozen ? <Badge variant="outline" data-column-frozen className={cn("h-4 px-1.5 text-xs", className)} {...props}>{labels.frozen}</Badge> : null } export function ColumnChooserRule({ ruleIndex, className, ...props }: Omit<ComponentProps<typeof Badge>, "children"> & { ruleIndex: number }) { const { row } = useColumnChooserItem() const reading = row.rules[ruleIndex] if (!reading) return null const { rule, description } = reading return <Badge variant="outline" data-column-rule={rule.id} title={description} className={cn("h-4 min-w-0 shrink px-1.5 text-xs", (RULE_TONES as readonly string[]).includes(rule.tone ?? "") && RULE_TONE_CLASS[rule.tone as RuleTone], className)} {...props}><span className="min-w-0 truncate">{rule.label?.trim() || description}</span></Badge> } export function ColumnChooserWidth({ className, "aria-label": ariaLabel, ...props }: Omit<ComponentProps<"span">, "children">) { const { row } = useColumnChooserItem() const { labels } = useColumnChooser() // Words in the span rather than a label on it: a screen reader can pass over a label on an element with no role. return <span aria-label={ariaLabel} data-column-width={row.width} className={cn("w-14 shrink-0 text-right text-muted-foreground", NUMERIC_CLASS, className)} {...props}>{ariaLabel || props["aria-labelledby"] ? null : <span className="sr-only">{labels.width} </span>}{row.width} px</span> } export function ColumnChooserResetWidth({ type = "button", variant = "ghost", size, disabled, onClick, className, tabIndex = -1, ref, "aria-label": ariaLabel, ...props }: ActionProps) { const { row, resetWidth } = useColumnChooserItem() const { labels } = useColumnChooser() const local = useRef<HTMLElement | null>(null) const mergedRef = useChooserRef(local, ref) const element = useCallback(() => local.current, []) // Presence tells the item Delete has a rendered counterpart, so the key runs only here — and // the control renders disabled for an unresized column, so the declaration follows both. useColumnChooserCommand("resetWidth", !disabled && row.resized, element) return <Button ref={mergedRef} type={type} variant={variant} size={size === undefined ? "sm" : size} aria-label={ariaLabel ?? (props["aria-labelledby"] ? undefined : `${labels.resetWidth}: ${row.name}`)} aria-hidden={!row.resized || undefined} tabIndex={tabIndex} className={cn(size === undefined && "h-6 px-1.5 text-xs", !row.resized && "invisible", className)} {...props} disabled={disabled || !row.resized} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) resetWidth() }} /> } export function ColumnChooserMove({ direction, type = "button", variant = "ghost", size, disabled, onClick, className, tabIndex = -1, "aria-label": ariaLabel, ...props }: ActionProps & { direction: "up" | "down" }) { const { row, canMoveUp, canMoveDown, move } = useColumnChooserItem() const { labels } = useColumnChooser() return <Button type={type} variant={variant} size={size === undefined ? "sm" : size} aria-label={ariaLabel ?? (props["aria-labelledby"] ? undefined : `${direction === "up" ? labels.moveUp : labels.moveDown}: ${row.name}`)} tabIndex={tabIndex} className={cn(size === undefined && "h-6 px-1.5 text-xs", className)} {...props} disabled={disabled || !(direction === "up" ? canMoveUp : canMoveDown)} onClick={(event) => { onClick?.(event) if (!event.defaultPrevented) move(direction === "up" ? -1 : 1) }} /> }components/ui/ data-grid.tsx import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual" import { cn } from "cn" import { Fragment, memo, useCallback, useEffect, useId, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type MouseEvent, type PointerEvent, type ReactNode, type RefObject, type SyntheticEvent, type UIEvent, } from "react" import { Checkbox } from "@/components/ui/checkbox" import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "@/components/ui/context-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { createFlashMemory, playFlash, useFlash, type FlashMemory } from "@/hooks/use-flash" import { useRow, useRowIds, useStoreMeta, useView } from "@/hooks/use-row-store" import { MONO_NUMERIC_CLASS, NULL_TOKEN, NUMERIC_CLASS } from "@/lib/format" import { applyRules, columnName, compareDirected, compareValues, compileComparator, compileFilter, type AppliedRules, type ReadGridRules, type RuleDecoration } from "@/lib/grid-rules" import type { RowId, RowStore, RowView } from "@/lib/row-store" // A virtualized grid fed by a RowStore one row at a time. // // Rows subscribe to their own store entry, so a delta to one row re-renders one row; header, body, // and every other row stay put. Numeric cells flash by direction through a shared flash memory keyed // by row and column, so a row that scrolls out and back resumes its flash. Rows are positioned by // TanStack Virtual with a fixed height, which makes "keep the viewport pinned while rows arrive // above" exact arithmetic instead of measurement. // // Markup is a div grid with ARIA grid roles rather than a <table>: transform-positioned rows and // per-cell sticky frozen columns do not survive <tbody>. export interface ColumnDef<T> { key: string header: ReactNode /** * The column's name in words, for its header's name, its menu and resize handle, and the column chooser. By default * the header when that is a string; give one to a column whose header is an icon or other markup. */ title?: string width: number minWidth?: number align?: "left" | "right" | "center" frozen?: "left" sortable?: boolean hidden?: boolean /** The value the column shows; also what sorting and flash direction compare. */ accessor: (row: T) => unknown /** Text for the value. Defaults to String(value), NULL_TOKEN for null. */ format?: (value: unknown, row: T) => string /** Custom cell content. The cell still flashes by `accessor` value. `edit` is there when the column is editable and the grid has `onEdit`. */ cell?: (ctx: { row: T; value: unknown; rowId: RowId; edit?: CellEditHandle }) => ReactNode /** Flash on change. Defaults to the preset's variant for numeric columns and off otherwise. */ flash?: false | "fill" | "ring" /** Right-aligned, lining tabular figures in the numeric family, flashes by default. */ numeric?: boolean /** The family a numeric cell sets in: `numeric` (the sans, digits at one width) by default, or `mono` for fraction quotes, so digits and the dash take one width; a tailed quote still runs longer than an untailed one. */ font?: "numeric" | "mono" /** Reads a value typed into a rule in this column's own format (`99-16+` on a 32nds column). Default: a number for a numeric column, the text itself otherwise. */ parse?: (text: string) => unknown /** Lets the cell be edited in place, when the grid has `onEdit`. An edit is a command the server answers, never a local truth. */ edit?: CellEdit<T> } export interface ColumnState { order: string[] widths: Record<string, number> hidden: string[] } /** What stops an edit, in a sentence: what `parse` or `validate` return instead of a value. */ export interface EditProblem { problem: string } export function editProblem(problem: string): EditProblem { return { problem } } export function isEditProblem(value: unknown): value is EditProblem { return typeof value === "object" && value !== null && Object.keys(value).length === 1 && typeof (value as EditProblem).problem === "string" } /** How a commit came: the editor's Enter or Tab, leaving the editor, or a toggle or cell control committing a value; whether the key was held; and which opening of the editor it came from. */ export interface EditCommit { via: "enter" | "tab" | "blur" | "value" /** True for a held key's repeats: Enter or Tab in an editor, or a cell control's commit that passes `{ repeat: true }`. False for a blur, and for a toggle, whose held Space, Enter, or F2 commits once. */ repeat: boolean /** One number per opening of an editor, unique across every grid on the page, so a check that asks a question can keep its answer to that opening. Value commits share session 0. */ session: number /** The value another cell of this row has sent and not yet had back, by column key, so a check reads the row as it shows; undefined while nothing is out there. */ pending?: (key: string) => unknown } // Numbered across every grid, so answers kept per opening never cross from one grid to another, or from a // grid's openings before its store changed to those after. let editorOpenings = 0 /** How a column's cells are edited. Every function gets the row, because a step or a check can depend on it. */ export interface CellEdit<T> { /** Reads the typed text as a value, or says what is wrong with it. */ parse: (text: string, row: T) => unknown /** The text the editor opens with. Default: the column's `format`, else the value as text, blank for null. */ format?: (value: unknown, row: T) => string /** A check on the parsed value before it is committed. The grid also says how the commit came, so a check that asks a question can insist on a fresh Enter for the answer; a direct call may leave it out. */ validate?: (value: unknown, row: T, commit?: EditCommit) => EditProblem | null | undefined /** Up and Down in the editor: the value one step away, ten with Shift. Left out, the arrows do nothing. */ step?: (value: unknown, dir: 1 | -1, big: boolean, row: T) => unknown /** Enter or Space on the focused cell commits this in place of opening an editor: a checkbox column. */ toggle?: (value: unknown, row: T) => unknown /** False keeps this row's cell read-only. Default: editable. */ canEdit?: (row: T) => boolean } /** What `onEdit` is handed: one cell's committed value, with what it replaces. */ export interface EditChange<T> { rowId: RowId key: string value: unknown previous: unknown row: T } /** * Where an edit stands. `editing` while the editor is open; `pending` from the commit until a later batch * brings the row's value to the committed one or `onEdit`'s promise resolves; `rejected` when that promise * rejects, with the server's message and the value that stands. */ export type EditStatus = | { kind: "editing"; text: string; problem: string | null; selectAll: boolean; initial?: string | null; focused?: boolean; prior?: EditStatus; session?: number } | { kind: "pending"; value: unknown; text: string; tracked?: boolean } | { kind: "rejected"; value: unknown; message: string } /** What a `cell` renderer gets for an editable column: the edit's status, and a way to commit a value of its own (a checkbox's). */ export interface CellEditHandle { status: EditStatus | undefined /** Validates and sends a value. Pass `{ repeat: true }` for a commit a held key repeats, so a check that asks a question never takes it for an answer. */ commit: (value: unknown, how?: { repeat?: boolean }) => void open: () => void } export type SortState = { key: string; dir: "asc" | "desc" } | null export type SelectionMode = "none" | "single" | "multi" export type DataGridPreset = "blotter" | "watchlist" | "rfq" | "option-chain" | "tape" | "parameters" export interface RowEnterBehavior { /** Highlight rows as they arrive. */ highlight: boolean /** Keep the first visible row where it is when rows arrive above it. */ pinViewport: boolean /** * Follow the tail: the viewport goes to the end as rows arrive there, until a key, a pointer, or a * scroll away from the end stops it. A "N new" pill then counts the arrivals and returns to the end. * What the `tape` preset does; off elsewhere. */ followTail?: boolean } export interface DataGridPresetConfig { rowHeight: number fontClass: string reorderHoldMs: number flash: "fill" | "ring" selectionMode: SelectionMode rowEnter: RowEnterBehavior announceRowCount: "off" | "debounced" } export const DATA_GRID_PRESETS: Record<DataGridPreset, DataGridPresetConfig> = { blotter: { rowHeight: 24, fontClass: "text-xs", reorderHoldMs: 750, flash: "fill", selectionMode: "multi", rowEnter: { highlight: true, pinViewport: true }, announceRowCount: "debounced" }, watchlist: { rowHeight: 22, fontClass: "text-xs", reorderHoldMs: 0, flash: "fill", selectionMode: "single", rowEnter: { highlight: false, pinViewport: false }, announceRowCount: "off" }, rfq: { rowHeight: 26, fontClass: "text-xs", reorderHoldMs: 1000, flash: "ring", selectionMode: "single", rowEnter: { highlight: true, pinViewport: true }, announceRowCount: "debounced" }, "option-chain": { rowHeight: 20, fontClass: "text-xs", reorderHoldMs: 0, flash: "ring", selectionMode: "none", rowEnter: { highlight: false, pinViewport: false }, announceRowCount: "off" }, tape: { rowHeight: 22, fontClass: "text-xs", reorderHoldMs: 0, flash: "fill", selectionMode: "single", rowEnter: { highlight: true, pinViewport: false, followTail: true }, announceRowCount: "debounced" }, parameters: { rowHeight: 24, fontClass: "text-xs", reorderHoldMs: 0, flash: "ring", selectionMode: "single", rowEnter: { highlight: false, pinViewport: true }, announceRowCount: "off" }, } export const EMPTY_COLUMN_STATE: ColumnState = { order: [], widths: {}, hidden: [] } /** * The grid's own words. A template fills `{name}` with a column's title and `{n}` and `{m}` with counts; a label that * holds a count also takes a function of it, for a plural a template can't hold. */ export interface DataGridLabels { /** A row's selection box. */ selectRow: string /** The selection boxes' column header. */ selection: string /** A header's menu button, `{name}` the column's title. */ columnMenu: string /** A header's resize handle, `{name}` the column's title. */ resizeColumn: string sortAscending: string sortDescending: string clearSort: string moveLeft: string moveRight: string hideColumn: string /** The header menu's reset item, `{n}` the hidden columns. */ resetColumns: string | ((hidden: number) => string) /** What a screen reader hears once the row count settles, `{n}` the rows in view and `{m}` the rows that arrived since. */ rowCount: string | ((rows: number, arrived: number) => string) /** The button that takes a paused tape back to its end, `{n}` the rows that arrived since. */ newRows: string | ((n: number) => string) /** A refused edit whose error carries no message of its own. */ rejected: string /** An open editor's commit after the cell stopped taking edits: the text stays until Escape. */ notEditable: string /** What a screen reader hears on a cell whose edit is sent and not yet back. */ pending: string /** An empty view, unless `emptyState` says otherwise. */ noRows: string } export const DEFAULT_DATA_GRID_LABELS: DataGridLabels = { selectRow: "Select row", selection: "Selection", columnMenu: "{name} column menu", resizeColumn: "Resize {name}", sortAscending: "Sort ascending", sortDescending: "Sort descending", clearSort: "Clear sort", moveLeft: "Move left", moveRight: "Move right", hideColumn: "Hide column", resetColumns: (hidden) => (hidden ? `Reset columns (${hidden.toLocaleString()} hidden)` : "Reset columns"), rowCount: (rows, arrived) => `${rows.toLocaleString()} ${rows === 1 ? "row" : "rows"}${arrived ? `, ${arrived.toLocaleString()} new` : ""}`, newRows: (n) => `${n.toLocaleString()} new`, rejected: "Rejected", notEditable: "This cell can't be edited now.", pending: "Pending", noRows: "No rows", } /** A label's text: a template filled with names and counts, a count's in the reader's locale. */ function fillLabel(template: string, values: Record<string, string | number>): string { return template.replace(/\{(\w+)\}/g, (match, name: string) => { const value = values[name] return value === undefined ? match : typeof value === "number" ? value.toLocaleString() : value }) } export interface DataGridProps<T> { store: RowStore<T> /** A view you own (your comparator, your filter). Without it the grid makes one from `sort` and `filter`. */ view?: RowView<T> columns: ColumnDef<T>[] preset?: DataGridPreset rowHeight?: number overscan?: number /** Only used by the grid's own view. */ filter?: (row: T) => boolean reorderHoldMs?: number columnState?: ColumnState onColumnStateChange?: (state: ColumnState) => void /** Initial uncontrolled columns and the latest Reset columns target. Changes do not overwrite current settings. */ baseState?: ColumnState sort?: SortState onSortChange?: (sort: SortState) => void selection?: ReadonlySet<RowId> onSelectionChange?: (selection: ReadonlySet<RowId>) => void selectionMode?: SelectionMode /** Show a checkbox column (multi only). */ selectionColumn?: boolean focusedRowId?: RowId | null onFocusedRowChange?: (id: RowId | null) => void onRowActivate?: (row: T, id: RowId) => void /** Items for the right-click menu, given the rows it applies to (the selection, or the row under the pointer) and the row it opened on. */ renderContextMenu?: (rows: T[], ids: RowId[], target?: RowId | null) => ReactNode rowEnter?: Partial<RowEnterBehavior> announceRowCount?: "off" | "debounced" /** Accessible name for the grid. */ label: string /** The grid's own words, for another language or house style. */ labels?: Partial<DataGridLabels> emptyState?: ReactNode getRowProps?: (row: T, id: RowId) => RowDecoration | undefined /** * A row's name that holds while its cells tick, such as its symbol. Without it a row is named by its cells, * so a screen reader can read the focused row again at every update. With it, the grid reads the focused * row's cells once, politely, when focus rests on the row. `null` is the same as leaving it out, for a preset * that names its rows. Keep its identity stable between renders, as with `filter`. */ getRowLabel?: ((row: T, id: RowId) => string) | null /** * Rules as data: cells and rows to color, rows to show, and the order, from `grid-rules`. The grid * wires them itself; `cell`, `getRowProps`, `filter`, and `sort` stay yours for what code has to do. * With a `view` of your own the grid ignores `rules.filter` and `rules.sort`, as it ignores `filter`. * Keep the object's identity stable between renders, as with `filter`. */ rules?: ReadGridRules /** * Totals: a sticky row under the body with one value per column named here, given the view's rows * (filtered and ordered, what is on screen). Recomputed once per applied batch, never per frame, and * never flashed. Keep the object's identity stable between renders, as with `filter`. */ footer?: Record<string, (rows: T[]) => string> /** * A cell was edited: send the change on. Editing is on only when this is given, for the columns with * `edit`. The cell shows the committed value as pending until a later batch brings the row's value to it * or the promise you return resolves; a rejected promise keeps the previous value and prints the * message in the cell. Multi-cell paste is not part of this. */ onEdit?: (change: EditChange<T>) => void | Promise<unknown> flashWindowMs?: number className?: string /** Viewport size before layout is measured (tests, server rendering). */ initialRect?: { width: number; height: number } } /** What `getRowProps` may put on a row. A rule's decoration from `grid-rules` is one. */ export interface RowDecoration { className?: string "data-state"?: string "data-rule"?: string "data-tone"?: string "aria-description"?: string } type Resolved<T> = ColumnDef<T> & { width: number; minWidth: number } const EMPTY_SET: ReadonlySet<RowId> = new Set() const SELECT_WIDTH = 32 const ENTER_WINDOW_MS = 1500 // How long focus rests on a row before the grid reads a labelled row's cells. const ROW_READING_REST_MS = 400 // What a live region said stays only as long as a screen reader takes to say it: left in the grid, it would meet a // reader browsing the rows later with words the grid no longer stands by. const SPOKEN_MS = 5000 // ARIA roles that take no name from an author, and the elements that take one without a role. const NAMELESS_ROLES = new Set(["generic", "presentation", "none", "caption", "code", "deletion", "emphasis", "insertion", "paragraph", "strong", "subscript", "superscript", "time"]) const NAMED_TAGS = new Set(["A", "AREA", "BUTTON", "IFRAME", "IMG", "INPUT", "METER", "OUTPUT", "PROGRESS", "SELECT", "TEXTAREA", "svg"]) const TEXT_INPUTS = new Set(["", "text", "search", "tel", "url", "email", "number"]) const takesName = (element: Element) => { const role = element.getAttribute("role")?.trim().split(/\s+/)[0] return role ? !NAMELESS_ROLES.has(role) : NAMED_TAGS.has(element.tagName) } // What a screen reader says for a node inside a cell, after the accessible-name rules for content: a text field's or // a select's value, which is what the row holds; on an element that takes a name, the targets of `aria-labelledby`, // hidden ones included, else its `aria-label`; an image's `alt`; otherwise its content, with hidden parts left out. function spokenText(node: Node, referenced = false): string { if (node.nodeType === 3) return node.textContent ?? "" if (node.nodeType !== 1) return "" const element = node as Element if (!referenced && (element.getAttribute("aria-hidden") === "true" || element.hasAttribute("hidden"))) return "" const tag = element.tagName if (tag === "TEXTAREA" || (tag === "INPUT" && TEXT_INPUTS.has((element as HTMLInputElement).type))) return (element as HTMLInputElement).value if (tag === "SELECT") return Array.from((element as HTMLSelectElement).selectedOptions, (option) => option.text).join(" ") const named = takesName(element) const ids = referenced || !named ? null : element.getAttribute("aria-labelledby")?.trim() if (ids) { const root = element.getRootNode() as Document | ShadowRoot const text = ids.split(/\s+/).map((id) => root.getElementById(id)).map((target) => (target ? spokenText(target, true).trim() : "")).filter(Boolean).join(" ") if (text) return text } const label = element.getAttribute("aria-label")?.trim() if (label && named) return label if (tag === "IMG") return element.getAttribute("alt") ?? "" // Elements side by side read as separate words, as their layout shows them ("Client A", "Tier 1"); text beside // an element runs on, so a word split across inline elements stays one word. let text = "" let last: Node | null = null for (const child of element.childNodes) { const part = spokenText(child, referenced) if (!part) continue if (last?.nodeType === 1 && child.nodeType === 1) text += " " text += part last = child } return text } // A row's cells as one reading: each column's words in order, the selection box left out. function rowReading(row: Element): string { return Array.from(row.children) .filter((cell) => cell.hasAttribute("data-col")) .map((cell) => spokenText(cell).replace(/\s+/g, " ").trim()) .filter(Boolean) .join(", ") } // When a mark's flash window begins: at arrival, or, for a mark a reorder hold // parked, when that hold lapses — frozen at `now` while the hold is still running. interface EnterMark { at: number until: number | null } const enterStart = (mark: EnterMark, now: number) => (mark.until !== null ? Math.max(mark.at, Math.min(mark.until, now)) : mark.at) // Native controls and focus targets own interaction regardless of an authored role. const ROW_CONTROLS = 'a[href], button, input, select, textarea, label, summary, audio[controls], video[controls], iframe, object, embed, [contenteditable]:not([contenteditable="false"]), [tabindex], [data-grid-interaction="control"]' const CONTROL_ROLES = new Set("button link checkbox radio switch combobox listbox option textbox searchbox slider spinbutton scrollbar tab tablist toolbar menu menubar menuitem menuitemcheckbox menuitemradio tree treeitem radiogroup doc-backlink doc-biblioref doc-glossref doc-noteref".split(" ")) // Include noninteractive roles: in "status button", status wins. Abstract roles cannot win a fallback list. const ARIA_ROLES = new Set([ ..."alert alertdialog application article banner blockquote button caption cell checkbox code columnheader combobox comment complementary contentinfo definition deletion dialog directory document emphasis feed figure form generic grid gridcell group heading image img insertion link list listbox listitem log main mark marquee math menu menubar menuitem menuitemcheckbox menuitemradio meter navigation none note option paragraph presentation progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox sectionfooter sectionheader separator slider spinbutton status strong subscript suggestion superscript switch tab table tablist tabpanel term textbox time timer toolbar tooltip tree treegrid treeitem".split(" "), ..."document object symbol".split(" ").map(role => `graphics-${role}`), ..."abstract acknowledgments afterword appendix backlink biblioentry bibliography biblioref chapter colophon conclusion cover credit credits dedication endnote endnotes epigraph epilogue errata example footnote foreword glossary glossref index introduction noteref notice pagebreak pagefooter pageheader pagelist part preface prologue pullquote qna subtitle tip toc".split(" ").map(role => `doc-${role}`), ]) function roleOf(element: Element): string | undefined { const value = element.getAttribute("role") if (!value) return for (const token of value.split(/[\t\n\f\r ]+/)) { if (!/^[A-Za-z-]+$/.test(token)) continue const role = token.toLowerCase() if (ARIA_ROLES.has(role)) return role } } function independentGrid(element: Element): boolean { const role = roleOf(element) return role === "grid" || role === "treegrid" || element.getAttribute("data-grid-interaction") === "independent" } function gridTarget(root: HTMLElement | null, target: EventTarget, path?: EventTarget[]): Element | undefined { const element = target as Element // Portals can dispatch both an inner-target pass and a host-retargeted pass. if (!root?.contains(element)) return undefined if (path) { for (const entry of path) { if (entry === root) return element if ((entry as Node).nodeType === 1 && independentGrid(entry as Element)) return undefined } } else { for (let node: Element | null = element; node; node = node.parentElement) { if (node === root) return element if (independentGrid(node)) return undefined } } return undefined } function pathMatches(root: HTMLElement | null, path: EventTarget[], matches: (element: Element) => boolean): boolean { for (const entry of path) { if (entry === root) return false if ((entry as Node).nodeType === 1 && matches(entry as Element)) return true } return false } function rejectMenuStart(root: HTMLElement | null, event: SyntheticEvent) { const native = event.nativeEvent if (event.type === "contextmenu" && root && native.composedPath().includes(root)) { // Base's document listener also claims contained context menus, including at a hydrated document root. event.stopPropagation() if (native.currentTarget === root.ownerDocument) native.stopImmediatePropagation() return } // React's dispatcher checks this query before visiting an ancestor. This compatibility shim // skips the menu trigger without blocking native listeners used by dialog dismissal and touch tracking. event.isPropagationStopped = () => true } function useControllable<V>(value: V | undefined, onChange: ((v: V) => void) | undefined, initial: V): [V, (v: V) => void] { const [internal, setInternal] = useState(initial) const controlled = value !== undefined const current = controlled ? value : internal const set = useCallback( (v: V) => { if (!controlled) setInternal(v) onChange?.(v) }, [controlled, onChange], ) return [current, set] } /** Nulls last, numbers numerically, everything else as text. `compareValues` in `grid-rules`, kept here by name. */ export function compareForSort(a: unknown, b: unknown): number { return compareValues(a, b) } /** The header's sort as a comparator, a null last whichever way it runs. */ export function comparatorFor<T>(columns: ColumnDef<T>[], sort: SortState): ((a: T, b: T) => number) | undefined { if (!sort) return undefined const col = columns.find((c) => c.key === sort.key) if (!col) return undefined return (x, y) => compareDirected(col.accessor(x), col.accessor(y), sort.dir) } /** One comparator after another: the first that tells two rows apart decides. Undefined when there is none. */ function chainComparators<T>(...comparators: (((a: T, b: T) => number) | undefined)[]): ((a: T, b: T) => number) | undefined { const list = comparators.filter((c): c is (a: T, b: T) => number => c !== undefined) if (!list.length) return undefined if (list.length === 1) return list[0] return (a, b) => { for (const compare of list) { const c = compare(a, b) if (c !== 0) return c } return 0 } } /** Order, hide, and size columns by state; frozen columns lead. */ export function resolveColumns<T>(columns: ColumnDef<T>[], state: ColumnState): Resolved<T>[] { const rank = new Map(state.order.map((k, i) => [k, i])) const hidden = new Set(state.hidden) const ordered = columns .map((c, i) => ({ c, r: rank.get(c.key) ?? 1e6 + i })) .sort((a, b) => a.r - b.r) .map(({ c }) => c) .filter((c) => !c.hidden && !hidden.has(c.key)) const frozen = ordered.filter((c) => c.frozen === "left") const rest = ordered.filter((c) => c.frozen !== "left") return [...frozen, ...rest].map((c) => { const minWidth = c.minWidth ?? 48 return { ...c, minWidth, width: Math.max(minWidth, state.widths[c.key] ?? c.width) } }) } /** * CSV of the given rows and columns, RFC 4180 quoting, leaving out a column whose definition says `hidden`. Pass * `resolveColumns(columns, columnState)` to export the columns as the grid shows them, in its order and without the * ones the column state hides. */ export function exportCsv<T>(store: RowStore<T>, columns: ColumnDef<T>[], ids: readonly RowId[]): string { const cols = columns.filter((c) => !c.hidden) // Spreadsheets execute cells led by = + - @ or a tab or carriage return: free-text // fields — a message, an author — must not run as formulas when the file is opened. // The apostrophe prefix is the spreadsheet convention for "this is text". // A sign-led field is data only when the WHOLE field is a number: a leading digit // does not stop a spreadsheet from evaluating what follows it. const wholeNumber = /^[+-]?(\d+(\.\d*)?|\.\d+)(e[+-]?\d+)?$/i const neutral = (s: string) => (/^[=@\t\r]/.test(s) || (/^[+-]/.test(s) && !wholeNumber.test(s)) ? `'${s}` : s) // A finite numeric accessor usually formats to a number — +1,234.50 from a signed // formatter included — and number-shaped text is data. The sign rule still applies // to numeric text that does not read as one number, and the hard leads always // neutralize: a formatter may emit arbitrary text. const numberShaped = /^[+-]?(\d{1,3}(,\d{3})+|\d+)(\.\d*)?$|^[+-]?\.\d+$|^[+-]?\d+(\.\d*)?e[+-]?\d+$/i const esc = (s: string, numeric = false) => { const t = numeric ? (/^[=@\t\r]/.test(s) || (/^[+-]/.test(s) && !numberShaped.test(s)) ? `'${s}` : s) : neutral(s) return /[",\r\n]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t } const header = cols.map((c) => esc(columnName(c))).join(",") const lines: string[] = [] for (const id of ids) { const row = store.getRow(id) if (row === undefined) continue lines.push( cols .map((c) => { const v = c.accessor(row) const numeric = typeof v === "number" && Number.isFinite(v) const text = c.format ? c.format(v, row) : v === null || v === undefined ? "" : String(v) // The library's formatters print a typographic minus, which a spreadsheet reads as text: a number's sign goes // out as the hyphen-minus, so a sum over the column counts every negative. // Only where the cell then reads as one number: a unit after it, as in -2.5mm, would be text either way. const ascii = numeric ? text.replace(/\u2212/g, "-") : text return esc(numeric && numberShaped.test(ascii) ? ascii : text, numeric) }) .join(","), ) } return [header, ...lines].join("\r\n") + "\r\n" } // Editing. One editor at a time, its status kept in a tracker the cells subscribe to one key at a time, // so typing in or settling one cell re-renders that cell and nothing else; opening, moving, or closing the // editor also renders the grid once, which keeps the row being edited rendered. The controller is one // object for the grid's life; its callbacks read the latest props through a ref. const cellKey = (rowId: RowId, key: string) => `${rowId}\u0000${key}` interface EditTracker { get(key: string): EditStatus | undefined set(key: string, status: EditStatus | undefined): void subscribe(key: string, cb: () => void): () => void /** The cell whose editor is open. */ editing(): string | null /** Called whenever the open editor moves to another cell, or closes. */ subscribeOpen(cb: () => void): () => void } function createEditTracker(): EditTracker { const statuses = new Map<string, EditStatus>() const listeners = new Map<string, Set<() => void>>() const openListeners = new Set<() => void>() let open: string | null = null return { get: (key) => statuses.get(key), set(key, status) { const was = open if (status) statuses.set(key, status) else statuses.delete(key) if (status?.kind === "editing") open = key else if (open === key) open = null const set = listeners.get(key) if (set) for (const cb of set) cb() if (open !== was) for (const cb of openListeners) cb() }, subscribe(key, cb) { let set = listeners.get(key) if (!set) listeners.set(key, (set = new Set())) set.add(cb) return () => { set!.delete(cb) if (!set!.size) listeners.delete(key) } }, editing: () => open, subscribeOpen(cb) { openListeners.add(cb) return () => void openListeners.delete(cb) }, } } interface EditController { tracker: EditTracker reconcileColumns(keys: ReadonlySet<string>): void unmountEditor(key: string, input: HTMLInputElement): void editorFocusFell: { current: boolean } markFocused(rowId: RowId, key: string): void rowOf(k: string): RowId | undefined forget(k: string): void open(rowId: RowId, key: string, typed?: string): void type(rowId: RowId, key: string, text: string): void /** Parse, check, and send. `move` opens the next (1) or previous (-1) editable cell of the row after. */ commit(rowId: RowId, key: string, how: EditCommit, move?: 1 | -1): void /** A value a cell renderer settled itself (a checkbox): sent as it is. */ commitValue(rowId: RowId, key: string, value: unknown, repeat?: boolean): void cancel(rowId: RowId, key: string): void step(rowId: RowId, key: string, dir: 1 | -1, big: boolean): void /** Focus left the editor: commit what parses, drop what does not. */ blur(rowId: RowId, key: string): void /** A later batch brought the row's value to the committed one. */ settle(rowId: RowId, key: string): void settlePrior(rowId: RowId, key: string): void } function editText<T>(col: ColumnDef<T>, value: unknown, row: T): string { if (col.edit?.format) return col.edit.format(value, row) if (value === null || value === undefined) return "" return col.format ? col.format(value, row) : String(value) } // Alt with a letter: the letter typed, or the key's place where Option composes another character, as "ß" for Option+S on // a Mac. AltGr typing a character is typing, not a shortcut. function altLetter(e: KeyboardEvent<HTMLElement>): string | null { if (!e.altKey) return null // Windows reports AltGr as Ctrl and Alt, whatever letter its key leaves; Firefox on a Mac reports AltGraph for Option // alone, which is no typing key. if (e.getModifierState("AltGraph") && e.ctrlKey) return null if (/^[a-z]$/i.test(e.key)) return e.key.toLowerCase() return /^Key[A-Z]$/.test(e.code) ? e.code.slice(3).toLowerCase() : null } // A row id as an id attribute can carry it: letters, digits, and hyphens as they stand, anything else, an underscore // included, as an underscore and its four hex digits. No space splits an IDREF list, no two row ids meet, and no // string, a lone surrogate included, throws. function idPart(id: RowId): string { return id.replace(/[^A-Za-z0-9-]/g, (unit) => `_${unit.charCodeAt(0).toString(16).padStart(4, "0")}`) } /** A data cell's DOM id: the grid's, the column's place, and the row's. */ function cellDomId(uid: string, rowId: RowId, index: number): string { return `${uid}-c${index}-${idPart(rowId)}` } function messageOf(error: unknown, fallback: string): string { // An Error with no words, as `new Error(response.statusText)` is over HTTP/2, says the fallback too, and so does // one whose message can't become text: the cell leaves pending whatever the rejection carries. if (typeof error === "string") return error.trim() || fallback if (!(error instanceof Error)) return fallback try { return String(error.message ?? "").trim() || fallback } catch { return fallback } } /** The tree an element's focus is read in: its shadow root's, else its document's, whose active element is a shadow host while focus is inside one. */ function focusRoot(el: Node): Document | ShadowRoot { return el.getRootNode() as Document | ShadowRoot } /** Focus fell when the element that held it left: to the body, or to a shadow root's host with nothing in that root focused. */ function focusFell(root: Document | ShadowRoot, doc: Document, gone?: Element): boolean { const active = doc.activeElement if (active === null || active === doc.body || (gone !== undefined && active === gone)) return true return root !== doc && active === (root as ShadowRoot).host && (root.activeElement === null || root.activeElement === gone) } function canEditCell<T>(col: ColumnDef<T> | undefined, row: T | undefined): col is ColumnDef<T> & { edit: CellEdit<T> } { return Boolean(col?.edit) && row !== undefined && (col!.edit!.canEdit?.(row) ?? true) } const noopSubscribe = () => () => {} const undefinedStatus = () => undefined const FILL_CLASSES = "data-[direction=up]:bg-up-soft data-[direction=down]:bg-down-soft data-[direction=flat]:bg-flat-soft" const RING_CLASSES = "data-[direction=up]:shadow-[inset_0_0_0_1px_var(--up)] data-[direction=down]:shadow-[inset_0_0_0_1px_var(--down)] data-[direction=flat]:shadow-[inset_0_0_0_1px_var(--flat)]" // A selected row is marked by a bar at the start of its first cell, not filled: a fill behind a row's up and down text // can drop it below 4.5 to 1 in the light themes. The first cell is the selection box's when there is one, and a frozen // first column keeps the bar in view as the grid scrolls sideways. const SELECTED_BAR = "before:pointer-events-none before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary" function alignClass(col: { align?: "left" | "right" | "center"; numeric?: boolean }) { const a = col.align ?? (col.numeric ? "right" : "left") return a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left" } interface CellProps<T> { col: Resolved<T> row: T rowId: RowId id: string colIndex: number left: number | undefined memory: FlashMemory flashVariant: "fill" | "ring" flashWindowMs: number focusedCol: boolean /** The first cell of a selected row with no selection box draws the selection's bar. */ selectedBar: boolean rules: AppliedRules<T> | null /** The row's own rule, for a frozen cell to paint: its opaque background would otherwise cut a gap in the row's tint. */ rowRule: RuleDecoration | undefined rowClassName: string | undefined /** The grid's editing, or null when it has no `onEdit`. */ edits: EditController | null pendingLabel: string /** The focused row's cell in the focused column: the keyboard's cell. */ active: boolean } interface CellEditorProps { rowId: RowId colKey: string label: string status: Extract<EditStatus, { kind: "editing" }> numeric: boolean className: string edits: EditController } // The editor: a bare input the size of the cell. Enter commits, Escape reverts, Tab and Shift+Tab commit // and move along the row, bare Up and Down step. With a modifier held the arrows are not its business: // they belong to whoever listens above it, the way a ticket's mod+up reaches its registry from a field. function CellEditor({ rowId, colKey, label, status, numeric, className, edits }: CellEditorProps) { const problemId = useId() const ref = useRef<HTMLInputElement>(null) const selectAll = status.selectAll useLayoutEffect(() => { const input = ref.current return () => { if (input) edits.unmountEditor(colKey, input) } }, [edits, colKey]) const focused = status.focused ?? false useEffect(() => { const el = ref.current if (!el) return // Once per OPEN, not per mount: a remount of the open editor (a Suspense or Activity // reconnect, or a grid that had no height getting one) must not take focus a second time, // while a fresh open() of a mounted cell resets the flag and focuses again. StrictMode's // rehearsal runs this twice before the flag is read back, focusing twice, harmlessly. if (focused) return edits.markFocused(rowId, colKey) el.focus({ preventScroll: true }) if (selectAll) el.select() else el.setSelectionRange(el.value.length, el.value.length) // A keystroke must not reselect the text under the hand. // eslint-disable-next-line react-hooks/exhaustive-deps }, [focused]) const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { const mod = e.metaKey || e.ctrlKey || e.altKey switch (e.key) { case "Enter": e.preventDefault() edits.commit(rowId, colKey, { via: "enter", repeat: e.repeat, session: status.session ?? 0 }) return case "Escape": e.preventDefault() edits.cancel(rowId, colKey) return case "Tab": e.preventDefault() edits.commit(rowId, colKey, { via: "tab", repeat: e.repeat, session: status.session ?? 0 }, e.shiftKey ? -1 : 1) return case "ArrowUp": case "ArrowDown": if (mod) return e.preventDefault() edits.step(rowId, colKey, e.key === "ArrowUp" ? 1 : -1, e.shiftKey) return } } return ( <> <input ref={ref} data-cell-editor="" data-numeric={numeric ? "" : undefined} aria-label={label} aria-invalid={status.problem ? true : undefined} aria-describedby={status.problem ? problemId : undefined} title={status.problem ?? undefined} value={status.text} autoComplete="off" spellCheck={false} // The text keyboard, not the decimal pad: a phone's pad has no minus, and a numeric column can go below zero. className={cn("h-full w-full min-w-0 bg-background px-2 text-inherit outline-none ring-2 ring-inset ring-foreground aria-invalid:ring-destructive", numeric && "text-right", className)} onChange={(e) => edits.type(rowId, colKey, e.target.value)} onKeyDown={onKeyDown} onBlur={() => edits.blur(rowId, colKey)} /> {/* The problem in words, over the cell above, where a keyboard user sees it: the ring alone says only that something is wrong. */} {status.problem && ( <span id={problemId} data-cell-problem="" className="pointer-events-none absolute bottom-full left-0 z-10 mb-px w-max max-w-[min(24rem,80vw)] rounded-sm border border-destructive bg-background px-1.5 py-0.5 text-xs font-normal whitespace-normal text-destructive not-italic shadow-sm"> {status.problem} </span> )} </> ) } function Cell<T>({ col, row, rowId, id, colIndex, left, memory, flashVariant, flashWindowMs, focusedCol, selectedBar, rules, rowRule, rowClassName, edits, pendingLabel, active }: CellProps<T>) { const value = col.accessor(row) const ref = useRef<HTMLDivElement>(null) const flash = col.flash ?? (col.numeric ? flashVariant : false) const key = cellKey(rowId, col.key) useFlash(ref, value, { memory, cellKey: key, variant: flash || "fill", windowMs: flashWindowMs, disabled: !flash }) // Editable cells follow their own entry in the tracker; the rest subscribe to nothing. const editable = edits !== null && Boolean(col.edit) const tracker = editable ? edits.tracker : null const subscribe = useCallback((cb: () => void) => (tracker ? tracker.subscribe(key, cb) : noopSubscribe()), [tracker, key]) const get = useCallback(() => (tracker ? tracker.get(key) : undefined), [tracker, key]) const status = useSyncExternalStore(subscribe, get, undefinedStatus) // A later batch brought the row's value to the committed one: the edit is settled, and the cell reads the store again. // A covered pending settles the same way, or a close after the store moved on would restore a pending nothing can clear. const settled = status?.kind === "pending" && Object.is(status.value, value) const coveredSettled = status?.kind === "editing" && status.prior?.kind === "pending" && Object.is(status.prior.value, value) useEffect(() => { if (settled) edits?.settle(rowId, col.key) else if (coveredSettled) edits?.settlePrior(rowId, col.key) }, [settled, coveredSettled, edits, rowId, col.key]) const handle = useMemo<CellEditHandle | undefined>( () => (editable ? { status, commit: (next, how) => edits!.commitValue(rowId, col.key, next, how?.repeat), open: () => edits!.open(rowId, col.key) } : undefined), [editable, status, edits, rowId, col.key], ) const numericClass = col.numeric ? (col.font === "mono" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS) : "" const header = columnName(col) const editing = status?.kind === "editing" const pendingText = status?.kind === "pending" && !col.cell ? status.text : null const content = editing ? null : pendingText !== null ? pendingText : col.cell ? col.cell({ row, value, rowId, edit: handle }) : col.format ? col.format(value, row) : value === null || value === undefined ? NULL_TOKEN : String(value) // A matched rule names itself on the cell and says its words to a screen reader; the color is the hint. const rule = rules?.cell(col.key, row) const rejected = status?.kind === "rejected" ? status.message : null return ( <div ref={ref} id={id} role="gridcell" aria-colindex={colIndex + 1} // A pending edit says so beside a rule's words: the italics and dotted underline are what a sighted reader sees. aria-description={rejected ?? ([status?.kind === "pending" ? pendingLabel : undefined, rule?.["aria-description"]].filter(Boolean).join(", ") || undefined)} aria-readonly={editable ? !canEditCell(col, row) : undefined} data-col={col.key} data-numeric={col.numeric ? "" : undefined} data-focused-col={focusedCol || undefined} data-rule={rule?.["data-rule"]} data-tone={rule?.["data-tone"]} data-editable={editable && canEditCell(col, row) ? "" : undefined} data-editing={editing || undefined} data-pending={status?.kind === "pending" || undefined} data-rejected={rejected ?? undefined} title={rejected ?? (typeof content === "string" ? content : undefined)} className={cn( "flex h-full min-w-0 items-center", editing ? "relative overflow-visible" : "truncate px-2", alignClass(col), col.numeric && cn("justify-end", numericClass), col.align === "center" && "justify-center", flash === "ring" ? RING_CLASSES : flash === "fill" ? FILL_CLASSES : undefined, // Before the frozen position, so a frozen cell stays sticky under the bar's `relative`. selectedBar && cn("relative", SELECTED_BAR), left !== undefined && "sticky z-10 bg-background", // On a frozen cell the column's tint is a background image, so the cell keeps its opaque background under it. focusedCol && (left !== undefined ? "bg-[linear-gradient(color-mix(in_oklab,var(--muted)_50%,transparent),color-mix(in_oklab,var(--muted)_50%,transparent))]" : "bg-muted/50"), // The underline outlasts a rule's tint, which sets the text color. status?.kind === "pending" && "text-muted-foreground italic underline decoration-dotted underline-offset-2", rejected !== null && "text-destructive", // A frozen cell paints an opaque background over the row's, so it takes the row's own decoration too: a rule's // tint, and your class from getRowProps, a tint or a mark, or the row would show a hole at the frozen columns. // A cell rule's own decoration comes last, so it wins over the row's where both set the same thing. left !== undefined ? cn(rowRule?.className, rowClassName, rule?.className) : rule?.className, // The keyboard's cell carries its own mark: the column's tint is too faint to find it by. active && !editing && "outline-2 -outline-offset-2 outline-foreground", )} style={left !== undefined ? { left } : undefined} > {editing ? ( <CellEditor rowId={rowId} colKey={col.key} label={header} status={status} numeric={Boolean(col.numeric)} className={numericClass} edits={edits!} /> ) : ( // Beside a refusal the value keeps its digits and the message is what gives way. <span className={cn("truncate", rejected !== null && "shrink-0")}>{content}</span> )} {rejected !== null && <span data-edit-message="" className="ml-1 truncate">{rejected}</span>} </div> ) } interface RowProps<T> { store: RowStore<T> id: RowId index: number domId: string /** The grid's id, which its cells' ids start with. */ uid: string columns: Resolved<T>[] template: string lefts: (number | undefined)[] width: number height: number start: number selected: boolean focused: boolean focusedColKey: string | null selectionColumn: boolean selectable: boolean selectLabel: string onToggle: (id: RowId) => void memory: FlashMemory flashVariant: "fill" | "ring" flashWindowMs: number entered: Map<RowId, EnterMark> highlightEnter: boolean getRowProps?: (row: T, id: RowId) => RowDecoration | undefined getRowLabel?: ((row: T, id: RowId) => string) | null rules: AppliedRules<T> | null edits: EditController | null pendingLabel: string } function RowInner<T>(p: RowProps<T>) { const row = useRow(p.store, p.id) const ref = useRef<HTMLDivElement>(null) useLayoutEffect(() => { const mark = p.entered.get(p.id) if (p.highlightEnter && mark !== undefined && ref.current) { const now = Date.now() // A mark still parked by a running hold keeps waiting: its window opens at // the release, and the grid's release sweep plays it — overscan rows mount // while parked and must not burn their flash off screen. if (mark.until !== null && now < mark.until) return p.entered.delete(p.id) // A row that arrived off-view and scrolls in later flashes only the remainder // of its window — measured from the hold release when one parked it — and an // arrival from minutes ago is not news. const elapsed = now - enterStart(mark, now) if (elapsed < ENTER_WINDOW_MS) playFlash(ref.current, "flat", { windowMs: ENTER_WINDOW_MS, variant: "fill", elapsed }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) if (row === undefined) return null const extra = p.getRowProps?.(row, p.id) // A row rule paints under whatever your own props say: yours are read last, so they win a class. const rule = p.rules?.getRowProps(row) const rowLabel = p.getRowLabel?.(row, p.id) || undefined // Each cell's id once per render, for the row's label and the cell alike. const cellIds = p.columns.map((_, i) => cellDomId(p.uid, p.id, i)) return ( <div ref={ref} role="row" id={p.domId} data-row-id={p.id} data-state={extra?.["data-state"]} data-rule={extra?.["data-rule"] ?? rule?.["data-rule"]} data-tone={extra?.["data-tone"] ?? rule?.["data-tone"]} // Your words for the row, then a rule's, so neither hides the other. aria-description={[extra?.["aria-description"], rule?.["aria-description"]].filter(Boolean).join(", ") || undefined} aria-label={rowLabel} // Without a label of its own a row is named by its data cells, never by its selection box. aria-labelledby={rowLabel ? undefined : cellIds.join(" ") || undefined} data-focused={p.focused || undefined} aria-rowindex={p.index + 2} aria-selected={p.selectable ? p.selected : undefined} className={cn( // An editor's problem shows over the row above, the sticky header included. "absolute top-0 left-0 grid items-stretch border-b border-border/60 has-[[data-cell-problem]]:z-40", FILL_CLASSES, // Focus marks in the foreground, which clears 3 to 1 on every surface in every palette; shadcn's default ring // doesn't against white. p.focused && "outline-1 -outline-offset-1 outline-foreground", rule?.className, extra?.className, )} style={{ gridTemplateColumns: p.template, width: p.width, height: p.height, transform: `translateY(${p.start}px)` }} > {p.selectionColumn && ( <div role="gridcell" aria-colindex={1} className={cn("sticky left-0 z-10 flex items-center justify-center bg-background", p.selected && SELECTED_BAR)}> <Checkbox tabIndex={-1} checked={p.selected} onCheckedChange={() => p.onToggle(p.id)} aria-label={p.selectLabel} /> </div> )} {p.columns.map((col, i) => ( <Cell key={col.key} col={col} row={row} rowId={p.id} id={cellIds[i]!} colIndex={i + (p.selectionColumn ? 1 : 0)} left={p.lefts[i]} memory={p.memory} flashVariant={p.flashVariant} flashWindowMs={p.flashWindowMs} focusedCol={p.focusedColKey === col.key} selectedBar={p.selected && !p.selectionColumn && i === 0} rules={p.rules} rowRule={rule} rowClassName={extra?.className} edits={p.edits} pendingLabel={p.pendingLabel} active={p.focused && p.focusedColKey === col.key} /> ))} </div> ) } const Row = memo(RowInner) as typeof RowInner interface FooterProps<T> { store: RowStore<T> ids: readonly RowId[] columns: Resolved<T>[] template: string lefts: (number | undefined)[] width: number height: number footer: Record<string, (rows: T[]) => string> selectionColumn: boolean rowIndex: number } // The totals row. Its own component on the store's meta, which changes once per applied batch: the // view's rows are read once and each column's function runs once, at the feed's pace and never per // frame or per row. Nothing here flashes. function FooterInner<T>(p: FooterProps<T>) { const meta = useStoreMeta(p.store) const values = useMemo(() => { void meta.version const rows: T[] = [] for (const id of p.ids) { const row = p.store.getRow(id) if (row !== undefined) rows.push(row) } const out = new Map<string, string>() for (const col of p.columns) { const total = p.footer[col.key] if (total) out.set(col.key, total(rows)) } return out }, [meta.version, p.ids, p.columns, p.footer, p.store]) return ( <div role="row" aria-rowindex={p.rowIndex} data-grid-footer="" className="sticky bottom-0 z-20 mt-auto grid border-t border-border bg-background font-medium" style={{ gridTemplateColumns: p.template, width: p.width, height: p.height }}> {p.selectionColumn && <div role="gridcell" aria-colindex={1} className="sticky left-0 z-10 bg-background" />} {p.columns.map((col, i) => { const left = p.lefts[i] const text = values.get(col.key) return ( <div key={col.key} role="gridcell" aria-colindex={i + (p.selectionColumn ? 1 : 0) + 1} data-col={col.key} data-numeric={col.numeric ? "" : undefined} title={text} className={cn("flex h-full min-w-0 items-center truncate px-2", alignClass(col), col.numeric && cn("justify-end", col.font === "mono" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS), col.align === "center" && "justify-center", left !== undefined && "sticky z-10 bg-background")} style={left !== undefined ? { left } : undefined} > <span className="truncate">{text}</span> </div> ) })} </div> ) } const FooterRow = memo(FooterInner) as typeof FooterInner export function DataGrid<T>(props: DataGridProps<T>) { const { store, columns, label, className, emptyState, getRowProps, getRowLabel, onRowActivate, renderContextMenu, footer, onEdit, initialRect, overscan = 8, baseState = EMPTY_COLUMN_STATE, } = props const preset = DATA_GRID_PRESETS[props.preset ?? "blotter"] const rowHeight = props.rowHeight ?? preset.rowHeight const selectionMode = props.selectionMode ?? preset.selectionMode const selectionColumn = Boolean(props.selectionColumn) && selectionMode === "multi" const rowEnter = { ...preset.rowEnter, ...props.rowEnter } const announceRowCount = props.announceRowCount ?? preset.announceRowCount // A window that isn't a finite length of zero or more would throw from the flash animation: the default stands in. const flashWindowMs = Number.isFinite(props.flashWindowMs) && props.flashWindowMs! >= 0 ? props.flashWindowMs! : 900 // A word left undefined keeps its default. const labels = useMemo(() => ({ ...DEFAULT_DATA_GRID_LABELS, ...Object.fromEntries(Object.entries(props.labels ?? {}).filter(([, word]) => word !== undefined)) }) as DataGridLabels, [props.labels]) // The words a timer says later, read when it fires. const latestLabels = useRef(labels) useInsertionEffect(() => { latestLabels.current = labels }) const reorderHoldMs = props.reorderHoldMs ?? preset.reorderHoldMs const [sort, setSort] = useControllable(props.sort, props.onSortChange, null as SortState) const [columnState, setColumnState] = useControllable(props.columnState, props.onColumnStateChange, baseState) const [selection, setSelection] = useControllable(props.selection, props.onSelectionChange, EMPTY_SET) const [focusedRowId, setFocusedRowId] = useControllable<RowId | null>(props.focusedRowId, props.onFocusedRowChange, null) const [focusedColKey, setFocusedColKey] = useState<string | null>(null) const anchorRef = useRef<RowId | null>(null) // The view: yours, or one made from sort, filter, and the rules. A header sort comes first and the // rules' order breaks its ties; every filter rule has to hold, along with your own filter. const ruleSort = props.rules?.sort const ruleFilter = props.rules?.filter const ruleColumns = props.rules?.columns const comparator = useMemo(() => chainComparators(comparatorFor(columns, sort), ruleSort?.length ? compileComparator(ruleSort, columns) : undefined), [columns, sort, ruleSort]) const ownFilter = props.filter const filter = useMemo(() => { const byRules = ruleFilter?.length ? compileFilter(ruleFilter, columns) : undefined if (!byRules) return ownFilter if (!ownFilter) return byRules return (row: T) => ownFilter(row) && byRules(row) }, [ownFilter, ruleFilter, columns]) const rules = useMemo(() => (ruleColumns?.length ? applyRules(ruleColumns, columns) : null), [ruleColumns, columns]) const ownOptions = useMemo(() => (props.view ? null : { comparator, filter, reorderHoldMs }), [props.view, comparator, filter, reorderHoldMs]) const ownView = useView(store, ownOptions) const view = props.view ?? ownView! const ids = useRowIds(view) const indexOf = useMemo(() => new Map(ids.map((id, i) => [id, i] as const)), [ids]) // A focused row the view lets go of hands focus to the row now at its place, the last row if it was last, so the keys // go on from where the trader was and a screen reader hears where focus went. Only a row the view held: focus a parent // sets on a row that hasn't arrived yet waits for it. const focusedAt = useRef<{ id: RowId; index: number } | null>(null) useLayoutEffect(() => { if (focusedRowId === null) { focusedAt.current = null return } const at = indexOf.get(focusedRowId) if (at !== undefined) { focusedAt.current = { id: focusedRowId, index: at } return } const was = focusedAt.current if (was?.id !== focusedRowId || ids.length === 0) return const index = Math.min(was.index, ids.length - 1) // The row asked for is the one recorded now: a parent that keeps the departed id is asked once, and one that applies // the row asked for after it has left too gets another. focusedAt.current = { id: ids[index]!, index } setFocusedRowId(ids[index]!) }) const resolved = useMemo(() => resolveColumns(columns, columnState), [columns, columnState]) if (focusedColKey !== null && !resolved.some(column => column.key === focusedColKey)) setFocusedColKey(null) const lefts = useMemo(() => { let x = selectionColumn ? SELECT_WIDTH : 0 return resolved.map((c) => { if (c.frozen !== "left") return undefined const left = x x += c.width return left }) }, [resolved, selectionColumn]) const template = useMemo(() => (selectionColumn ? `${SELECT_WIDTH}px ` : "") + resolved.map((c) => `${c.width}px`).join(" "), [resolved, selectionColumn]) const totalWidth = useMemo(() => resolved.reduce((s, c) => s + c.width, selectionColumn ? SELECT_WIDTH : 0), [resolved, selectionColumn]) const memory = useMemo(() => createFlashMemory(), []) const scrollRef = useRef<HTMLDivElement>(null) const rootRef = useRef<HTMLDivElement>(null) const visibleEditColumns = useRef<ReadonlySet<string>>(EMPTY_SET) const editorFocusChecks = useRef(new Set<{ key: string; unavailable: boolean }>()) // Scrolls a row into view. Set once the virtualizer exists; the controller calls it when an editor opens. const revealRow = useRef<(rowId: RowId) => void>(() => {}) // The column layout as of this commit, published before any layout effect runs, so a reveal called from a cell's own // layout effect, in the commit that adds or moves its column, measures the new layout. const layout = useRef({ resolved, selectionColumn }) useInsertionEffect(() => { layout.current = { resolved, selectionColumn } }) // Sideways, a column comes clear of the frozen ones on the left and inside the viewport's right edge, its left edge // first when it is wider than the space between. A frozen column is always in view. const revealColumn = useRef((key: string) => { const el = scrollRef.current const { resolved, selectionColumn } = layout.current const i = resolved.findIndex((c) => c.key === key) if (!el || i < 0 || resolved[i]!.frozen === "left") return const lead = selectionColumn ? SELECT_WIDTH : 0 let x = lead let stuck = lead for (let j = 0; j < resolved.length; j++) { if (j < i) x += resolved[j]!.width if (resolved[j]!.frozen === "left") stuck += resolved[j]!.width } const start = x - stuck const end = x + resolved[i]!.width - el.clientWidth if (el.scrollLeft > start) el.scrollLeft = start else if (el.scrollLeft < end) el.scrollLeft = Math.min(end, start) }) // A column moved or resized by the keys or its header menu, with the order and width the request gives the columns // and the column focus it was asked under: the commit that lays the columns out exactly so, however late a controlled // parent applies the change, brings the column into view, and no other commit does. const columnToReveal = useRef<{ key: string; order: readonly string[]; width: number; focus: string | null } | null>(null) // Editing: one controller for the grid's life, reading the latest columns and onEdit through a ref, so the // memoized rows are handed one object and never re-render for it. Null without `onEdit`: nothing opens. const editLatest = useRef({ columns, resolved, onEdit, inView: (rowId: RowId) => indexOf.has(rowId), rejected: labels.rejected, notEditable: labels.notEditable }) useInsertionEffect(() => { editLatest.current = { columns, resolved, onEdit, inView: (rowId: RowId) => indexOf.has(rowId), rejected: labels.rejected, notEditable: labels.notEditable } }) const editable = Boolean(onEdit) const edits = useMemo<EditController | null>(() => { if (!editable) return null const tracker = createEditTracker() let activeColumn: string | null = null const cellsByKey = new Map<string, RowId>() const column = (key: string) => editLatest.current.columns.find((c) => c.key === key) const focusGrid = () => rootRef.current?.focus({ preventScroll: true }) // A check reads the row as it shows: a value another of its cells sent is the one in view until the row brings it back. const withPending = (rowId: RowId, how: EditCommit): EditCommit => ({ ...how, pending: (key) => { const now = tracker.get(cellKey(rowId, key)) const out = now?.kind === "editing" ? now.prior : now return out?.kind === "pending" ? out.value : undefined }, }) const send = (rowId: RowId, col: ColumnDef<T> & { edit: CellEdit<T> }, row: T, value: unknown) => { const previous = col.accessor(row) const k = cellKey(rowId, col.key) if (Object.is(value, previous)) { tracker.set(k, undefined) return } tracker.set(k, { kind: "pending", value, text: editText(col, value, row), tracked: false }) let result: void | Promise<unknown> try { result = editLatest.current.onEdit?.({ rowId, key: col.key, value, previous, row }) } catch (error) { tracker.set(k, { kind: "rejected", value: previous, message: messageOf(error, editLatest.current.rejected) }) return } if (result && typeof (result as Promise<unknown>).then === "function") { const started = tracker.get(k) if (started?.kind === "pending") tracker.set(k, { ...started, tracked: true }) ;(result as Promise<unknown>).then( () => { const now = tracker.get(k) if (now?.kind === "pending" && Object.is(now.value, value)) tracker.set(k, undefined) // A reopened untouched editor covers the pending status as prior: the // settle rewrites it there, or the close would restore a pending with // no live promise behind it. else if (now?.kind === "editing" && now.prior?.kind === "pending" && Object.is(now.prior.value, value)) tracker.set(k, { ...now, prior: undefined }) }, (error: unknown) => { const now = tracker.get(k) if (now?.kind === "pending" && Object.is(now.value, value)) tracker.set(k, { kind: "rejected", value: previous, message: messageOf(error, editLatest.current.rejected) }) else if (now?.kind === "editing" && now.prior?.kind === "pending" && Object.is(now.prior.value, value)) tracker.set(k, { ...now, prior: { kind: "rejected", value: previous, message: messageOf(error, editLatest.current.rejected) } }) }, ) } } const moveOn = (rowId: RowId, key: string, move: 1 | -1 | undefined) => { if (!move) return focusGrid() const row = store.getRow(rowId) const list = editLatest.current.resolved let i = list.findIndex((c) => c.key === key) + move for (; i >= 0 && i < list.length; i += move) { const next = list[i]! if (canEditCell(next, row) && !next.edit.toggle) { setFocusedColKey(next.key) return open(rowId, next.key) } } focusGrid() } function open(rowId: RowId, key: string, typed?: string) { if (!visibleEditColumns.current.has(key)) return // A retained handle must not open an editor on a row the view no longer holds: // it would mount and take focus whenever the row next returned. if (!editLatest.current.inView(rowId)) return const col = column(key) const row = store.getRow(rowId) if (!canEditCell(col, row) || col.edit.toggle) return cellsByKey.set(cellKey(rowId, key), rowId) // Sideways as the row is below: the editor opens with its column in sight. revealColumn.current(key) const k = cellKey(rowId, key) const before = tracker.editing() if (before && before !== k) { const covered = tracker.get(before) cellsByKey.delete(before) tracker.set(before, covered?.kind === "editing" ? covered.prior : undefined) } // A pending cell reopens on what it shows, the committed value, not on the value the store still holds. const now = tracker.get(k) // Reopening the cell already being edited is a request for focus, not a reset: // the draft, its problem, and what the editor covers all stay. A blur always // resolves the editor, so one open without focus comes from a remount (above). if (now?.kind === "editing" && typed === undefined) { activeColumn = key tracker.set(k, { ...now, selectAll: true, focused: false }) revealRow.current(rowId) return } const text = now?.kind === "pending" ? now.text : editText(col, col.accessor(row!), row!) activeColumn = key // The status this editor replaced comes back if it closes untouched: reopening a // pending cell and leaving must not erase a promise-backed pending or a later // rejection. A pending with no live promise can never settle, so reopening // dismisses it the way v1 did — any close then clears the cell. const covered = now?.kind === "editing" ? now.prior : now const prior = covered?.kind === "pending" && !covered.tracked ? undefined : covered tracker.set(k, { kind: "editing", text: typed ?? text, problem: null, selectAll: typed === undefined, initial: typed === undefined ? text : null, focused: false, prior, session: ++editorOpenings }) // The editing row is always rendered (see `keep` below), so its editor mounts and takes focus now; bring it // to where the keys are, which for a row scrolled out of the window is not where the viewport is. revealRow.current(rowId) } const controller: EditController = { tracker, editorFocusFell: { current: false }, reconcileColumns(keys) { const active = tracker.editing() if (active !== null && activeColumn !== null && !keys.has(activeColumn)) { const covered = tracker.get(active) cellsByKey.delete(active) tracker.set(active, covered?.kind === "editing" ? covered.prior : undefined) } }, unmountEditor(key, input) { const doc = input.ownerDocument // Asked of the input's own tree: inside a shadow root the document's active element is the host. const root = focusRoot(input) if (root.activeElement !== input) return controller.editorFocusFell.current = true const check = { key, unavailable: !visibleEditColumns.current.has(key) } editorFocusChecks.current.add(check) // Remember the removal commit even if the column returns before this focus check runs. queueMicrotask(() => { editorFocusChecks.current.delete(check) const grid = rootRef.current if (input.isConnected || !check.unavailable || !grid?.isConnected || grid.ownerDocument !== doc) return if (!focusFell(root, doc, input)) return focusGrid() }) }, open, rowOf: (k: string) => cellsByKey.get(k), forget: (k: string) => void cellsByKey.delete(k), markFocused(rowId, key) { const k = cellKey(rowId, key) const now = tracker.get(k) if (now?.kind === "editing" && !now.focused) tracker.set(k, { ...now, focused: true }) }, type(rowId, key, text) { const k = cellKey(rowId, key) const now = tracker.get(k) if (now?.kind === "editing") tracker.set(k, { ...now, text, problem: null }) }, commit(rowId, key, how, move) { const k = cellKey(rowId, key) const now = tracker.get(k) if (now?.kind !== "editing") return // Opened and left unchanged: a format that rounds must not turn looking into // an edit, so the untouched text sends nothing and the covered state comes // back — ahead of the permission check, so a revocation mid-look cannot // erase a pending or rejected state the way it refuses a real change. if (now.initial != null && now.text === now.initial) { cellsByKey.delete(k) tracker.set(k, now.prior) moveOn(rowId, key, move) return } const col = column(key) const row = store.getRow(rowId) if (!canEditCell(col, row)) { // The row is still here and the server stopped allowing the edit: the typed text stays, saying so, until // Escape or the edit is allowed again. A row or column that left takes the editor with it. if (row !== undefined && col?.edit) return tracker.set(k, { ...now, problem: editLatest.current.notEditable }) cellsByKey.delete(k) tracker.set(k, now.prior) return focusGrid() } const parsed = col.edit.parse(now.text, row!) const problem = isEditProblem(parsed) ? parsed : col.edit.validate?.(parsed, row!, withPending(rowId, how)) if (problem) { tracker.set(k, { ...now, problem: problem.problem }) return } cellsByKey.delete(k) send(rowId, col, row!, parsed) moveOn(rowId, key, move) }, commitValue(rowId, key, value, repeat = false) { const col = column(key) const row = store.getRow(rowId) if (!canEditCell(col, row)) return const problem = col.edit.validate?.(value, row!, withPending(rowId, { via: "value", repeat, session: 0 })) if (problem) { tracker.set(cellKey(rowId, key), { kind: "rejected", value: col.accessor(row!), message: problem.problem }) return } send(rowId, col, row!, value) }, cancel(rowId, key) { const k = cellKey(rowId, key) cellsByKey.delete(k) const now = tracker.get(k) if (now?.kind === "editing") tracker.set(k, now.prior) focusGrid() }, step(rowId, key, dir, big) { const k = cellKey(rowId, key) const now = tracker.get(k) const col = column(key) const row = store.getRow(rowId) if (now?.kind !== "editing" || !canEditCell(col, row) || !col.edit.step) return const typed = col.edit.parse(now.text, row!) const from = isEditProblem(typed) ? col.accessor(row!) : typed const next = col.edit.step(from, dir, big, row!) tracker.set(k, { ...now, text: editText(col, next, row!), problem: null }) }, blur(rowId, key) { const k = cellKey(rowId, key) cellsByKey.delete(k) const now = tracker.get(k) if (now?.kind !== "editing") return if (now.initial != null && now.text === now.initial) return tracker.set(k, now.prior) const col = column(key) const row = store.getRow(rowId) if (!canEditCell(col, row)) return tracker.set(k, now.prior) const parsed = col.edit.parse(now.text, row!) if (isEditProblem(parsed) || col.edit.validate?.(parsed, row!, withPending(rowId, { via: "blur", repeat: false, session: now.session ?? 0 }))) return tracker.set(k, now.prior) send(rowId, col, row!, parsed) }, settle(rowId, key) { const k = cellKey(rowId, key) if (tracker.get(k)?.kind === "pending") tracker.set(k, undefined) }, settlePrior(rowId, key) { const k = cellKey(rowId, key) const now = tracker.get(k) if (now?.kind === "editing" && now.prior?.kind === "pending") tracker.set(k, { ...now, prior: undefined }) }, } return controller }, [editable, store]) // Publish committed visibility before custom cells run layout effects; notify trackers in layout. useInsertionEffect(() => { visibleEditColumns.current = edits ? new Set(resolved.filter(col => col.edit && !col.edit.toggle).map(col => col.key)) : EMPTY_SET for (const check of editorFocusChecks.current) { if (!visibleEditColumns.current.has(check.key)) check.unavailable = true } }, [edits, resolved]) useLayoutEffect(() => { edits?.reconcileColumns(visibleEditColumns.current) }, [edits, resolved]) // A row that a filter or removal takes out of the view takes its open editor // with it; the editor cannot then grab focus back when the row returns, and focus // its unmount dropped on body comes home to the grid — only then: a feed commit // that closes a never-focused editor moves nothing. useLayoutEffect(() => { if (!edits) return // Read-and-clear up front: the flag answers for this commit's unmount only, // never for a Tab chain or scroll-out from some earlier one. const fell = edits.editorFocusFell.current edits.editorFocusFell.current = false const k = edits.tracker.editing() if (k === null) return const rowId = edits.rowOf(k) if (rowId !== undefined && !indexOf.has(rowId)) { const covered = edits.tracker.get(k) edits.forget(k) edits.tracker.set(k, covered?.kind === "editing" ? covered.prior : undefined) const grid = rootRef.current if (fell && grid && focusFell(focusRoot(grid), grid.ownerDocument)) grid.focus({ preventScroll: true }) } }, [edits, indexOf]) // The row whose editor is open, so the grid renders again when an editor opens on another row, or closes. const subscribeOpenEditor = useCallback((cb: () => void) => (edits ? edits.tracker.subscribeOpen(cb) : noopSubscribe()), [edits]) const openEditor = useSyncExternalStore(subscribeOpenEditor, () => edits?.tracker.editing() ?? null, () => null) const editingRowId = openEditor === null ? undefined : edits?.rowOf(openEditor) // The focused row and the row being edited are rendered wherever the viewport is: aria-activedescendant names an // element that exists, Shift+F10 opens on a row that is there, and an editor scrolled or re-sorted out of the window // keeps its input, its draft, and its focus instead of dropping them on the page. const keep = useMemo(() => { const kept: number[] = [] for (const id of [focusedRowId, editingRowId]) { const i = id === null || id === undefined ? undefined : indexOf.get(id) if (i !== undefined && !kept.includes(i)) kept.push(i) } return kept }, [focusedRowId, editingRowId, indexOf]) const rangeExtractor = useCallback((range: Range) => { const indexes = defaultRangeExtractor(range) const extra = keep.filter((i) => i < range.count && !indexes.includes(i)) return extra.length ? [...indexes, ...extra].sort((a, b) => a - b) : indexes }, [keep]) const virtualizer = useVirtualizer({ count: ids.length, getScrollElement: () => scrollRef.current, estimateSize: () => rowHeight, overscan, getItemKey: (i) => ids[i]!, rangeExtractor, initialRect, // The sticky header sits in normal flow above the rows, so row i really starts at // (i+1) x rowHeight inside the scroller: scrollMargin tells the virtualizer so, and // the paddings keep an aligned row clear of the sticky header above and the sticky // footer below. Rows keep rendering at v.start - scrollMargin inside the rowgroup, // which itself sits after the header, so nothing moves visually. scrollMargin: rowHeight, scrollPaddingStart: rowHeight, scrollPaddingEnd: footer ? rowHeight : 0, }) const items = virtualizer.getVirtualItems() // Published before any layout effect runs, so an editor a cell opens from its own layout effect, in the commit that // adds its row, scrolls to where the row now is. useInsertionEffect(() => { revealRow.current = (rowId) => { const i = indexOf.get(rowId) if (i !== undefined) virtualizer.scrollToIndex(i, { align: "auto" }) } }) // A row focus moves to, by the keys, a parent, or a hand-off, comes into view, as does a row focus waited for when it // arrives; a row already in view stays where it is. const focusedInView = focusedRowId !== null && indexOf.has(focusedRowId) useLayoutEffect(() => { if (focusedRowId !== null && focusedInView) revealRow.current(focusedRowId) }, [focusedRowId, focusedInView]) useLayoutEffect(() => { const pending = columnToReveal.current if (pending !== null) { const i = resolved.findIndex((c) => c.key === pending.key) if (pending.focus !== focusedColKey || i < 0) columnToReveal.current = null else if (resolved.length === pending.order.length && resolved.every((c, k) => c.key === pending.order[k]) && resolved[i]!.width === pending.width) { columnToReveal.current = null revealColumn.current(pending.key) } } }) const uid = useId() // Escaped, so any row id makes one IDREF, and prefixed apart from the cells' ids, so none collide. const domId = useCallback((id: RowId) => `${uid}-r-${idPart(id)}`, [uid]) // A tape follows its tail: new rows land at the end and the viewport goes there after every commit, // until a key, a pointer, or a scroll away from the end stops it. Then the arrivals count up on a // pill, and pressing it, or scrolling back to the end, follows again. Both are state set from // handlers, so the effect below only reads them. const followTail = Boolean(rowEnter.followTail) const [following, setFollowing] = useState(true) // The store's rows when following stopped: the button counts the rows in view since that the store didn't hold, so a // filter letting rows back in, or rows leaving, never reads as news. const [pausedStore, setPausedStore] = useState<ReadonlySet<RowId> | null>(null) const behind = useMemo(() => (followTail && !following && pausedStore ? ids.reduce((n, id) => (pausedStore.has(id) ? n : n + 1), 0) : 0), [followTail, following, pausedStore, ids]) useLayoutEffect(() => { if (followTail && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight }, [followTail]) // Arrival marks, in the insertion phase: all insertion effects for a commit run // before any layout effect, so a row mounting in the commit of its arrival finds // its mark — a mount-only layout effect in the row checks after this has written. // A mark parked by a reorder hold carries that hold's deadline, captured at // arrival: its window starts when the hold lapses, however late the release is // observed, and no later hold can touch it. The grid's own touches extend the // deadlines of marks still parked, synchronously, where an extension cannot be // mistaken for a new hold; extending a supplied view directly leaves them, so // that extension shortens the remainder rather than restarting it. const entered = useRef(new Map<RowId, EnterMark>()).current // After a touch extends the hold, move still-parked marks to the new deadline. // Only unexpired marks move: one whose hold already lapsed belongs to a finished // hold, and the touch that follows starts a new one that must not revive it. const refreshParkedMarks = () => { const deadline = view.isHeld() ? (view.holdExpiresAt?.() ?? null) : null if (deadline === null) return const now = Date.now() for (const [id, mark] of entered) if (mark.until !== null && mark.until > now && mark.until < deadline) entered.set(id, { at: mark.at, until: deadline }) scheduleReleaseSweep() } // A release that reorders nothing publishes nothing, so no commit observes it: // the deadline timer plays those flashes. Rendered commits sweep synchronously // and the timer re-arms for whatever is still parked. const sweepTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const highlightRef = useRef(rowEnter.highlight) useInsertionEffect(() => { highlightRef.current = rowEnter.highlight }, [rowEnter.highlight]) const sweepDueMarks = useCallback(() => { // The ref, not the closure: a timer armed before highlighting turned off must // not flash on a stale true while the stand-down effect is still queued. if (!highlightRef.current || !scrollRef.current) return const now = Date.now() let due: Map<RowId, number> | null = null for (const [id, mark] of entered) { if (mark.until !== null && now >= mark.until) { const elapsed = now - enterStart(mark, now) if (elapsed < ENTER_WINDOW_MS) (due ??= new Map()).set(id, elapsed) else entered.delete(id) } } if (due) { for (const el of scrollRef.current.querySelectorAll<HTMLElement>("[data-row-id]")) { const id = el.dataset.rowId const elapsed = id !== undefined ? due.get(id) : undefined // A nested grid's rows can share ids with this one: the DOM id says whose row this is. if (id !== undefined && elapsed !== undefined && el.id === domId(id)) { entered.delete(id) playFlash(el, "flat", { windowMs: ENTER_WINDOW_MS, variant: "fill", elapsed }) } } } }, [entered, domId]) const scheduleReleaseSweep = useCallback(() => { if (sweepTimerRef.current !== null) clearTimeout(sweepTimerRef.current) sweepTimerRef.current = null const now = Date.now() let next = Infinity for (const [, mark] of entered) if (mark.until !== null && mark.until > now) next = Math.min(next, mark.until) if (next === Infinity) return sweepTimerRef.current = setTimeout(() => { sweepTimerRef.current = null sweepDueMarks() scheduleReleaseSweep() }, Math.min(next - now, 2 ** 31 - 1)) }, [entered, sweepDueMarks]) useEffect(() => () => { if (sweepTimerRef.current !== null) clearTimeout(sweepTimerRef.current) }, []) useEffect(() => { if (rowEnter.highlight) return // Highlighting turned off: an armed timer would fire its old closure and // flash anyway, and unobserved marks would linger. Stand both down. if (sweepTimerRef.current !== null) clearTimeout(sweepTimerRef.current) sweepTimerRef.current = null entered.clear() }, [rowEnter.highlight, entered]) const prevIdsRef = useRef<readonly RowId[]>(ids) const markIdsRef = useRef<readonly RowId[]>(ids) const newSinceAnnounce = useRef(0) // One diff per commit: the insertion effect computes it and the layout effect // consumes it, so an order change builds two id sets, not four. const diffRef = useRef<{ prev: readonly RowId[]; entering: RowId[]; arrived: RowId[]; departed: RowId[] } | null>(null) // The rows the store has held, from mount on: a row entering the view is an arrival only when the store didn't hold // it, so a row a filter lets back in neither flashes nor counts as new. Every row entering still moves the viewport. const knownIds = useRef<{ store: RowStore<T>; ids: Set<RowId> } | null>(null) useInsertionEffect(() => { const prev = markIdsRef.current // Another store starts known as it stands, so its rows don't all read as arrivals. if (knownIds.current?.store !== store) knownIds.current = { store, ids: new Set(store.getIds()) } const known = knownIds.current.ids if (prev === ids) return markIdsRef.current = ids const prevSet = new Set(prev) const nowSet = new Set(ids) const entering: RowId[] = [] const arrived: RowId[] = [] for (const id of ids) { if (prevSet.has(id)) continue entering.push(id) if (!known.has(id)) arrived.push(id) known.add(id) } const departed: RowId[] = [] for (const id of prev) { if (nowSet.has(id)) continue departed.push(id) if (store.getRow(id) === undefined) known.delete(id) } // A row the store let go of while out of view stays known: past twice the store, start again from what it holds. if (known.size > 2 * store.getIds().length + 64) knownIds.current = { store, ids: new Set(store.getIds()) } diffRef.current = { prev, entering, arrived, departed } if (!rowEnter.highlight) return const at = Date.now() const until = view.isHeld() ? (view.holdExpiresAt?.() ?? null) : null for (const id of arrived) entered.set(id, { at, until }) for (const [id, mark] of entered) { if (!nowSet.has(id)) entered.delete(id) else if (at - enterStart(mark, at) >= ENTER_WINDOW_MS) entered.delete(id) } if (until !== null) scheduleReleaseSweep() }, [ids, rowEnter.highlight, entered, view, store]) // The scroll work stays in the layout phase, where refs belong to this commit: // pin the viewport, follow the tail, count arrivals, forget departed flashes. useLayoutEffect(() => { const prev = prevIdsRef.current if (prev === ids) return // The insertion effect stashed this commit's diff; a StrictMode replay reaches // the early return above, so a stale stash is never consumed twice. const stash = diffRef.current const fresh = stash !== null && stash.prev === prev const entering = fresh ? stash.entering : [] // Without this commit's diff there's no telling arrivals from rows let back in, so none is counted as new. const arrived = fresh ? stash.arrived : [] const departed = fresh ? stash.departed : [] if (!fresh) { const prevSet = new Set(prev) const nowSet = new Set(ids) for (const id of ids) if (!prevSet.has(id)) (entering as RowId[]).push(id) for (const id of prev) if (!nowSet.has(id)) (departed as RowId[]).push(id) } diffRef.current = null for (const id of departed) memory.forget(`${id}\u0000`) if ((entering.length || departed.length) && rowEnter.pinViewport && scrollRef.current) { const el = scrollRef.current const firstIndex = Math.floor(el.scrollTop / rowHeight) const firstId = prev[firstIndex] const nextIndex = firstId !== undefined ? indexOf.get(firstId) : undefined if (nextIndex !== undefined && nextIndex !== firstIndex) el.scrollTop += (nextIndex - firstIndex) * rowHeight } if (entering.length && followTail && following && scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight newSinceAnnounce.current += arrived.length prevIdsRef.current = ids }, [ids, indexOf, rowHeight, rowEnter.pinViewport, followTail, following, memory]) const stopFollowing = () => { if (!followTail || !following) return setFollowing(false) setPausedStore(new Set(store.getIds())) } const toTail = (e: MouseEvent<HTMLButtonElement>) => { // The button goes once following resumes: focus on it moves to the grid first, rather than fall to the page. // Inside a shadow root the document's active element is the host, so ask the root the button is in. if ((e.currentTarget.getRootNode() as Document | ShadowRoot).activeElement === e.currentTarget) rootRef.current?.focus({ preventScroll: true }) const el = scrollRef.current if (el) el.scrollTop = el.scrollHeight setFollowing(true) } const onScroll = followTail ? (e: UIEvent<HTMLDivElement>) => { const el = e.currentTarget const atTail = el.scrollTop + el.clientHeight >= el.scrollHeight - 1 if (atTail === following) return if (atTail) setFollowing(true) else stopFollowing() } : undefined const [announcement, setAnnouncement] = useState("") useEffect(() => { if (announceRowCount === "off") return const t = setTimeout(() => { const fresh = newSinceAnnounce.current newSinceAnnounce.current = 0 const { rowCount } = latestLabels.current setAnnouncement(typeof rowCount === "function" ? rowCount(ids.length, fresh) : fillLabel(rowCount, { n: ids.length, m: fresh })) }, 1000) return () => clearTimeout(t) }, [ids.length, announceRowCount]) useEffect(() => { if (!announcement) return const t = setTimeout(() => setAnnouncement(""), SPOKEN_MS) return () => clearTimeout(t) }, [announcement]) // A row named by `getRowLabel` keeps its name while its cells tick, so its cells are read here instead: once, when // focus rests on a row (another row, or the grid taking focus), and never because a cell changed. const readsRows = typeof getRowLabel === "function" const [reading, setReading] = useState("") const [arrivals, setArrivals] = useState(0) useEffect(() => { if (!readsRows || focusedRowId === null) return const t = setTimeout(() => { const root = rootRef.current // Inside a shadow root the document's active element is the host, so ask the root the grid is in. if (!root || (root.getRootNode() as Document | ShadowRoot).activeElement !== root) return const id = domId(focusedRowId) const row = Array.from(root.querySelectorAll<HTMLElement>('[role="row"][data-row-id]')).find((el) => el.id === id) const text = row ? rowReading(row) : "" // The same words again would change nothing a live region hears, so a repeat differs by an invisible character. if (text) setReading((was) => (was === text ? `${text}\u200b` : text)) }, ROW_READING_REST_MS) return () => clearTimeout(t) }, [readsRows, focusedRowId, arrivals, domId]) useEffect(() => { if (!reading) return const t = setTimeout(() => setReading(""), SPOKEN_MS) return () => clearTimeout(t) }, [reading]) // Selection and focus, by id. const select = useCallback( (targets: RowId[], mode: "replace" | "toggle" | "range") => { if (selectionMode === "none") return if (selectionMode === "single" || mode === "replace") { setSelection(new Set(targets.slice(-1))) anchorRef.current = targets[targets.length - 1] ?? null return } const next = new Set(selection) if (mode === "toggle") { for (const id of targets) if (next.has(id)) next.delete(id) else next.add(id) anchorRef.current = targets[targets.length - 1] ?? anchorRef.current } else { const to = targets[targets.length - 1]! const from = anchorRef.current ?? to // Capture and focus callbacks can publish a new order before this render commits. const currentIds = view.getIds() const a = currentIds === ids ? indexOf.get(from) ?? 0 : Math.max(0, currentIds.indexOf(from)) const b = currentIds === ids ? indexOf.get(to) ?? -1 : currentIds.indexOf(to) if (b < 0) return for (let i = Math.min(a, b); i <= Math.max(a, b); i++) next.add(currentIds[i]!) } setSelection(next) }, [ids, indexOf, selection, selectionMode, setSelection, view], ) const focusIndex = useCallback( (index: number, extend = false) => { if (!ids.length) return const i = Math.max(0, Math.min(ids.length - 1, index)) const id = ids[i]! setFocusedRowId(id) virtualizer.scrollToIndex(i, { align: "auto" }) if (extend && selectionMode === "multi") select([id], "range") else if (selectionMode === "single") select([id], "replace") }, [ids, select, selectionMode, setFocusedRowId, virtualizer], ) const cycleSort = useCallback( (key: string) => { const col = columns.find((c) => c.key === key) if (!col?.sortable) return setSort(!sort || sort.key !== key ? { key, dir: "asc" } : sort.dir === "asc" ? { key, dir: "desc" } : null) }, [columns, sort, setSort], ) const updateColumns = useCallback( (fn: (s: ColumnState) => ColumnState) => setColumnState(fn(columnState)), [columnState, setColumnState], ) const moveColumn = useCallback( (key: string, delta: -1 | 1) => { const order = resolved.map((c) => c.key) const i = order.indexOf(key) const j = i + delta if (i < 0 || j < 0 || j >= order.length) return ;[order[i], order[j]] = [order[j]!, order[i]!] updateColumns((s) => ({ ...s, order })) }, [resolved, updateColumns], ) const resizeColumn = useCallback( (key: string, width: number) => { const col = resolved.find((c) => c.key === key) if (!col) return // A drag or a key that leaves the width as it is, at the minimum, changes nothing. const next = Math.max(col.minWidth, Math.round(width)) if (next === col.width) return updateColumns((s) => ({ ...s, widths: { ...s.widths, [key]: next } })) }, [resolved, updateColumns], ) const hideColumn = useCallback((key: string) => updateColumns((s) => ({ ...s, hidden: [...new Set([...s.hidden, key])] })), [updateColumns]) const resetColumns = useCallback(() => setColumnState(baseState), [baseState, setColumnState]) // A move or resize the keys or a header menu ask for. A request that can't change the layout changes nothing: past // either end, across the frozen boundary (frozen columns lead whatever the order says), or under the minimum width. // Otherwise it's recorded before the state changes, so a commit the change flushes at once still finds it. const requestColumn = (key: string, change: { move: -1 | 1 } | { widen: number }) => { const i = resolved.findIndex((c) => c.key === key) const at = resolved[i] if (!at) return const order = resolved.map((c) => c.key) if ("move" in change) { const beside = resolved[i + change.move] if (!beside || (beside.frozen === "left") !== (at.frozen === "left")) return ;[order[i], order[i + change.move]] = [beside.key, at.key] columnToReveal.current = { key, order, width: at.width, focus: focusedColKey } moveColumn(key, change.move) } else { const width = Math.max(at.minWidth, Math.round(at.width + change.widen)) if (width === at.width) return columnToReveal.current = { key, order, width, focus: focusedColKey } resizeColumn(key, width) } } // A key that moves row focus brings the focused column back too, should a wheel have scrolled it away. const revealFocusedColumn = () => { if (focusedColKey !== null) revealColumn.current(focusedColKey) } // The sticky header and footer each cover one row of the viewport. const visibleCount = Math.max(1, Math.floor((scrollRef.current?.clientHeight ?? initialRect?.height ?? rowHeight * 10) / rowHeight) - 1 - (footer ? 1 : 0)) // The focused cell's column when it can be edited now, else undefined. const editableFocus = () => { if (!edits || focusedRowId === null || focusedColKey === null) return undefined const col = resolved.find((c) => c.key === focusedColKey) return canEditCell(col, store.getRow(focusedRowId)) ? col : undefined } const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { // Nested grids and portals own their keys, including reorder holds and following. const path = e.nativeEvent.composedPath() if (!gridTarget(e.currentTarget, e.target, path)) return // A key is interaction wherever in the grid it lands, the editor included: it holds the order, so a batch cannot // re-sort the row being typed into away from the hand while a hold lasts, and it stops a tape following its tail. view.touch() refreshParkedMarks() stopFollowing() // The editor owns its keys; what it lets through (a modifier-held arrow) is for the listeners above the grid. if (pathMatches(e.currentTarget, path, element => element.hasAttribute("data-cell-editor"))) return // Focused controls own their keys; application handlers can also claim a grid key in capture. if (e.defaultPrevented || e.target !== e.currentTarget) return const fi = focusedRowId !== null ? (indexOf.get(focusedRowId) ?? -1) : -1 const ci = focusedColKey !== null ? resolved.findIndex((c) => c.key === focusedColKey) : -1 const mod = e.metaKey || e.ctrlKey // Commands that act on the focused row require it to be in the current view, the // same bar aria-activedescendant holds: a row a filter or removal took out is not // silently activated, edited, selected, or menued. const focusedInView = fi >= 0 // Typing on an editable cell opens its editor with the character typed. if (focusedInView && !e.repeat && e.key.length === 1 && e.key !== " " && !mod && !e.altKey) { const col = editableFocus() if (col && !col.edit.toggle) { e.preventDefault() edits!.open(focusedRowId!, col.key, e.key) return } } const letter = altLetter(e) if ((letter === "s" || letter === "h") && focusedColKey) { e.preventDefault() // Once per press: a held Alt+S would cycle the sort on every repeat, and a parent that hasn't applied a hide yet // would be asked again on every repeat. if (e.repeat) return if (letter === "s") cycleSort(focusedColKey) else hideColumn(focusedColKey) return } switch (e.key) { case "ArrowDown": e.preventDefault() focusIndex(fi + 1, e.shiftKey) revealFocusedColumn() return case "ArrowUp": e.preventDefault() focusIndex(fi <= 0 ? 0 : fi - 1, e.shiftKey) revealFocusedColumn() return case "PageDown": e.preventDefault() focusIndex(fi + visibleCount, e.shiftKey) revealFocusedColumn() return case "PageUp": e.preventDefault() focusIndex(fi - visibleCount, e.shiftKey) revealFocusedColumn() return case "Home": e.preventDefault() focusIndex(0, e.shiftKey) revealFocusedColumn() return case "End": e.preventDefault() focusIndex(ids.length - 1, e.shiftKey) revealFocusedColumn() return case "ArrowLeft": case "ArrowRight": { e.preventDefault() const delta = e.key === "ArrowLeft" ? -1 : 1 if (e.altKey && focusedColKey) { // The commit that lays the columns out as asked brings the column into view; focus or a pointer moving on // first drops the request, and a layout the parent changes or never applies doesn't scroll the grid for it. requestColumn(focusedColKey, e.shiftKey ? { widen: delta * 8 } : { move: delta }) return } const next = resolved[Math.max(0, Math.min(resolved.length - 1, ci < 0 ? (delta > 0 ? 0 : resolved.length - 1) : ci + delta))] setFocusedColKey(next?.key ?? null) // Focus moved into a column whose place hasn't changed: bring it into view now, so a key typed next edits a // column in sight. if (next) revealColumn.current(next.key) return } case " ": { e.preventDefault() // A held Space acts once, as a native checkbox's does: each repeat would toggle the value the server // just acknowledged, or the row's selection, back again. if (!focusedInView || e.repeat) return const col = editableFocus() if (col?.edit.toggle) { const row = store.getRow(focusedRowId!)! // A toggle commits where it can be seen, as an editor opens. revealColumn.current(col.key) edits!.commitValue(focusedRowId!, col.key, col.edit.toggle(col.accessor(row), row), false) return } if (focusedRowId !== null) select([focusedRowId], selectionMode === "multi" ? "toggle" : "replace") return } case "Enter": case "F2": { if (!focusedInView) return const col = editableFocus() if (col) { e.preventDefault() // Once per press: a held key would commit a toggle again, or close and reopen an editor on each repeat. if (e.repeat) return const row = store.getRow(focusedRowId!)! if (col.edit.toggle) { revealColumn.current(col.key) edits!.commitValue(focusedRowId!, col.key, col.edit.toggle(col.accessor(row), row), false) } else edits!.open(focusedRowId!, col.key) return } if (e.key === "F2") return if (focusedRowId !== null) { const row = store.getRow(focusedRowId) // A held Enter activates the row once, as a held key commits a toggle once. if (row !== undefined && !e.repeat) onRowActivate?.(row, focusedRowId) } return } case "Escape": // Once per press: the repeats of an Escape that closed an editor land here and would clear the selection. if (!e.repeat && selectionMode !== "none" && selection.size) setSelection(EMPTY_SET) return case "a": case "A": if (mod && selectionMode === "multi") { e.preventDefault() if (!e.repeat) setSelection(new Set(ids)) } return case "ContextMenu": case "F10": if (e.key === "F10" && !e.shiftKey) return e.preventDefault() if (!e.repeat) openContextMenuAtFocus() return } } const openContextMenuAtFocus = () => { if (focusedRowId === null) return const root = rootRef.current const id = domId(focusedRowId) // The focused row renders even outside the window: bring it into view, so the menu opens where it can be seen. const index = indexOf.get(focusedRowId) if (index !== undefined) virtualizer.scrollToIndex(index, { align: "auto" }) const el = Array.from(root?.querySelectorAll<HTMLElement>('[role="row"][data-row-id]') ?? []).find(row => row.id === id) const ownerWindow = el?.ownerDocument.defaultView if (!el || !ownerWindow || !gridTarget(root, el)) return // At the focused cell, brought into view first, as a wheel may have scrolled it away, else the row's visible left // edge: scrolled sideways, the row's own left edge sits off to the left of the grid. if (focusedColKey !== null) revealColumn.current(focusedColKey) const r = el.getBoundingClientRect() const cell = focusedColKey === null ? undefined : Array.from(el.children).find((child) => child.getAttribute("data-col") === focusedColKey) const port = scrollRef.current?.getBoundingClientRect() const left = cell ? cell.getBoundingClientRect().left : Math.max(r.left, port?.left ?? r.left) el.dispatchEvent(new ownerWindow.MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: left + 8, clientY: r.top + r.height / 2, button: 2 })) } const rowTarget = (event: { target: EventTarget; nativeEvent: Event }, includeShadowTarget = false) => { const path = event.nativeEvent.composedPath() const root = rootRef.current // Menu starts can reach the primitive before a shadow portal's host-targeted pass. const target = includeShadowTarget && !root?.contains(event.target as Node) ? path.find(entry => (entry as Node).nodeType === 1 && root?.contains(entry as Node)) ?? event.target : event.target const element = gridTarget(root, target, path) if (!element || pathMatches(root, path, element => element.matches(ROW_CONTROLS) || CONTROL_ROLES.has(roleOf(element) ?? ""))) return undefined const row = element.closest<HTMLElement>('[role="row"][data-row-id]') const id = row?.dataset.rowId if (id === undefined || row?.id !== domId(id) || !indexOf.has(id) || store.getRow(id) === undefined) return undefined // A capture handler may change membership before React commits the new rows. const currentIds = view.getIds() if (currentIds !== ids && !currentIds.includes(id)) return undefined return { element, id } } // The row the menu opens on, taken from the event that opens it, since a parent that controls focus may not // have moved focus there yet: a pointer down starts a touch's long press, and a contextmenu event — the // pointer's, or the one the keyboard sends to the focused row — opens it at once. const [menuRow, setMenuRow] = useState<RowId | null>(null) // Right-click targets the row under the pointer: focus it, and make it the selection unless it is already selected. const onContextMenu = (e: MouseEvent<HTMLDivElement>) => { const target = rowTarget(e, true) if (e.defaultPrevented || !target) { rejectMenuStart(rootRef.current, e) return } const { id } = target setMenuRow(id) setFocusedRowId(id) if (!selection.has(id)) select([id], "replace") } const onPointerDownCapture = (e: PointerEvent<HTMLDivElement>) => { if (!gridTarget(rootRef.current, e.target, e.nativeEvent.composedPath())) return columnToReveal.current = null view.touch() refreshParkedMarks() stopFollowing() } const onRowPointerDown = (e: PointerEvent<HTMLDivElement>) => { const target = rowTarget(e) if (e.defaultPrevented || !target) { // Radix starts long-press menus from pointerdown; Base UI uses touchstart below. if (renderContextMenu && e.pointerType !== "mouse" && (e.defaultPrevented || !rowTarget(e, true))) rejectMenuStart(rootRef.current, e) return } const { id } = target setMenuRow(id) setFocusedRowId(id) if (e.button !== 0) return if (e.shiftKey) select([id], "range") else if (e.metaKey || e.ctrlKey) select([id], "toggle") else select([id], "replace") } // A double click on an editable cell opens it; anywhere else on the row it activates the row. const onRowDoubleClick = (e: MouseEvent<HTMLDivElement>) => { if (e.defaultPrevented) return const target = rowTarget(e) if (!target) return const { element, id } = target const row = store.getRow(id) if (row === undefined) return const key = element.closest<HTMLElement>("[data-col]")?.dataset.col const col = key !== undefined ? resolved.find((c) => c.key === key) : undefined if (edits && canEditCell(col, row) && !col.edit.toggle) { setFocusedRowId(id) setFocusedColKey(col.key) edits.open(id, col.key) return } onRowActivate?.(row, id) } const toggle = useCallback((id: RowId) => select([id], "toggle"), [select]) // A rapid reopen must get fresh content even while the previous popup is still exiting. const [menuOpening, setMenuOpening] = useState(0) const contextRows = useMemo(() => { // With nothing selected the menu acts on the row it opened on, even when focus has since moved off a row that left. const opened = menuRow ?? focusedRowId const targets = selection.size ? [...selection] : opened !== null ? [opened] : [] return { opening: menuOpening, ids: targets, target: menuRow ?? focusedRowId, rows: targets.map((id) => store.getRow(id)).filter((r): r is T => r !== undefined) } }, [selection, focusedRowId, store, menuOpening, menuRow]) const body = ( <div ref={scrollRef} className="relative h-full min-h-0 flex-1 overflow-auto" onPointerDownCapture={onPointerDownCapture} onPointerDown={onRowPointerDown} onDoubleClick={onRowDoubleClick} onContextMenu={renderContextMenu ? onContextMenu : undefined} onTouchStart={renderContextMenu ? event => { // Multiple touches must reach the primitive so it can cancel a pending long press. if (event.touches.length === 1 && (event.defaultPrevented || !rowTarget(event, true))) rejectMenuStart(rootRef.current, event) } : undefined} onScroll={onScroll} > {/* At least the viewport tall, so a footer sits at the bottom edge when the rows do not reach it. */} <div className="flex min-h-full flex-col"> <div role="row" aria-rowindex={1} className="sticky top-0 z-20 grid border-b border-border bg-background text-muted-foreground" style={{ gridTemplateColumns: template, width: totalWidth, height: rowHeight }} > {selectionColumn && <div role="columnheader" aria-colindex={1} aria-label={labels.selection} className="sticky left-0 z-30 bg-background" />} {resolved.map((col, i) => ( <HeaderCell key={col.key} gridRef={rootRef} col={col} colIndex={i + (selectionColumn ? 1 : 0)} left={lefts[i]} sort={sort?.key === col.key ? sort.dir : null} focused={focusedColKey === col.key} canMoveLeft={i > 0 && (resolved[i - 1]!.frozen === "left") === (col.frozen === "left")} canMoveRight={i < resolved.length - 1 && (resolved[i + 1]!.frozen === "left") === (col.frozen === "left")} onSort={() => cycleSort(col.key)} onFocus={() => setFocusedColKey(col.key)} onHide={() => hideColumn(col.key)} onMove={(d) => requestColumn(col.key, { move: d })} onReset={resetColumns} onResize={(w) => resizeColumn(col.key, w)} hiddenCount={columnState.hidden.length} labels={labels} /> ))} </div> {ids.length === 0 ? ( <div id={`${uid}-empty`} className="p-4 text-muted-foreground">{emptyState ?? labels.noRows}</div> ) : ( <div role="rowgroup" className="relative shrink-0" style={{ height: virtualizer.getTotalSize(), width: totalWidth }}> {items.map((v) => { const id = ids[v.index]! return ( <Row key={v.key} store={store} id={id} index={v.index} domId={domId(id)} uid={uid} columns={resolved} template={template} lefts={lefts} width={totalWidth} height={rowHeight} start={v.start - rowHeight} selected={selection.has(id)} focused={focusedRowId === id} focusedColKey={focusedColKey} selectionColumn={selectionColumn} selectable={selectionMode !== "none"} selectLabel={labels.selectRow} pendingLabel={labels.pending} onToggle={toggle} memory={memory} flashVariant={preset.flash} flashWindowMs={flashWindowMs} entered={entered} highlightEnter={rowEnter.highlight} getRowProps={getRowProps} getRowLabel={getRowLabel} rules={rules} edits={edits} /> ) })} </div> )} {footer && <FooterRow store={store} ids={ids} columns={resolved} template={template} lefts={lefts} width={totalWidth} height={rowHeight} footer={footer} selectionColumn={selectionColumn} rowIndex={ids.length + 2} />} </div> </div> ) return ( <div ref={rootRef} role="grid" data-slot="tradecn-data-grid" data-preset={props.preset ?? "blotter"} data-editable={edits ? "" : undefined} tabIndex={0} aria-label={label} aria-rowcount={ids.length + (footer ? 2 : 1)} aria-colcount={resolved.length + (selectionColumn ? 1 : 0)} aria-multiselectable={selectionMode === "multi" || undefined} aria-activedescendant={focusedRowId !== null && indexOf.has(focusedRowId) ? domId(focusedRowId) : undefined} aria-describedby={ids.length === 0 ? `${uid}-empty` : undefined} onKeyDown={onKeyDown} onFocus={readsRows ? (e) => { if (e.target === e.currentTarget) setArrivals((n) => n + 1) } : undefined} // Focus leaving the grid takes the row's reading with it. onBlur={readsRows ? (e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setReading("") } : undefined} className={cn("relative flex h-full min-h-0 flex-col overflow-hidden rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-2 focus-visible:ring-foreground/60 lining-nums tabular-nums", preset.fontClass, className)} style={{ lineHeight: `${rowHeight}px` } as CSSProperties} > {renderContextMenu ? ( <ContextMenu onOpenChange={open => { if (open) setMenuOpening(value => value + 1) }}> <ContextMenuTrigger className="contents">{body}</ContextMenuTrigger> <ContextMenuContent><Fragment key={contextRows.opening}>{renderContextMenu(contextRows.rows, contextRows.ids, contextRows.target)}</Fragment></ContextMenuContent> </ContextMenu> ) : ( body )} {behind > 0 && ( <button type="button" data-grid-behind={behind} onClick={toTail} className="absolute left-1/2 z-30 -translate-x-1/2 rounded-full bg-primary px-2.5 py-0.5 text-primary-foreground shadow-sm hover:bg-primary/90" style={{ bottom: (footer ? rowHeight : 0) + 8 }} > {typeof labels.newRows === "function" ? labels.newRows(behind) : fillLabel(labels.newRows, { n: behind })} </button> )} {announceRowCount !== "off" && ( <div aria-live="polite" className="sr-only"> {announcement} </div> )} {readsRows && ( <div aria-live="polite" aria-atomic="true" className="sr-only" data-grid-row-reading=""> {reading} </div> )} </div> ) } interface HeaderCellProps<T> { gridRef: RefObject<HTMLDivElement | null> col: Resolved<T> colIndex: number left: number | undefined sort: "asc" | "desc" | null focused: boolean canMoveLeft: boolean canMoveRight: boolean hiddenCount: number labels: DataGridLabels onSort: () => void onFocus: () => void onHide: () => void onMove: (delta: -1 | 1) => void onReset: () => void onResize: (width: number) => void } function HeaderCell<T>(p: HeaderCellProps<T>) { const { col, gridRef } = p const triggerRef = useRef<HTMLButtonElement>(null) const menuRef = useRef<HTMLDivElement>(null) const resizeRef = useRef(p.onResize) const resizeGesture = useRef<{ pointerId: number; owner: Window; end: () => void } | null>(null) useInsertionEffect(() => { resizeRef.current = p.onResize }) useLayoutEffect(() => () => resizeGesture.current?.end(), []) useLayoutEffect(() => () => { const trigger = triggerRef.current const menu = menuRef.current const grid = gridRef.current const doc = trigger?.ownerDocument if (!trigger || !grid || !doc) return // Focus read in each tree it can be in: the trigger's, which inside a shadow root isn't the document's, and the // menu's, wherever it was portaled. const root = focusRoot(trigger) const inMenu = menu ? focusRoot(menu).activeElement : null const active = root.activeElement === trigger ? trigger : inMenu && menu?.contains(inMenu) ? inMenu : null if (!active) return // Wait for removal and the menu's cleanup. A surviving trigger (including effect replay) owns its focus. queueMicrotask(() => { if (trigger.isConnected || !grid.isConnected) return const current = menu ? focusRoot(menu).activeElement : null if (focusFell(root, doc, trigger) || (current !== null && (current === active || menu?.contains(current)))) grid.focus({ preventScroll: true }) }) }, [gridRef]) const startResize = (e: PointerEvent<HTMLDivElement>) => { if (e.defaultPrevented || e.button !== 0 || !e.isPrimary) return const handle = e.currentTarget const owner = handle.ownerDocument.defaultView if (!owner) return if (resizeGesture.current?.owner !== owner) resizeGesture.current?.end() if (resizeGesture.current && resizeGesture.current.pointerId !== e.pointerId) return e.preventDefault() e.stopPropagation() resizeGesture.current?.end() const pointerId = e.pointerId const startX = e.clientX const startW = col.width const move = (ev: globalThis.PointerEvent) => { if (ev.pointerId !== pointerId) return if (!(ev.buttons & 1) || !handle.isConnected || handle.ownerDocument.defaultView !== owner) { end() return } resizeRef.current(startW + ev.clientX - startX) } const finish = (ev: globalThis.PointerEvent) => { if (ev.pointerId === pointerId) end() } const end = () => { if (resizeGesture.current?.end !== end) return observer.disconnect() owner.removeEventListener("pointermove", move, true) owner.removeEventListener("pointerup", finish, true) owner.removeEventListener("pointercancel", finish, true) owner.removeEventListener("lostpointercapture", finish, true) owner.removeEventListener("blur", end) handle.removeEventListener("lostpointercapture", finish) resizeGesture.current = null if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId) } // Watch ancestor removal only while dragging; row updates need no subtree observation. const observer = new owner.MutationObserver(() => { if (!handle.isConnected || handle.ownerDocument.defaultView !== owner) end() else observeAncestors() }) const observeAncestors = () => { observer.disconnect() for (let node: Node | null = handle.parentNode; node; node = node.parentNode ?? (node.nodeType === 11 ? (node as ShadowRoot).host : null)) { observer.observe(node, { childList: true }) } } observeAncestors() resizeGesture.current = { pointerId, owner, end } owner.addEventListener("pointermove", move, true) owner.addEventListener("pointerup", finish, true) owner.addEventListener("pointercancel", finish, true) owner.addEventListener("lostpointercapture", finish, true) owner.addEventListener("blur", end) // Adoption can deliver capture loss through the handle's new document. handle.addEventListener("lostpointercapture", finish) // Synthetic events may not have an active browser pointer to capture. try { handle.setPointerCapture?.(pointerId) } catch { /* Window listeners still end the gesture. */ } } // The column's name in words: its title, its header when that is words, else its key. A header is named by it alone, // never by the menu button and resize handle inside, which have names of their own; give a header of markup a title. const name = columnName(col) const words = col.title?.trim() || (typeof col.header === "string" ? col.header.trim() : "") return ( <div role="columnheader" aria-label={name} aria-colindex={p.colIndex + 1} aria-sort={p.sort === "asc" ? "ascending" : p.sort === "desc" ? "descending" : col.sortable ? "none" : undefined} data-col={col.key} data-focused-col={p.focused || undefined} className={cn("group relative flex h-full min-w-0 items-center gap-1 px-2 font-medium select-none", alignClass(col), p.left !== undefined && "sticky z-30 bg-background", p.focused && "text-foreground")} style={p.left !== undefined ? { left: p.left } : undefined} onPointerDown={p.onFocus} > <span className={cn("min-w-0 flex-1 truncate", col.sortable && "cursor-pointer hover:text-foreground")} title={words || undefined} onClick={col.sortable ? p.onSort : undefined} > {col.header} {p.sort && <span aria-hidden className="ml-1">{p.sort === "asc" ? "\u25B2" : "\u25BC"}</span>} </span> <DropdownMenu> <DropdownMenuTrigger ref={triggerRef} aria-label={fillLabel(p.labels.columnMenu, { name })} className="rounded px-1 text-muted-foreground opacity-0 hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100 data-[popup-open]:opacity-100 data-[state=open]:opacity-100" > <svg aria-hidden width="10" height="10" viewBox="0 0 10 10" fill="currentColor"> <circle cx="5" cy="1.5" r="1.2" /> <circle cx="5" cy="5" r="1.2" /> <circle cx="5" cy="8.5" r="1.2" /> </svg> </DropdownMenuTrigger> <DropdownMenuContent ref={menuRef} align="end"> {col.sortable && <DropdownMenuItem onClick={p.onSort}>{p.sort === "asc" ? p.labels.sortDescending : p.sort === "desc" ? p.labels.clearSort : p.labels.sortAscending}</DropdownMenuItem>} <DropdownMenuItem disabled={!p.canMoveLeft} onClick={() => p.onMove(-1)}> {p.labels.moveLeft} </DropdownMenuItem> <DropdownMenuItem disabled={!p.canMoveRight} onClick={() => p.onMove(1)}> {p.labels.moveRight} </DropdownMenuItem> <DropdownMenuItem onClick={p.onHide}>{p.labels.hideColumn}</DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem onClick={p.onReset}>{typeof p.labels.resetColumns === "function" ? p.labels.resetColumns(p.hiddenCount) : fillLabel(p.labels.resetColumns, { n: p.hiddenCount })}</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> <div role="separator" aria-orientation="vertical" aria-label={fillLabel(p.labels.resizeColumn, { name })} onPointerDown={startResize} className="absolute top-0 right-0 h-full w-1.5 touch-none cursor-col-resize hover:bg-ring/40" /> </div> ) }hooks/use-flash.ts import { useLayoutEffect, useRef, type RefObject } from "react" // Flash a cell when its value changes, colored by direction, without a React re-render per tick. // // The previous value lives outside React (per cell, or in a shared FlashMemory keyed by cell so a // virtualized row that scrolls back within the window resumes its flash instead of losing it). // The flash itself is a Web Animations API animation on the existing element: retriggering cancels // the previous one for free, and nothing is remounted. export type Direction = "up" | "down" | "flat" export interface FlashRecord { value: unknown /** Clock time of the last flash, or -Infinity when the cell has never flashed. */ at: number dir: Direction } export interface FlashMemory { get(key: string): FlashRecord | undefined set(key: string, record: FlashRecord): void /** Drop every record whose key starts with `prefix` (a grid calls it with the row id on remove). */ forget(prefix: string): void readonly size: number } /** Bounded, least-recently-set memory. `max` records; oldest are evicted first. */ export function createFlashMemory(max = 50_000): FlashMemory { const map = new Map<string, FlashRecord>() return { get: (key) => map.get(key), set(key, record) { if (map.has(key)) map.delete(key) map.set(key, record) if (map.size > max) map.delete(map.keys().next().value!) }, forget(prefix) { for (const key of map.keys()) if (key.startsWith(prefix)) map.delete(key) }, get size() { return map.size }, } } export type Compare = (prev: unknown, next: unknown) => number | null /** Numeric difference when both sides are finite numbers; 0 when identical; null for any other change. */ export const compareValues: Compare = (prev, next) => { if (typeof prev === "number" && typeof next === "number" && Number.isFinite(prev) && Number.isFinite(next)) return next - prev return Object.is(prev, next) ? 0 : null } /** Direction of a change. Zero is flat, never up. A non-numeric change is flat. */ export function directionOf(prev: unknown, next: unknown, compare: Compare = compareValues): Direction { const d = compare(prev, next) if (d === null || d === 0) return "flat" return d > 0 ? "up" : "down" } export interface FlashOptions { /** How long a flash lasts, and how long a scrolled-away cell remembers it. Default 900. */ windowMs?: number /** `fill` tints the background; `ring` draws an inset outline, for cells that already carry a bar. */ variant?: "fill" | "ring" /** Paint with this instead of the direction's token: `var(--primary)` for an acknowledgement that has no direction. */ color?: string compare?: Compare /** * Flash flat when the same value arrives again. Needs `revision` to know it arrived: pass the row object * or the batch version, since an equal `value` alone looks unchanged to React. Default false. */ flashOnEqual?: boolean /** Anything that changes identity when a new reading arrives (the row object, a store version). */ revision?: unknown /** Shared memory across mounts; requires `cellKey`. */ memory?: FlashMemory cellKey?: string disabled?: boolean /** Injectable clock in ms; defaults to performance.now. */ now?: () => number } export const FILL_COLORS: Record<Direction, string> = { up: "var(--up-soft)", down: "var(--down-soft)", flat: "var(--flat-soft)", } export const RING_COLORS: Record<Direction, string> = { up: "var(--up)", down: "var(--down)", flat: "var(--flat)", } /** Tailwind classes for coloring a value by direction: text-up, text-down, text-flat. */ export function directionClass(dir: Direction | null | undefined): string { return dir === "up" ? "text-up" : dir === "down" ? "text-down" : "text-flat" } const animations = new WeakMap<Element, Animation>() const clearTimers = new WeakMap<Element, ReturnType<typeof setTimeout>>() let reducedMotion: boolean | null = null function prefersReducedMotion(): boolean { if (reducedMotion === null) { reducedMotion = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)").matches : false } return reducedMotion } /** Test hook: forget the cached media query. */ export function resetReducedMotionCache() { reducedMotion = null } function keyframes(dir: Direction, variant: "fill" | "ring", color?: string): Keyframe[] { if (variant === "ring") return [{ boxShadow: `inset 0 0 0 1px ${color ?? RING_COLORS[dir]}` }, { boxShadow: "inset 0 0 0 1px transparent" }] return [{ backgroundColor: color ?? FILL_COLORS[dir] }, { backgroundColor: "transparent" }] } /** Start (or resume, `elapsed` ms in) a flash on an element. */ export function playFlash(el: HTMLElement, dir: Direction, opts: { windowMs: number; variant: "fill" | "ring"; color?: string; elapsed?: number }) { const elapsed = Math.max(0, Math.min(opts.elapsed ?? 0, opts.windowMs)) animations.get(el)?.cancel() const pending = clearTimers.get(el) if (pending) clearTimeout(pending) el.dataset.direction = dir const done = () => { if (el.dataset.direction === dir) delete el.dataset.direction } if (prefersReducedMotion() || typeof el.animate !== "function") { // Static color from the data-direction styles, cleared when the window ends. clearTimers.set(el, setTimeout(done, opts.windowMs - elapsed)) return } const anim = el.animate(keyframes(dir, opts.variant, opts.color), { duration: opts.windowMs, easing: "ease-out", fill: "none" }) anim.currentTime = elapsed anim.onfinish = () => { if (animations.get(el) === anim) animations.delete(el) done() } animations.set(el, anim) } const NEVER: FlashRecord = { value: undefined, at: -Infinity, dir: "flat" } export function useFlash<E extends HTMLElement>(ref: RefObject<E | null>, value: unknown, opts: FlashOptions = {}): void { const { windowMs = 900, variant = "fill", color, compare = compareValues, flashOnEqual = false, memory, cellKey, disabled = false, revision } = opts const now = opts.now ?? (() => performance.now()) const local = useRef<FlashRecord | undefined>(undefined) const key = memory ? cellKey : undefined if (memory && !cellKey) throw new Error("useFlash: a shared memory needs a cellKey") const read = (): FlashRecord | undefined => (key !== undefined ? memory!.get(key) : local.current) const write = (record: FlashRecord) => { if (key !== undefined) memory!.set(key, record) else local.current = record } useLayoutEffect(() => { const el = ref.current if (!el || disabled) return const t = now() const prev = read() if (!prev) { // First sight of this cell: remember, do not flash. write({ ...NEVER, value }) return } const changed = !Object.is(prev.value, value) if (changed || flashOnEqual) { const dir = changed ? directionOf(prev.value, value, compare) : "flat" write({ value, at: t, dir }) playFlash(el, dir, { windowMs, variant, color }) return } // Same value on a fresh mount (a virtualized row scrolled back): resume a flash still in its window. const elapsed = t - prev.at if (elapsed < windowMs) playFlash(el, prev.dir, { windowMs, variant, color, elapsed }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, revision, ref, disabled, windowMs, variant, color, flashOnEqual, compare, memory, key]) useLayoutEffect(() => { const el = ref.current return () => { if (!el) return animations.get(el)?.cancel() animations.delete(el) const pending = clearTimers.get(el) if (pending) clearTimeout(pending) } }, [ref]) }hooks/use-row-store.ts import { useCallback, useLayoutEffect, useState, useSyncExternalStore } from "react" import type { RowId, RowStore, RowView, StoreMeta, ViewOptions } from "@/lib/row-store" /** One row. The component re-renders when this row is replaced or patched, and for nothing else. */ export function useRow<T>(store: RowStore<T>, id: RowId): T | undefined { const subscribe = useCallback((cb: () => void) => store.subscribeRow(id, cb), [store, id]) const get = useCallback(() => store.getRow(id), [store, id]) return useSyncExternalStore(subscribe, get, get) } /** The ids of a store (insertion or authoritative order) or of a view (filtered, sorted, held). */ export function useRowIds<T>(source: RowStore<T> | RowView<T>): readonly RowId[] { const subscribe = useCallback((cb: () => void) => ("subscribeOrder" in source ? source.subscribeOrder(cb) : source.subscribe(cb)), [source]) const get = useCallback(() => source.getIds(), [source]) return useSyncExternalStore(subscribe, get, get) } /** Version, size, lane, drop count, sequence, gap, and timestamps. Changes once per batch. */ export function useStoreMeta<T>(store: RowStore<T>): StoreMeta { const subscribe = useCallback((cb: () => void) => store.subscribeMeta(cb), [store]) const get = useCallback(() => store.getMeta(), [store]) return useSyncExternalStore(subscribe, get, get) } /** * A view whose connection belongs to this component. Preparation is pure, so abandoned renders and * server rendering leave no live resources. Cleanup disconnects; effect replay reconnects the same view. * Keep options stable (a `useMemo`): new store/options identities select a new view synchronously. * Null options give null, for a component that borrows a caller-owned view instead. */ export function useView<T>(store: RowStore<T>, options: ViewOptions<T> | null): RowView<T> | null { const [prepared, setPrepared] = useState(() => ({ store, options, view: options ? store.prepareView(options) : null })) let current = prepared if (prepared.store !== store || prepared.options !== options) { current = { store, options, view: options ? store.prepareView(options) : null } setPrepared(current) } const view = current.view useLayoutEffect(() => view?.connect(), [view]) return view }lib/row-store.ts // A row store that React components subscribe to one row at a time. // // The consumer owns the feed. It calls `applyDeltas` once per frame with everything that changed // (a Tauri consumer straight from its event listener; a WebSocket consumer through `createFrameBatcher`). // A delta to one row wakes that row's subscribers and nobody else, and React batches every // notification fired inside one `applyDeltas` into one commit, so a batch of two thousand row // changes is one render pass over the rows that changed. // // Views sit on top: a filtered, sorted list of ids with an optional reorder hold so rows do not // move under a trader's cursor while they work. // setTimeout's longest delay; a longer one fires at once. const MAX_TIMER_MS = 2 ** 31 - 1 // How long a batch waits for a frame before a timer flushes it: a hidden tab runs no frames. const FRAME_FALLBACK_MS = 250 export type RowId = string /** How the lane loses data. `coalesced` drops stale ticks and counts them; `ordered` never drops. */ export type Lane = "coalesced" | "ordered" export interface DeltaBatch<T> { /** Whole rows. Replaces the row object, so every subscriber of that row wakes. */ upsert?: readonly T[] /** Partial rows merged into a new object. Unknown ids are ignored. */ patch?: readonly { id: RowId; fields: Partial<T> }[] remove?: readonly RowId[] /** Authoritative order for an ordered lane. Ids not present keep their previous relative order at the end. */ order?: readonly RowId[] meta?: { lane?: Lane /** Messages the producer dropped since the last batch (coalesced lane). Accumulated. */ dropped?: number /** Last sequence number applied (ordered lane). */ seq?: number /** A gap was detected and replay is in progress. */ gap?: boolean /** Producer timestamp of the newest message in the batch, ms since epoch. */ producedAt?: number } } export interface StoreMeta { /** Increments per batch or clear, before any subscriber is notified. */ version: number size: number lane: Lane dropped: number seq: number | null gap: boolean /** Wall clock of the last applied batch, ms since epoch. */ lastBatchAt: number | null producedAt: number | null } export interface ViewOptions<T> { comparator?: (a: T, b: T) => number filter?: (row: T) => boolean /** * After `touch()` (the grid calls it on every key and pointer interaction) the order is frozen for this * long: new rows append, removed rows vanish, nothing moves. 0 disables the hold. */ reorderHoldMs?: number /** Injectable clock, ms. Defaults to Date.now. */ now?: () => number } export interface RowView<T> { /** The store this view reads from. */ readonly store: RowStore<T> /** Stable array reference until the order actually changes. */ getIds(): readonly RowId[] subscribe(cb: () => void): () => void /** The user interacted; start or extend the reorder hold. */ touch(): void /** True while a hold is in force. */ isHeld(): boolean /** When the current or most recent reorder hold lapses or lapsed, in Date.now() milliseconds; null before any hold. Optional: without it, parked arrival marks age from their arrival, so a hold longer than the highlight window swallows those flashes. */ holdExpiresAt?(): number | null dispose(): void /** True after `dispose`: the view no longer follows the store. */ isDisposed(): boolean } /** A render-safe snapshot with an explicitly owned, reversible connection to its store. */ export interface PreparedRowView<T> extends RowView<T> { /** Follow batches until the returned release runs. Multiple owners release independently. */ connect(): () => void } /** Publish current row, order and metadata snapshots before notifying any subscriber. */ export interface RowStore<T> { /** Stable object reference until the row is replaced or patched. */ getRow(id: RowId): T | undefined /** Stable array reference until the order changes. Insertion order unless a batch carries `order`. */ getIds(): readonly RowId[] /** Stable object reference until the next batch. */ getMeta(): StoreMeta subscribeRow(id: RowId, cb: () => void): () => void subscribeOrder(cb: () => void): () => void subscribeMeta(cb: () => void): () => void /** Apply one batch synchronously. Call it once per frame. */ applyDeltas(batch: DeltaBatch<T>): void /** Prepare a current snapshot without subscriptions, registration or timers. Safe during render. */ prepareView(opts?: ViewOptions<T>): PreparedRowView<T> /** Follow the store immediately, even without subscribers. The caller must dispose the view. */ createView(opts?: ViewOptions<T>): RowView<T> /** Drop every row (a re-snapshot is coming). Wakes every subscriber. */ clear(): void readonly getRowId: (row: T) => RowId } export interface RowStoreOptions<T> { getRowId: (row: T) => RowId lane?: Lane now?: () => number } type Listener = () => void export function createRowStore<T>(options: RowStoreOptions<T>): RowStore<T> { const { getRowId } = options const now = options.now ?? Date.now const rows = new Map<RowId, T>() let ids: RowId[] = [] // Snapshots never alias the live array: the first batch appends to ids in place. let idsSnapshot: readonly RowId[] = ids.slice() let meta: StoreMeta = { version: 0, size: 0, lane: options.lane ?? "coalesced", dropped: 0, seq: null, gap: false, lastBatchAt: null, producedAt: null } const rowListeners = new Map<RowId, Set<Listener>>() const orderListeners = new Set<Listener>() const metaListeners = new Set<Listener>() const views = new Set<ViewImpl<T>>() function notifyRow(id: RowId) { const set = rowListeners.get(id) if (set) for (const cb of set) cb() } function applyDeltas(batch: DeltaBatch<T>) { const touched = new Set<RowId>() const removed = new Set<RowId>() let orderChanged = false if (batch.upsert) { for (const row of batch.upsert) { const id = getRowId(row) if (!rows.has(id)) { ids.push(id) orderChanged = true } rows.set(id, row) touched.add(id) } } if (batch.patch) { for (const { id, fields } of batch.patch) { const existing = rows.get(id) if (existing === undefined) continue rows.set(id, { ...existing, ...fields }) touched.add(id) } } if (batch.remove?.length) { const drop = new Set<RowId>() for (const id of batch.remove) { if (rows.delete(id)) { drop.add(id) removed.add(id) touched.add(id) } } if (drop.size) { ids = ids.filter((id) => !drop.has(id)) orderChanged = true } } if (batch.order) { const next: RowId[] = [] const seen = new Set<RowId>() for (const id of batch.order) { if (rows.has(id) && !seen.has(id)) { next.push(id) seen.add(id) } } for (const id of ids) if (!seen.has(id)) next.push(id) if (!sameOrder(ids, next)) { ids = next orderChanged = true } } const m = batch.meta meta = { version: meta.version + 1, size: rows.size, lane: m?.lane ?? meta.lane, dropped: meta.dropped + (m?.dropped ?? 0), seq: m?.seq ?? meta.seq, gap: m?.gap ?? meta.gap, lastBatchAt: now(), producedAt: m?.producedAt ?? meta.producedAt, } if (orderChanged) idsSnapshot = ids.slice() for (const view of views) view.onBatch(touched, removed, orderChanged, meta.version) for (const id of touched) notifyRow(id) if (orderChanged) for (const cb of orderListeners) cb() for (const cb of metaListeners) cb() } const store: RowStore<T> = { getRowId, getRow: (id) => rows.get(id), getIds: () => idsSnapshot, getMeta: () => meta, subscribeRow(id, cb) { let set = rowListeners.get(id) if (!set) rowListeners.set(id, (set = new Set())) set.add(cb) return () => { set!.delete(cb) if (!set!.size) rowListeners.delete(id) } }, subscribeOrder(cb) { orderListeners.add(cb) return () => orderListeners.delete(cb) }, subscribeMeta(cb) { metaListeners.add(cb) return () => metaListeners.delete(cb) }, applyDeltas, prepareView(opts = {}) { const view = new ViewImpl<T>(store, opts, () => views.add(view), () => views.delete(view)) return view }, createView(opts) { const view = store.prepareView(opts) view.connect() return view }, clear() { const all = ids rows.clear() ids = [] idsSnapshot = [] meta = { ...meta, version: meta.version + 1, size: 0, lastBatchAt: now() } for (const view of views) view.onBatch(new Set(all), new Set(all), true, meta.version) for (const id of all) notifyRow(id) for (const cb of orderListeners) cb() for (const cb of metaListeners) cb() }, } return store } function sameOrder(a: readonly RowId[], b: readonly RowId[]): boolean { if (a === b) return true if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false return true } class ViewImpl<T> implements PreparedRowView<T> { private ids: readonly RowId[] = [] private published: readonly RowId[] = [] private version: number private readonly listeners = new Set<Listener>() private holdUntil = 0 private settlePending = false private timer: ReturnType<typeof setTimeout> | null = null private disposed = false private connections = 0 private readonly now: () => number readonly store: RowStore<T> private readonly opts: ViewOptions<T> private readonly attach: () => void private readonly detach: () => void constructor(store: RowStore<T>, opts: ViewOptions<T>, attach: () => void, detach: () => void) { this.store = store this.opts = opts this.attach = attach this.detach = detach this.now = opts.now ?? Date.now this.version = store.getMeta().version this.ids = this.compute() this.published = this.ids } getIds() { if (this.disposed) return this.ids const version = this.store.getMeta().version const expired = !this.connections && this.settlePending && !this.isHeld() if (version !== this.version || expired) { this.cache(this.isHeld() ? this.computeHeld() : this.compute()) this.version = version if (expired) this.settlePending = false } return this.ids } subscribe(cb: Listener) { if (this.disposed) return () => {} this.listeners.add(cb) return () => { this.listeners.delete(cb) } } connect() { if (this.disposed) return () => {} let released = false const release = () => { if (released || this.disposed) return released = true if (--this.connections === 0) { this.detach() this.clearTimer() } } if (this.connections === 0) { // Refresh while disconnected, including a hold that expired while effects were hidden. this.getIds() this.attach() } this.connections++ try { this.publish() this.armTimer() } catch (error) { release() throw error } return release } isHeld() { return this.now() < this.holdUntil } holdExpiresAt() { // The hold is kept in the view's own clock; the reported deadline is wall // time, so a grid can compare it with Date.now whatever clock was injected. return this.holdUntil > 0 ? Date.now() + (this.holdUntil - this.now()) : null } touch() { if (this.disposed) return // A hold that isn't a positive number holds nothing, and one longer than a timer can wait, Infinity included, holds // as long as one can, about 24.8 days: past that a timer fires at once. const asked = this.opts.reorderHoldMs ?? 0 const hold = asked > 0 ? Math.min(asked, MAX_TIMER_MS) : 0 if (hold <= 0) return if (!this.connections) this.getIds() this.holdUntil = this.now() + hold this.settlePending = true this.clearTimer() this.armTimer() } private armTimer() { if (this.disposed || !this.connections || !this.settlePending || this.timer !== null) return // A quiet feed still settles: recompute when the hold lapses. this.timer = setTimeout(() => { this.timer = null this.settlePending = false this.cache(this.compute()) this.publish() }, Math.max(0, this.holdUntil - this.now())) } private clearTimer() { if (this.timer !== null) clearTimeout(this.timer) this.timer = null } dispose() { if (this.disposed) return this.disposed = true this.clearTimer() this.connections = 0 this.listeners.clear() this.detach() } isDisposed() { return this.disposed } /** Called by the store inside applyDeltas, before row listeners fire. */ onBatch(touched: Set<RowId>, removed: Set<RowId>, orderChanged: boolean, version: number) { // Another view's listener may already have read our current snapshot. It cannot publish it. if (this.version === version) { this.publish() return } this.version = version if (!touched.size && !orderChanged) return if (this.isHeld()) { this.cache(this.computeHeld(removed)) this.publish() return } // Only recompute when the batch could have changed this view. let relevant = orderChanged if (!relevant) for (const id of touched) if (this.passes(id) || this.ids.includes(id)) { relevant = true; break } if (relevant) { this.cache(this.compute()) this.publish() } } private computeHeld(removed?: Set<RowId>): readonly RowId[] { // Frozen order: drop removals and rows outside the filter, then append sorted newcomers. const present = new Set(this.ids) const kept = this.ids.filter((id) => !removed?.has(id) && this.passes(id)) const newcomers = this.store.getIds().filter((id) => !present.has(id) && this.passes(id)) if (this.opts.comparator) newcomers.sort(this.compare) return kept.length === this.ids.length && !newcomers.length ? this.ids : [...kept, ...newcomers] } private passes(id: RowId): boolean { const row = this.store.getRow(id) if (row === undefined) return false return this.opts.filter ? this.opts.filter(row) : true } private readonly compare = (a: RowId, b: RowId) => this.opts.comparator!(this.store.getRow(a)!, this.store.getRow(b)!) private compute(): readonly RowId[] { const all = this.store.getIds() const list = this.opts.filter ? all.filter((id) => this.passes(id)) : all.slice() if (this.opts.comparator) list.sort(this.compare) return list } private cache(next: readonly RowId[]) { if (!sameOrder(this.ids, next)) this.ids = next } private publish() { if (sameOrder(this.published, this.ids)) return this.published = this.ids for (const cb of this.listeners) cb() } } export interface FrameBatcher<T> { /** Queue a delta; the merged batch applies on the next animation frame, or after 250 ms when no frame comes. */ push(delta: DeltaBatch<T>): void /** Apply now, without waiting for the frame. */ flush(): void /** Drop everything queued. */ cancel(): void pending(): boolean } /** * Coalesce many small deltas into one batch per frame, last write wins per row. For consumers whose * feed delivers messages one at a time. A consumer whose core already batches per frame calls * `applyDeltas` directly and skips this: another frame of latency buys nothing. */ export function createFrameBatcher<T>( apply: (batch: DeltaBatch<T>) => void, options: { getRowId: (row: T) => RowId; raf?: (cb: () => void) => number; caf?: (handle: number) => void }, ): FrameBatcher<T> { const raf = options.raf ?? ((cb) => requestAnimationFrame(cb)) const caf = options.caf ?? ((h) => (typeof cancelAnimationFrame === "function" ? cancelAnimationFrame(h) : undefined)) const upserts = new Map<RowId, T>() const patches = new Map<RowId, Partial<T>>() const removes = new Set<RowId>() let order: readonly RowId[] | undefined let meta: NonNullable<DeltaBatch<T>["meta"]> | undefined let handle: number | null = null // A hidden document runs no frames, so a timer stands in: whichever comes first flushes, and the other is cancelled. let timer: ReturnType<typeof setTimeout> | null = null // The frame scheduled now. A frame the flush cancelled can still run where it can't be cancelled, as with a `raf` // passed without its `caf`, and it finds another frame scheduled, or none, and flushes nothing. let scheduled: object | null = null function reset() { upserts.clear() patches.clear() removes.clear() order = undefined meta = undefined } function flush() { scheduled = null if (handle !== null) { caf(handle) handle = null } if (timer !== null) { clearTimeout(timer) timer = null } if (!upserts.size && !patches.size && !removes.size && !order && !meta) return const batch: DeltaBatch<T> = {} if (upserts.size) batch.upsert = [...upserts.values()] if (patches.size) batch.patch = [...patches].map(([id, fields]) => ({ id, fields })) if (removes.size) batch.remove = [...removes] if (order) batch.order = order if (meta) batch.meta = meta reset() apply(batch) } return { push(delta) { for (const row of delta.upsert ?? []) { const id = options.getRowId(row) removes.delete(id) patches.delete(id) upserts.set(id, row) } for (const { id, fields } of delta.patch ?? []) { if (removes.has(id)) continue const pendingUpsert = upserts.get(id) if (pendingUpsert !== undefined) upserts.set(id, { ...pendingUpsert, ...fields }) else patches.set(id, { ...(patches.get(id) ?? {}), ...fields }) } for (const id of delta.remove ?? []) { upserts.delete(id) patches.delete(id) removes.add(id) } if (delta.order) order = delta.order if (delta.meta) { // Omitted fields keep their previous values all the way through: a frame that // never mentions gap must not write one, or a replay's recorded gap is // silenced mid-flight. Mentions apply in order, so a gap that opens and // closes inside one frame ends closed, exactly as two frames would. const gapMentioned = delta.meta.gap !== undefined || meta?.gap !== undefined meta = { ...meta, ...delta.meta, dropped: (meta?.dropped ?? 0) + (delta.meta.dropped ?? 0), ...(gapMentioned ? { gap: delta.meta.gap ?? meta?.gap } : {}), } if (!gapMentioned) delete (meta as { gap?: boolean }).gap } if (handle === null && timer === null) { const frame = {} scheduled = frame const requested = raf(() => { if (scheduled !== frame) return handle = null flush() }) // A frame that ran as it was requested has flushed already, and leaves nothing to hold. if (scheduled === frame) { handle = requested timer = setTimeout(() => { timer = null flush() }, FRAME_FALLBACK_MS) } } }, flush, cancel() { scheduled = null if (handle !== null) caf(handle) if (timer !== null) clearTimeout(timer) handle = null timer = null reset() }, pending: () => handle !== null || timer !== null, } }lib/format.ts // Number formatting for trading screens. Pure functions, no React, no dependencies. // Every formatter returns NULL_TOKEN for null, undefined, NaN, and infinities, so a grid // never prints "NaN" or "undefined" while a feed is warming up. export const NULL_TOKEN = "–" const MINUS = "−" export type Nullable = number | null | undefined export interface Locale { /** BCP 47 tag; defaults to en-US. */ locale?: string } export type PriceConvention = /** Fixed decimals, e.g. equities at 2. */ | { kind: "decimal"; decimals: number } /** Decimals derived from the instrument's tick size, e.g. 0.001 gives 3. */ | { kind: "tick"; tick: number } /** * Fractional quoting. `denominator` 32 or 64; `half` renders a half tick as "+" (99-16+) or "5" (99-165); * `eighths` renders eighths of a tick as a trailing digit (99-162 is 99 and 16 2/8 thirty-seconds). */ | { kind: "fraction"; denominator: 32 | 64; half: "+" | "5"; eighths?: boolean } /** What a quote is typed and read in. Bills quote on discount, most bonds on price, some on yield, credit often on a spread in basis points. */ export type QuoteBasis = "price" | "yield" | "discount" | "spread" export interface InstrumentConvention { price: PriceConvention /** Smallest price increment, used for stepping and rounding. */ tick: number yieldDecimals?: number quantityUnit?: "notional" | "contracts" /** Default price. */ quoteBasis?: QuoteBasis /** What a quote steps by in its basis. Default: the tick for price, 0.001 for yield and discount, 0.1 for spread. */ quoteStep?: number /** Decimals for a yield, discount, or spread quote. Default 3, 3, and 1. Price quotes follow their convention. */ quoteDecimals?: number /** Whether a higher quote means a lower price, so a normal market quotes the bid above the offer. Defaults by basis: yield and discount invert; price and spread do not, since a CDS spread quotes bid below offer while cash credit quotes the other way — declare it for the instrument when the default reads wrong. */ quoteInverted?: boolean } function isNil(v: Nullable): v is null | undefined { return v === null || v === undefined || !Number.isFinite(v) } // One Intl.NumberFormat per locale and option set; construction is the expensive part. const formatters = new Map<string, Intl.NumberFormat>() /** True for a time a `Date` can hold: a finite number of milliseconds within 8.64e15 of the epoch either way. */ export const isInstant = (ms: unknown): ms is number => Number.isFinite(ms) && Math.abs(ms as number) <= 8.64e15 export function numberFormat(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat { const key = `${locale ?? "en-US"}|${JSON.stringify(options)}` let nf = formatters.get(key) if (!nf) { nf = new Intl.NumberFormat(locale ?? "en-US", options) formatters.set(key, nf) } return nf } function fixed(v: number, decimals: number, locale?: string, extra: Intl.NumberFormatOptions = {}): string { return numberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, ...extra }).format(v) } function typographicMinus(s: string): string { return s.replace(/-/g, MINUS) } /** Decimal places needed to show a price on this tick grid. 0.001 gives 3, 1/32 gives 5, 1 gives 0. Capped at `max`. */ export function decimalsFromTick(tick: number, max = 8): number { if (!Number.isFinite(tick) || tick <= 0) return 0 for (let d = 0; d <= max; d++) { const scaled = tick * 10 ** d if (Math.abs(scaled - Math.round(scaled)) < 1e-9 * Math.max(1, scaled)) return d } return max } /** Nearest price on the tick grid, cleaned of float noise. */ export function roundToTick(v: number, tick: number): number { if (!Number.isFinite(v) || !Number.isFinite(tick) || tick <= 0) return v return Number((Math.round(v / tick) * tick).toFixed(decimalsFromTick(tick))) } /** Move `steps` ticks (negative allowed) from the nearest grid price. */ export function stepByTick(v: number, tick: number, steps: number): number { return roundToTick(roundToTick(v, tick) + steps * tick, tick) } /** Whole-number and sub-tick decomposition on an integer grid so 32nds never drift. */ function decomposeFraction(v: number, c: Extract<PriceConvention, { kind: "fraction" }>) { const sub = c.eighths ? 8 : 2 const unitsPerWhole = c.denominator * sub const total = Math.round(Math.abs(v) * unitsPerWhole) const whole = Math.floor(total / unitsPerWhole) const rem = total - whole * unitsPerWhole const ticks = Math.floor(rem / sub) const subticks = rem - ticks * sub return { negative: v < 0 && total > 0, whole, ticks, subticks, sub } } /** 99.515625 as "99-16+" (32nds, half "+"), "99-165" (half "5"), or "99-164" with eighths. */ export function formatFraction(v: Nullable, c: Extract<PriceConvention, { kind: "fraction" }>): string { if (isNil(v)) return NULL_TOKEN const { negative, whole, ticks, subticks, sub } = decomposeFraction(v, c) // From 1e21 a whole part prints in exponent form, and further up the scaling overflows: neither is the notation, // and parsePrice refuses the same range. if (!(whole < 1e21)) return NULL_TOKEN let tail = "" if (subticks !== 0) { if (c.eighths) tail = String(subticks) else tail = c.half } void sub return `${negative ? MINUS : ""}${whole}-${String(ticks).padStart(2, "0")}${tail}` } export function formatPrice(v: Nullable, c: PriceConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN switch (c.kind) { case "decimal": return typographicMinus(fixed(v, c.decimals, l?.locale)) case "tick": return typographicMinus(fixed(roundToTick(v, c.tick), decimalsFromTick(c.tick), l?.locale)) case "fraction": return formatFraction(v, c) } } /** * Numeric text, trimmed and with either minus sign read as "-", with its thousands separators removed, or null when a * comma in it isn't one. A comma groups * thousands in the whole part only: a first group of one to three digits with no leading zero, then groups of three, * and none after the point or among the 32nds, so "99,5" from a decimal-comma keyboard is no number, never 995. With * `decimals`, for text whose notation prints digits after the point, one comma with nothing after the whole part is * no number either, since "4,253" reads as 4253 grouped or as 4.253 typed with a decimal comma; two or more groups, * or a point or a fraction's dash after them, can only be grouping. Text without a comma comes back trimmed with the minus * read, and the rest of any text is left to the caller's own pattern, which decides what is a number. */ export function stripGrouping(input: string, options: { decimals: boolean }): string | null { const text = input.trim().replace(/−/g, "-") if (!text.includes(",")) return text const m = /^([+-]?)([1-9]\d{0,2}(?:,\d{3})+)((?:[.-].*)?)$/.exec(text) if (!m || m[3]!.includes(",")) return null const whole = m[2]! if (options.decimals && !m[3] && whole.indexOf(",") === whole.lastIndexOf(",")) return null return m[1]! + whole.replace(/,/g, "") + m[3]! } // Whether a convention's own text carries digits after the point, or a fraction's ticks. const printsDecimals = (c: PriceConvention) => (c.kind === "fraction" ? true : c.kind === "decimal" ? c.decimals > 0 : decimalsFromTick(c.tick) > 0) /** * Inverse of formatPrice for ticket entry. Accepts the convention's own notation and a plain decimal. * Returns null when the text is not a price. Fraction input: "99-16+", "99-165", "99-162", "99-16", "99". */ export function parsePrice(s: string, c: PriceConvention): number | null { const text = stripGrouping(s, { decimals: printsDecimals(c) }) if (!text) return null if (c.kind === "fraction") { const m = /^([+-]?)(\d+)(?:-(\d{1,2})([+0-9])?)?$/.exec(text) if (m) { const [, sign, wholeText, ticksText, tail] = m const ticks = ticksText ? Number(ticksText) : 0 if (ticks >= c.denominator) return null let subFraction = 0 if (tail === "+") subFraction = 0.5 else if (tail !== undefined) { const digit = Number(tail) if (c.eighths) { if (digit > 7) return null subFraction = digit / 8 } else if (c.half === "5" && digit === 5) subFraction = 0.5 else if (digit === 0) subFraction = 0 else return null } const value = Number(wholeText) + (ticks + subFraction) / c.denominator // What cannot print back in the notation is refused: unit scaling must stay finite, the // whole part must stay out of exponent form (1e21 up), and "-0" stays plain zero. if (value >= 1e21 || !Number.isFinite(value * c.denominator * (c.eighths ? 8 : 2))) return null return value === 0 ? 0 : sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Every convention keeps the promise the reference row makes: what parses can print back. // Intl prints tick and decimal prices in full digits at any finite size, so only fraction // notation carries the 1e21 bound; tick scaling must stay finite, and zero stays plain zero. if (c.kind === "tick") { const rounded = roundToTick(n, c.tick) return Number.isFinite(rounded) ? (rounded === 0 ? 0 : rounded) : null } if (c.kind === "decimal") { const fixed = Number(n.toFixed(c.decimals)) return fixed === 0 ? 0 : fixed } // A decimal typed into a fraction convention snaps to the printable grid, so the value a field // reports is the price its formatted text shows. The magnitude rounds, as the formatter rounds, // so a negative tie snaps away from zero the way it prints. const unitsPerWhole = c.denominator * (c.eighths ? 8 : 2) const units = Math.round(Math.abs(n) * unitsPerWhole) // Scaling can overflow a finite input, a magnitude from 1e21 up prints in exponent form the // notation cannot parse back, and a sub-half-unit negative would make negative zero. if (!Number.isFinite(units) || units / unitsPerWhole >= 1e21) return null return units === 0 ? 0 : (n < 0 ? -units : units) / unitsPerWhole } /** 4.2531 as "4.253%". */ export function formatYield(v: Nullable, o: { decimals?: number; suffix?: "%" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + (o.suffix ?? "%") } /** 12.5 as "12.5 bp"; signed gives "+12.5 bp". */ export function formatBps(v: Nullable, o: { decimals?: number; signed?: boolean; unit?: "bp" | "bps" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const unit = o.unit ?? "bp" const body = o.signed ? formatSigned(v, { decimals: o.decimals ?? 1, locale: o.locale }) : typographicMinus(fixed(v, o.decimals ?? 1, o.locale)) return unit ? `${body} ${unit}` : body } /** Dollar value of a basis point as whole currency; compact gives "$1.23K". */ export function formatDv01(v: Nullable, o: { currency?: string; compact?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const currency = o.currency ?? "USD" if (o.compact) { const symbol = numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }) .formatToParts(0) .find((p) => p.type === "currency")?.value ?? "" return `${v < 0 ? MINUS : ""}${symbol}${formatNotional(Math.abs(v), { compact: true, locale: o.locale })}` } return typographicMinus(numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }).format(v)) } /** 1250000 as "1.25M" (compact), "1.25mm" (unit "mm", the desk's word for millions, never scaled further), or "1,250,000.00". Compact suffixes K, M, B, T. */ export function formatNotional(v: Nullable, o: { compact?: boolean; decimals?: number; unit?: "mm" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 if (o.unit === "mm") return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(v / 1e6)) + "mm" if (!o.compact) return typographicMinus(fixed(v, decimals, o.locale)) const abs = Math.abs(v) const [divisor, suffix] = abs >= 1e12 ? [1e12, "T"] : abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : abs >= 1e3 ? [1e3, "K"] : [1, ""] return typographicMinus(fixed(v / divisor, suffix ? decimals : 0, o.locale)) + suffix } /** "+0.12", "−0.12" (U+2212), and "0.00" for zero: flat carries no sign. */ export function formatSigned(v: Nullable, o: { decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 2, o.locale, { signDisplay: "exceptZero" })) } /** 1.234 as "1.23%"; signed gives "+1.23%". */ export function formatPercent(v: Nullable, o: { decimals?: number; signed?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 return (o.signed ? formatSigned(v, { decimals, locale: o.locale }) : typographicMinus(fixed(v, decimals, o.locale))) + "%" } /** Whole quantities with grouping: 1000000 as "1,000,000". */ export function formatQuantity(v: Nullable, l?: Locale): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(Math.round(v), 0, l?.locale)) } /** * A coupon the way a run prints it: 4.125 as "4 1/8", 4.5 as "4 1/2", 4 as "4", 0 as "0" (coupons step in eighths). * One off the eighths grid prints as a plain decimal, "4.1". `style: "decimal"` prints "4.125%". */ export function formatCoupon(v: Nullable, o: { style?: "fraction" | "decimal"; decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN if (o.style === "decimal") return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + "%" const eighths = Math.round(Math.abs(v) * 8) if (Math.abs(eighths / 8 - Math.abs(v)) > 1e-9) return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: o.decimals ?? 3 }).format(v)) const whole = Math.floor(eighths / 8) const rem = eighths % 8 const fraction = rem === 0 ? "" : rem % 4 === 0 ? "1/2" : rem % 2 === 0 ? `${rem / 2}/4` : `${rem}/8` const body = fraction ? (whole ? `${whole} ${fraction}` : fraction) : String(whole) return `${v < 0 && eighths > 0 ? MINUS : ""}${body}` } /** A date as an instant, a millisecond count, or a string `Date` can read. A date-only string ("2034-05-15") is that day in UTC. */ export type DateLike = Date | number | string function toDate(d: DateLike | null | undefined): Date | null { if (d === null || d === undefined) return null const date = d instanceof Date ? d : new Date(d) return Number.isNaN(date.getTime()) ? null : date } const dateFormatters = new Map<string, Intl.DateTimeFormat>() /** * A maturity as a run prints it: "05/15/34", or "05/15/2034" with `year: "numeric"`. Read in UTC, so a * date-only string prints as that day wherever the screen is. */ export function formatMaturity(d: DateLike | null | undefined, o: { year?: "2-digit" | "numeric" } & Locale = {}): string { const date = toDate(d) if (!date) return NULL_TOKEN const key = `${o.locale ?? "en-US"}|${o.year ?? "2-digit"}` let f = dateFormatters.get(key) if (!f) { f = new Intl.DateTimeFormat(o.locale ?? "en-US", { month: "2-digit", day: "2-digit", year: o.year ?? "2-digit", timeZone: "UTC" }) dateFormatters.set(key, f) } return f.format(date) } /** Whole UTC days from `now` (the moment, by default) to a maturity. Negative once it has passed; null when the date does not read. */ export function daysToMaturity(d: DateLike | null | undefined, now: DateLike = Date.now()): number | null { const maturity = toDate(d) const from = toDate(now) if (!maturity || !from) return null const day = (x: Date) => Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate()) return Math.round((day(maturity) - day(from)) / 86_400_000) } /** How many ticks `a` is from `b`, signed, to the nearest eighth of a tick: 99-17 against 99-16+ on a 1/64 tick is 1. NaN when a side is not a number or the tick is not positive. */ export function ticksBetween(a: number, b: number, tick: number): number { if (!Number.isFinite(a) || !Number.isFinite(b) || !(tick > 0)) return NaN return Math.round(((a - b) / tick) * 8) / 8 } /** A count of ticks: "+1", "−0.5", "0", up to three decimals and no trailing zeros. Signed unless told otherwise; `unit` adds a word; `grouping: false` drops the thousands separators, for an editor's text. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string; grouping?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero", ...(o.grouping === false ? { useGrouping: false } : {}) }).format(v)) return o.unit ? `${body} ${o.unit}` : body } /** * The class a number wears (contract rule 14): lining, tabular figures whatever the font, in the numeric family. * `--tradecn-font-numeric` is the sans by default, so letters stay proportional and only the digits take one * width, which is what a column of prices wants. docs/typography.md has the reasoning and the tokens. */ export const NUMERIC_CLASS = "font-(family-name:--tradecn-font-numeric) lining-nums tabular-nums" /** The same figures in the mono stack, `--tradecn-font-mono`: for a fraction quote, so digits and the dash take one width; tails still lengthen a quote. */ export const MONO_NUMERIC_CLASS = "font-(family-name:--tradecn-font-mono) lining-nums tabular-nums" /** * The class for a number printed under a convention: a fraction price (99-16+) sets in the mono stack, so * digits and the dash take one width down a column; a tailed quote still runs longer than an untailed one. * Every other price, and every quote in another basis, keeps the numeric family. */ export function numericFontClass(c?: PriceConvention | InstrumentConvention | null): string { const price = c && "price" in c ? (quoteBasisOf(c) === "price" ? c.price : null) : c return price?.kind === "fraction" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS } /** The word for a quote field's label, per basis. */ export const QUOTE_BASIS_LABELS: Record<QuoteBasis, string> = { price: "Price", yield: "Yield", discount: "Discount", spread: "Spread" } const QUOTE_DEFAULTS: Record<Exclude<QuoteBasis, "price">, { step: number; decimals: number }> = { yield: { step: 0.001, decimals: 3 }, discount: { step: 0.001, decimals: 3 }, spread: { step: 0.1, decimals: 1 }, } export function quoteBasisOf(c: InstrumentConvention): QuoteBasis { return c.quoteBasis ?? "price" } /** Whether a higher quote means a lower price for this instrument: declared, else by basis — yield and discount invert, price and spread do not. */ export function quoteInvertedOf(c: InstrumentConvention): boolean { if (c.quoteInverted !== undefined) return c.quoteInverted const basis = quoteBasisOf(c) return basis === "yield" || basis === "discount" } /** What a quote steps by in its basis: the tick for a price, else `quoteStep` or the basis default. */ export function quoteStepOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? c.tick : (c.quoteStep ?? QUOTE_DEFAULTS[basis].step) } function quoteDecimalsOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? 0 : (c.quoteDecimals ?? QUOTE_DEFAULTS[basis].decimals) } /** A quote in the instrument's basis: a price by its convention, else a fixed decimal with the basis's decimals and no unit, since the field's label says the basis. */ export function formatQuote(v: Nullable, c: InstrumentConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN if (quoteBasisOf(c) === "price") return formatPrice(v, c.price, l) return typographicMinus(fixed(roundToTick(v, quoteStepOf(c)), quoteDecimalsOf(c), l?.locale)) } /** Inverse of formatQuote for a quote field. A price takes its notation or a decimal; the others take a decimal and snap to the quote step. Null when the text is not a quote. */ export function parseQuote(s: string, c: InstrumentConvention): number | null { if (quoteBasisOf(c) === "price") return parsePrice(s, c.price) const text = stripGrouping(s, { decimals: quoteDecimalsOf(c) > 0 }) if (text === null || !/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Snapping divides by the step, which can overflow where the raw number does not; what // cannot print back is not a quote. const snapped = roundToTick(n, quoteStepOf(c)) return Number.isFinite(snapped) ? snapped : null } /** Move a quote by `steps` of its step, from the nearest grid value. */ export function stepQuote(v: number, c: InstrumentConvention, steps: number): number { return stepByTick(v, quoteStepOf(c), steps) } /** Bind a convention once per instrument; a grid column calls `formatters[row.instrumentId].price(v)`, a quote field its `quote`, `parseQuote`, and `stepQuote`. */ export function createInstrumentFormatter(c: InstrumentConvention, l?: Locale) { return { tick: c.tick, basis: quoteBasisOf(c), quoteStep: quoteStepOf(c), price: (v: Nullable) => formatPrice(v, c.price, l), yield: (v: Nullable) => formatYield(v, { decimals: c.yieldDecimals ?? 3, locale: l?.locale }), step: (v: number, steps: number) => stepByTick(v, c.tick, steps), parsePrice: (s: string) => parsePrice(s, c.price), quantity: (v: Nullable) => formatQuantity(v, l), quote: (v: Nullable) => formatQuote(v, c, l), parseQuote: (s: string) => parseQuote(s, c), stepQuote: (v: number, steps: number) => stepQuote(v, c, steps), } }lib/grid-rules.ts // Rules as data for a grid: which cells to color, which rows to show, and the order, as plain objects // a desk's support staff can write without a build, compiled here into the functions the grid runs. // // A rule names a column by its key and reads a row through that column's own accessor. A value typed // into a rule is read in the column's own format through its `parse`, so a price rule on a 32nds // column is written the way the column prints, "99-16+"; a numeric column without one reads a plain // number with commas only between thousands. Tones are token names, never a literal // color, and an applied rule carries its meaning in a channel besides the color: `data-rule` names // the rule on the element and the rule's words go in the element's accessible description (contract // rule 15). Nothing here decides a value; it only compares what the row says with what the rule says. import { stripGrouping } from "@/lib/format" export type RuleOp = "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "between" | "in" | "contains" | "startsWith" | "isNull" | "notNull" /** What a rule compares against, as typed. A string is read in the column's format; a number or boolean is used as it is. */ export type RuleValue = string | number | boolean | null /** A token name. The soft variant, or a 12% tint for primary and destructive, tints the background; the text on it is the foreground. */ export type RuleTone = "up" | "down" | "flat" | "stale" | "expiring" | "primary" | "destructive" export interface RuleCondition { op: RuleOp /** For eq, ne, gt, gte, lt, lte, contains, and startsWith. */ value?: RuleValue /** For between (two: low, then high, both inclusive) and in (any number). Each is read as `value` is. */ values?: RuleValue[] } export interface ColumnRule { id: string /** The column whose value the rule reads, by key. */ column: string when: RuleCondition tone: RuleTone /** The rule in words: the accessible description of what it paints. `describeRule` writes one when it is left out. */ label?: string /** What the tone paints: the cell in `column` (default), or the whole row. */ target?: "cell" | "row" } export interface FilterRule extends RuleCondition { column: string } export interface SortRule { key: string dir: "asc" | "desc" } export interface GridRules { /** Cells and rows to color. The first rule in the list that matches a cell decides it. */ columns?: ColumnRule[] /** Every rule has to hold for a row to show. */ filter?: FilterRule[] /** The first rule that tells two rows apart decides their order. */ sort?: SortRule[] } // The types above are rules as you write them. Rules read from saved data are looser: any field can be missing but // a sort key's direction, which reads as ascending unless it says descending, and an op or a tone can be text this version doesn't know, for `ruleProblem` to name. The readers return these, // and every helper, the grid, and the editor take them, so code that reads a rule's fields checks them first. /** A condition as read from saved data: its op can be missing or unknown text. */ export interface ReadCondition extends Omit<RuleCondition, "op"> { op?: string } /** A highlight as read from saved data: any field can be missing, and its tone can be unknown text. */ export interface ReadColumnRule extends Omit<ColumnRule, "id" | "column" | "when" | "tone"> { id?: string column?: string when?: ReadCondition tone?: string } /** A filter rule as read from saved data: its column and op can be missing, and its op unknown text. */ export interface ReadFilterRule extends ReadCondition { column?: string } /** A sort key as read from saved data: its key can be missing. */ export interface ReadSortRule extends Omit<SortRule, "key"> { key?: string } /** Rules as read from saved data. A set of rules you wrote is one of these too. */ export interface ReadGridRules { columns?: ReadColumnRule[] filter?: ReadFilterRule[] sort?: ReadSortRule[] } /** Which list a rule comes from, for the helpers that judge or describe one rule. */ export type RuleKind = "highlight" | "filter" /** What a rule needs of a column. The grid's `ColumnDef` satisfies it. */ export interface RuleColumn<T> { key: string /** Named in a rule's words when it is a string, unless `title` names it; the key otherwise. */ header?: unknown /** The column's name in words, before its header. */ title?: string accessor: (row: T) => unknown numeric?: boolean /** Reads a value typed into a rule in this column's own format. Default: a number for a numeric column, the text itself for the rest. */ parse?: (text: string) => unknown /** Prints a parsed rule value in the rule's words, so a description says what the rule compares — a data-grid column's `format` fits when it reads only the value, since no row exists here. */ format?: (value: unknown, ...rest: never[]) => string } export const RULE_OPS: readonly RuleOp[] = ["eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "contains", "startsWith", "isNull", "notNull"] export const NUMBER_OPS: readonly RuleOp[] = ["eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "isNull", "notNull"] export const TEXT_OPS: readonly RuleOp[] = ["eq", "ne", "contains", "startsWith", "in", "isNull", "notNull"] /** The word for each op, as a rule reads aloud: "Price above 99-16+". */ export const RULE_OP_LABELS: Record<RuleOp, string> = { eq: "is", ne: "is not", gt: "above", gte: "at or above", lt: "below", lte: "at or below", between: "between", in: "one of", contains: "contains", startsWith: "starts with", isNull: "is empty", notNull: "is not empty", } /** The words a rule and its problems are written in. Templates fill `{name}`, `{op}`, `{value}`, `{column}`, `{tone}`, `{low}`, and `{high}`. */ export interface RuleWords { ops: Record<RuleOp, string> tones: Record<RuleTone, string> /** The two ends of a range, `{low}` and `{high}`. */ range: string needsColumn: string noColumn: string needsComparison: string noComparison: string needsValue: string unreadable: string rangeNeedsEnds: string rangeOrder: string setNeedsValue: string needsTone: string noTone: string } /** Words to replace: any of them, a partial map of comparisons or tones included. */ export type RuleWordsInput = Partial<Omit<RuleWords, "ops" | "tones">> & { ops?: Partial<Record<RuleOp, string>>; tones?: Partial<Record<RuleTone, string>> } export const DEFAULT_RULE_WORDS: RuleWords = { ops: RULE_OP_LABELS, tones: { up: "up", down: "down", flat: "flat", stale: "stale", expiring: "expiring", primary: "primary", destructive: "destructive" }, range: "{low} and {high}", needsColumn: "The rule needs a column.", noColumn: 'No column is named "{name}".', needsComparison: "The rule needs a comparison.", noComparison: 'No comparison is named "{op}".', needsValue: "{op} needs a value.", unreadable: '"{value}" is not a value {column} reads.', rangeNeedsEnds: "{op} needs a low value and a high value.", rangeOrder: "{op} needs a low value at or below the high value.", setNeedsValue: "{op} needs at least one value.", needsTone: "The rule needs a tone.", noTone: 'No tone is named "{tone}".', } /** The defaults with yours over them, maps merged key by key. */ // An undefined word keeps its default, as DataGrid's labels do. const defined = <W extends object>(words: W | undefined): Partial<W> => Object.fromEntries(Object.entries(words ?? {}).filter(([, word]) => word !== undefined)) as Partial<W> export function ruleWords(words?: RuleWordsInput): RuleWords { return { ...DEFAULT_RULE_WORDS, ...defined(words), ops: { ...DEFAULT_RULE_WORDS.ops, ...defined(words?.ops) }, tones: { ...DEFAULT_RULE_WORDS.tones, ...defined(words?.tones) } } } const fillWords = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (match, key: string) => values[key] ?? match) export const RULE_TONES: readonly RuleTone[] = ["up", "down", "flat", "stale", "expiring", "primary", "destructive"] /** * The text on a tint: the foreground, with any up, down, flat, stale, expiring, destructive, or primary text inside * taking it too. A state color on a tint can drop below 4.5 to 1 in the light themes, and every state the library * colors also says itself another way, by a sign, a word, or a description. Put it beside every tint behind text. */ export const ON_TINT_CLASS = "text-foreground [&_.text-up]:text-inherit [&_.text-down]:text-inherit [&_.text-flat]:text-inherit [&_.text-stale]:text-inherit [&_.text-expiring]:text-inherit [&_.text-destructive]:text-inherit [&_.text-primary]:text-inherit [&_.text-muted-foreground]:text-inherit" // A tone is a tint of the token's soft variant, painted as a background image rather than a background // color so it layers over whatever color the element already has: a frozen cell keeps its opaque // `bg-background` under the tint and stays opaque as rows scroll beneath it. Literal strings, so Tailwind finds them. const ON_TINT = ON_TINT_CLASS export const RULE_TONE_CLASS: Record<RuleTone, string> = { up: `${ON_TINT} bg-[linear-gradient(var(--up-soft),var(--up-soft))]`, down: `${ON_TINT} bg-[linear-gradient(var(--down-soft),var(--down-soft))]`, flat: `${ON_TINT} bg-[linear-gradient(var(--flat-soft),var(--flat-soft))]`, stale: `${ON_TINT} bg-[linear-gradient(var(--stale-soft),var(--stale-soft))]`, expiring: `${ON_TINT} bg-[linear-gradient(var(--expiring-soft),var(--expiring-soft))]`, primary: `${ON_TINT} bg-[linear-gradient(color-mix(in_oklab,var(--primary)_12%,transparent),color-mix(in_oklab,var(--primary)_12%,transparent))]`, destructive: `${ON_TINT} bg-[linear-gradient(color-mix(in_oklab,var(--destructive)_12%,transparent),color-mix(in_oklab,var(--destructive)_12%,transparent))]`, } /** What an applied rule puts on a cell or a row. */ export interface RuleDecoration { "data-rule": string /** The rule's tone as text, absent when the rule has none: one outside RULE_TONES paints nothing. */ "data-tone"?: string "aria-description": string className: string } /** The ops a column offers: comparisons and ranges for a numeric column, text matching for the rest. */ export function opsFor(column: { numeric?: boolean } | undefined): readonly RuleOp[] { return column?.numeric ? NUMBER_OPS : TEXT_OPS } /** The column's name in words: its title, else its header when that is a string, else its key. */ export function columnName<T>(column: RuleColumn<T> | undefined, key?: string): string { if (column && typeof column.title === "string" && column.title.trim()) return column.title if (column && typeof column.header === "string" && column.header.trim()) return column.header return column?.key ?? textOf(key) ?? "" } /** Null for null, undefined, NaN, and the infinities; the value otherwise. */ export function normalizeValue(value: unknown): unknown { if (value === null || value === undefined) return null if (typeof value === "number" && !Number.isFinite(value)) return null return value } /** Nulls last, numbers numerically, everything else as text. */ export function compareValues(a: unknown, b: unknown): number { const an = normalizeValue(a) === null const bn = normalizeValue(b) === null if (an && bn) return 0 if (an) return 1 if (bn) return -1 if (typeof a === "number" && typeof b === "number") return a - b return String(a).localeCompare(String(b)) } /** `compareValues` in a direction, with a null last whichever way the list runs: a missing price never leads a list sorted high to low. */ export function compareDirected(a: unknown, b: unknown, dir: "asc" | "desc"): number { const an = normalizeValue(a) === null const bn = normalizeValue(b) === null if (an || bn) return compareValues(a, b) const c = compareValues(a, b) return dir === "desc" ? -c : c } /** * A value as typed into a rule, read for a column: a string goes through the column's `parse`, or for * a numeric column through `stripGrouping` and `Number`, and stays text otherwise; a number or boolean is used as it is. * Null when there is nothing to read, and null never matches anything but `isNull`. */ export function readRuleValue<T>(column: RuleColumn<T> | undefined, raw: RuleValue | undefined): unknown { if (raw === undefined || raw === null) return null if (typeof raw === "number" || typeof raw === "boolean") return normalizeValue(raw) // Anything else that is not text (an object or a list in a rule's JSON) has nothing to read. if (typeof raw !== "string") return null if (column?.parse) return normalizeValue(column.parse(raw)) if (column?.numeric) { // The column's notation is unknown here, so a comma reads only as a thousands separator and one comma with no // point after it, which reads either way, is no number: "1,5" is never 15, and "4,253" never 4253. if (raw.trim() === "") return null const text = stripGrouping(raw, { decimals: true }) return text === null ? null : normalizeValue(Number(text)) } return raw } function same(a: unknown, b: unknown): boolean { if (typeof a === "string" && typeof b === "string") return a.toLowerCase() === b.toLowerCase() if (typeof a === "number" && typeof b === "string") return a === Number(b) if (typeof a === "string" && typeof b === "number") return Number(a) === b return a === b } const needsValue = new Set<RuleOp>(["eq", "ne", "gt", "gte", "lt", "lte", "contains", "startsWith"]) /** True for an op this module compares with. Other text, inherited names such as `toString` included, is not one. */ function isRuleOp(op: unknown): op is RuleOp { return typeof op === "string" && (RULE_OPS as readonly string[]).includes(op) } /** True for a tone this module paints with. */ function isRuleTone(tone: unknown): tone is RuleTone { return typeof tone === "string" && (RULE_TONES as readonly string[]).includes(tone) } function isCondition(condition: unknown): condition is RuleCondition { return typeof condition === "object" && condition !== null && isRuleOp((condition as RuleCondition).op) } // A value that is not text is quoted as its JSON, so an object in a rule reads as `{}`, not [object Object]. One JSON // cannot print reads through `String`, so a bigint prints its digits, and by its tag when even that throws (an object // whose own `toString` is not a function), so this never throws. function shown(raw: unknown): string { if (typeof raw === "string") return raw if (typeof raw === "number") return String(raw) try { return JSON.stringify(raw) ?? String(raw) } catch { try { return String(raw) } catch { return Object.prototype.toString.call(raw) } } } // A name or a word from saved data: text as it is, anything else as its JSON, nothing for null or undefined. const textOf = (value: unknown): string | undefined => (value === undefined || value === null ? undefined : shown(value)) // A value to compare with: text, a number, a boolean, or null as they are; anything else as its JSON text, so the // value the editor shows is the value compared and `ruleProblem` can name it. const valueOf = (value: unknown): RuleValue | undefined => value === undefined ? undefined : value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : shown(value) // An object that is not a list: what a rule, a condition, or a set of rules is saved as. const isEntry = (value: unknown): value is object => typeof value === "object" && value !== null && !Array.isArray(value) // The saved object with the fields this module reads replaced by their readings, or removed when a reading is // nothing. Every other field an app keeps on a rule (an owner, a flag, an id on a filter) passes through, so an edit // made from a read rule never loses it. function keep<R>(saved: Record<string, unknown>, read: Record<string, unknown>): R { const rule: Record<string, unknown> = { ...saved } for (const [key, value] of Object.entries(read)) { if (value === undefined) delete rule[key] else rule[key] = value } return rule as R } /** * A condition as a desk saved it, read: its op as text (one this module does not know stays as its text, for * `ruleProblem` to name), its value as `valueOf` reads it, and its values only from a list; any other field passes * through. Undefined when the condition is not an object. */ export function readCondition(value: unknown): ReadCondition | undefined { if (!isEntry(value)) return undefined const saved = value as Record<string, unknown> return keep<ReadCondition>(saved, { op: textOf(saved.op), value: valueOf(saved.value), values: Array.isArray(saved.values) ? saved.values.flatMap((item) => (item === undefined ? [] : [valueOf(item)!])) : undefined, }) } /** * A highlight as a desk saved it, read: names and words as text, its condition read, a label only when it is text, * and the row as its target only when it says so; a field it reads to nothing is left out, and any other field * passes through. Null when it is not an object. */ export function readColumnRule(value: unknown): ReadColumnRule | null { if (!isEntry(value)) return null const saved = value as Record<string, unknown> return keep<ReadColumnRule>(saved, { id: textOf(saved.id), column: textOf(saved.column), when: readCondition(saved.when), tone: textOf(saved.tone), label: typeof saved.label === "string" ? saved.label : undefined, target: saved.target === "row" || saved.target === "cell" ? saved.target : undefined, }) } /** A filter rule as a desk saved it, read as a condition on its column; any other field passes through. Null when it is not an object. */ export function readFilterRule(value: unknown): ReadFilterRule | null { if (!isEntry(value)) return null return keep<ReadFilterRule>(readCondition(value) as unknown as Record<string, unknown>, { column: textOf((value as Record<string, unknown>).column) }) } /** A sort key as a desk saved it, read: its key as text, descending only when it says so; any other field passes through. Null when it is not an object. */ export function readSortRule(value: unknown): ReadSortRule | null { if (!isEntry(value)) return null const saved = value as Record<string, unknown> return keep<ReadSortRule>(saved, { key: textOf(saved.key), dir: saved.dir === "desc" ? "desc" : "asc" }) } /** * Rules a desk saved or shared, read into the shape the grid and the editor take: a list that is not a list is * left out, an entry that is not an object is dropped, and each rule is read as `readColumnRule`, `readFilterRule`, * and `readSortRule` read it; any other field passes through. Nothing is decided here: a rule that cannot apply * still reads, for `ruleProblem` to name. */ export function readRules(value: unknown): ReadGridRules { if (!isEntry(value)) return {} const saved = value as Record<string, unknown> const list = <R,>(items: unknown, read: (item: unknown) => R | null): R[] | undefined => Array.isArray(items) ? items.flatMap((item) => { const rule = read(item); return rule ? [rule] : [] }) : undefined return keep<ReadGridRules>(saved, { columns: list(saved.columns, readColumnRule), filter: list(saved.filter, readFilterRule), sort: list(saved.sort, readSortRule) }) } // A highlight carries its condition in `when` and has a tone; a filter rule carries its condition flat and has no tone. const isHighlightShape = (rule: object) => "when" in rule || "tone" in rule // A list that is not a list reads as none. const listOf = (value: unknown): readonly unknown[] => (Array.isArray(value) ? value : []) // A list of values that is not a list reads as none. const valuesOf = (condition: { values?: unknown }): readonly RuleValue[] => (Array.isArray(condition.values) ? condition.values : []) /** * One condition compiled against one column: the typed values are read once, and the function that * comes back only compares. A row whose value is null answers only `isNull` and `notNull`. Rules are * data a desk edits, so a condition that is missing or names no op this module knows matches nothing * rather than throwing, as do `in` and `between` when their `values` are not a list; `ruleProblem` says * what is wrong with it. */ export function compileCondition<T>(saved: ReadCondition | undefined, column: RuleColumn<T>): (row: T) => boolean { const condition = readCondition(saved) if (!isCondition(condition)) return () => false const { op } = condition const want = readRuleValue(column, condition.value) const list = valuesOf(condition).map((v) => readRuleValue(column, v)).filter((v) => v !== null) const read = (row: T) => normalizeValue(column.accessor(row)) switch (op) { case "isNull": return (row) => read(row) === null case "notNull": return (row) => read(row) !== null case "eq": return want === null ? () => false : (row) => same(read(row), want) case "ne": return want === null ? () => false : (row) => { const v = read(row) return v !== null && !same(v, want) } case "gt": case "gte": case "lt": case "lte": { if (want === null) return () => false return (row) => { const v = read(row) if (v === null) return false const c = compareValues(v, want) return op === "gt" ? c > 0 : op === "gte" ? c >= 0 : op === "lt" ? c < 0 : c <= 0 } } case "between": { const [lo, hi] = list if (lo === undefined || hi === undefined) return () => false return (row) => { const v = read(row) return v !== null && compareValues(v, lo) >= 0 && compareValues(v, hi) <= 0 } } case "in": return list.length === 0 ? () => false : (row) => list.some((item) => same(read(row), item)) case "contains": case "startsWith": { if (want === null) return () => false const needle = String(want).toLowerCase() return (row) => { const v = read(row) if (v === null) return false const text = String(v).toLowerCase() return op === "contains" ? text.includes(needle) : text.startsWith(needle) } } } } function findColumn<T>(columns: readonly RuleColumn<T>[], key: string | undefined): RuleColumn<T> | undefined { return key === undefined ? undefined : columns.find((column) => column.key === key) } /** * Why a rule cannot apply, in a sentence, or null when it can: a column the grid does not have, a * value the column cannot read, a range without its two ends, a set with nothing in it, a missing or * unknown comparison or tone. `kind` says which list the rule is in; without it, a `when` or a `tone` * key makes the rule a highlight. */ export function ruleProblem<T>(rule: ReadColumnRule & ReadFilterRule, columns: readonly RuleColumn<T>[], kind?: RuleKind, words?: RuleWordsInput): string | null { const w = ruleWords(words) if (typeof rule !== "object" || rule === null) return w.needsColumn const named = textOf(rule.column) if (named === undefined) return w.needsColumn const column = findColumn(columns, named) if (!column) return fillWords(w.noColumn, { name: named }) const highlight = kind ? kind === "highlight" : isHighlightShape(rule) // The condition as the grid reads it: one saved as anything but an object that is not a list is none. const read = readCondition(highlight ? rule.when : rule) const op = read?.op if (op === undefined || op === "") return w.needsComparison if (!isRuleOp(op)) return fillWords(w.noComparison, { op }) // The op is one this module knows, from here on. const condition = read as RuleCondition const name = columnName(column) const unreadable = (raw: RuleValue | undefined) => raw !== undefined && raw !== null && raw !== "" && readRuleValue(column, raw) === null if (needsValue.has(condition.op)) { if (condition.value === undefined || condition.value === null || condition.value === "") return fillWords(w.needsValue, { op: w.ops[condition.op] }) if (unreadable(condition.value)) return fillWords(w.unreadable, { value: shown(condition.value), column: name }) } if (condition.op === "between") { const values = valuesOf(condition) if (values.length !== 2) return fillWords(w.rangeNeedsEnds, { op: w.ops.between }) for (const raw of values) if (unreadable(raw) || raw === "" || raw === null) return fillWords(w.unreadable, { value: raw === null ? "" : shown(raw), column: name }) if (compareValues(readRuleValue(column, values[0]), readRuleValue(column, values[1])) > 0) return fillWords(w.rangeOrder, { op: w.ops.between }) } if (condition.op === "in") { const values = valuesOf(condition).filter((raw) => raw !== null && raw !== "") if (!values.length) return fillWords(w.setNeedsValue, { op: w.ops.in }) for (const raw of values) if (unreadable(raw)) return fillWords(w.unreadable, { value: shown(raw), column: name }) } // A highlight named as one always needs a tone; one only inferred from its keys is judged by the tone it carries. const tone = textOf(rule.tone) if (highlight && (kind || "tone" in rule) && !isRuleTone(tone)) return tone === undefined || tone === "" ? w.needsTone : fillWords(w.noTone, { tone }) return null } /** The rule in words: "Price above 99-16+", "Client one of ALPHA, BETA", "Status is empty". `kind` reads as for `ruleProblem`. */ export function describeRule<T>(rule: ReadColumnRule & ReadFilterRule, columns: readonly RuleColumn<T>[], kind?: RuleKind, words?: RuleWordsInput): string { if (typeof rule !== "object" || rule === null) return "" const w = ruleWords(words) const column = findColumn(columns, textOf(rule.column)) const highlight = kind ? kind === "highlight" : isHighlightShape(rule) const condition: Partial<ReadCondition> = readCondition(highlight ? rule.when : rule) ?? {} const name = columnName(column, rule.column) const word = isRuleOp(condition.op) ? w.ops[condition.op] : (condition.op ?? "") // A string threshold reads through the column's parse, so the words print what the rule // compares: a decimal typed into a fraction column describes, as it matches, on the grid. const text = (raw: RuleValue | undefined) => { if (raw === undefined || raw === null) return "" if (typeof raw === "string" && column?.parse && column.format) { // Normalized like the comparison: a parser's NaN matches nothing, so its words must not // print a formatted non-value. A parser's throw propagates here as it does everywhere. const parsed = normalizeValue(column.parse(raw)) if (parsed !== null && parsed !== undefined) { try { const printed = column.format(parsed) if (typeof printed === "string" && printed !== "") return printed } catch { // A format that needs its row has no row here; the typed text stands. } } } return shown(raw) } if (condition.op === "isNull" || condition.op === "notNull") return `${name} ${word}` if (condition.op === "between") { const [lo, hi] = valuesOf(condition) return `${name} ${word} ${fillWords(w.range, { low: text(lo), high: text(hi) })}` } if (condition.op === "in") return `${name} ${word} ${valuesOf(condition).map(text).join(", ")}` return `${name} ${word} ${text(condition.value)}`.trim() } /** * Every rule has to hold. A rule that can't apply is skipped, so it hides nothing: one on a column the grid does not * have, and one `ruleProblem` names, with a comparison it doesn't know or a value its column can't read. */ export function compileFilter<T>(rules: readonly ReadFilterRule[], columns: readonly RuleColumn<T>[]): (row: T) => boolean { const tests: ((row: T) => boolean)[] = [] for (const entry of listOf(rules)) { const rule = readFilterRule(entry) const column = rule ? findColumn(columns, rule.column) : undefined if (rule && column && ruleProblem(rule as ReadColumnRule & ReadFilterRule, columns, "filter") === null) tests.push(compileCondition(rule, column)) } if (!tests.length) return () => true if (tests.length === 1) return tests[0]! return (row) => tests.every((test) => test(row)) } /** The first rule that tells two rows apart decides, nulls last either way. Undefined when no rule names a column the grid has. */ export function compileComparator<T>(rules: readonly ReadSortRule[], columns: readonly RuleColumn<T>[]): ((a: T, b: T) => number) | undefined { const keys: { accessor: (row: T) => unknown; dir: "asc" | "desc" }[] = [] for (const entry of listOf(rules)) { const rule = readSortRule(entry) const column = rule ? findColumn(columns, rule.key) : undefined if (rule && column) keys.push({ accessor: column.accessor, dir: rule.dir }) } if (!keys.length) return undefined return (a, b) => { for (const { accessor, dir } of keys) { const c = compareDirected(accessor(a), accessor(b), dir) if (c !== 0) return c } return 0 } } /** The decoration a matched rule puts on its cell or row. */ export function ruleDecoration<T>(saved: ReadColumnRule, columns: readonly RuleColumn<T>[]): RuleDecoration { const rule = readColumnRule(saved) ?? {} return { "data-rule": rule.id ?? "", "data-tone": rule.tone, "aria-description": (typeof rule.label === "string" ? rule.label.trim() : "") || describeRule(rule, columns, "highlight"), className: isRuleTone(rule.tone) ? RULE_TONE_CLASS[rule.tone] : "", } } export interface AppliedRules<T> { /** The first cell rule on this column that the row matches, or undefined. */ cell: (columnKey: string, row: T) => RuleDecoration | undefined /** The first row rule the row matches, or undefined. Shaped for the grid's `getRowProps`. */ getRowProps: (row: T) => RuleDecoration | undefined /** The rules that name a column the grid has, by column key, in the order they apply. */ byColumn: ReadonlyMap<string, readonly ReadColumnRule[]> } /** * Column rules compiled once: each against its column, its decoration made ahead of time, and the * grid's two hooks, `cell` and `getRowProps`, answering per row with a matched rule's decoration. A * rule naming a column the grid does not have is skipped. */ export function applyRules<T>(rules: readonly ReadColumnRule[], columns: readonly RuleColumn<T>[]): AppliedRules<T> { const cells = new Map<string, { test: (row: T) => boolean; decoration: RuleDecoration }[]>() const rows: { test: (row: T) => boolean; decoration: RuleDecoration }[] = [] const byColumn = new Map<string, ReadColumnRule[]>() for (const entry of listOf(rules)) { const rule = readColumnRule(entry) const column = rule ? findColumn(columns, rule.column) : undefined if (!rule || !column) continue const compiled = { test: compileCondition(rule.when, column), decoration: ruleDecoration(rule, columns) } byColumn.set(column.key, [...(byColumn.get(column.key) ?? []), rule]) if (rule.target === "row") rows.push(compiled) else cells.set(column.key, [...(cells.get(column.key) ?? []), compiled]) } return { cell(columnKey, row) { const list = cells.get(columnKey) if (!list) return undefined for (const { test, decoration } of list) if (test(row)) return decoration return undefined }, getRowProps(row) { for (const { test, decoration } of rows) if (test(row)) return decoration return undefined }, byColumn, } } -
Add the tokens to your stylesheet:
:root { --down: oklch(0.46 0.19 40); --down-soft: oklch(0.46 0.19 40 / 18%); --expiring-soft: oklch(0.53 0.2 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.55 0 0 / 14%); --stale-soft: oklch(0.52 0.13 75 / 16%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --up: oklch(0.52 0.13 165); --up-soft: oklch(0.52 0.13 165 / 18%); } .dark { --down: oklch(0.66 0.19 40); --down-soft: oklch(0.66 0.19 40 / 18%); --expiring-soft: oklch(0.74 0.17 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.7 0 0 / 14%); --stale-soft: oklch(0.8 0.15 80 / 16%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --up: oklch(0.74 0.15 165); --up-soft: oklch(0.74 0.15 165 / 18%); } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { useState } from "react"
import type { ReadGridRules } from "@/lib/grid-rules"
import type { ColumnDef } from "@/components/ui/data-grid"
import {
RulesEditor,
RulesEditorAdd,
RulesEditorColumn,
RulesEditorItem,
RulesEditorOperator,
RulesEditorProblem,
RulesEditorRemove,
RulesEditorTone,
RulesEditorValue,
} from "@/components/ui/rules-editor"
interface Quote { px: number }
const columns: ColumnDef<Quote>[] = [
{ key: "px", header: "Price", width: 100, numeric: true, accessor: (row) => row.px },
]
function QuoteRules() {
const [rules, setRules] = useState<ReadGridRules>({
columns: [{ id: "price", column: "px", when: { op: "gte", value: "100" }, tone: "up" }],
})
return (
<RulesEditor columns={columns} rules={rules} onRulesChange={setRules} className="w-lg max-w-full">
{rules.columns?.length ? (
<ul className="space-y-2">
{rules.columns?.map((rule, index) => (
<li key={rule.id ?? index}>
<RulesEditorItem kind="highlights" index={index}>
<RulesEditorColumn />
<RulesEditorOperator />
<RulesEditorValue />
<RulesEditorValue field="low" />
<RulesEditorValue field="high" />
<RulesEditorValue field="values" />
<RulesEditorTone />
<RulesEditorRemove>Remove</RulesEditorRemove>
<RulesEditorProblem />
</RulesEditorItem>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground">No highlights.</p>
)}
<RulesEditorAdd kind="highlights">Add highlight</RulesEditorAdd>
</RulesEditor>
)
}
Composition
Use the following composition to build a RulesEditor:
RulesEditor
└── Tabs (optional)
├── TabsList (optional)
│ └── TabsTrigger
│ └── RulesEditorRuleCount (optional)
├── TabsContent (optional)
│ ├── Your list and empty state
│ │ └── RulesEditorItem
│ │ ├── RulesEditorColumn
│ │ ├── RulesEditorOperator (highlights and filters)
│ │ ├── RulesEditorValue
│ │ ├── RulesEditorValue field="low"
│ │ ├── RulesEditorValue field="high"
│ │ ├── RulesEditorValue field="values"
│ │ ├── RulesEditorTone (highlights)
│ │ ├── RulesEditorToneSwatch (optional)
│ │ ├── RulesEditorTarget (highlights)
│ │ ├── RulesEditorLabel (highlights)
│ │ ├── RulesEditorDirection (sort)
│ │ ├── RulesEditorMatchCount (optional)
│ │ ├── RulesEditorProblem
│ │ ├── RulesEditorMove direction="up" (optional)
│ │ ├── RulesEditorMove direction="down" (optional)
│ │ └── RulesEditorRemove
│ ├── RulesEditorAdd
│ └── RulesEditorFilterCount (optional)
└── TabsContent value="columns" (optional)
└── ColumnChooser
└── Your column settings composition
Place items directly inside your layout when you do not need tabs.
Tabs
Compose installed shadcn Tabs to group rules with a column chooser. Select each tab on focus so arrow keys switch panels on either base. Copy ColumnSettingsPanel and its helpers from the ColumnChooser Usage example into column-chooser.tsx beside this example, outside components/ui.
Control Tabs with value and onValueChange to drive panels from your own controls.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useState } from "react"
import { NUMERIC_CLASS, createInstrumentFormatter, formatNotional } from "@/lib/format"
import type { ReadGridRules } from "@/lib/grid-rules"
import { createRowStore } from "@/lib/row-store"
import { ColumnSettingsPanel } from "./column-chooser"
import { DataGrid, type ColumnDef, type ColumnState } from "@/components/ui/data-grid"
import { RulesEditor, RulesEditorAdd, RulesEditorColumn, RulesEditorDirection, RulesEditorFilterCount, RulesEditorItem, RulesEditorLabel, RulesEditorMatchCount, RulesEditorMove, RulesEditorOperator, RulesEditorProblem, RulesEditorRemove, RulesEditorRuleCount, RulesEditorTarget, RulesEditorTone, RulesEditorToneSwatch, RulesEditorValue, useRulesEditor, type RulesEditorProps } 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 RulesEditorTabsDemo() {
const [store] = useState(() => {
const s = createRowStore<Rfq>({ getRowId: (r) => r.id })
s.applyDeltas({ upsert: rows })
return s
})
const [rules, setRules] = useState<ReadGridRules>({
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">
<TabbedRulesEditor 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>
)
}
type TabValue = "highlights" | "filters" | "sort" | "columns"
export function TabbedRulesEditor<T>({ columnState, onColumnStateChange, defaultTab = "highlights", ...props }: Omit<RulesEditorProps<T>, "children"> & { defaultTab?: TabValue; columnState?: ColumnState; onColumnStateChange?: (state: ColumnState) => void }) {
return (
<RulesEditor {...props}>
<RulesEditorSections defaultTab={defaultTab} columns={props.columns} columnState={columnState} onColumnStateChange={onColumnStateChange} />
</RulesEditor>
)
}
export function RulesEditorSections<T>({ columns, columnState, onColumnStateChange, defaultTab = "highlights" }: { columns: ColumnDef<T>[]; defaultTab?: TabValue; columnState?: ColumnState; onColumnStateChange?: (state: ColumnState) => void }) {
const { rules, labels } = useRulesEditor()
const [tab, setTab] = useState<string>(defaultTab)
const hasColumns = columnState !== undefined && onColumnStateChange !== undefined
const selected = tab === "columns" && !hasColumns ? "highlights" : tab
return (
<Tabs value={selected} onValueChange={(value) => setTab(String(value))}>
<TabsList aria-label={labels.title} className="h-auto max-w-full flex-wrap">
<TabsTrigger value="highlights" onFocus={() => setTab("highlights")}>{labels.highlights}<RulesEditorRuleCount kind="highlights" /></TabsTrigger>
<TabsTrigger value="filters" onFocus={() => setTab("filters")}>{labels.filters}<RulesEditorRuleCount kind="filters" /></TabsTrigger>
<TabsTrigger value="sort" onFocus={() => setTab("sort")}>{labels.sort}<RulesEditorRuleCount kind="sort" /></TabsTrigger>
{hasColumns && <TabsTrigger value="columns" onFocus={() => setTab("columns")}>{labels.columns}{columnState.hidden.length > 0 && <span className={`${NUMERIC_CLASS} text-muted-foreground`}>{columnState.hidden.length}</span>}</TabsTrigger>}
</TabsList>
<TabsContent value="highlights">
<div className="flex flex-col gap-2">
{rules.columns?.length ? (
<ul className="flex flex-col gap-1">
{rules.columns?.map((rule, index) => (
<li key={rule.id ?? index}>
<RulesEditorItem kind="highlights" index={index}>
<span aria-hidden title={labels.dragHint} className="cursor-grab text-muted-foreground">⠿</span>
<RulesEditorColumn />
<RulesEditorOperator />
<RulesEditorValue />
<RulesEditorValue field="low" />
<RulesEditorValue field="high" />
<RulesEditorValue field="values" />
<RulesEditorTone />
<RulesEditorToneSwatch />
<RulesEditorTarget />
<RulesEditorLabel />
<RulesEditorMatchCount />
<RulesEditorMove direction="up"><span aria-hidden>▲</span></RulesEditorMove>
<RulesEditorMove direction="down"><span aria-hidden>▼</span></RulesEditorMove>
<RulesEditorRemove><span aria-hidden>×</span></RulesEditorRemove>
<RulesEditorProblem />
</RulesEditorItem>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground">{labels.noHighlights}</p>
)}
<RulesEditorAdd kind="highlights">{labels.addHighlight}</RulesEditorAdd>
<p className="text-muted-foreground">{labels.dragHint}</p>
</div>
</TabsContent>
<TabsContent value="filters">
<div className="flex flex-col gap-2">
{rules.filter?.length ? (
<ul className="flex flex-col gap-1">
{rules.filter?.map((_, index) => (
<li key={index}>
<RulesEditorItem kind="filters" index={index}>
<span aria-hidden title={labels.dragHint} className="cursor-grab text-muted-foreground">⠿</span>
<RulesEditorColumn />
<RulesEditorOperator />
<RulesEditorValue />
<RulesEditorValue field="low" />
<RulesEditorValue field="high" />
<RulesEditorValue field="values" />
<RulesEditorMatchCount />
<RulesEditorMove direction="up"><span aria-hidden>▲</span></RulesEditorMove>
<RulesEditorMove direction="down"><span aria-hidden>▼</span></RulesEditorMove>
<RulesEditorRemove><span aria-hidden>×</span></RulesEditorRemove>
<RulesEditorProblem />
</RulesEditorItem>
</li>
))}
</ul>
) : (
<p className="text-muted-foreground">{labels.noFilters}</p>
)}
<div className="flex flex-wrap items-center gap-2">
<RulesEditorAdd kind="filters">{labels.addFilter}</RulesEditorAdd>
<RulesEditorFilterCount />
</div>
<p className="text-muted-foreground">{labels.dragHint}</p>
</div>
</TabsContent>
<TabsContent value="sort">
<div className="flex flex-col gap-2">
{rules.sort?.length ? (
<ol className="flex flex-col gap-1">
{rules.sort?.map((_, index) => (
<li key={index}>
<RulesEditorItem kind="sort" index={index}>
<span aria-hidden title={labels.dragHint} className="cursor-grab text-muted-foreground">⠿</span>
<span aria-hidden className={`${NUMERIC_CLASS} w-4 text-right text-muted-foreground`}>{index + 1}</span>
<RulesEditorColumn />
<RulesEditorDirection />
<RulesEditorMove direction="up"><span aria-hidden>▲</span></RulesEditorMove>
<RulesEditorMove direction="down"><span aria-hidden>▼</span></RulesEditorMove>
<RulesEditorRemove><span aria-hidden>×</span></RulesEditorRemove>
<RulesEditorProblem />
</RulesEditorItem>
</li>
))}
</ol>
) : (
<p className="text-muted-foreground">{labels.noSort}</p>
)}
<RulesEditorAdd kind="sort">{labels.addSort}</RulesEditorAdd>
<p className="text-muted-foreground">{labels.dragHint}</p>
</div>
</TabsContent>
{hasColumns && <TabsContent value="columns"><div className="flex flex-col gap-2"><ColumnSettingsPanel columns={columns} columnState={columnState} onColumnStateChange={onColumnStateChange} rules={rules.columns} /></div></TabsContent>}
</Tabs>
)
}
Settings layout
Move fields and actions into cards with your own headings and content.
import { useId, useState } from "react"
import type { ReadGridRules } from "@/lib/grid-rules"
import { createRowStore } from "@/lib/row-store"
import type { ColumnDef } from "@/components/ui/data-grid"
import { RulesEditor, RulesEditorAdd, RulesEditorColumn, RulesEditorDirection, RulesEditorFilterCount, RulesEditorItem, RulesEditorMatchCount, RulesEditorMove, RulesEditorOperator, RulesEditorProblem, RulesEditorRemove, RulesEditorValue } from "@/components/ui/rules-editor"
interface Order { id: string; account: string; size: number }
const columns: ColumnDef<Order>[] = [
{ key: "account", header: "Account", width: 120, accessor: (row) => row.account },
{ key: "size", header: "Size", width: 100, numeric: true, accessor: (row) => row.size },
]
export default function RulesEditorLayoutDemo() {
const id = useId()
const [store] = useState(() => {
const store = createRowStore<Order>({ getRowId: (row) => row.id })
store.applyDeltas({ upsert: [{ id: "a", account: "ALPHA", size: 500 }, { id: "b", account: "BETA", size: 200 }] })
return store
})
const [rules, setRules] = useState<ReadGridRules>({
filter: [{ column: "size", op: "gte", value: "300" }],
sort: [{ key: "size", dir: "desc" }, { key: "account", dir: "asc" }],
})
return (
<RulesEditor columns={columns} rules={rules} onRulesChange={setRules} store={store} labels={{ moveUp: "Earlier", moveDown: "Later" }} aria-labelledby={`${id}-title`} className="w-2xl max-w-full gap-4">
<header className="flex flex-wrap items-center justify-between gap-2">
<h2 id={`${id}-title`} className="text-sm font-semibold">Order priority</h2>
<RulesEditorFilterCount />
</header>
<div className="grid gap-4 sm:grid-cols-2">
<section className="min-w-0 space-y-2" aria-labelledby={`${id}-include`}>
<h3 id={`${id}-include`} className="font-medium">Include orders</h3>
<p className="text-muted-foreground">Conditions apply to all accounts.</p>
{!rules.filter?.length && <p>Every order is included.</p>}
{rules.filter?.map((_, index) => (
<RulesEditorItem kind="filters" index={index} key={index} className="flex-col items-stretch gap-2 p-3">
<header className="flex flex-wrap items-center justify-between gap-1">
<RulesEditorColumn />
<RulesEditorRemove>Remove</RulesEditorRemove>
</header>
<div className="flex flex-wrap items-center gap-2">
<RulesEditorOperator />
<RulesEditorValue />
<RulesEditorValue field="low" />
<RulesEditorValue field="high" />
<RulesEditorValue field="values" />
</div>
<RulesEditorProblem />
<footer className="flex flex-wrap items-center gap-1">
<RulesEditorMatchCount />
<RulesEditorMove direction="up">Earlier</RulesEditorMove>
<RulesEditorMove direction="down">Later</RulesEditorMove>
</footer>
</RulesEditorItem>
))}
<RulesEditorAdd kind="filters">Add condition</RulesEditorAdd>
</section>
<section className="min-w-0 space-y-2" aria-labelledby={`${id}-priority`}>
<h3 id={`${id}-priority`} className="font-medium">Priority</h3>
<p className="text-muted-foreground">Earlier keys take precedence.</p>
{!rules.sort?.length && <p>Keep arrival order.</p>}
{rules.sort?.map((_, index) => (
<RulesEditorItem kind="sort" index={index} key={index} className="flex-col items-stretch gap-2 p-3">
<div className="flex flex-wrap items-center gap-2">
<RulesEditorDirection />
<RulesEditorColumn />
</div>
<RulesEditorProblem />
<footer className="flex flex-wrap items-center gap-1">
<RulesEditorMove direction="up">Earlier</RulesEditorMove>
<RulesEditorMove direction="down">Later</RulesEditorMove>
<RulesEditorRemove className="ml-auto">Remove</RulesEditorRemove>
</footer>
</RulesEditorItem>
))}
<RulesEditorAdd kind="sort">Add priority</RulesEditorAdd>
</section>
</div>
<p className="text-muted-foreground">Drag a card or use Alt+Up/Down to reorder.</p>
</RulesEditor>
)
}
API Reference
Props
RulesEditorProps<T> extends native div props and requires children.
| Prop | Type | Default | Purpose |
|---|---|---|---|
columns |
ColumnDef<T>[] |
Required | Column definitions shared with the grid. |
rules |
ReadGridRules |
Required | Controlled rules, written or read with readRules. Omitted lists are empty. |
onRulesChange |
(rules: ReadGridRules) => void |
Required | Receives each edit. An edit can leave a rule incomplete, as typing does, so hold the editor's rules as ReadGridRules, the type the grid takes. |
children |
ReactNode |
Required | Your sections, items, controls, and empty states. |
store |
RowStore<T> |
- | Rows for match counts. |
labels |
Partial<RulesEditorLabels> |
DEFAULT_RULES_EDITOR_LABELS |
Field, action, count, and region labels. A word given as undefined keeps its default. |
className |
string |
- | Additional classes on the root. |
RulesEditorKind is "highlights" | "filters" | "sort".
Each item indexes its source list: "highlights" uses rules.columns, "filters" uses rules.filter, and "sort" uses rules.sort.
Parts
Parts forward refs, classes, and native attributes to their element or the corresponding shadcn control.
| Part | Element | Description |
|---|---|---|
RulesEditorItem |
div |
Requires kind, source index, and children. Coordinates fields and reordering. |
RulesEditorColumn |
NativeSelect |
Column choices from the root. |
RulesEditorOperator |
NativeSelect |
Operators supported by the current column. |
RulesEditorValue |
Input |
Optional field: "value" | "low" | "high" | "values", default "value". Renders only for its operator shape. |
RulesEditorTone |
NativeSelect |
Highlight tone. |
RulesEditorToneSwatch |
span |
Decorative tone name in foreground text on the tone's tint. |
RulesEditorTarget |
NativeSelect |
Highlight target, "cell" or "row". |
RulesEditorLabel |
Input |
Highlight description. |
RulesEditorDirection |
NativeSelect |
Sort direction, "asc" or "desc". |
RulesEditorProblem |
p |
Validation message when the item has a problem. |
RulesEditorMatchCount |
span |
Independent highlight or filter matches. Requires store. |
RulesEditorFilterCount |
span |
Combined filter matches and total rows. Requires store. |
RulesEditorRuleCount |
span |
Requires kind. Hides zero counts. |
RulesEditorAdd |
Button |
Requires kind and children. Appends a rule with the helper defaults. |
RulesEditorMove |
Button |
Requires direction: "up" | "down" and children. Disabled at the corresponding end. |
RulesEditorRemove |
Button |
Requires children. Removes the current item. |
Field values and options belong to the editor. Use useRulesEditorItem to replace a control, or cancel its event with preventDefault().
Select classes apply to the shadcn wrapper. Input classes apply to the input.
Item fields, move/remove actions, problems, and match counts require RulesEditorItem. Fields for another rule kind render nothing.
Keyboard and focus
Drag an item onto another item of the same kind, or use Alt+Up/Down from the item or its buttons. A select, a text field, or an item given an arrow-owning role keeps Alt with its arrows, so Alt+Down opens a select, and Alt with Shift, Ctrl, or Meta moves nothing. Each item names its keys in aria-keyshortcuts, unless its role or contentEditable keeps the arrows. Reordering uses source indices, even when you render items in a different order.
Accepted moves keep focus on the corresponding field when available. Removal focuses the adjacent item or its remove action, then RulesEditorAdd when the list is empty.
useRulesEditor
Use useRulesEditor() inside the root for custom lists and controls.
| Field | Type | Description |
|---|---|---|
rules |
ReadGridRules |
Current controlled rules. |
labels |
RulesEditorLabels |
Merged labels. |
columns |
{ key: string; name: string; ops: readonly RuleOp[] }[] |
Column choices and supported operators. |
problem |
(rule: ReadColumnRule | ReadFilterRule, kind: RuleKind) => string | null |
Validation for the current columns, judged as the kind of rule it is. |
change |
(next: Partial<ReadGridRules>) => void |
Merges changed lists into the controlled rules. |
add |
(kind: RulesEditorKind) => void |
Appends the corresponding new rule. |
move |
(kind: RulesEditorKind, from: number, to: number) => void |
Moves a rule by source index. |
remove |
(kind: RulesEditorKind, index: number) => void |
Removes a rule by source index. |
useRulesEditorItem
Use useRulesEditorItem() inside an item to build a custom field.
| Field | Type | Description |
|---|---|---|
kind |
RulesEditorKind |
Item kind. |
index |
number |
Index in the source list. |
name |
string |
Highlight label or kind name and position. |
columnKey |
string |
Selected column, or "" for a rule without one. |
condition |
ReadCondition | null |
Highlight or filter condition, as read. |
highlight |
ReadColumnRule | null |
Current highlight, as read, if applicable. |
filter |
ReadFilterRule | null |
Current filter, as read, if applicable. |
sort |
ReadSortRule | null |
Current sort key, as read, if applicable. |
problem |
string | null |
Current validation message. |
setColumn |
(key: string) => void |
Changes the column and resets unsupported operators. |
setCondition |
(condition: ReadCondition) => void |
Changes a highlight or filter condition. |
updateHighlight |
(patch: Partial<ReadColumnRule>) => void |
Updates highlight properties. |
setDirection |
(direction: "asc" | "desc") => void |
Changes sort direction. |
It produces rules and keeps nothing
Accept each onRulesChange result into rules and share it with the grid. Keep a separate draft for a save step.
Edits create a new object and edited list. Untouched lists and rules retain their references, and an edited rule keeps every field your app saved on it and on its condition.
Replace changed arrays and objects. The editor retains transient drag and comma-field state, including unfinished text such as ALPHA,.
A rule as it is typed
RulesEditorOperator offers comparisons and ranges for numeric columns, text matching otherwise, and equality, sets, and null checks for both.
| Operators | RulesEditorValue fields |
|---|---|
eq, ne, gt, gte, lt, lte, contains, startsWith |
"value" |
between |
"low" and "high", inclusive |
in |
"values", comma separated |
isNull, notNull |
None |
Render all four value fields: <RulesEditorValue />, <RulesEditorValue field="low" />, <RulesEditorValue field="high" />, and <RulesEditorValue field="values" />. Each renders only when the operator needs it.
withOp keeps values for the same shape, or when the old op is one no version knows, and clears them otherwise. withColumn keeps supported conditions, or selects the first operator and drops values. Both keep any other field the condition carries.
Values stay as typed strings in the model and the fields, read through the column's parse function or numerically for numeric columns. A 32nds parser accepts 100-00, and a typed decimal compares on the convention's printable grid; a column format makes descriptions print the compared price.
The set field trims members and drops empties, without quoting or escaping. Enter 1000000 for one numeric member.
Errors
RulesEditorProblem reports missing columns, a missing or unknown comparison, missing values, unreadable values, a range whose low end is above its high end, and a missing or unknown tone, without blocking edits or evaluation. A stored op the column doesn't offer shows in its select under its word, an op or a tone this version doesn't know shows quoted, and a column key no column has shows as saved. A missing column, op, or tone shows blank, so a highlight with no condition gets a blank one to choose from, and a missing column stays blank beside a column keyed "", which the grid doesn't use for it. The editor reads each entry as the grid does, so an entry that isn't an object renders and counts nothing, and a name or a word saved as anything but text shows as its JSON. Read rules a desk saved or shared with readRules before you hold them, so your own composition maps over rule objects.
| Condition | Evaluation |
|---|---|
| Unreadable single numeric value | Matches nothing. |
Mixed valid/invalid in set |
Valid members can match. |
| Empty text | Can match despite a missing-value message. |
| Missing column | Individual count is zero. Combined filtering and sorting skip it. |
| Missing or unknown comparison | Matches nothing. |
Highlights
Use RulesEditorTone, RulesEditorTarget, and RulesEditorLabel for highlight properties.
| Property | Values or behavior |
|---|---|
| Tone | up, down, flat, stale, expiring, primary, destructive. |
| Target | "cell" by default, or "row". |
| Label | Grid description and chooser badge. Blank labels use describeRule. |
| Precedence | First matching cell rule per column, and first matching row rule. |
Filters and the count
Use RulesEditorMatchCount for individual matches and RulesEditorFilterCount for the combined total.
Every filter on a known column must match.
Counts read all store rows, including hidden rows and highlights that lose precedence. Separate grid filters and custom views do not affect them.
Counts recompute on mount and changes to their rule lists, columns, or store. Store notifications update counts at most once per 250 ms, while edits update immediately.
Repeated count parts share one subscription. Feed updates rerender readings without rerendering editable fields, and pending updates are cancelled on store replacement or unmount.
Sort
Use RulesEditorDirection to set each key's direction. The first key that distinguishes two rows decides their order.
Duplicate keys are allowed. Grid rules break header-sort ties, while a caller-supplied view owns filtering and sorting.
Helpers
Import these helpers from @/components/ui/rules-editor. Rule types, opsFor, ruleProblem, and describeRule come from grid-rules.
Here columns is readonly ColumnDef<T>[], condition is ReadCondition, and op is 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, and undefined gives "". |
withOp(condition, op) |
ReadCondition & { op: RuleOp } |
Copies for the same shape or an unknown old op. Otherwise drops value and values and keeps the other fields. |
withColumn(condition, column: ColumnDef<T> | undefined) |
ReadCondition & { op: RuleOp } |
Same condition if supported. Otherwise the first operator, without values, keeping the other fields. |
moveItem<X>(list: readonly X[], from: number, to: number) |
X[] |
Moves by index. Equal or 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 ReadSortRule[] = []) |
SortRule |
First unused column, or first column if exhausted. Uses dir: "asc". |
With no columns, add buttons stay enabled. Helpers use an empty key, with eq for highlights and filters.
Labels
Use labels to override field names, action names, count templates, and the region title.
Move and remove actions use <label>: <rule>. Match their visible text to labels.moveUp, labels.moveDown, and labels.remove.
Fields use <field>: <rule>, such as Value: Rich to the market. Highlights use their nonblank label, while unnamed items use kind and position.
Use useRulesEditor().labels for your tab text, action children, empty states, and drag hint. labels.ruleWords writes the comparison and tone choices and every problem in your words, as grid-rules' RuleWords, and labels.itemName names a rule with no label, {kind} its section's word and {n} its place. A rule with a problem marks its item invalid, and the item and its value fields point to RulesEditorProblem's line. A count's label is a template or a function, for a language whose plurals a template can't hold: matches takes {n}, the rows that match, or (n) => string, and shown takes {n}, the rows shown, and {m}, all the rows, or (n, m) => string. The defaults put one in the singular, as in 1 row matches.
Operator words, tone names, and problems default to grid-rules' DEFAULT_RULE_WORDS, which labels.ruleWords overrides; ColumnChooser takes its own labels.ruleWords.
What it does not do
The editor emits rules and evaluates counts. The grid applies them, and the caller owns persistence, list markup, empty states, and surrounding content.
Tokens
Installation adds missing up, down, and flat tokens, and the soft variants of those and of stale and expiring. primary and destructive use the host theme.
Built on shadcn's badge, button, checkbox, context-menu, dialog, dropdown-menu, input, native-select, and tabs.
This page is docs/rules-editor.md at v2.0.0.