diff --git a/.changeset/tidy-popovers-float.md b/.changeset/tidy-popovers-float.md new file mode 100644 index 000000000..70cb170bf --- /dev/null +++ b/.changeset/tidy-popovers-float.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": patch +--- + +Keep Popover arrows visible during opening and closing transitions when content uses overflow clipping or scrolling. diff --git a/packages/kumo/src/components/popover/popover.tsx b/packages/kumo/src/components/popover/popover.tsx index bdaab3c3e..7e575760d 100644 --- a/packages/kumo/src/components/popover/popover.tsx +++ b/packages/kumo/src/components/popover/popover.tsx @@ -206,31 +206,40 @@ function PopoverContent({ sideOffset={sideOffset} positionMethod={positionMethod} > - .kumo-popover-popup[data-starting-style])]:scale-90", + "[&:has(>.kumo-popover-popup[data-ending-style])]:scale-90", + "[&:has(>.kumo-popover-popup[data-instant])]:duration-0", )} > - - - - {children} - + + + + {children} + + ); diff --git a/packages/kumo/src/components/popover/popover.visual.browser.test.tsx b/packages/kumo/src/components/popover/popover.visual.browser.test.tsx new file mode 100644 index 000000000..8a9da8c46 --- /dev/null +++ b/packages/kumo/src/components/popover/popover.visual.browser.test.tsx @@ -0,0 +1,93 @@ +import { describe, expect, test } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; +import { render } from "vitest-browser-react"; +import { Popover } from "./popover"; + +describe("Popover", () => { + test("keeps the arrow outside scrollable content during the opening scale transition", async () => { + const { getByRole } = await render( + <> + + + Open popover + +
Scrollable content
+
+
+ , + ); + + const trigger = getByRole("button", { name: "Open popover" }); + await trigger.click(); + + const popup = getByRole("dialog").element(); + await expect.poll(() => getComputedStyle(popup).opacity).toBe("1"); + + const motionWrapper = popup.parentElement; + const arrow = popup.querySelector("[aria-hidden='true']"); + + expect(motionWrapper).not.toBeNull(); + expect(arrow).not.toBeNull(); + expect(getComputedStyle(popup).scale).toBe("none"); + expect(getComputedStyle(popup).transitionProperty).toBe("opacity"); + + const wrapperScale = Number.parseFloat( + getComputedStyle(motionWrapper!).scale, + ); + expect(wrapperScale).toBeGreaterThanOrEqual(0.9); + expect(wrapperScale).toBeLessThan(1); + + expect(popup.scrollHeight).toBeGreaterThan(popup.clientHeight); + const arrowTopBeforeScroll = arrow!.getBoundingClientRect().top; + + popup.scrollTop = 64; + await new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + + expect(popup.scrollTop).toBe(64); + expect( + Math.abs(arrow!.getBoundingClientRect().top - arrowTopBeforeScroll), + ).toBeLessThan(1); + + const arrowRect = arrow!.getBoundingClientRect(); + const popupRect = popup.getBoundingClientRect(); + const hitTarget = document.elementFromPoint( + arrowRect.left + arrowRect.width / 2, + arrowRect.top + arrowRect.height / 4, + ); + + expect(arrowRect.top).toBeLessThan(popupRect.top); + expect(hitTarget === arrow || arrow!.contains(hitTarget)).toBe(true); + + await trigger.click(); + await expect.poll(() => popup.hasAttribute("data-ending-style")).toBe(true); + expect(document.body.contains(popup)).toBe(true); + expect(getComputedStyle(motionWrapper!).scale).toBe("0.9"); + await expect.element(getByRole("dialog")).not.toBeInTheDocument(); + + trigger.element().focus(); + await userEvent.keyboard("{Enter}"); + await expect.element(getByRole("dialog")).toBeInTheDocument(); + + const instantPopup = getByRole("dialog").element(); + await expect + .poll(() => instantPopup.hasAttribute("data-instant")) + .toBe(true); + expect( + getComputedStyle(instantPopup.parentElement!).transitionDuration, + ).toBe("0s"); + }); +});