Skip to content

Commit

Permalink
feat(Menu): allow for positioning menu based on a coordinate within v…
Browse files Browse the repository at this point in the history
…iewport
  • Loading branch information
alirezamirian committed Dec 9, 2024
1 parent 085f195 commit 4a5f958
Show file tree
Hide file tree
Showing 10 changed files with 375 additions and 138 deletions.
31 changes: 16 additions & 15 deletions packages/jui/src/Menu/ContextMenuContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useMenuTriggerState } from "@react-stately/menu";
import { OverlayTriggerProps } from "@react-types/overlays";

import { useContextMenu, UseContextMenuProps } from "./useContextMenu";
import { MenuOverlay } from "./MenuOverlay";
import { MenuOverlayFromOrigin } from "@intellij-platform/core/Menu/MenuOverlayFromOrigin";

interface ContextMenuContainerProps
extends Omit<HTMLProps<HTMLDivElement>, "children">,
Expand Down Expand Up @@ -40,7 +40,7 @@ export const ContextMenuContainer = React.forwardRef(
) => {
const state = useMenuTriggerState({} as OverlayTriggerProps);

const { overlayProps, containerProps, overlayRef } = useContextMenu(
const { positionOrigin, containerProps, overlayRef } = useContextMenu(
{ onOpen, isDisabled },
state
);
Expand All @@ -54,19 +54,20 @@ export const ContextMenuContainer = React.forwardRef(
{children}
</div>
)}
<MenuOverlay
state={state}
overlayRef={overlayRef}
overlayProps={overlayProps}
restoreFocus
/**
* Context menus don't autofocus the first item in the reference impl.
* Note that this just defines the default value, and can always be controlled per case on the rendered Menu
*/
defaultAutoFocus={true}
>
{renderMenu()}
</MenuOverlay>
{state.isOpen && (
<MenuOverlayFromOrigin
onClose={state.close}
ref={overlayRef}
origin={positionOrigin}
/**
* Context menus don't autofocus the first item in the reference impl.
* Note that this just defines the default value, and can always be controlled per case on the rendered Menu
*/
defaultAutoFocus={true}
>
{renderMenu()}
</MenuOverlayFromOrigin>
)}
</>
);
}
Expand Down
44 changes: 41 additions & 3 deletions packages/jui/src/Menu/Menu.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { composeStories } from "@storybook/react";
import * as React from "react";
import * as stories from "./Menu.stories";
import {
ContextMenuContainer,
Divider,
Item,
Menu,
Expand Down Expand Up @@ -725,6 +726,14 @@ describe("Menu with trigger", () => {
matchImageSnapshot(`menu-with-trigger--position-${num}`);
}
});

it("closes when right clicking outside", () => {
// TODO: fix the issue!
cy.mount(<MenuWithTrigger />);
cy.get("button[aria-haspopup]").click(); // open the menu by clicking the trigger.
cy.get("body").rightclick("bottomRight");
cy.findByRole("menu").should("not.exist");
});
});

