# InputDropdown

**When to use:** Combobox-style input — the user can type to filter and also pick from a dropdown list. Use over `Dropdown` when free-text entry or search-to-filter behaviour is needed.

**Keywords:** autocomplete, typeahead

**Import:** `import { InputDropdown } from '@ahrowe/ui'`  
**Types:** `import type { InputDropdownItem, InputDropdownLabels, InputDropdownProps } from '@ahrowe/ui'`

```tsx
import { InputDropdown } from '@ahrowe/ui';
import type { InputDropdownItem } from '@ahrowe/ui';

const items: InputDropdownItem[] = [
  { value: 'react',      label: 'React' },
  { value: 'vue',        label: 'Vue' },
  { value: 'svelte',     label: 'Svelte' },
];

// Controlled
<InputDropdown
  label="Framework"
  value={selected}
  items={items}
  onSelect={(value) => setSelected(value)}
/>

// With FormValidator
const frameworkValidator = new FormValidator('');
<InputDropdown label="Framework" items={items} formValidator={frameworkValidator} />

// Allow free-text entries not in the list — onChange fires on every keystroke,
// onSelect only when an item is picked from the list
<InputDropdown
  label="Framework"
  value={selected}
  items={items}
  allowCustomValue
  onChange={(value) => setSelected(value as string)}
  onSelect={(value) => setSelected(value)}
/>
```

**InputDropdownItem:**

```ts
interface InputDropdownItem {
  value: string;
  label?: string;  // displayed text — falls back to value if omitted
}
```

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `label` | `string` | Floating label |
| `value` | `string` | Controlled value |
| `items` | `InputDropdownItem[]` | Dropdown options |
| `allowCustomValue` | `boolean` | Allow free-text values not in `items` — the typed text is emitted via `onChange` on every keystroke (default `false`) |
| `maxItems` | `number` | Maximum number of filtered matches rendered in the list. Further matches are dropped, since the list is narrowed by typing rather than by scrolling. `0` renders all matches. Default `100` |
| `onSelect` | `(value: string) => void` | Called when an item is picked from the list |
| `onChange` | `(value: unknown) => void` | Called on every text change |
| `placeholder` | `string` | |
| `readOnly` | `boolean` | Make the field read-only — typing is blocked, the list won't open, and keyboard navigation/selection is disabled. Default `false` |
| `labels` | `InputDropdownLabels` | Text the component renders of its own, see **Labels** below |
| `formValidator` | `FormValidator` | Connects to validation — shows the error state/message once the validator's `shouldShowError` is true (by default once the field has been blurred, see FormValidator.md), and the required mark (`*`) when the validator has a `required` validator |
| `error` | `string` | Manual error message (used when no `formValidator` is given) |
| `isRequired` | `boolean` | Shows the required mark (`*`) next to the label. Also inferred automatically when `formValidator` has a `required` validator. Default `false` |

**Keyboard:** `↑`/`↓` move the highlight through the suggestions, and the text you typed is part of
the cycle rather than a dead end: arrowing off either end returns to it, and one more press carries
on to the far end of the list. So `↓` on the last suggestion shows your text again, then the first
suggestion. This is how a browser's own address bar behaves, and it is why the list does not wrap
straight around the way `Dropdown` and `MultiSelect` do — neither of those has typed text to pass
through. `Enter` picks the highlighted suggestion, and the list scrolls to keep it in view.

Inside a `<form>`, `Enter` on a highlighted suggestion picks it without submitting the form; `Enter`
with nothing highlighted submits as any text field would. So cycling to your own text and pressing
`Enter` twice is the way to submit exactly what you typed. `Home` and `End` are left to the text field, so they move the caret.

**Nothing to show:** the list only opens when it has something in it. With `allowCustomValue` a search that matches nothing closes it, since the typed text is kept anyway, and it reopens as soon as something matches again. Without `allowCustomValue` the typed text is thrown away on blur, so a `No matches` row says so while the user is still typing, and a screen reader announces it. Nothing typed yet opens nothing in either mode, including a preset value that `items` does not contain.

**Labels:** `empty` (shown when nothing matches and `allowCustomValue` is off, default `No matches`): the text this component renders of its own. Pass `labels` to override any of them, on the component or app-wide through `ConfigProvider`; they merge per key. Type: `InputDropdownLabels`.

**Slots:** `root` `dropdown` `item` `empty`

Long lists are handled by capping how many matches are rendered (`maxItems`, default `100`) rather than by virtualizing: only about six rows fit in the scroll box, so anything past the cap is DOM the user never sees. Raise `maxItems` (or set it to `0`) if a list is genuinely meant to be scrolled end to end. For lists too large to pass as `items` at all, fetch on the server, keep the results in your own state and feed them in as `items`. Without `allowCustomValue`, say so while a fetch runs, or the field reports `No matches` for results that have not arrived yet: `labels={{ empty: isFetching ? 'Loading…' : 'No matches' }}`.

The list is rendered through a portal and tracks the trigger across scroll, so it always escapes clipping ancestors (cards, scroll containers, virtualized lists). The panel itself is a [Popover](Popover.md), so it flips and clamps the same way every other anchored surface in the library does. It is never a bottom sheet, whatever `presentation` a `ConfigProvider` sets: the field being typed into is the trigger, and a sheet would cover it with its backdrop and open the list away from it, below an on-screen keyboard. It flips to open upward automatically when there's no room below, and shifts horizontally to stay within the viewport when the trigger sits near the left or right edge of the screen. It hides (without closing) if the trigger itself scrolls behind a clipping ancestor, reappearing once it's back in view. It also re-measures when its own content changes size while open, so a list that shrinks (e.g. filtered down to fewer entries) stays anchored to the trigger instead of hanging above it.
