@@ -15,7 +15,6 @@ import React, {
1515} from 'react' ;
1616import {
1717 useFilter ,
18- useFocusWithin ,
1918 useKeyboard ,
2019 useOverlay ,
2120 useOverlayPosition ,
@@ -130,10 +129,10 @@ export interface CubeComboBoxProps<T>
130129 placeholder ?: string ;
131130 /** Whether the input should have autofocus */
132131 autoFocus ?: boolean ;
133- /** Callback fired when the wrapper element receives focus */
134- onFocus ?: ( e : React . FocusEvent ) => void ;
135- /** Callback fired when the wrapper element loses focus */
136- onBlur ?: ( e : React . FocusEvent ) => void ;
132+ /** Callback fired when focus enters the component (input, trigger, or popover). Does not receive event object. */
133+ onFocus ?: ( ) => void ;
134+ /** Callback fired when focus leaves the component entirely. Does not receive event object. */
135+ onBlur ?: ( ) => void ;
137136
138137 /** Popover trigger behavior: 'focus', 'input', or 'manual'. Defaults to 'input' */
139138 popoverTrigger ?: PopoverTriggerAction ;
@@ -414,6 +413,85 @@ function useComboBoxFiltering({
414413 } ;
415414}
416415
416+ // ============================================================================
417+ // Hook: useCompositeFocus
418+ // ============================================================================
419+ interface UseCompositeFocusProps {
420+ wrapperRef : RefObject < HTMLElement > ;
421+ popoverRef : RefObject < HTMLElement > ;
422+ onFocus ?: ( ) => void ;
423+ onBlur ?: ( ) => void ;
424+ isDisabled ?: boolean ;
425+ }
426+
427+ interface UseCompositeFocusReturn {
428+ compositeFocusProps : {
429+ onFocus : ( e : React . FocusEvent ) => void ;
430+ onBlur : ( e : React . FocusEvent ) => void ;
431+ } ;
432+ }
433+
434+ function useCompositeFocus ( {
435+ wrapperRef,
436+ popoverRef,
437+ onFocus,
438+ onBlur,
439+ isDisabled,
440+ } : UseCompositeFocusProps ) : UseCompositeFocusReturn {
441+ const wasInsideRef = useRef ( false ) ;
442+ const rafRef = useRef < number | null > ( null ) ;
443+
444+ const checkFocus = useCallback ( ( ) => {
445+ if ( isDisabled ) return ;
446+
447+ const activeElement = document . activeElement ;
448+ const isInside =
449+ ( wrapperRef . current ?. contains ( activeElement ) ?? false ) ||
450+ ( popoverRef . current ?. contains ( activeElement ) ?? false ) ;
451+
452+ if ( isInside !== wasInsideRef . current ) {
453+ wasInsideRef . current = isInside ;
454+ if ( isInside ) {
455+ onFocus ?.( ) ;
456+ } else {
457+ onBlur ?.( ) ;
458+ }
459+ }
460+ } , [ wrapperRef , popoverRef , onFocus , onBlur , isDisabled ] ) ;
461+
462+ const handleFocusOrBlur = useCallback (
463+ ( e : React . FocusEvent ) => {
464+ // Cancel any pending check
465+ if ( rafRef . current !== null ) {
466+ cancelAnimationFrame ( rafRef . current ) ;
467+ }
468+
469+ // Schedule focus check for next frame
470+ rafRef . current = requestAnimationFrame ( ( ) => {
471+ rafRef . current = null ;
472+ checkFocus ( ) ;
473+ } ) ;
474+ } ,
475+ [ checkFocus ] ,
476+ ) ;
477+
478+ // Cleanup on unmount
479+ useEffect ( ( ) => {
480+ return ( ) => {
481+ if ( rafRef . current !== null ) {
482+ cancelAnimationFrame ( rafRef . current ) ;
483+ }
484+ } ;
485+ } , [ ] ) ;
486+
487+ return {
488+ compositeFocusProps : {
489+ onFocus : handleFocusOrBlur ,
490+ onBlur : handleFocusOrBlur ,
491+ } ,
492+ } ;
493+ }
494+
417495// ============================================================================
418496// Hook: useComboBoxKeyboard
419497// ============================================================================
@@ -718,6 +796,10 @@ interface ComboBoxOverlayProps {
718796 onClose : ( ) => void ;
719797 label ?: ReactNode ;
720798 ariaLabel ?: string ;
799+ compositeFocusProps : {
800+ onFocus : ( e : React . FocusEvent ) => void ;
801+ onBlur : ( e : React . FocusEvent ) => void ;
802+ } ;
721803}
722804
723805function ComboBoxOverlay ( {
@@ -745,6 +827,7 @@ function ComboBoxOverlay({
745827 onClose,
746828 label,
747829 ariaLabel,
830+ compositeFocusProps,
748831} : ComboBoxOverlayProps ) {
749832 // Overlay positioning
750833 const {
@@ -796,7 +879,11 @@ function ComboBoxOverlay({
796879 < DisplayTransition exposeUnmounted isShown = { isOpen } >
797880 { ( { phase, isShown, ref : transitionRef } ) => (
798881 < ComboBoxOverlayElement
799- { ...mergeProps ( overlayPositionProps , overlayBehaviorProps ) }
882+ { ...mergeProps (
883+ overlayPositionProps ,
884+ overlayBehaviorProps ,
885+ compositeFocusProps ,
886+ ) }
800887 ref = { ( value ) => {
801888 transitionRef ( value as HTMLElement | null ) ;
802889 ( popoverRef as any ) . current = value ;
@@ -1094,11 +1181,62 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
10941181
10951182 const { isFocused, focusProps } = useFocus ( { isDisabled } ) ;
10961183
1097- // Wrapper-level focus/blur handlers
1098- const { focusWithinProps } = useFocusWithin ( {
1184+ // Composite blur handler - fires when focus leaves the entire component
1185+ const handleCompositeBlur = useEvent ( ( ) => {
1186+ // Always disable filter on blur
1187+ setIsFilterActive ( false ) ;
1188+
1189+ // In allowsCustomValue mode with shouldCommitOnBlur, commit the input value
1190+ if (
1191+ allowsCustomValue &&
1192+ shouldCommitOnBlur &&
1193+ effectiveInputValue &&
1194+ effectiveSelectedKey == null
1195+ ) {
1196+ externalOnSelectionChange ?.( effectiveInputValue as string ) ;
1197+ if ( ! isControlledKey ) {
1198+ setInternalSelectedKey ( effectiveInputValue as Key ) ;
1199+ }
1200+ // Call user's onBlur callback
1201+ onBlur ?.( ) ;
1202+ return ;
1203+ }
1204+
1205+ // In clearOnBlur mode (only for non-custom-value mode), clear selection and input
1206+ if ( clearOnBlur && ! allowsCustomValue ) {
1207+ externalOnSelectionChange ?.( null ) ;
1208+ if ( ! isControlledKey ) {
1209+ setInternalSelectedKey ( null ) ;
1210+ }
1211+ if ( ! isControlledInput ) {
1212+ setInternalInputValue ( '' ) ;
1213+ }
1214+ onInputChange ?.( '' ) ;
1215+ // Call user's onBlur callback
1216+ onBlur ?.( ) ;
1217+ return ;
1218+ }
1219+
1220+ // Reset input to show current selection (or empty if none)
1221+ const nextValue =
1222+ effectiveSelectedKey != null ? getItemLabel ( effectiveSelectedKey ) : '' ;
1223+
1224+ if ( ! isControlledInput ) {
1225+ setInternalInputValue ( nextValue ) ;
1226+ }
1227+ onInputChange ?.( nextValue ) ;
1228+
1229+ // Call user's onBlur callback
1230+ onBlur ?.( ) ;
1231+ } ) ;
1232+
1233+ // Composite focus hook - handles focus tracking across wrapper and portaled popover
1234+ const { compositeFocusProps } = useCompositeFocus ( {
1235+ wrapperRef,
1236+ popoverRef,
1237+ onFocus,
1238+ onBlur : handleCompositeBlur ,
10991239 isDisabled,
1100- onFocusWithin : onFocus ,
1101- onBlurWithin : onBlur ,
11021240 } ) ;
11031241
11041242 let isInvalid = validationState === 'invalid' ;
@@ -1240,59 +1378,9 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
12401378 }
12411379 } ) ;
12421380
1243- // Input blur handler
1381+ // Input blur handler - just handles internal focus props
12441382 const handleInputBlur = useEvent ( ( e : React . FocusEvent < HTMLInputElement > ) => {
12451383 focusProps . onBlur ?.( e as any ) ;
1246-
1247- const relatedTarget = e . relatedTarget as Node | null ;
1248-
1249- // Don't do anything if focus is moving within the combobox
1250- if (
1251- relatedTarget &&
1252- ( wrapperRef . current ?. contains ( relatedTarget ) ||
1253- popoverRef . current ?. contains ( relatedTarget ) )
1254- ) {
1255- return ;
1256- }
1257-
1258- // Always disable filter on blur
1259- setIsFilterActive ( false ) ;
1260-
1261- // In allowsCustomValue mode with shouldCommitOnBlur, commit the input value
1262- if (
1263- allowsCustomValue &&
1264- shouldCommitOnBlur &&
1265- effectiveInputValue &&
1266- effectiveSelectedKey == null
1267- ) {
1268- externalOnSelectionChange ?.( effectiveInputValue as string ) ;
1269- if ( ! isControlledKey ) {
1270- setInternalSelectedKey ( effectiveInputValue as Key ) ;
1271- }
1272- return ;
1273- }
1274-
1275- // In clearOnBlur mode (only for non-custom-value mode), clear selection and input
1276- if ( clearOnBlur && ! allowsCustomValue ) {
1277- externalOnSelectionChange ?.( null ) ;
1278- if ( ! isControlledKey ) {
1279- setInternalSelectedKey ( null ) ;
1280- }
1281- if ( ! isControlledInput ) {
1282- setInternalInputValue ( '' ) ;
1283- }
1284- onInputChange ?.( '' ) ;
1285- return ;
1286- }
1287-
1288- // Reset input to show current selection (or empty if none)
1289- const nextValue =
1290- effectiveSelectedKey != null ? getItemLabel ( effectiveSelectedKey ) : '' ;
1291-
1292- if ( ! isControlledInput ) {
1293- setInternalInputValue ( nextValue ) ;
1294- }
1295- onInputChange ?.( nextValue ) ;
12961384 } ) ;
12971385
12981386 // Clear button logic
@@ -1462,7 +1550,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
14621550 zIndex : isFocused ? 1 : 'initial' ,
14631551 } }
14641552 data-size = { size }
1465- { ...focusWithinProps }
1553+ { ...compositeFocusProps }
14661554 >
14671555 { prefix ? < div data-element = "Prefix" > { prefix } </ div > : null }
14681556 < ComboBoxInput
@@ -1558,6 +1646,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
15581646 listStateRef = { listStateRef }
15591647 label = { label }
15601648 ariaLabel = { ( props as any ) [ 'aria-label' ] }
1649+ compositeFocusProps = { compositeFocusProps }
15611650 onSelectionChange = { handleSelectionChange }
15621651 onClose = { ( ) => setIsPopoverOpen ( false ) }
15631652 >
0 commit comments