MultiSelect ​

A searchable list that picks several options, with Clear All and Select All buttons in its footer. To pick one option, use Combobox when the list needs search and Select when it does not.

label
size
variant
<MultiSelect
  label="Labels"
  placeholder="Select labels"
  :options="options"
  v-model="value"
/>

Examples ​

Assign reviewers ​

#prefix shows a stack of up to three avatars and a "+N" count for the rest. #summary lists the selected names.

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Avatar, MultiSelect } from 'frappe-ui'

type Member = {
  label: string
  value: string
  image: string
  role: string
}

const members: Member[] = [
  {
    label: 'Alex Rivera',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Engineering',
  },
  {
    label: 'Priya Shah',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Design',
  },
  {
    label: 'Marcus Lee',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Product',
  },
  {
    label: 'Sofia Hartmann',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Engineering',
  },
  {
    label: 'Kenji Tanaka',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Design',
  },
  {
    label: 'Nadia Okafor',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Product',
  },
]

const value = ref<string[]>(['[email protected]', '[email protected]'])

const MAX_AVATARS = 3

const visibleSelected = computed(() =>
  (
    value.value
      .map((v) => members.find((m) => m.value === v))
      .filter(Boolean) as Member[]
  ).slice(0, MAX_AVATARS),
)

const overflowCount = computed(() =>
  Math.max(0, value.value.length - MAX_AVATARS),
)
</script>

<template>
  <MultiSelect
    v-model="value"
    :options="members"
    placeholder="Assign reviewers…"
    class="w-80"
  >
    <template #prefix>
      <div v-if="visibleSelected.length" class="flex -space-x-1.5">
        <Avatar
          v-for="m in visibleSelected"
          :key="m.value"
          :image="m.image"
          :label="m.label"
          size="sm"
        />
        <span
          v-if="overflowCount > 0"
          class="z-10 grid size-5 place-items-center rounded-full bg-surface-gray-3 text-p-xs-medium text-ink-gray-7"
        >
          +{{ overflowCount }}
        </span>
      </div>
      <span v-else class="lucide-users size-4 text-ink-gray-5" />
    </template>

    <template #summary="{ selectedOptions, summary }">
      <template v-if="selectedOptions.length">
        {{ selectedOptions.map((o) => o.label).join(', ') }}
      </template>
      <template v-else>{{ summary }}</template>
    </template>

    <template #item-prefix="{ item }">
      <Avatar :image="(item as Member).image" :label="item.label" size="sm" />
    </template>

    <template #item-label="{ item }">
      <div class="min-w-0 flex justify-between">
        <div class="truncate">{{ item.label }}</div>
        <div class="truncate text-p-sm text-ink-gray-5">
          {{ (item as Member).role }}
        </div>
      </div>
    </template>
  </MultiSelect>
</template>

Search people on the server ​

Bind v-model:query, fetch as the query changes, and pass the results to options. See Server search for the four things to handle.

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { Avatar, MultiSelect } from 'frappe-ui'

type Member = {
  label: string
  value: string
  image: string
  role: string
}

const ALL_MEMBERS: Member[] = [
  {
    label: 'Alex Rivera',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Engineering',
  },
  {
    label: 'Alexandra Chen',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Design',
  },
  {
    label: 'Alexei Volkov',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Engineering',
  },
  {
    label: 'Priya Shah',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Design',
  },
  {
    label: 'Priyanka Mehta',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Product',
  },
  {
    label: 'Marcus Lee',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Product',
  },
  {
    label: 'Marco Silva',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Engineering',
  },
  {
    label: 'Maria Garcia',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Marketing',
  },
  {
    label: 'Sofia Hartmann',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Engineering',
  },
  {
    label: 'Sophie Laurent',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Sales',
  },
  {
    label: 'Kenji Tanaka',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Design',
  },
  {
    label: 'Kenta Mori',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Engineering',
  },
  {
    label: 'Nadia Okafor',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Product',
  },
  {
    label: 'Diego Alvarez',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Engineering',
  },
  {
    label: 'Lina Petrova',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Marketing',
  },
  {
    label: 'Liam Connor',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Product',
  },
  {
    label: 'Hassan Iqbal',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Sales',
  },
  {
    label: 'Ava Nguyen',
    value: '[email protected]',
    image: 'https://i.pravatar.cc/[email protected]',
    role: 'Engineering',
  },
]

