Skip to content
Merged
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
45 changes: 22 additions & 23 deletions app/components/StatCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { ReactNode } from "react";
import { ReactNode, useId } from "react";

export type StatCardVariant = "primary" | "secondary";

Expand All @@ -27,29 +27,23 @@ export function StatCard({
className = "",
id,
}: StatCardProps) {
const cardId = id || `stat-card-${label.toLowerCase().replace(/\s+/g, "-")}`;
const generatedId = useId().replace(/:/g, "");
const cardId = id || `stat-card-${label.toLowerCase().replace(/\s+/g, "-")}-${generatedId}`;
const labelId = `${cardId}-label`;
const valueId = `${cardId}-value`;
const descId = description ? `${cardId}-desc` : undefined;
const descId = description && !loading ? `${cardId}-desc` : undefined;

const baseClasses =
"rounded-2xl border p-5 transition-all duration-200 focus-within:ring-2 focus-within:ring-theme-primary focus-within:ring-offset-2 focus-within:ring-offset-black";
"w-full min-w-0 rounded-2xl border p-4 sm:p-5 transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-theme-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background";

const variantClasses: Record<StatCardVariant, string> = {
primary: "border-white/10 bg-white/5",
secondary: "border-white/5 bg-white/[0.02]",
primary: "border-foreground/10 bg-foreground/5",
secondary: "border-foreground/5 bg-foreground/[0.02]",
};

const disabledClasses = disabled
? "opacity-50 cursor-not-allowed"
: "cursor-default";
const disabledClasses = disabled ? "opacity-50 cursor-not-allowed" : "cursor-default";

const combinedClasses = [
baseClasses,
variantClasses[variant],
disabledClasses,
className,
]
const combinedClasses = [baseClasses, variantClasses[variant], disabledClasses, className]
.filter(Boolean)
.join(" ");

Expand All @@ -63,38 +57,43 @@ export function StatCard({
aria-labelledby={labelId}
aria-describedby={descId}
aria-disabled={disabled || loading}
aria-busy={loading}
tabIndex={disabled ? -1 : 0}
>
<div className="flex items-center gap-2 mb-2">
<div className="mb-2 flex items-center gap-2">
{icon && (
<span className="text-theme-primary" aria-hidden="true">
{icon}
</span>
)}
<span
id={labelId}
className="text-sm font-semibold text-white/60 uppercase tracking-wider"
className="text-foreground/70 text-sm font-semibold tracking-wider uppercase"
>
{label}
</span>
</div>

<p
id={valueId}
className="text-2xl font-black text-white tabular-nums"
aria-live={loading ? "polite" : "off"}
className="text-foreground text-xl font-black break-words tabular-nums sm:text-2xl"
aria-live="polite"
>
{loading && (
<span
className="inline-block w-4 h-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin mr-2 align-middle"
className="border-foreground/70 mr-2 inline-block h-4 w-4 animate-spin rounded-full border-2 border-t-transparent align-middle motion-reduce:animate-none"
aria-hidden="true"
/>
)}
<span className={loading ? "text-white/50" : undefined}>{displayValue}</span>
<span className={loading ? "text-foreground/70" : undefined}>{displayValue}</span>
</p>

{description && !loading && (
<p id={descId} className="text-xs text-white/40 mt-1">
{description && (
<p
id={`${cardId}-desc`}
className={`text-foreground/70 mt-1 text-xs ${loading ? "invisible" : ""}`}
aria-hidden={loading}
>
{description}
</p>
)}
Expand Down
107 changes: 107 additions & 0 deletions app/components/__tests__/StatCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/** @jest-environment jsdom */

import { render, screen } from "@testing-library/react";
import { StatCard } from "../StatCard";

describe("StatCard", () => {
it("renders a named group with the supplied value and description", () => {
render(<StatCard label="Total Volume" value="42.5K XLM" description="Seven-day average" />);

const card = screen.getByRole("group", { name: "Total Volume" });
expect(card.id).toMatch(/^stat-card-total-volume-/);
expect(card).toHaveAccessibleDescription("Seven-day average");
expect(card).toHaveTextContent("42.5K XLM");
expect(card).toHaveAttribute("aria-busy", "false");
});

it("accepts zero as a numeric value and uses a custom id", () => {
render(<StatCard id="pending-count" label="Pending" value={0} />);

const card = screen.getByRole("group", { name: "Pending" });
expect(card).toHaveAttribute("id", "pending-count");
expect(card).toHaveTextContent("0");
expect(card).not.toHaveAttribute("aria-describedby");
});

it("keeps label and description references unique when labels repeat", () => {
render(
<>
<StatCard label="Balance" value="10 XLM" description="Current" />
<StatCard label="Balance" value="20 XLM" description="Previous" />
</>
);

const cards = screen.getAllByRole("group", { name: "Balance" });
expect(cards[0].id).not.toBe(cards[1].id);
expect(cards[0]).toHaveAccessibleDescription("Current");
expect(cards[1]).toHaveAccessibleDescription("Previous");
});

it("treats an icon as decorative and preserves caller classes", () => {
render(
<StatCard
label="Top Asset"
value="XLM"
icon={
<svg data-testid="asset-icon">
<title>Decorative star</title>
</svg>
}
className="custom-card"
variant="secondary"
/>
);

const card = screen.getByRole("group", { name: "Top Asset" });
expect(card).toHaveClass("custom-card", "bg-foreground/[0.02]");
expect(screen.getByTestId("asset-icon").parentElement).toHaveAttribute("aria-hidden", "true");
});

it("announces loading without exposing hidden description or stale value", () => {
const { rerender } = render(
<StatCard label="Transactions" value="1,284" description="All time" loading />
);

const card = screen.getByRole("group", { name: "Transactions" });
expect(card).toHaveAttribute("aria-busy", "true");
expect(card).toHaveAttribute("aria-disabled", "true");
expect(card).not.toHaveAttribute("aria-describedby");
expect(card).not.toHaveAccessibleDescription("All time");
expect(card).toHaveTextContent("—");
expect(card).not.toHaveTextContent("1,284");
expect(screen.getByText("All time")).toHaveClass("invisible");
expect(screen.getByText("—").parentElement).toHaveAttribute("aria-live", "polite");
expect(card.querySelector(".animate-spin")).toHaveClass("motion-reduce:animate-none");

rerender(<StatCard label="Transactions" value="1,284" description="All time" />);
expect(card).toHaveAttribute("aria-busy", "false");
expect(card).toHaveAccessibleDescription("All time");
expect(card).toHaveTextContent("1,284");
expect(screen.getByText("All time")).not.toHaveClass("invisible");
});

it("removes disabled cards from keyboard navigation and focuses enabled cards", () => {
const { rerender } = render(<StatCard label="Balance" value="0 XLM" disabled />);
const card = screen.getByRole("group", { name: "Balance" });

expect(card).toHaveAttribute("aria-disabled", "true");
expect(card).toHaveAttribute("tabindex", "-1");
expect(card).toHaveClass("opacity-50", "cursor-not-allowed");
rerender(<StatCard label="Balance" value="0 XLM" />);
expect(card).toHaveAttribute("tabindex", "0");
expect(card).toHaveClass("focus-visible:ring-2");
card.focus();
expect(card).toHaveFocus();
});

it("uses fluid sizing and theme tokens for both card variants", () => {
const { rerender } = render(<StatCard label="Balance" value="12345678901234567890" />);
const card = screen.getByRole("group", { name: "Balance" });

expect(card).toHaveClass("w-full", "min-w-0", "bg-foreground/5");
expect(screen.getByText("12345678901234567890").parentElement).toHaveClass("break-words");
rerender(<StatCard label="Balance" value="12345678901234567890" variant="secondary" />);
expect(card).toHaveClass("bg-foreground/[0.02]");
expect(card).not.toHaveClass("bg-foreground/5");
});
});