@@ -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