# Search Picker

A searchable single-choice picker that renders a Combobox on desktop and a search drawer on mobile.

Search Picker is an open-source pattern from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/patterns/search-picker

**React**

**Example**

```tsx
import {
  SearchPicker,
  type SearchPickerItem,
} from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export default function SearchPickerDemo() {
  return (
    <SearchPicker
      items={frameworks}
      placeholder="Select a framework"
      searchPlaceholder="Search frameworks..."
      emptyText="No framework found."
      aria-label="Framework"
      className="w-56"
    />
  )
}
```

`SearchPicker` lets the user find and choose one item from a long list. On desktop it renders a suiss/ui `Combobox`: the field itself is the search input, and the list filters as the user types. On mobile (below `48rem`, via `useIsMobile`) it renders a field-style button that shows the selected label. Tapping it opens a `Drawer` with a swipe handle and a `Command` menu whose search input receives focus. Choosing an item closes the drawer.

Items are passed as data. When any item has a `group`, items are listed under group headings in both presentations. Use it for countries, cities, users and other lists too long to scan; for short lists, use `Picker`.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/search-picker
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add patterns search-picker
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Add the pieces it builds on: [Combobox](https://ui.suiss.app/docs/components/combobox), [Command](https://ui.suiss.app/docs/components/command), [Drawer](https://ui.suiss.app/docs/components/drawer), `use-mobile`.

3. Copy the source into `components/patterns/search-picker.tsx`:

```tsx title="components/patterns/search-picker.tsx"
"use client"

import { CaretUpDownIcon } from "@phosphor-icons/react"
import * as React from "react"
import {
  Combobox,
  ComboboxCollection,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxLabel,
  ComboboxList,
  ComboboxSeparator,
} from "@/components/ui/combobox"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
import {
  Drawer,
  DrawerContent,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"

type SearchPickerItem = {
  value: string
  label: string
  group?: string
  disabled?: boolean
}

type SearchPickerGroup = {
  value: string
  items: SearchPickerItem[]
}

function groupItems(items: SearchPickerItem[]): SearchPickerGroup[] {
  const groups = new Map<string, SearchPickerItem[]>()
  for (const item of items) {
    const key = item.group ?? ""
    groups.set(key, [...(groups.get(key) ?? []), item])
  }
  return [...groups].map(([value, groupItems]) => ({
    value,
    items: groupItems,
  }))
}

function SearchPicker({
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  placeholder,
  searchPlaceholder = "Search...",
  emptyText = "No results found.",
  title,
  disabled,
  floating,
  showCloseButton = false,
  closeLabel,
  id,
  className,
  "aria-invalid": ariaInvalid,
  "aria-label": ariaLabel,
  "aria-describedby": ariaDescribedBy,
}: {
  items: SearchPickerItem[]
  value?: string | null
  defaultValue?: string | null
  onValueChange?: (value: string | null) => void
  placeholder?: string
  searchPlaceholder?: string
  emptyText?: string
  title?: string
  disabled?: boolean
  floating?: boolean
  showCloseButton?: boolean
  closeLabel?: string
  id?: string
  className?: string
  "aria-invalid"?: boolean
  "aria-label"?: string
  "aria-describedby"?: string
}) {
  const isMobile = useIsMobile()
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    defaultValue ?? null
  )
  const value = valueProp === undefined ? uncontrolledValue : valueProp
  const [open, setOpen] = React.useState(false)
  const searchRef = React.useRef<HTMLInputElement>(null)

  const groups = React.useMemo(() => groupItems(items), [items])
  const grouped = groups.some((group) => group.value !== "")
  const selected = items.find((item) => item.value === value) ?? null

  const select = (next: string | null) => {
    if (valueProp === undefined) setUncontrolledValue(next)
    onValueChange?.(next)
  }

  if (isMobile) {
    return (
      <Drawer
        data-slot="search-picker"
        open={open}
        onOpenChange={setOpen}
        floating={floating}
        showSwipeHandle
      >
        <DrawerTrigger
          data-slot="search-picker-trigger"
          disabled={disabled}
          id={id}
          aria-invalid={ariaInvalid}
          aria-label={
            ariaLabel
              ? `${ariaLabel}, ${selected?.label ?? placeholder}`
              : undefined
          }
          aria-describedby={ariaDescribedBy}
          data-placeholder={selected ? undefined : ""}
          className={cn(
            "flex h-11 w-full items-center justify-between gap-1.5 rounded-lg border border-transparent bg-secondary px-3 text-start text-base outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:bg-destructive/5 data-placeholder:text-muted-foreground dark:aria-invalid:bg-destructive/10 disabled:opacity-50",
            className
          )}
        >
          <span className="truncate">{selected?.label ?? placeholder}</span>
          <CaretUpDownIcon className="size-4 shrink-0 text-muted-foreground" />
        </DrawerTrigger>
        <DrawerContent
          data-slot="search-picker-content"
          initialFocus={searchRef}
          showCloseButton={showCloseButton}
          closeLabel={closeLabel}
        >
          <DrawerHeader className={cn(!title && "sr-only")}>
            <DrawerTitle>{title ?? placeholder ?? ariaLabel}</DrawerTitle>
          </DrawerHeader>
          <Command
            className={cn(
              "rounded-none bg-transparent px-3 pt-1.25 pb-[max(--spacing(3),env(safe-area-inset-bottom))] **:data-[slot=command-list]:max-h-[60dvh] **:data-[slot=command-list]:min-h-[40dvh]",
              showCloseButton && "**:data-[slot=command-input-wrapper]:pe-11",
              showCloseButton &&
                closeLabel &&
                "**:data-[slot=command-input-wrapper]:pe-21"
            )}
          >
            <CommandInput ref={searchRef} placeholder={searchPlaceholder} />
            <CommandList>
              <CommandEmpty>{emptyText}</CommandEmpty>
              {groups.map((group) => (
                <CommandGroup
                  key={group.value}
                  heading={group.value || undefined}
                >
                  {group.items.map((item) => (
                    <CommandItem
                      key={item.value}
                      value={item.label}
                      disabled={item.disabled}
                      data-checked={item.value === value}
                      onSelect={() => {
                        select(item.value)
                        setOpen(false)
                      }}
                    >
                      {item.label}
                    </CommandItem>
                  ))}
                </CommandGroup>
              ))}
            </CommandList>
          </Command>
        </DrawerContent>
      </Drawer>
    )
  }

  return (
    <Combobox
      data-slot="search-picker"
      items={grouped ? groups : items}
      value={selected}
      onValueChange={(next: SearchPickerItem | null) =>
        select(next?.value ?? null)
      }
      itemToStringLabel={(item: SearchPickerItem) => item.label}
      isItemEqualToValue={(a: SearchPickerItem, b: SearchPickerItem) =>
        a.value === b.value
      }
      disabled={disabled}
    >
      <ComboboxInput
        placeholder={placeholder}
        disabled={disabled}
        aria-invalid={ariaInvalid}
        id={id}
        aria-label={ariaLabel}
        aria-describedby={ariaDescribedBy}
        className={className}
      />
      <ComboboxContent data-slot="search-picker-content">
        <ComboboxEmpty>{emptyText}</ComboboxEmpty>
        <ComboboxList>
          {grouped
            ? (group: SearchPickerGroup, index: number) => (
                <ComboboxGroup key={group.value} items={group.items}>
                  {index > 0 && <ComboboxSeparator />}
                  {group.value && <ComboboxLabel>{group.value}</ComboboxLabel>}
                  <ComboboxCollection>
                    {(item: SearchPickerItem) => (
                      <ComboboxItem
                        key={item.value}
                        value={item}
                        disabled={item.disabled}
                      >
                        {item.label}
                      </ComboboxItem>
                    )}
                  </ComboboxCollection>
                </ComboboxGroup>
              )
            : (item: SearchPickerItem) => (
                <ComboboxItem
                  key={item.value}
                  value={item}
                  disabled={item.disabled}
                >
                  {item.label}
                </ComboboxItem>
              )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

export { SearchPicker, type SearchPickerItem }
```

4. Update the import paths to match your project.

## Usage

```tsx
import { SearchPicker } from "@/components/patterns/search-picker"
```

```tsx
<SearchPicker items={frameworks} placeholder="Select a framework" searchPlaceholder="Search frameworks..." emptyText="No framework found." aria-label="Framework" className="w-56" />
```

- Each item in `items` has a `value`, a `label`, and an optional `group` and `disabled` flag. The `SearchPickerItem` type is exported.
- The value is uncontrolled by default; use `defaultValue`, or `value` with `onValueChange` to control it. The value is `null` when nothing is selected.
- `searchPlaceholder`, `title`, `floating`, `showCloseButton` and `closeLabel` affect only the mobile drawer. On desktop the field shows `placeholder`.
- Mobile search matches against each item's label.

## Examples

### With value

Starts with a selected item through `defaultValue`.

**Example**

```tsx
import {
  SearchPicker,
  type SearchPickerItem,
} from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export default function SearchPickerWithValue() {
  return (
    <SearchPicker
      items={frameworks}
      placeholder="Select a framework"
      searchPlaceholder="Search frameworks..."
      emptyText="No framework found."
      aria-label="Framework"
      className="w-56"
      defaultValue="astro"
    />
  )
}
```

### Grouped

Lists cities under group headings and filters across groups.

**Example**

```tsx
import {
  SearchPicker,
  type SearchPickerItem,
} from "@/components/patterns/search-picker"

const cities: SearchPickerItem[] = [
  { value: "nyc", label: "New York", group: "Americas" },
  { value: "chi", label: "Chicago", group: "Americas" },
  { value: "sao", label: "São Paulo", group: "Americas" },
  { value: "lon", label: "London", group: "Europe" },
  { value: "ber", label: "Berlin", group: "Europe" },
  { value: "ist", label: "Istanbul", group: "Europe" },
  { value: "tyo", label: "Tokyo", group: "Asia" },
  { value: "sin", label: "Singapore", group: "Asia" },
]

export default function SearchPickerGrouped() {
  return (
    <SearchPicker
      items={cities}
      placeholder="Select a city"
      searchPlaceholder="Search cities..."
      emptyText="No city found."
      aria-label="City"
      className="w-56"
    />
  )
}
```

### With title

Shows a visible title at the top of the mobile drawer.

**Example**

```tsx
import {
  SearchPicker,
  type SearchPickerItem,
} from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export default function SearchPickerWithTitle() {
  return (
    <SearchPicker
      items={frameworks}
      placeholder="Select a framework"
      searchPlaceholder="Search frameworks..."
      emptyText="No framework found."
      aria-label="Framework"
      className="w-56"
      title="Framework"
    />
  )
}
```

### With close button

Adds a close button to the mobile drawer.

**Example**

```tsx
import {
  SearchPicker,
  type SearchPickerItem,
} from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export default function SearchPickerWithCloseButton() {
  return (
    <SearchPicker
      items={frameworks}
      placeholder="Select a framework"
      searchPlaceholder="Search frameworks..."
      emptyText="No framework found."
      aria-label="Framework"
      className="w-56"
      showCloseButton
    />
  )
}
```

### With close label

Uses a Cancel text button for the mobile close button.

**Example**

```tsx
import {
  SearchPicker,
  type SearchPickerItem,
} from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export default function SearchPickerWithCloseLabel() {
  return (
    <SearchPicker
      items={frameworks}
      placeholder="Select a framework"
      searchPlaceholder="Search frameworks..."
      emptyText="No framework found."
      aria-label="Framework"
      className="w-56"
      showCloseButton
      closeLabel="Cancel"
    />
  )
}
```

### Disabled

Disables the field.

**Example**

```tsx
import {
  SearchPicker,
  type SearchPickerItem,
} from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export default function SearchPickerDisabled() {
  return (
    <SearchPicker
      items={frameworks}
      placeholder="Select a framework"
      searchPlaceholder="Search frameworks..."
      emptyText="No framework found."
      aria-label="Framework"
      className="w-56"
      disabled
    />
  )
}
```

### Invalid

Shows the invalid style through `aria-invalid`.

**Example**

```tsx
import {
  SearchPicker,
  type SearchPickerItem,
} from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export default function SearchPickerInvalid() {
  return (
    <SearchPicker
      items={frameworks}
      placeholder="Select a framework"
      searchPlaceholder="Search frameworks..."
      emptyText="No framework found."
      aria-label="Framework"
      className="w-56"
      aria-invalid
    />
  )
}
```

### Controlled

Owns the value in state with `value` and `onValueChange`.

**Example**

```tsx
import * as React from "react"
import {
  SearchPicker,
  type SearchPickerItem,
} from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

function ControlledExample(props: React.ComponentProps<typeof SearchPicker>) {
  const [value, setValue] = React.useState<string | null>("next")

  return (
    <div className="flex flex-col items-start gap-2">
      <SearchPicker {...props} value={value} onValueChange={setValue} />
      <p className="text-sm text-muted-foreground">
        Selected: {value ?? "none"}
      </p>
    </div>
  )
}

export default function SearchPickerControlled() {
  return (
    <ControlledExample
      items={frameworks}
      placeholder="Select a framework"
      searchPlaceholder="Search frameworks..."
      emptyText="No framework found."
      aria-label="Framework"
      className="w-56"
    />
  )
}
```

### Floating

Insets the mobile drawer from the screen edges.

**Example**

```tsx
import {
  SearchPicker,
  type SearchPickerItem,
} from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export default function SearchPickerFloating() {
  return (
    <SearchPicker
      items={frameworks}
      placeholder="Select a framework"
      searchPlaceholder="Search frameworks..."
      emptyText="No framework found."
      aria-label="Framework"
      className="w-56"
      floating
    />
  )
}
```

## API Reference

### SearchPicker

The complete searchable picker, rendered from the `items` array.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `SearchPickerItem[]` | Required | The items to choose from, each with a `value`, `label`, and optional `group` and `disabled`. |
| `value` | `string \| null` | - | The selected value when controlled, or `null` for no selection. |
| `defaultValue` | `string \| null` | - | The initially selected value when uncontrolled. |
| `onValueChange` | `(value: string \| null) => void` | - | Called with the new value, or `null`, when the selection changes. |
| `placeholder` | `string` | - | Text shown when nothing is selected. |
| `searchPlaceholder` | `string` | `"Search..."` | Placeholder of the search input in the mobile drawer. |
| `emptyText` | `string` | `"No results found."` | Text shown when no item matches the search. |
| `title` | `string` | - | A visible heading for the mobile drawer. When omitted, the drawer title is visually hidden and falls back to `placeholder` or `aria-label`. |
| `disabled` | `boolean` | - | Disables the field. |
| `floating` | `boolean` | - | Insets the mobile drawer from the screen edges. |
| `showCloseButton` | `boolean` | `false` | Shows a close button in the mobile drawer. |
| `closeLabel` | `string` | - | Text for the mobile close button in place of the icon. |
| `id` | `string` | - | The ID of the input on desktop or the trigger button on mobile. |
| `className` | `string` | - | Classes applied to the input on desktop or the trigger button on mobile. |
| `aria-invalid` | `boolean` | - | Marks the field as invalid and applies the error style. |
| `aria-label` | `string` | - | The accessible name of the field. |
| `aria-describedby` | `string` | - | The ID of an element that describes the field. |

## Accessibility

- On desktop the field has the `combobox` role and controls a `listbox`. The selected option has `aria-selected`, disabled options have `aria-disabled`, and groups are exposed as `group` elements named by their heading.
- Escape closes the desktop list.
- On mobile, the drawer is titled by `title`, `placeholder` or `aria-label`, and focus moves to the search input when it opens.

## Notes

Styling hooks: `data-slot="search-picker"`, `search-picker-trigger` (mobile) and `search-picker-content`. The mobile trigger has `data-placeholder` while nothing is selected, and the selected command item has `data-checked`.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/patterns-search-picker--default.light.webp)

