DropdownMenu 下拉菜单

点击触发器展开的一组操作。

<script setup lang="ts">
  import { Copy, Download, Pencil, Trash2 } from '@lucide/vue'
  import {
    Button,
    DisclosureIcon,
    DropdownMenu,
    DropdownMenuItem,
    DropdownMenuSeparator,
  } from '@hina-ui/vue'
</script>

<template>
  <DropdownMenu label="文件操作" align="start">
    <Button variant="outline" tone="neutral">
      文件
      <template #trailing>
        <DisclosureIcon />
      </template>
    </Button>
    <template #content>
      <DropdownMenuItem>
        <template #icon>
          <Pencil />
        </template>
        重命名
      </DropdownMenuItem>
      <DropdownMenuItem>
        <template #icon>
          <Copy />
        </template>
        复制
      </DropdownMenuItem>
      <DropdownMenuItem>
        <template #icon>
          <Download />
        </template>
        下载
      </DropdownMenuItem>
      <DropdownMenuSeparator />
      <DropdownMenuItem tone="danger">
        <template #icon>
          <Trash2 />
        </template>
        删除
      </DropdownMenuItem>
    </template>
  </DropdownMenu>
</template>
vue

用法

import { DropdownMenu, DropdownMenuItem } from '@hina-ui/vue'
ts

默认插槽是触发器,content 插槽是菜单里的条目。点击触发器展开,选中条目后自动收起。

<script setup lang="ts">
  import { Button, DropdownMenu, DropdownMenuItem } from '@hina-ui/vue'
</script>

<template>
  <DropdownMenu label="更多操作">
    <Button variant="outline" tone="neutral">更多操作</Button>
    <template #content>
      <DropdownMenuItem>编辑</DropdownMenuItem>
      <DropdownMenuItem>复制</DropdownMenuItem>
      <DropdownMenuItem>归档</DropdownMenuItem>
    </template>
  </DropdownMenu>
</template>
vue

示例

条目

条目的 icon 插槽用于前置图标,trailing 插槽用于尾部内容。删除这类不可撤销的操作设置 tone="danger"。

