@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.
@@ -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 width={containerWidth} height={height}>
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 ? 'Sankey chart' : undefined}
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
+ });