From ef7b022043eea82a28e3870945fd85b34658ba5a Mon Sep 17 00:00:00 2001
From: Kim Anh Nguyen <4783194+kimanhou@users.noreply.github.com>
Date: Mon, 12 Oct 2026 17:49:51 +0200
Subject: [PATCH] fix(ContextualMenuDropdown): responsiveness when vertical
space is low
Signed-off-by: Kim Anh Nguyen <4783194+kimanhou@users.noreply.github.com>
---
.../ContextualMenuDropdown.test.tsx | 153 +++++++++++----
.../ContextualMenuDropdown.tsx | 174 +++++++++++-------
2 files changed, 222 insertions(+), 105 deletions(-)
diff --git a/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.test.tsx b/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.test.tsx
index bdb70e3df..bfcb60fc5 100644
--- a/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.test.tsx
+++ b/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import merge from "deepmerge";
import React from "react";
@@ -53,10 +53,12 @@ describe("ContextualMenuDropdown ", () => {
await waitFor(() => {
expect(
document.querySelector(".p-contextual-menu__dropdown"),
- ).toHaveAttribute(
- "style",
- "min-height: 2rem; overflow-x: auto; max-height: 100px;",
- );
+ ).toHaveStyle({
+ maxHeight: "100px",
+ minHeight: "32px",
+ overflowX: "auto",
+ overflowY: "auto",
+ });
});
positionNode.remove();
});
@@ -174,32 +176,22 @@ describe("ContextualMenuDropdown ", () => {
});
describe("dropdown menu vertical positioning", () => {
- const renderMenu = (positionNode: HTMLElement, menuHeight: number) => {
- // need to mock overflow property for scrollparent
- jest
- .spyOn(global, "getComputedStyle")
- .mockImplementation((node: Element) => {
- if (node.id === "scrollParent") {
- return {
- overflowY: "auto",
- overflowX: "auto",
- getPropertyValue: (_: string) => "",
- } as CSSStyleDeclaration;
- }
- return {
- getPropertyValue: (_: string) => "",
- } as CSSStyleDeclaration;
- });
-
+ const renderMenu = (
+ positionNode: HTMLElement,
+ menuHeight: number,
+ scrollOverflow = false,
+ autoAdjust = true,
+ ) => {
// render the component once to setup initial jsdom elements
const links = Array.from({ length: 10 }).map((_, i) => i);
const { rerender } = render(
,
);
@@ -212,16 +204,22 @@ describe("ContextualMenuDropdown ", () => {
dropdownNode.getBoundingClientRect = jest
.fn()
.mockReturnValue({ height: menuHeight });
+ Object.defineProperty(dropdownNode, "scrollHeight", {
+ configurable: true,
+ value: menuHeight,
+ });
rerender(
,
);
+ fireEvent.resize(window);
};
const assertMenuPosition = async (position: "above" | "below") => {
@@ -235,7 +233,7 @@ describe("ContextualMenuDropdown ", () => {
condition = condition.not as jest.JestMatchers;
}
- condition.toHaveAttribute("style", "bottom: 0px;");
+ condition.toHaveAttribute("style", expect.stringContaining("bottom"));
});
};
@@ -245,7 +243,7 @@ describe("ContextualMenuDropdown ", () => {
const scrollParent = document.createElement("div");
jest.spyOn(scrollParent, "getBoundingClientRect").mockReturnValue({
top: 0,
- bottom: 0,
+ bottom: 1000,
height: 1000,
} as DOMRect);
scrollParent.id = "scrollParent";
@@ -253,7 +251,7 @@ describe("ContextualMenuDropdown ", () => {
const positionNode = document.createElement("div");
jest.spyOn(positionNode, "getBoundingClientRect").mockReturnValue({
top: 500,
- bottom: 480,
+ bottom: 520,
height: 20,
} as DOMRect);
@@ -268,21 +266,21 @@ describe("ContextualMenuDropdown ", () => {
positionNode.remove();
});
- it("places menu below toggle if there is not enough space in window or scroll parent, but there is more space below than above", async () => {
+ it("uses viewport space below even when the scroll parent is constrained", async () => {
global.innerHeight = 600;
const scrollParent = document.createElement("div");
jest.spyOn(scrollParent, "getBoundingClientRect").mockReturnValue({
top: 180,
bottom: 400,
- height: 20,
+ height: 220,
} as DOMRect);
scrollParent.id = "scrollParent";
const positionNode = document.createElement("div");
jest.spyOn(positionNode, "getBoundingClientRect").mockReturnValue({
- top: 100,
- bottom: 480,
+ top: 200,
+ bottom: 220,
height: 20,
} as DOMRect);
@@ -298,12 +296,12 @@ describe("ContextualMenuDropdown ", () => {
});
it("places menu above if there is not enough space in the window", async () => {
- global.innerHeight = 10;
+ global.innerHeight = 100;
const positionNode = document.createElement("div");
jest.spyOn(positionNode, "getBoundingClientRect").mockReturnValue({
- top: 0,
- bottom: 50,
+ top: 80,
+ bottom: 90,
height: 50,
} as DOMRect);
@@ -322,13 +320,13 @@ describe("ContextualMenuDropdown ", () => {
positionNode.remove();
});
- it("places menu above if there is enough space in the window, but not enough space in the scroll parent", async () => {
+ it("places menu below when it fits in the viewport despite scroll parent bounds", async () => {
global.innerHeight = 500;
const scrollParent = document.createElement("div");
jest.spyOn(scrollParent, "getBoundingClientRect").mockReturnValue({
top: 0,
- bottom: 0,
+ bottom: 300,
height: 300,
} as DOMRect);
scrollParent.id = "scrollParent";
@@ -336,7 +334,7 @@ describe("ContextualMenuDropdown ", () => {
const positionNode = document.createElement("div");
jest.spyOn(positionNode, "getBoundingClientRect").mockReturnValue({
top: 200,
- bottom: 400,
+ bottom: 220,
height: 20,
} as DOMRect);
@@ -346,7 +344,86 @@ describe("ContextualMenuDropdown ", () => {
const menuHeight = 200;
renderMenu(positionNode, menuHeight);
- await assertMenuPosition("above");
+ await assertMenuPosition("below");
+
+ positionNode.remove();
+ });
+
+ it("limits the height to the available space above when placed above", async () => {
+ global.innerHeight = 300;
+
+ const positionNode = document.createElement("div");
+ jest.spyOn(positionNode, "getBoundingClientRect").mockReturnValue({
+ top: 200,
+ bottom: 220,
+ height: 20,
+ } as DOMRect);
+ document.body.appendChild(positionNode);
+
+ renderMenu(positionNode, 200, true);
+
+ await waitFor(() => {
+ const dropdownNode = document.querySelector(
+ ".p-contextual-menu__dropdown",
+ ) as HTMLElement;
+ expect(dropdownNode.getAttribute("style")).toMatch(
+ /max-height: 184px;.*overflow-y: auto;.*bottom: 0px/,
+ );
+ });
+
+ positionNode.remove();
+ });
+
+ it("constrains the current direction without flipping when autoAdjust is false", async () => {
+ global.innerHeight = 300;
+
+ const positionNode = document.createElement("div");
+ jest.spyOn(positionNode, "getBoundingClientRect").mockReturnValue({
+ top: 200,
+ bottom: 220,
+ height: 20,
+ } as DOMRect);
+ document.body.appendChild(positionNode);
+
+ renderMenu(positionNode, 200, true, false);
+
+ await waitFor(() => {
+ const dropdownNode = document.querySelector(
+ ".p-contextual-menu__dropdown",
+ ) as HTMLElement;
+ expect(dropdownNode.getAttribute("style")).toContain(
+ "max-height: 64px",
+ );
+ });
+ const dropdownNode = document.querySelector(
+ ".p-contextual-menu__dropdown",
+ ) as HTMLElement;
+ expect(dropdownNode.getAttribute("style")).not.toContain("bottom: 0px");
+
+ positionNode.remove();
+ });
+
+ it("clamps the minimum height to the available space", async () => {
+ global.innerHeight = 116;
+
+ const positionNode = document.createElement("div");
+ jest.spyOn(positionNode, "getBoundingClientRect").mockReturnValue({
+ top: 15,
+ bottom: 115,
+ height: 100,
+ } as DOMRect);
+ document.body.appendChild(positionNode);
+
+ renderMenu(positionNode, 200, true);
+
+ await waitFor(() => {
+ const dropdownNode = document.querySelector(
+ ".p-contextual-menu__dropdown",
+ ) as HTMLElement;
+ expect(dropdownNode.getAttribute("style")).toMatch(
+ /max-height: 0;.*min-height: 0;/,
+ );
+ });
positionNode.remove();
});
diff --git a/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.tsx b/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.tsx
index c916bfee0..6601ffd36 100644
--- a/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.tsx
+++ b/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.tsx
@@ -163,23 +163,6 @@ const generateLink = (
);
};
-const getClosestScrollableParent = (
- node: HTMLElement | null,
-): HTMLElement | null => {
- let currentNode = node;
- while (currentNode && currentNode !== document.body) {
- const { overflowY, overflowX } = window.getComputedStyle(currentNode);
- if (
- ["auto", "scroll", "overlay"].includes(overflowY) &&
- ["auto", "scroll", "overlay"].includes(overflowX)
- ) {
- return currentNode;
- }
- currentNode = currentNode.parentElement;
- }
- return document.body;
-};
-
// nearest parents z-index that is not 0 or auto
export const getNearestParentsZIndex = (
element: HTMLElement | null,
@@ -218,82 +201,86 @@ const ContextualMenuDropdown = ({
...props
}: Props): React.JSX.Element => {
const dropdown = useRef(null);
- const [verticalPosition, setVerticalPosition] =
- useState("bottom");
+ const [verticalLayout, setVerticalLayout] = useState<{
+ position: VerticalPosition;
+ maxHeight?: number;
+ }>({ position: "bottom" });
const [positionStyle, setPositionStyle] = useState(
getPositionStyle(
adjustedPosition,
- verticalPosition,
+ verticalLayout.position,
positionCoords,
constrainPanelWidth,
),
);
- const [maxHeight, setMaxHeight] = useState();
// Update the styles to position the menu.
const updatePositionStyle = useCallback(() => {
setPositionStyle(
getPositionStyle(
adjustedPosition,
- verticalPosition,
+ verticalLayout.position,
positionCoords,
constrainPanelWidth,
),
);
- }, [adjustedPosition, positionCoords, verticalPosition, constrainPanelWidth]);
+ }, [
+ adjustedPosition,
+ positionCoords,
+ verticalLayout.position,
+ constrainPanelWidth,
+ ]);
const updateVerticalPosition = useCallback(() => {
- if (!positionNode) {
+ if (!positionNode || !dropdown.current) {
return;
}
- const scrollableParent = getClosestScrollableParent(positionNode);
- if (!scrollableParent) {
- return;
- }
-
- const scrollableParentRect = scrollableParent.getBoundingClientRect();
const toggleRect = positionNode.getBoundingClientRect();
+ const spaceAbove = Math.max(0, toggleRect.top);
+ const spaceBelow = Math.max(0, window.innerHeight - toggleRect.bottom);
+ const safetyMargin = scrollOverflow ? 16 : 0;
+ const availableAbove = Math.max(0, spaceAbove - safetyMargin);
+ const availableBelow = Math.max(0, spaceBelow - safetyMargin);
+ const desiredHeight =
+ dropdown.current.scrollHeight ||
+ dropdown.current.getBoundingClientRect().height ||
+ 0;
- // Calculate the rect in relation to the scrollableParent
- const relativeToScrollParentRect = {
- top: toggleRect.top - scrollableParentRect.top,
- bottom: toggleRect.bottom - scrollableParentRect.top,
- };
-
- const scrollParentSpaceBelow =
- scrollableParentRect.height - relativeToScrollParentRect.bottom;
- const scrollParentSpaceAbove = relativeToScrollParentRect.top;
-
- const dropdownHeight = dropdown.current.getBoundingClientRect().height ?? 0;
-
- const windowSpaceBelow = window.innerHeight - toggleRect.bottom;
+ let nextPosition = verticalLayout.position;
+ if (autoAdjust) {
+ if (desiredHeight <= availableBelow) {
+ nextPosition = "bottom";
+ } else if (desiredHeight <= availableAbove) {
+ nextPosition = "top";
+ } else {
+ nextPosition = availableBelow >= availableAbove ? "bottom" : "top";
+ }
+ }
- setVerticalPosition(
- (scrollParentSpaceBelow >= dropdownHeight &&
- windowSpaceBelow >= dropdownHeight) ||
- windowSpaceBelow > scrollParentSpaceAbove
- ? "bottom"
- : "top",
- );
- }, [positionNode]);
+ const availableHeight =
+ nextPosition === "bottom" ? availableBelow : availableAbove;
+ const nextLayout = {
+ position: nextPosition,
+ maxHeight: scrollOverflow ? availableHeight : undefined,
+ };
+ setVerticalLayout((currentLayout) => {
+ if (
+ currentLayout.position === nextLayout.position &&
+ currentLayout.maxHeight === nextLayout.maxHeight
+ ) {
+ return currentLayout;
+ }
+ return nextLayout;
+ });
+ }, [autoAdjust, positionNode, scrollOverflow, verticalLayout.position]);
// Update the position when the window fitment info changes.
const onUpdateWindowFitment = useCallback(
(fitsWindow: WindowFitment) => {
if (autoAdjust) {
setAdjustedPosition(adjustForWindow(position, fitsWindow));
- updateVerticalPosition();
- }
- if (scrollOverflow) {
- setMaxHeight(fitsWindow.fromBottom.spaceBelow - 16);
}
},
- [
- autoAdjust,
- position,
- scrollOverflow,
- setAdjustedPosition,
- updateVerticalPosition,
- ],
+ [autoAdjust, position, setAdjustedPosition],
);
// Handle adjusting the horizontal position and scrolling of the dropdown so that it remains on screen.
@@ -302,7 +289,7 @@ const ContextualMenuDropdown = ({
positionNode,
onUpdateWindowFitment,
0,
- isOpen && (autoAdjust || scrollOverflow),
+ isOpen && autoAdjust,
);
// Update the styles when the position changes.
@@ -312,7 +299,55 @@ const ContextualMenuDropdown = ({
useEffect(() => {
updateVerticalPosition();
- }, [updateVerticalPosition]);
+ }, [updateVerticalPosition, isOpen]);
+
+ useEffect(() => {
+ if (!isOpen || (!autoAdjust && !scrollOverflow)) return undefined;
+
+ window.addEventListener("resize", updateVerticalPosition);
+ window.addEventListener("scroll", updateVerticalPosition, true);
+ const resizeObserver =
+ typeof ResizeObserver === "undefined"
+ ? null
+ : new ResizeObserver(updateVerticalPosition);
+ const dropdownNode = dropdown.current;
+ if (positionNode) resizeObserver?.observe(positionNode);
+ if (dropdownNode) {
+ resizeObserver?.observe(dropdownNode);
+ Array.from(dropdownNode.children).forEach((child) =>
+ resizeObserver?.observe(child),
+ );
+ }
+ const mutationObserver =
+ dropdownNode && typeof MutationObserver !== "undefined"
+ ? new MutationObserver(() => {
+ if (dropdownNode) {
+ Array.from(dropdownNode.children).forEach((child) =>
+ resizeObserver?.observe(child),
+ );
+ }
+ updateVerticalPosition();
+ })
+ : null;
+ mutationObserver?.observe(dropdownNode, {
+ childList: true,
+ characterData: true,
+ subtree: true,
+ });
+
+ return () => {
+ window.removeEventListener("resize", updateVerticalPosition);
+ window.removeEventListener("scroll", updateVerticalPosition, true);
+ resizeObserver?.disconnect();
+ mutationObserver?.disconnect();
+ };
+ }, [
+ autoAdjust,
+ isOpen,
+ positionNode,
+ scrollOverflow,
+ updateVerticalPosition,
+ ]);
useEffect(() => {
if (!dropdown.current) return;
@@ -340,10 +375,15 @@ const ContextualMenuDropdown = ({
...(constrainPanelWidth && positionStyle?.width
? { width: positionStyle.width, minWidth: 0, maxWidth: "none" }
: {}),
- ...(scrollOverflow
- ? { maxHeight, minHeight: "2rem", overflowX: "auto" }
+ ...(scrollOverflow && verticalLayout.maxHeight !== undefined
+ ? {
+ maxHeight: verticalLayout.maxHeight,
+ minHeight: Math.min(32, verticalLayout.maxHeight),
+ overflowX: "auto",
+ overflowY: "auto",
+ }
: {}),
- ...(verticalPosition === "top" ? { bottom: "0" } : {}),
+ ...(verticalLayout.position === "top" ? { bottom: "0" } : {}),
}}
{...props}
>