Ticket
Enter an order in the instrument's notation and pass a checked draft to an allowed action.
import { useState } from "react"
import { Ticket, describeDraft, type TicketInstrument } from "@/components/ticket"
const ZN: TicketInstrument = { symbol: "ZN", convention: { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }, quantityStep: 1 }
export default function TicketDemo() {
const [submitted, setSubmitted] = useState("No draft submitted.")
return (
<div className="w-80 max-w-full space-y-2 text-xs lining-nums tabular-nums">
<Ticket
instrument={ZN}
reference={{ bid: 99.484375, ask: 99.5 }}
defaultDraft={{ quantity: 5, price: 99.5 }}
actions={[{ id: "send", label: (draft) => draft.side === "buy" ? "Submit buy" : "Submit sell", run: (draft) => setSubmitted(describeDraft(draft, ZN)) }]}
allowedActions={["send"]}
/>
<p role="status" className="text-muted-foreground">{submitted}</p>
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/ticket#v2.0.0
npx shadcn@latest add tradecn/ui/ticket#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/ticket#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/ticket#v2.0.0
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add button button-group field input input-group kbd native-selectnpx shadcn@latest add button button-group field input input-group kbd native-selectyarn dlx shadcn@latest add button button-group field input input-group kbd native-selectbunx --bun shadcn@latest add button button-group field input input-group kbd native-select -
Copy the files into your project:
components/ticket.tsx import { cn } from "cn" import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent, type MouseEvent } from "react" import { Button } from "@/components/ui/button" import { ButtonGroup } from "@/components/ui/button-group" import { Field, FieldError, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { Kbd, KbdGroup } from "@/components/ui/kbd" import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select" import { useFlash } from "@/hooks/use-flash" import { HotkeyScope, useMaybeHotkeys } from "@/hooks/use-hotkeys" import { NUMERIC_CLASS, formatNotional, formatQuantity, formatQuote, numericFontClass, stepQuote, stripGrouping, type InstrumentConvention } from "@/lib/format" import { blocks, checkLimits, confirms, problemsByField, type Limits, type LimitsLabels, type Problem as LimitProblem } from "@/lib/limits" import { formatKeys, type HotkeyBinding, type HotkeyRegistry } from "@/lib/hotkeys" import { QuoteField } from "@/components/ui/quote-field" // 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> // An order ticket. Its price field is a quote-field, so it types a price the way the instrument // quotes it and steps it by the quote step, and it hands a draft to whatever you named as an action. Two things it never does: work out a status, and // offer an action the server did not allow. The status is a string the server said, printed as is. // The buttons are the actions whose ids are in `allowedActions`, and no list means no buttons. // // Its keys are `editing` bindings inside its own scope, so they work while you type in it and // nowhere else, and they keep working when the ticket is inside a dialog. export type TicketSide = "buy" | "sell" export interface TicketOption { id: string label: string /** False for an order that takes no price, such as a market order. Default true. */ priced?: boolean } export interface TicketInstrument { symbol: string /** How prices print, parse, and step. */ convention: InstrumentConvention /** What the arrows step the quantity by: a whole number from 1 to `Number.MAX_SAFE_INTEGER`, else 1. Default 1. */ quantityStep?: number } export interface TicketDraft { side: TicketSide quantity: number | null /** Null while blank or not a price, and for an order type that takes none. */ price: number | null type: string tif: string account: string | null } /** Prices to start from: click one to take it, and the arrows step from `last` when the field is blank. */ export interface TicketReference { bid?: number | null ask?: number | null last?: number | null } export interface TicketAction { /** Matched against `allowedActions`. */ id: string /** A verb: "Send", "Amend". A function of the draft may say more. */ label: string | ((draft: TicketDraft) => string) /** The draft, checked: a positive quantity, and a price when the order type takes one. */ run: (draft: TicketDraft, instrument: TicketInstrument) => void destructive?: boolean /** The prominent action. `ticket.send` prefers it while it checks the draft; an action with `checked: false` never takes the send key. */ primary?: boolean /** The draft is checked, and held by a limit, before this runs. Default true; false for an action that sends nothing of the draft. */ checked?: boolean } export interface TicketLabels { ticket: string buy: string sell: string quantity: string /** The quick-size row's name. */ quickSizes: string price: string type: string tif: string account: string bid: string ask: string last: string quantityInvalid: string priceInvalid: string priceRequired: string /** What a select shows while the draft holds none of its options, as after a list reloads without it. */ choose: string typeRequired: string tifRequired: string accountRequired: string nothingAllowed: string /** The primary action's words while a limit asks again; `{action}` is its label. */ anyway: string /** The price field's placeholder while the order type takes no price. */ unpriced: string /** `describeDraft`'s words for an order type that takes no price. */ atMarket: string /** A reference price's button: `{name}` its label, `{price}` the level. */ takeReference: string /** A quick size's unit where the convention quotes notional. */ millions: string /** The price field's step buttons, `{label}` the price's label. */ stepUp: string stepDown: string } export const DEFAULT_TICKET_LABELS: TicketLabels = { ticket: "Order ticket", buy: "Buy", sell: "Sell", quantity: "Quantity", quickSizes: "Quick sizes", price: "Price", type: "Type", tif: "Time in force", account: "Account", bid: "Bid", ask: "Ask", last: "Last", quantityInvalid: "Enter a whole number above zero.", priceInvalid: "Not a price in this instrument's notation.", priceRequired: "This order type needs a price.", choose: "Choose…", typeRequired: "Choose an order type.", tifRequired: "Choose a time in force.", accountRequired: "Choose an account.", nothingAllowed: "Nothing can be done with this ticket right now.", anyway: "{action} anyway?", unpriced: "market", atMarket: "at market", takeReference: "{name} {price}, use it", millions: "mm", stepUp: "{label} up one step", stepDown: "{label} down one step", } const fill = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (_, key: string) => values[key] ?? "") export const DEFAULT_ORDER_TYPES: readonly TicketOption[] = [ { id: "limit", label: "Limit" }, { id: "market", label: "Market", priced: false }, ] export const DEFAULT_TIME_IN_FORCES: readonly TicketOption[] = [ { id: "day", label: "Day" }, { id: "gtc", label: "GTC" }, { id: "ioc", label: "IOC" }, ] /** `mod+1` to `mod+9`: the quick sizes, in order. */ export const QUICK_SIZE_KEYS: readonly string[] = ["mod+1", "mod+2", "mod+3", "mod+4", "mod+5", "mod+6", "mod+7", "mod+8", "mod+9"] /** The keys a ticket answers to, all `editing`: they run while you type in it. Declared by the ticket as registry defaults your own registration shadows. */ export const TICKET_BINDINGS: readonly HotkeyBinding[] = [ { id: "ticket.send", keys: "mod+enter", scope: "editing", description: "Send the ticket", group: "Ticket" }, { id: "ticket.flip", keys: "mod+shift+x", scope: "editing", description: "Flip buy and sell", group: "Ticket" }, { id: "ticket.tick-up", keys: "mod+up", scope: "editing", description: "Price up one step", group: "Ticket" }, { id: "ticket.tick-down", keys: "mod+down", scope: "editing", description: "Price down one step", group: "Ticket" }, ...QUICK_SIZE_KEYS.map((keys, i) => ({ id: `ticket.size-${i + 1}`, keys, scope: "editing" as const, description: `Quick size ${i + 1}`, group: "Ticket" })), ] /** A quick size the way the desk says it: millions of notional when the convention quotes notional, in `millions` as the unit, a count otherwise. */ export function formatQuickSize(size: number, convention: InstrumentConvention, millions = "mm"): string { return convention.quantityUnit === "notional" ? formatNotional(size, { unit: "mm" }).replace(/mm$/, millions) : formatQuantity(size) } export interface TicketProps { instrument: TicketInstrument reference?: TicketReference /** Default: limit and market. */ orderTypes?: readonly TicketOption[] /** Default: day, GTC, IOC. */ timeInForces?: readonly TicketOption[] /** Left out or empty, there is no account field. Given, a draft must hold one of them to be sent. */ accounts?: readonly TicketOption[] /** Where the ticket starts. Change the `key` to start again. */ defaultDraft?: Partial<TicketDraft> onDraftChange?: (draft: TicketDraft) => void actions: readonly TicketAction[] /** What the server says may be done with this ticket now. No list means nothing, and no buttons. */ allowedActions?: readonly string[] /** The desk's lines, from `limits`: a block shows under its field and holds the actions that send the draft; a confirm makes the action ask again. Checked against `reference` as the market. */ limits?: Limits /** The limit sentences' words, over `DEFAULT_LIMITS_LABELS`. */ limitsLabels?: Partial<LimitsLabels> /** Sizes a press or `mod+1` to `mod+9` puts in the quantity, as buttons under the field, printed in the convention's unit. Whole numbers above zero, each once; any other is left out. */ quickSizes?: readonly number[] /** The server's word for where the order stands. Printed as it is. */ status?: string /** What the server said about it, a rejection reason for one. Printed as it is. */ message?: string /** Anything that changes identity when the server acknowledges: an order id, a timestamp. The ticket rings once in `primary`. */ acknowledged?: unknown disabled?: boolean /** Declare `TICKET_BINDINGS` as registry defaults. Default true. */ hotkeys?: boolean labels?: Partial<TicketLabels> className?: string } const isPriced = (types: readonly TicketOption[], id: string) => types.find((t) => t.id === id)?.priced !== false /** "Buy 5 ZN @ 99-16+", or "Buy 5 ZN at market" for a type that takes no price. The level prints in the instrument's quote basis, as the field shows it. */ export function describeDraft(draft: TicketDraft, instrument: TicketInstrument, orderTypes: readonly TicketOption[] = DEFAULT_ORDER_TYPES, labels: Pick<TicketLabels, "buy" | "sell"> & Partial<Pick<TicketLabels, "atMarket">> = DEFAULT_TICKET_LABELS): string { const side = draft.side === "buy" ? labels.buy : labels.sell const quantity = draft.quantity === null ? "" : ` ${formatQuantity(draft.quantity)}` const price = !isPriced(orderTypes, draft.type) ? ` ${labels.atMarket ?? DEFAULT_TICKET_LABELS.atMarket}` : draft.price === null ? "" : ` @ ${formatQuote(draft.price, instrument.convention)}` return `${side}${quantity} ${instrument.symbol}${price}` } export interface TicketProblems { quantity?: string price?: string type?: string tif?: string account?: string } /** The lists a draft's choices must come from, beside the order types. An empty or missing list asks nothing. */ export interface TicketChoices { timeInForces?: readonly TicketOption[] accounts?: readonly TicketOption[] } const listed = (options: readonly TicketOption[] | undefined, id: string | null) => !options?.length || options.some((option) => option.id === id) /** What stops a draft from being sent: a quantity that is not a whole number above zero, a price its order type needs, or a choice not among its options. Empty when nothing does. */ export function checkDraft(draft: TicketDraft, orderTypes: readonly TicketOption[], labels: TicketLabels = DEFAULT_TICKET_LABELS, choices: TicketChoices = {}): TicketProblems { const problems: TicketProblems = {} if (draft.quantity === null || !Number.isSafeInteger(draft.quantity) || draft.quantity <= 0) problems.quantity = labels.quantityInvalid if (!listed(orderTypes, draft.type)) problems.type = labels.typeRequired if (isPriced(orderTypes, draft.type) && (draft.price === null || !Number.isFinite(draft.price))) problems.price = labels.priceRequired if (!listed(choices.timeInForces, draft.tif)) problems.tif = labels.tifRequired if (!listed(choices.accounts, draft.account)) problems.account = labels.accountRequired return problems } // A limit's identity apart from its words, which the market can change under it. const ruleOf = (p: LimitProblem) => `${p.field}\u0000${p.rule}` // What a press met, as it landed: the question an action asked, or (no action) the blocks it was refused for, by rule. interface Said { text: string revision: number draft: TicketDraft | null action: string | null rules: readonly string[] } // A press counts once: the second click of a double-click, and the clicks a held Enter repeats on a focused // button, run nothing and answer no question the first press asked. The hold belongs to the button it repeats // on, and ends when the key is let go or focus leaves that button. function useFreshPress() { const held = useRef<EventTarget | null>(null) const release = () => { held.current = null } return { onKeyDown: (event: KeyboardEvent<HTMLElement>) => { held.current = event.key === "Enter" && event.repeat ? event.currentTarget : null }, onKeyUp: release, onBlur: release, fresh: (event: MouseEvent<HTMLElement>) => event.detail <= 1 && !(event.detail === 0 && held.current === event.currentTarget), } } /** The send shortcut's target: the first allowed action that checks the draft, preferring the primary one. An unchecked action sends nothing of the draft and a destructive one cancels or pulls, so a shortcut named send runs neither. */ function sendTarget(allowed: readonly TicketAction[]): TicketAction | null { const sending = allowed.filter((action) => action.checked !== false && !action.destructive) return sending.find((action) => action.primary) ?? sending[0] ?? null } // Every ticket declares the bindings as registry defaults: the registry refcounts them, a // consumer registration of an id shadows its default, and unregistering surfaces it again, so // no mount order can delete a consumer's declaration or strand a remaining ticket without one. function declareBindings(registry: HotkeyRegistry, bindings: readonly HotkeyBinding[]): () => void { const releases = bindings.map((binding) => registry.declareDefault(binding)) return () => { for (const release of releases) release() } } /** Whole numbers, with commas only between thousands. A quantity is contracts or units of notional, never a fraction. */ export function parseQuantity(text: string): number | null { // "2,5" from a decimal-comma keyboard is no quantity, never 25. const clean = stripGrouping(text, { decimals: false }) if (clean === null || !/^\d+$/.test(clean)) return null const n = Number(clean) return Number.isSafeInteger(n) ? n : null } /** The quick sizes a ticket offers: whole numbers above zero, each once, in order. */ const wholeSizes = (sizes: readonly number[] | undefined): readonly number[] => [...new Set((sizes ?? []).filter((size) => Number.isSafeInteger(size) && size > 0))] // The acknowledgement ring has no direction, whatever the value that rings it. const noDirection = () => null const noop = () => () => {} const guardKey = (fn: () => void) => (event: KeyboardEvent | globalThis.KeyboardEvent) => { event.preventDefault() fn() } const TICKET_CORE_BINDINGS = TICKET_BINDINGS.filter((binding) => !binding.id.startsWith("ticket.size-")) const TICKET_SIZE_BINDINGS = TICKET_BINDINGS.filter((binding) => binding.id.startsWith("ticket.size-")) export function Ticket({ instrument, reference, orderTypes = DEFAULT_ORDER_TYPES, timeInForces = DEFAULT_TIME_IN_FORCES, accounts, defaultDraft, onDraftChange, actions, allowedActions, limits, limitsLabels, quickSizes, status, message, acknowledged, disabled = false, hotkeys: declareHotkeys = true, labels: labelsProp, className, }: TicketProps) { const labels = { ...DEFAULT_TICKET_LABELS, ...defined(labelsProp) } const id = useId() const { convention } = instrument // Quantities are whole: a step that is not a whole number from 1 to the safe-integer limit steps by one. const quantityStep = Number.isSafeInteger(instrument.quantityStep) && instrument.quantityStep! > 0 ? instrument.quantityStep! : 1 const [draft, setDraft] = useState<TicketDraft>(() => ({ side: "buy", type: orderTypes[0]?.id ?? "", tif: timeInForces[0]?.id ?? "", account: accounts?.[0]?.id ?? null, ...defaultDraft, // A quantity is whole, so a default that is not one starts the field blank, as text that is not one reads. quantity: typeof defaultDraft?.quantity === "number" && Number.isSafeInteger(defaultDraft.quantity) && defaultDraft.quantity >= 0 ? defaultDraft.quantity : null, // Snapped to the quote grid, as a reference click is: the field prints the grid value. price: typeof defaultDraft?.price === "number" && Number.isFinite(defaultDraft.price) ? stepQuote(defaultDraft.price, convention, 0) : null, })) const [quantityText, setQuantityText] = useState(() => (draft.quantity === null ? "" : formatQuantity(draft.quantity))) const [problems, setProblems] = useState<TicketProblems>({}) // The action a limit asked about: a fresh press on it sends, unless the market has added a reason since, which asks // again. Any change to the draft withdraws the question. const [confirming, setConfirming] = useState<string | null>(null) const sizes = useMemo(() => wholeSizes(quickSizes), [quickSizes]) const press = useFreshPress() const priced = isPriced(orderTypes, draft.type) // The limits, live: a block shows under its field and holds the actions that send the draft; a confirm waits for the click. const limitProblems = limits ? checkLimits({ side: draft.side, quantity: draft.quantity, price: priced ? draft.price : null }, limits, { market: reference, convention, labels: limitsLabels }) : [] const blocking = blocks(limitProblems) const blockedBy = problemsByField(blocking) const blocked = blocking.length > 0 const shownProblems = { quantity: problems.quantity ?? blockedBy.quantity, price: problems.price ?? blockedBy.price } const otherBlocks = blocking.filter((p) => p.field !== "quantity" && p.field !== "price") const asking = confirming !== null ? confirms(limitProblems) : [] const limitsText = [...otherBlocks, ...asking].map((p) => p.message).join(" ") // What a screen reader hears from the limits: what a press met, as it landed — the blocks it was refused for, or // the question it asked. Nothing is said as the market or the trader's typing rewords them; the line and the // fields keep the live words on screen. A message goes for good once the draft changes or what it said no longer // stands (a block it named stops blocking, or the question is answered or withdrawn), so a block the market brings // back waits for the next press. Each message is a new node, so the same words said again are still heard. const [said, setSaid] = useState<Said>({ text: "", revision: 0, draft: null, action: null, rules: [] }) const blockingRules = new Set(blocking.map(ruleOf)) const stands = said.draft === draft && (said.action === null ? said.rules.every((rule) => blockingRules.has(rule)) : confirming === said.action) if (said.draft !== null && !stands) setSaid({ ...said, text: "", draft: null, action: null, rules: [] }) const heard = stands ? said : null // The reasons the standing question asked about: a reason the market adds since makes the next press ask again. const askedRules = useRef<ReadonlySet<string>>(new Set()) const box = useRef<HTMLDivElement>(null) const priceInput = useRef<HTMLInputElement>(null) const latest = useRef({ onDraftChange, actions, allowedActions, draft, orderTypes, timeInForces, accounts, labels, instrument, disabled, limits, limitsLabels, reference, confirming, quickSizes: sizes, problems }) // Layout phase, not passive: a keydown can land between the commit that removed a button // and the passive effects, and the check at run time must see what the trader sees. useLayoutEffect(() => { latest.current = { onDraftChange, actions, allowedActions, draft, orderTypes, timeInForces, accounts, labels, instrument, disabled, limits, limitsLabels, reference, confirming, quickSizes: sizes, problems } }) // The draft is told after it changed, never on the first render. const told = useRef(draft) useEffect(() => { if (told.current === draft) return told.current = draft latest.current.onDraftChange?.(draft) }, [draft]) // The acknowledgement: a ring in primary, once, when the server says so, with no direction even when what rings it is a // count. useFlash(box, acknowledged, { variant: "ring", color: "var(--primary)", compare: noDirection }) // When the control under focus leaves — a sent action's button unmounts or disables with // the acknowledgement — focus falls to body, outside every fence, and the shortcuts go // dead. The scope root, which carries the fence's tabIndex, takes it instead. A deliberate // blur clears the record, or a later removal of that button would steal focus back in. const focusedInside = useRef<HTMLElement | null>(null) useLayoutEffect(() => { const node = box.current ? box.current.parentElement ?? box.current : null const previous = focusedInside.current if (!node || !previous) return const doc = node.ownerDocument const gone = !previous.isConnected || previous.matches(":disabled") if (gone && (doc.activeElement === previous || doc.activeElement === doc.body)) { focusedInside.current = null // Never scroll: the park can fire while the trader reads elsewhere, and a focus move // is what the page promises. node.focus({ preventScroll: true }) } }) function update(patch: Partial<TicketDraft>) { setDraft((d) => ({ ...d, ...patch })) setProblems((p) => { let next = p const answer = (field: keyof TicketProblems) => { if (next[field]) next = { ...next, [field]: undefined } } if (patch.quantity !== undefined) answer("quantity") // A new price, or a type that takes none, answers the price problem. if (patch.price !== undefined || (patch.type !== undefined && !isPriced(orderTypes, patch.type))) answer("price") if (patch.type !== undefined) answer("type") if (patch.tif !== undefined) answer("tif") if (patch.account !== undefined) answer("account") return next }) setConfirming(null) } // The quote field is controlled and follows the draft, so a step or a reference click is one update. function setPrice(value: number | null) { update({ price: value }) } /** Where a step starts when the field is blank: the last, then the mid, then whichever side there is. A warming feed's NaN is no reference at all. */ function priceToStepFrom(): number | null { if (draft.price !== null && Number.isFinite(draft.price)) return draft.price const { bid, ask, last } = reference ?? {} if (Number.isFinite(last)) return last as number if (Number.isFinite(bid) && Number.isFinite(ask)) return stepQuote(((bid as number) + (ask as number)) / 2, convention, 0) return Number.isFinite(bid) ? (bid as number) : Number.isFinite(ask) ? (ask as number) : null } function stepPrice(steps: number) { const from = priceToStepFrom() if (from === null) return // The field steps in the instrument's quote basis, so the shortcut steps the same way, // or the price sent would differ from the price shown on a discount, yield, or spread // instrument. setPrice(stepQuote(from, convention, steps)) } function stepQuantity(steps: number) { const from = draft.quantity ?? 0 const next = Math.max(0, Math.round((from + steps * quantityStep) / quantityStep) * quantityStep) update({ quantity: next }) setQuantityText(formatQuantity(next)) } /** The n-th quick size (from 1) into the quantity, when there is one. */ function quick(n: number) { const size = latest.current.quickSizes?.[n - 1] if (size === undefined || latest.current.disabled) return update({ quantity: size }) setQuantityText(formatQuantity(size)) } function onQuantityChange(text: string) { setQuantityText(text) update({ quantity: parseQuantity(text) }) } function onQuantityBlur() { const value = parseQuantity(quantityText) if (value !== null) setQuantityText(formatQuantity(value)) } // Arrows step the quantity, Shift ten at a time. With a modifier held the key belongs to the // registry: `mod+up` steps the price from any field. The quote field does the same for the price. const stepper = (step: (steps: number) => void) => (event: KeyboardEvent<HTMLInputElement>) => { if ((event.key !== "ArrowUp" && event.key !== "ArrowDown") || event.ctrlKey || event.metaKey || event.altKey) return event.preventDefault() step((event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 10 : 1)) } const allowed = actions.filter((action) => allowedActions?.includes(action.id)) // A question whose action the server took away no longer stands: if the action comes back, it asks again. if (confirming !== null && !allowed.some((action) => action.id === confirming)) setConfirming(null) const primary = allowed.find((action) => action.primary) ?? allowed[0] const sendAction = sendTarget(allowed) // A block holds a checked action without taking it out of reach: it looks disabled and stays focusable, and a // press on it, from any input, is refused and says why. const heldByLimit = (action: TicketAction) => blocked && action.checked !== false function run(action: TicketAction) { // Checked as the click lands, against the props as they are now: an action can stop being allowed. const { draft: current, allowedActions: allowedNow, orderTypes: types, timeInForces: tifs, accounts: books, labels: words, instrument: inst, disabled: off, limits: lines, limitsLabels: lineWords, reference: market, confirming: asked, problems: shown } = latest.current if (off || !allowedNow?.includes(action.id)) return const price = isPriced(types, current.type) ? current.price : null const send = () => { setConfirming(null) action.run({ ...current, price }, inst) } if (action.checked === false) return send() const found = checkDraft(current, types, words, { timeInForces: tifs, accounts: books }) // The limits, as the click lands: a block stops here and shows under its field; a confirm asks, and a fresh press // on the same action sends unless the market has added a reason since. const over = lines ? checkLimits({ side: current.side, quantity: current.quantity, price }, lines, { market, convention: inst.convention, labels: lineWords }) : [] const blocking = blocks(over) setProblems(found) // A field's new problem is an alert under it. One it already shows holds the same words, which a screen reader // does not hear again, so the announcer says it, beside the blocks. const again = (Object.keys(found) as (keyof TicketProblems)[]).filter((field) => found[field] === shown[field]).map((field) => found[field]!) const refused = [...again, ...blocking.map((p) => p.message)] if (refused.length) setSaid((now) => ({ text: refused.join(" "), revision: now.revision + 1, draft: current, action: null, rules: blocking.map(ruleOf) })) if (Object.keys(found).length || blocking.length) return const reasons = confirms(over) if (reasons.length && (asked !== action.id || !reasons.every((p) => askedRules.current.has(ruleOf(p))))) { askedRules.current = new Set(reasons.map(ruleOf)) setConfirming(action.id) setSaid((now) => ({ text: reasons.map((p) => p.message).join(" "), revision: now.revision + 1, draft: current, action: action.id, rules: [] })) return } send() } // Keys: defaults declared per ticket, handlers fenced to this ticket so another's keys stay its own. const registry = useMaybeHotkeys() const handlers = useRef({ send: () => {}, flip: () => {}, up: () => {}, down: () => {}, quick: (n: number) => { void n } }) useLayoutEffect(() => { handlers.current = { send: () => { const { actions: list, allowedActions: allowedNow } = latest.current const now = list.filter((action) => allowedNow?.includes(action.id)) const first = sendTarget(now) if (first) run(first) }, flip: () => { if (latest.current.disabled) return update({ side: draft.side === "buy" ? "sell" : "buy" }) }, up: () => { if (!latest.current.disabled) stepPrice(1) }, down: () => { if (!latest.current.disabled) stepPrice(-1) }, quick, } }) const quickCount = sizes.length // Fenced to the scope root, so the shortcuts run from the symbol, the status, and the padding too. const within = useMemo(() => ({ scope: "editing", element: () => (box.current ? box.current.parentElement ?? box.current : null) }), []) useEffect(() => { if (!registry) return const release = declareHotkeys ? declareBindings(registry, TICKET_CORE_BINDINGS) : noop() const unbind = [ registry.bind("ticket.send", guardKey(() => handlers.current.send()), within), registry.bind("ticket.flip", guardKey(() => handlers.current.flip()), within), registry.bind("ticket.tick-up", guardKey(() => handlers.current.up()), within), registry.bind("ticket.tick-down", guardKey(() => handlers.current.down()), within), ] return () => { for (const u of unbind) u() release() } }, [registry, declareHotkeys, within]) // The sizes declare only for the quick sizes passed — a declared id needs its handler or it // has no fence and conflicts with another ticket's — and a size-count change never touches // the core four. Handlers still bind fenced for all nine so an id anyone else declares — // spreading TICKET_BINDINGS is the documented pattern — always meets a fence. An undeclared // absent size passes through untouched; a declared one is consumed by the registry before // this handler runs, which then does nothing for a size this ticket lacks. useEffect(() => { if (!registry || quickCount === 0) return const release = declareHotkeys ? declareBindings(registry, TICKET_SIZE_BINDINGS.slice(0, quickCount)) : noop() return release }, [registry, declareHotkeys, quickCount]) useEffect(() => { if (!registry) return const unbind = TICKET_SIZE_BINDINGS.map((binding, i) => registry.bind(binding.id, (event) => { if (i >= (latest.current.quickSizes?.length ?? 0)) return event.preventDefault() handlers.current.quick(i + 1) }, within)) return () => { for (const u of unbind) u() } }, [registry, within]) const sendKeys = useSyncExternalStore( registry?.subscribe ?? noop, () => registry?.list().find((entry) => entry.id === "ticket.send")?.keys ?? null, () => null, ) const referencePrice = (name: keyof TicketReference, label: string) => { const value = reference?.[name] if (typeof value !== "number" || !Number.isFinite(value)) return null // Snapped to the quote grid and printed in the quote basis: what the click stores is what // the field will show. const quote = stepQuote(value, convention, 0) return ( <Button key={name} type="button" variant="ghost" size="sm" className={cn("h-5 gap-1 px-1 text-xs", numericFontClass(convention))} disabled={disabled || !priced} aria-label={fill(labels.takeReference, { name: label, price: formatQuote(quote, convention) })} data-reference={name} onClick={() => setPrice(quote)}> <span className="text-muted-foreground">{label}</span> {formatQuote(quote, convention)} </Button> ) } // A select shows what the draft holds. A value its options no longer list, as after a list loads or reloads under // the ticket, shows as a choice still to make, and a press asks for one, rather than showing the first option while // the draft sends another. const choice = (field: "type" | "tif" | "account", label: string, options: readonly TicketOption[], value: string | null, className?: string) => { const unlisted = !options.some((option) => option.id === value) return ( <Field className={className} data-invalid={problems[field] ? true : undefined}> <FieldLabel htmlFor={`${id}-${field}`}>{label}</FieldLabel> <NativeSelect id={`${id}-${field}`} value={value ?? ""} disabled={disabled} className="w-full" aria-invalid={problems[field] ? true : undefined} aria-describedby={problems[field] ? `${id}-${field}-error` : undefined} onChange={(event) => update({ [field]: event.target.value })}> {unlisted && ( <NativeSelectOption value={value ?? ""} disabled> {labels.choose} </NativeSelectOption> )} {options.map((option) => ( <NativeSelectOption key={option.id} value={option.id}> {option.label} </NativeSelectOption> ))} </NativeSelect> {problems[field] && <FieldError id={`${id}-${field}-error`}>{problems[field]}</FieldError>} </Field> ) } const sideButton = (side: TicketSide) => ( <Button type="button" variant="outline" size="sm" aria-pressed={draft.side === side} disabled={disabled} data-side={side} // Stacked with dark: too, or a style's own dark:bg-* on the outline button paints over the fill. className={cn( "h-7 flex-1 font-semibold aria-pressed:text-background dark:aria-pressed:text-background", side === "buy" ? "aria-pressed:border-up aria-pressed:bg-up dark:aria-pressed:border-up dark:aria-pressed:bg-up" : "aria-pressed:border-down aria-pressed:bg-down dark:aria-pressed:border-down dark:aria-pressed:bg-down", )} onClick={() => update({ side })} > {side === "buy" ? labels.buy : labels.sell} </Button> ) return ( <HotkeyScope scope="editing" role="group" aria-label={`${labels.ticket} ${instrument.symbol}`} data-slot="tradecn-ticket" data-side={draft.side} data-status={status} className={cn("block rounded-md outline-none focus-visible:ring-2 focus-visible:ring-foreground/60 lining-nums tabular-nums", className)}> <div ref={box} className="flex flex-col gap-2 rounded-md border border-border bg-card p-2 text-xs text-card-foreground" onFocusCapture={(event) => { focusedInside.current = event.target as HTMLElement }} onBlurCapture={(event) => { // Focus moving somewhere outside the ticket on purpose: the leaving control is still // in the document and enabled, so there is nothing to recover from. A window switch // also blurs with no destination, but the document loses focus with it — the record // stays, so a control withdrawn while the trader is away still parks on return. const leaving = event.target as HTMLElement const next = event.relatedTarget as HTMLElement | null if (!next && !event.currentTarget.ownerDocument.hasFocus()) return if (focusedInside.current === leaving && leaving.isConnected && !leaving.matches(":disabled") && (!next || !event.currentTarget.contains(next))) focusedInside.current = null }}> <div className="flex items-center gap-2"> <span className="font-(family-name:--tradecn-font-mono) text-sm font-semibold" data-ticket-symbol> {instrument.symbol} </span> <span className="ml-auto flex items-center gap-1"> {referencePrice("bid", labels.bid)} {referencePrice("ask", labels.ask)} {referencePrice("last", labels.last)} </span> </div> <ButtonGroup className="w-full" aria-label={`${labels.buy} / ${labels.sell}`}> {sideButton("buy")} {sideButton("sell")} </ButtonGroup> <div className="grid grid-cols-2 gap-2"> <Field data-invalid={shownProblems.quantity ? true : undefined}> <FieldLabel htmlFor={`${id}-quantity`}>{labels.quantity}</FieldLabel> <Input id={`${id}-quantity`} value={quantityText} inputMode="numeric" autoComplete="off" spellCheck={false} disabled={disabled} aria-invalid={shownProblems.quantity ? true : undefined} aria-describedby={problems.quantity ? `${id}-quantity-error` : undefined} data-numeric="" className={cn("h-7 text-xs md:text-xs", NUMERIC_CLASS)} onChange={(event) => onQuantityChange(event.target.value)} onBlur={onQuantityBlur} onKeyDown={stepper(stepQuantity)} /> {/* A press's own problem is an alert tied to the field; a live limit block is said when a press meets it. */} {shownProblems.quantity && ( <FieldError id={`${id}-quantity-error`} {...(problems.quantity ? {} : { role: "none" })}> {shownProblems.quantity} </FieldError> )} {sizes.length > 0 && ( <div role="group" aria-label={labels.quickSizes} data-ticket-quick-sizes="" className="flex flex-wrap gap-1"> {sizes.map((size, i) => ( <Button key={size} type="button" variant="outline" size="sm" className={cn("h-6 px-1.5 text-xs aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent dark:aria-pressed:text-accent-foreground", NUMERIC_CLASS)} disabled={disabled} aria-pressed={draft.quantity === size} aria-label={`${labels.quantity} ${formatQuickSize(size, convention, labels.millions)}`} data-quick-size={size} onClick={() => quick(i + 1)}> {formatQuickSize(size, convention, labels.millions)} </Button> ))} </div> )} </Field> <QuoteField id={`${id}-price`} convention={convention} label={labels.price} value={draft.price} onValueChange={setPrice} stepFrom={priceToStepFrom()} disabled={disabled || !priced} placeholder={priced ? undefined : labels.unpriced} error={shownProblems.price} announceError={problems.price !== undefined} invalidText={labels.priceInvalid} labels={{ stepUp: labels.stepUp, stepDown: labels.stepDown }} inputRef={priceInput} /> {choice("type", labels.type, orderTypes, draft.type)} {choice("tif", labels.tif, timeInForces, draft.tif)} {accounts && accounts.length > 0 && choice("account", labels.account, accounts, draft.account, "col-span-2")} </div> <div className="flex items-center gap-2" data-ticket-actions={allowed.length}> {allowed.length === 0 ? ( <p className="text-muted-foreground">{labels.nothingAllowed}</p> ) : ( allowed.map((action) => ( <Button key={action.id} type="button" // A destructive action says so in the destructive color on the outline button, which holds 4.5 to 1 at // rest and turns to the foreground under the pointer: the destructive variant puts that color on its // own tint, below it. variant={action === primary && !action.destructive ? "default" : "outline"} size="sm" className={cn("h-7 gap-2 aria-disabled:opacity-50", action.destructive && "text-destructive")} disabled={disabled} aria-disabled={heldByLimit(action) || undefined} data-action={action.id} data-destructive={action.destructive || undefined} data-confirming={confirming === action.id || undefined} onKeyDown={press.onKeyDown} onKeyUp={press.onKeyUp} onBlur={press.onBlur} onClick={(event) => { if (press.fresh(event)) run(action) }} > {confirming === action.id ? labels.anyway.replace("{action}", typeof action.label === "function" ? action.label(draft) : action.label) : typeof action.label === "function" ? action.label(draft) : action.label} {action === sendAction && sendKeys && ( <KbdGroup aria-hidden> {formatKeys(sendKeys)[0]?.map((cap) => ( <Kbd key={cap} className="text-xs">{cap}</Kbd> ))} </KbdGroup> )} </Button> )) )} </div> {limitsText && ( <p className={asking.length ? "text-stale" : "text-destructive"} data-ticket-limits={asking.length ? "confirm" : "block"}> {limitsText} </p> )} <span aria-live="polite" aria-atomic="true" className="sr-only" data-ticket-announcer> {heard && <span key={heard.revision}>{heard.text}</span>} </span> {/* The server's words, said as they change. The region is in the page from the first render, so the first status after a send is heard too, and a screen reader reads the words from it; the line a sighted trader reads is hidden from it, or each word would be read twice. */} <div role="status" className="sr-only" data-ticket-reply> {status && <p>{status}</p>} {message && <p>{message}</p>} </div> {(status || message) && ( <div aria-hidden className="flex flex-wrap items-baseline gap-x-2 border-t border-border pt-1.5"> {status && ( <span className="font-medium" data-ticket-status> {status} </span> )} {message && ( <span className="text-muted-foreground" data-ticket-message> {message} </span> )} </div> )} </div> </HotkeyScope> ) }components/ui/ quote-field.tsx import { cn } from "cn" import { useId, useState, type KeyboardEvent, type RefObject } from "react" import { Field, FieldError, FieldLabel } from "@/components/ui/field" import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group" import { QUOTE_BASIS_LABELS, formatQuote, numericFontClass, parseQuote, quoteBasisOf, stepQuote, type InstrumentConvention } from "@/lib/format" // An undefined word keeps its default, as DataGrid's labels do. const defined = <W extends object>(words: W | undefined): Partial<W> => Object.fromEntries(Object.entries(words ?? {}).filter(([, word]) => word !== undefined)) as Partial<W> // A field that types a quote the way the instrument quotes it: 99-16+ for a note on price, 4.253 // for a bill on discount, 12.6 for credit on a spread. It parses on every keystroke and hands the // number up, marks what is not a quote on blur and prints a good one back in the notation, and steps // by the instrument's own step with the arrows and the two buttons, ten steps with Shift held. With a // modifier held the arrows are not its business: they belong to whoever listens above it, which is // how a ticket's mod+up reaches its registry from inside this field. // // It is controlled, so the value is the parent's. When the parent moves it (a step from a key, a click // on a reference price, a reset) the text follows; when the text already reads as the new value, as it // does while someone is typing, the text is left alone under the cursor. export interface QuoteFieldLabels { /** The step buttons' names, `{label}` the field's label. */ stepUp: string stepDown: string } export const DEFAULT_QUOTE_FIELD_LABELS: QuoteFieldLabels = { stepUp: "{label} up one step", stepDown: "{label} down one step", } /** What the field says of text that is not a quote, unless `invalidText` says otherwise. */ export const DEFAULT_INVALID_QUOTE = "Not a quote in this instrument's notation." export interface QuoteFieldProps { /** How the quote prints, parses, and steps, and in what basis. */ convention: InstrumentConvention /** The quote, or null while the field is blank or not a quote. */ value: number | null onValueChange: (value: number | null) => void /** Where a step starts when the field is blank. Left out, the arrows do nothing on a blank field. */ stepFrom?: number | null /** The label, and the word in the button names. Default: the basis word, Price, Yield, Discount, or Spread. */ label?: string /** A problem your check found with the value, printed under the field and tied to the input. */ error?: string /** * False when you say `error` yourself: the field then shows it without an alert and without tying it to the input, * for an error whose words change on their own, such as a distance from a moving market, which would otherwise be * read again at every change. Default true. */ announceError?: boolean /** Said under the field when the text is not a quote. Default `DEFAULT_INVALID_QUOTE`. */ invalidText?: string /** Default: zero in the notation. */ placeholder?: string id?: string disabled?: boolean /** What Shift multiplies a step by. Default 10. */ shiftMultiplier?: number /** The side of a two-sided quote this field is, as `data-side` on the root. */ side?: "bid" | "ask" inputRef?: RefObject<HTMLInputElement | null> labels?: Partial<QuoteFieldLabels> className?: string } export function QuoteField({ convention, value: rawValue, onValueChange, stepFrom = null, label, error, announceError = true, invalidText, placeholder, id: idProp, disabled = false, shiftMultiplier = 10, side, inputRef, labels: labelsProp, className }: QuoteFieldProps) { const basis = quoteBasisOf(convention) const word = label ?? QUOTE_BASIS_LABELS[basis] const labels = { ...DEFAULT_QUOTE_FIELD_LABELS, ...defined(labelsProp) } const generated = useId() const id = idProp ?? generated // A feed warming up hands out NaN, and nothing non-finite prints or steps: such a value is // no value. NaN also never equals itself, so comparing it raw would re-run the follow below // every render until React stops the loop. const value = rawValue !== null && Number.isFinite(rawValue) ? rawValue : null const [text, setText] = useState(() => (value === null ? "" : formatQuote(value, convention))) const [invalid, setInvalid] = useState(false) // Derived state, set during render: the parent moved the value, so the text follows, unless the // text already reads as that value (someone is typing it) and would only be reformatted under them. const [known, setKnown] = useState(value) if (!Object.is(value, known)) { setKnown(value) if (parseQuote(text, convention) !== value) { setText(value === null ? "" : formatQuote(value, convention)) setInvalid(false) } } function change(next: string) { setText(next) setInvalid(false) onValueChange(parseQuote(next, convention)) } function blur() { if (!text.trim()) return setInvalid(false) const parsed = parseQuote(text, convention) if (parsed === null) return setInvalid(true) setText(formatQuote(parsed, convention)) } function step(steps: number) { const from = value ?? (stepFrom !== null && stepFrom !== undefined && Number.isFinite(stepFrom) ? stepFrom : null) if (from === null) return const next = stepQuote(from, convention, steps) setText(formatQuote(next, convention)) setInvalid(false) onValueChange(next) } // Bare arrows step. With a modifier held the key belongs to the registry above. function keyDown(event: KeyboardEvent<HTMLInputElement>) { if ((event.key !== "ArrowUp" && event.key !== "ArrowDown") || event.ctrlKey || event.metaKey || event.altKey) return event.preventDefault() step((event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? shiftMultiplier : 1)) } const problem = error ?? (invalid ? (invalidText ?? DEFAULT_INVALID_QUOTE) : undefined) // The field's own reading of the text is always said; your error is, unless you say it yourself. const announced = error === undefined || announceError return ( <div data-slot="tradecn-quote-field" data-basis={basis} data-side={side} className={cn("block lining-nums tabular-nums", className)}> <Field data-invalid={problem ? true : undefined}> <FieldLabel htmlFor={id}>{word}</FieldLabel> <InputGroup className="h-7"> <InputGroupInput ref={inputRef} id={id} value={text} placeholder={placeholder ?? formatQuote(0, convention)} // The text keyboard, not the decimal pad: a phone's pad has no minus or plus, which a negative quote and 99-16+ need. autoComplete="off" spellCheck={false} disabled={disabled} aria-invalid={problem ? true : undefined} aria-describedby={problem && announced ? `${id}-error` : undefined} data-numeric="" className={cn(numericFontClass(convention), "text-xs md:text-xs")} onChange={(event) => change(event.target.value)} onBlur={blur} onKeyDown={keyDown} /> <InputGroupAddon align="inline-end" className="gap-0"> <InputGroupButton type="button" size="icon-xs" aria-label={labels.stepDown.replace("{label}", word)} disabled={disabled} data-step="-1" onClick={() => step(-1)}> − </InputGroupButton> <InputGroupButton type="button" size="icon-xs" aria-label={labels.stepUp.replace("{label}", word)} disabled={disabled} data-step="1" onClick={() => step(1)}> + </InputGroupButton> </InputGroupAddon> </InputGroup> {problem && ( <FieldError id={`${id}-error`} {...(announced ? {} : { role: "none" })}> {problem} </FieldError> )} </Field> </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-hotkeys.tsx import { createContext, useContext, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentProps, type ReactNode } from "react" import { createHotkeyRegistry, type HandlerScope, type HotkeyBinding, type HotkeyEntry, type HotkeyHandler, type HotkeyRegistry, type HotkeyScopeName, type HotkeyTarget } from "@/lib/hotkeys" // React bindings for the hotkey registry. The registry does the work and knows nothing about React; // this file puts one in context, attaches its listener, marks scopes in the DOM, and keeps a // component's handler attached for as long as the component is mounted. const RegistryContext = createContext<HotkeyRegistry | null>(null) const ScopeContext = createContext<HandlerScope | null>(null) const EMPTY_DECLARED: ReadonlySet<string> = new Set() const EMPTY_DECLARED_MAP: ReadonlyMap<HotkeyRegistry, ReadonlySet<string>> = new Map() const DeclaredContext = createContext<ReadonlyMap<HotkeyRegistry, ReadonlySet<string>>>(EMPTY_DECLARED_MAP) export interface HotkeysProviderProps { /** Bring your own to declare bindings and load overrides before the first render. One is created otherwise. */ registry?: HotkeyRegistry /** Declared on mount, removed on unmount. Keep the array's identity stable: a module constant. */ bindings?: readonly HotkeyBinding[] /** Where to listen. `document` by default; a popout window's document for a second one. */ target?: HotkeyTarget children?: ReactNode } export function HotkeysProvider({ registry, bindings, target, children }: HotkeysProviderProps) { const [own] = useState(() => createHotkeyRegistry()) const value = registry ?? own // Visible during render, before any effect runs: a child that supplies a default for an id // the consumer declares here must see the declaration on its first pass, not an effect later. // Keyed by registry, so a nested provider with its own registry inherits nothing it doesn't own. const parentDeclared = useContext(DeclaredContext) const declared = useMemo(() => { if (!bindings?.length) return parentDeclared const next = new Map(parentDeclared) next.set(value, new Set([...(parentDeclared.get(value) ?? EMPTY_DECLARED), ...bindings.map((binding) => binding.id)])) return next }, [parentDeclared, bindings, value]) useEffect(() => value.attach(target), [value, target]) useEffect(() => { if (!bindings) return for (const binding of bindings) value.register(binding) return () => { for (const binding of bindings) value.unregister(binding.id) } }, [value, bindings]) return <RegistryContext.Provider value={value}><DeclaredContext.Provider value={declared}>{children}</DeclaredContext.Provider></RegistryContext.Provider> } /** The registry from the nearest provider. */ export function useHotkeys(): HotkeyRegistry { const registry = useContext(RegistryContext) if (!registry) throw new Error("useHotkeys needs a <HotkeysProvider> above it") return registry } /** The same, or null without a provider, for components that treat hotkeys as optional. */ export function useMaybeHotkeys(): HotkeyRegistry | null { return useContext(RegistryContext) } /** * Ids the surrounding providers declare through `bindings` for one registry — the nearest * provider's unless another is given — visible during render before any effect registers them. * Returns an empty set without a provider. */ export function useDeclaredHotkeyIds(registry?: HotkeyRegistry | null): ReadonlySet<string> { const map = useContext(DeclaredContext) const nearest = useContext(RegistryContext) const key = registry ?? nearest return (key && map.get(key)) || EMPTY_DECLARED } export interface UseHotkeyOptions { /** False detaches the handler; the binding stays declared and listed. */ enabled?: boolean } /** * Attach a handler to a declared binding while this component is mounted. Inside a `HotkeyScope` of * the binding's own scope, the handler answers only for events from that scope element, so two * instances of a panel each get their own keys. */ export function useHotkey(id: string, handler: HotkeyHandler, options: UseHotkeyOptions = {}): void { const registry = useHotkeys() const scope = useContext(ScopeContext) const enabled = options.enabled !== false const latest = useRef(handler) // A commit no key caused, such as a push that revokes an action, runs every layout effect before the passive ones, // and a key dispatched in between must reach the handler that commit rendered, or none. So the handler is swapped // before any layout effect runs, and the binding detaches in the commit that disables or unmounts it. useInsertionEffect(() => { latest.current = handler }) useLayoutEffect(() => { if (!enabled) return return registry.bind(id, (event) => latest.current(event), scope) }, [registry, id, enabled, scope]) } /** Every binding with its keys in force, for a help overlay or a settings screen. Re-renders on declare, remap, and reset, and when a handler attaches or detaches. */ export function useHotkeyList(): readonly HotkeyEntry[] { const registry = useHotkeys() return useSyncExternalStore(registry.subscribe, registry.list, registry.list) } /** The chord typed so far (`"g"`), or null. For a "g …" hint in a status bar. */ export function usePendingChord(): string | null { const registry = useHotkeys() return useSyncExternalStore(registry.subscribe, registry.pending, registry.pending) } /** * The nearest HotkeyScope's element getter, or null outside one. Hand it to a scoped * palette action's `within`, so the panel that answers is the one containing the * captured focus — the scope element itself included, since clicking a panel focuses it. */ export function useHotkeyScope(): (() => Element | null) | null { const scope = useContext(ScopeContext) return scope ? scope.element : null } export interface HotkeyScopeProps extends Omit<ComponentProps<"div">, "ref"> { scope: HotkeyScopeName } /** * Marks a subtree as a scope. Keys pressed with focus inside it reach this scope's bindings first. * It takes focus on click (`tabIndex={-1}`) so that clicking a panel is enough to aim the keyboard at it. */ export function HotkeyScope({ scope, children, ...props }: HotkeyScopeProps) { const [element, setElement] = useState<HTMLDivElement | null>(null) const value = useMemo<HandlerScope>(() => ({ scope, element: () => element }), [scope, element]) return ( <ScopeContext.Provider value={value}> <div tabIndex={-1} {...props} ref={setElement} data-hotkey-scope={scope}> {children} </div> </ScopeContext.Provider> ) }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/hotkeys.ts // A hotkey registry: bindings declared as data, one keydown listener, scopes read from the DOM. // // A binding is an id, keys, a scope, and a description. It is declared once, so the same list feeds // the dispatcher, a command palette's shortcut column, and a help overlay, and a remap changes all // three. Handlers attach separately and come and go with the components that own them, which is how // two instances of one panel share a binding: the handler whose scope element holds the event wins. // // Scope is where the key came from. The chain runs from the event target outward through every // `[data-hotkey-scope]` ancestor, then `editing`, then `global`, and the innermost match wins. // Typing is protected: inside an input only `editing` bindings run. Menus and listboxes own every // key while they have focus. A dialog is a wall while focus is inside it: only scopes declared // inside it are active. Keys typed in the gap before a dialog takes focus land on the page, // where single-key bindings still run — the command palette's early capture shows the fix. export type Platform = "mac" | "other" /** `global` runs anywhere outside inputs. `editing` runs anywhere, inputs included. `panel:<id>` runs inside `[data-hotkey-scope="panel:<id>"]`. */ export type HotkeyScopeName = "global" | "editing" | `panel:${string}` export type HotkeyHandler = (event: KeyboardEvent) => void export interface HotkeyBinding { id: string /** `"mod+k"`, `"shift+/"`, `"?"`, or a chord of steps separated by spaces, `"g h"`. `mod` is ⌘ on a Mac and Ctrl elsewhere. `""` is unbound. */ keys: string scope: HotkeyScopeName description: string /** A heading for overlays and palette rows. */ group?: string /** Checked at keydown. False skips this binding and lets an outer scope answer. */ when?: () => boolean /** Fire again while the key is held. Off by default: a held key should not cancel thirty orders. */ repeat?: boolean /** On by default. */ preventDefault?: boolean } export interface HotkeyEntry extends HotkeyBinding { /** The keys in force: the override when there is a readable one, else the binding's own. */ keys: string defaultKeys: string /** The keys exactly as the binding declared them, before normalization: `"mod+k"` stays `"mod+k"`. */ declaredKeys: string /** True while an override is stored for the id, one that fails to parse or matches the defaults included. */ remapped: boolean } export interface HotkeyConflict { /** `duplicate`: same keys where both can fire. `prefix`: one is the start of the other's chord. `shadow`: a panel binding hides a global or editing one while focus is in that panel. */ kind: "duplicate" | "prefix" | "shadow" /** The contested keys, normalized; for a prefix, the shorter of the two. */ keys: string ids: [string, string] } /** Binding id to keys. What a consumer persists. */ export type HotkeyOverrides = Record<string, string> export type HotkeyTarget = Pick<EventTarget, "addEventListener" | "removeEventListener"> /** Limits a handler to events from inside one scope element, when the binding lives in that scope. */ export interface HandlerScope { scope: string element: () => Element | null } export interface HotkeyRegistryOptions { /** How long a chord waits for its next key. Default 1000. */ chordTimeoutMs?: number platform?: Platform } export interface HotkeyRegistry { readonly platform: Platform /** Declare a binding, or replace the one with the same id. Returns the conflicts it takes part in. */ register(binding: HotkeyBinding, handler?: HotkeyHandler): HotkeyConflict[] unregister(id: string): void /** * Declare a component's built-in binding. It lists and dispatches like a registration until a * consumer registers the id — the registration shadows it, and unregistering brings it back — * and it stands while any declarer holds it. Returns the release. */ declareDefault(binding: HotkeyBinding): () => void /** Attach a handler to a declared (or not yet declared) binding. Returns the detach. A fenced handler can settle a conflict, so attaching and detaching wake `subscribe` as a declaration does. */ bind(id: string, handler: HotkeyHandler, within?: HandlerScope | null): () => void /** Override a binding's keys; `""` unbinds it. Notifies `onChange`. Returns the conflicts the new keys take part in. */ remap(id: string, keys: string): HotkeyConflict[] /** Drop one override, or all of them. Notifies `onChange`. */ reset(id?: string): void /** Replace every override, from the consumer's persistence. Ids not declared yet apply when they are. Does not notify `onChange`. */ load(overrides: HotkeyOverrides): void overrides(): HotkeyOverrides /** Overrides changed through `remap` or `reset`: the moment to persist. */ onChange(cb: (overrides: HotkeyOverrides) => void): () => void /** Every binding with its keys in force. Stable array reference until a binding, an override, or a handler changes. */ list(): readonly HotkeyEntry[] /** Every conflict the declarations leave standing once their handlers are read, against the document as it is now. A fresh array each call. */ conflicts(): HotkeyConflict[] /** Wakes on any change to `list()` or `pending()`, and when a handler comes or goes. */ subscribe(cb: () => void): () => void /** Listen for keydown on a target, `document` by default. Attaching the same target twice adds one listener. */ attach(target?: HotkeyTarget): () => void /** The chord typed so far, normalized (`"g"`), or null. */ pending(): string | null /** Run one event through the dispatcher. `attach` calls this; it is public for events forwarded from elsewhere. True when the event was consumed. */ handle(event: KeyboardEvent): boolean } interface Step { ctrl: boolean alt: boolean shift: boolean meta: boolean key: string } const KEY_ALIASES: Record<string, string> = { " ": "space", spacebar: "space", esc: "escape", return: "enter", del: "delete", ins: "insert", arrowup: "up", arrowdown: "down", arrowleft: "left", arrowright: "right", pgup: "pageup", pgdn: "pagedown", plus: "+", } const CODE_KEYS: Record<string, string> = { Slash: "/", Backslash: "\\", Period: ".", Comma: ",", Semicolon: ";", Quote: "'", BracketLeft: "[", BracketRight: "]", Minus: "-", Equal: "=", Backquote: "`", } const IGNORED_KEYS = new Set(["Shift", "Control", "Alt", "Meta", "AltGraph", "CapsLock", "OS", "Dead", "Process", "Unidentified"]) const NON_TEXT_INPUTS = new Set(["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"]) const SCOPE_ATTR = "data-hotkey-scope" const WALLS = '[role="dialog"], [role="alertdialog"]' const MAC_LABELS = { ctrl: "⌃", alt: "⌥", shift: "⇧", meta: "⌘" } const OTHER_LABELS = { ctrl: "Ctrl", alt: "Alt", shift: "Shift", meta: "Win" } const KEY_LABELS: Record<string, string> = { enter: "↵", escape: "Esc", space: "Space", tab: "Tab", backspace: "⌫", delete: "Del", insert: "Ins", up: "↑", down: "↓", left: "←", right: "→", pageup: "PgUp", pagedown: "PgDn", home: "Home", end: "End", } export function detectPlatform(): Platform { if (typeof navigator === "undefined") return "other" const nav = navigator as Navigator & { userAgentData?: { platform?: string } } return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform ?? nav.platform ?? "") ? "mac" : "other" } function parseStep(part: string, platform: Platform): Step { const step: Step = { ctrl: false, alt: false, shift: false, meta: false, key: "" } // The canonical form writes the + key as itself, so "ctrl++" and a bare "+" must read back. // Only the canonical shape qualifies: a malformed "ctrl+++" still fails instead of quietly // becoming a live binding from a corrupted stored override. let rest = part let plusKey = false if (rest === "+") { plusKey = true rest = "" } else if (rest.endsWith("++") && rest.length > 2 && rest.slice(0, -2).split("+").every(Boolean)) { plusKey = true rest = rest.slice(0, -2) } for (const token of rest ? rest.toLowerCase().split("+") : []) { if (token === "mod") step[platform === "mac" ? "meta" : "ctrl"] = true else if (token === "ctrl" || token === "control") step.ctrl = true else if (token === "alt" || token === "option" || token === "opt") step.alt = true else if (token === "shift") step.shift = true else if (token === "meta" || token === "cmd" || token === "command" || token === "win" || token === "super") step.meta = true else if (step.key) throw new Error(`hotkeys: "${part}" names two keys; separate the steps of a chord with a space`) else step.key = KEY_ALIASES[token] ?? token } if (plusKey) { if (step.key) throw new Error(`hotkeys: "${part}" names two keys; separate the steps of a chord with a space`) step.key = "+" } if (!step.key) throw new Error(`hotkeys: "${part}" has no key (write the + key as "plus")`) return step } function parseKeys(keys: string, platform: Platform): Step[] { return keys.trim().split(/\s+/).filter(Boolean).map((part) => parseStep(part, platform)) } function stepToString(step: Step): string { return [step.ctrl && "ctrl", step.alt && "alt", step.shift && "shift", step.meta && "meta", step.key].filter(Boolean).join("+") } /** Canonical form for comparing and storing: `mod` resolved, aliases folded, modifiers in one order. Throws on keys that do not parse. */ export function normalizeKeys(keys: string, platform: Platform = detectPlatform()): string { return parseKeys(keys, platform).map(stepToString).join(" ") } /** Key caps for shadcn's `Kbd`, one array per chord step: `"mod+k"` is `[["⌘", "K"]]` on a Mac and `[["Ctrl", "K"]]` elsewhere. */ export function formatKeys(keys: string, platform: Platform = detectPlatform()): string[][] { const labels = platform === "mac" ? MAC_LABELS : OTHER_LABELS return parseKeys(keys, platform).map((step) => { const caps: string[] = [] if (step.ctrl) caps.push(labels.ctrl) if (step.alt) caps.push(labels.alt) if (step.shift) caps.push(labels.shift) if (step.meta) caps.push(labels.meta) caps.push(KEY_LABELS[step.key] ?? (/^(?:[a-z]|f\d{1,2})$/.test(step.key) ? step.key.toUpperCase() : step.key)) return caps }) } const isSymbol = (key: string) => key.length === 1 && !/[a-z0-9]/.test(key) function codeKey(code: string): string | null { if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase() if (/^Digit\d$/.test(code)) return code.slice(5) return CODE_KEYS[code] ?? null } function matchesStep(step: Step, event: KeyboardEvent): boolean { if (event.ctrlKey !== step.ctrl || event.altKey !== step.alt || event.metaKey !== step.meta) return false const pressed = event.key.toLowerCase() const key = KEY_ALIASES[pressed] ?? pressed // "?" already says shift on the layouts that need it and nothing on the ones that do not. if (key === step.key) return isSymbol(key) && !step.shift ? true : event.shiftKey === step.shift // A modifier changed the character: shift+1 arrives as "!", option+k as "˚". Read the physical key, // unless the modifier is AltGr, which is how half of Europe types "@". if ((event.shiftKey || event.altKey) && isSymbol(key) && !event.getModifierState?.("AltGraph")) return event.shiftKey === step.shift && codeKey(event.code) === step.key return false } /** True for a single-step `keys` this event satisfies. For a component that must answer a binding itself, behind a wall the dispatcher will not cross. */ export function matchesKeys(event: KeyboardEvent, keys: string, platform: Platform = detectPlatform()): boolean { const steps = parseKeys(keys, platform) return steps.length === 1 && matchesStep(steps[0]!, event) } /** The step a keydown spells, in the form `remap` takes, or null for a bare modifier. For a "press the new shortcut" field. */ export function keysFromEvent(event: KeyboardEvent): string | null { if (IGNORED_KEYS.has(event.key)) return null const pressed = event.key.toLowerCase() let key = KEY_ALIASES[pressed] ?? pressed let shift = event.shiftKey if (isSymbol(key)) { // option+k arrives as "˚": store the physical key. A shifted symbol such as "?" already carries its shift. const physical = event.altKey && !event.getModifierState?.("AltGraph") ? codeKey(event.code) : null if (physical) key = physical else shift = false } return stepToString({ ctrl: event.ctrlKey, alt: event.altKey, shift, meta: event.metaKey, key }) } // Duck-typed so a target from a popout window's realm still counts. function asElement(target: EventTarget | null): Element | null { const el = target as Element | null return el && typeof el.closest === "function" ? el : null } /** Text inputs, textareas, selects, and anything contenteditable. A checkbox is not typing. */ export function isEditableTarget(target: EventTarget | null): boolean { const el = asElement(target) if (!el) return false if (el.tagName === "INPUT") return !NON_TEXT_INPUTS.has((el.getAttribute("type") ?? "text").toLowerCase()) if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true return el.closest('[contenteditable]:not([contenteditable="false"]), [role="textbox"]') !== null } /** Inside a menu, menubar, or listbox, which own arrows, letters, Enter, and Escape while focused. */ export function isMenuTarget(target: EventTarget | null): boolean { return asElement(target)?.closest('[role="menu"], [role="menubar"], [role="listbox"]') != null } /** The scopes active for an event target, innermost first. Inside a dialog only the scopes declared within it count. */ export function scopeChain(target: EventTarget | null): string[] { const chain: string[] = [] let el = asElement(target) const wall = el?.closest(WALLS) ?? null while (el) { const hit = el.closest(`[${SCOPE_ATTR}]`) if (!hit || (wall && !wall.contains(hit))) break const name = hit.getAttribute(SCOPE_ATTR) if (name && !chain.includes(name)) chain.push(name) el = hit.parentElement } if (!wall) for (const name of ["editing", "global"]) if (!chain.includes(name)) chain.push(name) return chain } interface Rec { binding: HotkeyBinding steps: Step[] sequence: string[] handler?: HotkeyHandler } interface Bound { handler: HotkeyHandler within: HandlerScope | null } const isAmbient = (scope: string) => scope === "global" || scope === "editing" function sameBinding(a: HotkeyBinding, b: HotkeyBinding): boolean { return a.keys === b.keys && a.scope === b.scope && a.description === b.description && a.group === b.group && a.when === b.when && a.repeat === b.repeat && a.preventDefault === b.preventDefault } function conflictBetween(a: Rec, b: Rec): HotkeyConflict | null { if (!a.sequence.length || !b.sequence.length) return null const [short, long] = a.sequence.length <= b.sequence.length ? [a, b] : [b, a] if (!short.sequence.every((step, i) => long.sequence[i] === step)) return null const sa = a.binding.scope const sb = b.binding.scope const together = sa === sb || (isAmbient(sa) && isAmbient(sb)) if (!together && !isAmbient(sa) && !isAmbient(sb)) return null const kind = !together ? "shadow" : short.sequence.length === long.sequence.length ? "duplicate" : "prefix" return { kind, keys: short.sequence.join(" "), ids: [a.binding.id, b.binding.id] } } export function createHotkeyRegistry(options: HotkeyRegistryOptions = {}): HotkeyRegistry { const platform = options.platform ?? detectPlatform() // A timer runs at once for 0, NaN, Infinity, or anything past 2^31 - 1 ms, and a chord could never finish: those read as the default. const given = options.chordTimeoutMs const chordTimeoutMs = typeof given === "number" && given >= 1 && given <= 2_147_483_647 ? given : 1000 const recs = new Map<string, Rec>() // Component defaults: each declaration is held individually beside the live records, installed // only while no consumer registration shadows the id — the earliest held declaration is the // one in force. Tokens, not the bindings, carry identity: two declarers may pass one shared // module constant. `defaultIds` marks the ids whose live record is the default. const defaults = new Map<string, { binding: HotkeyBinding }[]>() const defaultIds = new Set<string>() const overrides = new Map<string, string>() const bound = new Map<string, Bound[]>() const listeners = new Set<() => void>() const changeListeners = new Set<(overrides: HotkeyOverrides) => void>() const attached = new Map<HotkeyTarget, { count: number; listener: (event: Event) => void }>() let snapshot: readonly HotkeyEntry[] | null = null // Chord state: how many steps of which bindings have matched so far. let progress = new Map<string, number>() let pendingSteps: string[] = [] let timer: ReturnType<typeof setTimeout> | null = null function emit() { snapshot = null for (const cb of listeners) cb() } function notifyChange() { const current = Object.fromEntries(overrides) for (const cb of changeListeners) cb(current) } function clearPending(): boolean { if (timer) clearTimeout(timer) timer = null if (!pendingSteps.length) return false progress = new Map() pendingSteps = [] return true } function build(binding: HotkeyBinding, handler?: HotkeyHandler): Rec { const declaredSteps = parseKeys(binding.keys, platform) let steps = declaredSteps const override = overrides.get(binding.id) if (override !== undefined) { // A stale override from storage must not take the app down: fall back to the default. try { steps = parseKeys(override, platform) } catch { steps = declaredSteps } } return { binding, steps, sequence: steps.map(stepToString), handler } } function rebuild(id?: string) { for (const [key, rec] of recs) if (id === undefined || key === id) recs.set(key, build(rec.binding, rec.handler)) clearPending() emit() } // The elements a binding's handlers are fenced to, in the binding's own scope; null when any handler runs // unfenced, empty when nothing is bound. Mirrors what handlerFor lets through. function fencesOf(rec: Rec): (Element | null)[] | null { if (rec.handler) return null const out: (Element | null)[] = [] for (const { within } of bound.get(rec.binding.id) ?? []) { if (!within || within.scope !== rec.binding.scope) return null out.push(within.element()) } return out } // Two bindings whose every handler is fenced to boxes that do not hold one another cannot meet on one key, // which is how an order ticket and an RFQ ticket on one desk share mod+enter. Declarations with no handler, // or a handler that runs anywhere, are compared as declared. function apart(a: Rec, b: Rec): boolean { const fa = fencesOf(a) const fb = fencesOf(b) if (!fa?.length || !fb?.length) return false for (const ea of fa) for (const eb of fb) if (ea && eb && (ea === eb || ea.contains(eb) || eb.contains(ea))) return false return true } function conflictsFor(id: string): HotkeyConflict[] { const rec = recs.get(id) if (!rec) return [] const out: HotkeyConflict[] = [] for (const other of recs.values()) { const c = other === rec ? null : conflictBetween(rec, other) if (c && !apart(rec, other)) out.push(c) } return out } function handlerFor(rec: Rec, el: Element | null): HotkeyHandler | null { const list = bound.get(rec.binding.id) ?? [] for (let i = list.length - 1; i >= 0; i--) { const { handler, within } = list[i]! if (!within || within.scope !== rec.binding.scope) return handler const scopeEl = within.element() if (el && scopeEl?.contains(el)) return handler } return rec.handler ?? null } function attempt(event: KeyboardEvent, chain: string[], editable: boolean, el: Element | null): boolean { let complete: { rank: number; rec: Rec; handler: HotkeyHandler } | null = null let advanceRank = Infinity let advanceStep = "" const advanced = new Map<string, number>() for (const rec of recs.values()) { const at = pendingSteps.length ? progress.get(rec.binding.id) : 0 const step = at === undefined ? undefined : rec.steps[at] if (at === undefined || !step || !matchesStep(step, event)) continue const rank = chain.indexOf(rec.binding.scope) if (rank < 0 || (editable && rec.binding.scope !== "editing")) continue if (event.repeat && !rec.binding.repeat) continue if (rec.binding.when && !rec.binding.when()) continue const handler = handlerFor(rec, el) if (!handler) continue if (at === rec.steps.length - 1) { if (!complete || rank < complete.rank) complete = { rank, rec, handler } } else { advanced.set(rec.binding.id, at + 1) if (rank < advanceRank) { advanceRank = rank advanceStep = stepToString(step) } } } // The innermost scope decides. A chord that started closer to the target outranks a finished binding further out. if (complete && complete.rank <= advanceRank) { if (clearPending()) emit() if (complete.rec.binding.preventDefault !== false) event.preventDefault() complete.handler(event) return true } if (advanced.size) { if (timer) clearTimeout(timer) progress = advanced pendingSteps = [...pendingSteps, advanceStep] timer = setTimeout(() => { timer = null if (clearPending()) emit() }, chordTimeoutMs) event.preventDefault() emit() return true } return false } function handle(event: KeyboardEvent): boolean { // 229 is a key an input method takes, which Safari sends before isComposing says so. if (event.defaultPrevented || event.isComposing || event.keyCode === 229 || IGNORED_KEYS.has(event.key)) return false const target = event.target if (isMenuTarget(target)) { if (clearPending()) emit() return false } if (pendingSteps.length && event.key === "Escape") { clearPending() event.preventDefault() emit() return true } const chain = scopeChain(target) const editable = isEditableTarget(target) const el = asElement(target) if (attempt(event, chain, editable, el)) return true // Not a continuation of the chord in flight: drop the chord and read the key on its own. if (clearPending()) { emit() return attempt(event, chain, editable, el) } return false } return { platform, register(binding, handler) { if (!binding.id) throw new Error("hotkeys: a binding needs an id") const previous = recs.get(binding.id) // Declaring the same thing again (a remount, a hot reload) is not a change — though a // consumer registering over a default claims the id either way. if (previous && !handler && sameBinding(previous.binding, binding)) { defaultIds.delete(binding.id) return conflictsFor(binding.id) } // Built before any marker moves: a throw on malformed keys must leave ownership untouched. const next = build(binding, handler ?? previous?.handler) defaultIds.delete(binding.id) recs.set(binding.id, next) clearPending() emit() return conflictsFor(binding.id) }, unregister(id) { // A default is not removable here — only shadowed or released; `remap(id, "")` unbinds // its keys. Removing-and-reinstalling would wake subscribers with an unchanged list, and // a subscriber that unregisters whatever it sees listed would recurse forever. if (!recs.has(id) || defaultIds.has(id)) return // The earliest held default resurfaces when the consumer registration leaves — replaced // in place, since the record's position carries conflict resolution and list order. const held = defaults.get(id) if (held?.[0]) { recs.set(id, build(held[0].binding)) defaultIds.add(id) } else { recs.delete(id) } clearPending() emit() }, declareDefault(binding) { if (!binding.id) throw new Error("hotkeys: a binding needs an id") // Built before the declaration is held — shadowed or not — so malformed keys throw here // and never wait inside the held list to break a later resurface. const rec = build(binding) const installing = !recs.has(binding.id) const token = { binding } const held = defaults.get(binding.id) if (held) held.push(token) else defaults.set(binding.id, [token]) if (installing) { recs.set(binding.id, rec) defaultIds.add(binding.id) clearPending() emit() } return () => { const current = defaults.get(binding.id) if (!current) return const at = current.indexOf(token) if (at < 0) return current.splice(at, 1) if (current.length === 0) defaults.delete(binding.id) if (!defaultIds.has(binding.id)) return const next = current[0] if (next === undefined) { defaultIds.delete(binding.id) recs.delete(binding.id) clearPending() emit() } else if (!sameBinding(recs.get(binding.id)!.binding, next.binding)) { // A remaining declarer's own binding takes over from the released one's. recs.set(binding.id, build(next.binding)) clearPending() emit() } } }, bind(id, handler, within = null) { const entry: Bound = { handler, within } const list = bound.get(id) if (list) list.push(entry) else bound.set(id, [entry]) // A fenced handler can settle a conflict, so a settings screen wants to hear about it. emit() return () => { const current = bound.get(id) if (!current) return const i = current.indexOf(entry) if (i < 0) return current.splice(i, 1) if (!current.length) bound.delete(id) emit() } }, remap(id, keys) { const rec = recs.get(id) if (!rec) throw new Error(`hotkeys: cannot remap "${id}", no such binding`) const next = normalizeKeys(keys, platform) if (next === normalizeKeys(rec.binding.keys, platform)) overrides.delete(id) else overrides.set(id, next) rebuild(id) notifyChange() return conflictsFor(id) }, reset(id) { if (id === undefined) overrides.clear() else overrides.delete(id) rebuild(id) notifyChange() }, load(next) { overrides.clear() for (const [id, keys] of Object.entries(next)) overrides.set(id, keys) rebuild() }, overrides: () => Object.fromEntries(overrides), onChange(cb) { changeListeners.add(cb) return () => changeListeners.delete(cb) }, list() { return (snapshot ??= [...recs.values()].map((rec) => { const keys = rec.sequence.join(" ") const defaultKeys = normalizeKeys(rec.binding.keys, platform) // Remapped means an override is stored, not that the resolved keys differ: a corrupt // override falls back to the default keys, and Reset must still be able to clear it. return { ...rec.binding, keys, defaultKeys, declaredKeys: rec.binding.keys, remapped: overrides.has(rec.binding.id) } })) }, conflicts() { const all = [...recs.values()] const out: HotkeyConflict[] = [] for (let i = 0; i < all.length; i++) { for (let j = i + 1; j < all.length; j++) { const c = conflictBetween(all[i]!, all[j]!) if (c && !apart(all[i]!, all[j]!)) out.push(c) } } return out }, subscribe(cb) { listeners.add(cb) return () => listeners.delete(cb) }, attach(target) { const t = target ?? (typeof document === "undefined" ? null : document) if (!t) return () => {} let entry = attached.get(t) if (!entry) { entry = { count: 0, listener: (event) => void handle(event as KeyboardEvent) } t.addEventListener("keydown", entry.listener) attached.set(t, entry) } entry.count++ let done = false return () => { if (done) return done = true entry.count-- if (entry.count > 0) return t.removeEventListener("keydown", entry.listener) attached.delete(t) } }, pending: () => (pendingSteps.length ? pendingSteps.join(" ") : null), handle, } }lib/limits.ts import { formatQuantity, formatTicks, quoteBasisOf, ticksBetween, type InstrumentConvention } from "@/lib/format" // An undefined word keeps its default, as DataGrid's labels do. const defined = <W extends object>(words: W | undefined): Partial<W> => Object.fromEntries(Object.entries(words ?? {}).filter(([, word]) => word !== undefined)) as Partial<W> // Fat-finger checks are universal: a size above a line, a level too far from the market, a side the // book may not take. The tickets already stop a missing level and a crossed market; this gives them a // limits table, as data, and returns problems in the shape they already print. A `block` stops the // action; a `confirm` turns it into a two-step, the blotter's ask-again. Nothing here decides a // limit: the numbers are the desk's, the words are the consumer's, and the check runs where the // click lands, against the draft as it is then. export type ProblemLevel = "block" | "confirm" export interface Problem { /** The field it is about: "quantity", "price", "bid", "ask", or "side". */ field: string level: ProblemLevel /** A sentence, in the consumer's words. */ message: string /** Which limit raised it: "maxQuantity", "minQuantity", "maxDistance", "sides", or a custom rule's own name. */ rule: string } /** What the check reads of a draft: a ticket's side, quantity, and price, or a quote's bid and ask. */ export interface LimitsDraft { side?: "buy" | "sell" quantity?: number | null price?: number | null bid?: number | null ask?: number | null } export interface LimitsMarket { bid?: number | null ask?: number | null last?: number | null mid?: number | null } export interface LimitsLabels { /** `{n}` the quantity, `{max}` the limit. */ quantityAbove: string quantityAboveConfirm: string /** `{n}` the quantity, `{min}` the limit. */ quantityBelow: string quantityBelowConfirm: string /** `{field}` the level's field, `{distance}` how far, `{max}` the limit, both with their unit. */ tooFar: string tooFarConfirm: string /** `{side}` the side. */ sideNotAllowed: string buy: string sell: string /** The level's field in `{field}`: a ticket's price, a two-sided quote's bid and ask. */ price: string bid: string ask: string /** The unit after a distance in ticks: a word, or a function of the count, for a plural a word can't hold. */ ticks: string | ((n: number) => string) /** The unit after a distance in basis points: a word, or a function of the count. */ bps: string | ((n: number) => string) } export const DEFAULT_LIMITS_LABELS: LimitsLabels = { quantityAbove: "{n} is above the size limit of {max}.", quantityAboveConfirm: "{n} is above {max}. Send it anyway?", quantityBelow: "{n} is below the minimum of {min}.", quantityBelowConfirm: "{n} is below {min}. Send it anyway?", tooFar: "The {field} is {distance} from the market; the limit is {max}.", tooFarConfirm: "The {field} is {distance} from the market, past {max}. Send it anyway?", sideNotAllowed: "The book does not take a {side}.", buy: "buy", sell: "sell", price: "price", bid: "bid", ask: "ask", ticks: (n) => (n === 1 ? "tick" : "ticks"), bps: "bp", } export interface LimitsContext { market?: LimitsMarket convention?: InstrumentConvention labels?: Partial<LimitsLabels> } /** A line, or two: a size to ask again past, and a size to stop at. One number is a stop. */ export type Threshold = number | { confirm?: number; block?: number } export interface Limits { maxQuantity?: Threshold minQuantity?: Threshold /** How far a level may sit from the market's same side (the mid when there is none), in ticks for a price basis or basis points for the rest. */ maxDistance?: ({ ticks: number } | { bps: number }) & { level?: ProblemLevel } /** The sides the book may take. A ticket's side, or, for a quote, a bid is a buy and an offer a sell. */ sides?: readonly ("buy" | "sell")[] /** Your own rules, in the same shape. */ custom?: (draft: LimitsDraft, context: LimitsContext) => Problem[] } function fill(template: string, values: Record<string, string | number>): string { return template.replace(/\{(\w+)\}/g, (_, name: string) => String(values[name] ?? "")) } function thresholds(t: Threshold | undefined): { confirm?: number; block?: number } { if (t === undefined) return {} return typeof t === "number" ? { block: t } : t } /** The market's side a level is measured against: the same side as the level, else the mid, else the last. */ export function marketSideFor(field: "price" | "bid" | "ask", side: "buy" | "sell" | undefined, market: LimitsMarket | undefined): number | null { if (!market) return null const pick = (...values: (number | null | undefined)[]) => values.find((v): v is number => typeof v === "number" && Number.isFinite(v)) ?? null const mid = pick(market.mid) ?? (typeof market.bid === "number" && typeof market.ask === "number" ? (market.bid + market.ask) / 2 : null) if (field === "bid") return pick(market.bid, mid, market.last) if (field === "ask") return pick(market.ask, mid, market.last) // A price on a ticket: a buyer measures against the offer, a seller against the bid. if (side === "buy") return pick(market.ask, mid, market.last) if (side === "sell") return pick(market.bid, mid, market.last) return pick(mid, market.last) } /** How far a level is from a market level in the convention's unit: ticks for a price basis, basis points for the rest. */ export function distanceFromMarket(level: number, market: number, convention: InstrumentConvention | undefined): { value: number; unit: "ticks" | "bps" } { if (!convention || quoteBasisOf(convention) === "price") return { value: Math.abs(ticksBetween(level, market, convention?.tick ?? 1)), unit: "ticks" } // A yield or a discount is quoted in percent, so a hundredth of a point is a basis point; a spread is quoted in basis points already. const perPoint = quoteBasisOf(convention) === "spread" ? 1 : 100 // To a thousandth of a basis point, so 4.30 against 4.25 is 5 and not 4.999999999999982. return { value: Math.round(Math.abs(level - market) * perPoint * 1000) / 1000, unit: "bps" } } /** * What the limits say about a draft, in the tickets' shape, each with its level and a sentence. Empty * when nothing does. Quantity against the lines, each level against the market in the instrument's * unit, the side against the sides the book takes, then your own rules. */ export function checkLimits(draft: LimitsDraft, limits: Limits | undefined, context: LimitsContext = {}): Problem[] { if (!limits) return [] const labels = { ...DEFAULT_LIMITS_LABELS, ...defined(context.labels) } const problems: Problem[] = [] const quantity = draft.quantity // A fractional size prints exactly, or the prompt would round the trader's own number and // the limit into a lie such as "10 is above the size limit of 10". const size = (v: number) => (Number.isInteger(v) ? formatQuantity(v) : String(v).replace(/-/g, "−")) if (typeof quantity === "number" && Number.isFinite(quantity)) { const max = thresholds(limits.maxQuantity) if (max.block !== undefined && quantity > max.block) problems.push({ field: "quantity", level: "block", rule: "maxQuantity", message: fill(labels.quantityAbove, { n: size(quantity), max: size(max.block) }) }) else if (max.confirm !== undefined && quantity > max.confirm) problems.push({ field: "quantity", level: "confirm", rule: "maxQuantity", message: fill(labels.quantityAboveConfirm, { n: size(quantity), max: size(max.confirm) }) }) const min = thresholds(limits.minQuantity) if (min.block !== undefined && quantity < min.block) problems.push({ field: "quantity", level: "block", rule: "minQuantity", message: fill(labels.quantityBelow, { n: size(quantity), min: size(min.block) }) }) else if (min.confirm !== undefined && quantity < min.confirm) problems.push({ field: "quantity", level: "confirm", rule: "minQuantity", message: fill(labels.quantityBelowConfirm, { n: size(quantity), min: size(min.confirm) }) }) } if (limits.maxDistance) { const rule = limits.maxDistance const level = rule.level ?? "block" const max = "ticks" in rule ? { value: rule.ticks, unit: "ticks" as const } : { value: rule.bps, unit: "bps" as const } for (const field of ["price", "bid", "ask"] as const) { const value = draft[field] if (typeof value !== "number" || !Number.isFinite(value)) continue const against = marketSideFor(field, draft.side, context.market) if (against === null) continue const distance = distanceFromMarket(value, against, context.convention) if (distance.unit !== max.unit || !(distance.value > max.value)) continue const unit = (n: number) => { const word = labels[max.unit] return typeof word === "function" ? word(n) : word } const words = { field: labels[field], distance: `${formatTicks(distance.value, { signed: false })} ${unit(distance.value)}`, max: `${formatTicks(max.value, { signed: false })} ${unit(max.value)}` } problems.push({ field, level, rule: "maxDistance", message: fill(level === "block" ? labels.tooFar : labels.tooFarConfirm, words) }) } } if (limits.sides) { const taken = new Set(limits.sides) const refuse = (side: "buy" | "sell", field: string) => problems.push({ field, level: "block", rule: "sides", message: fill(labels.sideNotAllowed, { side: labels[side] }) }) if (draft.side && !taken.has(draft.side)) refuse(draft.side, "side") if (typeof draft.bid === "number" && !taken.has("buy")) refuse("buy", "bid") if (typeof draft.ask === "number" && !taken.has("sell")) refuse("sell", "ask") } if (limits.custom) problems.push(...limits.custom(draft, context)) return problems } /** The problems that stop the action. */ export function blocks(problems: readonly Problem[]): Problem[] { return problems.filter((p) => p.level === "block") } /** The problems that ask again. */ export function confirms(problems: readonly Problem[]): Problem[] { return problems.filter((p) => p.level === "confirm") } /** The first message per field, for printing under the fields the way the tickets do. */ export function problemsByField(problems: readonly Problem[]): Record<string, string> { const messages = new Map<string, string>() for (const p of problems) if (!messages.has(p.field)) messages.set(p.field, p.message) return Object.fromEntries(messages) } -
Add the tokens to your stylesheet:
:root { --down: oklch(0.46 0.19 40); --down-soft: oklch(0.46 0.19 40 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.55 0 0 / 14%); --stale: oklch(0.52 0.13 75); --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%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.7 0 0 / 14%); --stale: oklch(0.8 0.15 80); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --up: oklch(0.74 0.15 165); --up-soft: oklch(0.74 0.15 165 / 18%); } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { useState } from "react"
import { Ticket, describeDraft, type TicketInstrument } from "@/components/ticket"
const ZN: TicketInstrument = { symbol: "ZN", convention: { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }, quantityStep: 1 }
function OrderEntry() {
const [submitted, setSubmitted] = useState("No draft submitted.")
return (
<div className="w-80 max-w-full space-y-2 text-xs lining-nums tabular-nums">
<Ticket
instrument={ZN}
reference={{ bid: 99.484375, ask: 99.5 }}
defaultDraft={{ quantity: 5, price: 99.5 }}
actions={[{ id: "send", label: (draft) => draft.side === "buy" ? "Submit buy" : "Submit sell", run: (draft) => setSubmitted(describeDraft(draft, ZN)) }]}
allowedActions={["send"]}
/>
<p role="status" className="text-muted-foreground">{submitted}</p>
</div>
)
}
Enter quantity and price, choose a side, then submit the draft. This local handler displays the checked draft; it sends no order. Clear the quantity or price to see validation stop a priced submission. Choose Market to submit with a null price instead.
The instrument supplies ZN's fractional notation and tick size. Click Bid or Ask to use that reference; type 99-16+ to enter a half tick. Field arrows step by one tick or quantity unit, and Shift takes ten steps. A HotkeysProvider is only needed for the registry shortcuts in the next example.
Shortcuts and draft changes
The provider enables Ticket's editing shortcuts while focus is inside the ticket. Try mod+3 for the third quick size, mod+up to raise the price, mod+shift+x to flip the side, and mod+enter to submit. Here mod means Command on Mac and Ctrl elsewhere; the submit button shows the current binding. Plain Enter in a field submits nothing. The shortcuts run anywhere inside the ticket, its padding and symbol included.
Quick sizes and account choices are ordinary props. The Draft line follows onDraftChange; the separate submitted line changes only when the action runs. The initial draft is explicit because onDraftChange does not fire on mount. A Last reference supplies a starting price when the field is blank.
import { useState } from "react"
import { Ticket, describeDraft, type TicketDraft, type TicketInstrument } from "@/components/ticket"
import { HotkeysProvider } from "@/hooks/use-hotkeys"
const ZN: TicketInstrument = { symbol: "ZN", convention: { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }, quantityStep: 1 }
const initial: TicketDraft = { side: "buy", quantity: 5, price: 99.5, type: "limit", tif: "day", account: "A-1" }
export default function TicketShortcutsDemo() {
const [draft, setDraft] = useState(initial)
const [submitted, setSubmitted] = useState("No draft submitted.")
return (
<HotkeysProvider bindings={[]}>
<div className="w-80 max-w-full space-y-2 text-xs lining-nums tabular-nums">
<Ticket
instrument={ZN}
reference={{ last: 99.5 }}
defaultDraft={initial}
quickSizes={[1, 5, 10]}
accounts={[{ id: "A-1", label: "A-1" }, { id: "A-2", label: "A-2" }]}
onDraftChange={setDraft}
actions={[{ id: "send", label: "Submit draft", run: (value) => setSubmitted(`${describeDraft(value, ZN)} · ${value.account}`) }]}
allowedActions={["send"]}
/>
<p className="text-muted-foreground">Draft: {describeDraft(draft, ZN)} · {draft.account}</p>
<p role="status" className="text-muted-foreground">{submitted}</p>
</div>
</HotkeysProvider>
)
}
Server replies
Send order records a request and removes the allowed actions. The controls above the ticket stand in for server replies: acknowledge the order to assign a sample order id and offer Cancel order, or reject it to show a reason and allow a retry. Cancel order sends another request; acknowledge the cancellation to make Send order available again.
Only an order acknowledgement changes the ring token. Rejection, cancellation and Reset server leave it alone. Cancel order uses checked: false, so clearing a draft field cannot stop cancellation of an acknowledged order. The submitted draft is captured before any later field edits.
Every state stays visible until you choose the next event. Reset server restores the initial permissions and status without discarding the fields. An integration should replace these controls with responses from its server; Ticket does not infer order state from an action callback.
import { useState } from "react"
import { Ticket, describeDraft, type TicketDraft, type TicketInstrument } from "@/components/ticket"
const ZN: TicketInstrument = { symbol: "ZN", convention: { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }, quantityStep: 1 }
const ready = { status: "Ready", message: "", allowedActions: ["send"] }
export default function TicketServerDemo() {
const [server, setServer] = useState(ready)
const [pending, setPending] = useState<TicketDraft | null>(null)
const [acknowledged, setAcknowledged] = useState(0)
const reply = (status: string, message: string, allowedActions: string[]) => {
setServer({ status, message, allowedActions })
setPending(null)
}
const send = (draft: TicketDraft) => {
setPending(draft)
setServer({ status: "Sending", message: "Waiting for the order reply.", allowedActions: [] })
}
const acknowledge = () => {
if (!pending) return
const id = acknowledged + 1
setAcknowledged(id)
reply("Acknowledged", `ORD-${id}: ${describeDraft(pending, ZN)}`, ["cancel"])
}
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={!pending} onClick={acknowledge}>Acknowledge order</button>
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={!pending} onClick={() => reply("Rejected", "Sample venue rejected this draft.", ["send"])}>Reject order</button>
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={server.status !== "Cancel sent"} onClick={() => reply("Cancelled", "Cancellation confirmed.", ["send"])}>Acknowledge cancellation</button>
<button type="button" className="rounded border px-2 py-1" onClick={() => reply(ready.status, ready.message, ready.allowedActions)}>Reset server</button>
</div>
<div className="w-80 max-w-full text-xs lining-nums tabular-nums">
<Ticket
instrument={ZN}
defaultDraft={{ quantity: 5, price: 99.5 }}
actions={[
{ id: "send", label: "Send order", run: send },
{ id: "cancel", label: "Cancel order", checked: false, destructive: true, run: () => reply("Cancel sent", "Waiting for the cancellation reply.", []) },
]}
allowedActions={server.allowedActions}
status={server.status}
message={server.message}
acknowledged={acknowledged}
/>
<p role="status" className="sr-only">{server.message}</p>
</div>
</>
)
}
API Reference
This is the registry's first block: ticket.tsx installs into your components alias, not components/ui. Its shared files use the same source as quote-field, format, flash-cell, limits, and use-hotkeys. Editing the installed file is the intended way to change its layout. Everything in ticket.tsx is then yours — the markup, the draft state, the handlers and keys, and the exported checkDraft, describeDraft, parseQuantity, and formatQuickSize — while the shared files it installs beside it, quote-field, format, limits, use-flash, and the hotkey files, keep taking updates from any item that installs them. Take later fixes to ticket.tsx itself by hand: read an update with --diff before taking it.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
instrument |
TicketInstrument |
Required | Symbol, price convention, and quantity step. |
actions |
readonly TicketAction[] |
Required | Action definitions, in display order. |
allowedActions |
readonly string[] |
None allowed | Server-authorized action ids. |
reference |
TicketReference |
None | Bid, ask, and last for price buttons, stepping, and limits. |
orderTypes |
readonly TicketOption[] |
DEFAULT_ORDER_TYPES |
Limit and market. |
timeInForces |
readonly TicketOption[] |
DEFAULT_TIME_IN_FORCES |
Day, GTC, and IOC. |
accounts |
readonly TicketOption[] |
No field | Account choices; an empty list also hides the field. Given, a checked action needs the draft to hold one of them. |
defaultDraft |
Partial<TicketDraft> |
See The draft | Initial values. |
onDraftChange |
(draft: TicketDraft) => void |
None | Receives draft updates after mount. |
limits |
Limits |
None | Blocks and confirmation rules. |
limitsLabels |
Partial<LimitsLabels> |
DEFAULT_LIMITS_LABELS |
The words of the limits' sentences. |
quickSizes |
readonly number[] |
No buttons | Quantities to select with a button or shortcut: whole numbers above zero, each once, and any other is left out. |
status |
string |
None | Server status, printed as supplied and set as data-status on the group. |
message |
string |
None | Server detail, such as a rejection reason, printed as supplied. |
acknowledged |
unknown |
None | A changed value triggers the acknowledgement ring. |
disabled |
boolean |
false |
Disables fields and buttons; stops actions and every ticket shortcut — send, flip, price steps, and quick sizes. |
hotkeys |
boolean |
true |
Declares TICKET_BINDINGS as registry defaults, the size keys for the quick sizes passed. |
labels |
Partial<TicketLabels> |
DEFAULT_TICKET_LABELS |
Every word the ticket draws or says beside the limits' and the server's: field labels, validation messages, the unpriced placeholder, at market, the reference and step buttons' names, and the quick sizes' notional unit. The key caps the send target's button shows come from the hotkeys library. A word given as undefined keeps its default. |
className |
string |
None | Classes on the outer group. |
TicketInstrument requires symbol: string and convention: InstrumentConvention; quantityStep?: number defaults to 1, and anything but a whole number from 1 to Number.MAX_SAFE_INTEGER steps by 1. See format for conventions. TicketReference has optional bid, ask, and last fields, each number | null.
TicketOption requires id: string and label: string. Its optional priced: boolean defaults to true and matters only in orderTypes. A type with priced: false disables the price field and reference buttons; actions receive price: null.
What it does
The quote-field parses through parseQuote and formats through formatQuote: 99-16+ becomes 99.515625 and prints back in the instrument's notation on blur. A typed decimal snaps to the printable grid when parsed, so the described draft, the limits check, and run all receive the grid price the field's text formats to. Invalid text is marked on blur; typing clears the mark. The field's arrows and step buttons use stepQuote; Shift multiplies arrow steps by ten.
Reference prices appear above the fields; click one to use it. Blank or invalid prices step from last, then the bid/ask midpoint, then whichever side exists, each snapped to the quote grid; a non-finite reference — a feed still warming up — counts as absent. Without a parsed value or reference, stepping does nothing. Reference buttons print with formatQuote and store the snapped value they show.
Actions
TicketAction field |
Type | Default | Purpose |
|---|---|---|---|
id |
string |
Required | Matches an id in allowedActions. |
label |
string | ((draft: TicketDraft) => string) |
Required | Button text, optionally derived from the current draft. |
run |
(draft: TicketDraft, instrument: TicketInstrument) => void |
Required | Receives the draft and instrument. |
primary |
boolean |
First allowed action | Styles the prominent button and steers ticket.send and its key hint toward this action while it checks the draft and is not destructive. |
checked |
boolean |
true |
Checks the draft and limits before calling run. |
destructive |
boolean |
false |
Draws the action in the destructive color on the outline button, which holds 4.5:1 at rest and turns to the foreground under the pointer. |
The first allowed action marked primary wins; otherwise the first allowed action is primary. Checked actions require a whole quantity above zero, a finite price for priced order types, and a type, time in force, and account that their lists hold. Problems appear under the fields and prevent run; a price problem clears when the price changes or the order type stops taking one, and a choice's when it changes. Use checked: false for an action such as cancel that needs neither draft validation nor limit checks. The send shortcut and its key caps pass by every unchecked or destructive action, since a shortcut named send must never cancel an order: when only those remain allowed, mod+enter does nothing.
What it does not do
The ticket makes no network request and infers no order state from run. As with blotter, the server supplies the allowed actions and status.
Only actions named in allowedActions render, in actions order. A missing or empty allowlist shows labels.nothingAllowed. Permission and disabled are checked again when an action runs. status and message print as supplied; clicking a button never sets “Sent.” A screen reader hears them from a status region that is in the page from the first render, so the first status after a send is heard too; the line that shows them is hidden from it, so nothing is read twice.
A press counts once: the second click of a double-click, within the system's double-click time and distance, and the clicks a held Enter repeats on a focused button, run nothing. Two separate presses run the action twice unless run takes it out of allowedActions at once, as the server-replies demo does; do the same, or have your server refuse the repeat.
Change acknowledged when the server acknowledges, using an order id or timestamp. After mount, each change under Object.is triggers a 900 ms useFlash ring in primary, with no direction, a counter's rise included. The initial value does not flash. When the control under focus leaves — a sent action's button unmounts or disables, with or without an acknowledgement, a window switch in between included — focus moves to the ticket itself, so the shortcuts stay live. A deliberate click elsewhere is remembered as leaving, and a re-keyed ticket starts fresh, owning no focus until the trader returns to it. Under prefers-reduced-motion, the ticket does not ring.
Keys
| Key | Binding | Effect |
|---|---|---|
mod+enter |
ticket.send |
Runs the first allowed action that checks the draft and is not destructive, preferring the primary one; does nothing when no such action is allowed. |
mod+shift+x |
ticket.flip |
Swaps buy and sell. |
mod+up / mod+down |
ticket.tick-up / ticket.tick-down |
Steps the price by the quote step from anywhere in the ticket, as the field's own arrows do. |
mod+1 … mod+9 |
ticket.size-1 … ticket.size-9 |
Selects the corresponding quick size, if present. |
| Up / Down | Field behavior | Steps the focused price or quantity field. |
| Shift+Up / Shift+Down | Field behavior | Takes ten field steps. |
mod is Command on Mac and Ctrl elsewhere. Registry shortcuts need a HotkeysProvider. The ticket declares TICKET_BINDINGS as registry defaults: your registration of an id shadows the ticket's default whenever it comes, unregistering yours brings the default back, and the defaults leave when no ticket with hotkeys enabled remains. unregister("ticket.send") does nothing while a ticket holds the default — register your own binding to change keys or wording, and remap(id, "") turns a key off as a user override the hotkey editor shows and Reset undoes. The send target's button shows the registry's current send keys.
hotkeys={false} skips declarations but still attaches handlers for bindings you supply. Each ticket has its own HotkeyScope and handlers, so shortcuts work while typing inside that ticket. They also work inside a dialog; global keys cannot reach a blotter behind it.
Price-step shortcuts do not check whether the order type is priced. They step by the instrument's quote step, exactly as the field's own arrows do, so the stored price never differs from the price the field shows. A disabled ticket runs no shortcuts at all, though its keys are still consumed. mod+1 through mod+9 declare only for the quick sizes you pass. While nobody declares an absent size's id, its keys pass through untouched; once the id is declared — by you, or by another ticket with more sizes on the shared registry — the registry consumes the key wherever the fence reaches, and a ticket without that size does nothing with it. An id you declare in the editing scope always meets the ticket's fence; keep ticket ids there, since a declaration in another scope dispatches unfenced.
Plain Enter in a field does not submit an order. The ticket has no <form> or implicit submit.
The draft
TicketDraft field |
Type | Initial value before defaultDraft overrides |
|---|---|---|
side |
"buy" | "sell" |
"buy" |
quantity |
number | null |
null |
price |
number | null |
null |
type |
string |
First order-type id, or "". |
tif |
string |
First time-in-force id, or "". |
account |
string | null |
First account id, or null. |
defaultDraft applies on mount, and so does everything else the draft reads: the whole draft is built once, from the props of the first render. Changing instrument, orderTypes, timeInForces, accounts, or defaultDraft later never rewrites it: a new instrument under an old draft keeps the old price and its text, so change the React key whenever the ticket should start again, as for a new symbol or an amendment. A select whose list doesn't hold the draft's value, as when accounts load after mount or a list reloads without it, shows labels.choose, and a checked action asks for a choice rather than send a value the field doesn't show. A defaultDraft price snaps to the quote grid, as a reference click does, so the price sent is the one the field shows, and a defaultDraft quantity that isn't a whole number from zero up starts the field blank, holding null. onDraftChange receives draft updates, never the initial render. It retains the stored price for unpriced order types; only the draft passed to run replaces that price with null.
Typed quantities accept nonnegative safe integers, with commas only between thousands (5,000). Blank, negative, fractional, or invalid input becomes null, and so does any other comma, as in 2,5 typed on a decimal-comma keyboard. Text partway through a grouped number, such as 1, or ,000 after deleting the first digit of 5,000, reads as null until it is whole again. Quantity arrows add or subtract quantityStep, round to its nearest multiple, and clamp at zero. An empty field starts from zero.
| Helper | Result |
|---|---|
parseQuantity(text) |
Parsed quantity or null. |
checkDraft(draft, orderTypes, labels?, choices?) |
TicketProblems: optional quantity, price, type, tif, and account messages, or {}. choices takes timeInForces and accounts; an empty or missing list asks for nothing. Uses DEFAULT_TICKET_LABELS; does not check limits. |
describeDraft(draft, instrument, orderTypes?, labels?) |
Summary such as Buy 5 ZN @ 99-16+ or Buy 5 ZN at market. Defaults to DEFAULT_ORDER_TYPES and the default buy, sell, and atMarket labels; the level prints with formatQuote, in the instrument's quote basis. |
Use these helpers in a confirmation dialog, palette row, or test.
Limits
Pass a limits table for quantity thresholds, distance from market, permitted sides, or custom rules. Checks run live and again when an action runs, using reference as the market. A buyer's price is measured against the offer, a seller's against the bid, falling back to last when that side is missing, since the reference carries no midpoint. Distance uses ticks for a price basis and basis points for other quote bases. The sentences take their words from limitsLabels, over DEFAULT_LIMITS_LABELS, and say one tick in the singular.
- A
blockappears under its quantity or price field and holds checked actions: each looks disabled, carriesaria-disabled, and stays focusable, and a press on it is refused. Other fields, including side, appear below the actions. An action withchecked: falsecan still run. - A
confirmmakes the chosen checked action ask again: its label becomes{action} anyway?, and reasons appear below the actions. A reason added while the question stands makes the next press ask again, with every reason, rather than send. A question whose action the server takes away is withdrawn, so if the action comes back, the next press asks again. The next fresh press of that same action runs it if checks still pass: a double-click's second click or a held Enter's repeat does not count. Any draft update clears the confirmation; changes to the market or limits alone do not. - A screen reader hears the limits when a press meets them, from one polite announcer: the blocks the press was refused for, or the question it asked, in the words they have as it lands. A held action stays in reach, so a press from a pointer, a key, touch, or assistive technology counts. Nothing is said as typing or the market changes the limits; the fields and the line below the actions keep the live words. A field shows a limit's block without an alert or a description, since its words follow each keystroke and, for a price, the market. A press's own problem with a field, such as a missing price, is the field's alert and its description, and a later press that meets it again is said by the announcer, since the alert's words haven't changed. What was said goes once the draft changes or any of it stops standing, and a block that comes back waits for the next press.
Quick sizes
quickSizes renders your sizes below the quantity field; selecting one replaces the quantity. The matching size has aria-pressed="true". formatQuickSize(size, convention, millions?) is exported: it prints a count, or millions such as 2.5mm when quantityUnit is "notional", with millions as the unit. Supply raw quantities such as 2_500_000, not 2.5; a size that isn't a whole number above zero is left out, and a repeated one shows once. The ticket adds no sizes of its own; the first nine have shortcuts.
Labels
labels overrides DEFAULT_TICKET_LABELS. Option labels, action labels, and server text come from their own props; custom limit messages come from limit rules. The group is named "Order ticket ZN" by default, side buttons use aria-pressed, and invalid fields use aria-invalid with a FieldError below them. Focus resting on the ticket itself draws a ring in the foreground at 60%, which holds 3:1.
Tokens
The install adds up, down, and flat with their -soft variants, and stale, if absent, with the shared font tokens and the hyperlegible remap. Side buttons use up and down; the acknowledgement ring uses primary.
Built on shadcn's button, button-group, field, input, input-group, kbd, and native-select.
This page is docs/ticket.md at v2.0.0.