// Mocks a server endpoint: 400ms latency + substring match on label/value.
// A real backend would rank and fuzzy-match, which is why the picker below
// passes `:filterable="false"` — a second client-side substring pass would
// silently drop anything the server matched but the query doesn't contain.
function searchMembersApi(query: string): Promise<Member[]> {
  return new Promise((resolve) => {
    setTimeout(() => {
      const q = query.trim().toLowerCase()
      const matches = q
        ? ALL_MEMBERS.filter(
            (m) =>
              m.label.toLowerCase().includes(q) ||
              m.value.toLowerCase().includes(q),
          )
        : ALL_MEMBERS
      resolve(matches.slice(0, 6))
    }, 400)
  })
}

const value = ref<string[]>([])
const query = ref('')
const results = ref<Member[]>([])
const loading = ref(false)
const knownById = ref(new Map<string, Member>())

let requestId = 0
async function fetchMembers(query: string) {
  const id = ++requestId
  loading.value = true
  const members = await searchMembersApi(query)
  // Drop stale responses so an earlier-but-slower request can't overwrite
  // the latest results.
  if (id !== requestId) return
  results.value = members
  for (const m of members) knownById.value.set(m.value, m)
  loading.value = false
}

const onQueryChange = useDebounceFn(fetchMembers, 250)

// Merge currently-selected members into the options so chips stay
// resolvable after the query narrows the result set.
const options = computed<Member[]>(() => {
  const byId = new Map<string, Member>()
  for (const m of results.value) byId.set(m.value, m)
  for (const id of value.value) {
    if (!byId.has(id)) {
      const existing = knownById.value.get(id)
      if (existing) byId.set(id, existing)
    }
  }
  return Array.from(byId.values())
})

function onOpen(isOpen: boolean) {
  if (!isOpen) return
  // Listening for `@update:query` hands ownership of the query over, so the
  // open-time reset MultiSelect does for an unbound query is ours to do.
  // Without it the last search text stays in the box.
  query.value = ''
  if (results.value.length === 0) fetchMembers('')
}
</script>

<template>
  <!-- The `as` casts on the two handlers below exist because these model
       emits are declared twice; drop them when #1096 removes the duplicates. -->
  <MultiSelect
    v-model="value"
    v-model:query="query"
    :options="options"
    :loading="loading"
    :filterable="false"
    placeholder="Search members…"
    empty-text="No members found"
    class="w-80"
    @update:query="(q) => onQueryChange(q as string)"
    @update:open="(isOpen) => onOpen(isOpen as boolean)"
  >
    <template #item-prefix="{ item }">
      <Avatar :image="(item as Member).image" :label="item.label" size="sm" />
    </template>

    <template #item-label="{ item }">
      <div class="min-w-0 flex justify-between">
        <div class="truncate">{{ item.label }}</div>
        <div class="truncate text-p-sm text-ink-gray-5">
          {{ (item as Member).role }}
        </div>
      </div>
    </template>
  </MultiSelect>
</template>

Permissions ​

With a short, fixed list, #summary can list every selected label in place of the default "2 selected".

vue
<script setup lang="ts">
import { ref } from 'vue'
import { MultiSelect } from 'frappe-ui'

const value = ref<string[]>(['read', 'write'])

const options = [
  { label: 'Read', value: 'read' },
  { label: 'Write', value: 'write' },
  { label: 'Delete', value: 'delete' },
  { label: 'Share', value: 'share' },
]

// The default summary reads "2 selected" past one selection. Joining the
// labels keeps a short, fixed list readable at a glance.
function labelsFor(values: string[]) {
  return options
    .filter((o) => values.includes(o.value))
    .map((o) => o.label)
    .join(', ')
}
</script>

