Skip to content

Commit 479853d

Browse files
authored
Ensure ComboboxInput does not sync while you are still typing (#3259)
* track `isTyping` in state While you are typing, we should not sync the value with the `<input>` because otherwise it would override your changes. The moment you close the Combobox (by selecting an option, clicking outside, pressing escape or tabbing away) we can mark the component as not typing anymore. Once you are not typing anymore, then we can re-sync the input with the given value. * remove unused `useFrameDebounce` hook * require `isTyping` boolean * update changelog
1 parent f5ac361 commit 479853d

File tree

4 files changed

+33
-41
lines changed

4 files changed

+33
-41
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
- Prevent focus on `<Checkbox />` when it is `disabled` ([#3251](https://github.com/tailwindlabs/headlessui/pull/3251))
1919
- Fix visual jitter in `Combobox` component when using native scrollbar ([#3190](https://github.com/tailwindlabs/headlessui/pull/3190))
2020
- Use `useId` instead of React internals (for React 19 compatibility) ([#3254](https://github.com/tailwindlabs/headlessui/pull/3254))
21+
- Ensure `ComboboxInput` does not sync with current value while typing ([#3259](https://github.com/tailwindlabs/headlessui/pull/3259))
2122

2223
## [2.0.4] - 2024-05-25
2324

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

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import { useDefaultValue } from '../../hooks/use-default-value'
2828
import { useDisposables } from '../../hooks/use-disposables'
2929
import { useElementSize } from '../../hooks/use-element-size'
3030
import { useEvent } from '../../hooks/use-event'
31-
import { useFrameDebounce } from '../../hooks/use-frame-debounce'
3231
import { useId } from '../../hooks/use-id'
3332
import { useInertOthers } from '../../hooks/use-inert-others'
3433
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
@@ -112,6 +111,8 @@ interface StateDefinition<T> {
112111
activeOptionIndex: number | null
113112
activationTrigger: ActivationTrigger
114113

114+
isTyping: boolean
115+
115116
__demoMode: boolean
116117
}
117118

@@ -120,6 +121,7 @@ enum ActionTypes {
120121
CloseCombobox,
121122

122123
GoToOption,
124+
SetTyping,
123125

124126
RegisterOption,
125127
UnregisterOption,
@@ -170,6 +172,7 @@ type Actions<T> =
170172
idx: number
171173
trigger?: ActivationTrigger
172174
}
175+
| { type: ActionTypes.SetTyping; isTyping: boolean }
173176
| {
174177
type: ActionTypes.GoToOption
175178
focus: Exclude<Focus, Focus.Specific>
@@ -202,6 +205,8 @@ let reducers: {
202205
activeOptionIndex: null,
203206
comboboxState: ComboboxState.Closed,
204207

208+
isTyping: false,
209+
205210
// Clear the last known activation trigger
206211
// This is because if a user interacts with the combobox using a mouse
207212
// resulting in it closing we might incorrectly handle the next interaction
@@ -230,6 +235,10 @@ let reducers: {
230235

231236
return { ...state, comboboxState: ComboboxState.Open, __demoMode: false }
232237
},
238+
[ActionTypes.SetTyping](state, action) {
239+
if (state.isTyping === action.isTyping) return state
240+
return { ...state, isTyping: action.isTyping }
241+
},
233242
[ActionTypes.GoToOption](state, action) {
234243
if (state.dataRef.current?.disabled) return state
235244
if (
@@ -268,6 +277,7 @@ let reducers: {
268277
...state,
269278
activeOptionIndex,
270279
activationTrigger,
280+
isTyping: false,
271281
__demoMode: false,
272282
}
273283
}
@@ -308,6 +318,7 @@ let reducers: {
308318
return {
309319
...state,
310320
...adjustedState,
321+
isTyping: false,
311322
activeOptionIndex,
312323
activationTrigger,
313324
__demoMode: false,
@@ -413,6 +424,7 @@ let ComboboxActionsContext = createContext<{
413424
registerOption(id: string, dataRef: ComboboxOptionDataRef<unknown>): () => void
414425
goToOption(focus: Focus.Specific, idx: number, trigger?: ActivationTrigger): void
415426
goToOption(focus: Focus, idx?: number, trigger?: ActivationTrigger): void
427+
setIsTyping(isTyping: boolean): void
416428
selectActiveOption(): void
417429
setActivationTrigger(trigger: ActivationTrigger): void
418430
onChange(value: unknown): void
@@ -662,6 +674,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
662674
let [state, dispatch] = useReducer(stateReducer, {
663675
dataRef: createRef(),
664676
comboboxState: __demoMode ? ComboboxState.Open : ComboboxState.Closed,
677+
isTyping: false,
665678
options: [],
666679
virtual: virtual
667680
? { options: virtual.options, disabled: virtual.disabled ?? (() => false) }
@@ -793,6 +806,8 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
793806
let selectActiveOption = useEvent(() => {
794807
if (data.activeOptionIndex === null) return
795808

809+
actions.setIsTyping(false)
810+
796811
if (data.virtual) {
797812
onChange(data.virtual.options[data.activeOptionIndex])
798813
} else {
@@ -816,6 +831,10 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
816831
onClose?.()
817832
})
818833

834+
let setIsTyping = useEvent((isTyping: boolean) => {
835+
dispatch({ type: ActionTypes.SetTyping, isTyping })
836+
})
837+
819838
let goToOption = useEvent((focus, idx, trigger) => {
820839
defaultToFirstOption.current = false
821840

@@ -875,6 +894,7 @@ function ComboboxFn<TValue, TTag extends ElementType = typeof DEFAULT_COMBOBOX_T
875894
onChange,
876895
registerOption,
877896
goToOption,
897+
setIsTyping,
878898
closeCombobox,
879899
openCombobox,
880900
setActivationTrigger,
@@ -995,8 +1015,6 @@ function InputFn<
9951015
let inputRef = useSyncRefs(data.inputRef, ref, useFloatingReference())
9961016
let ownerDocument = useOwnerDocument(data.inputRef)
9971017

998-
let isTyping = useRef(false)
999-
10001018
let d = useDisposables()
10011019

10021020
let clear = useEvent(() => {
@@ -1044,7 +1062,7 @@ function InputFn<
10441062
([currentDisplayValue, state], [oldCurrentDisplayValue, oldState]) => {
10451063
// When the user is typing, we want to not touch the `input` at all. Especially when they are
10461064
// using an IME, we don't want to mess with the input at all.
1047-
if (isTyping.current) return
1065+
if (data.isTyping) return
10481066

10491067
let input = data.inputRef.current
10501068
if (!input) return
@@ -1060,7 +1078,7 @@ function InputFn<
10601078
// the user is currently typing, because we don't want to mess with the cursor position while
10611079
// typing.
10621080
requestAnimationFrame(() => {
1063-
if (isTyping.current) return
1081+
if (data.isTyping) return
10641082
if (!input) return
10651083

10661084
// Bail when the input is not the currently focused element. When it is not the focused
@@ -1080,7 +1098,7 @@ function InputFn<
10801098
input.setSelectionRange(input.value.length, input.value.length)
10811099
})
10821100
},
1083-
[currentDisplayValue, data.comboboxState, ownerDocument]
1101+
[currentDisplayValue, data.comboboxState, ownerDocument, data.isTyping]
10841102
)
10851103

10861104
// Trick VoiceOver in behaving a little bit better. Manually "resetting" the input makes VoiceOver
@@ -1094,7 +1112,7 @@ function InputFn<
10941112
if (newState === ComboboxState.Open && oldState === ComboboxState.Closed) {
10951113
// When the user is typing, we want to not touch the `input` at all. Especially when they are
10961114
// using an IME, we don't want to mess with the input at all.
1097-
if (isTyping.current) return
1115+
if (data.isTyping) return
10981116

10991117
let input = data.inputRef.current
11001118
if (!input) return
@@ -1128,18 +1146,13 @@ function InputFn<
11281146
})
11291147
})
11301148

1131-
let debounce = useFrameDebounce()
11321149
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLInputElement>) => {
1133-
isTyping.current = true
1134-
debounce(() => {
1135-
isTyping.current = false
1136-
})
1150+
actions.setIsTyping(true)
11371151

11381152
switch (event.key) {
11391153
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12
11401154

11411155
case Keys.Enter:
1142-
isTyping.current = false
11431156
if (data.comboboxState !== ComboboxState.Open) return
11441157

11451158
// When the user is still in the middle of composing by using an IME, then we don't want to
@@ -1162,16 +1175,15 @@ function InputFn<
11621175
break
11631176

11641177
case Keys.ArrowDown:
1165-
isTyping.current = false
11661178
event.preventDefault()
11671179
event.stopPropagation()
1180+
11681181
return match(data.comboboxState, {
11691182
[ComboboxState.Open]: () => actions.goToOption(Focus.Next),
11701183
[ComboboxState.Closed]: () => actions.openCombobox(),
11711184
})
11721185

11731186
case Keys.ArrowUp:
1174-
isTyping.current = false
11751187
event.preventDefault()
11761188
event.stopPropagation()
11771189
return match(data.comboboxState, {
@@ -1191,13 +1203,11 @@ function InputFn<
11911203
break
11921204
}
11931205

1194-
isTyping.current = false
11951206
event.preventDefault()
11961207
event.stopPropagation()
11971208
return actions.goToOption(Focus.First)
11981209

11991210
case Keys.PageUp:
1200-
isTyping.current = false
12011211
event.preventDefault()
12021212
event.stopPropagation()
12031213
return actions.goToOption(Focus.First)
@@ -1207,19 +1217,16 @@ function InputFn<
12071217
break
12081218
}
12091219

1210-
isTyping.current = false
12111220
event.preventDefault()
12121221
event.stopPropagation()
12131222
return actions.goToOption(Focus.Last)
12141223

12151224
case Keys.PageDown:
1216-
isTyping.current = false
12171225
event.preventDefault()
12181226
event.stopPropagation()
12191227
return actions.goToOption(Focus.Last)
12201228

12211229
case Keys.Escape:
1222-
isTyping.current = false
12231230
if (data.comboboxState !== ComboboxState.Open) return
12241231
event.preventDefault()
12251232
if (data.optionsRef.current && !data.optionsPropsRef.current.static) {
@@ -1240,7 +1247,6 @@ function InputFn<
12401247
return actions.closeCombobox()
12411248

12421249
case Keys.Tab:
1243-
isTyping.current = false
12441250
if (data.comboboxState !== ComboboxState.Open) return
12451251
if (data.mode === ValueMode.Single && data.activationTrigger !== ActivationTrigger.Focus) {
12461252
actions.selectActiveOption()
@@ -1275,7 +1281,6 @@ function InputFn<
12751281
let handleBlur = useEvent((event: ReactFocusEvent) => {
12761282
let relatedTarget =
12771283
(event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget)
1278-
isTyping.current = false
12791284

12801285
// Focus is moved into the list, we don't want to close yet.
12811286
if (data.optionsRef.current?.contains(relatedTarget)) return
@@ -1819,7 +1824,10 @@ function OptionFn<
18191824
virtualizer ? virtualizer.measureElement : null
18201825
)
18211826

1822-
let select = useEvent(() => actions.onChange(value))
1827+
let select = useEvent(() => {
1828+
actions.setIsTyping(false)
1829+
actions.onChange(value)
1830+
})
18231831
useIsoMorphicEffect(() => actions.registerOption(id, bag), [bag, id])
18241832

18251833
let enableScrollIntoView = useRef(data.virtual || data.__demoMode ? false : true)

packages/@headlessui-react/src/hooks/use-frame-debounce.ts

Lines changed: 0 additions & 18 deletions
This file was deleted.

packages/@headlessui-vue/src/components/combobox/combobox.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,6 +1068,7 @@ export let ComboboxInput = defineComponent({
10681068
function handleKeyDown(event: KeyboardEvent) {
10691069
isTyping.value = true
10701070
debounce(() => {
1071+
if (isComposing.value) return
10711072
isTyping.value = false
10721073
})
10731074

0 commit comments

Comments
 (0)