@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.
@@ -1,9 +1,55 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- import {describe, it, expect} from 'vitest';
4
- import {render, screen} from '@testing-library/react';
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 {useEffect, useLayoutEffect, useRef, useCallback, useState} from 'react';
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
- // Tab key: insert 2 spaces
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
- const el = editorRef.current;
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={() => setFocused(false)}
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
+ });