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`
This commit is contained in:
Robin Malfait
2024-03-29 16:15:29 +01:00
committed by GitHub
parent d03fbb19f5
commit 4f89588239
3 changed files with 68 additions and 5 deletions
+1
View File
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Respect `selectedIndex` for controlled `<Tab/>` 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
@@ -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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
} = props
let d = useDisposables()
let refocusInput = useRefocusableInput(data.inputRef)
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLUListElement>) => {
switch (event.key) {
// 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>(
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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}
})
}
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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
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) {
@@ -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<HTMLInputElement | null>) {
// 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 }
})
}