@trackunit/react-components 3.2.4 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs.js CHANGED
@@ -1490,6 +1490,59 @@ const cvaSpinnerContainer = cssClassVarianceUtilities.cva({
1490
1490
  });
1491
1491
  const cvaSpinnerLabel = cssClassVarianceUtilities.cva({ base: ["self-center", "text-center", "text-current"] });
1492
1492
 
1493
+ /**
1494
+ * Cycle duration, in milliseconds, matching Tailwind's `animate-spin` utility
1495
+ * (`spin 1s linear infinite`) — the animation `Spinner` uses.
1496
+ */
1497
+ const DEFAULT_SPIN_DURATION_MS = 1000;
1498
+ /**
1499
+ * Synchronizes an `animate-spin` element's rotation phase to the shared wall clock, so every
1500
+ * element using this hook — anywhere on the page, including across iframe boundaries — renders
1501
+ * at the same point in its spin cycle regardless of when its DOM node, document, or iframe was
1502
+ * created.
1503
+ *
1504
+ * On mount, in a layout effect, it writes a negative `animation-delay` —
1505
+ * `-(Date.now() % durationMs)` — directly onto `ref.current`. A negative delay skips the
1506
+ * animation forward by that amount, effectively pinning the element to `now`'s position in the
1507
+ * cycle instead of starting fresh at phase 0. Because `Date.now()` is the same system clock
1508
+ * everywhere, this keeps `animate-spin` elements visually synchronized across the whole page with
1509
+ * no message-passing required.
1510
+ *
1511
+ * The sample is taken in a layout effect rather than during render: React's render phase can be
1512
+ * time-sliced across several elements (a large table/page mount, a Suspense-covered subtree,
1513
+ * etc.), so elements rendered in the same commit could otherwise sample the clock many
1514
+ * milliseconds apart and end up visibly out of phase with each other. Layout effects for a given
1515
+ * commit all run together, synchronously, right after the DOM mutations land and before paint —
1516
+ * so every element written to in the same commit gets, for practical purposes, the same
1517
+ * timestamp.
1518
+ *
1519
+ * The delay is written once, on mount, and then held fixed for the life of the element (the
1520
+ * effect only re-runs if `ref` or `durationMs` change identity, which does not happen for a
1521
+ * stable ref and a constant duration). Recomputing it on every render would be actively harmful:
1522
+ * once a CSS animation is running, changing `animation-delay` does not re-anchor its phase to the
1523
+ * new value — it recombines the new delay with the animation's own already-elapsed local clock,
1524
+ * which desyncs the element instead of correcting it.
1525
+ *
1526
+ * Known trade-off: an element that is `display: none` at mount (a collapsed panel, inactive tab)
1527
+ * doesn't have a running animation yet, so its delay is anchored to mount time rather than to
1528
+ * when the animation actually starts once shown, and it does not resync after a
1529
+ * backgrounded tab/iframe throttles the animation and later resumes it — both are acceptable
1530
+ * trade-offs for a short-lived, purely decorative loading spinner.
1531
+ *
1532
+ * @param {RefObject<HTMLElement | null>} ref Ref to the element with the `animate-spin` animation.
1533
+ * @param {number} durationMs Duration of one full spin cycle, in milliseconds, at mount time.
1534
+ * Must match the animation's own `animation-duration`. Defaults to 1000ms, matching
1535
+ * `Spinner`'s `animate-spin` usage.
1536
+ */
1537
+ const useSyncedSpinDelay = (ref, durationMs = DEFAULT_SPIN_DURATION_MS) => {
1538
+ react.useLayoutEffect(() => {
1539
+ const el = ref.current;
1540
+ if (!el)
1541
+ return;
1542
+ el.style.animationDelay = `${-(Date.now() % durationMs)}ms`;
1543
+ }, [ref, durationMs]);
1544
+ };
1545
+
1493
1546
  /**
1494
1547
  * Spinner provides visual feedback that data is being processed. It reassures users that their action is being handled
1495
1548
  * during short operations (1-5 seconds) such as saving, loading, or refreshing data.
@@ -1526,7 +1579,11 @@ const cvaSpinnerLabel = cssClassVarianceUtilities.cva({ base: ["self-center", "t
1526
1579
  * @returns {ReactElement} Spinner component
1527
1580
  */
