Search dialog
A search that opens in a modal dialog, with the field at the top, results as you type, and key hints at the bottom. Use it for site or documentation search. For filtering a list on the page, use Search.
Code
import {KeyboardKey, SearchDialog, Text} from '@earendil-works/elanor-react';
import {useState} from 'react';
const pages = [
{
href: '#getting-started',
section: 'Guides',
title: 'Getting started',
excerpt: 'Install the command-line tool, sign in, and create your first project.',
},
{
href: '#deploying',
section: 'Guides',
title: 'Deploying a project',
excerpt: 'Every project deploys from its default branch. Connect a repository first.',
},
{
href: '#rollbacks',
section: 'Guides',
title: 'Rollbacks',
excerpt: 'A deploy that fails its checks never replaces the running version.',
},
{
href: '#configuration',
section: 'Reference',
title: 'Configuration file',
excerpt: 'Settings for builds, environments, and domains, in one file at the project root.',
},
];
export default function SearchDialogDemo() {
const [query, setQuery] = useState('');
const terms = query.trim().toLowerCase();
const results = terms
? pages.filter((page) => `${page.title} ${page.excerpt}`.toLowerCase().includes(terms))
: [];
return (
<SearchDialog.Root onOpenChange={(open) => open && setQuery('')}>
<SearchDialog.Trigger>Search documentation</SearchDialog.Trigger>
<SearchDialog.Popup aria-label="Search documentation" value={query} onValueChange={setQuery}>
<SearchDialog.Form>
<SearchDialog.Input placeholder="Search documentation" />
<SearchDialog.Close aria-label="Close search" />
</SearchDialog.Form>
<SearchDialog.Results>
<SearchDialog.Status>
{terms ? `${results.length} ${results.length === 1 ? 'result' : 'results'}` : ''}
</SearchDialog.Status>
{terms && results.length === 0 ? (
<SearchDialog.Message>
<Text>No results for “{query.trim()}”.</Text>
</SearchDialog.Message>
) : null}
{terms ? null : (
<SearchDialog.Message>
<Text>Search guides and reference pages.</Text>
</SearchDialog.Message>
)}
<SearchDialog.List aria-label="Results">
{results.map((page) => (
<SearchDialog.Result key={page.href} href={page.href}>
<SearchDialog.ResultContext>{page.section}</SearchDialog.ResultContext>
<SearchDialog.ResultTitle>{page.title}</SearchDialog.ResultTitle>
<SearchDialog.ResultExcerpt>{page.excerpt}</SearchDialog.ResultExcerpt>
</SearchDialog.Result>
))}
</SearchDialog.List>
</SearchDialog.Results>
<SearchDialog.Footer>
<SearchDialog.Hint>
<KeyboardKey>↑</KeyboardKey>
<KeyboardKey>↓</KeyboardKey> to move
</SearchDialog.Hint>
<SearchDialog.Hint>
<KeyboardKey>↵</KeyboardKey> to open
</SearchDialog.Hint>
<SearchDialog.Hint>
<KeyboardKey>Esc</KeyboardKey> to close
</SearchDialog.Hint>
</SearchDialog.Footer>
</SearchDialog.Popup>
</SearchDialog.Root>
);
} Usage
import {SearchDialog} from '@earendil-works/elanor-react';
<SearchDialog.Root>
<SearchDialog.Trigger>Search documentation</SearchDialog.Trigger>
<SearchDialog.Popup aria-label="Search documentation" value={query} onValueChange={setQuery}>
<SearchDialog.Form action="/search">
<SearchDialog.Input name="q" />
<SearchDialog.Close aria-label="Close search" />
</SearchDialog.Form>
<SearchDialog.Results>
<SearchDialog.Status />
<SearchDialog.Message />
<SearchDialog.List aria-label="Results">
<SearchDialog.Result href="/guides/deploying">
<SearchDialog.ResultContext />
<SearchDialog.ResultTitle />
<SearchDialog.ResultExcerpt />
</SearchDialog.Result>
</SearchDialog.List>
</SearchDialog.Results>
<SearchDialog.Footer>
<SearchDialog.Hint />
</SearchDialog.Footer>
</SearchDialog.Popup>
</SearchDialog.Root>;
The application finds the results; the dialog shows them. Control the query with value and onValueChange on the Popup, render a Result per match, and show a Message, such as an Empty state, when there are none. Status announces the count to assistive technology. Clear the query when the dialog opens, in Root’s onOpenChange, so each search starts empty.
The field is a combobox that controls the list. The first result is selected as results arrive; the arrow keys move the selection, and Enter or a click follows the selected result’s link and closes the dialog. A click with a modifier key, such as opening the link in a new tab, leaves it open, and so does ⌘, Ctrl, or Alt with Enter, which opens the selected result in a new tab. Without results, Enter submits the form, so set action and the field’s name for a search page.
Results are a elements. For a router’s link component, pass it as render, such as render={<Link />}; it receives the Result’s href.
The dialog sits semantic.dimension.space.padding.xxxxLarge from the top, so results grow downward without moving the field, and it is as wide as Modal. It grows with its results up to semantic.dimension.size.maxHeight.searchDialog, or the window if shorter. Only the results scroll, in a Scroll area, so the scrollbar overlays them instead of narrowing them. The query uses semantic.typography.ui.searchDialog.input, one step above the UI text.
Keyboard shortcut
The Trigger looks like a search field. The application chooses the keyboard shortcut that opens the dialog: create a handle with SearchDialog.createHandle(), pass it to Root, and call open(null) from a key listener. Show the keys in the Trigger with shortcut, such as a Keyboard shortcut, and name them in aria-keyshortcuts. The shortcut and the Footer’s hints show only on devices that hover, which usually have a keyboard.
Code
import {KeyboardKey, KeyboardShortcut, SearchDialog, Text} from '@earendil-works/elanor-react';
import {useEffect, useState} from 'react';
const searchDialog = SearchDialog.createHandle();
export default function SearchDialogKeyboardShortcutDemo() {
const [query, setQuery] = useState('');
// The application owns the shortcut: here, Ctrl+K or ⌘K opens the search.
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.isComposing) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
searchDialog.open(null);
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
return (
<SearchDialog.Root handle={searchDialog} onOpenChange={(open) => open && setQuery('')}>
<SearchDialog.Trigger
aria-keyshortcuts="Control+K Meta+K"
shortcut={
<KeyboardShortcut>
<KeyboardKey>Ctrl</KeyboardKey>
<KeyboardKey>K</KeyboardKey>
</KeyboardShortcut>
}
>
Search documentation
</SearchDialog.Trigger>
<SearchDialog.Popup aria-label="Search documentation" value={query} onValueChange={setQuery}>
<SearchDialog.Form>
<SearchDialog.Input placeholder="Search documentation" />
<SearchDialog.Close aria-label="Close search" />
</SearchDialog.Form>
<SearchDialog.Results>
<SearchDialog.Message>
<Text>
{query.trim()
? `No results for “${query.trim()}”.`
: 'Search guides and reference pages.'}
</Text>
</SearchDialog.Message>
</SearchDialog.Results>
</SearchDialog.Popup>
</SearchDialog.Root>
);
} Static rendering
On pages rendered on the server without React in the browser, use the static parts. Form, Footer, and Hint stay the same. The results come from a source in the page’s script instead of React:
<SearchDialog.StaticRoot>
<SearchDialog.StaticTrigger>Search documentation</SearchDialog.StaticTrigger>
<SearchDialog.StaticPopup aria-label="Search documentation">
<SearchDialog.Form action="/search">
<SearchDialog.StaticInput name="q" placeholder="Search documentation" />
<SearchDialog.StaticClose aria-label="Close search" />
</SearchDialog.Form>
<SearchDialog.StaticResults>
<SearchDialog.StaticStatus />
<SearchDialog.StaticMessage state="idle">
Search guides and reference pages.
</SearchDialog.StaticMessage>
<SearchDialog.StaticMessage state="empty">
No results for “<SearchDialog.StaticQuery />
”.
</SearchDialog.StaticMessage>
<SearchDialog.StaticList aria-label="Results" />
</SearchDialog.StaticResults>
</SearchDialog.StaticPopup>
</SearchDialog.StaticRoot>
import {
createModal,
createScrollArea,
createSearchDialog,
enhance,
} from '@earendil-works/elanor-react';
const source = {
load: () => loadIndex(),
search: (query) => index.search(query), // [{url, context, title, excerpt}, …]
};
enhance({
modal: createModal,
'scroll-area': createScrollArea,
'search-dialog': (element) => createSearchDialog(element, source),
});
The popup is a native modal dialog, opened and closed like Modal’s static parts. Each opening starts with an empty query; the source loads when the dialog first opens, and up to ten results show once the query has two characters, the first selected. StaticMessages show per state: idle, short, loading, empty, and error. Keyboard shortcuts are the page’s: open the dialog by clicking its trigger, such as with trigger.click(), so it animates like any other opening. Give StaticRoot an id to open the dialog from other buttons too, with commandfor and command="show-modal", and pass the page’s own behaviors to StaticPopup in data-elanor.
API
SearchDialog.Root
A search that opens in a modal dialog. Put a Trigger and a Popup inside. To open it with a keyboard shortcut, pass a handle from createHandle() and call open() from the application.
Accepts Base UI Dialog.Root props.
SearchDialog.Trigger
Opens the dialog. It looks like a search field, with its text as the placeholder.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | The trigger's text, such as "Search documentation". |
shortcut | ReactNode | - | A keyboard shortcut shown at the end on devices that hover, such as a KeyboardShortcut. It only shows the keys: the application listens for them, and names them in aria-keyshortcuts. |
Also accepts Base UI Dialog.Trigger props.
SearchDialog.Popup
The dialog, near the top of the window over a backdrop. Its parts are the Form, the Results, and the Footer, in that order. The field is a combobox that controls the results; the first result is selected as results arrive, and the arrow keys move the selection.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-labelrequired | string | - | The accessible name, such as "Search documentation". |
children | ReactNode | - |
Also accepts Base UI Autocomplete.Root props.
SearchDialog.Form
The top row, a search form: holds the Input and a Close. Without results, Enter submits.
Renders <form>. Accepts its HTML attributes and ref.
SearchDialog.Input
The field: the search icon and the query, without a field's fill or border; the dialog is the field. Focused when the dialog opens. Its type is text, because in a search field Escape first clears the query and browsers add their own clear button.
Accepts Base UI Combobox.Input props.
SearchDialog.Close
Closes the dialog.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-labelrequired | string | - | The accessible name, such as "Close search". |
Also accepts Base UI Autocomplete props.
SearchDialog.Results
The area below the form, for Messages, the List, and a Status. It scrolls in a scroll area, whose scrollbar overlays the results instead of narrowing them.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - |
Also accepts Base UI ScrollArea.Root props.
SearchDialog.Status
Announces the results to assistive technology, such as "5 results". Visually hidden.
Accepts Base UI Autocomplete props.
SearchDialog.Message
What the results area shows instead of results, such as an EmptyState.
Renders <div>. Accepts its HTML attributes and ref.
SearchDialog.List
The results, a listbox the field controls. Put Results inside.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-labelrequired | string | - | The accessible name, such as "Results". |
Also accepts Base UI Combobox.List props.
SearchDialog.Result
A result, a link: put a ResultContext, a ResultTitle, and a ResultExcerpt inside. The selected one gets the active fill. Choosing it follows the link and closes the dialog. It renders an a; for a router's link component, pass it as render, which receives href.
| Prop | Type | Default | Description |
|---|---|---|---|
hrefrequired | string | - | The result's URL. Choosing the result follows it. |
render | React.ReactElement | ComponentRenderFn<RenderFunctionProps, State> | renderResultLink | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |
Also accepts Base UI Autocomplete.Item props.
SearchDialog.ResultContext
Where the result sits, such as its section.
Renders <span>. Accepts its HTML attributes and ref.
SearchDialog.ResultTitle
Renders <span>. Accepts its HTML attributes and ref.
SearchDialog.ResultExcerpt
Up to two lines of the matching text.
Renders <span>. Accepts its HTML attributes and ref.
SearchDialog.Footer
The bottom row, for Hints. Shown on devices that hover, which usually have keyboards.
Renders <div>. Accepts its HTML attributes and ref.
SearchDialog.Hint
A key hint, such as KeyboardKeys followed by what they do.
Renders <span>. Accepts its HTML attributes and ref.
SearchDialog.StaticRoot
A search dialog for pages without React in the browser; createModal, createScrollArea, and createSearchDialog make it work. Renders no element.
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | - | The dialog's id, so other buttons can open it with commandfor. Generated by default. |
children | ReactNode | - |
Also accepts HTML attributes and ref.
SearchDialog.StaticTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | The trigger's text, such as "Search documentation". |
shortcut | ReactNode | - | A keyboard shortcut shown at the end on devices that hover, such as a KeyboardShortcut. |
Renders <button>. Also accepts its HTML attributes and ref.
SearchDialog.StaticPopup
| Prop | Type | Default | Description |
|---|---|---|---|
data-elanor | string | - | The page's own behaviors for this element, attached after the component's. |
aria-labelrequired | string | - | The accessible name, such as "Search documentation". |
Renders <dialog>. Also accepts its HTML attributes and ref.
SearchDialog.StaticInput
Renders <span>. Accepts its HTML attributes and ref.
SearchDialog.StaticClose
| Prop | Type | Default | Description |
|---|---|---|---|
aria-labelrequired | string | - | The accessible name, such as "Close search". |
Also accepts Base UI Button props.
SearchDialog.StaticResults
The area below the form, for StaticMessages, the StaticList, and a StaticStatus.
Renders <div>. Accepts its HTML attributes and ref.
SearchDialog.StaticStatus
Announces the result count to assistive technology. Visually hidden.
Renders <span>. Accepts its HTML attributes and ref.
SearchDialog.StaticMessage
| Prop | Type | Default | Description |
|---|---|---|---|
staterequired | 'idle' | 'short' | 'loading' | 'empty' | 'error' | - | When it shows: no query yet, a query too short to search, while the source loads, no results, or a source that failed to load. |
Renders <div>. Also accepts its HTML attributes and ref.
SearchDialog.StaticQuery
The query, inside a StaticMessage, such as “No results for …”.
Renders <span>. Accepts its HTML attributes and ref.
SearchDialog.StaticList
The results, filled by the behavior: each has its context, title, and excerpt.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-labelrequired | string | - | The accessible name, such as "Results". |
Renders <div>. Also accepts its HTML attributes and ref.