# Search

> The search trigger in the web top bar, and the parts of search results — rows for places and people, message and thread hits, grouped by kind with the matched words marked.

Source: https://raft-design.botiverse.build/components/search. Generated from the current release; where it differs from what you remember, this page is right.

Search is a field, a few filters and results grouped by kind. Places and people are rows; messages are hits that say who, where and when, with the matched words as small yellow stickers. On the web, search opens from the search field in the top bar (`SearchTrigger`); on a phone it is the Search tab, with the field in the tab's top bar.

```tsx
import type { KeyboardEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ConversationPanelContent, SearchShellRoot } from "@botiverse/raft-ui";
import {
  SEARCH_CHANNEL_FIXTURES,
  SEARCH_ENTITY_FIXTURES,
  SEARCH_MESSAGE_FIXTURES,
  SEARCH_SENDER_FIXTURES,
} from "../search.fixtures.ts";
import type {
  SearchChannelFixture,
  SearchEntityFixture,
  SearchMessageFixture,
  SearchSenderFixture,
} from "../search.fixtures.ts";

import { SearchFilters } from "../search-panel/search-panel.filters.tsx";
import { SearchHeader } from "../search-panel/search-panel.parts.tsx";
import type {
  SearchScope,
  SearchSort,
  SearchTimeRange,
} from "../search-panel/search-panel.parts.tsx";
import { SearchResults } from "../search-panel/search-panel.results.tsx";
import {
  filterSearchMessages,
  getExclusiveSenderKind,
  normalizeSearchQuery,
  rankSearchEntities,
  SEARCH_PAGE_SIZE,
} from "../search-panel.logic.ts";

export type SearchPanelCompositionProps = {
  entities?: readonly SearchEntityFixture[];
  messages?: readonly SearchMessageFixture[];
  senders?: readonly SearchSenderFixture[];
  channels?: readonly SearchChannelFixture[];
  defaultQuery?: string;
  defaultSenderId?: string;
  defaultOpenEmptySenderPopup?: boolean;
  view?: "ready" | "loading" | "error";
  focusRequestKey?: number;
  query?: string;
  selectedKey?: string | null;
  onQueryChange?: (query: string) => void;
  onBack: () => void;
  onOpenEntity?: (entity: SearchEntityFixture) => void;
  onOpenMessage?: (message: SearchMessageFixture) => void;
};

type SelectableResult =
  | { key: string; kind: "entity"; entity: SearchEntityFixture }
  | { key: string; kind: "message"; message: SearchMessageFixture };

export function SearchPanelComposition(props: SearchPanelCompositionProps) {
  return <SearchPanel {...props} />;
}

function SearchPanel({
  entities = SEARCH_ENTITY_FIXTURES,
  messages = SEARCH_MESSAGE_FIXTURES,
  senders = SEARCH_SENDER_FIXTURES,
  channels = SEARCH_CHANNEL_FIXTURES,
  defaultQuery = "preview",
  defaultSenderId,
  defaultOpenEmptySenderPopup = false,
  view = "ready",
  focusRequestKey,
  query: controlledQuery,
  selectedKey: controlledSelectedKey,
  onQueryChange,
  onBack,
  onOpenEntity,
  onOpenMessage,
}: SearchPanelCompositionProps) {
  const inputRef = useRef<HTMLInputElement>(null);
  const resultRefs = useRef<Map<string, HTMLButtonElement>>(undefined);
  const composing = useRef(false);
  const [uncontrolledQuery, setUncontrolledQuery] = useState(defaultQuery);
  const [sender, setSender] = useState<SearchSenderFixture | null>(
    () => senders.find((item) => item.id === defaultSenderId) ?? null,
  );
  const [channel, setChannel] = useState<SearchChannelFixture | null>(null);
  const [scopes, setScopes] = useState<SearchScope[]>([]);
  const [timeRange, setTimeRange] = useState<SearchTimeRange>("any");
  const [sort, setSort] = useState<SearchSort>("relevance");
  const [visibleLimit, setVisibleLimit] = useState(SEARCH_PAGE_SIZE);
  const [selectedKey, setSelectedKey] = useState<string | null>(controlledSelectedKey ?? null);
  const [prevControlledSelectedKey, setPrevControlledSelectedKey] = useState(controlledSelectedKey);
  const query = controlledQuery ?? uncontrolledQuery;

  if (controlledSelectedKey !== prevControlledSelectedKey) {
    setPrevControlledSelectedKey(controlledSelectedKey);
    if (controlledSelectedKey !== undefined) setSelectedKey(controlledSelectedKey);
  }

  const setQuery = useCallback(
    (nextQuery: string) => {
      if (controlledQuery === undefined) setUncontrolledQuery(nextQuery);
      onQueryChange?.(nextQuery);
    },
    [controlledQuery, onQueryChange],
  );

  useEffect(() => {
    if (focusRequestKey === undefined) return;
    inputRef.current?.focus({ preventScroll: true });
    inputRef.current?.select();
  }, [focusRequestKey]);

  const entityResults = useMemo(
    () => (normalizeSearchQuery(query) ? rankSearchEntities(entities, query) : []),
    [entities, query],
  );
  const messageResults = useMemo(
    () =>
      filterSearchMessages({
        messages,
        senders,
        channels,
        query,
        sender,
        channel,
        scopes,
        timeRange,
        sort,
      }),
    [messages, senders, channels, query, sender, channel, scopes, timeRange, sort],
  );

  const visibleMessages = useMemo(
    () => messageResults.slice(0, visibleLimit),
    [messageResults, visibleLimit],
  );
  const hasActiveFilters = Boolean(sender || channel || scopes.length || timeRange !== "any");
  const hasIntent = Boolean(normalizeSearchQuery(query) || hasActiveFilters);
  const selectableResults = useMemo<SelectableResult[]>(
    () =>
      view === "ready"
        ? [
            ...entityResults.map((entity) => ({
              key: entity.key,
              kind: "entity" as const,
              entity,
            })),
            ...visibleMessages.map((message) => ({
              key: `message:${message.id}`,
              kind: "message" as const,
              message,
            })),
          ]
        : [],
    [entityResults, view, visibleMessages],
  );
  const resolvedSelectedKey = selectableResults.some((item) => item.key === selectedKey)
    ? selectedKey
    : null;
  const keyboardSelectedKey = resolvedSelectedKey ?? selectableResults[0]?.key ?? null;

  const setResultRef = useCallback((key: string, element: HTMLButtonElement | null) => {
    const refs = (resultRefs.current ??= new Map());
    if (element) refs.set(key, element);
    else refs.delete(key);
  }, []);

  const activate = useCallback(
    (result: SelectableResult) => {
      setSelectedKey(result.key);
      if (result.kind === "entity") onOpenEntity?.(result.entity);
      else onOpenMessage?.(result.message);
    },
    [onOpenEntity, onOpenMessage],
  );

  const moveSelection = useCallback(
    (offset: number) => {
      if (selectableResults.length === 0) return;
      const currentIndex = selectableResults.findIndex((item) => item.key === resolvedSelectedKey);
      const current = currentIndex >= 0 ? currentIndex : offset > 0 ? -1 : selectableResults.length;
      const next = Math.max(0, Math.min(selectableResults.length - 1, current + offset));
      const result = selectableResults[next];
      if (!result) return;
      setSelectedKey(result.key);
      resultRefs.current?.get(result.key)?.focus();
    },
    [resolvedSelectedKey, selectableResults],
  );

  const handleKeyDown = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      if (event.defaultPrevented || event.nativeEvent.isComposing || composing.current) return;
      const target = event.target;
      const isSearchNavigationTarget =
        target === inputRef.current ||
        (target instanceof HTMLElement &&
          target.closest(
            "[data-slot='search-entity-result'], [data-slot='search-message-result']",
          ));
      if (!isSearchNavigationTarget) return;
      if (event.key === "Escape") {
        if (query) {
          event.preventDefault();
          setQuery("");
          inputRef.current?.focus();
        } else {
          onBack?.();
        }
        return;
      }
      if (event.key === "ArrowDown") {
        event.preventDefault();
        moveSelection(1);
      } else if (event.key === "ArrowUp") {
        event.preventDefault();
        moveSelection(-1);
      } else if (event.key === "Enter" && event.target === inputRef.current) {
        const result = selectableResults.find((item) => item.key === keyboardSelectedKey);
        if (result) {
          event.preventDefault();
          activate(result);
        }
      }
    },
    [activate, keyboardSelectedKey, moveSelection, onBack, query, selectableResults, setQuery],
  );

  const handleQueryChange = useCallback(
    (value: string) => {
      setQuery(value);
      setVisibleLimit(SEARCH_PAGE_SIZE);
    },
    [setQuery],
  );

  const handleCompositionStart = useCallback(() => {
    composing.current = true;
  }, []);

  const handleCompositionEnd = useCallback(
    (value: string) => {
      composing.current = false;
      setQuery(value);
      setVisibleLimit(SEARCH_PAGE_SIZE);
    },
    [setQuery],
  );

  const handleClear = useCallback(() => {
    setQuery("");
    inputRef.current?.focus();
  }, [setQuery]);

  const handleSenderChange = useCallback(
    (nextSender: SearchSenderFixture | null) => {
      setSender(nextSender);
      if (!nextSender) return;
      const exclusiveKind = getExclusiveSenderKind(scopes);
      if (exclusiveKind && exclusiveKind !== nextSender.kind) {
        setScopes((current) =>
          current.filter(
            (scope) =>
              scope === "mentioned" ||
              (scope === "humans" && nextSender.kind === "human") ||
              (scope === "agents" && nextSender.kind === "agent"),
          ),
        );
      }
    },
    [scopes],
  );

  const handleScopesChange = useCallback(
    (nextScopes: SearchScope[]) => {
      setScopes(nextScopes);
      const exclusiveKind = getExclusiveSenderKind(nextScopes);
      if (sender && exclusiveKind && exclusiveKind !== sender.kind) setSender(null);
    },
    [sender],
  );

  const handleOpenEntity = useCallback(
    (entity: SearchEntityFixture) => activate({ key: entity.key, kind: "entity", entity }),
    [activate],
  );

  const handleOpenMessage = useCallback(
    (message: SearchMessageFixture) =>
      activate({ key: `message:${message.id}`, kind: "message", message }),
    [activate],
  );

  const handleLoadMore = useCallback(
    () => setVisibleLimit((limit) => limit + SEARCH_PAGE_SIZE),
    [],
  );

  return (
    <SearchShellRoot onKeyDown={handleKeyDown}>
      <SearchHeader
        inputRef={inputRef}
        query={query}
        onBack={onBack}
        onQueryChange={handleQueryChange}
        onCompositionStart={handleCompositionStart}
        onCompositionEnd={handleCompositionEnd}
        onClear={handleClear}
      />

      <ConversationPanelContent render={<section />}>
        <SearchFilters
          senders={senders}
          channels={channels}
          query={query}
          sender={sender}
          channel={channel}
          scopes={scopes}
          timeRange={timeRange}
          sort={sort}
          showEmptySenderPopup={defaultOpenEmptySenderPopup}
          onSenderChange={handleSenderChange}
          onChannelChange={setChannel}
          onScopesChange={handleScopesChange}
          onTimeRangeChange={setTimeRange}
          onSortChange={setSort}
        />

        <SearchResults
          entities={entityResults}
          messages={visibleMessages}
          messageCount={messageResults.length}
          senders={senders}
          channels={channels}
          query={query}
          selectedKey={resolvedSelectedKey}
          hasIntent={hasIntent}
          hasActiveFilters={hasActiveFilters}
          view={view}
          setResultRef={setResultRef}
          onOpenEntity={handleOpenEntity}
          onOpenMessage={handleOpenMessage}
          onLoadMore={handleLoadMore}
        />
      </ConversationPanelContent>
    </SearchShellRoot>
  );
}
```

