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:
@@ -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 }
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user