1528
1581
  const Spinner = ({ mode = "light", size = "medium", centering = "centered", className, containerClassName, "data-testid": dataTestId = "spinner", label, style, ref, }) => {
1529
- return (jsxRuntime.jsx("div", { className: cvaSpinnerContainer({ centering, className: containerClassName }), ref: ref, style: style, children: jsxRuntime.jsxs("div", { className: cvaSpinnerContainerInner(), children: [jsxRuntime.jsx("div", { className: cvaSpinner({ size, mode, className }), "data-testid": dataTestId, role: "spinbutton" }), label ? jsxRuntime.jsx("span", { className: cvaSpinnerLabel(), children: label }) : null] }) }));
1582
+ // Keeps every Spinner's rotation in phase with every other one on the page -- including
1583
+ // spinners rendered in other iframes -- since the delay is derived from the shared wall clock.
1584
+ const spinnerElRef = react.useRef(null);
1585
+ useSyncedSpinDelay(spinnerElRef);
1586
+ return (jsxRuntime.jsx("div", { className: cvaSpinnerContainer({ centering, className: containerClassName }), ref: ref, style: style, children: jsxRuntime.jsxs("div", { className: cvaSpinnerContainerInner(), children: [jsxRuntime.jsx("div", { className: cvaSpinner({ size, mode, className }), "data-testid": dataTestId, ref: spinnerElRef, role: "spinbutton" }), label ? jsxRuntime.jsx("span", { className: cvaSpinnerLabel(), children: label }) : null] }) }));
1530
1587
  };
1531
1588
 
