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
49 changes: 49 additions & 0 deletions apps/dashboard/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
65 changes: 65 additions & 0 deletions apps/dashboard/src/components/InfoTip.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<InfoTip label="Explain the metric">Shared UTC window.</InfoTip>);
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(
<div>
<InfoTip label="First explanation">First tooltip.</InfoTip>
<InfoTip label="Second explanation">Second tooltip.</InfoTip>
<button type="button">Outside</button>
</div>
);
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(<InfoTip label="Hover explanation">Hoverable tooltip.</InfoTip>);
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());
});
});
184 changes: 176 additions & 8 deletions apps/dashboard/src/components/InfoTip.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLSpanElement>(null);
const closeTimerRef = useRef<number | undefined>(undefined);
const [openReason, setOpenReason] = useState<OpenReason | null>(null);
const [position, setPosition] = useState<TooltipPosition>({ 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<string>).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(
<span
id={id}
ref={tooltipRef}
role="tooltip"
className="tooltip"
data-open=""
data-placement={position.placement}
data-ready={position.ready || undefined}
style={{ left: position.left, top: position.top }}
onPointerEnter={(event) => {
if (event.pointerType === "mouse" && openReason === "hover") cancelScheduledClose();
}}
onPointerLeave={(event) => {
if (event.pointerType === "mouse" && openReason === "hover") scheduleHoverClose();
}}
>
{children}
</span>,
document.body
) : null;

return (
<span className="info-tip">
<button
ref={triggerRef}
type="button"
className="info-trigger"
aria-label={label}
aria-expanded={open}
aria-describedby={open ? id : undefined}
onClick={() => setOpen((value) => !value)}
onBlur={() => setOpen(false)}
onClick={() => openReason === "click" ? close() : openAs("click")}
onFocus={() => openAs("focus")}
onBlur={close}
onPointerEnter={(event) => {
if (event.pointerType === "mouse" && openReason !== "click" && openReason !== "focus") openAs("hover");
}}
onPointerLeave={(event) => {
if (event.pointerType === "mouse" && openReason === "hover") scheduleHoverClose();
}}
>
i
</button>
<span id={id} role="tooltip" className="tooltip" data-open={open || undefined}>
{children}
</span>
{tooltip}
</span>
);
}
Loading
Loading