From ca2e296ce3917bfe0470d5794b2256feaa9fb751 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:41:21 +0100 Subject: [PATCH] Make history charts readable to assistive tech Change-source: pulse-maintainer --- .../src/components/shared/HistoryChart.tsx | 25 ++++++- .../shared/__tests__/HistoryChart.test.tsx | 26 +++++++ .../historyChartModel.branchcov2.test.ts | 72 +++++++++++++++++++ .../components/shared/historyChartModel.ts | 64 +++++++++++++++++ 4 files changed, 185 insertions(+), 2 deletions(-) diff --git a/frontend-modern/src/components/shared/HistoryChart.tsx b/frontend-modern/src/components/shared/HistoryChart.tsx index 3409300cd..6448b34d2 100644 --- a/frontend-modern/src/components/shared/HistoryChart.tsx +++ b/frontend-modern/src/components/shared/HistoryChart.tsx @@ -1,5 +1,9 @@ -import { Component } from 'solid-js'; -import type { HistoryChartProps } from './historyChartModel'; +import { Component, createMemo, createUniqueId } from 'solid-js'; +import { + getHistoryChartAccessibleDescription, + getHistoryChartAccessibleLabel, + type HistoryChartProps, +} from './historyChartModel'; import { HistoryChartHeader } from './HistoryChartHeader'; import { HistoryChartHoverGroup, useHistoryChartHoverGroup } from './HistoryChartHoverGroup'; import { HistoryChartOverlay } from './HistoryChartOverlay'; @@ -12,6 +16,7 @@ export { HistoryChartHoverGroup }; export const HistoryChart: Component = (props) => { let canvasRef: HTMLCanvasElement | undefined; let containerRef: HTMLDivElement | undefined; + const descriptionId = `history-chart-description-${createUniqueId()}`; const hoverGroup = useHistoryChartHoverGroup(); const chart = useHistoryChartState( @@ -22,6 +27,16 @@ export const HistoryChart: Component = (props) => { }, hoverGroup, ); + const accessibleDescription = createMemo(() => + getHistoryChartAccessibleDescription({ + data: chart.data(), + error: chart.error(), + isLocked: chart.isLocked(), + loading: chart.loading(), + range: chart.range(), + unit: props.unit, + }), + ); return (
= (props) => { +

+ {accessibleDescription()} +

{ render(() => ); expect(screen.getByText('History')).toBeInTheDocument(); + expect(screen.getByRole('img', { name: 'History chart' })).toHaveAttribute('aria-describedby'); + }); + + it('provides a text equivalent for a populated history chart', () => { + render(() => ( + + )); + + const chart = screen.getByRole('img', { name: 'CPU usage chart' }); + const description = document.getElementById(chart.getAttribute('aria-describedby')!); + + expect(description).toHaveClass('sr-only'); + expect(description).toHaveTextContent('24-hour history contains 2 data points'); + expect(description).toHaveTextContent('Values increased from 10.0% to 30.0%.'); + expect(description).toHaveTextContent('Minimum 8.0%; maximum 35.0%.'); }); it('synchronizes the hovered timestamp across charts in the same group', () => { diff --git a/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts b/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts index 39eaefcdf..ddeb543f9 100644 --- a/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts +++ b/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts @@ -5,6 +5,8 @@ import { findHistoryChartClosestPoint, formatHistoryChartTimeLabel, formatHistoryChartTooltipValue, + getHistoryChartAccessibleDescription, + getHistoryChartAccessibleLabel, getHistoryChartDataMax, getHistoryChartDataMin, getHistoryChartDefaultColor, @@ -23,6 +25,76 @@ const pt = (timestamp: number, value: number, min: number, max: number): Aggrega max, }); +describe('history chart text alternatives', () => { + it('uses the visible metric label as the chart name', () => { + expect(getHistoryChartAccessibleLabel(' CPU usage ')).toBe('CPU usage chart'); + expect(getHistoryChartAccessibleLabel()).toBe('History chart'); + }); + + it.each([ + [{ loading: true, error: null, isLocked: false }, 'Loading 24-hour history data.'], + [ + { loading: false, error: 'request failed', isLocked: false }, + '24-hour history data could not be loaded.', + ], + [ + { loading: false, error: null, isLocked: true }, + '24-hour history data is unavailable on the current plan.', + ], + [{ loading: false, error: null, isLocked: false }, 'No 24-hour history data is available.'], + ])('describes an unavailable chart state', (state, expected) => { + expect( + getHistoryChartAccessibleDescription({ + data: [], + range: '24h', + ...state, + }), + ).toBe(expected); + }); + + it('describes a single sample without inventing a trend', () => { + const description = getHistoryChartAccessibleDescription({ + data: [pt(1_000, 23.6, 20, 25)], + error: null, + isLocked: false, + loading: false, + range: '30m', + unit: 'C', + }); + + expect(description).toContain('30-minute history contains 1 data point at'); + expect(description).toContain(': 24°C.'); + }); + + it('describes a stable multi-point series and its extrema', () => { + const description = getHistoryChartAccessibleDescription({ + data: [pt(1_000, 10, 8, 12), pt(2_000, 10, 7, 14)], + error: null, + isLocked: false, + loading: false, + range: '7d', + unit: 'rpm', + }); + + expect(description).toContain('7-day history contains 2 data points'); + expect(description).toContain('Values remained unchanged at 10 rpm.'); + expect(description).toContain('Minimum 7 rpm; maximum 14 rpm.'); + }); + + it('describes a decreasing multi-point series', () => { + const description = getHistoryChartAccessibleDescription({ + data: [pt(1_000, 30, 25, 35), pt(2_000, 10, 8, 12)], + error: null, + isLocked: false, + loading: false, + range: '6h', + unit: '%', + }); + + expect(description).toContain('Values decreased from 30.0% to 10.0%.'); + }); +}); + describe('formatHistoryChartTooltipValue', () => { it('formats percentage units with one decimal', () => { expect(formatHistoryChartTooltipValue(42.35, '%')).toBe('42.4%'); diff --git a/frontend-modern/src/components/shared/historyChartModel.ts b/frontend-modern/src/components/shared/historyChartModel.ts index 19f873801..ed788e2c3 100644 --- a/frontend-modern/src/components/shared/historyChartModel.ts +++ b/frontend-modern/src/components/shared/historyChartModel.ts @@ -44,6 +44,27 @@ export const HISTORY_CHART_RANGES: HistoryTimeRange[] = [ export const HISTORY_CHART_MIN_LEFT_INSET = 40; +const HISTORY_CHART_RANGE_LABELS: Record = { + '30m': '30-minute', + '1h': '1-hour', + '6h': '6-hour', + '12h': '12-hour', + '24h': '24-hour', + '7d': '7-day', + '14d': '14-day', + '30d': '30-day', + '90d': '90-day', +}; + +export interface HistoryChartAccessibleDescriptionInput { + data: AggregatedMetricPoint[]; + error: string | null; + isLocked: boolean; + loading: boolean; + range: HistoryTimeRange; + unit?: string; +} + export function formatHistoryChartTooltipValue(value: number, unit?: string): string { if (unit === '%') return `${value.toFixed(1)}%`; if (unit === 'B/s') return `${formatBytes(value)}/s`; @@ -52,6 +73,49 @@ export function formatHistoryChartTooltipValue(value: number, unit?: string): st return `${Number.isInteger(value) ? value : value.toFixed(1)} ${unit}`; } +export function getHistoryChartAccessibleLabel(label?: string): string { + return `${label?.trim() || 'History'} chart`; +} + +export function getHistoryChartAccessibleDescription({ + data, + error, + isLocked, + loading, + range, + unit, +}: HistoryChartAccessibleDescriptionInput): string { + const rangeLabel = HISTORY_CHART_RANGE_LABELS[range] ?? `${range} history`; + if (loading) return `Loading ${rangeLabel} history data.`; + if (error) return `${rangeLabel} history data could not be loaded.`; + if (isLocked) return `${rangeLabel} history data is unavailable on the current plan.`; + if (data.length === 0) return `No ${rangeLabel} history data is available.`; + + const first = data[0]; + const latest = data[data.length - 1]; + const minimum = getHistoryChartDataMin(data)!; + const maximum = getHistoryChartDataMax(data)!; + const formatTimestamp = (timestamp: number) => new Date(timestamp).toLocaleString(); + const formatValue = (value: number) => formatHistoryChartTooltipValue(value, unit); + + if (data.length === 1) { + return `${rangeLabel} history contains 1 data point at ${formatTimestamp(latest.timestamp)}: ${formatValue(latest.value)}.`; + } + + const direction = + latest.value > first.value + ? 'increased' + : latest.value < first.value + ? 'decreased' + : 'remained unchanged'; + const changeSummary = + direction === 'remained unchanged' + ? `Values remained unchanged at ${formatValue(latest.value)}.` + : `Values ${direction} from ${formatValue(first.value)} to ${formatValue(latest.value)}.`; + + return `${rangeLabel} history contains ${data.length} data points from ${formatTimestamp(first.timestamp)} to ${formatTimestamp(latest.timestamp)}. ${changeSummary} Minimum ${formatValue(minimum)}; maximum ${formatValue(maximum)}.`; +} + export function getHistoryChartRefreshIntervalMs(range: HistoryTimeRange) { switch (range) { case '7d':