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