Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui'

const items = [
{
label: 'Filter A',
icon: 'i-lucide-user'
}, {
label: 'Filter B',
icon: 'i-lucide-credit-card'
}, {
label: 'Filters C',
icon: 'i-lucide-filter',
children: [
{
label: 'Filter C-A',
icon: 'i-lucide-user'
}, {
label: 'Filter C-B',
icon: 'i-lucide-credit-card'
}
]
}, {
label: 'Other filters',
icon: 'i-lucide-list-filter-plus',
children: [
{
label: 'Filter D',
icon: 'i-lucide-user'
}, {
label: 'Filter E',
icon: 'i-lucide-credit-card'
}, {
label: 'Filter F',
icon: 'i-lucide-filter'
},
{
label: 'Filter G',
icon: 'i-lucide-list-filter'
}
]
}
] satisfies DropdownMenuItem[]
</script>

<template>
<UDropdownMenu :items="items" :ui="{ content: 'w-48' }" :search="3">
<UButton label="Open" color="neutral" variant="outline" icon="i-lucide-menu" />
</UDropdownMenu>
</template>
14 changes: 14 additions & 0 deletions docs/content/docs/2.components/dropdown-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,20 @@ name: 'dropdown-menu-custom-slot-example'
You can also use the `#item`, `#item-leading`, `#item-label` and `#item-trailing` slots to customize all items.
::

### With search

Use the `search` property to add a search input to the DropdownMenu.

::component-example
---
name: 'dropdown-menu-search-example'
---
::

::tip{to="#slots"}
You can also use the `#item`, `#item-leading`, `#item-label` and `#item-trailing` slots to customize all items.
::

### With trigger content width

You can expand the content to the full width of its button by adding the `w-(--reka-dropdown-menu-trigger-width)` class on the `ui.content` slot.
Expand Down
7 changes: 6 additions & 1 deletion playgrounds/nuxt/app/pages/components/dropdown-menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import theme from '#build/ui/dropdown-menu'

const loading = ref(false)

const search = ref(0)

