@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,6 +7,10 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Owns the radial coordinate space: center, radius, angle/radius scales.
|
|
9
9
|
* Same Tier 1 guarantee as Chart — all children map through the same 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 (slices in pie mode, axis values in spider mode).
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
16
|
import {
|
|
@@ -16,6 +20,7 @@ import {
|
|
|
16
20
|
useState,
|
|
17
21
|
useLayoutEffect,
|
|
18
22
|
} from 'react';
|
|
23
|
+
import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
|
|
19
24
|
import {RadialProvider} from './RadialContext';
|
|
20
25
|
import type {RadialMode} from './types';
|
|
21
26
|
|
|
@@ -52,9 +57,32 @@ export interface RadialChartProps {
|
|
|
52
57
|
* Enable touch interaction mode — blocks scroll on mobile.
|
|
53
58
|
*/
|
|
54
59
|
interactive?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Accessible name for the chart, announced by screen readers (the svg is
|
|
62
|
+
* exposed as `role="img"`). Defaults to an English description derived from
|
|
63
|
+
* the mode and keys (e.g. "Radar chart of speed, handling" or
|
|
64
|
+
* "Pie chart of revenue") — pass a localized string to override.
|
|
65
|
+
*/
|
|
66
|
+
label?: string;
|
|
55
67
|
children: ReactNode;
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Maximum number of data points (rows × axes in spider mode, slices in pie
|
|
72
|
+
* mode) for which the visually hidden data-table fallback is rendered. Beyond
|
|
73
|
+
* this a table is more noise than signal, so larger charts are name-only.
|
|
74
|
+
*/
|
|
75
|
+
const MAX_TABLE_POINTS = 100;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Row header for a spider-mode table row: the first string field in the row
|
|
79
|
+
* (the same heuristic RadialArea uses to match its `dataKey`), else the index.
|
|
80
|
+
*/
|
|
81
|
+
function spiderRowHeader(row: Record<string, unknown>, index: number): string {
|
|
82
|
+
const name = Object.values(row).find(v => typeof v === 'string');
|
|
83
|
+
return typeof name === 'string' ? name : `Series ${index + 1}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
58
86
|
/**
|
|
59
87
|
* Root radial chart container. Computes angular/radial scales and provides
|
|
60
88
|
* them to children via context.
|
|
@@ -82,6 +110,7 @@ export function RadialChart({
|
|
|
82
110
|
labelKey,
|
|
83
111
|
innerRadius: innerRadiusFraction = 0,
|
|
84
112
|
padAngle = 0.02,
|
|
113
|
+
label,
|
|
85
114
|
children,
|
|
86
115
|
}: RadialChartProps) {
|
|
87
116
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -202,13 +231,85 @@ export function RadialChart({
|
|
|
202
231
|
[cx, cy, outerRadius, innerRadiusPx, data, mode, spiderCtx, pieCtx],
|
|
203
232
|
);
|
|
204
233
|
|
|
234
|
+
const accessibleLabel =
|
|
235
|
+
label ??
|
|
236
|
+
(mode === 'spider'
|
|
237
|
+
? `Radar chart of ${(axes ?? []).join(', ')}`
|
|
238
|
+
: `Pie chart of ${valueKey ?? 'values'}`);
|
|
239
|
+
|
|
240
|
+
const slices = pieCtx.slices ?? [];
|
|
241
|
+
const showPieTable =
|
|
242
|
+
mode === 'pie' && slices.length > 0 && slices.length <= MAX_TABLE_POINTS;
|
|
243
|
+
const showSpiderTable =
|
|
244
|
+
mode === 'spider' &&
|
|
245
|
+
axes != null &&
|
|
246
|
+
axes.length > 0 &&
|
|
247
|
+
data.length > 0 &&
|
|
248
|
+
data.length * axes.length <= MAX_TABLE_POINTS;
|
|
249
|
+
|
|
205
250
|
return (
|
|
206
251
|
<div ref={containerRef} style={{width: '100%'}}>
|
|
207
252
|
{containerWidth > 0 && (
|
|
208
|
-
<svg
|
|
253
|
+
<svg
|
|
254
|
+
role="img"
|
|
255
|
+
aria-label={accessibleLabel}
|
|
256
|
+
width={containerWidth}
|
|
257
|
+
height={height}>
|
|
209
258
|
<RadialProvider value={ctx}>{children}</RadialProvider>
|
|
210
259
|
</svg>
|
|
211
260
|
)}
|
|
261
|
+
{showPieTable && (
|
|
262
|
+
<VisuallyHidden as="div">
|
|
263
|
+
<table>
|
|
264
|
+
<caption>{`${accessibleLabel} data`}</caption>
|
|
265
|
+
<thead>
|
|
266
|
+
<tr>
|
|
267
|
+
<th scope="col">{labelKey ?? 'Label'}</th>
|
|
268
|
+
<th scope="col">{valueKey}</th>
|
|
269
|
+
<th scope="col">Percentage</th>
|
|
270
|
+
</tr>
|
|
271
|
+
</thead>
|
|
272
|
+
<tbody>
|
|
273
|
+
{slices.map((slice, i) => (
|
|
274
|
+
<tr key={i}>
|
|
275
|
+
<th scope="row">{slice.key}</th>
|
|
276
|
+
<td>{String(slice.value)}</td>
|
|
277
|
+
<td>{`${(slice.percentage * 100).toFixed(1)}%`}</td>
|
|
278
|
+
</tr>
|
|
279
|
+
))}
|
|
280
|
+
</tbody>
|
|
281
|
+
</table>
|
|
282
|
+
</VisuallyHidden>
|
|
283
|
+
)}
|
|
284
|
+
{showSpiderTable && (
|
|
285
|
+
<VisuallyHidden as="div">
|
|
286
|
+
<table>
|
|
287
|
+
<caption>{`${accessibleLabel} data`}</caption>
|
|
288
|
+
<thead>
|
|
289
|
+
<tr>
|
|
290
|
+
<th scope="col">Series</th>
|
|
291
|
+
{axes.map(key => (
|
|
292
|
+
<th key={key} scope="col">
|
|
293
|
+
{key}
|
|
294
|
+
</th>
|
|
295
|
+
))}
|
|
296
|
+
</tr>
|
|
297
|
+
</thead>
|
|
298
|
+
<tbody>
|
|
299
|
+
{data.map((row, i) => (
|
|
300
|
+
<tr key={i}>
|
|
301
|
+
<th scope="row">{spiderRowHeader(row, i)}</th>
|
|
302
|
+
{axes.map(key => (
|
|
303
|
+
<td key={key}>
|
|
304
|
+
{row[key] == null ? '' : String(row[key])}
|
|
305
|
+
</td>
|
|
306
|
+
))}
|
|
307
|
+
</tr>
|
|
308
|
+
))}
|
|
309
|
+
</tbody>
|
|
310
|
+
</table>
|
|
311
|
+
</VisuallyHidden>
|
|
312
|
+
)}
|
|
212
313
|
</div>
|
|
213
314
|
);
|
|
214
315
|
}
|
|
@@ -75,3 +75,98 @@ describe('SankeyChart scroll container', () => {
|
|
|
75
75
|
).not.toBeInTheDocument();
|
|
76
76
|
});
|
|
77
77
|
});
|
|
78
|
+
|
|
79
|
+
describe('SankeyChart accessible name', () => {
|
|
80
|
+
it('names the svg when the chart does not scroll', () => {
|
|
81
|
+
render(
|
|
82
|
+
<SankeyChart nodes={nodes} links={links} minColumnWidth={50}>
|
|
83
|
+
<SankeyNode />
|
|
84
|
+
</SankeyChart>,
|
|
85
|
+
);
|
|
86
|
+
reportWidth(1000);
|
|
87
|
+
|
|
88
|
+
expect(screen.getByRole('img', {name: 'Sankey chart'})).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('names the svg when the chart scrolls', () => {
|
|
92
|
+
render(
|
|
93
|
+
<SankeyChart nodes={nodes} links={links} minColumnWidth={400}>
|
|
94
|
+
<SankeyNode />
|
|
95
|
+
</SankeyChart>,
|
|
96
|
+
);
|
|
97
|
+
reportWidth(300);
|
|
98
|
+
|
|
99
|
+
expect(screen.getByRole('img', {name: 'Sankey chart'})).toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('applies a custom label to both the svg and the scroll region', () => {
|
|
103
|
+
render(
|
|
104
|
+
<SankeyChart
|
|
105
|
+
nodes={nodes}
|
|
106
|
+
links={links}
|
|
107
|
+
minColumnWidth={400}
|
|
108
|
+
label="Traffic flow">
|
|
109
|
+
<SankeyNode />
|
|
110
|
+
</SankeyChart>,
|
|
111
|
+
);
|
|
112
|
+
reportWidth(300);
|
|
113
|
+
|
|
114
|
+
expect(screen.getByRole('img', {name: 'Traffic flow'})).toBeInTheDocument();
|
|
115
|
+
expect(
|
|
116
|
+
screen.getByRole('group', {name: 'Traffic flow'}),
|
|
117
|
+
).toBeInTheDocument();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('SankeyChart data table fallback', () => {
|
|
122
|
+
it('renders a visually hidden table of flows', () => {
|
|
123
|
+
render(
|
|
124
|
+
<SankeyChart nodes={nodes} links={links} minColumnWidth={50}>
|
|
125
|
+
<SankeyNode />
|
|
126
|
+
</SankeyChart>,
|
|
127
|
+
);
|
|
128
|
+
reportWidth(1000);
|
|
129
|
+
|
|
130
|
+
const table = screen.getByRole('table');
|
|
131
|
+
expect(table).toBeInTheDocument();
|
|
132
|
+
|
|
133
|
+
expect(
|
|
134
|
+
screen.getByRole('columnheader', {name: 'From'}),
|
|
135
|
+
).toBeInTheDocument();
|
|
136
|
+
expect(screen.getByRole('columnheader', {name: 'To'})).toBeInTheDocument();
|
|
137
|
+
expect(
|
|
138
|
+
screen.getByRole('columnheader', {name: 'Value'}),
|
|
139
|
+
).toBeInTheDocument();
|
|
140
|
+
|
|
141
|
+
// Link a → b (value 5), labels resolved from node definitions.
|
|
142
|
+
expect(screen.getByRole('rowheader', {name: 'A'})).toBeInTheDocument();
|
|
143
|
+
expect(screen.getByRole('rowheader', {name: 'B'})).toBeInTheDocument();
|
|
144
|
+
expect(screen.getAllByRole('cell', {name: '5'})).toHaveLength(2);
|
|
145
|
+
|
|
146
|
+
// Visually hidden, but still in the a11y tree.
|
|
147
|
+
const wrapper = table.parentElement as HTMLElement;
|
|
148
|
+
expect(wrapper.getAttribute('class')).toBeTruthy();
|
|
149
|
+
expect(wrapper).not.toHaveAttribute('hidden');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('skips the table when there are too many links', () => {
|
|
153
|
+
const manyNodes = Array.from({length: 102}, (_, i) => ({
|
|
154
|
+
id: `n${i}`,
|
|
155
|
+
label: `N${i}`,
|
|
156
|
+
value: 1,
|
|
157
|
+
}));
|
|
158
|
+
const manyLinks = Array.from({length: 101}, (_, i) => ({
|
|
159
|
+
source: `n${i}`,
|
|
160
|
+
target: `n${i + 1}`,
|
|
161
|
+
value: 1,
|
|
162
|
+
}));
|
|
163
|
+
render(
|
|
164
|
+
<SankeyChart nodes={manyNodes} links={manyLinks} minColumnWidth={5}>
|
|
165
|
+
<SankeyNode />
|
|
166
|
+
</SankeyChart>,
|
|
167
|
+
);
|
|
168
|
+
reportWidth(1000);
|
|
169
|
+
|
|
170
|
+
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -7,6 +7,10 @@
|
|
|
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 {
|
|
@@ -16,6 +20,7 @@ import {
|
|
|
16
20
|
useState,
|
|
17
21
|
useLayoutEffect,
|
|
18
22
|
} from 'react';
|
|
23
|
+
import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
|
|
19
24
|
import {SankeyProvider} from './SankeyContext';
|
|
20
25
|
import {computeLayout} from './layout';
|
|
21
26
|
import type {SankeyNodeDatum, SankeyLinkDatum, SankeyColumn} from './types';
|
|
@@ -51,10 +56,25 @@ export interface SankeyChartProps {
|
|
|
51
56
|
* When total min width exceeds the container, horizontal scrolling activates.
|
|
52
57
|
*/
|
|
53
58
|
minColumnWidth?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Accessible name for the chart, announced by screen readers. Applied to the
|
|
61
|
+
* svg (`role="img"`) and, when the chart scrolls, to the focusable scroll
|
|
62
|
+
* region. Pass a localized string to override the English default.
|
|
63
|
+
*
|
|
64
|
+
* @default 'Sankey chart'
|
|
65
|
+
*/
|
|
66
|
+
label?: string;
|
|
54
67
|
/** Chart contents */
|
|
55
68
|
children: ReactNode;
|
|
56
69
|
}
|
|
57
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Maximum number of links for which the visually hidden data-table fallback is
|
|
73
|
+
* rendered. Beyond this a table is more noise than signal, so larger charts
|
|
74
|
+
* are name-only.
|
|
75
|
+
*/
|
|
76
|
+
const MAX_TABLE_LINKS = 100;
|
|
77
|
+
|
|
58
78
|
/** Resolve column count from raw column input or auto-detect from graph */
|
|
59
79
|
function resolveColumnCount(
|
|
60
80
|
columns: SankeyColumn[] | undefined,
|
|
@@ -131,6 +151,7 @@ export function SankeyChart({
|
|
|
131
151
|
nodeGap = 14,
|
|
132
152
|
nodeColor,
|
|
133
153
|
minColumnWidth = 160,
|
|
154
|
+
label = 'Sankey chart',
|
|
134
155
|
children,
|
|
135
156
|
}: SankeyChartProps) {
|
|
136
157
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -197,17 +218,25 @@ export function SankeyChart({
|
|
|
197
218
|
};
|
|
198
219
|
}, [layout, chartWidth, height, nodeWidth, nodeColor]);
|
|
199
220
|
|
|
221
|
+
const nodeLabelById = useMemo(
|
|
222
|
+
() => new Map(nodes.map(n => [n.id, n.label || n.id])),
|
|
223
|
+
[nodes],
|
|
224
|
+
);
|
|
225
|
+
const showDataTable = links.length > 0 && links.length <= MAX_TABLE_LINKS;
|
|
226
|
+
|
|
200
227
|
return (
|
|
201
228
|
<div ref={containerRef} style={{width: '100%'}}>
|
|
202
229
|
{ctx && (
|
|
203
230
|
<div
|
|
204
231
|
role={needsScroll ? 'group' : undefined}
|
|
205
|
-
aria-label={needsScroll ?
|
|
232
|
+
aria-label={needsScroll ? label : undefined}
|
|
206
233
|
tabIndex={needsScroll ? 0 : undefined}
|
|
207
234
|
style={
|
|
208
235
|
needsScroll ? {overflowX: 'auto', overflowY: 'hidden'} : undefined
|
|
209
236
|
}>
|
|
210
237
|
<svg
|
|
238
|
+
role="img"
|
|
239
|
+
aria-label={label}
|
|
211
240
|
width={chartWidth}
|
|
212
241
|
height={height}
|
|
213
242
|
style={{overflow: 'visible', display: 'block'}}>
|
|
@@ -215,6 +244,31 @@ export function SankeyChart({
|
|
|
215
244
|
</svg>
|
|
216
245
|
</div>
|
|
217
246
|
)}
|
|
247
|
+
{showDataTable && (
|
|
248
|
+
<VisuallyHidden as="div">
|
|
249
|
+
<table>
|
|
250
|
+
<caption>{`${label} data`}</caption>
|
|
251
|
+
<thead>
|
|
252
|
+
<tr>
|
|
253
|
+
<th scope="col">From</th>
|
|
254
|
+
<th scope="col">To</th>
|
|
255
|
+
<th scope="col">Value</th>
|
|
256
|
+
</tr>
|
|
257
|
+
</thead>
|
|
258
|
+
<tbody>
|
|
259
|
+
{links.map((link, i) => (
|
|
260
|
+
<tr key={i}>
|
|
261
|
+
<th scope="row">
|
|
262
|
+
{nodeLabelById.get(link.source) ?? link.source}
|
|
263
|
+
</th>
|
|
264
|
+
<td>{nodeLabelById.get(link.target) ?? link.target}</td>
|
|
265
|
+
<td>{String(link.value)}</td>
|
|
266
|
+
</tr>
|
|
267
|
+
))}
|
|
268
|
+
</tbody>
|
|
269
|
+
</table>
|
|
270
|
+
</VisuallyHidden>
|
|
271
|
+
)}
|
|
218
272
|
</div>
|
|
219
273
|
);
|
|
220
274
|
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';
|
|
4
|
+
import {render, screen, act, fireEvent} from '@testing-library/react';
|
|
5
|
+
import {ThreeDChart} from './ThreeDChart';
|
|
6
|
+
import {use3D} from './ThreeDContext';
|
|
7
|
+
|
|
8
|
+
const data = [
|
|
9
|
+
{x: 0, y: 0, z: 0},
|
|
10
|
+
{x: 10, y: 20, z: 30},
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/** Renders the current camera angles so tests can observe rotation. */
|
|
14
|
+
function CameraProbe() {
|
|
15
|
+
const {camera} = use3D();
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
<text data-testid="azimuth">{camera.azimuth}</text>
|
|
19
|
+
<text data-testid="elevation">{camera.elevation}</text>
|
|
20
|
+
</>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function azimuth(): number {
|
|
25
|
+
return Number(screen.getByTestId('azimuth').textContent);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function elevation(): number {
|
|
29
|
+
return Number(screen.getByTestId('elevation').textContent);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Capture the ResizeObserver callback so tests can drive the reported width.
|
|
33
|
+
let resizeCallback: ResizeObserverCallback | undefined;
|
|
34
|
+
|
|
35
|
+
// Manual rAF queue: callbacks are collected and only run when the test
|
|
36
|
+
// flushes them, so we can observe whether the rotation loop is scheduled.
|
|
37
|
+
let rafCallbacks: Map<number, FrameRequestCallback>;
|
|
38
|
+
let nextRafId: number;
|
|
39
|
+
let cancelSpy: ReturnType<typeof vi.fn>;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
resizeCallback = undefined;
|
|
43
|
+
vi.stubGlobal(
|
|
44
|
+
'ResizeObserver',
|
|
45
|
+
class {
|
|
46
|
+
constructor(cb: ResizeObserverCallback) {
|
|
47
|
+
resizeCallback = cb;
|
|
48
|
+
}
|
|
49
|
+
observe() {}
|
|
50
|
+
unobserve() {}
|
|
51
|
+
disconnect() {}
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
rafCallbacks = new Map();
|
|
56
|
+
nextRafId = 0;
|
|
57
|
+
cancelSpy = vi.fn((id: number) => {
|
|
58
|
+
rafCallbacks.delete(id);
|
|
59
|
+
});
|
|
60
|
+
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
61
|
+
nextRafId += 1;
|
|
62
|
+
rafCallbacks.set(nextRafId, cb);
|
|
63
|
+
return nextRafId;
|
|
64
|
+
});
|
|
65
|
+
vi.stubGlobal('cancelAnimationFrame', cancelSpy);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
vi.unstubAllGlobals();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
function reportWidth(width: number) {
|
|
73
|
+
act(() => {
|
|
74
|
+
resizeCallback?.(
|
|
75
|
+
[{contentRect: {width}} as unknown as ResizeObserverEntry],
|
|
76
|
+
{} as ResizeObserver,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function flushFrame(now: number) {
|
|
82
|
+
const callbacks = [...rafCallbacks.values()];
|
|
83
|
+
rafCallbacks.clear();
|
|
84
|
+
act(() => {
|
|
85
|
+
callbacks.forEach(cb => cb(now));
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** matchMedia stub where prefers-reduced-motion: reduce matches. */
|
|
90
|
+
function stubReducedMotion() {
|
|
91
|
+
vi.stubGlobal('matchMedia', (query: string): MediaQueryList => {
|
|
92
|
+
return {
|
|
93
|
+
matches: query === '(prefers-reduced-motion: reduce)',
|
|
94
|
+
media: query,
|
|
95
|
+
onchange: null,
|
|
96
|
+
addListener: () => {},
|
|
97
|
+
removeListener: () => {},
|
|
98
|
+
addEventListener: () => {},
|
|
99
|
+
removeEventListener: () => {},
|
|
100
|
+
dispatchEvent: () => false,
|
|
101
|
+
} as unknown as MediaQueryList;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function renderChart(props: Partial<Parameters<typeof ThreeDChart>[0]> = {}) {
|
|
106
|
+
const result = render(
|
|
107
|
+
<ThreeDChart data={data} xKey="x" yKey="y" zKey="z" {...props}>
|
|
108
|
+
<CameraProbe />
|
|
109
|
+
</ThreeDChart>,
|
|
110
|
+
);
|
|
111
|
+
reportWidth(600);
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('ThreeDChart accessible name', () => {
|
|
116
|
+
it('exposes role="img" with a default label derived from the data keys', () => {
|
|
117
|
+
renderChart();
|
|
118
|
+
expect(
|
|
119
|
+
screen.getByRole('img', {name: '3D chart of y by x and z'}),
|
|
120
|
+
).toBeInTheDocument();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('uses the label prop when provided', () => {
|
|
124
|
+
renderChart({label: 'Revenue by region and quarter'});
|
|
125
|
+
expect(
|
|
126
|
+
screen.getByRole('img', {name: 'Revenue by region and quarter'}),
|
|
127
|
+
).toBeInTheDocument();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('is not a tab stop when static (no interaction, no rotation)', () => {
|
|
131
|
+
renderChart();
|
|
132
|
+
expect(screen.getByRole('img')).not.toHaveAttribute('tabindex');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('ThreeDChart auto-rotation', () => {
|
|
137
|
+
it('rotates the camera when autoRotate is set', () => {
|
|
138
|
+
renderChart({autoRotate: 1});
|
|
139
|
+
expect(rafCallbacks.size).toBe(1);
|
|
140
|
+
flushFrame(100);
|
|
141
|
+
expect(azimuth()).toBeGreaterThan(35);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('does not start the rotation loop under prefers-reduced-motion', () => {
|
|
145
|
+
stubReducedMotion();
|
|
146
|
+
renderChart({autoRotate: 1});
|
|
147
|
+
expect(rafCallbacks.size).toBe(0);
|
|
148
|
+
expect(azimuth()).toBe(35);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('pauses rotation while hovered and resumes on leave', () => {
|
|
152
|
+
renderChart({autoRotate: 1});
|
|
153
|
+
expect(rafCallbacks.size).toBe(1);
|
|
154
|
+
|
|
155
|
+
fireEvent.mouseOver(screen.getByRole('img'));
|
|
156
|
+
expect(cancelSpy).toHaveBeenCalled();
|
|
157
|
+
expect(rafCallbacks.size).toBe(0);
|
|
158
|
+
|
|
159
|
+
const before = azimuth();
|
|
160
|
+
flushFrame(200);
|
|
161
|
+
expect(azimuth()).toBe(before);
|
|
162
|
+
|
|
163
|
+
fireEvent.mouseOut(screen.getByRole('img'));
|
|
164
|
+
expect(rafCallbacks.size).toBe(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('pauses rotation while focused and resumes on blur', () => {
|
|
168
|
+
renderChart({autoRotate: 1});
|
|
169
|
+
expect(rafCallbacks.size).toBe(1);
|
|
170
|
+
|
|
171
|
+
fireEvent.focus(screen.getByRole('img'));
|
|
172
|
+
expect(cancelSpy).toHaveBeenCalled();
|
|
173
|
+
expect(rafCallbacks.size).toBe(0);
|
|
174
|
+
|
|
175
|
+
fireEvent.blur(screen.getByRole('img'));
|
|
176
|
+
expect(rafCallbacks.size).toBe(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('is focusable when auto-rotating so keyboard users can pause it', () => {
|
|
180
|
+
renderChart({autoRotate: 1});
|
|
181
|
+
expect(screen.getByRole('img')).toHaveAttribute('tabindex', '0');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('ThreeDChart keyboard camera', () => {
|
|
186
|
+
it('is focusable when interactive', () => {
|
|
187
|
+
renderChart({interactive: true});
|
|
188
|
+
expect(screen.getByRole('img')).toHaveAttribute('tabindex', '0');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('rotates yaw with ArrowLeft/ArrowRight', () => {
|
|
192
|
+
renderChart({interactive: true});
|
|
193
|
+
const svg = screen.getByRole('img');
|
|
194
|
+
|
|
195
|
+
fireEvent.keyDown(svg, {key: 'ArrowRight'});
|
|
196
|
+
expect(azimuth()).toBe(45);
|
|
197
|
+
|
|
198
|
+
fireEvent.keyDown(svg, {key: 'ArrowLeft'});
|
|
199
|
+
fireEvent.keyDown(svg, {key: 'ArrowLeft'});
|
|
200
|
+
expect(azimuth()).toBe(25);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('rotates pitch with ArrowUp/ArrowDown', () => {
|
|
204
|
+
renderChart({interactive: true});
|
|
205
|
+
const svg = screen.getByRole('img');
|
|
206
|
+
|
|
207
|
+
fireEvent.keyDown(svg, {key: 'ArrowUp'});
|
|
208
|
+
expect(elevation()).toBe(35);
|
|
209
|
+
|
|
210
|
+
fireEvent.keyDown(svg, {key: 'ArrowDown'});
|
|
211
|
+
fireEvent.keyDown(svg, {key: 'ArrowDown'});
|
|
212
|
+
expect(elevation()).toBe(15);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('clamps pitch to the same [-89, 89] range as pointer drag', () => {
|
|
216
|
+
renderChart({interactive: true, elevation: 85});
|
|
217
|
+
const svg = screen.getByRole('img');
|
|
218
|
+
|
|
219
|
+
fireEvent.keyDown(svg, {key: 'ArrowUp'});
|
|
220
|
+
expect(elevation()).toBe(89);
|
|
221
|
+
fireEvent.keyDown(svg, {key: 'ArrowUp'});
|
|
222
|
+
expect(elevation()).toBe(89);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('clamps pitch at the lower bound', () => {
|
|
226
|
+
renderChart({interactive: true, elevation: -85});
|
|
227
|
+
const svg = screen.getByRole('img');
|
|
228
|
+
|
|
229
|
+
fireEvent.keyDown(svg, {key: 'ArrowDown'});
|
|
230
|
+
expect(elevation()).toBe(-89);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('prevents default on arrow keys so the page does not scroll', () => {
|
|
234
|
+
renderChart({interactive: true});
|
|
235
|
+
const svg = screen.getByRole('img');
|
|
236
|
+
|
|
237
|
+
// fireEvent returns false when preventDefault() was called.
|
|
238
|
+
expect(fireEvent.keyDown(svg, {key: 'ArrowRight'})).toBe(false);
|
|
239
|
+
expect(fireEvent.keyDown(svg, {key: 'ArrowUp'})).toBe(false);
|
|
240
|
+
// Non-camera keys are left alone.
|
|
241
|
+
expect(fireEvent.keyDown(svg, {key: 'Enter'})).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('ignores arrow keys when not interactive', () => {
|
|
245
|
+
renderChart({autoRotate: 1});
|
|
246
|
+
const svg = screen.getByRole('img');
|
|
247
|
+
|
|
248
|
+
expect(fireEvent.keyDown(svg, {key: 'ArrowRight'})).toBe(true);
|
|
249
|
+
expect(azimuth()).toBe(35);
|
|
250
|
+
});
|
|
251
|
+
});
|