Skip to content

Commit c123d11

Browse files
cpsoinosclaude
andcommitted
fix(menu): resolve shadow DOM submenu closing issues
- Add data-radix-menu-sub-trigger attribute to MenuSubTrigger for proper identification - Implement custom event system for explicit submenu closure in shadow DOM contexts - Disable automatic grace timer closure in shadow DOM to prevent premature submenu closing - Use native event coordinates for improved accuracy in shadow DOM environments - Extend grace period to 800ms in shadow DOM to account for coordinate detection limitations - Ensure only one submenu remains open when navigating between multiple subtriggers 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 897233e commit c123d11

File tree

1 file changed

+107
-9
lines changed

1 file changed

+107
-9
lines changed

packages/react/menu/src/menu.tsx

Lines changed: 107 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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

13141405
function 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

Comments
 (0)