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
110 changes: 110 additions & 0 deletions components/cards/__tests__/stat-card.copy.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { StatCard } from "../stat-card"

// Mock the toast hook
jest.mock("@/hooks/use-toast", () => ({
useToast: () => ({
toast: jest.fn(),
}),
}))

describe("StatCard — Issue #770: Copy-to-clipboard affordance", () => {
const mockStat = { label: "Volume", value: "$4,325.49" }

it("renders a copy button in the success state", () => {
render(
<StatCard
stat={mockStat}
index={0}
status="success"
/>
)

const copyButton = screen.getByRole("button", {
name: /Copy Volume value \$4,325\.49/i,
})
expect(copyButton).toBeInTheDocument()
})

it("does not render a copy button in empty/loading/error states", () => {
const { rerender } = render(
<StatCard index={0} status="empty" emptyVariant="volume" />
)
expect(screen.queryByLabelText(/Copy .* value/i)).not.toBeInTheDocument()

rerender(<StatCard index={0} status="loading" />)
expect(screen.queryByLabelText(/Copy .* value/i)).not.toBeInTheDocument()

rerender(<StatCard index={0} status="error" onRetry={jest.fn()} />)
expect(screen.queryByLabelText(/Copy .* value/i)).not.toBeInTheDocument()
})

it("shows the copied state (aria-pressed) after a successful click", async () => {
// Provide a working clipboard for the browser API
Object.defineProperty(navigator, "clipboard", {
value: { writeText: jest.fn().mockResolvedValue(undefined) },
configurable: true,
})

const user = userEvent.setup()
render(
<StatCard
stat={mockStat}
index={0}
status="success"
/>
)

const copyButton = screen.getByRole("button", {
name: /Copy Volume value \$4,325\.49/i,
})
expect(copyButton).toHaveAttribute("aria-pressed", "false")

await user.click(copyButton)
expect(copyButton).toHaveAttribute("aria-pressed", "true")
})

it("has proper keyboard-accessible attributes", () => {
render(
<StatCard
stat={mockStat}
index={0}
status="success"
/>
)

const copyButton = screen.getByRole("button", {
name: /Copy Volume value \$4,325\.49/i,
})

// Button is a native <button> element, naturally keyboard-accessible
expect(copyButton.tagName).toBe("BUTTON")
expect(copyButton).toHaveAttribute("type", "button")
expect(copyButton).toHaveAttribute("title", "Copy value")
// The button has focus-visible styling for keyboard users
expect(copyButton.className).toMatch(/focus-visible:outline-none/)
expect(copyButton.className).toMatch(/focus-visible:ring-2/)
})

it("does not crash when clipboard API is unavailable", async () => {
const user = userEvent.setup()
Object.defineProperty(navigator, "clipboard", { value: undefined })

render(
<StatCard
stat={mockStat}
index={0}
status="success"
/>
)

const copyButton = screen.getByRole("button", {
name: /Copy Volume value \$4,325\.49/i,
})
await user.click(copyButton)

// No crash; aria-pressed should remain false (clipboard unavailable)
expect(copyButton).toHaveAttribute("aria-pressed", "false")
})
})
111 changes: 91 additions & 20 deletions components/cards/stat-card.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
/**
* StatCard Component — Issue #646: Focus Visible Accessibility
*
* Updated for Issue #770: Add copy-to-clipboard affordance on Dashboard.
* Each stat value in the success state gets a hover-reveal copy button that
* copies the value to the clipboard with a success toast.
*
* All interactive elements in this component use focus-visible to show
* keyboard focus outlines that meet WCAG 2.1 AA (3:1 contrast ratio).
*
* Interactive elements and their focus treatment:
* - Copy button: Uses Button component with full focus-visible ring
* - Empty state CTA Button: Uses Button component with full focus-visible ring
* - Error state Retry Button: Uses Button component with full focus-visible ring
* - Both buttons use Tailwind's focus-visible utilities: outline-none, ring-2, ring-offset-2
* - All buttons use Tailwind's focus-visible utilities: outline-none, ring-2, ring-offset-2
*
* Uses Tailwind's focus-visible: variant which targets :focus-visible pseudo-class
* — visible to keyboard users, hidden for mouse users.
*
* The component's display area (non-empty, non-error state) is not interactive
* and receives no focus styles.
*/

import type { Stat } from "@/types/index";
import { AlertCircle } from "lucide-react";
import { Copy, Check } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { useToast } from "@/hooks/use-toast";
import { useState, useCallback, useEffect, useRef } from "react";

export type StatEmptyVariant = 'volume' | 'predictions' | 'win-rate' | 'leaderboard';

Expand Down Expand Up @@ -73,6 +77,51 @@ const EMPTY_CONFIGS: Record<StatEmptyVariant, { illustration: string; title: str
};

export function StatCard({ stat, index, status, emptyVariant = 'volume', onRetry }: StatCardProps) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const { toast } = useToast();
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();

// Clear timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current !== undefined) {
clearTimeout(timeoutRef.current);
}
};
}, []);

