From 677892fe018d6847b09b32a6069af7b61b8f3112 Mon Sep 17 00:00:00 2001 From: JSE19 Date: Thu, 30 Jul 2026 13:11:59 +0100 Subject: [PATCH] Add accessible chart summaries --- .../trade/components/chart/TVChart.tsx | 2 +- .../chart/TVChartContainer.test.tsx | 330 ++++++++++++++++++ .../components/chart/TVChartContainer.tsx | 182 +++++++++- 3 files changed, 506 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/features/trade/components/chart/TVChartContainer.test.tsx diff --git a/apps/web/src/features/trade/components/chart/TVChart.tsx b/apps/web/src/features/trade/components/chart/TVChart.tsx index e8954c0..77b69ae 100644 --- a/apps/web/src/features/trade/components/chart/TVChart.tsx +++ b/apps/web/src/features/trade/components/chart/TVChart.tsx @@ -50,7 +50,7 @@ export function TVChart({ symbol, onSelectToken }: Props) { {symbol ? ( ) : ( -
+
Select a market
)} diff --git a/apps/web/src/features/trade/components/chart/TVChartContainer.test.tsx b/apps/web/src/features/trade/components/chart/TVChartContainer.test.tsx new file mode 100644 index 0000000..e42d750 --- /dev/null +++ b/apps/web/src/features/trade/components/chart/TVChartContainer.test.tsx @@ -0,0 +1,330 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { cleanup, render, screen } from "@testing-library/react" +import { TVChartContainer } from "./TVChartContainer" + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("lightweight-charts", () => ({ + CandlestickSeries: Symbol("CandlestickSeries"), + LineStyle: { Dashed: 0, LargeDashed: 1 }, + createChart: vi.fn(() => ({ + addSeries: vi.fn(() => ({ + setData: vi.fn(), + update: vi.fn(), + createPriceLine: vi.fn(() => ({ applyOptions: vi.fn() })), + removePriceLine: vi.fn(), + applyOptions: vi.fn(), + })), + remove: vi.fn(), + applyOptions: vi.fn(), + timeScale: vi.fn(() => ({ fitContent: vi.fn() })), + })), +})) + +let mockCandles: Array> = [] +let mockIsLoading = false +let mockIsError = false +let mockLiveBar: Record | null = null +let mockPositions: Array> = [] + +vi.mock("../../hooks/useOracleCandles", () => ({ + useOracleCandles: () => ({ + data: mockCandles, + isLoading: mockIsLoading, + isError: mockIsError, + }), +})) + +vi.mock("../../hooks/useLiveBar", () => ({ + useLiveBar: () => mockLiveBar, +})) + +vi.mock("../../hooks/usePositions", () => ({ + usePositions: () => ({ data: mockPositions }), +})) + +// JSDOM lacks ResizeObserver / MutationObserver +class ObserverStub { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() +} +globalThis.ResizeObserver = ObserverStub as unknown as typeof ResizeObserver +globalThis.MutationObserver = ObserverStub as unknown as typeof MutationObserver + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const SAMPLE_CANDLES = [ + { time: 1_700_000_000, open: 100, high: 110, low: 90, close: 105 }, + { time: 1_700_003_600, open: 105, high: 115, low: 95, close: 110 }, + { time: 1_700_007_200, open: 110, high: 120, low: 100, close: 115 }, +] + +// ── Suite ──────────────────────────────────────────────────────────────────── + +describe("TVChartContainer", () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + beforeEach(() => { + mockCandles = [] + mockIsLoading = false + mockIsError = false + mockLiveBar = null + mockPositions = [] + }) + + const defaultProps = { symbol: "BTC", period: "5m" } + + // ═════════════════════════════════════════════════════════════════════════ + // Summary content + // ═════════════════════════════════════════════════════════════════════════ + + it("renders an accessible summary with symbol, period, and latest price", () => { + mockCandles = SAMPLE_CANDLES + render() + + const summary = screen.getByText(/5m chart for BTC/i) + expect(summary).toBeInTheDocument() + + expect(screen.getByText(/Latest:/)).toBeInTheDocument() + }) + + it("does not render a summary when there is no candle data", () => { + mockCandles = [] + render() + + expect(screen.queryByText(/chart for BTC/i)).not.toBeInTheDocument() + }) + + it("renders chart container with accessible role and label", () => { + mockCandles = SAMPLE_CANDLES + render() + + const chart = screen.getByRole("img", { name: /price chart for btc/i }) + expect(chart).toBeInTheDocument() + }) + + it("describes the chart as linked from the summary", () => { + mockCandles = SAMPLE_CANDLES + const { container } = render() + + const chart = container.querySelector('[aria-describedby="chart-desc"]') + expect(chart).toBeInTheDocument() + }) + + it('identifies prices with "upward" when close is significantly higher than open', () => { + mockCandles = [ + { time: 1_700_000_000, open: 100, high: 150, low: 90, close: 145 }, + ] + render() + + expect(screen.getByText(/upward/i)).toBeInTheDocument() + }) + + it('identifies prices with "downward" when close is significantly lower than open', () => { + mockCandles = [ + { time: 1_700_000_000, open: 100, high: 102, low: 50, close: 55 }, + ] + render() + + expect(screen.getByText(/downward/i)).toBeInTheDocument() + }) + + it('identifies prices as "sideways" when change is small', () => { + mockCandles = [ + { time: 1_700_000_000, open: 100, high: 101, low: 99, close: 100.5 }, + ] + render() + + expect(screen.getByText(/sideways/i)).toBeInTheDocument() + }) + + // ═════════════════════════════════════════════════════════════════════════ + // Data-table semantics + // ═════════════════════════════════════════════════════════════════════════ + + it("provides a toggle button to show OHLC data", () => { + mockCandles = SAMPLE_CANDLES + render() + + const toggle = screen.getByRole("button", { name: /BTC OHLC Data/i }) + expect(toggle).toBeInTheDocument() + expect(toggle).toHaveAttribute("aria-expanded", "false") + }) + + it("shows the data table when toggled", async () => { + mockCandles = SAMPLE_CANDLES + render() + + const toggle = screen.getByRole("button", { name: /BTC OHLC Data/i }) + toggle.click() + + const headers = screen.getAllByRole("columnheader") + expect(headers).toHaveLength(5) + expect(headers[0]).toHaveTextContent(/time|date/i) + expect(headers[1]).toHaveTextContent("Open") + expect(headers[2]).toHaveTextContent("High") + expect(headers[3]).toHaveTextContent("Low") + expect(headers[4]).toHaveTextContent("Close") + + const rows = screen.getAllByRole("row") + // header row + data rows + expect(rows.length).toBeGreaterThanOrEqual(4) + }) + + it("renders semantic table elements", () => { + mockCandles = SAMPLE_CANDLES + const { container } = render() + + screen.getByRole("button", { name: /BTC OHLC Data/i }).click() + + expect(container.querySelector("table")).toBeInTheDocument() + expect(container.querySelector("thead")).toBeInTheDocument() + expect(container.querySelector("tbody")).toBeInTheDocument() + }) + + it("renders rows in chronological order", () => { + mockCandles = SAMPLE_CANDLES + render() + + screen.getByRole("button", { name: /BTC OHLC Data/i }).click() + + const cells = screen.getAllByRole("cell") + const timeCells = cells.filter( + (c) => c.textContent && /\d/.test(c.textContent), + ) + + const times = timeCells.map((c) => new Date(c.textContent!).getTime()) + for (let i = 1; i < times.length; i++) { + expect(times[i]).toBeGreaterThanOrEqual(times[i - 1]) + } + }) + + it("toggles the table hidden state correctly", () => { + mockCandles = SAMPLE_CANDLES + render() + + const toggle = screen.getByRole("button", { name: /BTC OHLC Data/i }) + expect(toggle).toHaveAttribute("aria-expanded", "false") + + toggle.click() + expect(toggle).toHaveAttribute("aria-expanded", "true") + expect(screen.getByText("Open")).toBeInTheDocument() + + toggle.click() + expect(toggle).toHaveAttribute("aria-expanded", "false") + }) + + it("limits representative rows to avoid giant tables", () => { + const manyCandles = Array.from({ length: 200 }, (_, i) => ({ + time: 1_700_000_000 + i * 60, + open: 100 + i, + high: 110 + i, + low: 90 + i, + close: 105 + i, + })) + mockCandles = manyCandles + render() + + screen.getByRole("button", { name: /BTC OHLC Data/i }).click() + + // header row + max 15 data rows + const rows = screen.getAllByRole("row") + expect(rows.length).toBeLessThanOrEqual(16) + }) + + // ═════════════════════════════════════════════════════════════════════════ + // Announcement throttling + // ═════════════════════════════════════════════════════════════════════════ + + it("announces price updates via live region", () => { + mockCandles = SAMPLE_CANDLES + mockLiveBar = { time: 1_700_010_000, open: 112, high: 118, low: 108, close: 115 } + render() + + const region = screen.getByRole("status") + expect(region).toBeInTheDocument() + expect(region).toHaveAttribute("aria-live", "polite") + }) + + it("does not make a new announcement within the throttle window", () => { + vi.useFakeTimers() + mockCandles = SAMPLE_CANDLES + + const { rerender } = render() + + // First live bar triggers announcement + mockLiveBar = { time: 1_700_010_000, open: 112, high: 118, low: 108, close: 115 } + rerender() + + const region = screen.getByRole("status") + const textAfterFirst = region.textContent ?? "" + expect(textAfterFirst).toMatch(/BTC/) + + // Second live bar within 5 seconds — no new announcement + mockLiveBar = { time: 1_700_010_001, open: 113, high: 119, low: 109, close: 116 } + rerender() + + expect(region.textContent).toBe(textAfterFirst) + + vi.useRealTimers() + }) + + it("makes a new announcement after the throttle window elapses", () => { + vi.useFakeTimers() + mockCandles = SAMPLE_CANDLES + + const { rerender } = render() + + // First + mockLiveBar = { time: 1_700_010_000, open: 112, high: 118, low: 108, close: 115 } + rerender() + + const region = screen.getByRole("status") + const textAfterFirst = region.textContent ?? "" + + // Advance past 5s throttle + vi.advanceTimersByTime(6000) + + // Second live bar after throttle window + mockLiveBar = { time: 1_700_010_001, open: 113, high: 119, low: 109, close: 116 } + rerender() + + expect(region.textContent).not.toBe(textAfterFirst) + + vi.useRealTimers() + }) + + // ═════════════════════════════════════════════════════════════════════════ + // Loading, empty, and error states + // ═════════════════════════════════════════════════════════════════════════ + + it("shows loading state with accessible text", () => { + mockIsLoading = true + mockCandles = [] + render() + + const loading = screen.getByRole("status", { name: /loading 5m chart data for btc/i }) + expect(loading).toBeInTheDocument() + }) + + it("shows empty state when no data and not loading", () => { + mockCandles = [] + render() + + const empty = screen.getByRole("status") + expect(empty).toHaveTextContent(/no trading data available for btc/i) + }) + + it("shows error state with alert role", () => { + mockIsError = true + mockCandles = [] + render() + + const error = screen.getByRole("alert") + expect(error).toHaveTextContent(/unable to load chart data for btc/i) + }) +}) diff --git a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx index bdb6c7e..6b6935b 100644 --- a/apps/web/src/features/trade/components/chart/TVChartContainer.tsx +++ b/apps/web/src/features/trade/components/chart/TVChartContainer.tsx @@ -1,6 +1,17 @@ import { CandlestickSeries, LineStyle, createChart } from "lightweight-charts" -import { useEffect, useRef, useState } from "react" +import { useEffect, useMemo, useRef, useState } from "react" import { Skeleton } from "@workspace/ui/components/skeleton" +import { VisuallyHidden } from "@workspace/ui/components/visually-hidden" +import { LiveRegion, useAnnouncer } from "@workspace/ui/components/live-region" +import { + Table, + TableHeader, + TableBody, + TableHeadRow, + TableHead, + TableRow, + TableCell, +} from "@workspace/ui/components/table" import { useOracleCandles } from "../../hooks/useOracleCandles" import { useLiveBar } from "../../hooks/useLiveBar" import { usePositions } from "../../hooks/usePositions" @@ -10,6 +21,7 @@ import { getChartPalette, positionLineColor, } from "../../lib/chart-theme" +import { formatUsd } from "@/shared/lib/format" import type {CandlestickData, IChartApi, IPriceLine, ISeriesApi, UTCTimestamp} from "lightweight-charts"; import type { OhlcBar } from "../../lib/oracle" @@ -26,6 +38,52 @@ type Props = { period: string } +type ChartSummary = { + timeRange: string + latestPrice: string + trend: string + description: string +} + +function getChartSummary( + candles: Array, + liveBar: OhlcBar | null, + symbol: string, + period: string, +): ChartSummary | null { + if (candles.length === 0) return null + const first = candles[0] + const last = candles[candles.length - 1] + const latest = liveBar ?? last + const start = new Date(first.time * 1000).toLocaleDateString() + const end = new Date(last.time * 1000).toLocaleDateString() + const timeRange = first.time === last.time ? start : `${start} – ${end}` + const latestPrice = formatUsd(latest.close, { decimals: 4 }) + const mid = (candles[0].open + last.close) / 2 + const change = mid === 0 ? 0 : ((last.close - candles[0].open) / mid) * 100 + const trend = change > 0.5 ? "upward" : change < -0.5 ? "downward" : "sideways" + const description = + `${period} chart for ${symbol}. ` + + `Range: ${timeRange}. ` + + `Latest: ${latestPrice}. ` + + `Trend: ${trend}.` + return { timeRange, latestPrice, trend, description } +} + +function getRepresentativeRows( + candles: Array, + maxRows = 15, +): Array { + if (candles.length <= maxRows) return candles + const step = Math.floor((candles.length - 2) / (maxRows - 2)) + const rows: Array = [candles[0]] + for (let i = 1; i < maxRows - 1; i++) { + rows.push(candles[i * step]) + } + rows.push(candles[candles.length - 1]) + return rows +} + function toChartBar(bar: OhlcBar): CandlestickData { return { time: bar.time as UTCTimestamp, @@ -45,13 +103,22 @@ export function TVChartContainer({ symbol, period }: Props) { // Prevents series.update() from firing against an empty or stale series. const hasDataRef = useRef(false) - const { data: candles = [], isLoading } = useOracleCandles(symbol, period) + const { data: candles = [], isLoading, isError } = useOracleCandles(symbol, period) const liveBar = useLiveBar(symbol, period) const { data: positions = [] } = usePositions() + const [showTable, setShowTable] = useState(false) + const lastAnnounceRef = useRef(0) + const announcer = useAnnouncer() + // Bumped by the theme observer below so colour-dependent effects re-run. const [themeVersion, setThemeVersion] = useState(0) + const hasData = candles.length > 0 + const isIdle = !isLoading && !hasData && !isError + const summary = useMemo(() => getChartSummary(candles, liveBar, symbol, period), [candles, liveBar, symbol, period]) + const tableRows = useMemo(() => getRepresentativeRows(candles), [candles]) + // ── Mount chart once ─────────────────────────────────────────────────────── useEffect(() => { if (!containerRef.current) return @@ -197,14 +264,115 @@ export function TVChartContainer({ symbol, period }: Props) { }) }, [positions, symbol, themeVersion]) + // ── Throttled live-price announcements ──────────────────────────────────── + const THROTTLE_MS = 5000 + useEffect(() => { + if (!liveBar || !summary) return + const now = Date.now() + if (now - lastAnnounceRef.current < THROTTLE_MS) return + lastAnnounceRef.current = now + announcer.announce(`${symbol} ${summary.latestPrice}`) + }, [liveBar, summary, symbol, announcer]) + return ( -
- {isLoading && ( -
- +
+ {/* ═══ Chart area ═══════════════════════════════════════════════════════ */} +
+ {/* Accessible summary for screen readers */} + {summary && ( + + {summary.description} + + )} + + {/* Loading skeleton */} + {isLoading && ( +
+ + Loading {period} chart data for {symbol}… +
+ )} + + {/* Empty state */} + {isIdle && ( +
+ No trading data available for {symbol} +
+ )} + + {/* Error state */} + {isError && ( +
+ Unable to load chart data for {symbol} +
+ )} + + {/* Lightweight Charts canvas — hidden from AT; the VisuallyHidden summary replaces it */} +
+
+ + {/* ═══ Data-table fallback ══════════════════════════════════════════════ */} + {hasData && tableRows.length > 0 && ( +
+ + + {showTable && ( +
+ + + + + Time + + + Open + High + Low + Close + + + + {tableRows.map((bar) => ( + + + {new Date(bar.time * 1000).toLocaleString()} + + {formatUsd(bar.open, { decimals: 4 })} + {formatUsd(bar.high, { decimals: 4 })} + {formatUsd(bar.low, { decimals: 4 })} + {formatUsd(bar.close, { decimals: 4 })} + + ))} + +
+
+ )}
)} -
+ + {/* ═══ Live price announcer (throttled, polite) ════════════════════════ */} +
) }