Make history charts readable to assistive tech

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-01 11:41:21 +01:00
parent a34f3c752b
commit ca2e296ce3
4 changed files with 185 additions and 2 deletions
@@ -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<HistoryChartProps> = (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<HistoryChartProps> = (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 (
<div
@@ -42,9 +57,15 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
<canvas
ref={canvasRef}
class="block w-full h-full cursor-crosshair"
role="img"
aria-label={getHistoryChartAccessibleLabel(props.label)}
aria-describedby={descriptionId}
onMouseMove={chart.handleMouseMove}
onMouseLeave={chart.handleMouseLeave}
/>
<p id={descriptionId} class="sr-only">
{accessibleDescription()}
</p>
<HistoryChartOverlay chart={chart} hideLock={props.hideLock} />
<HistoryChartTooltip
hoveredPoint={chart.hoveredPoint()}
@@ -132,6 +132,32 @@ describe('HistoryChart', () => {
render(() => <HistoryChart resourceType="agent" resourceId="node-1" metric="cpu" />);
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(() => (
<HistoryChart
resourceType="agent"
resourceId="node-1"
metric="cpu"
label="CPU usage"
unit="%"
range="24h"
data={[
{ timestamp: 1_000, value: 10, min: 8, max: 12 },
{ timestamp: 2_000, value: 30, min: 25, max: 35 },
]}
/>
));
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', () => {
@@ -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%');
@@ -44,6 +44,27 @@ export const HISTORY_CHART_RANGES: HistoryTimeRange[] = [
export const HISTORY_CHART_MIN_LEFT_INSET = 40;
const HISTORY_CHART_RANGE_LABELS: Record<HistoryTimeRange, string> = {
'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':