Files
headlessui/packages/@headlessui-react/src/components/menu/menu.tsx
T
Robin Malfait a293af9788 Improve Menu component performance (#3685)
This PR improves the performance of the `Menu` component.

Before this PR, the `Menu` component is built in a way where all the
state lives in the `Menu` itself. If state changes, everything
re-renders and re-computes the necessary derived state.

However, if you have a 1000 items, then every time the active item
changes, all 1000 items have to re-render.

To solve this, we can move the state outside of the `Menu` component,
and "subscribe" to state changes using the `useSlice` hook introduced in
https://github.com/tailwindlabs/headlessui/pull/3684.

This will allow us to subscribe to a slice of the state, and only
re-render if the computed slice actually changes.

If the active item changes, only 3 things will happen:

1. The `MenuItems` will re-render and have an updated
`aria-activedescendant`
2. The `MenuItem` that _was_ active, will re-render and the `data-focus`
attribute wil be removed.
3. The `MenuItem` that is now active, will re-render and the
`data-focus` attribute wil be added.

Another improvement is that in order to make sure that your arrow keys
go to the correct item, we need to sort the DOM nodes and make sure that
we go to the correct item when using arrow up and down. This sorting was
happening every time a new `MenuItem` was registered.

Luckily, once an array is sorted, you don't have to do a lot, but you
still have to loop over `n` items which is not ideal.

This PR will now delay the sorting until all `MenuItem`s are registered.

On that note, we also batch the `RegisterItem` so we can perform a
single update instead of `n` updates. We use a microTask for the
batching (so if you only are registering a single item, you don't have
to wait compared to a `setTimeout` or a `requestAnimationFrame`).

## Test plan

1. All tests still pass
2. Tested this in the browser with a 1000 items. In the videos below the
only thing I'm doing is holding down the `ArrowDown` key.

Before:


https://github.com/user-attachments/assets/513b02c1-fc69-47f3-a97e-c56d44dd585a

After:


https://github.com/user-attachments/assets/266236a0-b64a-4322-9a54-ead7fb62191f
2025-04-10 22:27:11 +02:00

883 lines
26 KiB
TypeScript

'use client'
// WAI-ARIA: https://www.w3.org/WAI/ARIA/apg/patterns/menubutton/
import { useFocusRing } from '@react-aria/focus'
import { useHover } from '@react-aria/interactions'
import React, {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ElementType,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type Ref,
} from 'react'
import { flushSync } from 'react-dom'
import { useActivePress } from '../../hooks/use-active-press'
import { useDidElementMove } from '../../hooks/use-did-element-move'
import { useDisposables } from '../../hooks/use-disposables'
import { useElementSize } from '../../hooks/use-element-size'
import { useEvent } from '../../hooks/use-event'
import { useId } from '../../hooks/use-id'
import { useInertOthers } from '../../hooks/use-inert-others'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useOnDisappear } from '../../hooks/use-on-disappear'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { useOwnerDocument } from '../../hooks/use-owner'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useScrollLock } from '../../hooks/use-scroll-lock'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useTextValue } from '../../hooks/use-text-value'
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
import { transitionDataAttributes, useTransition } from '../../hooks/use-transition'
import { useTreeWalker } from '../../hooks/use-tree-walker'
import {
FloatingProvider,
useFloatingPanel,
useFloatingPanelProps,
useFloatingReference,
useFloatingReferenceProps,
useResolvedAnchor,
type AnchorProps,
} from '../../internal/floating'
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
import { useSlice } from '../../react-glue'
import type { Props } from '../../types'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { Focus } from '../../utils/calculate-active-index'
import { disposables } from '../../utils/disposables'
import {
Focus as FocusManagementFocus,
FocusableMode,
focusFrom,
isFocusableElement,
restoreFocusIfNecessary,
} from '../../utils/focus-management'
import { match } from '../../utils/match'
import {
RenderFeatures,
forwardRefWithAs,
mergeProps,
useRender,
type HasDisplayName,
type RefProp,
} from '../../utils/render'
import { useDescriptions } from '../description/description'
import { Keys } from '../keyboard'
import { useLabelContext, useLabels } from '../label/label'
import { Portal } from '../portal/portal'
import { ActionTypes, ActivationTrigger, MenuState, type MenuItemDataRef } from './menu-machine'
import { MenuContext, useMenuMachine, useMenuMachineContext } from './menu-machine-glue'
let DEFAULT_MENU_TAG = Fragment
type MenuRenderPropArg = {
open: boolean
close: () => void
}
type MenuPropsWeControl = never
export type MenuProps<TTag extends ElementType = typeof DEFAULT_MENU_TAG> = Props<
TTag,
MenuRenderPropArg,
MenuPropsWeControl,
{
__demoMode?: boolean
}
>
function MenuFn<TTag extends ElementType = typeof DEFAULT_MENU_TAG>(
props: MenuProps<TTag>,
ref: Ref<HTMLElement>
) {
let { __demoMode = false, ...theirProps } = props
let machine = useMenuMachine({ __demoMode })
let [menuState, itemsElement, buttonElement] = useSlice(machine, (state) => [
state.menuState,
state.itemsElement,
state.buttonElement,
])
let menuRef = useSyncRefs(ref)
// Handle outside click
let outsideClickEnabled = menuState === MenuState.Open
useOutsideClick(outsideClickEnabled, [buttonElement, itemsElement], (event, target) => {
machine.send({ type: ActionTypes.CloseMenu })
if (!isFocusableElement(target, FocusableMode.Loose)) {
event.preventDefault()
machine.state.buttonElement?.focus()
}
})
let close = useEvent(() => {
machine.send({ type: ActionTypes.CloseMenu })
})
let slot = useMemo(
() => ({ open: menuState === MenuState.Open, close }) satisfies MenuRenderPropArg,
[menuState, close]
)
let ourProps = { ref: menuRef }
let render = useRender()
return (
<FloatingProvider>
<MenuContext.Provider value={machine}>
<OpenClosedProvider
value={match(menuState, {
[MenuState.Open]: State.Open,
[MenuState.Closed]: State.Closed,
})}
>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_MENU_TAG,
name: 'Menu',
})}
</OpenClosedProvider>
</MenuContext.Provider>
</FloatingProvider>
)
}
// ---
let DEFAULT_BUTTON_TAG = 'button' as const
type ButtonRenderPropArg = {
open: boolean
active: boolean
hover: boolean
focus: boolean
disabled: boolean
autofocus: boolean
}
type ButtonPropsWeControl = 'aria-controls' | 'aria-expanded' | 'aria-haspopup'
export type MenuButtonProps<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG> = Props<
TTag,
ButtonRenderPropArg,
ButtonPropsWeControl,
{
disabled?: boolean
autoFocus?: boolean
}
>
function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
props: MenuButtonProps<TTag>,
ref: Ref<HTMLButtonElement>
) {
let machine = useMenuMachineContext('Menu.Button')
let internalId = useId()
let {
id = `headlessui-menu-button-${internalId}`,
disabled = false,
autoFocus = false,
...theirProps
} = props
let internalButtonRef = useRef<HTMLButtonElement | null>(null)
let getFloatingReferenceProps = useFloatingReferenceProps()
let buttonRef = useSyncRefs(
ref,
internalButtonRef,
useFloatingReference(),
useEvent((element) => machine.send({ type: ActionTypes.SetButtonElement, element }))
)
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menubutton/#keyboard-interaction-13
case Keys.Space:
case Keys.Enter:
case Keys.ArrowDown:
event.preventDefault()
event.stopPropagation()
machine.send({ type: ActionTypes.OpenMenu, focus: { focus: Focus.First } })
break
case Keys.ArrowUp:
event.preventDefault()
event.stopPropagation()
machine.send({ type: ActionTypes.OpenMenu, focus: { focus: Focus.Last } })
break
}
})
let handleKeyUp = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case Keys.Space:
// Required for firefox, event.preventDefault() in handleKeyDown for
// the Space key doesn't cancel the handleKeyUp, which in turn
// triggers a *click*.
event.preventDefault()
break
}
})
let [menuState, itemsElement] = useSlice(machine, (state) => [
state.menuState,
state.itemsElement,
])
let handleClick = useEvent((event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
if (disabled) return
if (menuState === MenuState.Open) {
flushSync(() => machine.send({ type: ActionTypes.CloseMenu }))
internalButtonRef.current?.focus({ preventScroll: true })
} else {
event.preventDefault()
machine.send({
type: ActionTypes.OpenMenu,
focus: { focus: Focus.Nothing },
trigger: ActivationTrigger.Pointer,
})
}
})
let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus })
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled })
let { pressed: active, pressProps } = useActivePress({ disabled })
let slot = useMemo(() => {
return {
open: menuState === MenuState.Open,
active: active || menuState === MenuState.Open,
disabled,
hover,
focus,
autofocus: autoFocus,
} satisfies ButtonRenderPropArg
}, [menuState, hover, focus, active, disabled, autoFocus])
let ourProps = mergeProps(
getFloatingReferenceProps(),
{
ref: buttonRef,
id,
type: useResolveButtonType(props, internalButtonRef.current),
'aria-haspopup': 'menu',
'aria-controls': itemsElement?.id,
'aria-expanded': menuState === MenuState.Open,
disabled: disabled || undefined,
autoFocus,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
onClick: handleClick,
},
focusProps,
hoverProps,
pressProps
)
let render = useRender()
return render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_BUTTON_TAG,
name: 'Menu.Button',
})
}
// ---
let DEFAULT_ITEMS_TAG = 'div' as const
type ItemsRenderPropArg = {
open: boolean
}
type ItemsPropsWeControl = 'aria-activedescendant' | 'aria-labelledby' | 'role' | 'tabIndex'
let ItemsRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
export type MenuItemsProps<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG> = Props<
TTag,
ItemsRenderPropArg,
ItemsPropsWeControl,
{
anchor?: AnchorProps
portal?: boolean
modal?: boolean
transition?: boolean
// ItemsRenderFeatures
static?: boolean
unmount?: boolean
}
>
function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
props: MenuItemsProps<TTag>,
ref: Ref<HTMLElement>
) {
let internalId = useId()
let {
id = `headlessui-menu-items-${internalId}`,
anchor: rawAnchor,
portal = false,
modal = true,
transition = false,
...theirProps
} = props
let anchor = useResolvedAnchor(rawAnchor)
let machine = useMenuMachineContext('Menu.Items')
let [floatingRef, style] = useFloatingPanel(anchor)
let getFloatingPanelProps = useFloatingPanelProps()
// To improve the correctness of transitions (timing related race conditions),
// we track the element locally to this component, instead of relying on the
// context value. This way, the component can re-render independently of the
// parent component when the `useTransition(…)` hook performs a state change.
let [localItemsElement, setLocalItemsElement] = useState<HTMLElement | null>(null)
let itemsRef = useSyncRefs(
ref,
anchor ? floatingRef : null,
useEvent((element) => machine.send({ type: ActionTypes.SetItemsElement, element })),
setLocalItemsElement
)
let [menuState, buttonElement] = useSlice(machine, (state) => [
state.menuState,
state.buttonElement,
])
let portalOwnerDocument = useOwnerDocument(buttonElement)
let ownerDocument = useOwnerDocument(localItemsElement)
// Always enable `portal` functionality, when `anchor` is enabled
if (anchor) {
portal = true
}
let usesOpenClosedState = useOpenClosed()
let [visible, transitionData] = useTransition(
transition,
localItemsElement,
usesOpenClosedState !== null
? (usesOpenClosedState & State.Open) === State.Open
: menuState === MenuState.Open
)
// Ensure we close the menu as soon as the button becomes hidden
useOnDisappear(visible, buttonElement, () => {
machine.send({ type: ActionTypes.CloseMenu })
})
// Enable scroll locking when the menu is visible, and `modal` is enabled
let __demoMode = useSlice(machine, (state) => state.__demoMode)
let scrollLockEnabled = __demoMode ? false : modal && menuState === MenuState.Open
useScrollLock(scrollLockEnabled, ownerDocument)
// Mark other elements as inert when the menu is visible, and `modal` is enabled
let inertOthersEnabled = __demoMode ? false : modal && menuState === MenuState.Open
useInertOthers(inertOthersEnabled, {
allowed: useCallback(
() => [buttonElement, localItemsElement],
[buttonElement, localItemsElement]
),
})
// We keep track whether the button moved or not, we only check this when the menu state becomes
// closed. If the button moved, then we want to cancel pending transitions to prevent that the
// attached `MenuItems` is still transitioning while the button moved away.
//
// If we don't cancel these transitions then there will be a period where the `MenuItems` is
// visible and moving around because it is trying to re-position itself based on the new position.
//
// This can be solved by only transitioning the `opacity` instead of everything, but if you _do_
// want to transition the y-axis for example you will run into the same issue again.
let didButtonMoveEnabled = menuState !== MenuState.Open
let didButtonMove = useDidElementMove(didButtonMoveEnabled, buttonElement)
// Now that we know that the button did move or not, we can either disable the panel and all of
// its transitions, or rely on the `visible` state to hide the panel whenever necessary.
let panelEnabled = didButtonMove ? false : visible
useEffect(() => {
let container = localItemsElement
if (!container) return
if (menuState !== MenuState.Open) return
if (container === ownerDocument?.activeElement) return
container.focus({ preventScroll: true })
}, [menuState, localItemsElement, ownerDocument])
useTreeWalker(menuState === MenuState.Open, {
container: localItemsElement,
accept(node) {
if (node.getAttribute('role') === 'menuitem') return NodeFilter.FILTER_REJECT
if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP
return NodeFilter.FILTER_ACCEPT
},
walk(node) {
node.setAttribute('role', 'none')
},
})
let searchDisposables = useDisposables()
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLElement>) => {
searchDisposables.dispose()
switch (event.key) {
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12
// @ts-expect-error Fallthrough is expected here
case Keys.Space:
if (machine.state.searchQuery !== '') {
event.preventDefault()
event.stopPropagation()
return machine.send({ type: ActionTypes.Search, value: event.key })
}
// When in type ahead mode, fallthrough
case Keys.Enter:
event.preventDefault()
event.stopPropagation()
if (machine.state.activeItemIndex !== null) {
let { dataRef } = machine.state.items[machine.state.activeItemIndex]
dataRef.current?.domRef.current?.click()
}
machine.send({ type: ActionTypes.CloseMenu })
restoreFocusIfNecessary(machine.state.buttonElement)
break
case Keys.ArrowDown:
event.preventDefault()
event.stopPropagation()
return machine.send({ type: ActionTypes.GoToItem, focus: Focus.Next })
case Keys.ArrowUp:
event.preventDefault()
event.stopPropagation()
return machine.send({ type: ActionTypes.GoToItem, focus: Focus.Previous })
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
return machine.send({ type: ActionTypes.GoToItem, focus: Focus.First })
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return machine.send({ type: ActionTypes.GoToItem, focus: Focus.Last })
case Keys.Escape:
event.preventDefault()
event.stopPropagation()
flushSync(() => machine.send({ type: ActionTypes.CloseMenu }))
machine.state.buttonElement?.focus({ preventScroll: true })
break
case Keys.Tab:
event.preventDefault()
event.stopPropagation()
flushSync(() => machine.send({ type: ActionTypes.CloseMenu }))
focusFrom(
machine.state.buttonElement!,
event.shiftKey ? FocusManagementFocus.Previous : FocusManagementFocus.Next
)
break
default:
if (event.key.length === 1) {
machine.send({ type: ActionTypes.Search, value: event.key })
searchDisposables.setTimeout(() => machine.send({ type: ActionTypes.ClearSearch }), 350)
}
break
}
})
let handleKeyUp = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) {
case Keys.Space:
// Required for firefox, event.preventDefault() in handleKeyDown for
// the Space key doesn't cancel the handleKeyUp, which in turn
// triggers a *click*.
event.preventDefault()
break
}
})
let slot = useMemo(() => {
return {
open: menuState === MenuState.Open,
} satisfies ItemsRenderPropArg
}, [menuState])
let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, {
'aria-activedescendant': useSlice(machine, machine.selectors.activeDescendantId),
'aria-labelledby': useSlice(machine, (state) => state.buttonElement?.id),
id,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
role: 'menu',
// When the `Menu` is closed, it should not be focusable. This allows us
// to skip focusing the `MenuItems` when pressing the tab key on an
// open `Menu`, and go to the next focusable element.
tabIndex: menuState === MenuState.Open ? 0 : undefined,
ref: itemsRef,
style: {
...theirProps.style,
...style,
'--button-width': useElementSize(buttonElement, true).width,
} as CSSProperties,
...transitionDataAttributes(transitionData),
})
let render = useRender()
return (
<Portal enabled={portal ? props.static || visible : false} ownerDocument={portalOwnerDocument}>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_ITEMS_TAG,
features: ItemsRenderFeatures,
visible: panelEnabled,
name: 'Menu.Items',
})}
</Portal>
)
}
// ---
let DEFAULT_ITEM_TAG = Fragment
type ItemRenderPropArg = {
/** @deprecated use `focus` instead */
active: boolean
focus: boolean
disabled: boolean
close: () => void
}
type ItemPropsWeControl =
| 'aria-describedby'
| 'aria-disabled'
| 'aria-labelledby'
| 'role'
| 'tabIndex'
export type MenuItemProps<TTag extends ElementType = typeof DEFAULT_ITEM_TAG> = Props<
TTag,
ItemRenderPropArg,
ItemPropsWeControl,
{
disabled?: boolean
}
>
function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
props: MenuItemProps<TTag>,
ref: Ref<HTMLElement>
) {
let internalId = useId()
let { id = `headlessui-menu-item-${internalId}`, disabled = false, ...theirProps } = props
let machine = useMenuMachineContext('Menu.Item')
let active = useSlice(machine, (state) => machine.selectors.isActive(state, id))
let internalItemRef = useRef<HTMLElement | null>(null)
let itemRef = useSyncRefs(ref, internalItemRef)
let shouldScrollIntoView = useSlice(machine, (state) =>
machine.selectors.shouldScrollIntoView(state, id)
)
useIsoMorphicEffect(() => {
if (!shouldScrollIntoView) return
return disposables().requestAnimationFrame(() => {
internalItemRef.current?.scrollIntoView?.({ block: 'nearest' })
})
}, [shouldScrollIntoView, internalItemRef])
let getTextValue = useTextValue(internalItemRef)
let bag = useRef<MenuItemDataRef['current']>({
disabled,
domRef: internalItemRef,
get textValue() {
return getTextValue()
},
})
useIsoMorphicEffect(() => {
bag.current.disabled = disabled
}, [bag, disabled])
useIsoMorphicEffect(() => {
machine.actions.registerItem(id, bag)
return () => machine.send({ type: ActionTypes.UnregisterItem, id })
}, [bag, id])
let close = useEvent(() => {
machine.send({ type: ActionTypes.CloseMenu })
})
let handleClick = useEvent((event: MouseEvent) => {
if (disabled) return event.preventDefault()
machine.send({ type: ActionTypes.CloseMenu })
restoreFocusIfNecessary(machine.state.buttonElement)
})
let handleFocus = useEvent(() => {
if (disabled) return machine.send({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
machine.send({ type: ActionTypes.GoToItem, focus: Focus.Specific, id })
})
let pointer = useTrackedPointer()
let handleEnter = useEvent((evt) => {
pointer.update(evt)
if (disabled) return
if (active) return
machine.send({
type: ActionTypes.GoToItem,
focus: Focus.Specific,
id,
trigger: ActivationTrigger.Pointer,
})
})
let handleMove = useEvent((evt) => {
if (!pointer.wasMoved(evt)) return
if (disabled) return
if (active) return
machine.send({
type: ActionTypes.GoToItem,
focus: Focus.Specific,
id,
trigger: ActivationTrigger.Pointer,
})
})
let handleLeave = useEvent((evt) => {
if (!pointer.wasMoved(evt)) return
if (disabled) return
if (!active) return
machine.send({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
})
let [labelledby, LabelProvider] = useLabels()
let [describedby, DescriptionProvider] = useDescriptions()
let slot = useMemo(
() => ({ active, focus: active, disabled, close }) satisfies ItemRenderPropArg,
[active, disabled, close]
)
let ourProps = {
id,
ref: itemRef,
role: 'menuitem',
tabIndex: disabled === true ? undefined : -1,
'aria-disabled': disabled === true ? true : undefined,
'aria-labelledby': labelledby,
'aria-describedby': describedby,
disabled: undefined, // Never forward the `disabled` prop
onClick: handleClick,
onFocus: handleFocus,
onPointerEnter: handleEnter,
onMouseEnter: handleEnter,
onPointerMove: handleMove,
onMouseMove: handleMove,
onPointerLeave: handleLeave,
onMouseLeave: handleLeave,
}
let render = useRender()
return (
<LabelProvider>
<DescriptionProvider>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_ITEM_TAG,
name: 'Menu.Item',
})}
</DescriptionProvider>
</LabelProvider>
)
}
// ---
let DEFAULT_SECTION_TAG = 'div' as const
type SectionRenderPropArg = {}
type SectionPropsWeControl = 'role' | 'aria-labelledby'
export type MenuSectionProps<TTag extends ElementType = typeof DEFAULT_SECTION_TAG> = Props<
TTag,
SectionRenderPropArg,
SectionPropsWeControl
>
function SectionFn<TTag extends ElementType = typeof DEFAULT_SECTION_TAG>(
props: MenuSectionProps<TTag>,
ref: Ref<HTMLElement>
) {
let [labelledby, LabelProvider] = useLabels()
let theirProps = props
let ourProps = { ref, 'aria-labelledby': labelledby, role: 'group' }
let render = useRender()
return (
<LabelProvider>
{render({
ourProps,
theirProps,
slot: {},
defaultTag: DEFAULT_SECTION_TAG,
name: 'Menu.Section',
})}
</LabelProvider>
)
}
// --
let DEFAULT_HEADING_TAG = 'header' as const
type HeadingRenderPropArg = {}
type HeadingPropsWeControl = 'role'
export type MenuHeadingProps<TTag extends ElementType = typeof DEFAULT_HEADING_TAG> = Props<
TTag,
HeadingRenderPropArg,
HeadingPropsWeControl
>
function HeadingFn<TTag extends ElementType = typeof DEFAULT_HEADING_TAG>(
props: MenuHeadingProps<TTag>,
ref: Ref<HTMLElement>
) {
let internalId = useId()
let { id = `headlessui-menu-heading-${internalId}`, ...theirProps } = props
let context = useLabelContext()
useIsoMorphicEffect(() => context.register(id), [id, context.register])
let ourProps = { id, ref, role: 'presentation', ...context.props }
let render = useRender()
return render({
ourProps,
theirProps,
slot: {},
defaultTag: DEFAULT_HEADING_TAG,
name: 'Menu.Heading',
})
}
// ---
let DEFAULT_SEPARATOR_TAG = 'div' as const
type SeparatorRenderPropArg = {}
type SeparatorPropsWeControl = 'role'
export type MenuSeparatorProps<TTag extends ElementType = typeof DEFAULT_SEPARATOR_TAG> = Props<
TTag,
SeparatorRenderPropArg,
SeparatorPropsWeControl
>
function SeparatorFn<TTag extends ElementType = typeof DEFAULT_SEPARATOR_TAG>(
props: MenuSeparatorProps<TTag>,
ref: Ref<HTMLElement>
) {
let theirProps = props
let ourProps = { ref, role: 'separator' }
let render = useRender()
return render({
ourProps,
theirProps,
slot: {},
defaultTag: DEFAULT_SEPARATOR_TAG,
name: 'Menu.Separator',
})
}
// ---
export interface _internal_ComponentMenu extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_MENU_TAG>(
props: MenuProps<TTag> & RefProp<typeof MenuFn>
): React.JSX.Element
}
export interface _internal_ComponentMenuButton extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
props: MenuButtonProps<TTag> & RefProp<typeof ButtonFn>
): React.JSX.Element
}
export interface _internal_ComponentMenuItems extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
props: MenuItemsProps<TTag> & RefProp<typeof ItemsFn>
): React.JSX.Element
}
export interface _internal_ComponentMenuItem extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
props: MenuItemProps<TTag> & RefProp<typeof ItemFn>
): React.JSX.Element
}
export interface _internal_ComponentMenuSection extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_SECTION_TAG>(
props: MenuSectionProps<TTag> & RefProp<typeof SectionFn>
): React.JSX.Element
}
export interface _internal_ComponentMenuHeading extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_HEADING_TAG>(
props: MenuHeadingProps<TTag> & RefProp<typeof HeadingFn>
): React.JSX.Element
}
export interface _internal_ComponentMenuSeparator extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_SEPARATOR_TAG>(
props: MenuSeparatorProps<TTag> & RefProp<typeof SeparatorFn>
): React.JSX.Element
}
let MenuRoot = forwardRefWithAs(MenuFn) as _internal_ComponentMenu
export let MenuButton = forwardRefWithAs(ButtonFn) as _internal_ComponentMenuButton
export let MenuItems = forwardRefWithAs(ItemsFn) as _internal_ComponentMenuItems
export let MenuItem = forwardRefWithAs(ItemFn) as _internal_ComponentMenuItem
export let MenuSection = forwardRefWithAs(SectionFn) as _internal_ComponentMenuSection
export let MenuHeading = forwardRefWithAs(HeadingFn) as _internal_ComponentMenuHeading
export let MenuSeparator = forwardRefWithAs(SeparatorFn) as _internal_ComponentMenuSeparator
export let Menu = Object.assign(MenuRoot, {
/** @deprecated use `<MenuButton>` instead of `<Menu.Button>` */
Button: MenuButton,
/** @deprecated use `<MenuItems>` instead of `<Menu.Items>` */
Items: MenuItems,
/** @deprecated use `<MenuItem>` instead of `<Menu.Item>` */
Item: MenuItem,
/** @deprecated use `<MenuSection>` instead of `<Menu.Section>` */
Section: MenuSection,
/** @deprecated use `<MenuHeading>` instead of `<Menu.Heading>` */
Heading: MenuHeading,
/** @deprecated use `<MenuSeparator>` instead of `<Menu.Separator>` */
Separator: MenuSeparator,
})