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#v1.4.13
npx shadcn@latest add tradecn/ui/ticket#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/ticket#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/ticket#v1.4.13
-
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, useRef, useState, useSyncExternalStore, type KeyboardEvent } 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, formatPrice, formatQuantity, numericFontClass, stepByTick, type InstrumentConvention } from "@/lib/format" import { blocks, checkLimits, confirms, problemsByField, type Limits } from "@/lib/limits" import { formatKeys, type HotkeyBinding, type HotkeyRegistry } from "@/lib/hotkeys" import { QuoteField } from "@/components/ui/quote-field" // 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 tick, 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. 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 /** What `ticket.send` runs. The first allowed action by default. */ 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 nothingAllowed: string /** The primary action's words while a limit asks again; `{action}` is its label. */ anyway: 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 quantity above zero.", priceInvalid: "Not a price in this instrument's notation.", priceRequired: "This order type needs a price.", nothingAllowed: "Nothing can be done with this ticket right now.", anyway: "{action} anyway?", } 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 when you have not. */ 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 tick", group: "Ticket" }, { id: "ticket.tick-down", keys: "mod+down", scope: "editing", description: "Price down one tick", 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, a count otherwise. */ export function formatQuickSize(size: number, convention: InstrumentConvention): string { return convention.quantityUnit === "notional" ? formatNotional(size, { unit: "mm" }) : 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, there is no account field. */ 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 /** Sizes a press or `mod+1` to `mod+9` puts in the quantity, as buttons under the field, printed in the convention's unit. */ 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` in the hotkey registry when they are not. 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. */ export function describeDraft(draft: TicketDraft, instrument: TicketInstrument, orderTypes: readonly TicketOption[] = DEFAULT_ORDER_TYPES, labels: Pick<TicketLabels, "buy" | "sell"> = 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) ? " at market" : draft.price === null ? "" : ` @ ${formatPrice(draft.price, instrument.convention.price)}` return `${side}${quantity} ${instrument.symbol}${price}` } export interface TicketProblems { quantity?: string price?: string } /** What stops a draft from being sent. Empty when nothing does. */ export function checkDraft(draft: TicketDraft, orderTypes: readonly TicketOption[], labels: TicketLabels = DEFAULT_TICKET_LABELS): TicketProblems { const problems: TicketProblems = {} if (draft.quantity === null || !(draft.quantity > 0)) problems.quantity = labels.quantityInvalid if (isPriced(orderTypes, draft.type) && draft.price === null) problems.price = labels.priceRequired return problems } // Several tickets can be up at once, so the bindings are declared once per registry and taken // back when the last ticket that leaned on them leaves. A consumer that declared an id owns it. const declared = new WeakMap<HotkeyRegistry, Map<string, { count: number; ours: boolean }>>() function declareBindings(registry: HotkeyRegistry, bindings: readonly HotkeyBinding[]): () => void { let table = declared.get(registry) if (!table) declared.set(registry, (table = new Map())) const have = new Set(registry.list().map((entry) => entry.id)) for (const binding of bindings) { const entry = table.get(binding.id) if (entry) entry.count += 1 else { const ours = !have.has(binding.id) if (ours) registry.register(binding) table.set(binding.id, { count: 1, ours }) } } return () => { for (const binding of bindings) { const entry = table.get(binding.id) if (!entry) continue entry.count -= 1 if (entry.count > 0) continue table.delete(binding.id) if (entry.ours) registry.unregister(binding.id) } } } /** Whole numbers, with or without separators. A quantity is contracts or units of notional, never a fraction. */ export function parseQuantity(text: string): number | null { const clean = text.trim().replace(/,/g, "") if (!/^\d+$/.test(clean)) return null const n = Number(clean) return Number.isSafeInteger(n) ? n : null } const noop = () => () => {} export function Ticket({ instrument, reference, orderTypes = DEFAULT_ORDER_TYPES, timeInForces = DEFAULT_TIME_IN_FORCES, accounts, defaultDraft, onDraftChange, actions, allowedActions, limits, quickSizes, status, message, acknowledged, disabled = false, hotkeys: declareHotkeys = true, labels: labelsProp, className, }: TicketProps) { const labels = { ...DEFAULT_TICKET_LABELS, ...labelsProp } const id = useId() const { convention, quantityStep = 1 } = instrument const [draft, setDraft] = useState<TicketDraft>(() => ({ side: "buy", quantity: null, price: null, type: orderTypes[0]?.id ?? "", tif: timeInForces[0]?.id ?? "", account: accounts?.[0]?.id ?? null, ...defaultDraft, })) const [quantityText, setQuantityText] = useState(() => (draft.quantity === null ? "" : formatQuantity(draft.quantity))) const [problems, setProblems] = useState<TicketProblems>({}) // The action a limit asked again about; the next click on it sends. Any change to the draft withdraws the question. const [confirming, setConfirming] = useState<string | null>(null) 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 }) : [] const blockedBy = problemsByField(blocks(limitProblems)) const blocked = Object.keys(blockedBy).length > 0 const shownProblems = { quantity: problems.quantity ?? blockedBy.quantity, price: problems.price ?? blockedBy.price } const otherBlocks = blocks(limitProblems).filter((p) => p.field !== "quantity" && p.field !== "price") const asking = confirming !== null ? confirms(limitProblems) : [] const box = useRef<HTMLDivElement>(null) const priceInput = useRef<HTMLInputElement>(null) const latest = useRef({ onDraftChange, actions, allowedActions, draft, orderTypes, labels, instrument, disabled, limits, reference, confirming, quickSizes }) useEffect(() => { latest.current = { onDraftChange, actions, allowedActions, draft, orderTypes, labels, instrument, disabled, limits, reference, confirming, quickSizes } }) // 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. No direction, because it has none. useFlash(box, acknowledged, { variant: "ring", color: "var(--primary)" }) function update(patch: Partial<TicketDraft>) { setDraft((d) => ({ ...d, ...patch })) setProblems((p) => (patch.quantity !== undefined && p.quantity ? { ...p, quantity: undefined } : patch.price !== undefined && p.price ? { ...p, price: undefined } : p)) 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. */ function priceToStepFrom(): number | null { if (draft.price !== null) return draft.price const { bid, ask, last } = reference ?? {} if (typeof last === "number") return last if (typeof bid === "number" && typeof ask === "number") return stepByTick((bid + ask) / 2, convention.tick, 0) return typeof bid === "number" ? bid : typeof ask === "number" ? ask : null } function stepPrice(steps: number) { const from = priceToStepFrom() if (from === null) return setPrice(stepByTick(from, convention.tick, 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)) const primary = allowed.find((action) => action.primary) ?? allowed[0] 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, labels: words, instrument: inst, disabled: off, limits: lines, reference: market, confirming: asked } = 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) // The limits, as the click lands: a block stops here and shows under its field; a confirm asks once, and the next click on the same action sends. const over = lines ? checkLimits({ side: current.side, quantity: current.quantity, price }, lines, { market, convention: inst.convention }) : [] const stopped = problemsByField(blocks(over)) setProblems({ quantity: found.quantity ?? stopped.quantity, price: found.price ?? stopped.price }) if (found.quantity || found.price || Object.keys(stopped).length) return if (confirms(over).length && asked !== action.id) { setConfirming(action.id) return } send() } // Keys: declared once per registry, bound to this ticket's box so another ticket's keys stay its own. const registry = useMaybeHotkeys() const handlers = useRef({ send: () => {}, flip: () => {}, up: () => {}, down: () => {}, quick: (n: number) => { void n } }) useEffect(() => { handlers.current = { send: () => { const { actions: list, allowedActions: allowedNow } = latest.current const now = list.filter((action) => allowedNow?.includes(action.id)) const first = now.find((action) => action.primary) ?? now[0] if (first) run(first) }, flip: () => update({ side: draft.side === "buy" ? "sell" : "buy" }), up: () => stepPrice(1), down: () => stepPrice(-1), quick, } }) useEffect(() => { if (!registry) return const release = declareHotkeys ? declareBindings(registry, TICKET_BINDINGS) : noop() const within = { scope: "editing", element: () => box.current } const guard = (fn: () => void) => (event: KeyboardEvent | globalThis.KeyboardEvent) => { event.preventDefault() fn() } const unbind = [ registry.bind("ticket.send", guard(() => handlers.current.send()), within), registry.bind("ticket.flip", guard(() => handlers.current.flip()), within), registry.bind("ticket.tick-up", guard(() => handlers.current.up()), within), registry.bind("ticket.tick-down", guard(() => handlers.current.down()), within), ...QUICK_SIZE_KEYS.map((_, i) => registry.bind(`ticket.size-${i + 1}`, guard(() => handlers.current.quick(i + 1)), within)), ] return () => { for (const u of unbind) u() release() } }, [registry, declareHotkeys]) 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") return null return ( <Button key={name} type="button" variant="ghost" size="sm" className={cn("h-5 gap-1 px-1 text-xs", numericFontClass(convention.price))} disabled={disabled || !priced} aria-label={`${label} ${formatPrice(value, convention.price)}, use it`} data-reference={name} onClick={() => setPrice(value)}> <span className="text-muted-foreground">{label}</span> {formatPrice(value, convention.price)} </Button> ) } 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 outline-none 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"> <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="decimal" autoComplete="off" spellCheck={false} disabled={disabled} aria-invalid={shownProblems.quantity ? true : 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)} /> {shownProblems.quantity && <FieldError>{shownProblems.quantity}</FieldError>} {quickSizes && quickSizes.length > 0 && ( <div role="group" aria-label={labels.quickSizes} data-ticket-quick-sizes="" className="flex flex-wrap gap-1"> {quickSizes.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)}`} data-quick-size={size} onClick={() => quick(i + 1)}> {formatQuickSize(size, convention)} </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 : "market"} error={shownProblems.price} invalidText={labels.priceInvalid} inputRef={priceInput} /> <Field> <FieldLabel htmlFor={`${id}-type`}>{labels.type}</FieldLabel> <NativeSelect id={`${id}-type`} value={draft.type} disabled={disabled} className="w-full" onChange={(event) => update({ type: event.target.value })}> {orderTypes.map((option) => ( <NativeSelectOption key={option.id} value={option.id}> {option.label} </NativeSelectOption> ))} </NativeSelect> </Field> <Field> <FieldLabel htmlFor={`${id}-tif`}>{labels.tif}</FieldLabel> <NativeSelect id={`${id}-tif`} value={draft.tif} disabled={disabled} className="w-full" onChange={(event) => update({ tif: event.target.value })}> {timeInForces.map((option) => ( <NativeSelectOption key={option.id} value={option.id}> {option.label} </NativeSelectOption> ))} </NativeSelect> </Field> {accounts && accounts.length > 0 && ( <Field className="col-span-2"> <FieldLabel htmlFor={`${id}-account`}>{labels.account}</FieldLabel> <NativeSelect id={`${id}-account`} value={draft.account ?? ""} disabled={disabled} className="w-full" onChange={(event) => update({ account: event.target.value })}> {accounts.map((option) => ( <NativeSelectOption key={option.id} value={option.id}> {option.label} </NativeSelectOption> ))} </NativeSelect> </Field> )} </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" variant={action.destructive ? "destructive" : action === primary ? "default" : "outline"} size="sm" className="h-7 gap-2" disabled={disabled || (blocked && action.checked !== false)} data-action={action.id} data-confirming={confirming === action.id || undefined} onClick={() => 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 === primary && sendKeys && ( <KbdGroup aria-hidden> {formatKeys(sendKeys)[0]?.map((cap) => ( <Kbd key={cap}>{cap}</Kbd> ))} </KbdGroup> )} </Button> )) )} </div> {(otherBlocks.length > 0 || asking.length > 0) && ( <p className={asking.length ? "text-stale" : "text-destructive"} data-ticket-limits={asking.length ? "confirm" : "block"}> {[...otherBlocks, ...asking].map((p) => p.message).join(" ")} </p> )} {(status || message) && ( <div className="flex flex-wrap items-baseline gap-x-2 border-t border-border pt-1.5"> {status && ( <output className="font-medium" data-ticket-status> {status} </output> )} {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" // 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 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. */ error?: string /** Said under the field when the text is not a quote. Default names the basis. */ 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> className?: string } export function QuoteField({ convention, value, onValueChange, stepFrom = null, label, error, invalidText, placeholder, id: idProp, disabled = false, shiftMultiplier = 10, side, inputRef, className }: QuoteFieldProps) { const basis = quoteBasisOf(convention) const word = label ?? QUOTE_BASIS_LABELS[basis] const generated = useId() const id = idProp ?? generated 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 (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 if (from === null || from === undefined) 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 ?? `Not a ${word.toLowerCase()} in this instrument's notation.`) : undefined) 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)} inputMode="decimal" autoComplete="off" spellCheck={false} disabled={disabled} aria-invalid={problem ? true : 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={`${word} down one tick`} disabled={disabled} data-step="-1" onClick={() => step(-1)}> − </InputGroupButton> <InputGroupButton type="button" size="icon-xs" aria-label={`${word} up one tick`} disabled={disabled} data-step="1" onClick={() => step(1)}> + </InputGroupButton> </InputGroupAddon> </InputGroup> {problem && <FieldError>{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, 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) 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 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}>{children}</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) } 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) useEffect(() => { latest.current = handler }) useEffect(() => { 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) } 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 } function isNil(v: Nullable): v is null | undefined { return v === null || v === undefined || !Number.isFinite(v) } // One Intl.NumberFormat per locale and option set; construction is the expensive part. const formatters = new Map<string, Intl.NumberFormat>() export function numberFormat(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat { const key = `${locale ?? "en-US"}|${JSON.stringify(options)}` let nf = formatters.get(key) if (!nf) { nf = new Intl.NumberFormat(locale ?? "en-US", options) formatters.set(key, nf) } return nf } function fixed(v: number, decimals: number, locale?: string, extra: Intl.NumberFormatOptions = {}): string { return numberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, ...extra }).format(v) } function typographicMinus(s: string): string { return s.replace(/-/g, MINUS) } /** Decimal places needed to show a price on this tick grid. 0.001 gives 3, 1/32 gives 5, 1 gives 0. Capped at `max`. */ export function decimalsFromTick(tick: number, max = 8): number { if (!Number.isFinite(tick) || tick <= 0) return 0 for (let d = 0; d <= max; d++) { const scaled = tick * 10 ** d if (Math.abs(scaled - Math.round(scaled)) < 1e-9 * Math.max(1, scaled)) return d } return max } /** Nearest price on the tick grid, cleaned of float noise. */ export function roundToTick(v: number, tick: number): number { if (!Number.isFinite(v) || !Number.isFinite(tick) || tick <= 0) return v return Number((Math.round(v / tick) * tick).toFixed(decimalsFromTick(tick))) } /** Move `steps` ticks (negative allowed) from the nearest grid price. */ export function stepByTick(v: number, tick: number, steps: number): number { return roundToTick(roundToTick(v, tick) + steps * tick, tick) } /** Whole-number and sub-tick decomposition on an integer grid so 32nds never drift. */ function decomposeFraction(v: number, c: Extract<PriceConvention, { kind: "fraction" }>) { const sub = c.eighths ? 8 : 2 const unitsPerWhole = c.denominator * sub const total = Math.round(Math.abs(v) * unitsPerWhole) const whole = Math.floor(total / unitsPerWhole) const rem = total - whole * unitsPerWhole const ticks = Math.floor(rem / sub) const subticks = rem - ticks * sub return { negative: v < 0 && total > 0, whole, ticks, subticks, sub } } /** 99.515625 as "99-16+" (32nds, half "+"), "99-165" (half "5"), or "99-164" with eighths. */ export function formatFraction(v: Nullable, c: Extract<PriceConvention, { kind: "fraction" }>): string { if (isNil(v)) return NULL_TOKEN const { negative, whole, ticks, subticks, sub } = decomposeFraction(v, c) let tail = "" if (subticks !== 0) { if (c.eighths) tail = String(subticks) else tail = c.half } void sub return `${negative ? MINUS : ""}${whole}-${String(ticks).padStart(2, "0")}${tail}` } export function formatPrice(v: Nullable, c: PriceConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN switch (c.kind) { case "decimal": return typographicMinus(fixed(v, c.decimals, l?.locale)) case "tick": return typographicMinus(fixed(roundToTick(v, c.tick), decimalsFromTick(c.tick), l?.locale)) case "fraction": return formatFraction(v, c) } } /** * Inverse of formatPrice for ticket entry. Accepts the convention's own notation and a plain decimal. * Returns null when the text is not a price. Fraction input: "99-16+", "99-165", "99-162", "99-16", "99". */ export function parsePrice(s: string, c: PriceConvention): number | null { const text = s.trim().replace(/−/g, "-").replace(/,/g, "") if (!text) return null if (c.kind === "fraction") { const m = /^([+-]?)(\d+)(?:-(\d{1,2})([+0-9])?)?$/.exec(text) if (m) { const [, sign, wholeText, ticksText, tail] = m const ticks = ticksText ? Number(ticksText) : 0 if (ticks >= c.denominator) return null let subFraction = 0 if (tail === "+") subFraction = 0.5 else if (tail !== undefined) { const digit = Number(tail) if (c.eighths) { if (digit > 7) return null subFraction = digit / 8 } else if (c.half === "5" && digit === 5) subFraction = 0.5 else if (digit === 0) subFraction = 0 else return null } const value = Number(wholeText) + (ticks + subFraction) / c.denominator return sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null if (c.kind === "tick") return roundToTick(n, c.tick) if (c.kind === "decimal") return Number(n.toFixed(c.decimals)) return n } /** 4.2531 as "4.253%". */ export function formatYield(v: Nullable, o: { decimals?: number; suffix?: "%" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + (o.suffix ?? "%") } /** 12.5 as "12.5 bp"; signed gives "+12.5 bp". */ export function formatBps(v: Nullable, o: { decimals?: number; signed?: boolean; unit?: "bp" | "bps" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const unit = o.unit ?? "bp" const body = o.signed ? formatSigned(v, { decimals: o.decimals ?? 1, locale: o.locale }) : typographicMinus(fixed(v, o.decimals ?? 1, o.locale)) return unit ? `${body} ${unit}` : body } /** Dollar value of a basis point as whole currency; compact gives "$1.23K". */ export function formatDv01(v: Nullable, o: { currency?: string; compact?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const currency = o.currency ?? "USD" if (o.compact) { const symbol = numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }) .formatToParts(0) .find((p) => p.type === "currency")?.value ?? "" return `${v < 0 ? MINUS : ""}${symbol}${formatNotional(Math.abs(v), { compact: true, locale: o.locale })}` } return typographicMinus(numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }).format(v)) } /** 1250000 as "1.25M" (compact), "1.25mm" (unit "mm", the desk's word for millions, never scaled further), or "1,250,000.00". Compact suffixes K, M, B, T. */ export function formatNotional(v: Nullable, o: { compact?: boolean; decimals?: number; unit?: "mm" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 if (o.unit === "mm") return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(v / 1e6)) + "mm" if (!o.compact) return typographicMinus(fixed(v, decimals, o.locale)) const abs = Math.abs(v) const [divisor, suffix] = abs >= 1e12 ? [1e12, "T"] : abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : abs >= 1e3 ? [1e3, "K"] : [1, ""] return typographicMinus(fixed(v / divisor, suffix ? decimals : 0, o.locale)) + suffix } /** "+0.12", "−0.12" (U+2212), and "0.00" for zero: flat carries no sign. */ export function formatSigned(v: Nullable, o: { decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 2, o.locale, { signDisplay: "exceptZero" })) } /** 1.234 as "1.23%"; signed gives "+1.23%". */ export function formatPercent(v: Nullable, o: { decimals?: number; signed?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 return (o.signed ? formatSigned(v, { decimals, locale: o.locale }) : typographicMinus(fixed(v, decimals, o.locale))) + "%" } /** Whole quantities with grouping: 1000000 as "1,000,000". */ export function formatQuantity(v: Nullable, l?: Locale): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(Math.round(v), 0, l?.locale)) } /** * A coupon the way a run prints it: 4.125 as "4 1/8", 4.5 as "4 1/2", 4 as "4", 0 as "0" (coupons step in eighths). * One off the eighths grid prints as a plain decimal, "4.1". `style: "decimal"` prints "4.125%". */ export function formatCoupon(v: Nullable, o: { style?: "fraction" | "decimal"; decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN if (o.style === "decimal") return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + "%" const eighths = Math.round(Math.abs(v) * 8) if (Math.abs(eighths / 8 - Math.abs(v)) > 1e-9) return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: o.decimals ?? 3 }).format(v)) const whole = Math.floor(eighths / 8) const rem = eighths % 8 const fraction = rem === 0 ? "" : rem % 4 === 0 ? "1/2" : rem % 2 === 0 ? `${rem / 2}/4` : `${rem}/8` const body = fraction ? (whole ? `${whole} ${fraction}` : fraction) : String(whole) return `${v < 0 && eighths > 0 ? MINUS : ""}${body}` } /** A date as an instant, a millisecond count, or a string `Date` can read. A date-only string ("2034-05-15") is that day in UTC. */ export type DateLike = Date | number | string function toDate(d: DateLike | null | undefined): Date | null { if (d === null || d === undefined) return null const date = d instanceof Date ? d : new Date(d) return Number.isNaN(date.getTime()) ? null : date } const dateFormatters = new Map<string, Intl.DateTimeFormat>() /** * A maturity as a run prints it: "05/15/34", or "05/15/2034" with `year: "numeric"`. Read in UTC, so a * date-only string prints as that day wherever the screen is. */ export function formatMaturity(d: DateLike | null | undefined, o: { year?: "2-digit" | "numeric" } & Locale = {}): string { const date = toDate(d) if (!date) return NULL_TOKEN const key = `${o.locale ?? "en-US"}|${o.year ?? "2-digit"}` let f = dateFormatters.get(key) if (!f) { f = new Intl.DateTimeFormat(o.locale ?? "en-US", { month: "2-digit", day: "2-digit", year: o.year ?? "2-digit", timeZone: "UTC" }) dateFormatters.set(key, f) } return f.format(date) } /** Whole UTC days from `now` (the moment, by default) to a maturity. Negative once it has passed; null when the date does not read. */ export function daysToMaturity(d: DateLike | null | undefined, now: DateLike = Date.now()): number | null { const maturity = toDate(d) const from = toDate(now) if (!maturity || !from) return null const day = (x: Date) => Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate()) return Math.round((day(maturity) - day(from)) / 86_400_000) } /** How many ticks `a` is from `b`, signed, to the nearest eighth of a tick: 99-17 against 99-16+ on a 1/64 tick is 1. NaN when a side is not a number or the tick is not positive. */ export function ticksBetween(a: number, b: number, tick: number): number { if (!Number.isFinite(a) || !Number.isFinite(b) || !(tick > 0)) return NaN return Math.round(((a - b) / tick) * 8) / 8 } /** A count of ticks: "+1", "−0.5", "0", up to three decimals and no trailing zeros. Signed unless told otherwise; `unit` adds a word. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero" }).format(v)) return o.unit ? `${body} ${o.unit}` : body } /** * The class a number wears (contract rule 14): lining, tabular figures whatever the font, in the numeric family. * `--tradecn-font-numeric` is the sans by default, so letters stay proportional and only the digits take one * width, which is what a column of prices wants. docs/typography.md has the reasoning and the tokens. */ export const NUMERIC_CLASS = "font-(family-name:--tradecn-font-numeric) lining-nums tabular-nums" /** The same figures in the mono stack, `--tradecn-font-mono`: for a fraction quote, whose dash, ticks, and tail must line up down a column. */ export const MONO_NUMERIC_CLASS = "font-(family-name:--tradecn-font-mono) lining-nums tabular-nums" /** * The class for a number printed under a convention: a fraction price (99-16+) sets in the mono stack, so * 99-16+ over 99-17 keeps its dash and its tail in the same place; every other price, and every quote in * another basis, keeps the numeric family. */ export function numericFontClass(c?: PriceConvention | InstrumentConvention | null): string { const price = c && "price" in c ? (quoteBasisOf(c) === "price" ? c.price : null) : c return price?.kind === "fraction" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS } /** The word for a quote field's label, per basis. */ export const QUOTE_BASIS_LABELS: Record<QuoteBasis, string> = { price: "Price", yield: "Yield", discount: "Discount", spread: "Spread" } const QUOTE_DEFAULTS: Record<Exclude<QuoteBasis, "price">, { step: number; decimals: number }> = { yield: { step: 0.001, decimals: 3 }, discount: { step: 0.001, decimals: 3 }, spread: { step: 0.1, decimals: 1 }, } export function quoteBasisOf(c: InstrumentConvention): QuoteBasis { return c.quoteBasis ?? "price" } /** What a quote steps by in its basis: the tick for a price, else `quoteStep` or the basis default. */ export function quoteStepOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? c.tick : (c.quoteStep ?? QUOTE_DEFAULTS[basis].step) } function quoteDecimalsOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? 0 : (c.quoteDecimals ?? QUOTE_DEFAULTS[basis].decimals) } /** A quote in the instrument's basis: a price by its convention, else a fixed decimal with the basis's decimals and no unit, since the field's label says the basis. */ export function formatQuote(v: Nullable, c: InstrumentConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN if (quoteBasisOf(c) === "price") return formatPrice(v, c.price, l) return typographicMinus(fixed(roundToTick(v, quoteStepOf(c)), quoteDecimalsOf(c), l?.locale)) } /** Inverse of formatQuote for a quote field. A price takes its notation or a decimal; the others take a decimal and snap to the quote step. Null when the text is not a quote. */ export function parseQuote(s: string, c: InstrumentConvention): number | null { if (quoteBasisOf(c) === "price") return parsePrice(s, c.price) const text = s.trim().replace(/−/g, "-").replace(/,/g, "") if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) return Number.isFinite(n) ? roundToTick(n, quoteStepOf(c)) : null } /** Move a quote by `steps` of its step, from the nearest grid value. */ export function stepQuote(v: number, c: InstrumentConvention, steps: number): number { return stepByTick(v, quoteStepOf(c), steps) } /** Bind a convention once per instrument; a grid column calls `formatters[row.instrumentId].price(v)`, a quote field its `quote`, `parseQuote`, and `stepQuote`. */ export function createInstrumentFormatter(c: InstrumentConvention, l?: Locale) { return { tick: c.tick, basis: quoteBasisOf(c), quoteStep: quoteStepOf(c), price: (v: Nullable) => formatPrice(v, c.price, l), yield: (v: Nullable) => formatYield(v, { decimals: c.yieldDecimals ?? 3, locale: l?.locale }), step: (v: number, steps: number) => stepByTick(v, c.tick, steps), parsePrice: (s: string) => parsePrice(s, c.price), quantity: (v: Nullable) => formatQuantity(v, l), quote: (v: Nullable) => formatQuote(v, c, l), parseQuote: (s: string) => parseQuote(s, c), stepQuote: (v: number, steps: number) => stepQuote(v, c, steps), } }lib/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: only scopes declared inside it are active, so a // single-key binding cannot fire under a modal confirmation. 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 one, else the binding's own. */ keys: string defaultKeys: string 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 /** 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: "" } for (const token of part.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 (!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() const chordTimeoutMs = options.chordTimeoutMs ?? 1000 const recs = new Map<string, Rec>() 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 defaults = parseKeys(binding.keys, platform) let steps = defaults 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 = defaults } } 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 { if (event.defaultPrevented || event.isComposing || 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. if (previous && !handler && sameBinding(previous.binding, binding)) return conflictsFor(binding.id) recs.set(binding.id, build(binding, handler ?? previous?.handler)) clearPending() emit() return conflictsFor(binding.id) }, unregister(id) { if (!recs.delete(id)) return 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) return { ...rec.binding, keys, defaultKeys, remapped: keys !== defaultKeys } })) }, 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" // 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 ticks: string bps: 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", ticks: "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, ...context.labels } const problems: Problem[] = [] const quantity = draft.quantity 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: formatQuantity(quantity), max: formatQuantity(max.block) }) }) else if (max.confirm !== undefined && quantity > max.confirm) problems.push({ field: "quantity", level: "confirm", rule: "maxQuantity", message: fill(labels.quantityAboveConfirm, { n: formatQuantity(quantity), max: formatQuantity(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: formatQuantity(quantity), min: formatQuantity(min.block) }) }) else if (min.confirm !== undefined && quantity < min.confirm) problems.push({ field: "quantity", level: "confirm", rule: "minQuantity", message: fill(labels.quantityBelowConfirm, { n: formatQuantity(quantity), min: formatQuantity(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 = labels[max.unit] const words = { field, distance: `${formatTicks(distance.value, { signed: false })} ${unit}`, max: `${formatTicks(max.value, { signed: false })} ${unit}` } 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 out: Record<string, string> = {} for (const p of problems) if (!(p.field in out)) out[p.field] = p.message return out } -
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.
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, and use-hotkeys.
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. |
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. |
quickSizes |
readonly number[] |
No buttons | Quantities to select with a button or shortcut. |
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. |
acknowledged |
unknown |
None | A changed value triggers the acknowledgement ring. |
disabled |
boolean |
false |
Disables fields and buttons; stops actions and quick-size shortcuts. |
hotkeys |
boolean |
true |
Declares missing bindings in the nearest hotkey registry. |
labels |
Partial<TicketLabels> |
DEFAULT_TICKET_LABELS |
Built-in labels and validation messages. |
className |
string |
None | Classes on the outer group. |
TicketInstrument requires symbol: string and convention: InstrumentConvention; quantityStep?: number defaults to 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. 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 snapped to convention.tick, then whichever side exists. Without a parsed value or reference, stepping does nothing. Reference buttons format with formatPrice; see Keys for the modifier shortcuts' step.
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 | Selects the action for ticket.send and its key hint. |
checked |
boolean |
true |
Checks the draft and limits before calling run. |
destructive |
boolean |
false |
Uses the destructive button variant. |
The first allowed action marked primary wins; otherwise the first allowed action is primary. Checked actions require a quantity above zero and a non-null price for priced order types. Problems appear under the fields and prevent run. Use checked: false for an action such as cancel that needs neither draft validation nor limit checks.
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.”
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, without direction coloring. The initial value does not flash. Under prefers-reduced-motion, the ticket does not ring.
Keys
| Key | Binding | Effect |
|---|---|---|
mod+enter |
ticket.send |
Runs the primary allowed action, including its checks. |
mod+shift+x |
ticket.flip |
Swaps buy and sell. |
mod+up / mod+down |
ticket.tick-up / ticket.tick-down |
Steps the price by convention.tick from any field. |
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 missing TICKET_BINDINGS as editing bindings and removes its shared declarations when no ticket with hotkeys enabled uses them. Declare your own bindings or remap them to change keys or wording. The primary 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.
The flip and price-step shortcuts do not check disabled, and price-step shortcuts do not check whether the order type is priced. Those shortcuts use convention.tick, even when the quote field uses another quote basis or step.
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. Change the React key to start again, such as when opening an order for amendment. 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 optional commas. Blank, negative, fractional, or invalid input becomes null. 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?) |
TicketProblems: optional quantity and price messages, or {}. 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 labels; prices use formatPrice. |
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 the midpoint and then last. Distance uses ticks for a price basis and basis points for other quote bases.
- A
blockappears under its quantity or price field and disables checked actions. 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. The next activation of that same action runs it if checks still pass. Any draft update clears the confirmation; changes to the market or limits alone do not.
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) is exported: it prints a count, or millions such as 2.5mm when quantityUnit is "notional". Supply raw quantities such as 2_500_000, not 2.5. 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.
Tokens
The install adds up, down, and their -soft variants if absent. 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 v1.4.13.