@astryxdesign/lab 0.7.8-canary.36cde5e → 0.7.8-canary.3b41e2c

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.
Files changed (34) hide show
  1. package/dist/Chart/Chart.d.ts.map +1 -1
  2. package/dist/Chart/Chart.js +8 -7
  3. package/dist/Chart/ChartZoom.d.ts.map +1 -1
  4. package/dist/Chart/ChartZoom.js +10 -24
  5. package/dist/Comments/CommentActions.d.ts +2 -2
  6. package/dist/Comments/CommentActions.d.ts.map +1 -1
  7. package/dist/Comments/CommentActions.js +9 -23
  8. package/dist/Sankey/SankeyChart.d.ts.map +1 -1
  9. package/dist/Sankey/SankeyChart.js +4 -9
  10. package/package.json +5 -5
  11. package/src/Chart/Chart.test.tsx +31 -0
  12. package/src/Chart/Chart.tsx +8 -7
  13. package/src/Chart/ChartZoom.test.tsx +41 -2
  14. package/src/Chart/ChartZoom.tsx +12 -23
  15. package/src/Chat/__tests__/ChatTypingIndicator.a11y.chromium.spec.ts +1 -1
  16. package/src/CircularProgress/__tests__/CircularProgress.a11y.chromium.spec.ts +1 -1
  17. package/src/CircularProgress/__tests__/CircularProgressMotion.a11y.chromium.spec.ts +34 -35
  18. package/src/Comments/CommentActions.tsx +7 -29
  19. package/src/Comments/Comments.test.tsx +32 -0
  20. package/src/Sankey/SankeyChart.test.tsx +31 -0
  21. package/src/Sankey/SankeyChart.tsx +4 -15
  22. package/src/Schedule/__tests__/ScheduleEventPopover.a11y.chromium.spec.ts +1 -1
  23. package/src/Schedule/__tests__/ScheduleMonthChip.a11y.chromium.spec.ts +1 -1
  24. package/src/Schedule/__tests__/ScheduleMonthEventPopover.a11y.chromium.spec.ts +1 -1
  25. package/src/Schedule/__tests__/ScheduleMonthOverflow.a11y.chromium.spec.ts +1 -1
  26. package/src/Schedule/__tests__/ScheduleTimeDirection.a11y.chromium.spec.ts +1 -1
  27. package/src/Schedule/__tests__/ScheduleTimeGridGeometry.a11y.chromium.spec.ts +1 -1
  28. package/src/Schedule/__tests__/ScheduleTimeGridInitialPosition.a11y.chromium.spec.ts +1 -1
  29. package/src/Schedule/__tests__/ScheduleTimeGridLongSpan.a11y.chromium.spec.ts +1 -1
  30. package/src/Schedule/__tests__/ScheduleTimeGridMidnight.a11y.chromium.spec.ts +1 -1
  31. package/src/Schedule/__tests__/ScheduleTimeGridOverlap.a11y.chromium.spec.ts +1 -1
  32. package/src/Schedule/__tests__/ScheduleTimeGridSkippedMidnight.a11y.chromium.spec.ts +1 -1
  33. package/src/SelectableCardGroup/__tests__/SelectableCardGroupRadio.a11y.chromium.spec.ts +1 -1
  34. package/src/__tests__/ReorderGripPinchZoom.a11y.chromium.spec.ts +6 -1
