From e4a1ba7a15f6b62f594891b41382848f0cede573 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 5 Aug 2026 20:09:24 -0700 Subject: [PATCH 1/2] fix(combobox): keep the dropdown open while dragging its scrollbar --- .../emcn/src/components/combobox/combobox.tsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/emcn/src/components/combobox/combobox.tsx b/packages/emcn/src/components/combobox/combobox.tsx index 7cf58e0187c..b66a5897176 100644 --- a/packages/emcn/src/components/combobox/combobox.tsx +++ b/packages/emcn/src/components/combobox/combobox.tsx @@ -226,6 +226,13 @@ const Combobox = memo( const blurTimeoutRef = useRef>(null) const internalInputRef = useRef(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 `` — 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,33 @@ const Combobox = memo( } }, []) + /** + * Releases the pointer-press window and restores focus to the editable input, + * which a scrollbar drag left on ``. 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`. + */ + useEffect(() => { + if (!editable) return + const endPointerPress = () => { + if (!pointerDownInsideRef.current) return + pointerDownInsideRef.current = false + // Only restore focus if the press actually stole it: a press inside the + // popover parks focus on or the `tabIndex={-1}` content, but option + // mousedown is prevented, so it often never left the input or search box. + 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) { @@ -355,6 +389,8 @@ const Combobox = memo( setHighlightedIndex(-1) updateSearchQuery('') if (editable && inputRef.current) { + // The pointerup that follows must not hand focus back and reopen. + pointerDownInsideRef.current = false inputRef.current.blur() } } @@ -392,6 +428,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 +718,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 From f858231eaf73c8e0ae9d345fbbfdbfe2a9ca1e55 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 5 Aug 2026 20:16:37 -0700 Subject: [PATCH 2/2] improvement(combobox): move pointer-press notes into TSDoc --- packages/emcn/src/components/combobox/combobox.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/emcn/src/components/combobox/combobox.tsx b/packages/emcn/src/components/combobox/combobox.tsx index b66a5897176..41ccc3b7772 100644 --- a/packages/emcn/src/components/combobox/combobox.tsx +++ b/packages/emcn/src/components/combobox/combobox.tsx @@ -248,15 +248,16 @@ const Combobox = memo( * which a scrollbar drag left on ``. 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 `` 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 - // Only restore focus if the press actually stole it: a press inside the - // popover parks focus on or the `tabIndex={-1}` content, but option - // mousedown is prevented, so it often never left the input or search box. const active = document.activeElement const isTextEntry = active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement @@ -360,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) => { @@ -389,7 +392,6 @@ const Combobox = memo( setHighlightedIndex(-1) updateSearchQuery('') if (editable && inputRef.current) { - // The pointerup that follows must not hand focus back and reopen. pointerDownInsideRef.current = false inputRef.current.blur() }