# Search input

A search field. `onValueChange` hears every keystroke; `onSearch` hears the text once typing pauses (`debounceMs`), at once on Enter, and with "" at once when the field is cleared, by its Clear button or Escape. Focus stays in the field when Clear is pressed, since the button goes as the text does.

`label` names the field (a placeholder isn't a name). `shortcut` focuses it from anywhere on the page and is shown in the field and in `aria-keyshortcuts`. While `busy` a pulsing dot says it is searching. `results` is announced politely once a search settles: the first render after `onSearch` in which `busy` is false, so set `busy` (or the new `results`) in `onSearch` itself. `null` says nothing, for a count the app doesn't know.

For a button that opens a search surface use `SearchTrigger`; for the command palette, `CommandTrigger`.

- Registry name: `search-input` (`@candor/search-input`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/search-input

## Install

```bash
npx candor-ui add search-input
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/search-input`.
It brings in: kbd, locale, motion, tokens, use-announcer, use-shortcut, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import * as React from "react"
import { SearchInput, SearchTrigger } from "@/components/candor/ui/search-input"

const runs = ["Deploy api to staging", "Deploy web to production", "Backfill orders", "Rotate signing keys", "Rebuild search index"]

export default function Example() {
  const [query, setQuery] = React.useState("")
  const [busy, setBusy] = React.useState(false)
  const [results, setResults] = React.useState<number | null>(null)
  const [opened, setOpened] = React.useState(0)
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
  const shown = runs.filter((r) => r.toLowerCase().includes(query.toLowerCase()))

  return (
    <div className="flex max-w-sm flex-col gap-3 rounded-xl border bg-card p-4">
      <SearchInput
        label="Search runs"
        placeholder="Name or id"
        shortcut="/"
        busy={busy}
        results={results}
        onSearch={(q) => {
          clearTimeout(timer.current)
          if (!q) {
            setQuery("")
            setResults(null)
            return
          }
          // A stand-in for a server search that takes a moment.
          setBusy(true)
          timer.current = setTimeout(() => {
            setQuery(q)
            setResults(runs.filter((r) => r.toLowerCase().includes(q.toLowerCase())).length)
            setBusy(false)
          }, 400)
        }}
      />
      <ul className="flex flex-col gap-1 text-sm">
        {shown.map((r) => (
          <li key={r}>{r}</li>
        ))}
      </ul>
      <SearchTrigger onOpen={() => setOpened((n) => n + 1)} shortcut="mod+k" />
      <p className="text-xs text-muted-foreground">Search opened {opened} times.</p>
    </div>
  )
}
```

## <SearchInput>

Props:

- `label: string`: The field's accessible name ("Search runs"). Required: the placeholder isn't a name.
- `value?: string | undefined`: The text, when the app holds it; pair it with `onValueChange`. Setting it to text other than what the person typed drops the search their typing was waiting on.
- `defaultValue?: string | undefined` (default `""`): The text it starts with, when the field holds it.
- `onValueChange?: ((value: string) => void) | undefined`: Called with the text on every keystroke, and with "" when it is cleared.
- `onSearch?: ((value: string) => void) | undefined`: Called with the text to search for: after `debounceMs` without typing, at once on Enter, and with "" at once when cleared.
- `debounceMs?: number | undefined` (default `250`): How long typing must pause before `onSearch`, in milliseconds. 250 by default.
- `shortcut?: string | undefined`: A `useShortcut` combo ("mod+k", "/") that focuses the field from anywhere on the page, shown in it while it is empty.
- `busy?: boolean | undefined` (default `false`): A search is running: a pulsing dot shows, and the field is `aria-busy`.
- `results?: number | null | undefined`: How many results the last search found, announced once it settles ("12 results", "No results"); `null` says nothing.
- `placeholder?: string | undefined`: Hint text inside the empty field ("Name, id or owner"). Not a name: `label` is.
- `className?: string | undefined`: Classes for the wrapper, merged with Candor's.

## <SearchTrigger>

A button drawn like a search field that opens a search surface (a dialog, a page, a panel), for a top bar or a sidebar. Its name is "Search" (or `label`), and `shortcut`, pressed anywhere on the page, opens it too: the keys are drawn on it and given in `aria-keyshortcuts`. For the command palette, `CommandTrigger` does the same.

Props:

- `onOpen: () => void`: Opens the search surface; called on a press and on the shortcut.
- `label?: string | undefined`: The button's words. "Search" by default.
- `shortcut?: string | undefined`: A `useShortcut` combo ("mod+k", "/") that opens it from anywhere on the page, drawn on the button.
- `className?: string | undefined`: Classes for the button, merged with Candor's.

## Strings (`searchInput`)

The fixed words, English by default. Override any of them under `searchInput` in `<LocaleProvider strings={{ searchInput: { … } }}>`; the table is exported as `searchInputStrings`.

- `clear`: Clear search
- `searching`: Searching
- `results`: { one: "{count} result", other: "{count} results" }
- `noResults`: No results
- `trigger`: Search

## Motion

- **busy** (live): While `busy` is set, a dot in the field pulses to say a search is running; it stops as soon as `busy` is cleared, and under reduced motion it is still.

Set with `--cd-motion-search-input-<moment>-*` in your brand file; the families are in the design reference.

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
