下拉菜单
菜单提供了一种简单的方式来构建自定义的、可访问的下拉组件,并提供强大的键盘导航支持。
要开始使用,请通过 npm 安装无状态 UI
npm install @headlessui/react
菜单使用 Menu
、MenuButton
、MenuItems
和 MenuItem
组件构建。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
function Example() {
return (
<Menu>
<MenuButton>My account</MenuButton>
<MenuItems anchor="bottom">
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/settings">
Settings
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/support">
Support
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/license">
License
</a>
</MenuItem>
</MenuItems>
</Menu>
)
}
MenuButton
会在点击时自动打开和关闭 MenuItems
,并且当菜单打开时,项目列表会获得焦点,并可以通过键盘进行导航。
无状态 UI 会跟踪每个组件的很多状态,例如,哪个菜单项当前通过键盘获得焦点,弹出框是打开还是关闭,或者哪个列表框选项当前被选中。
但是,因为组件是无状态的,并且在开箱即用时完全没有样式,所以你无法在 UI 中看到这些信息,除非你自己为每个状态提供你想要的样式。
为无状态 UI 组件的不同状态设置样式最简单的方法是使用每个组件公开的 data-*
属性。
例如,MenuButton
组件公开了 data-active
属性,它告诉你这是否当前打开了菜单,而 MenuItem
组件公开了 data-focus
属性,它告诉你这是否当前通过鼠标或键盘将菜单项设置为焦点。
<!-- Rendered `MenuButton`, `MenuItems`, and `MenuItem` -->
<button data-active>Options</button>
<div data-open>
<a href="/settings">Settings</a>
<a href="/support" data-focus>Support</a>
<a href="/license">License</a>
</div>
使用 CSS 属性选择器 根据这些数据属性的存在有条件地应用样式。如果你使用的是 Tailwind CSS,那么 数据属性修饰符 可以简化操作。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
const links = [
{ href: '/settings', label: 'Settings' },
{ href: '/support', label: 'Support' },
{ href: '/license', label: 'License' },
]
function Example() {
return (
<Menu>
<MenuButton className="data-[active]:bg-blue-200">My account</MenuButton> <MenuItems anchor="bottom">
{links.map((link) => (
<MenuItem key={link.href} className="block data-[focus]:bg-blue-100"> <a href={link.href}>{link.label}</a>
</MenuItem>
))}
</MenuItems>
</Menu>
)
}
有关所有可用数据属性的列表,请参见 组件 API。
每个组件还通过 渲染道具 公开其当前状态的信息,你可以使用它有条件地应用不同的样式或渲染不同的内容。
例如,MenuButton
组件公开了 active
状态,它告诉你这是否当前打开了菜单,而 MenuItem
组件公开了 focus
状态,它告诉你这是否当前通过鼠标或键盘将菜单项设置为焦点。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import clsx from 'clsx'
import { Fragment } from 'react'
const links = [
{ href: '/settings', label: 'Settings' },
{ href: '/support', label: 'Support' },
{ href: '/license', label: 'License' },
]
function Example() {
return (
<Menu>
<MenuButton as={Fragment}> {({ active }) => <button className={clsx(active && 'bg-blue-200')}>My account</button>} </MenuButton> <MenuItems anchor="bottom">
{links.map((link) => (
<MenuItem key={link.href} as={Fragment}> {({ focus }) => ( <a className={clsx('block', focus && 'bg-blue-100')} href={link.href}> {link.label} </a> )} </MenuItem> ))}
</MenuItems>
</Menu>
)
}
有关所有可用渲染道具的列表,请参见 组件 API。
除了链接之外,你还可以将按钮用于 MenuItem
。当你想要触发一个动作,例如打开对话框或提交表单时,这很有用。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
function Example() {
function showSettingsDialog() {
alert('Open settings dialog!')
}
return (
<Menu>
<MenuButton>My account</MenuButton>
<MenuItems anchor="bottom">
<MenuItem> <button onClick={showSettingsDialog} className="block w-full text-left data-[focus]:bg-blue-100"> Settings </button> </MenuItem> <MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/support">
Support
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/license">
License
</a>
</MenuItem>
<form action="/logout" method="post"> <MenuItem> <button type="submit" className="block w-full text-left data-[focus]:bg-blue-100"> Sign out </button> </MenuItem> </form> </MenuItems>
</Menu>
)
}
使用 disabled
道具可以禁用 MenuItem
并阻止其被选中。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
function Example() {
return (
<Menu>
<MenuButton>My account</MenuButton>
<MenuItems anchor="bottom">
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/settings">
Settings
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/support">
Support
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/license">
License
</a>
</MenuItem>
<MenuItem disabled> <a className="block data-[disabled]:opacity-50" href="/invite-a-friend"> Invite a friend (coming soon!) </a> </MenuItem> </MenuItems>
</Menu>
)
}
使用 MenuSeparator
组件可以在菜单中的项目之间添加视觉分隔符。
import { Menu, MenuButton, MenuItem, MenuItems, MenuSeparator } from '@headlessui/react'
function Example() {
return (
<Menu>
<MenuButton>My account</MenuButton>
<MenuItems anchor="bottom">
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/settings">
Settings
</a>
</MenuItem>
<MenuSeparator className="my-1 h-px bg-black" /> <MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/support">
Support
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/license">
License
</a>
</MenuItem>
</MenuItems>
</Menu>
)
}
使用 MenuSection
、MenuHeading
和 MenuSeparator
组件可以将项目分组到带有标签的部分中。
import { Menu, MenuButton, MenuHeading, MenuItem, MenuItems, MenuSection, MenuSeparator } from '@headlessui/react'
function Example() {
return (
<Menu>
<MenuButton>My account</MenuButton>
<MenuItems anchor="bottom">
<MenuSection> <MenuHeading className="text-sm opacity-50">Settings</MenuHeading> <MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/profile">
My profile
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/notifications">
Notifications
</a>
</MenuItem>
</MenuSection> <MenuSeparator className="my-1 h-px bg-black" /> <MenuSection> <MenuHeading className="text-sm opacity-50">Support</MenuHeading> <MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/support">
Documentation
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/license">
License
</a>
</MenuItem>
</MenuSection> </MenuItems>
</Menu>
)
}
MenuItems
下拉默认情况下没有设置宽度,但你可以使用 CSS 添加宽度。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
function Example() {
return (
<Menu>
<MenuButton>My account</MenuButton>
<MenuItems anchor="bottom" className="w-52"> <MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/settings">
Settings
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/support">
Support
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/license">
License
</a>
</MenuItem>
</MenuItems>
</Menu>
)
}
如果你希望下拉宽度与 MenuButton
宽度匹配,请使用 MenuItems
元素上公开的 --button-width
CSS 变量。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
function Example() {
return (
<Menu>
<MenuButton>My account</MenuButton>
<MenuItems anchor="bottom" className="w-[var(--button-width)]"> <MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/settings">
Settings
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/support">
Support
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/license">
License
</a>
</MenuItem>
</MenuItems>
</Menu>
)
}
向 MenuItems
添加 anchor
道具可以自动将下拉菜单定位到 MenuButton
的相对位置。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
function Example() {
return (
<Menu>
<MenuButton>My account</MenuButton>
<MenuItems anchor="bottom start"> <MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/settings">
Settings
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/support">
Support
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/license">
License
</a>
</MenuItem>
</MenuItems>
</Menu>
)
}
使用 top
、right
、bottom
或 left
值可以将下拉菜单沿着相应的边缘居中,或者将其与 start
或 end
结合使用,以将下拉菜单对齐到特定角落,例如 top start
或 bottom end
。
要控制按钮和下拉菜单之间的间隙,请使用 --anchor-gap
CSS 变量。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
function Example() {
return (
<Menu>
<MenuButton>My account</MenuButton>
<MenuItems anchor="bottom start" className="[--anchor-gap:4px] sm:[--anchor-gap:8px]"> <MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/settings">
Settings
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/support">
Support
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/license">
License
</a>
</MenuItem>
</MenuItems>
</Menu>
)
}
此外,你可以使用 --anchor-offset
控制下拉菜单应该从其原始位置偏移的距离,以及使用 --anchor-padding
控制下拉菜单和视窗之间应该存在的最小空间。
anchor
道具还支持对象 API,允许你使用 JavaScript 控制 gap
、offset
和 padding
值。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
function Example() {
return (
<Menu>
<MenuButton>My account</MenuButton>
<MenuItems anchor={{ to: 'bottom start', gap: '4px' }}> <MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/settings">
Settings
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/support">
Support
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/license">
License
</a>
</MenuItem>
</MenuItems>
</Menu>
)
}
有关这些选项的更多信息,请参见 MenuItems API。
要为下拉菜单的打开和关闭添加动画,请向 MenuItems
组件添加 transition
道具,然后使用 CSS 为过渡的不同阶段设置样式。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
function Example() {
return (
<Menu>
<MenuButton>My account</MenuButton>
<MenuItems
anchor="bottom"
transition className="origin-top transition duration-200 ease-out data-[closed]:scale-95 data-[closed]:opacity-0" >
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/settings">
Settings
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/support">
Support
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/license">
License
</a>
</MenuItem>
</MenuItems>
</Menu>
)
}
在内部,transition
道具的实现方式与 Transition
组件完全相同。有关更多信息,请参见 过渡文档。
无状态 UI 还与 React 生态系统中的其他动画库(例如 Framer Motion 和 React Spring)很好地组合在一起。你只需要向这些库公开一些状态。
例如,要使用 Framer Motion 为菜单设置动画,请向 MenuItems
组件添加 static
道具,然后根据 open
渲染道具有条件地渲染它。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import { AnimatePresence, motion } from 'framer-motion'
function Example() {
return (
<Menu>
{({ open }) => ( <>
<MenuButton>My account</MenuButton>
<AnimatePresence>
{open && ( <MenuItems
static as={motion.div}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
anchor="bottom"
className="origin-top"
>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/settings">
Settings
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/support">
Support
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/license">
License
</a>
</MenuItem>
</MenuItems>
)} </AnimatePresence>
</>
)} </Menu>
)
}
默认情况下,Menu
会在点击 MenuItem
时关闭。但是,一些第三方 Link
组件使用 event.preventDefault()
,这会阻止菜单在点击时关闭。
在这些情况下,你可以使用 Menu
和 MenuItem
组件上都可用的 close
渲染道具以命令式地关闭菜单。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import { MyCustomLink } from './MyCustomLink'
function Example() {
return (
<Menu>
<MenuButton>Terms</MenuButton>
<MenuItems anchor="bottom">
<MenuItem>
{({ close }) => ( <MyCustomLink href="/" onClick={close}> Read and accept
</MyCustomLink>
)} </MenuItem>
</MenuItems>
</Menu>
)
}
默认情况下,Menu
及其子组件都会渲染一个适合该组件的默认元素。
例如,MenuButton
默认情况下会渲染一个 button
,而 MenuItems
会渲染一个 div
。相比之下,Menu
和 MenuItem
不会渲染元素,而是默认情况下直接渲染其子级。
使用 as
道具可以将组件渲染为不同的元素或你自己的自定义组件,确保你的自定义组件 转发 ref,以便无状态 UI 可以正确地连接所有内容。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import { forwardRef } from 'react'
let MyCustomButton = forwardRef(function (props, ref) { return <button className="..." ref={ref} {...props} />})
function Example() {
return (
<Menu>
<MenuButton as={MyCustomButton}>My account</MenuButton> <MenuItems anchor="bottom" as="section"> <MenuItem as="a" className="block data-[focus]:bg-blue-100" href="/settings"> Settings
</MenuItem>
<MenuItem as="a" className="block data-[focus]:bg-blue-100" href="/support"> Support
</MenuItem>
<MenuItem as="a" className="block data-[focus]:bg-blue-100" href="/license"> License
</MenuItem>
</MenuItems>
</Menu>
)
}
要告诉元素直接渲染其子级,而没有包装元素,请使用 as={Fragment}
。
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import { Fragment } from 'react'
function Example() {
return (
<Menu>
<MenuButton as={Fragment}> <button>My account</button> </MenuButton> <MenuItems anchor="bottom">
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/settings">
Settings
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/support">
Support
</a>
</MenuItem>
<MenuItem>
<a className="block data-[focus]:bg-blue-100" href="/license">
License
</a>
</MenuItem>
</MenuItems>
</Menu>
)
}
如果你在 MenuItem
中使用交互式元素,例如 <a>
标签,这很重要。如果 MenuItem
有一个 as="div"
,那么无状态 UI 提供的道具将转发到 div
而不是 a
,这意味着你无法再通过键盘转到 <a>
标签提供的 URL 了。
在 Next.js v13 之前,Link
组件不会将未知道具转发到底层的 a
元素,从而阻止菜单在用在 MenuItem
中时在点击时关闭。
如果你使用的是 Next.js v12 或更早版本,可以通过创建一个包装 Link
组件并转发未知属性到子 a
元素来解决这个问题。
import { forwardRef } from 'react'
import Link from 'next/link'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
const MyLink = forwardRef((props, ref) => { let { href, children, ...rest } = props return ( <Link href={href}> <a ref={ref} {...rest}> {children} </a> </Link> )})
function Example() {
return (
<Menu>
<MenuButton>My account</MenuButton>
<MenuItems anchor="bottom">
<MenuItem>
<MyLink href="/settings">Settings</MyLink> </MenuItem>
</MenuItems>
</Menu>
)
}
这将确保 Headless UI 需要添加到 a
元素的所有事件监听器都被正确应用。
Next.js v13 中更改了此行为,因此不再需要此解决方法。
命令 | 描述 |
Enter 或 Space当 | 打开菜单并聚焦第一个未禁用的项目 |
向下箭头 或 向上箭头当 | 打开菜单并聚焦第一个/最后一个未禁用的项目 |
Esc当菜单打开时 | 关闭任何打开的菜单 |
向下箭头 或 向上箭头当菜单打开时 | 聚焦上一个/下一个未禁用的项目 |
Home 或 PageUp当菜单打开时 | 聚焦第一个未禁用的项目 |
End 或 PageDown当菜单打开时 | 聚焦最后一个未禁用的项目 |
Enter 或 Space当菜单打开时 | 激活/点击当前菜单项 |
A–Z 或 a–z当菜单打开时 | 聚焦第一个匹配键盘输入的项目 |
属性 | 默认值 | 描述 |
as | Fragment | 字符串 | 组件 菜单应该呈现为的元素或组件。菜单 |
数据属性 | 渲染道具 | 描述 |
data-open | open |
菜单是否打开。菜单 |
— | close |
关闭菜单并重新聚焦 |
属性 | 默认值 | 描述 |
as | button | 字符串 | 组件 菜单应该呈现为的元素或组件。菜单按钮 |
disabled | false | 布尔值 菜单是否打开。菜单按钮是否禁用. |
数据属性 | 渲染道具 | 描述 |
data-open | open |
菜单是否打开。菜单 |
data-focus | focus |
菜单是否打开。菜单按钮是否获得焦点。 |
data-hover | hover |
菜单是否打开。菜单按钮是否悬停。 |
data-active | active |
菜单是否打开。菜单按钮是否处于活动或按下状态。 |
data-autofocus | autofocus |
|
属性 | 默认值 | 描述 |
as | div | 字符串 | 组件 菜单应该呈现为的元素或组件。菜单项 |
transition | false | 布尔值 元素是否应该渲染过渡属性,例如 |
anchor | — | 对象 配置下拉菜单如何锚定到按钮。 |
anchor.to | bottom | 字符串 下拉菜单相对于触发器的位置。菜单项使用 菜单项菜单项 |
anchor.gap | 0 | 数字 | 字符串 下拉菜单和触发器之间的间距。菜单按钮菜单项. 也可以使用 |
anchor.offset | 0 | 数字 | 字符串 下拉菜单从其原始位置偏移的距离。菜单项 也可以使用 |
anchor.padding | 0 | 数字 | 字符串 下拉菜单和视窗之间的最小间距。菜单项 也可以使用 |
static | false | 布尔值 元素是否应该忽略内部管理的打开/关闭状态。 |
unmount | true | 布尔值 元素是否应该根据打开/关闭状态卸载或隐藏。 |
portal | false | 布尔值 元素是否应该在门户中渲染。 当设置 |
modal | true | 布尔值 是否启用无障碍功能,例如滚动锁定、焦点捕获,以及使其他元素 |
数据属性 | 渲染道具 | 描述 |
data-open | open |
菜单是否打开。菜单 |
属性 | 默认值 | 描述 |
as | Fragment | 字符串 | 组件 菜单应该呈现为的元素或组件。菜单项 |
disabled | false | 布尔值 菜单是否打开。菜单项是否禁用用于键盘导航和 ARIA 目的. |
数据属性 | 渲染道具 | 描述 |
data-disabled | disabled |
菜单是否打开。菜单项是否禁用。 |
data-focus | focus |
菜单是否打开。菜单项是否获得焦点。 |
— | close |
关闭菜单并重新聚焦 |
属性 | 默认值 | 描述 |
as | div | 字符串 | 组件 菜单应该呈现为的元素或组件。菜单部分 |
属性 | 默认值 | 描述 |
as | header | 字符串 | 组件 菜单应该呈现为的元素或组件。菜单标题 |
属性 | 默认值 | 描述 |
as | div | 字符串 | 组件 菜单应该呈现为的元素或组件。菜单分隔符 |
如果你对使用 Headless UI 的预先设计的 Tailwind CSS 下拉菜单组件示例 感兴趣,请查看 **Tailwind UI** — 我们精心设计和制作的精美组件集合。
这是支持我们参与此类开源项目工作的好方法,并且使我们能够改进这些项目并保持其良好维护。