Status: accepted direction for frappe-ui v1 planning.
This document defines the exact public API for Select. It is a sub-spec of
selection.md and
inherits the shared design rules from that document.
Select is the simple single-choice picker for small static lists.
It should stay narrow:
- single selection only
- local static options
- no search input
- no action-menu semantics
- no grouped option support in v1
If the UI needs search, use Combobox. If the UI is choosing actions, use
Dropdown.
type SelectOptionValue = string | number | bigint | Record<string, any>
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>
}
type SelectOption =
| string
| {
label: string
value: SelectOptionValue
disabled?: boolean
icon?: string | Component
description?: string
slot?: string
slots?: ItemSlots<SelectOptionSlotProps>
[key: string]: any
}
type PopoverSide = 'top' | 'right' | 'bottom' | 'left'
type PopoverAlign = 'start' | 'center' | 'end'
interface SelectProps {
size?: 'sm' | 'md' | 'lg' | 'xl'
variant?: 'subtle' | 'outline' | 'ghost'
placeholder?: string
disabled?: boolean
id?: string
modelValue?: SelectOptionValue
open?: boolean
options?: SelectOption[]
side?: PopoverSide
align?: PopoverAlign
offset?: number
portalTo?: string | HTMLElement
emptyText?: string
}Defaults:
size = 'sm'variant = 'subtle'placeholder = 'Select option'open = falseoptions = []side = 'bottom'align = 'start'offset = 4portalTo = 'body'emptyText = 'No options'
Positioning follows the shared popover positioning conventions in
selection.md.
side, align, offset, and portalTo are additive in v1.x: they did not
exist in previous versions of Select, so no migration is needed. Apps that
never pass them continue to see the same default positioning as before.
State conventions:
- selected value uses
v-model/modelValue - menu visibility uses
v-model:open Selectdoes not own query stateSelectaccepts flat options only in v1
interface SelectEmits {
'update:modelValue': [value: SelectOptionValue | undefined]
'update:open': [value: boolean]
}There is no separate component-level select event in v1.
Guaranteed slot props:
type SelectTriggerSlotProps = {
open: boolean
disabled: boolean
selectedOption: Exclude<SelectOption, string> | null
displayValue: string
}
// `#trigger`, `#prefix`, and `#suffix` all receive the same shape.
type SelectSlotProps = SelectTriggerSlotProps
type SelectPrefixSlotProps = SelectSlotProps
type SelectSuffixSlotProps = SelectSlotProps
type SelectOptionSlotProps = {
option: Exclude<SelectOption, string>
}Supported slots:
#trigger="{ open, disabled, selectedOption, displayValue }"- advanced trigger customization
#prefix="{ open, disabled, selectedOption, displayValue }"- convenience slot inside the default trigger shell.
selectedOptionis alwaysnullhere (prefix renders pre-selection)
- convenience slot inside the default trigger shell.
#suffix="{ open, disabled, selectedOption, displayValue }"- convenience slot inside the default trigger shell. Replaces the default chevron — render an explicit chevron fallback when your slot content is conditional
#item-prefix="{ option }"#item-label="{ option }"#item-suffix="{ option }"#item-<slot>="{ option }"#option="{ option }"- compatibility alias for item label customization through
v1.x
- compatibility alias for item label customization through
#empty#footer
Exact slot rules:
- if
#triggeris provided, it replaces the default trigger content - when
#triggeris used,#prefixand#suffixare ignored - if
option.slotis set, it maps to#item-<slot>and overrides the label region #item-labelis the preferred label-region slot#optionremains supported as the compatibility fallback for the label region when#item-labelis not used#item-prefixand#item-suffixcustomize only those regions of the standard option row shell#item-suffixrenders before the built-in selected checkmark indicator#footeris rendered once after the option list#emptyis rendered when there are no normalized options
Per-region precedence for each option row (following shared design rule 9):
- Prefix:
#item-prefixslot >option.slots.prefix>option.iconauto-rendered (lucide-*string → Tailwind plugin, Component → rendered directly) > default (empty) - Label:
#item-<slot>slot (foroption.slot) >#item-labelslot >#optionslot (compatibility) >option.slots.label> default (label+ optionaldescription) - Suffix:
#item-suffixslot >option.slots.suffix> default (built-in selected checkmark indicator) - Full row:
option.slots.itemreplaces the standard row shell and skips all per-region rendering; there is no full-row template slot onSelect
Normalization rules:
Selectaccepts flatoptionsonly in v1; it does not accept grouped options- string options normalize to
{ label: option, value: option } - nullish options are ignored
- options whose
valueisundefinedornullare omitted - selected option lookup uses strict equality against
modelValue
Display rules:
- if a selected option exists, its
labelis the default display value - otherwise the trigger shows
placeholder displayValueexposed to#triggeris the selected option label or''selectedOptionexposed to#triggeris the normalized object option ornull
Row behavior:
- option rows should use the shared
ItemListRowshell selectedstate is derived fromoption.value === modelValue- disabled options are not selectable
- selecting an enabled option updates
modelValueand closes the list through select semantics - selected rows render a built-in trailing checkmark indicator
option.iconis allowed in the item shape but is not rendered by default; consumers should use#item-prefixor trigger slots when they want icon rendering- default label rendering is
labelplus optionaldescription
Follows the shared disabled-option rule (shared design rule 8 in the main RFC):
- disabled options are skipped by keyboard navigation and typeahead
- disabled options cannot be selected by click or keyboard
- disabled options never emit
update:modelValue - disabled options apply
ItemListRowdisabled styling anddata-disabled - an already-selected option that becomes disabled stays in
modelValue; it just stops being interactable
Stable hooks for Select should include:
data-slot="trigger"data-slot="content"data-slot="item"data-slot="empty"data-slot="footer"
Select 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 select primitivedata-state="checked|unchecked"on option items via the select primitivedata-disabled- row-level selected styling inherited from
ItemListRow
Select 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-select-content-transform-origin)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) 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
Select should follow the select/listbox pattern, not the menu button pattern.
That means:
- trigger and content use select semantics
- items are options, not actions
- keyboard navigation, typeahead, highlighted state, and selection behavior are delegated to the underlying select primitive
- selected state is semantic component state, not just visual decoration
These stay supported:
v-modelv-model:opensizevariantplaceholderdisabledidoptionsside,align,offset,portalTo(additive)emptyText(additive)#trigger#prefix#suffix#option#footer- string options
Keep working, but deprecate for ordinary customization:
#optionas the primary documented customization API once#item-labelexists
#option should remain as an alias/fallback for the label region through
v1.x.
<Select v-model="chartType" :options="options">
<template #option="{ option }">
<div class="flex items-center gap-2">
<component :is="option.icon" class="size-4" />
<span>{{ option.label }}</span>
</div>
</template>
</Select><Select v-model="chartType" :options="options">
<template #item-prefix="{ option }">
<component :is="option.icon" class="size-4" />
</template>
<template #item-label="{ option }">
{{ option.label }}
</template>
</Select>-
option.iconis auto-rendered in the prefix region. Settingiconon an option now shows that icon automatically — no#item-prefixslot needed for the common case. Precedence:#item-prefixslot →option.slots.prefix→option.icon→ empty. Existing prefix slots are unaffected. -
option.iconacceptslucide-*strings. Passicon: 'lucide-user'directly in an option definition — rendered via the Tailwind CSS-mask plugin, no import needed. Component values also work.