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
27 changes: 8 additions & 19 deletions src/components/ui/BarChart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,39 +44,28 @@ describe("BarChart accessibility", () => {

const bars = container.querySelectorAll("div[aria-hidden='true'] > div");
expect(bars.length).toBeGreaterThan(0);
// The bars are focusable for sighted keyboard users, but sit inside an
// aria-hidden subtree so assistive tech relies on the table alone.
const firstBar = container.querySelector<HTMLElement>("[tabindex='0']");
expect(firstBar).toBeInTheDocument();
expect(firstBar!.closest("[aria-hidden='true']")).not.toBeNull();
// Bars inside aria-hidden must NOT be focusable (no tabindex) to comply with
// axe's aria-hidden-focus rule (#708)
const focusable = container.querySelectorAll("[tabindex]");
expect(focusable).toHaveLength(0);
});

it("makes each bar keyboard focusable with a visible focus ring", () => {
const { container } = render(<BarChart data={DATA} title="Spend" />);

const focusable = container.querySelectorAll("[tabindex='0']");
expect(focusable).toHaveLength(DATA.length);
for (const bar of Array.from(focusable)) {
expect(bar.className).toContain("focus-visible:outline-2");
}
});

it("reveals the same tooltip on keyboard focus as on hover", () => {
it("reveals tooltip on hover", () => {
const { container } = render(
<BarChart data={DATA} formatValue={(v) => `$${v}`} title="Spend" />,
);

const tooltip = container.querySelector("[aria-hidden='true'] span");
expect(tooltip).toHaveTextContent("Week 1: $120");
// group-focus-within is what makes the focus-reachable tooltip visible.
expect(tooltip!.className).toContain("group-focus-within:opacity-100");
expect(tooltip!.className).toContain("group-hover:opacity-100");
});

it("draws a stripe pattern on negative bars as a non-colour sign cue", () => {
const { container } = render(<BarChart data={DATA} title="Spend" />);

const bars = Array.from(container.querySelectorAll<HTMLElement>("[tabindex='0']"));
const bars = Array.from(
container.querySelectorAll<HTMLElement>(".relative > div"),
);
expect(bars[1].style.backgroundImage).toContain("repeating-linear-gradient");
expect(bars[0].style.backgroundImage).toBe("");
});
Expand Down
17 changes: 7 additions & 10 deletions src/components/ui/BarChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,11 @@ export interface BarChartPoint {
* widely-supported role) and it means every figure is available as real text
* to a screen reader rather than being reconstructed from bar heights.
*
* Hover-only information used to live in a `title` attribute, which is not
* reachable by keyboard and is not reliably announced. Each bar is now
* focusable with a visible focus ring and reveals the same tooltip on focus
* (group-focus-within) as on hover, so a keyboard user gets the identical
* detail a mouse user does. The tooltip lives inside the aria-hidden subtree
* on purpose: sighted keyboard users need to *see* it, while assistive tech
* should read the table once rather than announce every bar again.
* The visual bars are purely decorative and non-focusable within the
* aria-hidden container to comply with axe's aria-hidden-focus rule (#708).
* The accessible equivalent is the visually-hidden data table carrying the same
* numbers. Sighted mouse users can hover to reveal the tooltip, while assistive
* tech reads the structured table.
*
* Colour is never the only signal: negative bars are drawn with a diagonal
* stripe as well as a different hue, and the value keeps its sign in both the
Expand Down Expand Up @@ -70,11 +68,10 @@ export function BarChart({
>
<div className="relative flex w-full flex-1 items-end justify-center">
<div
tabIndex={0}
className={
isNegative
? "w-full max-w-8 rounded-t-md bg-rose-600/80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-600 dark:bg-rose-400/70 dark:focus-visible:outline-rose-400"
: "w-full max-w-8 rounded-t-md bg-indigo-500/80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 dark:bg-indigo-400/70 dark:focus-visible:outline-indigo-400"
? "w-full max-w-8 rounded-t-md bg-rose-600/80 dark:bg-rose-400/70"
: "w-full max-w-8 rounded-t-md bg-indigo-500/80 dark:bg-indigo-400/70"
}
style={{
height: `${pct}%`,
Expand Down
17 changes: 17 additions & 0 deletions src/components/ui/StatCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,4 +337,21 @@ describe("StatCard — sparkline", () => {
"Earnings history: 4 data points, trending up from 10 to 30, range 10 to 30",
);
});

it("formats sparkline values when format is currency (#709)", () => {
render(
<StatCard
label="Lifetime earnings"
status="loaded"
value={8420}
format="currency"
asset="USDC"
sparkline={[1234.5, 8420]}
/>,
);
const svg = screen.getByRole("img");
expect(svg).toHaveAccessibleName(
expect.stringContaining("trending up from 1,234.5 USDC to 8,420 USDC"),
);
});
});
11 changes: 6 additions & 5 deletions src/components/ui/StatCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
* always available via the title attribute (keyboard-navigable, hover tooltip).
*/

import { cn, formatCurrency, getNumberFormat } from "@/lib/utils";
import { cn, formatCurrency, formatInteger, formatPercent } from "@/lib/utils";
import { isRTLLocale, resolveLocale } from "@/lib/locale";
import { ArrowUpRight, ArrowDownRight, AlertCircle } from "lucide-react";
Expand Down Expand Up @@ -93,8 +92,6 @@ function formatValue(
return { display: pct, exact: pct };
}
case "count": {
// Use cached formatter to avoid hydration mismatch (#656).
const s = getNumberFormat("en-US", { maximumFractionDigits: 0 }).format(value);
// Was `value.toLocaleString("en-US", …)` — a second, divergent money
// formatting path in the same component that also called
// `formatCurrency`, so counts and currencies disagreed on separators.
Expand Down Expand Up @@ -282,8 +279,12 @@ export function StatCard({
<div data-testid="statcard-sparkline" className="text-indigo-500 dark:text-indigo-400">
{/* The card's own label becomes the sparkline's accessible name, so
the trend is announced as "Earnings: 8 data points, trending up
from …" rather than as an unlabelled graphic. */}
<Sparkline data={sparkline} label={label} />
from …" rather than as an unlabelled graphic (#709). */}
<Sparkline
data={sparkline}
label={label}
formatValue={(v) => formatValue(v, format, asset, locale).display}
/>
</div>
)}
</div>,
Expand Down
13 changes: 13 additions & 0 deletions src/components/ui/Tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,16 @@ describe("Tabs — keyboard navigation (#24)", () => {
expect(screen.getByRole("tab", { name: "First" })).toHaveFocus();
});
});

describe("Tabs — aria-controls (#710)", () => {
it("sets aria-controls on the active tab and omits it on inactive tabs", () => {
render(<Tabs tabs={sampleTabs} active="active" onChange={() => {}} />);
const activeTab = screen.getByRole("tab", { name: "Active" });
const completedTab = screen.getByRole("tab", { name: "Completed" });
const archivedTab = screen.getByRole("tab", { name: "Archived" });

expect(activeTab).toHaveAttribute("aria-controls", "tabpanel-active");
expect(completedTab).not.toHaveAttribute("aria-controls");
expect(archivedTab).not.toHaveAttribute("aria-controls");
});
});
2 changes: 1 addition & 1 deletion src/components/ui/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function Tabs<T extends string>({
role="tab"
id={`tab-${tab.key}`}
aria-selected={isActive}
aria-controls={`tabpanel-${tab.key}`}
aria-controls={isActive ? `tabpanel-${tab.key}` : undefined}
tabIndex={isActive ? 0 : -1}
onClick={() => onChange(tab.key)}
onKeyDown={(e) => handleKeyDown(e, tab.key)}
Expand Down
19 changes: 19 additions & 0 deletions src/lib/mock-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
mockMaintenancePools,
mockMilestones,
recentActivity,
getRecentActivity,
} from "./mock-data";
import { formatCurrency, isPlausibleAmount } from "./utils";

Expand Down Expand Up @@ -44,3 +45,21 @@ describe("mock-data asset coverage", () => {
expect(formatCurrency(milestone.distributed, milestone.asset)).toBe("16,837.25 XLM");
});
});

describe("recentActivity dynamic timestamps (#707)", () => {
it("computes timestamps relative to execution time rather than module load", () => {
const events = getRecentActivity();
expect(events.length).toBeGreaterThan(0);
const now = Date.now();
for (const event of events) {
const diffMinutes = (now - new Date(event.occurredAt).getTime()) / 60_000;
expect(diffMinutes).toBeGreaterThanOrEqual(0);
expect(diffMinutes).toBeLessThan(250);
}
});

it("advances timestamps dynamically across accesses via proxy", () => {
const firstEvent = recentActivity[0];
expect(new Date(firstEvent.occurredAt).getTime()).toBeLessThanOrEqual(Date.now());
});
});
69 changes: 56 additions & 13 deletions src/lib/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,36 +268,40 @@ export interface ActivityEvent {
occurredAt: string;
}

// minutesAgo(n) anchors each mock event n minutes before whenever this
// module is evaluated, so the feed's relative-time text keeps advancing
// (within a given server render) instead of being frozen at deploy time.
function minutesAgo(n: number): string {
return new Date(Date.now() - n * 60_000).toISOString();
// Raw mock events data template without pre-evaluated timestamps (#707).
interface MockActivityTemplate {
id: string;
handle: string;
action: string;
target: string;
amount?: number;
asset?: "USDC" | "XLM";
minutesAgo: number;
}

export const recentActivity: ActivityEvent[] = [
const mockActivityTemplates: MockActivityTemplate[] = [
{
id: "a1",
handle: "devrel_ana",
action: "was paid",
target: "core-indexer#288",
amount: 40,
asset: "USDC",
occurredAt: minutesAgo(6),
minutesAgo: 6,
},
{
id: "a2",
handle: "0xkoda",
action: "claimed",
target: "soroban-escrow-sdk#17",
occurredAt: minutesAgo(22),
minutesAgo: 22,
},
{
id: "a3",
handle: "linh_dev",
action: "opened a pull request for",
target: "stellar-wallet-kit#475",
occurredAt: minutesAgo(48),
minutesAgo: 48,
},
{
id: "a4",
Expand All @@ -306,7 +310,7 @@ export const recentActivity: ActivityEvent[] = [
target: "v2.0: Multi-asset escrow",
amount: 2500,
asset: "USDC",
occurredAt: minutesAgo(71),
minutesAgo: 71,
},
{
id: "a4b",
Expand All @@ -315,7 +319,7 @@ export const recentActivity: ActivityEvent[] = [
target: "v1.4: Indexer performance pass",
amount: 15000,
asset: "XLM",
occurredAt: minutesAgo(95),
minutesAgo: 95,
},
{
id: "a5",
Expand All @@ -324,17 +328,56 @@ export const recentActivity: ActivityEvent[] = [
target: "core-indexer#301",
amount: 480,
asset: "USDC",
occurredAt: minutesAgo(130),
minutesAgo: 130,
},
{
id: "a6",
handle: "qa_marcus",
action: "joined as a",
target: "contributor",
occurredAt: minutesAgo(210),
minutesAgo: 210,
},
];

// minutesAgo(n) anchors each mock event n minutes before the current time,
// computed dynamically at request or render time rather than frozen at module load (#707).
function minutesAgo(n: number): string {
return new Date(Date.now() - n * 60_000).toISOString();
}

/**
* Returns recent activity events with timestamps computed at call/request time (#707).
* Avoids stale timestamps on long-lived Next.js server processes.
*/
export function getRecentActivity(): ActivityEvent[] {
return mockActivityTemplates.map(({ minutesAgo: mins, ...event }) => ({
...event,
occurredAt: minutesAgo(mins),
}));
}

/**
* Dynamic proxy export for recentActivity (#707).
* Dynamically computes timestamps whenever accessed or iterated so long-lived servers
* do not serve stale module-load timestamps.
*/
export const recentActivity: ActivityEvent[] = new Proxy([] as ActivityEvent[], {
get(_target, prop, receiver) {
const list = getRecentActivity();
const val = Reflect.get(list, prop, receiver);
return typeof val === "function" ? val.bind(list) : val;
},
has(_target, prop) {
return prop in getRecentActivity();
},
getOwnPropertyDescriptor(_target, prop) {
return Object.getOwnPropertyDescriptor(getRecentActivity(), prop);
},
ownKeys() {
return Reflect.ownKeys(getRecentActivity());
},
});


// Weekly earnings/spend series (8 weeks) for dashboard charts.
export const contributorEarningsHistory = [420, 560, 310, 780, 640, 890, 720, 1050];
Expand Down