Dropdown menu

A menu of actions with arrow-key navigation, type-to-select, checkbox and radio items, and submenus.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add dropdown-menu
pnpm dlx ermineui add dropdown-menu
yarn dlx ermineui add dropdown-menu
bunx ermineui add dropdown-menu

That copies 16 files into src/components/ui/dropdown-menu/, 5 helpers into src/lib/ermine/. Files you already have are kept.

Use

import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
<script setup lang="ts">
import { DropdownMenu } from '@/components/ui/dropdown-menu'
</script>

<template>
  <DropdownMenu />
</template>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island. The props are documented in the source below, on each one.

What gets copied

FileWhere it goes
DropdownMenu.vuesrc/components/ui/dropdown-menu/DropdownMenu.vue
DropdownMenuCheckboxItem.vuesrc/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue
DropdownMenuContent.vuesrc/components/ui/dropdown-menu/DropdownMenuContent.vue
DropdownMenuGroup.vuesrc/components/ui/dropdown-menu/DropdownMenuGroup.vue
DropdownMenuItem.vuesrc/components/ui/dropdown-menu/DropdownMenuItem.vue
DropdownMenuLabel.vuesrc/components/ui/dropdown-menu/DropdownMenuLabel.vue
DropdownMenuRadioGroup.vuesrc/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue
DropdownMenuRadioItem.vuesrc/components/ui/dropdown-menu/DropdownMenuRadioItem.vue
DropdownMenuSeparator.vuesrc/components/ui/dropdown-menu/DropdownMenuSeparator.vue
DropdownMenuShortcut.vuesrc/components/ui/dropdown-menu/DropdownMenuShortcut.vue
DropdownMenuSub.vuesrc/components/ui/dropdown-menu/DropdownMenuSub.vue
DropdownMenuSubContent.vuesrc/components/ui/dropdown-menu/DropdownMenuSubContent.vue
DropdownMenuSubTrigger.vuesrc/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue
DropdownMenuTrigger.vuesrc/components/ui/dropdown-menu/DropdownMenuTrigger.vue
context.tssrc/components/ui/dropdown-menu/context.ts
index.tssrc/components/ui/dropdown-menu/index.ts
collection.ts helpersrc/lib/ermine/collection.ts
context.ts helpersrc/lib/ermine/context.ts
floating.ts helpersrc/lib/ermine/floating.ts
layer.ts helpersrc/lib/ermine/layer.ts
primitive.ts helpersrc/lib/ermine/primitive.ts

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

DropdownMenu.vue 24 lines
<script setup lang="ts">
import { ref, useId } from 'vue'
import { provideDropdownMenu } from './context'

const open = defineModel<boolean>('open', { default: false })

function setOpen(next: boolean) {
  open.value = next
}

provideDropdownMenu({
  open,
  setOpen,
  anchor: ref(null),
  focusFirst: ref(false),
  triggerId: useId(),
  contentId: useId(),
})
</script>

<template>
  <slot :open="open" :close="() => setOpen(false)" />
</template>
DropdownMenuCheckboxItem.vue 46 lines
<script setup lang="ts">
import { onItemKeydown, onItemPointerLeave, onItemPointerMove, useDropdownMenu } from './context'

const props = defineProps<{
  disabled?: boolean
  /** Close the menu after toggling. Off by default, so several boxes can be changed in one go. */
  closeOnSelect?: boolean
}>()

const emit = defineEmits<{
  select: [event: Event]
}>()

const checked = defineModel<boolean>({ default: false })
const menu = useDropdownMenu()

function onClick(event: MouseEvent) {
  if (props.disabled) return
  checked.value = !checked.value
  emit('select', event)
  if (props.closeOnSelect) menu.setOpen(false)
}
</script>

