# MultiSelect

A cross-platform multi-select control that lets users pick multiple options from a dropdown list.

<!-- BEGIN:xui-mcp-instructions:multi-select -->
A dropdown input that allows selecting multiple values simultaneously. Displays the current selections inside the field as text labels or removable tag chips, opens a ContextMenu with checkboxes when clicked, and supports an optional count tag when selections overflow. Built on the same trigger pattern as Select but with multi-value semantics.

### When to use
- When the user must choose more than one value from a predefined list — multiple tags, categories, permissions, countries, features
- In filter bars where the user applies several filters simultaneously
- In forms where a many-to-many relationship must be configured (e.g. assigning roles, selecting supported platforms)
- When the full list of options must remain visible and re-selectable without navigating away

### When not to use
- When only one value can be selected at a time — use Select
- When the number of selectable options is very small (2–4) and all options should be permanently visible — use Checkbox group or ToggleButtonGroup
- When the user needs to type free-form tags or values not from a predefined list — use a tag input component
- When the user needs to rank or order selections — use a dedicated sortable list

### Content guidelines
- Placeholder text — use a concise instructional phrase: *"Select categories"*, *"Choose platforms"*, *"Pick permissions"*. Avoid generic *"Select…"* alone.
- Option labels — keep each option label short (1–4 words). Use parallel structure across all options (all nouns or all short phrases). No trailing punctuation.
- Option order — alphabetical or most common first. Do not randomise.
- Count tag format — always use +N format (e.g. +3, +12). Do not spell out *"3 more"* inside a chip.
- Clear button — enable for optional fields. Do not show on required fields — clearing all values on a required field immediately produces a validation error.
- Field label — always provide a visible label above the field via FieldGroup.
- Error messages — be specific:
- *"Please select at least one category"* (required, empty on submit)
- *"You can select a maximum of 5 items"* (max exceeded)

### Behaviour guidelines
- Multi-select model — multiple values can be selected independently. Selecting one value does not deselect others. Unchecking an option in the ContextMenu removes it from the field.
- Individual removal (Tag variant) — when Variant=Tag and Remove button=true, clicking ✕ on a chip removes that specific value from the selection without reopening the dropdown. The ContextMenu reflects the change if it is open.
- Clear all — clicking the clear button (✕, Clear button=true) removes all selections at once and returns the field to Fill=False.
- Count tag — when selections overflow the visible content area, show a +N count tag. Clicking it opens the dropdown panel so the user can review all selected values.
- Opening — clicking the field or pressing Enter / Space / ↓ when focused opens the ContextMenu. The field enters State=Focus; the chevron rotates upward.
- Closing — the ContextMenu closes on: clicking outside, pressing Escape, or pressing Tab. Focus returns to the MultiSelect field.
- Validation — validate on blur (field loses focus with the dropdown closed) or on form submit. Show State=Error with a message if the selection is invalid (e.g. required field with no selection, or max selection exceeded).
- Disabled — State=Disable prevents opening the dropdown. Existing selections remain visible in muted style.
- ContextMenu width — the ContextMenu panel must be at least as wide as the MultiSelect field. Never open a narrower panel.
- Real-time update — the field content updates as the user checks/unchecks options. Do not batch updates to the field until the dropdown closes.

### Accessibility
- MultiSelect must have role=*"combobox"* (or role=*"button"*) with aria-haspopup=*"listbox"* and aria-expanded=*"true"* / *"false"*.
- The field must have an accessible name via <label for> (from FieldGroup), aria-label, or aria-labelledby.
- The selected values must be reflected in aria-label or as visually visible text so screen readers can announce the current state — e.g. aria-label=*"Categories: Design, Development, Marketing"*.
- When Variant=Tag and Remove button=true, each chip's remove button must have aria-label=*"Remove [value name]"* — e.g. aria-label=*"Remove Design"*.
- The count tag (+N) must have aria-label describing the hidden count — e.g. aria-label=*"3 more selected items"*.
- The clear button must have aria-label=*"Clear all selections"*.
- When State=Error, the error message must be linked via aria-describedby and in an aria-live=*"polite"* region.
- The ContextMenu dropdown uses role=*"listbox"* (or role=*"menu"*) with each option as role=*"option"* / role=*"menuitemcheckbox"* and aria-checked=*"true"* / *"false"*.
- Keyboard navigation: Enter / Space / ↓ opens the dropdown; ↑ / ↓ navigate options; Space toggles the focused option; Escape closes without further changes; Tab closes and moves focus forward.
- When a value is added or removed (via chip ✕ or from dropdown), announce the change via aria-live=*"polite"*: *"Design added"* or *"Design removed"*.
<!-- END:xui-mcp-instructions:multi-select -->

## Installation

```bash
npm install @xsolla/xui-multi-select
```

## Imports

