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} >