@@ -438,7 +438,30 @@ const MenuContentImpl = React.forwardRef<MenuContentImplElement, MenuContentImpl
438
438
searchRef = { searchRef }
439
439
onItemEnter = { React . useCallback (
440
440
( 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
+ }
442
465
} ,
443
466
[ isPointerMovingToSubmenu ]
444
467
) }
@@ -732,6 +755,7 @@ const MenuItemImpl = React.forwardRef<MenuItemImplElement, MenuItemImplProps>(
732
755
if ( ! event . defaultPrevented ) {
733
756
const item = event . currentTarget ;
734
757
item . focus ( { preventScroll : true } ) ;
758
+
735
759
}
736
760
}
737
761
} )
@@ -1043,6 +1067,41 @@ const MenuSubTrigger = React.forwardRef<MenuSubTriggerElement, MenuSubTriggerPro
1043
1067
} ;
1044
1068
} , [ pointerGraceTimerRef , onPointerGraceIntentChange ] ) ;
1045
1069
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
+
1046
1105
return (
1047
1106
< MenuAnchor asChild { ...scope } >
1048
1107
< MenuItemImpl
@@ -1051,6 +1110,7 @@ const MenuSubTrigger = React.forwardRef<MenuSubTriggerElement, MenuSubTriggerPro
1051
1110
aria-expanded = { context . open }
1052
1111
aria-controls = { subContext . contentId }
1053
1112
data-state = { getOpenState ( context . open ) }
1113
+ data-radix-menu-sub-trigger = ""
1054
1114
{ ...props }
1055
1115
ref = { composeRefs ( forwardedRef , subContext . onTriggerChange ) }
1056
1116
// This is redundant for mouse users but we cannot determine pointer type from
@@ -1094,23 +1154,54 @@ const MenuSubTrigger = React.forwardRef<MenuSubTriggerElement, MenuSubTriggerPro
1094
1154
const contentNearEdge = contentRect [ rightSide ? 'left' : 'right' ] ;
1095
1155
const contentFarEdge = contentRect [ rightSide ? 'right' : 'left' ] ;
1096
1156
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
+
1097
1175
contentContext . onPointerGraceIntentChange ( {
1098
1176
area : [
1099
1177
// Apply a bleed on clientX to ensure that our exit point is
1100
1178
// 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 } ,
1106
1184
] ,
1107
1185
side,
1108
1186
} ) ;
1109
1187
1110
1188
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
+
1111
1193
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
1114
1205
) ;
1115
1206
} else {
1116
1207
contentContext . onTriggerLeave ( event ) ;
@@ -1313,7 +1404,14 @@ function isPointInPolygon(point: Point, polygon: Polygon) {
1313
1404
1314
1405
function isPointerInGraceArea ( event : React . PointerEvent , area ?: Polygon ) {
1315
1406
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
+
1317
1415
return isPointInPolygon ( cursorPos , area ) ;
1318
1416
}
1319
1417
0 commit comments