Orbex Sonar-DB Design system , home

Examples

Deliveries and invoices, in plain words

Type “late”, “invoices” or “127”.

Code
tsx
/* 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 true from onItemChoose to 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. icon and clearIcon take 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.
  • statusIcon draws a mark in the status line’s 28 px accent circle (the sparkle of a smart search). popupClassName, align and popupWidth place 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

DoIn a top bar: the magnifier and an example as placeholder; the label stays for screen readers.
Don’tInstructions as placeholder: long, cut off, and gone at the first key.
DoAn empty result suggests a query that works.
Don’t“No results” and nothing else: a dead end.

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.

tsx
import { SearchField } from '@orbex/sonar-components/search'
import '@orbex/sonar-components/search.css'

API

Props of SearchField
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: the outlined box. top-bar: the product’s top-bar search (v38): no box of its own, it fills the app bar’s search pill (39 px), with a 22 px icon, 12 px text and an 18 px clear mark. On touch screens its text is 16 px drawn at three quarters, so it looks the same and iOS does not zoom in.

'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: anchor is the field’s width, auto the field’s width but at least 320 px, or any CSS length (calc(100vw - 24px)).

'auto' | 'anchor' | string 'auto'