# Multi Selector

## Usage

`MultiSelector` picks several values from a fixed list. Without a search field, the trigger is a combobox. The menu is a listbox with `aria-multiselectable`. Choosing an option leaves the menu open.

An option can be a string, an object with a description, a divider, or a labeled section. `selectAll` adds a row that toggles every enabled option and skips disabled ones. `searchable` filters that same list. It does not call a remote source.

`triggerDisplay` controls the closed trigger. `count` shows how many are selected, `labels` joins the text, and `badges` uses tokens. `maxBadges` limits how many are visible, and the rest collapse to `+N`. A narrower trigger collapses further.

Read-only keeps the selection, stays focusable, and still submits. It does not open, and it has no clear button or chevron. It does not dim. While loading, the trigger shows a spinner, the create row stays hidden, and the menu does not claim the list is empty. `disabledMessage` explains a disabled field: a tooltip follows hover and keyboard focus, the trigger stays focusable through `aria-disabled`, and the menu still does not open.

A function can replace the closed text for the count and labels displays. Badges ignore it. An empty selection does not call it and keeps the placeholder.

Create works together with search. When the typed text matches no option label, ignoring case, the first row offers to create it. Choosing that row adds a description to the change event: the kind is create, and the text is the query. Add that option to the list in the same update. Without search, the create row never appears.

To open the menu from outside, pass the handle object itself from script. A template unwraps a ref, so pass a function that receives the handle. Open does nothing while the field is read-only or disabled.

Arrow keys move the highlight. Enter and Space toggle the highlighted option. Space inside the search field still types. Enter creates the current text when nothing is highlighted. Escape closes the menu. When the menu is closed and the user is not searching, Backspace clears the selection.

## Best Practices

| Guideline | Practice |
| --- | --- |
| Do | Use Multi Selector when the list is short and people need to compare the choices. |
| Do | Use `badges` and `+N` when the closed trigger should still name the selection. |
| Don't | Search a remote source with Multi Selector. Use Tokenizer for that. |

### Anatomy

- **Field**: Standalone Field shell that provides the label and optional supporting content; omitted inside InputGroup.
- **Trigger**: Painted control that displays the current selection or placeholder and opens the selection surface when editable.
- **Icon-rendered start icon**: Optional leading semantic icon or icon component rendered through Icon.
- **Caller-rendered start content**: Optional arbitrary slot content rendered directly at the start of the trigger.
- **Trigger clear button**: Shared clear action that removes every selected value when clearable is set.
- **Status icon**: Status glyph shown in place of the disclosure indicator for attached or tooltip status.
- **Indicator icon**: Trailing chevron shown when status presentation does not replace it; reflects collapsed or expanded state.
- **Search row**: Panel header with a borderless search input and optional clear action.
- **Search icon**: Leading magnifier rendered through Icon inside the search row.
- **Search clear button**: Shared clear action shown in the search row while a query is present.
- **Option row**: Selectable row for an option or the optional select-all choice.
- **Option checkbox indicator**: CheckboxInput indicator that presents each row’s selected, unselected, or indeterminate state.
- **Option divider**: Divider supplied in the public options data to separate adjacent option groups.
- **Section heading**: Visible heading for a labeled group of option rows.
- **Empty state**: Message shown when the shared panel content has no options or no search matches.
- **Pointer popup**: Anchored painted surface that hosts the shared panel content for popover presentation.
- **Touch sheet heading**: Heading above the shared panel content in bottom-sheet presentation.
- **Touch sheet**: BottomSheet surface that hosts the same panel content for bottom-sheet presentation.

## Examples

### Bottom Sheet

Keeps a multi-selection list open in a bottom sheet while choices are toggled.

```vue
<script setup lang="ts">
import { MultiSelector } from '@astryx-vue/core/MultiSelector'
import { computed, ref } from 'vue'

const selected = ref<string[]>([])
const options = computed(() => [
  'Design',
  'Engineering',
  'Marketing',
  'Operations',
])
</script>

<template>
  <div class="demo-field">
    <MultiSelector
      v-model="selected"
      :options="options"
      label="Teams"
      placeholder="Choose teams"
      presentation="bottom-sheet"
      select-all
    />
  </div>
</template>

<style scoped>
.demo-field {
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

### Column Visibility

Column visibility toggle with hidden label, search, select-all, and selection count.

```vue
<script setup lang="ts">
import { MultiSelector } from '@astryx-vue/core/MultiSelector'
import { computed, ref } from 'vue'

