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
109 changes: 109 additions & 0 deletions src/components/PreviewCard.tsx
Original file line number Diff line number Diff line change
@@ -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
* <PreviewCard label="Yes" variant="yes" badge="62%" selected={false}>
* <OutcomeChip label="Yes" variant="yes" badge="62%" />
* </PreviewCard>
* ```
*/
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<string, string> = {
yes: "bg-green-500",
no: "bg-red-500",
neutral: "bg-muted-foreground",
};

export function PreviewCard({ label, variant, badge, selected, children }: PreviewCardProps) {
return (
<HoverCard openDelay={300} closeDelay={150}>
<HoverCardTrigger asChild>
{children}
</HoverCardTrigger>
<HoverCardContent
side="top"
align="center"
className="w-72"
>
<div className="space-y-3">
{/* ── Header: label + variant indicator ──────── */}
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold leading-snug text-foreground">
{label}
</p>
<span
className={cn(
"mt-1 inline-block h-2.5 w-2.5 shrink-0 rounded-full",
variantColorMap[variant],
)}
aria-hidden="true"
/>
</div>

{/* ── Details row: variant name + badge ──────── */}
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span className="text-[10px] uppercase tracking-wider">Type</span>
<span className="font-medium capitalize">{variant}</span>
</span>
{badge && (
<span className="inline-flex items-center gap-1.5">
<span className="text-[10px] uppercase tracking-wider">Odds</span>
<span className="font-medium tabular-nums">{badge}</span>
</span>
)}
</div>

{/* ── Selection status ───────────────────────── */}
{selected && (
<p className="text-xs font-medium text-primary">
Currently selected
</p>
)}

{/* ── Keyboard hint ──────────────────────────── */}
<p className="text-[11px] leading-relaxed text-muted-foreground/70">
Use Tab to focus, then hover or press Enter to preview.
</p>
</div>
</HoverCardContent>
</HoverCard>
);
}

export default PreviewCard;
116 changes: 116 additions & 0 deletions src/components/__tests__/PreviewCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div data-testid="hover-card-root">{children}</div>
);
const MockHoverCardTrigger = ({ children }: { children: React.ReactNode }) => (
<div data-testid="hover-card-trigger">{children}</div>
);
const MockHoverCardContent = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => (
<div data-testid="hover-card-content" className={className}>
{children}
</div>
);
return {
HoverCard: MockHoverCard,
HoverCardTrigger: MockHoverCardTrigger,
HoverCardContent: MockHoverCardContent,
};
});

describe("PreviewCard", () => {
it("renders the trigger element", () => {
render(
<PreviewCard label="Yes" variant="yes">
<button data-testid="trigger-btn">Yes</button>
</PreviewCard>
);
expect(screen.getByTestId("hover-card-root")).toBeInTheDocument();
expect(screen.getByTestId("trigger-btn")).toBeInTheDocument();
});

it("renders the preview card content with label and variant", () => {
render(
<PreviewCard label="Yes" variant="yes" badge="62%">
<button>Yes</button>
</PreviewCard>
);
// 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(
<PreviewCard label="No" variant="no" selected>
<button>No</button>
</PreviewCard>
);
expect(screen.getByText("Currently selected")).toBeInTheDocument();
});

it("does not show the selected indicator when selected is false", () => {
render(
<PreviewCard label="No" variant="no" selected={false}>
<button>No</button>
</PreviewCard>
);
expect(screen.queryByText("Currently selected")).not.toBeInTheDocument();
});

it("renders the keyboard accessibility hint", () => {
render(
<PreviewCard label="Yes" variant="yes">
<button>Yes</button>
</PreviewCard>
);
expect(
screen.getByText(/use tab to focus/i)
).toBeInTheDocument();
});

it("renders different variant colors", () => {
const { rerender } = render(
<PreviewCard label="Yes" variant="yes">
<button>Yes</button>
</PreviewCard>
);
// Each variant should render without error
rerender(
<PreviewCard label="No" variant="no">
<button>No</button>
</PreviewCard>
);
expect(screen.getByText("no")).toBeInTheDocument();

rerender(
<PreviewCard label="Maybe" variant="neutral">
<button>Maybe</button>
</PreviewCard>
);
expect(screen.getByText("neutral")).toBeInTheDocument();
});

it("renders without badge when badge is not provided", () => {
render(
<PreviewCard label="Yes" variant="yes">
<button>Yes</button>
</PreviewCard>
);
expect(screen.queryByText("Odds")).not.toBeInTheDocument();
});
});
64 changes: 37 additions & 27 deletions src/pages/OutcomeChip.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@

import * as React from "react";
import { PreviewCard } from "@/src/components/PreviewCard";

/**
* OutcomeChip
*
* 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
Expand Down Expand Up @@ -85,34 +88,40 @@ export const OutcomeChip: React.FC<OutcomeChipProps> = ({
};

return (
<button
type="button"
role="button"
aria-pressed={selected}
aria-disabled={disabled || undefined}
disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={[
"outcome-chip",
selected ? "outcome-chip--selected" : "",
disabled ? "outcome-chip--disabled" : "",
className,
]
.filter(Boolean)
.join(" ")}
style={
{
"--chip-bg": selected ? tokens.bgSelected : tokens.bg,
"--chip-fg": selected ? "var(--outcome-selected-fg, #FFFFFF)" : tokens.fg,
"--chip-border": tokens.border,
} as React.CSSProperties
}
<PreviewCard
label={label}
variant={variant}
badge={badge}
selected={selected}
>
<span className="outcome-chip__label">{label}</span>
{badge ? <span className="outcome-chip__badge">{badge}</span> : null}
<button
type="button"
role="button"
aria-pressed={selected}
aria-disabled={disabled || undefined}
disabled={disabled}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={[
"outcome-chip",
selected ? "outcome-chip--selected" : "",
disabled ? "outcome-chip--disabled" : "",
className,
]
.filter(Boolean)
.join(" ")}
style={
{
"--chip-bg": selected ? tokens.bgSelected : tokens.bg,
"--chip-fg": selected ? "var(--outcome-selected-fg, #FFFFFF)" : tokens.fg,
"--chip-border": tokens.border,
} as React.CSSProperties
}
>
<span className="outcome-chip__label">{label}</span>
{badge ? <span className="outcome-chip__badge">{badge}</span> : null}

<style>{`
<style>{`
.outcome-chip {
display: inline-flex;
align-items: center;
Expand Down Expand Up @@ -195,7 +204,8 @@ export const OutcomeChip: React.FC<OutcomeChipProps> = ({
}
}
`}</style>
</button>
</button>
</PreviewCard>
);
};

Expand Down