diff --git a/apps/dashboard/e2e/dashboard.spec.ts b/apps/dashboard/e2e/dashboard.spec.ts index 9a2b133..6956051 100644 --- a/apps/dashboard/e2e/dashboard.spec.ts +++ b/apps/dashboard/e2e/dashboard.spec.ts @@ -105,6 +105,55 @@ test.describe("mobile portrait overview", () => { await expect(page.getByTestId("panel-snapchain")).toContainText("58,429"); await context.setOffline(false); }); + + test("keeps every explanatory tooltip inside the visual viewport", async ({ browser }) => { + const viewports = [ + { width: 320, height: 568, safe: { bottom: 18, left: 28, right: 24, top: 20 } }, + { width: 360, height: 844, safe: { bottom: 12, left: 12, right: 12, top: 12 } }, + { width: 390, height: 844, safe: { bottom: 12, left: 12, right: 12, top: 12 } }, + { width: 430, height: 844, safe: { bottom: 12, left: 12, right: 12, top: 12 } } + ]; + for (const { width, height, safe } of viewports) { + const context = await browser.newContext({ viewport: { width, height } }); + const page = await context.newPage(); + await page.goto("/?demo=1"); + const triggers = page.locator(".info-trigger"); + await expect(triggers).toHaveCount(4); + + for (let index = 0; index < 4; index += 1) { + const trigger = triggers.nth(index); + await trigger.click(); + if (index === 0) { + await page.evaluate((insets) => { + for (const [side, value] of Object.entries(insets)) { + document.documentElement.style.setProperty(`--fc-safe-area-inset-${side}`, `${value}px`); + } + }, safe); + } + const tooltipId = await trigger.getAttribute("aria-describedby"); + expect(tooltipId).not.toBeNull(); + const tooltip = page.locator(`[id="${tooltipId}"]`); + await expect(tooltip).toBeVisible(); + const bounds = await tooltip.boundingBox(); + const viewport = await page.evaluate(() => ({ + height: window.visualViewport?.height ?? window.innerHeight, + left: window.visualViewport?.offsetLeft ?? 0, + top: window.visualViewport?.offsetTop ?? 0, + width: window.visualViewport?.width ?? window.innerWidth + })); + expect(bounds).not.toBeNull(); + expect(bounds!.x).toBeGreaterThanOrEqual(viewport.left + safe.left - 1); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(viewport.left + viewport.width - safe.right + 1); + expect(bounds!.y).toBeGreaterThanOrEqual(viewport.top + safe.top - 1); + expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(viewport.top + viewport.height - safe.bottom + 1); + expect(await tooltip.evaluate((element) => element.parentElement === document.body)).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); + await page.keyboard.press("Escape"); + await expect(trigger).not.toHaveAttribute("aria-describedby", /.+/); + } + await context.close(); + } + }); }); test("switches to side-by-side protocol comparison on desktop", async ({ page }) => { diff --git a/apps/dashboard/src/components/InfoTip.test.tsx b/apps/dashboard/src/components/InfoTip.test.tsx new file mode 100644 index 0000000..81e5561 --- /dev/null +++ b/apps/dashboard/src/components/InfoTip.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { InfoTip } from "./InfoTip"; + +describe("InfoTip", () => { + it("keeps visual and described state synchronized for focus and Escape", async () => { + const user = userEvent.setup(); + render(Shared UTC window.); + const trigger = screen.getByRole("button", { name: "Explain the metric" }); + + await user.tab(); + const tooltip = screen.getByRole("tooltip"); + expect(trigger).toHaveFocus(); + expect(trigger).toHaveAttribute("aria-describedby", tooltip.id); + + await user.hover(trigger); + await user.unhover(trigger); + expect(trigger).toHaveAttribute("aria-describedby", tooltip.id); + expect(screen.getByRole("tooltip")).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + expect(trigger).toHaveFocus(); + expect(trigger).not.toHaveAttribute("aria-describedby"); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("toggles on click, closes outside, and permits only one open tip", async () => { + const user = userEvent.setup(); + render( +
+ First tooltip. + Second tooltip. + +
+ ); + const first = screen.getByRole("button", { name: "First explanation" }); + const second = screen.getByRole("button", { name: "Second explanation" }); + + await user.click(first); + expect(screen.getByRole("tooltip")).toHaveTextContent("First tooltip."); + await user.click(second); + expect(screen.getByRole("tooltip")).toHaveTextContent("Second tooltip."); + expect(first).not.toHaveAttribute("aria-describedby"); + + await user.click(screen.getByRole("button", { name: "Outside" })); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("remains hoverable before dismissing after the pointer leaves", async () => { + const user = userEvent.setup(); + render(Hoverable tooltip.); + const trigger = screen.getByRole("button", { name: "Hover explanation" }); + + await user.hover(trigger); + const tooltip = screen.getByRole("tooltip"); + await user.unhover(trigger); + await user.hover(tooltip); + expect(tooltip).toBeInTheDocument(); + await user.unhover(tooltip); + await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument()); + }); +}); diff --git a/apps/dashboard/src/components/InfoTip.tsx b/apps/dashboard/src/components/InfoTip.tsx index cfdbb2a..b22c960 100644 --- a/apps/dashboard/src/components/InfoTip.tsx +++ b/apps/dashboard/src/components/InfoTip.tsx @@ -1,24 +1,192 @@ -import { useId, useState } from "react"; +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +type OpenReason = "click" | "focus" | "hover"; +type TooltipPosition = Readonly<{ left: number; placement: "above" | "below"; ready: boolean; top: number }>; + +const OPEN_EVENT = "snapmeter:info-tip-open"; +const VIEWPORT_GUTTER_PX = 12; +const TRIGGER_GAP_PX = 8; + +function cssPixels(style: CSSStyleDeclaration, property: string): number { + const value = Number.parseFloat(style.getPropertyValue(property)); + return Number.isFinite(value) ? Math.max(0, value) : 0; +} export function InfoTip({ label, children }: { label: string; children: React.ReactNode }): React.JSX.Element { const id = useId(); - const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + const closeTimerRef = useRef(undefined); + const [openReason, setOpenReason] = useState(null); + const [position, setPosition] = useState({ left: 0, placement: "above", ready: false, top: 0 }); + const open = openReason !== null; + + const cancelScheduledClose = useCallback((): void => { + if (closeTimerRef.current !== undefined) window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = undefined; + }, []); + + const close = useCallback((): void => { + cancelScheduledClose(); + setOpenReason(null); + }, [cancelScheduledClose]); + + const openAs = useCallback((reason: OpenReason): void => { + cancelScheduledClose(); + setOpenReason(reason); + document.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: id })); + }, [cancelScheduledClose, id]); + + const scheduleHoverClose = useCallback((): void => { + cancelScheduledClose(); + closeTimerRef.current = window.setTimeout(() => { + setOpenReason((current) => current === "hover" ? null : current); + closeTimerRef.current = undefined; + }, 100); + }, [cancelScheduledClose]); + + const updatePosition = useCallback((): void => { + const trigger = triggerRef.current; + const tooltip = tooltipRef.current; + if (!trigger || !tooltip) return; + + const viewport = window.visualViewport; + const viewportLeft = viewport?.offsetLeft ?? 0; + const viewportTop = viewport?.offsetTop ?? 0; + const viewportRight = viewportLeft + (viewport?.width ?? window.innerWidth); + const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight); + const rootStyle = window.getComputedStyle(document.documentElement); + const minLeft = viewportLeft + Math.max(VIEWPORT_GUTTER_PX, cssPixels(rootStyle, "--fc-safe-area-inset-left")); + const maxRight = viewportRight - Math.max(VIEWPORT_GUTTER_PX, cssPixels(rootStyle, "--fc-safe-area-inset-right")); + const minTop = viewportTop + Math.max(VIEWPORT_GUTTER_PX, cssPixels(rootStyle, "--fc-safe-area-inset-top")); + const maxBottom = viewportBottom - Math.max(VIEWPORT_GUTTER_PX, cssPixels(rootStyle, "--fc-safe-area-inset-bottom")); + const usableWidth = Math.max(1, maxRight - minLeft); + const usableHeight = Math.max(1, maxBottom - minTop); + + // Constrain the overlay before measuring it. The visual viewport can be + // narrower than 100vw in a Mini App, and Farcaster safe-area insets make + // the actually usable rectangle smaller again. + tooltip.style.setProperty("--tooltip-max-width", `${usableWidth}px`); + tooltip.style.setProperty("--tooltip-max-height", `${usableHeight}px`); + const triggerRect = trigger.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const maxLeft = Math.max(minLeft, maxRight - tooltipRect.width); + const left = Math.min(maxLeft, Math.max(minLeft, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2)); + const aboveTop = triggerRect.top - TRIGGER_GAP_PX - tooltipRect.height; + const belowTop = triggerRect.bottom + TRIGGER_GAP_PX; + const fitsAbove = aboveTop >= minTop; + const fitsBelow = belowTop + tooltipRect.height <= maxBottom; + const placement = fitsAbove || (!fitsBelow && triggerRect.top - minTop >= maxBottom - triggerRect.bottom) + ? "above" + : "below"; + const desiredTop = placement === "above" ? aboveTop : belowTop; + const maxTop = Math.max(minTop, maxBottom - tooltipRect.height); + const top = Math.min(maxTop, Math.max(minTop, desiredTop)); + + setPosition({ left, placement, ready: true, top }); + }, []); + + useLayoutEffect(() => { + if (!open) return; + setPosition((current) => ({ ...current, ready: false })); + updatePosition(); + }, [children, open, updatePosition]); + + useEffect(() => { + if (!open) return; + let frame = 0; + const schedulePosition = (): void => { + window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(updatePosition); + }; + const viewport = window.visualViewport; + const rootStyleObserver = new MutationObserver(schedulePosition); + rootStyleObserver.observe(document.documentElement, { attributeFilter: ["style"], attributes: true }); + window.addEventListener("resize", schedulePosition); + window.addEventListener("scroll", schedulePosition, true); + viewport?.addEventListener("resize", schedulePosition); + viewport?.addEventListener("scroll", schedulePosition); + return () => { + window.cancelAnimationFrame(frame); + rootStyleObserver.disconnect(); + window.removeEventListener("resize", schedulePosition); + window.removeEventListener("scroll", schedulePosition, true); + viewport?.removeEventListener("resize", schedulePosition); + viewport?.removeEventListener("scroll", schedulePosition); + }; + }, [open, updatePosition]); + + useEffect(() => { + const handleOtherOpen = (event: Event): void => { + if ((event as CustomEvent).detail !== id) close(); + }; + document.addEventListener(OPEN_EVENT, handleOtherOpen); + return () => document.removeEventListener(OPEN_EVENT, handleOtherOpen); + }, [close, id]); + + useEffect(() => { + if (!open) return; + const handlePointerDown = (event: PointerEvent): void => { + const target = event.target as Node | null; + if (!triggerRef.current?.contains(target) && !tooltipRef.current?.contains(target)) close(); + }; + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") close(); + }; + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [close, open]); + + useEffect(() => () => cancelScheduledClose(), [cancelScheduledClose]); + + const tooltip = open ? createPortal( + { + if (event.pointerType === "mouse" && openReason === "hover") cancelScheduledClose(); + }} + onPointerLeave={(event) => { + if (event.pointerType === "mouse" && openReason === "hover") scheduleHoverClose(); + }} + > + {children} + , + document.body + ) : null; + return ( - - {children} - + {tooltip} ); } diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 595d62f..2afa290 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -669,8 +669,8 @@ canvas:focus-visible { position: relative; display: inline-grid; place-items: center; - width: 16px; - height: 16px; + width: 24px; + height: 24px; padding: 0; border: 1px solid rgba(255, 255, 255, 0.32); border-radius: 50%; @@ -683,57 +683,37 @@ canvas:focus-visible { line-height: 1; } -.info-trigger::after { - content: ""; - position: absolute; - inset: -10px; -} - .tooltip { - position: absolute; - z-index: 90; - right: -3px; - bottom: calc(100% + 8px); - width: min(260px, calc(100vw - 36px)); - padding: 9px 10px; + position: fixed; + z-index: 110; + width: min(260px, var(--tooltip-max-width, calc(100vw - 24px))); + max-width: var(--tooltip-max-width, calc(100vw - 24px)); + max-height: var(--tooltip-max-height, calc(100dvh - 24px)); + overflow: auto; + padding: 10px 11px; border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 5px; color: #e7edf2; background: #131820; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); font-family: Inter, ui-sans-serif, system-ui, sans-serif; - font-size: 0.7rem; + font-size: 0.8125rem; font-style: normal; font-weight: 450; letter-spacing: 0; - line-height: 1.4; + line-height: 1.45; text-align: left; text-transform: none; white-space: normal; + pointer-events: auto; opacity: 0; visibility: hidden; - transform: translateY(4px); - transition: opacity 120ms ease, transform 120ms ease, visibility 120ms; + transition: opacity 120ms ease, visibility 120ms; } -.tooltip[data-open], -.info-trigger:hover + .tooltip, -.info-trigger:focus-visible + .tooltip { +.tooltip[data-open][data-ready] { opacity: 1; visibility: visible; - transform: translateY(0); -} - -.metric-label .tooltip { - right: auto; - left: 50%; - transform: translate(-56%, 4px); -} - -.metric-label .tooltip[data-open], -.metric-label .info-trigger:hover + .tooltip, -.metric-label .info-trigger:focus-visible + .tooltip { - transform: translate(-56%, 0); } /* Deeper analytics */