import { Copy, Download, Pencil, Trash2 } from 'lucide-react'
import {
Button,
DisclosureIcon,
DropdownMenu,
DropdownMenuItem,
DropdownMenuSeparator,
} from '@hina-ui/react'
export default function Demo() {
return (
<DropdownMenu
label="文件操作"
align="start"
content={
<>
<DropdownMenuItem icon={<Pencil />}>重命名</DropdownMenuItem>
<DropdownMenuItem icon={<Copy />}>复制</DropdownMenuItem>
<DropdownMenuItem icon={<Download />}>下载</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem tone="danger" icon={<Trash2 />}>
删除
</DropdownMenuItem>
</>
}
>
<Button variant="outline" tone="neutral" trailing={<DisclosureIcon />}>
文件
</Button>
</DropdownMenu>
)
}
用法
import { DropdownMenu, DropdownMenuItem } from '@hina-ui/react'
children 是触发器,content 属性是菜单里的条目。点击触发器展开,选中条目后自动收起。
import { Button, DropdownMenu, DropdownMenuItem } from '@hina-ui/react'
export default function Demo() {
return (
<DropdownMenu
label="更多操作"
content={
<>
<DropdownMenuItem>编辑</DropdownMenuItem>
<DropdownMenuItem>复制</DropdownMenuItem>
<DropdownMenuItem>归档</DropdownMenuItem>
</>
}
>
<Button variant="outline" tone="neutral">
更多操作
</Button>
</DropdownMenu>
)
}
示例
条目
条目的 icon 属性用于前置图标,trailing 属性用于尾部内容。删除这类不可撤销的操作设置 tone="danger"。
import { ExternalLink, Pencil, Share2, Trash2 } from 'lucide-react'
import { Button, DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from '@hina-ui/react'
export default function Demo() {
return (
<DropdownMenu
label="条目"
content={
<>
<DropdownMenuItem icon={<Pencil />}>编辑</DropdownMenuItem>
<DropdownMenuItem
icon={<Share2 />}
trailing={<ExternalLink className="text-faint size-3.5" />}
>
分享
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem tone="danger" icon={<Trash2 />}>
删除
</DropdownMenuItem>
</>
}
>
<Button variant="outline" tone="neutral">
条目
</Button>
</DropdownMenu>
)
}
标题与分隔线
DropdownMenuLabel 是不可选中的分组标题,DropdownMenuSeparator 画一条分隔线。
import {
Avatar,
Button,
DropdownMenu,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@hina-ui/react'
export default function Demo() {
return (
<DropdownMenu
label="账户"
content={
<>
<DropdownMenuLabel>我的账户</DropdownMenuLabel>
<DropdownMenuItem>个人资料</DropdownMenuItem>
<DropdownMenuItem>偏好设置</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>工作区</DropdownMenuLabel>
<DropdownMenuItem>成员</DropdownMenuItem>
<DropdownMenuItem>计费</DropdownMenuItem>
</>
}
>
<Button
variant="outline"
tone="neutral"
icon={<Avatar size="sm" src="/avatars/huh.webp" alt="星见书音" />}
>
星见书音
</Button>
</DropdownMenu>
)
}
快捷键
条目对应的快捷键放在 trailing 属性中,用 Kbd 呈现。这里只是标注,按键的注册仍由页面负责。
import { Copy, Scissors, Trash2 } from 'lucide-react'
import { Button, DropdownMenu, DropdownMenuItem, Kbd } from '@hina-ui/react'
export default function Demo() {
return (
<DropdownMenu
label="编辑"
content={
<>
<DropdownMenuItem icon={<Copy />} trailing={<Kbd>Ctrl C</Kbd>}>
复制
</DropdownMenuItem>
<DropdownMenuItem icon={<Scissors />} trailing={<Kbd>Ctrl X</Kbd>}>
剪切
</DropdownMenuItem>
<DropdownMenuItem tone="danger" icon={<Trash2 />} trailing={<Kbd>Del</Kbd>}>
删除
</DropdownMenuItem>
</>
}
>
<Button variant="outline" tone="neutral">
编辑
</Button>
</DropdownMenu>
)
}
分组
DropdownMenuGroup 把相关的条目归为一组。组内带 DropdownMenuLabel 时,屏幕阅读器把这个标题作为整组的名称播报。
import {
Avatar,
Button,
DropdownMenu,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@hina-ui/react'
export default function Demo() {
return (
<DropdownMenu
label="账户"
content={
<>
<DropdownMenuGroup>
<DropdownMenuLabel>我的账户</DropdownMenuLabel>
<DropdownMenuItem>个人资料</DropdownMenuItem>
<DropdownMenuItem>偏好设置</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>工作区</DropdownMenuLabel>
<DropdownMenuItem>成员</DropdownMenuItem>
<DropdownMenuItem>计费</DropdownMenuItem>
</DropdownMenuGroup>
</>
}
>
<Button
variant="outline"
tone="neutral"
icon={<Avatar size="sm" src="/avatars/huh.webp" alt="星见书音" />}
>
星见书音
</Button>
</DropdownMenu>
)
}
多选项
DropdownMenuCheckboxItem 用于可以同时选中多项的开关,checked 可受控。选中后条目末尾出现选中标记,菜单保持展开。
'use client'
import { useState } from 'react'
import { Button, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuLabel } from '@hina-ui/react'
export default function Demo() {
const [showCover, setShowCover] = useState(true)
const [showSummary, setShowSummary] = useState(false)
const [showTags, setShowTags] = useState(true)
return (
<DropdownMenu
label="显示项"
content={
<>
<DropdownMenuLabel>列表中显示</DropdownMenuLabel>
<DropdownMenuCheckboxItem checked={showCover} onCheckedChange={setShowCover}>
封面
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={showSummary} onCheckedChange={setShowSummary}>
简介
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={showTags} onCheckedChange={setShowTags}>
标签
</DropdownMenuCheckboxItem>
</>
}
>
<Button variant="outline" tone="neutral">
显示项
</Button>
</DropdownMenu>
)
}
单选项
一组互斥的选项用 DropdownMenuRadioGroup 包裹,当前项自动带选中标记。
'use client'
import { useState } from 'react'
import {
Button,
DropdownMenu,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from '@hina-ui/react'
const labels: Record<string, string> = {
newest: '最新发布',
popular: '最多收藏',
rating: '评分最高',
}
export default function Demo() {
const [sort, setSort] = useState('newest')
return (
<DropdownMenu
label="排序方式"
content={
<>
<DropdownMenuLabel>排序方式</DropdownMenuLabel>
<DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
<DropdownMenuRadioItem value="newest">最新发布</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="popular">最多收藏</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="rating">评分最高</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</>
}
>
<Button variant="outline" tone="neutral">
排序:{labels[sort]}
</Button>
</DropdownMenu>
)
}
位置
side 指定菜单朝哪个方向展开,align 指定它与触发器的对齐方式。默认在正下方。
import { Button, DropdownMenu, DropdownMenuItem, Inline } from '@hina-ui/react'
export default function Demo() {
return (
<Inline align="center" className="gap-6">
<DropdownMenu
label="向下对齐起始边"
align="start"
content={
<>
<DropdownMenuItem>第一项</DropdownMenuItem>
<DropdownMenuItem>第二项</DropdownMenuItem>
</>
}
>
<Button variant="outline" tone="neutral">
start
</Button>
</DropdownMenu>
<DropdownMenu
label="向下居中"
align="center"
content={
<>
<DropdownMenuItem>第一项</DropdownMenuItem>
<DropdownMenuItem>第二项</DropdownMenuItem>
</>
}
>
<Button variant="outline" tone="neutral">
center
</Button>
</DropdownMenu>
<DropdownMenu
label="向右展开"
side="right"
align="start"
content={
<>
<DropdownMenuItem>第一项</DropdownMenuItem>
<DropdownMenuItem>第二项</DropdownMenuItem>
</>
}
>
<Button variant="outline" tone="neutral">
right
</Button>
</DropdownMenu>
</Inline>
)
}
受控
open 可受控,可以从外部展开或收起菜单。
当前:收起
'use client'
import { useState } from 'react'
import { Button, DropdownMenu, DropdownMenuItem, Inline, Text } from '@hina-ui/react'
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<Inline align="center">
<DropdownMenu
open={open}
onOpenChange={setOpen}
label="受控菜单"
content={
<>
<DropdownMenuItem>第一项</DropdownMenuItem>
<DropdownMenuItem>第二项</DropdownMenuItem>
</>
}
>
<Button variant="outline" tone="neutral">
菜单
</Button>
</DropdownMenu>
<Button size="sm" variant="soft" tone="neutral" onClick={() => setOpen(!open)}>
从外部{open ? '收起' : '展开'}
</Button>
<Text tone="muted" size="sm">
当前:{open ? '展开' : '收起'}
</Text>
</Inline>
)
}
外部锚点
anchor 接受 OverlayAnchor | null,可省略 children 并使用 open / onOpenChange 控制开关。锚点未就绪时菜单不显示;打开期间可以更换锚点,关闭时清空锚点会保留退场位置。
同时提供 children 与 anchor 时,children 负责触发,anchor 负责定位。外部元素的点击与键盘行为、aria-haspopup="menu" 和 aria-expanded 由调用方设置,菜单内部的键盘导航保持不变。外部定位规则与 Popover 一致。
'use client'
import { useState, type KeyboardEvent, type MouseEvent } from 'react'
import { Button, DropdownMenu, DropdownMenuItem, Inline } from '@hina-ui/react'
export default function Demo() {
const [open, setOpen] = useState(false)
const [current, setCurrent] = useState(0)
const [anchor, setAnchor] = useState<HTMLElement | null>(null)
function toggle(event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>, index: number) {
const target = event.currentTarget
setOpen(event.type === 'keydown' || anchor !== target || !open)
setAnchor(target)
setCurrent(index)
}
return (
<Inline>
{[1, 2].map(index => (
<Button
key={index}
variant="outline"
tone="neutral"
aria-haspopup="menu"
aria-expanded={open && current === index}
onClick={event => toggle(event, index)}
onKeyDown={event => {
if (event.key !== 'ArrowDown') return
event.preventDefault()
toggle(event, index)
}}
>
锚点 {index}
</Button>
))}
<DropdownMenu
open={open}
onOpenChange={setOpen}
anchor={anchor}
modal={false}
label="外部菜单"
content={
<>
<DropdownMenuItem>复制</DropdownMenuItem>
<DropdownMenuItem>重命名</DropdownMenuItem>
<DropdownMenuItem disabled>不可用</DropdownMenuItem>
</>
}
/>
</Inline>
)
}
虚拟锚点与持续跟随
anchor 也接受带 getBoundingClientRect() 的对象,返回视口坐标中的矩形。OverlayAnchor 类型可从包根导入;回调需要返回最新坐标,同一个对象不必反复替换。
可选的 contextElement 指定坐标所属的元素,用于识别滚动祖先和裁剪边界;它不会成为触发器,也不会扩大浮层的交互区域。
updatePositionStrategy 默认是 'optimized',在滚动、尺寸和布局变化时更新位置。设置为 'always' 后,挂载期间逐帧检查矩形,持续跟随仅有坐标变化的锚点。可以在打开期间切换策略。退场期间继续跟随,锚点清空或所属元素移除后保留最后的位置,卸载后停止测量。
虚拟锚点只改变定位,菜单仍保留自动聚焦、方向键导航和条目选择行为。需要保持外部焦点的自由内容可使用 Popover。
示例用 Button 打开面板,并用 ScrollArea 提供滚动容器。面板同时跟随坐标变化和容器滚动。
向下滚动可观察定位变化。
'use client'
import { useEffect, useRef, useState } from 'react'
import {
Button,
ScrollArea,
Inline,
Stack,
Text,
DropdownMenu,
DropdownMenuItem,
type OverlayAnchor,
} from '@hina-ui/react'
export default function Demo() {
const [open, setOpen] = useState(false)
const surface = useRef<HTMLElement>(null)
const [anchor, setAnchor] = useState<OverlayAnchor | null>(null)
const [x, setX] = useState(80)
const position = useRef(80)
useEffect(() => {
if (!open) return
let frame = 0
let previous = 0
function tick(now: number) {
const delta = previous ? now - previous : 0
previous = now
position.current = 80 + ((position.current - 80 + delta / 35) % 120)
setX(position.current)
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [open])
function show() {
const element = surface.current
if (!element) return
setAnchor({
contextElement: element,
getBoundingClientRect: () => {
const rect = element.getBoundingClientRect()
return new DOMRect(rect.left + position.current, rect.top + 100, 2, 20)
},
})
setOpen(true)
}
return (
<Stack className="w-full">
<Inline>
<Button variant="outline" tone="neutral" onClick={show}>
打开
</Button>
<Text size="sm" tone="muted">
向下滚动可观察定位变化。
</Text>
</Inline>
<ScrollArea className="border-line bg-inset h-64 rounded-lg border">
<Stack ref={surface} className="relative h-128 shrink-0">
<Text
as="span"
aria-hidden="true"
className="bg-accent absolute top-25 h-5 w-0.5"
style={{ left: `${x}px` }}
/>
</Stack>
</ScrollArea>
<DropdownMenu
open={open}
onOpenChange={setOpen}
anchor={anchor}
updatePositionStrategy="always"
align="start"
modal={false}
content={
<>
<DropdownMenuItem>第一项</DropdownMenuItem>
<DropdownMenuItem>第二项</DropdownMenuItem>
</>
}
/>
</Stack>
)
}
模态与焦点
modal 默认为 true,展开期间锁滚并限制外部交互;设置 modal={false} 后,页面可继续滚动和交互,点击外部仍会关闭菜单。
没有默认触发器时,关闭后恢复打开前的焦点,锚点仅负责定位;调用方已经将焦点移到菜单外时,不会恢复旧焦点。onCloseAutoFocus 可以取消焦点恢复,onInteractOutside 和 onEscapeKeyDown 可以取消对应的关闭行为。调用 event.preventDefault() 即可取消。
通过 label 或 aria-label 为菜单提供名称,aria-describedby 和 data-* 属性会传给菜单面板。
不可用的条目
设置 disabled 的条目不可点击,用键盘在条目间移动时也会跳过它。
import { Button, DropdownMenu, DropdownMenuItem } from '@hina-ui/react'
export default function Demo() {
return (
<DropdownMenu
label="导出"
content={
<>
<DropdownMenuItem>导出为 PDF</DropdownMenuItem>
<DropdownMenuItem disabled>导出为 EPUB</DropdownMenuItem>
<DropdownMenuItem>导出为纯文本</DropdownMenuItem>
</>
}
>
<Button variant="outline" tone="neutral">
导出
</Button>
</DropdownMenu>
)
}
行为
- 默认模态下,菜单展开期间页面停止滚动。
- 触发器在菜单展开期间保持按下时的样式。
- 方向键在条目间移动,到首尾时循环;输入文字跳到匹配的条目;回车选中当前条目;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 | 与触发器的距离 |
className | string | — | 追加至菜单面板的类名 |
| 属性 | 说明 |
|---|---|
children | 可选触发器 |
content | 菜单中的条目 |
| 回调 | 参数 | 说明 |
|---|---|---|
onCloseAutoFocus | Event | 关闭时恢复焦点前触发,可取消 |
onEscapeKeyDown | KeyboardEvent | 按 Esc 时触发,可取消关闭 |
onPointerDownOutside | PointerDownOutsideEvent | 外部按下时触发,可取消关闭 |
onFocusOutside | FocusOutsideEvent | 焦点移到外部时触发,可取消关闭 |
onInteractOutside | PointerDownOutsideEvent | FocusOutsideEvent | 外部按下或焦点移出时触发,可取消关闭 |
DropdownMenuItem
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
tone | 'neutral' | 'danger' | 'neutral' | 语义色调 |
disabled | boolean | false | 是否不可用 |
textValue | string | — | 供输入跳转匹配的文本 |
className | string | — | 追加至条目的类名 |
| 回调 | 参数 | 说明 |
|---|---|---|
onSelect | event: Event | 选中该条目时触发 |
| 属性 | 说明 |
|---|---|
children | 条目文字 |
icon | 前置图标 |
trailing | 尾部内容 |
DropdownMenuCheckboxItem
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
checked | boolean | false | 是否选中,可受控 |
disabled | boolean | false | 是否不可用 |
textValue | string | — | 供输入跳转匹配的文本 |
className | string | — | 追加至条目的类名 |
| 属性 | 说明 |
|---|---|
children | 条目文字 |
trailing | 尾部内容 |
DropdownMenuGroup
只接受 className,children 是同一组的条目。
DropdownMenuSub
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
open | boolean | — | 子菜单是否展开,可受控 |
label | ReactNode | — | 父条目的文字 |
disabled | boolean | false | 是否不可用 |
textValue | string | — | 供输入跳转匹配的文本 |
className | string | — | 追加至子菜单面板的类名 |
icon | ReactNode | — | 父条目的前置图标 |
children | ReactNode | — | 子菜单中的条目 |
DropdownMenuRadioGroup
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
value | string | — | 当前选中的值,可受控 |
DropdownMenuRadioItem
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
value | string | — | 必填。该项的值 |
disabled | boolean | false | 是否不可用 |
textValue | string | — | 供输入跳转匹配的文本 |
className | string | — | 追加至条目的类名 |
DropdownMenuLabel 与 DropdownMenuSeparator
两者都只接受 className。DropdownMenuLabel 的 children 是标题文字,DropdownMenuSeparator 没有内容。