1532
1589
  const cvaButton = cssClassVarianceUtilities.cva({
package/index.esm.js CHANGED
@@ -1489,6 +1489,59 @@ const cvaSpinnerContainer = cva({
1489
1489
  });
1490
1490
  const cvaSpinnerLabel = cva({ base: ["self-center", "text-center", "text-current"] });
1491
1491
 
1492
+ /**
1493
+ * Cycle duration, in milliseconds, matching Tailwind's `animate-spin` utility
1494
+ * (`spin 1s linear infinite`) — the animation `Spinner` uses.
1495
+ */
1496
+ const DEFAULT_SPIN_DURATION_MS = 1000;
1497
+ /**
1498
+ * Synchronizes an `animate-spin` element's rotation phase to the shared wall clock, so every
1499
+ * element using this hook — anywhere on the page, including across iframe boundaries — renders
1500
+ * at the same point in its spin cycle regardless of when its DOM node, document, or iframe was
1501
+ * created.
1502
+ *
1503
+ * On mount, in a layout effect, it writes a negative `animation-delay` —
1504
+ * `-(Date.now() % durationMs)` — directly onto `ref.current`. A negative delay skips the
1505
+ * animation forward by that amount, effectively pinning the element to `now`'s position in the
1506
+ * cycle instead of starting fresh at phase 0. Because `Date.now()` is the same system clock
1507
+ * everywhere, this keeps `animate-spin` elements visually synchronized across the whole page with
1508
+ * no message-passing required.
1509
+ *
1510
+ * The sample is taken in a layout effect rather than during render: React's render phase can be
1511
+ * time-sliced across several elements (a large table/page mount, a Suspense-covered subtree,
1512
+ * etc.), so elements rendered in the same commit could otherwise sample the clock many
1513
+ * milliseconds apart and end up visibly out of phase with each other. Layout effects for a given
1514
+ * commit all run together, synchronously, right after the DOM mutations land and before paint —
1515
+ * so every element written to in the same commit gets, for practical purposes, the same
1516
+ * timestamp.
1517
+ *
1518
+ * The delay is written once, on mount, and then held fixed for the life of the element (the
1519
+ * effect only re-runs if `ref` or `durationMs` change identity, which does not happen for a
1520
+ * stable ref and a constant duration). Recomputing it on every render would be actively harmful:
1521
+ * once a CSS animation is running, changing `animation-delay` does not re-anchor its phase to the
1522
+ * new value — it recombines the new delay with the animation's own already-elapsed local clock,
1523
+ * which desyncs the element instead of correcting it.
1524
+ *
1525
+ * Known trade-off: an element that is `display: none` at mount (a collapsed panel, inactive tab)
1526
+ * doesn't have a running animation yet, so its delay is anchored to mount time rather than to
1527
+ * when the animation actually starts once shown, and it does not resync after a
1528
+ * backgrounded tab/iframe throttles the animation and later resumes it — both are acceptable
1529
+ * trade-offs for a short-lived, purely decorative loading spinner.
1530
+ *
1531
+ * @param {RefObject<HTMLElement | null>} ref Ref to the element with the `animate-spin` animation.
1532
+ * @param {number} durationMs Duration of one full spin cycle, in milliseconds, at mount time.
1533
+ * Must match the animation's own `animation-duration`. Defaults to 1000ms, matching
1534
+ * `Spinner`'s `animate-spin` usage.
1535
+ */
1536
+ const useSyncedSpinDelay = (ref, durationMs = DEFAULT_SPIN_DURATION_MS) => {
1537
+ useLayoutEffect(() => {
1538
+ const el = ref.current;
1539
+ if (!el)
1540
+ return;
1541
+ el.style.animationDelay = `${-(Date.now() % durationMs)}ms`;
1542
+ }, [ref, durationMs]);
1543
+ };
1544
+
1492
1545
  /**
1493
1546
  * Spinner provides visual feedback that data is being processed. It reassures users that their action is being handled
1494
1547
  * during short operations (1-5 seconds) such as saving, loading, or refreshing data.
@@ -1525,7 +1578,11 @@ const cvaSpinnerLabel = cva({ base: ["self-center", "text-center", "text-current
1525
1578
  * @returns {ReactElement} Spinner component
1526
1579
  */
1527
1580
  const Spinner = ({ mode = "light", size = "medium", centering = "centered", className, containerClassName, "data-testid": dataTestId = "spinner", label, style, ref, }) => {
1528
- return (jsx("div", { className: cvaSpinnerContainer({ centering, className: containerClassName }), ref: ref, style: style, children: jsxs("div", { className: cvaSpinnerContainerInner(), children: [jsx("div", { className: cvaSpinner({ size, mode, className }), "data-testid": dataTestId, role: "spinbutton" }), label ? jsx("span", { className: cvaSpinnerLabel(), children: label }) : null] }) }));
1581
+ // Keeps every Spinner's rotation in phase with every other one on the page -- including
1582
+ // spinners rendered in other iframes -- since the delay is derived from the shared wall clock.
1583
+ const spinnerElRef = useRef(null);
1584
+ useSyncedSpinDelay(spinnerElRef);
1585
+ return (jsx("div", { className: cvaSpinnerContainer({ centering, className: containerClassName }), ref: ref, style: style, children: jsxs("div", { className: cvaSpinnerContainerInner(), children: [jsx("div", { className: cvaSpinner({ size, mode, className }), "data-testid": dataTestId, ref: spinnerElRef, role: "spinbutton" }), label ? jsx("span", { className: cvaSpinnerLabel(), children: label }) : null] }) }));
1529
1586
  };
1530
1587
 
1531
1588
  const cvaButton = cva({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trackunit/react-components",
3
- "version": "3.2.4",
3
+ "version": "3.3.0",
4
4
  "repository": "https://github.com/Trackunit/manager",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "migrations": "./migrations.json",
@@ -14,16 +14,16 @@
14
14
  "@floating-ui/react": "^0.26.25",
15
15
  "string-ts": "^2.0.0",
16
16
  "tailwind-merge": "^2.0.0",
17
- "@trackunit/ui-design-tokens": "1.15.41",
18
- "@trackunit/css-class-variance-utilities": "2.0.21",
19
- "@trackunit/shared-utils": "1.16.56",
20
- "@trackunit/ui-icons": "1.14.49",
17
+ "@trackunit/ui-design-tokens": "1.15.42",
18
+ "@trackunit/css-class-variance-utilities": "2.0.22",
19
+ "@trackunit/shared-utils": "1.16.57",
20
+ "@trackunit/ui-icons": "1.14.50",
21
21
  "es-toolkit": "^1.39.10",
22
22
  "@tanstack/react-virtual": "^3.14.3",
23
23
  "dequal": "^2.0.3",
24
24
  "fflate": "^0.8.2",
25
25
  "zod": "^3.25.76",
26
- "@trackunit/i18n-library-translation": "2.5.7"
26
+ "@trackunit/i18n-library-translation": "2.5.9"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "react": "^19.0.0",
@@ -1,8 +1,8 @@
1
1
  import { ReactElement } from "react";
2
2
  import { CommonProps } from "../../common/CommonProps";
3
- import type { Styleable } from "../../common/Styleable";
4
3
  import { Refable } from "../../common/Refable";
5
4
  import { Size } from "../../common/Size";
5
+ import type { Styleable } from "../../common/Styleable";
6
6
  type Centering = "horizontally" | "vertically" | "centered";
7
7
  export interface SpinnerProps extends CommonProps, Styleable, Refable<HTMLDivElement> {
8
8
  /**
@@ -0,0 +1,46 @@
1
+ import { type RefObject } from "react";
2
+ /**
3
+ * Cycle duration, in milliseconds, matching Tailwind's `animate-spin` utility
4
+ * (`spin 1s linear infinite`) — the animation `Spinner` uses.
5
+ */
6
+ export declare const DEFAULT_SPIN_DURATION_MS = 1000;
7
+ /**
8
+ * Synchronizes an `animate-spin` element's rotation phase to the shared wall clock, so every
9
+ * element using this hook — anywhere on the page, including across iframe boundaries — renders
10
+ * at the same point in its spin cycle regardless of when its DOM node, document, or iframe was
11
+ * created.
12
+ *
13
+ * On mount, in a layout effect, it writes a negative `animation-delay` —
14
+ * `-(Date.now() % durationMs)` — directly onto `ref.current`. A negative delay skips the
15
+ * animation forward by that amount, effectively pinning the element to `now`'s position in the
16
+ * cycle instead of starting fresh at phase 0. Because `Date.now()` is the same system clock
17
+ * everywhere, this keeps `animate-spin` elements visually synchronized across the whole page with
18
+ * no message-passing required.
19
+ *
20
+ * The sample is taken in a layout effect rather than during render: React's render phase can be
21
+ * time-sliced across several elements (a large table/page mount, a Suspense-covered subtree,
22
+ * etc.), so elements rendered in the same commit could otherwise sample the clock many
23
+ * milliseconds apart and end up visibly out of phase with each other. Layout effects for a given
24
+ * commit all run together, synchronously, right after the DOM mutations land and before paint —
25
+ * so every element written to in the same commit gets, for practical purposes, the same
26
+ * timestamp.
27
+ *
28
+ * The delay is written once, on mount, and then held fixed for the life of the element (the
29
+ * effect only re-runs if `ref` or `durationMs` change identity, which does not happen for a
30
+ * stable ref and a constant duration). Recomputing it on every render would be actively harmful:
31
+ * once a CSS animation is running, changing `animation-delay` does not re-anchor its phase to the
32
+ * new value — it recombines the new delay with the animation's own already-elapsed local clock,
33
+ * which desyncs the element instead of correcting it.
34
+ *
35
+ * Known trade-off: an element that is `display: none` at mount (a collapsed panel, inactive tab)
36
+ * doesn't have a running animation yet, so its delay is anchored to mount time rather than to
37
+ * when the animation actually starts once shown, and it does not resync after a
38
+ * backgrounded tab/iframe throttles the animation and later resumes it — both are acceptable
39
+ * trade-offs for a short-lived, purely decorative loading spinner.
40
+ *
41
+ * @param {RefObject<HTMLElement | null>} ref Ref to the element with the `animate-spin` animation.
42
+ * @param {number} durationMs Duration of one full spin cycle, in milliseconds, at mount time.
43
+ * Must match the animation's own `animation-duration`. Defaults to 1000ms, matching
44
+ * `Spinner`'s `animate-spin` usage.
45
+ */
46
+ export declare const useSyncedSpinDelay: (ref: RefObject<HTMLElement | null>, durationMs?: number) => void;