<script setup lang="ts">
  import { ExternalLink, Pencil, Share2, Trash2 } from '@lucide/vue'
  import { Button, DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from '@hina-ui/vue'
</script>

<template>
  <DropdownMenu label="条目">
    <Button variant="outline" tone="neutral">条目</Button>
    <template #content>
      <DropdownMenuItem>
        <template #icon>
          <Pencil />
        </template>
        编辑
      </DropdownMenuItem>
      <DropdownMenuItem>
        <template #icon>
          <Share2 />
        </template>
        分享
        <template #trailing>
          <ExternalLink class="text-faint size-3.5" />
        </template>
      </DropdownMenuItem>
      <DropdownMenuSeparator />
      <DropdownMenuItem tone="danger">
        <template #icon>
          <Trash2 />
        </template>
        删除
      </DropdownMenuItem>
    </template>
  </DropdownMenu>
</template>
vue

标题与分隔线

DropdownMenuLabel 是不可选中的分组标题,DropdownMenuSeparator 画一条分隔线。

<script setup lang="ts">
  import {
    Avatar,
    Button,
    DropdownMenu,
    DropdownMenuItem,
    DropdownMenuLabel,
    DropdownMenuSeparator,
  } from '@hina-ui/vue'
</script>

<template>
  <DropdownMenu label="账户">
    <Button variant="outline" tone="neutral">
      <template #icon>
        <Avatar size="sm" src="/avatars/huh.webp" alt="星见书音" />
      </template>
      星见书音
    </Button>
    <template #content>
      <DropdownMenuLabel>我的账户</DropdownMenuLabel>
      <DropdownMenuItem>个人资料</DropdownMenuItem>
      <DropdownMenuItem>偏好设置</DropdownMenuItem>
      <DropdownMenuSeparator />
      <DropdownMenuLabel>工作区</DropdownMenuLabel>
      <DropdownMenuItem>成员</DropdownMenuItem>
      <DropdownMenuItem>计费</DropdownMenuItem>
    </template>
  </DropdownMenu>
</template>
vue

快捷键

条目对应的快捷键放在 trailing 插槽中,用 Kbd 呈现。这里只是标注,按键的注册仍由页面负责。

<script setup lang="ts">
  import { Copy, Scissors, Trash2 } from '@lucide/vue'
  import { Button, DropdownMenu, DropdownMenuItem, Kbd } from '@hina-ui/vue'
</script>

<template>
  <DropdownMenu label="编辑">
    <Button variant="outline" tone="neutral">编辑</Button>
    <template #content>
      <DropdownMenuItem>
        <template #icon>
          <Copy />
        </template>
        复制
        <template #trailing>
          <Kbd>Ctrl C</Kbd>
        </template>
      </DropdownMenuItem>
      <DropdownMenuItem>
        <template #icon>
          <Scissors />
        </template>
        剪切
        <template #trailing>
          <Kbd>Ctrl X</Kbd>
        </template>
      </DropdownMenuItem>
      <DropdownMenuItem tone="danger">
        <template #icon>
          <Trash2 />
        </template>
        删除
        <template #trailing>
          <Kbd>Del</Kbd>
        </template>
      </DropdownMenuItem>
    </template>
  </DropdownMenu>
</template>
vue

分组

DropdownMenuGroup 把相关的条目归为一组。组内带 DropdownMenuLabel 时,屏幕阅读器把这个标题作为整组的名称播报。

<script setup lang="ts">
  import {
    Avatar,
    Button,
    DropdownMenu,
    DropdownMenuGroup,
    DropdownMenuItem,
    DropdownMenuLabel,
    DropdownMenuSeparator,
  } from '@hina-ui/vue'
</script>

<template>
  <DropdownMenu label="账户">
    <Button variant="outline" tone="neutral">
      <template #icon>
        <Avatar size="sm" src="/avatars/huh.webp" alt="星见书音" />
      </template>
      星见书音
    </Button>
    <template #content>
      <DropdownMenuGroup>
        <DropdownMenuLabel>我的账户</DropdownMenuLabel>
        <DropdownMenuItem>个人资料</DropdownMenuItem>
        <DropdownMenuItem>偏好设置</DropdownMenuItem>
      </DropdownMenuGroup>
      <DropdownMenuSeparator />
      <DropdownMenuGroup>
        <DropdownMenuLabel>工作区</DropdownMenuLabel>
        <DropdownMenuItem>成员</DropdownMenuItem>
        <DropdownMenuItem>计费</DropdownMenuItem>
      </DropdownMenuGroup>
    </template>
  </DropdownMenu>
</template>
vue

多选项

DropdownMenuCheckboxItem 用于可以同时选中多项的开关,checked 支持双向绑定。选中后条目末尾出现选中标记,菜单保持展开。

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuLabel } from '@hina-ui/vue'

  const showCover = ref(true)
  const showSummary = ref(false)
  const showTags = ref(true)
</script>

<template>
  <DropdownMenu label="显示项">
    <Button variant="outline" tone="neutral">显示项</Button>
    <template #content>
      <DropdownMenuLabel>列表中显示</DropdownMenuLabel>
      <DropdownMenuCheckboxItem v-model:checked="showCover">封面</DropdownMenuCheckboxItem>
      <DropdownMenuCheckboxItem v-model:checked="showSummary">简介</DropdownMenuCheckboxItem>
      <DropdownMenuCheckboxItem v-model:checked="showTags">标签</DropdownMenuCheckboxItem>
    </template>
  </DropdownMenu>
</template>
vue

单选项

一组互斥的选项用 DropdownMenuRadioGroup 包裹,当前项自动带选中标记。

<script setup lang="ts">
  import { ref } from 'vue'
  import {
    Button,
    DropdownMenu,
    DropdownMenuLabel,
    DropdownMenuRadioGroup,
    DropdownMenuRadioItem,
  } from '@hina-ui/vue'

  const sort = ref('newest')
  const labels: Record<string, string> = {
    newest: '最新发布',
    popular: '最多收藏',
    rating: '评分最高',
  }
</script>

