# Search field

A search box with a search icon, a clear button and an optional spinner, inside a search landmark.

- **Import:** `import { SearchField } from "@booleanpress/ui/search-field"`
- **APG Search landmark:** <https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/search.html>
- **Page:** <https://ui.booleanpress.com/components/search-field> · @booleanpress/ui 0.2.0

## Usage

`SearchField` is the library's `Input` in an `InputGroup`, inside a search landmark (`role="search"`). Enter calls `onSearch` with the text.

```tsx
import { SearchField } from "@booleanpress/ui/search-field"

export function LogSearch({ onSearch }: { onSearch: (text: string) => void }) {
  return <SearchField aria-label="Search delivery logs" placeholder="Search delivery logs" onSearch={onSearch} />
}
```

It is uncontrolled with `defaultValue`, or controlled with `value` and `onChange`; filter as people type in `onChange`, or on Enter in `onSearch`. A × button shows while there is text: it empties the field, calls `onChange` and keeps the focus in the field. Escape does the same; a second Escape, on the empty field, leaves it. `loading` shows a spinner at the end and marks the field busy while results load. `size` is `sm` (28 px), `default` (35 px) or `lg` (42 px), and `variant="filled"` fills the field grey; both default to the provider's `controlSize` and `fieldVariant`. `className` goes on the landmark round the field; the other attributes go on the input. It is not a form of its own, so it can sit inside one, such as a settings page: Enter searches and never sends that form, and with `name` the text is submitted with it.

## Examples

### Basic

A named search box with a placeholder.

```tsx
import { SearchField } from "@booleanpress/ui/search-field"

export default function SearchFieldBasic() {
  return (
    <div className="w-full max-w-sm">
      <SearchField aria-label="Search delivery logs" placeholder="Search delivery logs" />
    </div>
  )
}
```

### With results count

The list filters as people type, and a status line under the field says how many match.

```tsx
import { useState } from "react"
import { SearchField } from "@booleanpress/ui/search-field"

const MAILERS = ["Amazon SES", "Brevo", "Mailgun", "Postmark", "SendGrid", "SMTP2GO", "SparkPost", "Zoho Mail"]

export default function SearchFieldResultsCount() {
  const [query, setQuery] = useState("")
  const matches = MAILERS.filter((name) => name.toLowerCase().includes(query.trim().toLowerCase()))

  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      <SearchField
        aria-label="Search mailers"
        placeholder="Search mailers"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        aria-describedby="mailer-count"
      />
      <p id="mailer-count" role="status" className="text-xs text-muted-foreground">
        {matches.length} of {MAILERS.length} mailers
      </p>
      <ul className="flex flex-col gap-1 text-sm">
        {matches.map((name) => (
          <li key={name}>{name}</li>
        ))}
      </ul>
    </div>
  )
}
```

### Loading

`loading` shows a spinner at the end; here it shows until you type, then for 800 ms after each change.

```tsx
import { useEffect, useRef, useState } from "react"
import { SearchField } from "@booleanpress/ui/search-field"

export default function SearchFieldLoading() {
  const [loading, setLoading] = useState(true)
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined)
  useEffect(() => () => clearTimeout(timer.current), [])

  return (
    <div className="w-full max-w-sm">
      <SearchField
        aria-label="Search tickets"
        placeholder="Search tickets"
        defaultValue="invoice"
        loading={loading}
        onChange={() => {
          // A pretend lookup: the results arrive 800 ms after the last change.
          setLoading(true)
          clearTimeout(timer.current)
          timer.current = setTimeout(() => setLoading(false), 800)
        }}
      />
    </div>
  )
}
```

### Sizes

`sm` is 28 px tall with 12 px text and icon, `default` 35 px with 14 px, `lg` 42 px with 16 px.

```tsx
import { SearchField } from "@booleanpress/ui/search-field"

export default function SearchFieldSizes() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <SearchField size="sm" aria-label="Search customers, small" placeholder="Small" />
      <SearchField aria-label="Search customers, normal" placeholder="Normal" />
      <SearchField size="lg" aria-label="Search customers, large" placeholder="Large" />
    </div>
  )
}
```

### Filled

`variant="filled"` draws the grey `--field-filled` fill, which stays on hover and focus.

```tsx
import { SearchField } from "@booleanpress/ui/search-field"

export default function SearchFieldFilled() {
  return (
    <div className="w-full max-w-sm">
      <SearchField variant="filled" aria-label="Search organisations" placeholder="Search organisations" />
    </div>
  )
}
```

### Disabled

The text shows; the field and its clear button cannot be used.

```tsx
import { SearchField } from "@booleanpress/ui/search-field"

export default function SearchFieldDisabled() {
  return (
    <div className="w-full max-w-sm">
      <SearchField disabled aria-label="Search API keys" defaultValue="live" />
    </div>
  )
}
```

## Accessibility

**Semantics.** A `div` with `role="search"` (a search landmark) round a native `input type="search"` (a `searchbox`). The clear button is a native `button`; the spinner is a `status` named by the provider string `loading`, and the input carries `aria-busy` while it shows.

**Labels.** Name it with `aria-label`, or a `Label` whose `htmlFor` is its `id`. With neither, the field and the landmark are named by the provider string `search`. With more than one search on a page, give each its own name. The clear button is named by the provider string `clear`.

**Focus.** The input is a tab stop, then the clear button while it shows. Focus turns the edge `--ring`. Clearing keeps the focus in the input.

**Known limits.**

- Inside a dialog, the dialog's own Escape handling runs first and closes it.
- A results count is the app's to show; put it in a `role="status"` element so it is announced.

### Keyboard

| Key | Behaviour |
| --- | --- |
| Enter | Runs the search: calls `onSearch` with the text. It never submits a form round the field. |
| Escape | Empties the field and keeps the focus in it; on an empty field, leaves the field. |
| Tab | Moves from the input to the clear button while it shows. |

## API

### SearchField

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `keyFilter` | `InputKeyFilter` |  | Lets only some characters in: `int`, `num`, `money`, `hex`, `alpha`, `alphanum` or a regular expression. Typed and pasted text it refuses is not inserted. |
| `loading` | `boolean` | `false` | Shows a spinner at the end while results load, and marks the field busy. |
| `onSearch` | `((value: string) => void)` |  | Called with the text when the search is submitted with Enter. |
| `size` | `"default" \| "sm" \| "lg"` |  | The field's size: 28, 35 or 42 px tall. Defaults to the provider's `controlSize`. |
| `variant` | `"default" \| "filled"` |  | `filled` draws the grey `--field-filled` fill. Defaults to the provider's `fieldVariant`. |

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="search-field"` (SearchField), and `data-disabled`.

**Provider strings:** `search` (`BooleanUIProvider`'s `strings`).

## Theming

The field is `InputGroup`'s: `--field`, `--control`, `--ring`, `--invalid` and `--field-disabled`. The search icon and the clear × are `--control-hover`; the spinner is `--muted-foreground`.

| Token | Used for |
| --- | --- |
| `--muted-foreground` | text |
