Status: accepted direction for frappe-ui v1 planning.
This document defines the exact public API for Dropdown. It is a sub-spec of
selection.md and
inherits the shared design rules from that document.
Dropdown is the action menu component.
It should continue to support:
- simple action lists
- grouped actions
- submenus
- switch/toggle rows
- route-based actions
- occasional advanced custom rows
- menu-style "choose one of a few actions" cases where the app marks the current choice
Important boundary:
- if the UI is semantically choosing a form value or picker value, use
Select - if the UI is a menu of actions or view/filter/sort modes and one is currently
active,
Dropdownis still acceptable
So Dropdown can support checkmarks and active menu items, but it should not
become the generic replacement for Select.
type PopoverSide = 'top' | 'right' | 'bottom' | 'left'
type PopoverAlign = 'start' | 'center' | 'end'
/** @deprecated use `align` with start | center | end */
type DropdownPlacement = 'left' | 'center' | 'right'
interface DropdownProps {
button?: ButtonProps
options?: DropdownOptions
open?: boolean
side?: PopoverSide
align?: PopoverAlign
offset?: number
portalTo?: string | HTMLElement
emptyText?: string
/** @deprecated alias for `align`; maps left→start, center→center, right→end */
placement?: DropdownPlacement
}Defaults:
options = []open = falseside = 'bottom'align = 'start'offset = 4portalTo = 'body'emptyText = 'No options'
Positioning follows the shared popover positioning conventions in
selection.md.
Compatibility rules for placement:
placementremains supported throughv1.x- if
placementis provided withoutalign, it is silently mapped:'left'→align: 'start''center'→align: 'center''right'→align: 'end'
- if both
placementandalignare provided,alignwins and a dev warning is emitted - docs should show
alignin primary examples;placementshould only appear in the deprecation table
State conventions:
- visibility is controlled with
v-model:open Dropdowndoes not exposev-modelfor a selected valueDropdowndoes not own query state
button is only used when no custom trigger slot is provided.
interface DropdownEmits {
'update:open': [value: boolean]
}There is no component-level select event in v1. Action handling stays
item-owned through route and onClick.
Guaranteed slot props:
type DropdownTriggerSlotProps = {
open: boolean
close: () => void
disabled: boolean
}
type DropdownItemSlotProps = {
item: DropdownOption
close: () => void
selected: boolean
}
type DropdownGroupLabelSlotProps = {
group: DropdownGroupOption
}Supported slots:
#trigger="{ open, close, disabled }"- preferred advanced trigger slot
- default slot
- supported trigger slot with the same contract as
#trigger - especially ergonomic when trigger customization is the only customization needed
- supported trigger slot with the same contract as
#item-prefix="{ item, close, selected }"- custom leading content for all item rows, including submenu and switch rows
#item-label="{ item, close, selected }"- custom label/content region for all item rows
#item-suffix="{ item, close, selected }"- custom trailing content for all item rows
#item="{ item, close, selected }"- full-row escape hatch for leaf action rows only
#item-<slot>="{ item, close, selected }"- dynamic named label slot selected via
item.slot
- dynamic named label slot selected via
#group-label="{ group }"- optional custom group label rendering
#empty- empty state for any menu level with no visible items
Exact slot rules:
#triggerwins over the default slot- the default slot wins over the generated
buttontrigger close()closes the whole dropdown, not just the current submenuselectedis alwaysBoolean(item.selected)#item-<slot>overrides the label region only; it does not replace the full row shell#itemis an escape hatch for leaf action rows only- submenu and switch rows keep their shell-owned structure even when
#itemexists #item-prefix,#item-label, and#item-suffixapply at every menu depth- on submenu rows,
#item-suffixrenders before the built-in submenu chevron - on switch rows,
#item-suffixrenders before the built-in switch control
type DropdownTheme = 'gray' | 'red'
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 DropdownBaseOption {
icon?: string | Component | null
description?: string
selected?: boolean
disabled?: boolean
theme?: DropdownTheme
slot?: string
slots?: ItemSlots<DropdownItemSlotProps>
condition?: () => boolean
[key: string]: any
}
interface DropdownActionOption extends DropdownBaseOption {
label: string
route?: RouteLocationRaw
onClick?: (event: PointerEvent) => void
submenu?: never
switch?: never
switchValue?: never
component?: never
}
interface DropdownSwitchOption extends DropdownBaseOption {
label: string
switch: true
switchValue?: boolean
onClick?: (value: boolean) => void
route?: never
submenu?: never
component?: never
}
interface DropdownSubmenuOption extends DropdownBaseOption {
label: string
submenu: DropdownOptions
route?: never
onClick?: never
switch?: never
switchValue?: never
component?: never
}
/** @deprecated use `slots: { item: fn }` */
interface DropdownComponentOption extends DropdownBaseOption {
component: any
label?: string
route?: never
submenu?: never
switch?: never
switchValue?: never
}
interface DropdownGroupOption {
key?: string | number
group: string
hideLabel?: boolean
theme?: DropdownTheme
options: DropdownOption[]
/** @deprecated alias for `options` (Dropdown previously used `items`) */
items?: DropdownOption[]
}
type DropdownOption =
| DropdownActionOption
| DropdownSwitchOption
| DropdownSubmenuOption
| DropdownComponentOption
type DropdownOptions = Array<DropdownOption | DropdownGroupOption>Compatibility rule for the group field:
- the canonical group entry is
{ group, options }, matchingCombobox,MultiSelect, andSelect { group, items }(the previous Dropdown shape) keeps working throughv1.xas a deprecated alias- if both
optionsanditemsare provided on the same group entry,optionswins and a dev warning is emitted - if only
itemsis provided, it is silently mapped tooptions
Notes:
labelis required for every standard action, switch, and submenu rowlabelis optional only forcomponentescape-hatch rowssubmenu,switch, andcomponentare mutually exclusive item modes- app-defined extra fields like
value,id,image,shortcut, and analytics metadata are allowed and must be passed through unchanged to slot props slotis the preferred name for dynamic label slot selection- keep
onClickandconditionas canonical names
optionsmay mix plain items and explicit groups- plain items are rendered as implicit unlabeled groups in source order
- each menu level should normalize its
DropdownOptionsinput into an explicit grouped structure before row rendering - the normalization shape is internal to
Dropdown; only the{ group, options }external API contract is part of the public surface condition()is evaluated before rendering at every menu depth- items whose
condition()returns false are omitted - groups with zero visible items are omitted
- if a menu or submenu level has no visible items, render
#empty
- if
#triggeris provided, use it - else if the default slot is provided, use it as the compatibility trigger slot
- else render the generated
Buttonfrombutton - trigger disabled state is derived from
button.disabledor a forwardeddisabledattribute
selectedis visual-only state owned by the appDropdowndoes not infer selection and does not emit selection changes- selected rows receive shell-owned selected styling, but
Dropdowndoes not render a trailing checkmark automatically - if any visible item in a group has an icon, items without icons in that same group should reserve the same prefix space for alignment
routetakes precedence overonClickon leaf action rows- leaf action rows close the dropdown on selection through menu semantics
- switch rows do not auto-close on toggle
- submenu rows open nested menu content and do not call
onClick componentrows are escape hatches for exceptional content and do not receive shell-owned prefix/label/suffix regions
Follows the shared disabled-option rule (shared design rule 8 in the main RFC):
- disabled items are skipped by keyboard navigation and typeahead
- disabled leaf actions do not call
onClickand do not followroute - disabled submenu rows do not open their submenu
- disabled switch rows do not toggle and do not emit
- disabled items apply
ItemListRowdisabled styling anddata-disabled
For each visible item:
- if
item.submenuexists, render a submenu row - else if
item.switch === true, render a switch row - else determine the row by combining template slots,
item.slots, and the default shell (see per-region precedence below)
Per-region precedence for standard action rows (following shared design rule 9):
Full row (if any of these provide a full-row renderer, the per-region renderers below are skipped):
#itemslotitem.slots.itemitem.component— kept as a deprecated alias ofitem.slots.item; if both are present,slots.itemwins and a dev warning fires
Prefix region:
#item-prefixslotitem.slots.prefix- default:
iconwith group-level alignment placeholder behavior
Label region:
#item-<slot>slot matchingitem.slot#item-labelslotitem.slots.label- default:
labelplus optionaldescription
Suffix region:
#item-suffixslotitem.slots.suffix- default: empty for leaf action rows; submenu chevron or switch control is appended after the suffix region on submenu / switch rows
Notes:
- submenu and switch rows keep their shell-owned affordances even when a
full-row renderer is provided elsewhere — the full-row escape hatch
applies to leaf action rows only, matching the existing
#itemrule
Stable hooks for Dropdown should include:
data-slot="content"data-slot="group"data-slot="group-label"data-slot="item"data-slot="empty"
Standard rows inside Dropdown 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 menu content via the menu primitivedata-disabled- row-level selected/active styling hooks inherited from
ItemListRow
Dropdown 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-dropdown-menu-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
Dropdown should follow the menu button pattern, not the listbox/select
pattern.
That means:
- trigger uses menu-trigger semantics
- leaf actions are menu actions, not form options
- submenu items expose submenu semantics
- keyboard navigation, escape handling, typeahead, and submenu arrow-key behavior are delegated to the underlying menu primitive
selectedis visual state only; it does not change the component into a single-select control
These stay supported:
buttonoptionsplacement(as an alias foralign)sideoffsetportalTo- grouped items, including the legacy
{ group, items }shape submenuswitchswitchValuecomponent#item- current default trigger slot behavior
Keep working, but deprecate for ordinary row customization:
#itemas the default recommendationitem.componentin favor ofitem.slots(useslots.itemfor the full-row escape hatch; useslots.prefix/slots.label/slots.suffixfor per-region customization)placementprop in favor ofalign{ group, items }group entries in favor of{ group, options }(for symmetry withCombobox/MultiSelect/Select)
Keep as escape hatches:
- deeply custom rows
- destructive full-width special rows
- embedded app selectors or similar exceptional content
Do not deprecate:
onClickconditionroutesubmenuswitch
<Dropdown :options="items">
<template #item="{ item }">
<button class="flex h-7 w-full items-center justify-between rounded px-2 hover:bg-surface-gray-3">
<span>{{ item.label }}</span>
<LucideCheck v-if="active === item.value" class="size-4" />
</button>
</template>
</Dropdown><Dropdown :options="items">
<template #item-suffix="{ item }">
<LucideCheck v-if="item.selected" class="size-4" />
</template>
</Dropdown>Old:
const actions = [
{
group: 'Edit',
items: [
{ label: 'Rename', onClick: rename },
{ label: 'Duplicate', onClick: duplicate },
],
},
]New:
const actions = [
{
group: 'Edit',
options: [
{ label: 'Rename', onClick: rename },
{ label: 'Duplicate', onClick: duplicate },
],
},
]{ group, items } keeps working through v1.x; a dev warning fires if
both options and items are provided on the same group entry.
{
label: 'Delete',
component: h(Button, { variant: 'solid', theme: 'red' }, () => 'Delete'),
}{
label: 'Delete',
slots: {
item: () =>
h(Button, { variant: 'solid', theme: 'red' }, () => 'Delete'),
},
}import { h } from 'vue'
import LucideCheck from '~icons/lucide/check'
import Avatar from '@/components/Avatar.vue'
const options = users.map((user) => ({
label: user.name,
selected: user.id === activeId,
onClick: () => switchTo(user.id),
slots: {
prefix: ({ item }) =>
h(Avatar, { image: item.image, class: 'size-4' }),
suffix: ({ selected }) =>
selected ? h(LucideCheck, { class: 'size-4' }) : null,
},
}))component is still supported as a deprecated alias for slots.item. Use
slots.prefix / slots.label / slots.suffix for ordinary
icon/label/check/suffix customization authored in JS; use slots.item
when the whole row needs to be taken over.
-
item.iconacceptslucide-*strings. Passicon: 'lucide-pen'directly in an item definition — no component import needed. Strings starting withlucide-are rendered as a<span>styled via the Tailwind CSS-mask plugin. Other strings still route to FeatherIcon (back-compat). Component values continue to work unchanged. -
Group labels toned to
text-ink-gray-4. Separator group headings are now visually quieter so they recede behind the action items.