Search input
Search a list or choose a matching result, with clear feedback and keyboard interaction.
On this page
Details appear here when you choose a vessel.
Code
import { SearchSuggestions, type SearchSuggestion } from "@bosunkit/components/search-input";
import { Box } from "@bosunkit/components/box";
import { Stack } from "@bosunkit/components/stack";
import { Text } from "@bosunkit/components/text";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";
interface Vessel extends SearchSuggestion {
mmsi: string;
callSign: string;
}
const VESSELS: readonly Vessel[] = [
{ value: "windgrazer", label: "Windgrazer", mmsi: "244123456", callSign: "PCWY" },
{ value: "althea", label: "Althea", mmsi: "244234567", callSign: "PDAL" },
{ value: "northwind", label: "North Wind", mmsi: "235098765", callSign: "MNTW7" },
{ value: "sea-lark", label: "Sea Lark", mmsi: "227123456", callSign: "FSLK" },
];
const MATCH_STYLE: CSSProperties = { background: "var(--accent-dim)", color: "inherit", fontWeight: "bold" };
function highlight(text: string, query: string): ReactElement {
const start: number = text.toLowerCase().indexOf(query);
return start < 0 || query === "" ? (
<>{text}</>
) : (
<>
{text.slice(0, start)}
<mark style={MATCH_STYLE}>{text.slice(start, start + query.length)}</mark>
{text.slice(start + query.length)}
</>
);
}
export const description: string =
"Search the example vessels by name, MMSI or call sign. Try wind, 244 or PCWY, then choose a vessel to see its details.";
export default function Find(): ReactElement {
const [query, setQuery]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
const [selected, setSelected]: [Vessel | null, Dispatch<SetStateAction<Vessel | null>>] = useState<Vessel | null>(
null,
);
const wanted: string = query.trim().toLowerCase();
const shown: readonly Vessel[] =
wanted === ""
? []
: VESSELS.filter((vessel: Vessel): boolean =>
[vessel.label, vessel.mmsi, vessel.callSign].some((text: string): boolean =>
text.toLowerCase().includes(wanted),
),
);
const status: string =
wanted === ""
? "Type a name, MMSI or call sign to find a vessel."
: shown.length === 0
? "No vessels match. Try fewer letters or check the identifier."
: `${String(shown.length)} ${shown.length === 1 ? "vessel matches" : "vessels match"}. Choose one to see its details.`;
return (
<Stack space="md">
<SearchSuggestions
label="Find a vessel"
placeholder="Name, MMSI or call sign"
hint="Try wind, 244 or PCWY. Use the arrow keys and Enter, or tap a result."
value={query}
onValueChange={setQuery}
items={shown}
onSelect={(item: SearchSuggestion): void => {
setSelected(VESSELS.find((vessel: Vessel): boolean => vessel.value === item.value) ?? null);
}}
status={status}
clearLabel="Clear the vessel search"
renderItem={(item: SearchSuggestion): ReactElement => {
const vessel: Vessel | undefined = VESSELS.find((entry: Vessel): boolean => entry.value === item.value);
return (
<span>
{highlight(item.label, wanted)}
<br />
<Text as="span" size="sm" family="mono">
{highlight(vessel?.mmsi ?? "", wanted)} · {highlight(vessel?.callSign ?? "", wanted)}
</Text>
</span>
);
}}
/>
{selected === null ? (
<Text color="text-dim">Details appear here when you choose a vessel.</Text>
) : (
<section aria-label="Vessel details" aria-live="polite">
<Box background="surface2" padding="md" radius="md">
<Stack space="sm">
<Text>{selected.label}</Text>
<Text family="mono">MMSI: {selected.mmsi}</Text>
<Text family="mono">Call sign: {selected.callSign}</Text>
</Stack>
</Box>
</section>
)}
</Stack>
);
}
SearchInput supplies the field, magnifier, clear action and associated hint. The caller owns the query and its
meaning. SearchSuggestions adds an inline list with keyboard and pointer selection; matching records and the
status message come from the caller. Both render without an application provider or a network connection.
Examples
Filter a visible list
- Anchor watch
- Anchor windlass service
- Engine maintenance
- Radio check
Code
import { SearchInput } from "@bosunkit/components/search-input";
import { Label } from "@bosunkit/components/label";
import { Stack } from "@bosunkit/components/stack";
import { Text } from "@bosunkit/components/text";
import { useId, useState, type ChangeEvent, type Dispatch, type ReactElement, type SetStateAction } from "react";
const RECORDS: readonly string[] = ["Anchor watch", "Anchor windlass service", "Engine maintenance", "Radio check"];
export const description: string =
"Type anchor to narrow the list, zzzz for no matches, and clear to restore every record. The field keeps normal search semantics.";
export default function Filter(): ReactElement {
const id: string = useId();
const [query, setQuery]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
const shown: readonly string[] = RECORDS.filter((record: string): boolean =>
record.toLowerCase().includes(query.trim().toLowerCase()),
);
return (
<Stack space="sm">
<Label htmlFor={id}>Filter the records</Label>
<SearchInput
id={id}
value={query}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setQuery(event.target.value);
}}
onClear={
query === ""
? undefined
: (): void => {
setQuery("");
}
}
clearLabel="Clear the record filter"
/>
<span role="status" aria-atomic="true">
{shown.length === 0
? "No records match. Try fewer words or clear the filter."
: `${String(shown.length)} ${shown.length === 1 ? "record" : "records"} shown.`}
</span>
<ul aria-label="Filtered records">
{shown.map((record: string): ReactElement => (
<li key={record}>
<Text as="span">{record}</Text>
</li>
))}
</ul>
</Stack>
);
}
Use a plain search field when typing narrows content already on the page. Clearing restores every record. An empty query is different from a query with no matches, and the result count updates in a polite live region.
Choose a result with the keyboard
Code
import { SearchSuggestions, type SearchSuggestion } from "@bosunkit/components/search-input";
import { Box } from "@bosunkit/components/box";
import { Stack } from "@bosunkit/components/stack";
import { Text } from "@bosunkit/components/text";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";
const RECORDS: readonly SearchSuggestion[] = [
{ value: "watch", label: "Anchor watch" },
{ value: "windlass", label: "Anchor windlass service" },
{ value: "plan", label: "Anchorage plan" },
];
const MATCH_STYLE: CSSProperties = { background: "var(--accent-dim)", color: "inherit", fontWeight: "bold" };
export const description: string =
"Type windlass, or use the arrow keys and Enter to choose a record. Escape dismisses the list and keeps your query.";
export default function Results(): ReactElement {
const [query, setQuery]: [string, Dispatch<SetStateAction<string>>] = useState<string>("anchor");
const [chosen, setChosen]: [SearchSuggestion | null, Dispatch<SetStateAction<SearchSuggestion | null>>] =
useState<SearchSuggestion | null>(null);
const wanted: string = query.trim().toLowerCase();
const shown: readonly SearchSuggestion[] =
wanted === ""
? []
: RECORDS.filter((entry: SearchSuggestion): boolean => entry.label.toLowerCase().includes(wanted));
const status: string =
wanted === ""
? "Type to search the records."
: shown.length === 0
? "No matches. Try fewer words."
: `${String(shown.length)} ${shown.length === 1 ? "match" : "matches"} for ${query}`;
return (
<Stack space="md">
<SearchSuggestions
label="Search the records"
value={query}
onValueChange={setQuery}
items={shown}
onSelect={setChosen}
clearLabel="Clear the search"
status={status}
renderItem={(item: SearchSuggestion): ReactElement => {
const start: number = item.label.toLowerCase().indexOf(wanted);
return (
<span>
{item.label.slice(0, start)}
<mark style={MATCH_STYLE}>{item.label.slice(start, start + wanted.length)}</mark>
{item.label.slice(start + wanted.length)}
</span>
);
}}
/>
{chosen === null ? null : (
<section aria-label="Selected record">
<Box background="surface2" padding="md" radius="md">
<Text>{chosen.label}</Text>
</Box>
</section>
)}
</Stack>
);
}
Focus or click the field, use the arrow keys to move through suggestions, and press Enter to choose one. A click or tap chooses the same result. Escape dismisses the list and keeps the query; Tab leaves the field without choosing a result. The field keeps focus while moving through suggestions and exposes the active option to assistive technology. Each instance has its own field and result IDs.
Search on submit
Code
import { SearchInput } from "@bosunkit/components/search-input";
import { Button } from "@bosunkit/components/button";
import { Label } from "@bosunkit/components/label";
import { Stack } from "@bosunkit/components/stack";
import {
useId,
useState,
type ChangeEvent,
type Dispatch,
type SubmitEvent,
type ReactElement,
type SetStateAction,
} from "react";
const RECORDS: readonly string[] = ["Anchor watch", "Anchor windlass service", "Engine maintenance"];
export const description: string =
"Type anchor, then press Enter or Search records. Results change only when submitted. This example matches literal text and ignores letter case.";
export default function Syntax(): ReactElement {
const id: string = useId();
const [draft, setDraft]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
const [query, setQuery]: [string | null, Dispatch<SetStateAction<string | null>>] = useState<string | null>(null);
const shown: readonly string[] =
query === null || query === ""
? []
: RECORDS.filter((record: string): boolean => record.toLowerCase().includes(query.toLowerCase()));
return (
<form
role="search"
aria-label="Submitted record search"
onSubmit={(event: SubmitEvent<HTMLFormElement>): void => {
event.preventDefault();
setQuery(draft.trim());
}}
>
<Stack space="sm">
<Label htmlFor={id}>Search records on submit</Label>
<SearchInput
id={id}
value={draft}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setDraft(event.target.value);
}}
hint="Matches literal text, ignoring letter case. Quotes, |, * and - have no special meaning in this example."
onClear={
draft === ""
? undefined
: (): void => {
setDraft("");
setQuery(null);
}
}
clearLabel="Clear the submitted search"
/>
<Button type="submit" disabled={draft.trim() === ""} motion={false}>
Search records
</Button>
<span role="status" aria-atomic="true">
{query === null || query === ""
? "Enter a query, then search."
: shown.length === 0
? "No matches. Try fewer words."
: `${String(shown.length)} ${shown.length === 1 ? "match" : "matches"} for ${query}.`}
</span>
<ul aria-label="Submitted results">
{shown.map((record: string): ReactElement => (
<li key={record}>{record}</li>
))}
</ul>
</Stack>
</form>
);
}
Use a form when a query should run only after Enter or the search button. The draft and submitted query are separate, so editing the field does not silently change the displayed results. Clearing resets both.
Loading and recovery
Example controls: choose how the local directory responds.
Code
import { SearchSuggestions, type SearchSuggestion } from "@bosunkit/components/search-input";
import { Button } from "@bosunkit/components/button";
import { Inline } from "@bosunkit/components/inline";
import { SectionMessage } from "@bosunkit/components/section-message";
import { Stack } from "@bosunkit/components/stack";
import { Text } from "@bosunkit/components/text";
import {
useEffect,
useRef,
useState,
type Dispatch,
type ReactElement,
type RefObject,
type SetStateAction,
} from "react";
export const description: string =
"Choose a slow or failed response, then search for anchor. Retry keeps your query; changing it cancels the previous lookup.";
type ResponseMode = "normal" | "slow" | "failed";
export type DirectoryLookup = (
query: string,
signal: AbortSignal,
mode: ResponseMode,
) => Promise<readonly SearchSuggestion[]>;
type Result = { query: string; mode: ResponseMode; attempt: number } & (
{ phase: "ready"; items: readonly SearchSuggestion[] } | { phase: "failed" }
);
const RECORDS: readonly SearchSuggestion[] = [
{ value: "watch", label: "Anchor watch" },
{ value: "windlass", label: "Anchor windlass service" },
{ value: "engine", label: "Engine maintenance" },
];
/** A local directory with a controllable delay and failure; no network or vessel data. */
const lookupDirectory: DirectoryLookup = (
query: string,
signal: AbortSignal,
mode: ResponseMode,
): Promise<readonly SearchSuggestion[]> =>
new Promise((resolve: (items: readonly SearchSuggestion[]) => void, reject: (error: Error) => void): void => {
if (signal.aborted) {
reject(new Error("Lookup canceled"));
return;
}
const cancel: () => void = (): void => {
window.clearTimeout(timer);
reject(new Error("Lookup canceled"));
};
const timer: number = window.setTimeout(
(): void => {
signal.removeEventListener("abort", cancel);
if (mode === "failed") reject(new Error("Example directory unavailable"));
else resolve(RECORDS.filter((record: SearchSuggestion): boolean => record.label.toLowerCase().includes(query)));
},
mode === "slow" ? 1600 : 500,
);
signal.addEventListener("abort", cancel, { once: true });
});
/** The injectable lookup lets the browser test resolve requests out of order. */
export default function Remote({ lookup = lookupDirectory }: { lookup?: DirectoryLookup }): ReactElement {
const [query, setQuery]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
const [mode, setMode]: [ResponseMode, Dispatch<SetStateAction<ResponseMode>>] = useState<ResponseMode>("normal");
const [attempt, setAttempt]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
const [result, setResult]: [Result | null, Dispatch<SetStateAction<Result | null>>] = useState<Result | null>(null);
const [chosen, setChosen]: [SearchSuggestion | null, Dispatch<SetStateAction<SearchSuggestion | null>>] =
useState<SearchSuggestion | null>(null);
const field: RefObject<HTMLDivElement | null> = useRef<HTMLDivElement | null>(null);
const wanted: string = query.trim().toLowerCase();
const current: Result | null =
result?.query === wanted && result.mode === mode && result.attempt === attempt ? result : null;
useEffect((): (() => void) | undefined => {
if (wanted === "") return undefined;
const controller: AbortController = new AbortController();
// A transport may finish after abort. Only the current effect may publish its response.
void lookup(wanted, controller.signal, mode).then(
(items: readonly SearchSuggestion[]): void => {
if (!controller.signal.aborted) setResult({ query: wanted, mode, attempt, phase: "ready", items });
},
(): void => {
if (!controller.signal.aborted) setResult({ query: wanted, mode, attempt, phase: "failed" });
},
);
return (): void => {
controller.abort();
};
}, [wanted, mode, attempt, lookup]);
const items: readonly SearchSuggestion[] = current?.phase === "ready" ? current.items : [];
const status: string =
wanted === ""
? "Type to search the example directory."
: current === null
? "Searching the example directory…"
: current.phase === "failed"
? "Couldn't search because the example directory is unavailable. Your query is kept. Retry the search."
: items.length === 0
? "No matches. Try fewer words."
: `${String(items.length)} ${items.length === 1 ? "match" : "matches"}.`;
return (
<Stack space="md">
<Text size="sm">Example controls: choose how the local directory responds.</Text>
<Inline space="sm">
<Button
type="button"
aria-pressed={mode === "normal"}
onClick={(): void => {
setMode("normal");
}}
motion={false}
>
Normal response
</Button>
<Button
type="button"
aria-pressed={mode === "slow"}
onClick={(): void => {
setMode("slow");
}}
motion={false}
>
Slow response
</Button>
<Button
type="button"
aria-pressed={mode === "failed"}
onClick={(): void => {
setMode("failed");
}}
motion={false}
>
Failed response
</Button>
</Inline>
<div ref={field}>
<SearchSuggestions
label="Search the directory"
value={query}
onValueChange={(next: string): void => {
setQuery(next);
setAttempt((previous: number): number => previous + 1);
}}
items={items}
onSelect={setChosen}
status={status}
clearLabel="Clear the directory search"
hint="Search for anchor or engine. Change the query during a slow response to cancel that lookup."
/>
</div>
{wanted !== "" && current?.phase === "failed" ? (
<SectionMessage kind="error" title="The example directory is unavailable">
<Button
type="button"
onClick={(): void => {
setMode("normal");
setAttempt((previous: number): number => previous + 1);
field.current?.querySelector("input")?.focus();
}}
motion={false}
>
Retry the search
</Button>
</SectionMessage>
) : null}
{chosen === null ? null : (
<section aria-label="Directory record">
<Text>{chosen.label}</Text>
</section>
)}
</Stack>
);
}
The example controls simulate responses locally. A changed query cancels the previous lookup; a response arriving after cancellation cannot replace the current results. Pending and unsuccessful searches have distinct messages. Retry preserves the query and returns focus to the field.
Empty, disabled and read-only fields
Code
import { SearchInput } from "@bosunkit/components/search-input";
import { Label } from "@bosunkit/components/label";
import { Stack } from "@bosunkit/components/stack";
import { useId, useState, type ChangeEvent, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
"Edit or clear the first query. The disabled and read-only fields keep their text. A wrapped hint leaves the clear button aligned with the field.";
export default function States(): ReactElement {
const id: string = useId();
const [query, setQuery]: [string, Dispatch<SetStateAction<string>>] = useState<string>("Windgrazer");
const clear: () => void = (): void => {
setQuery("");
};
return (
<Stack space="md">
<Stack space="sm">
<Label htmlFor={`${id}-editable`}>Editable vessel search</Label>
<SearchInput
id={`${id}-editable`}
value={query}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
setQuery(event.target.value);
}}
onClear={query === "" ? undefined : clear}
clearLabel="Clear the editable search"
hint="Search by the vessel name, its MMSI or its call sign. This example matches literal text and ignores letter case."
/>
</Stack>
<Stack space="sm">
<Label htmlFor={`${id}-disabled`}>Disabled vessel search</Label>
<SearchInput
id={`${id}-disabled`}
defaultValue="Althea"
disabled
onClear={clear}
clearLabel="Clear the disabled search"
/>
</Stack>
<Stack space="sm">
<Label htmlFor={`${id}-readonly`}>Read-only vessel search</Label>
<SearchInput
id={`${id}-readonly`}
defaultValue="Sea Lark"
readOnly
onClear={clear}
clearLabel="Clear the read-only search"
/>
</Stack>
</Stack>
);
}
The caller clears the value through onClear; focus returns to the input. A disabled or read-only field also
disables its clear button. The button stays beside the field when the hint wraps, and a custom clear button
takes the place of the browser's duplicate clear control. Omit onClear when the query is empty.
Explain the supported query syntax
The examples match literal text and ignore letter case. hint describes the actual search implementation; the
input itself does not parse quotes, alternatives, prefixes or exclusions. If a backend supports those operators,
describe them in its caller's hint and show parsing errors beside the field with a correction the reader can make.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | undefined | – | Additional input classes. |
| unit | string | undefined | – | The unit of the value, inside the field's right edge (m, kn, °T), as SN.1/Circ.243 writes it. It is read
after the value, through aria-describedby, so the field's name need not repeat it. |
| ref | Ref<HTMLInputElement> | undefined | – | The input element. |
| hint | ReactNode | undefined | – | Guidance under the field: the syntax the search reads, translated by the caller ("Quotes keep words together, | joins alternatives, * matches a prefix, a leading - excludes"). The crew write better queries when the field says how it reads them, and a screen reader hears it: the field describes itself by the hint. |
| onClear | (() => void) | undefined | – | Called when the crew press clear; the caller clears its value and focus returns to the input. |
| clearLabel | string | undefined | – | The clear button's name, translated by the caller. With onClear it draws, disabled for disabled/readOnly fields. |
| onValueChange | ((value: string, eventDetails: Input.ChangeEventDetails) => void) | undefined | – | Callback fired when the value changes. Use when controlled. |
| defaultValue | Field.Control.Props['defaultValue'] | undefined | – | The default value of the input. Use when uncontrolled. |
| value | React.ComponentProps<'input'>['value'] | undefined | – | The value of the input. Use when controlled. |
| render | React.ReactElement | ComponentRenderFn<RenderFunctionProps, State> | undefined | – | Allows you to replace the component's HTML element
with a different tag, or compose it with another component.
Accepts a ReactElement or a function that returns the element to render. |
| style | React.CSSProperties | ((state: State) => React.CSSProperties | undefined) | undefined | – | Style applied to the element, or a function that returns a style object based on the component's state. |
Also takes every attribute of Omit<InputProps, "type" | "icon">.
| Prop | Type | Default | Description |
|---|---|---|---|
| labelrequired | string | – | The visible label naming the field and its suggestions. |
| valuerequired | string | – | The query held by the caller. |
| onValueChangerequired | (value: string) => void | – | Called when typing or clearing changes the query. Choosing a result does not replace the query. |
| itemsrequired | readonly SearchSuggestion[] | – | Matching results supplied in display order; the caller owns filtering and fetching. |
| onSelectrequired | (item: SearchSuggestion) => void | – | Called when Enter or a pointer chooses a result. |
| statusrequired | string | – | A complete, translated count, empty-state or pending message, announced politely. |
| clearLabelrequired | string | – | The clear button's accessible name. |
| hint | ReactNode | undefined | – | Guidance associated with the field, translated by the caller. |
| placeholder | string | undefined | – | Text shown inside the empty field. |
| renderItem | ((item: SearchSuggestion) => ReactNode) | undefined | – | Optional result content, such as a highlighted name and its identifying code. |
| testId | string | undefined | – | Becomes data-testid on the root. |
Related
- Input — field presentation and form integration.
- Combobox — choosing a field value from suggestions.
- Section message — explaining a failed lookup beside its controls.