DropdownMenu 下拉菜单

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

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>
  )
}
tsx

用法

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

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>
  )
}
tsx

示例

条目

条目的 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>
  )
}
tsx

标题与分隔线

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>
  )
}
tsx

快捷键

条目对应的快捷键放在 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>
  )
}
tsx

分组

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>
  )
}
tsx

多选项

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>
  )
}
tsx

单选项

一组互斥的选项用 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>
  )
}
tsx

位置

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>
  )
}
tsx

受控

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>
  )
}
tsx

外部锚点

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>
  )
}
tsx

虚拟锚点与持续跟随

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>
  )
}
tsx

不可用的条目

设置 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>
  )
}
tsx

行为

  • 默认模态下,菜单展开期间页面停止滚动。
  • 触发器在菜单展开期间保持按下时的样式。
  • 方向键在条目间移动,到首尾时循环;输入文字跳到匹配的条目;回车选中当前条目;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 没有内容。