From 5ea896fed3ce1019df6abff166818dfdbf28f1b0 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 15 Apr 2025 12:43:45 +0200 Subject: [PATCH 01/12] reduce duration in playground example --- playgrounds/react/pages/combobox/combobox-countries.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playgrounds/react/pages/combobox/combobox-countries.tsx b/playgrounds/react/pages/combobox/combobox-countries.tsx index 4afa861a5..045c07e5e 100644 --- a/playgrounds/react/pages/combobox/combobox-countries.tsx +++ b/playgrounds/react/pages/combobox/combobox-countries.tsx @@ -75,7 +75,7 @@ export default function Home() { {countries.map((country) => ( Date: Tue, 15 Apr 2025 12:54:46 +0200 Subject: [PATCH 02/12] add Combobox state as a state machine --- .../combobox/combobox-machine-glue.tsx | 17 + .../components/combobox/combobox-machine.ts | 445 ++++++++++++++++++ 2 files changed, 462 insertions(+) create mode 100644 packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx create mode 100644 packages/@headlessui-react/src/components/combobox/combobox-machine.ts diff --git a/packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx b/packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx new file mode 100644 index 000000000..27a89ff6d --- /dev/null +++ b/packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext, useMemo } from 'react' +import { ComboboxMachine } from './combobox-machine' + +export const ComboboxContext = createContext(null) +export function useComboboxMachineContext(component: string) { + let context = useContext(ComboboxContext) + if (context === null) { + let err = new Error(`<${component} /> is missing a parent component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxMachine) + throw err + } + return context +} + +export function useComboboxMachine({ __demoMode = false } = {}) { + return useMemo(() => ComboboxMachine.new({ __demoMode }), []) +} diff --git a/packages/@headlessui-react/src/components/combobox/combobox-machine.ts b/packages/@headlessui-react/src/components/combobox/combobox-machine.ts new file mode 100644 index 000000000..7c3890583 --- /dev/null +++ b/packages/@headlessui-react/src/components/combobox/combobox-machine.ts @@ -0,0 +1,445 @@ +import { Machine } from '../../machine' +import type { EnsureArray } from '../../types' +import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' +import { sortByDomNode } from '../../utils/focus-management' +import { match } from '../../utils/match' + +interface MutableRefObject { + current: T +} + +enum ComboboxState { + Open, + Closed, +} + +enum ValueMode { + Single, + Multi, +} + +enum ActivationTrigger { + Pointer, + Focus, + Other, +} + +type ComboboxOptionDataRef = MutableRefObject<{ + disabled: boolean + value: T + domRef: MutableRefObject + order: number | null +}> + +interface State { + dataRef: MutableRefObject< + { + value: unknown + defaultValue: unknown + disabled: boolean + invalid: boolean + mode: ValueMode + activeOptionIndex: number | null + immediate: boolean + + virtual: { options: T[]; disabled: (value: T) => boolean } | null + calculateIndex(value: unknown): number + compare(a: unknown, z: unknown): boolean + isSelected(value: unknown): boolean + isActive(value: unknown): boolean + + __demoMode: boolean + + optionsPropsRef: MutableRefObject<{ + static: boolean + hold: boolean + }> + } & Omit, 'dataRef'> + > + + virtual: { options: T[]; disabled: (value: unknown) => boolean } | null + + comboboxState: ComboboxState + + options: { id: string; dataRef: ComboboxOptionDataRef }[] + activeOptionIndex: number | null + activationTrigger: ActivationTrigger + + isTyping: boolean + + inputElement: HTMLInputElement | null + buttonElement: HTMLButtonElement | null + optionsElement: HTMLElement | null + + __demoMode: boolean +} + +enum ActionTypes { + OpenCombobox, + CloseCombobox, + + GoToOption, + SetTyping, + + RegisterOption, + UnregisterOption, + + SetActivationTrigger, + + UpdateVirtualConfiguration, + + SetInputElement, + SetButtonElement, + SetOptionsElement, +} + +function adjustOrderedState( + state: State, + adjustment: (options: State['options']) => State['options'] = (i) => i +) { + let currentActiveOption = + state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null + + let list = adjustment(state.options.slice()) + let sortedOptions = + list.length > 0 && list[0].dataRef.current.order !== null + ? // Prefer sorting based on the `order` + list.sort((a, z) => a.dataRef.current.order! - z.dataRef.current.order!) + : // Fallback to much slower DOM order + sortByDomNode(list, (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.CloseCombobox } + | { type: ActionTypes.OpenCombobox } + | { + type: ActionTypes.GoToOption + focus: Focus.Specific + idx: number + trigger?: ActivationTrigger + } + | { type: ActionTypes.SetTyping; isTyping: boolean } + | { + type: ActionTypes.GoToOption + focus: Exclude + trigger?: ActivationTrigger + } + | { + type: ActionTypes.RegisterOption + payload: { id: string; dataRef: ComboboxOptionDataRef } + } + | { type: ActionTypes.UnregisterOption; id: string } + | { type: ActionTypes.SetActivationTrigger; trigger: ActivationTrigger } + | { + type: ActionTypes.UpdateVirtualConfiguration + options: T[] + disabled: ((value: any) => boolean) | null + } + | { type: ActionTypes.SetInputElement; element: HTMLInputElement | null } + | { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null } + | { type: ActionTypes.SetOptionsElement; element: HTMLElement | null } + +let reducers: { + [P in ActionTypes]: (state: State, action: Extract, { type: P }>) => State +} = { + [ActionTypes.CloseCombobox](state) { + if (state.dataRef.current?.disabled) return state + if (state.comboboxState === ComboboxState.Closed) return state + + return { + ...state, + activeOptionIndex: null, + comboboxState: ComboboxState.Closed, + + isTyping: false, + + // Clear the last known activation trigger + // This is because if a user interacts with the combobox using a mouse + // resulting in it closing we might incorrectly handle the next interaction + // for example, not scrolling to the active option in a virtual list + activationTrigger: ActivationTrigger.Other, + + __demoMode: false, + } + }, + [ActionTypes.OpenCombobox](state) { + if (state.dataRef.current?.disabled) return state + if (state.comboboxState === ComboboxState.Open) return state + + // Check if we have a selected value that we can make active + if (state.dataRef.current?.value) { + let idx = state.dataRef.current.calculateIndex(state.dataRef.current.value) + if (idx !== -1) { + return { + ...state, + activeOptionIndex: idx, + comboboxState: ComboboxState.Open, + __demoMode: false, + } + } + } + + return { ...state, comboboxState: ComboboxState.Open, __demoMode: false } + }, + [ActionTypes.SetTyping](state, action) { + if (state.isTyping === action.isTyping) return state + return { ...state, isTyping: action.isTyping } + }, + [ActionTypes.GoToOption](state, action) { + if (state.dataRef.current?.disabled) return state + if ( + state.optionsElement && + !state.dataRef.current?.optionsPropsRef.current.static && + state.comboboxState === ComboboxState.Closed + ) { + return state + } + + if (state.virtual) { + let { options, disabled } = state.virtual + let activeOptionIndex = + action.focus === Focus.Specific + ? action.idx + : calculateActiveIndex(action, { + resolveItems: () => options, + resolveActiveIndex: () => + state.activeOptionIndex ?? options.findIndex((option) => !disabled(option)) ?? null, + resolveDisabled: disabled, + resolveId() { + throw new Error('Function not implemented.') + }, + }) + + let activationTrigger = action.trigger ?? ActivationTrigger.Other + + if ( + state.activeOptionIndex === activeOptionIndex && + state.activationTrigger === activationTrigger + ) { + return state + } + + return { + ...state, + activeOptionIndex, + activationTrigger, + isTyping: false, + __demoMode: false, + } + } + + let adjustedState = adjustOrderedState(state) + + // It's possible that the activeOptionIndex is set to `null` internally, but + // this means that we will fallback to the first non-disabled option by default. + // We have to take this into account. + if (adjustedState.activeOptionIndex === null) { + let localActiveOptionIndex = adjustedState.options.findIndex( + (option) => !option.dataRef.current.disabled + ) + + if (localActiveOptionIndex !== -1) { + adjustedState.activeOptionIndex = localActiveOptionIndex + } + } + + let activeOptionIndex = + action.focus === Focus.Specific + ? action.idx + : calculateActiveIndex(action, { + resolveItems: () => adjustedState.options, + resolveActiveIndex: () => adjustedState.activeOptionIndex, + resolveId: (item) => item.id, + resolveDisabled: (item) => item.dataRef.current.disabled, + }) + let activationTrigger = action.trigger ?? ActivationTrigger.Other + + if ( + state.activeOptionIndex === activeOptionIndex && + state.activationTrigger === activationTrigger + ) { + return state + } + + return { + ...state, + ...adjustedState, + isTyping: false, + activeOptionIndex, + activationTrigger, + __demoMode: false, + } + }, + [ActionTypes.RegisterOption]: (state, action) => { + if (state.dataRef.current?.virtual) { + return { + ...state, + options: [...state.options, action.payload], + } + } + + let option = action.payload + + let adjustedState = adjustOrderedState(state, (options) => { + options.push(option) + return options + }) + + // Check if we need to make the newly registered option active. + if (state.activeOptionIndex === null) { + if (state.dataRef.current?.isSelected(action.payload.dataRef.current.value)) { + adjustedState.activeOptionIndex = adjustedState.options.indexOf(option) + } + } + + let nextState = { + ...state, + ...adjustedState, + activationTrigger: ActivationTrigger.Other, + } + + if (state.dataRef.current?.__demoMode && state.dataRef.current.value === undefined) { + nextState.activeOptionIndex = 0 + } + + return nextState + }, + [ActionTypes.UnregisterOption]: (state, action) => { + if (state.dataRef.current?.virtual) { + return { + ...state, + options: state.options.filter((option) => option.id !== action.id), + } + } + + let adjustedState = adjustOrderedState(state, (options) => { + let idx = options.findIndex((option) => option.id === action.id) + if (idx !== -1) options.splice(idx, 1) + return options + }) + + return { + ...state, + ...adjustedState, + activationTrigger: ActivationTrigger.Other, + } + }, + [ActionTypes.SetActivationTrigger]: (state, action) => { + if (state.activationTrigger === action.trigger) { + return state + } + + return { + ...state, + activationTrigger: action.trigger, + } + }, + [ActionTypes.UpdateVirtualConfiguration]: (state, action) => { + if (state.virtual === null) { + return { + ...state, + virtual: { options: action.options, disabled: action.disabled ?? (() => false) }, + } + } + + if (state.virtual.options === action.options && state.virtual.disabled === action.disabled) { + return state + } + + let adjustedActiveOptionIndex = state.activeOptionIndex + if (state.activeOptionIndex !== null) { + let idx = action.options.indexOf(state.virtual.options[state.activeOptionIndex]) + if (idx !== -1) { + adjustedActiveOptionIndex = idx + } else { + adjustedActiveOptionIndex = null + } + } + + return { + ...state, + activeOptionIndex: adjustedActiveOptionIndex, + virtual: { options: action.options, disabled: action.disabled ?? (() => false) }, + } + }, + [ActionTypes.SetInputElement]: (state, action) => { + if (state.inputElement === action.element) return state + return { ...state, inputElement: action.element } + }, + [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 } + }, +} + +export class ComboboxMachine extends Machine, Actions> { + static new({ + virtual = null, + __demoMode = false, + }: { + virtual?: { + options: TMultiple extends true ? EnsureArray> : NoInfer[] + disabled?: ( + value: TMultiple extends true ? EnsureArray>[number] : NoInfer + ) => boolean + } | null + __demoMode?: boolean + } = {}) { + return new ComboboxMachine({ + // @ts-expect-error TODO: Re-structure such that we don't need to ignore this + dataRef: { current: {} }, + comboboxState: __demoMode ? ComboboxState.Open : ComboboxState.Closed, + isTyping: false, + options: [], + virtual: virtual + ? { options: virtual.options, disabled: virtual.disabled ?? (() => false) } + : null, + activeOptionIndex: null, + activationTrigger: ActivationTrigger.Other, + inputElement: null, + buttonElement: null, + optionsElement: null, + __demoMode, + }) + } + + actions = { + onChange() {}, + registerOption() {}, + goToOption() {}, + setIsTyping() {}, + closeCombobox() {}, + openCombobox() {}, + setActivationTrigger() {}, + selectActiveOption() {}, + setInputElement() {}, + setButtonElement() {}, + setOptionsElement() {}, + } + + selectors = {} + + reduce(state: Readonly>, action: Actions): State { + return match(action.type, reducers, state, action) as State + } +} From 3bd990bf32f18b76bcb947ba333e97e08f238f4d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 16 Apr 2025 00:31:47 +0200 Subject: [PATCH 03/12] use machine state directly --- .../combobox/combobox-machine-glue.tsx | 13 +- .../components/combobox/combobox-machine.ts | 206 +++- .../src/components/combobox/combobox.tsx | 1086 +++++------------ 3 files changed, 487 insertions(+), 818 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx b/packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx index 27a89ff6d..4121fbef2 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox-machine-glue.tsx @@ -1,17 +1,20 @@ import { createContext, useContext, useMemo } from 'react' import { ComboboxMachine } from './combobox-machine' -export const ComboboxContext = createContext(null) -export function useComboboxMachineContext(component: string) { +export const ComboboxContext = createContext | null>(null) +export function useComboboxMachineContext(component: string) { let context = useContext(ComboboxContext) if (context === null) { let err = new Error(`<${component} /> is missing a parent component.`) if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxMachine) throw err } - return context + return context as ComboboxMachine } -export function useComboboxMachine({ __demoMode = false } = {}) { - return useMemo(() => ComboboxMachine.new({ __demoMode }), []) +export function useComboboxMachine({ + virtual = null, + __demoMode = false, +}: Parameters[0] = {}) { + return useMemo(() => ComboboxMachine.new({ virtual, __demoMode }), []) } diff --git a/packages/@headlessui-react/src/components/combobox/combobox-machine.ts b/packages/@headlessui-react/src/components/combobox/combobox-machine.ts index 7c3890583..8a6d3beb1 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox-machine.ts +++ b/packages/@headlessui-react/src/components/combobox/combobox-machine.ts @@ -8,59 +8,59 @@ interface MutableRefObject { current: T } -enum ComboboxState { +export enum ComboboxState { Open, Closed, } -enum ValueMode { +export enum ValueMode { Single, Multi, } -enum ActivationTrigger { +export enum ActivationTrigger { Pointer, Focus, Other, } -type ComboboxOptionDataRef = MutableRefObject<{ +export type ComboboxOptionDataRef = MutableRefObject<{ disabled: boolean value: T domRef: MutableRefObject order: number | null }> -interface State { - dataRef: MutableRefObject< - { - value: unknown - defaultValue: unknown - disabled: boolean - invalid: boolean - mode: ValueMode - activeOptionIndex: number | null - immediate: boolean - - virtual: { options: T[]; disabled: (value: T) => boolean } | null - calculateIndex(value: unknown): number - compare(a: unknown, z: unknown): boolean - isSelected(value: unknown): boolean - isActive(value: unknown): boolean - - __demoMode: boolean - - optionsPropsRef: MutableRefObject<{ - static: boolean - hold: boolean - }> - } & Omit, 'dataRef'> - > +export interface State { + dataRef: MutableRefObject<{ + value: unknown + defaultValue: unknown + disabled: boolean + invalid: boolean + mode: ValueMode + immediate: boolean + onChange: (value: T) => void + onClose?: () => void + compare(a: unknown, z: unknown): boolean + isSelected(value: unknown): boolean + + virtual: { options: T[]; disabled: (value: T) => boolean } | null + calculateIndex(value: unknown): number + + __demoMode: boolean + + optionsPropsRef: MutableRefObject<{ + static: boolean + hold: boolean + }> + }> virtual: { options: T[]; disabled: (value: unknown) => boolean } | null comboboxState: ComboboxState + defaultToFirstOption: boolean + options: { id: string; dataRef: ComboboxOptionDataRef }[] activeOptionIndex: number | null activationTrigger: ActivationTrigger @@ -74,7 +74,7 @@ interface State { __demoMode: boolean } -enum ActionTypes { +export enum ActionTypes { OpenCombobox, CloseCombobox, @@ -84,6 +84,8 @@ enum ActionTypes { RegisterOption, UnregisterOption, + DefaultToFirstOption, + SetActivationTrigger, UpdateVirtualConfiguration, @@ -145,6 +147,7 @@ type Actions = payload: { id: string; dataRef: ComboboxOptionDataRef } } | { type: ActionTypes.UnregisterOption; id: string } + | { type: ActionTypes.DefaultToFirstOption; value: boolean } | { type: ActionTypes.SetActivationTrigger; trigger: ActivationTrigger } | { type: ActionTypes.UpdateVirtualConfiguration @@ -303,7 +306,7 @@ let reducers: { // Check if we need to make the newly registered option active. if (state.activeOptionIndex === null) { - if (state.dataRef.current?.isSelected(action.payload.dataRef.current.value)) { + if (state.dataRef.current.isSelected?.(action.payload.dataRef.current.value)) { adjustedState.activeOptionIndex = adjustedState.options.indexOf(option) } } @@ -340,6 +343,14 @@ let reducers: { activationTrigger: ActivationTrigger.Other, } }, + [ActionTypes.DefaultToFirstOption]: (state, action) => { + if (state.defaultToFirstOption === action.value) return state + + return { + ...state, + defaultToFirstOption: action.value, + } + }, [ActionTypes.SetActivationTrigger]: (state, action) => { if (state.activationTrigger === action.trigger) { return state @@ -411,6 +422,7 @@ export class ComboboxMachine extends Machine, Actions> { comboboxState: __demoMode ? ComboboxState.Open : ComboboxState.Closed, isTyping: false, options: [], + // @ts-expect-error TODO: Ensure we use the correct type virtual: virtual ? { options: virtual.options, disabled: virtual.disabled ?? (() => false) } : null, @@ -424,20 +436,128 @@ export class ComboboxMachine extends Machine, Actions> { } actions = { - onChange() {}, - registerOption() {}, - goToOption() {}, - setIsTyping() {}, - closeCombobox() {}, - openCombobox() {}, - setActivationTrigger() {}, - selectActiveOption() {}, - setInputElement() {}, - setButtonElement() {}, - setOptionsElement() {}, + 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: (id: string, dataRef: ComboboxOptionDataRef) => { + this.send({ type: ActionTypes.RegisterOption, payload: { id, dataRef } }) + return () => { + // When we are unregistering the currently active option, then we also have to make sure to + // reset the `defaultToFirstOption` flag, so that visually something is selected and the next + // time you press a key on your keyboard it will go to the proper next or previous option in + // the list. + // + // Since this was the active option and it could have been anywhere in the list, resetting to + // the very first option seems like a fine default. We _could_ be smarter about this by going + // to the previous / next item in list if we know the direction of the keyboard navigation, + // but that might be too complex/confusing from an end users perspective. + if (this.selectors.isActive(this.state, dataRef.current.value)) { + this.send({ type: ActionTypes.DefaultToFirstOption, value: true }) + } + + this.send({ type: ActionTypes.UnregisterOption, id }) + } + }, + goToOption: ( + focus: { focus: Focus.Specific; idx: number } | { focus: Exclude }, + trigger?: ActivationTrigger + ) => { + this.send({ type: ActionTypes.DefaultToFirstOption, value: false }) + return this.send({ type: ActionTypes.GoToOption, ...focus, trigger }) + }, + setIsTyping: (isTyping: boolean) => { + this.send({ type: ActionTypes.SetTyping, isTyping }) + }, + closeCombobox: () => { + this.send({ type: ActionTypes.CloseCombobox }) + this.send({ type: ActionTypes.DefaultToFirstOption, value: false }) + this.state.dataRef.current.onClose?.() + }, + openCombobox: () => { + this.send({ type: ActionTypes.OpenCombobox }) + this.send({ type: ActionTypes.DefaultToFirstOption, value: true }) + }, + setActivationTrigger: (trigger: ActivationTrigger) => { + this.send({ type: ActionTypes.SetActivationTrigger, trigger }) + }, + selectActiveOption: () => { + let activeOptionIndex = this.selectors.activeOptionIndex(this.state) + if (activeOptionIndex === null) return + + this.actions.setIsTyping(false) + + if (this.state.virtual) { + this.actions.onChange(this.state.virtual.options[activeOptionIndex]) + } else { + let { dataRef } = this.state.options[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.actions.goToOption({ focus: Focus.Specific, idx: activeOptionIndex }) + }, + setInputElement: (element: HTMLInputElement | null) => { + this.send({ type: ActionTypes.SetInputElement, element }) + }, + setButtonElement: (element: HTMLButtonElement | null) => { + this.send({ type: ActionTypes.SetButtonElement, element }) + }, + setOptionsElement: (element: HTMLElement | null) => { + this.send({ type: ActionTypes.SetOptionsElement, element }) + }, } - selectors = {} + selectors = { + activeOptionIndex: (state: State) => { + if ( + state.defaultToFirstOption && + state.activeOptionIndex === null && + (state.virtual ? state.virtual.options.length > 0 : state.options.length > 0) + ) { + if (state.virtual) { + let { options, disabled } = state.virtual + let localActiveOptionIndex = options.findIndex((option) => !(disabled?.(option) ?? false)) + + if (localActiveOptionIndex !== -1) { + return localActiveOptionIndex + } + } + + let localActiveOptionIndex = state.options.findIndex((option) => { + return !option.dataRef.current.disabled + }) + + if (localActiveOptionIndex !== -1) { + return localActiveOptionIndex + } + } + + return state.activeOptionIndex + }, + + isActive: (state: State, other: T) => { + return this.selectors.activeOptionIndex(state) === state.dataRef.current.calculateIndex(other) + }, + } reduce(state: Readonly>, action: Actions): State { return match(action.type, reducers, state, action) as State diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index fd364bc43..d1aca2fb6 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -6,11 +6,9 @@ import { Virtualizer, useVirtualizer } from '@tanstack/react-virtual' import React, { Fragment, createContext, - createRef, useCallback, useContext, useMemo, - useReducer, useRef, useState, type CSSProperties, @@ -57,12 +55,12 @@ import { FormFields } from '../../internal/form-fields' import { Frozen, 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 { history } from '../../utils/active-element-history' 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 { sortByDomNode } from '../../utils/focus-management' import { match } from '../../utils/match' import { isMobile } from '../../utils/platform' import { @@ -79,398 +77,52 @@ import { Keys } from '../keyboard' import { Label, useLabelledBy, useLabels, type _internal_ComponentLabel } from '../label/label' import { MouseButton } from '../mouse' import { Portal } from '../portal/portal' - -enum ComboboxState { - Open, - Closed, -} - -enum ValueMode { - Single, - Multi, -} - -enum ActivationTrigger { - Pointer, - Focus, - Other, -} - -type ComboboxOptionDataRef = MutableRefObject<{ +import { + ActionTypes, + ActivationTrigger, + ComboboxState, + ValueMode, + type ComboboxOptionDataRef, +} from './combobox-machine' +import { + ComboboxContext, + useComboboxMachine, + useComboboxMachineContext, +} from './combobox-machine-glue' + +let ComboboxDataContext = createContext<{ + value: unknown + defaultValue: unknown disabled: boolean - value: T - domRef: MutableRefObject - order: number | null -}> - -interface StateDefinition { - dataRef: MutableRefObject<_Data | null> - - virtual: { options: T[]; disabled: (value: unknown) => boolean } | null - - comboboxState: ComboboxState - - options: { id: string; dataRef: ComboboxOptionDataRef }[] - activeOptionIndex: number | null - activationTrigger: ActivationTrigger - - isTyping: boolean + invalid: boolean + mode: ValueMode + immediate: boolean - inputElement: HTMLInputElement | null - buttonElement: HTMLButtonElement | null - optionsElement: HTMLElement | null + virtual: { options: unknown[]; disabled: (value: unknown) => boolean } | null + calculateIndex(value: unknown): number + compare(a: unknown, z: unknown): boolean + isSelected(value: unknown): boolean + onChange(value: unknown): void __demoMode: boolean -} - -enum ActionTypes { - OpenCombobox, - CloseCombobox, - - GoToOption, - SetTyping, - - RegisterOption, - UnregisterOption, - - SetActivationTrigger, - - UpdateVirtualConfiguration, - - SetInputElement, - 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 list = adjustment(state.options.slice()) - let sortedOptions = - list.length > 0 && list[0].dataRef.current.order !== null - ? // Prefer sorting based on the `order` - list.sort((a, z) => a.dataRef.current.order! - z.dataRef.current.order!) - : // Fallback to much slower DOM order - sortByDomNode(list, (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.CloseCombobox } - | { type: ActionTypes.OpenCombobox } - | { - type: ActionTypes.GoToOption - focus: Focus.Specific - idx: number - trigger?: ActivationTrigger - } - | { type: ActionTypes.SetTyping; isTyping: boolean } - | { - type: ActionTypes.GoToOption - focus: Exclude - trigger?: ActivationTrigger - } - | { - type: ActionTypes.RegisterOption - payload: { id: string; dataRef: ComboboxOptionDataRef } - } - | { type: ActionTypes.UnregisterOption; id: string } - | { type: ActionTypes.SetActivationTrigger; trigger: ActivationTrigger } - | { - type: ActionTypes.UpdateVirtualConfiguration - options: T[] - disabled: ((value: any) => boolean) | null - } - | { type: ActionTypes.SetInputElement; element: HTMLInputElement | null } - | { 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.CloseCombobox](state) { - if (state.dataRef.current?.disabled) return state - if (state.comboboxState === ComboboxState.Closed) return state - - return { - ...state, - activeOptionIndex: null, - comboboxState: ComboboxState.Closed, - - isTyping: false, - - // Clear the last known activation trigger - // This is because if a user interacts with the combobox using a mouse - // resulting in it closing we might incorrectly handle the next interaction - // for example, not scrolling to the active option in a virtual list - activationTrigger: ActivationTrigger.Other, - - __demoMode: false, - } - }, - [ActionTypes.OpenCombobox](state) { - if (state.dataRef.current?.disabled) return state - if (state.comboboxState === ComboboxState.Open) return state - - // Check if we have a selected value that we can make active - if (state.dataRef.current?.value) { - let idx = state.dataRef.current.calculateIndex(state.dataRef.current.value) - if (idx !== -1) { - return { - ...state, - activeOptionIndex: idx, - comboboxState: ComboboxState.Open, - __demoMode: false, - } - } - } - - return { ...state, comboboxState: ComboboxState.Open, __demoMode: false } - }, - [ActionTypes.SetTyping](state, action) { - if (state.isTyping === action.isTyping) return state - return { ...state, isTyping: action.isTyping } - }, - [ActionTypes.GoToOption](state, action) { - if (state.dataRef.current?.disabled) return state - if ( - state.optionsElement && - !state.dataRef.current?.optionsPropsRef.current.static && - state.comboboxState === ComboboxState.Closed - ) { - return state - } - - if (state.virtual) { - let { options, disabled } = state.virtual - let activeOptionIndex = - action.focus === Focus.Specific - ? action.idx - : calculateActiveIndex(action, { - resolveItems: () => options, - resolveActiveIndex: () => - state.activeOptionIndex ?? options.findIndex((option) => !disabled(option)) ?? null, - resolveDisabled: disabled, - resolveId() { - throw new Error('Function not implemented.') - }, - }) - - let activationTrigger = action.trigger ?? ActivationTrigger.Other - - if ( - state.activeOptionIndex === activeOptionIndex && - state.activationTrigger === activationTrigger - ) { - return state - } - - return { - ...state, - activeOptionIndex, - activationTrigger, - isTyping: false, - __demoMode: false, - } - } - - let adjustedState = adjustOrderedState(state) - - // It's possible that the activeOptionIndex is set to `null` internally, but - // this means that we will fallback to the first non-disabled option by default. - // We have to take this into account. - if (adjustedState.activeOptionIndex === null) { - let localActiveOptionIndex = adjustedState.options.findIndex( - (option) => !option.dataRef.current.disabled - ) - - if (localActiveOptionIndex !== -1) { - adjustedState.activeOptionIndex = localActiveOptionIndex - } - } - - let activeOptionIndex = - action.focus === Focus.Specific - ? action.idx - : calculateActiveIndex(action, { - resolveItems: () => adjustedState.options, - resolveActiveIndex: () => adjustedState.activeOptionIndex, - resolveId: (item) => item.id, - resolveDisabled: (item) => item.dataRef.current.disabled, - }) - let activationTrigger = action.trigger ?? ActivationTrigger.Other - - if ( - state.activeOptionIndex === activeOptionIndex && - state.activationTrigger === activationTrigger - ) { - return state - } - - return { - ...state, - ...adjustedState, - isTyping: false, - activeOptionIndex, - activationTrigger, - __demoMode: false, - } - }, - [ActionTypes.RegisterOption]: (state, action) => { - if (state.dataRef.current?.virtual) { - return { - ...state, - options: [...state.options, action.payload], - } - } - let option = action.payload - - let adjustedState = adjustOrderedState(state, (options) => { - options.push(option) - return options - }) - - // Check if we need to make the newly registered option active. - if (state.activeOptionIndex === null) { - if (state.dataRef.current?.isSelected(action.payload.dataRef.current.value)) { - adjustedState.activeOptionIndex = adjustedState.options.indexOf(option) - } - } - - let nextState = { - ...state, - ...adjustedState, - activationTrigger: ActivationTrigger.Other, - } - - if (state.dataRef.current?.__demoMode && state.dataRef.current.value === undefined) { - nextState.activeOptionIndex = 0 - } - - return nextState - }, - [ActionTypes.UnregisterOption]: (state, action) => { - if (state.dataRef.current?.virtual) { - return { - ...state, - options: state.options.filter((option) => option.id !== action.id), - } - } - - let adjustedState = adjustOrderedState(state, (options) => { - let idx = options.findIndex((option) => option.id === action.id) - if (idx !== -1) options.splice(idx, 1) - return options - }) - - return { - ...state, - ...adjustedState, - activationTrigger: ActivationTrigger.Other, - } - }, - [ActionTypes.SetActivationTrigger]: (state, action) => { - if (state.activationTrigger === action.trigger) { - return state - } - - return { - ...state, - activationTrigger: action.trigger, - } - }, - [ActionTypes.UpdateVirtualConfiguration]: (state, action) => { - if (state.virtual === null) { - return { - ...state, - virtual: { options: action.options, disabled: action.disabled ?? (() => false) }, - } - } - - if (state.virtual.options === action.options && state.virtual.disabled === action.disabled) { - return state - } - - let adjustedActiveOptionIndex = state.activeOptionIndex - if (state.activeOptionIndex !== null) { - let idx = action.options.indexOf(state.virtual.options[state.activeOptionIndex]) - if (idx !== -1) { - adjustedActiveOptionIndex = idx - } else { - adjustedActiveOptionIndex = null - } - } - - return { - ...state, - activeOptionIndex: adjustedActiveOptionIndex, - virtual: { options: action.options, disabled: action.disabled ?? (() => false) }, - } - }, - [ActionTypes.SetInputElement]: (state, action) => { - if (state.inputElement === action.element) return state - return { ...state, inputElement: action.element } - }, - [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 ComboboxActionsContext = createContext<{ - openCombobox(): void - closeCombobox(): void - registerOption(id: string, dataRef: ComboboxOptionDataRef): () => void - goToOption(focus: Focus.Specific, idx: number, trigger?: ActivationTrigger): void - goToOption(focus: Focus, idx?: number, trigger?: ActivationTrigger): void - setIsTyping(isTyping: boolean): void - selectActiveOption(): void - setActivationTrigger(trigger: ActivationTrigger): void - onChange(value: unknown): void - - setInputElement(element: HTMLInputElement | null): void - setButtonElement(element: HTMLButtonElement | null): void - setOptionsElement(element: HTMLElement | null): void + optionsPropsRef: MutableRefObject<{ + static: boolean + hold: boolean + }> } | null>(null) -ComboboxActionsContext.displayName = 'ComboboxActionsContext' +ComboboxDataContext.displayName = 'ComboboxDataContext' -function useActions(component: string) { - let context = useContext(ComboboxActionsContext) +function useData(component: string) { + let context = useContext(ComboboxDataContext) if (context === null) { let err = new Error(`<${component} /> is missing a parent component.`) - if (Error.captureStackTrace) Error.captureStackTrace(err, useActions) + if (Error.captureStackTrace) Error.captureStackTrace(err, useData) throw err } return context } -type _Actions = ReturnType +type _Data = ReturnType let VirtualContext = createContext | null>(null) @@ -478,12 +130,15 @@ function VirtualProvider(props: { slot: OptionsRenderPropArg children: (data: { option: unknown; open: boolean }) => React.ReactElement }) { + let machine = useComboboxMachineContext('VirtualProvider') let data = useData('VirtualProvider') let d = useDisposables() let { options } = data.virtual! + let optionsElement = useSlice(machine, (state) => state.optionsElement) + let [paddingStart, paddingEnd] = useMemo(() => { - let el = data.optionsElement + let el = optionsElement if (!el) return [0, 0] let styles = window.getComputedStyle(el) @@ -492,7 +147,7 @@ function VirtualProvider(props: { parseFloat(styles.paddingBlockStart || styles.paddingTop), parseFloat(styles.paddingBlockEnd || styles.paddingBottom), ] - }, [data.optionsElement]) + }, [optionsElement]) let virtualizer = useVirtualizer({ enabled: options.length !== 0, @@ -503,7 +158,7 @@ function VirtualProvider(props: { return 40 }, getScrollElement() { - return data.optionsElement + return machine.state.optionsElement }, overscan: 12, }) @@ -515,6 +170,11 @@ function VirtualProvider(props: { let items = virtualizer.getVirtualItems() + let isPointerActivationTrigger = useSlice(machine, (state) => { + return state.activationTrigger === ActivationTrigger.Pointer + }) + let activeOptionIndex = useSlice(machine, machine.selectors.activeOptionIndex) + if (items.length === 0) { return null } @@ -534,7 +194,7 @@ function VirtualProvider(props: { } // Do not scroll when the mouse/pointer is being used - if (data.activationTrigger === ActivationTrigger.Pointer) { + if (isPointerActivationTrigger) { return } @@ -542,8 +202,8 @@ function VirtualProvider(props: { // // Workaround for: https://github.com/TanStack/virtual/issues/879 d.nextFrame(() => { - if (data.activeOptionIndex !== null && options.length > data.activeOptionIndex) { - virtualizer.scrollToIndex(data.activeOptionIndex) + if (activeOptionIndex !== null && options.length > activeOptionIndex) { + virtualizer.scrollToIndex(activeOptionIndex) } }) }} @@ -578,48 +238,6 @@ function VirtualProvider(props: { ) } -let ComboboxDataContext = createContext< - | ({ - value: unknown - defaultValue: unknown - disabled: boolean - invalid: boolean - mode: ValueMode - activeOptionIndex: number | null - immediate: boolean - - virtual: { options: unknown[]; disabled: (value: unknown) => boolean } | null - calculateIndex(value: unknown): number - compare(a: unknown, z: unknown): boolean - isSelected(value: unknown): boolean - isActive(value: unknown): boolean - - __demoMode: boolean - - optionsPropsRef: MutableRefObject<{ - static: boolean - hold: boolean - }> - } & Omit, 'dataRef'>) - | null ->(null) -ComboboxDataContext.displayName = 'ComboboxDataContext' - -function useData(component: string) { - let context = useContext(ComboboxDataContext) - if (context === null) { - let err = new Error(`<${component} /> is missing a parent component.`) - if (Error.captureStackTrace) Error.captureStackTrace(err, useData) - throw err - } - return context -} -type _Data = ReturnType - -function stateReducer(state: StateDefinition, action: Actions) { - return match(action.type, reducers, state, action) -} - // --- let DEFAULT_COMBOBOX_TAG = Fragment @@ -687,7 +305,7 @@ function ComboboxFn false) } - : null, - activeOptionIndex: null, - activationTrigger: ActivationTrigger.Other, - inputElement: null, - buttonElement: null, - optionsElement: null, - __demoMode, - } as StateDefinition) - - let defaultToFirstOption = useRef(false) + let machine = useComboboxMachine({ virtual, __demoMode }) let optionsPropsRef = useRef<_Data['optionsPropsRef']['current']>({ static: false, hold: false }) @@ -735,27 +337,27 @@ function ComboboxFn compare(other, value)) } } else { - return state.options.findIndex((other) => compare(other.dataRef.current.value, value)) + return machine.state.options.findIndex((other) => compare(other.dataRef.current.value, value)) } }) let isSelected: (value: TValue) => boolean = useCallback( - (other) => - match(data.mode, { - [ValueMode.Multi]: () => - (value as EnsureArray).some((option) => compare(option, other)), + (other) => { + return match(data.mode, { + [ValueMode.Multi]: () => { + return (value as EnsureArray).some((option) => compare(option, other)) + }, [ValueMode.Single]: () => compare(value as TValue, other), - }), + }) + }, [value] ) - let isActive = useEvent((other: TValue) => { - return state.activeOptionIndex === calculateIndex(other) - }) - + let virtualSlice = useSlice(machine, (state) => state.virtual) + let onClose = useEvent(() => theirOnClose?.()) let data = useMemo<_Data>( () => ({ - ...state, + __demoMode, immediate, optionsPropsRef, value, @@ -763,45 +365,32 @@ function ComboboxFn 0 : state.options.length > 0) - ) { - if (virtual) { - let localActiveOptionIndex = virtual.options.findIndex( - (option) => !(virtual.disabled?.(option) ?? false) - ) - - if (localActiveOptionIndex !== -1) { - return localActiveOptionIndex - } - } - - let localActiveOptionIndex = state.options.findIndex((option) => { - return !option.dataRef.current.disabled - }) - - if (localActiveOptionIndex !== -1) { - return localActiveOptionIndex - } - } - - return state.activeOptionIndex - }, + virtual: virtual ? virtualSlice : null, + onChange: theirOnChange, + isSelected, calculateIndex, compare, - isSelected, - isActive, + onClose, }), - [value, defaultValue, disabled, invalid, multiple, __demoMode, state, virtual] + [ + value, + defaultValue, + disabled, + invalid, + multiple, + theirOnChange, + isSelected, + __demoMode, + machine, + virtual, + virtualSlice, + onClose, + ] ) useIsoMorphicEffect(() => { if (!virtual) return - dispatch({ + machine.send({ type: ActionTypes.UpdateVirtualConfiguration, options: virtual.options, disabled: virtual.disabled ?? null, @@ -809,147 +398,47 @@ function ComboboxFn { - state.dataRef.current = data + machine.state.dataRef.current = data }, [data]) + let [comboboxState, buttonElement, inputElement, optionsElement] = useSlice(machine, (state) => [ + state.comboboxState, + state.buttonElement, + state.inputElement, + state.optionsElement, + ]) + // Handle outside click - let outsideClickEnabled = data.comboboxState === ComboboxState.Open - useOutsideClick( - outsideClickEnabled, - [data.buttonElement, data.inputElement, data.optionsElement], - () => actions.closeCombobox() + let outsideClickEnabled = comboboxState === ComboboxState.Open + useOutsideClick(outsideClickEnabled, [buttonElement, inputElement, optionsElement], () => + machine.actions.closeCombobox() + ) + + let activeOptionIndex = useSlice(machine, machine.selectors.activeOptionIndex) + let activeOption = useSlice( + machine, + useCallback( + (state) => { + return activeOptionIndex === null + ? null + : state.virtual + ? state.virtual.options[activeOptionIndex ?? 0] + : (state.options[activeOptionIndex]?.dataRef.current.value as TValue) ?? null + }, + [activeOptionIndex] + ) ) let slot = useMemo(() => { return { - open: data.comboboxState === ComboboxState.Open, + open: comboboxState === ComboboxState.Open, disabled, invalid, - activeIndex: data.activeOptionIndex, - activeOption: - data.activeOptionIndex === null - ? null - : data.virtual - ? data.virtual.options[data.activeOptionIndex ?? 0] - : (data.options[data.activeOptionIndex]?.dataRef.current.value as TValue) ?? null, + activeIndex: activeOptionIndex, + activeOption, value, } satisfies ComboboxRenderPropArg - }, [data, disabled, value, invalid]) - - let selectActiveOption = useEvent(() => { - if (data.activeOptionIndex === null) return - - actions.setIsTyping(false) - - if (data.virtual) { - onChange(data.virtual.options[data.activeOptionIndex]) - } else { - let { dataRef } = 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. - actions.goToOption(Focus.Specific, data.activeOptionIndex) - }) - - let openCombobox = useEvent(() => { - dispatch({ type: ActionTypes.OpenCombobox }) - defaultToFirstOption.current = true - }) - - let closeCombobox = useEvent(() => { - dispatch({ type: ActionTypes.CloseCombobox }) - defaultToFirstOption.current = false - onClose?.() - }) - - let setIsTyping = useEvent((isTyping: boolean) => { - dispatch({ type: ActionTypes.SetTyping, isTyping }) - }) - - let goToOption = useEvent((focus, idx, trigger) => { - defaultToFirstOption.current = false - - if (focus === Focus.Specific) { - return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, idx: idx!, trigger }) - } - - return dispatch({ type: ActionTypes.GoToOption, focus, trigger }) - }) - - let registerOption = useEvent((id, dataRef) => { - dispatch({ type: ActionTypes.RegisterOption, payload: { id, dataRef } }) - return () => { - // When we are unregistering the currently active option, then we also have to make sure to - // reset the `defaultToFirstOption` flag, so that visually something is selected and the next - // time you press a key on your keyboard it will go to the proper next or previous option in - // the list. - // - // Since this was the active option and it could have been anywhere in the list, resetting to - // the very first option seems like a fine default. We _could_ be smarter about this by going - // to the previous / next item in list if we know the direction of the keyboard navigation, - // but that might be too complex/confusing from an end users perspective. - if (data.isActive(dataRef.current.value)) { - defaultToFirstOption.current = true - } - - dispatch({ type: ActionTypes.UnregisterOption, id }) - } - }) - - let onChange = useEvent((value: unknown) => { - return match(data.mode, { - [ValueMode.Single]() { - return theirOnChange?.(value as TValue) - }, - [ValueMode.Multi]() { - let copy = (data.value as TValue[]).slice() - - let idx = copy.findIndex((item) => compare(item, value as TValue)) - if (idx === -1) { - copy.push(value as TValue) - } else { - copy.splice(idx, 1) - } - - return theirOnChange?.(copy as TValue[]) - }, - }) - }) - - let setActivationTrigger = useEvent((trigger: ActivationTrigger) => { - dispatch({ type: ActionTypes.SetActivationTrigger, trigger }) - }) - - let setInputElement = useEvent((element: HTMLInputElement | null) => { - dispatch({ type: ActionTypes.SetInputElement, element }) - }) - - 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, - setIsTyping, - closeCombobox, - openCombobox, - setActivationTrigger, - selectActiveOption, - setInputElement, - setButtonElement, - setOptionsElement, - }), - [] - ) + }, [data, disabled, value, invalid, activeOption, comboboxState]) let [labelledby, LabelProvider] = useLabels() @@ -966,18 +455,18 @@ function ComboboxFn - - + + - - + + ) @@ -1047,8 +536,8 @@ function InputFn< // But today is not that day.. TType = Parameters[0]['value'], >(props: ComboboxInputProps, ref: Ref) { + let machine = useComboboxMachineContext('Combobox.Input') let data = useData('Combobox.Input') - let actions = useActions('Combobox.Input') let internalId = useId() let providedId = useProvidedId() @@ -1063,18 +552,30 @@ function InputFn< ...theirProps } = props + let [inputElement] = useSlice(machine, (state) => [state.inputElement]) + let internalInputRef = useRef(null) - let inputRef = useSyncRefs(internalInputRef, ref, useFloatingReference(), actions.setInputElement) - let ownerDocument = useOwnerDocument(data.inputElement) + let inputRef = useSyncRefs( + internalInputRef, + ref, + useFloatingReference(), + machine.actions.setInputElement + ) + let ownerDocument = useOwnerDocument(inputElement) + + let [comboboxState, isTyping] = useSlice(machine, (state) => [ + state.comboboxState, + state.isTyping, + ]) let d = useDisposables() let clear = useEvent(() => { - actions.onChange(null) - if (data.optionsElement) { - data.optionsElement.scrollTop = 0 + machine.actions.onChange(null) + if (machine.state.optionsElement) { + machine.state.optionsElement.scrollTop = 0 } - actions.goToOption(Focus.Nothing) + machine.actions.goToOption({ focus: Focus.Nothing }) }) // When a `displayValue` prop is given, we should use it to transform the current selected @@ -1114,7 +615,7 @@ function InputFn< ([currentDisplayValue, state], [oldCurrentDisplayValue, oldState]) => { // When the user is typing, we want to not touch the `input` at all. Especially when they are // using an IME, we don't want to mess with the input at all. - if (data.isTyping) return + if (machine.state.isTyping) return let input = internalInputRef.current if (!input) return @@ -1130,7 +631,7 @@ function InputFn< // the user is currently typing, because we don't want to mess with the cursor position while // typing. requestAnimationFrame(() => { - if (data.isTyping) return + if (machine.state.isTyping) return if (!input) return // Bail when the input is not the currently focused element. When it is not the focused @@ -1150,7 +651,7 @@ function InputFn< input.setSelectionRange(input.value.length, input.value.length) }) }, - [currentDisplayValue, data.comboboxState, ownerDocument, data.isTyping] + [currentDisplayValue, comboboxState, ownerDocument, isTyping] ) // Trick VoiceOver in behaving a little bit better. Manually "resetting" the input makes VoiceOver @@ -1164,7 +665,7 @@ function InputFn< if (newState === ComboboxState.Open && oldState === ComboboxState.Closed) { // When the user is typing, we want to not touch the `input` at all. Especially when they are // using an IME, we don't want to mess with the input at all. - if (data.isTyping) return + if (machine.state.isTyping) return let input = internalInputRef.current if (!input) return @@ -1185,7 +686,7 @@ function InputFn< } } }, - [data.comboboxState] + [comboboxState] ) let isComposing = useRef(false) @@ -1199,13 +700,13 @@ function InputFn< }) let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { - actions.setIsTyping(true) + machine.actions.setIsTyping(true) switch (event.key) { // Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12 case Keys.Enter: - if (data.comboboxState !== ComboboxState.Open) return + if (machine.state.comboboxState !== ComboboxState.Open) return // When the user is still in the middle of composing by using an IME, then we don't want to // submit this value and close the Combobox yet. Instead, we will fallback to the default @@ -1215,14 +716,14 @@ function InputFn< event.preventDefault() event.stopPropagation() - if (data.activeOptionIndex === null) { - actions.closeCombobox() + if (machine.selectors.activeOptionIndex(machine.state) === null) { + machine.actions.closeCombobox() return } - actions.selectActiveOption() + machine.actions.selectActiveOption() if (data.mode === ValueMode.Single) { - actions.closeCombobox() + machine.actions.closeCombobox() } break @@ -1230,19 +731,19 @@ function InputFn< event.preventDefault() event.stopPropagation() - return match(data.comboboxState, { - [ComboboxState.Open]: () => actions.goToOption(Focus.Next), - [ComboboxState.Closed]: () => actions.openCombobox(), + return match(machine.state.comboboxState, { + [ComboboxState.Open]: () => machine.actions.goToOption({ focus: Focus.Next }), + [ComboboxState.Closed]: () => machine.actions.openCombobox(), }) case Keys.ArrowUp: event.preventDefault() event.stopPropagation() - return match(data.comboboxState, { - [ComboboxState.Open]: () => actions.goToOption(Focus.Previous), + return match(machine.state.comboboxState, { + [ComboboxState.Open]: () => machine.actions.goToOption({ focus: Focus.Previous }), [ComboboxState.Closed]: () => { - flushSync(() => actions.openCombobox()) - if (!data.value) actions.goToOption(Focus.Last) + flushSync(() => machine.actions.openCombobox()) + if (!data.value) machine.actions.goToOption({ focus: Focus.Last }) }, }) @@ -1253,12 +754,12 @@ function InputFn< event.preventDefault() event.stopPropagation() - return actions.goToOption(Focus.First) + return machine.actions.goToOption({ focus: Focus.First }) case Keys.PageUp: event.preventDefault() event.stopPropagation() - return actions.goToOption(Focus.First) + return machine.actions.goToOption({ focus: Focus.First }) case Keys.End: if (event.shiftKey) { @@ -1267,17 +768,17 @@ function InputFn< event.preventDefault() event.stopPropagation() - return actions.goToOption(Focus.Last) + return machine.actions.goToOption({ focus: Focus.Last }) case Keys.PageDown: event.preventDefault() event.stopPropagation() - return actions.goToOption(Focus.Last) + return machine.actions.goToOption({ focus: Focus.Last }) case Keys.Escape: - if (data.comboboxState !== ComboboxState.Open) return + if (machine.state.comboboxState !== ComboboxState.Open) return event.preventDefault() - if (data.optionsElement && !data.optionsPropsRef.current.static) { + if (machine.state.optionsElement && !data.optionsPropsRef.current.static) { event.stopPropagation() } @@ -1292,14 +793,17 @@ function InputFn< } } - return actions.closeCombobox() + return machine.actions.closeCombobox() case Keys.Tab: - if (data.comboboxState !== ComboboxState.Open) return - if (data.mode === ValueMode.Single && data.activationTrigger !== ActivationTrigger.Focus) { - actions.selectActiveOption() + if (machine.state.comboboxState !== ComboboxState.Open) return + if ( + data.mode === ValueMode.Single && + machine.state.activationTrigger !== ActivationTrigger.Focus + ) { + machine.actions.selectActiveOption() } - actions.closeCombobox() + machine.actions.closeCombobox() break } }) @@ -1323,7 +827,7 @@ function InputFn< } // Open the combobox to show the results based on what the user has typed - actions.openCombobox() + machine.actions.openCombobox() }) let handleBlur = useEvent((event: ReactFocusEvent) => { @@ -1331,17 +835,17 @@ function InputFn< (event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget) // Focus is moved into the list, we don't want to close yet. - if (data.optionsElement?.contains(relatedTarget)) return + if (machine.state.optionsElement?.contains(relatedTarget)) return // Focus is moved to the button, we don't want to close yet. - if (data.buttonElement?.contains(relatedTarget)) return + if (machine.state.buttonElement?.contains(relatedTarget)) return // Focus is moved, but the combobox is not open. This can mean two things: // // 1. The combobox was never opened, so we don't have to do anything. // 2. The combobox was closed and focus was moved already. At that point we // don't need to try and select the active option. - if (data.comboboxState !== ComboboxState.Open) return + if (machine.state.comboboxState !== ComboboxState.Open) return event.preventDefault() @@ -1355,18 +859,18 @@ function InputFn< clear() } - return actions.closeCombobox() + return machine.actions.closeCombobox() }) let handleFocus = useEvent((event: ReactFocusEvent) => { let relatedTarget = (event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget) - if (data.buttonElement?.contains(relatedTarget)) return - if (data.optionsElement?.contains(relatedTarget)) return + if (machine.state.buttonElement?.contains(relatedTarget)) return + if (machine.state.optionsElement?.contains(relatedTarget)) return if (data.disabled) return if (!data.immediate) return - if (data.comboboxState === ComboboxState.Open) return + if (machine.state.comboboxState === ComboboxState.Open) return // In a scenario where you have this setup: // @@ -1391,13 +895,13 @@ function InputFn< // Which is why we wrap this in a `microTask` to make sure we are not in the // middle of rendering. d.microTask(() => { - flushSync(() => actions.openCombobox()) + flushSync(() => machine.actions.openCombobox()) // We need to make sure that tabbing through a form doesn't result in // incorrectly setting the value of the combobox. We will set the // activation trigger to `Focus`, and we will ignore selecting the active // option when the user tabs away. - actions.setActivationTrigger(ActivationTrigger.Focus) + machine.actions.setActivationTrigger(ActivationTrigger.Focus) }) }) @@ -1407,9 +911,34 @@ function InputFn< let { isFocused: focus, focusProps } = useFocusRing({ autoFocus }) let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) + let optionsElement = useSlice(machine, (state) => state.optionsElement) + let activedescendantId = useSlice( + machine, + useCallback((state) => { + let activeOptionIndex = machine.selectors.activeOptionIndex(state) + if (activeOptionIndex === null) { + return undefined + } + + if (!state.virtual) { + return state.options[activeOptionIndex]?.id + } + + return state.options.find((option) => { + return ( + !option.dataRef.current.disabled && + state.dataRef.current.compare( + option.dataRef.current.value, + state.virtual!.options[activeOptionIndex] + ) + ) + })?.id + }, []) + ) + let slot = useMemo(() => { return { - open: data.comboboxState === ComboboxState.Open, + open: comboboxState === ComboboxState.Open, disabled, invalid: data.invalid, hover, @@ -1424,21 +953,9 @@ function InputFn< id, role: 'combobox', type, - 'aria-controls': data.optionsElement?.id, - 'aria-expanded': data.comboboxState === ComboboxState.Open, - 'aria-activedescendant': - data.activeOptionIndex === null - ? undefined - : data.virtual - ? data.options.find( - (option) => - !option.dataRef.current.disabled && - data.compare( - option.dataRef.current.value, - data.virtual!.options[data.activeOptionIndex!] - ) - )?.id - : data.options[data.activeOptionIndex]?.id, + 'aria-controls': optionsElement?.id, + 'aria-expanded': comboboxState === ComboboxState.Open, + 'aria-activedescendant': activedescendantId, 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, 'aria-autocomplete': 'list', @@ -1504,9 +1021,10 @@ function ButtonFn( props: ComboboxButtonProps, ref: Ref ) { + let machine = useComboboxMachineContext('Combobox.Button') let data = useData('Combobox.Button') - let actions = useActions('Combobox.Button') - let buttonRef = useSyncRefs(ref, actions.setButtonElement) + let [localButtonElement, setLocalButtonElement] = useState(null) + let buttonRef = useSyncRefs(ref, setLocalButtonElement, machine.actions.setButtonElement) let internalId = useId() let { @@ -1516,7 +1034,8 @@ function ButtonFn( ...theirProps } = props - let refocusInput = useRefocusableInput(data.inputElement) + let inputElement = useSlice(machine, (state) => state.inputElement) + let refocusInput = useRefocusableInput(inputElement) let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { switch (event.key) { @@ -1526,8 +1045,8 @@ function ButtonFn( case Keys.Enter: event.preventDefault() event.stopPropagation() - if (data.comboboxState === ComboboxState.Closed) { - flushSync(() => actions.openCombobox()) + if (machine.state.comboboxState === ComboboxState.Closed) { + flushSync(() => machine.actions.openCombobox()) } refocusInput() return @@ -1535,9 +1054,10 @@ function ButtonFn( case Keys.ArrowDown: event.preventDefault() event.stopPropagation() - if (data.comboboxState === ComboboxState.Closed) { - flushSync(() => actions.openCombobox()) - if (!data.value) actions.goToOption(Focus.First) + if (machine.state.comboboxState === ComboboxState.Closed) { + flushSync(() => machine.actions.openCombobox()) + if (!machine.state.dataRef.current.value) + machine.actions.goToOption({ focus: Focus.First }) } refocusInput() return @@ -1545,20 +1065,22 @@ function ButtonFn( case Keys.ArrowUp: event.preventDefault() event.stopPropagation() - if (data.comboboxState === ComboboxState.Closed) { - flushSync(() => actions.openCombobox()) - if (!data.value) actions.goToOption(Focus.Last) + if (machine.state.comboboxState === ComboboxState.Closed) { + flushSync(() => machine.actions.openCombobox()) + if (!machine.state.dataRef.current.value) { + machine.actions.goToOption({ focus: Focus.Last }) + } } refocusInput() return case Keys.Escape: - if (data.comboboxState !== ComboboxState.Open) return + if (machine.state.comboboxState !== ComboboxState.Open) return event.preventDefault() - if (data.optionsElement && !data.optionsPropsRef.current.static) { + if (machine.state.optionsElement && !data.optionsPropsRef.current.static) { event.stopPropagation() } - flushSync(() => actions.closeCombobox()) + flushSync(() => machine.actions.closeCombobox()) refocusInput() return @@ -1580,10 +1102,10 @@ function ButtonFn( // to preserve the focus of the `ComboboxInput`, we need to also check // that the `left` mouse button was clicked. if (event.button === MouseButton.Left) { - if (data.comboboxState === ComboboxState.Open) { - actions.closeCombobox() + if (machine.state.comboboxState === ComboboxState.Open) { + machine.actions.closeCombobox() } else { - actions.openCombobox() + machine.actions.openCombobox() } } @@ -1597,26 +1119,31 @@ function ButtonFn( let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) let { pressed: active, pressProps } = useActivePress({ disabled }) + let [comboboxState, optionsElement] = useSlice(machine, (state) => [ + state.comboboxState, + state.optionsElement, + ]) + let slot = useMemo(() => { return { - open: data.comboboxState === ComboboxState.Open, - active: active || data.comboboxState === ComboboxState.Open, + open: comboboxState === ComboboxState.Open, + active: active || comboboxState === ComboboxState.Open, disabled, invalid: data.invalid, value: data.value, hover, focus, } satisfies ButtonRenderPropArg - }, [data, hover, focus, active, disabled]) + }, [data, hover, focus, active, disabled, comboboxState]) let ourProps = mergeProps( { ref: buttonRef, id, - type: useResolveButtonType(props, data.buttonElement), + type: useResolveButtonType(props, localButtonElement), tabIndex: -1, 'aria-haspopup': 'listbox', - 'aria-controls': data.optionsElement?.id, - 'aria-expanded': data.comboboxState === ComboboxState.Open, + 'aria-controls': optionsElement?.id, + 'aria-expanded': comboboxState === ComboboxState.Open, 'aria-labelledby': labelledBy, disabled: disabled || undefined, autoFocus, @@ -1677,8 +1204,8 @@ function OptionsFn( transition = false, ...theirProps } = props + let machine = useComboboxMachineContext('Combobox.Options') let data = useData('Combobox.Options') - let actions = useActions('Combobox.Options') let anchor = useResolvedAnchor(rawAnchor) // Always enable `portal` functionality, when `anchor` is enabled @@ -1698,11 +1225,21 @@ function OptionsFn( let optionsRef = useSyncRefs( ref, anchor ? floatingRef : null, - actions.setOptionsElement, + machine.actions.setOptionsElement, setLocalOptionsElement ) - let portalOwnerDocument = useOwnerDocument(data.buttonElement || data.inputElement) - let ownerDocument = useOwnerDocument(data.optionsElement) + let [comboboxState, inputElement, buttonElement, optionsElement, activationTrigger] = useSlice( + machine, + (state) => [ + state.comboboxState, + state.inputElement, + state.buttonElement, + state.optionsElement, + state.activationTrigger, + ] + ) + let portalOwnerDocument = useOwnerDocument(inputElement || buttonElement) + let ownerDocument = useOwnerDocument(optionsElement) let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( @@ -1710,26 +1247,22 @@ function OptionsFn( localOptionsElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open - : data.comboboxState === ComboboxState.Open + : comboboxState === ComboboxState.Open ) // Ensure we close the combobox as soon as the input becomes hidden - useOnDisappear(visible, data.inputElement, actions.closeCombobox) + useOnDisappear(visible, inputElement, machine.actions.closeCombobox) // Enable scroll locking when the combobox is visible, and `modal` is enabled - let scrollLockEnabled = data.__demoMode - ? false - : modal && data.comboboxState === ComboboxState.Open + let scrollLockEnabled = data.__demoMode ? false : modal && comboboxState === ComboboxState.Open useScrollLock(scrollLockEnabled, ownerDocument) // Mark other elements as inert when the combobox is visible, and `modal` is enabled - let inertOthersEnabled = data.__demoMode - ? false - : modal && data.comboboxState === ComboboxState.Open + let inertOthersEnabled = data.__demoMode ? false : modal && comboboxState === ComboboxState.Open useInertOthers(inertOthersEnabled, { allowed: useCallback( - () => [data.inputElement, data.buttonElement, data.optionsElement], - [data.inputElement, data.buttonElement, data.optionsElement] + () => [inputElement, buttonElement, optionsElement], + [inputElement, buttonElement, optionsElement] ), }) @@ -1740,8 +1273,8 @@ function OptionsFn( data.optionsPropsRef.current.hold = hold }, [data.optionsPropsRef, hold]) - useTreeWalker(data.comboboxState === ComboboxState.Open, { - container: data.optionsElement, + useTreeWalker(comboboxState === ComboboxState.Open, { + container: optionsElement, accept(node) { if (node.getAttribute('role') === 'option') return NodeFilter.FILTER_REJECT if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP @@ -1752,19 +1285,19 @@ function OptionsFn( }, }) - let labelledBy = useLabelledBy([data.buttonElement?.id]) + let labelledBy = useLabelledBy([buttonElement?.id]) let slot = useMemo(() => { return { - open: data.comboboxState === ComboboxState.Open, + open: comboboxState === ComboboxState.Open, option: undefined, } satisfies OptionsRenderPropArg - }, [data.comboboxState]) + }, [comboboxState]) // When the user scrolls **using the mouse** (so scroll event isn't appropriate) // we want to make sure that the current activation trigger is set to pointer. let handleWheel = useEvent(() => { - actions.setActivationTrigger(ActivationTrigger.Pointer) + machine.actions.setActivationTrigger(ActivationTrigger.Pointer) }) let handleMouseDown = useEvent((event: ReactMouseEvent) => { @@ -1781,7 +1314,7 @@ function OptionsFn( // When the user clicks in the ``, we want to make sure that we // set the activation trigger to `pointer` to prevent auto scrolling to the // active option while the user is scrolling. - actions.setActivationTrigger(ActivationTrigger.Pointer) + machine.actions.setActivationTrigger(ActivationTrigger.Pointer) }) let ourProps = mergeProps(anchor ? getFloatingPanelProps() : {}, { @@ -1793,10 +1326,10 @@ function OptionsFn( style: { ...theirProps.style, ...style, - '--input-width': useElementSize(data.inputElement, true).width, - '--button-width': useElementSize(data.buttonElement, true).width, + '--input-width': useElementSize(inputElement, true).width, + '--button-width': useElementSize(buttonElement, true).width, } as CSSProperties, - onWheel: data.activationTrigger === ActivationTrigger.Pointer ? undefined : handleWheel, + onWheel: activationTrigger === ActivationTrigger.Pointer ? undefined : handleWheel, onMouseDown: handleMouseDown, ...transitionDataAttributes(transitionData), }) @@ -1804,7 +1337,7 @@ function OptionsFn( // We should freeze when the combobox 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.comboboxState === ComboboxState.Closed + let shouldFreeze = visible && comboboxState === ComboboxState.Closed let options = useFrozenData(shouldFreeze, data.virtual?.options) @@ -1814,18 +1347,19 @@ function OptionsFn( let isSelected = useEvent((compareValue) => data.compare(frozenValue, compareValue)) // Map the children in a scrollable container when virtualization is enabled - if (data.virtual) { + let newDataContextValue = useMemo(() => { + if (!data.virtual) return data if (options === undefined) throw new Error('Missing `options` in virtual mode') + return options !== data.virtual.options + ? { ...data, virtual: { ...data.virtual, options } } + : data + }, [data, options, data.virtual?.options]) + + if (data.virtual) { Object.assign(theirProps, { children: ( - + {/* @ts-expect-error The `children` prop now is a callback function that receives `{option}` */} {theirProps.children} @@ -1835,11 +1369,13 @@ function OptionsFn( let render = useRender() + let newData = useMemo(() => { + return data.mode === ValueMode.Multi ? data : { ...data, isSelected } + }, [data, isSelected]) + return ( - + {render({ ourProps, theirProps: { @@ -1896,7 +1432,7 @@ function OptionFn< TType = Parameters[0]['value'], >(props: ComboboxOptionProps, ref: Ref) { let data = useData('Combobox.Option') - let actions = useActions('Combobox.Option') + let machine = useComboboxMachineContext('Combobox.Option') let internalId = useId() let { @@ -1907,13 +1443,21 @@ function OptionFn< ...theirProps } = props - let refocusInput = useRefocusableInput(data.inputElement) + let [inputElement] = useSlice(machine, (state) => [state.inputElement]) - let active = data.virtual - ? data.activeOptionIndex === data.calculateIndex(value) - : data.activeOptionIndex === null - ? false - : data.options[data.activeOptionIndex]?.id === id + let refocusInput = useRefocusableInput(inputElement) + + let active = useSlice( + machine, + useCallback((state) => { + let activeOptionIndex = machine.selectors.activeOptionIndex(state) + return state.virtual + ? activeOptionIndex === state.dataRef.current.calculateIndex(value) + : activeOptionIndex === null + ? false + : state.options[activeOptionIndex]?.id === id + }, []) + ) let selected = data.isSelected(value) let internalOptionRef = useRef(null) @@ -1933,10 +1477,12 @@ function OptionFn< ) let select = useEvent(() => { - actions.setIsTyping(false) - actions.onChange(value) + machine.actions.setIsTyping(false) + machine.actions.onChange(value) }) - useIsoMorphicEffect(() => actions.registerOption(id, bag), [bag, id]) + useIsoMorphicEffect(() => machine.actions.registerOption(id, bag), [bag, id]) + + let activeOptionIndex = useSlice(machine, machine.selectors.activeOptionIndex) let enableScrollIntoView = useRef(data.virtual || data.__demoMode ? false : true) useIsoMorphicEffect(() => { @@ -1949,18 +1495,18 @@ function OptionFn< useIsoMorphicEffect(() => { if (!enableScrollIntoView.current) return - if (data.comboboxState !== ComboboxState.Open) return + if (machine.state.comboboxState !== ComboboxState.Open) return if (!active) return - if (data.activationTrigger === ActivationTrigger.Pointer) return + if (machine.state.activationTrigger === ActivationTrigger.Pointer) return return disposables().requestAnimationFrame(() => { internalOptionRef.current?.scrollIntoView?.({ block: 'nearest' }) }) }, [ internalOptionRef, active, - data.comboboxState, - 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, + machine.state.comboboxState, + machine.state.activationTrigger, + /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ activeOptionIndex, ]) let handleMouseDown = useEvent((event: ReactMouseEvent) => { @@ -1997,16 +1543,16 @@ function OptionFn< } if (data.mode === ValueMode.Single) { - actions.closeCombobox() + machine.actions.closeCombobox() } }) let handleFocus = useEvent(() => { if (disabled) { - return actions.goToOption(Focus.Nothing) + return machine.actions.goToOption({ focus: Focus.Nothing }) } let idx = data.calculateIndex(value) - actions.goToOption(Focus.Specific, idx) + machine.actions.goToOption({ focus: Focus.Specific, idx }) }) let pointer = useTrackedPointer() @@ -2018,7 +1564,7 @@ function OptionFn< if (disabled) return if (active) return let idx = data.calculateIndex(value) - actions.goToOption(Focus.Specific, idx, ActivationTrigger.Pointer) + machine.actions.goToOption({ focus: Focus.Specific, idx }, ActivationTrigger.Pointer) }) let handleLeave = useEvent((evt) => { @@ -2026,7 +1572,7 @@ function OptionFn< if (disabled) return if (!active) return if (data.optionsPropsRef.current.hold) return - actions.goToOption(Focus.Nothing) + machine.actions.goToOption({ focus: Focus.Nothing }) }) let slot = useMemo(() => { From fdc4473d4ae03f7b61f6b7a888d78fbb803c9214 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 16 Apr 2025 13:16:11 +0200 Subject: [PATCH 04/12] use more efficient `useSlice` implementations --- .../components/combobox/combobox-machine.ts | 51 +++++++++++++ .../src/components/combobox/combobox.tsx | 76 +++---------------- 2 files changed, 60 insertions(+), 67 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox-machine.ts b/packages/@headlessui-react/src/components/combobox/combobox-machine.ts index 8a6d3beb1..5399aa5dc 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox-machine.ts +++ b/packages/@headlessui-react/src/components/combobox/combobox-machine.ts @@ -527,6 +527,27 @@ export class ComboboxMachine extends Machine, Actions> { } selectors = { + activeDescendantId: (state: State) => { + let activeOptionIndex = this.selectors.activeOptionIndex(state) + if (activeOptionIndex === null) { + return undefined + } + + if (!state.virtual) { + return state.options[activeOptionIndex]?.id + } + + return state.options.find((option) => { + return ( + !option.dataRef.current.disabled && + state.dataRef.current.compare( + option.dataRef.current.value, + state.virtual!.options[activeOptionIndex] + ) + ) + })?.id + }, + activeOptionIndex: (state: State) => { if ( state.defaultToFirstOption && @@ -554,9 +575,39 @@ export class ComboboxMachine extends Machine, Actions> { return state.activeOptionIndex }, + activeOption: (state: State) => { + let activeOptionIndex = this.selectors.activeOptionIndex(state) + return activeOptionIndex === null + ? null + : state.virtual + ? state.virtual.options[activeOptionIndex ?? 0] + : state.options[activeOptionIndex]?.dataRef.current.value ?? null + }, + isActive: (state: State, other: T) => { return this.selectors.activeOptionIndex(state) === state.dataRef.current.calculateIndex(other) }, + + isActiveOption: (state: State, value: T, id: string) => { + let activeOptionIndex = this.selectors.activeOptionIndex(state) + return state.virtual + ? activeOptionIndex === state.dataRef.current.calculateIndex(value) + : activeOptionIndex === null + ? false + : state.options[activeOptionIndex]?.id === id + }, + + shouldScrollIntoView: (state: State, value: T, id: string): boolean => { + if (state.virtual) return false + if (state.__demoMode) return false + if (state.comboboxState !== ComboboxState.Open) return false + if (state.activationTrigger === ActivationTrigger.Pointer) return false + + let active = this.selectors.isActiveOption(state, value, id) + if (!active) return false + + return true + }, } reduce(state: Readonly>, action: Actions): State { diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index d1aca2fb6..33eddbdc8 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -415,19 +415,7 @@ function ComboboxFn { - return activeOptionIndex === null - ? null - : state.virtual - ? state.virtual.options[activeOptionIndex ?? 0] - : (state.options[activeOptionIndex]?.dataRef.current.value as TValue) ?? null - }, - [activeOptionIndex] - ) - ) + let activeOption = useSlice(machine, machine.selectors.activeOption) let slot = useMemo(() => { return { @@ -912,29 +900,6 @@ function InputFn< let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) let optionsElement = useSlice(machine, (state) => state.optionsElement) - let activedescendantId = useSlice( - machine, - useCallback((state) => { - let activeOptionIndex = machine.selectors.activeOptionIndex(state) - if (activeOptionIndex === null) { - return undefined - } - - if (!state.virtual) { - return state.options[activeOptionIndex]?.id - } - - return state.options.find((option) => { - return ( - !option.dataRef.current.disabled && - state.dataRef.current.compare( - option.dataRef.current.value, - state.virtual!.options[activeOptionIndex] - ) - ) - })?.id - }, []) - ) let slot = useMemo(() => { return { @@ -955,7 +920,7 @@ function InputFn< type, 'aria-controls': optionsElement?.id, 'aria-expanded': comboboxState === ComboboxState.Open, - 'aria-activedescendant': activedescendantId, + 'aria-activedescendant': useSlice(machine, machine.selectors.activeDescendantId), 'aria-labelledby': labelledBy, 'aria-describedby': describedBy, 'aria-autocomplete': 'list', @@ -1449,16 +1414,8 @@ function OptionFn< let active = useSlice( machine, - useCallback((state) => { - let activeOptionIndex = machine.selectors.activeOptionIndex(state) - return state.virtual - ? activeOptionIndex === state.dataRef.current.calculateIndex(value) - : activeOptionIndex === null - ? false - : state.options[activeOptionIndex]?.id === id - }, []) + useCallback((state) => machine.selectors.isActiveOption(state, value, id), [value, id]) ) - let selected = data.isSelected(value) let internalOptionRef = useRef(null) @@ -1482,32 +1439,17 @@ function OptionFn< }) useIsoMorphicEffect(() => machine.actions.registerOption(id, bag), [bag, id]) - let activeOptionIndex = useSlice(machine, machine.selectors.activeOptionIndex) - - let enableScrollIntoView = useRef(data.virtual || data.__demoMode ? false : true) - useIsoMorphicEffect(() => { - if (data.virtual) return - if (data.__demoMode) return - return disposables().requestAnimationFrame(() => { - enableScrollIntoView.current = true - }) - }, [data.virtual, data.__demoMode]) + let shouldScrollIntoView = useSlice( + machine, + useCallback((state) => machine.selectors.shouldScrollIntoView(state, value, id), [value, id]) + ) useIsoMorphicEffect(() => { - if (!enableScrollIntoView.current) return - if (machine.state.comboboxState !== ComboboxState.Open) return - if (!active) return - if (machine.state.activationTrigger === ActivationTrigger.Pointer) return + if (!shouldScrollIntoView) return return disposables().requestAnimationFrame(() => { internalOptionRef.current?.scrollIntoView?.({ block: 'nearest' }) }) - }, [ - internalOptionRef, - active, - machine.state.comboboxState, - machine.state.activationTrigger, - /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ activeOptionIndex, - ]) + }, [shouldScrollIntoView, internalOptionRef]) let handleMouseDown = useEvent((event: ReactMouseEvent) => { // We use the `mousedown` event here since it fires before the focus event, From aa09fcd3c977c380265a12f1db276571a12d1f31 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 16 Apr 2025 23:59:54 +0200 Subject: [PATCH 05/12] reduce confusion Inlines the single `isActive` call, and renames the `isActiveOption` to `isActive` such that the API is similar to other components. Unfortunately, due to the virtualization layer, not all `ComboboxOption` IDs are known/available, so instead we also use the `value` itself to lookup the active index by comparing the `value` values. --- .../components/combobox/combobox-machine.ts | 25 ++++++++++--------- .../src/components/combobox/combobox.tsx | 2 +- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox-machine.ts b/packages/@headlessui-react/src/components/combobox/combobox-machine.ts index 5399aa5dc..13c5d1472 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox-machine.ts +++ b/packages/@headlessui-react/src/components/combobox/combobox-machine.ts @@ -469,7 +469,10 @@ export class ComboboxMachine extends Machine, Actions> { // the very first option seems like a fine default. We _could_ be smarter about this by going // to the previous / next item in list if we know the direction of the keyboard navigation, // but that might be too complex/confusing from an end users perspective. - if (this.selectors.isActive(this.state, dataRef.current.value)) { + if ( + this.state.activeOptionIndex === + this.state.dataRef.current.calculateIndex(dataRef.current.value) + ) { this.send({ type: ActionTypes.DefaultToFirstOption, value: true }) } @@ -584,17 +587,15 @@ export class ComboboxMachine extends Machine, Actions> { : state.options[activeOptionIndex]?.dataRef.current.value ?? null }, - isActive: (state: State, other: T) => { - return this.selectors.activeOptionIndex(state) === state.dataRef.current.calculateIndex(other) - }, - - isActiveOption: (state: State, value: T, id: string) => { + isActive: (state: State, value: T, id: string) => { let activeOptionIndex = this.selectors.activeOptionIndex(state) - return state.virtual - ? activeOptionIndex === state.dataRef.current.calculateIndex(value) - : activeOptionIndex === null - ? false - : state.options[activeOptionIndex]?.id === id + if (activeOptionIndex === null) return false + + if (state.virtual) { + return activeOptionIndex === state.dataRef.current.calculateIndex(value) + } + + return state.options[activeOptionIndex]?.id === id }, shouldScrollIntoView: (state: State, value: T, id: string): boolean => { @@ -603,7 +604,7 @@ export class ComboboxMachine extends Machine, Actions> { if (state.comboboxState !== ComboboxState.Open) return false if (state.activationTrigger === ActivationTrigger.Pointer) return false - let active = this.selectors.isActiveOption(state, value, id) + let active = this.selectors.isActive(state, value, id) if (!active) return false return true diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 33eddbdc8..b6bc997a5 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -1414,7 +1414,7 @@ function OptionFn< let active = useSlice( machine, - useCallback((state) => machine.selectors.isActiveOption(state, value, id), [value, id]) + useCallback((state) => machine.selectors.isActive(state, value, id), [value, id]) ) let selected = data.isSelected(value) let internalOptionRef = useRef(null) From 013a5e1568e51db8936d93fd7440965ac19cd9ec Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 17 Apr 2025 00:42:09 +0200 Subject: [PATCH 06/12] remove less confusing name --- .../src/components/combobox/combobox-machine.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox-machine.ts b/packages/@headlessui-react/src/components/combobox/combobox-machine.ts index 13c5d1472..aa6353c08 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox-machine.ts +++ b/packages/@headlessui-react/src/components/combobox/combobox-machine.ts @@ -559,19 +559,19 @@ export class ComboboxMachine extends Machine, Actions> { ) { if (state.virtual) { let { options, disabled } = state.virtual - let localActiveOptionIndex = options.findIndex((option) => !(disabled?.(option) ?? false)) + let activeOptionIndex = options.findIndex((option) => !(disabled?.(option) ?? false)) - if (localActiveOptionIndex !== -1) { - return localActiveOptionIndex + if (activeOptionIndex !== -1) { + return activeOptionIndex } } - let localActiveOptionIndex = state.options.findIndex((option) => { + let activeOptionIndex = state.options.findIndex((option) => { return !option.dataRef.current.disabled }) - if (localActiveOptionIndex !== -1) { - return localActiveOptionIndex + if (activeOptionIndex !== -1) { + return activeOptionIndex } } From 261899486523154c2a66794816a0054267efb925 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 17 Apr 2025 10:58:04 +0200 Subject: [PATCH 07/12] bump @tanstack/{react,vue}-virtual By bumping the versions to the same version, we can hoist the `@tanstack/virtual-core` because it will be the same version now. --- package-lock.json | 61 ++++++++++++++----------- packages/@headlessui-react/package.json | 2 +- packages/@headlessui-vue/package.json | 2 +- 3 files changed, 37 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1368bc4c8..2c6786212 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2638,12 +2638,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.11.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.1.tgz", - "integrity": "sha512-orn2QNe5tF6SqjucHJ6cKTKcRDe3GG7bcYqPNn72Yejj7noECdzgAyRfGt2pGDPemhYim3d1HIR/dgruCnLfUA==", + "version": "3.13.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.6.tgz", + "integrity": "sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.10.9" + "@tanstack/virtual-core": "3.13.6" }, "funding": { "type": "github", @@ -2654,22 +2654,30 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@tanstack/react-virtual/node_modules/@tanstack/virtual-core": { - "version": "3.10.9", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.10.9.tgz", - "integrity": "sha512-kBknKOKzmeR7lN+vSadaKWXaLS0SZZG+oqpQ/k80Q6g9REn6zRHS/ZYdrIzHnpHgy/eWs00SujveUN/GJT2qTw==", + "node_modules/@tanstack/virtual-core": { + "version": "3.13.6", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.6.tgz", + "integrity": "sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/virtual-core": { - "version": "3.0.0-beta.60", + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.6", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.6.tgz", + "integrity": "sha512-GYdZ3SJBQPzgxhuCE2fvpiH46qzHiVx5XzBSdtESgiqh4poj8UgckjGWYEhxaBbcVt1oLzh1m3Ql4TyH32TOzQ==", "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.6" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" } }, "node_modules/@testing-library/dom": { @@ -3141,6 +3149,7 @@ }, "node_modules/@vue/compiler-core": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.16.4", @@ -3151,6 +3160,7 @@ }, "node_modules/@vue/compiler-dom": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-core": "3.2.37", @@ -3218,6 +3228,7 @@ }, "node_modules/@vue/compiler-ssr": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.2.37", @@ -3238,6 +3249,7 @@ }, "node_modules/@vue/reactivity-transform": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.16.4", @@ -3330,6 +3342,7 @@ }, "node_modules/@vue/shared": { "version": "3.2.37", + "dev": true, "license": "MIT" }, "node_modules/abab": { @@ -7820,6 +7833,7 @@ }, "node_modules/magic-string": { "version": "0.25.9", + "dev": true, "license": "MIT", "dependencies": { "sourcemap-codec": "^1.4.8" @@ -10586,6 +10600,7 @@ }, "node_modules/source-map": { "version": "0.6.1", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10628,6 +10643,7 @@ }, "node_modules/sourcemap-codec": { "version": "1.4.8", + "dev": true, "license": "MIT" }, "node_modules/spdx-correct": { @@ -11997,7 +12013,7 @@ "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.17.1", "@react-aria/interactions": "^3.21.3", - "@tanstack/react-virtual": "^3.11.1", + "@tanstack/react-virtual": "^3.13.6", "use-sync-external-store": "^1.5.0" }, "devDependencies": { @@ -12056,7 +12072,7 @@ "version": "1.7.22", "license": "MIT", "dependencies": { - "@tanstack/vue-virtual": "3.0.0-beta.60" + "@tanstack/vue-virtual": "3.13.6" }, "devDependencies": { "@testing-library/vue": "8.0.0", @@ -12070,20 +12086,6 @@ "vue": "^3.2.0" } }, - "packages/@headlessui-vue/node_modules/@tanstack/vue-virtual": { - "version": "3.0.0-beta.60", - "license": "MIT", - "dependencies": { - "@tanstack/virtual-core": "3.0.0-beta.60" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "vue": "^2.7.0 || ^3.0.0" - } - }, "packages/@headlessui-vue/node_modules/@testing-library/dom": { "version": "9.3.3", "dev": true, @@ -12121,6 +12123,7 @@ }, "packages/@headlessui-vue/node_modules/@vue/compiler-sfc": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.16.4", @@ -12137,6 +12140,7 @@ }, "packages/@headlessui-vue/node_modules/@vue/reactivity": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@vue/shared": "3.2.37" @@ -12144,6 +12148,7 @@ }, "packages/@headlessui-vue/node_modules/@vue/runtime-core": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.2.37", @@ -12152,6 +12157,7 @@ }, "packages/@headlessui-vue/node_modules/@vue/runtime-dom": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@vue/runtime-core": "3.2.37", @@ -12161,6 +12167,7 @@ }, "packages/@headlessui-vue/node_modules/@vue/server-renderer": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-ssr": "3.2.37", @@ -12201,6 +12208,7 @@ }, "packages/@headlessui-vue/node_modules/csstype": { "version": "2.6.21", + "dev": true, "license": "MIT" }, "packages/@headlessui-vue/node_modules/pretty-format": { @@ -12218,6 +12226,7 @@ }, "packages/@headlessui-vue/node_modules/vue": { "version": "3.2.37", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.2.37", diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json index 9e02672ad..98d0c6924 100644 --- a/packages/@headlessui-react/package.json +++ b/packages/@headlessui-react/package.json @@ -59,7 +59,7 @@ "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.17.1", "@react-aria/interactions": "^3.21.3", - "@tanstack/react-virtual": "^3.11.1", + "@tanstack/react-virtual": "^3.13.6", "use-sync-external-store": "^1.5.0" } } diff --git a/packages/@headlessui-vue/package.json b/packages/@headlessui-vue/package.json index 0dd685ecc..14ce885ca 100644 --- a/packages/@headlessui-vue/package.json +++ b/packages/@headlessui-vue/package.json @@ -50,6 +50,6 @@ "vue": "3.2.37" }, "dependencies": { - "@tanstack/vue-virtual": "3.0.0-beta.60" + "@tanstack/vue-virtual": "3.13.6" } } From b9c7abca79c555c7d0a8c3afe329053a6b770e3a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 17 Apr 2025 11:02:53 +0200 Subject: [PATCH 08/12] add `patch-package` --- package-lock.json | 427 +++++++++++++++++++++++++++++++++++++++++++--- package.json | 4 +- 2 files changed, 405 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c6786212..b7da19686 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "headlessui", "version": "0.0.0", + "hasInstallScript": true, "license": "MIT", "workspaces": [ "packages/*", @@ -25,6 +26,7 @@ "jest": "26", "lint-staged": "^12.2.1", "npm-run-all": "^4.1.5", + "patch-package": "^8.0.0", "prettier": "^3.1.0", "prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-tailwindcss": "^0.6.11", @@ -3345,6 +3347,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/abab": { "version": "2.0.6", "dev": true, @@ -3590,6 +3599,16 @@ "dev": true, "license": "MIT" }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/atob": { "version": "2.1.2", "dev": true, @@ -3871,13 +3890,50 @@ } }, "node_modules/call-bind": { - "version": "1.0.5", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4418,16 +4474,21 @@ } }, "node_modules/define-data-property": { - "version": "1.1.1", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.1", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-properties": { @@ -4542,6 +4603,21 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "license": "MIT" @@ -4694,6 +4770,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-get-iterator": { "version": "1.1.3", "dev": true, @@ -4713,6 +4809,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-set-tostringtag": { "version": "2.0.2", "dev": true, @@ -5377,6 +5486,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flatpickr": { "version": "4.6.13", "license": "MIT" @@ -5471,6 +5590,32 @@ "tslib": "^2.1.0" } }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "dev": true, @@ -5536,14 +5681,25 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.2", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5557,6 +5713,20 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "dev": true, @@ -5654,11 +5824,13 @@ } }, "node_modules/gopd": { - "version": "1.0.1", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5691,11 +5863,13 @@ } }, "node_modules/has-property-descriptors": { - "version": "1.0.1", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.2" + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5713,7 +5887,9 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { @@ -5774,7 +5950,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6139,7 +6317,6 @@ "version": "2.2.1", "dev": true, "license": "MIT", - "optional": true, "bin": { "is-docker": "cli.js" }, @@ -6385,7 +6562,6 @@ "version": "2.2.0", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "is-docker": "^2.0.0" }, @@ -7302,6 +7478,26 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.2.1.tgz", + "integrity": "sha512-Lp6HbbBgosLmJbjx0pBLbgvx68FaFU1sdkmBuckmhhJ88kL13OA51CDtR2yJB50eCNMH9wRqtQNNiAqQH4YXnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json5": { "version": "2.2.3", "dev": true, @@ -7318,6 +7514,39 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/kind-of": { "version": "3.2.2", "dev": true, @@ -7329,6 +7558,16 @@ "node": ">=0.10.0" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/kleur": { "version": "3.0.3", "dev": true, @@ -7946,6 +8185,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/memorystream": { "version": "0.3.1", "dev": true, @@ -8614,6 +8863,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/opencollective-postinstall": { "version": "2.0.3", "dev": true, @@ -8622,6 +8888,16 @@ "opencollective-postinstall": "index.js" } }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/p-each-series": { "version": "2.2.0", "dev": true, @@ -8729,6 +9005,90 @@ "node": ">=0.10.0" } }, + "node_modules/patch-package": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.0.tgz", + "integrity": "sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^9.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "rimraf": "^2.6.3", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.0.33", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/patch-package/node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/patch-package/node_modules/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -10043,14 +10403,18 @@ "license": "ISC" }, "node_modules/set-function-length": { - "version": "1.1.1", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", "dependencies": { - "define-data-property": "^1.1.1", - "get-intrinsic": "^1.2.1", + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -11179,6 +11543,19 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "dev": true, diff --git a/package.json b/package.json index 9d75aa7d2..9c87b7d09 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "lint-types": "CI=true npm run lint-types --workspaces --if-present", "release-channel": "node ./scripts/release-channel.js", "release-notes": "node ./scripts/release-notes.js", - "package-path": "node ./scripts/package-path.js" + "package-path": "node ./scripts/package-path.js", + "postinstall": "patch-package" }, "husky": { "hooks": { @@ -76,6 +77,7 @@ "jest": "26", "lint-staged": "^12.2.1", "npm-run-all": "^4.1.5", + "patch-package": "^8.0.0", "prettier": "^3.1.0", "prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-tailwindcss": "^0.6.11", From 96f4da70b16d5cf259643191d85bfb6244793800 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 17 Apr 2025 11:03:00 +0200 Subject: [PATCH 09/12] patch `@tanstack/virtual-core` There is a timing issue if the Virtualizer is enabled and disabled in rapid succession. This happens in our combobox when you see a list of items and quickly search for something which in turn filters the list and the options will become empty. The `scrollToIndex` checks if an element's DOM node is available (via the measurementsCache) but it also has an internal `setTimeout` where the same code is used, but it's not re-checking the measurementsCache cache. The measurementsCache is also cleared the moment the `enabled` prop becomes false. This means that there will be a split second where the `measurementsCache[x]` will be `undefined` even though it was not undefined moments ago. In this scenario, it's safe to just not scroll (yet), but instead the `@tanstack/virtual-core` is throwing an error via the `notUndefined` function. We unfortunately can't catch the error because it happens in a `setTimeout`... Instead of throwing, this patch ignores the result if it becomes `undefined`. This also means that we don't scroll to the item, but there is no item to scroll to in the first place. --- patches/@tanstack+virtual-core+3.13.6.patch | 51 +++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 patches/@tanstack+virtual-core+3.13.6.patch diff --git a/patches/@tanstack+virtual-core+3.13.6.patch b/patches/@tanstack+virtual-core+3.13.6.patch new file mode 100644 index 000000000..0f1c0f62a --- /dev/null +++ b/patches/@tanstack+virtual-core+3.13.6.patch @@ -0,0 +1,51 @@ +diff --git a/node_modules/@tanstack/virtual-core/dist/cjs/index.cjs b/node_modules/@tanstack/virtual-core/dist/cjs/index.cjs +index dd4e77f..127987e 100644 +--- a/node_modules/@tanstack/virtual-core/dist/cjs/index.cjs ++++ b/node_modules/@tanstack/virtual-core/dist/cjs/index.cjs +@@ -665,9 +665,9 @@ class Virtualizer { + this.options.getItemKey(index) + ); + if (elementInDOM) { +- const [latestOffset] = utils.notUndefined( +- this.getOffsetForIndex(index, align) +- ); ++ const result = this.getOffsetForIndex(index, align) ++ if (!result) return ++ const [latestOffset] = result + if (!utils.approxEqual(latestOffset, this.getScrollOffset())) { + this.scrollToIndex(index, { align, behavior }); + } +diff --git a/node_modules/@tanstack/virtual-core/dist/esm/index.js b/node_modules/@tanstack/virtual-core/dist/esm/index.js +index 8da519d..8c78af8 100644 +--- a/node_modules/@tanstack/virtual-core/dist/esm/index.js ++++ b/node_modules/@tanstack/virtual-core/dist/esm/index.js +@@ -663,9 +663,9 @@ class Virtualizer { + this.options.getItemKey(index) + ); + if (elementInDOM) { +- const [latestOffset] = notUndefined( +- this.getOffsetForIndex(index, align) +- ); ++ const result = this.getOffsetForIndex(index, align) ++ if (!result) return ++ const [latestOffset] = result + if (!approxEqual(latestOffset, this.getScrollOffset())) { + this.scrollToIndex(index, { align, behavior }); + } +diff --git a/node_modules/@tanstack/virtual-core/src/index.ts b/node_modules/@tanstack/virtual-core/src/index.ts +index 3a0c446..4a9e792 100644 +--- a/node_modules/@tanstack/virtual-core/src/index.ts ++++ b/node_modules/@tanstack/virtual-core/src/index.ts +@@ -1003,9 +1003,9 @@ export class Virtualizer< + ) + + if (elementInDOM) { +- const [latestOffset] = notUndefined( +- this.getOffsetForIndex(index, align), +- ) ++ const result = this.getOffsetForIndex(index, align) ++ if (!result) return ++ const [latestOffset] = result + + if (!approxEqual(latestOffset, this.getScrollOffset())) { + this.scrollToIndex(index, { align, behavior }) From 2129d3c956307b03be9b683103a708c0ac3908f2 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 17 Apr 2025 11:07:20 +0200 Subject: [PATCH 10/12] remove workaround hacks This workaround worked by delaying the scroll and then the internal `enabled` state in `@tanstack/virtual-core` was up to date. The problem is that this causes flickering when holding `ArrowDown` for example. --- .../src/components/combobox/combobox.tsx | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index b6bc997a5..f192364a0 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -132,7 +132,6 @@ function VirtualProvider(props: { }) { let machine = useComboboxMachineContext('VirtualProvider') let data = useData('VirtualProvider') - let d = useDisposables() let { options } = data.virtual! let optionsElement = useSlice(machine, (state) => state.optionsElement) @@ -188,24 +187,15 @@ function VirtualProvider(props: { height: `${virtualizer.getTotalSize()}px`, }} ref={(el) => { - if (!el) { - d.dispose() - return - } + if (!el) return // Do not scroll when the mouse/pointer is being used - if (isPointerActivationTrigger) { - return - } + if (isPointerActivationTrigger) return // Scroll to the active index - // - // Workaround for: https://github.com/TanStack/virtual/issues/879 - d.nextFrame(() => { - if (activeOptionIndex !== null && options.length > activeOptionIndex) { - virtualizer.scrollToIndex(activeOptionIndex) - } - }) + if (activeOptionIndex !== null && options.length > activeOptionIndex) { + virtualizer.scrollToIndex(activeOptionIndex) + } }} > {items.map((item) => { From 6f9c1ab2a2bc9786ed3daf1c6f49fcffb6d64dc1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 17 Apr 2025 11:23:40 +0200 Subject: [PATCH 11/12] update changelog --- packages/@headlessui-react/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index da294743a..e3d45a0d7 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve `Listbox` component performance ([#3688](https://github.com/tailwindlabs/headlessui/pull/3688)) - Open `Menu` and `Listbox` on `mousedown` ([#3689](https://github.com/tailwindlabs/headlessui/pull/3689)) - Fix `Transition` component from incorrectly exposing the `Closing` state ([#3696](https://github.com/tailwindlabs/headlessui/pull/3696)) +- Improve `Combobox` component performance ([#3697](https://github.com/tailwindlabs/headlessui/pull/3697)) ## [2.2.1] - 2025-04-04 From 76d37905057ea7626625700e7fcc084a211d1a86 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 17 Apr 2025 15:02:10 +0200 Subject: [PATCH 12/12] disable Vue virtual tests --- .../src/components/combobox/combobox.test.ts | 11 ++++++++++- .../src/components/combobox/combobox.ts | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts index 433fdddda..17f1f193a 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.test.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.ts @@ -4710,7 +4710,16 @@ describe.each([{ virtual: true }, { virtual: false }])( } ) -describe.each([{ virtual: true }, { virtual: false }])('Mouse interactions %s', ({ virtual }) => { +// TODO: Re-enable virtual tests once we migrated from `npm` to `pnpm` and +// rolled back the `@tanstack/virtual-vue` version. +// +// We had to bump `@tanstack/virtual-vue` such that the `@tanstack/virtual-core` +// version was the same _and_ hoisted such that we could write a patch for it. +// Different versions meant that the `@tanstack/virtual-core` version was +// embedded in +// `node_modules/@tanstack/virtual-react/node_modules/@tanstack/virtual-core` +// which wasn't patchable via patch-package. Pnpm will solve this. +describe.each([{ virtual: false }, { virtual: false }])('Mouse interactions %s', ({ virtual }) => { let data = ['Option A', 'Option B', 'Option C'] let MyCombobox = defineComponent({ components: getDefaultComponents(), diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 27b2a1c1d..b5149f271 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -151,6 +151,8 @@ let VirtualProvider = defineComponent({ }) let virtualizer = useVirtualizer( + // @ts-expect-error TODO: Drop when using `pnpm` and `@tanstack/virtual-vue` + // has been rolled back to the older version. computed(() => { return { scrollPaddingStart: padding.value.start, @@ -173,6 +175,8 @@ let VirtualProvider = defineComponent({ baseKey.value += 1 }) + // @ts-expect-error TODO: Drop when using `pnpm` and `@tanstack/virtual-vue` + // has been rolled back to the older version. provide(VirtualContext, api.virtual.value ? virtualizer : null) return () => {