Skip to content
Open
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
90 changes: 88 additions & 2 deletions app/(marketing)/_components/__tests__/markets-widget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>) => (
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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(<MarketsWidget />);

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(<MarketsWidget />);

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(<MarketsWidget />);

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();
});
});
48 changes: 39 additions & 9 deletions app/(marketing)/_components/markets-widget.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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 (
<Card
Expand Down Expand Up @@ -166,8 +170,22 @@ function MarketCard({ market, IconComponent, colors, index, reducedMotion }: Mar
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium text-green-400">Yes: {market.yesOdds}%</div>
<div className="text-sm text-red-400">No: {market.noOdds}%</div>
<Tooltip
content={yesTooltip}
data-testid={`market-${market.id}-yes-trigger`}
className="justify-end gap-1 rounded-sm text-sm font-medium text-green-400"
>
<span>Yes: {market.yesOdds}%</span>
<Info className="h-3 w-3" aria-hidden="true" />
</Tooltip>
<Tooltip
content={noTooltip}
data-testid={`market-${market.id}-no-trigger`}
className="mt-1 justify-end gap-1 rounded-sm text-sm text-red-400"
>
<span>No: {market.noOdds}%</span>
<Info className="h-3 w-3" aria-hidden="true" />
</Tooltip>
</div>
</div>

Expand All @@ -179,10 +197,22 @@ function MarketCard({ market, IconComponent, colors, index, reducedMotion }: Mar
/>
</div>

<div className="flex justify-between text-xs text-white/60">
<span>Pool: {market.poolAmount.toLocaleString()} USDC</span>
<span>Ends in {market.endsIn}</span>
<div className="flex justify-between gap-3 text-xs text-white/60">
<Tooltip
content={poolTooltip}
data-testid={`market-${market.id}-pool-trigger`}
className="rounded-sm"
>
<span>Pool: {market.poolAmount.toLocaleString()} USDC</span>
</Tooltip>
<Tooltip
content={endsTooltip}
data-testid={`market-${market.id}-ends-trigger`}
className="rounded-sm text-right"
>
<span>Ends in {market.endsIn}</span>
</Tooltip>
</div>
</Card>
);
}
}
168 changes: 168 additions & 0 deletions app/components/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

export interface TooltipProps
extends Omit<React.HTMLAttributes<HTMLSpanElement>, "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<ReturnType<typeof setTimeout> | null>(null);
const longPressTimer = React.useRef<ReturnType<typeof setTimeout> | 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 (
<span
{...triggerProps}
tabIndex={tabIndex ?? 0}
aria-describedby={describedBy}
data-state={open ? "open" : "closed"}
className={cn(
"relative inline-flex cursor-help items-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#201F37]",
className,
)}
onBlur={(event) => {
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 && (
<span
id={id}
role="tooltip"
className={cn(
"absolute bottom-[calc(100%+0.5rem)] left-1/2 z-50 w-max max-w-60 -translate-x-1/2 rounded-md border border-white/10 bg-[#111827] px-3 py-2 text-left text-xs leading-relaxed text-white shadow-xl shadow-black/30",
"after:absolute after:left-1/2 after:top-full after:h-2 after:w-2 after:-translate-x-1/2 after:-translate-y-1/2 after:rotate-45 after:border-b after:border-r after:border-white/10 after:bg-[#111827]",
tooltipClassName,
)}
>
{content}
</span>
)}
</span>
);
}