Skip to content

Commit

Permalink
Fix cursor position when re-focusing the ComboboxInput component (#…
Browse files Browse the repository at this point in the history
…3065)

* add `useRefocusableInput` hook

* use the new `useRefocusableInput` hook

* update changelog

* infer types of the `ref`
  • Loading branch information
RobinMalfait authored Mar 29, 2024
1 parent d03fbb1 commit 4f89588
Show file tree
Hide file tree
Showing 3 changed files with 68 additions and 5 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 10 additions & 5 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
57 changes: 57 additions & 0 deletions packages/@headlessui-react/src/hooks/use-refocusable-input.ts
Original file line number Diff line number Diff line change
@@ -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 }
})
}

0 comments on commit 4f89588

Please sign in to comment.