<template>
  <div
    role="menuitemcheckbox"
    tabindex="-1"
    class="er-menu-item"
    :aria-checked="checked"
    :aria-disabled="disabled || undefined"
    :data-disabled="disabled ? '' : undefined"
    @click="onClick"
    @keydown="onItemKeydown"
    @pointermove="onItemPointerMove"
    @pointerleave="onItemPointerLeave"
  >
    <span class="er-menu-indicator">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="M20 6 9 17l-5-5" />
      </svg>
    </span>
    <slot />
  </div>
</template>
DropdownMenuContent.vue 176 lines
<script setup lang="ts">
import { ref } from 'vue'
import { arrowTarget, createTypeahead } from '../../lib/collection'
import { useFloating, type Align, type Side } from '../../lib/floating'
import { useLayer } from '../../lib/layer'
import { Portal } from '../../lib/primitive'
import { useDropdownMenu } from './context'

defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    side?: Side
    align?: Align
    /** Gap between trigger and menu, in px. */
    sideOffset?: number
    alignOffset?: number
  }>(),
  { side: 'bottom', align: 'start', sideOffset: 4, alignOffset: 0 },
)

const menu = useDropdownMenu()
const content = ref<HTMLElement | null>(null)
const typeahead = createTypeahead()

function items(): HTMLElement[] {
  return Array.from(content.value?.querySelectorAll<HTMLElement>('[role^="menuitem"]:not([data-disabled])') ?? [])
}

useFloating(menu.anchor, content, menu.open, props)
useLayer(content, menu.open, {
  ignore: () => [menu.anchor.value],
  onDismiss: () => menu.setOpen(false),
  initialFocus: () => (menu.focusFirst.value ? items()[0] : null) ?? content.value,
})

function onKeydown(event: KeyboardEvent) {
  if (event.key === 'Tab') {
    event.preventDefault()
    return
  }
  const list = items()
  const target = arrowTarget(event, list, 'vertical') ?? typeahead(event, list)
  if (!target) return
  event.preventDefault()
  target.focus()
}
</script>

<template>
  <Portal>
    <Transition name="er-pop">
      <div
        v-if="menu.open.value"
        :id="menu.contentId"
        ref="content"
        v-bind="$attrs"
        role="menu"
        tabindex="-1"
        class="er-floating er-menu"
        :aria-labelledby="menu.anchor.value?.id || undefined"
        @keydown="onKeydown"
      >
        <slot />
      </div>
    </Transition>
  </Portal>
</template>

<style>
@layer components {
  .er-menu {
    min-width: 10rem;
    max-height: min(24rem, var(--er-available-size, 24rem));
    padding: 0.25rem;
    border: 1px solid var(--er-border);
    border-radius: var(--er-radius-md);
    background: var(--er-popover);
    color: var(--er-popover-foreground);
    font-size: 0.875rem;
    box-shadow: var(--er-shadow-md);
    overflow-y: auto;
  }

  .er-menu-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.375rem 0.5rem;
    border-radius: var(--er-radius-sm);
    color: inherit;
    line-height: 1.25rem;
    text-decoration: none;
    cursor: default;
    user-select: none;
    outline: none;
  }

  .er-menu-item:focus {
    background: var(--er-accent);
    color: var(--er-accent-foreground);
  }

  .er-menu-item[data-variant='destructive'] {
    color: var(--er-destructive);
  }

  .er-menu-item[data-variant='destructive']:focus {
    background: color-mix(in oklab, var(--er-destructive) 12%, transparent);
    color: var(--er-destructive);
  }

  .er-menu-item[data-disabled] {
    opacity: 0.5;
    pointer-events: none;
  }

  .er-menu-item svg {
    flex-shrink: 0;
    width: 1rem;
    height: 1rem;
    color: var(--er-muted-foreground);
  }

  .er-menu-item[data-variant='destructive'] svg {
    color: inherit;
  }

  /* Checkbox and radio items keep a fixed slot for their mark, so labels line up whether or not it shows. */
  .er-menu-indicator {
    display: inline-grid;
    flex-shrink: 0;
    place-items: center;
    width: 1rem;
    height: 1rem;
  }

  .er-menu-indicator svg {
    color: currentColor;
  }

  .er-menu-item[aria-checked='false'] .er-menu-indicator svg {
    visibility: hidden;
  }

  .er-menu-item[aria-haspopup='menu'][data-state='open'] {
    background: var(--er-accent);
    color: var(--er-accent-foreground);
  }

  .er-menu-chevron {
    margin-left: auto;
  }

  .er-menu-shortcut {
    margin-left: auto;
    padding-left: 1.5rem;
    color: var(--er-muted-foreground);
    font-size: 0.75rem;
    letter-spacing: 0.08em;
  }

  .er-menu-label {
    padding: 0.375rem 0.5rem;
    font-size: 0.8125rem;
    font-weight: var(--er-weight-semibold);
  }

  .er-menu-separator {
    height: 1px;
    margin: 0.25rem -0.25rem;
    background: var(--er-border);
  }
}
</style>
DropdownMenuGroup.vue 5 lines
<template>
  <!-- Give it an aria-label (or aria-labelledby pointing at a label) so screen readers name the group. -->
  <div role="group" class="er-menu-group"><slot /></div>
