# 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](/components/search.md).

```tsx
// demo.tsx
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

```tsx
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](/components/empty-state.md), 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](/components/modal.md). 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](/components/scroll-area.md), 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](/components/keyboard-shortcut.md), and name them in `aria-keyshortcuts`. The shortcut and the Footer's hints show only on devices that hover, which usually have a keyboard.

```tsx
// keyboard-shortcut.tsx
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:

```tsx
<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>
```

```ts
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](/components/modal.md)'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. `StaticMessage`s 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`](https://base-ui.com/react/components/dialog.md) 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`](https://base-ui.com/react/components/dialog.md) 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-label` (required) | `string` | - | The accessible name, such as "Search documentation". |
| `children` | `ReactNode` | - |  |

Also accepts Base UI [`Autocomplete.Root`](https://base-ui.com/react/components/autocomplete.md) 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`](https://base-ui.com/react/components/combobox.md) props.

### `SearchDialog.Close`

Closes the dialog.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `aria-label` (required) | `string` | - | The accessible name, such as "Close search". |

Also accepts Base UI [`Autocomplete`](https://base-ui.com/react/components/autocomplete.md) props.

### `SearchDialog.Results`

The area below the form, for `Message`s, 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`](https://base-ui.com/react/components/scroll-area.md) props.

### `SearchDialog.Status`

Announces the results to assistive technology, such as "5 results". Visually hidden.

Accepts Base UI [`Autocomplete`](https://base-ui.com/react/components/autocomplete.md) 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 `Result`s inside.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `aria-label` (required) | `string` | - | The accessible name, such as "Results". |

Also accepts Base UI [`Combobox.List`](https://base-ui.com/react/components/combobox.md) 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 |
| --- | --- | --- | --- |
| `href` (required) | `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`](https://base-ui.com/react/components/autocomplete.md) 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 `Hint`s. Shown on devices that hover, which usually have keyboards.

Renders `<div>`. Accepts its HTML attributes and `ref`.

### `SearchDialog.Hint`

A key hint, such as `KeyboardKey`s 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-label` (required) | `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-label` (required) | `string` | - | The accessible name, such as "Close search". |

Also accepts Base UI [`Button`](https://base-ui.com/react/components/button.md) props.

### `SearchDialog.StaticResults`

The area below the form, for `StaticMessage`s, 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 |
| --- | --- | --- | --- |
| `state` (required) | `'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-label` (required) | `string` | - | The accessible name, such as "Results". |

Renders `<div>`. Also accepts its HTML attributes and `ref`.
