Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: Menu with openOnContext no longer overrides an imperative positioningRef.setTarget() call made in onOpenChange; the internal context target is now set imperatively and onOpenChange fires after it",
"packageName": "@fluentui/react-menu",
"email": "144495202+AKnassa@users.noreply.github.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '@fluentui/react-menu';
import { FluentProvider } from '@fluentui/react-provider';
import { Portal } from '@fluentui/react-portal';
import type { PositioningImperativeRef } from '@fluentui/react-positioning';
import { teamsLightTheme } from '@fluentui/react-theme';
import * as React from 'react';

Expand Down Expand Up @@ -1129,6 +1130,78 @@ describe('Context menu', () => {
.get(menuTriggerSelector)
.should('have.focus');
});

it('should anchor to the mouse position on right click', () => {
mount(<ContextMenuExample />);

cy.get(menuTriggerSelector).then(([trigger]) => {
const triggerRect = trigger.getBoundingClientRect();

// The positioned element is the popover surface (the parent of [role="menu"])
cy.get(menuTriggerSelector)
.rightclick(10, 10)
.get(menuSelector)
.parent()
.should(([popover]) => {
const popoverRect = popover.getBoundingClientRect();
// The context target is a 1px virtual element at the click point, the menu opens below-start of it
expect(popoverRect.left).to.be.closeTo(triggerRect.left + 10, 4);
expect(popoverRect.top).to.be.closeTo(triggerRect.top + 10 + 1, 4);
});
});
});

// https://github.com/microsoft/fluentui/issues/31727
it('should not override an imperative positioningRef.setTarget() called from onOpenChange', () => {
const ImperativeTargetContextMenuExample = () => {
const positioningRef = React.useRef<PositioningImperativeRef>(null);

return (
<>
<div
id="custom-anchor"
style={{ position: 'absolute', top: '200px', left: '200px', width: '50px', height: '50px' }}
/>
<Menu
openOnContext
positioning={{ positioningRef }}
onOpenChange={(e, data) => {
if (data.open) {
positioningRef.current?.setTarget(document.getElementById('custom-anchor'));
}
}}
>
<MenuTrigger disableButtonEnhancement>
<button id={menuTriggerId}>trigger</button>
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem>Item</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
</>
);
};

mount(<ImperativeTargetContextMenuExample />);

cy.get(menuTriggerSelector).rightclick(5, 5);

cy.get('#custom-anchor').then(([anchor]) => {
const anchorRect = anchor.getBoundingClientRect();

// The positioned element is the popover surface (the parent of [role="menu"])
cy.get(menuSelector)
.parent()
.should(([popover]) => {
const popoverRect = popover.getBoundingClientRect();
// The menu should be anchored below-start of the imperatively set target, not at the click point
expect(popoverRect.left).to.be.closeTo(anchorRect.left, 4);
expect(popoverRect.top).to.be.closeTo(anchorRect.bottom, 4);
});
});
});
});

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import * as React from 'react';
import { render, fireEvent } from '@testing-library/react';
import type { PositioningImperativeRef, PositioningVirtualElement } from '@fluentui/react-positioning';
import { Menu } from './Menu';
import { MenuTrigger } from '../MenuTrigger/index';
import { MenuList } from '../MenuList/index';
import { MenuItem } from '../MenuItem/index';
import { MenuPopover } from '../MenuPopover/index';

// The internal position manager is mocked at its resolved source path so that the effective
// positioning target can be asserted in jsdom. usePositioning imports './createPositionManager'
// relatively, which resolves to the same module registry entry as this deep relative path.
import { createPositionManager } from '../../../../../react-positioning/library/src/createPositionManager';

jest.mock('../../../../../react-positioning/library/src/createPositionManager', () => ({
createPositionManager: jest.fn(() => ({ updatePosition: jest.fn(), dispose: jest.fn() })),
}));

const lastTarget = () => {
const calls = (createPositionManager as jest.Mock).mock.calls;
return calls[calls.length - 1][0].target;
};