</template>
DropdownMenuItem.vue 64 lines
<script setup lang="ts">
import type { Component } from 'vue'
import { Primitive } from '../../lib/primitive'
import { useDropdownMenu } from './context'

const props = withDefaults(
  defineProps<{
    /** Tag or component to render — use "a" (or a router link) for navigation items. */
    as?: string | Component
    disabled?: boolean
    variant?: 'default' | 'destructive'
  }>(),
  { as: 'div', variant: 'default' },
)

const emit = defineEmits<{
  /** Fired when the item is chosen. Call event.preventDefault() to keep the menu open. */
  select: [event: Event]
}>()

const menu = useDropdownMenu()

function onClick(event: MouseEvent) {
  if (props.disabled) {
    event.preventDefault()
    return
  }
  emit('select', event)
  if (!event.defaultPrevented) menu.setOpen(false)
}

function onKeydown(event: KeyboardEvent) {
  if (event.key !== 'Enter' && event.key !== ' ') return
  event.preventDefault()
  ;(event.currentTarget as HTMLElement).click()
}

function onPointerMove(event: PointerEvent) {
  if (!props.disabled) (event.currentTarget as HTMLElement).focus({ preventScroll: true })
}

function onPointerLeave(event: PointerEvent) {
  ;(event.currentTarget as HTMLElement).closest<HTMLElement>('[role="menu"]')?.focus({ preventScroll: true })
}
</script>

<template>
  <Primitive
    :as="as"
    role="menuitem"
    tabindex="-1"
    class="er-menu-item"
    :data-variant="variant"
    :data-disabled="disabled ? '' : undefined"
    :aria-disabled="disabled || undefined"
    @click="onClick"
    @keydown="onKeydown"
    @pointermove="onPointerMove"
    @pointerleave="onPointerLeave"
  >
    <slot />
  </Primitive>
</template>
DropdownMenuLabel.vue 4 lines
<template>
  <div class="er-menu-label"><slot /></div>
</template>
DropdownMenuRadioGroup.vue 17 lines
<script setup lang="ts">
import { provideDropdownMenuRadio } from './context'

const value = defineModel<string>()

provideDropdownMenuRadio({
  value,
  select: (next) => {
    value.value = next
  },
})
</script>

<template>
  <div role="group" class="er-menu-group"><slot /></div>
</template>
DropdownMenuRadioItem.vue 49 lines
<script setup lang="ts">
import { computed } from 'vue'
import { onItemKeydown, onItemPointerLeave, onItemPointerMove, useDropdownMenu, useDropdownMenuRadio } from './context'

const props = defineProps<{
  value: string
  disabled?: boolean
  /** Close the menu after choosing. Off by default, like the checkbox item. */
  closeOnSelect?: boolean
}>()

const emit = defineEmits<{
  select: [event: Event]
}>()

const menu = useDropdownMenu()
const group = useDropdownMenuRadio()
const checked = computed(() => group.value.value === props.value)

