Files
headlessui/packages/@headlessui-react/src/hooks/use-refocusable-input.ts
T
Robin Malfait 4ed69f640c Keep focus inside of the <ComboboxInput /> component (#3073)
* bail the refocus if focus is already on the correct element

* use `mousedown` instead of `click` event

The `mousedown` event happens before the `focus` event. When we
`e.preventDefault()` in this listener, the `focus` event will not fire.

This also means that the focus is not lost on the actual `input`
component which in turn means that we can maintain the selection /
cursor position inside the `input`.

We still use the `refocusInput()` as a fallback in case something else
goes wrong.

* add comments to describe _why_ we use `mousedown`

* ensure we handle mouse buttons correctly

* ensure we handle `Enter` and `Space` explicitly

Now that we use the `mousedown` event instead of the `click` event, we
have to make sure that we handle the `enter` and `space` keys
explicitly.

This used to be covered by the `click` event, but not for the `mousedown` event.

* ensure we focus the first element when using `ArrowDown` on the `ComboboxButton`

We go to the last one on `ArrownUp`, but we forgot to do this on
`ArrowDown`.

* fix tiny typo

Not related to this PR, but noticed it and fixed it anyway.

* update changelog

* ensure we reset the `isTyping` flag

While we are typing, the flag can remain true. But once we stop typing,
the `nextFrame` handler will kick in and set it to `false` again.

It currently behaves as a debounce-like function such that the
`nextFrame` callbacks are cancelled once a new event is fired.

* ensure unique callbacks in the `_disposables` array

This allows us to keep re-adding dispose functions and only register the
callbacks once.

Ideally we can use a `Set`, but we also want to remove a single callback
if the callback is disposed on its own instead of disposing the whole
group. For this we do require an `idx` which is not available in a
`Set` unless you are looping over all disposable functions.

* Update packages/@headlessui-react/src/components/combobox/combobox.tsx

Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>

* Update packages/@headlessui-react/src/components/combobox/combobox.tsx

Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>

* update comments

* abstract confusing logic to a `useFrameDebounce()` hook

* use correct path import

* add some breathing room

---------

Co-authored-by: Jonathan Reinink <jonathan@reinink.ca>
2024-04-03 15:06:41 +02:00

62 lines
2.0 KiB
TypeScript

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 the input is already focused, we don't need to do anything
if (document.activeElement === input) return
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 }
})
}