<template>
  <DropdownMenu label="排序方式">
    <Button variant="outline" tone="neutral">排序:{{ labels[sort] }}</Button>
    <template #content>
      <DropdownMenuLabel>排序方式</DropdownMenuLabel>
      <DropdownMenuRadioGroup v-model="sort">
        <DropdownMenuRadioItem value="newest">最新发布</DropdownMenuRadioItem>
        <DropdownMenuRadioItem value="popular">最多收藏</DropdownMenuRadioItem>
        <DropdownMenuRadioItem value="rating">评分最高</DropdownMenuRadioItem>
      </DropdownMenuRadioGroup>
    </template>
  </DropdownMenu>
</template>
vue

位置

side 指定菜单朝哪个方向展开,align 指定它与触发器的对齐方式。默认在正下方。

<script setup lang="ts">
  import { Button, DropdownMenu, DropdownMenuItem, Inline } from '@hina-ui/vue'
</script>

<template>
  <Inline align="center" class="gap-6">
    <DropdownMenu label="向下对齐起始边" align="start">
      <Button variant="outline" tone="neutral">start</Button>
      <template #content>
        <DropdownMenuItem>第一项</DropdownMenuItem>
        <DropdownMenuItem>第二项</DropdownMenuItem>
      </template>
    </DropdownMenu>
    <DropdownMenu label="向下居中" align="center">
      <Button variant="outline" tone="neutral">center</Button>
      <template #content>
        <DropdownMenuItem>第一项</DropdownMenuItem>
        <DropdownMenuItem>第二项</DropdownMenuItem>
      </template>
    </DropdownMenu>
    <DropdownMenu label="向右展开" side="right" align="start">
      <Button variant="outline" tone="neutral">right</Button>
      <template #content>
        <DropdownMenuItem>第一项</DropdownMenuItem>
        <DropdownMenuItem>第二项</DropdownMenuItem>
      </template>
    </DropdownMenu>
  </Inline>
</template>
vue

受控

open 支持双向绑定,可以从外部展开或收起菜单。

当前:收起

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, DropdownMenu, DropdownMenuItem, Inline, Text } from '@hina-ui/vue'

  const open = ref(false)
</script>

<template>
  <Inline align="center">
    <DropdownMenu v-model:open="open" label="受控菜单">
      <Button variant="outline" tone="neutral">菜单</Button>
      <template #content>
        <DropdownMenuItem>第一项</DropdownMenuItem>
        <DropdownMenuItem>第二项</DropdownMenuItem>
      </template>
    </DropdownMenu>
    <Button size="sm" variant="soft" tone="neutral" @click="open = !open">
      从外部{{ open ? '收起' : '展开' }}
    </Button>
    <Text tone="muted" size="sm">当前:{{ open ? '展开' : '收起' }}</Text>
  </Inline>
</template>
vue

外部锚点

anchor 接受 OverlayAnchor | null,可省略默认插槽并使用 v-model:open 控制开关。锚点未就绪时菜单不显示;打开期间可以更换锚点,关闭时清空锚点会保留退场位置。

同时提供默认插槽与 anchor 时,插槽负责触发,anchor 负责定位。外部元素的点击与键盘行为、aria-haspopup="menu" 和 aria-expanded 由调用方设置,菜单内部的键盘导航保持不变。外部定位规则与 Popover 一致。

<script setup lang="ts">
  import { ref, shallowRef } from 'vue'
  import { Button, DropdownMenu, DropdownMenuItem, Inline } from '@hina-ui/vue'

  const open = ref(false)
  const current = ref(0)
  const anchor = shallowRef<HTMLElement | null>(null)

  function toggle(event: MouseEvent | KeyboardEvent, index: number) {
    const target = event.currentTarget as HTMLElement
    open.value = event.type === 'keydown' || anchor.value !== target || !open.value
    anchor.value = target
    current.value = index
  }
</script>

