Skip to content
Closed
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 .changeset/tidy-popovers-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/kumo": patch
---

Keep Popover arrows visible during opening and closing transitions when content uses overflow clipping or scrolling.
47 changes: 28 additions & 19 deletions packages/kumo/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,31 +206,40 @@ function PopoverContent({
sideOffset={sideOffset}
positionMethod={positionMethod}
>
<PopoverBase.Popup
<div
className={cn(
"flex origin-(--transform-origin) flex-col rounded-lg bg-kumo-base px-4 py-3 text-sm text-kumo-default",
"shadow-md outline outline-kumo-line",
"transition-[transform,scale,opacity] duration-150",
"data-starting-style:scale-90 data-starting-style:opacity-0",
"data-ending-style:scale-90 data-ending-style:opacity-0",
"data-instant:duration-0",
"kumo-popover-popup",
className,
"relative origin-(--transform-origin) overflow-visible transition-[scale] duration-150",
"[&:has(>.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",
)}
>
<PopoverBase.Arrow
<PopoverBase.Popup
className={cn(
"flex",
"data-[side=bottom]:-top-2",
"data-[side=left]:right-[-13px] data-[side=left]:rotate-90",
"data-[side=right]:left-[-13px] data-[side=right]:-rotate-90",
"data-[side=top]:-bottom-2 data-[side=top]:rotate-180",
"flex flex-col rounded-lg bg-kumo-base px-4 py-3 text-sm text-kumo-default",
"shadow-md outline outline-kumo-line",
"transition-opacity duration-150",
"data-starting-style:opacity-0",
"data-ending-style:opacity-0",
"data-instant:duration-0",
"kumo-popover-popup",
className,
)}
>
<ArrowSvg />
</PopoverBase.Arrow>
{children}
</PopoverBase.Popup>
<PopoverBase.Arrow
className={cn(
"flex",
"data-[side=bottom]:-top-2",
"data-[side=left]:right-[-13px] data-[side=left]:rotate-90",
"data-[side=right]:left-[-13px] data-[side=right]:-rotate-90",
"data-[side=top]:-bottom-2 data-[side=top]:rotate-180",
)}
>
<ArrowSvg />
</PopoverBase.Arrow>
{children}
</PopoverBase.Popup>
Comment thread
nocdn marked this conversation as resolved.
</div>
</PopoverBase.Positioner>
</PopoverBase.Portal>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<>
<style>{`
.kumo-popover-popup {
transition-duration: 50ms !important;
}

*:has(> .kumo-popover-popup:not([data-ending-style]):not([data-instant])) {
transition-duration: 10s !important;
}

*:has(> .kumo-popover-popup[data-ending-style]) {
transition-duration: 0s !important;
}
`}</style>
<Popover>
<Popover.Trigger>Open popover</Popover.Trigger>
<Popover.Content className="h-24 w-56 overflow-auto">
<div className="h-96 shrink-0">Scrollable content</div>
</Popover.Content>
</Popover>
</>,
);

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<HTMLElement>("[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<void>((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");
});
});
Loading