1ee4cfd1b7
* move `enabled` parameter in hooks to front
Whenever a hook requires an `enabled` state, the `enabled` parameter is
moved to the front. Initially this was the last argument and enabled by
default but everywhere that we use these hooks we have to pass a
dedicated boolean anyway.
This makes sure these hooks follow a similar pattern. Bonus points
because Prettier can now improve formatting the usage of these hooks.
The reason why is because there is no additional argument after the
potential last callback.
Before:
```ts
let enabled = data.__demoMode ? false : modal && data.comboboxState === ComboboxState.Open
useInertOthers(
{
allowed: useEvent(() => [
data.inputRef.current,
data.buttonRef.current,
data.optionsRef.current,
]),
},
enabled
)
```
After:
```ts
let enabled = data.__demoMode ? false : modal && data.comboboxState === ComboboxState.Open
useInertOthers(enabled, {
allowed: useEvent(() => [
data.inputRef.current,
data.buttonRef.current,
data.optionsRef.current,
]),
})
```
Much better!
* inline variables
1122 lines
33 KiB
TypeScript
1122 lines
33 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,
|
|
createContext,
|
|
createRef,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useReducer,
|
|
useRef,
|
|
type CSSProperties,
|
|
type Dispatch,
|
|
type ElementType,
|
|
type MutableRefObject,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
type MouseEvent as ReactMouseEvent,
|
|
type Ref,
|
|
} from 'react'
|
|
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 { 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 type { Props } from '../../types'
|
|
import { isDisabledReactIssue7711 } from '../../utils/bugs'
|
|
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
|
|
import { disposables } from '../../utils/disposables'
|
|
import {
|
|
Focus as FocusManagementFocus,
|
|
FocusableMode,
|
|
focusFrom,
|
|
isFocusableElement,
|
|
restoreFocusIfNecessary,
|
|
sortByDomNode,
|
|
} from '../../utils/focus-management'
|
|
import { match } from '../../utils/match'
|
|
import {
|
|
RenderFeatures,
|
|
forwardRefWithAs,
|
|
mergeProps,
|
|
render,
|
|
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'
|
|
|
|
enum MenuStates {
|
|
Open,
|
|
Closed,
|
|
}
|
|
|
|
enum ActivationTrigger {
|
|
Pointer,
|
|
Other,
|
|
}
|
|
|
|
type MenuItemDataRef = MutableRefObject<{
|
|
textValue?: string
|
|
disabled: boolean
|
|
domRef: MutableRefObject<HTMLElement | null>
|
|
}>
|
|
|
|
interface StateDefinition {
|
|
__demoMode: boolean
|
|
menuState: MenuStates
|
|
buttonRef: MutableRefObject<HTMLButtonElement | null>
|
|
itemsRef: MutableRefObject<HTMLElement | null>
|
|
items: { id: string; dataRef: MenuItemDataRef }[]
|
|
searchQuery: string
|
|
activeItemIndex: number | null
|
|
activationTrigger: ActivationTrigger
|
|
}
|
|
|
|
enum ActionTypes {
|
|
OpenMenu,
|
|
CloseMenu,
|
|
|
|
GoToItem,
|
|
Search,
|
|
ClearSearch,
|
|
RegisterItem,
|
|
UnregisterItem,
|
|
}
|
|
|
|
function adjustOrderedState(
|
|
state: StateDefinition,
|
|
adjustment: (items: StateDefinition['items']) => StateDefinition['items'] = (i) => i
|
|
) {
|
|
let currentActiveItem = state.activeItemIndex !== null ? state.items[state.activeItemIndex] : null
|
|
|
|
let sortedItems = sortByDomNode(
|
|
adjustment(state.items.slice()),
|
|
(item) => item.dataRef.current.domRef.current
|
|
)
|
|
|
|
// If we inserted an item before the current active item then the active item index
|
|
// would be wrong. To fix this, we will re-lookup the correct index.
|
|
let adjustedActiveItemIndex = currentActiveItem ? sortedItems.indexOf(currentActiveItem) : null
|
|
|
|
// Reset to `null` in case the currentActiveItem was removed.
|
|
if (adjustedActiveItemIndex === -1) {
|
|
adjustedActiveItemIndex = null
|
|
}
|
|
|
|
return {
|
|
items: sortedItems,
|
|
activeItemIndex: adjustedActiveItemIndex,
|
|
}
|
|
}
|
|
|
|
type Actions =
|
|
| { type: ActionTypes.CloseMenu }
|
|
| { type: ActionTypes.OpenMenu }
|
|
| { type: ActionTypes.GoToItem; focus: Focus.Specific; id: string; trigger?: ActivationTrigger }
|
|
| {
|
|
type: ActionTypes.GoToItem
|
|
focus: Exclude<Focus, Focus.Specific>
|
|
trigger?: ActivationTrigger
|
|
}
|
|
| { type: ActionTypes.Search; value: string }
|
|
| { type: ActionTypes.ClearSearch }
|
|
| { type: ActionTypes.RegisterItem; id: string; dataRef: MenuItemDataRef }
|
|
| { type: ActionTypes.UnregisterItem; id: string }
|
|
|
|
let reducers: {
|
|
[P in ActionTypes]: (
|
|
state: StateDefinition,
|
|
action: Extract<Actions, { type: P }>
|
|
) => StateDefinition
|
|
} = {
|
|
[ActionTypes.CloseMenu](state) {
|
|
if (state.menuState === MenuStates.Closed) return state
|
|
return { ...state, activeItemIndex: null, menuState: MenuStates.Closed }
|
|
},
|
|
[ActionTypes.OpenMenu](state) {
|
|
if (state.menuState === MenuStates.Open) return state
|
|
return {
|
|
...state,
|
|
/* We can turn off demo mode once we re-open the `Menu` */
|
|
__demoMode: false,
|
|
menuState: MenuStates.Open,
|
|
}
|
|
},
|
|
[ActionTypes.GoToItem]: (state, action) => {
|
|
if (state.menuState === MenuStates.Closed) return state
|
|
|
|
let base = {
|
|
...state,
|
|
searchQuery: '',
|
|
activationTrigger: action.trigger ?? ActivationTrigger.Other,
|
|
__demoMode: false,
|
|
}
|
|
|
|
// Optimization:
|
|
//
|
|
// There is no need to sort the DOM nodes if we know that we don't want to focus anything
|
|
if (action.focus === Focus.Nothing) {
|
|
return {
|
|
...base,
|
|
activeItemIndex: null,
|
|
}
|
|
}
|
|
|
|
// Optimization:
|
|
//
|
|
// There is no need to sort the DOM nodes if we know exactly where to go
|
|
if (action.focus === Focus.Specific) {
|
|
return {
|
|
...base,
|
|
activeItemIndex: state.items.findIndex((o) => o.id === action.id),
|
|
}
|
|
}
|
|
|
|
// Optimization:
|
|
//
|
|
// If the current DOM node and the previous DOM node are next to each other,
|
|
// or if the previous DOM node is already the first DOM node, then we don't
|
|
// have to sort all the DOM nodes.
|
|
else if (action.focus === Focus.Previous) {
|
|
let activeItemIdx = state.activeItemIndex
|
|
if (activeItemIdx !== null) {
|
|
let currentDom = state.items[activeItemIdx].dataRef.current.domRef
|
|
let previousItemIndex = calculateActiveIndex(action, {
|
|
resolveItems: () => state.items,
|
|
resolveActiveIndex: () => state.activeItemIndex,
|
|
resolveId: (item) => item.id,
|
|
resolveDisabled: (item) => item.dataRef.current.disabled,
|
|
})
|
|
if (previousItemIndex !== null) {
|
|
let previousDom = state.items[previousItemIndex].dataRef.current.domRef
|
|
if (
|
|
// Next to each other
|
|
currentDom.current?.previousElementSibling === previousDom.current ||
|
|
// Or already the first element
|
|
previousDom.current?.previousElementSibling === null
|
|
) {
|
|
return {
|
|
...base,
|
|
activeItemIndex: previousItemIndex,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Optimization:
|
|
//
|
|
// If the current DOM node and the next DOM node are next to each other, or
|
|
// if the next DOM node is already the last DOM node, then we don't have to
|
|
// sort all the DOM nodes.
|
|
else if (action.focus === Focus.Next) {
|
|
let activeItemIdx = state.activeItemIndex
|
|
if (activeItemIdx !== null) {
|
|
let currentDom = state.items[activeItemIdx].dataRef.current.domRef
|
|
let nextItemIndex = calculateActiveIndex(action, {
|
|
resolveItems: () => state.items,
|
|
resolveActiveIndex: () => state.activeItemIndex,
|
|
resolveId: (item) => item.id,
|
|
resolveDisabled: (item) => item.dataRef.current.disabled,
|
|
})
|
|
if (nextItemIndex !== null) {
|
|
let nextDom = state.items[nextItemIndex].dataRef.current.domRef
|
|
if (
|
|
// Next to each other
|
|
currentDom.current?.nextElementSibling === nextDom.current ||
|
|
// Or already the last element
|
|
nextDom.current?.nextElementSibling === null
|
|
) {
|
|
return {
|
|
...base,
|
|
activeItemIndex: nextItemIndex,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Slow path:
|
|
//
|
|
// Ensure all the items are correctly sorted according to DOM position
|
|
let adjustedState = adjustOrderedState(state)
|
|
let activeItemIndex = calculateActiveIndex(action, {
|
|
resolveItems: () => adjustedState.items,
|
|
resolveActiveIndex: () => adjustedState.activeItemIndex,
|
|
resolveId: (item) => item.id,
|
|
resolveDisabled: (item) => item.dataRef.current.disabled,
|
|
})
|
|
|
|
return {
|
|
...base,
|
|
...adjustedState,
|
|
activeItemIndex,
|
|
}
|
|
},
|
|
[ActionTypes.Search]: (state, action) => {
|
|
let wasAlreadySearching = state.searchQuery !== ''
|
|
let offset = wasAlreadySearching ? 0 : 1
|
|
let searchQuery = state.searchQuery + action.value.toLowerCase()
|
|
|
|
let reOrderedItems =
|
|
state.activeItemIndex !== null
|
|
? state.items
|
|
.slice(state.activeItemIndex + offset)
|
|
.concat(state.items.slice(0, state.activeItemIndex + offset))
|
|
: state.items
|
|
|
|
let matchingItem = reOrderedItems.find(
|
|
(item) =>
|
|
item.dataRef.current.textValue?.startsWith(searchQuery) && !item.dataRef.current.disabled
|
|
)
|
|
|
|
let matchIdx = matchingItem ? state.items.indexOf(matchingItem) : -1
|
|
if (matchIdx === -1 || matchIdx === state.activeItemIndex) return { ...state, searchQuery }
|
|
return {
|
|
...state,
|
|
searchQuery,
|
|
activeItemIndex: matchIdx,
|
|
activationTrigger: ActivationTrigger.Other,
|
|
}
|
|
},
|
|
[ActionTypes.ClearSearch](state) {
|
|
if (state.searchQuery === '') return state
|
|
return { ...state, searchQuery: '', searchActiveItemIndex: null }
|
|
},
|
|
[ActionTypes.RegisterItem]: (state, action) => {
|
|
let adjustedState = adjustOrderedState(state, (items) => [
|
|
...items,
|
|
{ id: action.id, dataRef: action.dataRef },
|
|
])
|
|
|
|
return { ...state, ...adjustedState }
|
|
},
|
|
[ActionTypes.UnregisterItem]: (state, action) => {
|
|
let adjustedState = adjustOrderedState(state, (items) => {
|
|
let idx = items.findIndex((a) => a.id === action.id)
|
|
if (idx !== -1) items.splice(idx, 1)
|
|
return items
|
|
})
|
|
|
|
return {
|
|
...state,
|
|
...adjustedState,
|
|
activationTrigger: ActivationTrigger.Other,
|
|
}
|
|
},
|
|
}
|
|
|
|
let MenuContext = createContext<[StateDefinition, Dispatch<Actions>] | null>(null)
|
|
MenuContext.displayName = 'MenuContext'
|
|
|
|
function useMenuContext(component: string) {
|
|
let context = useContext(MenuContext)
|
|
if (context === null) {
|
|
let err = new Error(`<${component} /> is missing a parent <Menu /> component.`)
|
|
if (Error.captureStackTrace) Error.captureStackTrace(err, useMenuContext)
|
|
throw err
|
|
}
|
|
return context
|
|
}
|
|
|
|
function stateReducer(state: StateDefinition, action: Actions) {
|
|
return match(action.type, reducers, state, action)
|
|
}
|
|
|
|
// ---
|
|
|
|
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 reducerBag = useReducer(stateReducer, {
|
|
__demoMode,
|
|
menuState: __demoMode ? MenuStates.Open : MenuStates.Closed,
|
|
buttonRef: createRef(),
|
|
itemsRef: createRef(),
|
|
items: [],
|
|
searchQuery: '',
|
|
activeItemIndex: null,
|
|
activationTrigger: ActivationTrigger.Other,
|
|
} as StateDefinition)
|
|
let [{ menuState, itemsRef, buttonRef }, dispatch] = reducerBag
|
|
let menuRef = useSyncRefs(ref)
|
|
|
|
// Handle outside click
|
|
let outsideClickEnabled = menuState === MenuStates.Open
|
|
useOutsideClick(outsideClickEnabled, [buttonRef, itemsRef], (event, target) => {
|
|
dispatch({ type: ActionTypes.CloseMenu })
|
|
|
|
if (!isFocusableElement(target, FocusableMode.Loose)) {
|
|
event.preventDefault()
|
|
buttonRef.current?.focus()
|
|
}
|
|
})
|
|
|
|
let close = useEvent(() => {
|
|
dispatch({ type: ActionTypes.CloseMenu })
|
|
})
|
|
|
|
let slot = useMemo(
|
|
() => ({ open: menuState === MenuStates.Open, close }) satisfies MenuRenderPropArg,
|
|
[menuState, close]
|
|
)
|
|
|
|
let ourProps = { ref: menuRef }
|
|
|
|
return (
|
|
<FloatingProvider>
|
|
<MenuContext.Provider value={reducerBag}>
|
|
<OpenClosedProvider
|
|
value={match(menuState, {
|
|
[MenuStates.Open]: State.Open,
|
|
[MenuStates.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 internalId = useId()
|
|
let {
|
|
id = `headlessui-menu-button-${internalId}`,
|
|
disabled = false,
|
|
autoFocus = false,
|
|
...theirProps
|
|
} = props
|
|
let [state, dispatch] = useMenuContext('Menu.Button')
|
|
let getFloatingReferenceProps = useFloatingReferenceProps()
|
|
let buttonRef = useSyncRefs(state.buttonRef, ref, useFloatingReference())
|
|
|
|
let d = useDisposables()
|
|
|
|
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()
|
|
dispatch({ type: ActionTypes.OpenMenu })
|
|
d.nextFrame(() => dispatch({ type: ActionTypes.GoToItem, focus: Focus.First }))
|
|
break
|
|
|
|
case Keys.ArrowUp:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
dispatch({ type: ActionTypes.OpenMenu })
|
|
d.nextFrame(() => dispatch({ type: ActionTypes.GoToItem, 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 handleClick = useEvent((event: ReactMouseEvent) => {
|
|
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
|
|
if (disabled) return
|
|
if (state.menuState === MenuStates.Open) {
|
|
dispatch({ type: ActionTypes.CloseMenu })
|
|
d.nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
|
|
} else {
|
|
event.preventDefault()
|
|
dispatch({ type: ActionTypes.OpenMenu })
|
|
}
|
|
})
|
|
|
|
let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus })
|
|
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled })
|
|
let { pressed: active, pressProps } = useActivePress({ disabled })
|
|
|
|
let slot = useMemo(() => {
|
|
return {
|
|
open: state.menuState === MenuStates.Open,
|
|
active: active || state.menuState === MenuStates.Open,
|
|
disabled,
|
|
hover,
|
|
focus,
|
|
autofocus: autoFocus,
|
|
} satisfies ButtonRenderPropArg
|
|
}, [state, hover, focus, active, disabled, autoFocus])
|
|
|
|
let ourProps = mergeProps(
|
|
getFloatingReferenceProps(),
|
|
{
|
|
ref: buttonRef,
|
|
id,
|
|
type: useResolveButtonType(props, state.buttonRef),
|
|
'aria-haspopup': 'menu',
|
|
'aria-controls': state.itemsRef.current?.id,
|
|
'aria-expanded': state.menuState === MenuStates.Open,
|
|
disabled: disabled || undefined,
|
|
autoFocus,
|
|
onKeyDown: handleKeyDown,
|
|
onKeyUp: handleKeyUp,
|
|
onClick: handleClick,
|
|
},
|
|
focusProps,
|
|
hoverProps,
|
|
pressProps
|
|
)
|
|
|
|
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
|
|
|
|
// 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,
|
|
...theirProps
|
|
} = props
|
|
let anchor = useResolvedAnchor(rawAnchor)
|
|
let [state, dispatch] = useMenuContext('Menu.Items')
|
|
let [floatingRef, style] = useFloatingPanel(anchor)
|
|
let getFloatingPanelProps = useFloatingPanelProps()
|
|
let itemsRef = useSyncRefs(state.itemsRef, ref, anchor ? floatingRef : null)
|
|
let ownerDocument = useOwnerDocument(state.itemsRef)
|
|
|
|
// Always enable `portal` functionality, when `anchor` is enabled
|
|
if (anchor) {
|
|
portal = true
|
|
}
|
|
|
|
let searchDisposables = useDisposables()
|
|
|
|
let usesOpenClosedState = useOpenClosed()
|
|
let visible = (() => {
|
|
if (usesOpenClosedState !== null) {
|
|
return (usesOpenClosedState & State.Open) === State.Open
|
|
}
|
|
|
|
return state.menuState === MenuStates.Open
|
|
})()
|
|
|
|
// Ensure we close the menu as soon as the button becomes hidden
|
|
useOnDisappear(visible, state.buttonRef, () => {
|
|
dispatch({ type: ActionTypes.CloseMenu })
|
|
})
|
|
|
|
// Enable scroll locking when the menu is visible, and `modal` is enabled
|
|
let scrollLockEnabled = state.__demoMode ? false : modal && state.menuState === MenuStates.Open
|
|
useScrollLock(scrollLockEnabled, ownerDocument)
|
|
|
|
// Mark other elements as inert when the menu is visible, and `modal` is enabled
|
|
let inertOthersEnabled = state.__demoMode ? false : modal && state.menuState === MenuStates.Open
|
|
useInertOthers(inertOthersEnabled, {
|
|
allowed: useEvent(() => [state.buttonRef.current, state.itemsRef.current]),
|
|
})
|
|
|
|
// 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 = state.menuState !== MenuStates.Open
|
|
let didButtonMove = useDidElementMove(didButtonMoveEnabled, state.buttonRef)
|
|
|
|
// 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 = state.itemsRef.current
|
|
if (!container) return
|
|
if (state.menuState !== MenuStates.Open) return
|
|
if (container === ownerDocument?.activeElement) return
|
|
|
|
container.focus({ preventScroll: true })
|
|
}, [state.menuState, state.itemsRef, ownerDocument, state.itemsRef.current])
|
|
|
|
useTreeWalker(state.menuState === MenuStates.Open, {
|
|
container: state.itemsRef.current,
|
|
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 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 (state.searchQuery !== '') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
return dispatch({ type: ActionTypes.Search, value: event.key })
|
|
}
|
|
// When in type ahead mode, fallthrough
|
|
case Keys.Enter:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
dispatch({ type: ActionTypes.CloseMenu })
|
|
if (state.activeItemIndex !== null) {
|
|
let { dataRef } = state.items[state.activeItemIndex]
|
|
dataRef.current?.domRef.current?.click()
|
|
}
|
|
restoreFocusIfNecessary(state.buttonRef.current)
|
|
break
|
|
|
|
case Keys.ArrowDown:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Next })
|
|
|
|
case Keys.ArrowUp:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Previous })
|
|
|
|
case Keys.Home:
|
|
case Keys.PageUp:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.First })
|
|
|
|
case Keys.End:
|
|
case Keys.PageDown:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Last })
|
|
|
|
case Keys.Escape:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
dispatch({ type: ActionTypes.CloseMenu })
|
|
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
|
|
break
|
|
|
|
case Keys.Tab:
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
dispatch({ type: ActionTypes.CloseMenu })
|
|
disposables().microTask(() => {
|
|
focusFrom(
|
|
state.buttonRef.current!,
|
|
event.shiftKey ? FocusManagementFocus.Previous : FocusManagementFocus.Next
|
|
)
|
|
})
|
|
break
|
|
|
|
default:
|
|
if (event.key.length === 1) {
|
|
dispatch({ type: ActionTypes.Search, value: event.key })
|
|
searchDisposables.setTimeout(() => dispatch({ 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(
|
|
() => ({ open: state.menuState === MenuStates.Open }) satisfies ItemsRenderPropArg,
|
|
[state]
|
|
)
|
|
|
|
let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, {
|
|
'aria-activedescendant':
|
|
state.activeItemIndex === null ? undefined : state.items[state.activeItemIndex]?.id,
|
|
'aria-labelledby': state.buttonRef.current?.id,
|
|
id,
|
|
onKeyDown: handleKeyDown,
|
|
onKeyUp: handleKeyUp,
|
|
role: 'menu',
|
|
tabIndex: 0,
|
|
ref: itemsRef,
|
|
style: {
|
|
...style,
|
|
'--button-width': useElementSize(state.buttonRef, true).width,
|
|
} as CSSProperties,
|
|
})
|
|
|
|
return (
|
|
<Portal enabled={portal ? props.static || visible : false}>
|
|
{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 [state, dispatch] = useMenuContext('Menu.Item')
|
|
let active = state.activeItemIndex !== null ? state.items[state.activeItemIndex].id === id : false
|
|
let internalItemRef = useRef<HTMLElement | null>(null)
|
|
let itemRef = useSyncRefs(ref, internalItemRef)
|
|
|
|
useIsoMorphicEffect(() => {
|
|
if (state.__demoMode) return
|
|
if (state.menuState !== MenuStates.Open) return
|
|
if (!active) return
|
|
if (state.activationTrigger === ActivationTrigger.Pointer) return
|
|
let d = disposables()
|
|
d.requestAnimationFrame(() => {
|
|
internalItemRef.current?.scrollIntoView?.({ block: 'nearest' })
|
|
})
|
|
return d.dispose
|
|
}, [
|
|
state.__demoMode,
|
|
internalItemRef,
|
|
active,
|
|
state.menuState,
|
|
state.activationTrigger,
|
|
/* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeItemIndex,
|
|
])
|
|
|
|
let getTextValue = useTextValue(internalItemRef)
|
|
|
|
let bag = useRef<MenuItemDataRef['current']>({
|
|
disabled,
|
|
domRef: internalItemRef,
|
|
get textValue() {
|
|
return getTextValue()
|
|
},
|
|
})
|
|
|
|
useIsoMorphicEffect(() => {
|
|
bag.current.disabled = disabled
|
|
}, [bag, disabled])
|
|
|
|
useIsoMorphicEffect(() => {
|
|
dispatch({ type: ActionTypes.RegisterItem, id, dataRef: bag })
|
|
return () => dispatch({ type: ActionTypes.UnregisterItem, id })
|
|
}, [bag, id])
|
|
|
|
let close = useEvent(() => {
|
|
dispatch({ type: ActionTypes.CloseMenu })
|
|
})
|
|
|
|
let handleClick = useEvent((event: MouseEvent) => {
|
|
if (disabled) return event.preventDefault()
|
|
dispatch({ type: ActionTypes.CloseMenu })
|
|
restoreFocusIfNecessary(state.buttonRef.current)
|
|
})
|
|
|
|
let handleFocus = useEvent(() => {
|
|
if (disabled) return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
|
|
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Specific, id })
|
|
})
|
|
|
|
let pointer = useTrackedPointer()
|
|
|
|
let handleEnter = useEvent((evt) => {
|
|
pointer.update(evt)
|
|
if (disabled) return
|
|
if (active) return
|
|
dispatch({
|
|
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
|
|
dispatch({
|
|
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
|
|
dispatch({ 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,
|
|
}
|
|
|
|
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' }
|
|
|
|
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 }
|
|
|
|
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' }
|
|
|
|
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>
|
|
): JSX.Element
|
|
}
|
|
|
|
export interface _internal_ComponentMenuButton extends HasDisplayName {
|
|
<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
|
|
props: MenuButtonProps<TTag> & RefProp<typeof ButtonFn>
|
|
): JSX.Element
|
|
}
|
|
|
|
export interface _internal_ComponentMenuItems extends HasDisplayName {
|
|
<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
|
|
props: MenuItemsProps<TTag> & RefProp<typeof ItemsFn>
|
|
): JSX.Element
|
|
}
|
|
|
|
export interface _internal_ComponentMenuItem extends HasDisplayName {
|
|
<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
|
|
props: MenuItemProps<TTag> & RefProp<typeof ItemFn>
|
|
): JSX.Element
|
|
}
|
|
|
|
export interface _internal_ComponentMenuSection extends HasDisplayName {
|
|
<TTag extends ElementType = typeof DEFAULT_SECTION_TAG>(
|
|
props: MenuSectionProps<TTag> & RefProp<typeof SectionFn>
|
|
): JSX.Element
|
|
}
|
|
|
|
export interface _internal_ComponentMenuHeading extends HasDisplayName {
|
|
<TTag extends ElementType = typeof DEFAULT_HEADING_TAG>(
|
|
props: MenuHeadingProps<TTag> & RefProp<typeof HeadingFn>
|
|
): JSX.Element
|
|
}
|
|
|
|
export interface _internal_ComponentMenuSeparator extends HasDisplayName {
|
|
<TTag extends ElementType = typeof DEFAULT_SEPARATOR_TAG>(
|
|
props: MenuSeparatorProps<TTag> & RefProp<typeof SeparatorFn>
|
|
): 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,
|
|
})
|