mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55:51 +00:00
Make history charts readable to assistive tech
Change-source: pulse-maintainer
This commit is contained in:
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user