@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,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
- return /*#__PURE__*/_jsx("div", {
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 ? 'Sankey chart' : undefined,
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
- /** Auto-rotate speed in degrees per frame (default: 0 = off) */
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;;;;GAIG;AAEH,OAAO,EAEL,KAAK,SAAS,EAOf,MAAM,OAAO,CAAC;AAIf,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,gEAAgE;IAChE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,SAAS,CAAC;CACrB;AAsBD,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,QAAQ,GACT,EAAE,gBAAgB,+BA6LlB"}
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
- // Auto-rotation — throttled to ~20fps to avoid overwhelming React with re-renders
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 (autoRotate === 0) {
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: handleEnd,
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.f306f13",
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.f306f13",
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
+ });
@@ -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
  }