<template>
  <Inline>
    <Button
      v-for="index in 2"
      :key="index"
      variant="outline"
      tone="neutral"
      aria-haspopup="menu"
      :aria-expanded="open && current === index"
      @click="toggle($event, index)"
      @keydown.down.prevent="toggle($event, index)"
    >
      锚点 {{ index }}
    </Button>
    <DropdownMenu v-model:open="open" :anchor="anchor" :modal="false" label="外部菜单">
      <template #content>
        <DropdownMenuItem>复制</DropdownMenuItem>
        <DropdownMenuItem>重命名</DropdownMenuItem>
        <DropdownMenuItem disabled>不可用</DropdownMenuItem>
      </template>
    </DropdownMenu>
  </Inline>
</template>
vue

虚拟锚点与持续跟随

anchor 也接受带 getBoundingClientRect() 的对象,返回视口坐标中的矩形。OverlayAnchor 类型可从包根导入;回调需要返回最新坐标,同一个对象不必反复替换。

可选的 contextElement 指定坐标所属的元素,用于识别滚动祖先和裁剪边界;它不会成为触发器,也不会扩大浮层的交互区域。

updatePositionStrategy 默认是 'optimized',在滚动、尺寸和布局变化时更新位置。设置为 'always' 后,挂载期间逐帧检查矩形,持续跟随仅有坐标变化的锚点。可以在打开期间切换策略。退场期间继续跟随,锚点清空或所属元素移除后保留最后的位置,卸载后停止测量。

虚拟锚点只改变定位,菜单仍保留自动聚焦、方向键导航和条目选择行为。需要保持外部焦点的自由内容可使用 Popover。

示例用 Button 打开面板,并用 ScrollArea 提供滚动容器。面板同时跟随坐标变化和容器滚动。

向下滚动可观察定位变化。

<script setup lang="ts">
  import { ref, shallowRef, watch } from 'vue'
  import { useRafFn } from '@vueuse/core'
  import {
    Button,
    ScrollArea,
    Inline,
    Stack,
    Text,
    DropdownMenu,
    DropdownMenuItem,
    type OverlayAnchor,
  } from '@hina-ui/vue'

  const open = ref(false)
  const surface = ref<InstanceType<typeof Stack>>()
  const anchor = shallowRef<OverlayAnchor | null>(null)
  const x = ref(80)
  const { pause, resume } = useRafFn(
    ({ delta }) => {
      x.value = 80 + ((x.value - 80 + delta / 35) % 120)
    },
    { immediate: false },
  )
  watch(open, value => (value ? resume() : pause()))

  function show() {
    const element = surface.value?.$el as HTMLElement | undefined
    if (!element) return
    anchor.value = {
      contextElement: element,
      getBoundingClientRect: () => {
        const rect = element.getBoundingClientRect()
        return new DOMRect(rect.left + x.value, rect.top + 100, 2, 20)
      },
    }
    open.value = true
  }
</script>

<template>
  <Stack class="w-full">
    <Inline>
      <Button variant="outline" tone="neutral" @click="show">打开</Button>
      <Text size="sm" tone="muted">向下滚动可观察定位变化。</Text>
    </Inline>
    <ScrollArea class="h-64 rounded-lg border border-line bg-inset">
      <Stack ref="surface" class="relative h-128 shrink-0">
        <Text
          as="span"
          aria-hidden="true"
          class="absolute top-25 h-5 w-0.5 bg-accent"
          :style="{ left: `${x}px` }"
        />
      </Stack>
    </ScrollArea>
    <DropdownMenu
      v-model:open="open"
      :anchor="anchor"
      update-position-strategy="always"
      align="start"
      :modal="false"
    >
      <template #content>
        <DropdownMenuItem>第一项</DropdownMenuItem>
        <DropdownMenuItem>第二项</DropdownMenuItem>
      </template>
    </DropdownMenu>
  </Stack>
</template>
vue

不可用的条目

设置 disabled 的条目不可点击,用键盘在条目间移动时也会跳过它。

<script setup lang="ts">
  import { Button, DropdownMenu, DropdownMenuItem } from '@hina-ui/vue'
</script>

<template>
  <DropdownMenu label="导出">
    <Button variant="outline" tone="neutral">导出</Button>
    <template #content>
      <DropdownMenuItem>导出为 PDF</DropdownMenuItem>
      <DropdownMenuItem disabled>导出为 EPUB</DropdownMenuItem>
      <DropdownMenuItem>导出为纯文本</DropdownMenuItem>
    </template>
  </DropdownMenu>
