Skip to content

Commit e4a1ba7

Browse files
committed
fix(combobox): keep the dropdown open while dragging its scrollbar
1 parent 117fe31 commit e4a1ba7

1 file changed

Lines changed: 40 additions & 0 deletions

File tree

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

Lines changed: 40 additions & 0 deletions
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,33 @@ 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+
useEffect(() => {
253+
if (!editable) return
254+
const endPointerPress = () => {
255+
if (!pointerDownInsideRef.current) return
256+
pointerDownInsideRef.current = false
257+
// Only restore focus if the press actually stole it: a press inside the
258+
// popover parks focus on <body> or the `tabIndex={-1}` content, but option
259+
// mousedown is prevented, so it often never left the input or search box.
260+
const active = document.activeElement
261+
const isTextEntry =
262+
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
263+
if (!isTextEntry) inputRef.current?.focus({ preventScroll: true })
264+
}
265+
window.addEventListener('pointerup', endPointerPress)
266+
window.addEventListener('pointercancel', endPointerPress)
267+
return () => {
268+
window.removeEventListener('pointerup', endPointerPress)
269+
window.removeEventListener('pointercancel', endPointerPress)
270+
}
271+
}, [editable, inputRef])
272+
239273
// Flatten groups into options if groups are provided
240274
const allOptions = useMemo(() => {
241275
if (groups) {
@@ -355,6 +389,8 @@ const Combobox = memo(
355389
setHighlightedIndex(-1)
356390
updateSearchQuery('')
357391
if (editable && inputRef.current) {
392+
// The pointerup that follows must not hand focus back and reopen.
393+
pointerDownInsideRef.current = false
358394
inputRef.current.blur()
359395
}
360396
}
@@ -392,6 +428,7 @@ const Combobox = memo(
392428
if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current)
393429
// Delay to allow dropdown clicks
394430
blurTimeoutRef.current = setTimeout(() => {
431+
if (pointerDownInsideRef.current) return
395432
const activeElement = document.activeElement
396433
// Check if focus is in the container, dropdown, or search input
397434
const isInContainer = containerRef.current?.contains(activeElement)
@@ -681,6 +718,9 @@ const Combobox = memo(
681718
setTimeout(() => searchInputRef.current?.focus(), 0)
682719
}
683720
}}
721+
onPointerDownCapture={() => {
722+
if (editable) pointerDownInsideRef.current = true
723+
}}
684724
onInteractOutside={(e) => {
685725
// If the user clicks the anchor/trigger while the popover is open,
686726
// prevent Radix from auto-closing on mousedown. Our own toggle handler

0 commit comments

Comments
 (0)