Skip to content

Commit 764300e

Browse files
committed
fix(tooltip): update tooltip plugin + tests
1 parent 5ba864f commit 764300e

File tree

5 files changed

+463
-401
lines changed

5 files changed

+463
-401
lines changed

e2e/tooltip.test.ts

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import { expect, type Page, test } from "@playwright/test";
2+
3+
// ============================================================================
4+
// HELPERS
5+
// ============================================================================
6+
7+
const TOOLTIP_OFFSET = 8; // Matches TOOLTIP_OFFSET_X / TOOLTIP_OFFSET_Y in tooltip.tsx
8+
9+
/**
10+
* Tolerance in pixels for position assertions.
11+
* We allow some slack because uPlot snaps the cursor to data points,
12+
* so the actual cursor position may differ slightly from where we hovered.
13+
*/
14+
const POSITION_TOLERANCE = 30;
15+
16+
/**
17+
* Get the u-over canvas element (the chart's interactive overlay area)
18+
* within a given test section.
19+
*/
20+
function getChartOverlay(page: Page, sectionTestId: string) {
21+
return page.getByTestId(sectionTestId).locator(".u-over");
22+
}
23+
24+
/**
25+
* Hover over the chart overlay at a relative position and wait for the tooltip to appear.
26+
*/
27+
async function hoverChartAndWaitForTooltip(
28+
page: Page,
29+
sectionTestId: string,
30+
tooltipId: string,
31+
position: { x: number; y: number },
32+
) {
33+
const overlay = getChartOverlay(page, sectionTestId);
34+
const overlayBox = await overlay.boundingBox();
35+
expect(overlayBox).not.toBeNull();
36+
37+
await page.mouse.move(overlayBox!.x + position.x, overlayBox!.y + position.y);
38+
39+
const tooltip = page.locator(`#${tooltipId}`);
40+
await tooltip.waitFor({ state: "visible", timeout: 5000 });
41+
42+
return { overlay: overlayBox!, tooltip };
43+
}
44+
45+
// ============================================================================
46+
// TESTS
47+
// ============================================================================
48+
49+
test.describe("Tooltip Positioning", () => {
50+
test.beforeEach(async ({ page }) => {
51+
await page.goto("/tooltip-test");
52+
await page.waitForSelector(".u-wrap");
53+
});
54+
55+
// -------------------------------------------------------------------------
56+
// ABSOLUTE POSITIONING (default)
57+
// -------------------------------------------------------------------------
58+
test.describe("Absolute Positioning", () => {
59+
test("tooltip appears near cursor on hover", async ({ page }) => {
60+
const hoverX = 200;
61+
const hoverY = 100;
62+
63+
const { overlay, tooltip } = await hoverChartAndWaitForTooltip(
64+
page,
65+
"absolute-chart-section",
66+
"test-tooltip-absolute",
67+
{ x: hoverX, y: hoverY },
68+
);
69+
70+
const tooltipBox = await tooltip.boundingBox();
71+
expect(tooltipBox).not.toBeNull();
72+
73+
// The hover point in viewport coordinates
74+
const hoverViewportX = overlay.x + hoverX;
75+
const hoverViewportY = overlay.y + hoverY;
76+
77+
// With placement "top-right", tooltip should be:
78+
// - To the RIGHT of cursor: tooltip.left ≈ cursorX + OFFSET
79+
// - ABOVE cursor: tooltip.bottom ≈ cursorY - OFFSET
80+
// Allow tolerance since uPlot snaps cursor to nearest data point
81+
expect(tooltipBox!.x).toBeGreaterThan(hoverViewportX - POSITION_TOLERANCE);
82+
expect(tooltipBox!.x).toBeLessThan(hoverViewportX + tooltipBox!.width + POSITION_TOLERANCE);
83+
expect(tooltipBox!.y).toBeGreaterThan(
84+
hoverViewportY - tooltipBox!.height - POSITION_TOLERANCE,
85+
);
86+
expect(tooltipBox!.y).toBeLessThan(hoverViewportY + POSITION_TOLERANCE);
87+
});
88+
89+
test("tooltip position is relative to chart, not offset by container document position", async ({
90+
page,
91+
}) => {
92+
// This is the KEY test that catches the bug.
93+
// The chart is wrapped with 100px top padding and 80px left padding,
94+
// so its container is NOT at the document origin.
95+
//
96+
// With the bug: tooltip uses document-absolute coordinates but renders
97+
// inside a position:relative container, causing a double-offset.
98+
// The tooltip would appear ~100px too far down and ~80px too far right.
99+
100+
const hoverX = 150;
101+
// Use a Y position far enough from the top so the tooltip doesn't flip
102+
const hoverY = 200;
103+
104+
const { overlay, tooltip } = await hoverChartAndWaitForTooltip(
105+
page,
106+
"absolute-chart-section",
107+
"test-tooltip-absolute",
108+
{ x: hoverX, y: hoverY },
109+
);
110+
111+
const tooltipBox = await tooltip.boundingBox();
112+
expect(tooltipBox).not.toBeNull();
113+
114+
const hoverViewportX = overlay.x + hoverX;
115+
const hoverViewportY = overlay.y + hoverY;
116+
117+
// With placement "top-right":
118+
// Tooltip left edge should be near (cursorViewportX + 8)
119+
// Tooltip bottom edge should be near (cursorViewportY - 8)
120+
//
121+
// With the bug, the tooltip's actual viewport position would be offset
122+
// by the container's document position (roughly +80px X, +100px Y on top
123+
// of where it should be), pushing it far from the cursor.
124+
//
125+
// We assert the tooltip's left edge is within a reasonable range of
126+
// where "top-right" placement should put it.
127+
const expectedTooltipLeft = hoverViewportX + TOOLTIP_OFFSET;
128+
expect(tooltipBox!.x).toBeGreaterThan(expectedTooltipLeft - POSITION_TOLERANCE);
129+
expect(tooltipBox!.x).toBeLessThan(expectedTooltipLeft + POSITION_TOLERANCE);
130+
131+
// Verify Y-axis: tooltip should be near the cursor vertically.
132+
// Note: on first render, tooltipRoot.offsetHeight may be 0,
133+
// causing the tooltip to appear slightly below ideal "top" placement.
134+
// We verify the tooltip is reasonably close to the cursor, not offset
135+
// by the container's document position (which would be 100+ px off).
136+
expect(tooltipBox!.y).toBeGreaterThan(
137+
hoverViewportY - tooltipBox!.height - POSITION_TOLERANCE,
138+
);
139+
expect(tooltipBox!.y).toBeLessThan(hoverViewportY + POSITION_TOLERANCE);
140+
});
141+
142+
test("tooltip follows cursor as it moves across chart", async ({ page }) => {
143+
const overlay = getChartOverlay(page, "absolute-chart-section");
144+
const overlayBox = await overlay.boundingBox();
145+
expect(overlayBox).not.toBeNull();
146+
147+
// Hover at position A
148+
const posA = { x: 100, y: 100 };
149+
await page.mouse.move(overlayBox!.x + posA.x, overlayBox!.y + posA.y);
150+
const tooltip = page.locator("#test-tooltip-absolute");
151+
await tooltip.waitFor({ state: "visible", timeout: 5000 });
152+
const boxA = await tooltip.boundingBox();
153+
expect(boxA).not.toBeNull();
154+
155+
// Move to position B (150px to the right)
156+
const deltaX = 150;
157+
const posB = { x: posA.x + deltaX, y: posA.y };
158+
await page.mouse.move(overlayBox!.x + posB.x, overlayBox!.y + posB.y);
159+
160+
// Wait a frame for the tooltip to update
161+
await page.waitForTimeout(100);
162+
163+
const boxB = await tooltip.boundingBox();
164+
expect(boxB).not.toBeNull();
165+
166+
// Tooltip should have moved roughly in the same direction as the cursor.
167+
// It won't be exactly deltaX because uPlot snaps to data points,
168+
// but it should have moved to the right.
169+
expect(boxB!.x).toBeGreaterThan(boxA!.x);
170+
});
171+
});
172+
173+
// -------------------------------------------------------------------------
174+
// FIXED POSITIONING (dialog)
175+
// -------------------------------------------------------------------------
176+
test.describe("Fixed Positioning (Dialog)", () => {
177+
test("tooltip appears near cursor in dialog", async ({ page }) => {
178+
// Open the dialog
179+
await page.getByTestId("open-dialog-btn").click();
180+
await page.waitForSelector("#test-tooltip-fixed", { state: "hidden" }).catch(() => {});
181+
// Wait for the chart inside dialog to render
182+
const dialogChart = page.getByTestId("fixed-chart-container").locator(".u-over");
183+
await dialogChart.waitFor({ state: "visible", timeout: 5000 });
184+
185+
const overlayBox = await dialogChart.boundingBox();
186+
expect(overlayBox).not.toBeNull();
187+
188+
// Hover over center of chart
189+
const hoverX = Math.floor(overlayBox!.width / 2);
190+
const hoverY = Math.floor(overlayBox!.height / 2);
191+
await page.mouse.move(overlayBox!.x + hoverX, overlayBox!.y + hoverY);
192+
193+
const tooltip = page.locator("#test-tooltip-fixed");
194+
await tooltip.waitFor({ state: "visible", timeout: 5000 });
195+
196+
const tooltipBox = await tooltip.boundingBox();
197+
expect(tooltipBox).not.toBeNull();
198+
199+
const hoverViewportX = overlayBox!.x + hoverX;
200+
const hoverViewportY = overlayBox!.y + hoverY;
201+
202+
// Tooltip should be near the cursor in viewport coordinates
203+
expect(tooltipBox!.x).toBeGreaterThan(hoverViewportX - POSITION_TOLERANCE);
204+
expect(tooltipBox!.x).toBeLessThan(hoverViewportX + tooltipBox!.width + POSITION_TOLERANCE);
205+
expect(tooltipBox!.y).toBeGreaterThan(
206+
hoverViewportY - tooltipBox!.height - POSITION_TOLERANCE,
207+
);
208+
expect(tooltipBox!.y).toBeLessThan(hoverViewportY + POSITION_TOLERANCE);
209+
});
210+
211+
test("tooltip uses fixed positioning in dialog", async ({ page }) => {
212+
// Open the dialog
213+
await page.getByTestId("open-dialog-btn").click();
214+
const dialogChart = page.getByTestId("fixed-chart-container").locator(".u-over");
215+
await dialogChart.waitFor({ state: "visible", timeout: 5000 });
216+
217+
const overlayBox = await dialogChart.boundingBox();
218+
expect(overlayBox).not.toBeNull();
219+
220+
// Hover to show tooltip
221+
await page.mouse.move(
222+
overlayBox!.x + Math.floor(overlayBox!.width / 2),
223+
overlayBox!.y + Math.floor(overlayBox!.height / 2),
224+
);
225+
226+
const tooltip = page.locator("#test-tooltip-fixed");
227+
await tooltip.waitFor({ state: "visible", timeout: 5000 });
228+
229+
// Verify fixed positioning
230+
const position = await tooltip.evaluate((el) => getComputedStyle(el).position);
231+
expect(position).toBe("fixed");
232+
});
233+
});
234+
235+
// -------------------------------------------------------------------------
236+
// CLEANUP
237+
// -------------------------------------------------------------------------
238+
test("tooltip disappears when cursor leaves chart", async ({ page }) => {
239+
const overlay = getChartOverlay(page, "absolute-chart-section");
240+
const overlayBox = await overlay.boundingBox();
241+
expect(overlayBox).not.toBeNull();
242+
243+
// Hover to show tooltip
244+
await page.mouse.move(overlayBox!.x + 200, overlayBox!.y + 100);
245+
const tooltip = page.locator("#test-tooltip-absolute");
246+
await tooltip.waitFor({ state: "visible", timeout: 5000 });
247+
248+
// Move cursor far away from the chart
249+
await page.mouse.move(0, 0);
250+
251+
// Tooltip should disappear
252+
await expect(async () => {
253+
const isVisible = await tooltip.isVisible();
254+
expect(isVisible).toBe(false);
255+
}).toPass({ timeout: 5000 });
256+
});
257+
});

playground/App.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { PluginsPage } from "./pages/Plugins";
1212
import { ResizeBugTest } from "./pages/ResizeBugTest";
1313
import { Streaming } from "./pages/Streaming";
1414
import { TooltipDialogPage } from "./pages/TooltipDialog";
15+
import { TooltipTest } from "./pages/TooltipTest";
1516
import { Sidebar } from "./Sidebar";
1617

1718
const RootLayout: Component<ParentProps> = (props) => (
@@ -33,8 +34,9 @@ export const App: Component = () => {
3334
<Route path="/multi-plot" component={MultiPlotPage} />
3435
<Route path="/children-placement" component={ChildrenPlacementPlayground} />
3536
<Route path="/dynamic-resize" component={DynamicResize} />
36-
{/* Test page for E2E tests - not linked in sidebar */}
37+
{/* Test pages for E2E tests - not linked in sidebar */}
3738
<Route path="/resize-bug-test" component={ResizeBugTest} />
39+
<Route path="/tooltip-test" component={TooltipTest} />
3840
<Route path="*" component={ErrorPage} />
3941
</Router>
4042
);

0 commit comments

Comments
 (0)