</template>
vue

行为

  • 默认模态下,菜单展开期间页面停止滚动。
  • 触发器在菜单展开期间保持按下时的样式。
  • 方向键在条目间移动,到首尾时循环;输入文字跳到匹配的条目;回车选中当前条目;Esc 收起菜单并把焦点交还给触发器。子菜单用向右方向键展开,向左方向键收起。
  • 选中普通条目或单选项后菜单收起,选中多选项后菜单保持展开。
  • 默认模态下,点击菜单以外的区域时菜单收起,这次点击不会传到下层的元素上。

无障碍

  • 触发器带 aria-haspopup="menu",菜单是 role="menu",条目是 role="menuitem"。
  • 通过 label 为菜单本身提供名称,屏幕阅读器在进入菜单时播报它。
  • 单选项渲染为 menuitemradio,多选项渲染为 menuitemcheckbox,两者都带 aria-checked。
  • 子菜单的父条目带 aria-haspopup="menu" 与 aria-expanded。

API

DropdownMenu

属性
类型
默认值
说明
open
boolean
—
是否展开,支持双向绑定
label
string
—
菜单的无障碍名称
anchor
OverlayAnchor | null
—
定位元素或虚拟锚点
updatePositionStrategy
'optimized' | 'always'
'optimized'
定位更新策略
modal
boolean
true
是否限制外部交互并锁滚
dir
'ltr' | 'rtl'
跟随配置
菜单方向
side
'top' | 'right' | 'bottom' | 'left'
'bottom'
展开方向
align
'start' | 'center' | 'end'
'center'
与触发器的对齐方式
sideOffset
number
8
与触发器的距离
class
string
—
追加至菜单面板的类名
插槽
说明
default
可选触发器
content
菜单中的条目
事件
参数
说明
closeAutoFocus
Event
关闭时恢复焦点前触发,可取消
escapeKeyDown
KeyboardEvent
按 Esc 时触发,可取消关闭
pointerDownOutside
PointerDownOutsideEvent
外部按下时触发,可取消关闭
focusOutside
FocusOutsideEvent
焦点移到外部时触发,可取消关闭
interactOutside
PointerDownOutsideEvent | FocusOutsideEvent
外部按下或焦点移出时触发,可取消关闭

DropdownMenuItem

属性
类型
默认值
说明
tone
'neutral' | 'danger'
'neutral'
语义色调
disabled
boolean
false
是否不可用
textValue
string
—
供输入跳转匹配的文本
class
string
—
追加至条目的类名
事件
参数
说明
select
event: Event
选中该条目时触发
插槽
说明
default
条目文字
icon
前置图标
trailing
尾部内容

DropdownMenuCheckboxItem

属性
类型
默认值
说明
checked
boolean
false
是否选中,支持双向绑定
disabled
boolean
false
是否不可用
textValue
string
—
供输入跳转匹配的文本
class
string
—
追加至条目的类名
插槽
说明
default
条目文字
trailing
尾部内容

DropdownMenuGroup

只接受 class,默认插槽是同一组的条目。

DropdownMenuSub

属性
类型
默认值
说明
open
boolean
—
子菜单是否展开,支持双向绑定
label
string
—
父条目的文字
disabled
boolean
false
是否不可用
textValue
string
—
供输入跳转匹配的文本
class
string
—
追加至子菜单面板的类名
插槽
说明
default
子菜单中的条目
label
父条目的文字,覆盖 label
icon
父条目的前置图标

DropdownMenuRadioGroup

属性
类型
默认值
说明
modelValue
string
—
当前选中的值,支持双向绑定

DropdownMenuRadioItem

属性
类型
默认值
说明
value
string
—
必填。该项的值
disabled
boolean
false
是否不可用
textValue
string
—
供输入跳转匹配的文本
class
string
—
追加至条目的类名

DropdownMenuLabel 与 DropdownMenuSeparator

两者都只接受 class。DropdownMenuLabel 的默认插槽是标题文字,DropdownMenuSeparator 没有内容。