diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md
index 9086dd5168..bd9dbba6e0 100644
--- a/packages/@headlessui-react/CHANGELOG.md
+++ b/packages/@headlessui-react/CHANGELOG.md
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Respect `selectedIndex` for controlled `` components ([#3037](https://github.com/tailwindlabs/headlessui/pull/3037))
- Prevent unnecessary execution of the `displayValue` callback in the `ComboboxInput` component ([#3048](https://github.com/tailwindlabs/headlessui/pull/3048))
- Expose missing `data-disabled` and `data-focus` attributes on the `TabsPanel`, `MenuButton`, `PopoverButton` and `DisclosureButton` components ([#3061](https://github.com/tailwindlabs/headlessui/pull/3061))
+- Fix cursor position when re-focusing the `ComboboxInput` component ([#3065](https://github.com/tailwindlabs/headlessui/pull/3065))
### Changed
diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx
index 4e5f0073c6..061cdbe58a 100644
--- a/packages/@headlessui-react/src/components/combobox/combobox.tsx
+++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx
@@ -32,6 +32,7 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useLatestValue } from '../../hooks/use-latest-value'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { useOwnerDocument } from '../../hooks/use-owner'
+import { useRefocusableInput } from '../../hooks/use-refocusable-input'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
@@ -1381,6 +1382,8 @@ function ButtonFn(
} = props
let d = useDisposables()
+ let refocusInput = useRefocusableInput(data.inputRef)
+
let handleKeyDown = useEvent((event: ReactKeyboardEvent) => {
switch (event.key) {
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12
@@ -1392,7 +1395,7 @@ function ButtonFn(
actions.openCombobox()
}
- return d.nextFrame(() => data.inputRef.current?.focus({ preventScroll: true }))
+ return d.nextFrame(() => refocusInput())
case Keys.ArrowUp:
event.preventDefault()
@@ -1405,7 +1408,7 @@ function ButtonFn(
}
})
}
- return d.nextFrame(() => data.inputRef.current?.focus({ preventScroll: true }))
+ return d.nextFrame(() => refocusInput())
case Keys.Escape:
if (data.comboboxState !== ComboboxState.Open) return
@@ -1414,7 +1417,7 @@ function ButtonFn(
event.stopPropagation()
}
actions.closeCombobox()
- return d.nextFrame(() => data.inputRef.current?.focus({ preventScroll: true }))
+ return d.nextFrame(() => refocusInput())
default:
return
@@ -1430,7 +1433,7 @@ function ButtonFn(
actions.openCombobox()
}
- d.nextFrame(() => data.inputRef.current?.focus({ preventScroll: true }))
+ d.nextFrame(() => refocusInput())
})
let labelledBy = useLabelledBy([id])
@@ -1629,6 +1632,8 @@ function OptionFn<
let data = useData('Combobox.Option')
let actions = useActions('Combobox.Option')
+ let refocusInput = useRefocusableInput(data.inputRef)
+
let active = data.virtual
? data.activeOptionIndex === data.calculateIndex(value)
: data.activeOptionIndex === null
@@ -1701,7 +1706,7 @@ function OptionFn<
// But right now this is still an experimental feature:
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/virtualKeyboard
if (!isMobile()) {
- requestAnimationFrame(() => data.inputRef.current?.focus({ preventScroll: true }))
+ requestAnimationFrame(() => refocusInput())
}
if (data.mode === ValueMode.Single) {
diff --git a/packages/@headlessui-react/src/hooks/use-refocusable-input.ts b/packages/@headlessui-react/src/hooks/use-refocusable-input.ts
new file mode 100644
index 0000000000..a13b2438ca
--- /dev/null
+++ b/packages/@headlessui-react/src/hooks/use-refocusable-input.ts
@@ -0,0 +1,57 @@
+import { useRef, type MutableRefObject } from 'react'
+import { useEvent } from './use-event'
+import { useEventListener } from './use-event-listener'
+
+/**
+ * The `useRefocusableInput` hook exposes a function to re-focus the input element.
+ *
+ * This hook will also keep the cursor position into account to make sure the
+ * cursor is placed at the correct position as-if we didn't loose focus at all.
+ */
+export function useRefocusableInput(ref: MutableRefObject) {
+ // Track the cursor position and the value of the input
+ let info = useRef({
+ value: '',
+ selectionStart: null as number | null,
+ selectionEnd: null as number | null,
+ })
+
+ useEventListener(ref.current, 'blur', (event) => {
+ let target = event.target
+ if (!(target instanceof HTMLInputElement)) return
+
+ info.current = {
+ value: target.value,
+ selectionStart: target.selectionStart,
+ selectionEnd: target.selectionEnd,
+ }
+ })
+
+ return useEvent(() => {
+ let input = ref.current
+ if (!(input instanceof HTMLInputElement)) return
+ if (!input.isConnected) return
+
+ // Focus the input
+ input.focus({ preventScroll: true })
+
+ // Try to restore the cursor position
+ //
+ // If the value changed since we recorded the cursor position, then we can't
+ // restore the cursor position and we'll just leave it at the end.
+ if (input.value !== info.current.value) {
+ input.setSelectionRange(input.value.length, input.value.length)
+ }
+
+ // If the value is the same, we can restore to the previous cursor position.
+ else {
+ let { selectionStart, selectionEnd } = info.current
+ if (selectionStart !== null && selectionEnd !== null) {
+ input.setSelectionRange(selectionStart, selectionEnd)
+ }
+ }
+
+ // Reset the cursor position
+ info.current = { value: '', selectionStart: null, selectionEnd: null }
+ })
+}