<template>
  <MultiSelect
    v-model="value"
    :options="options"
    placeholder="Select permissions"
    class="w-72"
  >
    <template #summary="{ summary }">
      {{ value.length ? labelsFor(value) : summary }}
    </template>
  </MultiSelect>
</template>

Issue labels ​

#trigger shows each selected option as a Badge with a remove button.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Badge, MultiSelect, type BadgeProps } from 'frappe-ui'

type Tag = {
  label: string
  value: string
  // Derived from Badge, never hand-written. A copied union is how the
  // deprecated `orange` theme outlived the ADR-0008 sweep: nothing failed when
  // the real union changed. Note `tsconfig.app.json` excludes `stories/**`, so
  // this does not fail CI today — it fails in the editor, and it fails the
  // moment stories join the type-check program.
  theme: BadgeProps['theme']
}

const tags = ref<string[]>(['bug', 'p0'])

const tagOptions: Tag[] = [
  { label: 'Bug', value: 'bug', theme: 'red' },
  { label: 'Feature', value: 'feature', theme: 'blue' },
  { label: 'Enhancement', value: 'enhancement', theme: 'green' },
  { label: 'P0', value: 'p0', theme: 'red' },
  { label: 'P1', value: 'p1', theme: 'amber' },
  { label: 'P2', value: 'p2', theme: 'gray' },
  { label: 'Frontend', value: 'frontend', theme: 'blue' },
  { label: 'Backend', value: 'backend', theme: 'gray' },
  { label: 'Docs', value: 'docs', theme: 'green' },
]

function removeTag(value: string | number) {
  tags.value = tags.value.filter((v) => v !== value)
}
</script>

<template>
  <MultiSelect v-model="tags" :options="tagOptions">
    <template #trigger="{ open, selectedOptions, setOpen }">
      <button
        type="button"
        :data-state="open ? 'open' : 'closed'"
        class="flex w-96 min-h-8 cursor-pointer items-center gap-1.5 rounded-4 border border-[--surface-gray-2] px-1.5 py-1 text-left transition-colors hover:border-outline-elevation-2 data-[state=open]:focus-ring"
        @click="setOpen(!open)"
      >
        <div class="flex min-w-0 flex-1 flex-wrap items-center gap-1">
          <Badge
            v-for="option in selectedOptions"
            :key="option.value"
            :theme="(option as Tag).theme"
            size="md"
          >
            {{ option.label }}
            <template #suffix>
              <span
                role="button"
                tabindex="-1"
                class="-mr-0.5 inline-flex cursor-pointer items-center justify-center rounded-1 p-0.5 opacity-70 hover:opacity-100"
                @click.stop="removeTag(option.value)"
                @pointerdown.stop
              >
                <span class="lucide-x size-3" />
              </span>
            </template>
          </Badge>

          <span
            v-if="!selectedOptions.length"
            class="px-1 text-base text-ink-gray-4"
          >
            Add tags…
          </span>
        </div>

        <span
          :class="[
            'lucide-chevron-down size-4 shrink-0 text-ink-gray-4 transition-transform',
            open && 'rotate-180',
          ]"
        />
      </button>
    </template>
  </MultiSelect>
</template>

Assign button in a toolbar ​

#trigger replaces the default trigger with a Button that shows a count.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Button, MultiSelect } from 'frappe-ui'

const state = ref<string[]>(['alice'])

const options = [
  { label: 'Alice Rivera', value: 'alice' },
  { label: 'Bao Nguyen', value: 'bao' },
  { label: 'Chen Wei', value: 'chen' },
  { label: 'Diego Ruiz', value: 'diego' },
  { label: 'Elena Park', value: 'elena' },
]
</script>

<template>
  <MultiSelect v-model="state" :options="options" placeholder="Assign to">
    <template #trigger="{ selectedOptions, open }">
      <Button icon-left="lucide-users">
        {{
          selectedOptions.length
            ? `${selectedOptions.length} assigned`
            : 'Assign to'
        }}
        <template #suffix>
          <span
            :class="[
              'lucide-chevron-down size-4 transition-transform',
              open && 'rotate-180',
            ]"
          />
        </template>
      </Button>
    </template>
  </MultiSelect>