@@ -1 +1 @@
1
- {"version":3,"file":"Chart.d.ts","sourceRoot":"","sources":["../../src/Chart/Chart.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AAEH,OAAO,EAEL,KAAK,SAAS,EAMf,MAAM,OAAO,CAAC;AAMf,OAAO,KAAK,EAAC,WAAW,EAAa,MAAM,SAAS,CAAC;AAErD;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAEjD,MAAM,WAAW,UAAU;IACzB,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAChC;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+BAA+B;IAC/B,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IAC9B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3B;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3B;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6CAA6C;IAC7C,QAAQ,EAAE,SAAS,CAAC;CACrB;AAgBD;;;;;;;;;;;GAWG;AACH,wBAAgB,KAAK,CAAC,EACpB,IAAI,EACJ,IAAI,EACJ,KAAK,EACL,MAAY,EACZ,MAAM,EAAE,cAAc,EACtB,SAAkB,EAClB,OAAO,EAAE,WAAW,EACpB,OAAO,EAAE,WAAW,EACpB,WAAmB,EACnB,KAAK,EACL,QAAQ,GACT,EAAE,UAAU,+BA+OZ"}
1
+ {"version":3,"file":"Chart.d.ts","sourceRoot":"","sources":["../../src/Chart/Chart.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AAEH,OAAO,EAEL,KAAK,SAAS,EAMf,MAAM,OAAO,CAAC;AAMf,OAAO,KAAK,EAAC,WAAW,EAAa,MAAM,SAAS,CAAC;AAErD;;;;;GAKG;AACH,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAEjD,MAAM,WAAW,UAAU;IACzB,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAChC;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+BAA+B;IAC/B,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IAC9B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3B;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3B;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6CAA6C;IAC7C,QAAQ,EAAE,SAAS,CAAC;CACrB;AAgBD;;;;;;;;;;;GAWG;AACH,wBAAgB,KAAK,CAAC,EACpB,IAAI,EACJ,IAAI,EACJ,KAAK,EACL,MAAY,EACZ,MAAM,EAAE,cAAc,EACtB,SAAkB,EAClB,OAAO,EAAE,WAAW,EACpB,OAAO,EAAE,WAAW,EACpB,WAAmB,EACnB,KAAK,EACL,QAAQ,GACT,EAAE,UAAU,+BAgPZ"}
@@ -73,18 +73,19 @@ export function Chart({
73
73
  const containerRef = useRef(null);
74
74
  const svgRef = useRef(null);
75
75
  const [containerWidth, setContainerWidth] = useState(0);
76
- useLayoutEffect(() => {
77
- if (!containerRef.current) {
78
- return;
79
- }
76
+ const attachContainer = useCallback(container => {
77
+ containerRef.current = container;
80
78
  const observer = new ResizeObserver(entries => {
81
79
  const entry = entries[0];
82
80
  if (entry) {
83
81
  setContainerWidth(entry.contentRect.width);
84
82
  }
85
83
  });
86
- observer.observe(containerRef.current);
87
- return () => observer.disconnect();
84
+ observer.observe(container);
85
+ return () => {
86
+ observer.disconnect();
87
+ containerRef.current = null;
88
+ };
88
89
  }, []);
89
90
 
90
91
  // Block scroll/zoom on the container when interactive.
@@ -236,7 +237,7 @@ export function Chart({
236
237
  const accessibleLabel = label ?? `Chart of ${yKeys.join(', ')} by ${xKey}`;
237
238
  const showDataTable = data.length > 0 && data.length * Math.max(yKeys.length, 1) <= MAX_TABLE_POINTS;
238
239
  return /*#__PURE__*/_jsxs("div", {
239
- ref: containerRef,
240
+ ref: attachContainer,
240
241
  style: containerStyle,
241
242
  children: [containerWidth > 0 && /*#__PURE__*/_jsxs("svg", {
242
243
  ref: svgRef,
@@ -1 +1 @@
1
- {"version":3,"file":"ChartZoom.d.ts","sourceRoot":"","sources":["../../src/Chart/ChartZoom.tsx"],"names":[],"mappings":"AAmBA,6CAA6C;AAC7C,MAAM,MAAM,mBAAmB,GAC7B,WAAW,GAAG,UAAU,GAAG,cAAc,GAAG,aAAa,CAAC;AAE5D,MAAM,WAAW,cAAc;IAC7B,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,IAAI,CAAC;IACrD,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,IAAI,CAAC;IAErD;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC;IAEhB;;;;OAIG;IACH,OAAO,CAAC,EAAE,KAAK,GAAG,mBAAmB,CAAC;CACvC;AAiDD,wBAAgB,SAAS,CAAC,EACxB,eAAe,EACf,eAAe,EACf,SAAe,EACf,KAAa,EACb,KAAa,EACb,OAAqB,GACtB,EAAE,cAAc,+BAwShB"}
1
+ {"version":3,"file":"ChartZoom.d.ts","sourceRoot":"","sources":["../../src/Chart/ChartZoom.tsx"],"names":[],"mappings":"AAmBA,6CAA6C;AAC7C,MAAM,MAAM,mBAAmB,GAC7B,WAAW,GAAG,UAAU,GAAG,cAAc,GAAG,aAAa,CAAC;AAE5D,MAAM,WAAW,cAAc;IAC7B,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,IAAI,CAAC;IACrD,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,IAAI,CAAC;IAErD;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,+BAA+B;IAC/B,KAAK,CAAC,EAAE,OAAO,CAAC;IAEhB;;;;OAIG;IACH,OAAO,CAAC,EAAE,KAAK,GAAG,mBAAmB,CAAC;CACvC;AAiDD,wBAAgB,SAAS,CAAC,EACxB,eAAe,EACf,eAAe,EACf,SAAe,EACf,KAAa,EACb,KAAa,EACb,OAAqB,GACtB,EAAE,cAAc,+BA6RhB"}
@@ -7,10 +7,10 @@
7
7
  *
8
8
  * Zoom is aspect-ratio-normalized: both axes scale by the same visual
9
9
  * factor regardless of their domain ranges. Reset button restores
10
- * the initial domains captured on mount.
10
+ * the domains of the first render.
11
11
  */
12
12
 
13
- import { useCallback, useRef, useEffect } from 'react';
13
+ import { useCallback, useRef, useState } from 'react';
14
14
  import { createPortal } from 'react-dom';
15
15
  import { IconButton } from '@astryxdesign/core/IconButton';
16
16
  import { useChart } from "./ChartContext.js";
@@ -77,20 +77,10 @@ export function ChartZoom({
77
77
  yScale,
78
78
  svgRef
79
79
  } = useChart();
80
-
81
- // Capture initial domains on mount for reset
82
- const initialDomainsRef = useRef(null);
83
- useEffect(() => {
84
- if (initialDomainsRef.current) {
85
- return;
86
- } // only capture once
87
- const xDomain = isBandScale(xScale) ? null : xScale.domain();
88
- const yDomain = yScale.domain();
89
- initialDomainsRef.current = {
90
- x: xDomain,
91
- y: yDomain
92
- };
93
- }, [xScale, yScale]);
80
+ const [initialDomains] = useState(() => ({
81
+ x: isBandScale(xScale) ? null : xScale.domain(),
82
+ y: yScale.domain()
83
+ }));
94
84
  const dragRef = useRef(null);
95
85
  const pointersRef = useRef(new Map());
96
86
  const pinchRef = useRef(null);
@@ -219,15 +209,11 @@ export function ChartZoom({
219
209
  }
220
210
  }, []);
221
211
  const handleReset = useCallback(() => {
222
- const init = initialDomainsRef.current;
223
- if (!init) {
224
- return;
225
- }
226
- if (init.x) {
227
- onXDomainChange?.(init.x);
212
+ if (initialDomains.x) {
213
+ onXDomainChange?.(initialDomains.x);
228
214
  }
229
- onYDomainChange?.(init.y);
230
- }, [onXDomainChange, onYDomainChange]);
215
+ onYDomainChange?.(initialDomains.y);
216
+ }, [initialDomains, onXDomainChange, onYDomainChange]);
231
217
 
232
218
  // Toolbar portal target
233
219
  const portalTarget = svgRef.current?.parentElement;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @file CommentActions.tsx
3
- * @input Uses React, React DOM flushSync, StyleX, IconButton, Button, Tooltip,
4
- * ToggleButton, OverflowList, DropdownMenu, Popover, Text
3
+ * @input Uses React, React DOM flushSync, StyleX, useClipboard, IconButton,
4
+ * Button, Tooltip, ToggleButton, OverflowList, DropdownMenu, Popover, Text
5
5
  * @output Exports CommentActions (a comment's header actions), ReactionSummary
6
6
  * (its reaction toggles), and the action-lookup helpers rows use for focus
7
7
  * hand-offs
@@ -1 +1 @@
1
- {"version":3,"file":"CommentActions.d.ts","sourceRoot":"","sources":["../../src/Comments/CommentActions.tsx"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AAEH,OAAO,EAA2C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAI/E,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,iCAAiC,CAAC;AAazC,OAAO,EAAkB,KAAK,UAAU,EAAC,MAAM,SAAS,CAAC;AACzD,OAAO,KAAK,EAAkB,qBAAqB,EAAC,MAAM,SAAS,CAAC;AAoBpE,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,GAAG,MAAM,CAAC;AAEhF,oEAAoE;AACpE,MAAM,MAAM,aAAa,GAAG;IAAC,IAAI,EAAE,SAAS,GAAG,QAAQ,CAAC;IAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAAC,CAAC;AAE9E;;sBAEsB;AACtB,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,WAAW,GAAG,IAAI,EACxB,MAAM,EAAE,eAAe,GACtB,WAAW,GAAG,IAAI,CAMpB;AAED,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,WAAW,GAAG,IAAI,GACvB,WAAW,GAAG,IAAI,CAIpB;AAiCD,kFAAkF;AAClF,wBAAgB,eAAe,CAAC,EAC9B,QAAQ,EACR,OAAO,EACP,MAAM,EACN,OAAO,GACR,EAAE;IACD,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,UAAU,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACrD,sCA0CA;AAkDD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,EAC7B,iBAAiB,EACjB,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,MAAM,EACN,SAAS,EACT,SAAS,EACT,YAAY,EACZ,OAAO,EACP,OAAO,EACP,OAAO,EACP,aAAa,GACd,EAAE;IACD,iBAAiB,CAAC,EAAE,SAAS,CAAC;IAC9B,WAAW,EAAE,OAAO,CAAC;IACrB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,UAAU,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,sDAAsD;IACtD,SAAS,EAAE,SAAS,kBAAkB,EAAE,CAAC;IACzC,SAAS,EAAE,MAAM,CAAC;IAClB,sEAAsE;IACtE,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IACxC,OAAO,EAAE,CACP,OAAO,EAAE,UAAU,EACnB,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,qBAAqB,KAC3B,IAAI,CAAC;IACV,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,EAAE,SAAS,qBAAqB,EAAE,CAAC;IAC1C,aAAa,CAAC,EAAE,aAAa,CAAC;CAC/B,+BA6RA"}
1
+ {"version":3,"file":"CommentActions.d.ts","sourceRoot":"","sources":["../../src/Comments/CommentActions.tsx"],"names":[],"mappings":"AAIA;;;;;;;;GAQG;AAEH,OAAO,EAAgC,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAIpE,OAAO,EAEL,KAAK,kBAAkB,EACxB,MAAM,iCAAiC,CAAC;AAczC,OAAO,EAAkB,KAAK,UAAU,EAAC,MAAM,SAAS,CAAC;AACzD,OAAO,KAAK,EAAkB,qBAAqB,EAAC,MAAM,SAAS,CAAC;AAmBpE,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,GAAG,MAAM,CAAC;AAEhF,oEAAoE;AACpE,MAAM,MAAM,aAAa,GAAG;IAAC,IAAI,EAAE,SAAS,GAAG,QAAQ,CAAC;IAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAAC,CAAC;AAE9E;;sBAEsB;AACtB,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,WAAW,GAAG,IAAI,EACxB,MAAM,EAAE,eAAe,GACtB,WAAW,GAAG,IAAI,CAMpB;AAED,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,WAAW,GAAG,IAAI,GACvB,WAAW,GAAG,IAAI,CAIpB;AAiCD,kFAAkF;AAClF,wBAAgB,eAAe,CAAC,EAC9B,QAAQ,EACR,OAAO,EACP,MAAM,EACN,OAAO,GACR,EAAE;IACD,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,UAAU,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CACrD,sCA0CA;AAkDD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,EAC7B,iBAAiB,EACjB,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,MAAM,EACN,SAAS,EACT,SAAS,EACT,YAAY,EACZ,OAAO,EACP,OAAO,EACP,OAAO,EACP,aAAa,GACd,EAAE;IACD,iBAAiB,CAAC,EAAE,SAAS,CAAC;IAC9B,WAAW,EAAE,OAAO,CAAC;IACrB,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,UAAU,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,sDAAsD;IACtD,SAAS,EAAE,SAAS,kBAAkB,EAAE,CAAC;IACzC,SAAS,EAAE,MAAM,CAAC;IAClB,sEAAsE;IACtE,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IACxC,OAAO,EAAE,CACP,OAAO,EAAE,UAAU,EACnB,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,qBAAqB,KAC3B,IAAI,CAAC;IACV,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,EAAE,SAAS,qBAAqB,EAAE,CAAC;IAC1C,aAAa,CAAC,EAAE,aAAa,CAAC;CAC/B,+BAuQA"}
@@ -4,18 +4,19 @@
4
4
 
5
5
  /**
6
6
  * @file CommentActions.tsx
7
- * @input Uses React, React DOM flushSync, StyleX, IconButton, Button, Tooltip,
8
- * ToggleButton, OverflowList, DropdownMenu, Popover, Text
7
+ * @input Uses React, React DOM flushSync, StyleX, useClipboard, IconButton,
8
+ * Button, Tooltip, ToggleButton, OverflowList, DropdownMenu, Popover, Text
9
9
  * @output Exports CommentActions (a comment's header actions), ReactionSummary
10
10
  * (its reaction toggles), and the action-lookup helpers rows use for focus
11
11
  * hand-offs
12
12
  * @position Internal parts of a comment row in the Comments family
13
13
  */
14
- import { useCallback, useEffect, useRef, useState } from 'react';
14
+ import { useCallback, useRef, useState } from 'react';
15
15
  import { flushSync } from 'react-dom';
16
16
  import * as stylex from '@stylexjs/stylex';
17
17
  import { Button } from '@astryxdesign/core/Button';
18
18
  import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
19
+ import { useClipboard } from '@astryxdesign/core/hooks';
19
20
  import { Icon } from '@astryxdesign/core/Icon';
20
21
  import { IconButton } from '@astryxdesign/core/IconButton';
21
22
  import { HStack, VStack } from '@astryxdesign/core/Layout';
@@ -31,7 +32,6 @@ import { reactionDescription } from "./i18n.js";
31
32
  import { CommentIcon } from "./icons.js";
32
33
  import { commentAnchorId } from "./utils.js";
33
34
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
34
- const LINK_COPIED_RESET_MS = 2_000;
35
35
  const MORE_MENU_MIN_WIDTH = `calc(${spacingVars['--spacing-8']} * 5)`;
36
36
  const styles = {
37
37
  actionsList: {
@@ -218,7 +218,10 @@ export function CommentActions({
218
218
  resolveAction
219
219
  }) {
220
220
  const t = useTranslator();
221
- const [isLinkCopied, setIsLinkCopied] = useState(false);
221
+ const {
222
+ copy: copyToClipboard,
223
+ isCopied: isLinkCopied
224
+ } = useClipboard();
222
225
  const [isLinkTooltipOpen, setIsLinkTooltipOpen] = useState(false);
223
226
  const [isPickerOpen, setIsPickerOpen] = useState(false);
224
227
  const [collapsed, setCollapsed] = useState(NO_COLLAPSED_ACTIONS);
@@ -236,24 +239,7 @@ export function CommentActions({
236
239
  }) : /*#__PURE__*/_jsx(CommentIcon, {
237
240
  name: "copyLink"
238
241
  });
239
- const copyCommentLink = async () => {
240
- if (navigator.clipboard == null) {
241
- return;
242
- }
243
- try {
244
- await navigator.clipboard.writeText(new URL(href, window.location.href).href);
245
- setIsLinkCopied(true);
246
- } catch {
247
- setIsLinkCopied(false);
248
- }
249
- };
250
- useEffect(() => {
251
- if (!isLinkCopied) {
252
- return;
253
- }
254
- const timer = window.setTimeout(() => setIsLinkCopied(false), LINK_COPIED_RESET_MS);
255
- return () => window.clearTimeout(timer);
256
- }, [isLinkCopied]);
242
+ const copyCommentLink = () => copyToClipboard(new URL(href, window.location.href).href);
257
243
  const changePicker = isOpen => {
258
244
  setIsPickerOpen(isOpen);
259
245
  onOpenChange(isOpen);
@@ -1 +1 @@
1
- {"version":3,"file":"SankeyChart.d.ts","sourceRoot":"","sources":["../../src/Sankey/SankeyChart.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AAEH,OAAO,EACL,KAAK,SAAS,EAKf,MAAM,OAAO,CAAC;AAIf,OAAO,KAAK,EAAC,eAAe,EAAE,eAAe,EAAE,YAAY,EAAC,MAAM,SAAS,CAAC;AAE5E,MAAM,WAAW,gBAAgB;IAC/B,uBAAuB;IACvB,KAAK,EAAE,eAAe,EAAE,CAAC;IACzB,uBAAuB;IACvB,KAAK,EAAE,eAAe,EAAE,CAAC;IACzB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,YAAY,EAAE,CAAC;IACzB,wCAAwC;IACxC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yCAAyC;IACzC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,EAAE,SAAS,CAAC;CACrB;AAsDD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,WAAW,CAAC,EAC1B,KAAK,EACL,KAAK,EACL,OAAO,EACP,MAAY,EACZ,SAAc,EACd,OAAY,EACZ,SAAS,EACT,cAAoB,EACpB,KAAsB,EACtB,QAAQ,GACT,EAAE,gBAAgB,+BAsHlB;yBAjIe,WAAW"}
1
+ {"version":3,"file":"SankeyChart.d.ts","sourceRoot":"","sources":["../../src/Sankey/SankeyChart.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAC,KAAK,SAAS,EAAiC,MAAM,OAAO,CAAC;AAIrE,OAAO,KAAK,EAAC,eAAe,EAAE,eAAe,EAAE,YAAY,EAAC,MAAM,SAAS,CAAC;AAE5E,MAAM,WAAW,gBAAgB;IAC/B,uBAAuB;IACvB,KAAK,EAAE,eAAe,EAAE,CAAC;IACzB,uBAAuB;IACvB,KAAK,EAAE,eAAe,EAAE,CAAC;IACzB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,YAAY,EAAE,CAAC;IACzB,wCAAwC;IACxC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yCAAyC;IACzC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qBAAqB;IACrB,QAAQ,EAAE,SAAS,CAAC;CACrB;AAsDD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,WAAW,CAAC,EAC1B,KAAK,EACL,KAAK,EACL,OAAO,EACP,MAAY,EACZ,SAAc,EACd,OAAY,EACZ,SAAS,EACT,cAAoB,EACpB,KAAsB,EACtB,QAAQ,GACT,EAAE,gBAAgB,+BAiHlB;yBA5He,WAAW"}
@@ -13,7 +13,7 @@
13
13
  * small graphs are mirrored in a visually hidden From/To/Value table.
14
14
  */
15
15
 
16
- import { useMemo, useRef, useState, useLayoutEffect } from 'react';
16
+ import { useCallback, useMemo, useState } from 'react';
17
17
  import { VisuallyHidden } from '@astryxdesign/core/VisuallyHidden';
18
18
  import { SankeyProvider } from "./SankeyContext.js";
19
19
  import { computeLayout } from "./layout.js";
@@ -100,18 +100,13 @@ export function SankeyChart({
100
100
  label = 'Sankey chart',
101
101
  children
102
102
  }) {
103
- const containerRef = useRef(null);
104
103
  const [containerWidth, setContainerWidth] = useState(0);
105
- useLayoutEffect(() => {
106
- const el = containerRef.current;
107
- if (!el) {
108
- return;
109
- }
104
+ const observeContainerWidth = useCallback(container => {
110
105
  const ro = new ResizeObserver(entries => {
111
106
  const w = entries[0]?.contentRect.width ?? 0;
112
107
  setContainerWidth(w);
113
108
  });
114
- ro.observe(el);
109
+ ro.observe(container);
115
110
  return () => ro.disconnect();
116
111
  }, []);
117
112
  const colCount = useMemo(() => resolveColumnCount(columns, nodes, links), [columns, nodes, links]);
@@ -149,7 +144,7 @@ export function SankeyChart({
149
144
  const nodeLabelById = useMemo(() => new Map(nodes.map(n => [n.id, n.label || n.id])), [nodes]);
150
145
  const showDataTable = links.length > 0 && links.length <= MAX_TABLE_LINKS;
151
146
  return /*#__PURE__*/_jsxs("div", {
152
- ref: containerRef,
147
+ ref: observeContainerWidth,
153
148
  style: {
154
149
  width: '100%'
155
150
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/lab",
3
- "version": "0.7.8-canary.36cde5e",
3
+ "version": "0.7.8-canary.3b41e2c",
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",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@stylexjs/stylex": ">=0.10.0",
52
- "@astryxdesign/core": "0.7.8-canary.36cde5e",
52
+ "@astryxdesign/core": "0.7.8-canary.3b41e2c",
53
53
  "react": ">=19.0.0",
54
54
  "react-dom": ">=19.0.0"
55
55
  },
@@ -59,9 +59,9 @@
59
59
  "d3-shape": "^3.2.0"
60
60
  },
61
61
  "devDependencies": {
62
- "@astryxdesign/a11y-spec": "0.7.8-canary.36cde5e",
63
- "@astryxdesign/cli": "0.7.8-canary.36cde5e",
64
- "@astryxdesign/interaction-harness": "0.7.8-canary.36cde5e",
62
+ "@astryxdesign/a11y-spec": "0.7.8-canary.3b41e2c",
63
+ "@astryxdesign/cli": "0.7.8-canary.3b41e2c",
64
+ "@astryxdesign/interaction-harness": "0.7.8-canary.3b41e2c",
65
65
  "@babel/cli": "^7.29.7",
66
66
  "@babel/core": "^7.29.7",
67
67
  "@babel/preset-react": "^7.29.7",
@@ -171,6 +171,37 @@ describe('Chart measurement gate', () => {
171
171
  });
172
172
  });
173
173
 
174
+ describe('Chart container observation', () => {
175
+ it('observes its container while mounted and stops on unmount', () => {
176
+ const observed = new Set<Element>();
177
+ vi.stubGlobal(
178
+ 'ResizeObserver',
179
+ class {
180
+ observe(element: Element) {
181
+ observed.add(element);
182
+ }
183
+ unobserve(element: Element) {
184
+ observed.delete(element);
185
+ }
186
+ disconnect() {
187
+ observed.clear();
188
+ }
189
+ },
190
+ );
191
+ const {container, unmount} = render(
192
+ <Chart data={data} xKey="month" yKeys={['revenue']}>
193
+ <g />
194
+ </Chart>,
195
+ );
196
+ const chartContainer = container.firstElementChild;
197
+ expect(chartContainer).not.toBeNull();
198
+ expect(observed.has(chartContainer!)).toBe(true);
199
+
200
+ unmount();
201
+ expect(observed.size).toBe(0);
202
+ });
203
+ });
204
+
174
205
  describe('useChart', () => {
175
206
  it('throws a clear error when used outside <Chart>', () => {
176
207
  // React reports the uncaught render error via console.error; keep the
@@ -134,18 +134,19 @@ export function Chart({
134
134
  const svgRef = useRef<SVGSVGElement>(null);
135
135
  const [containerWidth, setContainerWidth] = useState(0);
136
136
 
137
- useLayoutEffect(() => {
138
- if (!containerRef.current) {
139
- return;
140
- }
137
+ const attachContainer = useCallback((container: HTMLDivElement) => {
138
+ containerRef.current = container;
141
139
  const observer = new ResizeObserver(entries => {
142
140
  const entry = entries[0];
143
141
  if (entry) {
144
142
  setContainerWidth(entry.contentRect.width);
145
143
  }
146
144
  });
147
- observer.observe(containerRef.current);
148
- return () => observer.disconnect();
145
+ observer.observe(container);
146
+ return () => {
147
+ observer.disconnect();
148
+ containerRef.current = null;
149
+ };
149
150
  }, []);
150
151
 
151
152
  // Block scroll/zoom on the container when interactive.
@@ -320,7 +321,7 @@ export function Chart({
320
321
  data.length * Math.max(yKeys.length, 1) <= MAX_TABLE_POINTS;
321
322
 
322
323
  return (
323
- <div ref={containerRef} style={containerStyle}>
324
+ <div ref={attachContainer} style={containerStyle}>
324
325
  {containerWidth > 0 && (
325
326
  <svg
326
327
  ref={svgRef}
@@ -3,14 +3,15 @@
3
3
  /**
4
4
  * @file ChartZoom.test.tsx
5
5
  * @input Renders ChartZoom inside Chart with a stubbed ResizeObserver
6
- * @output Render-smoke coverage for zoom: capture surface, portaled toolbar, toolbar opt-out.
6
+ * @output Render-smoke coverage for zoom: capture surface, portaled toolbar, toolbar opt-out,
7
+ * and Reset back to the first render's domains.
7
8
  * Wheel/pinch gestures need createSVGPoint/getScreenCTM, which do not exist in
8
9
  * jsdom, so they are intentionally out of scope here. (#4295)
9
10
  * @position Colocated test for ChartZoom.tsx
10
11
  */
11
12
 
12
13
  import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';
13
- import {render, screen, act} from '@testing-library/react';
14
+ import {render, screen, act, fireEvent} from '@testing-library/react';
14
15
  import {Chart} from './Chart';
15
16
  import {ChartZoom} from './ChartZoom';
16
17
 
@@ -87,6 +88,44 @@ describe('ChartZoom', () => {
87
88
  ).toBeInTheDocument();
88
89
  });
89
90
 
91
+ it("resets to the first render's domains after the caller changes them", () => {
92
+ const onXDomainChange = vi.fn();
93
+ const onYDomainChange = vi.fn();
94
+ function ZoomedChart({
95
+ xDomain,
96
+ yDomain,
97
+ }: {
98
+ xDomain: [number, number];
99
+ yDomain: [number, number];
100
+ }) {
101
+ return (
102
+ <Chart
103
+ data={data}
104
+ xKey="x"
105
+ yKeys={['y']}
106
+ xDomain={xDomain}
107
+ yDomain={yDomain}
108
+ interactive>
109
+ <ChartZoom
110
+ onXDomainChange={onXDomainChange}
111
+ onYDomainChange={onYDomainChange}
112
+ />
113
+ </Chart>
114
+ );
115
+ }
116
+ const {rerender} = render(
117
+ <ZoomedChart xDomain={[0, 2]} yDomain={[0, 4]} />,
118
+ );
119
+ reportWidth(464);
120
+ reportWidth(465);
121
+
122
+ rerender(<ZoomedChart xDomain={[0.5, 1.5]} yDomain={[1, 3]} />);
123
+ fireEvent.click(screen.getByRole('button', {name: 'Reset zoom'}));
124
+
125
+ expect(onXDomainChange).toHaveBeenLastCalledWith([0, 2]);
126
+ expect(onYDomainChange).toHaveBeenLastCalledWith([0, 4]);
127
+ });
128
+
90
129
  it('renders no toolbar when toolbar is false', () => {
91
130
  render(
92
131
  <Chart data={data} xKey="x" yKeys={['y']} interactive>
@@ -7,10 +7,10 @@
7
7
  *
8
8
  * Zoom is aspect-ratio-normalized: both axes scale by the same visual
9
9
  * factor regardless of their domain ranges. Reset button restores
10
- * the initial domains captured on mount.
10
+ * the domains of the first render.
11
11
  */
12
12
 
13
- import {type CSSProperties, useCallback, useRef, useEffect} from 'react';
13
+ import {type CSSProperties, useCallback, useRef, useState} from 'react';
14
14
  import {createPortal} from 'react-dom';
15
15
  import {IconButton} from '@astryxdesign/core/IconButton';
16
16
  import {useChart} from './ChartContext';
@@ -101,22 +101,15 @@ export function ChartZoom({
101
101
  }: ChartZoomProps) {
102
102
  const {width, height, xScale, yScale, svgRef} = useChart();
103
103
 
104
- // Capture initial domains on mount for reset
105
- const initialDomainsRef = useRef<{
104
+ const [initialDomains] = useState<{
106
105
  x: [number, number] | null;
107
106
  y: [number, number];
108
- } | null>(null);
109
-
110
- useEffect(() => {
111
- if (initialDomainsRef.current) {
112
- return;
113
- } // only capture once
114
- const xDomain = isBandScale(xScale)
107
+ }>(() => ({
108
+ x: isBandScale(xScale)
115
109
  ? null
116
- : ((xScale as ScaleLinear<number, number>).domain() as [number, number]);
117
- const yDomain = yScale.domain() as [number, number];
118
- initialDomainsRef.current = {x: xDomain, y: yDomain};
119
- }, [xScale, yScale]);
110
+ : ((xScale as ScaleLinear<number, number>).domain() as [number, number]),
111
+ y: yScale.domain() as [number, number],
112
+ }));
120
113
 
121
114
  const dragRef = useRef<{
122
115
  startX: number;
@@ -303,15 +296,11 @@ export function ChartZoom({
303
296
  }, []);
304
297
 
305
298
  const handleReset = useCallback(() => {
306
- const init = initialDomainsRef.current;
307
- if (!init) {
308
- return;
309
- }
310
- if (init.x) {
311
- onXDomainChange?.(init.x);
299
+ if (initialDomains.x) {
300
+ onXDomainChange?.(initialDomains.x);
312
301
  }
313
- onYDomainChange?.(init.y);
314
- }, [onXDomainChange, onYDomainChange]);
302
+ onYDomainChange?.(initialDomains.y);
303
+ }, [initialDomains, onXDomainChange, onYDomainChange]);
315
304
 
316
305
  // Toolbar portal target
317
306
  const portalTarget = svgRef.current?.parentElement;
@@ -9,7 +9,7 @@ import {
9
9
  type CDPSession,
10
10
  type Locator,
11
11
  type Page,
12
- } from '@playwright/test';
12
+ } from '@astryxdesign/a11y-spec/playwright';
13
13
  import {
14
14
  STATUS_MESSAGE_PATTERN,
15
15
  blockingResults,
@@ -14,7 +14,7 @@ import {
14
14
  type CDPSession,
15
15
  type Locator,
16
16
  type Page,
17
- } from '@playwright/test';
17
+ } from '@astryxdesign/a11y-spec/playwright';
18
18
  import {
19
19
  PROGRESSBAR_PATTERN,
20
20
  blockingResults,
@@ -19,7 +19,7 @@
19
19
 
20
20
  import fs from 'node:fs';
21
21
  import path from 'node:path';
22
- import {expect, test, type Page} from '@playwright/test';
22
+ import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright';
23
23
  import {
24
24
  DEFAULT_STORYBOOK_DIR,
25
25
  serveStorybook,
@@ -28,8 +28,10 @@ import {
28
28
  import {
29
29
  busyWindowProof,
30
30
  CONTROL_RGB,
31
+ countBetween,
31
32
  inPresentationOrder,
32
33
  startBusyControl,
34
+ traceRendering,
33
35
  trackMarker,
34
36
  type TrackedFrame,
35
37
  } from '@astryxdesign/interaction-harness';
@@ -413,50 +415,47 @@ test('a value change keeps moving while a script holds the main thread', async (
413
415
  ).toBeLessThanOrEqual(6);
414
416
  });
415
417
 
416
- test('a value change lays out and paints nothing per frame', async ({
417
- page,
418
- browser,
419
- }) => {
418
+ test('a value change lays out and paints nothing per frame', async ({page}) => {
420
419
  await open(page, 20);
421
- // Stretch the theme's duration so the trace sits wholly inside the change.
420
+ // Stretch the theme's duration so the second judged below sits wholly
421
+ // inside the change.
422
422
  await page.evaluate(() =>
423
423
  (
424
424
  document.querySelector('.astryx-circular-progress-fill') as HTMLElement
425
425
  ).style.setProperty('--duration-medium', '3000ms'),
426
426
  );
427
- await setArgs(page, {value: 80});
428
- await page.waitForTimeout(200);
429
- await browser.startTracing(page, {
430
- categories: ['devtools.timeline', 'disabled-by-default-devtools.timeline'],
427
+ // Tracing starts before the change, so however long it takes to start, it
428
+ // takes none of the change's time. The judged second starts two frames
429
+ // after the change's animations are ready, 200 ms in.
430
+ let from = 0;
431
+ let to = 0;
432
+ let moving = false;
433
+ const trace = await traceRendering(page, async () => {
434
+ await setArgs(page, {value: 80});
435
+ await page.waitForTimeout(200);
436
+ from = await page.evaluate(async () => {
437
+ await Promise.all(document.getAnimations().map(async a => a.ready));
438
+ await new Promise(resolve =>
439
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
440
+ );
441
+ return performance.timeOrigin + performance.now();
442
+ });
443
+ await page.waitForTimeout(1000);
444
+ [to, moving] = await page.evaluate(
445
+ () =>
446
+ [
447
+ performance.timeOrigin + performance.now(),
448
+ document.getAnimations().some(a => a.playState === 'running'),
449
+ ] as const,
450
+ );
431
451
  });
432
- await page.waitForTimeout(1000);
433
- const moving = await page.evaluate(() =>
434
- document.getAnimations().some(a => a.playState === 'running'),
435
- );
436
- const trace = JSON.parse((await browser.stopTracing()).toString()) as {
437
- traceEvents: {
438
- name: string;
439
- ph: string;
440
- pid: number;
441
- tid: number;
442
- args?: {name?: string};
443
- }[];
444
- };
445
- const mainThreads = new Set(
446
- trace.traceEvents
447
- .filter(e => e.ph === 'M' && e.name === 'thread_name')
448
- .filter(e => e.args?.name === 'CrRendererMain')
449
- .map(e => `${e.pid}:${e.tid}`),
450
- );
451
- const count = (name: string) =>
452
- trace.traceEvents.filter(
453
- e => e.name === name && mainThreads.has(`${e.pid}:${e.tid}`),
454
- ).length;
452
+ const counts = countBetween(trace, from, to);
455
453
  // A full second of the change: nothing lays out or paints. A
456
454
  // stroke-dashoffset transition repainted every frame (36 paints in 410 ms).
455
+ expect(to - from).toBeGreaterThanOrEqual(1000);
457
456
  expect(moving).toBe(true);
458
- expect(count('Layout')).toBe(0);
459
- expect(count('Paint')).toBe(0);
457
+ expect(counts.Layout ?? 0).toBe(0);
458
+ expect(counts.Paint ?? 0).toBe(0);
460
459
  });
461
460
 
462
461
  test('at rest the ring draws what the stroked circle drew', async ({
@@ -4,15 +4,15 @@
4
4
 
5
5
  /**
6
6
  * @file CommentActions.tsx
7
- * @input Uses React, React DOM flushSync, StyleX, IconButton, Button, Tooltip,
8
- * ToggleButton, OverflowList, DropdownMenu, Popover, Text
7
+ * @input Uses React, React DOM flushSync, StyleX, useClipboard, IconButton,
8
+ * Button, Tooltip, ToggleButton, OverflowList, DropdownMenu, Popover, Text
9
9
  * @output Exports CommentActions (a comment's header actions), ReactionSummary
10
10
  * (its reaction toggles), and the action-lookup helpers rows use for focus
11
11
  * hand-offs
12
12
  * @position Internal parts of a comment row in the Comments family
13
13
  */
14
14
 
15
- import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react';
15
+ import {useCallback, useRef, useState, type ReactNode} from 'react';
16
16
  import {flushSync} from 'react-dom';
17
17
  import * as stylex from '@stylexjs/stylex';
18
18
  import {Button} from '@astryxdesign/core/Button';
@@ -20,6 +20,7 @@ import {
20
20
  DropdownMenu,
21
21
  type DropdownMenuOption,
22
22
  } from '@astryxdesign/core/DropdownMenu';
23
+ import {useClipboard} from '@astryxdesign/core/hooks';
23
24
  import {Icon} from '@astryxdesign/core/Icon';
24
25
  import {IconButton} from '@astryxdesign/core/IconButton';
25
26
  import {HStack, VStack} from '@astryxdesign/core/Layout';
@@ -35,7 +36,6 @@ import {CommentIcon} from './icons';
35
36
  import {commentAnchorId, type AnyComment} from './utils';
36
37
  import type {CommentReaction, CommentReactionOption} from './types';
37
38
 
38
- const LINK_COPIED_RESET_MS = 2_000;
39
39
  const MORE_MENU_MIN_WIDTH = `calc(${spacingVars['--spacing-8']} * 5)`;
40
40
 
41
41
  const styles = stylex.create({
@@ -256,7 +256,7 @@ export function CommentActions({
256
256
  resolveAction?: ResolveAction;
257
257
  }) {
258
258
  const t = useTranslator();
259
- const [isLinkCopied, setIsLinkCopied] = useState(false);
259
+ const {copy: copyToClipboard, isCopied: isLinkCopied} = useClipboard();
260
260
  const [isLinkTooltipOpen, setIsLinkTooltipOpen] = useState(false);
261
261
  const [isPickerOpen, setIsPickerOpen] = useState(false);
262
262
  const [collapsed, setCollapsed] = useState(NO_COLLAPSED_ACTIONS);
@@ -276,30 +276,8 @@ export function CommentActions({
276
276
  <CommentIcon name="copyLink" />
277
277
  );
278
278
 
279
- const copyCommentLink = async () => {
280
- if (navigator.clipboard == null) {
281
- return;
282
- }
283
- try {
284
- await navigator.clipboard.writeText(
285
- new URL(href, window.location.href).href,
286
- );
287
- setIsLinkCopied(true);
288
- } catch {
289
- setIsLinkCopied(false);
290
- }
291
- };
292
-
293
- useEffect(() => {
294
- if (!isLinkCopied) {
295
- return;
296
- }
297
- const timer = window.setTimeout(
298
- () => setIsLinkCopied(false),
299
- LINK_COPIED_RESET_MS,
300
- );
301
- return () => window.clearTimeout(timer);
302
- }, [isLinkCopied]);
279
+ const copyCommentLink = () =>
280
+ copyToClipboard(new URL(href, window.location.href).href);
303
281
 
304
282
  const changePicker = (isOpen: boolean) => {
305
283
  setIsPickerOpen(isOpen);
@@ -957,6 +957,38 @@ describe('Comments threads', () => {
957
957
  ).toBeTruthy();
958
958
  });
959
959
 
960
+ it('keeps the copied label for the full time after a second copy', async () => {
961
+ vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']});
962
+ const wait = (ms: number) => act(() => vi.advanceTimersByTimeAsync(ms));
963
+ const copyButton = (name: string) => screen.getByRole('button', {name});
964
+ try {
965
+ render(
966
+ <Sized>
967
+ <Comments
968
+ comments={[{...COMMENTS[0], replies: []}]}
969
+ currentUser={OTHER}
970
+ />
971
+ </Sized>,
972
+ );
973
+ await click(copyButton('Copy comment link'));
974
+ await wait(0);
975
+ expect(copyButton('Comment link copied')).toBeTruthy();
976
+
977
+ await wait(1500);
978
+ await click(copyButton('Comment link copied'));
979
+ await wait(0);
980
+ // 2.5 s after the first copy and 1 s after the second one.
981
+ await wait(1000);
982
+ expect(writeText).toHaveBeenCalledTimes(2);
983
+ expect(copyButton('Comment link copied')).toBeTruthy();
984
+
985
+ await wait(1000);
986
+ expect(copyButton('Copy comment link')).toBeTruthy();
987
+ } finally {
988
+ vi.useRealTimers();
989
+ }
990
+ });
991
+
960
992
  it('collapses header actions into the more menu when space runs low', async () => {
961
993
  overflowControl.collapsed = new Set(['reply', 'link']);
962
994
  const onSubmit = vi.fn();
@@ -76,6 +76,37 @@ describe('SankeyChart scroll container', () => {
76
76
  });
77
77
  });
78
78
 
79
+ describe('SankeyChart container observation', () => {
80
+ it('observes its container while mounted and stops on unmount', () => {
81
+ const observed = new Set<Element>();
82
+ vi.stubGlobal(
83
+ 'ResizeObserver',
84
+ class {
85
+ observe(element: Element) {
86
+ observed.add(element);
87
+ }
88
+ unobserve(element: Element) {
89
+ observed.delete(element);
90
+ }
91
+ disconnect() {
92
+ observed.clear();
93
+ }
94
+ },
95
+ );
96
+ const {container, unmount} = render(
97
+ <SankeyChart nodes={nodes} links={links}>
98
+ <SankeyNode />
99
+ </SankeyChart>,
100
+ );
101
+ const chartContainer = container.firstElementChild;
102
+ expect(chartContainer).not.toBeNull();
103
+ expect(observed.has(chartContainer!)).toBe(true);
104
+
105
+ unmount();
106
+ expect(observed.size).toBe(0);
107
+ });
108
+ });
109
+
79
110
  describe('SankeyChart accessible name', () => {
80
111
  it('names the svg when the chart does not scroll', () => {
81
112
  render(
@@ -13,13 +13,7 @@
13
13
  * small graphs are mirrored in a visually hidden From/To/Value table.
14
14
  */
15
15
 
16
- import {
17
- type ReactNode,
18
- useMemo,
19
- useRef,
20
- useState,
21
- useLayoutEffect,
22
- } from 'react';
16
+ import {type ReactNode, useCallback, useMemo, useState} from 'react';
23
17
  import {VisuallyHidden} from '@astryxdesign/core/VisuallyHidden';
24
18
  import {SankeyProvider} from './SankeyContext';
25
19
  import {computeLayout} from './layout';
@@ -154,19 +148,14 @@ export function SankeyChart({
154
148
  label = 'Sankey chart',
155
149
  children,
156
150
  }: SankeyChartProps) {
157
- const containerRef = useRef<HTMLDivElement>(null);
158
151
  const [containerWidth, setContainerWidth] = useState(0);
159
152
 
160
- useLayoutEffect(() => {
161
- const el = containerRef.current;
162
- if (!el) {
163
- return;
164
- }
153
+ const observeContainerWidth = useCallback((container: HTMLDivElement) => {
165
154
  const ro = new ResizeObserver(entries => {
166
155
  const w = entries[0]?.contentRect.width ?? 0;
167
156
  setContainerWidth(w);
168
157
  });
169
- ro.observe(el);
158
+ ro.observe(container);
170
159
  return () => ro.disconnect();
171
160
  }, []);
172
161
 
@@ -225,7 +214,7 @@ export function SankeyChart({
225
214
  const showDataTable = links.length > 0 && links.length <= MAX_TABLE_LINKS;
226
215
 
227
216
  return (
228
- <div ref={containerRef} style={{width: '100%'}}>
217
+ <div ref={observeContainerWidth} style={{width: '100%'}}>
229
218
  {ctx && (
230
219
  <div
231
220
  role={needsScroll ? 'group' : undefined}
@@ -15,7 +15,7 @@
15
15
  * focus ring, or produce a touch tap.
16
16
  */
17
17
 
18
- import {expect, test, type Page} from '@playwright/test';
18
+ import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright';
19
19
  import pixelmatch from 'pixelmatch';
20
20
  // @ts-expect-error -- pngjs ships no declarations; runtime support is pinned.
21
21
  import {PNG} from 'pngjs';
@@ -11,7 +11,7 @@
11
11
  * layout, so it cannot tell whether a time fits beside a title.
12
12
  */
13
13
 
14
- import {expect, test, type Page} from '@playwright/test';
14
+ import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright';
15
15
  import {
16
16
  finishEvidence,
17
17
  openStory,
@@ -13,7 +13,7 @@
13
13
  * cannot show a native popover, return focus through it, or lay out chips.
14
14
  */
15
15
 
16
- import {expect, test, type Page} from '@playwright/test';
16
+ import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright';
17
17
  import {
18
18
  finishEvidence,
19
19
  openStory,
@@ -16,7 +16,7 @@
16
16
  * through it, or paint a focus ring.
17
17
  */
18
18
 
19
- import {expect, test, type Page} from '@playwright/test';
19
+ import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright';
20
20
  import pixelmatch from 'pixelmatch';
21
21
  // @ts-expect-error -- pngjs ships no declarations; runtime support is pinned.
22
22
  import {PNG} from 'pngjs';
@@ -12,7 +12,7 @@
12
12
  * algorithm, so only a browser shows the painted order.
13
13
  */
14
14
 
15
- import {expect, test, type Page} from '@playwright/test';
15
+ import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright';
16
16
  import {
17
17
  finishEvidence,
18
18
  openStory,
@@ -11,7 +11,7 @@
11
11
  * ownership are checkable only here.
12
12
  */
13
13
 
14
- import {expect, test} from '@playwright/test';
14
+ import {expect, test} from '@astryxdesign/a11y-spec/playwright';
15
15
  import pixelmatch from 'pixelmatch';
16
16
  // @ts-expect-error -- pngjs ships no declarations; runtime support is pinned.
17
17
  import {PNG} from 'pngjs';
@@ -12,7 +12,7 @@
12
12
  * the viewport really lands on.
13
13
  */
14
14
 
15
- import {expect, test, type Page} from '@playwright/test';
15
+ import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright';
16
16
  import {
17
17
  DAY,
18
18
  finishEvidence,
@@ -11,7 +11,7 @@
11
11
  * no layout, so it cannot measure where a span paints or how wide a block is.
12
12
  */
13
13
 
14
- import {expect, test, type Page} from '@playwright/test';
14
+ import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright';
15
15
  import {
16
16
  finishEvidence,
17
17
  NARROW,
@@ -9,7 +9,7 @@
9
9
  * dropped) at the midnight boundary
10
10
  */
11
11
 
12
- import {expect, test} from '@playwright/test';
12
+ import {expect, test} from '@astryxdesign/a11y-spec/playwright';
13
13
  import {
14
14
  finishEvidence,
15
15
  openStory,
@@ -10,7 +10,7 @@
10
10
  * is a question only a layout engine can answer.
11
11
  */
12
12
 
13
- import {expect, test} from '@playwright/test';
13
+ import {expect, test} from '@astryxdesign/a11y-spec/playwright';
14
14
  import {
15
15
  blockOverlap,
16
16
  finishEvidence,
@@ -10,7 +10,7 @@
10
10
  * dropped) across a skipped midnight
11
11
  */
12
12
 
13
- import {expect, test, type Page} from '@playwright/test';
13
+ import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright';
14
14
  import {
15
15
  finishEvidence,
16
16
  record,
@@ -10,7 +10,7 @@
10
10
  * jsdom has no native radio-group keyboard, so only a real engine shows it.
11
11
  */
12
12
 
13
- import {expect, test, type Page} from '@playwright/test';
13
+ import {expect, test, type Page} from '@astryxdesign/a11y-spec/playwright';
14
14
  import {
15
15
  DEFAULT_STORYBOOK_DIR,
16
16
  serveStorybook,
@@ -13,7 +13,12 @@
13
13
  * (packages/lab/src/reorderStyles.ts)
14
14
  */
15
15
 
16
- import {expect, test, type CDPSession, type Page} from '@playwright/test';
16
+ import {
17
+ expect,
18
+ test,
19
+ type CDPSession,
20
+ type Page,
21
+ } from '@astryxdesign/a11y-spec/playwright';
17
22
  import {
18
23
  DEFAULT_STORYBOOK_DIR,
19
24
  serveStorybook,