```tsx
import { SearchPicker, type SearchPickerItem } from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export function SearchPickerExample() {
  return (
    <SearchPicker items={frameworks} placeholder="Select a framework" searchPlaceholder="Search frameworks..." emptyText="No framework found." accessibilityLabel="Framework" className="w-56" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

let searchPickerFrameworks = [
  SearchPickerItem(value: "next", label: "Next.js"),
  SearchPickerItem(value: "remix", label: "Remix"),
  SearchPickerItem(value: "astro", label: "Astro"),
  SearchPickerItem(value: "sveltekit", label: "SvelteKit"),
  SearchPickerItem(value: "nuxt", label: "Nuxt"),
  SearchPickerItem(value: "gatsby", label: "Gatsby", disabled: true),
]

struct SearchPickerDemo: View {
  var items = searchPickerFrameworks
  var placeholder: LocalizedStringKey = "Select a framework"
  var searchPlaceholder: LocalizedStringKey = "Search frameworks..."
  var emptyText: LocalizedStringKey = "No framework found."
  var label: LocalizedStringKey = "Framework"
  var title: LocalizedStringKey?
  var showCloseButton = false
  var closeLabel: LocalizedStringKey?

  @State private var value: String?

  init(
    value: String? = nil,
    items: [SearchPickerItem] = searchPickerFrameworks,
    placeholder: LocalizedStringKey = "Select a framework",
    searchPlaceholder: LocalizedStringKey = "Search frameworks...",
    emptyText: LocalizedStringKey = "No framework found.",
    label: LocalizedStringKey = "Framework",
    title: LocalizedStringKey? = nil,
    showCloseButton: Bool = false,
    closeLabel: LocalizedStringKey? = nil
  ) {
    _value = State(initialValue: value)
    self.items = items
    self.placeholder = placeholder
    self.searchPlaceholder = searchPlaceholder
    self.emptyText = emptyText
    self.label = label
    self.title = title
    self.showCloseButton = showCloseButton
    self.closeLabel = closeLabel
  }

  var body: some View {
    SearchPicker(
      items: items,
      selection: $value,
      placeholder: placeholder,
      searchPlaceholder: searchPlaceholder,
      emptyText: emptyText,
      title: title,
      showCloseButton: showCloseButton,
      closeLabel: closeLabel
    )
    .frame(width: 224)
    .accessibilityLabel(Text(label))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.patterns.SearchPicker
import com.esuiss.ui.patterns.SearchPickerItem

val searchPickerFrameworks = listOf(
    SearchPickerItem("next", "Next.js"),
    SearchPickerItem("remix", "Remix"),
    SearchPickerItem("astro", "Astro"),
    SearchPickerItem("sveltekit", "SvelteKit"),
    SearchPickerItem("nuxt", "Nuxt"),
    SearchPickerItem("gatsby", "Gatsby", enabled = false),
)

@Composable
fun SearchPickerDemo(
    initial: String? = null,
    items: List<SearchPickerItem> = searchPickerFrameworks,
    placeholder: String = "Select a framework",
    searchPlaceholder: String = "Search frameworks...",
    emptyText: String = "No framework found.",
    label: String = "Framework",
    title: String? = null,
    enabled: Boolean = true,
    invalid: Boolean? = null,
    floating: Boolean = false,
    showCloseButton: Boolean = false,
    closeLabel: String? = null,
) {
    var value by rememberSaveable { mutableStateOf(initial) }
    SearchPicker(
        items = items,
        value = value,
        onValueChange = { value = it },
        modifier = Modifier.width(224.dp).semantics { contentDescription = label },
        placeholder = placeholder,
        searchPlaceholder = searchPlaceholder,
        emptyText = emptyText,
        title = title,
        enabled = enabled,
        invalid = invalid,
        floating = floating,
        showCloseButton = showCloseButton,
        closeLabel = closeLabel,
    )
}
```

`SearchPicker` renders a field-style `DrawerTrigger` with the selected label and an up-down caret. Pressing it opens a `Drawer` with a search input and a scrollable list of options.

Typing filters options by label, case-insensitively. Options with a `group` are shown under a group heading. Choosing an option selects it, closes the drawer and clears the search. Use it when the list is too long to scan, such as countries, cities or frameworks.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/search-picker
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add search-picker
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add search-picker --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add search-picker --platform kotlin
```

The CLI also adds what it builds on: [Drawer](https://ui.suiss.app/docs/components/drawer), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text).

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-svg
```

