Skip to content

Commit dc818ff

Browse files
committed
add input move check to Combobox
1 parent 56ef876 commit dc818ff

File tree

2 files changed

+66
-2
lines changed

2 files changed

+66
-2
lines changed

packages/@headlessui-react/src/components/combobox/combobox-machine.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { Machine } from '../../machine'
22
import { ActionTypes as StackActionTypes, stackMachines } from '../../machines/stack-machine'
33
import type { EnsureArray } from '../../types'
44
import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index'
5+
import {
6+
ElementPositionState,
7+
computeVisualPosition,
8+
detectMovement,
9+
} from '../../utils/element-movement'
510
import { sortByDomNode } from '../../utils/focus-management'
611
import { match } from '../../utils/match'
712

@@ -74,6 +79,9 @@ export interface State<T> {
7479
buttonElement: HTMLButtonElement | null
7580
optionsElement: HTMLElement | null
7681

82+
// Track input to determine if it moved
83+
inputPositionState: ElementPositionState
84+
7785
__demoMode: boolean
7886
}
7987

@@ -96,6 +104,8 @@ export enum ActionTypes {
96104
SetInputElement,
97105
SetButtonElement,
98106
SetOptionsElement,
107+
108+
MarkInputAsMoved,
99109
}
100110

101111
function adjustOrderedState<T>(
@@ -160,13 +170,17 @@ type Actions<T> =
160170
| { type: ActionTypes.SetInputElement; element: HTMLInputElement | null }
161171
| { type: ActionTypes.SetButtonElement; element: HTMLButtonElement | null }
162172
| { type: ActionTypes.SetOptionsElement; element: HTMLElement | null }
173+
| { type: ActionTypes.MarkInputAsMoved }
163174

164175
let reducers: {
165176
[P in ActionTypes]: <T>(state: State<T>, action: Extract<Actions<T>, { type: P }>) => State<T>
166177
} = {
167178
[ActionTypes.CloseCombobox](state) {
168179
if (state.dataRef.current?.disabled) return state
169180
if (state.comboboxState === ComboboxState.Closed) return state
181+
let inputPositionState = state.inputElement
182+
? ElementPositionState.Tracked(computeVisualPosition(state.inputElement))
183+
: state.inputPositionState
170184

171185
return {
172186
...state,
@@ -181,6 +195,8 @@ let reducers: {
181195
// for example, not scrolling to the active option in a virtual list
182196
activationTrigger: ActivationTrigger.Other,
183197

198+
inputPositionState,
199+
184200
__demoMode: false,
185201
}
186202
},
@@ -197,11 +213,17 @@ let reducers: {
197213
activeOptionIndex: idx,
198214
comboboxState: ComboboxState.Open,
199215
__demoMode: false,
216+
inputPositionState: ElementPositionState.Idle,
200217
}
201218
}
202219
}
203220

204-
return { ...state, comboboxState: ComboboxState.Open, __demoMode: false }
221+
return {
222+
...state,
223+
comboboxState: ComboboxState.Open,
224+
inputPositionState: ElementPositionState.Idle,
225+
__demoMode: false,
226+
}
205227
},
206228
[ActionTypes.SetTyping](state, action) {
207229
if (state.isTyping === action.isTyping) return state
@@ -404,6 +426,14 @@ let reducers: {
404426
if (state.optionsElement === action.element) return state
405427
return { ...state, optionsElement: action.element }
406428
},
429+
[ActionTypes.MarkInputAsMoved](state) {
430+
if (state.inputPositionState.kind !== 'Tracked') return state
431+
432+
return {
433+
...state,
434+
inputPositionState: ElementPositionState.Moved,
435+
}
436+
},
407437
}
408438

409439
export class ComboboxMachine<T> extends Machine<State<T>, Actions<T>> {
@@ -438,6 +468,7 @@ export class ComboboxMachine<T> extends Machine<State<T>, Actions<T>> {
438468
buttonElement: null,
439469
optionsElement: null,
440470
__demoMode,
471+
inputPositionState: ElementPositionState.Idle,
441472
})
442473
}
443474

@@ -464,6 +495,20 @@ export class ComboboxMachine<T> extends Machine<State<T>, Actions<T>> {
464495
this.on(ActionTypes.OpenCombobox, () => stackMachine.actions.push(id))
465496
this.on(ActionTypes.CloseCombobox, () => stackMachine.actions.pop(id))
466497
}
498+
499+
// Track whether the input moved or not
500+
this.disposables.group((d) => {
501+
this.on(ActionTypes.CloseCombobox, (state) => {
502+
if (!state.inputElement) return
503+
504+
d.dispose()
505+
d.add(
506+
detectMovement(state.inputElement, state.inputPositionState, () => {
507+
this.send({ type: ActionTypes.MarkInputAsMoved })
508+
})
509+
)
510+
})
511+
})
467512
}
468513

469514
actions = {
@@ -640,6 +685,10 @@ export class ComboboxMachine<T> extends Machine<State<T>, Actions<T>> {
640685

641686
return true
642687
},
688+
689+
didInputMove(state: State<T>) {
690+
return state.inputPositionState.kind === 'Moved'
691+
},
643692
}
644693

645694
reduce(state: Readonly<State<T>>, action: Actions<T>): State<T> {

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1261,6 +1261,21 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
12611261
),
12621262
})
12631263

1264+
// We keep track whether the input moved or not, we only check this when the
1265+
// combobox state becomes closed. If the input moved, then we want to cancel
1266+
// pending transitions to prevent that the attached `ComboboxOptions` is still
1267+
// transitioning while the input visually moved away.
1268+
//
1269+
// If we don't cancel these transitions then there will be a period where the
1270+
// `ComboboxOptions` is visible and moving around because it is trying to
1271+
// re-position itself based on the new position.
1272+
let didInputMove = useSlice(machine, machine.selectors.didInputMove)
1273+
1274+
// Now that we know that the input did move or not, we can either disable the
1275+
// panel and all of its transitions, or rely on the `visible` state to hide
1276+
// the panel whenever necessary.
1277+
let panelEnabled = didInputMove ? false : visible
1278+
12641279
useIsoMorphicEffect(() => {
12651280
data.optionsPropsRef.current.static = props.static ?? false
12661281
}, [data.optionsPropsRef, props.static])
@@ -1392,7 +1407,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
13921407
slot,
13931408
defaultTag: DEFAULT_OPTIONS_TAG,
13941409
features: OptionsRenderFeatures,
1395-
visible,
1410+
visible: panelEnabled,
13961411
name: 'Combobox.Options',
13971412
})}
13981413
</ComboboxDataContext.Provider>

0 commit comments

Comments
 (0)