Skip to content
buttery

v1.0.0

Source on GitHub

Version 1.0.0

CommandSearch

The top bar's search: grouped results from typed sources, filters and recent searches.

import { CommandSearch } from "@butteryui/react";

Demo

When to use it

Use it for

  • The app-wide search in a TopBar: pages, records and help in one place
  • A search that takes key:value filters, such as status:shipped

Not for

  • Narrowing one table or list: use SearchField
  • Choosing one value in a form: use Select

Examples

Order filters

Orders narrowed by a status filter typed as key:value, which turns into a chip.

Every order

Roastery search

A search across a roastery's pages and customers that remembers recent searches.

Nothing opened yet

Top bar search

The app's search in its top bar: finding an order opens it on the page under the bar.

Orders

Search for an order to open it here.

Variants and states

Pages, customers and help

Focus it with / or ⌘K. Pages and customers answer as you type; help asks a pretend server and shows a spinner row in its own group.

Props

CommandSearch

PropTypeDefaultDescription
styleMotionStyleInline styles for the field, which can include motion values.
labelstring"Search"Names the field for screen readers, and shows as its placeholder unless there's one. Defaults to "Search".
valuestringWhat's in the field, finished filters included, such as "status:shipped decaf". Pair with onValueChange.
defaultValuestringWhat's in the field at first when uncontrolled.
onValueChange((value: string) => void)Called with what's in the field on every change.
sources

required

readonly SearchSource[]Where it looks: one group each in the panel, in this order.
filtersreadonly SearchFilter[]The key:value filters people can type, suggested while the field is empty and as they type a key.
filtersHrefstringWhere the "View more filters" link under an empty search goes. Leave it out for no link.
resultsHref((query: SearchQuery) => string)Where "View all results" and Enter with no row chosen go, such as a SearchResults page.
onSearch((query: SearchQuery) => void)Called when someone asks for every result: Enter with no row chosen, or "View all results".
storageKeystringRemembers the last five searches under this key, listed while the field is empty.
shortcutbooleantrueWhether / and ⌘K (Ctrl K) focus it from anywhere. Defaults to true.
emptyHintReactNodeA line under "No results", such as a link to the help site.
suggestionsreadonly SearchItem[]Results listed while the field is empty, after the recent searches, such as the pages most people want.
didYouMean((query: SearchQuery) => string | undefined)A better search for a query that found nothing, such as the closest word in the index, which the panel offers as a row under "No results" that runs it. Return undefined for none.
preview((item: SearchItem, query: SearchQuery) => ReactNode)Draws the highlighted result, or the first one, in a pane beside the rows whenever the window has room for it to the field's end, such as its description and a small demo. The pane is for the eye: it's inert, so focus stays in the field and screen readers hear the highlighted row.
labelsPartial<CommandSearchLabels>Its words, for another language or your own.

It also takes Omit<ComponentPropsWithRef<"input">, "size" | "value" | "defaultValue" | "children" | "type" | "style">.

SearchHighlight

PropTypeDefaultDescription
text

required

stringThe words to draw, such as a result's label.
query

required

stringThe search, whose words are marked where they appear in text.

SearchSource

Where a `CommandSearch` looks: one group in its panel, and one section on a `SearchResults` page.

FieldTypeDefaultDescription
remotebooleanLeave it out, or false, for a source that answers at once. true: it answers later.
search

required

(query: SearchQuery) => readonly SearchItem[] | (query: SearchQuery, signal: AbortSignal) => Promise<readonly SearchItem[]>With remote={false}: Finds what matches the query. With remote={true}: Finds what matches the query; signal aborts once the query has moved on.
id

required

stringIdentifies it among the sources.
label

required

stringIts group's heading, such as "Orders".
limitnumber5How many results its group shows in the panel, at most. Defaults to 5.
renderItem((item: SearchItem, query: SearchQuery) => ReactNode)Draws one of its results in place of the standard row.

SearchFilter

A filter people can type into a `CommandSearch` as `key:value`.

FieldTypeDefaultDescription
key

required

stringWhat they type before the colon, such as "status".
description

required

stringWhat it narrows by, shown at the end of its suggestion, such as "where an order has got to".
example

required

stringA value shown after the key while nothing is typed, such as "shipped".
valuesreadonly string[]The values it takes, suggested as people type after the colon. Leave it out for free values.

SearchQuery

What someone is searching for: their words and the filters they've finished.

FieldTypeDefaultDescription
text

required

stringThe words in the field, without the finished filters.
filters

required

readonly SearchToken[]The finished filters, in the order they were typed.

SearchItem

One result in a `CommandSearch` group or on a `SearchResults` page.

