diff --git a/app/(marketing)/_components/__tests__/markets-widget.test.tsx b/app/(marketing)/_components/__tests__/markets-widget.test.tsx index 770b5ade..c48cf407 100644 --- a/app/(marketing)/_components/__tests__/markets-widget.test.tsx +++ b/app/(marketing)/_components/__tests__/markets-widget.test.tsx @@ -7,9 +7,23 @@ */ import React from "react"; -import { render, screen, act } from "@testing-library/react"; +import { render, screen, act, fireEvent } from "@testing-library/react"; import { useFollowsStore } from "@/app/state/follows"; +Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + // ── minimal stub for the Card component ───────────────────────────────────── jest.mock("@/components/ui/card", () => ({ Card: ({ children, className, style }: React.PropsWithChildren<{ className?: string; style?: React.CSSProperties }>) => ( @@ -25,7 +39,6 @@ function resetFollows() { } // We import the file under test AFTER mocks are in place. -// eslint-disable-next-line @typescript-eslint/no-var-requires const { MarketsWidget } = require("../markets-widget"); describe("MarketsWidget – following indicator", () => { @@ -65,3 +78,76 @@ describe("MarketsWidget – following indicator", () => { expect(indicators).toHaveLength(1); }); }); + +describe("MarketsWidget – accessible market tooltips", () => { + beforeEach(() => { + resetFollows(); + jest.useRealTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("shows pool details after the hover delay and links the trigger with aria-describedby", () => { + jest.useFakeTimers(); + render(); + + const trigger = screen.getByTestId("market-btc-price-pool-trigger"); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + + fireEvent.mouseEnter(trigger); + act(() => { + jest.advanceTimersByTime(299); + }); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1); + }); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toHaveTextContent("1,245 USDC has been committed"); + expect(trigger).toHaveAttribute("aria-describedby", tooltip.id); + + fireEvent.mouseLeave(trigger); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("opens on keyboard focus and closes with Escape", () => { + render(); + + const trigger = screen.getByTestId("market-btc-price-yes-trigger"); + fireEvent.focus(trigger); + + expect(screen.getByRole("tooltip")).toHaveTextContent( + "Current implied probability for the Yes outcome", + ); + + fireEvent.keyDown(trigger, { key: "Escape" }); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("supports touch long-press without opening before the configured delay", () => { + jest.useFakeTimers(); + render(); + + const trigger = screen.getByTestId("market-btc-price-ends-trigger"); + fireEvent.touchStart(trigger); + + act(() => { + jest.advanceTimersByTime(599); + }); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(screen.getByRole("tooltip")).toHaveTextContent( + "Approximate time remaining", + ); + + fireEvent.touchEnd(trigger); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); +}); diff --git a/app/(marketing)/_components/markets-widget.tsx b/app/(marketing)/_components/markets-widget.tsx index 90ad334f..63ec7557 100644 --- a/app/(marketing)/_components/markets-widget.tsx +++ b/app/(marketing)/_components/markets-widget.tsx @@ -1,7 +1,7 @@ "use client"; -import { ArrowRight, TrendingUp, Globe, BarChart3, CheckCircle2, Coins, Bell } from "lucide-react"; -import LanguageBadge from "@/components/LanguageBadge"; +import { TrendingUp, Globe, BarChart3, CheckCircle2, Coins, Bell, Info, type LucideIcon } from "lucide-react"; +import { Tooltip } from "@/app/components/Tooltip"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { sampleMarkets, winNotifications, type Market } from "@/content/markets.sample"; @@ -108,7 +108,7 @@ export function MarketsWidget({ className }: MarketsWidgetProps) { interface MarketCardProps { market: Market; - IconComponent: any; + IconComponent: LucideIcon; colors: { bg: string; icon: string }; index: number; reducedMotion: boolean; @@ -129,6 +129,10 @@ interface MarketCardProps { */ function MarketCard({ market, IconComponent, colors, index, reducedMotion }: MarketCardProps) { const isFollowing = useFollowsStore((s) => s.isFollowing(market.id)); + const yesTooltip = `Current implied probability for the Yes outcome in ${market.title}.`; + const noTooltip = `Current implied probability for the No outcome in ${market.title}.`; + const poolTooltip = `${market.poolAmount.toLocaleString()} USDC has been committed to this market pool.`; + const endsTooltip = `Approximate time remaining before ${market.title} stops accepting predictions.`; return (
-
Yes: {market.yesOdds}%
-
No: {market.noOdds}%
+ + Yes: {market.yesOdds}% + + + No: {market.noOdds}% +
@@ -179,10 +197,22 @@ function MarketCard({ market, IconComponent, colors, index, reducedMotion }: Mar /> -
- Pool: {market.poolAmount.toLocaleString()} USDC - Ends in {market.endsIn} +
+ + Pool: {market.poolAmount.toLocaleString()} USDC + + + Ends in {market.endsIn} +
); -} \ No newline at end of file +} diff --git a/app/components/Tooltip.tsx b/app/components/Tooltip.tsx new file mode 100644 index 00000000..2247aeba --- /dev/null +++ b/app/components/Tooltip.tsx @@ -0,0 +1,168 @@ +"use client"; + +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export interface TooltipProps + extends Omit, "content"> { + /** Content announced and displayed inside the tooltip bubble. */ + content: React.ReactNode; + /** Focusable or descriptive trigger content. */ + children: React.ReactNode; + /** Delay before showing on hover, in milliseconds. */ + hoverDelay?: number; + /** Delay before showing on touch or pen long-press, in milliseconds. */ + longPressDelay?: number; + /** Optional id for the tooltip bubble. */ + tooltipId?: string; + /** Extra classes for the tooltip bubble. */ + tooltipClassName?: string; +} + +export function Tooltip({ + content, + children, + hoverDelay = 300, + longPressDelay = 600, + tooltipId, + tooltipClassName, + className, + tabIndex, + onBlur, + onFocus, + onKeyDown, + onMouseEnter, + onMouseLeave, + onPointerCancel, + onPointerDown, + onPointerLeave, + onPointerUp, + onTouchCancel, + onTouchEnd, + onTouchStart, + "aria-describedby": ariaDescribedBy, + ...triggerProps +}: TooltipProps) { + const reactId = React.useId(); + const id = tooltipId ?? `tooltip-${reactId.replace(/:/g, "")}`; + const [open, setOpen] = React.useState(false); + const hoverTimer = React.useRef | null>(null); + const longPressTimer = React.useRef | null>(null); + + const clearHoverTimer = React.useCallback(() => { + if (hoverTimer.current) { + clearTimeout(hoverTimer.current); + hoverTimer.current = null; + } + }, []); + + const clearLongPressTimer = React.useCallback(() => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }, []); + + const close = React.useCallback(() => { + clearHoverTimer(); + clearLongPressTimer(); + setOpen(false); + }, [clearHoverTimer, clearLongPressTimer]); + + React.useEffect(() => { + return () => { + clearHoverTimer(); + clearLongPressTimer(); + }; + }, [clearHoverTimer, clearLongPressTimer]); + + const describedBy = [ariaDescribedBy, open ? id : undefined] + .filter(Boolean) + .join(" ") || undefined; + + return ( + { + onBlur?.(event); + close(); + }} + onFocus={(event) => { + onFocus?.(event); + setOpen(true); + }} + onKeyDown={(event) => { + onKeyDown?.(event); + if (event.key === "Escape") { + close(); + } + }} + onMouseEnter={(event) => { + onMouseEnter?.(event); + clearHoverTimer(); + hoverTimer.current = setTimeout(() => setOpen(true), hoverDelay); + }} + onMouseLeave={(event) => { + onMouseLeave?.(event); + close(); + }} + onPointerCancel={(event) => { + onPointerCancel?.(event); + close(); + }} + onPointerDown={(event) => { + onPointerDown?.(event); + if (event.pointerType === "mouse" || event.button !== 0) return; + clearLongPressTimer(); + longPressTimer.current = setTimeout(() => setOpen(true), longPressDelay); + }} + onPointerLeave={(event) => { + onPointerLeave?.(event); + close(); + }} + onPointerUp={(event) => { + onPointerUp?.(event); + clearLongPressTimer(); + if (event.pointerType !== "mouse") { + setOpen(false); + } + }} + onTouchCancel={(event) => { + onTouchCancel?.(event); + close(); + }} + onTouchEnd={(event) => { + onTouchEnd?.(event); + close(); + }} + onTouchStart={(event) => { + onTouchStart?.(event); + clearLongPressTimer(); + longPressTimer.current = setTimeout(() => setOpen(true), longPressDelay); + }} + > + {children} + {open && ( + + {content} + + )} + + ); +}