Alerts
Compose notices and actions with caller-owned data, markup, and layout.
import { Alerts, AlertsList, AlertItem, AlertHeader, AlertTitle, AlertBody, AlertSeverity } from "@/components/ui/alerts"
export default function AlertsDemo() {
return (
<Alerts className="w-lg max-w-full">
<AlertsList>
<AlertItem>
<AlertHeader>
<AlertSeverity>fill</AlertSeverity>
<AlertTitle>Order filled</AlertTitle>
</AlertHeader>
<AlertBody>5mm UST at 99-16+</AlertBody>
</AlertItem>
</AlertsList>
</Alerts>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/alerts#v2.0.0
npx shadcn@latest add tradecn/ui/alerts#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/alerts#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/alerts#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 dropdown-menunpx shadcn@latest add badge button checkbox context-menu dropdown-menuyarn dlx shadcn@latest add badge button checkbox context-menu dropdown-menubunx --bun shadcn@latest add badge button checkbox context-menu dropdown-menu -
Copy the files into your project:
components/ui/ alerts.tsx import { cn } from "cn" import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentProps, type ReactNode } from "react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { useRow, useView } from "@/hooks/use-row-store" import { byNewest, type Alert, type AlertStore, type AlertTone } from "@/lib/alert-store" import { NULL_TOKEN, isInstant } from "@/lib/format" import type { RowId, RowView } from "@/lib/row-store" import { DataGrid, type ColumnDef, type DataGridPreset } from "@/components/ui/data-grid" // Notices assembled by the caller. The parts own styling; useAlert owns a row's subscription and // optional expiry. Mount one announcer for the collection, and put history wherever it belongs. /** The notice container. The caller supplies the list, controls, and any overflow presentation. */ export function Alerts({ className, ...props }: ComponentProps<"div">) { return <div role="group" aria-label={props["aria-labelledby"] ? undefined : "Notices"} data-slot="tradecn-alerts" className={cn("flex min-w-0 flex-col gap-2 text-xs lining-nums tabular-nums", className)} {...props} /> } export function AlertsList({ className, ...props }: ComponentProps<"ul">) { return <ul role="list" data-slot="tradecn-alerts-list" className={cn("flex min-w-0 list-none flex-col gap-2", className)} {...props} /> } export interface AlertItemProps extends ComponentProps<"li"> { tone?: AlertTone } /** Tone decorates the notice; include a severity word or another non-color cue in its content. */ export function AlertItem({ tone, className, ...props }: AlertItemProps) { return <li data-slot="tradecn-alert-item" data-tone={tone} className={cn("flex min-w-0 flex-col gap-1 rounded-sm border border-border/60 border-s-2 bg-card px-2 py-1.5", "data-[tone=up]:border-s-up data-[tone=down]:border-s-down data-[tone=flat]:border-s-flat data-[tone=stale]:border-s-stale data-[tone=expiring]:border-s-expiring data-[tone=primary]:border-s-primary data-[tone=destructive]:border-s-destructive", className)} {...props} /> } export function AlertHeader({ className, ...props }: ComponentProps<"div">) { return <div data-slot="tradecn-alert-header" className={cn("flex min-w-0 flex-wrap items-baseline gap-2", className)} {...props} /> } export function AlertTitle({ className, ...props }: ComponentProps<"div">) { return <div data-slot="tradecn-alert-title" className={cn("min-w-0 flex-1 font-medium wrap-anywhere", className)} {...props} /> } export function AlertBody({ className, ...props }: ComponentProps<"div">) { return <div data-slot="tradecn-alert-body" className={cn("min-w-0 text-muted-foreground wrap-anywhere", className)} {...props} /> } export function AlertActions({ className, ...props }: ComponentProps<"div">) { return <div data-slot="tradecn-alert-actions" className={cn("flex flex-wrap items-center gap-1", className)} {...props} /> } export function AlertsEmpty({ className, ...props }: ComponentProps<"p">) { return <p data-slot="tradecn-alerts-empty" className={cn("text-muted-foreground", className)} {...props} /> } export interface AlertSeverityProps extends ComponentProps<typeof Badge> { tone?: AlertTone } // A toned chip clears any fill a style gives an outline badge: the tones clear 4.5 to 1 on the page and a card, and can // fall below on such a fill. export function AlertSeverity({ tone, className, ...props }: AlertSeverityProps) { return <Badge variant="outline" data-slot="tradecn-alert-severity" data-alert-severity="" className={cn("h-auto shrink-0 px-1.5 text-xs", tone && ALERT_TONE_TEXT[tone], tone && "bg-transparent dark:bg-transparent", className)} {...props} /> } export interface AlertActionButtonProps extends Omit<ComponentProps<typeof Button>, "onClick"> { alert: Alert action: string onAction: (alert: Alert) => void } /** A caller-composed button shown only when the notice allows its action. It does not dismiss. */ export function AlertActionButton({ alert, action, onAction, className, ...props }: AlertActionButtonProps) { if (!alert.allowedActions?.includes(action)) return null return <Button type="button" variant="outline" size="sm" data-slot="tradecn-alert-action-button" data-alert-action={action} className={cn("h-6 px-2 text-xs", className)} {...props} onClick={() => onAction(alert)} /> } export function AlertDismiss({ className, children, ...props }: ComponentProps<typeof Button>) { return <Button type="button" variant="ghost" size="sm" aria-label={children === undefined && !props["aria-labelledby"] ? "Dismiss" : undefined} data-slot="tradecn-alert-dismiss" className={cn("h-6 shrink-0 px-1.5 text-xs", className)} {...props}>{children === undefined ? <span aria-hidden>×</span> : children}</Button> } export interface UseAlertOptions { /** Expiry while this hook is mounted. Any allowed action disables it. */ ttlMs?: number /** Use the same clock basis as the store. Defaults to Date.now. */ now?: () => number } /** Subscribe to one notice. Mount in a consumer's row component to retain per-row updates. */ export function useAlert(alerts: AlertStore, id: RowId, { ttlMs, now = Date.now }: UseAlertOptions = {}): Alert | undefined { const alert = useRow(alerts.store, id) const at = alert?.at const actionable = Boolean(alert?.allowedActions?.length) const latest = useRef(now) useEffect(() => { latest.current = now }) useEffect(() => { if (ttlMs === undefined || typeof at !== "number" || actionable) return // A time, a TTL, or a clock that isn't a finite number schedules nothing: a timer told NaN or Infinity runs at // once, and would take a notice off the moment it shows. const delay = at + ttlMs - latest.current() if (!Number.isFinite(delay)) return const timer = setTimeout(() => alerts.dismiss(id), Math.max(0, delay)) return () => clearTimeout(timer) }, [alerts, id, at, actionable, ttlMs]) return alert } export interface AlertsAnnouncerProps { alerts: AlertStore /** The notice to announce, usually the first displayed ID; null leaves both regions empty. */ id: RowId | null assertive?: readonly string[] } /** Mount one announcer for the collection. Repeats update its subscribed notice's count. */ export function AlertsAnnouncer({ alerts, id, assertive = [] }: AlertsAnnouncerProps) { const subscribe = useCallback((notify: () => void) => id === null ? () => {} : alerts.store.subscribeRow(id, notify), [alerts, id]) const get = useCallback(() => id === null ? undefined : alerts.store.getRow(id), [alerts, id]) const alert = useSyncExternalStore(subscribe, get, get) const text = alert ? `${alert.severity}: ${alert.title}${alert.message ? `. ${alert.message}` : ""}${alert.count > 1 ? ` (${alert.count})` : ""}` : "" const urgent = alert !== undefined && assertive.includes(alert.severity) // A new notice can carry the previous one's exact words — a second identical rejection — // and an unchanged text write never reaches the live region. The inner node is keyed by // the notice's identity, so the region receives an addition either way, the feed-health // announcer's pattern. Sequence numbers restart per store, so a store swap bumps a local // epoch to keep the key fresh across replacement — keyed on the subscribed store itself, // so a fresh wrapper around the same store re-announces nothing. const [tracked, setTracked] = useState({ store: alerts.store, epoch: 0 }) if (tracked.store !== alerts.store) setTracked({ store: alerts.store, epoch: tracked.epoch + 1 }) const epoch = tracked.store === alerts.store ? tracked.epoch : tracked.epoch + 1 const revision = alert ? `${epoch}:${alert.id}:${alert.seq}` : "" return <> <div role="status" aria-live="polite" aria-atomic="true" className="sr-only" data-alerts-polite>{!urgent && text ? <span key={revision}>{text}</span> : ""}</div> <div role="alert" aria-live="assertive" aria-atomic="true" className="sr-only" data-alerts-assertive>{urgent && text ? <span key={revision}>{text}</span> : ""}</div> </> } export interface AlertHistoryLabels { title: string empty: string time: string severity: string noticeTitle: string message: string count: string } export const DEFAULT_ALERT_HISTORY_LABELS: AlertHistoryLabels = { title: "All notices", empty: "No notices.", time: "Time", severity: "Severity", noticeTitle: "Title", message: "Message", count: "Count", } /** Background classes for caller-composed bars or other decoration; pair them with a visible cue. */ export const ALERT_TONE_BAR: Record<AlertTone, string> = { up: "bg-up", down: "bg-down", flat: "bg-flat", stale: "bg-stale", expiring: "bg-expiring", primary: "bg-primary", destructive: "bg-destructive", } export const ALERT_TONE_TEXT: Record<AlertTone, string> = { up: "text-up", down: "text-down", flat: "text-flat", stale: "text-stale", expiring: "text-expiring", primary: "text-primary", destructive: "text-destructive", } let clockFormat: Intl.DateTimeFormat | null = null // Intl throws on a time that is not an instant (NaN, an infinity, or past the ±8.64e15 ms a Date holds), and a // throw in a cell takes the whole grid down: such a time prints the null token, whatever formatter is in use. const localTime = (ms: number) => (clockFormat ??= new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" })).format(ms) const NEWEST_FIRST = { comparator: byNewest } /** A newest-first view, connected while its owner's effects are mounted. Read its IDs with useRowIds. */ export function useAlertView(alerts: AlertStore): RowView<Alert> { return useView(alerts.store, NEWEST_FIRST)! } /** Time, severity, title, message, count. Spread them into your own list to add, drop, or reorder. */ export function alertColumns(options: { time?: (ms: number) => string; labels?: Partial<AlertHistoryLabels> } = {}): ColumnDef<Alert>[] { const time = options.time ?? localTime const labels = { ...DEFAULT_ALERT_HISTORY_LABELS, ...options.labels } return [ { key: "at", header: labels.time, width: 80, frozen: "left", sortable: true, flash: false, accessor: (a) => a.at, format: (v) => (isInstant(v) ? time(v) : NULL_TOKEN) }, // The word, in the tone: the tone is a hint on a word that is always there. { key: "severity", header: labels.severity, width: 88, sortable: true, flash: false, accessor: (a) => a.severity, cell: ({ row }) => <span className={cn("font-medium", row.tone && ALERT_TONE_TEXT[row.tone])}>{row.severity}</span> }, { key: "title", header: labels.noticeTitle, width: 200, sortable: true, flash: false, accessor: (a) => a.title }, { key: "message", header: labels.message, width: 280, flash: false, accessor: (a) => a.message ?? null }, { key: "count", header: labels.count, width: 64, numeric: true, sortable: true, flash: false, accessor: (a) => a.count }, ] } export interface AlertHistoryProps { alerts: AlertStore columns?: ColumnDef<Alert>[] preset?: DataGridPreset /** The embedded grid's row-count announcements. The preset's setting unless given. */ announceRowCount?: "off" | "debounced" label?: string labels?: Partial<AlertHistoryLabels> renderContextMenu?: (rows: Alert[], ids: RowId[], target?: RowId | null) => ReactNode className?: string } /** The optional grid view. Put it in a panel, dialog, or sheet at the call site. */ export function AlertHistory({ alerts, columns, preset = "blotter", announceRowCount, label, labels: labelsProp, renderContextMenu, className }: AlertHistoryProps) { const labels = { ...DEFAULT_ALERT_HISTORY_LABELS, ...labelsProp } const view = useAlertView(alerts) // Depend on the caller's partial labels, not the merged object recreated on every render — and // keep caller columns keyed to their own identity, so an inline labels object cannot rebuild them. const defaults = useMemo(() => (columns ? undefined : alertColumns({ labels: labelsProp })), [columns, labelsProp]) // The history's newest-first view owns the order, so a header sort could not reorder it: the // grid gets no sort affordances, whatever the column definitions say. const cols = useMemo(() => (columns ?? defaults!).map((col) => (col.sortable ? { ...col, sortable: false } : col)), [columns, defaults]) return ( <div data-slot="tradecn-alert-history" className={cn("h-full min-h-0 min-w-0", className)}> <DataGrid<Alert> store={alerts.store} view={view} columns={cols} preset={preset} announceRowCount={announceRowCount} label={label ?? labels.title} renderContextMenu={renderContextMenu} emptyState={labels.empty} /> </div> ) } /** * Forwards each new notice to whatever toast function you have (your shadcn `sonner`, or none): the * bridge imports no toast package. A notice folded into an existing one by its key is not a new notice * and is not forwarded; read the count on the row for that. */ export function useToastBridge(alerts: AlertStore, toast: ((alert: Alert) => void) | null | undefined): void { const latest = useRef(toast) useEffect(() => { latest.current = toast }) useEffect(() => { const seen = new Set(alerts.store.getIds()) return alerts.store.subscribeOrder(() => { const fresh: Alert[] = [] for (const id of alerts.store.getIds()) { if (seen.has(id)) continue seen.add(id) const row = alerts.store.getRow(id) if (row) fresh.push(row) } for (const id of [...seen]) if (alerts.store.getRow(id) === undefined) seen.delete(id) if (!latest.current) return for (const alert of fresh.sort(byNewest).reverse()) latest.current(alert) }) }, [alerts]) }lib/alert-store.ts import { createRowStore, type RowId, type RowStore } from "@/lib/row-store" // Notices need a store, not a toast each. At the close a thousand events land in a short window, and // a pop-up per event buries the screen; here they go into a row store on the ordered lane, a notice // with the same key replaces the last one and counts it instead of adding a row, and a cap keeps the // list to a size. Nothing here decides what a notice means: the severity is the consumer's word, the // tone a token name beside it, and the actions ids the server allowed. /** A token name. The severity word carries the meaning; the tone is the hint beside it. */ export type AlertTone = "up" | "down" | "flat" | "stale" | "expiring" | "primary" | "destructive" export type AlertJson = string | number | boolean | null | AlertJson[] | { [key: string]: AlertJson } export interface Alert { id: string /** Notices with one key coalesce: the newest replaces the last and `count` grows. */ key?: string /** When it happened, ms since the epoch. */ at: number /** Arrival order in this store, counting up from one; two notices in one millisecond still have an order. */ seq: number /** The consumer's or the server's word: "info", "warning", "critical", "fill", whatever it says. Printed as is. */ severity: string tone?: AlertTone title: string message?: string /** How many notices this one stands for. */ count: number /** Ids of the actions that may be offered for it. No list, no actions. */ allowedActions?: string[] meta?: { [key: string]: AlertJson } } /** What `push` takes: an alert without the fields the store fills. */ export type AlertInput = Omit<Alert, "id" | "at" | "count" | "seq"> & { id?: string; at?: number; count?: number } export interface AlertStoreOptions { /** Injectable clock, ms. Defaults to Date.now. */ now?: () => number /** At most this many notices; the oldest without an action go first, then the oldest with one. Default 500. */ max?: number /** Ids for notices pushed without one. */ nextId?: () => string } export interface AlertStore { /** The rows, on the ordered lane, for a grid or a view. */ readonly store: RowStore<Alert> /** Add a notice, or fold it into the one with the same key. Returns the row as stored. */ push(input: AlertInput): Alert dismiss(ids: RowId | readonly RowId[]): void clear(): void /** Every notice, newest first. */ list(): Alert[] /** How many notices there are. */ size(): number } /** Newest first; at one time the later arrival leads, so notices pushed in one millisecond keep their push order and the order is stable. */ export function byNewest(a: Alert, b: Alert): number { return b.at - a.at || b.seq - a.seq } let counter = 0 function defaultId(): string { counter += 1 return `alert-${Date.now().toString(36)}-${counter}` } export function createAlertStore(options: AlertStoreOptions = {}): AlertStore { const now = options.now ?? Date.now const max = options.max ?? 500 const nextId = options.nextId ?? defaultId const store = createRowStore<Alert>({ getRowId: (alert) => alert.id, lane: "ordered", now }) const byKey = new Map<string, RowId>() let seq = 0 const list = () => store.getIds().map((id) => store.getRow(id)!).sort(byNewest) function trim(): RowId[] { // Room for the newcomer: what is here plus one, against the cap. const over = store.getIds().length + 1 - max if (over <= 0) return [] // Oldest first, and a notice with something to do on it outlives one without. const rows = list().reverse() const without = rows.filter((alert) => !alert.allowedActions?.length) const withActions = rows.filter((alert) => alert.allowedActions?.length) return [...without, ...withActions].slice(0, over).map((alert) => alert.id) } function forget(ids: readonly RowId[]) { for (const id of ids) { const row = store.getRow(id) if (row?.key !== undefined && byKey.get(row.key) === id) byKey.delete(row.key) } } return { store, push(input) { const at = input.at ?? now() const existingId = input.key !== undefined ? byKey.get(input.key) : undefined const existing = existingId !== undefined ? store.getRow(existingId) : undefined if (existing) { // The folded row takes the newcomer's place in time and in arrival order. seq += 1 const fields: Partial<Alert> = { at, seq, severity: input.severity, tone: input.tone, title: input.title, message: input.message, count: existing.count + (input.count ?? 1), allowedActions: input.allowedActions, meta: input.meta, } store.applyDeltas({ patch: [{ id: existing.id, fields }] }) return store.getRow(existing.id)! } seq += 1 const alert: Alert = { ...input, id: input.id ?? nextId(), at, seq, count: input.count ?? 1 } if (alert.key !== undefined) byKey.set(alert.key, alert.id) const remove = trim() // The cap makes room before the newcomer lands, and never takes the newcomer itself. forget(remove) store.applyDeltas({ upsert: [alert], remove: remove.length ? remove : undefined }) return alert }, dismiss(ids) { const list = Array.isArray(ids) ? ids : [ids as RowId] const present = list.filter((id) => store.getRow(id) !== undefined) if (!present.length) return forget(present) store.applyDeltas({ remove: present }) }, clear() { byKey.clear() store.clear() }, list, size: () => store.getIds().length, } }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: oklch(0.53 0.2 350); --expiring-soft: oklch(0.53 0.2 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.55 0 0 / 14%); --stale: oklch(0.52 0.13 75); --stale-soft: oklch(0.52 0.13 75 / 16%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --up: oklch(0.52 0.13 165); --up-soft: oklch(0.52 0.13 165 / 18%); } .dark { --down: oklch(0.66 0.19 40); --down-soft: oklch(0.66 0.19 40 / 18%); --expiring: oklch(0.74 0.17 350); --expiring-soft: oklch(0.74 0.17 350 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.7 0 0 / 14%); --stale: oklch(0.8 0.15 80); --stale-soft: oklch(0.8 0.15 80 / 16%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --up: oklch(0.74 0.15 165); --up-soft: oklch(0.74 0.15 165 / 18%); } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { Alerts, AlertsList, AlertItem, AlertHeader, AlertTitle, AlertBody, AlertSeverity } from "@/components/ui/alerts"
export default function AlertsDemo() {
return (
<Alerts className="w-lg max-w-full">
<AlertsList>
<AlertItem>
<AlertHeader>
<AlertSeverity>fill</AlertSeverity>
<AlertTitle>Order filled</AlertTitle>
</AlertHeader>
<AlertBody>5mm UST at 99-16+</AlertBody>
</AlertItem>
</AlertsList>
</Alerts>
)
}
Compose a single notice directly in JSX. Alerts renders its children; it does not create notices, buttons, announcements, or a dialog.
Composition
Alerts
├── AlertsAnnouncer (optional store announcements)
├── AlertsList
│ └── AlertItem
│ ├── AlertHeader
│ │ ├── AlertSeverity
│ │ ├── AlertTitle
│ │ └── AlertDismiss
│ ├── AlertBody
│ └── AlertActions
│ └── AlertActionButton
└── AlertsEmpty (when your collection is empty)
Map your collection into AlertItem children. Place, omit, or reorder the other parts as needed. AlertBody supports rich content and wraps by default. Use an accessible link or button for interactive content, and pair tone with a severity word or another non-color cue.
For a live store, use useRowIds(useAlertView(alerts)) to read newest-first IDs, then map them into your own row component. Call useAlert(alerts, id) inside that row so a notice update rerenders its subscriber. The store coalesces repeated keys and enforces its cap; see alert-store.
Local collection
Map local data into notice parts and supply your own dismiss handlers. Dismiss either notice, then choose Restore notices to replay. When the collection is empty, render AlertsEmpty in place of the list. No alert store is needed.
import { useRef, useState } from "react"
import { Alerts, AlertsList, AlertsEmpty, AlertItem, AlertHeader, AlertTitle, AlertBody, AlertSeverity, AlertDismiss } from "@/components/ui/alerts"
const initial = [
{ id: "fill", severity: "fill", title: "Order filled", message: "5mm UST at 99-16+" },
{ id: "feed", severity: "info", title: "Feed connected", message: "Market data is available." },
]
export default function AlertsCollectionDemo() {
const [notices, setNotices] = useState(initial)
// Removal never moves focus, so the dismiss handler does: the first remaining notice's
// dismiss, or Restore notices when the list empties.
const region = useRef<HTMLDivElement>(null)
const restore = useRef<HTMLButtonElement>(null)
const refocus = () => queueMicrotask(() => (region.current?.querySelector<HTMLElement>("[data-slot='tradecn-alert-dismiss']") ?? restore.current)?.focus())
return (
<>
<div data-demo-controls className="text-xs">
<button ref={restore} type="button" className="rounded border border-border px-2 py-1" onClick={() => setNotices(initial)}>Restore notices</button>
</div>
<Alerts ref={region} className="w-lg max-w-full">
{notices.length > 0 && <AlertsList>
{notices.map((notice) => (
<AlertItem key={notice.id}>
<AlertHeader>
<AlertSeverity>{notice.severity}</AlertSeverity>
<AlertTitle>{notice.title}</AlertTitle>
<AlertDismiss aria-label={`Dismiss: ${notice.title}`} onClick={() => { setNotices((rows) => rows.filter((row) => row.id !== notice.id)); refocus() }} />
</AlertHeader>
<AlertBody>{notice.message}</AlertBody>
</AlertItem>
))}
</AlertsList>}
{notices.length === 0 && <AlertsEmpty>No notices.</AlertsEmpty>}
</Alerts>
</>
)
}
Allowed actions and repeated notices
This layout moves severity after the title and puts actions below a multiline body. Each row subscribes through useAlert; repeating a notice updates its count even when its position stays the same. The action handlers record the request and dismiss the notice explicitly. Replace them with your application handlers.
Choose Receive slow feed or Receive rejection to restore or repeat a notice. AlertActionButton renders only when its action ID appears in allowedActions. One AlertsAnnouncer announces the first displayed notice, using the assertive region for critical notices.
import { useRef, useState } from "react"
import { createAlertStore, type Alert, type AlertInput, type AlertStore } from "@/lib/alert-store"
import { useRowIds } from "@/hooks/use-row-store"
import { Alerts, AlertsList, AlertsEmpty, AlertsAnnouncer, AlertItem, AlertHeader, AlertTitle, AlertBody, AlertSeverity, AlertActions, AlertActionButton, AlertDismiss, useAlert, useAlertView } from "@/components/ui/alerts"
const slow: AlertInput = { key: "feed:slow", severity: "warning", tone: "stale", title: "Feed slow", message: "Market data is 1.2 seconds behind. Reconnect to request a fresh subscription.", allowedActions: ["reconnect"] }
const rejected: AlertInput = { key: "order:rejected", severity: "critical", tone: "destructive", title: "Order rejected", message: "Price away from market. Review the order before submitting again.", allowedActions: ["ack"] }
function Notice({ alerts, id, onAction, onDismiss }: { alerts: AlertStore; id: string; onAction: (action: string, alert: Alert) => void; onDismiss: (id: string) => void }) {
const alert = useAlert(alerts, id)
if (!alert) return null
return (
<AlertItem tone={alert.tone}>
<AlertHeader>
<AlertTitle>{alert.title}</AlertTitle>
<AlertSeverity tone={alert.tone}>{alert.severity}</AlertSeverity>
</AlertHeader>
<AlertBody>
<p>{alert.message}</p>
<p className="mt-1">Received {alert.count} {alert.count === 1 ? "time" : "times"}.</p>
</AlertBody>
<AlertActions>
<AlertActionButton alert={alert} action="reconnect" onAction={(row) => onAction("Reconnect requested", row)}>Reconnect</AlertActionButton>
<AlertActionButton alert={alert} action="ack" onAction={(row) => onAction("Acknowledged", row)}>Acknowledge</AlertActionButton>
<AlertDismiss className="ml-auto" aria-label={`Dismiss: ${alert.title}`} onClick={() => onDismiss(id)}>Dismiss</AlertDismiss>
</AlertActions>
</AlertItem>
)
}
export default function AlertsActionsDemo() {
const [alerts] = useState(() => {
const store = createAlertStore()
store.push(slow)
store.push(rejected)
return store
})
const ids = useRowIds(useAlertView(alerts))
const [acted, setActed] = useState("No action yet")
// Removal never moves focus, so the demo restores it: the first remaining notice's
// dismiss — never a server-action button, which a held Enter would fire — or a receive
// button when the list empties; the controls row is the column's previous sibling.
const region = useRef<HTMLDivElement>(null)
const refocus = () => queueMicrotask(() => (region.current?.querySelector<HTMLElement>("[data-slot='tradecn-alert-dismiss']") ?? region.current?.previousElementSibling?.querySelector<HTMLElement>("button"))?.focus())
const onAction = (action: string, alert: Alert) => {
setActed(`${action}: ${alert.title}`)
alerts.dismiss(alert.id)
refocus()
}
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => alerts.push(slow)}>Receive slow feed</button>
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => alerts.push(rejected)}>Receive rejection</button>
</div>
<div ref={region} className="flex w-lg max-w-full flex-col gap-3 text-xs">
<Alerts>
<AlertsAnnouncer alerts={alerts} id={ids[0] ?? null} assertive={["critical"]} />
{ids.length > 0 && <AlertsList>{ids.map((id) => <Notice key={id} alerts={alerts} id={id} onAction={onAction} onDismiss={(noticeId) => { alerts.dismiss(noticeId); refocus() }} />)}</AlertsList>}
{ids.length === 0 && <AlertsEmpty>No notices.</AlertsEmpty>}
</Alerts>
<p role="status">{acted}</p>
</div>
</>
)
}
History in your own dialog
This example displays two notices and puts the full history in a caller-owned dialog. You decide the slice, history button, clear control, and history height. The button uses DialogTrigger and stays mounted so closing the dialog returns focus to it even if notices expire or are cleared. It reads "History" when nothing overflows. The history passes announceRowCount="off", keeping the announcer the one announcement path while the dialog is open. Install shadcn's dialog component separately before copying this example. AlertHistory also works in an always-open panel with a height.
import { useRef, useState } from "react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { createAlertStore, type AlertStore } from "@/lib/alert-store"
import { useRowIds } from "@/hooks/use-row-store"
import { Alerts, AlertsList, AlertsEmpty, AlertsAnnouncer, AlertItem, AlertHeader, AlertTitle, AlertBody, AlertDismiss, AlertHistory, useAlert, useAlertView } from "@/components/ui/alerts"
function Notice({ alerts, id, onDismiss }: { alerts: AlertStore; id: string; onDismiss: (id: string) => void }) {
const alert = useAlert(alerts, id)
if (!alert) return null
return (
<AlertItem>
<AlertHeader>
<AlertTitle>{alert.title}</AlertTitle>
<AlertDismiss aria-label={`Dismiss: ${alert.title}`} onClick={() => onDismiss(id)} />
</AlertHeader>
<AlertBody>{alert.message}</AlertBody>
</AlertItem>
)
}
export default function AlertsHistoryDemo() {
const [alerts] = useState(() => {
const store = createAlertStore()
store.push({ severity: "info", title: "Feed connected", message: "Market data is available." })
store.push({ severity: "info", title: "Session open", message: "The trading session has begun." })
store.push({ severity: "fill", title: "Order filled", message: "5mm UST at 99-16+." })
store.push({ severity: "info", title: "Report ready", message: "The execution report is available." })
return store
})
const ids = useRowIds(useAlertView(alerts))
const shown = ids.slice(0, 2)
// Removal never moves focus, so the demo restores it: the first remaining notice's
// control, or the History trigger, which stays rendered whatever the list holds.
const region = useRef<HTMLDivElement>(null)
const refocus = () => queueMicrotask(() => (region.current?.querySelector<HTMLElement>("[data-slot='tradecn-alert-dismiss']") ?? region.current?.querySelector<HTMLElement>("button"))?.focus())
return (
<Dialog>
<Alerts ref={region} className="w-lg max-w-full">
<AlertsAnnouncer alerts={alerts} id={shown[0] ?? null} />
{ids.length > 0 && <AlertsList>{shown.map((id) => <Notice key={id} alerts={alerts} id={id} onDismiss={(noticeId) => { alerts.dismiss(noticeId); refocus() }} />)}</AlertsList>}
{ids.length === 0 && <AlertsEmpty>No notices.</AlertsEmpty>}
<div className="flex gap-2">
<DialogTrigger className={buttonVariants({ variant: "outline", size: "sm" })}>{ids.length > shown.length ? `${ids.length - shown.length} more` : "History"}</DialogTrigger>
{ids.length > 0 && <Button variant="ghost" size="sm" className="ml-auto" onClick={() => { alerts.clear(); refocus() }}>Clear all</Button>}
</div>
</Alerts>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] min-w-0 flex-col sm:max-w-3xl">
<DialogHeader className="shrink-0">
<DialogTitle>Notice history</DialogTitle>
<DialogDescription>Every notice, newest first.</DialogDescription>
</DialogHeader>
<div className="h-80 min-h-0 min-w-0"><AlertHistory alerts={alerts} announceRowCount="off" /></div>
</DialogContent>
</Dialog>
)
}
Forwarding new notices
useToastBridge forwards new IDs after subscribing. The seeded notice is skipped. Choose Receive slow notice once to forward it, then again to increase its repeat count without another callback. After Clear all, receiving it creates a new notice and forwards it again.
The readout is a polite live region that announces the callback count and last title. In an application, replace this callback with your toast adapter. Mount one announcement path for new notices; an adapter that already announces them can take that role.
import { useRef, useState } from "react"
import { createAlertStore, type AlertStore } from "@/lib/alert-store"
import { useRowIds } from "@/hooks/use-row-store"
import { Alerts, AlertsList, AlertsEmpty, AlertItem, AlertHeader, AlertTitle, AlertBody, AlertDismiss, useAlert, useAlertView, useToastBridge } from "@/components/ui/alerts"
function Notice({ alerts, id, onDismiss }: { alerts: AlertStore; id: string; onDismiss: (id: string) => void }) {
const alert = useAlert(alerts, id)
if (!alert) return null
return (
<AlertItem>
<AlertHeader>
<AlertTitle>{alert.title}</AlertTitle>
<AlertDismiss aria-label={`Dismiss: ${alert.title}`} onClick={() => onDismiss(id)} />
</AlertHeader>
<AlertBody>{alert.message} Received {alert.count} {alert.count === 1 ? "time" : "times"}.</AlertBody>
</AlertItem>
)
}
export default function AlertsBridgeDemo() {
const [alerts] = useState(() => {
const store = createAlertStore()
store.push({ severity: "info", title: "Feed connected" })
return store
})
const ids = useRowIds(useAlertView(alerts))
const [forwarded, setForwarded] = useState({ count: 0, title: "None yet" })
useToastBridge(alerts, (alert) => setForwarded((previous) => ({ count: previous.count + 1, title: alert.title })))
// Removal never moves focus, so the handlers do: the first remaining dismiss, or Receive
// slow notice when the list empties — the controls row is the column's previous sibling.
const region = useRef<HTMLDivElement>(null)
const refocus = () => queueMicrotask(() => (region.current?.querySelector<HTMLElement>("[data-slot='tradecn-alert-dismiss']") ?? region.current?.previousElementSibling?.querySelector<HTMLElement>("button"))?.focus())
return (
<>
<div data-demo-controls className="text-xs">
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => alerts.push({ key: "feed:slow", severity: "warning", title: "Feed slow", message: "1.2 s behind." })}>Receive slow notice</button>
</div>
<div ref={region} className="flex w-lg max-w-full flex-col gap-3 text-xs lining-nums tabular-nums">
<Alerts>
{ids.length > 0 && <AlertsList>{ids.map((id) => <Notice key={id} alerts={alerts} id={id} onDismiss={(noticeId) => { alerts.dismiss(noticeId); refocus() }} />)}</AlertsList>}
{ids.length === 0 ? <AlertsEmpty>No notices.</AlertsEmpty> : <button type="button" className="self-end rounded border border-border px-2 py-1" onClick={() => { alerts.clear(); refocus() }}>Clear all</button>}
</Alerts>
<div role="status" aria-atomic="true">
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
<dt>Forwarded</dt><dd>{forwarded.count}</dd>
<dt>Last forwarded</dt><dd>{forwarded.title}</dd>
</dl>
</div>
</div>
</>
)
}
API Reference
Previous call shapes and every removed prop are mapped in Migrating to v2.
Presentation parts
All presentation parts accept their underlying element's props, including children, className, events, and ref. The styling classes are defaults you can extend at each part. None reads an alert store or chooses content.
| Part | Underlying element | Defaults and additional props |
|---|---|---|
Alerts |
div |
role="group"; defaults to aria-label="Notices" unless aria-labelledby is supplied. Set either naming prop to name your collection. |
AlertsList |
ul |
role="list"; supply AlertItem children. |
AlertItem |
li |
Optional tone: AlertTone; sets data-tone and a colored start border. |
AlertHeader |
div |
Wrapping header layout. |
AlertTitle |
div |
Flexible, wrapping title. |
AlertBody |
div |
Wrapping content; accepts block elements and links. |
AlertActions |
div |
Wrapping controls. |
AlertsEmpty |
p |
Muted text; visibility and content belong to you. |
AlertSeverity |
Your Badge |
Optional tone: AlertTone; default variant="outline"; data-alert-severity marker. With a tone, the text takes the tone's color on the surface beneath the chip, which clears any fill your style gives an outline badge. |
AlertDismiss |
Your Button |
type="button", variant="ghost", size="sm", and a × child. The default icon is named "Dismiss" unless aria-labelledby is supplied. Custom children provide their own name; explicit naming props take precedence. Supply onClick and a notice-specific name for icon buttons. |
Each piece has a data-slot matching its kebab-case name with the tradecn- prefix, such as tradecn-alert-header.
AlertActionButton
AlertActionButton accepts Button props, including children and a ref, except onClick. It renders only when alert.allowedActions contains action. Missing or empty permissions render nothing. It defaults to type="button", variant="outline", and size="sm", with data-alert-action set to the action ID. It doesn't hold repeated presses: a handler that waits for the server should ignore presses while its request is out; otherwise a held Enter sends one request per key repeat. Removing the action from allowedActions instead unmounts the focused button, so move focus first, as Removal never moves focus describes.
| Prop | Type | Default | Purpose |
|---|---|---|---|
alert |
Alert |
Required | Current notice, normally supplied by useAlert. |
action |
string |
Required | ID checked against allowedActions. |
onAction |
(alert: Alert) => void |
Required | Called with the rendered notice when clicked. |
children |
ReactNode |
Unset | Button content, including its visible label. |
| Button props | ComponentProps<typeof Button> excluding onClick |
See above | Styling, disabled state, accessible naming, and other native behavior. |
The button checks the supplied alert when it renders; it does not subscribe or re-read the store on click. Use the current row from useAlert for changing permissions. The callback does not dismiss the notice. Send requests and call alerts.dismiss(id) explicitly when appropriate. UI permissions do not replace server authorization.
useAlert
useAlert(alerts, id, options?) returns the current Alert, or undefined when the ID is absent. The row subscribes to that ID; switching the store or ID replaces its subscription.
| Input | Type | Default | Purpose |
|---|---|---|---|
alerts |
AlertStore |
Required | Store to subscribe to. |
id |
RowId |
Required | Notice ID. |
options.ttlMs |
number |
Off | Automatic dismissal while this hook is mounted. |
options.now |
() => number |
Date.now |
Clock in milliseconds since the epoch, on the same basis as the store. |
Expiry waits max(0, alert.at + ttlMs - now()) milliseconds, and the remaining delay must stay within the platform's timer range — about twenty-five days — or the timer fires early. A notice whose at isn't a number, or a delay that comes out as NaN or Infinity from at, ttlMs, or now(), schedules no expiry, so the notice stays until it is dismissed. A repeat with a new at reschedules it. Any nonempty allowedActions disables expiry, including action IDs for which you render no button. Removing those permissions enables the remaining timer, or schedules immediate dismissal if already overdue. Unmounting, changing stores or IDs, or disabling TTL cancels the old timer.
Timers belong to the hook invocation. Put TTL on the displayed notice row only. Hidden notices and history-only rows have no timer unless your application mounts another expiry-enabled hook for them — and expiry measures from the notice's at, not from when it was shown, so an already overdue notice is dismissed the moment it is promoted into view, each dismissal removing its row from history, and a backlog can cascade. When displaying one notice in several places, choose one owner for automatic dismissal; omit TTL from the other subscriptions.
Removal never moves focus: dismissal, TTL expiry, the cap, clear(), and an arrival displacing a shown notice in a sliced list unmount the notice's controls where they stand, and focus falls to the body when one of them held it. Restore focus yourself when you remove the notice under the user — the dismiss handler knows where to send it — and keep TTL off notices whose controls expect to hold focus.
AlertsAnnouncer
Mount one announcer for the collection. It keeps two visually hidden regions: polite role="status" and assertive role="alert". Only one contains the selected notice's announcement, including severity, title, optional message, and repeat count above one. It does not take focus or open UI. An announcer mounted with a notice already selected renders its text at once, so a screen reader may speak that notice as the region appears.
| Prop | Type | Default | Purpose |
|---|---|---|---|
alerts |
AlertStore |
Required | Store to subscribe to. |
id |
RowId | null |
Required | Notice to announce; usually the first displayed ID. Null leaves both regions empty. |
assertive |
readonly string[] |
[] |
Severities announced assertively. |
The announcer follows the selected row, including repeats; it is not a queue of every arrival. It announces whatever becomes first — a removal promotes an older notice and announces it as if it were new, assertively when its severity is listed in assertive — and a keyed notice repeating across tasks announces each time, with no debounce; pushes within one synchronous run render once. Replacing the store, or reusing an id with identical words, announces again. Its region markers are data-alerts-polite and data-alerts-assertive. Avoid duplicate announcements when a toast adapter already announces the same notices.
The whole list
AlertHistory is an optional DataGrid presentation, ordered newest first by at, then seq, with its time column frozen. Give its container a height. It does not supply action or dismiss controls, create a dialog, or start TTL timers. Its default blotter preset adjusts scroll position when notices arrive above the first visible row.
The history's view owns its order, so the grid renders without sort affordances, whatever the column definitions say. Under the default blotter preset the grid also announces its row count politely, about a second after the count stops changing, including on mount; folded repeats and arrivals into a full store leave the count unchanged and announce nothing. Pass announceRowCount="off" to silence it, and keep one announcement path when the history sits beside an AlertsAnnouncer or a toast adapter.
| Prop | Type | Default | Purpose |
|---|---|---|---|
alerts |
AlertStore |
Required | Shared store. |
columns |
ColumnDef<Alert>[] |
alertColumns({ labels }) |
Add, remove, reorder, or replace grid columns. |
preset |
DataGridPreset |
"blotter" |
Grid behavior and presentation. |
announceRowCount |
"off" | "debounced" |
Preset's setting | The embedded grid's row-count announcements. |
label |
string |
labels.title |
Accessible grid name. |
labels |
Partial<AlertHistoryLabels> |
DEFAULT_ALERT_HISTORY_LABELS |
Empty state, grid name, and default column labels. |
renderContextMenu |
(rows: Alert[], ids: RowId[], target?: RowId | null) => ReactNode |
Unset | Caller-composed context menu. target is the notice the menu opened on, which can sit inside a larger selection, and can also name a notice the store has since removed. The grid reads the rows when the menu opens, so check allowedActions again when an item runs, and find each notice by its id rather than pairing rows and ids by index: ids can still hold a notice the store has since removed. |
className |
string |
Unset | History wrapper classes. |
Keep labels referentially stable when the grid builds its default columns, since they rebuild whenever it changes, and keep a custom columns array stable for the same reason; with custom columns, a labels change deliberately rebuilds nothing.
alertColumns({ time?, labels? }) returns time, severity, title, message, and count columns, imported from @/components/ui/alerts with Alert and AlertTone coming from the installed @/lib/alert-store. time accepts (ms: number) => string and defaults to local 24-hour time with seconds. A time a Date can't hold, such as NaN, prints – instead of calling it. The default reads the runtime's time zone, so server-rendered times use the server's. Pass labels explicitly when constructing custom columns. Its time, severity, title, and count columns are marked sortable for grids that build their own view: omit view and the grid orders rows from its sort. A supplied view owns its order and ignores sort, so derive the view you supply from your controlled sort state. useAlertView's is fixed newest-first, and the history removes those affordances.
useAlertView(alerts) returns a RowView<Alert> and replaces it synchronously when the store changes. It owns the view's connection: cleanup stops feed work, and effect replay reconnects the same handle. Read it through useRowIds from the installed use-row-store hooks. Do not dispose it yourself; see view ownership.
Seven v1 labels moved or fell away; Migrating to v2 maps each, and v1's title named the whole strip where the history's title names its grid.
| History label | Default |
|---|---|
title |
All notices |
empty |
No notices. |
time |
Time |
severity |
Severity |
noticeTitle |
Title |
message |
Message |
count |
Count |
The toast bridge
useToastBridge(alerts, toast) returns void and imports no toast package. Pass your own toast adapter. The adapter runs inside the store's push, so one that throws throws from your producer's push call and skips the store's later listeners: catch inside the adapter.
| Argument | Type | Purpose |
|---|---|---|
alerts |
AlertStore |
Required store. |
toast |
((alert: Alert) => void) | null | undefined |
Callback for newly observed IDs, or no callback. |
Existing IDs are skipped on subscription. Newly observed IDs are forwarded oldest first within a batch; a repeat folded into an existing ID does not toast again. Removed IDs are forgotten, so a later reused ID can toast. A null or undefined callback still marks IDs seen; enabling it later does not replay them. Changing stores starts a new subscription, and unmounting unsubscribes.
Tokens
AlertTone accepts up, down, flat, stale, expiring, primary, or destructive. ALERT_TONE_BAR supplies background classes for custom bars or decoration; ALERT_TONE_TEXT supplies text classes. Item tone colors its start border; severity tone colors its text independently, so pass the tone to both when desired. Include a non-color cue in custom compositions. The install adds the five trading tokens and their soft variants if absent, along with the shared font tokens and the hyperlegible remap the grid columns use; primary and destructive come from your theme.
Built on shadcn's badge, button, checkbox, context-menu, and dropdown-menu.
This page is docs/alerts.md at v2.0.0.