@astryxdesign/charts 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.
@@ -1 +1 @@
1
- {"version":3,"file":"Chart.d.ts","sourceRoot":"","sources":["../src/Chart.tsx"],"names":[],"mappings":"AAeA,OAAO,EACL,KAAK,SAAS,EAOf,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,EAAC,SAAS,EAAE,SAAS,EAAC,MAAM,SAAS,CAAC;AAClD,OAAO,KAAK,EAAe,WAAW,EAAoB,MAAM,SAAS,CAAC;AAM1E,OAAO,EAAc,KAAK,gBAAgB,EAAC,MAAM,eAAe,CAAC;AAEjE,OAAO,EAAe,KAAK,iBAAiB,EAAC,MAAM,gBAAgB,CAAC;AAIpE,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IAC9B,+EAA+E;IAC/E,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,gFAAgF;IAChF,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3B;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3B,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAAC;IACpC,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC,iBAAiB,EAAE,QAAQ,CAAC,CAAC;IACtD,YAAY,CAAC,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAkBD,wBAAgB,KAAK,CAAC,EACpB,IAAI,EACJ,IAAI,EACJ,MAAM,EACN,MAAY,EACZ,MAAM,EAAE,cAAc,EACtB,SAAS,EACT,OAAO,EACP,OAAO,EACP,IAAI,EACJ,IAAI,EACJ,MAAM,EACN,OAAO,EACP,YAAY,EACZ,QAAQ,EACR,KAAK,EACL,QAAQ,GACT,EAAE,UAAU,+BAwRZ"}
1
+ {"version":3,"file":"Chart.d.ts","sourceRoot":"","sources":["../src/Chart.tsx"],"names":[],"mappings":"AAmBA,OAAO,EACL,KAAK,SAAS,EAOf,MAAM,OAAO,CAAC;AACf,OAAO,KAAK,EAAC,SAAS,EAAE,SAAS,EAAC,MAAM,SAAS,CAAC;AAClD,OAAO,KAAK,EAAe,WAAW,EAAoB,MAAM,SAAS,CAAC;AAO1E,OAAO,EAAc,KAAK,gBAAgB,EAAC,MAAM,eAAe,CAAC;AAEjE,OAAO,EAAe,KAAK,iBAAiB,EAAC,MAAM,gBAAgB,CAAC;AAIpE,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IAC9B,+EAA+E;IAC/E,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,gFAAgF;IAChF,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3B;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3B,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,MAAM,CAAC,EAAE,OAAO,GAAG,gBAAgB,CAAC;IACpC,OAAO,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC,iBAAiB,EAAE,QAAQ,CAAC,CAAC;IACtD,YAAY,CAAC,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AA8BD,wBAAgB,KAAK,CAAC,EACpB,IAAI,EACJ,IAAI,EACJ,MAAM,EACN,MAAY,EACZ,MAAM,EAAE,cAAc,EACtB,SAAS,EACT,OAAO,EACP,OAAO,EACP,IAAI,EACJ,IAAI,EACJ,MAAM,EACN,OAAO,EACP,YAAY,EACZ,QAAQ,EACR,KAAK,EACL,QAAQ,GACT,EAAE,UAAU,+BA4UZ"}
package/dist/Chart.js CHANGED
@@ -9,6 +9,10 @@
9
9
  * 1. Runs the layout engine (scales + stacking + grouping)
10
10
  * 2. Calls each series' resolve() and render() methods
11
11
  * 3. Provides a single event layer for interaction children
12
+ *
13
+ * Accessibility: the svg is exposed as a named image (role="img"; `title` or a
14
+ * derived default as aria-label), and small datasets are mirrored in a
15
+ * visually hidden data table for screen reader users.
12
16
  */
13
17
 
14
18
  'use client';
@@ -18,6 +22,7 @@ import { computeLayout } from "./layout.js";
18
22
  import { ChartProvider } from "./ChartContext.js";
19
23
  import { Text } from '@astryxdesign/core';
20
24
  import { VStack, HStack } from '@astryxdesign/core';
25
+ import { VisuallyHidden } from '@astryxdesign/core';
21
26
  import * as stylex from '@stylexjs/stylex';
22
27
  import { ChartLegend } from "./ChartLegend.js";
23
28
  import { deriveLegendItems } from "./legend.js";
@@ -31,6 +36,18 @@ const DEFAULT_MARGIN = {
31
36
  bottom: 32,
32
37
  left: 48
33
38
  };
