diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 25ae52a5a8..c115bd045f 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Prevent focus on `` when it is `disabled` ([#3251](https://github.com/tailwindlabs/headlessui/pull/3251))
- Fix visual jitter in `Combobox` component when using native scrollbar ([#3190](https://github.com/tailwindlabs/headlessui/pull/3190))
- Use `useId` instead of React internals (for React 19 compatibility) ([#3254](https://github.com/tailwindlabs/headlessui/pull/3254))
+- Ensure `ComboboxInput` does not sync with current value while typing ([#3259](https://github.com/tailwindlabs/headlessui/pull/3259))
## [2.0.4] - 2024-05-25
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx
index 638f2e8776..8482c8a353 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx
@@ -28,7 +28,6 @@ import { useDefaultValue } from '../../hooks/use-default-value'
import { useDisposables } from '../../hooks/use-disposables'
import { useElementSize } from '../../hooks/use-element-size'
import { useEvent } from '../../hooks/use-event'
-import { useFrameDebounce } from '../../hooks/use-frame-debounce'
import { useId } from '../../hooks/use-id'
import { useInertOthers } from '../../hooks/use-inert-others'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
@@ -112,6 +111,8 @@ interface StateDefinition {
activeOptionIndex: number | null
activationTrigger: ActivationTrigger
+ isTyping: boolean
+
__demoMode: boolean
}
@@ -120,6 +121,7 @@ enum ActionTypes {
CloseCombobox,
GoToOption,
+ SetTyping,
RegisterOption,
UnregisterOption,
@@ -170,6 +172,7 @@ type Actions =
idx: number
trigger?: ActivationTrigger
}
+ | { type: ActionTypes.SetTyping; isTyping: boolean }
| {
type: ActionTypes.GoToOption
focus: Exclude
@@ -202,6 +205,8 @@ let reducers: {
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
@@ -230,6 +235,10 @@ let reducers: {
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 (
@@ -268,6 +277,7 @@ let reducers: {
...state,
activeOptionIndex,
activationTrigger,
+ isTyping: false,
__demoMode: false,
}
}
@@ -308,6 +318,7 @@ let reducers: {
return {
...state,
...adjustedState,
+ isTyping: false,
activeOptionIndex,
activationTrigger,
__demoMode: false,
@@ -413,6 +424,7 @@ let ComboboxActionsContext = createContext<{
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
@@ -662,6 +674,7 @@ function ComboboxFn false) }
@@ -793,6 +806,8 @@ function ComboboxFn {
if (data.activeOptionIndex === null) return
+ actions.setIsTyping(false)
+
if (data.virtual) {
onChange(data.virtual.options[data.activeOptionIndex])
} else {
@@ -816,6 +831,10 @@ function ComboboxFn {
+ dispatch({ type: ActionTypes.SetTyping, isTyping })
+ })
+
let goToOption = useEvent((focus, idx, trigger) => {
defaultToFirstOption.current = false
@@ -875,6 +894,7 @@ function ComboboxFn {
@@ -1044,7 +1062,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 (isTyping.current) return
+ if (data.isTyping) return
let input = data.inputRef.current
if (!input) return
@@ -1060,7 +1078,7 @@ function InputFn<
// the user is currently typing, because we don't want to mess with the cursor position while
// typing.
requestAnimationFrame(() => {
- if (isTyping.current) return
+ if (data.isTyping) return
if (!input) return
// Bail when the input is not the currently focused element. When it is not the focused
@@ -1080,7 +1098,7 @@ function InputFn<
input.setSelectionRange(input.value.length, input.value.length)
})
},
- [currentDisplayValue, data.comboboxState, ownerDocument]
+ [currentDisplayValue, data.comboboxState, ownerDocument, data.isTyping]
)
// Trick VoiceOver in behaving a little bit better. Manually "resetting" the input makes VoiceOver
@@ -1094,7 +1112,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 (isTyping.current) return
+ if (data.isTyping) return
let input = data.inputRef.current
if (!input) return
@@ -1128,18 +1146,13 @@ function InputFn<
})
})
- let debounce = useFrameDebounce()
let handleKeyDown = useEvent((event: ReactKeyboardEvent) => {
- isTyping.current = true
- debounce(() => {
- isTyping.current = false
- })
+ actions.setIsTyping(true)
switch (event.key) {
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12
case Keys.Enter:
- isTyping.current = false
if (data.comboboxState !== ComboboxState.Open) return
// 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<
break
case Keys.ArrowDown:
- isTyping.current = false
event.preventDefault()
event.stopPropagation()
+
return match(data.comboboxState, {
[ComboboxState.Open]: () => actions.goToOption(Focus.Next),
[ComboboxState.Closed]: () => actions.openCombobox(),
})
case Keys.ArrowUp:
- isTyping.current = false
event.preventDefault()
event.stopPropagation()
return match(data.comboboxState, {
@@ -1191,13 +1203,11 @@ function InputFn<
break
}
- isTyping.current = false
event.preventDefault()
event.stopPropagation()
return actions.goToOption(Focus.First)
case Keys.PageUp:
- isTyping.current = false
event.preventDefault()
event.stopPropagation()
return actions.goToOption(Focus.First)
@@ -1207,19 +1217,16 @@ function InputFn<
break
}
- isTyping.current = false
event.preventDefault()
event.stopPropagation()
return actions.goToOption(Focus.Last)
case Keys.PageDown:
- isTyping.current = false
event.preventDefault()
event.stopPropagation()
return actions.goToOption(Focus.Last)
case Keys.Escape:
- isTyping.current = false
if (data.comboboxState !== ComboboxState.Open) return
event.preventDefault()
if (data.optionsRef.current && !data.optionsPropsRef.current.static) {
@@ -1240,7 +1247,6 @@ function InputFn<
return actions.closeCombobox()
case Keys.Tab:
- isTyping.current = false
if (data.comboboxState !== ComboboxState.Open) return
if (data.mode === ValueMode.Single && data.activationTrigger !== ActivationTrigger.Focus) {
actions.selectActiveOption()
@@ -1275,7 +1281,6 @@ function InputFn<
let handleBlur = useEvent((event: ReactFocusEvent) => {
let relatedTarget =
(event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget)
- isTyping.current = false
// Focus is moved into the list, we don't want to close yet.
if (data.optionsRef.current?.contains(relatedTarget)) return
@@ -1819,7 +1824,10 @@ function OptionFn<
virtualizer ? virtualizer.measureElement : null
)
- let select = useEvent(() => actions.onChange(value))
+ let select = useEvent(() => {
+ actions.setIsTyping(false)
+ actions.onChange(value)
+ })
useIsoMorphicEffect(() => actions.registerOption(id, bag), [bag, id])
let enableScrollIntoView = useRef(data.virtual || data.__demoMode ? false : true)
diff --git a/packages/@headlessui-react/src/hooks/use-frame-debounce.ts b/packages/@headlessui-react/src/hooks/use-frame-debounce.ts
deleted file mode 100644
index 94c085340a..0000000000
--- a/packages/@headlessui-react/src/hooks/use-frame-debounce.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { useDisposables } from './use-disposables'
-import { useEvent } from './use-event'
-
-/**
- * Schedule some task in the next frame.
- *
- * - If you call the returned function multiple times, only the last task will
- * be executed.
- * - If the component is unmounted, the task will be cancelled.
- */
-export function useFrameDebounce() {
- let d = useDisposables()
-
- return useEvent((cb: () => void) => {
- d.dispose()
- d.nextFrame(cb)
- })
-}
diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts
index f2d3ef6274..5bafc677ea 100644
--- a/packages/@headlessui-vue/src/components/combobox/combobox.ts
+++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts
@@ -1068,6 +1068,7 @@ export let ComboboxInput = defineComponent({
function handleKeyDown(event: KeyboardEvent) {
isTyping.value = true
debounce(() => {
+ if (isComposing.value) return
isTyping.value = false
})