菜单 (下拉菜单)

菜单提供了一种简单的方法来构建自定义的、可访问的下拉组件,并提供对键盘导航的强大支持。

要开始,请通过 npm 安装无头 UI

npm install @headlessui/react

菜单按钮使用 MenuMenu.ButtonMenu.ItemsMenu.Item 组件构建。

Menu.Button 会在点击时自动打开/关闭 Menu.Items,当菜单打开时,项目列表会获得焦点,并可以通过键盘自动导航。

import { Menu } from '@headlessui/react' function MyDropdown() { return ( <Menu> <Menu.Button>More</Menu.Button> <Menu.Items> <Menu.Item> {({ active }) => ( <a className={`${active && 'bg-blue-500'}`} href="/account-settings" > Account settings </a> )} </Menu.Item> <Menu.Item> {({ active }) => ( <a className={`${active && 'bg-blue-500'}`} href="/account-settings" > Documentation </a> )} </Menu.Item> <Menu.Item disabled> <span className="opacity-75">Invite a friend (coming soon!)</span> </Menu.Item> </Menu.Items> </Menu> ) }

无头 UI 跟踪每个组件的大量状态,例如当前选中的列表框选项、弹出窗口是打开还是关闭,或者菜单中哪个项目当前通过键盘激活。

但由于组件是无头的,并且在开箱即用时完全没有样式,因此您无法看到此信息在您的 UI 中,直到您自己为每个状态提供您想要的样式。

每个组件通过 渲染道具 公开其当前状态的信息,您可以使用它来有条件地应用不同的样式或渲染不同的内容。

例如,Menu.Item 组件公开一个 active 状态,它告诉您该项目当前是否通过鼠标或键盘获得焦点。

import { Fragment } from 'react' import { Menu } from '@headlessui/react' const links = [ { href: '/account-settings', label: 'Account settings' }, { href: '/support', label: 'Support' }, { href: '/license', label: 'License' }, { href: '/sign-out', label: 'Sign out' }, ] function MyMenu() { return ( <Menu> <Menu.Button>Options</Menu.Button> <Menu.Items> {links.map((link) => ( /* Use the `active` state to conditionally style the active item. */ <Menu.Item key={link.href} as={Fragment}>
{({ active }) => (
<a href={link.href} className={`${
active ? 'bg-blue-500 text-white' : 'bg-white text-black'
}
`
}
>
{link.label} </a> )} </Menu.Item> ))} </Menu.Items> </Menu> ) }

有关每个组件的完整渲染道具 API,请参阅 组件 API 文档

每个组件还通过 data-headlessui-state 属性公开其当前状态的信息,您可以使用它来有条件地应用不同的样式。

渲染道具 API 中的任何状态为 true 时,它们将以空格分隔的字符串的形式列在这个属性中,以便您可以使用 CSS 属性选择器[attr~=value] 的形式来定位它们。

例如,以下是 Menu.Items 组件与一些子 Menu.Item 组件在菜单打开且第二个项目为 active 时渲染的内容

<!-- Rendered `Menu.Items` --> <ul data-headlessui-state="open"> <li data-headlessui-state="">Account settings</li> <li data-headlessui-state="active">Support</li> <li data-headlessui-state="">License</li> </ul>

如果您使用的是 Tailwind CSS,则可以使用 @headlessui/tailwindcss 插件来使用修饰符(如 ui-open:*ui-active:*)来定位此属性。

import { Menu } from '@headlessui/react' const links = [ { href: '/account-settings', label: 'Account settings' }, { href: '/support', label: 'Support' }, { href: '/license', label: 'License' }, { href: '/sign-out', label: 'Sign out' }, ] function MyMenu() { return ( <Menu> <Menu.Button>Options</Menu.Button> <Menu.Items> {links.map((link) => ( <Menu.Item as="a" key={link.href} href={link.href}
className="ui-active:bg-blue-500 ui-active:text-white ui-not-active:bg-white ui-not-active:text-black"
>
{link.label} </Menu.Item> ))} </Menu.Items> </Menu> ) }

默认情况下,您的 Menu.Items 实例将根据 Menu 组件本身内部跟踪的内部 open 状态自动显示/隐藏。

import { Menu } from '@headlessui/react' function MyDropdown() { return ( <Menu> <Menu.Button>More</Menu.Button> {/* By default, the `Menu.Items` will automatically show/hide when the `Menu.Button` is pressed. */} <Menu.Items> <Menu.Item>{/* ... */}</Menu.Item> {/* ... */} </Menu.Items> </Menu> ) }

如果您希望自己处理此问题(也许是因为您出于某种原因需要添加额外的包装元素),您可以向 Menu.Items 实例添加 static 属性以告诉它始终渲染,并检查由 Menu 提供的 open 插槽属性以控制您自己显示/隐藏哪个元素。

import { Menu } from '@headlessui/react' function MyDropdown() { return ( <Menu>
{({ open }) => (
<> <Menu.Button>More</Menu.Button>
{open && (
<div> {/* Using the `static` prop, the `Menu.Items` are always rendered and the `open` state is ignored. */}
<Menu.Items static>
<Menu.Item>{/* ... */}</Menu.Item> {/* ... */} </Menu.Items> </div> )} </> )} </Menu> ) }