```tsx
import { MultiSelect } from "@xsolla/xui-multi-select";
import type {
  MultiSelectProps,
  MultiSelectOption,
  MultiSelectValue,
  MultiSelectVariant,
  MultiSelectSize,
  MultiSelectState,
} from "@xsolla/xui-multi-select";
```

## Quick start

```tsx
const options = [
  { label: "React", value: "react" },
  { label: "Vue", value: "vue" },
  { label: "Angular", value: "angular" },
];

const [selected, setSelected] = useState<MultiSelectValue>([]);

<MultiSelect
  label="Frameworks"
  options={options}
  value={selected}
  onChange={setSelected}
  placeholder="Select frameworks"
/>;
```

### External panel (B2B grouped select)

When the option list is rendered elsewhere (for example [`@xsolla/xui-b2b-group-select`](./b2b-group-select.md)), set **`dropdownMenu={false}`** so the control does not open the built-in list. Wire the same **`value`** / **`onChange`** to both components; use **`onTriggerPress`** to toggle your panel, **`menuOpen`** for chevron/open styling, and **`menuMinWidth`** (default **540**, aligned with `GROUP_SELECT_MIN_PANEL_WIDTH`) so the field matches the panel width.

```tsx
import * as React from "react";
import { MultiSelect } from "@xsolla/xui-multi-select";
import {
  GroupSelect,
  GROUP_SELECT_MIN_PANEL_WIDTH,
  type GroupSelectGroup,
} from "@xsolla/xui-b2b-group-select";

const groups: GroupSelectGroup[] = [
  /* ... */
];
const flatOptions = groups.flatMap((g) =>
  g.items.map((it) => ({ value: it.id, label: it.label }))
);

export default function GroupedFieldShell() {
  const [value, setValue] = React.useState<string[]>([]);
  const [open, setOpen] = React.useState(false);

  return (
    <>
      <MultiSelect
        options={flatOptions}
        value={value}
        onChange={(v) => setValue(v.map(String))}
        placeholder="Select regions"
        size="sm"
        dropdownMenu={false}
        menuOpen={open}
        menuMinWidth={GROUP_SELECT_MIN_PANEL_WIDTH}
        onTriggerPress={() => setOpen((o) => !o)}
      />
      {open && (
        <GroupSelect groups={groups} value={value} onChange={setValue} />
      )}
    </>
  );
}
```

Add backdrop, click-outside, and Escape handling in your layout as needed (see Storybook).

## API Reference

### `<MultiSelect>`

| Prop                | Type                                                      | Default    | Description                                                                                                                                                |
| ------------------- | --------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `testID`            | `string`                                                  | —          | Test ID for testing frameworks. On web this renders as `data-testid`; on React Native it renders as `testID`.                                              |
| `options`           | `MultiSelectOption[]`                                     | —          | Available options.                                                                                                                                         |
| `value`             | `MultiSelectValue`                                        | `[]`       | Selected values.                                                                                                                                           |
| `onChange`          | `(values: MultiSelectValue) => void`                      | —          | Fired when the selection changes.                                                                                                                          |
| `placeholder`       | `string`                                                  | `'Select'` | Placeholder shown when empty.                                                                                                                              |
| `label`             | `string`                                                  | —          | Label rendered above the control.                                                                                                                          |
| `size`              | `'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl'`                    | `'md'`     | Control size.                                                                                                                                              |
| `state`             | `'default' \| 'hover' \| 'focus' \| 'disable' \| 'error'` | —          | Forced visual state.                                                                                                                                       |
| `disabled`          | `boolean`                                                 | `false`    | Disable the control.                                                                                                                                       |
| `errorMessage`      | `string`                                                  | —          | Error message; also marks the control invalid.                                                                                                             |
| `variant`           | `'tag' \| 'text'`                                         | `'tag'`    | How selected options are displayed.                                                                                                                        |
| `flexible`          | `boolean`                                                 | `true`     | When `true` the control grows with content; otherwise fixed-height.                                                                                        |
| `removeTagsButtons` | `boolean`                                                 | `true`     | Show a remove button on each tag.                                                                                                                          |
| `extraClear`        | `boolean`                                                 | `false`    | Show a clear-all button.                                                                                                                                   |
| `maxHeight`         | `number`                                                  | `300`      | Maximum dropdown height in pixels.                                                                                                                         |
| `searchable`        | `boolean`                                                 | `false`    | Show a search input at the top of the built-in dropdown that filters options in memory by label. Web only.                                                 |
| `searchPlaceholder` | `string`                                                  | `'Search'` | Placeholder for the search input (used when `searchable`).                                                                                                 |
| `noOptionsMessage`  | `string`                                                  | `'No results'` | Message shown when the search filter matches no options.                                                                                               |
| `iconLeft`          | `ReactNode`                                               | —          | Icon rendered on the left of the control.                                                                                                                  |
| `iconRight`         | `ReactNode`                                               | —          | Icon on the right (overrides the default caret).                                                                                                           |
| `dropdownMenu`      | `boolean`                                                 | `true`     | When `false`, hides the built-in list and disables click-to-open; use with an external picker (e.g. `GroupSelect`) wired to the same `value` / `onChange`. |
| `onTriggerPress`    | `() => void`                                              | —          | When `dropdownMenu` is `false`: fired when the user activates the field. Typically toggles the external panel.                                             |
| `menuOpen`          | `boolean`                                                 | `false`    | When `dropdownMenu` is `false`: drives chevron direction and layering like the built-in open state.                                                        |
| `menuMinWidth`      | `number`                                                  | `540`      | When `dropdownMenu` is `false`: field `min-width` in px (matches `GroupSelect`). Use `0` for no minimum.                                                   |

