Skip to content
Open
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
5 changes: 5 additions & 0 deletions packages/pluggableWidgets/popup-menu-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Fixed

- We fixed an issue where the "Close on" option for the Hover trigger was not working. The menu will now correctly stay open until you click outside when "Click outside" is selected, or close when you hover away when "Hover leave" is selected.
- We fixed an issue where nested popup menus (a popup menu inside another popup menu's content) would close unexpectedly when hovering between parent and child menus.

## [4.2.0] - 2026-05-04

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export function MenuWithContext(props: MenuProps): ReactElement {
onOpenChange: jest.fn(),
placement: props.position,
trigger: props.trigger,
clippingStrategy: props.clippingStrategy
clippingStrategy: props.clippingStrategy,
hoverCloseOn: props.hoverCloseOn
});

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import { PopupTrigger } from "../components/PopupTrigger";
import { usePopup } from "../hooks/usePopup";

export function PopupTriggerWithContext(props: PropsWithChildren): ReactElement {
const popup = usePopup({ open: true, onOpenChange: jest.fn(), trigger: "onclick", clippingStrategy: "absolute" });
const popup = usePopup({
open: true,
onOpenChange: jest.fn(),
trigger: "onclick",
clippingStrategy: "absolute",
placement: "top",
hoverCloseOn: "onClickOutside"
});

return (
<PopupContext.Provider value={popup}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ exports[`Menu renders menu 1`] = `
class="popupmenu-menu"
data-floating-ui-focusable=""
data-overlay-content="true"
id=":r0:"
id=":r1:"
role="dialog"
style="position: absolute; left: 0px; top: 0px; transform: translate(0px, 0px);"
tabindex="0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ exports[`Popup Menu renders popup menu 1`] = `
class="popupmenu mx-popup-menu"
>
<div
aria-controls=":r0:"
aria-controls=":r1:"
aria-expanded="true"
aria-haspopup="dialog"
aria-hidden="true"
Expand Down Expand Up @@ -34,7 +34,7 @@ exports[`Popup Menu renders popup menu 1`] = `
class="popupmenu-menu"
data-floating-ui-focusable=""
data-overlay-content="true"
id=":r0:"
id=":r1:"
role="dialog"
style="position: absolute; left: 0px; top: 0px; transform: translate(0px, 0px);"
tabindex="0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
exports[`Popup Trigger renders popup trigger 1`] = `
<DocumentFragment>
<div
aria-controls=":r0:"
aria-controls=":r1:"
aria-expanded="true"
aria-haspopup="dialog"
class="popupmenu-trigger"
Expand Down
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's populate this test

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added some

Original file line number Diff line number Diff line change
@@ -1,13 +1,127 @@
import { useHover } from "@floating-ui/react";
import { renderHook } from "@testing-library/react";

import { usePopup } from "../hooks/usePopup";

jest.mock("@floating-ui/react", () => ({
...jest.requireActual("@floating-ui/react"),
useHover: jest.fn()
}));

describe("usePopup", () => {
it("somethign", () => {
const popup = renderHook(() => {
return usePopup({ open: false, onOpenChange: jest.fn(), trigger: "onclick", clippingStrategy: "absolute" });
const mockUseHover = useHover as jest.MockedFunction<typeof useHover>;
const mockOnOpenChange = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
mockUseHover.mockReturnValue({});
});

describe("basic functionality", () => {
it("returns open state when false", () => {
const { result } = renderHook(() =>
usePopup({
open: false,
onOpenChange: mockOnOpenChange,
trigger: "onclick",
clippingStrategy: "absolute",
placement: "top",
hoverCloseOn: "onClickOutside"
})
);

expect(result.current.open).toBe(false);
});

expect(popup.result.current.open).toBe(false);
it("returns open state when true", () => {
const { result } = renderHook(() =>
usePopup({
open: true,
onOpenChange: mockOnOpenChange,
trigger: "onclick",
clippingStrategy: "absolute",
placement: "bottom",
hoverCloseOn: "onHoverLeave"
})
);

expect(result.current.open).toBe(true);
});

it("returns nodeId", () => {
const { result } = renderHook(() =>
usePopup({
open: true,
onOpenChange: mockOnOpenChange,
trigger: "onclick",
clippingStrategy: "absolute",
placement: "bottom",
hoverCloseOn: "onHoverLeave"
})
);

expect(result.current.nodeId).toBeDefined();
expect(typeof result.current.nodeId).toBe("string");
});

it("returns required floating UI properties", () => {
const { result } = renderHook(() =>
usePopup({
open: true,
onOpenChange: mockOnOpenChange,
trigger: "onclick",
clippingStrategy: "fixed",
placement: "top",
hoverCloseOn: "onHoverLeave"
})
);

expect(result.current.context).toBeDefined();
expect(result.current.floatingStyles).toBeDefined();
expect(result.current.refs).toBeDefined();
expect(result.current.getReferenceProps).toBeDefined();
expect(result.current.getFloatingProps).toBeDefined();
});
});

describe("hover interaction with hoverCloseOn", () => {
it("disables hover when trigger is onclick", () => {
renderHook(() =>
usePopup({
open: false,
onOpenChange: mockOnOpenChange,
trigger: "onclick",
clippingStrategy: "absolute",
placement: "bottom",
hoverCloseOn: "onHoverLeave"
})
);

expect(mockUseHover).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
enabled: false
})
);
});

it("enables hover when trigger is onhover", () => {
renderHook(() =>
usePopup({
open: false,
onOpenChange: mockOnOpenChange,
trigger: "onhover",
clippingStrategy: "absolute",
placement: "bottom",
hoverCloseOn: "onHoverLeave"
})
);

expect(mockUseHover).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
enabled: true
})
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface MenuProps extends PopupMenuContainerProps {
}

export const Menu = forwardRef((props: MenuProps, propRef: RefObject<HTMLDivElement>): ReactElement | null => {
const { context: floatingContext, floatingStyles, getFloatingProps, modal, refs } = usePopupContext();
const { context: floatingContext, floatingStyles, getFloatingProps, refs } = usePopupContext();
const ref = useMergeRefs([refs.setFloating, propRef]);

if (!floatingContext.open) {
Expand All @@ -20,7 +20,7 @@ export const Menu = forwardRef((props: MenuProps, propRef: RefObject<HTMLDivElem
const menuOptions = createMenuOptions(props, props.onItemClick);

return (
<FloatingFocusManager context={floatingContext} modal={modal}>
<FloatingFocusManager context={floatingContext}>
<div className="widget-popupmenu-root">
<ul
ref={ref}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FloatingNode, FloatingTree, useFloatingParentNodeId } from "@floating-ui/react";
import classNames from "classnames";
import { ActionValue } from "mendix";
import { ReactElement, useCallback, useEffect, useState } from "react";
Expand All @@ -8,15 +9,16 @@ import { PopupTrigger } from "./PopupTrigger";
import { PopupMenuContainerProps } from "../../typings/PopupMenuProps";
import { usePopup } from "../hooks/usePopup";

export function PopupMenu(props: PopupMenuContainerProps): ReactElement {
function PopupMenuComponent(props: PopupMenuContainerProps): ReactElement {
const [visibility, setVisibility] = useState(props.menuToggle);
const open = visibility;
const popup = usePopup({
open,
onOpenChange: setVisibility,
placement: props.position,
trigger: props.trigger,
clippingStrategy: props.clippingStrategy
clippingStrategy: props.clippingStrategy,
hoverCloseOn: props.hoverCloseOn
});

const handleOnClickItem = useCallback(
Expand All @@ -33,10 +35,28 @@ export function PopupMenu(props: PopupMenuContainerProps): ReactElement {

return (
<PopupContext.Provider value={popup}>
<div className={classNames("popupmenu", props.class)}>
<PopupTrigger>{props.menuTrigger}</PopupTrigger>
<Menu {...props} onItemClick={handleOnClickItem} />
</div>
<FloatingNode id={popup.nodeId}>
<div className={classNames("popupmenu", props.class)}>
<PopupTrigger>{props.menuTrigger}</PopupTrigger>
<Menu {...props} onItemClick={handleOnClickItem} />
</div>
</FloatingNode>
</PopupContext.Provider>
);
}

export function PopupMenu(props: PopupMenuContainerProps): ReactElement {
const parentId = useFloatingParentNodeId();

// If this is a root popup (no parent), wrap it in FloatingTree
if (parentId == null) {
return (
<FloatingTree>
<PopupMenuComponent {...props} />
</FloatingTree>
);
}

// If this is a nested popup, just render the component
return <PopupMenuComponent {...props} />;
}
43 changes: 29 additions & 14 deletions packages/pluggableWidgets/popup-menu-web/src/hooks/usePopup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,41 +8,45 @@ import {
useClick,
useDismiss,
useFloating,
useFloatingNodeId,
UseFloatingReturn,
useHover,
useInteractions,
UseInteractionsReturn,
useRole
} from "@floating-ui/react";
import { ClippingStrategyEnum, TriggerEnum } from "../../typings/PopupMenuProps";
import { ClippingStrategyEnum, HoverCloseOnEnum, TriggerEnum } from "../../typings/PopupMenuProps";

interface PopupOptions {
placement?: Placement;
modal?: boolean;
open?: boolean;
placement: Placement;
open: boolean;
onOpenChange?: (open: boolean) => void;
clippingStrategy?: ClippingStrategyEnum;
trigger?: TriggerEnum;
clippingStrategy: ClippingStrategyEnum;
trigger: TriggerEnum;
hoverCloseOn: HoverCloseOnEnum;
}

type FloatingReturn = Pick<UseFloatingReturn, "context" | "floatingStyles" | "refs">;
type InteractionReturn = Pick<UseInteractionsReturn, "getFloatingProps" | "getReferenceProps">;

export type UsePopupReturn = FloatingReturn &
InteractionReturn & {
modal?: boolean;
open?: boolean;
open: boolean;
nodeId: string;
};

export function usePopup({
placement = "bottom",
modal,
open,
onOpenChange: setOpen,
trigger,
clippingStrategy
}: PopupOptions = {}): UsePopupReturn {
clippingStrategy,
hoverCloseOn
}: PopupOptions): UsePopupReturn {
const nodeId = useFloatingNodeId();

const { context, floatingStyles, refs } = useFloating({
nodeId,
middleware: [offset(5), flip(), shift()],
onOpenChange: setOpen,
strategy: clippingStrategy,
Expand All @@ -54,7 +58,11 @@ export function usePopup({
const dismiss = useDismiss(context);
const role = useRole(context);
const click = useClick(context, { enabled: trigger === "onclick" });
const hover = useHover(context, { enabled: trigger === "onhover", handleClose: safePolygon() });

const hover = useHover(context, {
enabled: trigger === "onhover",
handleClose: hoverCloseOn === "onHoverLeave" ? safePolygon() : neverClose
});

const { getFloatingProps, getReferenceProps } = useInteractions([dismiss, role, click, hover]);

Expand All @@ -63,8 +71,15 @@ export function usePopup({
floatingStyles,
getFloatingProps,
getReferenceProps,
modal,
open,
refs
refs,
nodeId
};
}

const neverClose = Object.assign(
(): (() => void) => {
return (): void => {};
},
{ __options: { blockPointerEvents: false } }
);
Loading