@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.
@@ -2,12 +2,15 @@
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 {
10
12
  type CSSProperties,
13
+ type KeyboardEvent as ReactKeyboardEvent,
11
14
  type ReactNode,
12
15
  useMemo,
13
16
  useRef,
@@ -16,6 +19,7 @@ import {
16
19
  useLayoutEffect,
17
20
  useCallback,
18
21
  } from 'react';
22
+ import {useMediaQuery} from '@astryxdesign/core/hooks';
19
23
  import {ThreeDProvider} from './ThreeDContext';
20
24
  import type {Camera, ProjectedPoint} from './types';
21
25
 
@@ -31,11 +35,25 @@ export interface ThreeDChartProps {
31
35
  azimuth?: number;
32
36
  elevation?: number;
33
37
  interactive?: boolean;
34
- /** Auto-rotate speed in degrees per frame (default: 0 = off) */
38
+ /**
39
+ * Auto-rotate speed in degrees per frame (default: 0 = off).
40
+ *
41
+ * Rotation pauses while the chart is hovered or focused, and is disabled
42
+ * entirely when the user has `prefers-reduced-motion: reduce` set. Pass 0
43
+ * (or omit) to turn it off.
44
+ */
35
45
  autoRotate?: number;
46
+ /**
47
+ * Accessible label for the chart. Defaults to a description derived from
48
+ * the data keys, e.g. "3D chart of sales by month and region".
49
+ */
50
+ label?: string;
36
51
  children: ReactNode;
37
52
  }
38
53
 
54
+ /** Degrees the camera rotates per arrow-key press. */
55
+ const KEYBOARD_STEP_DEGREES = 10;
56
+
39
57
  function computeDomain(
40
58
  data: Record<string, unknown>[],
41
59
  key: string,
@@ -69,10 +87,16 @@ export function ThreeDChart({
69
87
  elevation: elevationProp = 25,
70
88
  interactive = false,
71
89
  autoRotate = 0,
90
+ label,
72
91
  children,
73
92
  }: ThreeDChartProps) {
74
93
  const containerRef = useRef<HTMLDivElement>(null);
75
94
  const [containerWidth, setContainerWidth] = useState(0);
95
+ const [hovered, setHovered] = useState(false);
96
+ const [focused, setFocused] = useState(false);
97
+ const prefersReducedMotion = useMediaQuery(
98
+ '(prefers-reduced-motion: reduce)',
99
+ );
76
100
  const [camera, setCamera] = useState<Camera>({
77
101
  azimuth: azimuthProp,
78
102
  elevation: elevationProp,
@@ -168,9 +192,48 @@ export function ThreeDChart({
168
192
  dragRef.current = null;
169
193
  }, []);
170
194
 
171
- // Auto-rotation — throttled to ~20fps to avoid overwhelming React with re-renders
195
+ // Keyboard camera: arrow keys rotate yaw/pitch with the same clamps as
196
+ // pointer drag (WCAG 2.1.1 — every pointer operation needs a keyboard
197
+ // equivalent). preventDefault stops the page from scrolling while focused.
198
+ const handleKeyDown = useCallback(
199
+ (e: ReactKeyboardEvent<SVGSVGElement>) => {
200
+ if (!interactive) {
201
+ return;
202
+ }
203
+ let deltaAzimuth = 0;
204
+ let deltaElevation = 0;
205
+ switch (e.key) {
206
+ case 'ArrowLeft':
207
+ deltaAzimuth = -KEYBOARD_STEP_DEGREES;
208
+ break;
209
+ case 'ArrowRight':
210
+ deltaAzimuth = KEYBOARD_STEP_DEGREES;
211
+ break;
212
+ case 'ArrowUp':
213
+ deltaElevation = KEYBOARD_STEP_DEGREES;
214
+ break;
215
+ case 'ArrowDown':
216
+ deltaElevation = -KEYBOARD_STEP_DEGREES;
217
+ break;
218
+ default:
219
+ return;
220
+ }
221
+ e.preventDefault();
222
+ setCamera(prev => ({
223
+ azimuth: prev.azimuth + deltaAzimuth,
224
+ elevation: Math.max(-89, Math.min(89, prev.elevation + deltaElevation)),
225
+ }));
226
+ },
227
+ [interactive],
228
+ );
229
+
230
+ // Auto-rotation — throttled to ~20fps to avoid overwhelming React with
231
+ // re-renders. Disabled under prefers-reduced-motion (WCAG 2.3.3) and
232
+ // paused while the chart is hovered or focused (WCAG 2.2.2).
233
+ const rotationActive =
234
+ autoRotate !== 0 && !prefersReducedMotion && !hovered && !focused;
172
235
  useEffect(() => {
173
- if (autoRotate === 0) {
236
+ if (!rotationActive) {
174
237
  return;
175
238
  }
176
239
  let raf: number;
@@ -187,7 +250,7 @@ export function ThreeDChart({
187
250
  };
188
251
  raf = requestAnimationFrame(tick);
189
252
  return () => cancelAnimationFrame(raf);
190
- }, [autoRotate]);
253
+ }, [autoRotate, rotationActive]);
191
254
 
192
255
  const ctx = useMemo(
193
256
  () => ({
@@ -226,16 +289,31 @@ export function ThreeDChart({
226
289
  userSelect: interactive ? 'none' : undefined,
227
290
  };
228
291
 
292
+ const accessibleLabel = label ?? `3D chart of ${yKey} by ${xKey} and ${zKey}`;
293
+ // Focusable when there is something keyboard users can do with it:
294
+ // rotate the camera (interactive) or pause the auto-rotation.
295
+ const focusable = interactive || autoRotate !== 0;
296
+
229
297
  return (
230
298
  <div ref={containerRef} style={containerStyle}>
231
299
  {containerWidth > 0 && (
232
300
  <svg
301
+ role="img"
302
+ aria-label={accessibleLabel}
303
+ tabIndex={focusable ? 0 : undefined}
233
304
  width={containerWidth}
234
305
  height={height}
306
+ onKeyDown={handleKeyDown}
307
+ onFocus={() => setFocused(true)}
308
+ onBlur={() => setFocused(false)}
309
+ onMouseEnter={() => setHovered(true)}
235
310
  onMouseDown={e => handleStart(e.clientX, e.clientY)}
236
311
  onMouseMove={e => handleMove(e.clientX, e.clientY)}
237
312
  onMouseUp={handleEnd}
238
- onMouseLeave={handleEnd}
313
+ onMouseLeave={() => {
314
+ setHovered(false);
315
+ handleEnd();
316
+ }}
239
317
  onTouchStart={e => {
240
318
  const t = e.touches[0];
241
319
  if (t) {