Skip to content
Merged
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
52 changes: 40 additions & 12 deletions frontend/src/components/InfoTooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import React, { useState, useRef, useEffect } from "react";
import React, { useId, useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";

interface InfoTooltipProps {
Expand All @@ -16,44 +16,72 @@ export function InfoTooltip({
}: InfoTooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const tooltipId = useId();

// Close on escape
useEffect(() => {
if (!isVisible) return;

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setIsVisible(false);
};
if (isVisible) {
window.addEventListener("keydown", handleKeyDown);
}
return () => window.removeEventListener("keydown", handleKeyDown);

const handleOutsidePointer = (event: MouseEvent | TouchEvent) => {
if (
triggerRef.current &&
contentRef.current &&
!triggerRef.current.contains(event.target as Node) &&
!contentRef.current.contains(event.target as Node)
) {
setIsVisible(false);
}
};

window.addEventListener("keydown", handleKeyDown);
document.addEventListener("mousedown", handleOutsidePointer);
document.addEventListener("touchstart", handleOutsidePointer);

return () => {
window.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("mousedown", handleOutsidePointer);
document.removeEventListener("touchstart", handleOutsidePointer);
};
}, [isVisible]);

return (
<div className={`relative inline-flex items-center ${className}`}>
<div
className={`relative inline-flex items-center ${className}`}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocusCapture={() => setIsVisible(true)}
onBlurCapture={() => setIsVisible(false)}
>
<button
ref={triggerRef}
type="button"
className="group/tooltip cursor-help rounded px-0.5 border-b border-dotted border-[var(--text-secondary)]/40 decoration-[var(--text-secondary)]/40 transition-all duration-300 text-[var(--text-secondary)] hover:text-[var(--pluto-600)] hover:border-[var(--pluto-600)] hover:bg-[var(--pluto-50)] focus-visible:text-[var(--pluto-600)] focus-visible:border-[var(--pluto-600)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--pluto-300)] focus-visible:ring-offset-2 focus-visible:ring-offset-white"
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
onClick={() => setIsVisible(true)}
aria-label="More information"
aria-describedby={isVisible ? "tooltip-content" : undefined}
aria-expanded={isVisible}
aria-controls={tooltipId}
aria-describedby={isVisible ? tooltipId : undefined}
>
{children}
</button>

<AnimatePresence>
{isVisible && (
<motion.div
id="tooltip-content"
ref={contentRef}
id={tooltipId}
role="tooltip"
initial={{ opacity: 0, scale: 0.95, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 8 }}
transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}
className="absolute bottom-full left-1/2 z-[100] mb-4 w-64 -translate-x-1/2 rounded-2xl border border-[var(--pluto-100)] bg-white/95 p-4 text-[12px] font-medium leading-relaxed text-[var(--text-primary)] shadow-[0_20px_50px_rgba(0,0,0,0.12)] backdrop-blur-md"
className="absolute bottom-full left-1/2 z-[100] mb-4 w-[min(18rem,calc(100vw-2rem))] max-w-full -translate-x-1/2 rounded-2xl border border-[var(--pluto-100)] bg-white/95 p-4 text-[12px] font-medium leading-relaxed text-[var(--text-primary)] shadow-[0_20px_50px_rgba(0,0,0,0.12)] backdrop-blur-md max-h-[65vh] overflow-auto"
>
<div className="relative z-10">{content}</div>
{/* Arrow */}
Expand Down
56 changes: 56 additions & 0 deletions frontend/tests/e2e/tooltip-responsiveness.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { expect, test, type Page } from "@playwright/test";

const MERCHANT_API_KEY = "sk_test_tooltip_responsiveness_key";

async function seedMerchantSession(page: Page) {
await page.addInitScript(
({ apiKey, token }) => {
window.localStorage.setItem("merchant_api_key", apiKey);
window.localStorage.setItem("merchant_token", token);
document.cookie = "NEXT_LOCALE=en; path=/";
},
{
apiKey: MERCHANT_API_KEY,
token: "eyJhbGciOiJub25lIn0.eyJpZCI6InRlc3QtaWQiLCJlbWFpbCI6InRlc3RAdGx1dG8uY2MiLCJleHAiOjE3NzY5ODI2ODl9.",
},
);
}

async function mockHealthApi(page: Page) {
await page.route("**/api/health", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ status: "ok" }),
});
});
}

test.describe("Tooltip responsiveness", () => {
test.beforeEach(async ({ page }) => {
await seedMerchantSession(page);
await mockHealthApi(page);
});

test("shows tooltip content and keeps it inside the viewport", async ({ page }) => {
await page.goto("/dashboard/create");

const tooltipButton = page.getByRole("button", { name: "More information" }).first();
await tooltipButton.click();

const tooltip = page.getByRole("tooltip");
await expect(tooltip).toBeVisible({ timeout: 10000 });

const box = await tooltip.boundingBox();
const viewport = page.viewportSize();

expect(box).not.toBeNull();
expect(viewport).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.y).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width);
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height);

await expect(tooltip).toHaveCSS("background-color", "rgba(255, 255, 255, 0.95)");
});
});
Loading