InstrumentSearch
A search field that recognizes CUSIPs, ISINs, tickers, and coupon-and-maturity phrases, sends your search function the query and a recognition hint, and displays the results in your own command.
import { useState } from "react"
import { InstrumentSearch, 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))
}
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" }} />
<p role="status">Selected: {selected}</p>
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/instrument-search#v1.4.13
npx shadcn@latest add tradecn/ui/instrument-search#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/instrument-search#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/instrument-search#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 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 { useEffect, useId, useMemo, useRef, useState } from "react" import { Badge } from "@/components/ui/badge" import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" import { QUERY_KIND_LABELS, recognizeQuery, type QueryHint, type QueryKind } 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<{ 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({ query, hits }) } const timer = setTimeout(() => { search(query, hint, controller.signal).then(settle, () => 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.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 { search: InstrumentSearchFn onSelect: (hit: InstrumentHit, hint: QueryHint) => void /** 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 /** Show what the query was read as under the field. Default true. */ showHint?: boolean autoFocus?: boolean labels?: Partial<InstrumentSearchLabels> /** How a hit's row reads. Default: the symbol, the name, and the kind as a badge. */ renderHit?: (hit: InstrumentHit, hint: QueryHint) => React.ReactNode className?: string } /** 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 function InstrumentSearch({ search, onSelect, query: queryProp, onQueryChange, minLength, debounceMs, clearOnSelect = true, showHint = true, autoFocus, labels: labelsProp, renderHit, className }: InstrumentSearchProps) { const labels = { ...DEFAULT_INSTRUMENT_SEARCH_LABELS, ...labelsProp } const [ownQuery, setOwnQuery] = useState("") const query = queryProp ?? ownQuery const latest = useRef({ onSelect, onQueryChange }) useEffect(() => { latest.current = { onSelect, onQueryChange } }) const setQuery = (next: string) => { if (queryProp === undefined) setOwnQuery(next) latest.current.onQueryChange?.(next) } const { hint, hits, loading } = useInstrumentSearch(search, query, { minLength, debounceMs }) const hintId = useId() const kind: QueryKind = hint.kind const pick = (hit: InstrumentHit) => { latest.current.onSelect(hit, hint) if (clearOnSelect) setQuery("") } const showList = kind !== "empty" && query.trim().length >= (minLength ?? 1) return ( <div data-slot="tradecn-instrument-search" data-query-kind={kind === "empty" ? undefined : kind} className={cn("flex flex-col gap-1 text-xs lining-nums tabular-nums", className)}> {/* The consumer's command does the filtering off: the server answered the query, and every answer stays. */} <Command shouldFilter={false} className="rounded-md border border-border"> <CommandInput value={query} onValueChange={setQuery} placeholder={labels.placeholder} autoFocus={autoFocus} aria-describedby={showHint && kind !== "empty" ? hintId : undefined} className="h-8 text-xs" /> {showList && ( <CommandList> {loading && hits.length === 0 && <div className="px-2 py-1.5 text-muted-foreground">{labels.searching}</div>} {!loading && hits.length === 0 && <CommandEmpty>{fill(labels.empty, { query: query.trim() })}</CommandEmpty>} {hits.length > 0 && ( <CommandGroup heading={labels.results}> {hits.map((hit) => ( <CommandItem key={hit.id} value={hit.id} data-instrument-hit={hit.id} onSelect={() => pick(hit)}> {renderHit ? ( renderHit(hit, hint) ) : ( <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>} {matchedIdentifier(hit, hint) && <span className="font-(family-name:--tradecn-font-mono) text-muted-foreground lining-nums tabular-nums">{matchedIdentifier(hit, hint)}</span>} {hit.kind && ( <Badge variant="outline" className="ml-auto h-4 px-1 text-xs"> {hit.kind} </Badge> )} </span> )} </CommandItem> ))} </CommandGroup> )} </CommandList> )} </Command> {showHint && kind !== "empty" && ( <p id={hintId} data-instrument-hint={kind} className="px-1 text-muted-foreground"> {fill(labels.recognized, { kind: QUERY_KIND_LABELS[kind] })} {kind === "coupon-maturity" && hint.coupon !== undefined && " "} {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> )} </div> ) }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 { InstrumentSearch, 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))
}
function SearchInstrument() {
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" }} />
<p role="status">Selected: {selected}</p>
</div>
)
}
Type z to list the two supplied instruments, use the arrow keys to choose one, and press Enter. The field clears after selection; the readout keeps the selected symbol. A query such as ES shows the empty result.
The component recognizes the query and calls your promise-returning search function. This local lookup matches symbol prefixes. For a service request, use the supplied hint to choose the lookup and pass its abort signal to your request.
Recognition and cancellation
Choose a sample query or type your own. This lookup uses the recognized CUSIP or ISIN to find the equity, and numeric coupon and maturity fields to find the illustrative Treasury. Try 4.125 5/15/2034 as another spelling of 4 1/8 05/34. This fixture interprets two-digit years as 2000–2099; the recognizer itself does not expand them. An optional day or ticker must match when supplied.
The example waits 300 ms after the component's debounce to simulate a service request. Editing the query aborts the pending lookup and removes its timer. The controls set a controlled query; onQueryChange applies edits and the empty string after selection. The readout uses the hint received by onSelect.
import { useState } from "react"
import { InstrumentSearch, type InstrumentHit, type InstrumentSearchFn } from "@/components/ui/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" }} />
<p role="status">Selected: {selected}</p>
</div>
</>
)
}
Palette adapter
Install command-palette separately to use toSymbolAdapter(search) with its symbols prop. This example repeats the basic lookup in an inline go-bar. The adapter recognizes the query and forwards the palette's abort signal, then maps hits to symbol results. Instrument ids and identifiers are not part of those results.
The palette owns its query, debounce, selection and recent symbols. Focus the field and search z; selecting a result updates the readout and clears the field. This example declares no hotkey.
import { useState } from "react"
import { CommandPalette, 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" }} />
<p role="status">Selected: {selected}</p>
</div>
)
}
API Reference
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
search |
InstrumentSearchFn |
Required | Your asynchronous instrument lookup. |
onSelect |
(hit: InstrumentHit, hint: QueryHint) => void |
Required | Receives the selected hit and the current query's hint. |
query |
string |
Internal state, initially "" |
Controls the field when supplied; update it through onQueryChange. |
onQueryChange |
(query: string) => void |
Unset | Receives edits and the empty string when a selection clears the field. Also works in uncontrolled mode. |
minLength |
number |
1 |
Minimum trimmed query length for searching and showing the list. Blank input never searches. |
debounceMs |
number |
150 |
Delay before starting a search, in milliseconds. |
clearOnSelect |
boolean |
true |
Clears the query after calling onSelect. |
showHint |
boolean |
true |
Shows the recognized kind below nonblank input and connects it with aria-describedby. |
autoFocus |
boolean |
Unset | Passed to your CommandInput. |
labels |
Partial<InstrumentSearchLabels> |
DEFAULT_INSTRUMENT_SEARCH_LABELS |
Overrides the strings below. |
renderHit |
(hit: InstrumentHit, hint: QueryHint) => React.ReactNode |
Built-in row | Replaces a result row's contents. |
className |
string |
Unset | Styles the outer container. |
It knows no instruments
InstrumentSearchFn takes (query: string, hint: QueryHint, signal: AbortSignal) and returns Promise<readonly InstrumentHit[]>. The query reaches your function as typed, including its spaces and case; 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 can remain visible while a new request runs.
The searching label appears during the debounce and request when there is no stored result for that query. A rejected promise becomes an empty result and 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 default row. |
cusip |
string |
No | Shown when the query hint is cusip. |
isin |
string |
No | Shown when the query hint is isin. |
Empty optional strings are omitted from the default row. renderHit replaces these contents while keeping the command item's selection behavior.
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
Once the trimmed query reaches minLength, the list opens under the field. Up and Down move through 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. Results stay in server order. The recognition hint can appear before minLength is reached; showHint={false} hides it without changing recognition or the hint sent to your 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.
Two more helpers are exported 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, 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 search status. |
empty |
Nothing matches {query}. |
Empty or rejected search; {query} is the trimmed input. |
results |
Instruments |
Results group heading. |
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 v1.4.13.