Skip to content

DropdownMenu

Demo

This requires the following components to be installed:

This requires the following utils to be installed:

This requires the following types to be installed:

This requires the following theme to be installed:

Component

DropdownMenu.vue
vue
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { AvatarProps } from '@/ui/components/Avatar.vue'
import type { KbdProps } from '@/ui/components/Kbd.vue'
import type { LinkProps } from '@/ui/components/Link.vue'
import type { ArrayOrNested, ComponentConfig, DynamicSlots, MergeTypes, NestedItem } from '@/ui/utils/utils'
import type { DropdownMenuArrowProps, DropdownMenuContentEmits, DropdownMenuContentProps, DropdownMenuRootEmits, DropdownMenuRootProps } from 'reka-ui'
import type { EmitsToProps } from 'vue'
import DropdownMenuContent from '@/ui/components/DropdownMenuContent.vue'
import theme from '@/ui/theme/dropdown-menu'
import { omit } from '@/ui/utils/omit'
import { reactivePick } from '@vueuse/shared'
import defu from 'defu'
import { DropdownMenuArrow, DropdownMenuRoot, DropdownMenuTrigger, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, toRef } from 'vue'

type DropdownMenu = ComponentConfig<typeof theme>

export interface DropdownMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custom'> {
  label?: string
  icon?: string
  color?: DropdownMenu['variants']['color']
  avatar?: AvatarProps
  content?: Omit<DropdownMenuContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<DropdownMenuContentEmits>>
  kbds?: KbdProps['value'][] | KbdProps[]
  type?: 'label' | 'separator' | 'link' | 'checkbox'
  slot?: string
  loading?: boolean
  disabled?: boolean
  checked?: boolean
  open?: boolean
  defaultOpen?: boolean
  children?: ArrayOrNested<DropdownMenuItem>
  onSelect?: (e: Event) => void
  onUpdateChecked?: (checked: boolean) => void
  [key: string]: any
}

export interface DropdownMenuProps<T extends ArrayOrNested<DropdownMenuItem> = ArrayOrNested<DropdownMenuItem>> extends Omit<DropdownMenuRootProps, 'dir'> {
  size?: DropdownMenu['variants']['size']
  items?: T
  content?: Omit<DropdownMenuContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<DropdownMenuContentEmits>>
  arrow?: boolean | Omit<DropdownMenuArrowProps, 'as' | 'asChild'>
  portal?: boolean | string | HTMLElement
  labelKey?: keyof NestedItem<T>
  disabled?: boolean
  class?: any
  ui?: DropdownMenu['slots']
}

export interface DropdownMenuEmits extends DropdownMenuRootEmits {}

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

export type DropdownMenuSlots<
  A extends ArrayOrNested<DropdownMenuItem> = ArrayOrNested<DropdownMenuItem>,
  T extends NestedItem<A> = NestedItem<A>,
> = {
  'default': (props: { open: boolean }) => any
  'item': SlotProps<T>
  'item-leading': SlotProps<T>
  'item-label': SlotProps<T>
  'item-trailing': SlotProps<T>
  'content-top': (props?: object) => any
  'content-bottom': (props?: object) => any
} & DynamicSlots<MergeTypes<T>, 'leading' | 'label' | 'trailing', { active?: boolean, index: number }>
</script>

<script setup lang="ts" generic="T extends ArrayOrNested<DropdownMenuItem>">
const props = withDefaults(defineProps<DropdownMenuProps<T>>(), {
  portal: true,
  modal: true,
  externalIcon: true,
  labelKey: 'label',
})
const emits = defineEmits<DropdownMenuEmits>()
const slots = defineSlots<DropdownMenuSlots<T>>()

const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultOpen', 'open', 'modal'), emits)
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8 }) as DropdownMenuContentProps)
const arrowProps = toRef(() => props.arrow as DropdownMenuArrowProps)
const proxySlots = omit(slots, ['default'])

const ui = computed(() => tv(theme)({
  size: props.size,
}))
</script>

<template>
  <DropdownMenuRoot v-slot="{ open }" v-bind="rootProps">
    <DropdownMenuTrigger v-if="!!slots.default" as-child :class="props.class" :disabled="disabled">
      <slot :open="open" />
    </DropdownMenuTrigger>

    <DropdownMenuContent
      :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })"
      :ui="ui"
      :ui-override="props.ui"
      v-bind="contentProps"
      :items="items"
      :portal="portal"
      :label-key="(labelKey as keyof NestedItem<T>)"
    >
      <template v-for="(_, name) in proxySlots" #[name]="slotData">
        <slot :name="(name as keyof DropdownMenuSlots<T>)" v-bind="slotData" />
      </template>

      <DropdownMenuArrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow({ class: props.ui?.arrow })" />
    </DropdownMenuContent>
  </DropdownMenuRoot>