</template>

#footer replaces the default Clear All and Select All footer. It receives selectAll along with the other control props.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { Button, MultiSelect } from 'frappe-ui'

const state = ref<string[]>([])

const options = [
  { value: 'red-apple', label: 'Red Apple' },
  { value: 'blueberry-burst', label: 'Blueberry Burst' },
  { value: 'orange-grove', label: 'Orange Grove' },
  { value: 'banana-split', label: 'Banana Split' },
  { value: 'grapes-cluster', label: 'Grapes Cluster' },
  { value: 'kiwi-slice', label: 'Kiwi Slice' },
  { value: 'mango-fusion', label: 'Mango Fusion' },
]
</script>

<template>
  <MultiSelect v-model="state" :options="options" class="w-64">
    <template #footer="{ clear, selectAll, selectedOptions }">
      <div
        class="flex items-center justify-between gap-2 border-t border-outline-gray-1 px-2 py-1.5"
      >
        <Button theme="red" variant="ghost" @click="clear">
          <template #prefix>
            <span class="lucide-trash-2 size-4" />
          </template>
          Clear ({{ selectedOptions.length }})
        </Button>

        <Button variant="ghost" @click="selectAll">
          <template #prefix>
            <span class="lucide-check-check size-4" />
          </template>
          Select All
        </Button>
      </div>
    </template>
  </MultiSelect>
</template>

Behavior ​

Options ​

options takes { label, value } objects or groups of { group, options }. A group label shows above its options. An option is selected when its value is in the model.

Selected options ​

@update:modelValue gives the selected values. @update:selectedOptions fires with it and gives the matching option objects from options, custom fields included. Use it instead of looking the objects up yourself.

vue
<MultiSelect
  v-model="value"
  :options="members"
  @update:selectedOptions="(options) => (emails = options.map((o) => o.email))"
/>

When nothing is selected, the value is [].

Trigger summary ​

With one option selected, the trigger shows that option's label. With two or more, it reads "N selected". #summary replaces that text. It receives the default text as summary, so you can fall back to it, for example when nothing is selected.

Prefix ​

When you pass #prefix, it fills the whole prefix area for any number of selected options, including none.

To load options from a server:

  1. Pass :filterable="false", so the list does not filter again what the server already matched.
  2. Drop old responses, for example with a request id, so a slow earlier request cannot overwrite newer results.
  3. Keep the selected options in options, so they can still be shown after the results change.
  4. Clear the query yourself when the popover opens. See Search query.

While loading is set, a spinner shows in the search row. The options you passed stay visible and can still be picked, so people can keep choosing from the last results. With hide-search there is no search row, so a loading row replaces the results. In both cases the "No results" text is hidden while loading.

Search query ​

v-model:query is optional. Listening for @update:query counts as binding it: there is no way to only watch the query. Once you bind it, the component stops clearing the search box, so the last text stays in it. If you listen, also bind v-model:query and clear it on @update:open.

To read the query without taking control of it, use #search-prefix, #search-suffix or #footer. Each receives the query.

Search row ​

#search-prefix and #search-suffix add content around the search input. Both receive { query, setQuery, disabled, focus }. setQuery('') clears the query, and focus() moves focus back to the search input. The slots are inside the search row, so hide-search removes them too.

Item slots ​

#item-prefix, #item-label and #item-suffix change parts of the standard row. #item replaces the whole row, including its outer element.

Trigger slots ​

#trigger replaces the default button. It receives open, disabled, query, selectedOptions, clear, setOpen and close. #footer receives the same props and selectAll. close() is the same as setOpen(false).

Label, description and error ​

label renders above the trigger and description below it. error renders below the trigger and hides description. It takes a string, an array of strings (one line each), or an Error, the same values as ErrorMessage. An empty string or an empty array means no error. required adds a red asterisk to the label.

The #label slot replaces the label text and the required marker, and receives { required }. A #description slot is not hidden by error. It renders above the error.

Accessibility ​