菜单默认情况下会自动关闭,但是可能会发生第三方 Link 组件使用 event.preventDefault() 的情况,这会阻止默认行为,因此不会关闭菜单。

MenuMenu.Item 公开了一个 close() 渲染道具,您可以使用它来命令式地关闭菜单

import { Menu } from '@headlessui/react' import { MyCustomLink } from './MyCustomLink' function MyMenu() { return ( <Menu> <Menu.Button>Terms</Menu.Button> <Menu.Items> <Menu.Item>
{({ close }) => (
<MyCustomLink href="/" onClick={close}>
Read and accept </MyCustomLink> )} </Menu.Item> </Menu.Items> </Menu> ) }

使用 disabled 属性禁用 Menu.Item。这将使它无法通过键盘导航进行选择,并且在按下向上/向下箭头时它会被跳过。

import { Menu } from '@headlessui/react' function MyDropdown() { return ( <Menu> <Menu.Button>More</Menu.Button> <Menu.Items> {/* ... */} {/* This item will be skipped by keyboard navigation. */}
<Menu.Item disabled>
<span className="opacity-75">Invite a friend (coming soon!)</span> </Menu.Item> {/* ... */} </Menu.Items> </Menu> ) }

要动画化菜单面板的打开/关闭,请使用提供的 Transition 组件。您只需要将 Menu.Items 包裹在一个 <Transition> 中,过渡将自动应用。

import { Menu, Transition } from '@headlessui/react' function MyDropdown() { return ( <Menu> <Menu.Button>More</Menu.Button> {/* Use the `Transition` component. */}
<Transition
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Menu.Items> <Menu.Item>{/* ... */}</Menu.Item> {/* ... */} </Menu.Items>
</Transition>
</Menu> ) }

默认情况下,我们内置的 Transition 组件会自动与 Menu 组件通信以处理打开/关闭状态。但是,如果您需要对这种行为进行更多控制,可以显式地控制它。

import { Menu, Transition } from '@headlessui/react' function MyDropdown() { return ( <Menu>
{({ open }) => (
<>
<Menu.Button>More</Menu.Button> {/* Use the `Transition` component. */} <Transition
show={open}
enter="transition duration-100 ease-out" enterFrom="transform scale-95 opacity-0" enterTo="transform scale-100 opacity-100" leave="transition duration-75 ease-out" leaveFrom="transform scale-100 opacity-100" leaveTo="transform scale-95 opacity-0" >
{/* Mark this component as `static` */}
<Menu.Items static>
<Menu.Item>{/* ... */}</Menu.Item> {/* ... */} </Menu.Items> </Transition>
</>
)}
</Menu> ) }

由于它们是无渲染的,无头 UI 组件还可以很好地与 React 生态系统中的其他动画库(如 Framer MotionReact Spring)组合使用。

role="menu" 的无障碍语义相当严格,任何不是 Menu.Item 组件的 Menu 子项都会被自动隐藏在辅助技术之外,以确保菜单按照屏幕阅读器用户预期的方式工作。

出于这个原因,不建议渲染除 Menu.Item 组件以外的任何子项,因为这些内容将无法被使用辅助技术的人员访问。

如果您想构建一个具有更灵活内容的下拉菜单,请考虑使用 Popover 而不是它。

默认情况下,Menu 及其子组件都会渲染一个对该组件来说是明智的默认元素。

例如,Menu.Button 默认渲染一个 button,而 Menu.Items 渲染一个 div。相比之下,MenuMenu.Item 不渲染元素,而是默认直接渲染其子项。

使用 as 属性将组件渲染为不同的元素,或者渲染为您自己的自定义组件,确保您的自定义组件 转发 ref,以便无头 UI 可以正确地连接它们。

import { forwardRef } from 'react' import { Menu } from '@headlessui/react'
let MyCustomButton = forwardRef(function (props, ref) {
return <button className="..." ref={ref} {...props} />
}) function MyDropdown() { return (
<Menu>
<Menu.Button as={MyCustomButton}>More</Menu.Button>
<Menu.Items as="section"> <Menu.Item> {({ active }) => ( <a className={`${active && 'bg-blue-500'}`} href="/account-settings" > Account settings </a> )} </Menu.Item> {/* ... */} </Menu.Items> </Menu> ) }

要告诉元素直接渲染其子项,而不使用包装元素,请使用 as={React.Fragment}

import { Menu } from '@headlessui/react' function MyDropdown() { return ( <Menu> {/* Render no wrapper, instead pass in a `button` manually. */}
<Menu.Button as={React.Fragment}>
<button>More</button> </Menu.Button> <Menu.Items> <Menu.Item> {({ active }) => ( <a className={`${active && 'bg-blue-500'}`} href="/account-settings" > Account settings </a> )} </Menu.Item> {/* ... */} </Menu.Items> </Menu> ) }