Inherits `ThemeOverrideProps` (`themeMode`, `themeProductContext`).

### Types

```ts
type MultiSelectValue = (string | number)[];
type MultiSelectVariant = "tag" | "text";
type MultiSelectSize = "xs" | "sm" | "md" | "lg" | "xl";
type MultiSelectState = "default" | "hover" | "focus" | "disable" | "error";

interface MultiSelectOption {
  label: ReactNode;
  value: string | number;
  disabled?: boolean;
}
```

## Examples

### Sizes

```tsx
const options = [
  { label: 'React', value: 'react' },
  { label: 'Vue', value: 'vue' },
  { label: 'Angular', value: 'angular' },
];

<MultiSelect options={options} size="xs" placeholder="Extra small" />
<MultiSelect options={options} size="sm" placeholder="Small" />
<MultiSelect options={options} size="md" placeholder="Medium" />
<MultiSelect options={options} size="lg" placeholder="Large" />
<MultiSelect options={options} size="xl" placeholder="Extra large" />
```

### Text variant with clear-all

```tsx
const options = [
  { label: "React", value: "react" },
  { label: "Vue", value: "vue" },
  { label: "Angular", value: "angular" },
];

const [selected, setSelected] = useState<MultiSelectValue>([]);

<MultiSelect
  options={options}
  value={selected}
  onChange={setSelected}
  variant="text"
  extraClear
/>;
```

### Error state

```tsx
const options = [
  { label: "Design", value: "design" },
  { label: "Engineering", value: "engineering" },
  { label: "Product", value: "product" },
];

const [skills, setSkills] = useState<MultiSelectValue>([]);

<MultiSelect
  label="Skills"
  options={options}
  value={skills}
  onChange={setSkills}
  errorMessage="Please select at least one skill"
/>;
```

### Disabled

```tsx
const options = [
  { label: "React", value: "react" },
  { label: "Vue", value: "vue" },
  { label: "Angular", value: "angular" },
];

<MultiSelect options={options} value={["react"]} disabled />;
```

### Searchable

Enable `searchable` to show a search field at the top of the dropdown. It filters the options in memory by label as the user types, mirroring the [`Select`](./select.md) component's `searchable` behavior. Customize the input hint with `searchPlaceholder` and the empty-result text with `noOptionsMessage`. Web only — the prop is ignored on native.

```tsx
const options = [
  { label: "React", value: "react" },
  { label: "Vue", value: "vue" },
  { label: "Angular", value: "angular" },
  { label: "Svelte", value: "svelte" },
  { label: "Solid", value: "solid" },
];

const [selected, setSelected] = useState<MultiSelectValue>([]);

<MultiSelect
  label="Frameworks"
  options={options}
  value={selected}
  onChange={setSelected}
  searchable
  searchPlaceholder="Search frameworks"
  noOptionsMessage="No frameworks found"
  placeholder="Select frameworks"
/>;
```

### Long option labels

Long, unbreakable option labels (URLs, IDs, tokens) wrap inside the dropdown menu instead of forcing a horizontal scrollbar; the menu width stays pinned to the control.

```tsx
const options = [
  {
    label:
      "https://prototype.xsolla.dev/mini-apps/some-really-long-slug?token=abcdef0123456789",
    value: "url",
  },
  { label: "urn:xsolla:resource:0123456789abcdef0123456789abcdef", value: "id" },
  { label: "Short", value: "short" },
];

const [selected, setSelected] = useState<MultiSelectValue>([]);

<MultiSelect
  options={options}
  value={selected}
  onChange={setSelected}
  placeholder="Select options"
/>;
```

## Accessibility

- Selection is rendered as a checkbox list inside the dropdown.
- The dropdown is keyboard navigable; selection state is announced to assistive technology.
- An `errorMessage` marks the control as invalid for screen readers.
- When `searchable`, the dropdown's search input exposes an `aria-label` matching `searchPlaceholder`; options filter in memory as the user types (web only).