2. Add the pieces it builds on: [Drawer](https://ui.suiss.app/docs/components/drawer), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/patterns/search-picker.tsx`:

```tsx title="uniwind/components/patterns/search-picker.tsx"
import { cn } from "cn"
import {
  CaretUpDownIcon,
  CheckIcon,
  MagnifyingGlassIcon,
} from "phosphor-react-native"
import * as React from "react"
import {
  Pressable,
  ScrollView,
  TextInput,
  useWindowDimensions,
  View,
} from "react-native"
import {
  Drawer,
  DrawerContent,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type SearchPickerItem = {
  value: string
  label: string
  group?: string
  disabled?: boolean
}

type SearchPickerGroup = {
  value: string
  items: SearchPickerItem[]
}

function groupItems(items: SearchPickerItem[]): SearchPickerGroup[] {
  const groups = new Map<string, SearchPickerItem[]>()
  for (const item of items) {
    const key = item.group ?? ""
    groups.set(key, [...(groups.get(key) ?? []), item])
  }
  return [...groups].map(([value, groupItems]) => ({
    value,
    items: groupItems,
  }))
}

function SearchPicker({
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  placeholder,
  searchPlaceholder = "Search...",
  emptyText = "No results found.",
  title,
  disabled,
  floating,
  showCloseButton = false,
  closeLabel,
  invalid,
  className,
  accessibilityLabel,
  accessibilityLabelledBy,
}: {
  items: SearchPickerItem[]
  value?: string | null
  defaultValue?: string | null
  onValueChange?: (value: string | null) => void
  placeholder?: string
  searchPlaceholder?: string
  emptyText?: string
  title?: string
  disabled?: boolean
  floating?: boolean
  showCloseButton?: boolean
  closeLabel?: string
  invalid?: boolean
  className?: string
  accessibilityLabel?: string
  accessibilityLabelledBy?: string
}) {
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    defaultValue ?? null
  )
  const value = valueProp === undefined ? uncontrolledValue : valueProp
  const [open, setOpen] = React.useState(false)
  const [query, setQuery] = React.useState("")
  const { height } = useWindowDimensions()

  const selected = items.find((item) => item.value === value) ?? null
  const groups = React.useMemo(() => {
    const search = query.trim().toLocaleLowerCase()
    const matches = search
      ? items.filter((item) => item.label.toLocaleLowerCase().includes(search))
      : items
    return groupItems(matches)
  }, [items, query])

  const select = (next: string | null) => {
    if (valueProp === undefined) setUncontrolledValue(next)
    onValueChange?.(next)
  }

  return (
    <Drawer
      open={open}
      onOpenChange={(next) => {
        setOpen(next)
        if (!next) setQuery("")
      }}
      floating={floating}
      showSwipeHandle
    >
      <DrawerTrigger
        disabled={disabled}
        accessibilityLabel={accessibilityLabel}
        accessibilityLabelledBy={accessibilityLabelledBy}
        className={cn(
          "h-11 w-full flex-row items-center justify-between gap-1.5 rounded-lg border border-transparent bg-secondary px-3",
          invalid &&
            "border-destructive bg-destructive/5 dark:bg-destructive/10",
          disabled && "opacity-50",
          className
        )}
      >
        <Text
          numberOfLines={1}
          className={cn(
            "flex-1 text-base text-foreground",
            !selected && "text-muted-foreground"
          )}
        >
          {selected?.label ?? placeholder}
        </Text>
        <Icon
          as={CaretUpDownIcon}
          className="size-4 shrink-0 text-muted-foreground"
        />
      </DrawerTrigger>
      <DrawerContent
        showCloseButton={showCloseButton}
        closeLabel={closeLabel}
        accessibilityLabel={
          title ? undefined : (placeholder ?? accessibilityLabel)
        }
      >
        {title ? (
          <DrawerHeader>
            <DrawerTitle>{title}</DrawerTitle>
          </DrawerHeader>
        ) : null}
        <View className="px-3 pt-1.25 pb-3">
          <View
            className={cn(
              "h-14 shrink-0 flex-row items-center gap-3 border-b border-border px-4",
              showCloseButton && "pr-11",
              showCloseButton && closeLabel && "pr-21"
            )}
          >
            <Icon
              as={MagnifyingGlassIcon}
              className="size-5.5 shrink-0 text-muted-foreground"
            />
            <TextInput
              value={query}
              onChangeText={setQuery}
              placeholder={searchPlaceholder}
              accessibilityLabel={searchPlaceholder}
              autoCorrect={false}
              autoCapitalize="none"
              returnKeyType="search"
              className="h-full min-w-0 flex-1 py-0 text-[20px] text-foreground"
              placeholderTextColorClassName="accent-muted-foreground"
              cursorColorClassName="accent-primary"
              selectionColorClassName="accent-primary"
            />
          </View>
          <ScrollView
            keyboardShouldPersistTaps="handled"
            showsVerticalScrollIndicator={false}
            style={{ minHeight: height * 0.4, maxHeight: height * 0.6 }}
            contentContainerClassName="p-1.5"
          >
            {groups.length === 0 ? (
              <Text className="py-10 text-center text-base text-muted-foreground">
                {emptyText}
              </Text>
            ) : (
              groups.map((group, index) => (
                <View
                  key={group.value}
                  role="group"
                  accessibilityLabel={group.value || undefined}
                  className={cn(index > 0 && "pt-2")}
                >
                  {group.value ? (
                    <Text className="px-3 pt-1 pb-1.5 text-xs font-semibold text-muted-foreground">
                      {group.value}
                    </Text>
                  ) : null}
                  {group.items.map((item) => {
                    const checked = item.value === value
                    return (
                      <Pressable
                        key={item.value}
                        disabled={item.disabled}
                        accessibilityRole="button"
                        accessibilityState={{
                          selected: checked,
                          disabled: !!item.disabled,
                        }}
                        onPress={() => {
                          select(item.value)
                          setOpen(false)
                          setQuery("")
                        }}
                        className="h-10 flex-row items-center gap-3 rounded-lg px-3 active:opacity-80"
                      >
                        <Text
                          numberOfLines={1}
                          className={cn(
                            "flex-1 text-base text-foreground",
                            item.disabled && "opacity-50"
                          )}
                        >
                          {item.label}
                        </Text>
                        {checked ? (
                          <Icon
                            as={CheckIcon}
                            className="size-5 shrink-0 text-muted-foreground"
                          />
                        ) : null}
                      </Pressable>
                    )
                  })}
                </View>
              ))
            )}
          </ScrollView>
        </View>
      </DrawerContent>
    </Drawer>
  )
}

export { SearchPicker, type SearchPickerItem }
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn nativewind phosphor-react-native react-native-svg
```

2. Add the pieces it builds on: [Drawer](https://ui.suiss.app/docs/components/drawer), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/patterns/search-picker.tsx`:

```tsx title="nativewind/components/patterns/search-picker.tsx"
import { cn } from "cn"
import { cssInterop } from "nativewind"
import {
  CaretUpDownIcon,
  CheckIcon,
  MagnifyingGlassIcon,
} from "phosphor-react-native"
import * as React from "react"
import {
  Pressable,
  ScrollView,
  TextInput,
  useWindowDimensions,
  View,
} from "react-native"
import {
  Drawer,
  DrawerContent,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

declare module "react-native" {
  interface TextInputProps {
    cursorColorClassName?: string
    selectionColorClassName?: string
  }
}

cssInterop(TextInput, {
  className: { target: "style", nativeStyleToProp: { textAlign: true } },
  placeholderClassName: {
    target: false,
    nativeStyleToProp: { color: "placeholderTextColor" },
  },
  cursorColorClassName: {
    target: false,
    nativeStyleToProp: { color: "cursorColor" },
  },
  selectionColorClassName: {
    target: false,
    nativeStyleToProp: { color: "selectionColor" },
  },
})

type SearchPickerItem = {
  value: string
  label: string
  group?: string
  disabled?: boolean
}

type SearchPickerGroup = {
  value: string
  items: SearchPickerItem[]
}

function groupItems(items: SearchPickerItem[]): SearchPickerGroup[] {
  const groups = new Map<string, SearchPickerItem[]>()
  for (const item of items) {
    const key = item.group ?? ""
    groups.set(key, [...(groups.get(key) ?? []), item])
  }
  return [...groups].map(([value, groupItems]) => ({
    value,
    items: groupItems,
  }))
}

function SearchPicker({
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  placeholder,
  searchPlaceholder = "Search...",
  emptyText = "No results found.",
  title,
  disabled,
  floating,
  showCloseButton = false,
  closeLabel,
  invalid,
  className,
  accessibilityLabel,
  accessibilityLabelledBy,
}: {
  items: SearchPickerItem[]
  value?: string | null
  defaultValue?: string | null
  onValueChange?: (value: string | null) => void
  placeholder?: string
  searchPlaceholder?: string
  emptyText?: string
  title?: string
  disabled?: boolean
  floating?: boolean
  showCloseButton?: boolean
  closeLabel?: string
  invalid?: boolean
  className?: string
  accessibilityLabel?: string
  accessibilityLabelledBy?: string
}) {
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    defaultValue ?? null
  )
  const value = valueProp === undefined ? uncontrolledValue : valueProp
  const [open, setOpen] = React.useState(false)
  const [query, setQuery] = React.useState("")
  const { height } = useWindowDimensions()

  const selected = items.find((item) => item.value === value) ?? null
  const groups = React.useMemo(() => {
    const search = query.trim().toLocaleLowerCase()
    const matches = search
      ? items.filter((item) => item.label.toLocaleLowerCase().includes(search))
      : items
    return groupItems(matches)
  }, [items, query])

  const select = (next: string | null) => {
    if (valueProp === undefined) setUncontrolledValue(next)
    onValueChange?.(next)
  }

  return (
    <Drawer
      open={open}
      onOpenChange={(next) => {
        setOpen(next)
        if (!next) setQuery("")
      }}
      floating={floating}
      showSwipeHandle
    >
      <DrawerTrigger
        disabled={disabled}
        accessibilityLabel={accessibilityLabel}
        accessibilityLabelledBy={accessibilityLabelledBy}
        className={cn(
          "h-11 w-full flex-row items-center justify-between gap-1.5 rounded-lg border border-transparent bg-secondary px-3",
          invalid &&
            "border-destructive bg-destructive-5 dark:bg-destructive-10",
          disabled && "opacity-50",
          className
        )}
      >
        <Text
          numberOfLines={1}
          className={cn(
            "flex-1 text-base text-foreground",
            !selected && "text-muted-foreground"
          )}
        >
          {selected?.label ?? placeholder}
        </Text>
        <Icon
          as={CaretUpDownIcon}
          className="size-4 shrink-0 text-muted-foreground"
        />
      </DrawerTrigger>
      <DrawerContent
        showCloseButton={showCloseButton}
        closeLabel={closeLabel}
        accessibilityLabel={
          title ? undefined : (placeholder ?? accessibilityLabel)
        }
      >
        {title ? (
          <DrawerHeader>
            <DrawerTitle>{title}</DrawerTitle>
          </DrawerHeader>
        ) : null}
        <View className="px-3 pt-[0.3125rem] pb-3">
          <View
            className={cn(
              "h-14 shrink-0 flex-row items-center gap-3 border-b border-border px-4",
              showCloseButton && "pr-11",
              showCloseButton && closeLabel && "pr-[5.25rem]"
            )}
          >
            <Icon
              as={MagnifyingGlassIcon}
              className="size-[1.375rem] shrink-0 text-muted-foreground"
            />
            <TextInput
              value={query}
              onChangeText={setQuery}
              placeholder={searchPlaceholder}
              accessibilityLabel={searchPlaceholder}
              autoCorrect={false}
              autoCapitalize="none"
              returnKeyType="search"
              className="h-full min-w-0 flex-1 py-0 text-[20px] text-foreground"
              placeholderClassName="text-muted-foreground"
              cursorColorClassName="text-primary"
              selectionColorClassName="text-primary"
            />
          </View>
          <ScrollView
            keyboardShouldPersistTaps="handled"
            showsVerticalScrollIndicator={false}
            style={{ minHeight: height * 0.4, maxHeight: height * 0.6 }}
            contentContainerClassName="p-1.5"
          >
            {groups.length === 0 ? (
              <Text className="py-10 text-center text-base text-muted-foreground">
                {emptyText}
              </Text>
            ) : (
              groups.map((group, index) => (
                <View
                  key={group.value}
                  role="group"
                  accessibilityLabel={group.value || undefined}
                  className={cn(index > 0 && "pt-2")}
                >
                  {group.value ? (
                    <Text className="px-3 pt-1 pb-1.5 text-xs font-semibold text-muted-foreground">
                      {group.value}
                    </Text>
                  ) : null}
                  {group.items.map((item) => {
                    const checked = item.value === value
                    return (
                      <Pressable
                        key={item.value}
                        disabled={item.disabled}
                        accessibilityRole="button"
                        accessibilityState={{
                          selected: checked,
                          disabled: !!item.disabled,
                        }}
                        onPress={() => {
                          select(item.value)
                          setOpen(false)
                          setQuery("")
                        }}
                        className="h-10 flex-row items-center gap-3 rounded-lg px-3 active:opacity-80"
                      >
                        <Text
                          numberOfLines={1}
                          className={cn(
                            "flex-1 text-base text-foreground",
                            item.disabled && "opacity-50"
                          )}
                        >
                          {item.label}
                        </Text>
                        {checked ? (
                          <Icon
                            as={CheckIcon}
                            className="size-5 shrink-0 text-muted-foreground"
                          />
                        ) : null}
                      </Pressable>
                    )
                  })}
                </View>
              ))
            )}
          </ScrollView>
        </View>
      </DrawerContent>
    </Drawer>
  )
}

export { SearchPicker, type SearchPickerItem }
```

4. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-svg
```

2. Add the pieces it builds on: [Drawer](https://ui.suiss.app/docs/components/drawer), [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/patterns/search-picker.tsx`:

```tsx title="stylesheet/components/patterns/search-picker.tsx"
import {
  CaretUpDownIcon,
  CheckIcon,
  MagnifyingGlassIcon,
} from "phosphor-react-native"
import * as React from "react"
import {
  Pressable,
  ScrollView,
  type StyleProp,
  StyleSheet,
  TextInput,
  useWindowDimensions,
  View,
  type ViewStyle,
} from "react-native"
import {
  Drawer,
  DrawerContent,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
import { fontSize, radius, useTheme } from "@/components/ui/theme"

type SearchPickerItem = {
  value: string
  label: string
  group?: string
  disabled?: boolean
}

type SearchPickerGroup = {
  value: string
  items: SearchPickerItem[]
}

const styles = StyleSheet.create({
  trigger: {
    height: 44,
    width: "100%",
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "space-between",
    gap: 6,
    borderRadius: radius.lg,
    borderWidth: 1,
    borderColor: "transparent",
    paddingHorizontal: 12,
  },
  triggerText: { flex: 1, ...fontSize.base },
  icon: { flexShrink: 0 },
  body: { paddingHorizontal: 12, paddingTop: 5, paddingBottom: 12 },
  search: {
    height: 56,
    flexShrink: 0,
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
    borderBottomWidth: 1,
    paddingHorizontal: 16,
  },
  input: {
    height: "100%",
    minWidth: 0,
    flex: 1,
    paddingVertical: 0,
    fontSize: fontSize.xl.fontSize,
  },
  list: { padding: 6 },
  empty: { paddingVertical: 40, textAlign: "center", ...fontSize.base },
  spaced: { paddingTop: 8 },
  heading: {
    paddingHorizontal: 12,
    paddingTop: 4,
    paddingBottom: 6,
    ...fontSize.xs,
    fontWeight: "600",
  },
  item: {
    height: 40,
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
    borderRadius: radius.lg,
    paddingHorizontal: 12,
  },
  itemText: { flex: 1, ...fontSize.base },
})

function withAlpha(color: string, alpha: number) {
  if (!color.startsWith("#") || color.length !== 7) return color
  const r = Number.parseInt(color.slice(1, 3), 16)
  const g = Number.parseInt(color.slice(3, 5), 16)
  const b = Number.parseInt(color.slice(5, 7), 16)
  return `rgba(${r}, ${g}, ${b}, ${alpha})`
}

function groupItems(items: SearchPickerItem[]): SearchPickerGroup[] {
  const groups = new Map<string, SearchPickerItem[]>()
  for (const item of items) {
    const key = item.group ?? ""
    groups.set(key, [...(groups.get(key) ?? []), item])
  }
  return [...groups].map(([value, groupItems]) => ({
    value,
    items: groupItems,
  }))
}

function SearchPicker({
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  placeholder,
  searchPlaceholder = "Search...",
  emptyText = "No results found.",
  title,
  disabled,
  floating,
  showCloseButton = false,
  closeLabel,
  invalid,
  style,
  accessibilityLabel,
  accessibilityLabelledBy,
}: {
  items: SearchPickerItem[]
  value?: string | null
  defaultValue?: string | null
  onValueChange?: (value: string | null) => void
  placeholder?: string
  searchPlaceholder?: string
  emptyText?: string
  title?: string
  disabled?: boolean
  floating?: boolean
  showCloseButton?: boolean
  closeLabel?: string
  invalid?: boolean
  style?: StyleProp<ViewStyle>
  accessibilityLabel?: string
  accessibilityLabelledBy?: string
}) {
  const { colors, isDark } = useTheme()
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    defaultValue ?? null
  )
  const value = valueProp === undefined ? uncontrolledValue : valueProp
  const [open, setOpen] = React.useState(false)
  const [query, setQuery] = React.useState("")
  const { height } = useWindowDimensions()

  const selected = items.find((item) => item.value === value) ?? null
  const groups = React.useMemo(() => {
    const search = query.trim().toLocaleLowerCase()
    const matches = search
      ? items.filter((item) => item.label.toLocaleLowerCase().includes(search))
      : items
    return groupItems(matches)
  }, [items, query])

  const select = (next: string | null) => {
    if (valueProp === undefined) setUncontrolledValue(next)
    onValueChange?.(next)
  }

  const triggerColor = selected ? colors.foreground : colors.mutedForeground

  return (
    <Drawer
      open={open}
      onOpenChange={(next) => {
        setOpen(next)
        if (!next) setQuery("")
      }}
      floating={floating}
      showSwipeHandle
    >
      <DrawerTrigger
        disabled={disabled}
        accessibilityLabel={accessibilityLabel}
        accessibilityLabelledBy={accessibilityLabelledBy}
        style={[
          styles.trigger,
          { backgroundColor: colors.secondary },
          invalid && {
            borderColor: colors.destructive,
            backgroundColor: withAlpha(colors.destructive, isDark ? 0.1 : 0.05),
          },
          disabled && { opacity: 0.5 },
          style,
        ]}
      >
        <Text
          numberOfLines={1}
          style={[
            styles.triggerText,
            {
              color: triggerColor,
            },
          ]}
        >
          {selected?.label ?? placeholder}
        </Text>
        <Icon
          as={CaretUpDownIcon}
          size={16}
          color={colors.mutedForeground}
          style={styles.icon}
        />
      </DrawerTrigger>
      <DrawerContent
        showCloseButton={showCloseButton}
        closeLabel={closeLabel}
        accessibilityLabel={
          title ? undefined : (placeholder ?? accessibilityLabel)
        }
      >
        {title ? (
          <DrawerHeader>
            <DrawerTitle>{title}</DrawerTitle>
          </DrawerHeader>
        ) : null}
        <View style={styles.body}>
          <View
            style={[
              styles.search,
              { borderBottomColor: colors.border },
              showCloseButton && { paddingRight: closeLabel ? 84 : 44 },
            ]}
          >
            <Icon
              as={MagnifyingGlassIcon}
              size={22}
              color={colors.mutedForeground}
              style={styles.icon}
            />
            <TextInput
              value={query}
              onChangeText={setQuery}
              placeholder={searchPlaceholder}
              accessibilityLabel={searchPlaceholder}
              autoCorrect={false}
              autoCapitalize="none"
              returnKeyType="search"
              style={[styles.input, { color: colors.foreground }]}
              placeholderTextColor={colors.mutedForeground}
              cursorColor={colors.primary}
              selectionColor={colors.primary}
            />
          </View>
          <ScrollView
            keyboardShouldPersistTaps="handled"
            showsVerticalScrollIndicator={false}
            style={{ minHeight: height * 0.4, maxHeight: height * 0.6 }}
            contentContainerStyle={styles.list}
          >
            {groups.length === 0 ? (
              <Text style={[styles.empty, { color: colors.mutedForeground }]}>
                {emptyText}
              </Text>
            ) : (
              groups.map((group, index) => (
                <View
                  key={group.value}
                  role="group"
                  accessibilityLabel={group.value || undefined}
                  style={index > 0 ? styles.spaced : undefined}
                >
                  {group.value ? (
                    <Text
                      style={[
                        styles.heading,
                        { color: colors.mutedForeground },
                      ]}
                    >
                      {group.value}
                    </Text>
                  ) : null}
                  {group.items.map((item) => {
                    const checked = item.value === value
                    return (
                      <Pressable
                        key={item.value}
                        disabled={item.disabled}
                        accessibilityRole="button"
                        accessibilityState={{
                          selected: checked,
                          disabled: !!item.disabled,
                        }}
                        onPress={() => {
                          select(item.value)
                          setOpen(false)
                          setQuery("")
                        }}
                        style={({ pressed }) => [
                          styles.item,
                          pressed && { opacity: 0.8 },
                          item.disabled && { opacity: 0.5 },
                        ]}
                      >
                        <Text
                          numberOfLines={1}
                          style={[
                            styles.itemText,
                            {
                              color: colors.foreground,
                            },
                          ]}
                        >
                          {item.label}
                        </Text>
                        {checked ? (
                          <Icon
                            as={CheckIcon}
                            size={20}
                            color={colors.mutedForeground}
                            style={styles.icon}
                          />
                        ) : null}
                      </Pressable>
                    )
                  })}
                </View>
              ))
            )}
          </ScrollView>
        </View>
      </DrawerContent>
    </Drawer>
  )
}

export { SearchPicker, type SearchPickerItem }
```

4. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Patterns/SearchPicker.swift"
import PhosphorSwift
import SwiftUI

public struct SearchPickerItem: Hashable, Sendable, Identifiable {
  public var value: String
  public var label: String
  public var group: String?
  public var disabled: Bool

  public var id: String { value }

  public init(value: String, label: String, group: String? = nil, disabled: Bool = false) {
    self.value = value
    self.label = label
    self.group = group
    self.disabled = disabled
  }
}

struct SearchPickerGroup: Hashable {
  let name: String
  let items: [SearchPickerItem]

  static func grouping(_ items: [SearchPickerItem], query: String) -> [SearchPickerGroup] {
    let trimmed = query.trimmingCharacters(in: .whitespaces)
    var order: [String] = []
    var buckets: [String: [SearchPickerItem]] = [:]
    for item in items where trimmed.isEmpty || item.label.localizedCaseInsensitiveContains(trimmed) {
      let key = item.group ?? ""
      if buckets[key] == nil { order.append(key) }
      buckets[key, default: []].append(item)
    }
    return order.map { SearchPickerGroup(name: $0, items: buckets[$0] ?? []) }
  }
}

public struct SearchPicker: View {
  let items: [SearchPickerItem]
  let selection: Binding<String?>
  let placeholder: LocalizedStringKey?
  let searchPlaceholder: LocalizedStringKey
  let emptyText: LocalizedStringKey
  let title: LocalizedStringKey?
  let showCloseButton: Bool
  let closeLabel: LocalizedStringKey?

  @State private var open = false
  @CompactLayout private var compact
  @Environment(\.isEnabled) private var isEnabled
  @Environment(\.isInvalid) private var invalid
  @Environment(\.colorScheme) private var colorScheme

  public init(
    items: [SearchPickerItem],
    selection: Binding<String?>,
    placeholder: LocalizedStringKey? = nil,
    searchPlaceholder: LocalizedStringKey = "Search...",
    emptyText: LocalizedStringKey = "No results found.",
    title: LocalizedStringKey? = nil,
    showCloseButton: Bool = false,
    closeLabel: LocalizedStringKey? = nil
  ) {
    self.items = items
    self.selection = selection
    self.placeholder = placeholder
    self.searchPlaceholder = searchPlaceholder
    self.emptyText = emptyText
    self.title = title
    self.showCloseButton = showCloseButton
    self.closeLabel = closeLabel
  }

  public var body: some View {
    let isOpen = $open
    trigger
      .drawer(isPresented: $open.gated(compact), detents: [.medium, .large]) {
        SearchPickerSheet(
          items: items,
          selection: selection,
          searchPlaceholder: searchPlaceholder,
          emptyText: emptyText,
          title: title,
          showCloseButton: showCloseButton,
          closeLabel: closeLabel
        )
      }
      .popover(isPresented: $open.gated(!compact)) {
        SearchPickerPanel(
          items: items,
          selection: selection,
          searchPlaceholder: searchPlaceholder,
          emptyText: emptyText
        )
        .frame(width: 320, height: 360)
        .environment(\.overlayDismiss, OverlayDismiss { isOpen.wrappedValue = false })
        .presentationCompactAdaptation(.popover)
        .presentationBackground(EsuissColor.card)
      }
  }

  private var selected: SearchPickerItem? {
    items.first { $0.value == selection.wrappedValue }
  }

  private var trigger: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
    let selected = selected
    return Button {
      open = true
    } label: {
      HStack(spacing: 6) {
        Group {
          if let selected {
            Text(verbatim: selected.label)
          } else if let placeholder {
            Text(placeholder)
          } else {
            Text(verbatim: "")
          }
        }
        .scaledFont(TextStyle.base)
        .lineLimit(1)
        .foregroundStyle(selected == nil ? EsuissColor.mutedForeground : EsuissColor.foreground)
        .frame(maxWidth: .infinity, alignment: .leading)
        Ph.caretUpDown.regular
          .frame(width: 16, height: 16)
          .foregroundStyle(EsuissColor.mutedForeground)
          .accessibilityHidden(true)
      }
      .padding(.horizontal, 12)
      .frame(height: 44)
      .background(background, in: shape)
      .overlay {
        if invalid { shape.strokeBorder(EsuissColor.destructive) }
      }
      .contentShape(shape)
      .opacity(isEnabled ? 1 : 0.5)
    }
    .buttonStyle(.plain)
    .accessibilityHint(Text("Shows options"))
  }

  private var background: Color {
    if invalid { return EsuissColor.destructive.opacity(colorScheme == .dark ? 0.1 : 0.05) }
    return EsuissColor.secondary
  }
}

struct SearchPickerSheet: View {
  let items: [SearchPickerItem]
  let selection: Binding<String?>
  let searchPlaceholder: LocalizedStringKey
  let emptyText: LocalizedStringKey
  let title: LocalizedStringKey?
  let showCloseButton: Bool
  let closeLabel: LocalizedStringKey?
  var initialQuery = ""
  var autofocus = true

  var body: some View {
    DrawerContent(showCloseButton: showCloseButton, closeLabel: closeLabel) {
      if let title {
        DrawerHeader {
          DrawerTitle(title)
        }
      }
      SearchPickerPanel(
        items: items,
        selection: selection,
        searchPlaceholder: searchPlaceholder,
        emptyText: emptyText,
        trailingInset: title == nil && showCloseButton ? (closeLabel == nil ? 44 : 84) : 0,
        initialQuery: initialQuery,
        autofocus: autofocus
      )
      .padding(.horizontal, 12)
      .padding(.top, 5)
      .padding(.bottom, 12)
    }
  }
}

struct SearchPickerPanel: View {
  let items: [SearchPickerItem]
  let selection: Binding<String?>
  let searchPlaceholder: LocalizedStringKey
  let emptyText: LocalizedStringKey
  var trailingInset: CGFloat = 0
  var initialQuery = ""
  var autofocus = true

  @State private var query = ""
  @FocusState private var focused: Bool
  @Environment(\.overlayDismiss) private var dismiss

  var body: some View {
    let groups = SearchPickerGroup.grouping(items, query: query)
    VStack(spacing: 0) {
      HStack(spacing: 12) {
        Ph.magnifyingGlass.regular
          .frame(width: 22, height: 22)
          .foregroundStyle(EsuissColor.mutedForeground)
          .accessibilityHidden(true)
        TextField(searchPlaceholder, text: $query, prompt: Text(searchPlaceholder).foregroundStyle(EsuissColor.mutedForeground))
          .focused($focused)
          .textInputAutocapitalization(.never)
          .autocorrectionDisabled()
          .scaledFont(TextStyle.xl)
          .foregroundStyle(EsuissColor.foreground)
          .tint(EsuissColor.primary)
      }
      .padding(.leading, 16)
      .padding(.trailing, 16 + trailingInset)
      .frame(height: 56)
      .overlay(alignment: .bottom) {
        EsuissColor.border.frame(height: 1)
      }
      ScrollView {
        LazyVStack(alignment: .leading, spacing: 0) {
          if groups.isEmpty {
            Text(emptyText)
              .scaledFont(TextStyle.base)
              .foregroundStyle(EsuissColor.mutedForeground)
              .multilineTextAlignment(.center)
              .frame(maxWidth: .infinity)
              .padding(.vertical, 40)
          }
          ForEach(Array(groups.enumerated()), id: \.element.name) { index, group in
            VStack(alignment: .leading, spacing: 0) {
              if !group.name.isEmpty {
                Text(verbatim: group.name)
                  .scaledFont(TextStyle.xs, weight: .semibold)
                  .foregroundStyle(EsuissColor.mutedForeground)
                  .padding(.horizontal, 12)
                  .padding(.top, 4)
                  .padding(.bottom, 6)
                  .accessibilityAddTraits(.isHeader)
              }
              ForEach(group.items) { item in
                row(item)
              }
            }
            .padding(.top, index > 0 ? 8 : 0)
          }
        }
        .padding(6)
      }
      .scrollDismissesKeyboard(.interactively)
    }
    .foregroundStyle(EsuissColor.foreground)
    .onAppear {
      query = initialQuery
      if autofocus { focused = true }
    }
  }

  private func row(_ item: SearchPickerItem) -> some View {
    let checked = item.value == selection.wrappedValue
    return Button {
      selection.wrappedValue = item.value
      dismiss?()
    } label: {
      Text(verbatim: item.label)
    }
    .buttonStyle(SearchPickerRowStyle(checked: checked))
    .disabled(item.disabled)
    .accessibilityAddTraits(checked ? .isSelected : [])
  }
}

struct SearchPickerRowStyle: ButtonStyle {
  let checked: Bool

  @Environment(\.isEnabled) private var isEnabled

  func makeBody(configuration: Configuration) -> some View {
    HStack(spacing: 12) {
      configuration.label
        .scaledFont(TextStyle.base)
        .lineLimit(1)
        .frame(maxWidth: .infinity, alignment: .leading)
      if checked {
        Ph.check.regular
          .frame(width: 20, height: 20)
          .foregroundStyle(EsuissColor.mutedForeground)
          .accessibilityHidden(true)
      }
    }
    .foregroundStyle(EsuissColor.foreground)
    .padding(.horizontal, 12)
    .frame(height: 40)
    .opacity(isEnabled ? (configuration.isPressed ? 0.8 : 1) : 0.5)
    .contentShape(Rectangle())
  }
}
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Dialog.swift`: see [Dialog](https://ui.suiss.app/docs/components/dialog.md)

- `swift/Components/Drawer.swift`: see [Drawer](https://ui.suiss.app/docs/components/drawer.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

- `swift/Patterns/Adaptive.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Patterns/Adaptive.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/patterns/SearchPicker.kt"
package com.esuiss.ui.patterns

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.CaretUpDown
import com.adamglin.phosphoricons.regular.Check
import com.adamglin.phosphoricons.regular.MagnifyingGlass
import com.esuiss.ui.components.Drawer
import com.esuiss.ui.components.DrawerHeader
import com.esuiss.ui.components.DrawerTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.RegisterPaneTitle
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

@Immutable
data class SearchPickerItem(
    val value: String,
    val label: String,
    val group: String? = null,
    val enabled: Boolean = true,
)

@Composable
fun SearchPicker(
    items: List<SearchPickerItem>,
    value: String?,
    onValueChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    placeholder: String = "",
    searchPlaceholder: String = "Search...",
    emptyText: String = "No results found.",
    title: String? = null,
    enabled: Boolean = true,
    invalid: Boolean? = null,
    floating: Boolean = false,
    showCloseButton: Boolean = false,
    closeLabel: String? = null,
) {
    var open by remember { mutableStateOf(false) }
    val selected = items.firstOrNull { it.value == value }
    PickerTrigger(
        text = selected?.label,
        placeholder = placeholder,
        modifier = modifier,
        enabled = enabled,
        invalid = invalid,
        onClick = { open = true },
        trailing = { tint -> Icon(PhosphorIcons.Regular.CaretUpDown, contentDescription = null, size = 16.dp, tint = tint) },
    )
    Drawer(
        open = open,
        onOpenChange = { open = it },
        floating = floating,
        showSwipeHandle = true,
        showCloseButton = showCloseButton,
        closeLabel = closeLabel,
    ) {
        if (title != null) {
            DrawerHeader { DrawerTitle(title) }
        } else {
            RegisterPaneTitle(placeholder)
        }
        SearchPickerContent(
            items = items,
            value = value,
            searchPlaceholder = searchPlaceholder,
            emptyText = emptyText,
            endInset = when {
                showCloseButton && closeLabel != null -> 84.dp
                showCloseButton -> 44.dp
                else -> 0.dp
            },
            onSelect = {
                onValueChange(it)
                open = false
            },
        )
    }
}

@Composable
private fun SearchPickerContent(
    items: List<SearchPickerItem>,
    value: String?,
    searchPlaceholder: String,
    emptyText: String,
    endInset: Dp,
    onSelect: (String) -> Unit,
) {
    val colors = EsuissTheme.colors
    var query by remember { mutableStateOf("") }
    val focus = remember { FocusRequester() }
    LaunchedEffect(Unit) { focus.requestFocus() }
    val visible = if (query.isBlank()) items else items.filter { it.label.contains(query.trim(), ignoreCase = true) }
    val groups = visible.groupBy { it.group.orEmpty() }.entries.toList()
    val windowHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
    val inputStyle = Typography.xl.copy(color = colors.foreground)

    Column(modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 5.dp, bottom = 12.dp)) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(end = endInset)
                .height(56.dp)
                .drawBehind {
                    val stroke = 1.dp.toPx()
                    drawRect(colors.border, Offset(0f, size.height - stroke), Size(size.width, stroke))
                }
                .padding(horizontal = 16.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Icon(PhosphorIcons.Regular.MagnifyingGlass, contentDescription = null, size = 22.dp, tint = colors.mutedForeground)
            CompositionLocalProvider(
                LocalTextSelectionColors provides TextSelectionColors(colors.primary, colors.primary.copy(alpha = 0.3f)),
            ) {
                BasicTextField(
                    value = query,
                    onValueChange = { query = it },
                    modifier = Modifier.weight(1f).focusRequester(focus),
                    singleLine = true,
                    textStyle = inputStyle,
                    cursorBrush = SolidColor(colors.primary),
                    decorationBox = { inner ->
                        Box(contentAlignment = Alignment.CenterStart) {
                            if (query.isEmpty()) {
                                BasicText(
                                    searchPlaceholder,
                                    style = inputStyle.copy(color = colors.mutedForeground),
                                    maxLines = 1,
                                )
                            }
                            inner()
                        }
                    },
                )
            }
        }
        LazyColumn(
            modifier = Modifier
                .fillMaxWidth()
                .heightIn(min = windowHeight * 0.4f, max = windowHeight * 0.6f),
            contentPadding = PaddingValues(6.dp),
        ) {
            if (visible.isEmpty()) {
                item(key = "empty") {
                    BasicText(
                        emptyText,
                        modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp),
                        style = Typography.base.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                    )
                }
            }
            groups.forEachIndexed { index, (group, groupItems) ->
                if (group.isNotEmpty()) {
                    item(key = "group:$group") {
                        BasicText(
                            group,
                            modifier = Modifier
                                .fillMaxWidth()
                                .semantics { heading() }
                                .padding(start = 12.dp, end = 12.dp, top = if (index > 0) 12.dp else 4.dp, bottom = 6.dp),
                            style = Typography.xs.copy(color = colors.mutedForeground, fontWeight = FontWeight.SemiBold),
                        )
                    }
                } else if (index > 0) {
                    item(key = "gap:$index") { Box(Modifier.height(8.dp)) }
                }
                items(groupItems, key = { "item:${it.value}" }) { item ->
                    SearchPickerOption(item, item.value == value) { onSelect(item.value) }
                }
            }
        }
    }
}

