@astryxdesign/lab 0.2.0-canary.f306f13 → 0.2.0-canary.fc53ff7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Chart/Chart.d.ts +12 -1
- package/dist/Chart/Chart.d.ts.map +1 -1
- package/dist/Chart/Chart.js +51 -3
- package/dist/CodeEditor/CodeEditor.d.ts +14 -2
- package/dist/CodeEditor/CodeEditor.d.ts.map +1 -1
- package/dist/CodeEditor/CodeEditor.js +110 -9
- package/dist/Radial/RadialChart.d.ts +12 -1
- package/dist/Radial/RadialChart.d.ts.map +1 -1
- package/dist/Radial/RadialChart.js +89 -4
- package/dist/Sankey/SankeyChart.d.ts +13 -1
- package/dist/Sankey/SankeyChart.d.ts.map +1 -1
- package/dist/Sankey/SankeyChart.js +53 -5
- package/dist/ThreeD/ThreeDChart.d.ts +16 -3
- package/dist/ThreeD/ThreeDChart.d.ts.map +1 -1
- package/dist/ThreeD/ThreeDChart.js +63 -5
- package/package.json +2 -2
- package/src/Chart/Chart.test.tsx +126 -0
- package/src/Chart/Chart.tsx +59 -0
- package/src/CodeEditor/CodeEditor.test.tsx +172 -2
- package/src/CodeEditor/CodeEditor.tsx +142 -9
- package/src/Radial/RadialChart.test.tsx +157 -0
- package/src/Radial/RadialChart.tsx +102 -1
- package/src/Sankey/SankeyChart.test.tsx +95 -0
- package/src/Sankey/SankeyChart.tsx +55 -1
- package/src/ThreeD/ThreeDChart.test.tsx +251 -0
- package/src/ThreeD/ThreeDChart.tsx +84 -6
|
@@ -7,12 +7,24 @@
|
|
|
7
7
|
*
|
|
8
8
|
* When columns × minColumnWidth exceeds the container, the chart
|
|
9
9
|
* scrolls horizontally rather than squishing columns together.
|
|
10
|
+
*
|
|
11
|
+
* Accessibility: the svg is exposed as a named image (role="img" + aria-label,
|
|
12
|
+
* overridable via `label`) in both the scrolling and non-scrolling paths, and
|
|
13
|
+
* small graphs are mirrored in a visually hidden From/To/Value table.
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
16
|
import { useMemo, useRef, useState, useLayoutEffect } from 'react';
|
|
17
|
+
import { VisuallyHidden } from '@astryxdesign/core/VisuallyHidden';
|
|
13
18
|
import { SankeyProvider } from "./SankeyContext.js";
|
|
14
19
|
import { computeLayout } from "./layout.js";
|
|
15
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
20
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
21
|
+
/**
|
|
22
|
+
* Maximum number of links for which the visually hidden data-table fallback is
|
|
23
|
+
* rendered. Beyond this a table is more noise than signal, so larger charts
|
|
24
|
+
* are name-only.
|
|
25
|
+
*/
|
|
26
|
+
const MAX_TABLE_LINKS = 100;
|
|
27
|
+
|
|
16
28
|
/** Resolve column count from raw column input or auto-detect from graph */
|
|
17
29
|
function resolveColumnCount(columns, nodes, links) {
|
|
18
30
|
if (columns) {
|
|
@@ -85,6 +97,7 @@ export function SankeyChart({
|
|
|
85
97
|
nodeGap = 14,
|
|
86
98
|
nodeColor,
|
|
87
99
|
minColumnWidth = 160,
|
|
100
|
+
label = 'Sankey chart',
|
|
88
101
|
children
|
|
89
102
|
}) {
|
|
90
103
|
const containerRef = useRef(null);
|
|
@@ -133,20 +146,24 @@ export function SankeyChart({
|
|
|
133
146
|
nodeColor
|
|
134
147
|
};
|
|
135
148
|
}, [layout, chartWidth, height, nodeWidth, nodeColor]);
|
|
136
|
-
|
|
149
|
+
const nodeLabelById = useMemo(() => new Map(nodes.map(n => [n.id, n.label || n.id])), [nodes]);
|
|
150
|
+
const showDataTable = links.length > 0 && links.length <= MAX_TABLE_LINKS;
|
|
151
|
+
return /*#__PURE__*/_jsxs("div", {
|
|
137
152
|
ref: containerRef,
|
|
138
153
|
style: {
|
|
139
154
|
width: '100%'
|
|
140
155
|
},
|
|
141
|
-
children: ctx && /*#__PURE__*/_jsx("div", {
|
|
156
|
+
children: [ctx && /*#__PURE__*/_jsx("div", {
|
|
142
157
|
role: needsScroll ? 'group' : undefined,
|
|
143
|
-
"aria-label": needsScroll ?
|
|
158
|
+
"aria-label": needsScroll ? label : undefined,
|
|
144
159
|
tabIndex: needsScroll ? 0 : undefined,
|
|
145
160
|
style: needsScroll ? {
|
|
146
161
|
overflowX: 'auto',
|
|
147
162
|
overflowY: 'hidden'
|
|
148
163
|
} : undefined,
|
|
149
164
|
children: /*#__PURE__*/_jsx("svg", {
|
|
165
|
+
role: "img",
|
|
166
|
+
"aria-label": label,
|
|
150
167
|
width: chartWidth,
|
|
151
168
|
height: height,
|
|
152
169
|
style: {
|
|
@@ -158,7 +175,38 @@ export function SankeyChart({
|
|
|
158
175
|
children: children
|
|
159
176
|
})
|
|
160
177
|
})
|
|
161
|
-
})
|
|
178
|
+
}), showDataTable && /*#__PURE__*/_jsx(VisuallyHidden, {
|
|
179
|
+
as: "div",
|
|
180
|
+
children: /*#__PURE__*/_jsxs("table", {
|
|
181
|
+
children: [/*#__PURE__*/_jsx("caption", {
|
|
182
|
+
children: `${label} data`
|
|
183
|
+
}), /*#__PURE__*/_jsx("thead", {
|
|
184
|
+
children: /*#__PURE__*/_jsxs("tr", {
|
|
185
|
+
children: [/*#__PURE__*/_jsx("th", {
|
|
186
|
+
scope: "col",
|
|
187
|
+
children: "From"
|
|
188
|
+
}), /*#__PURE__*/_jsx("th", {
|
|
189
|
+
scope: "col",
|
|
190
|
+
children: "To"
|
|
191
|
+
}), /*#__PURE__*/_jsx("th", {
|
|
192
|
+
scope: "col",
|
|
193
|
+
children: "Value"
|
|
194
|
+
})]
|
|
195
|
+
})
|
|
196
|
+
}), /*#__PURE__*/_jsx("tbody", {
|
|
197
|
+
children: links.map((link, i) => /*#__PURE__*/_jsxs("tr", {
|
|
198
|
+
children: [/*#__PURE__*/_jsx("th", {
|
|
199
|
+
scope: "row",
|
|
200
|
+
children: nodeLabelById.get(link.source) ?? link.source
|
|
201
|
+
}), /*#__PURE__*/_jsx("td", {
|
|
202
|
+
children: nodeLabelById.get(link.target) ?? link.target
|
|
203
|
+
}), /*#__PURE__*/_jsx("td", {
|
|
204
|
+
children: String(link.value)
|
|
205
|
+
})]
|
|
206
|
+
}, i))
|
|
207
|
+
})]
|
|
208
|
+
})
|
|
209
|
+
})]
|
|
162
210
|
});
|
|
163
211
|
}
|
|
164
212
|
SankeyChart.displayName = 'SankeyChart';
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file ThreeDChart.tsx
|
|
3
|
-
* @output Root 3D chart container — projected SVG with depth sorting
|
|
3
|
+
* @output Root 3D chart container — projected SVG with depth sorting,
|
|
4
|
+
* accessible name, pointer + keyboard camera controls, and pausable
|
|
5
|
+
* auto-rotation (respects prefers-reduced-motion)
|
|
4
6
|
* @position Parent component; all 3D marks read from its context
|
|
5
7
|
*/
|
|
6
8
|
import { type ReactNode } from 'react';
|
|
@@ -16,9 +18,20 @@ export interface ThreeDChartProps {
|
|
|
16
18
|
azimuth?: number;
|
|
17
19
|
elevation?: number;
|
|
18
20
|
interactive?: boolean;
|
|
19
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Auto-rotate speed in degrees per frame (default: 0 = off).
|
|
23
|
+
*
|
|
24
|
+
* Rotation pauses while the chart is hovered or focused, and is disabled
|
|
25
|
+
* entirely when the user has `prefers-reduced-motion: reduce` set. Pass 0
|
|
26
|
+
* (or omit) to turn it off.
|
|
27
|
+
*/
|
|
20
28
|
autoRotate?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Accessible label for the chart. Defaults to a description derived from
|
|
31
|
+
* the data keys, e.g. "3D chart of sales by month and region".
|
|
32
|
+
*/
|
|
33
|
+
label?: string;
|
|
21
34
|
children: ReactNode;
|
|
22
35
|
}
|
|
23
|
-
export declare function ThreeDChart({ data, xKey, yKey, zKey, xDomain: xDomainProp, yDomain: yDomainProp, zDomain: zDomainProp, height, azimuth: azimuthProp, elevation: elevationProp, interactive, autoRotate, children, }: ThreeDChartProps): import("react").JSX.Element;
|
|
36
|
+
export declare function ThreeDChart({ data, xKey, yKey, zKey, xDomain: xDomainProp, yDomain: yDomainProp, zDomain: zDomainProp, height, azimuth: azimuthProp, elevation: elevationProp, interactive, autoRotate, label, children, }: ThreeDChartProps): import("react").JSX.Element;
|
|
24
37
|
//# sourceMappingURL=ThreeDChart.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ThreeDChart.d.ts","sourceRoot":"","sources":["../../src/ThreeD/ThreeDChart.tsx"],"names":[],"mappings":"AAEA
|
|
1
|
+
{"version":3,"file":"ThreeDChart.d.ts","sourceRoot":"","sources":["../../src/ThreeD/ThreeDChart.tsx"],"names":[],"mappings":"AAEA;;;;;;GAMG;AAEH,OAAO,EAGL,KAAK,SAAS,EAOf,MAAM,OAAO,CAAC;AAKf,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3B,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3B,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,SAAS,CAAC;CACrB;AAyBD,wBAAgB,WAAW,CAAC,EAC1B,IAAI,EACJ,IAAI,EACJ,IAAI,EACJ,IAAI,EACJ,OAAO,EAAE,WAAW,EACpB,OAAO,EAAE,WAAW,EACpB,OAAO,EAAE,WAAW,EACpB,MAAY,EACZ,OAAO,EAAE,WAAgB,EACzB,SAAS,EAAE,aAAkB,EAC7B,WAAmB,EACnB,UAAc,EACd,KAAK,EACL,QAAQ,GACT,EAAE,gBAAgB,+BAwPlB"}
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @file ThreeDChart.tsx
|
|
5
|
-
* @output Root 3D chart container — projected SVG with depth sorting
|
|
5
|
+
* @output Root 3D chart container — projected SVG with depth sorting,
|
|
6
|
+
* accessible name, pointer + keyboard camera controls, and pausable
|
|
7
|
+
* auto-rotation (respects prefers-reduced-motion)
|
|
6
8
|
* @position Parent component; all 3D marks read from its context
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import { useMemo, useRef, useState, useEffect, useLayoutEffect, useCallback } from 'react';
|
|
12
|
+
import { useMediaQuery } from '@astryxdesign/core/hooks';
|
|
10
13
|
import { ThreeDProvider } from "./ThreeDContext.js";
|
|
11
14
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
15
|
+
/** Degrees the camera rotates per arrow-key press. */
|
|
16
|
+
const KEYBOARD_STEP_DEGREES = 10;
|
|
12
17
|
function computeDomain(data, key) {
|
|
13
18
|
let min = Infinity,
|
|
14
19
|
max = -Infinity;
|
|
@@ -38,10 +43,14 @@ export function ThreeDChart({
|
|
|
38
43
|
elevation: elevationProp = 25,
|
|
39
44
|
interactive = false,
|
|
40
45
|
autoRotate = 0,
|
|
46
|
+
label,
|
|
41
47
|
children
|
|
42
48
|
}) {
|
|
43
49
|
const containerRef = useRef(null);
|
|
44
50
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
51
|
+
const [hovered, setHovered] = useState(false);
|
|
52
|
+
const [focused, setFocused] = useState(false);
|
|
53
|
+
const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
|
45
54
|
const [camera, setCamera] = useState({
|
|
46
55
|
azimuth: azimuthProp,
|
|
47
56
|
elevation: elevationProp
|
|
@@ -123,9 +132,44 @@ export function ThreeDChart({
|
|
|
123
132
|
dragRef.current = null;
|
|
124
133
|
}, []);
|
|
125
134
|
|
|
126
|
-
//
|
|
135
|
+
// Keyboard camera: arrow keys rotate yaw/pitch with the same clamps as
|
|
136
|
+
// pointer drag (WCAG 2.1.1 — every pointer operation needs a keyboard
|
|
137
|
+
// equivalent). preventDefault stops the page from scrolling while focused.
|
|
138
|
+
const handleKeyDown = useCallback(e => {
|
|
139
|
+
if (!interactive) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
let deltaAzimuth = 0;
|
|
143
|
+
let deltaElevation = 0;
|
|
144
|
+
switch (e.key) {
|
|
145
|
+
case 'ArrowLeft':
|
|
146
|
+
deltaAzimuth = -KEYBOARD_STEP_DEGREES;
|
|
147
|
+
break;
|
|
148
|
+
case 'ArrowRight':
|
|
149
|
+
deltaAzimuth = KEYBOARD_STEP_DEGREES;
|
|
150
|
+
break;
|
|
151
|
+
case 'ArrowUp':
|
|
152
|
+
deltaElevation = KEYBOARD_STEP_DEGREES;
|
|
153
|
+
break;
|
|
154
|
+
case 'ArrowDown':
|
|
155
|
+
deltaElevation = -KEYBOARD_STEP_DEGREES;
|
|
156
|
+
break;
|
|
157
|
+
default:
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
setCamera(prev => ({
|
|
162
|
+
azimuth: prev.azimuth + deltaAzimuth,
|
|
163
|
+
elevation: Math.max(-89, Math.min(89, prev.elevation + deltaElevation))
|
|
164
|
+
}));
|
|
165
|
+
}, [interactive]);
|
|
166
|
+
|
|
167
|
+
// Auto-rotation — throttled to ~20fps to avoid overwhelming React with
|
|
168
|
+
// re-renders. Disabled under prefers-reduced-motion (WCAG 2.3.3) and
|
|
169
|
+
// paused while the chart is hovered or focused (WCAG 2.2.2).
|
|
170
|
+
const rotationActive = autoRotate !== 0 && !prefersReducedMotion && !hovered && !focused;
|
|
127
171
|
useEffect(() => {
|
|
128
|
-
if (
|
|
172
|
+
if (!rotationActive) {
|
|
129
173
|
return;
|
|
130
174
|
}
|
|
131
175
|
let raf;
|
|
@@ -145,7 +189,7 @@ export function ThreeDChart({
|
|
|
145
189
|
};
|
|
146
190
|
raf = requestAnimationFrame(tick);
|
|
147
191
|
return () => cancelAnimationFrame(raf);
|
|
148
|
-
}, [autoRotate]);
|
|
192
|
+
}, [autoRotate, rotationActive]);
|
|
149
193
|
const ctx = useMemo(() => ({
|
|
150
194
|
width,
|
|
151
195
|
height,
|
|
@@ -165,16 +209,30 @@ export function ThreeDChart({
|
|
|
165
209
|
touchAction: interactive ? 'none' : undefined,
|
|
166
210
|
userSelect: interactive ? 'none' : undefined
|
|
167
211
|
};
|
|
212
|
+
const accessibleLabel = label ?? `3D chart of ${yKey} by ${xKey} and ${zKey}`;
|
|
213
|
+
// Focusable when there is something keyboard users can do with it:
|
|
214
|
+
// rotate the camera (interactive) or pause the auto-rotation.
|
|
215
|
+
const focusable = interactive || autoRotate !== 0;
|
|
168
216
|
return /*#__PURE__*/_jsx("div", {
|
|
169
217
|
ref: containerRef,
|
|
170
218
|
style: containerStyle,
|
|
171
219
|
children: containerWidth > 0 && /*#__PURE__*/_jsx("svg", {
|
|
220
|
+
role: "img",
|
|
221
|
+
"aria-label": accessibleLabel,
|
|
222
|
+
tabIndex: focusable ? 0 : undefined,
|
|
172
223
|
width: containerWidth,
|
|
173
224
|
height: height,
|
|
225
|
+
onKeyDown: handleKeyDown,
|
|
226
|
+
onFocus: () => setFocused(true),
|
|
227
|
+
onBlur: () => setFocused(false),
|
|
228
|
+
onMouseEnter: () => setHovered(true),
|
|
174
229
|
onMouseDown: e => handleStart(e.clientX, e.clientY),
|
|
175
230
|
onMouseMove: e => handleMove(e.clientX, e.clientY),
|
|
176
231
|
onMouseUp: handleEnd,
|
|
177
|
-
onMouseLeave:
|
|
232
|
+
onMouseLeave: () => {
|
|
233
|
+
setHovered(false);
|
|
234
|
+
handleEnd();
|
|
235
|
+
},
|
|
178
236
|
onTouchStart: e => {
|
|
179
237
|
const t = e.touches[0];
|
|
180
238
|
if (t) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astryxdesign/lab",
|
|
3
|
-
"version": "0.2.0-canary.
|
|
3
|
+
"version": "0.2.0-canary.fc53ff7",
|
|
4
4
|
"description": "Experimental Astryx components — published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
|
|
5
5
|
"author": "Meta Open Source",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"@stylexjs/stylex": ">=0.10.0",
|
|
48
|
-
"@astryxdesign/core": "0.2.0-canary.
|
|
48
|
+
"@astryxdesign/core": "0.2.0-canary.fc53ff7",
|
|
49
49
|
"react": ">=19.0.0",
|
|
50
50
|
"react-dom": ">=19.0.0",
|
|
51
51
|
"lexical": "^0.46.0",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Chart.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, Chart component
|
|
6
|
+
* @output Unit tests for Chart accessibility (WCAG 1.1.1)
|
|
7
|
+
* @position Testing; validates Chart.tsx accessible name + data-table fallback
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';
|
|
11
|
+
import {render, screen, act} from '@testing-library/react';
|
|
12
|
+
import {Chart} from './Chart';
|
|
13
|
+
|
|
14
|
+
const data = [
|
|
15
|
+
{month: 'Jan', revenue: 100, profit: 20},
|
|
16
|
+
{month: 'Feb', revenue: 200, profit: 40},
|
|
17
|
+
{month: 'Mar', revenue: 150, profit: 30},
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Capture the ResizeObserver callback so tests can drive the reported width.
|
|
21
|
+
let resizeCallback: ResizeObserverCallback | undefined;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
resizeCallback = undefined;
|
|
25
|
+
vi.stubGlobal(
|
|
26
|
+
'ResizeObserver',
|
|
27
|
+
class {
|
|
28
|
+
constructor(cb: ResizeObserverCallback) {
|
|
29
|
+
resizeCallback = cb;
|
|
30
|
+
}
|
|
31
|
+
observe() {}
|
|
32
|
+
unobserve() {}
|
|
33
|
+
disconnect() {}
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.unstubAllGlobals();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function reportWidth(width: number) {
|
|
43
|
+
act(() => {
|
|
44
|
+
resizeCallback?.(
|
|
45
|
+
[{contentRect: {width}} as unknown as ResizeObserverEntry],
|
|
46
|
+
{} as ResizeObserver,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('Chart accessible name', () => {
|
|
52
|
+
it('exposes the svg as a named image with a default label', () => {
|
|
53
|
+
render(
|
|
54
|
+
<Chart data={data} xKey="month" yKeys={['revenue', 'profit']}>
|
|
55
|
+
<g />
|
|
56
|
+
</Chart>,
|
|
57
|
+
);
|
|
58
|
+
reportWidth(600);
|
|
59
|
+
|
|
60
|
+
expect(
|
|
61
|
+
screen.getByRole('img', {name: 'Chart of revenue, profit by month'}),
|
|
62
|
+
).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('uses a custom label when provided', () => {
|
|
66
|
+
render(
|
|
67
|
+
<Chart data={data} xKey="month" yKeys={['revenue']} label="Q1 revenue">
|
|
68
|
+
<g />
|
|
69
|
+
</Chart>,
|
|
70
|
+
);
|
|
71
|
+
reportWidth(600);
|
|
72
|
+
|
|
73
|
+
expect(screen.getByRole('img', {name: 'Q1 revenue'})).toBeInTheDocument();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('Chart data table fallback', () => {
|
|
78
|
+
it('renders a visually hidden table mirroring the data', () => {
|
|
79
|
+
render(
|
|
80
|
+
<Chart data={data} xKey="month" yKeys={['revenue', 'profit']}>
|
|
81
|
+
<g />
|
|
82
|
+
</Chart>,
|
|
83
|
+
);
|
|
84
|
+
reportWidth(600);
|
|
85
|
+
|
|
86
|
+
const table = screen.getByRole('table');
|
|
87
|
+
expect(table).toBeInTheDocument();
|
|
88
|
+
|
|
89
|
+
// Column headers: x key + each y key
|
|
90
|
+
expect(
|
|
91
|
+
screen.getByRole('columnheader', {name: 'month'}),
|
|
92
|
+
).toBeInTheDocument();
|
|
93
|
+
expect(
|
|
94
|
+
screen.getByRole('columnheader', {name: 'revenue'}),
|
|
95
|
+
).toBeInTheDocument();
|
|
96
|
+
expect(
|
|
97
|
+
screen.getByRole('columnheader', {name: 'profit'}),
|
|
98
|
+
).toBeInTheDocument();
|
|
99
|
+
|
|
100
|
+
// Row headers and values
|
|
101
|
+
expect(screen.getByRole('rowheader', {name: 'Feb'})).toBeInTheDocument();
|
|
102
|
+
expect(screen.getByRole('cell', {name: '200'})).toBeInTheDocument();
|
|
103
|
+
expect(screen.getByRole('cell', {name: '40'})).toBeInTheDocument();
|
|
104
|
+
|
|
105
|
+
// Visually hidden, but still in the a11y tree (StyleX clip class attached,
|
|
106
|
+
// not display:none/hidden — jsdom does not apply the stylesheet itself).
|
|
107
|
+
const wrapper = table.parentElement as HTMLElement;
|
|
108
|
+
expect(wrapper.getAttribute('class')).toBeTruthy();
|
|
109
|
+
expect(wrapper).not.toHaveAttribute('hidden');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('skips the table when the dataset exceeds the size cutoff', () => {
|
|
113
|
+
const big = Array.from({length: 101}, (_, i) => ({
|
|
114
|
+
month: `m${i}`,
|
|
115
|
+
revenue: i,
|
|
116
|
+
}));
|
|
117
|
+
render(
|
|
118
|
+
<Chart data={big} xKey="month" yKeys={['revenue']}>
|
|
119
|
+
<g />
|
|
120
|
+
</Chart>,
|
|
121
|
+
);
|
|
122
|
+
reportWidth(600);
|
|
123
|
+
|
|
124
|
+
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
});
|
package/src/Chart/Chart.tsx
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Handles the d3 margin convention: outer SVG at full size, inner <g> translated
|
|
9
9
|
* by margins. Children receive scales and dimensions via context.
|
|
10
|
+
*
|
|
11
|
+
* Accessibility: the svg is exposed as a named image (role="img" + aria-label,
|
|
12
|
+
* overridable via `label`), and small datasets are mirrored in a visually
|
|
13
|
+
* hidden data table so screen reader users can read the underlying values.
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
16
|
import {
|
|
@@ -20,6 +24,7 @@ import {
|
|
|
20
24
|
} from 'react';
|
|
21
25
|
import {scaleLinear, scaleBand} from 'd3-scale';
|
|
22
26
|
import type {ScaleLinear} from 'd3-scale';
|
|
27
|
+
import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
|
|
23
28
|
import {isBandScale} from './utils';
|
|
24
29
|
import {ChartProvider} from './ChartContext';
|
|
25
30
|
import type {ChartMargin, ChartScale} from './types';
|
|
@@ -75,12 +80,31 @@ export interface ChartProps {
|
|
|
75
80
|
* interference on mobile.
|
|
76
81
|
*/
|
|
77
82
|
interactive?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Accessible name for the chart, announced by screen readers (the svg is
|
|
85
|
+
* exposed as `role="img"`). Defaults to an English description derived from
|
|
86
|
+
* the keys (e.g. "Chart of revenue by month") — pass a localized string to
|
|
87
|
+
* override.
|
|
88
|
+
*/
|
|
89
|
+
label?: string;
|
|
78
90
|
/** Chart contents — axes, marks, tooltips */
|
|
79
91
|
children: ReactNode;
|
|
80
92
|
}
|
|
81
93
|
|
|
82
94
|
const DEFAULT_MARGIN: ChartMargin = {top: 16, right: 16, bottom: 32, left: 48};
|
|
83
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Maximum number of data points (rows × series) for which the visually hidden
|
|
98
|
+
* data-table fallback is rendered. Beyond this a table is more noise than
|
|
99
|
+
* signal for screen reader users, so larger charts are name-only.
|
|
100
|
+
*/
|
|
101
|
+
const MAX_TABLE_POINTS = 100;
|
|
102
|
+
|
|
103
|
+
/** Stringify a cell value for the hidden data table. */
|
|
104
|
+
function tableCell(value: unknown): string {
|
|
105
|
+
return value == null ? '' : String(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
84
108
|
/**
|
|
85
109
|
* Root chart container. Computes scales from data and provides them to children.
|
|
86
110
|
*
|
|
@@ -103,6 +127,7 @@ export function Chart({
|
|
|
103
127
|
yDomain: yDomainProp,
|
|
104
128
|
xDomain: xDomainProp,
|
|
105
129
|
interactive = false,
|
|
130
|
+
label,
|
|
106
131
|
children,
|
|
107
132
|
}: ChartProps) {
|
|
108
133
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -289,11 +314,18 @@ export function Chart({
|
|
|
289
314
|
userSelect: interactive ? 'none' : undefined,
|
|
290
315
|
};
|
|
291
316
|
|
|
317
|
+
const accessibleLabel = label ?? `Chart of ${yKeys.join(', ')} by ${xKey}`;
|
|
318
|
+
const showDataTable =
|
|
319
|
+
data.length > 0 &&
|
|
320
|
+
data.length * Math.max(yKeys.length, 1) <= MAX_TABLE_POINTS;
|
|
321
|
+
|
|
292
322
|
return (
|
|
293
323
|
<div ref={containerRef} style={containerStyle}>
|
|
294
324
|
{containerWidth > 0 && (
|
|
295
325
|
<svg
|
|
296
326
|
ref={svgRef}
|
|
327
|
+
role="img"
|
|
328
|
+
aria-label={accessibleLabel}
|
|
297
329
|
width={containerWidth}
|
|
298
330
|
height={height}
|
|
299
331
|
style={interactive ? {touchAction: 'none'} : undefined}>
|
|
@@ -307,6 +339,33 @@ export function Chart({
|
|
|
307
339
|
</g>
|
|
308
340
|
</svg>
|
|
309
341
|
)}
|
|
342
|
+
{showDataTable && (
|
|
343
|
+
<VisuallyHidden as="div">
|
|
344
|
+
<table>
|
|
345
|
+
<caption>{`${accessibleLabel} data`}</caption>
|
|
346
|
+
<thead>
|
|
347
|
+
<tr>
|
|
348
|
+
<th scope="col">{xKey}</th>
|
|
349
|
+
{yKeys.map(key => (
|
|
350
|
+
<th key={key} scope="col">
|
|
351
|
+
{key}
|
|
352
|
+
</th>
|
|
353
|
+
))}
|
|
354
|
+
</tr>
|
|
355
|
+
</thead>
|
|
356
|
+
<tbody>
|
|
357
|
+
{data.map((d, i) => (
|
|
358
|
+
<tr key={i}>
|
|
359
|
+
<th scope="row">{tableCell(d[xKey])}</th>
|
|
360
|
+
{yKeys.map(key => (
|
|
361
|
+
<td key={key}>{tableCell(d[key])}</td>
|
|
362
|
+
))}
|
|
363
|
+
</tr>
|
|
364
|
+
))}
|
|
365
|
+
</tbody>
|
|
366
|
+
</table>
|
|
367
|
+
</VisuallyHidden>
|
|
368
|
+
)}
|
|
310
369
|
</div>
|
|
311
370
|
);
|
|
312
371
|
}
|