Status: accepted direction for frappe-ui v1 planning.
This document defines the exact public API for MultiSelect. It is a sub-spec
of selection.md
and inherits the shared design rules from that document.
MultiSelect is the canonical searchable multi-choice picker.
It should stay narrower than a full people-picker or chips input, but it
should inherit the same item-slot model as Combobox and Select.
Use MultiSelect when the UI needs:
- multiple simultaneously selected values from a list
- in-popover search over those values
- clear-all / select-all affordances in the footer
If the UI needs chips in the trigger, avatars everywhere, grouped async remote
results, custom selected-summary behavior, create-new actions, or
person-specific affordances all at once, that combination may justify a
separate future component such as MultiCombobox or PeoplePicker.
type MultiSelectVariant = 'subtle' | 'outline' | 'ghost'
type MultiSelectSize = 'sm' | 'md' | 'lg' | 'xl'
type PopoverSide = 'top' | 'right' | 'bottom' | 'left'
type PopoverAlign = 'start' | 'center' | 'end'
type SlotFn<TProps> = (props: TProps) => VNodeChild
interface ItemSlots<TProps> {
prefix?: SlotFn<TProps>
label?: SlotFn<TProps>
suffix?: SlotFn<TProps>
/** Full-row replacement; mutually exclusive with prefix/label/suffix */
item?: SlotFn<TProps>
}
interface MultiSelectOption {
label: string
value: string
icon?: string | Component
description?: string
disabled?: boolean
slot?: string
slots?: ItemSlots<MultiSelectItemSlotProps>
[key: string]: any
}
interface MultiSelectGroupedOption {
key?: string | number
group: string
hideLabel?: boolean
options: MultiSelectOption[]
}
type MultiSelectOptions = Array<MultiSelectOption | MultiSelectGroupedOption>interface MultiSelectProps {
modelValue?: string[]
options?: MultiSelectOptions
variant?: MultiSelectVariant
size?: MultiSelectSize
placeholder?: string
disabled?: boolean
id?: string
open?: boolean
hideSearch?: boolean
loading?: boolean
emptyText?: string
side?: PopoverSide
align?: PopoverAlign
offset?: number
portalTo?: string | HTMLElement
compareFn?: (a: MultiSelectOption, b: MultiSelectOption) => boolean
}Defaults:
modelValue = []options = []variant = 'subtle'size = 'sm'placeholder = 'Select option'disabled = falseopen = falsehideSearch = falseloading = falseemptyText = 'No results'side = 'bottom'align = 'start'offset = 4portalTo = 'body'
State conventions:
- selected values use
v-model/modelValue(array of option values) - menu visibility uses
v-model:open - query state stays internal;
MultiSelectemitsupdate:querybut does not accept av-model:queryin v1 compareFnoverrides the default===value equality used to decide which options are selected; it is invoked with full option objects
Positioning follows the shared popover positioning conventions in
selection.md.
side, align, offset, and portalTo did not exist in previous versions
of MultiSelect, so their addition is purely additive.
interface MultiSelectEmits {
'update:modelValue': [value: string[]]
'update:open': [value: boolean]
'update:query': [value: string]
}Emit rules:
update:modelValuefires with the new array whenever the selection changes (add, remove, clear-all, select-all)update:openfires on open/close transitions driven by user interactionupdate:queryfires on every user-driven change to the search input- disabled options do not toggle selection and do not emit
update:modelValue
Guaranteed slot props:
// Shared shape for `#trigger`, `#prefix`, `#suffix`, and `#summary`
// (the latter adds `summary`). `clearAll` / `toggleOpen` are exposed
// on every slot so consumers don't need to hoist into `#trigger` just
// to clear the selection or toggle the popover.
type MultiSelectSlotProps = {
open: boolean
disabled: boolean
query: string
selectedOptions: MultiSelectOption[]
displayValue: string
clearAll: () => void
toggleOpen: () => void
}
type MultiSelectTriggerSlotProps = MultiSelectSlotProps
type MultiSelectPrefixSlotProps = MultiSelectSlotProps
type MultiSelectSuffixSlotProps = MultiSelectSlotProps
type MultiSelectSummarySlotProps = MultiSelectSlotProps & {
// default summary text — placeholder, single label, or `"N selected"`
summary: string
}
type MultiSelectItemSlotProps = {
item: MultiSelectOption
query: string
selected: boolean
}
type MultiSelectFooterSlotProps = {
clearAll: () => void
selectAll: () => void
selectedOptions: MultiSelectOption[]
query: string
}
type MultiSelectGroupLabelSlotProps = {
group: MultiSelectGroupedOption
}
type MultiSelectEmptySlotProps = {
query: string
}Supported slots:
#trigger="{ open, disabled, query, selectedOptions, displayValue, clearAll, toggleOpen }"- preferred advanced trigger slot; replaces the default button trigger
#prefix="{ open, disabled, query, selectedOptions, displayValue, clearAll, toggleOpen }"- convenience slot rendered before the trigger label. When provided,
it owns the entire prefix area regardless of selection count — use
it for aggregate visuals like stacked avatars across multiple
selections. If omitted, the trigger auto-renders the selected
option's
#item-prefix/iconwhen exactly one is selected
- convenience slot rendered before the trigger label. When provided,
it owns the entire prefix area regardless of selection count — use
it for aggregate visuals like stacked avatars across multiple
selections. If omitted, the trigger auto-renders the selected
option's
#summary="{ open, disabled, query, selectedOptions, displayValue, clearAll, toggleOpen, summary }"- overrides the trigger's label region. Receives the default summary
text as
summary— placeholder, single label, or"N selected"— so the consumer can fall back to it or replace entirely (e.g. with a comma-separated label list). Providing this slot suppresses the default phantom-sizer (which only knows the default summary's worst-case width), so the trigger is content-sized — pin a width on the trigger (or wrap with one) if you need a stable layout
- overrides the trigger's label region. Receives the default summary
text as
#suffix="{ open, disabled, query, selectedOptions, displayValue, clearAll, toggleOpen }"- convenience slot rendered after the trigger label. Replaces the
default chevron — render an explicit chevron fallback when your
slot content is conditional. Use
@click.stop/@pointerdown.stopso the press doesn't toggle the popover. Canonical home for a clear-all button — callclearAllfrom the slot prop
- convenience slot rendered after the trigger label. Replaces the
default chevron — render an explicit chevron fallback when your
slot content is conditional. Use
#item-prefix="{ item, query, selected }"#item-label="{ item, query, selected }"#item-suffix="{ item, query, selected }"#item="{ item, query, selected }"- full-row escape hatch for a single item
#item-<slot>="{ item, query, selected }"- dynamic named label slot selected via
item.slot
- dynamic named label slot selected via
#group-label="{ group }"#empty="{ query }"#footer="{ clearAll, selectAll, selectedOptions, query }"- compatibility alias for the current
#footerslot; default footer contains Clear All / Select All buttons
- compatibility alias for the current
Exact slot rules:
- if
option.slotis set, it maps to#item-<slot>and overrides the label region #item-labelis the fallback label-region slot when no matching#item-<slot>exists#item-prefixand#item-suffixcustomize only those regions of the standard option row shell#item-suffixrenders before the built-in selected checkmark indicator#itemis a per-row escape hatch and, when used, fully replaces the standard row shell for that row#emptyreceives the current query#footerreplaces the default Clear All / Select All footer; when not provided, the default footer is rendered if either action is available
Normalization rules:
- nullish entries in
optionsare ignored - options with missing or
undefinedvalueare omitted - grouped entries with empty
optionsafter filtering are omitted compareFn, when provided, is used to resolve which options are currently selected for display and rendering; otherwiseoption.valuestrict equality against entries inmodelValueis used
Filtering rules:
- filtering is internal to
MultiSelectand is based on the current query - a case-insensitive substring match against
label(andvalue) is used by default - filtering never removes already-selected options from the selection; it only hides them from the list
Selection behavior:
- clicking an enabled option toggles its value in
modelValue - disabled options cannot be toggled and do not emit
update:modelValue - the popover does not auto-close on selection; it stays open until the user closes it
clearAllemptiesmodelValueselectAllsetsmodelValueto the concatenated values of every enabled, non-disabled option across all groups
Loading behavior:
- when
loadingistrue, the popover shows a loading indicator in the search input (or in place of the list whenhideSearchis true) and suspends the empty state
Search behavior:
- when
hideSearchistrue, no search input is rendered andupdate:queryis never emitted - when
hideSearchisfalse, the search input is always rendered at the top of the popover
Display rules:
- when at least one option is selected, the trigger shows the comma-separated labels of the selected options
- otherwise the trigger shows
placeholder displayValueexposed to#triggeris the same comma-separated string or''selectedOptionsexposed to#triggeris the resolved option objects array, preservingmodelValueorder
Disabled handling:
- disabled items are skipped during keyboard navigation
- disabled items cannot be clicked into selection
- disabled items apply shared
ItemListRowdisabled styling selectAllskips disabled options- selecting never emits
update:modelValuefrom a disabled item
Rows follow the per-region precedence from shared design rule 9. For each visible item:
Full row:
#itemslotitem.slots.item
Prefix region:
#item-prefixslotitem.slots.prefix- default: empty
Label region:
#item-<slot>slot matchingitem.slot#item-labelslot#optionslot (compatibility)item.slots.label- default:
labelplus optionaldescription
Suffix region:
#item-suffixslotitem.slots.suffix- default: built-in selected checkmark indicator
Stable hooks for MultiSelect should include:
data-slot="trigger"data-slot="content"data-slot="search"data-slot="group"data-slot="group-label"data-slot="item"data-slot="empty"data-slot="footer"data-variantdata-size
MultiSelect rows should use ItemListRow, which provides:
data-slot="item-list-row"data-slot="item-prefix"data-slot="item-label"data-slot="item-suffix"
State hooks should include, where relevant:
data-state="open|closed"on trigger/content via the primitivedata-state="checked|unchecked"on option rows via the primitivedata-loadingon content whenloadingis truedata-disabled- row-level selected styling inherited from
ItemListRow
MultiSelect follows the shared popover motion conventions (shared design
rule 10 in the main RFC):
- content scales in from the trigger via
transform-origin: var(--reka-popper-transform-origin)(or the equivalent primitive-provided variable) on the animated element (the inner content-body, not the outer positioned wrapper) - enter
180ms/ exit140mswithcubic-bezier(0.23, 1, 0.32, 1), fromscale(0.97)+translateY(2px)+opacity: 0 - keyboard-driven opens — Enter, Space, ArrowUp, ArrowDown on the trigger,
or typing in the search input when
hideSearchisfalse— skip the animation entirely - pointer-driven opens (click / tap) play the full animation
- classification is pointer-recency based: an open transition counts as
pointer-driven only if a
pointerdownfired on the trigger within ~300ms before it; everything else defaults to keyboard. The resolved mode is exposed asdata-motion="animated" | "instant"on the content-body prefers-reduced-motion: reducedisables the content animation
MultiSelect should follow the ARIA listbox pattern with multi-selection.
That means:
- the trigger uses
aria-haspopup="listbox"andaria-expanded - the list exposes
role="listbox"andaria-multiselectable="true" - items expose
role="option"witharia-selectedreflecting their presence inmodelValue - keyboard navigation (arrow keys, home/end, typeahead), escape handling, and multi-select toggling are delegated to the underlying primitive
- escape closes the popover without clearing selection
- the
idprop is forwarded to the trigger so<label for="...">works
Current API stays supported:
v-modelplaceholderoptionshideSearchloadingcompareFn#option#footer
size,variant,id,open,disabled,emptyTextside,align,offset,portalTo
v-model:open
@update:query
Query stays internal otherwise. The old version of MultiSelect did not
expose a search event, so no alias is needed.
#trigger- keep the default Button-based trigger as the fallback when
#triggeris not provided
#item-prefix#item-label#item-suffix#empty#footer#itemas the full takeover escape hatch
Simple options can keep their current shape ({ label, value, disabled? }),
but richer object items should converge on:
{
label: string
value: string
icon?: string | Component
description?: string
disabled?: boolean
slot?: string
}Grouped options should also be supported so apps do not keep building richer local multi-select variants just for grouped pickers:
{
group: string
key?: string | number
hideLabel?: boolean
options: MultiSelectOption[]
}Keep working, but deprecate for ordinary customization:
#optionas the primary documented customization API once#item-labelexists
#option remains as an alias for the label region through v1.x. Its slot
prop signature ({ item }) continues to work unchanged.
Do not deprecate:
hideSearchloadingcompareFn- default footer behavior
Do not force every richer multi-picker need into the base component.
If apps need all of these together:
- chips in the trigger
- avatars everywhere
- grouped async remote results
- custom selected summary behavior
- create-new actions
- person-specific affordances
that may justify a separate future component such as MultiCombobox or
PeoplePicker.
<MultiSelect v-model="values" :options="options">
<template #option="{ item }">
<div class="flex items-center gap-2">
<Avatar :image="item.image" class="size-4" />
<span>{{ item.label }}</span>
</div>
</template>
</MultiSelect><MultiSelect v-model="values" :options="options">
<template #item-prefix="{ item }">
<Avatar :image="item.image" class="size-4" />
</template>
<template #item-label="{ item }">
{{ item.label }}
</template>
</MultiSelect>Old: no public search event.
New:
<MultiSelect
v-model="values"
:options="options"
@update:query="onQueryChange"
/>Old: grouped options not supported; apps built custom variants.
New:
const options = [
{
group: 'Active',
options: [
{ label: 'Alpha', value: 'alpha' },
{ label: 'Beta', value: 'beta' },
],
},
{
group: 'Archived',
options: [{ label: 'Gamma', value: 'gamma' }],
},
]