InstrumentSearch
Composable instrument search with query recognition, debounced requests, and caller-owned results.
import { useState } from "react"
import { Badge } from "@/components/ui/badge"
import { CommandEmpty, CommandGroup } from "@/components/ui/command"
import { InstrumentSearch, InstrumentSearchContent, InstrumentSearchInput, InstrumentSearchList, InstrumentSearchItem, InstrumentSearchHint, useInstrumentSearchState, matchedIdentifier, type InstrumentHit, type InstrumentSearchFn } from "@/components/ui/instrument-search"
const instruments: InstrumentHit[] = [
{ id: "zn", symbol: "ZN", name: "T-Note future" },
{ id: "zb", symbol: "ZB", name: "T-Bond future" },
]
const search: InstrumentSearchFn = async (query) => instruments.filter((hit) => hit.symbol.startsWith(query.trim().toUpperCase()))
export default function InstrumentSearchDemo() {
const [selected, setSelected] = useState("None")
return <div className="min-h-56 w-sm max-w-full space-y-2 text-xs lining-nums tabular-nums">
<InstrumentSearch search={search} onSelect={(hit) => setSelected(hit.symbol)} labels={{ placeholder: "Search ZN or ZB" }}>
<InstrumentSearchContent>
<InstrumentSearchInput />
<InstrumentSearchList><InstrumentOptions /></InstrumentSearchList>
</InstrumentSearchContent>
<InstrumentSearchHint />
</InstrumentSearch>
<p role="status">Selected: {selected}</p>
</div>
}
export function InstrumentOptions() {
const { hits, loading, labels, emptyMessage } = useInstrumentSearchState()
if (loading) return <div className="px-2 py-1.5 text-muted-foreground">{labels.searching}</div>
if (!hits.length) return <CommandEmpty>{emptyMessage}</CommandEmpty>
return <CommandGroup heading={labels.results}>
{hits.map((hit) => <InstrumentSearchItem key={hit.id} hit={hit}><InstrumentHitContent hit={hit} /></InstrumentSearchItem>)}
</CommandGroup>
}
export function InstrumentHitContent({ hit }: { hit: InstrumentHit }) {
const { hint } = useInstrumentSearchState()
const identifier = matchedIdentifier(hit, hint)
return <span className="flex min-w-0 flex-1 items-center gap-2">
<span className="font-semibold">{hit.symbol}</span>
{hit.name && <span className="min-w-0 truncate text-muted-foreground">{hit.name}</span>}
{identifier && <span className="font-(family-name:--tradecn-font-mono) text-muted-foreground lining-nums tabular-nums">{identifier}</span>}
{hit.kind && <Badge variant="outline" className="ml-auto h-4 px-1 text-xs">{hit.kind}</Badge>}
</span>
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/instrument-search#v2.0.0
npx shadcn@latest add tradecn/ui/instrument-search#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/instrument-search#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/instrument-search#v2.0.0
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add badge commandnpx shadcn@latest add badge commandyarn dlx shadcn@latest add badge commandbunx --bun shadcn@latest add badge command -
Copy the files into your project:
components/ui/ instrument-search.tsx import { cn } from "cn" import { createContext, useCallback, useContext, useEffect, useId, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from "react" import { Command, CommandInput, CommandItem, CommandList } from "@/components/ui/command" import { QUERY_KIND_LABELS, recognizeQuery, type QueryHint } from "@/lib/instrument-query" // A typed field over the consumer's instrument search: it recognizes what was typed (a CUSIP with its // check digit, an ISIN with its Luhn digit, a ticker, a coupon-and-maturity phrase) and hands the // server the query with that hint, then lists what came back on the consumer's own `command`. // Keyboard first: type, arrows, Enter. It knows no instruments; the server does. export interface InstrumentHit { id: string /** The name the desk types: a ticker, a run's short form. */ symbol: string /** The long name, "T 4 1/8 05/15/34", "Apple Inc." */ name?: string /** Asset class or instrument type, drawn as a badge. */ kind?: string exchange?: string cusip?: string isin?: string } /** The consumer's search: the query as typed, what it looks like, and a signal that aborts when the query moves on. */ export type InstrumentSearchFn = (query: string, hint: QueryHint, signal: AbortSignal) => Promise<readonly InstrumentHit[]> export interface InstrumentSearchLabels { placeholder: string /** While a search is out. */ searching: string /** A search that came back with nothing. `{query}`. */ empty: string /** The list's heading. */ results: string /** Under the field, what the query was taken for. `{kind}`. */ recognized: string } export const DEFAULT_INSTRUMENT_SEARCH_LABELS: InstrumentSearchLabels = { placeholder: "Ticker, CUSIP, ISIN, or coupon and maturity", searching: "Searching…", empty: "Nothing matches {query}.", results: "Instruments", recognized: "Read as {kind}", } const NO_HITS: readonly InstrumentHit[] = [] const fill = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (_, key: string) => values[key] ?? "") /** The debounced, abortable search: only answers to the query on screen come back. */ export function useInstrumentSearch(search: InstrumentSearchFn | undefined, query: string, options: { minLength?: number; debounceMs?: number } = {}) { const minLength = options.minLength ?? 1 const debounceMs = options.debounceMs ?? 150 const hint = useMemo(() => recognizeQuery(query), [query]) const [found, setFound] = useState<{ search?: InstrumentSearchFn; query: string; hits: readonly InstrumentHit[] }>({ query: "", hits: NO_HITS }) const active = search !== undefined && hint.kind !== "empty" && query.trim().length >= minLength useEffect(() => { if (!search || !active) return const controller = new AbortController() const settle = (hits: readonly InstrumentHit[]) => { if (!controller.signal.aborted) setFound({ search, query, hits }) } const timer = setTimeout(async () => { try { settle(await search(query, hint, controller.signal)) } catch { settle(NO_HITS) } }, debounceMs) return () => { clearTimeout(timer) controller.abort() } }, [search, active, query, hint, debounceMs]) // Only answers to the query on screen. Enter on a row left over from three letters ago is how the wrong instrument gets loaded. const current = active && found.search === search && found.query === query return { hint, hits: current ? found.hits : NO_HITS, loading: active && !current } } /** An adapter the command palette's `symbols` takes, over the same search function. */ export function toSymbolAdapter(search: InstrumentSearchFn, options: { minLength?: number; debounceMs?: number } = {}) { return { search: async (query: string, signal: AbortSignal) => { const hits = await search(query, recognizeQuery(query), signal) return hits.map((hit) => ({ symbol: hit.symbol, name: hit.name, exchange: hit.exchange, kind: hit.kind })) }, minLength: options.minLength, debounceMs: options.debounceMs, } } export interface InstrumentSearchProps extends Omit<ComponentProps<"div">, "children" | "onSelect" | "autoFocus"> { search: InstrumentSearchFn onSelect: (hit: InstrumentHit, hint: QueryHint) => void children: ReactNode /** Controlled query, with `onQueryChange`. */ query?: string onQueryChange?: (query: string) => void /** Default 1. */ minLength?: number /** Default 150. */ debounceMs?: number /** Clear the field after a pick. Default true. */ clearOnSelect?: boolean labels?: Partial<InstrumentSearchLabels> } /** The identifier on a hit that the query named, for the row to show beside the name. */ export function matchedIdentifier(hit: InstrumentHit, hint: QueryHint): string | null { if (hint.kind === "cusip" && hit.cusip) return hit.cusip if (hint.kind === "isin" && hit.isin) return hit.isin return null } export interface InstrumentSearchState { query: string setQuery: (query: string) => void hint: QueryHint hits: readonly InstrumentHit[] /** The nonblank query meets minLength. */ active: boolean loading: boolean labels: InstrumentSearchLabels emptyMessage: string /** Select the currently offered hit with this id. Stale or missing ids do nothing. */ select: (hit: InstrumentHit) => void } interface SearchContextState extends InstrumentSearchState { descriptions: readonly string[] registerDescription: (id: string) => () => void } const SearchContext = createContext<SearchContextState | null>(null) function useSearchContext() { const state = useContext(SearchContext) if (!state) throw new Error("InstrumentSearch parts require InstrumentSearch") return state } /** Share the root's reading and commands without starting another search. */ export function useInstrumentSearchState(): InstrumentSearchState { return useSearchContext() } export function InstrumentSearch({ search, onSelect, children, query: queryProp, onQueryChange, minLength = 1, debounceMs, clearOnSelect = true, labels: labelsProp, className, ...props }: InstrumentSearchProps) { const labels = { ...DEFAULT_INSTRUMENT_SEARCH_LABELS, ...labelsProp } const [ownQuery, setOwnQuery] = useState("") const query = queryProp ?? ownQuery const { hint, hits, loading } = useInstrumentSearch(search, query, { minLength, debounceMs }) const active = hint.kind !== "empty" && query.trim().length >= minLength const latest = useRef({ queryProp, onSelect, onQueryChange, clearOnSelect, hint, hits }) useInsertionEffect(() => { latest.current = { queryProp, onSelect, onQueryChange, clearOnSelect, hint, hits } }) const setQuery = useCallback((next: string) => { const current = latest.current if (current.queryProp === undefined) setOwnQuery(next) current.onQueryChange?.(next) }, []) const select = useCallback((hit: InstrumentHit) => { const current = latest.current const offered = current.hits.find((candidate) => candidate.id === hit.id) if (!offered) return current.onSelect(offered, current.hint) if (current.clearOnSelect) setQuery("") }, [setQuery]) const [descriptions, setDescriptions] = useState<string[]>([]) const registerDescription = useCallback((id: string) => { setDescriptions((ids) => [...ids, id]) return () => setDescriptions((ids) => { const index = ids.indexOf(id) return index < 0 ? ids : [...ids.slice(0, index), ...ids.slice(index + 1)] }) }, []) return ( <SearchContext value={{ query, setQuery, hint, hits, active, loading, labels, emptyMessage: fill(labels.empty, { query: query.trim() }), select, descriptions, registerDescription }}> <div {...props} data-slot="tradecn-instrument-search" data-query-kind={hint.kind === "empty" ? undefined : hint.kind} className={cn("flex flex-col gap-1 text-xs lining-nums tabular-nums", className)}>{children}</div> </SearchContext> ) } export type InstrumentSearchContentProps = Omit<ComponentProps<typeof Command>, "children" | "shouldFilter" | "filter"> & { children: ReactNode } /** The installed command owns keyboard navigation. Server results are never filtered again. */ export function InstrumentSearchContent({ children, className, label = "Instrument search", ...props }: InstrumentSearchContentProps) { useSearchContext() return <Command {...props} label={label} shouldFilter={false} className={cn("rounded-md border border-border", className)}>{children}</Command> } type InputOwnedProps = "value" | "defaultValue" | "onValueChange" | "onChange" | "id" | "type" | "role" | "autoComplete" | "autoCorrect" | "spellCheck" | "aria-autocomplete" | "aria-expanded" | "aria-controls" | "aria-labelledby" | "aria-label" | "aria-activedescendant" export type InstrumentSearchInputProps = Omit<ComponentProps<typeof CommandInput>, InputOwnedProps> & Partial<Record<InputOwnedProps, never>> export function InstrumentSearchInput({ className, "aria-describedby": describedBy, ...props }: InstrumentSearchInputProps) { const { query, setQuery, labels, descriptions } = useSearchContext() const ids = [...new Set([describedBy, ...descriptions].flatMap((id) => id?.split(/\s+/).filter(Boolean) ?? []))] return <CommandInput placeholder={labels.placeholder} {...props} value={query} onValueChange={setQuery} aria-describedby={ids.join(" ") || undefined} className={cn("h-8 text-xs", className)} /> } type ListOwnedProps = "id" | "role" | "tabIndex" | "aria-activedescendant" | "aria-label" export type InstrumentSearchListProps = Omit<ComponentProps<typeof CommandList>, ListOwnedProps | "children"> & Partial<Record<ListOwnedProps, never>> & { children: ReactNode } export function InstrumentSearchList({ children, ...props }: InstrumentSearchListProps) { const { active } = useSearchContext() // cmdk exposes an expanded inline combobox, so retain its listbox target even before a query. return <CommandList {...props}>{active ? children : null}</CommandList> } type ItemOwnedProps = "value" | "onSelect" | "onClick" | "onPointerMove" | "id" | "role" | "aria-disabled" | "aria-selected" export type InstrumentSearchItemProps = Omit<ComponentProps<typeof CommandItem>, ItemOwnedProps | "children"> & Partial<Record<ItemOwnedProps, never>> & { hit: InstrumentHit; children: ReactNode } export function InstrumentSearchItem({ hit, children, disabled, ...props }: InstrumentSearchItemProps) { const { select } = useSearchContext() return <CommandItem {...props} disabled={disabled} value={hit.id} data-instrument-hit={hit.id} onSelect={() => { if (!disabled) select(hit) }}>{children}</CommandItem> } export function InstrumentSearchHint({ id: idProp, children, className, ...props }: ComponentProps<"p">) { const { hint, labels, registerDescription } = useSearchContext() const generatedId = useId() const id = idProp ?? generatedId const shown = hint.kind !== "empty" useLayoutEffect(() => { if (shown) return registerDescription(id) }, [shown, id, registerDescription]) if (!shown) return null return ( <p {...props} id={id} data-slot="tradecn-instrument-hint" data-instrument-hint={hint.kind} className={cn("px-1 text-muted-foreground lining-nums tabular-nums", className)}> {children !== undefined ? children : <> {fill(labels.recognized, { kind: QUERY_KIND_LABELS[hint.kind] })} {hint.kind === "coupon-maturity" && hint.coupon !== undefined && <> <span className="font-(family-name:--tradecn-font-mono) lining-nums tabular-nums">{hint.ticker ? `${hint.ticker} ` : ""}{hint.coupon} {hint.maturity}</span></>} </>} </p> ) }lib/instrument-query.ts // What was typed into an instrument search, recognized before it goes to the server: a CUSIP, an ISIN, a // ticker, or a coupon-and-maturity phrase the way a run prints it, "4 1/8 05/34". The recognizers are pure // and check what can be checked here (the CUSIP's modulus-10 digit, the ISIN's Luhn digit); what an // identifier names is the server's to say. export type QueryKind = "cusip" | "isin" | "coupon-maturity" | "ticker" | "text" | "empty" export interface QueryHint { kind: QueryKind /** The query with its spaces and case settled: a CUSIP or ISIN upper-cased, a ticker upper-cased. */ normalized: string cusip?: string isin?: string ticker?: string /** The coupon as a number, 4.125 for "4 1/8". */ coupon?: number /** The maturity as typed, settled to MM/YY or MM/DD/YY or the full date, whichever was given. */ maturity?: string /** The maturity's month, day (when given), and two- or four-digit year, as numbers. */ maturityParts?: { month: number; day?: number; year: number } } const CUSIP_VALUES: Record<string, number> = { "*": 36, "@": 37, "#": 38 } /** A character's value in a CUSIP: digits as they are, letters 10 to 35, then *, @, #. */ function cusipValue(ch: string): number | null { if (/[0-9]/.test(ch)) return Number(ch) if (/[A-Z]/.test(ch)) return ch.charCodeAt(0) - 55 return CUSIP_VALUES[ch] ?? null } /** Nine characters whose ninth is the modulus-10 double-add-double check digit of the first eight. */ export function isCusip(text: string): boolean { const s = text.trim().toUpperCase() if (!/^[0-9A-Z*@#]{8}[0-9]$/.test(s)) return false let sum = 0 for (let i = 0; i < 8; i++) { let v = cusipValue(s[i]!) if (v === null) return false if (i % 2 === 1) v *= 2 sum += Math.floor(v / 10) + (v % 10) } return (10 - (sum % 10)) % 10 === Number(s[8]) } /** Twelve characters, two letters of country, nine of identifier, and a Luhn check digit over the letters expanded to numbers. */ export function isIsin(text: string): boolean { const s = text.trim().toUpperCase() if (!/^[A-Z]{2}[0-9A-Z]{9}[0-9]$/.test(s)) return false const digits = [...s].map((ch) => (/[0-9]/.test(ch) ? ch : String(ch.charCodeAt(0) - 55))).join("") let sum = 0 let double = false for (let i = digits.length - 1; i >= 0; i--) { let d = Number(digits[i]) if (double) { d *= 2 if (d > 9) d -= 9 } sum += d double = !double } return sum % 10 === 0 } const EIGHTHS: Record<string, number> = { "1/8": 0.125, "1/4": 0.25, "3/8": 0.375, "1/2": 0.5, "5/8": 0.625, "3/4": 0.75, "7/8": 0.875 } /** A coupon as a run prints it, "4 1/8", "4.125", "4", "4½" spelled with a slash: the number, or null. */ export function parseCoupon(text: string): number | null { const s = text.trim() const mixed = /^(\d{1,2})(?:\s+(\d\/\d))?$/.exec(s) if (mixed) { const whole = Number(mixed[1]) if (!mixed[2]) return whole const eighth = EIGHTHS[mixed[2]] return eighth === undefined ? null : whole + eighth } const fraction = /^(\d\/\d)$/.exec(s) if (fraction) return EIGHTHS[fraction[1]!] ?? null const decimal = /^(\d{1,2}(?:\.\d{1,3})?)%?$/.exec(s) if (decimal) return Number(decimal[1]) return null } /** A maturity as typed after a coupon: MM/YY, MM/YYYY, MM/DD/YY, MM/DD/YYYY, or an ISO date. */ export function parseMaturity(text: string): { month: number; day?: number; year: number; text: string } | null { const s = text.trim() const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s) if (iso) { const [, y, m, d] = iso return { month: Number(m), day: Number(d), year: Number(y), text: s } } const slashed = /^(\d{1,2})\/(?:(\d{1,2})\/)?(\d{2}|\d{4})$/.exec(s) if (!slashed) return null const month = Number(slashed[1]) const day = slashed[2] === undefined ? undefined : Number(slashed[2]) const year = Number(slashed[3]) if (month < 1 || month > 12 || (day !== undefined && (day < 1 || day > 31))) return null return { month, day, year, text: s } } /** "4 1/8 05/34", "4.125 5/15/2034", "T 4 1/8 05/15/34": a coupon and a maturity, or null. A leading word is a prefix the server may know. */ export function parseCouponMaturity(text: string): { coupon: number; maturity: string; maturityParts: { month: number; day?: number; year: number }; prefix?: string } | null { const words = text.trim().split(/\s+/) if (words.length < 2) return null const maturityWord = words[words.length - 1]! const maturity = parseMaturity(maturityWord) if (!maturity) return null const rest = words.slice(0, -1) // The coupon is the last one or two words before the maturity; anything before that is a prefix. for (const take of [2, 1]) { if (rest.length < take) continue const coupon = parseCoupon(rest.slice(rest.length - take).join(" ")) if (coupon === null) continue const prefix = rest.slice(0, rest.length - take).join(" ") return { coupon, maturity: maturity.text, maturityParts: { month: maturity.month, day: maturity.day, year: maturity.year }, ...(prefix ? { prefix } : {}) } } return null } /** What the query looks like. Checked identifiers first, then a coupon-and-maturity phrase, then a ticker (letters and digits, up to twelve, one optional dot or dash), else text. */ export function recognizeQuery(text: string): QueryHint { const trimmed = text.trim().replace(/\s+/g, " ") if (!trimmed) return { kind: "empty", normalized: "" } const upper = trimmed.toUpperCase() if (isCusip(upper)) return { kind: "cusip", normalized: upper, cusip: upper } if (isIsin(upper)) return { kind: "isin", normalized: upper, isin: upper } const run = parseCouponMaturity(trimmed) if (run) return { kind: "coupon-maturity", normalized: trimmed, coupon: run.coupon, maturity: run.maturity, maturityParts: run.maturityParts, ...(run.prefix ? { ticker: run.prefix.toUpperCase() } : {}) } if (/^[A-Z][A-Z0-9]{0,11}(?:[.-][A-Z0-9]{1,4})?$/.test(upper)) return { kind: "ticker", normalized: upper, ticker: upper } return { kind: "text", normalized: trimmed } } export const QUERY_KIND_LABELS: Record<QueryKind, string> = { cusip: "CUSIP", isin: "ISIN", "coupon-maturity": "Coupon and maturity", ticker: "Ticker", text: "Text", empty: "", } -
Add the tokens to your stylesheet:
:root { --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; } .dark { --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; } -
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 { Badge } from "@/components/ui/badge"
import { CommandEmpty, CommandGroup } from "@/components/ui/command"
import { InstrumentSearch, InstrumentSearchContent, InstrumentSearchInput, InstrumentSearchList, InstrumentSearchItem, InstrumentSearchHint, useInstrumentSearchState, matchedIdentifier, type InstrumentHit, type InstrumentSearchFn } from "@/components/ui/instrument-search"
const instruments: InstrumentHit[] = [
{ id: "zn", symbol: "ZN", name: "T-Note future" },
{ id: "zb", symbol: "ZB", name: "T-Bond future" },
]
const search: InstrumentSearchFn = async (query) => instruments.filter((hit) => hit.symbol.startsWith(query.trim().toUpperCase()))
export default function InstrumentSearchDemo() {
const [selected, setSelected] = useState("None")
return <div className="min-h-56 w-sm max-w-full space-y-2 text-xs lining-nums tabular-nums">
<InstrumentSearch search={search} onSelect={(hit) => setSelected(hit.symbol)} labels={{ placeholder: "Search ZN or ZB" }}>
<InstrumentSearchContent>
<InstrumentSearchInput />
<InstrumentSearchList><InstrumentOptions /></InstrumentSearchList>
</InstrumentSearchContent>
<InstrumentSearchHint />
</InstrumentSearch>
<p role="status">Selected: {selected}</p>
</div>
}
export function InstrumentOptions() {
const { hits, loading, labels, emptyMessage } = useInstrumentSearchState()
if (loading) return <div className="px-2 py-1.5 text-muted-foreground">{labels.searching}</div>
if (!hits.length) return <CommandEmpty>{emptyMessage}</CommandEmpty>
return <CommandGroup heading={labels.results}>
{hits.map((hit) => <InstrumentSearchItem key={hit.id} hit={hit}><InstrumentHitContent hit={hit} /></InstrumentSearchItem>)}
</CommandGroup>
}
export function InstrumentHitContent({ hit }: { hit: InstrumentHit }) {
const { hint } = useInstrumentSearchState()
const identifier = matchedIdentifier(hit, hint)
return <span className="flex min-w-0 flex-1 items-center gap-2">
<span className="font-semibold">{hit.symbol}</span>
{hit.name && <span className="min-w-0 truncate text-muted-foreground">{hit.name}</span>}
{identifier && <span className="font-(family-name:--tradecn-font-mono) text-muted-foreground lining-nums tabular-nums">{identifier}</span>}
{hit.kind && <Badge variant="outline" className="ml-auto h-4 px-1 text-xs">{hit.kind}</Badge>}
</span>
}
Type z, use the arrow keys to choose a result, and press Enter. Selection clears the query; the readout retains the chosen symbol.
Save the complete example as instrument-search.tsx outside components/ui to reuse InstrumentOptions and InstrumentHitContent. The installation includes the command and badge primitives used by this recipe.
Composition
Use the following composition to build an InstrumentSearch:
InstrumentSearch
├── InstrumentSearchContent
│ ├── InstrumentSearchInput
│ └── InstrumentSearchList
│ ├── Loading or empty content
│ └── CommandGroup
│ └── InstrumentSearchItem
└── InstrumentSearchHint
The root shares one search and selection implementation. Put application controls around Content, move or omit Hint, and write your own result collection with useInstrumentSearchState. Keep one Input and List inside each Content.
Recognition and cancellation
Use the same parts with a controlled query and an abortable service request. Save InstrumentOptions from Usage in ./instrument-search.tsx first.
This example adds a 300 ms request delay after the debounce. Its local lookup matches recognized identifiers or numeric coupon and maturity fields. It treats two-digit years as 2000–2099; the recognizer preserves the written year. An optional day or ticker must match when supplied.
The install writes the shared font tokens and the hyperlegible remap into the stylesheet where missing, since the hint prints in the mono font.
import { useState } from "react"
import { InstrumentSearch, InstrumentSearchContent, InstrumentSearchInput, InstrumentSearchList, InstrumentSearchHint, type InstrumentHit, type InstrumentSearchFn } from "@/components/ui/instrument-search"
import { InstrumentOptions } from "./instrument-search"
type SampleInstrument = InstrumentHit & {
ticker?: string
coupon?: number
maturity?: { month: number; day: number; year: number }
}
const instruments: SampleInstrument[] = [
{ id: "aapl", symbol: "AAPL", cusip: "037833100", isin: "US0378331005" },
{ id: "treasury", symbol: "T 4 1/8 05/34", ticker: "T", coupon: 4.125, maturity: { month: 5, day: 15, year: 2034 } },
]
const queries = [
{ label: "CUSIP example", query: "037833100" },
{ label: "ISIN example", query: "US0378331005" },
{ label: "Coupon example", query: "4 1/8 05/34" },
]
const search: InstrumentSearchFn = async (query, hint, signal) => {
// A delayed local lookup stands in for a request to your instrument service.
await new Promise<void>((resolve, reject) => {
signal.throwIfAborted()
const timer = setTimeout(() => {
signal.removeEventListener("abort", abort)
resolve()
}, 300)
function abort() {
clearTimeout(timer)
reject(signal.reason)
}
signal.addEventListener("abort", abort, { once: true })
})
signal.throwIfAborted()
if (hint.kind === "cusip") return instruments.filter((hit) => hit.cusip === hint.cusip)
if (hint.kind === "isin") return instruments.filter((hit) => hit.isin === hint.isin)
if (hint.kind === "coupon-maturity" && hint.maturityParts) {
const { month, day, year } = hint.maturityParts
const shortYear = /\/\d{2}$/.test(hint.maturity ?? "")
const fullYear = shortYear ? 2000 + year : year
return instruments.filter((hit) => hit.coupon === hint.coupon && hit.maturity?.month === month && hit.maturity.year === fullYear && (day === undefined || hit.maturity.day === day) && (!hint.ticker || hit.ticker === hint.ticker))
}
return instruments.filter((hit) => hit.symbol.startsWith(query.trim().toUpperCase()))
}
export default function InstrumentSearchHintsDemo() {
const [query, setQuery] = useState("037833100")
const [selected, setSelected] = useState("None")
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
{queries.map((example) => <button key={example.label} type="button" className="rounded border border-border px-2 py-1" onClick={() => setQuery(example.query)}>{example.label}</button>)}
</div>
<div className="min-h-56 w-sm max-w-full space-y-2 text-xs lining-nums tabular-nums">
<InstrumentSearch search={search} query={query} onQueryChange={setQuery} onSelect={(hit, hint) => setSelected(`${hit.symbol} (${hint.kind})`)} labels={{ placeholder: "Identifier or coupon and maturity" }}>
<InstrumentSearchContent>
<InstrumentSearchInput />
<InstrumentSearchList><InstrumentOptions /></InstrumentSearchList>
</InstrumentSearchContent>
<InstrumentSearchHint />
</InstrumentSearch>
<p role="status">Selected: {selected}</p>
</div>
</>
)
}
Custom layout
Move the input and hint beside a reordered result list. The clear action shares query state and returns focus to the input; selection retains the query.
import { useRef, useState, type RefObject } from "react"
import { CommandEmpty, CommandGroup } from "@/components/ui/command"
import { InstrumentSearch, InstrumentSearchContent, InstrumentSearchInput, InstrumentSearchList, InstrumentSearchItem, InstrumentSearchHint, useInstrumentSearchState, type InstrumentHit, type InstrumentSearchFn } from "@/components/ui/instrument-search"
const instruments: InstrumentHit[] = [
{ id: "zn", symbol: "ZN", name: "T-Note future", exchange: "CBOT" },
{ id: "zb", symbol: "ZB", name: "T-Bond future", exchange: "CBOT" },
]
const search: InstrumentSearchFn = async (query) => instruments.filter((hit) => hit.symbol.startsWith(query.trim().toUpperCase()))
export default function InstrumentSearchLayoutDemo() {
const [query, setQuery] = useState("z")
const [selected, setSelected] = useState("None")
const input = useRef<HTMLInputElement>(null)
return (
<InstrumentSearch search={search} query={query} onQueryChange={setQuery} onSelect={(hit) => setSelected(hit.symbol)} clearOnSelect={false} className="min-h-56 w-xl max-w-full">
<div className="flex flex-wrap items-center justify-between gap-2">
<div><h3 className="font-semibold">Choose a contract</h3><p role="status" className="text-muted-foreground">Selected: {selected}</p></div>
<ClearQuery input={input} />
</div>
<InstrumentSearchContent label="Choose a contract" className="grid gap-3 p-3 sm:grid-cols-[12rem_minmax(0,1fr)]">
<div className="min-w-0 space-y-2"><InstrumentSearchInput ref={input} /><InstrumentSearchHint /></div>
<InstrumentSearchList label="Contracts" className="min-w-0"><ContractOptions /></InstrumentSearchList>
</InstrumentSearchContent>
</InstrumentSearch>
)
}
function ClearQuery({ input }: { input: RefObject<HTMLInputElement | null> }) {
const { setQuery } = useInstrumentSearchState()
return <button type="button" className="rounded border border-border px-2 py-1" onClick={() => { setQuery(""); input.current?.focus() }}>Clear search</button>
}
function ContractOptions() {
const { hits, loading, labels, emptyMessage } = useInstrumentSearchState()
if (loading) return <div className="text-muted-foreground">{labels.searching}</div>
if (!hits.length) return <CommandEmpty>{emptyMessage}</CommandEmpty>
return <CommandGroup heading="Contracts">
{hits.toReversed().map((hit) => <InstrumentSearchItem key={hit.id} hit={hit} className="items-start">
<span className="min-w-0 flex-1"><span className="block text-muted-foreground">{hit.name}</span><strong>{hit.symbol}</strong></span>
<span>{hit.exchange}</span>
</InstrumentSearchItem>)}
</CommandGroup>
}
Palette adapter
Install command-palette separately to use toSymbolAdapter(search) with its symbols prop. Create the adapter once, at module scope or with useMemo: the palette keys its request and results on the adapter's identity, so an inline call aborts the request and hides results on every parent render. The adapter forwards the recognized query and abort signal; the palette owns debounce, selection and recents.
This standalone example searches the same two symbols in an inline go-bar and declares no hotkey. Instrument IDs and identifiers are not part of the adapter's results: the palette identifies a row by symbol plus exchange, so keep that pair unique across your reference data, or two hits collide and a pick can load the other instrument.
import { CommandGroup } from "@/components/ui/command"
import { useState } from "react"
import { CommandPalette, CommandPaletteContent, CommandPaletteEmpty, CommandPaletteInput, CommandPaletteItem, CommandPaletteList, CommandPaletteResults, useCommandPalette, createActionRegistry } from "@/components/ui/command-palette"
import { toSymbolAdapter, type InstrumentHit, type InstrumentSearchFn } from "@/components/ui/instrument-search"
const instruments: InstrumentHit[] = [
{ id: "zn", symbol: "ZN", name: "T-Note future" },
{ id: "zb", symbol: "ZB", name: "T-Bond future" },
]
const search: InstrumentSearchFn = async (query) => {
const prefix = query.trim().toUpperCase()
return instruments.filter((hit) => hit.symbol.startsWith(prefix))
}
const symbols = toSymbolAdapter(search)
export default function InstrumentSearchPaletteDemo() {
const [actions] = useState(() => createActionRegistry())
const [selected, setSelected] = useState("None")
return (
<div className="min-h-64 w-sm max-w-full space-y-2 text-xs lining-nums tabular-nums">
<CommandPalette variant="go-bar" actions={actions} symbols={symbols} hotkey={false} onSymbolSelect={(hit) => setSelected(hit.symbol)} labels={{ title: "Find an instrument", placeholder: "Search ZN or ZB" }}>
<CommandPaletteContent>
<CommandPaletteInput />
<CommandPaletteList><PaletteResults /></CommandPaletteList>
</CommandPaletteContent>
</CommandPalette>
<p role="status">Selected: {selected}</p>
</div>
)
}
function PaletteResults() {
const { loading } = useCommandPalette()
return (
<>
<CommandPaletteEmpty>{loading ? "Searching…" : "No results"}</CommandPaletteEmpty>
<CommandPaletteResults>
{(group) => (
<CommandGroup heading={group.heading}>
{(group.id === "recent" ? group.rows.slice(0, 5) : group.rows).map((row) => (
<CommandPaletteItem key={row.key} row={row}>
<span className="truncate">{row.title}</span>
{row.subtitle && <span className="truncate text-muted-foreground">{row.subtitle}</span>}
</CommandPaletteItem>
))}
</CommandGroup>
)}
</CommandPaletteResults>
</>
)
}
API Reference
Props
InstrumentSearch accepts native div props and refs, with required children. Its onSelect is the instrument callback, not the native text-selection event.
| Prop | Type | Default | Purpose |
|---|---|---|---|
search |
InstrumentSearchFn |
Required | Your asynchronous instrument lookup. |
onSelect |
(hit, hint) => void |
Required | Receives the current offered hit and query hint. |
children |
ReactNode |
Required | Search parts and application content. |
query |
string |
Internal "" |
Controlled query. |
onQueryChange |
(query: string) => void |
Unset | Edits and clear requests, including uncontrolled mode. |
minLength |
number |
1 |
Minimum trimmed query length; blank input never searches. |
debounceMs |
number |
150 |
Delay before a request, in milliseconds. |
clearOnSelect |
boolean |
true |
Requests an empty query after selection. |
labels |
Partial<InstrumentSearchLabels> |
See Labels | Field, recipe and recognition text. |
className |
string |
Unset | Additional classes to apply to the root. |
The full selection signature is (hit: InstrumentHit, hint: QueryHint) => void. The callback runs before a clear request. In controlled mode, the parent must update query; omitting onQueryChange leaves displayed input under the parent's control.
InstrumentSearchContent
Wraps your installed Command, with required children and shouldFilter={false}. Server results are not filtered again. Native props, refs, classes and command options such as loop remain available; filter and shouldFilter belong to the search integration.
label defaults to "Instrument search" and supplies the input's accessible name through cmdk's hidden label. Set it to a meaningful localized name. A placeholder is separate from that name.
InstrumentSearchInput
Wraps CommandInput and binds value and edits to the root query. Pass autoFocus, disabled, placeholder, refs, classes and supported input events here. The default placeholder comes from labels.placeholder; root onQueryChange receives edits.
cmdk owns the input ID, text type, combobox role, autocomplete settings, selection ARIA and naming association. These overridden props are excluded from the public type. Use Content's label to name the field and the input ref to focus it. aria-describedby is merged with mounted Hint IDs, with duplicates removed.
InstrumentSearchList
Wraps CommandList, with required children. It remains mounted when the query is inactive, but renders its children only once the nonblank query meets minLength. This retains the inline combobox's list target without showing stale results.
label defaults to "Suggestions" and names the listbox. Pass refs, classes and native events; cmdk owns its ID, role, tab index, active descendant and aria-label.
InstrumentSearchItem
Takes a required hit: InstrumentHit and required children: ReactNode. It binds the command item's value and selection to hit.id; use a unique ID for each offered hit. Selection resolves the ID against the latest committed results and sends that current record to onSelect. A stale or missing ID does nothing.
Pass disabled, refs, classes and supported native events. cmdk owns the item's ID, option role, selected/disabled ARIA, click and pointer-move handlers; these overrides are excluded from the public type. Keep row content noninteractive and place separate application actions outside Content.
InstrumentSearchHint
A paragraph showing the recognized query kind, with parsed coupon and maturity when present. It accepts native paragraph props, refs and classes; children replace its default text. It renders nothing for a blank query.
Each mounted nonblank Hint supplies its unique ID, or your id, to the root's inputs. Association survives reordering and portals and is removed when the Hint is omitted. CSS-hidden referenced descriptions retain native ARIA behavior; omit the part when you want no description. Keep custom IDs unique.
useInstrumentSearchState
Reads the nearest root without starting another request. Use it for your own statuses, result order or controls.
| Field | Type | Purpose |
|---|---|---|
query |
string |
Current input. |
setQuery |
(query: string) => void |
Apply or request a query change. |
hint |
QueryHint |
Recognition of the current query. |
hits |
readonly InstrumentHit[] |
Results for this query and search function. |
active |
boolean |
Nonblank query meets minLength. |
loading |
boolean |
Active search without a matching stored result. |
labels |
InstrumentSearchLabels |
Merged default and caller labels. |
emptyMessage |
string |
Empty label with the trimmed query substituted. |
select |
(hit: InstrumentHit) => void |
Select the current offered record with this ID. |
setQuery and select retain their identity and use committed callbacks and settings. Custom controls own their disabled state and focus destination.
It knows no instruments
InstrumentSearchFn takes (query: string, hint: QueryHint, signal: AbortSignal) and returns Promise<readonly InstrumentHit[]>. The query reaches your function as typed from the root, including its spaces and case; through the palette it arrives trimmed. The hint contains the recognized form.
The field waits for the debounce before searching. A query change cancels the pending timer and aborts the previous request; unmounting does the same. Responses from an aborted request are ignored even if your search does not honor the signal. Stored hits appear only when their query string equals the current input, so rows from a different query cannot be selected. A stored result for the exact same query and search function can remain visible while a new request runs. Replacing the search function removes the previous provider's hits immediately.
Keep search stable: define it at module scope or memoize it with useCallback. An inline function changes identity on each parent render and restarts the search. Standalone useInstrumentSearch cannot settle visible results when each render supplies a new function.
The searching label appears during the debounce and request when there is no stored result for that query. A rejected promise or synchronous throw becomes an empty result; the Usage recipe shows the empty label. There is no separate error display.
InstrumentHit
| Field | Type | Required | Use |
|---|---|---|---|
id |
string |
Yes | Unique row key and command-item value. |
symbol |
string |
Yes | Ticker or short form shown at the start of the row. |
name |
string |
No | Long name shown after the symbol. |
kind |
string |
No | Asset class or instrument type shown as a badge. |
exchange |
string |
No | Passed through the symbol adapter; not shown by the Usage recipe. |
cusip |
string |
No | Shown when the query hint is cusip. |
isin |
string |
No | Shown when the query hint is isin. |
The Usage recipe omits empty optional strings. Replace the children of InstrumentSearchItem to change the row while retaining current-hit selection.
What was typed
recognizeQuery(text) comes from @/lib/instrument-query, installed alongside the component. It trims the input, collapses whitespace, and returns the first matching kind in this order:
| Kind | Recognition | Hint fields beyond kind and normalized |
|---|---|---|
empty |
Blank or whitespace-only input. | None; normalized is "". |
cusip |
Nine characters with a valid modulus-10 check digit. | cusip, uppercase. |
isin |
Twelve characters with the expected identifier syntax and a valid Luhn check digit. | isin, uppercase. |
coupon-maturity |
A coupon followed by a maturity, optionally prefixed: 4 1/8 05/34, 4.125 5/15/2034, or T 4 1/8 05/15/34. |
Numeric coupon, maturity, maturityParts, and uppercase ticker when a prefix is present. |
ticker |
A letter followed by up to 11 letters or digits, optionally a dot or dash and 1–4 more letters or digits: ZN, ESZ6, BRK.B. |
ticker, uppercase. |
text |
Anything else. | None. |
normalized is uppercase for identifiers and tickers. Coupon-and-maturity and text queries retain their case after whitespace cleanup. A failed check digit falls through to the remaining recognizers: 037833101 becomes text, while A37833101 becomes ticker. Recognition checks the identifier's format and digit; your server decides what it names.
QueryHint always has kind: QueryKind and normalized: string. Its optional identifier and ticker fields are strings. coupon is a number in percentage points (4 1/8 becomes 4.125); maturity retains the date's written form. maturityParts is { month: number; day?: number; year: number }; the year is not expanded to a century (34, not 2034).
These pure helpers are also exported from instrument-query for other fields or paste handlers. Each takes a string:
| Helper | Result | Accepted input and limits |
|---|---|---|
isCusip(text) |
boolean |
Trims and uppercases; accepts eight letters, digits, *, @, or #, followed by the correct numeric check digit. |
isIsin(text) |
boolean |
Trims and uppercases; accepts two letters, nine letters or digits, and a numeric check digit that passes Luhn. |
parseCoupon(text) |
number | null |
Whole numbers or decimals with 1–2 whole digits, up to three decimal places, and an optional % suffix. Fractions are 1/8, 1/4, 3/8, 1/2, 5/8, 3/4, or 7/8, alone or after 1–2 whole digits; fractions cannot carry %. |
parseMaturity(text) |
{ month: number; day?: number; year: number; text: string } | null |
Slash forms M/YY, M/YYYY, M/D/YY, M/D/YYYY (one or two month/day digits), or YYYY-MM-DD. Returns the trimmed text unchanged. |
parseCouponMaturity(text) |
{ coupon: number; maturity: string; maturityParts: { month: number; day?: number; year: number }; prefix?: string } | null |
Takes the final word as maturity, then tries the preceding two words as a coupon before trying one. Any words left become prefix, preserving case; recognizeQuery exposes that prefix as uppercase ticker. |
Maturity parsing is not calendar validation. Slash dates require months 1–12 and days 1–31 but can still name impossible dates; ISO-shaped input receives no month or day range check.
Keyboard first
Results appear once the trimmed query reaches minLength. The inline command keeps an empty listbox mounted before then, so the input always has a valid list target. Up and Down move through the rendered results; Enter selects one and calls onSelect(hit, hint). The field clears after selection unless clearOnSelect={false}. In controlled mode, onQueryChange("") asks the parent to clear it; the parent must update query for the displayed value to change.
The list uses your command with filtering off. The Usage recipe retains server order; your composition can reorder or group hits. Place application buttons outside InstrumentSearchContent to keep command keyboard handling separate from their activation.
The recognition hint can appear before minLength is reached. Omit InstrumentSearchHint to remove its presentation and input association without changing recognition or the hint sent to callbacks.
The same search everywhere
toSymbolAdapter(search, options?) supplies command-palette's symbols prop. It recognizes each query and forwards the palette's abort signal to your search. Each hit becomes { symbol, name, exchange, kind }; id, cusip, and isin are dropped. Optional minLength and debounceMs pass through to the palette, which defaults to a minimum query length of 1 character and a debounce of 150 milliseconds. The adapter itself does not debounce or enforce a minimum length.
watchlist's onAdd and validate receive a symbol string. Run the asynchronous search in onAdd with recognizeQuery(symbol) and your own abort signal. Watchlist does not await onAdd; handle search failures in your callback. validate must return a synchronous boolean, so use it for immediate checks or cached results.
The standalone search and identifier helpers remain available from @/components/ui/instrument-search:
| Helper | Result | Behavior |
|---|---|---|
useInstrumentSearch(search, query, options?) |
{ hint, hits, loading } |
The component's search hook. search may be undefined; options are minLength (default 1) and debounceMs (default 150 milliseconds). An inactive search returns empty hits and loading: false, while still recognizing the query. loading is also false when stored results match the query and search function, even during a new request. |
matchedIdentifier(hit, hint) |
string | null |
Returns a nonempty hit.cusip for a cusip hint, or hit.isin for an isin hint; otherwise null. It does not compare that value with the query's identifier. |
Labels
Pass a partial labels object to override DEFAULT_INSTRUMENT_SEARCH_LABELS:
| Label | Default | Shown as |
|---|---|---|
placeholder |
Ticker, CUSIP, ISIN, or coupon and maturity |
Input placeholder. |
searching |
Searching… |
Pending text in the Usage recipe. |
empty |
Nothing matches {query}. |
Empty or failed search in the recipe; {query} is the trimmed input. |
results |
Instruments |
Results group heading in the recipe. |
recognized |
Read as {kind} |
Recognition hint; {kind} comes from QUERY_KIND_LABELS. |
QUERY_KIND_LABELS in instrument-query supplies CUSIP, ISIN, Coupon and maturity, Ticker, Text, and an empty string for empty. These words are separate from the labels overrides. Coupon-and-maturity hints append the parsed ticker (when present), coupon, and maturity after the recognized label.
What it does not do
The component does not resolve identifiers, maintain an instrument catalog, or decide what qualifies as a hit. Recents and hotkeys belong to the palette; a surrounding dialog is yours.
Built on shadcn's badge and command.
This page is docs/instrument-search.md at v2.0.0.