From b7197d8d995f6b95585fe5448655f6bfcc811e83 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 29 Mar 2024 15:33:50 +0100 Subject: [PATCH 1/4] add `useRefocusableInput` hook --- .../src/hooks/use-refocusable-input.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 packages/@headlessui-react/src/hooks/use-refocusable-input.ts 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..088d0b250d --- /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: string + selectionStart: number | null + selectionEnd: number | null + }>({ value: '', selectionStart: null, selectionEnd: 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 } + }) +} From 38767ec148064e7a238be779af150f4ec79caa27 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 29 Mar 2024 15:34:00 +0100 Subject: [PATCH 2/4] use the new `useRefocusableInput` hook --- .../src/components/combobox/combobox.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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) { From 20c2736244833c6e611a206e00b9ad2fbc1fe02d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 29 Mar 2024 15:39:57 +0100 Subject: [PATCH 3/4] update changelog --- packages/@headlessui-react/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From 9a58fdd28685fdc0c5ed1222c9939ccda040ce68 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 29 Mar 2024 16:11:34 +0100 Subject: [PATCH 4/4] infer types of the `ref` --- .../src/hooks/use-refocusable-input.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-refocusable-input.ts b/packages/@headlessui-react/src/hooks/use-refocusable-input.ts index 088d0b250d..a13b2438ca 100644 --- a/packages/@headlessui-react/src/hooks/use-refocusable-input.ts +++ b/packages/@headlessui-react/src/hooks/use-refocusable-input.ts @@ -10,11 +10,11 @@ import { useEventListener } from './use-event-listener' */ export function useRefocusableInput(ref: MutableRefObject) { // Track the cursor position and the value of the input - let info = useRef<{ - value: string - selectionStart: number | null - selectionEnd: number | null - }>({ value: '', selectionStart: null, selectionEnd: null }) + let info = useRef({ + value: '', + selectionStart: null as number | null, + selectionEnd: null as number | null, + }) useEventListener(ref.current, 'blur', (event) => { let target = event.target