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
This commit is contained in:
Robin Malfait
2025-04-10 22:27:11 +02:00
committed by GitHub
parent e2a63760aa
commit a293af9788
4 changed files with 515 additions and 401 deletions
+3 -1
View File
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- Nothing yet!
### Fixed
- Improve `Menu` component performance ([#3685](https://github.com/tailwindlabs/headlessui/pull/3685))
## [2.2.1] - 2025-04-04
@@ -0,0 +1,17 @@
import { createContext, useContext, useMemo } from 'react'
import { MenuMachine } from './menu-machine'
export const MenuContext = createContext<MenuMachine | null>(null)
export function useMenuMachineContext(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, useMenuMachine)
throw err
}
return context
}
export function useMenuMachine({ __demoMode = false } = {}) {
return useMemo(() => MenuMachine.new({ __demoMode }), [])
}
@@ -0,0 +1,385 @@
import { Machine, batch } from '../../machine'
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
import { sortByDomNode } from '../../utils/focus-management'
import { match } from '../../utils/match'
export enum MenuState {
Open,
Closed,
}
export enum ActivationTrigger {
Pointer,
Other,
}
export type MenuItemDataRef = {
current: {
textValue?: string
disabled: boolean
domRef: { current: HTMLElement | null }
}
}
export interface State {
__demoMode: boolean
menuState: MenuState
buttonElement: HTMLButtonElement | null
itemsElement: HTMLElement | null
items: { id: string; dataRef: MenuItemDataRef }[]
searchQuery: string
activeItemIndex: number | null
activationTrigger: ActivationTrigger
pendingShouldSort: boolean
pendingFocus: { focus: Exclude<Focus, Focus.Specific> } | { focus: Focus.Specific; id: string }
}
export enum ActionTypes {
OpenMenu,
CloseMenu,
GoToItem,
Search,
ClearSearch,
RegisterItems,
UnregisterItem,
SetButtonElement,
SetItemsElement,
SortItems,
}
function adjustOrderedState(
state: State,
adjustment: (items: State['items']) => State['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,
}
}
export type Actions =
| { type: ActionTypes.CloseMenu }
| {
type: ActionTypes.OpenMenu
focus: { focus: Exclude<Focus, Focus.Specific> } | { focus: Focus.Specific; id: string }
trigger?: ActivationTrigger
}
| { 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.RegisterItems; items: { id: string; dataRef: MenuItemDataRef }[] }
| { type: ActionTypes.UnregisterItem; id: string }
| { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null }
| { type: ActionTypes.SetItemsElement; element: HTMLElement | null }
| { type: ActionTypes.SortItems }
let reducers: {
[P in ActionTypes]: (state: State, action: Extract<Actions, { type: P }>) => State
} = {
[ActionTypes.CloseMenu](state) {
if (state.menuState === MenuState.Closed) return state
return {
...state,
activeItemIndex: null,
pendingFocus: { focus: Focus.Nothing },
menuState: MenuState.Closed,
}
},
[ActionTypes.OpenMenu](state, action) {
if (state.menuState === MenuState.Open) return state
return {
...state,
/* We can turn off demo mode once we re-open the `Menu` */
__demoMode: false,
pendingFocus: action.focus,
menuState: MenuState.Open,
}
},
[ActionTypes.GoToItem]: (state, action) => {
if (state.menuState === MenuState.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.RegisterItems]: (state, action) => {
let items = state.items.concat(action.items.map((item) => item))
let activeItemIndex = state.activeItemIndex
if (state.pendingFocus.focus !== Focus.Nothing) {
activeItemIndex = calculateActiveIndex(state.pendingFocus, {
resolveItems: () => items,
resolveActiveIndex: () => state.activeItemIndex,
resolveId: (item) => item.id,
resolveDisabled: (item) => item.dataRef.current.disabled,
})
}
return {
...state,
items,
activeItemIndex,
pendingFocus: { focus: Focus.Nothing },
pendingShouldSort: true,
}
},
[ActionTypes.UnregisterItem]: (state, action) => {
let items = state.items
let idx = items.findIndex((a) => a.id === action.id)
if (idx !== -1) {
items = items.slice()
items.splice(idx, 1)
}
return {
...state,
items,
activationTrigger: ActivationTrigger.Other,
}
},
[ActionTypes.SetButtonElement]: (state, action) => {
if (state.buttonElement === action.element) return state
return { ...state, buttonElement: action.element }
},
[ActionTypes.SetItemsElement]: (state, action) => {
if (state.itemsElement === action.element) return state
return { ...state, itemsElement: action.element }
},
[ActionTypes.SortItems]: (state) => {
if (!state.pendingShouldSort) return state
return {
...state,
...adjustOrderedState(state),
pendingShouldSort: false,
}
},
}
export class MenuMachine extends Machine<State, Actions> {
static new({ __demoMode = false } = {}) {
return new MenuMachine({
__demoMode,
menuState: __demoMode ? MenuState.Open : MenuState.Closed,
buttonElement: null,
itemsElement: null,
items: [],
searchQuery: '',
activeItemIndex: null,
activationTrigger: ActivationTrigger.Other,
pendingShouldSort: false,
pendingFocus: { focus: Focus.Nothing },
})
}
constructor(initialState: State) {
super(initialState)
this.on(ActionTypes.RegisterItems, () => {
// Schedule a sort of the items when the DOM is ready. This doesn't
// change anything rendering wise, but the sorted items are used when
// using arrow keys so we can jump to previous / next items.
requestAnimationFrame(() => {
this.send({ type: ActionTypes.SortItems })
})
})
}
reduce(state: Readonly<State>, action: Actions): State {
return match(action.type, reducers, state, action)
}
actions = {
// Batched version to register multiple items at the same time
registerItem: batch(() => {
let items: { id: string; dataRef: MenuItemDataRef }[] = []
return [
(id: string, dataRef: MenuItemDataRef) => items.push({ id, dataRef }),
() => this.send({ type: ActionTypes.RegisterItems, items: items.splice(0) }),
]
}),
}
selectors = {
activeDescendantId(state: State) {
let activeItemIndex = state.activeItemIndex
let items = state.items
return activeItemIndex === null ? undefined : items[activeItemIndex]?.id
},
isActive(state: State, id: string) {
let activeItemIndex = state.activeItemIndex
let items = state.items
return activeItemIndex !== null ? items[activeItemIndex]?.id === id : false
},
shouldScrollIntoView(state: State, id: string) {
if (state.__demoMode) return false
if (state.menuState !== MenuState.Open) return false
if (state.activationTrigger === ActivationTrigger.Pointer) return false
return this.isActive(state, id)
},
}
}
@@ -5,18 +5,13 @@ import { useFocusRing } from '@react-aria/focus'
import { useHover } from '@react-aria/interactions'
import React, {
Fragment,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
useState,
type CSSProperties,
type Dispatch,
type ElementType,
type MutableRefObject,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type Ref,
@@ -50,9 +45,10 @@ import {
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, calculateActiveIndex } from '../../utils/calculate-active-index'
import { Focus } from '../../utils/calculate-active-index'
import { disposables } from '../../utils/disposables'
import {
Focus as FocusManagementFocus,
@@ -60,7 +56,6 @@ import {
focusFrom,
isFocusableElement,
restoreFocusIfNecessary,
sortByDomNode,
} from '../../utils/focus-management'
import { match } from '../../utils/match'
import {
@@ -75,299 +70,8 @@ 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
buttonElement: HTMLButtonElement | null
itemsElement: HTMLElement | null
items: { id: string; dataRef: MenuItemDataRef }[]
searchQuery: string
activeItemIndex: number | null
activationTrigger: ActivationTrigger
}
enum ActionTypes {
OpenMenu,
CloseMenu,
GoToItem,
Search,
ClearSearch,
RegisterItem,
UnregisterItem,
SetButtonElement,
SetItemsElement,
}
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 }
| { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null }
| { type: ActionTypes.SetItemsElement; element: HTMLElement | null }
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,
}
},
[ActionTypes.SetButtonElement]: (state, action) => {
if (state.buttonElement === action.element) return state
return { ...state, buttonElement: action.element }
},
[ActionTypes.SetItemsElement]: (state, action) => {
if (state.itemsElement === action.element) return state
return { ...state, itemsElement: action.element }
},
}
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)
}
// ---
import { ActionTypes, ActivationTrigger, MenuState, type MenuItemDataRef } from './menu-machine'
import { MenuContext, useMenuMachine, useMenuMachineContext } from './menu-machine-glue'
let DEFAULT_MENU_TAG = Fragment
type MenuRenderPropArg = {
@@ -390,36 +94,32 @@ function MenuFn<TTag extends ElementType = typeof DEFAULT_MENU_TAG>(
ref: Ref<HTMLElement>
) {
let { __demoMode = false, ...theirProps } = props
let reducerBag = useReducer(stateReducer, {
__demoMode,
menuState: __demoMode ? MenuStates.Open : MenuStates.Closed,
buttonElement: null,
itemsElement: null,
items: [],
searchQuery: '',
activeItemIndex: null,
activationTrigger: ActivationTrigger.Other,
} as StateDefinition)
let [{ menuState, itemsElement, buttonElement }, dispatch] = reducerBag
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 === MenuStates.Open
let outsideClickEnabled = menuState === MenuState.Open
useOutsideClick(outsideClickEnabled, [buttonElement, itemsElement], (event, target) => {
dispatch({ type: ActionTypes.CloseMenu })
machine.send({ type: ActionTypes.CloseMenu })
if (!isFocusableElement(target, FocusableMode.Loose)) {
event.preventDefault()
buttonElement?.focus()
machine.state.buttonElement?.focus()
}
})
let close = useEvent(() => {
dispatch({ type: ActionTypes.CloseMenu })
machine.send({ type: ActionTypes.CloseMenu })
})
let slot = useMemo(
() => ({ open: menuState === MenuStates.Open, close }) satisfies MenuRenderPropArg,
() => ({ open: menuState === MenuState.Open, close }) satisfies MenuRenderPropArg,
[menuState, close]
)
@@ -429,11 +129,11 @@ function MenuFn<TTag extends ElementType = typeof DEFAULT_MENU_TAG>(
return (
<FloatingProvider>
<MenuContext.Provider value={reducerBag}>
<MenuContext.Provider value={machine}>
<OpenClosedProvider
value={match(menuState, {
[MenuStates.Open]: State.Open,
[MenuStates.Closed]: State.Closed,
[MenuState.Open]: State.Open,
[MenuState.Closed]: State.Closed,
})}
>
{render({
@@ -476,6 +176,7 @@ 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}`,
@@ -483,12 +184,13 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
autoFocus = false,
...theirProps
} = props
let [state, dispatch] = useMenuContext('Menu.Button')
let internalButtonRef = useRef<HTMLButtonElement | null>(null)
let getFloatingReferenceProps = useFloatingReferenceProps()
let buttonRef = useSyncRefs(
ref,
internalButtonRef,
useFloatingReference(),
useEvent((element) => dispatch({ type: ActionTypes.SetButtonElement, element }))
useEvent((element) => machine.send({ type: ActionTypes.SetButtonElement, element }))
)
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
@@ -500,15 +202,13 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
case Keys.ArrowDown:
event.preventDefault()
event.stopPropagation()
flushSync(() => dispatch({ type: ActionTypes.OpenMenu }))
dispatch({ type: ActionTypes.GoToItem, focus: Focus.First })
machine.send({ type: ActionTypes.OpenMenu, focus: { focus: Focus.First } })
break
case Keys.ArrowUp:
event.preventDefault()
event.stopPropagation()
flushSync(() => dispatch({ type: ActionTypes.OpenMenu }))
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Last })
machine.send({ type: ActionTypes.OpenMenu, focus: { focus: Focus.Last } })
break
}
})
@@ -524,15 +224,24 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}
})
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 (state.menuState === MenuStates.Open) {
flushSync(() => dispatch({ type: ActionTypes.CloseMenu }))
state.buttonElement?.focus({ preventScroll: true })
if (menuState === MenuState.Open) {
flushSync(() => machine.send({ type: ActionTypes.CloseMenu }))
internalButtonRef.current?.focus({ preventScroll: true })
} else {
event.preventDefault()
dispatch({ type: ActionTypes.OpenMenu })
machine.send({
type: ActionTypes.OpenMenu,
focus: { focus: Focus.Nothing },
trigger: ActivationTrigger.Pointer,
})
}
})
@@ -542,24 +251,24 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
let slot = useMemo(() => {
return {
open: state.menuState === MenuStates.Open,
active: active || state.menuState === MenuStates.Open,
open: menuState === MenuState.Open,
active: active || menuState === MenuState.Open,
disabled,
hover,
focus,
autofocus: autoFocus,
} satisfies ButtonRenderPropArg
}, [state, hover, focus, active, disabled, autoFocus])
}, [menuState, hover, focus, active, disabled, autoFocus])
let ourProps = mergeProps(
getFloatingReferenceProps(),
{
ref: buttonRef,
id,
type: useResolveButtonType(props, state.buttonElement),
type: useResolveButtonType(props, internalButtonRef.current),
'aria-haspopup': 'menu',
'aria-controls': state.itemsElement?.id,
'aria-expanded': state.menuState === MenuStates.Open,
'aria-controls': itemsElement?.id,
'aria-expanded': menuState === MenuState.Open,
disabled: disabled || undefined,
autoFocus,
onKeyDown: handleKeyDown,
@@ -622,7 +331,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
...theirProps
} = props
let anchor = useResolvedAnchor(rawAnchor)
let [state, dispatch] = useMenuContext('Menu.Items')
let machine = useMenuMachineContext('Menu.Items')
let [floatingRef, style] = useFloatingPanel(anchor)
let getFloatingPanelProps = useFloatingPanelProps()
@@ -635,11 +344,17 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
let itemsRef = useSyncRefs(
ref,
anchor ? floatingRef : null,
useEvent((element) => dispatch({ type: ActionTypes.SetItemsElement, element })),
useEvent((element) => machine.send({ type: ActionTypes.SetItemsElement, element })),
setLocalItemsElement
)
let portalOwnerDocument = useOwnerDocument(state.buttonElement)
let ownerDocument = useOwnerDocument(state.itemsElement)
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) {
@@ -652,24 +367,25 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
localItemsElement,
usesOpenClosedState !== null
? (usesOpenClosedState & State.Open) === State.Open
: state.menuState === MenuStates.Open
: menuState === MenuState.Open
)
// Ensure we close the menu as soon as the button becomes hidden
useOnDisappear(visible, state.buttonElement, () => {
dispatch({ type: ActionTypes.CloseMenu })
useOnDisappear(visible, buttonElement, () => {
machine.send({ 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
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 = state.__demoMode ? false : modal && state.menuState === MenuStates.Open
let inertOthersEnabled = __demoMode ? false : modal && menuState === MenuState.Open
useInertOthers(inertOthersEnabled, {
allowed: useCallback(
() => [state.buttonElement, state.itemsElement],
[state.buttonElement, state.itemsElement]
() => [buttonElement, localItemsElement],
[buttonElement, localItemsElement]
),
})
@@ -682,24 +398,24 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
//
// 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.buttonElement)
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 = state.itemsElement
let container = localItemsElement
if (!container) return
if (state.menuState !== MenuStates.Open) return
if (menuState !== MenuState.Open) return
if (container === ownerDocument?.activeElement) return
container.focus({ preventScroll: true })
}, [state.menuState, state.itemsElement, ownerDocument])
}, [menuState, localItemsElement, ownerDocument])
useTreeWalker(state.menuState === MenuStates.Open, {
container: state.itemsElement,
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
@@ -719,66 +435,66 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
// @ts-expect-error Fallthrough is expected here
case Keys.Space:
if (state.searchQuery !== '') {
if (machine.state.searchQuery !== '') {
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.Search, value: event.key })
return machine.send({ 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]
if (machine.state.activeItemIndex !== null) {
let { dataRef } = machine.state.items[machine.state.activeItemIndex]
dataRef.current?.domRef.current?.click()
}
restoreFocusIfNecessary(state.buttonElement)
machine.send({ type: ActionTypes.CloseMenu })
restoreFocusIfNecessary(machine.state.buttonElement)
break
case Keys.ArrowDown:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Next })
return machine.send({ type: ActionTypes.GoToItem, focus: Focus.Next })
case Keys.ArrowUp:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Previous })
return machine.send({ type: ActionTypes.GoToItem, focus: Focus.Previous })
case Keys.Home:
case Keys.PageUp:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.First })
return machine.send({ type: ActionTypes.GoToItem, focus: Focus.First })
case Keys.End:
case Keys.PageDown:
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Last })
return machine.send({ type: ActionTypes.GoToItem, focus: Focus.Last })
case Keys.Escape:
event.preventDefault()
event.stopPropagation()
flushSync(() => dispatch({ type: ActionTypes.CloseMenu }))
state.buttonElement?.focus({ preventScroll: true })
flushSync(() => machine.send({ type: ActionTypes.CloseMenu }))
machine.state.buttonElement?.focus({ preventScroll: true })
break
case Keys.Tab:
event.preventDefault()
event.stopPropagation()
flushSync(() => dispatch({ type: ActionTypes.CloseMenu }))
flushSync(() => machine.send({ type: ActionTypes.CloseMenu }))
focusFrom(
state.buttonElement!,
machine.state.buttonElement!,
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)
machine.send({ type: ActionTypes.Search, value: event.key })
searchDisposables.setTimeout(() => machine.send({ type: ActionTypes.ClearSearch }), 350)
}
break
}
@@ -797,14 +513,13 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
let slot = useMemo(() => {
return {
open: state.menuState === MenuStates.Open,
open: menuState === MenuState.Open,
} satisfies ItemsRenderPropArg
}, [state.menuState])
}, [menuState])
let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, {
'aria-activedescendant':
state.activeItemIndex === null ? undefined : state.items[state.activeItemIndex]?.id,
'aria-labelledby': state.buttonElement?.id,
'aria-activedescendant': useSlice(machine, machine.selectors.activeDescendantId),
'aria-labelledby': useSlice(machine, (state) => state.buttonElement?.id),
id,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
@@ -812,12 +527,12 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
// 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: state.menuState === MenuStates.Open ? 0 : undefined,
tabIndex: menuState === MenuState.Open ? 0 : undefined,
ref: itemsRef,
style: {
...theirProps.style,
...style,
'--button-width': useElementSize(state.buttonElement, true).width,
'--button-width': useElementSize(buttonElement, true).width,
} as CSSProperties,
...transitionDataAttributes(transitionData),
})
@@ -871,27 +586,22 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
) {
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 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 (state.__demoMode) return
if (state.menuState !== MenuStates.Open) return
if (!active) return
if (state.activationTrigger === ActivationTrigger.Pointer) return
if (!shouldScrollIntoView) return
return disposables().requestAnimationFrame(() => {
internalItemRef.current?.scrollIntoView?.({ block: 'nearest' })
})
}, [
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,
])
}, [shouldScrollIntoView, internalItemRef])
let getTextValue = useTextValue(internalItemRef)
@@ -908,23 +618,23 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
}, [bag, disabled])
useIsoMorphicEffect(() => {
dispatch({ type: ActionTypes.RegisterItem, id, dataRef: bag })
return () => dispatch({ type: ActionTypes.UnregisterItem, id })
machine.actions.registerItem(id, bag)
return () => machine.send({ type: ActionTypes.UnregisterItem, id })
}, [bag, id])
let close = useEvent(() => {
dispatch({ type: ActionTypes.CloseMenu })
machine.send({ type: ActionTypes.CloseMenu })
})
let handleClick = useEvent((event: MouseEvent) => {
if (disabled) return event.preventDefault()
dispatch({ type: ActionTypes.CloseMenu })
restoreFocusIfNecessary(state.buttonElement)
machine.send({ type: ActionTypes.CloseMenu })
restoreFocusIfNecessary(machine.state.buttonElement)
})
let handleFocus = useEvent(() => {
if (disabled) return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Specific, id })
if (disabled) return machine.send({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
machine.send({ type: ActionTypes.GoToItem, focus: Focus.Specific, id })
})
let pointer = useTrackedPointer()
@@ -933,7 +643,7 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
pointer.update(evt)
if (disabled) return
if (active) return
dispatch({
machine.send({
type: ActionTypes.GoToItem,
focus: Focus.Specific,
id,
@@ -945,7 +655,7 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
if (!pointer.wasMoved(evt)) return
if (disabled) return
if (active) return
dispatch({
machine.send({
type: ActionTypes.GoToItem,
focus: Focus.Specific,
id,
@@ -957,7 +667,7 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
if (!pointer.wasMoved(evt)) return
if (disabled) return
if (!active) return
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
machine.send({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
})
let [labelledby, LabelProvider] = useLabels()