# MultiSelect

**When to use:** Pick several options from a list. Selections appear as removable chips in the control, and the list opens in a panel below it. For a single choice use [Dropdown](Dropdown.md); for a handful of options that should stay visible use [OptionPicker](OptionPicker.md) or [Checkbox](Checkbox.md); for type-ahead against one value use [InputDropdown](InputDropdown.md).

**Keywords:** tags, token input, multi-value, pick many, select multiple, facet, chips picker

**Import:** `import { MultiSelect } from '@ahrowe/ui'`  
**Types:** `import type { MultiSelectProps, MultiSelectItem, MultiSelectValue } from '@ahrowe/ui'` (`MultiSelectValue` is `string | number`, an item's `key`)

```tsx
import { useState } from 'react';
import { MultiSelect } from '@ahrowe/ui';
import type { MultiSelectItem, MultiSelectValue } from '@ahrowe/ui';

const items: MultiSelectItem[] = [
  { key: 'apple', label: 'Apple' },
  { key: 'banana', label: 'Banana' },
  { key: 'fig', label: 'Fig', disabled: true },
];

// Controlled
const [value, setValue] = useState<MultiSelectValue[]>([]);
<MultiSelect items={items} value={value} onChange={setValue} label="Fruit" placeholder="Pick some" />

// Uncontrolled
<MultiSelect items={items} defaultValue={['apple']} onChange={(value) => console.log(value)} />

// Searchable, for a list too long to scan
<MultiSelect items={countries} searchable labels={{ searchPlaceholder: 'Filter countries…' }} />

// Tag entry: type a value that isn't in `items` and pick the "add" row
<MultiSelect items={commonTags} allowCustomValues labels={{ create: (value) => `Neu: ${value}` }} />

// Cap the selection — unselected options disable once the cap is reached
<MultiSelect items={items} maxSelected={3} />

// Connected to a FormValidator, like Input and Dropdown
<MultiSelect items={items} formValidator={form.fruit} label="Fruit" />

// Manual error state, when there's no validator
<MultiSelect items={items} isValid={false} errorMessage="Pick at least one" />

// Always a bottom sheet, rather than only on a small touch screen
import { Presentation } from '@ahrowe/ui';
<MultiSelect items={items} presentation={Presentation.Sheet} />
```

**Value:** `onChange` gets the full new selection as an array of `key`s, in the order the user picked them, and the chips render in that same order. It is never called with a partial change, so `onChange={setValue}` is all a controlled field needs.

**The label floats like `Input`'s.** It rests vertically centred over the control and rises into a notch cut out of the border once the field is focused, open, or has a selection, using the same `fieldset`/`legend` construction and the same `--input-notch-height` geometry as `Input`. A `MultiSelect` sitting next to an `Input` in a form animates with it rather than against it. Set `alwaysFloatLabel` to pin it up, which is what you want when a `placeholder` should always be readable: with a label present the placeholder is otherwise faded in on a delay matching the label's animation, so the two never sit on top of each other. Without a label there is nothing to wait for and it fades in at once.

**Errors:** with a `formValidator` the field stays quiet until the validator's `showErrors` lets the error through (by default once touched: the first time the panel closes, see FormValidator.md), then shows the validator's message while the field is hovered or focused, and drops it as soon as the validator is happy again. Without a validator, `isValid` and `errorMessage` are plain props and nothing clears them for you: derive them from your own state, as in `errorMessage={picked.length ? undefined : 'Pick at least one'}`. A non-empty message is an error by itself, so a constant one beside `isValid={picked.length > 0}` would show it all the time.

**Height:** a MultiSelect with one row of chips is exactly as tall as an `Input` or `Dropdown` beside it, so a form row lines up. It grows only when the chips wrap onto a second row. The chips render at `0.8em` to fit: a standalone `Chip` is nearly as tall as the whole field, because its remove button carries `ActionIcon`'s padding around a `1em` icon.

**Custom values** (`allowCustomValues`) turn this into a tag input. Anything typed that matches no item's label or key, and isn't already selected, gets an extra row at the bottom of the panel offering to add it; picking that row commits the typed string as its own key and clears the box. It goes through the same highlight and `Enter` path as a normal option, so the keyboard works unchanged, and `maxSelected` still caps it. Because a custom value has no entry in `items`, its chip falls back to showing the value itself. Without `allowCustomValues`, a selected key that isn't in `items` renders no chip at all.

**Item labels are strings, not nodes.** The same text renders twice, in the option row and inside the `Chip`, and `Chip` takes a string. Reach for `Dropdown` when an option needs rich content.

**Key props:**

| Prop | Type | Description |
|------|------|-------------|
| `items` | `MultiSelectItem[]` | The options: `{ key, label, disabled? }` |
| `value` | `MultiSelectValue[]` | Controlled selection (omit for uncontrolled) |
| `defaultValue` | `MultiSelectValue[]` | Initial selection when uncontrolled (default `[]`) |
| `onChange` | `(value: MultiSelectValue[]) => void` | Fires with the full new selection |
| `label` | `string` | Field label. Rests inside the control, floats into the border notch |
| `alwaysFloatLabel` | `boolean` | Keep the label floating even when empty and unfocused (default `false`) |
| `placeholder` | `string` | Shown while nothing is selected. With a `label`, it fades in only after the label has finished floating clear |
| `searchable` | `boolean` | Show a search box in the panel (default `false`) |
| `allowCustomValues` | `boolean` | Let the user add a value not in `items`. Implies `searchable` |
| `labels.create` | `(value: string) => string` | Label for the add row (default `Add "…"`) |
| `labels.searchPlaceholder` | `string` | Placeholder for that box (default `'Search…'`) |
| `labels.empty` | `string` | Shown when nothing matches (default `'No matches'`) |
| `maxSelected` | `number` | Cap the selection; unselected options disable at the cap |
| `clearable` | `boolean` | Show the clear-all button (default `true`) |
| `disabled` | `boolean` | Dims the field and blocks interaction |
| `readOnly` | `boolean` | Shows the selection, allows no changes, no dimming |
| `presentation` | `Presentation` | Anchored panel, bottom sheet, or sheet only on small touch screens |
| `formValidator` | `FormValidator \| null` | Owns the value when present. See [FormValidator.md](FormValidator.md) |
| `errorMessage` | `string` | Manual error, shown in a tooltip when there's no validator. A non-empty message puts the field in error by itself, so pass it only while there is one |
| `isValid` | `boolean` | Manual valid state (default `true`) |

**Keyboard:** `↓` or `Enter` opens the panel. `↑`/`↓` move the highlight and wrap around, skipping options that cannot be picked — a disabled one, or any unselected one once `maxSelected` is reached. `Home`/`End` jump to the ends, `Enter` toggles the highlighted option, `Escape` closes. The panel scrolls to keep the highlighted option in view. `Backspace` removes the last selection when the search box is empty. With `searchable`, focus moves into the search box on open and the same keys work from there.

**Accessibility:** the control is a `role="combobox"` with `aria-expanded`, `aria-haspopup="listbox"` and `aria-controls`; the panel is a `role="listbox"` with `aria-multiselectable="true"`, and each option carries `aria-selected`. The highlighted option is reported through `aria-activedescendant`, so focus stays on the control (or the search box) rather than moving between options. Each chip's remove button is labelled `Remove <label>`.

**Theming:** override these CSS variables theme-wide via `ThemeProvider` or per instance via `style`; each falls back to a built-in default:

| Variable | Falls back to |
|----------|---------------|
| `--multi-select-min-height` | `calc(1.25em + 14px)`, the same height `Input` and `Dropdown` resolve to |
| `--multi-select-panel-height` | `260px` |

**Global defaults:** adopts `ConfigProvider`, e.g. `defaultProps={{ MultiSelect: { searchable: true } }}`. See [ConfigProvider.md](ConfigProvider.md).

**Slots:** `root` `label` `fieldset` `control` `values` `chip` `placeholder` `clearButton` `icon` `panel` `search` `options` `option` `optionCheck` `optionLabel` `empty`

**Labels:** `clearSelection` `create(value)` `searchPlaceholder` `empty` — 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: `MultiSelectLabels`.
