Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion packages/emcn/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 =
Comment thread
waleedlatif1 marked this conversation as resolved.
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) {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -355,6 +392,7 @@ const Combobox = memo(
setHighlightedIndex(-1)
updateSearchQuery('')
if (editable && inputRef.current) {
pointerDownInsideRef.current = false
inputRef.current.blur()
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading