diff --git a/src/components/PreviewCard.tsx b/src/components/PreviewCard.tsx new file mode 100644 index 0000000..9d77516 --- /dev/null +++ b/src/components/PreviewCard.tsx @@ -0,0 +1,109 @@ +"use client"; + +import * as React from "react"; +import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card"; +import { cn } from "@/lib/utils"; + +/** + * PreviewCard — A hover-triggered preview card for OutcomeChip. + * + * Uses Radix HoverCard under the hood for accessible, keyboard-friendly + * hover interactions. The preview displays outcome details (label, variant + * color, badge, selection state) so users can quickly inspect an outcome + * without committing to a selection. + * + * ## Accessibility + * - Radix HoverCard provides full keyboard support (Tab to focus, then + * hover card opens on focus and closes on blur). + * - `openDelay` (300ms) prevents accidental triggers during fast mouse + * movement; `closeDelay` (150ms) prevents flicker. + * - The preview card content is rendered with polite `aria-live` via + * Radix's built-in tooltip semantics. + * - Respects `prefers-reduced-motion`: the card's fade-in animation is + * disabled automatically via the `motion-safe:` prefix on the shadcn + * HoverCardContent classes. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export interface PreviewCardProps { + /** The outcome label shown in the preview header */ + label: string; + /** Visual variant driving the color indicator dot */ + variant: "yes" | "no" | "neutral"; + /** Optional badge/odds text shown in the preview */ + badge?: string; + /** Whether the outcome is currently selected */ + selected?: boolean; + /** The trigger element (typically an OutcomeChip) */ + children: React.ReactNode; +} + +const variantColorMap: Record = { + yes: "bg-green-500", + no: "bg-red-500", + neutral: "bg-muted-foreground", +}; + +export function PreviewCard({ label, variant, badge, selected, children }: PreviewCardProps) { + return ( + + + {children} + + +
+ {/* ── Header: label + variant indicator ──────── */} +
+

+ {label} +

+
+ + {/* ── Details row: variant name + badge ──────── */} +
+ + Type + {variant} + + {badge && ( + + Odds + {badge} + + )} +
+ + {/* ── Selection status ───────────────────────── */} + {selected && ( +

+ Currently selected +

+ )} + + {/* ── Keyboard hint ──────────────────────────── */} +

+ Use Tab to focus, then hover or press Enter to preview. +

+
+
+
+ ); +} + +export default PreviewCard; \ No newline at end of file diff --git a/src/components/__tests__/PreviewCard.test.tsx b/src/components/__tests__/PreviewCard.test.tsx new file mode 100644 index 0000000..bfacc44 --- /dev/null +++ b/src/components/__tests__/PreviewCard.test.tsx @@ -0,0 +1,116 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { PreviewCard } from "@/src/components/PreviewCard"; + +// Mock the Radix UI HoverCard since it uses browser APIs not available in jsdom. +// The mock renders the trigger and content unconditionally so we can assert +// on the preview card content. +jest.mock("@/components/ui/hover-card", () => { + const MockHoverCard = ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ); + const MockHoverCardTrigger = ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ); + const MockHoverCardContent = ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) => ( +
+ {children} +
+ ); + return { + HoverCard: MockHoverCard, + HoverCardTrigger: MockHoverCardTrigger, + HoverCardContent: MockHoverCardContent, + }; +}); + +describe("PreviewCard", () => { + it("renders the trigger element", () => { + render( + + + + ); + expect(screen.getByTestId("hover-card-root")).toBeInTheDocument(); + expect(screen.getByTestId("trigger-btn")).toBeInTheDocument(); + }); + + it("renders the preview card content with label and variant", () => { + render( + + + + ); + // The variant name appears in the card content + expect(screen.getByTestId("hover-card-content")).toHaveTextContent("yes"); + // The badge value appears in the card content + expect(screen.getByTestId("hover-card-content")).toHaveTextContent("62%"); + }); + + it("shows the selected indicator when selected is true", () => { + render( + + + + ); + expect(screen.getByText("Currently selected")).toBeInTheDocument(); + }); + + it("does not show the selected indicator when selected is false", () => { + render( + + + + ); + expect(screen.queryByText("Currently selected")).not.toBeInTheDocument(); + }); + + it("renders the keyboard accessibility hint", () => { + render( + + + + ); + expect( + screen.getByText(/use tab to focus/i) + ).toBeInTheDocument(); + }); + + it("renders different variant colors", () => { + const { rerender } = render( + + + + ); + // Each variant should render without error + rerender( + + + + ); + expect(screen.getByText("no")).toBeInTheDocument(); + + rerender( + + + + ); + expect(screen.getByText("neutral")).toBeInTheDocument(); + }); + + it("renders without badge when badge is not provided", () => { + render( + + + + ); + expect(screen.queryByText("Odds")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/pages/OutcomeChip.tsx b/src/pages/OutcomeChip.tsx index e1415f3..3ab4237 100644 --- a/src/pages/OutcomeChip.tsx +++ b/src/pages/OutcomeChip.tsx @@ -1,5 +1,6 @@ import * as React from "react"; +import { PreviewCard } from "@/src/components/PreviewCard"; /** * OutcomeChip @@ -7,6 +8,8 @@ import * as React from "react"; * Displays a single market outcome (e.g. "Yes" / "No", or a named outcome) * as a tappable chip, optionally with a probability/odds badge. * + * Hover (or keyboard focus) shows a PreviewCard with outcome details. + * * Mobile audit fixes (issue #636): * - Chip no longer truncates or overflows at <=375px: label wraps onto a * second line instead of clipping, and the badge drops below the label @@ -85,34 +88,40 @@ export const OutcomeChip: React.FC = ({ }; return ( - + + ); };