39
+
40
+ /**
41
+ * Maximum number of data points (rows × data keys) for which the visually
42
+ * hidden data-table fallback is rendered. Beyond this a table is more noise
43
+ * than signal for screen reader users, so larger charts are name-only.
44
+ */
45
+ const MAX_TABLE_POINTS = 100;
46
+
47
+ /** Stringify a cell value for the hidden data table. */
48
+ function tableCell(value) {
49
+ return value == null ? '' : String(value);
50
+ }
34
51
  export function Chart({
35
52
  data,
36
53
  xKey,
@@ -191,6 +208,14 @@ export function Chart({
191
208
  svgRef
192
209
  }), [innerWidth, innerHeight, margin, data, xKey, layout, onPointer]);
193
210
 
211
+ // ─── Accessibility ─────────────────────────────────────────────────────
212
+ // The svg is exposed as a named image. `title` (when given) is the name;
213
+ // otherwise an English default is derived from the primary series + x key.
214
+ const primarySeries = series.filter(s => !isUtilityMarkType(s.type));
215
+ const accessibleLabel = title ?? (primarySeries.length > 0 ? `Chart of ${primarySeries.map(s => s.label ?? s.key).join(', ')} by ${xKey}` : 'Chart');
216
+ const tableKeys = Array.from(new Set(primarySeries.flatMap(s => s.dataKeys)));
217
+ const showDataTable = data.length > 0 && tableKeys.length > 0 && data.length * tableKeys.length <= MAX_TABLE_POINTS;
218
+
194
219
  // ─── Legend ────────────────────────────────────────────────────────────
195
220
  const legendConfig = legend === true ? {} : legend || null;
196
221
  const legendElement = legendConfig ? /*#__PURE__*/_jsx(ChartLegend, {
@@ -246,14 +271,15 @@ export function Chart({
246
271
  gap: 4,
247
272
  children: [legendPosition === 'top' && legendElement, renderSvg(), legendPosition === 'bottom' && legendElement]
248
273
  })
249
- })]
274
+ }), renderDataTable()]
250
275
  });
