Search field
A search box whose results open under it as people type. Base UI’s Autocomplete provides the behavior; the app does the searching and passes the results, with a status line that says how the text was read.
- Behavior from Base UI
- 4.2 KB gzipped · script and styles
- Shared with Search result
@orbex/sonar-components/search
Examples
Type “late”, “invoices” or “127”.
Code
/* The app reads the text and passes the results; SearchField shows them as people type */
import { SearchField, SearchResult } from '@orbex/sonar-components/search'
import { Status } from '@orbex/sonar-components/status'
import '@orbex/sonar-components/search.css'
import '@orbex/sonar-components/status.css'
import { useEffect, useState } from 'react'
type Tone = 'neutral' | 'positive' | 'warning' | 'danger'
interface Item {
id: string
kind: 'Delivery' | 'Invoice'
day: string
text: string
status: string
tone: Tone
}
const items: Item[] = [
{ id: '04-127C', kind: 'Delivery', day: 'Tue May 12', text: '04-127C 1 item · 15 plt', status: 'Delayed', tone: 'warning' },
{ id: '04-121A', kind: 'Delivery', day: 'Tue May 12', text: '04-121A 7 items · 150 plt', status: 'Delayed', tone: 'warning' },
{ id: '04-133B', kind: 'Delivery', day: 'Wed May 13', text: '04-133B 3 items · 60 plt', status: 'Scheduled', tone: 'neutral' },
{ id: 'INV-7712', kind: 'Invoice', day: 'Tue May 12', text: 'INV-7712 · 30,000 USD', status: 'Due today', tone: 'warning' },
{ id: 'INV-7716', kind: 'Invoice', day: 'Tue May 12', text: 'INV-7716 · 16,800 USD', status: 'Paid', tone: 'positive' },
]
/* The app’s own reading of the text: here, a kind, a state and a code */
function search(text: string) {
const query = text.toLowerCase()
const kind = /deliver/.test(query) ? 'Delivery' : /invoice/.test(query) ? 'Invoice' : null
const late = /late|delay|overdue/.test(query)
const words = query.split(/\s+/).filter((word) => /\d/.test(word))
return items.filter(
(item) =>
(!kind || item.kind === kind) &&
(!late || ['Delayed', 'Overdue'].includes(item.status)) &&
words.every((word) => item.id.toLowerCase().includes(word)),
)
}
export default function Basic() {
const [opened, setOpened] = useState<Item | null>(null)
const [value, setValue] = useState('')
const [results, setResults] = useState<Item[]>([])
const [busy, setBusy] = useState(false)
/* Search after a short pause in typing, not on every key */
useEffect(() => {
if (!value.trim()) return setResults([])
setBusy(true)
const timer = setTimeout(() => {
setResults(search(value))
setBusy(false)
}, 380)
return () => clearTimeout(timer)
}, [value])
return (
<div style={{ display: 'grid', gap: 12, width: '100%', maxWidth: 480 }}>
<SearchField
label="Search orders, deliveries and invoices"
placeholder="Search…"
value={value}
onValueChange={setValue}
items={results}
itemToString={(item) => item.id}
renderItem={(item) => (
<SearchResult title={item.text} description={`${item.kind} · ${item.day}`} trailing={<Status tone={item.tone}>{item.status}</Status>} />
)}
onItemChoose={(item) => {
setValue('')
setOpened(item)
}}
status={busy ? `Understanding “${value}”…` : `${results.length} results for “${value}”`}
busy={busy}
empty="No results. Try “late deliveries” or “INV-77”."
/>
<p aria-live="polite" style={{ margin: 0, font: 'var(--sonar-type-body-md)' }}>
{opened ? `Opened ${opened.kind.toLowerCase()} ${opened.id}.` : 'Type “late”, “invoices” or “127”.'}
</p>
</div>
)
}
Usage
- Always controlled: the app keeps the text and decides the results. Search after a short pause in typing, not on every key.
- Return
truefromonItemChooseto keep the list open, as a suggestion that fills the field does. - Draw each result with Search result. The whole pattern, with its rules, is in Patterns › Search.
- In the app’s top bar, use
variant="top-bar": no box of its own, it fills the bar’s 39 px search pill with a 22 px icon, 12 px text and an 18 px clear mark, as in the product. On touch screens its text is 16 px drawn at three quarters, so it looks the same and iOS does not zoom in when it takes focus.iconandclearIcontake the app’s own marks. - The first result is highlighted whenever the list opens or changes, so Enter takes it at once. Escape clears the text first and keeps the list (now the suggestions); a second Escape closes it.
statusIcondraws a mark in the status line’s 28 px accent circle (the sparkle of a smart search).popupClassName,alignandpopupWidthplace and size the results from the app, such as as wide as the pill on a desktop, or the window less 12 px each side on a phone.
Do and don’t
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Typing | Searches; the list opens under the field with the results, the first one highlighted. |
| Down and Up arrows | Move through the results while focus stays in the field. |
| Enter | Chooses the highlighted result. |
| Escape | Closes the list; again, clears the field. |
Roles and attributes
| Element or attribute | What it gives |
|---|---|
role="combobox" on the input |
With aria-expanded and aria-activedescendant: the highlighted result is read as focus moves, without leaving the field. |
role="status" |
The status line is announced politely: that the search is running, then how the text was read and how many results there are. |
aria-busy |
On the field and the list while results are on their way. |
| Label | Always present for assistive technology, drawn only with showLabel. |
Notes
- The input carries its name itself: while the list is open, the rest of the page is hidden from assistive technology.
Use it
Search field shares its entry point and stylesheet with Search result. The tokens and the cascade layers are set once for the app: see Installation.
import { SearchField } from '@orbex/sonar-components/search'
import '@orbex/sonar-components/search.css'
API
| Prop | Type | Default |
|---|---|---|
label
Required
What is searched, read by assistive technology: “Search orders, deliveries and invoices”. |
string
|
— |
showLabel
Whether the label is drawn above the field. In a top bar the icon and the placeholder say what it is. |
boolean
|
false |
placeholder
An example of what to type, never a label. |
string
|
— |
value
Required
The text in the field. The field is always controlled. |
string
|
— |
onValueChange
Required
Called with the new text as people type or clear the field; not when a result is chosen. |
(value: string) => void
|
— |
items
Required
The results for the current text, or suggestions while the field is empty. |
Item[]
|
— |
itemToString
Required
The text of an item: its key, and what is announced when it is highlighted. |
(item: Item) => string
|
— |
renderItem
Required
Draws one result, usually as a SearchResult. |
(item: Item) => ReactNode
|
— |
onItemChoose
Required
Called when a result is chosen, by click or with Enter. Return true to keep the list open with the field focused, as when a suggestion fills the field with its text. |
(item: Item) => boolean | void
|
— |
status
A line at the top of the list, announced politely: how the text was read, or that the search is running. |
ReactNode
|
— |
busy
While the results are on their way: the field says so and the list keeps the previous results out of reach. |
boolean
|
false |
empty
Shown when the text has no results. |
ReactNode
|
— |
busyIcon
Icon shown while busy, instead of the magnifier. |
ReactNode
|
— |
size
Medium in a top bar; small inside a card or a panel. |
'small' | 'medium'
|
'medium' |
className
Classes added to the field’s wrapper, after sonar-search. |
string
|
— |
variant
|
'field' | 'top-bar'
|
'field' |
icon
Replaces the magnifier, such as the app’s own icon font glyph. |
ReactNode
|
— |
clearIcon
Replaces the clear mark. |
ReactNode
|
— |
statusIcon
Drawn before the status line as the 28 px accent circle: what reads the text (an AI sparkle). |
ReactNode
|
— |
popupClassName
Classes added to the results popup, to place or size it from the app. |
string
|
— |
align
Where the results line up with the field. |
'start' | 'center' | 'end'
|
'start' |
popupWidth
Width of the results: |
'auto' | 'anchor' | string
|
'auto' |