@jcsoftdev/ui 0.1.3 → 0.1.4

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.
@@ -14,7 +14,7 @@ import { ChartFrame } from "../chart-frame.js";
14
14
  import { tint } from "../color-scale.js";
15
15
  import { ChartDataTable } from "../data-table.js";
16
16
  import { formatNumber, resolveFormat } from "../format.js";
17
- import { useEntryAnimation } from "../motion.js";
17
+ import { fadeKeyframes, useEntryAnimation } from "../motion.js";
18
18
  import { TONE_COLORS } from "../palette.js";
19
19
  import { formatDate } from "../scales/time.js";
20
20
  import { ChartTooltip } from "../tooltip.js";
@@ -633,7 +633,7 @@ function XYPointPlot({
633
633
  );
634
634
  });
635
635
  root.querySelectorAll("[data-trend]").forEach((line) => {
636
- line.animate([{ opacity: 0 }, { opacity: 1 }], {
636
+ line.animate(fadeKeyframes(line), {
637
637
  duration: 500,
638
638
  delay: 500,
639
639
  easing: "ease-out",
@@ -647,7 +647,7 @@ function XYPointPlot({
647
647
  wrapRef,
648
648
  (root) => {
649
649
  root.querySelectorAll("[data-canvas]").forEach((layer) => {
650
- layer.animate([{ opacity: 0 }, { opacity: 1 }], {
650
+ layer.animate(fadeKeyframes(layer), {
651
651
  duration: 450,
652
652
  easing: "ease-out",
653
653
  fill: "backwards"
@@ -19,6 +19,12 @@ export interface EntryAnimationOptions {
19
19
  export declare function useEntryAnimation<T extends Element>(ref: RefObject<T | null>, play: (root: T) => void, { disabled, replayKey }?: EntryAnimationOptions): void;
20
20
  /** Reveals children left to right by scaling a clip rectangle. */
21
21
  export declare function revealClip(element: Element, duration?: number, delay?: number): void;
22
+ /**
23
+ * Fade-in keyframes that end at the element's own opacity. Ending at 1 would
24
+ * show a translucent element at full strength, then snap it back when the
25
+ * animation finishes.
26
+ */
27
+ export declare function fadeKeyframes(element: Element): Keyframe[];
22
28
  /** Fades an element in. */
23
29
  export declare function fadeIn(element: Element, duration?: number, delay?: number): void;
24
30
  /**
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { useEffect, useRef, useState } from "react";
3
+ import { useIsomorphicLayoutEffect } from "./use-chart-size.js";
3
4
  const QUERY = "(prefers-reduced-motion: reduce)";
4
5
  function useReducedMotion() {
5
6
  const [reduced, setReduced] = useState(false);
@@ -21,7 +22,7 @@ function prefersReducedMotion() {
21
22
  return typeof window !== "undefined" && !!window.matchMedia?.(QUERY).matches;
22
23
  }
23
24
  function useEntryAnimation(ref, play, { disabled = false, replayKey } = {}) {
24
- useEffect(() => {
25
+ useIsomorphicLayoutEffect(() => {
25
26
  const root = ref.current;
26
27
  if (disabled || !root || typeof root.animate !== "function" || prefersReducedMotion()) return;
27
28
  play(root);
@@ -35,8 +36,12 @@ function revealClip(element, duration = 900, delay = 0) {
35
36
  fill: "backwards"
36
37
  });
37
38
  }
39
+ function fadeKeyframes(element) {
40
+ const resting = typeof getComputedStyle === "function" ? getComputedStyle(element).opacity : "1";
41
+ return [{ opacity: 0 }, { opacity: resting || "1" }];
42
+ }
38
43
  function fadeIn(element, duration = 400, delay = 0) {
39
- element.animate([{ opacity: 0 }, { opacity: 1 }], {
44
+ element.animate(fadeKeyframes(element), {
40
45
  duration,
41
46
  delay,
42
47
  easing: "ease-out",
@@ -60,7 +65,7 @@ function useAnimatedNumber(target, {
60
65
  const startedAt = performance.now();
61
66
  let frame = 0;
62
67
  const tick = (now) => {
63
- const progress = Math.min(1, (now - startedAt) / duration);
68
+ const progress = Math.min(1, Math.max(0, (now - startedAt) / duration));
64
69
  const eased = 1 - (1 - progress) ** 3;
65
70
  setValue(start + (target - start) * eased);
66
71
  if (progress < 1) frame = requestAnimationFrame(tick);
@@ -74,6 +79,7 @@ export {
74
79
  EASE_OUT,
75
80
  easeOutCubic,
76
81
  fadeIn,
82
+ fadeKeyframes,
77
83
  revealClip,
78
84
  useAnimatedNumber,
79
85
  useEntryAnimation,
@@ -50,7 +50,7 @@ function useAnimatedView(target, { disabled = false, duration = 650 } = {}) {
50
50
  let frame = 0;
51
51
  const start = performance.now();
52
52
  const step = (now) => {
53
- const t = Math.min(1, (now - start) / duration);
53
+ const t = Math.min(1, Math.max(0, (now - start) / duration));
54
54
  const next = interpolateView(from, to, easeOutCubic(t));
55
55
  current.current = next;
56
56
  setView(next);
@@ -620,7 +620,7 @@ function useTweenedValues(items, disabled) {
620
620
  const started = performance.now();
621
621
  let frame = 0;
622
622
  const step = (now) => {
623
- const t = easeOutCubic(Math.min(1, (now - started) / 700));
623
+ const t = easeOutCubic(Math.min(1, Math.max(0, (now - started) / 700)));
624
624
  const next = {};
625
625
  for (const id of Object.keys(target)) {
626
626
  const start = from[id] ?? 0;
@@ -6,7 +6,13 @@ import { DECORATIVE } from "../core/a11y.js";
6
6
  import { ChartFrame } from "../core/chart-frame.js";
7
7
  import { ChartDataTable } from "../core/data-table.js";
8
8
  import { resolveFormat } from "../core/format.js";
9
- import { EASE_OUT, fadeIn, useAnimatedNumber, useEntryAnimation } from "../core/motion.js";
9
+ import {
10
+ EASE_OUT,
11
+ fadeIn,
12
+ fadeKeyframes,
13
+ useAnimatedNumber,
14
+ useEntryAnimation
15
+ } from "../core/motion.js";
10
16
  import { TONE_COLORS } from "../core/palette.js";
11
17
  import { arcLine, degToRad, polarPoint, radToDeg } from "../core/polar.js";
12
18
  import { ChartTooltip } from "../core/tooltip.js";
@@ -202,7 +208,7 @@ function GaugePlot({
202
208
  svgRef,
203
209
  (root) => {
204
210
  root.querySelectorAll("[data-band]").forEach((node, i) => {
205
- node.animate([{ opacity: 0 }, { opacity: 1 }], {
211
+ node.animate(fadeKeyframes(node), {
206
212
  duration: 500,
207
213
  delay: i * 90,
208
214
  easing: EASE_OUT,
@@ -8,7 +8,7 @@ import {
8
8
  rangeColor,
9
9
  scaleGradient
10
10
  } from "../core/color-scale.js";
11
- import { EASE_OUT, useEntryAnimation } from "../core/motion.js";
11
+ import { EASE_OUT, fadeKeyframes, useEntryAnimation } from "../core/motion.js";
12
12
  import { ChartTooltip } from "../core/tooltip.js";
13
13
  import { DEFAULT_LABELS } from "../core/types.js";
14
14
  import { useCanvasPaint } from "../core/use-canvas.js";
@@ -99,7 +99,7 @@ function HeatmapGrid({
99
99
  svgRef,
100
100
  (element) => {
101
101
  element.querySelectorAll("[data-column]").forEach((group, index) => {
102
- group.animate([{ opacity: 0 }, { opacity: 1 }], {
102
+ group.animate(fadeKeyframes(group), {
103
103
  duration: 420,
104
104
  delay: Math.min(index * (360 / Math.max(1, columns)), 360),
105
105
  easing: EASE_OUT,
@@ -18,7 +18,7 @@ import {
18
18
  } from "../core/distribution/stats.js";
19
19
  import { useDistributionLegend } from "../core/distribution/use-legend.js";
20
20
  import { formatNumber, formatPercent, resolveFormat } from "../core/format.js";
21
- import { EASE_OUT, useEntryAnimation } from "../core/motion.js";
21
+ import { EASE_OUT, fadeKeyframes, useEntryAnimation } from "../core/motion.js";
22
22
  import { barPath, linePath } from "../core/path.js";
23
23
  import { ChartTooltip } from "../core/tooltip.js";
24
24
  function Histogram({
@@ -285,7 +285,7 @@ function HistogramPlot({
285
285
  });
286
286
  });
287
287
  root.querySelectorAll("[data-curve]").forEach((curve) => {
288
- curve.animate([{ opacity: 0 }, { opacity: 1 }], {
288
+ curve.animate(fadeKeyframes(curve), {
289
289
  duration: 500,
290
290
  delay: 420,
291
291
  easing: "ease-out",
@@ -59,7 +59,7 @@ function useMapView({
59
59
  const from = viewRef.current;
60
60
  const start = performance.now();
61
61
  const step = (now) => {
62
- const t = Math.min(1, (now - start) / DURATION);
62
+ const t = Math.min(1, Math.max(0, (now - start) / DURATION));
63
63
  const e = easeOut(t);
64
64
  const k = from.k * (goal.k / from.k) ** e;
65
65
  apply({ k, x: from.x + (goal.x - from.x) * e, y: from.y + (goal.y - from.y) * e });
@@ -10,7 +10,7 @@ import {
10
10
  import { cn } from "../../utils/cn.js";
11
11
  import { ChartFrame } from "../core/chart-frame.js";
12
12
  import { ChartDataTable } from "../core/data-table.js";
13
- import { EASE_OUT, useEntryAnimation } from "../core/motion.js";
13
+ import { EASE_OUT, fadeKeyframes, useEntryAnimation } from "../core/motion.js";
14
14
  import { treeFitView } from "../core/tree-fit.js";
15
15
  import {
16
16
  treeEdgePath,
@@ -388,7 +388,7 @@ function TreePlot({
388
388
  );
389
389
  });
390
390
  root.querySelectorAll("[data-tree-edges]").forEach((element) => {
391
- element.animate([{ opacity: 0 }, { opacity: 1 }], {
391
+ element.animate(fadeKeyframes(element), {
392
392
  duration: 500,
393
393
  delay: 160,
394
394
  easing: "ease-out",
@@ -58,7 +58,7 @@ function useTreeTransition(layout, parentOf, { disabled = false, duration = 360
58
58
  let frame = 0;
59
59
  const start = performance.now();
60
60
  const step = (now) => {
61
- const t = Math.min(1, (now - start) / duration);
61
+ const t = Math.min(1, Math.max(0, (now - start) / duration));
62
62
  if (t >= 1) {
63
63
  show(final);
64
64
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcsoftdev/ui",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Token-driven React component library: React 19, Radix, Tailwind v4.",
5
5
  "author": "jcsoftdev",
6
6
  "license": "MIT",
@@ -17,7 +17,7 @@ import { ChartFrame } from "../chart-frame.js";
17
17
  import { tint } from "../color-scale.js";
18
18
  import { ChartDataTable } from "../data-table.js";
19
19
  import { formatNumber, resolveFormat, type ValueFormat } from "../format.js";
20
- import { useEntryAnimation } from "../motion.js";
20
+ import { fadeKeyframes, useEntryAnimation } from "../motion.js";
21
21
  import { type ChartTone, TONE_COLORS } from "../palette.js";
22
22
  import { formatDate } from "../scales/time.js";
23
23
  import { ChartTooltip, type TooltipRow } from "../tooltip.js";
@@ -858,7 +858,7 @@ function XYPointPlot({
858
858
  );
859
859
  });
860
860
  root.querySelectorAll("[data-trend]").forEach((line) => {
861
- line.animate([{ opacity: 0 }, { opacity: 1 }], {
861
+ line.animate(fadeKeyframes(line), {
862
862
  duration: 500,
863
863
  delay: 500,
864
864
  easing: "ease-out",
@@ -873,7 +873,7 @@ function XYPointPlot({
873
873
  wrapRef,
874
874
  (root) => {
875
875
  root.querySelectorAll("[data-canvas]").forEach((layer) => {
876
- layer.animate([{ opacity: 0 }, { opacity: 1 }], {
876
+ layer.animate(fadeKeyframes(layer), {
877
877
  duration: 450,
878
878
  easing: "ease-out",
879
879
  fill: "backwards",
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { type RefObject, useEffect, useRef, useState } from "react";
4
+ import { useIsomorphicLayoutEffect } from "./use-chart-size.js";
4
5
 
5
6
  const QUERY = "(prefers-reduced-motion: reduce)";
6
7
 
@@ -47,8 +48,10 @@ export function useEntryAnimation<T extends Element>(
47
48
  play: (root: T) => void,
48
49
  { disabled = false, replayKey }: EntryAnimationOptions = {},
49
50
  ): void {
50
- // biome-ignore lint/correctness/useExhaustiveDependencies: replays only when the key changes.
51
- useEffect(() => {
51
+ // A layout effect starts the animations before the first paint. With a plain
52
+ // effect the final state paints for one frame, then `fill: "backwards"` hides
53
+ // it again, which reads as a flicker. Replays only when the key changes.
54
+ useIsomorphicLayoutEffect(() => {
52
55
  const root = ref.current;
53
56
  if (disabled || !root || typeof root.animate !== "function" || prefersReducedMotion()) return;
54
57
  play(root);
@@ -65,9 +68,19 @@ export function revealClip(element: Element, duration = 900, delay = 0): void {
65
68
  });
66
69
  }
67
70
 
71
+ /**
72
+ * Fade-in keyframes that end at the element's own opacity. Ending at 1 would
73
+ * show a translucent element at full strength, then snap it back when the
74
+ * animation finishes.
75
+ */
76
+ export function fadeKeyframes(element: Element): Keyframe[] {
77
+ const resting = typeof getComputedStyle === "function" ? getComputedStyle(element).opacity : "1";
78
+ return [{ opacity: 0 }, { opacity: resting || "1" }];
79
+ }
80
+
68
81
  /** Fades an element in. */
69
82
  export function fadeIn(element: Element, duration = 400, delay = 0): void {
70
- element.animate([{ opacity: 0 }, { opacity: 1 }], {
83
+ element.animate(fadeKeyframes(element), {
71
84
  duration,
72
85
  delay,
73
86
  easing: "ease-out",
@@ -100,7 +113,8 @@ export function useAnimatedNumber(
100
113
  const startedAt = performance.now();
101
114
  let frame = 0;
102
115
  const tick = (now: number) => {
103
- const progress = Math.min(1, (now - startedAt) / duration);
116
+ // rAF timestamps are the frame start and can precede `startedAt`.
117
+ const progress = Math.min(1, Math.max(0, (now - startedAt) / duration));
104
118
  const eased = 1 - (1 - progress) ** 3;
105
119
  setValue(start + (target - start) * eased);
106
120
  if (progress < 1) frame = requestAnimationFrame(tick);
@@ -86,7 +86,7 @@ export function useAnimatedView(
86
86
  let frame = 0;
87
87
  const start = performance.now();
88
88
  const step = (now: number) => {
89
- const t = Math.min(1, (now - start) / duration);
89
+ const t = Math.min(1, Math.max(0, (now - start) / duration));
90
90
  const next = interpolateView(from, to, easeOutCubic(t));
91
91
  current.current = next;
92
92
  setView(next);
@@ -810,7 +810,7 @@ function useTweenedValues(items: readonly Resolved[], disabled: boolean): number
810
810
  const started = performance.now();
811
811
  let frame = 0;
812
812
  const step = (now: number) => {
813
- const t = easeOutCubic(Math.min(1, (now - started) / 700));
813
+ const t = easeOutCubic(Math.min(1, Math.max(0, (now - started) / 700)));
814
814
  const next: Record<string, number> = {};
815
815
  for (const id of Object.keys(target)) {
816
816
  const start = from[id] ?? 0;
@@ -6,7 +6,13 @@ import { DECORATIVE } from "../core/a11y.js";
6
6
  import { ChartFrame } from "../core/chart-frame.js";
7
7
  import { ChartDataTable } from "../core/data-table.js";
8
8
  import { resolveFormat, type ValueFormat } from "../core/format.js";
9
- import { EASE_OUT, fadeIn, useAnimatedNumber, useEntryAnimation } from "../core/motion.js";
9
+ import {
10
+ EASE_OUT,
11
+ fadeIn,
12
+ fadeKeyframes,
13
+ useAnimatedNumber,
14
+ useEntryAnimation,
15
+ } from "../core/motion.js";
10
16
  import { type ChartTone, TONE_COLORS } from "../core/palette.js";
11
17
  import { arcLine, degToRad, polarPoint, radToDeg } from "../core/polar.js";
12
18
  import { ChartTooltip } from "../core/tooltip.js";
@@ -309,7 +315,7 @@ function GaugePlot({
309
315
  svgRef,
310
316
  (root) => {
311
317
  root.querySelectorAll("[data-band]").forEach((node, i) => {
312
- node.animate([{ opacity: 0 }, { opacity: 1 }], {
318
+ node.animate(fadeKeyframes(node), {
313
319
  duration: 500,
314
320
  delay: i * 90,
315
321
  easing: EASE_OUT,
@@ -11,7 +11,7 @@ import {
11
11
  scaleGradient,
12
12
  } from "../core/color-scale.js";
13
13
  import type { ValueFormatter } from "../core/format.js";
14
- import { EASE_OUT, useEntryAnimation } from "../core/motion.js";
14
+ import { EASE_OUT, fadeKeyframes, useEntryAnimation } from "../core/motion.js";
15
15
  import { ChartTooltip } from "../core/tooltip.js";
16
16
  import { type ChartLabels, DEFAULT_LABELS } from "../core/types.js";
17
17
  import { useCanvasPaint } from "../core/use-canvas.js";
@@ -172,7 +172,7 @@ export function HeatmapGrid({
172
172
  svgRef,
173
173
  (element) => {
174
174
  element.querySelectorAll("[data-column]").forEach((group, index) => {
175
- group.animate([{ opacity: 0 }, { opacity: 1 }], {
175
+ group.animate(fadeKeyframes(group), {
176
176
  duration: 420,
177
177
  delay: Math.min(index * (360 / Math.max(1, columns)), 360),
178
178
  easing: EASE_OUT,
@@ -20,7 +20,7 @@ import {
20
20
  } from "../core/distribution/stats.js";
21
21
  import { type ColoredSeries, useDistributionLegend } from "../core/distribution/use-legend.js";
22
22
  import { formatNumber, formatPercent, resolveFormat, type ValueFormat } from "../core/format.js";
23
- import { EASE_OUT, useEntryAnimation } from "../core/motion.js";
23
+ import { EASE_OUT, fadeKeyframes, useEntryAnimation } from "../core/motion.js";
24
24
  import { barPath, linePath } from "../core/path.js";
25
25
  import { ChartTooltip, type TooltipRow } from "../core/tooltip.js";
26
26
 
@@ -400,7 +400,7 @@ function HistogramPlot({
400
400
  });
401
401
  });
402
402
  root.querySelectorAll("[data-curve]").forEach((curve) => {
403
- curve.animate([{ opacity: 0 }, { opacity: 1 }], {
403
+ curve.animate(fadeKeyframes(curve), {
404
404
  duration: 500,
405
405
  delay: 420,
406
406
  easing: "ease-out",
@@ -90,7 +90,7 @@ export function useMapView<E extends Element>({
90
90
  const from = viewRef.current;
91
91
  const start = performance.now();
92
92
  const step = (now: number) => {
93
- const t = Math.min(1, (now - start) / DURATION);
93
+ const t = Math.min(1, Math.max(0, (now - start) / DURATION));
94
94
  const e = easeOut(t);
95
95
  // Interpolate zoom geometrically so the speed feels constant.
96
96
  const k = from.k * (goal.k / from.k) ** e;
@@ -12,7 +12,7 @@ import {
12
12
  import { cn } from "../../utils/cn.js";
13
13
  import { ChartFrame } from "../core/chart-frame.js";
14
14
  import { ChartDataTable } from "../core/data-table.js";
15
- import { EASE_OUT, useEntryAnimation } from "../core/motion.js";
15
+ import { EASE_OUT, fadeKeyframes, useEntryAnimation } from "../core/motion.js";
16
16
  import { type TreeFitMode, treeFitView } from "../core/tree-fit.js";
17
17
  import {
18
18
  type TreeDirection,
@@ -568,7 +568,7 @@ function TreePlot({
568
568
  );
569
569
  });
570
570
  root.querySelectorAll("[data-tree-edges]").forEach((element) => {
571
- element.animate([{ opacity: 0 }, { opacity: 1 }], {
571
+ element.animate(fadeKeyframes(element), {
572
572
  duration: 500,
573
573
  delay: 160,
574
574
  easing: "ease-out",
@@ -92,7 +92,7 @@ export function useTreeTransition(
92
92
  let frame = 0;
93
93
  const start = performance.now();
94
94
  const step = (now: number) => {
95
- const t = Math.min(1, (now - start) / duration);
95
+ const t = Math.min(1, Math.max(0, (now - start) / duration));
96
96
  if (t >= 1) {
97
97
  show(final);
98
98
  return;