</template>
DropdownMenu.vue
vue
<!-- eslint-disable vue/block-tag-newline -->
<script lang="ts">
import type { AvatarProps } from '@/UI/Components/Avatar.vue'
import type { KbdProps } from '@/UI/Components/Kbd.vue'
import type { LinkProps } from '@/UI/Components/Link.vue'
import type { ArrayOrNested, ComponentConfig, DynamicSlots, MergeTypes, NestedItem } from '@/UI/Utils/utils'
import type { DropdownMenuArrowProps, DropdownMenuContentEmits, DropdownMenuContentProps, DropdownMenuRootEmits, DropdownMenuRootProps } from 'reka-ui'
import type { EmitsToProps } from 'vue'
import DropdownMenuContent from '@/UI/Components/DropdownMenuContent.vue'
import theme from '@/UI/Theme/dropdown-menu'
import { omit } from '@/UI/Utils/omit'
import { reactivePick } from '@vueuse/shared'
import defu from 'defu'
import { DropdownMenuArrow, DropdownMenuRoot, DropdownMenuTrigger, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, toRef } from 'vue'

type DropdownMenu = ComponentConfig<typeof theme>

export interface DropdownMenuItem extends Omit<LinkProps, 'type' | 'raw' | 'custom'> {
  label?: string
  icon?: string
  color?: DropdownMenu['variants']['color']
  avatar?: AvatarProps
  content?: Omit<DropdownMenuContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<DropdownMenuContentEmits>>
  kbds?: KbdProps['value'][] | KbdProps[]
  type?: 'label' | 'separator' | 'link' | 'checkbox'
  slot?: string
  loading?: boolean
  disabled?: boolean
  checked?: boolean
  open?: boolean
  defaultOpen?: boolean
  children?: ArrayOrNested<DropdownMenuItem>
  onSelect?: (e: Event) => void
  onUpdateChecked?: (checked: boolean) => void
  [key: string]: any
}

export interface DropdownMenuProps<T extends ArrayOrNested<DropdownMenuItem> = ArrayOrNested<DropdownMenuItem>> extends Omit<DropdownMenuRootProps, 'dir'> {
  size?: DropdownMenu['variants']['size']
  items?: T
  content?: Omit<DropdownMenuContentProps, 'as' | 'asChild' | 'forceMount'> & Partial<EmitsToProps<DropdownMenuContentEmits>>
  arrow?: boolean | Omit<DropdownMenuArrowProps, 'as' | 'asChild'>
  portal?: boolean | string | HTMLElement
  labelKey?: keyof NestedItem<T>
  disabled?: boolean
  class?: any
  ui?: DropdownMenu['slots']
}

export interface DropdownMenuEmits extends DropdownMenuRootEmits {}

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

export type DropdownMenuSlots<
  A extends ArrayOrNested<DropdownMenuItem> = ArrayOrNested<DropdownMenuItem>,
  T extends NestedItem<A> = NestedItem<A>,
> = {
  'default': (props: { open: boolean }) => any
  'item': SlotProps<T>
  'item-leading': SlotProps<T>
  'item-label': SlotProps<T>
  'item-trailing': SlotProps<T>
  'content-top': (props?: object) => any
  'content-bottom': (props?: object) => any
} & DynamicSlots<MergeTypes<T>, 'leading' | 'label' | 'trailing', { active?: boolean, index: number }>
</script>

<script setup lang="ts" generic="T extends ArrayOrNested<DropdownMenuItem>">
const props = withDefaults(defineProps<DropdownMenuProps<T>>(), {
  portal: true,
  modal: true,
  externalIcon: true,
  labelKey: 'label',
})
const emits = defineEmits<DropdownMenuEmits>()
const slots = defineSlots<DropdownMenuSlots<T>>()

const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultOpen', 'open', 'modal'), emits)
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8 }) as DropdownMenuContentProps)
const arrowProps = toRef(() => props.arrow as DropdownMenuArrowProps)
const proxySlots = omit(slots, ['default'])

const ui = computed(() => tv(theme)({
  size: props.size,
}))
</script>