describe("ContextMenu", () => {
Expand Down Expand Up @@ -815,6 +824,27 @@ describe("ContextMenu", () => {
cy.findByRole("menu").should("not.exist");
});

it("is closed when right clicking on another context menu trigger area", () => {
const renderMenu = () => (
<Menu aria-label="Test Context Menu">
<Item>Menu item</Item>
</Menu>
);
cy.mount(
<>
<ContextMenuContainer renderMenu={renderMenu}>
Container 1
</ContextMenuContainer>
<ContextMenuContainer renderMenu={renderMenu}>
Container 2
</ContextMenuContainer>
</>
);
cy.contains("Container 1").rightclick();
cy.contains("Container 2").rightclick("left");
cy.findAllByRole("menu").should("have.length", 1);
});

it("is closed after an action is triggered", () => {
cy.mount(<ContextMenu />);
cy.scrollTo("bottom", { duration: 0 });
Expand Down Expand Up @@ -842,11 +872,19 @@ describe("ContextMenu", () => {
it("lets user select nested menu items by mouse", () => {
const onAction = cy.stub();
cy.mount(<ContextMenu menuProps={{ onAction }} />);
cy.get("#context-menu-container").rightclick("top", {
scrollBehavior: false,
});
cy.get("#context-menu-container")
// Not sure why but this extra click here became
// necessary for test to pass, after some refactoring.
// Without it, clicking "Show History" doesn't work, and realClicking it
// will also not trigger onAction, only the first time the menu is opened.
// The issue wasn't reproducible in real interaction.
.click()
.rightclick("top", {
scrollBehavior: false,
});
cy.findByRole("menuitem", { name: "Local History" }).click();
cy.findByRole("menuitem", { name: "Show History" }).click();

cy.wrap(onAction).should("be.calledOnce");
});
});
Expand Down
85 changes: 62 additions & 23 deletions packages/jui/src/Menu/MenuOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,87 @@
import React, { HTMLProps } from "react";
import { MenuTriggerState } from "@react-stately/menu";
import React, { HTMLProps, useEffect } from "react";
import { FocusScope } from "@intellij-platform/core/utils/FocusScope";
import {
MenuOverlayContext,
MenuProps,
} from "@intellij-platform/core/Menu/Menu";
import { Overlay } from "@intellij-platform/core/Overlay";
import { areInNestedOverlays, Overlay } from "@intellij-platform/core/Overlay";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import { useOverlay, usePreventScroll } from "@react-aria/overlays";

export interface MenuOverlayProps {
children: React.ReactNode;
restoreFocus?: boolean;
overlayProps: HTMLProps<HTMLDivElement>;
overlayRef?: React.Ref<HTMLDivElement>;
/**
* Sets the default value of {@link Menu}'s {@link MenuProps#autoFocus} prop.
*/
defaultAutoFocus?: MenuProps<unknown>["autoFocus"];
onClose: () => void;
}

/**
* Overlay container for menu. Extracted into a separate component, to be used by components like MenuTrigger or
* ContextMenuContainer, that need to render a menu as an overlay.
* @private
* Overlay container for Menu.
* Positioning is not implemented at this layer.
* {@link MenuOverlayProps#overlayProps} should be used for positioning.
*/
export function MenuOverlay({
children,
restoreFocus,
overlayProps,
overlayRef,
overlayProps: otherOverlayProps,
overlayRef: inputOverlayRef,
defaultAutoFocus,
state,
}: {
children: React.ReactNode;
restoreFocus?: boolean;
overlayProps: HTMLProps<HTMLDivElement>;
overlayRef: React.Ref<HTMLDivElement>;
onClose,
}: MenuOverlayProps) {
const overlayRef = useObjectRef(inputOverlayRef);
const { overlayProps } = useOverlay(
{
onClose,
shouldCloseOnBlur: false,
isOpen: true,
isKeyboardDismissDisabled: false,
isDismissable: true,
shouldCloseOnInteractOutside: (element) => {
// FIXME: this is kind of hacky and should be removed when nested menu is properly supported
return !areInNestedOverlays(overlayRef.current, element);
},
},
overlayRef
);

usePreventScroll();

/**
* Sets the default value of {@link Menu}'s {@link MenuProps#autoFocus} prop.
* right clicks outside are not currently captured as "outside interaction" by react-aria's useOverlay hook.
* so we set up a global listener to close the context menu when contextmenu event is triggered outside the
* context menu container.
* NOTE: event handler is set up for the `capture` phase, to have it run before the handler for context menu
* when the menu is used as a context menu
*/
defaultAutoFocus?: MenuProps<unknown>["autoFocus"];
state: MenuTriggerState;
}) {
if (!state.isOpen) {
return null;
}
useEffect(() => {
const onOutsideContextMenu = () => {
onClose();
};
document.addEventListener("contextmenu", onOutsideContextMenu, {
capture: true,
});
return () =>
document.removeEventListener("contextmenu", onOutsideContextMenu);
}, []);

return (
<Overlay>
<FocusScope restoreFocus={restoreFocus} autoFocus>
<MenuOverlayContext.Provider
value={{
...state,
close: onClose,
defaultAutoFocus,
}}
>
<div {...overlayProps} ref={overlayRef}>
<div
{...mergeProps(overlayProps, otherOverlayProps)}
ref={overlayRef}
>
{children}
</div>
</MenuOverlayContext.Provider>
Expand Down
62 changes: 62 additions & 0 deletions packages/jui/src/Menu/MenuOverlayFromOrigin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React, { ForwardedRef } from "react";
import {
MenuOverlay,
MenuOverlayProps,
} from "@intellij-platform/core/Menu/MenuOverlay";
import { useOverlayPositionFromOrigin } from "@intellij-platform/core/Menu/useOverlayPositionFromOrigin";
import { useObjectRef } from "@react-aria/utils";

interface MenuOverlayFromOriginProps
extends Pick<MenuOverlayProps, "onClose" | "defaultAutoFocus"> {
/**
* Origin point within the viewport, based on which the menu overlay should be positioned.
* Any pointer/mouse event, or a plain object with clientX and clientY can be passed.
*/
origin:
| {
/**
* Horizontal coordinate of the origin point within the viewport.
* See {@link MouseEvent.clientX}
*/
clientX: number;
/**
* Vertical coordinate of the origin point within the viewport.
* See {@link MouseEvent.clientX}
*/
clientY: number;
}
| undefined;
children: React.ReactNode;
}

/**
* Menu overlay position based on an origin point on the screen.
* Useful when the menu is opened by a pointer event.
*/
export const MenuOverlayFromOrigin = React.forwardRef(
function MenuOverlayFromOrigin(
{ children, origin, ...otherProps }: MenuOverlayFromOriginProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const overlayRef = useObjectRef(forwardedRef);
const { positionProps } = useOverlayPositionFromOrigin({
overlayRef,
origin,
containerPadding: { x: 0, y: 4 },
});
return (
<>
{Boolean(origin) && (
<MenuOverlay
overlayProps={positionProps}
overlayRef={overlayRef}
restoreFocus
{...otherProps}
>
{children}
</MenuOverlay>
)}
</>
);
}
);
38 changes: 12 additions & 26 deletions packages/jui/src/Menu/MenuTrigger.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import React, { HTMLAttributes, RefObject } from "react";
import { useButton } from "@react-aria/button";
import { AriaMenuOptions, useMenuTrigger } from "@react-aria/menu";
import { useOverlay, useOverlayPosition } from "@react-aria/overlays";
import { mergeProps } from "@react-aria/utils";
import { useOverlayPosition } from "@react-aria/overlays";
import { useMenuTriggerState } from "@react-stately/menu";
import { MenuTriggerProps as AriaMenuTriggerProps } from "@react-types/menu";

Expand Down Expand Up @@ -89,22 +88,6 @@ export const MenuTrigger: React.FC<MenuTriggerProps> = ({
preventFocusOnPress,
};
const { buttonProps } = useButton(ariaButtonProps, triggerRef);
const { overlayProps } = useOverlay(
{
onClose: () => {
return state.close();
},
shouldCloseOnBlur: false,
isOpen: state.isOpen,
isKeyboardDismissDisabled: false,
isDismissable: true,
shouldCloseOnInteractOutside: (element) => {
// FIXME: this is kind of hacky and should be removed when nested menu is properly supported
return !element.matches("[role=menu] *");
},
},
overlayRef
);

const { overlayProps: positionProps } = useOverlayPosition({
targetRef: positioningTargetRef ?? triggerRef,
Expand All @@ -113,20 +96,23 @@ export const MenuTrigger: React.FC<MenuTriggerProps> = ({
shouldFlip,
offset: 0,
containerPadding: 0,
onClose: () => state.close(),
isOpen: state.isOpen,
});

return (
<>
{children(buttonProps, triggerRef)}
<MenuOverlay
overlayProps={mergeProps(overlayProps, positionProps)}
overlayRef={overlayRef}
state={state}
restoreFocus={restoreFocus}
>
{renderMenu({ menuProps })}
</MenuOverlay>
{state.isOpen && (
<MenuOverlay
overlayProps={positionProps}
overlayRef={overlayRef}
onClose={state.close}
restoreFocus={restoreFocus}
>
{renderMenu({ menuProps })}
</MenuOverlay>
)}
</>
);
};
Expand Down
1 change: 1 addition & 0 deletions packages/jui/src/Menu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { SpeedSearchMenu, type SpeedSearchMenuProps } from "./SpeedSearchMenu";
export { MenuTrigger, type MenuTriggerProps } from "./MenuTrigger";
export { MenuItemLayout } from "./MenuItemLayout";
export { ContextMenuContainer } from "./ContextMenuContainer";
export { MenuOverlayFromOrigin } from "./MenuOverlayFromOrigin";

// Collection components are public API of Menu too, but not re-exported because of https://github.com/parcel-bundler/parcel/issues/4399
// export * from "../Collections";
Loading

0 comments on commit 4a5f958

Please sign in to comment.