Skip to content

Commit 4733d55

Browse files
feat: add StatTile trend indicator
1 parent d08d0e0 commit 4733d55

4 files changed

Lines changed: 111 additions & 4 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,15 @@ normalization so duplicate pairs such as `usdc` and `USDC` cannot be registered.
9292

9393
## Accessibility
9494

95+
### StatTile Trend Indicators
96+
97+
`StatTile` accepts optional `delta?: number` and `trend?: "up" | "down" | "flat"`
98+
props for dashboards that need to show movement between polling intervals. A
99+
numeric `delta` automatically selects the trend direction; an explicit `trend`
100+
can be used when the caller already has direction metadata. The rendered badge
101+
uses direction text such as `Up +4`, `Down -2`, or `Flat 0` plus screen-reader
102+
copy, so color is never the only signal.
103+
95104
### ARIA Live Regions
96105

97106
Dynamic list updates (loading → loaded / loading → empty) on the pairs, events, api-keys, and webhooks pages are wrapped in `aria-live="polite"` regions so screen-reader users are notified when content arrives. Error messages continue to use `role="alert"` for assertive announcements. A single polite region per page prevents double announcements.

src/app/quote/Client.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState } from "react";
44
import type { ApiError } from "@/lib/apiClient";
5+
import { assetsDiffer, isValidAmount } from "@/lib/quote";
56

67
type Quote = {
78
source_asset: string;
@@ -38,11 +39,19 @@ export default function QuoteClient() {
3839
setRequestId(null);
3940
setQuote(null);
4041

41-
if (!assetsDiffer(sourceAsset, destAsset)) {
42+
const normalizedSourceAsset = normalizeAssetCode(sourceAsset);
43+
const normalizedDestAsset = normalizeAssetCode(destAsset);
44+
const normalizedAmount = amount.trim();
45+
46+
if (!normalizedSourceAsset || !normalizedDestAsset) {
47+
setError("Asset codes must be 1-12 letters or numbers.");
48+
return;
49+
}
50+
if (!assetsDiffer(normalizedSourceAsset, normalizedDestAsset)) {
4251
setError("Source and destination assets must differ.");
4352
return;
4453
}
45-
if (!isValidAmount(amount)) {
54+
if (!isValidAmount(normalizedAmount)) {
4655
setError("Amount must be a positive integer (base units).");
4756
return;
4857
}

src/components/StatTile.tsx

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,58 @@
11
import { type ReactNode } from "react";
2+
import { Badge } from "./Badge";
3+
4+
type Trend = "up" | "down" | "flat";
25

36
type Props = {
47
label: ReactNode;
58
value: ReactNode;
9+
delta?: number;
10+
trend?: Trend;
11+
};
12+
13+
const trendFromDelta = (delta: number): Trend => {
14+
if (delta > 0) return "up";
15+
if (delta < 0) return "down";
16+
return "flat";
17+
};
18+
19+
const trendCopy: Record<Trend, { label: string; variant: "neutral" | "ok" | "danger" }> = {
20+
up: { label: "Up", variant: "ok" },
21+
down: { label: "Down", variant: "danger" },
22+
flat: { label: "Flat", variant: "neutral" },
23+
};
24+
25+
const signedDelta = (trend: Trend, delta: number): string => {
26+
const magnitude = Math.abs(delta).toString();
27+
if (trend === "up") return `+${magnitude}`;
28+
if (trend === "down") return `-${magnitude}`;
29+
return "0";
630
};
731

8-
export function StatTile({ label, value }: Props) {
32+
export function StatTile({ label, value, delta, trend }: Props) {
33+
const resolvedTrend = trend ?? (delta !== undefined ? trendFromDelta(delta) : undefined);
34+
const trendMeta = resolvedTrend ? trendCopy[resolvedTrend] : undefined;
35+
const visualDelta =
36+
resolvedTrend && delta !== undefined ? signedDelta(resolvedTrend, delta) : undefined;
37+
938
return (
1039
<div className="rounded-lg border border-neutral-200 p-4 text-center dark:border-neutral-800">
1140
<dt className="text-xs uppercase tracking-wide text-neutral-500">{label}</dt>
12-
<dd className="mt-1 text-2xl font-semibold">{value}</dd>
41+
<dd className="mt-1 flex flex-col items-center gap-2">
42+
<span className="text-2xl font-semibold">{value}</span>
43+
{resolvedTrend && trendMeta && (
44+
<Badge variant={trendMeta.variant}>
45+
<span aria-hidden="true">
46+
{trendMeta.label}
47+
{visualDelta ? ` ${visualDelta}` : ""}
48+
</span>
49+
<span className="sr-only">
50+
Trend {resolvedTrend}
51+
{visualDelta ? ` by ${Math.abs(delta ?? 0)}` : ""}
52+
</span>
53+
</Badge>
54+
)}
55+
</dd>
1356
</div>
1457
);
1558
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { StatTile } from "../StatTile";
3+
4+
describe("StatTile", () => {
5+
it("renders unchanged when no delta or trend is provided", () => {
6+
render(<StatTile label="Pairs" value="12" />);
7+
8+
expect(screen.getByText("Pairs")).toBeInTheDocument();
9+
expect(screen.getByText("12")).toBeInTheDocument();
10+
expect(screen.queryByText(/Trend /)).not.toBeInTheDocument();
11+
});
12+
13+
it("renders a positive delta as an accessible upward trend", () => {
14+
render(<StatTile label="Volume" value="120" delta={8} />);
15+
16+
const visible = screen.getByText("Up +8");
17+
expect(visible).toBeInTheDocument();
18+
expect(screen.getByText("Trend up by 8")).toHaveClass("sr-only");
19+
expect(visible.parentElement?.className).toMatch(/emerald/);
20+
});
21+
22+
it("renders a negative delta as an accessible downward trend", () => {
23+
render(<StatTile label="Errors" value="3" delta={-2} />);
24+
25+
const visible = screen.getByText("Down -2");
26+
expect(visible).toBeInTheDocument();
27+
expect(screen.getByText("Trend down by 2")).toHaveClass("sr-only");
28+
expect(visible.parentElement?.className).toMatch(/rose/);
29+
});
30+
31+
it("renders zero delta as a flat trend", () => {
32+
render(<StatTile label="Routes" value="50" delta={0} />);
33+
34+
const visible = screen.getByText("Flat 0");
35+
expect(visible).toBeInTheDocument();
36+
expect(screen.getByText("Trend flat by 0")).toHaveClass("sr-only");
37+
expect(visible.parentElement?.className).toMatch(/neutral/);
38+
});
39+
40+
it("can render an explicit trend without a numeric delta", () => {
41+
render(<StatTile label="Status" value="Live" trend="up" />);
42+
43+
expect(screen.getByText("Up")).toBeInTheDocument();
44+
expect(screen.getByText("Trend up")).toHaveClass("sr-only");
45+
});
46+
});

0 commit comments

Comments
 (0)