From 9685af7148eabed25fa08f9645727de1fb4420ad Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 10 Apr 2025 22:43:35 +0200 Subject: [PATCH] Improve `Listbox` component performance (#3688) This PR improves the performance of the `Listbox` component. Before this PR, the `Listbox` component is built in a way where all the state lives in the `Listbox` itself. If state changes, everything re-renders and re-computes the necessary derived state. However, if you have a 1000 options, then every time the active option changes, all 1000 options have to re-render. To solve this, we can move the state outside of the `Listbox` 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 option changes, only 3 things will happen: 1. The `ListboxOptions` will re-render and have an updated `aria-activedescendant` 2. The `ListboxOption` that _was_ active, will re-render and the `data-focus` attribute wil be removed. 3. The `ListboxOption` 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 option, we need to sort the DOM nodes and make sure that we go to the correct option when using arrow up and down. This sorting was happening every time a new `ListboxOption` was registered. Luckily, once an array is sorted, you don't have to do a lot, but you still have to loop over `n` options which is not ideal. This PR will now delay the sorting until all `ListboxOption`s are registered. On that note, we also batch the `RegisterOption` 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 option, 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 2000 options. In the videos below the only thing I'm doing is holding down the `ArrowDown` key. Before: https://github.com/user-attachments/assets/a2850c84-57f6-428a-aa51-e6f83d2aee97 After: https://github.com/user-attachments/assets/157c6e99-5da8-4d72-87c6-a5e34f122531 --- packages/@headlessui-react/CHANGELOG.md | 1 + .../listbox/listbox-machine-glue.tsx | 17 + .../src/components/listbox/listbox-machine.ts | 497 ++++++++++++ .../src/components/listbox/listbox.tsx | 745 ++++-------------- 4 files changed, 679 insertions(+), 581 deletions(-) create mode 100644 packages/@headlessui-react/src/components/listbox/listbox-machine-glue.tsx create mode 100644 packages/@headlessui-react/src/components/listbox/listbox-machine.ts 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(() => {