Skip to content

Commit 79bfff7

Browse files
authored
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
1 parent 117fe31 commit 79bfff7

1 file changed

Lines changed: 43 additions & 1 deletion

File tree

packages/emcn/src/components/combobox/combobox.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,13 @@ const Combobox = memo(
226226
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
227227
const internalInputRef = useRef<HTMLInputElement>(null)
228228
const inputRef = externalInputRef || internalInputRef
229+
/**
230+
* True while a pointer press that began inside the dropdown is still held.
231+
* Grabbing the list's native scrollbar blurs the editable input and parks
232+
* focus on `<body>` — which `handleBlur` would otherwise read as "focus
233+
* left the combobox" and close the dropdown mid-drag.
234+
*/
235+
const pointerDownInsideRef = useRef(false)
229236

230237
const effectiveSelectedValue = selectedValue ?? value
231238

@@ -236,6 +243,34 @@ const Combobox = memo(
236243
}
237244
}, [])
238245

246+
/**
247+
* Releases the pointer-press window and restores focus to the editable input,
248+
* which a scrollbar drag left on `<body>`. Bound to `window` so a release
249+
* outside the popover still clears the flag; `pointercancel` is included
250+
* because a touch scroll gesture ends there instead of `pointerup`.
251+
*
252+
* Focus is only restored when the press actually stole it — a press inside the
253+
* popover parks it on `<body>` or the `tabIndex={-1}` content, but option
254+
* mousedown is prevented, so it often never left the input or the search box.
255+
*/
256+
useEffect(() => {
257+
if (!editable) return
258+
const endPointerPress = () => {
259+
if (!pointerDownInsideRef.current) return
260+
pointerDownInsideRef.current = false
261+
const active = document.activeElement
262+
const isTextEntry =
263+
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
264+
if (!isTextEntry) inputRef.current?.focus({ preventScroll: true })
265+
}
266+
window.addEventListener('pointerup', endPointerPress)
267+
window.addEventListener('pointercancel', endPointerPress)
268+
return () => {
269+
window.removeEventListener('pointerup', endPointerPress)
270+
window.removeEventListener('pointercancel', endPointerPress)
271+
}
272+
}, [editable, inputRef])
273+
239274
// Flatten groups into options if groups are provided
240275
const allOptions = useMemo(() => {
241276
if (groups) {
@@ -326,7 +361,9 @@ const Combobox = memo(
326361
}, [groups, searchable, searchQuery])
327362

328363
/**
329-
* Handles selection of an option
364+
* Handles selection of an option. In editable mode the input is blurred on
365+
* purpose, so the pointer-press window is ended first — otherwise the `pointerup`
366+
* that follows would hand focus back and reopen the dropdown.
330367
*/
331368
const handleSelect = useCallback(
332369
(selectedValue: string, customOnSelect?: () => void, keepOpen?: boolean) => {
@@ -355,6 +392,7 @@ const Combobox = memo(
355392
setHighlightedIndex(-1)
356393
updateSearchQuery('')
357394
if (editable && inputRef.current) {
395+
pointerDownInsideRef.current = false
358396
inputRef.current.blur()
359397
}
360398
}
@@ -392,6 +430,7 @@ const Combobox = memo(
392430
if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current)
393431
// Delay to allow dropdown clicks
394432
blurTimeoutRef.current = setTimeout(() => {
433+
if (pointerDownInsideRef.current) return
395434
const activeElement = document.activeElement
396435
// Check if focus is in the container, dropdown, or search input
397436
const isInContainer = containerRef.current?.contains(activeElement)
@@ -681,6 +720,9 @@ const Combobox = memo(
681720
setTimeout(() => searchInputRef.current?.focus(), 0)
682721
}
683722
}}
723+
onPointerDownCapture={() => {
724+
if (editable) pointerDownInsideRef.current = true
725+
}}
684726
onInteractOutside={(e) => {
685727
// If the user clicks the anchor/trigger while the popover is open,
686728
// prevent Radix from auto-closing on mousedown. Our own toggle handler

0 commit comments

Comments
 (0)