While error is set, the trigger gets aria-invalid and an aria-errormessage that points to the error text.

API Reference ​

Show types
typescript
import type { Component, VNodeChild } from 'vue'
import type { InputSize, InputVariant } from '../../composables/inputTypes'
import type { InputLabelingProps } from '../../composables/useInputLabeling'
import type { PortalTarget } from '../../composables/usePortalTarget'

/** The input scales under MultiSelect's own names. See `ComboboxSize`. */
export type MultiSelectVariant = InputVariant
export type MultiSelectSize = InputSize

import type { PopoverSide, PopoverAlign } from '../shared/selection/types'
export type { PopoverSide, PopoverAlign }

export type MultiSelectSlotFn<TProps> = (props: TProps) => VNodeChild

export interface MultiSelectItemSlots<TProps> {
  /** Replaces the prefix region of the standard row shell. */
  prefix?: MultiSelectSlotFn<TProps>

  /** Replaces the label region of the standard row shell. */
  label?: MultiSelectSlotFn<TProps>

  /** Replaces the suffix region of the standard row shell. */
  suffix?: MultiSelectSlotFn<TProps>

  /** Replaces the entire row; mutually exclusive with `prefix` / `label` / `suffix`. */
  item?: MultiSelectSlotFn<TProps>
}

export interface MultiSelectOption {
  label: string
  value: string | number
  icon?: string | Component
  description?: string
  disabled?: boolean
  /**
   * Dispatches this row to the `#item-<slot>` template slot, e.g.
   * `slot: 'member'` renders `#item-member` in the row's label region.
   */
  slot?: string
  /** Per-item inline slot implementations for the row shell. */
  slots?: MultiSelectItemSlots<MultiSelectItemSlotProps>
  [key: string]: any
}

export interface MultiSelectGroupedOption {
  key?: string | number
  group: string
  hideLabel?: boolean
  options: MultiSelectOption[]
}

export type MultiSelectOptions = Array<
  MultiSelectOption | MultiSelectGroupedOption
>

export interface MultiSelectProps extends InputLabelingProps {
  /** Array of selected option values. */
  modelValue?: Array<string | number>

  /** Options rendered in the popover. */
  options?: MultiSelectOptions

  /** Visual style of the trigger. */
  variant?: MultiSelectVariant

  /** Size of the trigger and option rows. */
  size?: MultiSelectSize

  /** Placeholder text shown when no value is selected. */
  placeholder?: string

  /** Disables the multi-select. */
  disabled?: boolean

  /** Controls the popover visibility. */
  open?: boolean

  /**
   * Controls the in-popover search query. Optional — the component owns the
   * query when this is not bound, so `v-model:query` is never required.
   *
   * When it is bound the consumer owns the query: the component never resets
   * it on its own — not on open, not on close, not on mount, not on `clear()`.
   * Only typing (or the `setQuery` slot prop) changes it, and a seeded query
   * filters the list immediately. Unbound, the query still clears every time
   * the popover opens.
   */
  query?: string

  /** Hides the in-popover search input. */
  hideSearch?: boolean

  /**
   * Marks a fetch as in flight. With the search row showing, a spinner appears
   * in it and the options you passed stay selectable; under `hide-search` a
   * loading row replaces the results instead.
   */
  loading?: boolean

  /**
   * Client-side query filtering. Defaults to `true`. Set to `false` for
   * pickers whose options come from a server search — the backend already
   * decided what matches, and a second literal substring pass on the client
   * silently drops fuzzy, ranked, or id-based results. This turns off query
   * filtering only; nothing else about the component changes.
   */
  filterable?: boolean

  /** Fallback empty-state copy. */
  emptyText?: string

  /** Preferred popover side. */
  side?: PopoverSide

  /** Preferred popover alignment. */
  align?: PopoverAlign

  /** Gap between trigger and content. */
  offset?: number

  /** Teleport target for the popover content. Unset, an embedding host's target is used, else `body`. */
  portalTo?: PortalTarget
}

