diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index f0054d3..4db2126 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Improve `Menu` component performance ([#3685](https://github.com/tailwindlabs/headlessui/pull/3685)) +- Improve `Listbox` component performance ([#3688](https://github.com/tailwindlabs/headlessui/pull/3688)) ## [2.2.1] - 2025-04-04 diff --git a/packages/@headlessui-react/src/components/listbox/listbox-machine-glue.tsx b/packages/@headlessui-react/src/components/listbox/listbox-machine-glue.tsx new file mode 100644 index 0000000..42a271c --- /dev/null +++ b/packages/@headlessui-react/src/components/listbox/listbox-machine-glue.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext, useMemo } from 'react' +import { ListboxMachine } from './listbox-machine' + +export const ListboxContext = createContext | null>(null) +export function useListboxMachineContext(component: string) { + let context = useContext(ListboxContext) + if (context === null) { + let err = new Error(`<${component} /> is missing a parent component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useListboxMachine) + throw err + } + return context as ListboxMachine +} + +export function useListboxMachine({ __demoMode = false } = {}) { + return useMemo(() => ListboxMachine.new({ __demoMode }), []) +} diff --git a/packages/@headlessui-react/src/components/listbox/listbox-machine.ts b/packages/@headlessui-react/src/components/listbox/listbox-machine.ts new file mode 100644 index 0000000..c1c75a2 --- /dev/null +++ b/packages/@headlessui-react/src/components/listbox/listbox-machine.ts @@ -0,0 +1,497 @@ +import { Machine, batch } from '../../machine' +import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' +import { sortByDomNode } from '../../utils/focus-management' +import { match } from '../../utils/match' + +interface MutableRefObject { + current: T +} + +export enum ListboxStates { + Open, + Closed, +} + +export enum ValueMode { + Single, + Multi, +} + +export enum ActivationTrigger { + Pointer, + Other, +} + +type ListboxOptionDataRef = MutableRefObject<{ + textValue?: string + disabled: boolean + value: T + domRef: MutableRefObject +}> + +interface State { + __demoMode: boolean + + dataRef: MutableRefObject<{ + value: unknown + disabled: boolean + invalid: boolean + mode: ValueMode + orientation: 'horizontal' | 'vertical' + onChange: (value: T) => void + compare(a: unknown, z: unknown): boolean + isSelected(value: unknown): boolean + + optionsPropsRef: MutableRefObject<{ + static: boolean + hold: boolean + }> + + listRef: MutableRefObject> + }> + + listboxState: ListboxStates + + options: { id: string; dataRef: ListboxOptionDataRef }[] + searchQuery: string + activeOptionIndex: number | null + activationTrigger: ActivationTrigger + + buttonElement: HTMLButtonElement | null + optionsElement: HTMLElement | null + + pendingShouldSort: boolean +} + +export enum ActionTypes { + OpenListbox, + CloseListbox, + + GoToOption, + Search, + ClearSearch, + + RegisterOptions, + UnregisterOption, + + SetButtonElement, + SetOptionsElement, + + SortOptions, +} + +function adjustOrderedState( + state: State, + adjustment: (options: State['options']) => State['options'] = (i) => i +) { + let currentActiveOption = + state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null + + let sortedOptions = sortByDomNode( + adjustment(state.options.slice()), + (option) => option.dataRef.current.domRef.current + ) + + // If we inserted an option before the current active option then the active option index + // would be wrong. To fix this, we will re-lookup the correct index. + let adjustedActiveOptionIndex = currentActiveOption + ? sortedOptions.indexOf(currentActiveOption) + : null + + // Reset to `null` in case the currentActiveOption was removed. + if (adjustedActiveOptionIndex === -1) { + adjustedActiveOptionIndex = null + } + + return { + options: sortedOptions, + activeOptionIndex: adjustedActiveOptionIndex, + } +} + +type Actions = + | { type: ActionTypes.CloseListbox } + | { type: ActionTypes.OpenListbox } + | { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string; trigger?: ActivationTrigger } + | { + type: ActionTypes.GoToOption + focus: Exclude + trigger?: ActivationTrigger + } + | { type: ActionTypes.Search; value: string } + | { type: ActionTypes.ClearSearch } + | { + type: ActionTypes.RegisterOptions + options: { id: string; dataRef: ListboxOptionDataRef }[] + } + | { type: ActionTypes.UnregisterOption; id: string } + | { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null } + | { type: ActionTypes.SetOptionsElement; element: HTMLElement | null } + | { type: ActionTypes.SortOptions } + +let reducers: { + [P in ActionTypes]: (state: State, action: Extract, { type: P }>) => State +} = { + [ActionTypes.CloseListbox](state) { + if (state.dataRef.current.disabled) return state + if (state.listboxState === ListboxStates.Closed) return state + return { + ...state, + activeOptionIndex: null, + listboxState: ListboxStates.Closed, + __demoMode: false, + } + }, + [ActionTypes.OpenListbox](state) { + if (state.dataRef.current.disabled) return state + if (state.listboxState === ListboxStates.Open) return state + + // Check if we have a selected value that we can make active + let activeOptionIndex = state.activeOptionIndex + let { isSelected } = state.dataRef.current + let optionIdx = state.options.findIndex((option) => isSelected(option.dataRef.current.value)) + + if (optionIdx !== -1) { + activeOptionIndex = optionIdx + } + + return { ...state, listboxState: ListboxStates.Open, activeOptionIndex, __demoMode: false } + }, + [ActionTypes.GoToOption](state, action) { + if (state.dataRef.current.disabled) return state + if (state.listboxState === ListboxStates.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, + activeOptionIndex: 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, + activeOptionIndex: state.options.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 activeOptionIdx = state.activeOptionIndex + if (activeOptionIdx !== null) { + let currentDom = state.options[activeOptionIdx].dataRef.current.domRef + let previousOptionIndex = calculateActiveIndex(action, { + resolveItems: () => state.options, + resolveActiveIndex: () => state.activeOptionIndex, + resolveId: (option) => option.id, + resolveDisabled: (option) => option.dataRef.current.disabled, + }) + if (previousOptionIndex !== null) { + let previousDom = state.options[previousOptionIndex].dataRef.current.domRef + if ( + // Next to each other + currentDom.current?.previousElementSibling === previousDom.current || + // Or already the first element + previousDom.current?.previousElementSibling === null + ) { + return { + ...base, + activeOptionIndex: previousOptionIndex, + } + } + } + } + } + + // 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 activeOptionIdx = state.activeOptionIndex + if (activeOptionIdx !== null) { + let currentDom = state.options[activeOptionIdx].dataRef.current.domRef + let nextOptionIndex = calculateActiveIndex(action, { + resolveItems: () => state.options, + resolveActiveIndex: () => state.activeOptionIndex, + resolveId: (option) => option.id, + resolveDisabled: (option) => option.dataRef.current.disabled, + }) + if (nextOptionIndex !== null) { + let nextDom = state.options[nextOptionIndex].dataRef.current.domRef + if ( + // Next to each other + currentDom.current?.nextElementSibling === nextDom.current || + // Or already the last element + nextDom.current?.nextElementSibling === null + ) { + return { + ...base, + activeOptionIndex: nextOptionIndex, + } + } + } + } + } + + // Slow path: + // + // Ensure all the options are correctly sorted according to DOM position + let adjustedState = adjustOrderedState(state) + let activeOptionIndex = calculateActiveIndex(action, { + resolveItems: () => adjustedState.options, + resolveActiveIndex: () => adjustedState.activeOptionIndex, + resolveId: (option) => option.id, + resolveDisabled: (option) => option.dataRef.current.disabled, + }) + + return { + ...base, + ...adjustedState, + activeOptionIndex, + } + }, + [ActionTypes.Search]: (state, action) => { + if (state.dataRef.current.disabled) return state + if (state.listboxState === ListboxStates.Closed) return state + + let wasAlreadySearching = state.searchQuery !== '' + let offset = wasAlreadySearching ? 0 : 1 + + let searchQuery = state.searchQuery + action.value.toLowerCase() + + let reOrderedOptions = + state.activeOptionIndex !== null + ? state.options + .slice(state.activeOptionIndex + offset) + .concat(state.options.slice(0, state.activeOptionIndex + offset)) + : state.options + + let matchingOption = reOrderedOptions.find( + (option) => + !option.dataRef.current.disabled && + option.dataRef.current.textValue?.startsWith(searchQuery) + ) + + let matchIdx = matchingOption ? state.options.indexOf(matchingOption) : -1 + + if (matchIdx === -1 || matchIdx === state.activeOptionIndex) return { ...state, searchQuery } + return { + ...state, + searchQuery, + activeOptionIndex: matchIdx, + activationTrigger: ActivationTrigger.Other, + } + }, + [ActionTypes.ClearSearch](state) { + if (state.dataRef.current.disabled) return state + if (state.listboxState === ListboxStates.Closed) return state + if (state.searchQuery === '') return state + return { ...state, searchQuery: '' } + }, + [ActionTypes.RegisterOptions]: (state, action) => { + let options = state.options.concat(action.options) + + let activeOptionIndex = state.activeOptionIndex + + // Check if we need to make the newly registered option active. + if (state.activeOptionIndex === null) { + let { isSelected } = state.dataRef.current + if (isSelected) { + let idx = options.findIndex((option) => isSelected?.(option.dataRef.current.value)) + if (idx !== -1) activeOptionIndex = idx + } + } + + return { + ...state, + options, + activeOptionIndex, + pendingShouldSort: true, + } + }, + [ActionTypes.UnregisterOption]: (state, action) => { + let options = state.options + let idx = options.findIndex((a) => a.id === action.id) + if (idx !== -1) { + options = options.slice() + options.splice(idx, 1) + } + + return { + ...state, + options, + activationTrigger: ActivationTrigger.Other, + } + }, + [ActionTypes.SetButtonElement]: (state, action) => { + if (state.buttonElement === action.element) return state + return { ...state, buttonElement: action.element } + }, + [ActionTypes.SetOptionsElement]: (state, action) => { + if (state.optionsElement === action.element) return state + return { ...state, optionsElement: action.element } + }, + [ActionTypes.SortOptions]: (state) => { + if (!state.pendingShouldSort) return state + + return { + ...state, + ...adjustOrderedState(state), + pendingShouldSort: false, + } + }, +} + +export class ListboxMachine extends Machine, Actions> { + static new({ __demoMode = false } = {}) { + return new ListboxMachine({ + // @ts-expect-error TODO: Re-structure such that we don't need to ignore this + dataRef: { current: {} }, + listboxState: __demoMode ? ListboxStates.Open : ListboxStates.Closed, + options: [], + searchQuery: '', + activeOptionIndex: null, + activationTrigger: ActivationTrigger.Other, + buttonElement: null, + optionsElement: null, + __demoMode, + }) + } + + constructor(initialState: State) { + super(initialState) + + this.on(ActionTypes.RegisterOptions, () => { + // Schedule a sort of the options when the DOM is ready. This doesn't + // change anything rendering wise, but the sorted options are used when + // using arrow keys so we can jump to previous / next options. + requestAnimationFrame(() => { + this.send({ type: ActionTypes.SortOptions }) + }) + }) + } + + actions = { + onChange: (newValue: T) => { + let { onChange, compare, mode, value } = this.state.dataRef.current + + return match(mode, { + [ValueMode.Single]: () => { + return onChange?.(newValue) + }, + [ValueMode.Multi]: () => { + let copy = (value as T[]).slice() + + let idx = copy.findIndex((item) => compare(item, newValue)) + if (idx === -1) { + copy.push(newValue) + } else { + copy.splice(idx, 1) + } + + return onChange?.(copy as T) + }, + }) + }, + registerOption: batch(() => { + let options: { id: string; dataRef: ListboxOptionDataRef }[] = [] + return [ + (id: string, dataRef: ListboxOptionDataRef) => options.push({ id, dataRef }), + () => { + this.send({ type: ActionTypes.RegisterOptions, options: options.splice(0) }) + }, + ] + }), + goToOption: batch(() => { + let last: Extract, { type: ActionTypes.GoToOption }> | null = null + return [ + ( + focus: { focus: Focus.Specific; id: string } | { focus: Exclude }, + trigger?: ActivationTrigger + ) => { + last = { type: ActionTypes.GoToOption, ...focus, trigger } + }, + () => last && this.send(last), + ] + }), + closeListbox: () => { + this.send({ type: ActionTypes.CloseListbox }) + }, + openListbox: () => { + this.send({ type: ActionTypes.OpenListbox }) + }, + selectActiveOption: () => { + if (this.state.activeOptionIndex !== null) { + let { dataRef, id } = this.state.options[this.state.activeOptionIndex] + this.actions.onChange(dataRef.current.value) + + // It could happen that the `activeOptionIndex` stored in state is actually null, + // but we are getting the fallback active option back instead. + this.send({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) + } + }, + selectOption: (id: string) => { + let option = this.state.options.find((item) => item.id === id) + if (!option) return + + this.actions.onChange(option.dataRef.current.value) + }, + search: (value: string) => { + this.send({ type: ActionTypes.Search, value }) + }, + clearSearch: () => { + this.send({ type: ActionTypes.ClearSearch }) + }, + setButtonElement: (element: HTMLButtonElement | null) => { + this.send({ type: ActionTypes.SetButtonElement, element }) + }, + setOptionsElement: (element: HTMLElement | null) => { + this.send({ type: ActionTypes.SetOptionsElement, element }) + }, + } + + selectors = { + activeDescendantId(state: State) { + let activeOptionIndex = state.activeOptionIndex + let options = state.options + return activeOptionIndex === null ? undefined : options[activeOptionIndex]?.id + }, + + isActive(state: State, id: string) { + let activeOptionIndex = state.activeOptionIndex + let options = state.options + + return activeOptionIndex !== null ? options[activeOptionIndex]?.id === id : false + }, + + shouldScrollIntoView(state: State, id: string) { + if (state.__demoMode) return false + if (state.listboxState !== ListboxStates.Open) return false + if (state.activationTrigger === ActivationTrigger.Pointer) return false + return this.isActive(state, id) + }, + } + + reduce(state: Readonly>, action: Actions): State { + return match(action.type, reducers, state, action) as State + } +} diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 65d06cf..c3a333a 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -5,12 +5,10 @@ import { useHover } from '@react-aria/interactions' import React, { Fragment, createContext, - createRef, useCallback, useContext, useEffect, useMemo, - useReducer, useRef, useState, type CSSProperties, @@ -56,16 +54,16 @@ import { FormFields } from '../../internal/form-fields' import { useFrozenData } from '../../internal/frozen' import { useProvidedId } from '../../internal/id' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' +import { useSlice } from '../../react-glue' import type { EnsureArray, 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, FocusableMode, focusFrom, isFocusableElement, - sortByDomNode, } from '../../utils/focus-management' import { attemptSubmit } from '../../utils/form' import { match } from '../../utils/match' @@ -83,21 +81,8 @@ import { useDescribedBy } from '../description/description' import { Keys } from '../keyboard' import { Label, useLabelledBy, useLabels, type _internal_ComponentLabel } from '../label/label' import { Portal } from '../portal/portal' - -enum ListboxStates { - Open, - Closed, -} - -enum ValueMode { - Single, - Multi, -} - -enum ActivationTrigger { - Pointer, - Other, -} +import { ActionTypes, ActivationTrigger, ListboxStates, ValueMode } from './listbox-machine' +import { ListboxContext, useListboxMachine, useListboxMachineContext } from './listbox-machine-glue' type ListboxOptionDataRef = MutableRefObject<{ textValue?: string @@ -106,346 +91,23 @@ type ListboxOptionDataRef = MutableRefObject<{ domRef: MutableRefObject }> -interface StateDefinition { - dataRef: MutableRefObject<_Data> - - listboxState: ListboxStates - - options: { id: string; dataRef: ListboxOptionDataRef }[] - searchQuery: string - activeOptionIndex: number | null - activationTrigger: ActivationTrigger - - buttonElement: HTMLButtonElement | null - optionsElement: HTMLElement | null - - __demoMode: boolean -} - -enum ActionTypes { - OpenListbox, - CloseListbox, - - GoToOption, - Search, - ClearSearch, - - RegisterOption, - UnregisterOption, - - SetButtonElement, - SetOptionsElement, -} - -function adjustOrderedState( - state: StateDefinition, - adjustment: (options: StateDefinition['options']) => StateDefinition['options'] = (i) => i -) { - let currentActiveOption = - state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null - - let sortedOptions = sortByDomNode( - adjustment(state.options.slice()), - (option) => option.dataRef.current.domRef.current - ) - - // If we inserted an option before the current active option then the active option index - // would be wrong. To fix this, we will re-lookup the correct index. - let adjustedActiveOptionIndex = currentActiveOption - ? sortedOptions.indexOf(currentActiveOption) - : null - - // Reset to `null` in case the currentActiveOption was removed. - if (adjustedActiveOptionIndex === -1) { - adjustedActiveOptionIndex = null - } - - return { - options: sortedOptions, - activeOptionIndex: adjustedActiveOptionIndex, - } -} - -type Actions = - | { type: ActionTypes.CloseListbox } - | { type: ActionTypes.OpenListbox } - | { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string; trigger?: ActivationTrigger } - | { - type: ActionTypes.GoToOption - focus: Exclude - trigger?: ActivationTrigger - } - | { type: ActionTypes.Search; value: string } - | { type: ActionTypes.ClearSearch } - | { type: ActionTypes.RegisterOption; id: string; dataRef: ListboxOptionDataRef } - | { type: ActionTypes.UnregisterOption; id: string } - | { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null } - | { type: ActionTypes.SetOptionsElement; element: HTMLElement | null } - -let reducers: { - [P in ActionTypes]: ( - state: StateDefinition, - action: Extract, { type: P }> - ) => StateDefinition -} = { - [ActionTypes.CloseListbox](state) { - if (state.dataRef.current.disabled) return state - if (state.listboxState === ListboxStates.Closed) return state - return { - ...state, - activeOptionIndex: null, - listboxState: ListboxStates.Closed, - __demoMode: false, - } - }, - [ActionTypes.OpenListbox](state) { - if (state.dataRef.current.disabled) return state - if (state.listboxState === ListboxStates.Open) return state - - // Check if we have a selected value that we can make active - let activeOptionIndex = state.activeOptionIndex - let { isSelected } = state.dataRef.current - let optionIdx = state.options.findIndex((option) => isSelected(option.dataRef.current.value)) - - if (optionIdx !== -1) { - activeOptionIndex = optionIdx - } - - return { ...state, listboxState: ListboxStates.Open, activeOptionIndex, __demoMode: false } - }, - [ActionTypes.GoToOption](state, action) { - if (state.dataRef.current.disabled) return state - if (state.listboxState === ListboxStates.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, - activeOptionIndex: 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, - activeOptionIndex: state.options.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 activeOptionIdx = state.activeOptionIndex - if (activeOptionIdx !== null) { - let currentDom = state.options[activeOptionIdx].dataRef.current.domRef - let previousOptionIndex = calculateActiveIndex(action, { - resolveItems: () => state.options, - resolveActiveIndex: () => state.activeOptionIndex, - resolveId: (option) => option.id, - resolveDisabled: (option) => option.dataRef.current.disabled, - }) - if (previousOptionIndex !== null) { - let previousDom = state.options[previousOptionIndex].dataRef.current.domRef - if ( - // Next to each other - currentDom.current?.previousElementSibling === previousDom.current || - // Or already the first element - previousDom.current?.previousElementSibling === null - ) { - return { - ...base, - activeOptionIndex: previousOptionIndex, - } - } - } - } - } - - // 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 activeOptionIdx = state.activeOptionIndex - if (activeOptionIdx !== null) { - let currentDom = state.options[activeOptionIdx].dataRef.current.domRef - let nextOptionIndex = calculateActiveIndex(action, { - resolveItems: () => state.options, - resolveActiveIndex: () => state.activeOptionIndex, - resolveId: (option) => option.id, - resolveDisabled: (option) => option.dataRef.current.disabled, - }) - if (nextOptionIndex !== null) { - let nextDom = state.options[nextOptionIndex].dataRef.current.domRef - if ( - // Next to each other - currentDom.current?.nextElementSibling === nextDom.current || - // Or already the last element - nextDom.current?.nextElementSibling === null - ) { - return { - ...base, - activeOptionIndex: nextOptionIndex, - } - } - } - } - } - - // Slow path: - // - // Ensure all the options are correctly sorted according to DOM position - let adjustedState = adjustOrderedState(state) - let activeOptionIndex = calculateActiveIndex(action, { - resolveItems: () => adjustedState.options, - resolveActiveIndex: () => adjustedState.activeOptionIndex, - resolveId: (option) => option.id, - resolveDisabled: (option) => option.dataRef.current.disabled, - }) - - return { - ...base, - ...adjustedState, - activeOptionIndex, - } - }, - [ActionTypes.Search]: (state, action) => { - if (state.dataRef.current.disabled) return state - if (state.listboxState === ListboxStates.Closed) return state - - let wasAlreadySearching = state.searchQuery !== '' - let offset = wasAlreadySearching ? 0 : 1 - - let searchQuery = state.searchQuery + action.value.toLowerCase() - - let reOrderedOptions = - state.activeOptionIndex !== null - ? state.options - .slice(state.activeOptionIndex + offset) - .concat(state.options.slice(0, state.activeOptionIndex + offset)) - : state.options - - let matchingOption = reOrderedOptions.find( - (option) => - !option.dataRef.current.disabled && - option.dataRef.current.textValue?.startsWith(searchQuery) - ) - - let matchIdx = matchingOption ? state.options.indexOf(matchingOption) : -1 - - if (matchIdx === -1 || matchIdx === state.activeOptionIndex) return { ...state, searchQuery } - return { - ...state, - searchQuery, - activeOptionIndex: matchIdx, - activationTrigger: ActivationTrigger.Other, - } - }, - [ActionTypes.ClearSearch](state) { - if (state.dataRef.current.disabled) return state - if (state.listboxState === ListboxStates.Closed) return state - if (state.searchQuery === '') return state - return { ...state, searchQuery: '' } - }, - [ActionTypes.RegisterOption]: (state, action) => { - let option = { id: action.id, dataRef: action.dataRef } - let adjustedState = adjustOrderedState(state, (options) => [...options, option]) - - // Check if we need to make the newly registered option active. - if (state.activeOptionIndex === null) { - if (state.dataRef.current.isSelected(action.dataRef.current.value)) { - adjustedState.activeOptionIndex = adjustedState.options.indexOf(option) - } - } - - return { ...state, ...adjustedState } - }, - [ActionTypes.UnregisterOption]: (state, action) => { - let adjustedState = adjustOrderedState(state, (options) => { - let idx = options.findIndex((a) => a.id === action.id) - if (idx !== -1) options.splice(idx, 1) - return options - }) - - return { - ...state, - ...adjustedState, - activationTrigger: ActivationTrigger.Other, - } - }, - [ActionTypes.SetButtonElement]: (state, action) => { - if (state.buttonElement === action.element) return state - return { ...state, buttonElement: action.element } - }, - [ActionTypes.SetOptionsElement]: (state, action) => { - if (state.optionsElement === action.element) return state - return { ...state, optionsElement: action.element } - }, -} - -let ListboxActionsContext = createContext<{ - openListbox(): void - closeListbox(): void - registerOption(id: string, dataRef: ListboxOptionDataRef): () => void - goToOption(focus: Focus.Specific, id: string, trigger?: ActivationTrigger): void - goToOption(focus: Focus, id?: string, trigger?: ActivationTrigger): void - selectOption(id: string): void - selectActiveOption(): void +let ListboxDataContext = createContext<{ + value: unknown + disabled: boolean + invalid: boolean + mode: ValueMode + orientation: 'horizontal' | 'vertical' onChange(value: unknown): void - search(query: string): void - clearSearch(): void - setButtonElement(element: HTMLButtonElement | null): void - setOptionsElement(element: HTMLElement | null): void + compare(a: unknown, z: unknown): boolean + isSelected(value: unknown): boolean + + optionsPropsRef: MutableRefObject<{ + static: boolean + hold: boolean + }> + + listRef: MutableRefObject> } | null>(null) -ListboxActionsContext.displayName = 'ListboxActionsContext' - -function useActions(component: string) { - let context = useContext(ListboxActionsContext) - if (context === null) { - let err = new Error(`<${component} /> is missing a parent component.`) - if (Error.captureStackTrace) Error.captureStackTrace(err, useActions) - throw err - } - return context -} -type _Actions = ReturnType - -let ListboxDataContext = createContext< - | ({ - value: unknown - disabled: boolean - invalid: boolean - mode: ValueMode - orientation: 'horizontal' | 'vertical' - activeOptionIndex: number | null - compare(a: unknown, z: unknown): boolean - isSelected(value: unknown): boolean - - optionsPropsRef: MutableRefObject<{ - static: boolean - hold: boolean - }> - - listRef: MutableRefObject> - } & Omit, 'dataRef'>) - | null ->(null) ListboxDataContext.displayName = 'ListboxDataContext' function useData(component: string) { @@ -459,10 +121,6 @@ function useData(component: string) { } type _Data = ReturnType -function stateReducer(state: StateDefinition, action: Actions) { - return match(action.type, reducers, state, action) -} - // --- let DEFAULT_LISTBOX_TAG = Fragment @@ -528,19 +186,7 @@ function ListboxFn< defaultValue ) - let [state, dispatch] = useReducer(stateReducer, { - dataRef: createRef(), - listboxState: __demoMode ? ListboxStates.Open : ListboxStates.Closed, - options: [], - searchQuery: '', - activeOptionIndex: null, - activationTrigger: ActivationTrigger.Other, - optionsVisible: false, - buttonElement: null, - optionsElement: null, - __demoMode, - } as StateDefinition) - + let machine = useListboxMachine({ __demoMode }) let optionsPropsRef = useRef<_Data['optionsPropsRef']['current']>({ static: false, hold: false }) let listRef = useRef<_Data['listRef']['current']>(new Map()) @@ -562,131 +208,60 @@ function ListboxFn< let data = useMemo<_Data>( () => ({ - ...state, value, disabled, invalid, mode: multiple ? ValueMode.Multi : ValueMode.Single, orientation, + onChange: theirOnChange, compare, isSelected, optionsPropsRef, listRef, }), - [value, disabled, invalid, multiple, state, listRef] + [ + value, + disabled, + invalid, + multiple, + orientation, + theirOnChange, + compare, + isSelected, + optionsPropsRef, + listRef, + ] ) useIsoMorphicEffect(() => { - state.dataRef.current = data + machine.state.dataRef.current = data }, [data]) - // Handle outside click - let outsideClickEnabled = data.listboxState === ListboxStates.Open - useOutsideClick( - outsideClickEnabled, - [data.buttonElement, data.optionsElement], - (event, target) => { - dispatch({ type: ActionTypes.CloseListbox }) + let listboxState = useSlice(machine, (state) => state.listboxState) - if (!isFocusableElement(target, FocusableMode.Loose)) { - event.preventDefault() - data.buttonElement?.focus() - } + // Handle outside click + let outsideClickEnabled = listboxState === ListboxStates.Open + let [buttonElement, optionsElement] = useSlice(machine, (state) => [ + state.buttonElement, + state.optionsElement, + ]) + useOutsideClick(outsideClickEnabled, [buttonElement, optionsElement], (event, target) => { + machine.send({ type: ActionTypes.CloseListbox }) + + if (!isFocusableElement(target, FocusableMode.Loose)) { + event.preventDefault() + buttonElement?.focus() } - ) + }) let slot = useMemo(() => { return { - open: data.listboxState === ListboxStates.Open, + open: listboxState === ListboxStates.Open, disabled, invalid, value, } satisfies ListboxRenderPropArg - }, [data, disabled, value, invalid]) - - let selectOption = useEvent((id: string) => { - let option = data.options.find((item) => item.id === id) - if (!option) return - - onChange(option.dataRef.current.value) - }) - - let selectActiveOption = useEvent(() => { - if (data.activeOptionIndex !== null) { - let { dataRef, id } = data.options[data.activeOptionIndex] - onChange(dataRef.current.value) - - // It could happen that the `activeOptionIndex` stored in state is actually null, - // but we are getting the fallback active option back instead. - dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) - } - }) - - let openListbox = useEvent(() => dispatch({ type: ActionTypes.OpenListbox })) - let closeListbox = useEvent(() => dispatch({ type: ActionTypes.CloseListbox })) - - let d = useDisposables() - let goToOption = useEvent((focus, id, trigger) => { - d.dispose() - d.microTask(() => { - if (focus === Focus.Specific) { - return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id: id!, trigger }) - } - - return dispatch({ type: ActionTypes.GoToOption, focus, trigger }) - }) - }) - - let registerOption = useEvent((id, dataRef) => { - dispatch({ type: ActionTypes.RegisterOption, id, dataRef }) - return () => dispatch({ type: ActionTypes.UnregisterOption, id }) - }) - - let onChange = useEvent((value: unknown) => { - return match(data.mode, { - [ValueMode.Single]() { - return theirOnChange?.(value as TType) - }, - [ValueMode.Multi]() { - let copy = (data.value as TActualType[]).slice() - - let idx = copy.findIndex((item) => compare(item, value as TActualType)) - if (idx === -1) { - copy.push(value as TActualType) - } else { - copy.splice(idx, 1) - } - - return theirOnChange?.(copy as unknown as TType[]) - }, - }) - }) - - let search = useEvent((value: string) => dispatch({ type: ActionTypes.Search, value })) - let clearSearch = useEvent(() => dispatch({ type: ActionTypes.ClearSearch })) - let setButtonElement = useEvent((element: HTMLButtonElement | null) => { - dispatch({ type: ActionTypes.SetButtonElement, element }) - }) - let setOptionsElement = useEvent((element: HTMLElement | null) => { - dispatch({ type: ActionTypes.SetOptionsElement, element }) - }) - - let actions = useMemo<_Actions>( - () => ({ - onChange, - registerOption, - goToOption, - closeListbox, - openListbox, - selectActiveOption, - selectOption, - search, - clearSearch, - setButtonElement, - setOptionsElement, - }), - [] - ) + }, [listboxState, disabled, invalid, value]) let [labelledby, LabelProvider] = useLabels({ inherit: true }) @@ -702,19 +277,14 @@ function ListboxFn< return ( - + - + ) @@ -776,18 +346,17 @@ function ButtonFn( props: ListboxButtonProps, ref: Ref ) { - let data = useData('Listbox.Button') - let actions = useActions('Listbox.Button') - let internalId = useId() let providedId = useProvidedId() + let data = useData('Listbox.Button') + let machine = useListboxMachineContext('Listbox.Button') let { id = providedId || `headlessui-listbox-button-${internalId}`, disabled = data.disabled || false, autoFocus = false, ...theirProps } = props - let buttonRef = useSyncRefs(ref, useFloatingReference(), actions.setButtonElement) + let buttonRef = useSyncRefs(ref, useFloatingReference(), machine.actions.setButtonElement) let getFloatingReferenceProps = useFloatingReferenceProps() let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { @@ -801,14 +370,14 @@ function ButtonFn( case Keys.Space: case Keys.ArrowDown: event.preventDefault() - flushSync(() => actions.openListbox()) - if (!data.value) actions.goToOption(Focus.First) + flushSync(() => machine.actions.openListbox()) + if (!data.value) machine.actions.goToOption({ focus: Focus.First }) break case Keys.ArrowUp: event.preventDefault() - flushSync(() => actions.openListbox()) - if (!data.value) actions.goToOption(Focus.Last) + flushSync(() => machine.actions.openListbox()) + if (!data.value) machine.actions.goToOption({ focus: Focus.Last }) break } }) @@ -826,12 +395,12 @@ function ButtonFn( let handleClick = useEvent((event: ReactMouseEvent) => { if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() - if (data.listboxState === ListboxStates.Open) { - flushSync(() => actions.closeListbox()) - data.buttonElement?.focus({ preventScroll: true }) + if (machine.state.listboxState === ListboxStates.Open) { + flushSync(() => machine.actions.closeListbox()) + machine.state.buttonElement?.focus({ preventScroll: true }) } else { event.preventDefault() - actions.openListbox() + machine.actions.openListbox() } }) @@ -845,10 +414,12 @@ function ButtonFn( let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) let { pressed: active, pressProps } = useActivePress({ disabled }) + let listboxState = useSlice(machine, (state) => state.listboxState) + let slot = useMemo(() => { return { - open: data.listboxState === ListboxStates.Open, - active: active || data.listboxState === ListboxStates.Open, + open: listboxState === ListboxStates.Open, + active: active || listboxState === ListboxStates.Open, disabled, invalid: data.invalid, value: data.value, @@ -856,17 +427,22 @@ function ButtonFn( focus, autofocus: autoFocus, } satisfies ButtonRenderPropArg - }, [data.listboxState, data.value, disabled, hover, focus, active, data.invalid, autoFocus]) + }, [listboxState, data.value, disabled, hover, focus, active, data.invalid, autoFocus]) + let open = useSlice(machine, (state) => state.listboxState === ListboxStates.Open) + let [buttonElement, optionsElement] = useSlice(machine, (state) => [ + state.buttonElement, + state.optionsElement, + ]) let ourProps = mergeProps( getFloatingReferenceProps(), { ref: buttonRef, id, - type: useResolveButtonType(props, data.buttonElement), + type: useResolveButtonType(props, buttonElement), 'aria-haspopup': 'listbox', - 'aria-controls': data.optionsElement?.id, - 'aria-expanded': data.listboxState === ListboxStates.Open, + 'aria-controls': optionsElement?.id, + 'aria-expanded': open, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, disabled: disabled || undefined, @@ -949,10 +525,17 @@ function OptionsFn( } let data = useData('Listbox.Options') - let actions = useActions('Listbox.Options') + let machine = useListboxMachineContext('Listbox.Options') - let portalOwnerDocument = useOwnerDocument(data.buttonElement) - let ownerDocument = useOwnerDocument(data.optionsElement) + let [listboxState, buttonElement, optionsElement, __demoMode] = useSlice(machine, (state) => [ + state.listboxState, + state.buttonElement, + state.optionsElement, + state.__demoMode, + ]) + + let portalOwnerDocument = useOwnerDocument(buttonElement) + let ownerDocument = useOwnerDocument(optionsElement) let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( @@ -960,27 +543,20 @@ function OptionsFn( localOptionsElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open - : data.listboxState === ListboxStates.Open + : listboxState === ListboxStates.Open ) // Ensure we close the listbox as soon as the button becomes hidden - useOnDisappear(visible, data.buttonElement, actions.closeListbox) + useOnDisappear(visible, buttonElement, machine.actions.closeListbox) // Enable scroll locking when the listbox is visible, and `modal` is enabled - let scrollLockEnabled = data.__demoMode - ? false - : modal && data.listboxState === ListboxStates.Open + let scrollLockEnabled = __demoMode ? false : modal && listboxState === ListboxStates.Open useScrollLock(scrollLockEnabled, ownerDocument) // Mark other elements as inert when the listbox is visible, and `modal` is enabled - let inertOthersEnabled = data.__demoMode - ? false - : modal && data.listboxState === ListboxStates.Open + let inertOthersEnabled = __demoMode ? false : modal && listboxState === ListboxStates.Open useInertOthers(inertOthersEnabled, { - allowed: useCallback( - () => [data.buttonElement, data.optionsElement], - [data.buttonElement, data.optionsElement] - ), + allowed: useCallback(() => [buttonElement, optionsElement], [buttonElement, optionsElement]), }) // We keep track whether the button moved or not, we only check this when the menu state becomes @@ -992,8 +568,8 @@ function OptionsFn( // // 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 didElementMoveEnabled = data.listboxState !== ListboxStates.Open - let didButtonMove = useDidElementMove(didElementMoveEnabled, data.buttonElement) + let didElementMoveEnabled = listboxState !== ListboxStates.Open + let didButtonMove = useDidElementMove(didElementMoveEnabled, 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. @@ -1002,24 +578,25 @@ function OptionsFn( // We should freeze when the listbox is visible but "closed". This means that // a transition is currently happening and the component is still visible (for // the transition) but closed from a functionality perspective. - let shouldFreeze = visible && data.listboxState === ListboxStates.Closed + let shouldFreeze = visible && listboxState === ListboxStates.Closed // Frozen state, the selected value will only update visually when the user re-opens the let frozenValue = useFrozenData(shouldFreeze, data.value) let isSelected = useEvent((compareValue: unknown) => data.compare(frozenValue, compareValue)) - let selectedOptionIndex = useMemo(() => { + let selectedOptionIndex = useSlice(machine, (state) => { if (anchor == null) return null if (!anchor?.to?.includes('selection')) return null // Only compute the selected option index when using `selection` in the // `anchor` prop. - let idx = data.options.findIndex((option) => isSelected(option.dataRef.current.value)) + let idx = state.options.findIndex((option) => isSelected(option.dataRef.current.value)) // Ensure that if no data is selected, we default to the first item. if (idx === -1) idx = 0 + return idx - }, [anchor, data.options]) + }) let anchorOptions = (() => { if (anchor == null) return undefined @@ -1041,20 +618,20 @@ function OptionsFn( let optionsRef = useSyncRefs( ref, anchor ? floatingRef : null, - actions.setOptionsElement, + machine.actions.setOptionsElement, setLocalOptionsElement ) let searchDisposables = useDisposables() useEffect(() => { - let container = data.optionsElement + let container = optionsElement if (!container) return - if (data.listboxState !== ListboxStates.Open) return + if (listboxState !== ListboxStates.Open) return if (container === getOwnerDocument(container)?.activeElement) return container?.focus({ preventScroll: true }) - }, [data.listboxState, data.optionsElement]) + }, [listboxState, optionsElement]) let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { searchDisposables.dispose() @@ -1064,86 +641,92 @@ function OptionsFn( // @ts-expect-error Fallthrough is expected here case Keys.Space: - if (data.searchQuery !== '') { + if (machine.state.searchQuery !== '') { event.preventDefault() event.stopPropagation() - return actions.search(event.key) + return machine.actions.search(event.key) } // When in type ahead mode, fallthrough case Keys.Enter: event.preventDefault() event.stopPropagation() - if (data.activeOptionIndex !== null) { - let { dataRef } = data.options[data.activeOptionIndex] - actions.onChange(dataRef.current.value) + if (machine.state.activeOptionIndex !== null) { + let { dataRef } = machine.state.options[machine.state.activeOptionIndex] + machine.actions.onChange(dataRef.current.value) } if (data.mode === ValueMode.Single) { - flushSync(() => actions.closeListbox()) - data.buttonElement?.focus({ preventScroll: true }) + flushSync(() => machine.actions.closeListbox()) + machine.state.buttonElement?.focus({ preventScroll: true }) } break - case match(data.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }): + case match(data.orientation, { + vertical: Keys.ArrowDown, + horizontal: Keys.ArrowRight, + }): event.preventDefault() event.stopPropagation() - return actions.goToOption(Focus.Next) + return machine.actions.goToOption({ focus: Focus.Next }) - case match(data.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }): + case match(data.orientation, { + vertical: Keys.ArrowUp, + horizontal: Keys.ArrowLeft, + }): event.preventDefault() event.stopPropagation() - return actions.goToOption(Focus.Previous) + return machine.actions.goToOption({ focus: Focus.Previous }) case Keys.Home: case Keys.PageUp: event.preventDefault() event.stopPropagation() - return actions.goToOption(Focus.First) + return machine.actions.goToOption({ focus: Focus.First }) case Keys.End: case Keys.PageDown: event.preventDefault() event.stopPropagation() - return actions.goToOption(Focus.Last) + return machine.actions.goToOption({ focus: Focus.Last }) case Keys.Escape: event.preventDefault() event.stopPropagation() - flushSync(() => actions.closeListbox()) - data.buttonElement?.focus({ preventScroll: true }) + flushSync(() => machine.actions.closeListbox()) + machine.state.buttonElement?.focus({ preventScroll: true }) return case Keys.Tab: event.preventDefault() event.stopPropagation() - flushSync(() => actions.closeListbox()) + flushSync(() => machine.actions.closeListbox()) focusFrom( - data.buttonElement!, + machine.state.buttonElement!, event.shiftKey ? FocusManagementFocus.Previous : FocusManagementFocus.Next ) break default: if (event.key.length === 1) { - actions.search(event.key) - searchDisposables.setTimeout(() => actions.clearSearch(), 350) + machine.actions.search(event.key) + searchDisposables.setTimeout(() => machine.actions.clearSearch(), 350) } break } }) - let labelledby = data.buttonElement?.id + let labelledby = useSlice(machine, (state) => state.buttonElement?.id) + let slot = useMemo(() => { return { - open: data.listboxState === ListboxStates.Open, + open: listboxState === ListboxStates.Open, } satisfies OptionsRenderPropArg - }, [data.listboxState]) + }, [listboxState]) let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, { id, ref: optionsRef, - 'aria-activedescendant': - data.activeOptionIndex === null ? undefined : data.options[data.activeOptionIndex]?.id, + 'aria-activedescendant': useSlice(machine, machine.selectors.activeDescendantId), 'aria-multiselectable': data.mode === ValueMode.Multi ? true : undefined, 'aria-labelledby': labelledby, 'aria-orientation': data.orientation, @@ -1152,22 +735,27 @@ function OptionsFn( // When the `Listbox` is closed, it should not be focusable. This allows us // to skip focusing the `ListboxOptions` when pressing the tab key on an // open `Listbox`, and go to the next focusable element. - tabIndex: data.listboxState === ListboxStates.Open ? 0 : undefined, + tabIndex: listboxState === ListboxStates.Open ? 0 : undefined, style: { ...theirProps.style, ...style, - '--button-width': useElementSize(data.buttonElement, true).width, + '--button-width': useElementSize(buttonElement, true).width, } as CSSProperties, ...transitionDataAttributes(transitionData), }) let render = useRender() + // We want to use the local `isSelected` with frozen values when we are in + // single value mode. + let newData = useMemo( + () => (data.mode === ValueMode.Multi ? data : { ...data, isSelected }), + [data, isSelected] + ) + return ( - + {render({ ourProps, theirProps, @@ -1224,10 +812,9 @@ function OptionFn< } = props let usedInSelectedOption = useContext(SelectedOptionContext) === true let data = useData('Listbox.Option') - let actions = useActions('Listbox.Option') + let machine = useListboxMachineContext('Listbox.Option') - let active = - data.activeOptionIndex !== null ? data.options[data.activeOptionIndex].id === id : false + let active = useSlice(machine, (state) => machine.selectors.isActive(state, id)) let selected = data.isSelected(value) let internalOptionRef = useRef(null) @@ -1249,40 +836,36 @@ function OptionFn< } }) + let shouldScrollIntoView = useSlice(machine, (state) => + machine.selectors.shouldScrollIntoView(state, id) + ) useIsoMorphicEffect(() => { - if (data.__demoMode) return - if (data.listboxState !== ListboxStates.Open) return - if (!active) return - if (data.activationTrigger === ActivationTrigger.Pointer) return + if (!shouldScrollIntoView) return return disposables().requestAnimationFrame(() => { internalOptionRef.current?.scrollIntoView?.({ block: 'nearest' }) }) - }, [ - internalOptionRef, - active, - data.__demoMode, - data.listboxState, - data.activationTrigger, - /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ data.activeOptionIndex, - ]) + }, [shouldScrollIntoView, internalOptionRef]) useIsoMorphicEffect(() => { if (usedInSelectedOption) return - return actions.registerOption(id, bag) + machine.actions.registerOption(id, bag) + return () => { + machine.send({ type: ActionTypes.UnregisterOption, id }) + } }, [bag, id, usedInSelectedOption]) let handleClick = useEvent((event: { preventDefault: Function }) => { if (disabled) return event.preventDefault() - actions.onChange(value) + machine.actions.onChange(value) if (data.mode === ValueMode.Single) { - flushSync(() => actions.closeListbox()) - data.buttonElement?.focus({ preventScroll: true }) + flushSync(() => machine.actions.closeListbox()) + machine.state.buttonElement?.focus({ preventScroll: true }) } }) let handleFocus = useEvent(() => { - if (disabled) return actions.goToOption(Focus.Nothing) - actions.goToOption(Focus.Specific, id) + if (disabled) return machine.actions.goToOption({ focus: Focus.Nothing }) + machine.actions.goToOption({ focus: Focus.Specific, id }) }) let pointer = useTrackedPointer() @@ -1291,21 +874,21 @@ function OptionFn< pointer.update(evt) if (disabled) return if (active) return - actions.goToOption(Focus.Specific, id, ActivationTrigger.Pointer) + machine.actions.goToOption({ focus: Focus.Specific, id }, ActivationTrigger.Pointer) }) let handleMove = useEvent((evt) => { if (!pointer.wasMoved(evt)) return if (disabled) return if (active) return - actions.goToOption(Focus.Specific, id, ActivationTrigger.Pointer) + machine.actions.goToOption({ focus: Focus.Specific, id }, ActivationTrigger.Pointer) }) let handleLeave = useEvent((evt) => { if (!pointer.wasMoved(evt)) return if (disabled) return if (!active) return - actions.goToOption(Focus.Nothing) + machine.actions.goToOption({ focus: Focus.Nothing }) }) let slot = useMemo(() => {