这对于在 Menu.Item 中使用交互式元素(如 <a> 标记)非常重要。如果 Menu.Item 有一个 as="div",那么无头 UI 提供的道具将转发到 div 而不是 a,这意味着您无法再通过键盘访问 <a> 标记提供的 URL。

在 Next.js v13 之前,Link 组件不会将未知道具转发到底层的 a 元素,这会阻止菜单在 Menu.Item 中使用时在点击时关闭。

如果您使用的是 Next.js v12 或更旧版本,您可以通过创建一个包装 Link 并将未知道具转发到底层 a 元素的组件来解决此问题。

import { forwardRef } from 'react'
import Link from 'next/link'
import { Menu } 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> <Menu.Button>More</Menu.Button> <Menu.Items> <Menu.Item>
<MyLink href="/profile">Profile</MyLink>
</Menu.Item> </Menu.Items> </Menu> ) }

这将确保将无头 UI 需要添加到 a 元素的所有事件监听器都正确应用。

此行为在 Next.js v13 中更改,因此不再需要此解决方法。

点击 Menu.Button 会切换菜单并使 Menu.Items 组件获得焦点。焦点会一直停留在打开的菜单中,直到按下 Escape 或用户点击菜单之外。关闭菜单会将焦点返回到 Menu.Button

点击 Menu.Button 会切换菜单。点击打开菜单之外的任何地方都会关闭该菜单。

命令描述

EnterSpaceMenu.Button 获得焦点时

打开菜单并使第一个未禁用的项目获得焦点

ArrowDownArrowUpMenu.Button 获得焦点时

打开菜单并使第一个/最后一个未禁用的项目获得焦点

Esc 当菜单打开时

关闭所有打开的菜单

ArrowDownArrowUp当菜单打开时

使上一个/下一个未禁用的项目获得焦点

HomePageUp 当菜单打开时

使第一个未禁用的项目获得焦点

EndPageDown 当菜单打开时

聚焦最后一个未禁用的项目

EnterSpace 当菜单打开时

激活/点击当前菜单项

A–Za–z 当菜单打开时

聚焦第一个与键盘输入匹配的项目

所有相关的 ARIA 属性都会自动管理。

有关在 Menu 中实现的所有无障碍功能的完整参考,请参阅 菜单按钮的 ARIA 规范

菜单最适合类似于在大多数操作系统标题栏中找到的菜单的 UI 元素。它们具有特定的无障碍语义,其内容应限于链接或按钮列表。焦点被困在打开的菜单中,因此您无法使用 Tab 键遍历内容或离开菜单。相反,箭头键在菜单的项目中导航。

以下是在何时可以使用 Headless UI 中的其他类似组件

  • <Popover />。弹出窗口是通用的浮动菜单。它们出现在触发它们的按钮附近,您可以在其中放置任意的标记,例如图像或不可点击的内容。Tab 键以与其他正常标记相同的方式导航弹出窗口的内容。它们非常适合构建带有可扩展内容和弹出面板的标题导航项。

  • <Disclosure />。披露对于扩展以显示其他信息的元素很有用,例如可切换的常见问题解答部分。它们通常以内联方式呈现,并在显示或隐藏时重新调整文档。

  • <Dialog />。对话框旨在吸引用户的全部注意力。它们通常在屏幕中央呈现一个浮动面板,并使用背景来使应用程序的其余内容变暗。它们还会捕获焦点并阻止在对话框内容消失之前离开对话框的内容进行制表符切换。

属性默认值描述
asFragment
字符串 | 组件

Menu 应该渲染成的元素或组件。

渲染属性描述
open

布尔值

菜单是否打开。

close

() => void

关闭菜单并重新聚焦 Menu.Button

属性默认值描述
asbutton
字符串 | 组件

Menu.Button 应该渲染成的元素或组件。

渲染属性描述
open

布尔值

菜单是否打开。

属性默认值描述
asdiv
字符串 | 组件

Menu.Items 应该渲染成的元素或组件。

staticfalse
布尔值

元素是否应该忽略内部管理的打开/关闭状态。

注意:staticunmount 不能同时使用。如果您尝试这样做,您将收到一个 TypeScript 错误。

unmounttrue
布尔值

元素是否应该根据打开/关闭状态卸载或隐藏。

注意:staticunmount 不能同时使用。如果您尝试这样做,您将收到一个 TypeScript 错误。

渲染属性描述
open

布尔值

菜单是否打开。

属性默认值描述
asFragment
字符串 | 组件

Menu.Item 应该渲染成的元素或组件。

disabledfalse
布尔值

项目是否应该被禁用以进行键盘导航和 ARIA 目的。

渲染属性描述
active

布尔值

项目是否为列表中的活动/聚焦项目。

disabled

布尔值

项目是否被禁用以进行键盘导航和 ARIA 目的。

close

() => void

关闭菜单并重新聚焦 Menu.Button

如果您有兴趣使用 Headless UI 和 Tailwind CSS 的预先设计的组件示例,请查看 Tailwind UI — 我们精心设计和制作的精美组件集。

这是一种支持我们对诸如这个开源项目的工作的好方法,并使我们能够改进它们并保持良好的维护。