@astryxdesign/lab 0.2.0-canary.ea693ac → 0.2.0-canary.f306f13

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,24 +7,12 @@
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.
14
10
  */
15
11
 
16
12
  import { useMemo, useRef, useState, useLayoutEffect } from 'react';
17
- import { VisuallyHidden } from '@astryxdesign/core/VisuallyHidden';
18
13
  import { SankeyProvider } from "./SankeyContext.js";
19
14
  import { computeLayout } from "./layout.js";
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
-
15
+ import { jsx as _jsx } from "react/jsx-runtime";
28
16
  /** Resolve column count from raw column input or auto-detect from graph */
29
17
  function resolveColumnCount(columns, nodes, links) {
30
18
  if (columns) {
@@ -97,7 +85,6 @@ export function SankeyChart({
97
85
  nodeGap = 14,
98
86
  nodeColor,
99
87
  minColumnWidth = 160,
100
- label = 'Sankey chart',
101
88
  children
102
89
  }) {
103
90
  const containerRef = useRef(null);
@@ -146,24 +133,20 @@ export function SankeyChart({
146
133
  nodeColor
147
134
  };
148
135
  }, [layout, chartWidth, height, nodeWidth, nodeColor]);
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", {
136
+ return /*#__PURE__*/_jsx("div", {
152
137
  ref: containerRef,
153
138
  style: {
154
139
  width: '100%'
155
140
  },
156
- children: [ctx && /*#__PURE__*/_jsx("div", {
141
+ children: ctx && /*#__PURE__*/_jsx("div", {
157
142
  role: needsScroll ? 'group' : undefined,
158
- "aria-label": needsScroll ? label : undefined,
143
+ "aria-label": needsScroll ? 'Sankey chart' : undefined,
159
144
  tabIndex: needsScroll ? 0 : undefined,
160
145
  style: needsScroll ? {
161
146
  overflowX: 'auto',
162
147
  overflowY: 'hidden'
163
148
  } : undefined,
164
149
  children: /*#__PURE__*/_jsx("svg", {
165
- role: "img",
166
- "aria-label": label,
167
150
  width: chartWidth,
168
151
  height: height,
169
152
  style: {
@@ -175,38 +158,7 @@ export function SankeyChart({
175
158
  children: children
176
159
  })
177
160
  })
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
- })]
161
+ })
210
162
  });
211
163
  }
212
164
  SankeyChart.displayName = 'SankeyChart';
@@ -1,8 +1,6 @@
1
1
  /**
2
2
  * @file ThreeDChart.tsx
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)
3
+ * @output Root 3D chart container — projected SVG with depth sorting
6
4
  * @position Parent component; all 3D marks read from its context
7
5
  */
8
6
  import { type ReactNode } from 'react';
@@ -18,20 +16,9 @@ export interface ThreeDChartProps {
18
16
  azimuth?: number;
19
17
  elevation?: number;
20
18
  interactive?: boolean;
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
- */
19
+ /** Auto-rotate speed in degrees per frame (default: 0 = off) */
28
20
  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;
34
21
  children: ReactNode;
35
22
  }
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;
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;
37
24
  //# sourceMappingURL=ThreeDChart.d.ts.map
@@ -1 +1 @@
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"}
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"}
@@ -2,18 +2,13 @@
2
2
 
3
3
  /**
4
4
  * @file ThreeDChart.tsx
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)
5
+ * @output Root 3D chart container — projected SVG with depth sorting
8
6
  * @position Parent component; all 3D marks read from its context
9
7
  */
10
8
 
11
9
  import { useMemo, useRef, useState, useEffect, useLayoutEffect, useCallback } from 'react';
12
- import { useMediaQuery } from '@astryxdesign/core/hooks';
13
10
  import { ThreeDProvider } from "./ThreeDContext.js";
14
11
  import { jsx as _jsx } from "react/jsx-runtime";
15
- /** Degrees the camera rotates per arrow-key press. */
16
- const KEYBOARD_STEP_DEGREES = 10;
17
12
  function computeDomain(data, key) {
18
13
  let min = Infinity,
19
14
  max = -Infinity;
@@ -43,14 +38,10 @@ export function ThreeDChart({
43
38
  elevation: elevationProp = 25,
44
39
  interactive = false,
45
40
  autoRotate = 0,
46
- label,
47
41
  children
48
42
  }) {
49
43
  const containerRef = useRef(null);
50
44
  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)');
54
45
  const [camera, setCamera] = useState({
55
46
  azimuth: azimuthProp,
56
47
  elevation: elevationProp
@@ -132,44 +123,9 @@ export function ThreeDChart({
132
123
  dragRef.current = null;
133
124
  }, []);
134
125
 
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;
126
+ // Auto-rotation — throttled to ~20fps to avoid overwhelming React with re-renders
171
127
  useEffect(() => {
172
- if (!rotationActive) {
128
+ if (autoRotate === 0) {
173
129
  return;
174
130
  }
175
131
  let raf;
@@ -189,7 +145,7 @@ export function ThreeDChart({
189
145
  };
190
146
  raf = requestAnimationFrame(tick);
191
147
  return () => cancelAnimationFrame(raf);
192
- }, [autoRotate, rotationActive]);
148
+ }, [autoRotate]);
193
149
  const ctx = useMemo(() => ({
194
150
  width,
195
151
  height,
@@ -209,30 +165,16 @@ export function ThreeDChart({
209
165
  touchAction: interactive ? 'none' : undefined,
210
166
  userSelect: interactive ? 'none' : undefined
211
167
  };
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;
216
168
  return /*#__PURE__*/_jsx("div", {
217
169
  ref: containerRef,
218
170
  style: containerStyle,
219
171
  children: containerWidth > 0 && /*#__PURE__*/_jsx("svg", {
220
- role: "img",
221
- "aria-label": accessibleLabel,
222
- tabIndex: focusable ? 0 : undefined,
223
172
  width: containerWidth,
224
173
  height: height,
225
- onKeyDown: handleKeyDown,
226
- onFocus: () => setFocused(true),
227
- onBlur: () => setFocused(false),
228
- onMouseEnter: () => setHovered(true),
229
174
  onMouseDown: e => handleStart(e.clientX, e.clientY),
230
175
  onMouseMove: e => handleMove(e.clientX, e.clientY),
231
176
  onMouseUp: handleEnd,
232
- onMouseLeave: () => {
233
- setHovered(false);
234
- handleEnd();
235
- },
177
+ onMouseLeave: handleEnd,
236
178
  onTouchStart: e => {
237
179
  const t = e.touches[0];
238
180
  if (t) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/lab",
3
- "version": "0.2.0-canary.ea693ac",
3
+ "version": "0.2.0-canary.f306f13",
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.ea693ac",
48
+ "@astryxdesign/core": "0.2.0-canary.f306f13",
49
49
  "react": ">=19.0.0",
50
50
  "react-dom": ">=19.0.0",
51
51
  "lexical": "^0.46.0",
@@ -7,10 +7,6 @@
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.
14
10
  */
15
11
 
16
12
  import {
@@ -24,7 +20,6 @@ import {
24
20
  } from 'react';
25
21
  import {scaleLinear, scaleBand} from 'd3-scale';
26
22
  import type {ScaleLinear} from 'd3-scale';
27
- import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
28
23
  import {isBandScale} from './utils';
29
24
  import {ChartProvider} from './ChartContext';
30
25
  import type {ChartMargin, ChartScale} from './types';
@@ -80,31 +75,12 @@ export interface ChartProps {
80
75
  * interference on mobile.
81
76
  */
82
77
  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;
90
78
  /** Chart contents — axes, marks, tooltips */
91
79
  children: ReactNode;
92
80
  }
93
81
 
94
82
  const DEFAULT_MARGIN: ChartMargin = {top: 16, right: 16, bottom: 32, left: 48};
95
83
 
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
-
108
84
  /**
109
85
  * Root chart container. Computes scales from data and provides them to children.
110
86
  *
@@ -127,7 +103,6 @@ export function Chart({
127
103
  yDomain: yDomainProp,
128
104
  xDomain: xDomainProp,
129
105
  interactive = false,
130
- label,
131
106
  children,
132
107
  }: ChartProps) {
133
108
  const containerRef = useRef<HTMLDivElement>(null);
@@ -314,18 +289,11 @@ export function Chart({
314
289
  userSelect: interactive ? 'none' : undefined,
315
290
  };
316
291
 
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
-
322
292
  return (
323
293
  <div ref={containerRef} style={containerStyle}>
324
294
  {containerWidth > 0 && (
325
295
  <svg
326
296
  ref={svgRef}
327
- role="img"
328
- aria-label={accessibleLabel}
329
297
  width={containerWidth}
330
298
  height={height}
331
299
  style={interactive ? {touchAction: 'none'} : undefined}>
@@ -339,33 +307,6 @@ export function Chart({
339
307
  </g>
340
308
  </svg>
341
309
  )}
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
- )}
369
310
  </div>
370
311
  );
371
312
  }
@@ -1,55 +1,9 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
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';
3
+ import {describe, it, expect} from 'vitest';
4
+ import {render, screen} from '@testing-library/react';
15
5
  import {CodeEditor} from './CodeEditor';
16
6
 
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
-
53
7
  describe('CodeEditor', () => {
54
8
  it('uses the label prop as the accessible name', () => {
55
9
  render(
@@ -64,128 +18,4 @@ describe('CodeEditor', () => {
64
18
  screen.getByRole('textbox', {name: 'Edit snippet'}),
65
19
  ).toBeInTheDocument();
66
20
  });
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
- });
191
21
  });