fix(combobox): keep the dropdown open while dragging its scrollbar (#6307)

* fix(combobox): keep the dropdown open while dragging its scrollbar

* improvement(combobox): move pointer-press notes into TSDoc
This commit is contained in:
Waleed
2026-08-05 20:21:23 -07:00
committed by GitHub
parent 117fe3137b
commit 79bfff728b
@@ -226,6 +226,13 @@ const Combobox = memo(
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
const internalInputRef = useRef<HTMLInputElement>(null)
const inputRef = externalInputRef || internalInputRef
/**
* True while a pointer press that began inside the dropdown is still held.
* Grabbing the list's native scrollbar blurs the editable input and parks
* focus on `<body>` — which `handleBlur` would otherwise read as "focus
* left the combobox" and close the dropdown mid-drag.
*/
const pointerDownInsideRef = useRef(false)
const effectiveSelectedValue = selectedValue ?? value
@@ -236,6 +243,34 @@ const Combobox = memo(
}
}, [])
/**
* Releases the pointer-press window and restores focus to the editable input,
* which a scrollbar drag left on `<body>`. Bound to `window` so a release
* outside the popover still clears the flag; `pointercancel` is included
* because a touch scroll gesture ends there instead of `pointerup`.
*
* Focus is only restored when the press actually stole it — a press inside the
* popover parks it on `<body>` or the `tabIndex={-1}` content, but option
* mousedown is prevented, so it often never left the input or the search box.
*/
useEffect(() => {
if (!editable) return
const endPointerPress = () => {
if (!pointerDownInsideRef.current) return
pointerDownInsideRef.current = false
const active = document.activeElement
const isTextEntry =
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
if (!isTextEntry) inputRef.current?.focus({ preventScroll: true })
}
window.addEventListener('pointerup', endPointerPress)
window.addEventListener('pointercancel', endPointerPress)
return () => {
window.removeEventListener('pointerup', endPointerPress)
window.removeEventListener('pointercancel', endPointerPress)
}
}, [editable, inputRef])
// Flatten groups into options if groups are provided
const allOptions = useMemo(() => {
if (groups) {
@@ -326,7 +361,9 @@ const Combobox = memo(
}, [groups, searchable, searchQuery])
/**
* Handles selection of an option
* Handles selection of an option. In editable mode the input is blurred on
* purpose, so the pointer-press window is ended first — otherwise the `pointerup`
* that follows would hand focus back and reopen the dropdown.
*/
const handleSelect = useCallback(
(selectedValue: string, customOnSelect?: () => void, keepOpen?: boolean) => {
@@ -355,6 +392,7 @@ const Combobox = memo(
setHighlightedIndex(-1)
updateSearchQuery('')
if (editable && inputRef.current) {
pointerDownInsideRef.current = false
inputRef.current.blur()
}
}
@@ -392,6 +430,7 @@ const Combobox = memo(
if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current)
// Delay to allow dropdown clicks
blurTimeoutRef.current = setTimeout(() => {
if (pointerDownInsideRef.current) return
const activeElement = document.activeElement
// Check if focus is in the container, dropdown, or search input
const isInContainer = containerRef.current?.contains(activeElement)
@@ -681,6 +720,9 @@ const Combobox = memo(
setTimeout(() => searchInputRef.current?.focus(), 0)
}
}}
onPointerDownCapture={() => {
if (editable) pointerDownInsideRef.current = true
}}
onInteractOutside={(e) => {
// If the user clicks the anchor/trigger while the popover is open,
// prevent Radix from auto-closing on mousedown. Our own toggle handler