@@ -438,7 +438,30 @@ const MenuContentImpl = React.forwardRef<MenuContentImplElement, MenuContentImpl
438438 searchRef = { searchRef }
439439 onItemEnter = { React . useCallback (
440440 ( event ) => {
441- if ( isPointerMovingToSubmenu ( event ) ) event . preventDefault ( ) ;
441+ if ( isPointerMovingToSubmenu ( event ) ) {
442+ event . preventDefault ( ) ;
443+ } else {
444+ // In shadow DOM, force close other submenus when entering any menu item
445+ const target = event . target as Element ;
446+ const isInShadowDOM = target && target . getRootNode ( ) !== document && 'host' in target . getRootNode ( ) ;
447+ if ( isInShadowDOM ) {
448+ const menuItem = event . currentTarget as HTMLElement ;
449+
450+ // Clear grace intent
451+ pointerGraceIntentRef . current = null ;
452+
453+ // Always close other submenus, regardless of whether this is a subtrigger or not
454+ setTimeout ( ( ) => {
455+ // Dispatch a custom event that submenu triggers can listen for
456+ const closeEvent = new CustomEvent ( 'radix-force-close-submenu' , {
457+ bubbles : true ,
458+ cancelable : false ,
459+ detail : { currentTrigger : menuItem } // Pass the current trigger to exclude it
460+ } ) ;
461+ menuItem . dispatchEvent ( closeEvent ) ;
462+ } , 0 ) ;
463+ }
464+ }
442465 } ,
443466 [ isPointerMovingToSubmenu ]
444467 ) }
@@ -732,6 +755,7 @@ const MenuItemImpl = React.forwardRef<MenuItemImplElement, MenuItemImplProps>(
732755 if ( ! event . defaultPrevented ) {
733756 const item = event . currentTarget ;
734757 item . focus ( { preventScroll : true } ) ;
758+
735759 }
736760 }
737761 } )
@@ -1043,6 +1067,41 @@ const MenuSubTrigger = React.forwardRef<MenuSubTriggerElement, MenuSubTriggerPro
10431067 } ;
10441068 } , [ pointerGraceTimerRef , onPointerGraceIntentChange ] ) ;
10451069
1070+ // Listen for forced close events in shadow DOM
1071+ React . useEffect ( ( ) => {
1072+ const handleForceClose = ( event : CustomEvent ) => {
1073+ // Don't close this submenu if it's the current trigger being hovered
1074+ const currentTrigger = event . detail ?. currentTrigger ;
1075+ const thisTrigger = subContext . trigger ;
1076+
1077+ if ( currentTrigger === thisTrigger ) {
1078+ return ; // Don't close the submenu that's currently being hovered
1079+ }
1080+
1081+ if ( context . open ) {
1082+ context . onOpenChange ( false ) ;
1083+ }
1084+ } ;
1085+
1086+ const currentElement = subContext . trigger ;
1087+ if ( currentElement ) {
1088+ currentElement . addEventListener ( 'radix-force-close-submenu' , handleForceClose as EventListener ) ;
1089+ // Also listen on parent elements since the event bubbles
1090+ const menuContent = currentElement . closest ( '[data-radix-menu-content]' ) ;
1091+ if ( menuContent ) {
1092+ menuContent . addEventListener ( 'radix-force-close-submenu' , handleForceClose as EventListener ) ;
1093+ }
1094+
1095+ return ( ) => {
1096+ currentElement . removeEventListener ( 'radix-force-close-submenu' , handleForceClose as EventListener ) ;
1097+ if ( menuContent ) {
1098+ menuContent . removeEventListener ( 'radix-force-close-submenu' , handleForceClose as EventListener ) ;
1099+ }
1100+ } ;
1101+ }
1102+ } , [ context , subContext . trigger ] ) ;
1103+
1104+
10461105 return (
10471106 < MenuAnchor asChild { ...scope } >
10481107 < MenuItemImpl
@@ -1051,6 +1110,7 @@ const MenuSubTrigger = React.forwardRef<MenuSubTriggerElement, MenuSubTriggerPro
10511110 aria-expanded = { context . open }
10521111 aria-controls = { subContext . contentId }
10531112 data-state = { getOpenState ( context . open ) }
1113+ data-radix-menu-sub-trigger = ""
10541114 { ...props }
10551115 ref = { composeRefs ( forwardedRef , subContext . onTriggerChange ) }
10561116 // This is redundant for mouse users but we cannot determine pointer type from
@@ -1094,23 +1154,54 @@ const MenuSubTrigger = React.forwardRef<MenuSubTriggerElement, MenuSubTriggerPro
10941154 const contentNearEdge = contentRect [ rightSide ? 'left' : 'right' ] ;
10951155 const contentFarEdge = contentRect [ rightSide ? 'right' : 'left' ] ;
10961156
1157+ // In shadow DOM, we may need to adjust coordinates to ensure
1158+ // both the mouse position and rectangle are in the same coordinate system
1159+ let adjustedClientX = event . clientX ;
1160+ let adjustedClientY = event . clientY ;
1161+ const adjustedContentRect = contentRect ;
1162+
1163+ const eventTarget = event . target as Element ;
1164+ const isInShadowDOM = eventTarget && eventTarget . getRootNode ( ) !== document && 'host' in eventTarget . getRootNode ( ) ;
1165+
1166+ if ( isInShadowDOM && context . content ) {
1167+ // Use native event coordinates for more reliable positioning in shadow DOM
1168+ const nativeEvent = event . nativeEvent ;
1169+ if ( nativeEvent ) {
1170+ adjustedClientX = nativeEvent . clientX ;
1171+ adjustedClientY = nativeEvent . clientY ;
1172+ }
1173+ }
1174+
10971175 contentContext . onPointerGraceIntentChange ( {
10981176 area : [
10991177 // Apply a bleed on clientX to ensure that our exit point is
11001178 // consistently within polygon bounds
1101- { x : event . clientX + bleed , y : event . clientY } ,
1102- { x : contentNearEdge , y : contentRect . top } ,
1103- { x : contentFarEdge , y : contentRect . top } ,
1104- { x : contentFarEdge , y : contentRect . bottom } ,
1105- { x : contentNearEdge , y : contentRect . bottom } ,
1179+ { x : adjustedClientX + bleed , y : adjustedClientY } ,
1180+ { x : contentNearEdge , y : adjustedContentRect . top } ,
1181+ { x : contentFarEdge , y : adjustedContentRect . top } ,
1182+ { x : contentFarEdge , y : adjustedContentRect . bottom } ,
1183+ { x : contentNearEdge , y : adjustedContentRect . bottom } ,
11061184 ] ,
11071185 side,
11081186 } ) ;
11091187
11101188 window . clearTimeout ( pointerGraceTimerRef . current ) ;
1189+
1190+ // Use longer grace period in shadow DOM since coordinate detection may be less reliable
1191+ const gracePeriod = isInShadowDOM ? 800 : 300 ;
1192+
11111193 pointerGraceTimerRef . current = window . setTimeout (
1112- ( ) => contentContext . onPointerGraceIntentChange ( null ) ,
1113- 300
1194+ ( ) => {
1195+ contentContext . onPointerGraceIntentChange ( null ) ;
1196+
1197+ // In shadow DOM, don't automatically close submenu on grace timer expiry
1198+ // Only close via explicit menu item selection logic
1199+ if ( ! isInShadowDOM && context . open ) {
1200+ // Normal behavior for non-shadow DOM
1201+ context . onOpenChange ( false ) ;
1202+ }
1203+ } ,
1204+ gracePeriod
11141205 ) ;
11151206 } else {
11161207 contentContext . onTriggerLeave ( event ) ;
@@ -1313,7 +1404,14 @@ function isPointInPolygon(point: Point, polygon: Polygon) {
13131404
13141405function isPointerInGraceArea ( event : React . PointerEvent , area ?: Polygon ) {
13151406 if ( ! area ) return false ;
1316- const cursorPos = { x : event . clientX , y : event . clientY } ;
1407+
1408+ // Use the most reliable coordinates available
1409+ const nativeEvent = event . nativeEvent ;
1410+ const cursorPos = {
1411+ x : nativeEvent ? nativeEvent . clientX : event . clientX ,
1412+ y : nativeEvent ? nativeEvent . clientY : event . clientY
1413+ } ;
1414+
13171415 return isPointInPolygon ( cursorPos , area ) ;
13181416}
13191417
0 commit comments