const selected = ref(['name', 'email', 'role', 'status'])
const options = computed(() => [
  { value: 'name', label: 'Name' },
  { value: 'email', label: 'Email' },
  { value: 'role', label: 'Role' },
  { value: 'status', label: 'Status' },
  { value: 'created', label: 'Created' },
  { value: 'updated', label: 'Updated' },
  { value: 'actions', label: 'Actions' },
])
</script>

<template>
  <div class="demo-field">
    <MultiSelector
      v-model="selected"
      :options="options"
      label="Columns"
      label-hidden
      placeholder="Columns"
      searchable
      select-all
      trigger-display="count"
    />
  </div>
</template>

<style scoped>
.demo-field {
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

### Form Composition

Two multi-selectors in a form with required and optional states, plus a disabled one that explains itself on hover or keyboard focus.

```vue
<script setup lang="ts">
import { MultiSelector } from '@astryx-vue/core/MultiSelector'
import { ref } from 'vue'

const columns = ref(['name', 'email'])
const filters = ref<string[]>([])
const lockedColumns = ref(['name'])
</script>

<template>
  <div class="demo-stack">
    <MultiSelector
      v-model="columns"
      :options="[
        { value: 'name', label: 'Name' },
        { value: 'email', label: 'Email' },
        { value: 'role', label: 'Role' },
        { value: 'status', label: 'Status' },
        { value: 'created', label: 'Created at' },
      ]"
      label="Visible columns"
      description="Choose which columns to display in the table"
      required
      select-all
      trigger-display="labels"
    />
    <MultiSelector
      v-model="filters"
      :options="[
        'Active',
        'Inactive',
        'Pending',
        'Archived',
      ]"
      label="Status filter"
      description="Filter by status"
      placeholder="All statuses"
      optional
      trigger-display="badges"
    />
    <MultiSelector
      v-model="lockedColumns"
      :options="[
        { value: 'name', label: 'Name' },
        { value: 'email', label: 'Email' },
      ]"
      label="Locked columns"
      disabled
      disabled-message="Ask an administrator to change the saved view"
    />
  </div>
</template>

<style scoped>
.demo-stack {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

### Ghost Toolbar

Borderless variant composed with ghost buttons in a toolbar.

```vue
<script setup lang="ts">
import { Button } from '@astryx-vue/core/Button'
import { MultiSelector } from '@astryx-vue/core/MultiSelector'
import { ref } from 'vue'

const columns = ref(['name', 'email'])
const filters = ref(['active'])
</script>

<template>
  <div class="toolbar">
    <Button label="Refresh" variant="ghost" />
    <MultiSelector
      v-model="columns"
      :options="[
        { value: 'name', label: 'Name' },
        { value: 'email', label: 'Email' },
        { value: 'role', label: 'Role' },
        { value: 'status', label: 'Status' },
        { value: 'created', label: 'Created' },
      ]"
      label="Columns"
      label-hidden
      variant="ghost"
      trigger-display="labels"
      placeholder="Columns"
      width="max-content"
    />
    <MultiSelector
      v-model="filters"
      :options="[
        { value: 'active', label: 'Active' },
        { value: 'inactive', label: 'Inactive' },
        { value: 'pending', label: 'Pending' },
        { value: 'archived', label: 'Archived' },
      ]"
      label="Status"
      label-hidden
      variant="ghost"
      trigger-display="labels"
      placeholder="Status"
      status="warning"
      status-message="Some filters hide archived rows"
      status-variant="tooltip"
      width="max-content"
    />
    <Button label="Export" variant="ghost" />
  </div>
</template>