/**
 * Shared shape for `#trigger`, `#prefix`, `#suffix`, `#footer` (with an added
 * `selectAll`), and `#summary` (with an added `summary`). The imperative
 * helpers `clear` and `setOpen` are exposed on every slot so consumers don't
 * need to hoist into `#trigger` just to clear the selection.
 */
export interface MultiSelectSlotProps {
  /** Whether the popover is open. */
  open: boolean

  /** Whether the multi-select is disabled. */
  disabled: boolean

  /** Current search query — empty when the user hasn't typed since opening. */
  query: string

  /** Resolved option objects for the selected values, in `modelValue` order. */
  selectedOptions: MultiSelectOption[]

  /** Clears all selected values. It leaves the search query alone. */
  clear: () => void

  /** Sets the popover open state. */
  setOpen: (value: boolean) => void
  /** Closes the popover. Equivalent to `setOpen(false)`. */
  close: () => void
}

export type MultiSelectTriggerSlotProps = MultiSelectSlotProps
export type MultiSelectPrefixSlotProps = MultiSelectSlotProps
export type MultiSelectSuffixSlotProps = MultiSelectSlotProps

/**
 * Props for `#search-prefix` and `#search-suffix`.
 *
 * Both slots render inside the search row, which only exists while
 * `hide-search` is not set — they disappear silently when it is.
 */
export interface MultiSelectSearchSlotProps {
  /** Current search query — empty when the user hasn't typed since opening. */
  query: string

  /** Updates the search query and emits `update:query`. Pass `''` to clear. */
  setQuery: (value: string) => void

  /** Whether the multi-select is disabled. */
  disabled: boolean

  /** Moves focus to the search input. */
  focus: (options?: FocusOptions) => void
}

/**
 * `#footer` gets the shared control shape plus one addition, so it is named
 * for the same reason `MultiSelectSummarySlotProps` is: a consumer annotating
 * a footer handler needs something to import.
 */
export interface MultiSelectFooterSlotProps extends MultiSelectSlotProps {
  /** Selects every enabled option across all groups. */
  selectAll: () => void
}

export interface MultiSelectSummarySlotProps extends MultiSelectSlotProps {
  /** Default label text the trigger would render (e.g. placeholder,
   * single selected label, or `"N selected"`). Use it as a fallback. */
  summary: string
}

export interface MultiSelectItemSlotProps {
  /** Item currently being rendered. */
  item: MultiSelectOption

  /** Current search query — empty when the user hasn't typed since opening. */
  query: string

  /** Whether the item is in `modelValue`. */
  selected: boolean
}

export interface MultiSelectGroupLabelSlotProps {
  /** Group currently being rendered. */
  group: MultiSelectGroupedOption
}

export interface MultiSelectEmptySlotProps {
  /** Current search query — empty when the user hasn't typed since opening. */
  query: string
}

/**
 * Fixed slot names. Kept separate from `MultiSelectSlots` so the dynamic
 * `` `item-${string}` `` index signature can be intersected in without
 * constraining names that don't match the pattern.
 */
interface MultiSelectFixedSlots {
  /** Fully custom trigger renderer. */
  trigger?: (props: MultiSelectTriggerSlotProps) => any

  /**
   * Content rendered before the trigger label. When provided, this slot
   * owns the entire prefix area regardless of selection count — useful
   * for aggregate visuals like stacked avatars. If omitted, the trigger
   * auto-renders the selected option's `#item-prefix` / `icon` when
   * exactly one is selected, and nothing otherwise.
   */
  prefix?: (props: MultiSelectPrefixSlotProps) => any

  /**
   * Overrides the trigger label region. Receives the default summary
   * text as `summary` — use it as a fallback. Useful when you want to
   * show comma-separated labels (or any other format) instead of the
   * default `"N selected"` for multi-selection states.
   */
  summary?: (props: MultiSelectSummarySlotProps) => any

  /**
   * Content rendered after the trigger label. Providing this slot
   * **replaces the default chevron** — render your own fallback when
   * your slot content is conditional. Use `@click.stop` and
   * `@pointerdown.stop` so the press doesn't toggle the popover.
   */
  suffix?: (props: MultiSelectSuffixSlotProps) => any

