@@ -375,16 +375,52 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
375
375
[ closeMenu , returnFocusToTrigger ]
376
376
) ;
377
377
378
- const handleMenuBlur = useCallback (
379
- ( event : MouseEvent ) => {
380
- const path = event . composedPath ( ) ;
378
+ /**
379
+ * 1. Determine if the next element receiving focus is focusable
380
+ * (event.relatedTarget is null when focus moves to non-focusable elements or body).
381
+ *
382
+ * 2. When an element loses focus (on blur), and focus moves to a non-focusable element
383
+ * like <body>, `event.relatedTarget` should be `null`. However, due to a bug in jsdom
384
+ * (prior to version 24.1.2), `relatedTarget` is incorrectly set to the `Document` node
385
+ * (`nodeName === '#document'`).
386
+ *
387
+ * Currently, `jest-environment-jsdom` (v29.7.0) uses [email protected] , which still has this issue.
388
+ * Until Jest updates its jsdom dependency, this workaround ensures accurate
389
+ * testing of focus behavior.
390
+ *
391
+ * @see https://github.com/jsdom/jsdom/pull/3767
392
+ * @see https://github.com/jsdom/jsdom/releases/tag/24.1.2
393
+ * @see https://github.com/jestjs/jest/blob/v29.7.0/packages/jest-environment-jsdom/package.json
394
+ *
395
+ * 3. Skip focus-return to trigger in these scenarios:
396
+ * a. Focus is moving to another focusable element
397
+ * b. Menu is closed and focus would naturally go to body
398
+ */
399
+ const handleBlur = useCallback (
400
+ ( event : React . FocusEvent ) => {
401
+ const win = environment || window ;
381
402
382
- if ( ! path . includes ( menuRef . current ! ) && ! path . includes ( triggerRef . current ! ) ) {
383
- returnFocusToTrigger ( ) ;
384
- closeMenu ( StateChangeTypes . MenuBlur ) ;
385
- }
403
+ setTimeout ( ( ) => {
404
+ // Timeout is required to ensure blur is handled after focus
405
+ const activeElement = win . document . activeElement ;
406
+ const isMenuOrTriggerFocused =
407
+ menuRef . current ?. contains ( activeElement ) || triggerRef . current ?. contains ( activeElement ) ;
408
+
409
+ if ( ! isMenuOrTriggerFocused ) {
410
+ const nextElementIsFocusable =
411
+ ! ! event . relatedTarget /* [1] */ &&
412
+ event . relatedTarget ?. nodeName !== '#document' ; /* [2] */
413
+
414
+ const shouldSkipFocusReturn =
415
+ nextElementIsFocusable || ( ! controlledIsExpanded && ! nextElementIsFocusable ) ; /* [3] */
416
+
417
+ returnFocusToTrigger ( shouldSkipFocusReturn ) ;
418
+
419
+ closeMenu ( StateChangeTypes . MenuBlur ) ;
420
+ }
421
+ } ) ;
386
422
} ,
387
- [ closeMenu , menuRef , returnFocusToTrigger , triggerRef ]
423
+ [ closeMenu , controlledIsExpanded , environment , menuRef , returnFocusToTrigger , triggerRef ]
388
424
) ;
389
425
390
426
const handleMenuMouseLeave = useCallback ( ( ) => {
@@ -602,18 +638,15 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
602
638
const win = environment || window ;
603
639
604
640
if ( controlledIsExpanded ) {
605
- win . document . addEventListener ( 'click' , handleMenuBlur , true ) ;
606
641
win . document . addEventListener ( 'keydown' , handleMenuKeyDown , true ) ;
607
642
} else if ( ! controlledIsExpanded ) {
608
- win . document . removeEventListener ( 'click' , handleMenuBlur , true ) ;
609
643
win . document . removeEventListener ( 'keydown' , handleMenuKeyDown , true ) ;
610
644
}
611
645
612
646
return ( ) => {
613
- win . document . removeEventListener ( 'click' , handleMenuBlur , true ) ;
614
647
win . document . removeEventListener ( 'keydown' , handleMenuKeyDown , true ) ;
615
648
} ;
616
- } , [ controlledIsExpanded , handleMenuBlur , handleMenuKeyDown , environment ] ) ;
649
+ } , [ controlledIsExpanded , handleMenuKeyDown , environment ] ) ;
617
650
618
651
/**
619
652
* When the menu is opened, this effect sets focus on the current menu item using `focusedValue`
@@ -690,7 +723,15 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
690
723
*/
691
724
692
725
const getTriggerProps = useCallback < IUseMenuReturnValue [ 'getTriggerProps' ] > (
693
- ( { onClick, onKeyDown, type = 'button' , role = 'button' , disabled, ...other } = { } ) => ( {
726
+ ( {
727
+ onBlur,
728
+ onClick,
729
+ onKeyDown,
730
+ type = 'button' ,
731
+ role = 'button' ,
732
+ disabled,
733
+ ...other
734
+ } = { } ) => ( {
694
735
...other ,
695
736
'data-garden-container-id' : 'containers.menu.trigger' ,
696
737
'data-garden-container-version' : PACKAGE_VERSION ,
@@ -702,14 +743,22 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
702
743
tabIndex : disabled ? - 1 : 0 ,
703
744
type : type === null ? undefined : type ,
704
745
role : role === null ? undefined : role ,
705
- onKeyDown : composeEventHandlers ( onKeyDown , handleTriggerKeyDown ) ,
706
- onClick : composeEventHandlers ( onClick , handleTriggerClick )
746
+ onBlur : composeEventHandlers ( onBlur , handleBlur ) ,
747
+ onClick : composeEventHandlers ( onClick , handleTriggerClick ) ,
748
+ onKeyDown : composeEventHandlers ( onKeyDown , handleTriggerKeyDown )
707
749
} ) ,
708
- [ triggerRef , controlledIsExpanded , handleTriggerClick , handleTriggerKeyDown , triggerId ]
750
+ [
751
+ controlledIsExpanded ,
752
+ handleBlur ,
753
+ handleTriggerClick ,
754
+ handleTriggerKeyDown ,
755
+ triggerId ,
756
+ triggerRef
757
+ ]
709
758
) ;
710
759
711
760
const getMenuProps = useCallback < IUseMenuReturnValue [ 'getMenuProps' ] > (
712
- ( { role = 'menu' , onMouseLeave, ...other } = { } ) => ( {
761
+ ( { role = 'menu' , onBlur , onMouseLeave, ...other } = { } ) => ( {
713
762
...other ,
714
763
...getGroupProps ( {
715
764
onMouseLeave : composeEventHandlers ( onMouseLeave , handleMenuMouseLeave )
@@ -719,9 +768,10 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
719
768
'aria-labelledby' : triggerId ,
720
769
tabIndex : - 1 ,
721
770
role : role === null ? undefined : role ,
722
- ref : menuRef as any
771
+ ref : menuRef as any ,
772
+ onBlur : composeEventHandlers ( onBlur , handleBlur )
723
773
} ) ,
724
- [ triggerId , menuRef , getGroupProps , handleMenuMouseLeave ]
774
+ [ getGroupProps , handleBlur , handleMenuMouseLeave , menuRef , triggerId ]
725
775
) ;
726
776
727
777
const getSeparatorProps = useCallback < IUseMenuReturnValue [ 'getSeparatorProps' ] > (
0 commit comments