SearchBar
A search field with a list of results — prefilled, grouped, or fetched as you type.
import { ... } from '@corndev/ui' HTML
<div class="corn-search-bar">
<label class="corn-label" for="tables">Search tables</label>
<input id="tables" class="corn-input" data-size="md" data-full-width role="combobox" aria-expanded="true" />
<ul class="corn-listbox" role="listbox">
<li class="corn-listbox-group-item" role="presentation">
<ul class="corn-listbox-group" role="group" aria-labelledby="tables-g0">
<li class="corn-listbox-group-label" id="tables-g0" role="presentation">Tables</li>
<li class="corn-listbox-option" role="option" data-active>users</li>
<li class="corn-listbox-option" role="option">orders</li>
</ul>
</li>
</ul>
<div class="corn-search-bar-status" role="status" aria-live="polite">2 results</div>
</div>
Classes
| Class |
|---|
.corn-input |
.corn-listbox |
.corn-search-bar |
.corn-search-bar-status |
Vanilla JS
Required for behaviour — adds builds the result list, filters a static list or calls a function of the query as you type, handles the keyboard, and announces the result count.
import { createSearchBar } from '@corndev/ui';
import '@corndev/ui/theme';
const inputEl = document.querySelector('#target');
const instance = createSearchBar(inputEl, { /* options */ });
Options
| Option | Type | Default | Description |
|---|---|---|---|
options | SearchBarEntries|SearchBarLoader | required | The data: strings or numbers (a number is handed back as a number), { label, value } objects, { group, items } groups, or a function of the query returning any of those. An object with items is a group; any other object is an option. |
onSelect | (value: *, option: SearchBarOption) => void | undefined | Called when a result is chosen, by click or by Enter on a highlighted one. |
onSearch | (query: string) => void | undefined | Called when Enter is pressed with nothing highlighted and nothing pending — the “search for this text” case. Without it, Enter on free text does nothing. |
onQueryChange | (query: string) => void | undefined | Called as the text changes. |
autoHighlight | boolean | false | Keep the first result highlighted as the user types, so Enter always picks it — the Spotlight or ⌘K behaviour. Off by default so a search bar never quietly turns what was typed into whatever ranked first. |
debounce | number | 250 | Milliseconds of quiet before a function options is called. Ignored for a static list. |
minQueryLength | number | 1 | A function options is not called for shorter queries. 0 calls it on an empty query too — for recent or popular results shown as soon as the field opens. |
filter | (option: SearchBarOption, query: string) => boolean | undefined | Filters a static list. Default: case-insensitive substring on the label. |
emptyMessage | string | 'No results' | Shown when there is nothing to list. |
loadingMessage | string | 'Searching…' | Shown while a function options is answering. |
errorMessage | string|((error: *) => string) | "Couldn't load results" | Shown when a function options throws. A function receives what was thrown — an HttpError from httpSearch carries the status. |
onError | (error: *) => void | undefined | Called with what a function options threw, for logging. |
placeholder | string | undefined | |
size | 'sm'|'md'|'lg' | 'md' | |
disabled | boolean | false |
React
SearchBar — from @corndev/ui-react.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
options | SearchBarEntries|SearchBarLoader | required | The data: strings or numbers (a number is handed back as a number), { label, value } objects, { group, items } groups, or a function of the query returning any of those. An object with items is a group; any other object is an option. |
onSelect | (value: *, option: SearchBarOption) => void | undefined | Called when a result is chosen, by click or by Enter on a highlighted one. |
onSearch | (query: string) => void | undefined | Called when Enter is pressed with nothing highlighted and nothing pending — the “search for this text” case. Without it, Enter on free text does nothing. |
onQueryChange | (query: string) => void | undefined | Called as the text changes. |
defaultQuery | string | '' | Initial text. |
autoHighlight | boolean | false | Keep the first result highlighted as the user types, so Enter always picks it — the Spotlight or ⌘K behaviour. Off by default so a search bar never quietly turns what was typed into whatever ranked first. |
debounce | number | 250 | Milliseconds of quiet before a function options is called. Ignored for a static list. |
minQueryLength | number | 1 | A function options is not called for shorter queries. 0 calls it on an empty query too — for recent or popular results shown as soon as the field opens. |
filter | (option: SearchBarOption, query: string) => boolean | undefined | Filters a static list. Default: case-insensitive substring on the label. |
emptyMessage | string | 'No results' | Shown when there is nothing to list. |
loadingMessage | string | 'Searching…' | Shown while a function options is answering. |
errorMessage | string|((error: *) => string) | "Couldn't load results" | Shown when a function options throws. A function receives what was thrown — an HttpError from httpSearch carries the status. |
onError | (error: *) => void | undefined | Called with what a function options threw, for logging. |
placeholder | string | undefined | |
size | 'sm'|'md'|'lg' | 'md' | |
disabled | boolean | false | |
className | string | undefined |
A search bar’s typed text is a query, not a value. Choosing a result does
something — onSelect — and Enter on free text can search for exactly what was
typed — onSearch. That is the difference from
Combobox, where the text is only a filter
and what matters is the option chosen.
A prefilled list
<SearchBar options={['users', 'orders', 'invoices']} onSelect={openTable} />
Strings and numbers both work, and a number is handed back as a number — 2024,
not "2024". Use { label, value } when what is shown and what is meant differ.
Opening the field lists everything; typing filters it.
A grouped list
<SearchBar
options={[
{ group: 'Tables', items: ['users', 'orders', 'invoices'] },
{ group: 'Views', items: ['active_users', 'monthly_revenue'] },
]}
/>An object with items is a group; any other object is an option. Groups render
under headings a screen reader announces, the arrow keys move through every
result as one list, and a group left empty by the filter disappears.
Results from a server
Pass a function of the query instead of an array. It gets the typed text and a
signal, and returns results in either of the shapes above:
import { SearchBar, httpSearch } from '@corndev/ui-react';
<SearchBar
options={httpSearch({
url: '/api/tables',
params: { schema: 'public', limit: 20 }, // → ?schema=public&limit=20
queryParam: 'q', // the typed text, as &q=…
parse: json => json.data.map(t => t.name),
})}
onSelect={openTable}
/>The component owns everything about when:
- A debounce — one request after the typing pauses, not one per keystroke.
Tune it with
debounce. - Stale requests are cancelled. Type “use” then “user” and the “use” request is
aborted through its
signal; if it answers anyway, the answer is dropped. A slow earlier response can never overwrite a later one. - Loading, empty and error states, each with a message you can change:
loadingMessage,emptyMessage, anderrorMessage— which can be a function of what was thrown.onErrorreceives it too, for logging. minQueryLength(default 1) — shorter queries don’t call the function. Set it to0to show recent or popular results as soon as the field opens.
The server did the filtering, so a function’s answer is shown exactly as returned.
httpSearch
Builds that function for a GET that answers with JSON. It adds params and the
query to the URL — keeping any query string already on it — fetches with the
search’s own signal, and hands the parsed JSON to parse, along with the raw
response for headers such as a total count.
Its only error handling is to throw: a non-2xx status or a body that isn’t
JSON becomes an HttpError with status, statusText and url, and the search
bar shows it. Pagination, sorting and other post-processing go in parse.
Anything else — POST, auth headers, a cursor — is a function you write yourself.
That is always available, and it is why httpSearch never has to grow into a fetch
framework:
options={async (query, { signal }) => {
const res = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ query }),
signal,
});
if (!res.ok) throw new Error(`Search failed: ${res.status}`);
return (await res.json()).results;
}}
Enter
The first of these that applies:
- A result is highlighted → it is selected, and
onSelectfires. - Results aren’t in yet — the debounce is still waiting, or a request is in flight → the search runs now, rather than making the reader wait.
- Otherwise →
onSearch(query), if you passed one. Without it, Enter on free text does nothing.
autoHighlight
Keeps the first result highlighted as you type, so Enter always picks it — the behaviour of a command palette or Spotlight:
<SearchBar options={commands} autoHighlight onSelect={run} />
It is off by default. In a search bar, Enter on “users” should search for “users”, not quietly swap in whatever happened to rank first.
Escape
The first Escape closes the list. The next one clears the text.
Without React
import { createSearchBar, httpSearch } from '@corndev/ui';
const bar = createSearchBar(document.querySelector('#tables'), {
options: httpSearch({ url: '/api/tables', parse: json => json.names }),
onSelect: value => openTable(value),
});
bar.setOptions(['a', 'b']); // swap the data later
bar.destroy(); // put the input back as it was
Accessibility
- It is an ARIA combobox: focus stays in the input while the arrow keys move a
highlight through the list, announced with
aria-activedescendant. - Results arrive after the reader stops typing, often somewhere a screen reader’s cursor isn’t. A polite live region announces how many arrived — “3 results” — or why there are none, without moving focus.
- The list is marked
aria-busywhile a request is in flight. - On a phone, the keyboard’s return key reads “Search”.