diff --git a/src/components/ui/BarChart.test.tsx b/src/components/ui/BarChart.test.tsx index 1c4f934..c572555 100644 --- a/src/components/ui/BarChart.test.tsx +++ b/src/components/ui/BarChart.test.tsx @@ -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("[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(); - - 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( `$${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(); - const bars = Array.from(container.querySelectorAll("[tabindex='0']")); + const bars = Array.from( + container.querySelectorAll(".relative > div"), + ); expect(bars[1].style.backgroundImage).toContain("repeating-linear-gradient"); expect(bars[0].style.backgroundImage).toBe(""); }); diff --git a/src/components/ui/BarChart.tsx b/src/components/ui/BarChart.tsx index eabc6c8..df4fb1f 100644 --- a/src/components/ui/BarChart.tsx +++ b/src/components/ui/BarChart.tsx @@ -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 @@ -70,11 +68,10 @@ export function BarChart({ >
{ "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( + , + ); + const svg = screen.getByRole("img"); + expect(svg).toHaveAccessibleName( + expect.stringContaining("trending up from 1,234.5 USDC to 8,420 USDC"), + ); + }); }); diff --git a/src/components/ui/StatCard.tsx b/src/components/ui/StatCard.tsx index d262a5f..d70b095 100644 --- a/src/components/ui/StatCard.tsx +++ b/src/components/ui/StatCard.tsx @@ -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"; @@ -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. @@ -282,8 +279,12 @@ export function StatCard({
{/* 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. */} - + from …" rather than as an unlabelled graphic (#709). */} + formatValue(v, format, asset, locale).display} + />
)}
, diff --git a/src/components/ui/Tabs.test.tsx b/src/components/ui/Tabs.test.tsx index aca1135..438f4b3 100644 --- a/src/components/ui/Tabs.test.tsx +++ b/src/components/ui/Tabs.test.tsx @@ -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( {}} />); + 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"); + }); +}); diff --git a/src/components/ui/Tabs.tsx b/src/components/ui/Tabs.tsx index a5236b0..0375dd0 100644 --- a/src/components/ui/Tabs.tsx +++ b/src/components/ui/Tabs.tsx @@ -79,7 +79,7 @@ export function Tabs({ 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)} diff --git a/src/lib/mock-data.test.ts b/src/lib/mock-data.test.ts index 1bd0ec5..8b097d0 100644 --- a/src/lib/mock-data.test.ts +++ b/src/lib/mock-data.test.ts @@ -3,6 +3,7 @@ import { mockMaintenancePools, mockMilestones, recentActivity, + getRecentActivity, } from "./mock-data"; import { formatCurrency, isPlausibleAmount } from "./utils"; @@ -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()); + }); +}); diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts index de0dab0..3bca2e6 100644 --- a/src/lib/mock-data.ts +++ b/src/lib/mock-data.ts @@ -268,14 +268,18 @@ 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", @@ -283,21 +287,21 @@ export const recentActivity: ActivityEvent[] = [ 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", @@ -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", @@ -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", @@ -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];