Files
headlessui/packages/@headlessui-react/src/components/menu/menu.tsx
T
Robin Malfait 30a6d51665 Fix focus not returned to SVG Element (#3704)
This PR fixes an issue where the focus is not returned to an `SVG`
element with a `tabIndex` correctly.

There are a few issues going on here:

1. We assume that the element to focus (`e.target`) is an instanceof
`HTMLElement`, but the `SVGElement` is not an instanceof `HTMLElement`.
2. By using `instanceof` we are checking against concrete classes, so if
this happen to cross certain contexts (Shadow DOM, Iframes, ...) then
the instances would be different.

To solve this, we will now:

1. Relax the types and only care about the actual attributes and methods
we are interested in. In most cases this means changing internal types
from `HTMLElement` to `Element` for example.
2. We will check whether certain properties are available in the object
to deduce the correct type from the object.

Fixes: #3660

## Test plan

Added an SVG to open a Dialog component and made sure that pressing
`escape` or clicking outside of the Dialog does restore the focus to the
SVG itself.
```tsx
<svg
  tabIndex={0}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      setIsOpen((v) => !v)
    }
  }}
  onClick={() => setIsOpen((v) => !v)}
  className="h-6 w-6 text-gray-500"
>
  <BookOpenIcon />
</svg>
```


Here is a video of that behavior:


https://github.com/user-attachments/assets/1805ca67-8bc7-4315-98a7-2490cba9230c
2025-04-25 14:52:32 +02:00

913 lines
27 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 PointerEvent as ReactPointerEvent,
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 { Action as QuickReleaseAction, useQuickRelease } from '../../hooks/use-quick-release'
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 * as DOM from '../../utils/dom'
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, buttonElement, itemsElement] = useSlice(machine, (state) => [
state.menuState,
state.buttonElement,
state.itemsElement,
])
let enableQuickRelease = menuState === MenuState.Open
useQuickRelease(enableQuickRelease, {
trigger: buttonElement,
action: useCallback(
(e) => {
if (buttonElement?.contains(e.target)) {
return QuickReleaseAction.Ignore
}
let item = e.target.closest('[role="menuitem"]:not([data-disabled])')
if (DOM.isHTMLElement(item)) {
return QuickReleaseAction.Select(item)
}
if (itemsElement?.contains(e.target)) {
return QuickReleaseAction.Ignore
}
return QuickReleaseAction.Close
},
[buttonElement, itemsElement]
),
close: useCallback(() => machine.send({ type: ActionTypes.CloseMenu }), []),
select: useCallback((target) => target.click(), []),
})
let handlePointerDown = useEvent((event: ReactPointerEvent) => {
if (event.button !== 0) return // Only handle left clicks
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,
onPointerDown: handlePointerDown,
},
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.actions.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((event) => {
pointer.update(event)
if (disabled) return
if (active) return
machine.send({
type: ActionTypes.GoToItem,
focus: Focus.Specific,
id,
trigger: ActivationTrigger.Pointer,
})
})
let handleMove = useEvent((event) => {
if (!pointer.wasMoved(event)) return
if (disabled) return
if (active) return
machine.send({
type: ActionTypes.GoToItem,
focus: Focus.Specific,
id,
trigger: ActivationTrigger.Pointer,
})
})
let handleLeave = useEvent((event) => {
if (!pointer.wasMoved(event)) 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,
})