Passer au contenu principal

SearchSelect

Implemented

Async typeahead search input with debounced server-side querying, optional inline create, keyboard navigation, and ARIA combobox semantics. Generic — takes a fetcher callback, not a hardcoded endpoint.

Categorymolecules
Sourcepatterns/molecules/search-select.html
AI HintUse for any async typeahead picker (venues, artists, profiles). Takes a generic fetcher. Supports inline create when onCreate is provided.

Async single-select typeahead with debounced server queries, optional inline create, race-guarded responses, and ARIA combobox semantics.

SearchSelect

Form input molecule — Family 2

A controlled combobox input that calls a debounced async onSearch fetcher as the user types and renders results in an absolutely-positioned listbox. Optionally offers an inline-create row (onCreate) when the query matches nothing. Single-select only; focus never leaves the input.

This component supports light and dark mode.

Usage

SearchSelect is a controlled async typeahead: pass a fetcher (onSearch), hold the selection in state (value + onChange), and always provide ariaLabel — it is required at the type level. The component owns debouncing (300 ms by default), a request race guard that drops stale responses, outside-click dismissal, and keyboard navigation. Wire it to your real API inside the fetcher — the component is endpoint-agnostic.

import { useState } from 'react'
import { SearchSelect } from '@epasslive/design-system'

const [venue, setVenue] = useState(null)

<SearchSelect
  ariaLabel="Venue"
  placeholder="Search venues…"
  value={venue}
  onChange={setVenue}
  onSearch={(q) => fetchVenues(q)}
  resultLimit={3}
  refineLabel="Keep typing to refine"
/>

Inline create

Provide onCreate and a create row appears whenever the search returns no results. Selecting it (click or Enter) calls your creator with the trimmed query and adopts the returned option as the selection. The row label defaults to the literal text Add "query" built from the trimmed query — pass createLabel to customize it, as in the second live example above. While the create request is in flight the row shows loadingLabel.

<SearchSelect
  ariaLabel="Artist"
  value={artist}
  onChange={setArtist}
  onSearch={(q) => searchArtists(q)}
  onCreate={(q) => createArtist(q)}
  createLabel={(q) => `Add new artist "${q}"`}
  noResultsLabel="No artist found"
/>

SearchSelect vs Select vs Listbox

Use SearchSelect for async, server-backed single-select typeahead with optional inline create. Use Select for static or locally searched option lists, multi-select, and button-triggered popovers. Use Listbox for an always-visible inline selection list with no text input. Do not use SearchSelect for static option lists — Select is the correct static widget.

Accessibility

The input exposes role="combobox" with aria-expanded, aria-controls, aria-autocomplete="list", and the required ariaLabel. The popup is a role="listbox" whose rows are role="option". Keyboard: ArrowDown opens, ArrowUp/Down navigate (including the create row), Home/End jump, Enter selects or creates, Escape closes and restores the selected label, Tab closes. Focus stays in the input throughout.

Known gap (tracked for the next cascade step): the input does not yet set aria-activedescendant and option rows carry no ids, so arrow-key navigation is silent to screen readers; aria-selected also currently tracks the visually active row rather than the chosen option. The active-option tint (teal at 10% with theme-swapping text tokens) passes WCAG AA 4.5:1 in both light and dark themes.

Props

Prop
Type
Default
Description
placeholder
string
Search…

Placeholder text shown when the input is empty.

value*
SearchSelectOption | null
—

Controlled selected option. Pass null to clear.

onChange*
(option: SearchSelectOption | null) => void
—

Fired on select or create; fired with null whenever the query is edited (selection clears while typing).

onSearch*
(query: string) => Promise<SearchSelectOption[]>
—

Async fetcher called with the debounced, trimmed query. Stale responses are dropped by a request race guard.

onCreate
(query: string) => Promise<SearchSelectOption>
—

When provided, an inline-create row appears when search returns no results.

createLabel
(query: string) => string
—

Label builder for the create row. Falls back to the literal Add "query" text when omitted.

noResultsLabel
string
No results

Text shown when the search finished with no results and onCreate is not provided.

loadingLabel
string
Loading…

Text shown while a search or create request is in flight.

refineLabel
string
—

Muted hint row appended when the result count reaches resultLimit.

resultLimit
number
10

Maximum number of results displayed; also gates the refineLabel hint.

debounceMs
number
300

Debounce delay in milliseconds before onSearch fires.

disabled
boolean
false

Disables the whole control. Per-option disabling is not supported.

required
boolean
false

Marks the field required (aria-required on the input).

id
string
—

Input id; auto-generated with React.useId() when omitted. The listbox id derives from it.

className
string
—

Additional CSS classes appended to the wrapper div.

ariaLabel*
string
—

Accessible name for the combobox input and listbox popup.

Props

Preview

SearchSelect

Prop
Type
Required
Default
Description
onSearch
(query: string) => Promise<SearchSelectOption[]>
Required
—

Async fetcher called with the debounced query string

value
SearchSelectOption | null
Required
null

Currently selected option

onChange
(option: SearchSelectOption | null) => void
Required
—

Callback fired when the user selects an option or clears the input

onCreate
(query: string) => Promise<SearchSelectOption>
Optional
undefined

When provided, an inline-create affordance appears when search returns no results

debounceMs
number
Optional
300

Debounce delay before firing the search request

resultLimit
number
Optional
10

Maximum results to display

placeholder
string
Optional
'Search…'

Placeholder text shown when the value is empty.

createLabel
(query: string) => string
Optional
`Add "${query}"`

Builds the inline-create row label from the trimmed query. Falls back to `Add "${query}"` when omitted.

noResultsLabel
string
Optional
'No results'

Empty-state text shown when the query returns no results and no onCreate is provided.

loadingLabel
string
Optional
'Loading…'

Text shown while results are fetching, and while an inline create is in progress.

refineLabel
string
Optional
—

Hint shown when the result count hits resultLimit, prompting a more specific query.

disabled
boolean
Optional
false

Disables the control.

required
boolean
Optional
false

Marks the field as required.

ariaLabel
string
Required
—

Accessible name applied to both the input and the results listbox.

id
string
Optional
useId()

Stable id for the input; the listbox id is derived as `${id}-listbox`.

className
string
Optional
—

Class appended to the relative-positioned root container.

Quick use:<SearchSelect />

Accessibility

ARIA rolecombobox

ARIA

1 rule

WAI-ARIA Combobox Pattern

role='combobox' + aria-expanded + aria-controls on input; role='listbox' and role='option' + aria-selected on results

a11y

1 rule

WCAG 2.1.1

Keyboard: ArrowDown/Up navigate, Enter selects, Escape closes, Home/End jump

Usage

Import

tsxSnippet
import { SearchSelect } from "@epasslive/design-system"

Example 1

tsxSnippet
<SearchSelect onSearch={searchVenues} value={venue} onChange={setVenue} ariaLabel="Venue" />

Example 2

tsxSnippet
<SearchSelect onSearch={searchArtists} value={artist} onChange={setArtist} onCreate={createArtist} ariaLabel="Artist" />

Anti-patterns

— Do not use for static option lists — use Select instead

— Do not add a static-options variant here — a native <select> (Select atom) is the correct static widget

Design System v4.1.0 | ePassLive