Skip to content
53 changes: 52 additions & 1 deletion apps/web/src/components/ui/tooltip.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, it, vi } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";
import { commands, page, userEvent } from "vite-plus/test/browser";

import "~/index.css";
import { render } from "../../../test/render";
Expand Down Expand Up @@ -41,3 +41,54 @@ it.each([
if (c.keptOpen) await expect.element(page.getByText("Description")).toBeVisible();
else await expect.element(page.getByText("Description")).not.toBeInTheDocument();
});

it("stays open while the pointer selects its text, then closes when the pointer leaves", async () => {
await using screen = await render(
<Tooltip>
<TooltipTrigger>File</TooltipTrigger>
<TooltipPopup>/tmp/project/docs/notes.md</TooltipPopup>
</Tooltip>,
);
await userEvent.hover(screen.getByRole("button", { name: "File" }));
const text = page.getByText("/tmp/project/docs/notes.md");
await expect.element(text).toBeVisible();

// Forced so the pointer lands on the popup even when it ignores pointer events.
await userEvent.tripleClick(text, { force: true });
expect(window.getSelection()?.toString().trim()).toBe("/tmp/project/docs/notes.md");
await expect.element(text).toBeVisible();

await commands.resetPointer();
await expect.element(text).not.toBeInTheDocument();
});

it("stays open when its own content scrolls inside a scroll dismiss area", async () => {
await using screen = await render(
<TooltipScrollDismissArea data-testid="timeline" className="h-32 overflow-y-auto">
<Tooltip>
<TooltipTrigger>Pull requests</TooltipTrigger>
<TooltipPopup>
<div data-testid="list" className="max-h-10 overflow-y-auto">
<div className="h-40">Pull request list</div>
</div>
</TooltipPopup>
</Tooltip>
<div className="h-96" />
</TooltipScrollDismissArea>,
);
const trigger = screen.getByRole("button", { name: "Pull requests" });
await userEvent.hover(trigger);
const list = page.getByTestId("list");
await expect.element(list).toBeVisible();

const listElement = list.element();
const listScrolled = new Promise((resolve) =>
listElement.addEventListener("scroll", resolve, { once: true }),
);
listElement.scrollTop = 20;
await listScrolled;
await expect.element(trigger).toHaveAttribute("data-popup-open");

screen.getByTestId("timeline").element().scrollTop = 20;
await expect.element(list).not.toBeInTheDocument();
});
7 changes: 5 additions & 2 deletions apps/web/src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ function TooltipScrollDismissArea({ onScrollCapture, ...props }: ComponentProps<
{...props}
onScrollCapture={(event) => {
onScrollCapture?.(event);
tracker.dismissUnfocused();
// React bubbles scrolls from portaled popups here; only this area's own scrolls dismiss.
if (event.target instanceof Node && event.currentTarget.contains(event.target)) {
tracker.dismissUnfocused();
}
}}
/>
</TooltipScrollContext>
Expand Down Expand Up @@ -140,7 +143,7 @@ function TooltipPopup({
<TooltipPositioner align={align} anchor={anchor} side={side} sideOffset={sideOffset}>
<TooltipPrimitive.Popup
className={cn(
"relative flex origin-(--transform-origin) text-balance rounded-md text-popover-foreground text-xs transition-[scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-md)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:duration-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
"pointer-events-auto relative flex origin-(--transform-origin) text-balance rounded-md text-popover-foreground text-xs transition-[scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-md)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 data-instant:duration-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
variant === "glass"
? "dropdown-glass shadow-xl shadow-black/25 before:hidden"
: "border bg-popover not-dark:bg-clip-padding shadow-md/5",
Expand Down
Loading