diff --git a/packages/emcn/src/components/combobox/combobox.tsx b/packages/emcn/src/components/combobox/combobox.tsx index 7cf58e0187c..41ccc3b7772 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,34 @@ 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`. + * + * 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 + 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