function onClick(event: MouseEvent) {
  if (props.disabled) return
  group.select(props.value)
  emit('select', event)
  if (props.closeOnSelect) menu.setOpen(false)
}
</script>

<template>
  <div
    role="menuitemradio"
    tabindex="-1"
    class="er-menu-item"
    :aria-checked="checked"
    :aria-disabled="disabled || undefined"
    :data-disabled="disabled ? '' : undefined"
    @click="onClick"
    @keydown="onItemKeydown"
    @pointermove="onItemPointerMove"
    @pointerleave="onItemPointerLeave"
  >
    <span class="er-menu-indicator">
      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
        <circle cx="12" cy="12" r="5" />
      </svg>
    </span>
    <slot />
  </div>
</template>
DropdownMenuSeparator.vue 4 lines
<template>
  <div role="separator" class="er-menu-separator" />
</template>
DropdownMenuShortcut.vue 4 lines
<template>
  <span class="er-menu-shortcut" aria-hidden="true"><slot /></span>
</template>
DropdownMenuSub.vue 51 lines
<script setup lang="ts">
import { onBeforeUnmount, ref, useId, watch } from 'vue'
import { provideDropdownMenuSub, useDropdownMenu } from './context'

const menu = useDropdownMenu()
const open = ref(false)
const focusFirst = ref(false)
const anchor = ref<HTMLElement | null>(null)
const content = ref<HTMLElement | null>(null)

let timer: ReturnType<typeof setTimeout> | undefined
function cancelClose() {
  clearTimeout(timer)
}

function setOpen(next: boolean, first = false) {
  cancelClose()
  focusFirst.value = next && first
  open.value = next
}

provideDropdownMenuSub({
  open,
  setOpen,
  focusFirst,
  anchor,
  content,
  contentId: useId(),
  // The short wait lets the pointer travel diagonally from the trigger into the submenu without it closing.
  scheduleClose: () => {
    cancelClose()
    timer = setTimeout(() => setOpen(false), 150)
  },
  cancelClose,
})

// When the whole menu closes, this closes with it. Focus steps back into the parent menu first,
// so the menu can hand it back to the button that opened it.
watch(menu.open, (isOpen) => {
  if (isOpen || !open.value) return
  if (content.value?.contains(document.activeElement)) anchor.value?.focus({ preventScroll: true })
  setOpen(false)
})

onBeforeUnmount(cancelClose)
</script>

<template>
  <slot :open="open" />
</template>
DropdownMenuSubContent.vue 79 lines
<script setup lang="ts">
import { computed } from 'vue'
import { arrowTarget, createTypeahead } from '../../lib/collection'
import { useFloating } from '../../lib/floating'
import { useLayer } from '../../lib/layer'
import { Portal } from '../../lib/primitive'
import { menuItems, useDropdownMenu, useDropdownMenuSub } from './context'

defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    /** Gap between the parent menu and this one, in px. */
    sideOffset?: number
    /** Lifts the submenu so its first item lines up with the item that opened it. */
    alignOffset?: number
  }>(),
  { sideOffset: 2, alignOffset: -5 },
)

const menu = useDropdownMenu()
const sub = useDropdownMenuSub()
const typeahead = createTypeahead()
const active = computed(() => sub.open.value && menu.open.value)

function closeToTrigger() {
  sub.setOpen(false)
  sub.anchor.value?.focus({ preventScroll: true })
}

useFloating(sub.anchor, sub.content, active, { side: 'right', align: 'start', sideOffset: props.sideOffset, alignOffset: props.alignOffset })
useLayer(sub.content, active, {
  ignore: () => [sub.anchor.value],
  // Escape steps back one level; a press elsewhere just closes this level and lets the press through.
  onDismiss: (reason) => (reason === 'escape' ? closeToTrigger() : sub.setOpen(false)),
  // Opened by hovering, focus stays on the trigger; opened from the keyboard, it moves to the first item.
  initialFocus: () => (sub.focusFirst.value ? (menuItems(sub.content.value)[0] ?? sub.content.value) : false),
  restoreFocus: false,
})

