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 } + }) +}