Frappe UIFrappe UI

MultiSelect

Searchable multi-choice picker. Matches the Combobox / Select item-slot model and provides built-in Clear All / Select All footer controls.

Playground

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

Default

A plain picker — button trigger opens a popover with a search input, option list, and default footer.

vue
<script setup lang="ts">
import { ref } from 'vue'
import { 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"
    placeholder="Select fruit"
    class="w-64"
  />
</template>

Item Prefix

Use #item-prefix to render avatars, icons, or indicators next to each option label.

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

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

const img =
  'https://images.unsplash.com/photo-1502741338009-cac2772e18bc?w=100&h=100&fit=crop'

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

<template>
  <MultiSelect
    v-model="state"
    :options="options"
    placeholder="Select fruit"
    class="w-64"
  >
    <template #item-prefix="{ item }">
      <Avatar :image="(item as any).img" size="sm" />
    </template>
  </MultiSelect>
</template>

Members

Use #prefix to render an aggregate visual across the current selection — here, a stack of avatars capped at three with a "+N" overflow badge. When #prefix is provided it owns the entire prefix area regardless of selection count, so the same template handles 0 / 1 / many.

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>

Grouped Options

Options can be split into named groups. Group labels render above each group's items.

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

const state = ref<string[]>(['platform-infra'])

const options = [
  {
    group: 'Engineering',
    options: [
      { label: 'Platform Infra', value: 'platform-infra' },
      { label: 'Mobile 2.0', value: 'mobile-2' },
      { label: 'Growth', value: 'growth' },
    ],
  },
  {
    group: 'Product',
    options: [
      { label: 'Discovery', value: 'discovery' },
      { label: 'Roadmap', value: 'roadmap' },
      { label: 'Feedback', value: 'feedback' },
    ],
  },
  {
    group: 'Design',
    options: [
      { label: 'System', value: 'system' },
      { label: 'Research', value: 'research' },
      { label: 'Brand', value: 'brand' },
    ],
  },
]
</script>

<template>
  <MultiSelect
    v-model="state"
    :options="options"
    placeholder="Select spaces"
    class="w-72"
  />
</template>

Trigger Summary

The trigger reads "N selected" past one selection. Use #summary to render the label region yourself — joined labels, a count with a unit, or anything else. The slot receives the default text as summary, so it doubles as a fallback for the empty state.

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>

Search Prefix and Suffix

Use #search-prefix and #search-suffix to add content around the popover's search input without changing the trigger slots. Both slots receive { query, setQuery, disabled, focus }setQuery('') clears the query and focus() moves focus back to the search input. They render inside the search row, so they disappear when hide-search is set.

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

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

const options = [
  { label: 'Apple', value: 'apple' },
  { label: 'Banana', value: 'banana' },
  { label: 'Blueberry', value: 'blueberry' },
  { label: 'Cherry', value: 'cherry' },
  { label: 'Grape', value: 'grape' },
  { label: 'Orange', value: 'orange' },
]

function clearSearch(setQuery: (value: string) => void, focus: () => void) {
  setQuery('')
  focus()
}
</script>

<template>
  <MultiSelect
    v-model="value"
    :options="options"
    placeholder="Select fruits"
    class="w-72"
  >
    <template #search-prefix>
      <span class="lucide-search size-4 shrink-0 text-ink-gray-5" />
    </template>

    <template #search-suffix="{ query, setQuery, focus }">
      <button
        v-if="query"
        type="button"
        aria-label="Clear search"
        class="grid size-5 shrink-0 place-items-center rounded-4 text-ink-gray-5 hover:bg-surface-gray-2 hover:text-ink-gray-8"
        @pointerdown.prevent
        @click="clearSearch(setQuery, focus)"
      >
        <span class="lucide-x size-3.5" />
      </button>
      <kbd
        v-else
        class="shrink-0 flex items-center justify-center rounded-1 size-5 border border-outline-gray-2 bg-surface-gray-1 text-p-xs text-ink-gray-5"
      >
        F
      </kbd>
    </template>
  </MultiSelect>
</template>

Fetch options from a server as the user types. Bind v-model:query, debounce the request, and feed the results back into :options. The :loading prop swaps the result body for a loading state. Four things to watch for: pass :filterable="false" so the client doesn't substring-filter what the server already matched, drop stale responses with a request id so a slower earlier query can't overwrite the latest results, merge currently-selected items into the options array so chips stay resolvable after the query narrows the list, and clear the query yourself when the popover opens — see the note below on who owns it.

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>

Reading the Selected Options

@update:modelValue gives the selected values. @update:selectedOptions fires alongside it with the original option objects out of :options, so custom fields on your options survive — use it instead of resolving values back to objects yourself.

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

Replace the default Clear All / Select All footer with a custom one. The slot receives the shared control props (open, disabled, query, selectedOptions, clear, setOpen) plus selectAll.

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>

Custom Trigger

Use #trigger to fully replace the default button trigger. The slot receives open, disabled, query, selectedOptions, clear, and setOpen.

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>

Tags Trigger

A chips-style trigger: each selected option renders as a removable Badge, with inline remove buttons. Authored through #trigger using selectedOptions and the parent's v-model.

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>

Label, Description, Error

MultiSelect supports label, description, error, and required directly — no FormControl wrapper needed. The error suppresses the description and wires aria-invalid + aria-errormessage onto the trigger.

Pick as many as you like.

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

const value = ref<string[]>([])
const required = ref(true)
const showError = ref(false)

const error = computed(() =>
  showError.value ? 'Pick at least one fruit.' : '',
)

const options = [
  { label: 'Apple', value: 'apple' },
  { label: 'Banana', value: 'banana' },
  { label: 'Cherry', value: 'cherry' },
  { label: 'Durian', value: 'durian' },
]
</script>

<template>
  <div class="flex gap-8 items-start">
    <MultiSelect
      v-model="value"
      :options="options"
      label="Favourite fruits"
      description="Pick as many as you like."
      :error="error"
      :required="required"
      placeholder="Pick some"
      class="w-72"
    />
    <div
      class="flex flex-col gap-2 items-start border-l border-outline-gray-2 pl-6"
    >
      <Checkbox v-model="required" label="required" />
      <Checkbox v-model="showError" label="show error" />
    </div>
  </div>
</template>

Template Ref

A template ref exposes { clear, focus } — the same shape as Select and Combobox. clear() empties the selection and leaves the search query alone; focus() moves focus to the trigger.

vue
<script setup lang="ts">
import { useTemplateRef } from 'vue'

const picker = useTemplateRef('picker')

function reset() {
  picker.value?.clear()
  picker.value?.focus()
}
</script>

<template>
  <MultiSelect ref="picker" v-model="value" :options="options" />
</template>

Notes

  • v-model:query is optional, but listening for @update:query already makes the query yours. There is no observe-only mode: an @update:query handler counts as binding it, so the component stops resetting the search box and the last committed text stays in it. If you listen, bind v-model:query too and clear it on @update:open. To read the query without owning it, use #search-prefix, #search-suffix, or #footer, which all hand it out.
  • Use #item-prefix, #item-label, and #item-suffix to customize the standard option row; reach for #item only when you need to replace the row shell too.
  • For a single choice, use Combobox when the list needs search and Select when it doesn't.

API Reference

Show types
typescript
import type { Component, VNodeChild } from 'vue'
import type { InputLabelingProps } from '../../composables/useInputLabeling'

export type MultiSelectVariant = 'subtle' | 'outline' | 'ghost'
export type MultiSelectSize = 'sm' | 'md' | 'lg' | 'xl'

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

  /** Replaces the results with a loading state. */
  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?: string | HTMLElement
}

/**
 * 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
}

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 {}

export interface MultiSelectEmits {
  /** Fired when the selection changes. */
  'update:modelValue': [value: Array<string | number>]

  /**
   * 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 open state changes. */
  'update:open': [value: boolean]

  /** Fired when the search query changes. */
  'update:query': [value: string]
}
modelValue
= []
(string | number)[]

Array of selected option values.

options
= []
MultiSelectOptions

Options rendered in the popover.

variant
= "subtle"
MultiSelectVariant

Visual style of the trigger.

size
= "sm"
MultiSelectSize

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

Replaces the results with a loading state.

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
string | HTMLElement

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
string | FrappeUIError

Error message rendered below the input. When set, the control receives `aria-invalid="true"` and `data-state="invalid"`. May be either a string or an `Error` object whose `messages?: string[]` is rendered as stacked lines (with `Error.message` as the fallback).

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 selection changes.

update:query
[value: string]

Fired when the search 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.