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

OptionTypeDefaultDescription
optionsSearchBarEntries|SearchBarLoaderrequiredThe 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) => voidundefinedCalled when a result is chosen, by click or by Enter on a highlighted one.
onSearch(query: string) => voidundefinedCalled 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) => voidundefinedCalled as the text changes.
autoHighlightbooleanfalseKeep 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.
debouncenumber250Milliseconds of quiet before a function options is called. Ignored for a static list.
minQueryLengthnumber1A 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) => booleanundefinedFilters a static list. Default: case-insensitive substring on the label.
emptyMessagestring'No results'Shown when there is nothing to list.
loadingMessagestring'Searching…'Shown while a function options is answering.
errorMessagestring|((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: *) => voidundefinedCalled with what a function options threw, for logging.
placeholderstringundefined
size'sm'|'md'|'lg''md'
disabledbooleanfalse

React

SearchBar — from @corndev/ui-react.

Props

PropTypeDefaultDescription
optionsSearchBarEntries|SearchBarLoaderrequiredThe 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) => voidundefinedCalled when a result is chosen, by click or by Enter on a highlighted one.
onSearch(query: string) => voidundefinedCalled 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) => voidundefinedCalled as the text changes.
defaultQuerystring''Initial text.
autoHighlightbooleanfalseKeep 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.
debouncenumber250Milliseconds of quiet before a function options is called. Ignored for a static list.
minQueryLengthnumber1A 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) => booleanundefinedFilters a static list. Default: case-insensitive substring on the label.
emptyMessagestring'No results'Shown when there is nothing to list.
loadingMessagestring'Searching…'Shown while a function options is answering.
errorMessagestring|((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: *) => voidundefinedCalled with what a function options threw, for logging.
placeholderstringundefined
size'sm'|'md'|'lg''md'
disabledbooleanfalse
classNamestringundefined

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, and errorMessage — which can be a function of what was thrown. onError receives it too, for logging.
  • minQueryLength (default 1) — shorter queries don’t call the function. Set it to 0 to 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:

  1. A result is highlighted → it is selected, and onSelect fires.
  2. 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.
  3. 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-busy while a request is in flight.
  • On a phone, the keyboard’s return key reads “Search”.