## When to use

| Situation                                       | Use                                                                                                                     |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Finding a place, a person or a message to go to | Search                                                                                                                  |
| Finding a value to fill in, inside a popup      | A select with search ([ComboboxSelect](https://raft-design.botiverse.build/components/combobox-select.md)) or a [Combobox](https://raft-design.botiverse.build/components/combobox.md) |
| Narrowing a list that is already on screen      | A filter ([Choosing a component](https://raft-design.botiverse.build/components.md#picking-something))                       |

## Anatomy

### Trigger

- `SearchTrigger`: the top bar's search field. It looks like a field and is a button that opens the search dialog. Like every field it doesn't hover; a press lays the medium tier ([Hover and press](https://raft-design.botiverse.build/foundations/states.md#hover-and-press)).
  - A search icon, then `SearchTriggerLabel` (the placeholder, Search Raft), then `SearchTriggerShortcut` (⌘K) at the right end. The label truncates first.

```tsx
import { Search } from "lucide-react";
import {
  Dialog,
  DialogBody,
  DialogClose,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  SearchTrigger,
  SearchTriggerLabel,
  SearchTriggerShortcut,
} from "@botiverse/raft-ui";

export function SearchTriggerComposition() {
  return (
    <Dialog>
      <DialogTrigger render={<SearchTrigger />}>
        <Search />
        <SearchTriggerLabel>Search Raft</SearchTriggerLabel>
        <SearchTriggerShortcut>⌘K</SearchTriggerShortcut>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Search</DialogTitle>
          <DialogClose aria-label="Close" />
        </DialogHeader>
        <DialogBody>
          <InputGroup>
            <InputGroupAddon align="inline-start">
              <Search aria-hidden />
            </InputGroupAddon>
            <InputGroupInput aria-label="Search" placeholder="Search Raft" />
          </InputGroup>
        </DialogBody>
      </DialogContent>
    </Dialog>
  );
}
```

### Results

- `SearchShellRoot`: the search surface, with `SearchShellFilters` (the row of filters) and `SearchShellViewport` (the scrolling results).
- `SearchResultsSummary`: one line about the results: "Searching for “launch”…".
- `SearchResultsSection`: one kind of result, with `SearchResultsSectionHeading` (Messages 13) and `SearchResultsList`.
- `SearchEntityResult`: a channel, a person or an agent. It follows the layout of a [ListRow](https://raft-design.botiverse.build/components/list-row.md): `SearchEntityResultLeading` (a `SearchEntityResultIcon` or an avatar, with an optional `SearchEntityResultActivity` light), and `SearchEntityResultContent` with `SearchEntityResultHeader`, `SearchEntityResultTitle` and `SearchEntityResultDescription`.
- `SearchMessageResult`: a message. `SearchMessageResultHeader` holds `SearchMessageResultSender` (who), `SearchMessageResultMeta` (where), `SearchMessageResultThreadMeta` (in a thread) and `SearchMessageResultTimestamp` (when); `SearchMessageResultBody` holds the excerpt, with each match in `SearchMessageResultMatch`.
- `SearchThreadResult`: a thread with several hits: `SearchThreadResultHeader` (`SearchThreadResultMeta`, `SearchThreadResultTimestamp`, `SearchThreadResultTitle`) and `SearchThreadResultMessages`, a list of `SearchThreadMessageResult`.

```tsx
import { Hash, MessageSquare, User } from "lucide-react";
import {
  Badge,
  SearchEntityResult,
  SearchEntityResultActivity,
  SearchEntityResultContent,
  SearchEntityResultDescription,
  SearchEntityResultHeader,
  SearchEntityResultIcon,
  SearchEntityResultLeading,
  SearchEntityResultTitle,
  SearchMessageResult,
  SearchMessageResultBody,
  SearchMessageResultHeader,
  SearchMessageResultMatch,
  SearchMessageResultMeta,
  SearchMessageResultSender,
  SearchMessageResultThreadMeta,
  SearchMessageResultTimestamp,
  SearchResultsList,
  SearchResultsSection,
  SearchResultsSectionHeading,
  SearchThreadMessageResult,
  SearchThreadResult,
  SearchThreadResultHeader,
  SearchThreadResultMessages,
  SearchThreadResultMeta,
  SearchThreadResultTimestamp,
  SearchThreadResultTitle,
} from "@botiverse/raft-ui";

/** Static gallery of Search result structures and visual states. */
export function SearchPreviewComposition() {
  return (
    <div className="flex w-full max-w-5xl flex-col gap-4">
      <SearchResultsSection>
        <SearchResultsSectionHeading>Entity items</SearchResultsSectionHeading>
        <SearchResultsList>
          <SearchEntityResult disabled selected>
            <SearchEntityResultLeading>
              <SearchEntityResultIcon>
                <User aria-hidden size={14} />
              </SearchEntityResultIcon>
              <SearchEntityResultActivity role="img" aria-label="Active" />
            </SearchEntityResultLeading>
            <SearchEntityResultContent>
              <SearchEntityResultHeader>
                <SearchEntityResultTitle>Maya Chen</SearchEntityResultTitle>
                <Badge appearance="solid" variant="default">
                  Human
                </Badge>
              </SearchEntityResultHeader>
              <SearchEntityResultDescription>Product design</SearchEntityResultDescription>
            </SearchEntityResultContent>
          </SearchEntityResult>

          <SearchEntityResult disabled>
            <SearchEntityResultLeading>
              <SearchEntityResultIcon>
                <Hash aria-hidden size={14} />
              </SearchEntityResultIcon>
            </SearchEntityResultLeading>
            <SearchEntityResultContent>
              <SearchEntityResultHeader>
                <SearchEntityResultTitle muted>proj-search-v1</SearchEntityResultTitle>
                <Badge appearance="soft" variant="muted">
                  Archived
                </Badge>
              </SearchEntityResultHeader>
              <SearchEntityResultDescription muted>
                Earlier search implementation notes
              </SearchEntityResultDescription>
            </SearchEntityResultContent>
          </SearchEntityResult>
        </SearchResultsList>
      </SearchResultsSection>

      <SearchResultsSection>
        <SearchResultsSectionHeading>Message item</SearchResultsSectionHeading>
        <SearchResultsList>
          <SearchMessageResult disabled selected>
            <SearchMessageResultHeader>
              <SearchMessageResultMeta>#design</SearchMessageResultMeta>
              <SearchMessageResultThreadMeta>
                <MessageSquare aria-hidden size={12} /> thread
              </SearchMessageResultThreadMeta>
              <SearchMessageResultSender>Atlas</SearchMessageResultSender>
              <SearchMessageResultTimestamp>73 days ago</SearchMessageResultTimestamp>
            </SearchMessageResultHeader>
            <SearchMessageResultBody>
              I mapped the <SearchMessageResultMatch>Search</SearchMessageResultMatch> composition
              filters to shared primitives so the{" "}
              <SearchMessageResultMatch>preview</SearchMessageResultMatch> keeps native keyboard
              behavior.
            </SearchMessageResultBody>
          </SearchMessageResult>
        </SearchResultsList>
      </SearchResultsSection>

      <SearchResultsSection>
        <SearchResultsSectionHeading>Thread item</SearchResultsSectionHeading>
        <SearchResultsList>
          <SearchThreadResult selected>
            <SearchThreadResultHeader>
              <SearchThreadResultMeta>
                <Badge appearance="soft" variant="muted">
                  <MessageSquare aria-hidden size={12} /> Thread
                </Badge>
                <span>#design</span>
                <span>3 hits</span>
                <SearchThreadResultTimestamp>73 days ago</SearchThreadResultTimestamp>
              </SearchThreadResultMeta>
              <SearchThreadResultTitle>Search source-parity review</SearchThreadResultTitle>
            </SearchThreadResultHeader>
            <SearchThreadResultMessages>
              <SearchThreadMessageResult disabled>
                <SearchMessageResultHeader>
                  <SearchMessageResultMeta>#design</SearchMessageResultMeta>
                  <SearchMessageResultSender>Maya Chen</SearchMessageResultSender>
                  <SearchMessageResultTimestamp>73 days ago</SearchMessageResultTimestamp>
                </SearchMessageResultHeader>
                <SearchMessageResultBody>
                  The grouped thread <SearchMessageResultMatch>preview</SearchMessageResultMatch>{" "}
                  now announces its result count.
                </SearchMessageResultBody>
              </SearchThreadMessageResult>
            </SearchThreadResultMessages>
          </SearchThreadResult>
        </SearchResultsList>
      </SearchResultsSection>
    </div>
  );
}
```

## States

| State                                        | Use for                                                                                                                                                                                                                |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `selected` on a result                       | The keyboard highlight: `brand-soft`, as in menus ([States](https://raft-design.botiverse.build/foundations/states.md#usage))                                                                                                        |
| Menu open (`data-popup-open` on a result)    | `fill-subtle`, the same subtle tier as the hover, so the result the menu acts on stays marked; a `selected` result keeps `brand-soft` ([Hover and press](https://raft-design.botiverse.build/foundations/states.md#hover-and-press)) |
| `muted` on an entity's title and description | A place the person can't post in any more, such as an archived channel                                                                                                                                                 |
| Loading                                      | A summary line and skeletons in the shape of the results ([Feedback](https://raft-design.botiverse.build/patterns/feedback.md#empty-loading-and-failed))                                                                             |
| No results                                   | An [EmptyState](https://raft-design.botiverse.build/components/empty-state.md) that says how to widen the search                                                                                                                                       |

```tsx
import { Hash } from "lucide-react";
import {
  Badge,
  SearchEntityResult,
  SearchEntityResultContent,
  SearchEntityResultDescription,
  SearchEntityResultHeader,
  SearchEntityResultIcon,
  SearchEntityResultLeading,
  SearchEntityResultTitle,
  SearchResultsList,
} from "@botiverse/raft-ui";

export function SearchMutedEntityComposition() {
  return (
    <SearchResultsList className="w-full max-w-xl">
      <SearchEntityResult disabled>
        <SearchEntityResultLeading>
          <SearchEntityResultIcon>
            <Hash aria-hidden size={14} />
          </SearchEntityResultIcon>
        </SearchEntityResultLeading>
        <SearchEntityResultContent>
          <SearchEntityResultHeader>
            <SearchEntityResultTitle muted>proj-search-v1</SearchEntityResultTitle>
            <Badge appearance="soft" variant="muted">
              Archived
            </Badge>
          </SearchEntityResultHeader>
          <SearchEntityResultDescription muted>
            Earlier search implementation notes
          </SearchEntityResultDescription>
        </SearchEntityResultContent>
      </SearchEntityResult>
    </SearchResultsList>
  );
}
```

```tsx
import {
  SearchResultsList,
  SearchResultsSection,
  SearchResultsSectionHeading,
  SearchResultsSummary,
  SearchShellRoot,
  SearchShellViewport,
  Skeleton,
} from "@botiverse/raft-ui";

export function SearchLoadingComposition() {
  return (
    <SearchShellRoot aria-busy="true">
      <SearchShellViewport>
        <SearchResultsSummary>Searching for “design system”…</SearchResultsSummary>
        <SearchResultsSection className="mb-0">
          <SearchResultsSectionHeading>Messages</SearchResultsSectionHeading>
          <SearchResultsList>
            <Skeleton className="h-20" />
            <Skeleton className="h-20" />
          </SearchResultsList>
        </SearchResultsSection>
      </SearchShellViewport>
    </SearchShellRoot>
  );
}
```

```tsx
import { SearchX } from "lucide-react";
import {
  EmptyState,
  EmptyStateContent,
  EmptyStateDescription,
  EmptyStateIcon,
  EmptyStateTitle,
  SearchShellRoot,
  SearchShellViewport,
} from "@botiverse/raft-ui";

export function SearchNoResultsComposition() {
  return (
    <SearchShellRoot>
      <SearchShellViewport>
        <EmptyState className="h-full">
          <EmptyStateContent>
            <EmptyStateIcon>
              <SearchX aria-hidden />
            </EmptyStateIcon>
            <EmptyStateTitle>No results for “design system”</EmptyStateTitle>
            <EmptyStateDescription>
              Try another query or remove one of the active filters.
            </EmptyStateDescription>
          </EmptyStateContent>
        </EmptyState>
      </SearchShellViewport>
    </SearchShellRoot>
  );
}
```

## Behavior

- On a phone, search is the Search tab, and the field is in its navigation row ([Page structure](https://raft-design.botiverse.build/patterns/page-structure.md#phone)). Its keyboard, its kept state, opening a result and searching inside one place are in [Navigation](https://raft-design.botiverse.build/patterns/navigation.md#search-on-a-phone).
- Give the field the same margin on both sides, the content edge of what is under it (16px on a phone). Don't leave a narrower edge on one side for an icon.
- On the web, open search from the top bar's field as a centered dialog, 720px wide, whose top edge covers the top bar's field, so the field seems to grow into it. It has the scrim and no animation, like every centered overlay ([Choosing a component](https://raft-design.botiverse.build/components.md#overlays)).
- Group results by kind ([Conversation](https://raft-design.botiverse.build/patterns/conversation.md#search)): places and people first, then messages.
- Show the scope, the sender, the place and the time range as filters in the filter row, each a select or combobox.
- Open a selected result in the side panel ([Conversation](https://raft-design.botiverse.build/patterns/conversation.md#search)). A hit in a thread opens that thread directly.
- Move the keyboard highlight from the field into the results with the Down arrow, and open the highlighted result with Enter.

## Content

- Make the excerpt of a message at most 2 lines, with the matched words in `SearchMessageResultMatch`.
- Write the no-results state as what wasn't found and how to widen it: "No results for “launch”", "Try another word or remove a filter."
- Write the trigger's placeholder as what it searches: Search Raft.

## Accessibility

- The parts render native elements through Base UI's `useRender`; no Base UI primitive is involved. Results are `button`s by default; pass a link with `render` for a result that navigates.
- `SearchTrigger` is a `button` named by its label. It opens a [Dialog](https://raft-design.botiverse.build/components/dialog.md); give the dialog a title and the field inside it an accessible name (`aria-label="Search"`).
- Put the field and its filters in an element with `role="search"`, and give the clear button `aria-label="Clear search"`.
- `selected` is visual only. When you move a keyboard highlight through the results, keep focus on the field and point to the highlighted result with `aria-activedescendant`, or move focus to it.
- Give an entity's activity light a name: `role="img"` and `aria-label="Active"`.
- `SearchMessageResultMatch` is a `mark`, so matches are announced where the reader supports it.

## API

### SearchShellRoot

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `edge` | `PanelEdge` | `"inset"` |  |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchShellFilters

Also takes the attributes of its element.

### SearchShellViewport

Also takes the attributes of its element.

### SearchResultsSummary

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchResultsSection

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchResultsSectionHeading

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchResultsList

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchEntityResult

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `selected` | `boolean` | `false` |  |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchEntityResultContent

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchEntityResultHeader

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchEntityResultLeading

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchEntityResultIcon

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchEntityResultActivity

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchEntityResultTitle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `muted` | `boolean` | `false` |  |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchEntityResultDescription

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `muted` | `boolean` | `false` |  |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchMessageResult

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `selected` | `boolean` |  |  |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchThreadMessageResult

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `selected` | `boolean` |  |  |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchMessageResultHeader

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchMessageResultBody

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchMessageResultMeta

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchMessageResultThreadMeta

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchMessageResultSender

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchMessageResultTimestamp

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchMessageResultMatch

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchThreadResult

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `selected` | `boolean` | `false` |  |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchThreadResultHeader

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchThreadResultMeta

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchThreadResultTimestamp

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchThreadResultTitle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchThreadResultMessages

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchTrigger

The top bar's search box. Children: a search icon, a `SearchTriggerLabel` and an optional `SearchTriggerShortcut`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchTriggerLabel

The placeholder ("Search Raft"), truncated before the shortcut gives way.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| ((props, state) => ReactElement)` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component. |

Also takes the attributes of its element.

### SearchTriggerShortcut

The shortcut that opens search, at the right end.

Also takes the attributes of its element.

## Changes

- 0.10.0 (Breaking Changes): Field frames (`Input`, `Textarea`, `InputGroup`, `Select`, `Combobox`, `ComboboxSelect`, `Composer`, option `Card`, `SearchTrigger`) are drawn at 1px in `line-field` instead of 1.5px, on the phone too.
- 0.6.1 (Added): `SearchTrigger` (`SearchTriggerLabel`, `SearchTriggerShortcut`): the web top bar's search box, a button drawn as a field.