function onKeydown(event: KeyboardEvent) {
  if (event.key === 'Tab') {
    event.preventDefault()
    return
  }
  if (event.key === 'ArrowLeft') {
    event.preventDefault()
    closeToTrigger()
    return
  }
  const list = menuItems(sub.content.value)
  const target = arrowTarget(event, list, 'vertical') ?? typeahead(event, list)
  if (!target) return
  event.preventDefault()
  target.focus()
}
</script>

<template>
  <Portal>
    <Transition name="er-pop">
      <div
        v-if="active"
        :id="sub.contentId"
        :ref="(el) => (sub.content.value = el as HTMLElement | null)"
        v-bind="$attrs"
        role="menu"
        tabindex="-1"
        class="er-floating er-menu"
        @keydown="onKeydown"
        @pointerenter="sub.cancelClose()"
        @focusin="sub.cancelClose()"
      >
        <slot />
      </div>
    </Transition>
  </Portal>
</template>
DropdownMenuSubTrigger.vue 55 lines
<script setup lang="ts">
import { menuItems, useDropdownMenuSub } from './context'

const props = defineProps<{
  disabled?: boolean
}>()

const sub = useDropdownMenuSub()

function onPointerMove(event: PointerEvent) {
  if (props.disabled) return
  ;(event.currentTarget as HTMLElement).focus({ preventScroll: true })
  if (sub.open.value) sub.cancelClose()
  else sub.setOpen(true)
}

function onKeydown(event: KeyboardEvent) {
  if (props.disabled || (event.key !== 'ArrowRight' && event.key !== 'Enter' && event.key !== ' ')) return
  event.preventDefault()
  // Already open from hovering: the keyboard just steps inside.
  if (sub.open.value) menuItems(sub.content.value)[0]?.focus()
  else sub.setOpen(true, true)
}

function onBlur(event: FocusEvent) {
  const next = event.relatedTarget
  if (next instanceof Node && sub.content.value?.contains(next)) return
  sub.scheduleClose()
}
</script>

<template>
  <div
    :ref="(el) => (sub.anchor.value = el as HTMLElement | null)"
    role="menuitem"
    tabindex="-1"
    class="er-menu-item"
    aria-haspopup="menu"
    :aria-expanded="sub.open.value"
    :aria-controls="sub.open.value ? sub.contentId : undefined"
    :aria-disabled="disabled || undefined"
    :data-disabled="disabled ? '' : undefined"
    :data-state="sub.open.value ? 'open' : 'closed'"
    @click="!disabled && sub.setOpen(true)"
    @keydown="onKeydown"
    @pointermove="onPointerMove"
    @blur="onBlur"
  >
    <slot />
    <svg class="er-menu-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="m9 18 6-6-6-6" />
    </svg>
  </div>
</template>
DropdownMenuTrigger.vue 52 lines
<script setup lang="ts">
import type { Component } from 'vue'
import { Primitive, toElement } from '../../lib/primitive'
import { useDropdownMenu } from './context'

withDefaults(
  defineProps<{
    as?: string | Component
    /** Use the single child (e.g. a Button) as the trigger instead of rendering one. */
    asChild?: boolean
  }>(),
  { as: 'button' },
)

const menu = useDropdownMenu()

function setAnchor(target: unknown) {
  menu.anchor.value = toElement(target)
}

function onClick(event: MouseEvent) {
  // detail is 0 when the click came from Enter or Space rather than a pointer.
  menu.focusFirst.value = event.detail === 0
  menu.setOpen(!menu.open.value)
}

function onKeydown(event: KeyboardEvent) {
  if (event.key !== 'ArrowDown' || menu.open.value) return
  event.preventDefault()
  menu.focusFirst.value = true
  menu.setOpen(true)
}
</script>

