@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
|
@@ -1,9 +1,55 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* @file CodeEditor.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, @testing-library/user-event, CodeEditor
|
|
6
|
+
* @output Unit tests for CodeEditor accessibility and keyboard behavior
|
|
7
|
+
* @position Testing; validates CodeEditor.tsx implementation
|
|
8
|
+
*
|
|
9
|
+
* SYNC: When CodeEditor.tsx changes, update tests to match new behavior
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {describe, it, expect, vi} from 'vitest';
|
|
13
|
+
import {render, screen, fireEvent} from '@testing-library/react';
|
|
14
|
+
import userEvent from '@testing-library/user-event';
|
|
5
15
|
import {CodeEditor} from './CodeEditor';
|
|
6
16
|
|
|
17
|
+
/** Place a collapsed caret at a character offset inside the editor. */
|
|
18
|
+
function setCaret(el: HTMLElement, offset: number) {
|
|
19
|
+
const sel = window.getSelection();
|
|
20
|
+
const range = document.createRange();
|
|
21
|
+
const node = el.firstChild;
|
|
22
|
+
if (node && node.nodeType === Node.TEXT_NODE) {
|
|
23
|
+
range.setStart(node, offset);
|
|
24
|
+
} else {
|
|
25
|
+
range.setStart(el, 0);
|
|
26
|
+
}
|
|
27
|
+
range.collapse(true);
|
|
28
|
+
sel?.removeAllRanges();
|
|
29
|
+
sel?.addRange(range);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function renderEditor(value = '', props: {escapeHint?: string} = {}) {
|
|
33
|
+
const onChange = vi.fn();
|
|
34
|
+
render(
|
|
35
|
+
<div>
|
|
36
|
+
<button>before</button>
|
|
37
|
+
<CodeEditor
|
|
38
|
+
value={value}
|
|
39
|
+
onChange={onChange}
|
|
40
|
+
language="typescript"
|
|
41
|
+
label="Edit snippet"
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
<button>after</button>
|
|
45
|
+
</div>,
|
|
46
|
+
);
|
|
47
|
+
const editor = screen.getByRole('textbox', {name: 'Edit snippet'});
|
|
48
|
+
editor.focus();
|
|
49
|
+
setCaret(editor, value.length);
|
|
50
|
+
return {editor, onChange};
|
|
51
|
+
}
|
|
52
|
+
|
|
7
53
|
describe('CodeEditor', () => {
|
|
8
54
|
it('uses the label prop as the accessible name', () => {
|
|
9
55
|
render(
|
|
@@ -18,4 +64,128 @@ describe('CodeEditor', () => {
|
|
|
18
64
|
screen.getByRole('textbox', {name: 'Edit snippet'}),
|
|
19
65
|
).toBeInTheDocument();
|
|
20
66
|
});
|
|
67
|
+
|
|
68
|
+
it('advertises the Escape-then-Tab escape via aria-describedby', () => {
|
|
69
|
+
const {editor} = renderEditor();
|
|
70
|
+
const hintId = editor.getAttribute('aria-describedby');
|
|
71
|
+
expect(hintId).toBeTruthy();
|
|
72
|
+
const hint = document.getElementById(hintId as string);
|
|
73
|
+
expect(hint).toHaveTextContent(
|
|
74
|
+
'Press Escape then Tab to move focus out of the editor.',
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('supports overriding the escape hint text', () => {
|
|
79
|
+
const {editor} = renderEditor('', {escapeHint: 'Échap puis Tab'});
|
|
80
|
+
const hintId = editor.getAttribute('aria-describedby');
|
|
81
|
+
const hint = document.getElementById(hintId as string);
|
|
82
|
+
expect(hint).toHaveTextContent('Échap puis Tab');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('Tab inserts two spaces by default and keeps focus in the editor', async () => {
|
|
86
|
+
const user = userEvent.setup();
|
|
87
|
+
const {editor, onChange} = renderEditor();
|
|
88
|
+
|
|
89
|
+
await user.keyboard('{Tab}');
|
|
90
|
+
|
|
91
|
+
expect(onChange).toHaveBeenCalledWith(' ');
|
|
92
|
+
expect(document.activeElement).toBe(editor);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('Escape then Tab moves focus out of the editor without indenting', async () => {
|
|
96
|
+
const user = userEvent.setup();
|
|
97
|
+
const {editor, onChange} = renderEditor();
|
|
98
|
+
|
|
99
|
+
await user.keyboard('{Escape}{Tab}');
|
|
100
|
+
|
|
101
|
+
expect(document.activeElement).not.toBe(editor);
|
|
102
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('Escape then Shift+Tab lets the browser move focus backward', async () => {
|
|
106
|
+
// user-event cannot compute a backward tab destination from a
|
|
107
|
+
// contenteditable element in jsdom, so assert on the preventDefault
|
|
108
|
+
// contract: an unprevented Shift+Tab keydown means the browser performs
|
|
109
|
+
// its native backward focus move.
|
|
110
|
+
const user = userEvent.setup();
|
|
111
|
+
const {editor, onChange} = renderEditor();
|
|
112
|
+
|
|
113
|
+
await user.keyboard('{Escape}');
|
|
114
|
+
fireEvent.keyDown(editor, {key: 'Shift'});
|
|
115
|
+
const notPrevented = fireEvent.keyDown(editor, {
|
|
116
|
+
key: 'Tab',
|
|
117
|
+
shiftKey: true,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(notPrevented).toBe(true);
|
|
121
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('tab-moves-focus mode is one-shot: the following Tab indents again', async () => {
|
|
125
|
+
const user = userEvent.setup();
|
|
126
|
+
const {editor, onChange} = renderEditor();
|
|
127
|
+
|
|
128
|
+
await user.keyboard('{Escape}{Tab}');
|
|
129
|
+
expect(document.activeElement).not.toBe(editor);
|
|
130
|
+
|
|
131
|
+
editor.focus();
|
|
132
|
+
setCaret(editor, 0);
|
|
133
|
+
await user.keyboard('{Tab}');
|
|
134
|
+
|
|
135
|
+
expect(onChange).toHaveBeenCalledWith(' ');
|
|
136
|
+
expect(document.activeElement).toBe(editor);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('typing after Escape re-arms Tab indentation', async () => {
|
|
140
|
+
const user = userEvent.setup();
|
|
141
|
+
const {editor, onChange} = renderEditor();
|
|
142
|
+
|
|
143
|
+
await user.keyboard('{Escape}');
|
|
144
|
+
// Any non-modifier key re-arms indentation mode.
|
|
145
|
+
fireEvent.keyDown(editor, {key: 'a'});
|
|
146
|
+
await user.keyboard('{Tab}');
|
|
147
|
+
|
|
148
|
+
expect(document.activeElement).toBe(editor);
|
|
149
|
+
expect(onChange).toHaveBeenCalledWith(' ');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('Shift+Tab outdents the current line in normal mode', async () => {
|
|
153
|
+
const user = userEvent.setup();
|
|
154
|
+
const {editor, onChange} = renderEditor(' const x = 1;');
|
|
155
|
+
|
|
156
|
+
await user.keyboard('{Shift>}{Tab}{/Shift}');
|
|
157
|
+
|
|
158
|
+
expect(onChange).toHaveBeenCalledWith('const x = 1;');
|
|
159
|
+
expect(document.activeElement).toBe(editor);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('Shift+Tab removes a single leading space when only one exists', async () => {
|
|
163
|
+
const user = userEvent.setup();
|
|
164
|
+
const {onChange} = renderEditor(' const x = 1;');
|
|
165
|
+
|
|
166
|
+
await user.keyboard('{Shift>}{Tab}{/Shift}');
|
|
167
|
+
|
|
168
|
+
expect(onChange).toHaveBeenCalledWith('const x = 1;');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('Shift+Tab on an unindented line changes nothing and keeps focus', async () => {
|
|
172
|
+
const user = userEvent.setup();
|
|
173
|
+
const {editor, onChange} = renderEditor('const x = 1;');
|
|
174
|
+
|
|
175
|
+
await user.keyboard('{Shift>}{Tab}{/Shift}');
|
|
176
|
+
|
|
177
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
178
|
+
expect(document.activeElement).toBe(editor);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('Shift+Tab outdents only the current line in multi-line code', async () => {
|
|
182
|
+
const user = userEvent.setup();
|
|
183
|
+
const {editor, onChange} = renderEditor(' a;\n b;');
|
|
184
|
+
// Caret on the second line (offset 7 = inside " b;").
|
|
185
|
+
setCaret(editor, 7);
|
|
186
|
+
|
|
187
|
+
await user.keyboard('{Shift>}{Tab}{/Shift}');
|
|
188
|
+
|
|
189
|
+
expect(onChange).toHaveBeenCalledWith(' a;\nb;');
|
|
190
|
+
});
|
|
21
191
|
});
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @file CodeEditor.tsx
|
|
5
|
-
* @input Uses React, StyleX, theme tokens, CSS Custom Highlight API
|
|
5
|
+
* @input Uses React, StyleX, theme tokens, CSS Custom Highlight API,
|
|
6
|
+
* VisuallyHidden (core)
|
|
6
7
|
* @output Exports CodeEditor component and CodeEditorProps
|
|
7
8
|
* @position Core implementation; editable code input (lab/experimental)
|
|
8
9
|
*
|
|
@@ -14,8 +15,16 @@
|
|
|
14
15
|
|
|
15
16
|
'use client';
|
|
16
17
|
|
|
17
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
useEffect,
|
|
20
|
+
useId,
|
|
21
|
+
useLayoutEffect,
|
|
22
|
+
useRef,
|
|
23
|
+
useCallback,
|
|
24
|
+
useState,
|
|
25
|
+
} from 'react';
|
|
18
26
|
import type {BaseProps} from '@astryxdesign/core';
|
|
27
|
+
import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
|
|
19
28
|
import * as stylex from '@stylexjs/stylex';
|
|
20
29
|
import {
|
|
21
30
|
colorVars,
|
|
@@ -145,6 +154,13 @@ export interface CodeEditorProps extends Omit<
|
|
|
145
154
|
size?: 'sm' | 'md';
|
|
146
155
|
/** Custom tokenizer */
|
|
147
156
|
tokenizer?: (code: string, language: string) => TokenLine[];
|
|
157
|
+
/**
|
|
158
|
+
* Hint exposed to assistive technology (via aria-describedby) explaining
|
|
159
|
+
* how to move focus out of the editor. English default — the lab package
|
|
160
|
+
* has no i18n catalog yet, so override this prop to localize.
|
|
161
|
+
* @default 'Press Escape then Tab to move focus out of the editor.'
|
|
162
|
+
*/
|
|
163
|
+
escapeHint?: string;
|
|
148
164
|
}
|
|
149
165
|
|
|
150
166
|
// ---------------------------------------------------------------------------
|
|
@@ -168,6 +184,47 @@ const AUTO_CLOSE_PAIRS: Record<string, string> = {
|
|
|
168
184
|
'`': '`',
|
|
169
185
|
};
|
|
170
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Modifier keys that must NOT re-arm Tab indentation after Escape has armed
|
|
189
|
+
* "tab moves focus" mode — otherwise the Shift keydown that precedes
|
|
190
|
+
* Shift+Tab would cancel the mode before Tab arrives.
|
|
191
|
+
*/
|
|
192
|
+
const MODE_NEUTRAL_KEYS = new Set([
|
|
193
|
+
'Shift',
|
|
194
|
+
'Control',
|
|
195
|
+
'Alt',
|
|
196
|
+
'Meta',
|
|
197
|
+
'CapsLock',
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
/** Place a collapsed caret at a character offset within an element's text. */
|
|
201
|
+
function setCaretAtTextOffset(el: HTMLElement, offset: number): void {
|
|
202
|
+
const sel = window.getSelection();
|
|
203
|
+
if (!sel) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const range = document.createRange();
|
|
207
|
+
let remaining = offset;
|
|
208
|
+
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
209
|
+
let node = walker.nextNode();
|
|
210
|
+
while (node) {
|
|
211
|
+
const length = node.textContent?.length ?? 0;
|
|
212
|
+
if (remaining <= length) {
|
|
213
|
+
range.setStart(node, remaining);
|
|
214
|
+
range.collapse(true);
|
|
215
|
+
sel.removeAllRanges();
|
|
216
|
+
sel.addRange(range);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
remaining -= length;
|
|
220
|
+
node = walker.nextNode();
|
|
221
|
+
}
|
|
222
|
+
range.selectNodeContents(el);
|
|
223
|
+
range.collapse(false);
|
|
224
|
+
sel.removeAllRanges();
|
|
225
|
+
sel.addRange(range);
|
|
226
|
+
}
|
|
227
|
+
|
|
171
228
|
let editorInstanceCounter = 0;
|
|
172
229
|
|
|
173
230
|
// ---------------------------------------------------------------------------
|
|
@@ -178,7 +235,12 @@ let editorInstanceCounter = 0;
|
|
|
178
235
|
* An editable code input using contentEditable="plaintext-only".
|
|
179
236
|
*
|
|
180
237
|
* Uses CSS Custom Highlight API for syntax coloring. Supports
|
|
181
|
-
* auto-indent, tab insertion, and bracket auto-closing.
|
|
238
|
+
* auto-indent, tab insertion, Shift+Tab outdent, and bracket auto-closing.
|
|
239
|
+
*
|
|
240
|
+
* Because Tab is captured for indentation, pressing Escape arms a one-shot
|
|
241
|
+
* "tab moves focus" mode so keyboard users can leave the editor (WCAG 2.1.2).
|
|
242
|
+
* The escape is advertised to assistive technology via a visually hidden
|
|
243
|
+
* aria-describedby hint (see `escapeHint`).
|
|
182
244
|
*
|
|
183
245
|
* @example
|
|
184
246
|
* ```
|
|
@@ -203,6 +265,7 @@ export function CodeEditor({
|
|
|
203
265
|
size = 'md',
|
|
204
266
|
tokenizer: customTokenizer,
|
|
205
267
|
label,
|
|
268
|
+
escapeHint = 'Press Escape then Tab to move focus out of the editor.',
|
|
206
269
|
xstyle,
|
|
207
270
|
className,
|
|
208
271
|
style,
|
|
@@ -213,6 +276,10 @@ export function CodeEditor({
|
|
|
213
276
|
const [instanceId] = useState(() => ++editorInstanceCounter);
|
|
214
277
|
const [focused, setFocused] = useState(false);
|
|
215
278
|
const isComposingRef = useRef(false);
|
|
279
|
+
// One-shot "tab moves focus" mode, armed by Escape (WCAG 2.1.2 — the
|
|
280
|
+
// standard escape from a code editor's Tab-to-indent keyboard trap).
|
|
281
|
+
const tabMovesFocusRef = useRef(false);
|
|
282
|
+
const hintId = useId();
|
|
216
283
|
|
|
217
284
|
const lines = value.split('\n');
|
|
218
285
|
|
|
@@ -306,13 +373,68 @@ export function CodeEditor({
|
|
|
306
373
|
return;
|
|
307
374
|
}
|
|
308
375
|
|
|
309
|
-
//
|
|
376
|
+
// Escape arms one-shot "tab moves focus" mode so keyboard users can
|
|
377
|
+
// leave the editor. Only engage when nothing inside the editor (e.g.
|
|
378
|
+
// an embedded popover/autocomplete) already consumed the Escape.
|
|
379
|
+
if (e.key === 'Escape') {
|
|
380
|
+
if (!e.defaultPrevented) {
|
|
381
|
+
tabMovesFocusRef.current = true;
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
310
386
|
if (e.key === 'Tab') {
|
|
387
|
+
// Tab-moves-focus mode: let the browser handle Tab / Shift+Tab
|
|
388
|
+
// natively so focus leaves the editor. One-shot — disarm.
|
|
389
|
+
if (tabMovesFocusRef.current) {
|
|
390
|
+
tabMovesFocusRef.current = false;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
311
394
|
e.preventDefault();
|
|
312
395
|
const sel = window.getSelection();
|
|
313
396
|
if (!sel || sel.rangeCount === 0) {
|
|
314
397
|
return;
|
|
315
398
|
}
|
|
399
|
+
const el = editorRef.current;
|
|
400
|
+
if (!el) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Shift+Tab: outdent — remove up to two leading spaces on the
|
|
405
|
+
// current line.
|
|
406
|
+
if (e.shiftKey) {
|
|
407
|
+
const range = sel.getRangeAt(0);
|
|
408
|
+
const preCaretRange = range.cloneRange();
|
|
409
|
+
preCaretRange.selectNodeContents(el);
|
|
410
|
+
preCaretRange.setEnd(range.startContainer, range.startOffset);
|
|
411
|
+
const cursorOffset = preCaretRange.toString().length;
|
|
412
|
+
|
|
413
|
+
const fullText = el.textContent ?? '';
|
|
414
|
+
const lineStart = fullText.lastIndexOf('\n', cursorOffset - 1) + 1;
|
|
415
|
+
const line = fullText.slice(lineStart);
|
|
416
|
+
const removeCount = line.startsWith(' ')
|
|
417
|
+
? 2
|
|
418
|
+
: line.startsWith(' ')
|
|
419
|
+
? 1
|
|
420
|
+
: 0;
|
|
421
|
+
if (removeCount === 0) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const newText =
|
|
425
|
+
fullText.slice(0, lineStart) +
|
|
426
|
+
fullText.slice(lineStart + removeCount);
|
|
427
|
+
el.textContent = newText;
|
|
428
|
+
const removedBeforeCursor = Math.min(
|
|
429
|
+
removeCount,
|
|
430
|
+
Math.max(0, cursorOffset - lineStart),
|
|
431
|
+
);
|
|
432
|
+
setCaretAtTextOffset(el, cursorOffset - removedBeforeCursor);
|
|
433
|
+
onChange(newText);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Tab: insert 2 spaces
|
|
316
438
|
const range = sel.getRangeAt(0);
|
|
317
439
|
range.deleteContents();
|
|
318
440
|
const textNode = document.createTextNode(' ');
|
|
@@ -321,13 +443,17 @@ export function CodeEditor({
|
|
|
321
443
|
range.setEndAfter(textNode);
|
|
322
444
|
sel.removeAllRanges();
|
|
323
445
|
sel.addRange(range);
|
|
324
|
-
|
|
325
|
-
if (el) {
|
|
326
|
-
onChange(el.textContent ?? '');
|
|
327
|
-
}
|
|
446
|
+
onChange(el.textContent ?? '');
|
|
328
447
|
return;
|
|
329
448
|
}
|
|
330
449
|
|
|
450
|
+
// Any other non-modifier key re-arms Tab indentation. Modifiers are
|
|
451
|
+
// excluded so Escape → Shift+Tab still moves focus backward (the
|
|
452
|
+
// Shift keydown fires before the Tab keydown).
|
|
453
|
+
if (!MODE_NEUTRAL_KEYS.has(e.key)) {
|
|
454
|
+
tabMovesFocusRef.current = false;
|
|
455
|
+
}
|
|
456
|
+
|
|
331
457
|
// Enter key: preserve indentation
|
|
332
458
|
if (e.key === 'Enter') {
|
|
333
459
|
e.preventDefault();
|
|
@@ -453,15 +579,22 @@ export function CodeEditor({
|
|
|
453
579
|
aria-multiline="true"
|
|
454
580
|
aria-label={label}
|
|
455
581
|
aria-readonly={isReadOnly}
|
|
582
|
+
aria-describedby={isReadOnly ? undefined : hintId}
|
|
456
583
|
spellCheck={false}
|
|
457
584
|
onInput={handleInput}
|
|
458
585
|
onKeyDown={handleKeyDown}
|
|
459
586
|
onFocus={() => setFocused(true)}
|
|
460
|
-
onBlur={() =>
|
|
587
|
+
onBlur={() => {
|
|
588
|
+
setFocused(false);
|
|
589
|
+
tabMovesFocusRef.current = false;
|
|
590
|
+
}}
|
|
461
591
|
onCompositionStart={handleCompositionStart}
|
|
462
592
|
onCompositionEnd={handleCompositionEnd}
|
|
463
593
|
{...stylex.props(styles.editor, sizeStyle)}
|
|
464
594
|
/>
|
|
595
|
+
{!isReadOnly && (
|
|
596
|
+
<VisuallyHidden id={hintId}>{escapeHint}</VisuallyHidden>
|
|
597
|
+
)}
|
|
465
598
|
</div>
|
|
466
599
|
</div>
|
|
467
600
|
</div>
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file RadialChart.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, RadialChart component
|
|
6
|
+
* @output Unit tests for RadialChart accessibility (WCAG 1.1.1)
|
|
7
|
+
* @position Testing; validates RadialChart.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 {RadialChart} from './RadialChart';
|
|
13
|
+
|
|
14
|
+
const spiderData = [
|
|
15
|
+
{name: 'modelA', speed: 8, handling: 6, comfort: 7},
|
|
16
|
+
{name: 'modelB', speed: 5, handling: 9, comfort: 6},
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const pieData = [
|
|
20
|
+
{region: 'NA', revenue: 60},
|
|
21
|
+
{region: 'EU', revenue: 40},
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Capture the ResizeObserver callback so tests can drive the reported width.
|
|
25
|
+
let resizeCallback: ResizeObserverCallback | undefined;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
resizeCallback = undefined;
|
|
29
|
+
vi.stubGlobal(
|
|
30
|
+
'ResizeObserver',
|
|
31
|
+
class {
|
|
32
|
+
constructor(cb: ResizeObserverCallback) {
|
|
33
|
+
resizeCallback = cb;
|
|
34
|
+
}
|
|
35
|
+
observe() {}
|
|
36
|
+
unobserve() {}
|
|
37
|
+
disconnect() {}
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.unstubAllGlobals();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function reportWidth(width: number) {
|
|
47
|
+
act(() => {
|
|
48
|
+
resizeCallback?.(
|
|
49
|
+
[{contentRect: {width}} as unknown as ResizeObserverEntry],
|
|
50
|
+
{} as ResizeObserver,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('RadialChart accessible name', () => {
|
|
56
|
+
it('names the svg from the axes in spider mode', () => {
|
|
57
|
+
render(
|
|
58
|
+
<RadialChart data={spiderData} axes={['speed', 'handling', 'comfort']}>
|
|
59
|
+
<g />
|
|
60
|
+
</RadialChart>,
|
|
61
|
+
);
|
|
62
|
+
reportWidth(500);
|
|
63
|
+
|
|
64
|
+
expect(
|
|
65
|
+
screen.getByRole('img', {
|
|
66
|
+
name: 'Radar chart of speed, handling, comfort',
|
|
67
|
+
}),
|
|
68
|
+
).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('names the svg from the value key in pie mode', () => {
|
|
72
|
+
render(
|
|
73
|
+
<RadialChart data={pieData} valueKey="revenue" labelKey="region">
|
|
74
|
+
<g />
|
|
75
|
+
</RadialChart>,
|
|
76
|
+
);
|
|
77
|
+
reportWidth(500);
|
|
78
|
+
|
|
79
|
+
expect(
|
|
80
|
+
screen.getByRole('img', {name: 'Pie chart of revenue'}),
|
|
81
|
+
).toBeInTheDocument();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('uses a custom label when provided', () => {
|
|
85
|
+
render(
|
|
86
|
+
<RadialChart
|
|
87
|
+
data={pieData}
|
|
88
|
+
valueKey="revenue"
|
|
89
|
+
labelKey="region"
|
|
90
|
+
label="Revenue by region">
|
|
91
|
+
<g />
|
|
92
|
+
</RadialChart>,
|
|
93
|
+
);
|
|
94
|
+
reportWidth(500);
|
|
95
|
+
|
|
96
|
+
expect(
|
|
97
|
+
screen.getByRole('img', {name: 'Revenue by region'}),
|
|
98
|
+
).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('RadialChart data table fallback', () => {
|
|
103
|
+
it('renders slice labels, values, and percentages in pie mode', () => {
|
|
104
|
+
render(
|
|
105
|
+
<RadialChart data={pieData} valueKey="revenue" labelKey="region">
|
|
106
|
+
<g />
|
|
107
|
+
</RadialChart>,
|
|
108
|
+
);
|
|
109
|
+
reportWidth(500);
|
|
110
|
+
|
|
111
|
+
const table = screen.getByRole('table');
|
|
112
|
+
expect(table).toBeInTheDocument();
|
|
113
|
+
|
|
114
|
+
expect(screen.getByRole('rowheader', {name: 'NA'})).toBeInTheDocument();
|
|
115
|
+
expect(screen.getByRole('cell', {name: '60'})).toBeInTheDocument();
|
|
116
|
+
expect(screen.getByRole('cell', {name: '60.0%'})).toBeInTheDocument();
|
|
117
|
+
expect(screen.getByRole('rowheader', {name: 'EU'})).toBeInTheDocument();
|
|
118
|
+
expect(screen.getByRole('cell', {name: '40.0%'})).toBeInTheDocument();
|
|
119
|
+
|
|
120
|
+
// Visually hidden, but still in the a11y tree.
|
|
121
|
+
const wrapper = table.parentElement as HTMLElement;
|
|
122
|
+
expect(wrapper.getAttribute('class')).toBeTruthy();
|
|
123
|
+
expect(wrapper).not.toHaveAttribute('hidden');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('renders one row per series across the axes in spider mode', () => {
|
|
127
|
+
render(
|
|
128
|
+
<RadialChart data={spiderData} axes={['speed', 'handling', 'comfort']}>
|
|
129
|
+
<g />
|
|
130
|
+
</RadialChart>,
|
|
131
|
+
);
|
|
132
|
+
reportWidth(500);
|
|
133
|
+
|
|
134
|
+
expect(
|
|
135
|
+
screen.getByRole('columnheader', {name: 'speed'}),
|
|
136
|
+
).toBeInTheDocument();
|
|
137
|
+
expect(screen.getByRole('rowheader', {name: 'modelA'})).toBeInTheDocument();
|
|
138
|
+
expect(screen.getByRole('rowheader', {name: 'modelB'})).toBeInTheDocument();
|
|
139
|
+
expect(screen.getByRole('cell', {name: '8'})).toBeInTheDocument();
|
|
140
|
+
expect(screen.getByRole('cell', {name: '9'})).toBeInTheDocument();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('skips the table when the dataset exceeds the size cutoff', () => {
|
|
144
|
+
const big = Array.from({length: 101}, (_, i) => ({
|
|
145
|
+
region: `r${i}`,
|
|
146
|
+
revenue: i + 1,
|
|
147
|
+
}));
|
|
148
|
+
render(
|
|
149
|
+
<RadialChart data={big} valueKey="revenue" labelKey="region">
|
|
150
|
+
<g />
|
|
151
|
+
</RadialChart>,
|
|
152
|
+
);
|
|
153
|
+
reportWidth(500);
|
|
154
|
+
|
|
155
|
+
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
|
156
|
+
});
|
|
157
|
+
});
|