251
276
  function renderSvg() {
252
277
  return /*#__PURE__*/_jsxs("svg", {
253
278
  ref: svgRef,
279
+ role: "img",
254
280
  width: "100%",
255
281
  height: safeHeight,
256
- "aria-label": title ?? undefined,
282
+ "aria-label": accessibleLabel,
257
283
  "aria-describedby": subtitle ? descId : undefined,
258
284
  children: [title && /*#__PURE__*/_jsx("title", {
259
285
  children: title
@@ -298,4 +324,36 @@ export function Chart({
298
324
  })]
299
325
  });
300
326
  }
327
+ function renderDataTable() {
328
+ if (!showDataTable) {
329
+ return null;
330
+ }
331
+ return /*#__PURE__*/_jsx(VisuallyHidden, {
332
+ as: "div",
333
+ children: /*#__PURE__*/_jsxs("table", {
334
+ children: [/*#__PURE__*/_jsx("caption", {
335
+ children: `${accessibleLabel} data`
336
+ }), /*#__PURE__*/_jsx("thead", {
337
+ children: /*#__PURE__*/_jsxs("tr", {
338
+ children: [/*#__PURE__*/_jsx("th", {
339
+ scope: "col",
340
+ children: xKey
341
+ }), tableKeys.map(key => /*#__PURE__*/_jsx("th", {
342
+ scope: "col",
343
+ children: key
344
+ }, key))]
345
+ })
346
+ }), /*#__PURE__*/_jsx("tbody", {
347
+ children: data.map((d, i) => /*#__PURE__*/_jsxs("tr", {
348
+ children: [/*#__PURE__*/_jsx("th", {
349
+ scope: "row",
350
+ children: tableCell(d[xKey])
351
+ }), tableKeys.map(key => /*#__PURE__*/_jsx("td", {
352
+ children: tableCell(d[key])
353
+ }, key))]
354
+ }, i))
355
+ })]
356
+ })
357
+ });
358
+ }
301
359
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/charts",
3
- "version": "0.2.0-canary.f306f13",
3
+ "version": "0.2.0-canary.fc53ff7",
4
4
  "description": "Astryx charts — a config-model data visualization library (d3-based). 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
  },
@@ -0,0 +1,129 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Chart.test.tsx
5
+ * @input Uses vitest, @testing-library/react, Chart root + bar mark
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
+ import {bar} from './marks';
14
+
15
+ const data = [
16
+ {month: 'Jan', revenue: 100, profit: 20},
17
+ {month: 'Feb', revenue: 200, profit: 40},
18
+ {month: 'Mar', revenue: 150, profit: 30},
19
+ ];
20
+
21
+ // Capture the ResizeObserver callback so tests can drive the reported width.
22
+ let resizeCallback: ResizeObserverCallback | undefined;
23
+
24
+ beforeEach(() => {
25
+ resizeCallback = undefined;
26
+ vi.stubGlobal(
27
+ 'ResizeObserver',
28
+ class {
29
+ constructor(cb: ResizeObserverCallback) {
30
+ resizeCallback = cb;
31
+ }
32
+ observe() {}
33
+ unobserve() {}
34
+ disconnect() {}
35
+ },
36
+ );
37
+ });
38
+
39
+ afterEach(() => {
40
+ vi.unstubAllGlobals();
41
+ });
42
+
43
+ function reportWidth(width: number) {
44
+ act(() => {
45
+ resizeCallback?.(
46
+ [{contentRect: {width}} as unknown as ResizeObserverEntry],
47
+ {} as ResizeObserver,
48
+ );
49
+ });
50
+ }
51
+
52
+ describe('Chart accessible name', () => {
53
+ it('exposes the svg as a named image with a default label', () => {
54
+ render(
55
+ <Chart
56
+ data={data}
57
+ xKey="month"
58
+ series={[bar('revenue'), bar('profit')]}
59
+ />,
60
+ );
61
+ reportWidth(600);
62
+
63
+ expect(
64
+ screen.getByRole('img', {name: 'Chart of revenue, profit by month'}),
65
+ ).toBeInTheDocument();
66
+ });
67
+
68
+ it('prefers the title prop as the accessible name', () => {
69
+ render(
70
+ <Chart
71
+ data={data}
72
+ xKey="month"
73
+ series={[bar('revenue')]}
74
+ title="Quarterly revenue"
75
+ />,
76
+ );
77
+ reportWidth(600);
78
+
79
+ expect(
80
+ screen.getByRole('img', {name: 'Quarterly revenue'}),
81
+ ).toBeInTheDocument();
82
+ });
83
+ });
84
+
85
+ describe('Chart data table fallback', () => {
86
+ it('renders a visually hidden table mirroring the data', () => {
87
+ render(
88
+ <Chart
89
+ data={data}
90
+ xKey="month"
91
+ series={[bar('revenue'), bar('profit')]}
92
+ />,
93
+ );
94
+ reportWidth(600);
95
+
96
+ const table = screen.getByRole('table');
97
+ expect(table).toBeInTheDocument();
98
+
99
+ expect(
100
+ screen.getByRole('columnheader', {name: 'month'}),
101
+ ).toBeInTheDocument();
102
+ expect(
103
+ screen.getByRole('columnheader', {name: 'revenue'}),
104
+ ).toBeInTheDocument();
105
+ expect(
106
+ screen.getByRole('columnheader', {name: 'profit'}),
107
+ ).toBeInTheDocument();
108
+
109
+ expect(screen.getByRole('rowheader', {name: 'Feb'})).toBeInTheDocument();
110
+ expect(screen.getByRole('cell', {name: '200'})).toBeInTheDocument();
111
+ expect(screen.getByRole('cell', {name: '40'})).toBeInTheDocument();
112
+
113
+ // Visually hidden, but still in the a11y tree.
114
+ const wrapper = table.parentElement as HTMLElement;
115
+ expect(wrapper.getAttribute('class')).toBeTruthy();
116
+ expect(wrapper).not.toHaveAttribute('hidden');
117
+ });
118
+
119
+ it('skips the table when the dataset exceeds the size cutoff', () => {
120
+ const big = Array.from({length: 101}, (_, i) => ({
121
+ month: `m${i}`,
122
+ revenue: i,
123
+ }));
124
+ render(<Chart data={big} xKey="month" series={[bar('revenue')]} />);
125
+ reportWidth(600);
126
+
127
+ expect(screen.queryByRole('table')).not.toBeInTheDocument();
128
+ });
129
+ });
package/src/Chart.tsx CHANGED
@@ -9,6 +9,10 @@
9
9
  * 1. Runs the layout engine (scales + stacking + grouping)
10
10
  * 2. Calls each series' resolve() and render() methods
11
11
  * 3. Provides a single event layer for interaction children
12
+ *
13
+ * Accessibility: the svg is exposed as a named image (role="img"; `title` or a
14
+ * derived default as aria-label), and small datasets are mirrored in a
15
+ * visually hidden data table for screen reader users.
12
16
  */
13
17
 
14
18
  'use client';
@@ -28,6 +32,7 @@ import {computeLayout} from './layout';
28
32
  import {ChartProvider} from './ChartContext';
29
33
  import {Text} from '@astryxdesign/core';
30
34
  import {VStack, HStack} from '@astryxdesign/core';
35
+ import {VisuallyHidden} from '@astryxdesign/core';
31
36
  import * as stylex from '@stylexjs/stylex';
32
37
  import {ChartLegend, type ChartLegendProps} from './ChartLegend';
33
38
  import {deriveLegendItems} from './legend';
@@ -62,6 +67,18 @@ export interface ChartProps {
62
67
 
63
68
  const DEFAULT_MARGIN: ChartMargin = {top: 24, right: 24, bottom: 32, left: 48};
64
69
 
70
+ /**
71
+ * Maximum number of data points (rows × data keys) for which the visually
72
+ * hidden data-table fallback is rendered. Beyond this a table is more noise
73
+ * than signal for screen reader users, so larger charts are name-only.
74
+ */
75
+ const MAX_TABLE_POINTS = 100;
76
+
77
+ /** Stringify a cell value for the hidden data table. */
78
+ function tableCell(value: unknown): string {
79
+ return value == null ? '' : String(value);
80
+ }
81
+
65
82
  const styles = stylex.create({
66
83
  container: {
67
84
  width: '100%',
@@ -250,6 +267,23 @@ export function Chart({
250
267
  [innerWidth, innerHeight, margin, data, xKey, layout, onPointer],
251
268
  );
252
269
 
270
+ // ─── Accessibility ─────────────────────────────────────────────────────
271
+ // The svg is exposed as a named image. `title` (when given) is the name;
272
+ // otherwise an English default is derived from the primary series + x key.
273
+ const primarySeries = series.filter(s => !isUtilityMarkType(s.type));
274
+ const accessibleLabel =
275
+ title ??
276
+ (primarySeries.length > 0
277
+ ? `Chart of ${primarySeries
278
+ .map(s => s.label ?? s.key)
279
+ .join(', ')} by ${xKey}`
280
+ : 'Chart');
281
+ const tableKeys = Array.from(new Set(primarySeries.flatMap(s => s.dataKeys)));
282
+ const showDataTable =
283
+ data.length > 0 &&
284
+ tableKeys.length > 0 &&
285
+ data.length * tableKeys.length <= MAX_TABLE_POINTS;
286
+
253
287
  // ─── Legend ────────────────────────────────────────────────────────────
254
288
  const legendConfig = legend === true ? {} : legend || null;
255
289
 
@@ -309,6 +343,7 @@ export function Chart({
309
343
  </VStack>
310
344
  )}
311
345
  </ChartProvider>
346
+ {renderDataTable()}
312
347
  </div>
313
348
  );
314
349
 
@@ -316,9 +351,10 @@ export function Chart({
316
351
  return (
317
352
  <svg
318
353
  ref={svgRef}
354
+ role="img"
319
355
  width="100%"
320
356
  height={safeHeight}
321
- aria-label={title ?? undefined}
357
+ aria-label={accessibleLabel}
322
358
  aria-describedby={subtitle ? descId : undefined}>
323
359
  {title && <title>{title}</title>}
324
360
  {subtitle && <desc id={descId}>{subtitle}</desc>}
@@ -373,4 +409,37 @@ export function Chart({
373
409
  </svg>
374
410
  );
375
411
  }
412
+
413
+ function renderDataTable() {
414
+ if (!showDataTable) {
415
+ return null;
416
+ }
417
+ return (
418
+ <VisuallyHidden as="div">
419
+ <table>
420
+ <caption>{`${accessibleLabel} data`}</caption>
421
+ <thead>
422
+ <tr>
423
+ <th scope="col">{xKey}</th>
424
+ {tableKeys.map(key => (
425
+ <th key={key} scope="col">
426
+ {key}
427
+ </th>
428
+ ))}
429
+ </tr>
430
+ </thead>
431
+ <tbody>
432
+ {data.map((d, i) => (
433
+ <tr key={i}>
434
+ <th scope="row">{tableCell(d[xKey])}</th>
435
+ {tableKeys.map(key => (
436
+ <td key={key}>{tableCell(d[key])}</td>
437
+ ))}
438
+ </tr>
439
+ ))}
440
+ </tbody>
441
+ </table>
442
+ </VisuallyHidden>
443
+ );
444
+ }
376
445
  }