describe('Menu openOnContext positioning target', () => {
// Regression test for https://github.com/microsoft/fluentui/issues/31727
it('does not clobber an imperative positioningRef.setTarget() called from onOpenChange', () => {
const anchor = document.createElement('div');
document.body.appendChild(anchor);
const positioningRef = React.createRef<PositioningImperativeRef>();

const { getByRole } = render(
<Menu
openOnContext
positioning={{ positioningRef }}
onOpenChange={(e, data) => {
if (data.open) {
positioningRef.current?.setTarget(anchor);
}
}}
>
<MenuTrigger disableButtonEnhancement>
<button>trigger</button>
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem>Item</MenuItem>
</MenuList>
</MenuPopover>
</Menu>,
);

fireEvent.contextMenu(getByRole('button'), { clientX: 42, clientY: 24 });

expect(lastTarget()).toBe(anchor);

anchor.remove();
});

it('anchors to the mouse position on right click by default', () => {
const { getByRole } = render(
<Menu openOnContext>
<MenuTrigger disableButtonEnhancement>
<button>trigger</button>
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem>Item</MenuItem>
</MenuList>
</MenuPopover>
</Menu>,
);

fireEvent.contextMenu(getByRole('button'), { clientX: 42, clientY: 24 });

const target = lastTarget() as PositioningVirtualElement;
expect(target.getBoundingClientRect().x).toBe(42);
expect(target.getBoundingClientRect().y).toBe(24);
});

it('restores the trigger as positioning target when the context menu closes', () => {
const { getByRole } = render(
<Menu openOnContext>
<MenuTrigger disableButtonEnhancement>
<button>trigger</button>
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem>Item</MenuItem>
</MenuList>
</MenuPopover>
</Menu>,
);

const trigger = getByRole('button');
fireEvent.contextMenu(trigger, { clientX: 42, clientY: 24 });
fireEvent.click(getByRole('menuitem'));

expect(lastTarget()).toBe(trigger);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
usePositioning,
useSafeZoneArea,
usePositioningSlideDirection,
createVirtualElementFromClick,
type PositioningShorthandValue,
type PositioningImperativeRef,
type SetVirtualMouseTarget,
} from '@fluentui/react-positioning';
import { presenceMotionSlot } from '@fluentui/react-motion';
import {
Expand Down Expand Up @@ -102,9 +105,29 @@ export const useMenuBase_unstable = (

const { targetDocument } = useFluent();
const triggerId = useId('menu');
const [contextTarget, setContextTarget] = usePositioningMouseTarget();
const [contextTarget, setContextTargetState] = usePositioningMouseTarget();

const resolvedPositioning = resolvePositioningShorthand(props.positioning);
const internalPositioningRef = React.useRef<PositioningImperativeRef>(null);
const positioningRef = useMergedRefs(internalPositioningRef, resolvedPositioning.positioningRef);

// Sets the context target imperatively so that a later user positioningRef.setTarget() call is not
// clobbered by a state-driven `target` option re-sync (https://github.com/microsoft/fluentui/issues/31727).
// The legacy contextTarget state is kept in sync for consumers of MenuState.contextTarget.
const setContextTarget: SetVirtualMouseTarget = useEventCallback(event => {
setContextTargetState(event);

// Preserve existing precedence: an explicit `positioning.target` from the user always wins
if (openOnContext && !('target' in resolvedPositioning)) {
if (event === undefined || event === null) {
internalPositioningRef.current?.setTarget(null);
} else {
const nativeEvent = event instanceof MouseEvent ? event : event.nativeEvent;
internalPositioningRef.current?.setTarget(createVirtualElementFromClick(nativeEvent));
}
}
});

const handlePositionEnd = usePositioningSlideDirection({
targetDocument,
onPositioningEnd: resolvedPositioning.onPositioningEnd,
Expand All @@ -113,10 +136,10 @@ export const useMenuBase_unstable = (
const positioningOptions = {
position: isSubmenu ? 'after' : 'below',
align: isSubmenu ? 'top' : 'start',
target: props.openOnContext ? contextTarget : undefined,
fallbackPositions: isSubmenu ? submenuFallbackPositions : undefined,
...resolvedPositioning,
onPositioningEnd: handlePositionEnd,
positioningRef,
} as const;

const children = React.Children.toArray(props.children) as React.ReactElement[];
Expand Down Expand Up @@ -286,7 +309,7 @@ const useMenuOpenState = (

const trySetOpen = useEventCallback((e: MenuOpenEvent, data: MenuOpenChangeData) => {
const event = e instanceof CustomEvent && e.type === MENU_ENTER_EVENT ? e.detail.nativeEvent : e;
onOpenChange?.(event, { ...data });

if (data.open && e.type === 'contextmenu') {
state.setContextTarget(e as React.MouseEvent);
}
Expand All @@ -295,6 +318,11 @@ const useMenuOpenState = (
state.setContextTarget(undefined);
}

// Fire the user's onOpenChange only after the internal context target is set so that an
// imperative positioningRef.setTarget() call inside onOpenChange takes precedence
// (https://github.com/microsoft/fluentui/issues/31727).
onOpenChange?.(event, { ...data });

if (data.bubble) {
parentSetOpen(e, { ...data });
}
Expand Down