Skip to content

Commit 4f89588

Browse files
authored
Fix cursor position when re-focusing the ComboboxInput component (#3065)
* add `useRefocusableInput` hook * use the new `useRefocusableInput` hook * update changelog * infer types of the `ref`
1 parent d03fbb1 commit 4f89588

File tree

3 files changed

+68
-5
lines changed

3 files changed

+68
-5
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- Respect `selectedIndex` for controlled `<Tab/>` components ([#3037](https://github.com/tailwindlabs/headlessui/pull/3037))
2222
- Prevent unnecessary execution of the `displayValue` callback in the `ComboboxInput` component ([#3048](https://github.com/tailwindlabs/headlessui/pull/3048))
2323
- Expose missing `data-disabled` and `data-focus` attributes on the `TabsPanel`, `MenuButton`, `PopoverButton` and `DisclosureButton` components ([#3061](https://github.com/tailwindlabs/headlessui/pull/3061))
24+
- Fix cursor position when re-focusing the `ComboboxInput` component ([#3065](https://github.com/tailwindlabs/headlessui/pull/3065))
2425

2526
### Changed
2627

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
3232
import { useLatestValue } from '../../hooks/use-latest-value'
3333
import { useOutsideClick } from '../../hooks/use-outside-click'
3434
import { useOwnerDocument } from '../../hooks/use-owner'
35+
import { useRefocusableInput } from '../../hooks/use-refocusable-input'
3536
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
3637
import { useSyncRefs } from '../../hooks/use-sync-refs'
3738
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
@@ -1381,6 +1382,8 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
13811382
} = props
13821383
let d = useDisposables()
13831384

1385+
let refocusInput = useRefocusableInput(data.inputRef)
1386+
13841387
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLUListElement>) => {
13851388
switch (event.key) {
13861389
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12
@@ -1392,7 +1395,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
13921395
actions.openCombobox()
13931396
}
13941397

1395-
return d.nextFrame(() => data.inputRef.current?.focus({ preventScroll: true }))
1398+
return d.nextFrame(() => refocusInput())
13961399

13971400
case Keys.ArrowUp:
13981401
event.preventDefault()
@@ -1405,7 +1408,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
14051408
}
14061409
})
14071410
}
1408-
return d.nextFrame(() => data.inputRef.current?.focus({ preventScroll: true }))
1411+
return d.nextFrame(() => refocusInput())
14091412

14101413
case Keys.Escape:
14111414
if (data.comboboxState !== ComboboxState.Open) return
@@ -1414,7 +1417,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
14141417
event.stopPropagation()
14151418
}
14161419
actions.closeCombobox()
1417-
return d.nextFrame(() => data.inputRef.current?.focus({ preventScroll: true }))
1420+
return d.nextFrame(() => refocusInput())
14181421

14191422
default:
14201423
return
@@ -1430,7 +1433,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
14301433
actions.openCombobox()
14311434
}
14321435

1433-
d.nextFrame(() => data.inputRef.current?.focus({ preventScroll: true }))
1436+
d.nextFrame(() => refocusInput())
14341437
})
14351438

14361439
let labelledBy = useLabelledBy([id])
@@ -1629,6 +1632,8 @@ function OptionFn<
16291632
let data = useData('Combobox.Option')
16301633
let actions = useActions('Combobox.Option')
16311634

1635+
let refocusInput = useRefocusableInput(data.inputRef)
1636+
16321637
let active = data.virtual
16331638
? data.activeOptionIndex === data.calculateIndex(value)
16341639
: data.activeOptionIndex === null
@@ -1701,7 +1706,7 @@ function OptionFn<
17011706
// But right now this is still an experimental feature:
17021707
// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/virtualKeyboard
17031708
if (!isMobile()) {
1704-
requestAnimationFrame(() => data.inputRef.current?.focus({ preventScroll: true }))
1709+
requestAnimationFrame(() => refocusInput())
17051710
}
17061711

17071712
if (data.mode === ValueMode.Single) {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useRef, type MutableRefObject } from 'react'
2+
import { useEvent } from './use-event'
3+
import { useEventListener } from './use-event-listener'
4+
5+
/**
6+
* The `useRefocusableInput` hook exposes a function to re-focus the input element.
7+
*
8+
* This hook will also keep the cursor position into account to make sure the
9+
* cursor is placed at the correct position as-if we didn't loose focus at all.
10+
*/
11+
export function useRefocusableInput(ref: MutableRefObject<HTMLInputElement | null>) {
12+
// Track the cursor position and the value of the input
13+
let info = useRef({
14+
value: '',
15+
selectionStart: null as number | null,
16+
selectionEnd: null as number | null,
17+
})
18+
19+
useEventListener(ref.current, 'blur', (event) => {
20+
let target = event.target
21+
if (!(target instanceof HTMLInputElement)) return
22+
23+
info.current = {
24+
value: target.value,
25+
selectionStart: target.selectionStart,
26+
selectionEnd: target.selectionEnd,
27+
}
28+
})
29+
30+
return useEvent(() => {
31+
let input = ref.current
32+
if (!(input instanceof HTMLInputElement)) return
33+
if (!input.isConnected) return
34+
35+
// Focus the input
36+
input.focus({ preventScroll: true })
37+
38+
// Try to restore the cursor position
39+
//
40+
// If the value changed since we recorded the cursor position, then we can't
41+
// restore the cursor position and we'll just leave it at the end.
42+
if (input.value !== info.current.value) {
43+
input.setSelectionRange(input.value.length, input.value.length)
44+
}
45+
46+
// If the value is the same, we can restore to the previous cursor position.
47+
else {
48+
let { selectionStart, selectionEnd } = info.current
49+
if (selectionStart !== null && selectionEnd !== null) {
50+
input.setSelectionRange(selectionStart, selectionEnd)
51+
}
52+
}
53+
54+
// Reset the cursor position
55+
info.current = { value: '', selectionStart: null, selectionEnd: null }
56+
})
57+
}

0 commit comments

Comments
 (0)