<style scoped>
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: center;
  max-width: 100%;
}
</style>
```

### Searchable

Multi-select with search filtering and select-all.

```vue
<script setup lang="ts">
import { MultiSelector } from '@astryx-vue/core/MultiSelector'
import { ref } from 'vue'

const selected = ref<string[]>([])
</script>

<template>
  <div class="demo-field">
    <MultiSelector
      v-model="selected"
      :options="[
        'United States',
        'United Kingdom',
        'Canada',
        'Australia',
        'Germany',
        'France',
        'Japan',
        'Brazil',
        'India',
        'Mexico',
      ]"
      label="Countries"
      placeholder="Select countries..."
      searchable
      select-all
    />
  </div>
</template>

<style scoped>
.demo-field {
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

### Sectioned Permissions

Multi-select with options grouped into labeled sections.

```vue
<script setup lang="ts">
import { MultiSelector } from '@astryx-vue/core/MultiSelector'
import { computed, ref } from 'vue'

const selected = ref<string[]>([])
const options = computed(() => [
  {
    type: 'section' as const,
    label: 'Read',
    options: [
      { value: 'read_posts', label: 'Read posts' },
      { value: 'read_comments', label: 'Read comments' },
      { value: 'read_users', label: 'Read users' },
    ],
  },
  {
    type: 'section' as const,
    label: 'Write',
    options: [
      { value: 'write_posts', label: 'Write posts' },
      { value: 'write_comments', label: 'Write comments' },
    ],
  },
])
</script>

<template>
  <div class="demo-field">
    <MultiSelector
      v-model="selected"
      :options="options"
      label="Permissions"
      placeholder="Select permissions..."
    />
  </div>
</template>

<style scoped>
.demo-field {
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

### Create and read-only

Typing a name that is not in the list offers to create it. The closed text can be a custom sentence. Read-only keeps the selection.

```vue
<script setup lang="ts">
import type { MultiSelectorCreateChange } from '@astryx-vue/core/MultiSelector'
import { MultiSelector } from '@astryx-vue/core/MultiSelector'
import { VStack } from '@astryx-vue/core/Stack'
import { ref } from 'vue'

interface Permission {
  value: string
  label: string
}

const base: Permission[] = [
  { value: 'read', label: 'Read' },
  { value: 'billing', label: 'Billing' },
]

const options = ref<Permission[]>([...base])
const selected = ref<string[]>([])
const summary = ref(['read', 'billing'])
const locked = ref(['read'])

function onChange(values: (string | number)[], change?: MultiSelectorCreateChange): void {
  selected.value = values.map(String)
  if (change?.type !== 'create')
    return
  const query = change.query
  if (options.value.some(option => option.label.toLowerCase() === query.toLowerCase()))
    return
  options.value = [...options.value, { value: query, label: query }]
}
</script>

<template>
  <div style="width: 320px; max-width: 100%">
    <VStack :gap="6">
      <MultiSelector
        :model-value="selected"
        label="Permissions"
        :options="options"
        searchable
        has-create
        @change="onChange"
      />
      <MultiSelector
        v-model="summary"
        label="Summary"
        :options="base"
        trigger-display="labels"
        :format-value="items => items.map(item => item.label).join(' & ')"
      />
      <MultiSelector v-model="locked" label="Locked" :options="base" is-read-only />
    </VStack>
  </div>
</template>
```

## MultiSelector Props

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `modelValue` | `(string \| number)[]` | `[]` | Selected values. |
| `options` | `MultiSelectorOption[]` | Required | Strings, option objects, dividers, or sections. |
| `label` | `string` | Required | Field label. |
| `isLabelHidden` | `boolean` | `false` | Hides the label visually while keeping it accessible. `labelHidden` is the older name; either one hides it. |
| `placeholder` | `string` | `Select…` / `请选择` | Placeholder when nothing is selected. |
| `searchable` | `boolean` | `false` | Show a search field in the menu. |
| `selectAll` | `boolean` | `false` | Show a select-all row. Disabled options are skipped. |
| `triggerDisplay` | `'count' \| 'labels' \| 'badges'` | `'count'` | How the closed trigger shows the selection. |
| `maxBadges` | `number` | `3` | How many badges or labels stay visible before `+N`. Width can collapse them further. |
| `clearable` | `boolean` | `false` | Show a clear button. |
| `hasClear` | `boolean` | `false` | Shows a clear button when values are selected. `clearable` is the older name; either one turns it on. |
| `checkboxPosition` | `'start' \| 'end'` | `'start'` | Where the check mark sits. |
| `variant` | `'input' \| 'secondary' \| 'ghost'` | `'input'` | `input` has a border. `secondary` matches a secondary button. `ghost` fits a toolbar. On the latter two, an attached status becomes detached. |
| `containerClass` / `controlClass` | `class` | — | Shell and trigger box. A root class lands on the box. An option `action` latches a two-column grid and does not change the selection. |
| `disabled` | `boolean` | `false` | Disable the field. |
| `disabledMessage` | `string` | — | Why the field is disabled. With `disabled`, hovering or keyboard focus shows a tooltip and the trigger stays focusable through `aria-disabled`; it still cannot open the list. Do not wrap a disabled selector in a tooltip: it would swallow the hover. `disabledReason` is the older name. |
| `disabledReason` | `string` | — | Reason for the disabled state. The control stays focusable. Same as `disabledMessage`. |
| `isDefaultOpen` | `boolean` | `false` | Starts with the list open on mount. |
| `status` | `'default' \| 'success' \| 'warning' \| 'error'` | `'default'` | Status style. |
| `statusMessage` | `string` | — | Status text under the field. |
| `statusVariant` | `'attached' \| 'detached' \| 'tooltip'` | `'attached'` | Where the status message sits. Ghost detaches an attached message. Tooltip keeps it on the icon inside the field. |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Size. |
| `name` | `string` | — | Form field name. Each selected value is submitted once. |
| `isReadOnly` | `boolean` | `false` | Read-only. The selection stays, can be focused and submitted, and cannot be opened or cleared. It does not dim. |
| `isLoading` | `boolean` | `false` | Shows a spinner in the trigger. The create row and the empty state stay hidden. |
| `formatValue` | `(items) => string` | — | Custom closed text. Used by count and labels only. Badges ignore it. An empty selection does not call it. |
| `hasCreate` | `boolean` | `false` | Use with search. When the text matches no label, ignoring case, the first row creates it. |
| `handleRef` | `Ref \| (handle) => void` | — | Open, close, or ask whether the menu is open. Pass a function from a template. Open does nothing when read-only or disabled. |
| `emptyText` | `string` | No matches / 无匹配项 | Shown when the list itself has no options. |
| `emptySearchText` | `string` | No results found / 未找到结果 | Shown when a search matches nothing. |

## MultiSelector Emits

| Name | Parameters | Description |
| --- | --- | --- |
| `update:modelValue` | `value: (string \| number)[]` | Emitted when the selection changes. |
| `change` | `value, change?` | Emitted when the selection changes. A create also passes `{ type: 'create', query }`. |
| `search` | `query: string` | Emitted when the search text changes. |
| `openChange` | `isOpen: boolean` | Emitted when the menu opens or closes. |
| `click` | `event: MouseEvent` | Emitted for a click on the trigger, after the menu toggles. It cannot cancel the toggle. |
| `focus` | `event: FocusEvent` | Emitted when the trigger takes focus. |
| `keydown` | `event: KeyboardEvent` | Emitted for keys on the trigger that the built-in keyboard model leaves alone. Keys it handles and cancels are not emitted. |

## MultiSelector Slots

| Name | Parameters | Description |
| --- | --- | --- |
| `icon` | — | Icon at the start of the trigger. |
| `option` | `{ option, selected, active }` | Custom option. |
| `empty` | — | Custom empty state. |
| `trigger` | `{ id, onClick, onKeyDown, onFocus, aria-haspopup, aria-expanded, aria-controls, aria-busy, triggerRef }` | Replaces the whole field and trigger with your own control (a glyph in a row, a chip, an icon button). Spread the bindings onto it and pass `triggerRef` as `:ref` so the list anchors to it. The field chrome (label, status, clear, spinner) is not rendered. |