<template>
  <Primitive
    :id="menu.triggerId"
    :ref="setAnchor"
    :as="as"
    :as-child="asChild"
    :type="as === 'button' && !asChild ? 'button' : undefined"
    aria-haspopup="menu"
    :aria-expanded="menu.open.value"
    :aria-controls="menu.open.value ? menu.contentId : undefined"
    :data-state="menu.open.value ? 'open' : 'closed'"
    @click="onClick"
    @keydown="onKeydown"
  >
    <slot />
  </Primitive>
</template>
context.ts 59 lines
import type { Ref } from 'vue'
import { createContext } from '../../lib/context'

export interface DropdownMenuContext {
  open: Ref<boolean>
  setOpen: (open: boolean) => void
  anchor: Ref<HTMLElement | null>
  /** True when the menu was opened from the keyboard, so focus goes to the first item. */
  focusFirst: Ref<boolean>
  triggerId: string
  contentId: string
}

export const [provideDropdownMenu, useDropdownMenu] = createContext<DropdownMenuContext>('DropdownMenu')

export interface DropdownMenuRadioContext {
  value: Ref<string | undefined>
  select: (value: string) => void
}

export const [provideDropdownMenuRadio, useDropdownMenuRadio] = createContext<DropdownMenuRadioContext>('DropdownMenuRadioGroup')

export interface DropdownMenuSubContext {
  open: Ref<boolean>
  /** Open or close the submenu. `focusFirst` moves focus to its first item, as the keyboard expects. */
  setOpen: (open: boolean, focusFirst?: boolean) => void
  focusFirst: Ref<boolean>
  anchor: Ref<HTMLElement | null>
  content: Ref<HTMLElement | null>
  contentId: string
  /** Close shortly, unless the pointer arrives in the submenu first. */
  scheduleClose: () => void
  cancelClose: () => void
}

export const [provideDropdownMenuSub, useDropdownMenuSub] = createContext<DropdownMenuSubContext>('DropdownMenuSub')

/** Items of one menu that can take focus, in order. */
export function menuItems(menu: HTMLElement | null | undefined): HTMLElement[] {
  return Array.from(menu?.querySelectorAll<HTMLElement>('[role^="menuitem"]:not([data-disabled])') ?? [])
}

/** Enter and Space activate the focused item, the same as a click. */
export function onItemKeydown(event: KeyboardEvent) {
  if (event.key !== 'Enter' && event.key !== ' ') return
  event.preventDefault()
  ;(event.currentTarget as HTMLElement).click()
}

/** The item under the pointer takes focus, so hover and keyboard highlight are one and the same. */
export function onItemPointerMove(event: PointerEvent) {
  const item = event.currentTarget as HTMLElement
  if (!item.hasAttribute('data-disabled')) item.focus({ preventScroll: true })
}

export function onItemPointerLeave(event: PointerEvent) {
  ;(event.currentTarget as HTMLElement).closest<HTMLElement>('[role="menu"]')?.focus({ preventScroll: true })
}
index.ts 16 lines
/** Dropdown menu — a menu of actions with arrow-key navigation, type-to-select, checkbox and radio items, and submenus. */
export { default as DropdownMenu } from './DropdownMenu.vue'
export { default as DropdownMenuCheckboxItem } from './DropdownMenuCheckboxItem.vue'
export { default as DropdownMenuContent } from './DropdownMenuContent.vue'
export { default as DropdownMenuGroup } from './DropdownMenuGroup.vue'
export { default as DropdownMenuItem } from './DropdownMenuItem.vue'
export { default as DropdownMenuLabel } from './DropdownMenuLabel.vue'
export { default as DropdownMenuRadioGroup } from './DropdownMenuRadioGroup.vue'
export { default as DropdownMenuRadioItem } from './DropdownMenuRadioItem.vue'
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.vue'
export { default as DropdownMenuShortcut } from './DropdownMenuShortcut.vue'
export { default as DropdownMenuSub } from './DropdownMenuSub.vue'
export { default as DropdownMenuSubContent } from './DropdownMenuSubContent.vue'
export { default as DropdownMenuSubTrigger } from './DropdownMenuSubTrigger.vue'
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.vue'