<template>
  <DropdownMenuRoot v-slot="{ open }" v-bind="rootProps">
    <DropdownMenuTrigger v-if="!!slots.default" as-child :class="props.class" :disabled="disabled">
      <slot :open="open" />
    </DropdownMenuTrigger>

    <DropdownMenuContent
      :class="ui.content({ class: [!slots.default && props.class, props.ui?.content] })"
      :ui="ui"
      :ui-override="props.ui"
      v-bind="contentProps"
      :items="items"
      :portal="portal"
      :label-key="(labelKey as keyof NestedItem<T>)"
    >
      <template v-for="(_, name) in proxySlots" #[name]="slotData">
        <slot :name="(name as keyof DropdownMenuSlots<T>)" v-bind="slotData" />
      </template>

      <DropdownMenuArrow v-if="!!arrow" v-bind="arrowProps" :class="ui.arrow({ class: props.ui?.arrow })" />
    </DropdownMenuContent>
  </DropdownMenuRoot>
</template>

Theme

dropdown-menu.ts
ts
export default {
  slots: {
    content: '',
    arrow: '',
    group: '',
    label: '',
    separator: '',
    item: '',
    itemLeadingIcon: '',
    itemLeadingAvatar: '',
    itemLeadingAvatarSize: '',
    itemTrailing: '',
    itemTrailingIcon: '',
    itemTrailingKbds: '',
    itemTrailingKbdsSize: '',
    itemLabel: '',
    itemLabelExternalIcon: '',
  },
  variants: {
    color: {
      primary: '',
      secondary: '',
      success: '',
      info: '',
      warning: '',
      error: '',
      neutral: '',
    },
    active: {
      true: {
        item: '',
        itemLeadingIcon: '',
      },
      false: {
        item: '',
        itemLeadingIcon: '',
      },
    },
    loading: {
      true: {
        itemLeadingIcon: '',
      },
    },
    size: {
      xs: {
        label: '',
        item: '',
        itemLeadingIcon: '',
        itemLeadingAvatarSize: '',
        itemTrailingIcon: '',
        itemTrailingKbds: '',
        itemTrailingKbdsSize: '',
      },
      sm: {
        label: '',
        item: '',
        itemLeadingIcon: '',
        itemLeadingAvatarSize: '',
        itemTrailingIcon: '',
        itemTrailingKbds: '',
        itemTrailingKbdsSize: '',
      },
      md: {
        label: '',
        item: '',
        itemLeadingIcon: '',
        itemLeadingAvatarSize: '',
        itemTrailingIcon: '',
        itemTrailingKbds: '',
        itemTrailingKbdsSize: '',
      },
      lg: {
        label: '',
        item: '',
        itemLeadingIcon: '',
        itemLeadingAvatarSize: '',
        itemTrailingIcon: '',
        itemTrailingKbds: '',
        itemTrailingKbdsSize: '',
      },
      xl: {
        label: '',
        item: '',
        itemLeadingIcon: '',
        itemLeadingAvatarSize: '',
        itemTrailingIcon: '',
        itemTrailingKbds: '',
        itemTrailingKbdsSize: '',
      },
    },
  },
  compoundVariants: [],
  defaultVariants: {
    size: 'md',
  } as const,
}
View Nuxt UI theme
dropdown-menu.ts
ts
export default {
  slots: {
    content: 'min-w-32 bg-default shadow-lg rounded-md ring ring-default divide-y divide-default overflow-y-auto scroll-py-1 data-[state=open]:animate-[scale-in_100ms_ease-out] data-[state=closed]:animate-[scale-out_100ms_ease-in]',
    arrow: 'fill-default',
    group: 'p-1 isolate',
    label: 'w-full flex items-center font-semibold text-highlighted',
    separator: '-mx-1 my-1 h-px bg-border',
    item: 'group relative w-full flex items-center select-none outline-none before:absolute before:z-[-1] before:inset-px before:rounded-md data-disabled:cursor-not-allowed data-disabled:opacity-75',
    itemLeadingIcon: 'shrink-0',
    itemLeadingAvatar: 'shrink-0',
    itemLeadingAvatarSize: '',
    itemTrailing: 'ms-auto inline-flex gap-1.5 items-center',
    itemTrailingIcon: 'shrink-0',
    itemTrailingKbds: 'hidden lg:inline-flex items-center shrink-0',
    itemTrailingKbdsSize: '',
    itemLabel: 'truncate',
    itemLabelExternalIcon: 'inline-block size-3 align-top text-dimmed',
  },
  variants: {
    color: {
      primary: '',
      secondary: '',
      success: '',
      info: '',
      warning: '',
      error: '',
      neutral: '',
    },
    active: {
      true: {
        item: 'text-highlighted before:bg-elevated',
        itemLeadingIcon: 'text-default',
      },
      false: {
        item: 'text-default data-highlighted:text-highlighted data-[state=open]:text-highlighted data-highlighted:before:bg-elevated/50 data-[state=open]:before:bg-elevated/50 transition-colors before:transition-colors',
        itemLeadingIcon: 'text-dimmed group-data-highlighted:text-default group-data-[state=open]:text-default transition-colors',
      },
    },
    loading: {
      true: {
        itemLeadingIcon: 'animate-spin',
      },
    },
    size: {
      xs: {
        label: 'p-1 text-xs gap-1',
        item: 'p-1 text-xs gap-1',
        itemLeadingIcon: 'size-4',
        itemLeadingAvatarSize: '3xs',
        itemTrailingIcon: 'size-4',
        itemTrailingKbds: 'gap-0.5',
        itemTrailingKbdsSize: 'sm',
      },
      sm: {
        label: 'p-1.5 text-xs gap-1.5',
        item: 'p-1.5 text-xs gap-1.5',
        itemLeadingIcon: 'size-4',
        itemLeadingAvatarSize: '3xs',
        itemTrailingIcon: 'size-4',
        itemTrailingKbds: 'gap-0.5',
        itemTrailingKbdsSize: 'sm',
      },
      md: {
        label: 'p-1.5 text-sm gap-1.5',
        item: 'p-1.5 text-sm gap-1.5',
        itemLeadingIcon: 'size-5',
        itemLeadingAvatarSize: '2xs',
        itemTrailingIcon: 'size-5',
        itemTrailingKbds: 'gap-0.5',
        itemTrailingKbdsSize: 'md',
      },
      lg: {
        label: 'p-2 text-sm gap-2',
        item: 'p-2 text-sm gap-2',
        itemLeadingIcon: 'size-5',
        itemLeadingAvatarSize: '2xs',
        itemTrailingIcon: 'size-5',
        itemTrailingKbds: 'gap-1',
        itemTrailingKbdsSize: 'md',
      },
      xl: {
        label: 'p-2 text-base gap-2',
        item: 'p-2 text-base gap-2',
        itemLeadingIcon: 'size-6',
        itemLeadingAvatarSize: 'xs',
        itemTrailingIcon: 'size-6',
        itemTrailingKbds: 'gap-1',
        itemTrailingKbdsSize: 'lg',
      },
    },
  },
  compoundVariants: [
    {
      color: 'primary',
      active: false,
      class: {
        item: 'text-primary data-highlighted:text-primary data-highlighted:before:bg-primary/10 data-[state=open]:before:bg-primary/10',
        itemLeadingIcon: 'text-primary/75 group-data-highlighted:text-primary group-data-[state=open]:text-primary',
      },
    } as const,
    {
      color: 'primary',
      active: true,
      class: {
        item: 'text-primary before:bg-primary/10',
        itemLeadingIcon: 'text-primary',
      },
    } as const,
    {
      color: 'secondary',
      active: false,
      class: {
        item: 'text-secondary data-highlighted:text-secondary data-highlighted:before:bg-secondary/10 data-[state=open]:before:bg-secondary/10',
        itemLeadingIcon: 'text-secondary/75 group-data-highlighted:text-secondary group-data-[state=open]:text-secondary',
      },
    } as const,
    {
      color: 'secondary',
      active: true,
      class: {
        item: 'text-secondary before:bg-secondary/10',
        itemLeadingIcon: 'text-secondary',
      },
    } as const,
    {
      color: 'success',
      active: false,
      class: {
        item: 'text-success data-highlighted:text-success data-highlighted:before:bg-success/10 data-[state=open]:before:bg-success/10',
        itemLeadingIcon: 'text-success/75 group-data-highlighted:text-success group-data-[state=open]:text-success',
      },
    } as const,
    {
      color: 'success',
      active: true,
      class: {
        item: 'text-success before:bg-success/10',
        itemLeadingIcon: 'text-success',
      },
    } as const,
    {
      color: 'info',
      active: false,
      class: {
        item: 'text-info data-highlighted:text-info data-highlighted:before:bg-info/10 data-[state=open]:before:bg-info/10',
        itemLeadingIcon: 'text-info/75 group-data-highlighted:text-info group-data-[state=open]:text-info',
      },
    } as const,
    {
      color: 'info',
      active: true,
      class: {
        item: 'text-info before:bg-info/10',
        itemLeadingIcon: 'text-info',
      },
    } as const,
    {
      color: 'warning',
      active: false,
      class: {
        item: 'text-warning data-highlighted:text-warning data-highlighted:before:bg-warning/10 data-[state=open]:before:bg-warning/10',
        itemLeadingIcon: 'text-warning/75 group-data-highlighted:text-warning group-data-[state=open]:text-warning',
      },
    } as const,
    {
      color: 'warning',
      active: true,
      class: {
        item: 'text-warning before:bg-warning/10',
        itemLeadingIcon: 'text-warning',
      },
    } as const,
    {
      color: 'error',
      active: false,
      class: {
        item: 'text-error data-highlighted:text-error data-highlighted:before:bg-error/10 data-[state=open]:before:bg-error/10',
        itemLeadingIcon: 'text-error/75 group-data-highlighted:text-error group-data-[state=open]:text-error',
      },
    } as const,
    {
      color: 'error',
      active: true,
      class: {
        item: 'text-error before:bg-error/10',
        itemLeadingIcon: 'text-error',
      },
    } as const,
  ],
  defaultVariants: {
    size: 'md',
  } as const,
}