@Composable
private fun SearchPickerOption(item: SearchPickerItem, checked: Boolean, onClick: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val shape = RoundedCornerShape(Radius.lg)
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .height(40.dp)
            .clip(shape)
            .alpha(
                when {
                    !item.enabled -> 0.5f
                    pressed -> 0.8f
                    else -> 1f
                },
            )
            .clickable(
                interactionSource = interaction,
                indication = null,
                enabled = item.enabled,
                role = Role.RadioButton,
                onClick = onClick,
            )
            .semantics { selected = checked }
            .padding(horizontal = 12.dp),
        horizontalArrangement = Arrangement.spacedBy(12.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        BasicText(
            item.label,
            modifier = Modifier.weight(1f),
            style = Typography.base.copy(color = colors.foreground),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        if (checked) {
            Icon(PhosphorIcons.Regular.Check, contentDescription = null, size = 20.dp, tint = colors.mutedForeground)
        }
    }
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Drawer.kt`: see [Drawer](https://ui.suiss.app/docs/components/drawer.md)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Overlay.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Overlay.kt)

- `kotlin/components/Select.kt`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `kotlin/components/WheelPicker.kt`: see [Wheel Picker](https://ui.suiss.app/docs/components/wheel-picker.md)

- `kotlin/patterns/DatePicker.kt`: see [Date Picker](https://ui.suiss.app/docs/patterns/date-picker.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { SearchPicker } from "@/components/patterns/search-picker"
```

```tsx
<SearchPicker items={frameworks} placeholder="Select a framework" searchPlaceholder="Search frameworks..." emptyText="No framework found." accessibilityLabel="Framework" className="w-56" />
```

Pass `items` as an array of `{ value, label, group?, disabled? }`. The `SearchPickerItem` type is exported.

Use it uncontrolled with `defaultValue`, or controlled with `value` and `onValueChange`; the value is a string or `null`. `emptyText` shows when nothing matches.

## Examples

### With value

Starts with a preset selection via `defaultValue`.

![Preview](https://ui.suiss.app/previews/patterns-search-picker--with-value.light.webp)

```tsx
import { SearchPicker, type SearchPickerItem } from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export function SearchPickerWithValueExample() {
  return (
    <SearchPicker items={frameworks} placeholder="Select a framework" searchPlaceholder="Search frameworks..." emptyText="No framework found." accessibilityLabel="Framework" className="w-56" defaultValue="astro" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SearchPickerWithValueExample: View {
  var body: some View {
    SearchPickerDemo(value: "astro")
  }
}

let searchPickerFrameworks = [
  SearchPickerItem(value: "next", label: "Next.js"),
  SearchPickerItem(value: "remix", label: "Remix"),
  SearchPickerItem(value: "astro", label: "Astro"),
  SearchPickerItem(value: "sveltekit", label: "SvelteKit"),
  SearchPickerItem(value: "nuxt", label: "Nuxt"),
  SearchPickerItem(value: "gatsby", label: "Gatsby", disabled: true),
]

struct SearchPickerDemo: View {
  var items = searchPickerFrameworks
  var placeholder: LocalizedStringKey = "Select a framework"
  var searchPlaceholder: LocalizedStringKey = "Search frameworks..."
  var emptyText: LocalizedStringKey = "No framework found."
  var label: LocalizedStringKey = "Framework"
  var title: LocalizedStringKey?
  var showCloseButton = false
  var closeLabel: LocalizedStringKey?

  @State private var value: String?

  init(
    value: String? = nil,
    items: [SearchPickerItem] = searchPickerFrameworks,
    placeholder: LocalizedStringKey = "Select a framework",
    searchPlaceholder: LocalizedStringKey = "Search frameworks...",
    emptyText: LocalizedStringKey = "No framework found.",
    label: LocalizedStringKey = "Framework",
    title: LocalizedStringKey? = nil,
    showCloseButton: Bool = false,
    closeLabel: LocalizedStringKey? = nil
  ) {
    _value = State(initialValue: value)
    self.items = items
    self.placeholder = placeholder
    self.searchPlaceholder = searchPlaceholder
    self.emptyText = emptyText
    self.label = label
    self.title = title
    self.showCloseButton = showCloseButton
    self.closeLabel = closeLabel
  }

  var body: some View {
    SearchPicker(
      items: items,
      selection: $value,
      placeholder: placeholder,
      searchPlaceholder: searchPlaceholder,
      emptyText: emptyText,
      title: title,
      showCloseButton: showCloseButton,
      closeLabel: closeLabel
    )
    .frame(width: 224)
    .accessibilityLabel(Text(label))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.patterns.SearchPicker
import com.esuiss.ui.patterns.SearchPickerItem

@Composable
fun SearchPickerWithValueExample() {
    SearchPickerDemo(initial = "astro")
}

val searchPickerFrameworks = listOf(
    SearchPickerItem("next", "Next.js"),
    SearchPickerItem("remix", "Remix"),
    SearchPickerItem("astro", "Astro"),
    SearchPickerItem("sveltekit", "SvelteKit"),
    SearchPickerItem("nuxt", "Nuxt"),
    SearchPickerItem("gatsby", "Gatsby", enabled = false),
)

@Composable
fun SearchPickerDemo(
    initial: String? = null,
    items: List<SearchPickerItem> = searchPickerFrameworks,
    placeholder: String = "Select a framework",
    searchPlaceholder: String = "Search frameworks...",
    emptyText: String = "No framework found.",
    label: String = "Framework",
    title: String? = null,
    enabled: Boolean = true,
    invalid: Boolean? = null,
    floating: Boolean = false,
    showCloseButton: Boolean = false,
    closeLabel: String? = null,
) {
    var value by rememberSaveable { mutableStateOf(initial) }
    SearchPicker(
        items = items,
        value = value,
        onValueChange = { value = it },
        modifier = Modifier.width(224.dp).semantics { contentDescription = label },
        placeholder = placeholder,
        searchPlaceholder = searchPlaceholder,
        emptyText = emptyText,
        title = title,
        enabled = enabled,
        invalid = invalid,
        floating = floating,
        showCloseButton = showCloseButton,
        closeLabel = closeLabel,
    )
}
```

### Grouped

Cities grouped by country, with custom placeholders.

![Preview](https://ui.suiss.app/previews/patterns-search-picker--grouped.light.webp)

```tsx
import { SearchPicker, type SearchPickerItem } from "@/components/patterns/search-picker"

const cities: SearchPickerItem[] = [
  { value: "nyc", label: "New York", group: "Americas" },
  { value: "chi", label: "Chicago", group: "Americas" },
  { value: "sao", label: "São Paulo", group: "Americas" },
  { value: "lon", label: "London", group: "Europe" },
  { value: "ber", label: "Berlin", group: "Europe" },
  { value: "ist", label: "Istanbul", group: "Europe" },
  { value: "tyo", label: "Tokyo", group: "Asia" },
  { value: "sin", label: "Singapore", group: "Asia" },
]

export function SearchPickerGroupedExample() {
  return (
    <SearchPicker items={cities} placeholder="Select a city" searchPlaceholder="Search cities..." emptyText="No city found." accessibilityLabel="City" className="w-56" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SearchPickerGroupedExample: View {
  var body: some View {
    SearchPickerDemo(
      items: searchPickerCities,
      placeholder: "Select a city",
      searchPlaceholder: "Search cities...",
      emptyText: "No city found.",
      label: "City"
    )
  }
}

let searchPickerFrameworks = [
  SearchPickerItem(value: "next", label: "Next.js"),
  SearchPickerItem(value: "remix", label: "Remix"),
  SearchPickerItem(value: "astro", label: "Astro"),
  SearchPickerItem(value: "sveltekit", label: "SvelteKit"),
  SearchPickerItem(value: "nuxt", label: "Nuxt"),
  SearchPickerItem(value: "gatsby", label: "Gatsby", disabled: true),
]

let searchPickerCities = [
  SearchPickerItem(value: "nyc", label: "New York", group: "Americas"),
  SearchPickerItem(value: "chi", label: "Chicago", group: "Americas"),
  SearchPickerItem(value: "sao", label: "São Paulo", group: "Americas"),
  SearchPickerItem(value: "lon", label: "London", group: "Europe"),
  SearchPickerItem(value: "ber", label: "Berlin", group: "Europe"),
  SearchPickerItem(value: "ist", label: "Istanbul", group: "Europe"),
  SearchPickerItem(value: "tyo", label: "Tokyo", group: "Asia"),
  SearchPickerItem(value: "sin", label: "Singapore", group: "Asia"),
]

struct SearchPickerDemo: View {
  var items = searchPickerFrameworks
  var placeholder: LocalizedStringKey = "Select a framework"
  var searchPlaceholder: LocalizedStringKey = "Search frameworks..."
  var emptyText: LocalizedStringKey = "No framework found."
  var label: LocalizedStringKey = "Framework"
  var title: LocalizedStringKey?
  var showCloseButton = false
  var closeLabel: LocalizedStringKey?

  @State private var value: String?

  init(
    value: String? = nil,
    items: [SearchPickerItem] = searchPickerFrameworks,
    placeholder: LocalizedStringKey = "Select a framework",
    searchPlaceholder: LocalizedStringKey = "Search frameworks...",
    emptyText: LocalizedStringKey = "No framework found.",
    label: LocalizedStringKey = "Framework",
    title: LocalizedStringKey? = nil,
    showCloseButton: Bool = false,
    closeLabel: LocalizedStringKey? = nil
  ) {
    _value = State(initialValue: value)
    self.items = items
    self.placeholder = placeholder
    self.searchPlaceholder = searchPlaceholder
    self.emptyText = emptyText
    self.label = label
    self.title = title
    self.showCloseButton = showCloseButton
    self.closeLabel = closeLabel
  }

  var body: some View {
    SearchPicker(
      items: items,
      selection: $value,
      placeholder: placeholder,
      searchPlaceholder: searchPlaceholder,
      emptyText: emptyText,
      title: title,
      showCloseButton: showCloseButton,
      closeLabel: closeLabel
    )
    .frame(width: 224)
    .accessibilityLabel(Text(label))
  }
}

struct SearchPickerControlled: View {
  @State private var value: String? = "next"

  var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      SearchPicker(
        items: searchPickerFrameworks,
        selection: $value,
        placeholder: "Select a framework",
        searchPlaceholder: "Search frameworks...",
        emptyText: "No framework found."
      )
      .frame(width: 224)
      .accessibilityLabel(Text("Framework"))
      Text(verbatim: "Selected: \(value ?? "none")")
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
    }
  }
}

let group = StoryGroup(.patterns, "Search Picker", stories: [
  Story("Default") {
    SearchPickerDemo()
  },
  Story("WithValue") {
    SearchPickerDemo(value: "astro")
  },
  Story("Grouped") {
    SearchPickerDemo(
      items: searchPickerCities,
      placeholder: "Select a city",
      searchPlaceholder: "Search cities...",
      emptyText: "No city found.",
      label: "City"
    )
  },
  Story("WithTitle") {
    SearchPickerDemo(title: "Framework")
  },
  Story("WithCloseButton") {
    SearchPickerDemo(showCloseButton: true)
  },
  Story("WithCloseLabel") {
    SearchPickerDemo(showCloseButton: true, closeLabel: "Cancel")
  },
  Story("Disabled") {
    SearchPickerDemo()
      .disabled(true)
  },
  Story("Invalid") {
    SearchPickerDemo()
      .invalid()
  },
  Story("Controlled") {
    SearchPickerControlled()
  },
]
```

```kotlin
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.patterns.SearchPicker
import com.esuiss.ui.patterns.SearchPickerItem

@Composable
fun SearchPickerGroupedExample() {
    SearchPickerDemo(
        items = searchPickerCities,
        placeholder = "Select a city",
        searchPlaceholder = "Search cities...",
        emptyText = "No city found.",
        label = "City",
    )
}

val searchPickerFrameworks = listOf(
    SearchPickerItem("next", "Next.js"),
    SearchPickerItem("remix", "Remix"),
    SearchPickerItem("astro", "Astro"),
    SearchPickerItem("sveltekit", "SvelteKit"),
    SearchPickerItem("nuxt", "Nuxt"),
    SearchPickerItem("gatsby", "Gatsby", enabled = false),
)

val searchPickerCities = listOf(
    SearchPickerItem("nyc", "New York", group = "Americas"),
    SearchPickerItem("chi", "Chicago", group = "Americas"),
    SearchPickerItem("sao", "São Paulo", group = "Americas"),
    SearchPickerItem("lon", "London", group = "Europe"),
    SearchPickerItem("ber", "Berlin", group = "Europe"),
    SearchPickerItem("ist", "Istanbul", group = "Europe"),
    SearchPickerItem("tyo", "Tokyo", group = "Asia"),
    SearchPickerItem("sin", "Singapore", group = "Asia"),
)

@Composable
fun SearchPickerDemo(
    initial: String? = null,
    items: List<SearchPickerItem> = searchPickerFrameworks,
    placeholder: String = "Select a framework",
    searchPlaceholder: String = "Search frameworks...",
    emptyText: String = "No framework found.",
    label: String = "Framework",
    title: String? = null,
    enabled: Boolean = true,
    invalid: Boolean? = null,
    floating: Boolean = false,
    showCloseButton: Boolean = false,
    closeLabel: String? = null,
) {
    var value by rememberSaveable { mutableStateOf(initial) }
    SearchPicker(
        items = items,
        value = value,
        onValueChange = { value = it },
        modifier = Modifier.width(224.dp).semantics { contentDescription = label },
        placeholder = placeholder,
        searchPlaceholder = searchPlaceholder,
        emptyText = emptyText,
        title = title,
        enabled = enabled,
        invalid = invalid,
        floating = floating,
        showCloseButton = showCloseButton,
        closeLabel = closeLabel,
    )
}
```

### With title

Adds a heading to the drawer with `title`.

![Preview](https://ui.suiss.app/previews/patterns-search-picker--with-title.light.webp)

```tsx
import { SearchPicker, type SearchPickerItem } from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export function SearchPickerWithTitleExample() {
  return (
    <SearchPicker items={frameworks} placeholder="Select a framework" searchPlaceholder="Search frameworks..." emptyText="No framework found." accessibilityLabel="Framework" className="w-56" title="Framework" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SearchPickerWithTitleExample: View {
  var body: some View {
    SearchPickerDemo(title: "Framework")
  }
}

let searchPickerFrameworks = [
  SearchPickerItem(value: "next", label: "Next.js"),
  SearchPickerItem(value: "remix", label: "Remix"),
  SearchPickerItem(value: "astro", label: "Astro"),
  SearchPickerItem(value: "sveltekit", label: "SvelteKit"),
  SearchPickerItem(value: "nuxt", label: "Nuxt"),
  SearchPickerItem(value: "gatsby", label: "Gatsby", disabled: true),
]

struct SearchPickerDemo: View {
  var items = searchPickerFrameworks
  var placeholder: LocalizedStringKey = "Select a framework"
  var searchPlaceholder: LocalizedStringKey = "Search frameworks..."
  var emptyText: LocalizedStringKey = "No framework found."
  var label: LocalizedStringKey = "Framework"
  var title: LocalizedStringKey?
  var showCloseButton = false
  var closeLabel: LocalizedStringKey?

  @State private var value: String?

  init(
    value: String? = nil,
    items: [SearchPickerItem] = searchPickerFrameworks,
    placeholder: LocalizedStringKey = "Select a framework",
    searchPlaceholder: LocalizedStringKey = "Search frameworks...",
    emptyText: LocalizedStringKey = "No framework found.",
    label: LocalizedStringKey = "Framework",
    title: LocalizedStringKey? = nil,
    showCloseButton: Bool = false,
    closeLabel: LocalizedStringKey? = nil
  ) {
    _value = State(initialValue: value)
    self.items = items
    self.placeholder = placeholder
    self.searchPlaceholder = searchPlaceholder
    self.emptyText = emptyText
    self.label = label
    self.title = title
    self.showCloseButton = showCloseButton
    self.closeLabel = closeLabel
  }

  var body: some View {
    SearchPicker(
      items: items,
      selection: $value,
      placeholder: placeholder,
      searchPlaceholder: searchPlaceholder,
      emptyText: emptyText,
      title: title,
      showCloseButton: showCloseButton,
      closeLabel: closeLabel
    )
    .frame(width: 224)
    .accessibilityLabel(Text(label))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.patterns.SearchPicker
import com.esuiss.ui.patterns.SearchPickerItem

@Composable
fun SearchPickerWithTitleExample() {
    SearchPickerDemo(title = "Framework")
}

val searchPickerFrameworks = listOf(
    SearchPickerItem("next", "Next.js"),
    SearchPickerItem("remix", "Remix"),
    SearchPickerItem("astro", "Astro"),
    SearchPickerItem("sveltekit", "SvelteKit"),
    SearchPickerItem("nuxt", "Nuxt"),
    SearchPickerItem("gatsby", "Gatsby", enabled = false),
)

@Composable
fun SearchPickerDemo(
    initial: String? = null,
    items: List<SearchPickerItem> = searchPickerFrameworks,
    placeholder: String = "Select a framework",
    searchPlaceholder: String = "Search frameworks...",
    emptyText: String = "No framework found.",
    label: String = "Framework",
    title: String? = null,
    enabled: Boolean = true,
    invalid: Boolean? = null,
    floating: Boolean = false,
    showCloseButton: Boolean = false,
    closeLabel: String? = null,
) {
    var value by rememberSaveable { mutableStateOf(initial) }
    SearchPicker(
        items = items,
        value = value,
        onValueChange = { value = it },
        modifier = Modifier.width(224.dp).semantics { contentDescription = label },
        placeholder = placeholder,
        searchPlaceholder = searchPlaceholder,
        emptyText = emptyText,
        title = title,
        enabled = enabled,
        invalid = invalid,
        floating = floating,
        showCloseButton = showCloseButton,
        closeLabel = closeLabel,
    )
}
```

### With close button

Shows a close button in the drawer.

![Preview](https://ui.suiss.app/previews/patterns-search-picker--with-close-button.light.webp)

```tsx
import { SearchPicker, type SearchPickerItem } from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export function SearchPickerWithCloseButtonExample() {
  return (
    <SearchPicker items={frameworks} placeholder="Select a framework" searchPlaceholder="Search frameworks..." emptyText="No framework found." accessibilityLabel="Framework" className="w-56" showCloseButton />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SearchPickerWithCloseButtonExample: View {
  var body: some View {
    SearchPickerDemo(showCloseButton: true)
  }
}

let searchPickerFrameworks = [
  SearchPickerItem(value: "next", label: "Next.js"),
  SearchPickerItem(value: "remix", label: "Remix"),
  SearchPickerItem(value: "astro", label: "Astro"),
  SearchPickerItem(value: "sveltekit", label: "SvelteKit"),
  SearchPickerItem(value: "nuxt", label: "Nuxt"),
  SearchPickerItem(value: "gatsby", label: "Gatsby", disabled: true),
]

struct SearchPickerDemo: View {
  var items = searchPickerFrameworks
  var placeholder: LocalizedStringKey = "Select a framework"
  var searchPlaceholder: LocalizedStringKey = "Search frameworks..."
  var emptyText: LocalizedStringKey = "No framework found."
  var label: LocalizedStringKey = "Framework"
  var title: LocalizedStringKey?
  var showCloseButton = false
  var closeLabel: LocalizedStringKey?

  @State private var value: String?

  init(
    value: String? = nil,
    items: [SearchPickerItem] = searchPickerFrameworks,
    placeholder: LocalizedStringKey = "Select a framework",
    searchPlaceholder: LocalizedStringKey = "Search frameworks...",
    emptyText: LocalizedStringKey = "No framework found.",
    label: LocalizedStringKey = "Framework",
    title: LocalizedStringKey? = nil,
    showCloseButton: Bool = false,
    closeLabel: LocalizedStringKey? = nil
  ) {
    _value = State(initialValue: value)
    self.items = items
    self.placeholder = placeholder
    self.searchPlaceholder = searchPlaceholder
    self.emptyText = emptyText
    self.label = label
    self.title = title
    self.showCloseButton = showCloseButton
    self.closeLabel = closeLabel
  }

  var body: some View {
    SearchPicker(
      items: items,
      selection: $value,
      placeholder: placeholder,
      searchPlaceholder: searchPlaceholder,
      emptyText: emptyText,
      title: title,
      showCloseButton: showCloseButton,
      closeLabel: closeLabel
    )
    .frame(width: 224)
    .accessibilityLabel(Text(label))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.patterns.SearchPicker
import com.esuiss.ui.patterns.SearchPickerItem

@Composable
fun SearchPickerWithCloseButtonExample() {
    SearchPickerDemo(showCloseButton = true)
}

val searchPickerFrameworks = listOf(
    SearchPickerItem("next", "Next.js"),
    SearchPickerItem("remix", "Remix"),
    SearchPickerItem("astro", "Astro"),
    SearchPickerItem("sveltekit", "SvelteKit"),
    SearchPickerItem("nuxt", "Nuxt"),
    SearchPickerItem("gatsby", "Gatsby", enabled = false),
)

@Composable
fun SearchPickerDemo(
    initial: String? = null,
    items: List<SearchPickerItem> = searchPickerFrameworks,
    placeholder: String = "Select a framework",
    searchPlaceholder: String = "Search frameworks...",
    emptyText: String = "No framework found.",
    label: String = "Framework",
    title: String? = null,
    enabled: Boolean = true,
    invalid: Boolean? = null,
    floating: Boolean = false,
    showCloseButton: Boolean = false,
    closeLabel: String? = null,
) {
    var value by rememberSaveable { mutableStateOf(initial) }
    SearchPicker(
        items = items,
        value = value,
        onValueChange = { value = it },
        modifier = Modifier.width(224.dp).semantics { contentDescription = label },
        placeholder = placeholder,
        searchPlaceholder = searchPlaceholder,
        emptyText = emptyText,
        title = title,
        enabled = enabled,
        invalid = invalid,
        floating = floating,
        showCloseButton = showCloseButton,
        closeLabel = closeLabel,
    )
}
```

### With close label

Labels the close button `Cancel` with `closeLabel`.

![Preview](https://ui.suiss.app/previews/patterns-search-picker--with-close-label.light.webp)

```tsx
import { SearchPicker, type SearchPickerItem } from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export function SearchPickerWithCloseLabelExample() {
  return (
    <SearchPicker items={frameworks} placeholder="Select a framework" searchPlaceholder="Search frameworks..." emptyText="No framework found." accessibilityLabel="Framework" className="w-56" showCloseButton closeLabel="Cancel" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SearchPickerWithCloseLabelExample: View {
  var body: some View {
    SearchPickerDemo(showCloseButton: true, closeLabel: "Cancel")
  }
}

let searchPickerFrameworks = [
  SearchPickerItem(value: "next", label: "Next.js"),
  SearchPickerItem(value: "remix", label: "Remix"),
  SearchPickerItem(value: "astro", label: "Astro"),
  SearchPickerItem(value: "sveltekit", label: "SvelteKit"),
  SearchPickerItem(value: "nuxt", label: "Nuxt"),
  SearchPickerItem(value: "gatsby", label: "Gatsby", disabled: true),
]

struct SearchPickerDemo: View {
  var items = searchPickerFrameworks
  var placeholder: LocalizedStringKey = "Select a framework"
  var searchPlaceholder: LocalizedStringKey = "Search frameworks..."
  var emptyText: LocalizedStringKey = "No framework found."
  var label: LocalizedStringKey = "Framework"
  var title: LocalizedStringKey?
  var showCloseButton = false
  var closeLabel: LocalizedStringKey?

  @State private var value: String?

  init(
    value: String? = nil,
    items: [SearchPickerItem] = searchPickerFrameworks,
    placeholder: LocalizedStringKey = "Select a framework",
    searchPlaceholder: LocalizedStringKey = "Search frameworks...",
    emptyText: LocalizedStringKey = "No framework found.",
    label: LocalizedStringKey = "Framework",
    title: LocalizedStringKey? = nil,
    showCloseButton: Bool = false,
    closeLabel: LocalizedStringKey? = nil
  ) {
    _value = State(initialValue: value)
    self.items = items
    self.placeholder = placeholder
    self.searchPlaceholder = searchPlaceholder
    self.emptyText = emptyText
    self.label = label
    self.title = title
    self.showCloseButton = showCloseButton
    self.closeLabel = closeLabel
  }

  var body: some View {
    SearchPicker(
      items: items,
      selection: $value,
      placeholder: placeholder,
      searchPlaceholder: searchPlaceholder,
      emptyText: emptyText,
      title: title,
      showCloseButton: showCloseButton,
      closeLabel: closeLabel
    )
    .frame(width: 224)
    .accessibilityLabel(Text(label))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.patterns.SearchPicker
import com.esuiss.ui.patterns.SearchPickerItem

@Composable
fun SearchPickerWithCloseLabelExample() {
    SearchPickerDemo(showCloseButton = true, closeLabel = "Cancel")
}

val searchPickerFrameworks = listOf(
    SearchPickerItem("next", "Next.js"),
    SearchPickerItem("remix", "Remix"),
    SearchPickerItem("astro", "Astro"),
    SearchPickerItem("sveltekit", "SvelteKit"),
    SearchPickerItem("nuxt", "Nuxt"),
    SearchPickerItem("gatsby", "Gatsby", enabled = false),
)

@Composable
fun SearchPickerDemo(
    initial: String? = null,
    items: List<SearchPickerItem> = searchPickerFrameworks,
    placeholder: String = "Select a framework",
    searchPlaceholder: String = "Search frameworks...",
    emptyText: String = "No framework found.",
    label: String = "Framework",
    title: String? = null,
    enabled: Boolean = true,
    invalid: Boolean? = null,
    floating: Boolean = false,
    showCloseButton: Boolean = false,
    closeLabel: String? = null,
) {
    var value by rememberSaveable { mutableStateOf(initial) }
    SearchPicker(
        items = items,
        value = value,
        onValueChange = { value = it },
        modifier = Modifier.width(224.dp).semantics { contentDescription = label },
        placeholder = placeholder,
        searchPlaceholder = searchPlaceholder,
        emptyText = emptyText,
        title = title,
        enabled = enabled,
        invalid = invalid,
        floating = floating,
        showCloseButton = showCloseButton,
        closeLabel = closeLabel,
    )
}
```

### Disabled

A disabled field that cannot open the drawer.

![Preview](https://ui.suiss.app/previews/patterns-search-picker--disabled.light.webp)

```tsx
import { SearchPicker, type SearchPickerItem } from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export function SearchPickerDisabledExample() {
  return (
    <SearchPicker items={frameworks} placeholder="Select a framework" searchPlaceholder="Search frameworks..." emptyText="No framework found." accessibilityLabel="Framework" className="w-56" disabled />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SearchPickerDisabledExample: View {
  var body: some View {
    SearchPickerDemo()
      .disabled(true)
  }
}

let searchPickerFrameworks = [
  SearchPickerItem(value: "next", label: "Next.js"),
  SearchPickerItem(value: "remix", label: "Remix"),
  SearchPickerItem(value: "astro", label: "Astro"),
  SearchPickerItem(value: "sveltekit", label: "SvelteKit"),
  SearchPickerItem(value: "nuxt", label: "Nuxt"),
  SearchPickerItem(value: "gatsby", label: "Gatsby", disabled: true),
]

struct SearchPickerDemo: View {
  var items = searchPickerFrameworks
  var placeholder: LocalizedStringKey = "Select a framework"
  var searchPlaceholder: LocalizedStringKey = "Search frameworks..."
  var emptyText: LocalizedStringKey = "No framework found."
  var label: LocalizedStringKey = "Framework"
  var title: LocalizedStringKey?
  var showCloseButton = false
  var closeLabel: LocalizedStringKey?

  @State private var value: String?

  init(
    value: String? = nil,
    items: [SearchPickerItem] = searchPickerFrameworks,
    placeholder: LocalizedStringKey = "Select a framework",
    searchPlaceholder: LocalizedStringKey = "Search frameworks...",
    emptyText: LocalizedStringKey = "No framework found.",
    label: LocalizedStringKey = "Framework",
    title: LocalizedStringKey? = nil,
    showCloseButton: Bool = false,
    closeLabel: LocalizedStringKey? = nil
  ) {
    _value = State(initialValue: value)
    self.items = items
    self.placeholder = placeholder
    self.searchPlaceholder = searchPlaceholder
    self.emptyText = emptyText
    self.label = label
    self.title = title
    self.showCloseButton = showCloseButton
    self.closeLabel = closeLabel
  }

  var body: some View {
    SearchPicker(
      items: items,
      selection: $value,
      placeholder: placeholder,
      searchPlaceholder: searchPlaceholder,
      emptyText: emptyText,
      title: title,
      showCloseButton: showCloseButton,
      closeLabel: closeLabel
    )
    .frame(width: 224)
    .accessibilityLabel(Text(label))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.patterns.SearchPicker
import com.esuiss.ui.patterns.SearchPickerItem

@Composable
fun SearchPickerDisabledExample() {
    SearchPickerDemo(enabled = false)
}

val searchPickerFrameworks = listOf(
    SearchPickerItem("next", "Next.js"),
    SearchPickerItem("remix", "Remix"),
    SearchPickerItem("astro", "Astro"),
    SearchPickerItem("sveltekit", "SvelteKit"),
    SearchPickerItem("nuxt", "Nuxt"),
    SearchPickerItem("gatsby", "Gatsby", enabled = false),
)

@Composable
fun SearchPickerDemo(
    initial: String? = null,
    items: List<SearchPickerItem> = searchPickerFrameworks,
    placeholder: String = "Select a framework",
    searchPlaceholder: String = "Search frameworks...",
    emptyText: String = "No framework found.",
    label: String = "Framework",
    title: String? = null,
    enabled: Boolean = true,
    invalid: Boolean? = null,
    floating: Boolean = false,
    showCloseButton: Boolean = false,
    closeLabel: String? = null,
) {
    var value by rememberSaveable { mutableStateOf(initial) }
    SearchPicker(
        items = items,
        value = value,
        onValueChange = { value = it },
        modifier = Modifier.width(224.dp).semantics { contentDescription = label },
        placeholder = placeholder,
        searchPlaceholder = searchPlaceholder,
        emptyText = emptyText,
        title = title,
        enabled = enabled,
        invalid = invalid,
        floating = floating,
        showCloseButton = showCloseButton,
        closeLabel = closeLabel,
    )
}
```

### Invalid

Shows the error border and tint with `invalid`.

![Preview](https://ui.suiss.app/previews/patterns-search-picker--invalid.light.webp)

```tsx
import { SearchPicker, type SearchPickerItem } from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export function SearchPickerInvalidExample() {
  return (
    <SearchPicker items={frameworks} placeholder="Select a framework" searchPlaceholder="Search frameworks..." emptyText="No framework found." accessibilityLabel="Framework" className="w-56" invalid />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SearchPickerInvalidExample: View {
  var body: some View {
    SearchPickerDemo()
      .invalid()
  }
}

let searchPickerFrameworks = [
  SearchPickerItem(value: "next", label: "Next.js"),
  SearchPickerItem(value: "remix", label: "Remix"),
  SearchPickerItem(value: "astro", label: "Astro"),
  SearchPickerItem(value: "sveltekit", label: "SvelteKit"),
  SearchPickerItem(value: "nuxt", label: "Nuxt"),
  SearchPickerItem(value: "gatsby", label: "Gatsby", disabled: true),
]

struct SearchPickerDemo: View {
  var items = searchPickerFrameworks
  var placeholder: LocalizedStringKey = "Select a framework"
  var searchPlaceholder: LocalizedStringKey = "Search frameworks..."
  var emptyText: LocalizedStringKey = "No framework found."
  var label: LocalizedStringKey = "Framework"
  var title: LocalizedStringKey?
  var showCloseButton = false
  var closeLabel: LocalizedStringKey?

  @State private var value: String?

  init(
    value: String? = nil,
    items: [SearchPickerItem] = searchPickerFrameworks,
    placeholder: LocalizedStringKey = "Select a framework",
    searchPlaceholder: LocalizedStringKey = "Search frameworks...",
    emptyText: LocalizedStringKey = "No framework found.",
    label: LocalizedStringKey = "Framework",
    title: LocalizedStringKey? = nil,
    showCloseButton: Bool = false,
    closeLabel: LocalizedStringKey? = nil
  ) {
    _value = State(initialValue: value)
    self.items = items
    self.placeholder = placeholder
    self.searchPlaceholder = searchPlaceholder
    self.emptyText = emptyText
    self.label = label
    self.title = title
    self.showCloseButton = showCloseButton
    self.closeLabel = closeLabel
  }

  var body: some View {
    SearchPicker(
      items: items,
      selection: $value,
      placeholder: placeholder,
      searchPlaceholder: searchPlaceholder,
      emptyText: emptyText,
      title: title,
      showCloseButton: showCloseButton,
      closeLabel: closeLabel
    )
    .frame(width: 224)
    .accessibilityLabel(Text(label))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.patterns.SearchPicker
import com.esuiss.ui.patterns.SearchPickerItem

@Composable
fun SearchPickerInvalidExample() {
    SearchPickerDemo(invalid = true)
}

val searchPickerFrameworks = listOf(
    SearchPickerItem("next", "Next.js"),
    SearchPickerItem("remix", "Remix"),
    SearchPickerItem("astro", "Astro"),
    SearchPickerItem("sveltekit", "SvelteKit"),
    SearchPickerItem("nuxt", "Nuxt"),
    SearchPickerItem("gatsby", "Gatsby", enabled = false),
)

@Composable
fun SearchPickerDemo(
    initial: String? = null,
    items: List<SearchPickerItem> = searchPickerFrameworks,
    placeholder: String = "Select a framework",
    searchPlaceholder: String = "Search frameworks...",
    emptyText: String = "No framework found.",
    label: String = "Framework",
    title: String? = null,
    enabled: Boolean = true,
    invalid: Boolean? = null,
    floating: Boolean = false,
    showCloseButton: Boolean = false,
    closeLabel: String? = null,
) {
    var value by rememberSaveable { mutableStateOf(initial) }
    SearchPicker(
        items = items,
        value = value,
        onValueChange = { value = it },
        modifier = Modifier.width(224.dp).semantics { contentDescription = label },
        placeholder = placeholder,
        searchPlaceholder = searchPlaceholder,
        emptyText = emptyText,
        title = title,
        enabled = enabled,
        invalid = invalid,
        floating = floating,
        showCloseButton = showCloseButton,
        closeLabel = closeLabel,
    )
}
```

### Controlled

Holds the value in state and prints the selection below.

![Preview](https://ui.suiss.app/previews/patterns-search-picker--controlled.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { SearchPicker, type SearchPickerItem } from "@/components/patterns/search-picker"
import { Text } from "@/components/ui/text"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

function ControlledExample(args: React.ComponentProps<typeof SearchPicker>) {
  const [value, setValue] = React.useState<string | null>("next")

  return (
    <View className="items-start gap-2">
      <SearchPicker {...args} value={value} onValueChange={setValue} />
      <Text className="text-sm text-muted-foreground">
        Selected: {value ?? "none"}
      </Text>
    </View>
  )
}

export function SearchPickerControlledExample() {
  return (
    <ControlledExample items={frameworks} placeholder="Select a framework" searchPlaceholder="Search frameworks..." emptyText="No framework found." accessibilityLabel="Framework" className="w-56" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

let searchPickerFrameworks = [
  SearchPickerItem(value: "next", label: "Next.js"),
  SearchPickerItem(value: "remix", label: "Remix"),
  SearchPickerItem(value: "astro", label: "Astro"),
  SearchPickerItem(value: "sveltekit", label: "SvelteKit"),
  SearchPickerItem(value: "nuxt", label: "Nuxt"),
  SearchPickerItem(value: "gatsby", label: "Gatsby", disabled: true),
]

struct SearchPickerControlled: View {
  @State private var value: String? = "next"

  var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      SearchPicker(
        items: searchPickerFrameworks,
        selection: $value,
        placeholder: "Select a framework",
        searchPlaceholder: "Search frameworks...",
        emptyText: "No framework found."
      )
      .frame(width: 224)
      .accessibilityLabel(Text("Framework"))
      Text(verbatim: "Selected: \(value ?? "none")")
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.patterns.SearchPicker
import com.esuiss.ui.patterns.SearchPickerItem
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

val searchPickerFrameworks = listOf(
    SearchPickerItem("next", "Next.js"),
    SearchPickerItem("remix", "Remix"),
    SearchPickerItem("astro", "Astro"),
    SearchPickerItem("sveltekit", "SvelteKit"),
    SearchPickerItem("nuxt", "Nuxt"),
    SearchPickerItem("gatsby", "Gatsby", enabled = false),
)

@Composable
fun SearchPickerControlled() {
    var value by rememberSaveable { mutableStateOf<String?>("next") }
    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
        SearchPicker(
            items = searchPickerFrameworks,
            value = value,
            onValueChange = { value = it },
            modifier = Modifier.width(224.dp).semantics { contentDescription = "Framework" },
            placeholder = "Select a framework",
            searchPlaceholder = "Search frameworks...",
            emptyText = "No framework found.",
        )
        Text("Selected: ${value ?: "none"}", style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground))
    }
}
```

**React Native, Jetpack Compose**

### Floating

Detaches the drawer from the screen edges.

![Preview](https://ui.suiss.app/previews/patterns-search-picker--floating.light.webp)

```tsx
import { SearchPicker, type SearchPickerItem } from "@/components/patterns/search-picker"

const frameworks: SearchPickerItem[] = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "sveltekit", label: "SvelteKit" },
  { value: "nuxt", label: "Nuxt" },
  { value: "gatsby", label: "Gatsby", disabled: true },
]

export function SearchPickerFloatingExample() {
  return (
    <SearchPicker items={frameworks} placeholder="Select a framework" searchPlaceholder="Search frameworks..." emptyText="No framework found." accessibilityLabel="Framework" className="w-56" floating />
  )
}
```

```kotlin
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.patterns.SearchPicker
import com.esuiss.ui.patterns.SearchPickerItem

@Composable
fun SearchPickerFloatingExample() {
    SearchPickerDemo(floating = true)
}

val searchPickerFrameworks = listOf(
    SearchPickerItem("next", "Next.js"),
    SearchPickerItem("remix", "Remix"),
    SearchPickerItem("astro", "Astro"),
    SearchPickerItem("sveltekit", "SvelteKit"),
    SearchPickerItem("nuxt", "Nuxt"),
    SearchPickerItem("gatsby", "Gatsby", enabled = false),
)

@Composable
fun SearchPickerDemo(
    initial: String? = null,
    items: List<SearchPickerItem> = searchPickerFrameworks,
    placeholder: String = "Select a framework",
    searchPlaceholder: String = "Search frameworks...",
    emptyText: String = "No framework found.",
    label: String = "Framework",
    title: String? = null,
    enabled: Boolean = true,
    invalid: Boolean? = null,
    floating: Boolean = false,
    showCloseButton: Boolean = false,
    closeLabel: String? = null,
) {
    var value by rememberSaveable { mutableStateOf(initial) }
    SearchPicker(
        items = items,
        value = value,
        onValueChange = { value = it },
        modifier = Modifier.width(224.dp).semantics { contentDescription = label },
        placeholder = placeholder,
        searchPlaceholder = searchPlaceholder,
        emptyText = emptyText,
        title = title,
        enabled = enabled,
        invalid = invalid,
        floating = floating,
        showCloseButton = showCloseButton,
        closeLabel = closeLabel,
    )
}
```

## API Reference

### SearchPicker

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `SearchPickerItem[]` | Required | The options, each with `value`, `label` and optional `group` and `disabled`. |
| `value` | `string \| null` | - | The selected value when controlled, or `null`. |
| `defaultValue` | `string \| null` | - | The initial value when uncontrolled. |
| `onValueChange` | `(value: string \| null) => void` | - | Called with the chosen value. |
| `placeholder` | `string` | - | Text shown in the field when nothing is selected. |
| `searchPlaceholder` | `string` | `"Search..."` | Placeholder and label of the search input. Defaults to `Search...`. |
| `emptyText` | `string` | `"No results found."` | Text shown when no option matches. Defaults to `No results found.`. |
| `title` | `string` | - | Heading shown at the top of the drawer. |
| `disabled` | `boolean` | - | Prevents opening and dims the field. |
| `floating` | `boolean` | - | Insets the drawer from the screen edges. |
| `showCloseButton` | `boolean` | `false` | Shows a close button in the drawer. Defaults to `false`. |
| `closeLabel` | `string` | - | Label of the close button. |
| `invalid` | `boolean` | - | Applies the error border and background. |
| `className` | `string` | - |  |
| `accessibilityLabel` | `string` | - | Label for the field. |
| `accessibilityLabelledBy` | `string` | - | Links the field to a visible label. |

## Accessibility

- Each option is a button that exposes `selected` and `disabled` states.
- Each group has the `group` role and is labelled by its name.
- The search input is labelled by `searchPlaceholder`.
- Without a `title`, the drawer is labelled by the placeholder or `accessibilityLabel`.

## Notes

- The list stays between 40% and 60% of the window height, and taps work while the keyboard is open.
- The field uses `bg-secondary`, `border-destructive` with a `bg-destructive` tint when invalid, and `opacity-50` when disabled; the tint is stronger in dark mode.
- The search input disables autocorrect and autocapitalization.