FieldTypeDefaultDescription
id

required

stringIdentifies it within its source.
label

required

stringIts words, with the characters that match the search highlighted.
pathreadonly string[]Where it sits, shown before the label with a chevron after each part, such as ["Settings"].
detailstringA short line after the label in text-secondary, such as a handle or an order's total.
iconComponentType<IconComponentProps>An icon before it, such as CircleHelp for a help article.
mediaReactNodeA small element before it in place of an icon, such as a 20px Avatar.
hrefstringWhere choosing it goes.
onSelect(() => void)What choosing it does, as well as going to href when there's one.

CommandSearchLabels

The words a `CommandSearch` shows and announces.

FieldTypeDefaultDescription
results

required

stringNames the list of rows under the field.
filters

required

stringHeads the filters it suggests.
recent

required

stringHeads the recent searches.
moreFilters

required

stringThe link to every filter, under an empty search.
allResults

required

stringThe link to every result, under the groups.
noResults

required

(text: string) => stringSays nothing matched, such as No results for "decaf".
searching

required

(source: string) => stringThe row a remote group shows while it's asking, such as "Searching orders".
clear

required

stringNames the clear button.
removeFilter

required

(token: string) => stringNames a chip's remove button, such as "Remove status:shipped".
filtering

required

(tokens: readonly string[]) => stringDescribes the field while it holds finished filters, such as "Filtered by status:shipped".
resultCount

required

(count: number) => stringAnnounces how many results its sources found, such as "12 results".
suggestionCount

required

(count: number) => stringAnnounces how many filters it suggests while nothing has been found, such as "4 suggestions".
suggestions

required

stringHeads the results suggested while the field is empty.
didYouMean

required

stringHeads the better search offered when nothing is found.

SearchToken

A finished `key:value` filter, shown as a chip in the field.

FieldTypeDefaultDescription
key

required

stringThe filter's key.
value

required

stringWhat it's set to.

Also exported

ExportDescription
formatSearch(query: SearchQuery): stringWrites a query back as the text a CommandSearch holds: each finished filter followed by a space, then the words. parseSearch reads it back as the same query.
matchRanges(label: string, text: string): readonly (readonly [number, number])[]Where the words of text appear in label, ignoring case, as [start, end) ranges in order, merged where they touch or overlap: what CommandSearch highlights.
matchesSearch(text: string, ...fields: readonly (string | undefined)[]): booleanWhether every word of text appears somewhere in fields, ignoring case: how the built-in sources match.
pageSource({ pages, id = "pages", label = "Go to", limit, }: PageSourceOptions): LocalSearchSourceA source for the app's pages, matched by their names and the sections they sit in. Its results show their path before their name. It finds nothing while the search has filters, which pages don't take.
parseSearch(value: string, filters: readonly SearchFilter[], { sent = false }: ParseSearchOptions = {}): SearchQueryReads what's in a CommandSearch field into its finished filters and its words. A key:value whose key is one of filters is finished once a space follows it; until then it's still being typed. A sent search ({ sent: true }), such as the q of a results page, finishes the last one too.

Accessibility

A combobox named by its label that controls a listbox named Results, in a panel with no role of its own. Focus stays in the field while the arrow keys move through the rows across groups and on to the footer's link (aria-activedescendant); Enter opens the row, or every result when no row is chosen; Escape clears, then closes. Each group is named by its heading. The field is described by its finished filters, and a polite status line says how many results its sources found, or that nothing matched, once typing pauses. / and ⌘K focus it. A chip's remove button is named for its filter and takes Tab, and Backspace at the start of the field removes the last chip. Keys an input method is composing with are left alone.

KeysWhat they do
/ or ⌘K (Ctrl K)Focuses the field from anywhere on the page, unless shortcut is false
ArrowDown or ArrowUpMoves through the rows, across groups and on to the link to every result
EnterOpens the highlighted row, or every result when no row is highlighted
TabCompletes the filter key being typed, or takes the highlighted filter
BackspaceRemoves the last filter chip, from the start of the field
EscapeClears the field, then closes the panel, keeping focus in the field

Motion

It changes shape as one surface, on `spring.smooth`, or `spring.morph` for a big change, rather than closing and reopening.

With reduced motion: The field takes its wider size at once as it gains focus, and back as it loses it, instead of growing.

Layout

The field is 34px tall (--bt-h-control) and 360px wide at rest (--bt-w-search), 600 while it has focus (--bt-w-search-open), never wider than its container; filter chips truncate before the text drops under 64px. The panel is as wide as the field, 8px under it, 160 to 480px tall within the window, with rows at least 32px tall whose text truncates.

See it in the roastery demo.