Test

To test this component, you can use the following test file:

DropdownMenu.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import DropdownMenu from '@/ui/components/DropdownMenu.vue'
import theme from '@/ui/theme/dropdown-menu'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('container', () => {
  globalThis.ResizeObserver = class ResizeObserver {
    observe() {}
    unobserve() {}
    disconnect() {}
  }

  const sizes = Object.keys(theme.variants.size) as any

  const items = [
    [{
      label: 'My account',
      avatar: {
        src: 'https://github.com/vue.png',
      },
      type: 'label',
    }],
    [{
      label: 'Profile',
      icon: 'i-lucide-user',
      slot: 'custom',
    }, {
      label: 'Billing',
      icon: 'i-lucide-credit-card',
      kbds: ['meta', 'b'],
    }, {
      label: 'Settings',
      icon: 'i-lucide-cog',
      kbds: ['?'],
    }],
    [{
      label: 'Team',
      icon: 'i-lucide-users',
    }, {
      label: 'Invite users',
      icon: 'i-lucide-user-plus',
      children: [[{
        label: 'Invite by email',
        icon: 'i-lucide-send-horizontal',
      }, {
        label: 'Invite by link',
        icon: 'i-lucide-link',
        kbds: ['meta', 'i'],
      }], [{
        label: 'More',
        icon: 'i-lucide-circle-plus',
        children: [{
          label: 'Import from Slack',
          icon: 'i-simple-icons-slack',
          to: 'https://slack.com',
          target: '_blank',
        }, {
          label: 'Import from Trello',
          icon: 'i-simple-icons-trello',
        }, {
          label: 'Import from Asana',
          icon: 'i-simple-icons-asana',
        }],
      }]],
    }, {
      label: 'New team',
      icon: 'i-lucide-plus',
      kbds: ['meta', 'n'],
    }],
    [{
      label: 'GitHub',
      icon: 'i-simple-icons-github',
      to: 'https://github.com/nuxt/ui',
      target: '_blank',
    }, {
      label: 'Support',
      icon: 'i-lucide-life-buoy',
      to: '/components/dropdown-menu',
    }, {
      type: 'separator',
    }, {
      label: 'Keyboard Shortcuts',
      icon: 'i-lucide-key-round',
    }, {
      label: 'API',
      icon: 'i-lucide-box',
      disabled: true,
    }],
    [{
      label: 'Logout',
      color: 'error',
      icon: 'i-lucide-log-out',
      kbds: ['shift', 'meta', 'q'],
    }],
  ]

  const props = { open: true, portal: false, items }

  it.each<[string, RenderOptions<typeof DropdownMenu>]>([
    // Props
    ['with items', { props }],
    ['with disabled', { props: { ...props, disabled: true } }],
    ['with arrow', { props: { ...props, arrow: true } }],
    ...sizes.map((size: string) => [`with size ${size}`, { props: { ...props, size } }]),
    ['with class', { props: { ...props, class: 'min-w-96' } }],
    ['with ui', { props: { ...props, ui: { itemLeadingIcon: 'size-4' } } }],
    // Slots
    ['with default slot', { props, slots: { default: () => 'Default slot' } }],
    ['with item slot', { props, slots: { item: () => 'Item slot' } }],
    ['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }],
    ['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
    ['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
    ['with custom slot', { props, slots: { custom: () => 'Custom slot' } }],
  ])(`renders %s correctly`, async (name, options) => {
    const { html } = render(DropdownMenu, options)

    await new Promise(resolve => setTimeout(resolve, 0))

    expect(html()).toMatchSnapshot()
  })
})
DropdownMenu.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import DropdownMenu from '@/UI/Components/DropdownMenu.vue'
import theme from '@/UI/Theme/dropdown-menu'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('container', () => {
  globalThis.ResizeObserver = class ResizeObserver {
    observe() {}
    unobserve() {}
    disconnect() {}
  }

  const sizes = Object.keys(theme.variants.size) as any

  const items = [
    [{
      label: 'My account',
      avatar: {
        src: 'https://github.com/vue.png',
      },
      type: 'label',
    }],
    [{
      label: 'Profile',
      icon: 'i-lucide-user',
      slot: 'custom',
    }, {
      label: 'Billing',
      icon: 'i-lucide-credit-card',
      kbds: ['meta', 'b'],
    }, {
      label: 'Settings',
      icon: 'i-lucide-cog',
      kbds: ['?'],
    }],
    [{
      label: 'Team',
      icon: 'i-lucide-users',
    }, {
      label: 'Invite users',
      icon: 'i-lucide-user-plus',
      children: [[{
        label: 'Invite by email',
        icon: 'i-lucide-send-horizontal',
      }, {
        label: 'Invite by link',
        icon: 'i-lucide-link',
        kbds: ['meta', 'i'],
      }], [{
        label: 'More',
        icon: 'i-lucide-circle-plus',
        children: [{
          label: 'Import from Slack',
          icon: 'i-simple-icons-slack',
          to: 'https://slack.com',
          target: '_blank',
        }, {
          label: 'Import from Trello',
          icon: 'i-simple-icons-trello',
        }, {
          label: 'Import from Asana',
          icon: 'i-simple-icons-asana',
        }],
      }]],
    }, {
      label: 'New team',
      icon: 'i-lucide-plus',
      kbds: ['meta', 'n'],
    }],
    [{
      label: 'GitHub',
      icon: 'i-simple-icons-github',
      to: 'https://github.com/nuxt/ui',
      target: '_blank',
    }, {
      label: 'Support',
      icon: 'i-lucide-life-buoy',
      to: '/components/dropdown-menu',
    }, {
      type: 'separator',
    }, {
      label: 'Keyboard Shortcuts',
      icon: 'i-lucide-key-round',
    }, {
      label: 'API',
      icon: 'i-lucide-box',
      disabled: true,
    }],
    [{
      label: 'Logout',
      color: 'error',
      icon: 'i-lucide-log-out',
      kbds: ['shift', 'meta', 'q'],
    }],
  ]

  const props = { open: true, portal: false, items }

  it.each<[string, RenderOptions<typeof DropdownMenu>]>([
    // Props
    ['with items', { props }],
    ['with disabled', { props: { ...props, disabled: true } }],
    ['with arrow', { props: { ...props, arrow: true } }],
    ...sizes.map((size: string) => [`with size ${size}`, { props: { ...props, size } }]),
    ['with class', { props: { ...props, class: 'min-w-96' } }],
    ['with ui', { props: { ...props, ui: { itemLeadingIcon: 'size-4' } } }],
    // Slots
    ['with default slot', { props, slots: { default: () => 'Default slot' } }],
    ['with item slot', { props, slots: { item: () => 'Item slot' } }],
    ['with item-leading slot', { props, slots: { 'item-leading': () => 'Item leading slot' } }],
    ['with item-label slot', { props, slots: { 'item-label': () => 'Item label slot' } }],
    ['with item-trailing slot', { props, slots: { 'item-trailing': () => 'Item trailing slot' } }],
    ['with custom slot', { props, slots: { custom: () => 'Custom slot' } }],
  ])(`renders %s correctly`, async (name, options) => {
    const { html } = render(DropdownMenu, options)

    await new Promise(resolve => setTimeout(resolve, 0))

    expect(html()).toMatchSnapshot()
  })
})

Contributors

barbapapazes

Changelog

5ffcd - feat: add DropdowmMenu component (#92) on 1/9/2025