  /** Overrides the rendered label content. Receives `{ required }`. */
  label?: (props: { required: boolean }) => any

  /** Overrides the rendered description content. */
  description?: () => any

  /**
   * Content rendered before the in-popover search input. Renders inside the
   * search row, so it is not rendered at all when `hide-search` is set.
   */
  'search-prefix'?: (props: MultiSelectSearchSlotProps) => any

  /**
   * Content rendered after the in-popover search input and loading
   * indicator. Renders inside the search row, so it is not rendered at all
   * when `hide-search` is set.
   */
  'search-suffix'?: (props: MultiSelectSearchSlotProps) => any

  /** Custom renderer for group labels. */
  'group-label'?: (props: MultiSelectGroupLabelSlotProps) => any

  /** Fallback content rendered when there are no results. */
  empty?: (props: MultiSelectEmptySlotProps) => any

  /**
   * Replaces the default Clear All / Select All footer. Receives the shared
   * control slot props plus `selectAll`.
   */
  footer?: (props: MultiSelectFooterSlotProps) => any

  /** Replaces the entire row. */
  item?: (props: MultiSelectItemSlotProps) => any
}

/**
 * Item slot names: the three fixed regions of the row shell, the whole-row
 * takeover, and any `#item-<name>` dispatched from an option's `slot` field.
 *
 * The index signature is deliberately narrowed to `` `item-${string}` `` —
 * `MultiSelectResults` resolves `option.slot` to `` `item-${option.slot}` ``,
 * so this is exactly the runtime behavior, and every fixed slot name stays
 * typechecked instead of every typo compiling clean.
 */
interface MultiSelectItemSlotsByName {
  /** Shared content rendered before the standard row label. */
  'item-prefix'?: (props: MultiSelectItemSlotProps) => any

  /** Shared content rendered for the standard row label area. */
  'item-label'?: (props: MultiSelectItemSlotProps) => any

  /** Shared content rendered after the standard row label area. */
  'item-suffix'?: (props: MultiSelectItemSlotProps) => any

  /** Per-option dynamic slot, selected by the option's `slot` field. */
  [slotName: `item-${string}`]:
    | ((props: MultiSelectItemSlotProps) => any)
    | undefined
}

export interface MultiSelectSlots
  extends MultiSelectFixedSlots, MultiSelectItemSlotsByName {}

/**
 * The events the component declares itself. `update:modelValue`,
 * `update:open` and `update:query` are not here: `defineModel` declares those,
 * and listing them again published one event twice with two payload types
 * that could drift apart.
 */
export interface MultiSelectEmits {
  /**
   * Fired alongside `update:modelValue` with the original option objects
   * resolved out of `options`, so custom fields on an option survive.
   */
  'update:selectedOptions': [value: MultiSelectOption[]]

  /**
   * Fired when the highlighted row changes, by arrow keys or the pointer, with
   * the original option object. `null` once the popover closes.
   */
  highlight: [option: MultiSelectOption | null]
}
modelValue
= []
(string | number)[]

Array of selected option values.

options
= []
MultiSelectOptions

Options rendered in the popover.

variant
= "subtle"
InputVariant

Visual style of the trigger.

size
= "sm"
InputSize

Size of the trigger and option rows.

placeholder
= "Select option"
string

Placeholder text shown when no value is selected.

disabled
= false
boolean

Disables the multi-select.

open
= false
boolean

Controls the popover visibility.

query
= ""
string

Controls the in-popover search query. Optional — the component owns the query when this is not bound, so `v-model:query` is never required. When it is bound the consumer owns the query: the component never resets it on its own — not on open, not on close, not on mount, not on `clear()`. Only typing (or the `setQuery` slot prop) changes it, and a seeded query filters the list immediately. Unbound, the query still clears every time the popover opens.

hideSearch
= false
boolean

Hides the in-popover search input.

loading
= false
boolean

Marks a fetch as in flight. With the search row showing, a spinner appears in it and the options you passed stay selectable; under `hide-search` a loading row replaces the results instead.

