<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>
用法
import { DropdownMenu, DropdownMenuItem } from '@hina-ui/vue'
默认插槽是触发器,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>
示例
条目
条目的 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>
标题与分隔线
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>
快捷键
条目对应的快捷键放在 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>
分组
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>
多选项
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>
单选项
一组互斥的选项用 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>
位置
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>
受控
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>
外部锚点
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>
虚拟锚点与持续跟随
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>
模态与焦点
modal 默认为 true,展开期间锁滚并限制外部交互;设置 :modal="false" 后,页面可继续滚动和交互,点击外部仍会关闭菜单。
没有默认触发器时,关闭后恢复打开前的焦点,锚点仅负责定位;调用方已经将焦点移到菜单外时,不会恢复旧焦点。closeAutoFocus 可以取消焦点恢复,interactOutside 和 escapeKeyDown 可以取消对应的关闭行为。调用 event.preventDefault() 即可取消。
通过 label 或 aria-label 为菜单提供名称,aria-describedby 和 data-* 属性会传给菜单面板。
不可用的条目
设置 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>
行为
- 默认模态下,菜单展开期间页面停止滚动。
- 触发器在菜单展开期间保持按下时的样式。
- 方向键在条目间移动,到首尾时循环;输入文字跳到匹配的条目;回车选中当前条目;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 没有内容。