const handleCopy = useCallback(async (value: string, idx: number) => {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
toast({
title: "Copy not supported",
description: "Your browser does not support copying to clipboard.",
variant: "destructive",
});
return;
}

try {
await navigator.clipboard.writeText(value);
setCopiedIndex(idx);
toast({
title: "Copied!",
description: `"${value}" copied to clipboard.`,
});

if (timeoutRef.current !== undefined) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setCopiedIndex(null);
}, 2000);
} catch {
toast({
title: "Failed to copy",
description: "Could not copy the value. Please try again.",
variant: "destructive",
});
}
}, [toast]);
if (status === "loading") {
return <Skeleton className="h-44 w-full rounded-xl" />;
}
Expand Down Expand Up @@ -114,20 +163,42 @@ export function StatCard({ stat, index, status, emptyVariant = 'volume', onRetry
}

// Normal success state – render the stat card UI
return (
<div className="relative group">
<div className="bg-slate-900/50 backdrop-blur-sm border border-cyan-500/20 rounded-2xl p-6 sm:p-8 shadow-xl hover:shadow-cyan-500/20 transition-all duration-300 transform hover:scale-105 hover:-translate-y-2">
<div
data-numeric="true"
className="text-3xl sm:text-4xl lg:text-5xl font-bold bg-gradient-to-r from-cyan-400 to-emerald-400 bg-clip-text text-transparent mb-2 tabular-nums"
>
{stat?.value}
</div>
<div className="text-slate-400 font-medium text-sm sm:text-base">
{stat?.label}
const isCopied = copiedIndex === index;
return (
<div className="relative group">
<div className="bg-slate-900/50 backdrop-blur-sm border border-cyan-500/20 rounded-2xl p-6 sm:p-8 shadow-xl hover:shadow-cyan-500/20 transition-all duration-300 transform hover:scale-105 hover:-translate-y-2">
<div className="flex items-start justify-between gap-2">
<div
data-numeric="true"
className="text-3xl sm:text-4xl lg:text-5xl font-bold bg-gradient-to-r from-cyan-400 to-emerald-400 bg-clip-text text-transparent mb-2 tabular-nums"
>
{stat?.value}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="-m-1 h-8 w-8 shrink-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
onClick={() => stat?.value && handleCopy(stat.value, index)}
aria-label={`Copy ${stat?.label ?? "stat"} value ${stat?.value ?? ""}`}
aria-pressed={isCopied}
title="Copy value"
>
{isCopied ? (
<Check className="h-4 w-4 text-green-500" aria-hidden="true" />
) : (
<Copy className="h-4 w-4" aria-hidden="true" />
)}
<span className="sr-only" aria-live="polite">
{isCopied ? `${stat?.value} copied to clipboard` : ""}
</span>
</Button>
</div>
<div className="text-slate-400 font-medium text-sm sm:text-base">
{stat?.label}
</div>
</div>
<div className="absolute inset-0 bg-gradient-to-r from-cyan-500/10 to-emerald-500/10 rounded-2xl blur opacity-0 group-hover:opacity-100 transition-opacity duration-300 -z-10" />
</div>
<div className="absolute inset-0 bg-gradient-to-r from-cyan-500/10 to-emerald-500/10 rounded-2xl blur opacity-0 group-hover:opacity-100 transition-opacity duration-300 -z-10" />
</div>
);
}
);
}
15 changes: 14 additions & 1 deletion components/error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@
import React, { Component, ErrorInfo, ReactNode } from "react";
import { ErrorRecoveryScreen } from "@/components/error/ErrorRecoveryScreen";

interface FallbackRenderProps {
error: Error;
incidentId: string | null;
resetErrorBoundary: () => void;
}

interface Props {
children: ReactNode;
fallback?: ReactNode;
fallback?: ReactNode | ((props: FallbackRenderProps) => ReactNode);
}

interface State {
Expand Down Expand Up @@ -50,6 +56,13 @@ export class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
if (this.props.fallback) {
if (typeof this.props.fallback === "function") {
return (this.props.fallback as (props: FallbackRenderProps) => ReactNode)({
error: this.state.error!,
incidentId: this.state.incidentId,
resetErrorBoundary: this.handleReset,
});
}
return this.props.fallback;
}

Expand Down
66 changes: 66 additions & 0 deletions src/components/BetFormErrorFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import React from "react";
import { Button } from "@/components/ui/button";
import { AlertTriangle, RefreshCw } from "lucide-react";

interface BetFormErrorFallbackProps {
error: Error;
incidentId: string | null;
resetErrorBoundary: () => void;
}

/**
* BetForm error-boundary fallback UI.
*
* Renders an attractive, card-styled fallback with a retry action when BetForm
* throws during render. Respects design tokens and dark mode, and exposes the
* incident id + error message for transparency without leaking stack traces.
*/
export function BetFormErrorFallback({
error,
incidentId,
resetErrorBoundary,
}: BetFormErrorFallbackProps) {
return (
<div className="mx-auto max-w-md p-4 sm:p-6 lg:p-8">
<div className="overflow-hidden rounded-xl border border-border/60 bg-card/80 shadow-sm">
<div className="flex flex-col items-center p-8 text-center">
<div
className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10 dark:bg-destructive/20"
role="img"
aria-label="Error"
>
<AlertTriangle className="h-7 w-7 text-destructive" aria-hidden="true" />
</div>

<h2 className="mb-2 text-lg font-semibold text-foreground">
We couldn't load the bet form
</h2>
<p className="mb-5 max-w-sm text-sm text-muted-foreground">
An unexpected error occurred while rendering the bet form. Please try again.
</p>

{incidentId && (
<p className="mb-4 rounded-md bg-muted px-3 py-1.5 font-mono text-xs text-muted-foreground">
Incident ID: {incidentId}
</p>
)}

<div className="flex flex-col gap-3 sm:flex-row">
<Button onClick={resetErrorBoundary} size="lg" className="gap-2">
<RefreshCw className="h-4 w-4" aria-hidden="true" />
Retry
</Button>
</div>

{error?.message && (
<p className="mt-5 hidden text-xs text-muted-foreground" data-testid="bet-form-error-msg">
{error.message}
</p>
)}
</div>
</div>
</div>
);
}
Loading