<!-- Protocore Design System — MultiSelect -->
# MultiSelect

- **Category:** Inputs (`inputs`)
- **Slug:** `inputs/multi-select`
- **Status:** stable
- **Platforms:** web
- **Import:** `import { MultiSelect } from "@protocore/pds";`
- **Docs:** https://pds.protocore.io/components/inputs/multi-select

> Combobox that holds many values as dismissible Tags, with a checkable, type-to-filter listbox.

## When to use it

Reach for **MultiSelect** when a user picks **several values from a known set** — tags on a resource, scopes on a token, regions for a deploy. It differs from **TagsInput**, which lets users type *free-form* values that aren't in a list. Use a **Combobox** when only one value may be selected, and a **Select** for a short single-choice list. The listbox follows the ARIA multiselect combobox pattern: focus stays in the input while `aria-activedescendant` tracks the highlighted option and each option carries `aria-selected`.

## Props

| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `aria-label` | `string` | no | — | Accessible label for the input (required if no visible label wraps it). |
| `className` | `string` | no | — | Merged after the pds class on the root field. |
| `clearable` | `boolean` | no | `false` | Show a clear button once anything is selected. |
| `defaultValue` | `string[]` | no | — | Initial selected values in uncontrolled mode. |
| `disabled` | `boolean` | no | `false` | Disable the control. |
| `filter` | `((options: MultiSelectOption[], query: string) => MultiSelectOption[])` | no | `(options: MultiSelectOption[], query: string): MultiSelectOption[] => { const q = query.trim().toLowerCase(); if (!q) return options; return options.filter((o) => o.label.toLowerCase().includes(q)); }` | Custom filter predicate; defaults to case-insensitive label substring match. |
| `id` | `string` | no | — | Root id — also seeds the listbox/option ids. |
| `invalid` | `boolean` | no | `false` | Mark the field invalid — danger border plus `aria-invalid`. |
| `max` | `number` | no | — | Maximum number of selections. Options beyond it are disabled. |
| `onValueChange` | `((value: string[]) => void)` | no | — | Fires with the next list whenever a value is toggled or removed. |
| `options` | `MultiSelectOption[]` | yes | — | The full option set. |
| `placeholder` | `string` | no | `Select…` | Placeholder for the entry (shown only while nothing is selected). |
| `size` | `enum` | no | `md` | Control height: `sm` (32) · `md` (36, default) · `lg` (40) — the minimum row height. |
| `value` | `string[]` | no | — | Controlled selected values. |

## Examples

### Basics

Pass `options` with `value` / `label` and an optional `hint`. Selected values render as Tags inside the sunken field; picking an option toggles it, and typing filters the list.

```tsx
import { useState } from "react";
import { MultiSelect, Text } from "@protocore/pds";

const SCOPES = [
  { value: "read", label: "ledger:read", hint: "grpc" },
  { value: "write", label: "ledger:write", hint: "grpc" },
  { value: "settle", label: "settlement:run", hint: "queue" },
  { value: "admin", label: "gateway:admin", hint: "http" },
  { value: "audit", label: "audit:export", hint: "batch" },
];

export default function Demo() {
  const [value, setValue] = useState<string[]>(["read", "settle"]);

  return (
    <div style={{ display: "grid", gap: 12, width: 320 }}>
      <MultiSelect
        options={SCOPES}
        value={value}
        onValueChange={setValue}
        placeholder="Add scopes…"
        aria-label="Token scopes"
      />
      <Text size="sm" color="muted">
        {value.length ? `${value.length} scope(s) granted` : "No scopes granted"}
      </Text>
    </div>
  );
}
```

### Max & clearable

Cap the selection with `max` — unselected options disable once the limit is reached. Add `clearable` for a one-click reset. Drive `value` from state and read `onValueChange`.

```tsx
import { useState } from "react";
import { MultiSelect, Text } from "@protocore/pds";

const REGIONS = [
  { value: "eu-central", label: "eu-central-1" },
  { value: "eu-west", label: "eu-west-1" },
  { value: "us-east", label: "us-east-1" },
  { value: "us-west", label: "us-west-2" },
  { value: "ap-south", label: "ap-south-1" },
];

export default function Demo() {
  const [value, setValue] = useState<string[]>(["eu-central"]);

  return (
    <div style={{ display: "grid", gap: 12, width: 320 }}>
      <MultiSelect
        options={REGIONS}
        value={value}
        onValueChange={setValue}
        max={3}
        clearable
        placeholder="Pick up to 3 regions…"
        aria-label="Replica regions"
      />
      <Text size="sm" color="muted">
        {value.length}/3 regions selected
      </Text>
    </div>
  );
}
```

## Do & don't

**Do**

- Give the input an accessible name via `aria-label` or a wrapping `Field`.
- Use `max` when the field has a hard cap — options gate automatically.
- Use it over a long list of checkboxes when the option set is long.
- Make it `clearable` when 'select none' is a valid state.

**Don't**

- Don't use it for free-form entry — use TagsInput.
- Don't use it for a single choice — use Combobox or Select.
- Don't pack long descriptions into `label`; each row is one line.
- Don't leave it unlabelled; it announces as a nameless combobox.

## Accessibility

**Keyboard**

| Keys | Action |
| --- | --- |
| `Type` | Filter the list; opens the popup and highlights the first match. |
| `Arrow Down / Up` | Move the active option (opens the popup if closed). |
| `Home / End` | Jump to the first / last option while open. |
| `Enter` | Toggle the active option; the popup stays open. |
| `Backspace` | With an empty query, remove the last selected value. |
| `Esc` | Close the popup. |

**Notes**

- Implements the ARIA 1.2 multiselect combobox: the `role="combobox"` input keeps focus while `aria-activedescendant` points at the highlighted `role="option"`.
- The listbox is `aria-multiselectable="true"`; selected options expose `aria-selected="true"` and gated options `aria-disabled`.
- Additions, removals, and clears are announced through a polite live region.

## Related

`combobox`, `select`, `tags-input`, `field`

---

© Protocore. All rights reserved. Use of the Protocore Design System requires prior written authorization from Protocore (contact@protocore.io). These machine-readable materials must not be ingested into ML-training datasets or derivative design systems. See https://pds.protocore.io/legal/