const items = computed(() => [
[{
label: 'My account',
Expand Down Expand Up @@ -141,10 +143,13 @@ defineShortcuts(extractShortcuts(items.value))
<Navbar>
<USelect v-model="attrs.size" :items="sizes" multiple placeholder="Size" />
<USwitch v-model="arrow" label="Arrow" />
<USeparator orientation="vertical" class="h-6 mx-2" />
<USwitch v-if="search === 0" :model-value="false" unchecked-icon="i-lucide-search" checked-icon="i-lucide-search" @update:model-value="search = 1" />
<UInputNumber v-else v-model="search" label="Search" class="w-20" />
</Navbar>

<Matrix v-slot="props" :attrs="attrs">
<UDropdownMenu :items="items" :arrow="arrow" :content="{ side: 'bottom', align: 'start' }" v-bind="props">
<UDropdownMenu :items="items" :arrow="arrow" :content="{ side: 'bottom', align: 'start' }" v-bind="props" :search>
<UButton label="Open" color="neutral" variant="outline" icon="i-lucide-menu" />

<template #custom-trailing>
Expand Down
14 changes: 12 additions & 2 deletions src/runtime/components/DropdownMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface DropdownMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'cust
onSelect?: (e: Event) => void
onUpdateChecked?: (checked: boolean) => void
class?: any
ui?: Pick<DropdownMenu['slots'], 'content' | 'item' | 'label' | 'separator' | 'itemLeadingIcon' | 'itemLeadingAvatarSize' | 'itemLeadingAvatar' | 'itemWrapper' | 'itemLabel' | 'itemDescription' | 'itemLabelExternalIcon' | 'itemTrailing' | 'itemTrailingIcon' | 'itemTrailingKbds' | 'itemTrailingKbdsSize'>
ui?: Pick<DropdownMenu['slots'], 'content' | 'item' | 'label' | 'separator' | 'itemLeadingIcon' | 'itemLeadingAvatarSize' | 'itemLeadingAvatar' | 'itemLabel' | 'itemWrapper' | 'itemDescription' | 'itemLabelExternalIcon' | 'itemTrailing' | 'itemTrailingIcon' | 'itemTrailingKbds' | 'itemTrailingKbdsSize'>
[key: string]: any
}

Expand Down Expand Up @@ -89,12 +89,20 @@ export interface DropdownMenuProps<T extends ArrayOrNested<DropdownMenuItem> = A
* @defaultValue 'description'
*/
descriptionKey?: GetItemKeys<T>
/**
* Enable search functionality. Can be:
* - true: Always show search
* - number: Show search when items.length >= number
* - false: Disable search
* @defaultValue false
*/
search?: boolean | number
disabled?: boolean
class?: any
ui?: DropdownMenu['slots']
}

export interface DropdownMenuEmits extends DropdownMenuRootEmits {}
export interface DropdownMenuEmits extends DropdownMenuRootEmits { }

type SlotProps<T extends DropdownMenuItem> = (props: { item: T, active?: boolean, index: number, ui: DropdownMenu['ui'] }) => any

Expand All @@ -108,6 +116,7 @@ export type DropdownMenuSlots<
'item-label': (props: { item: T, active?: boolean, index: number }) => any
'item-description': (props: { item: T, active?: boolean, index: number }) => any
'item-trailing': SlotProps<T>
'search': (props: { modelValue: string, clear: () => void }) => any
'content-top': (props?: {}) => any
'content-bottom': (props?: {}) => any
}
Expand Down Expand Up @@ -166,6 +175,7 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.dropdownMenu
:checked-icon="checkedIcon"
:loading-icon="loadingIcon"
:external-icon="externalIcon"
:search="search ?? undefined"
>
<template v-for="(_, name) in getProxySlots()" #[name]="slotData">
<slot :name="(name as keyof DropdownMenuSlots<T>)" v-bind="slotData" />
Expand Down
61 changes: 57 additions & 4 deletions src/runtime/components/DropdownMenuContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ interface DropdownMenuContentProps<T extends ArrayOrNested<DropdownMenuItem>> ex
sub?: boolean
labelKey: GetItemKeys<T>
descriptionKey: GetItemKeys<T>
/**
* Enable search functionality
*/
search?: boolean | number
/**
* @IconifyIcon
*/
Expand All @@ -37,7 +41,7 @@ interface DropdownMenuContentEmits extends RekaDropdownMenuContentEmits {}
type DropdownMenuContentSlots<
A extends ArrayOrNested<DropdownMenuItem> = ArrayOrNested<DropdownMenuItem>,
T extends NestedItem<A> = NestedItem<A>
> = Pick<DropdownMenuSlots<A>, 'item' | 'item-leading' | 'item-label' | 'item-description' | 'item-trailing' | 'content-top' | 'content-bottom'> & {
> = Pick<DropdownMenuSlots<A>, 'item' | 'item-leading' | 'item-label' | 'item-description' | 'item-trailing' | 'content-top' | 'content-bottom' | 'search'> & {
default(props?: {}): any
}
& DynamicSlots<MergeTypes<T>, 'label' | 'description', { active?: boolean, index: number }>
Expand All @@ -46,7 +50,7 @@ type DropdownMenuContentSlots<
</script>

<script setup lang="ts" generic="T extends ArrayOrNested<DropdownMenuItem>">
import { computed, toRef } from 'vue'
import { computed, toRef, ref } from 'vue'
import { DropdownMenu } from 'reka-ui/namespaced'
import { useForwardPropsEmits } from 'reka-ui'
import { reactiveOmit, createReusableTemplate } from '@vueuse/core'
Expand All @@ -59,6 +63,7 @@ import ULinkBase from './LinkBase.vue'
import ULink from './Link.vue'
import UAvatar from './Avatar.vue'
import UIcon from './Icon.vue'
import UInput from './Input.vue'
import UKbd from './Kbd.vue'
import UDropdownMenuContent from './DropdownMenuContent.vue'

Expand All @@ -70,19 +75,45 @@ const { dir } = useLocale()
const appConfig = useAppConfig()

const portalProps = usePortal(toRef(() => props.portal))
const contentProps = useForwardPropsEmits(reactiveOmit(props, 'sub', 'items', 'portal', 'labelKey', 'descriptionKey', 'checkedIcon', 'loadingIcon', 'externalIcon', 'class', 'ui', 'uiOverride'), emits)
const contentProps = useForwardPropsEmits(reactiveOmit(props, 'sub', 'items', 'portal', 'labelKey', 'descriptionKey', 'checkedIcon', 'loadingIcon', 'externalIcon', 'class', 'ui', 'uiOverride', 'search'), emits)
const getProxySlots = () => omit(slots, ['default'])

const [DefineItemTemplate, ReuseItemTemplate] = createReusableTemplate<{ item: DropdownMenuItem, active?: boolean, index: number }>()

const childrenIcon = computed(() => dir.value === 'rtl' ? appConfig.ui.icons.chevronLeft : appConfig.ui.icons.chevronRight)

const searchValue = ref('')

const showSearch = computed(() => {
if (!props.search) return false
if (typeof props.search === 'number') {
return (props.items?.length ?? 0) >= props.search
}
return true
})
const clearSearch = () => {
searchValue.value = ''
}

const groups = computed<DropdownMenuItem[][]>(() =>
props.items?.length
? isArrayOfArray(props.items)
? props.items
: [props.items]
: []
)
const filteredGroups = computed(() => {
if (!showSearch.value || !searchValue.value) return groups.value
return groups.value?.reduce<DropdownMenuItem[][]>((acc, group) => {
// Search for label or description
const filtered = group.filter(item =>
((item[props.labelKey as string]?.toString().toLowerCase() ?? '')
+ (item[props.descriptionKey as string]?.toString().toLowerCase() ?? ''))
.includes(searchValue.value.toLowerCase()))
if (filtered.length) acc.push(filtered)
return acc
}, [])
})
</script>

<template>
Expand Down Expand Up @@ -127,10 +158,31 @@ const groups = computed<DropdownMenuItem[][]>(() =>

<DropdownMenu.Portal v-bind="portalProps">
<component :is="sub ? DropdownMenu.SubContent : DropdownMenu.Content" :class="ui.content({ class: [uiOverride?.content, props.class] })" v-bind="contentProps">
<slot
v-if="showSearch"
name="search"
:model-value="searchValue"
:clear="clearSearch"
>
<UInput
v-model="searchValue"
placeholder="Search"
:ui="{
base: 'rounded-none ring-0 placeholder-gray-500 dark:placeholder-gray-400'
}"
class="border-b border-gray-200 dark:border-gray-800"
:class="{
'border-gray-100 dark:border-gray-900': filteredGroups.length === 0
}"
icon="i-lucide-search"
trailing
/>
</slot>

<slot name="content-top" />

<div role="presentation" :class="ui.viewport({ class: uiOverride?.viewport })">
<DropdownMenu.Group v-for="(group, groupIndex) in groups" :key="`group-${groupIndex}`" :class="ui.group({ class: uiOverride?.group })">
<DropdownMenu.Group v-for="(group, groupIndex) in filteredGroups" :key="`group-${groupIndex}`" :class="ui.group({ class: uiOverride?.group })">
<template v-for="(item, index) in group" :key="`group-${groupIndex}-${index}`">
<DropdownMenu.Label v-if="item.type === 'label'" :class="ui.label({ class: [uiOverride?.label, item.ui?.label, item.class] })">
<ReuseItemTemplate :item="item" :index="index" />
Expand All @@ -154,6 +206,7 @@ const groups = computed<DropdownMenuItem[][]>(() =>
:ui-override="uiOverride"
:portal="portal"
:items="(item.children as T)"
:search="props.search"
align="start"
:align-offset="-4"
:side-offset="3"
Expand Down
2 changes: 2 additions & 0 deletions test/components/DropdownMenu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ describe('DropdownMenu', () => {
['without externalIcon', { props: { ...props, externalIcon: false } }],
['with class', { props: { ...props, class: 'min-w-96' } }],
['with ui', { props: { ...props, ui: { itemLeadingIcon: 'size-4' } } }],
['with search', { props: { ...props, search: true } }],
['with search (number)', { props: { ...props, search: 2 } }],
// Slots
['with default slot', { props, slots: { default: () => 'Default slot' } }],
['with item slot', { props, slots: { item: () => 'Item slot' } }],
Expand Down
Loading
Loading