filterable
= true
boolean

Client-side query filtering. Defaults to `true`. Set to `false` for pickers whose options come from a server search — the backend already decided what matches, and a second literal substring pass on the client silently drops fuzzy, ranked, or id-based results. This turns off query filtering only; nothing else about the component changes.

emptyText
= "No results"
string

Fallback empty-state copy.

side
= "bottom"
PopoverSide

Preferred popover side.

align
= "start"
PopoverAlign

Preferred popover alignment.

offset
= 4
number

Gap between trigger and content.

portalTo
PortalTarget

Teleport target for the popover content. Unset, an embedding host's target is used, else `body`.

label
string

Label rendered above (or beside, for binary controls) the input.

description
string

Helper text rendered below the input. Hidden when `error` is set. A `#description` slot is not: it renders beside the error, and is referenced alongside it.

error
ErrorMessageValue

Error message rendered below the input. When set, the control receives `aria-invalid="true"` and `data-state="invalid"`. Takes a string, an array of strings, or an `Error` whose `messages` are rendered as stacked lines (with `Error.message` as the fallback). This is the same value `ErrorMessage.message` takes. An empty array and an empty string both mean no error.

required
boolean

Marks the field as required. Renders an asterisk next to the label, with `sr-only` text that announces it, and forwards `required` / `aria-required` to the underlying control where the control's role allows it. `data-required` is set either way.

id
string

HTML id of the underlying control. Auto-generated via `useId()` if omitted.

trigger
MultiSelectSlotProps

Fully custom trigger renderer.

prefix
MultiSelectSlotProps

Content rendered before the trigger label. When provided, this slot owns the entire prefix area regardless of selection count — useful for aggregate visuals like stacked avatars. If omitted, the trigger auto-renders the selected option's `#item-prefix` / `icon` when exactly one is selected, and nothing otherwise.

summary
MultiSelectSummarySlotProps

Overrides the trigger label region. Receives the default summary text as `summary` — use it as a fallback. Useful when you want to show comma-separated labels (or any other format) instead of the default `"N selected"` for multi-selection states.

suffix
MultiSelectSlotProps

Content rendered after the trigger label. Providing this slot **replaces the default chevron** — render your own fallback when your slot content is conditional. Use `@click.stop` and `@pointerdown.stop` so the press doesn't toggle the popover.

label
{ required: boolean; }

Overrides the rendered label content. Receives `{ required }`.

description
—

Overrides the rendered description content.

search-prefix
MultiSelectSearchSlotProps

Content rendered before the in-popover search input. Renders inside the search row, so it is not rendered at all when `hide-search` is set.

search-suffix
MultiSelectSearchSlotProps

Content rendered after the in-popover search input and loading indicator. Renders inside the search row, so it is not rendered at all when `hide-search` is set.

group-label
MultiSelectGroupLabelSlotProps

Custom renderer for group labels.

empty
MultiSelectEmptySlotProps

Fallback content rendered when there are no results.

footer
MultiSelectFooterSlotProps

Replaces the default Clear All / Select All footer. Receives the shared control slot props plus `selectAll`.

item
MultiSelectItemSlotProps

Replaces the entire row.

item-prefix
MultiSelectItemSlotProps

Shared content rendered before the standard row label.

item-label
MultiSelectItemSlotProps

Shared content rendered for the standard row label area.

item-suffix
MultiSelectItemSlotProps

Shared content rendered after the standard row label area.

update:open
[value: boolean]

Fired when the open state changes.

update:modelValue
[value: (string | number)[]]

Fired when the model value changes.

update:query
[value: string]

Fired when the query changes.

update:selectedOptions
[value: MultiSelectOption[]]

Fired alongside `update:modelValue` with the original option objects resolved out of `options`, so custom fields on an option survive.

highlight
[option: MultiSelectOption | null]

Fired when the highlighted row changes, by arrow keys or the pointer, with the original option object. `null` once the popover closes.

clear
() => void

Clears the current selection. It leaves the search query alone.

focus
(options?: FocusOptions) => void

Moves focus to the trigger.