@windrun-huaiin/third-ui 29.2.1 → 30.1.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.
Files changed (121) hide show
  1. package/dist/fuma/fuma-page-genarator.d.ts +2 -6
  2. package/dist/fuma/fuma-page-genarator.js +3 -2
  3. package/dist/fuma/fuma-page-genarator.mjs +3 -2
  4. package/dist/fuma/mdx/cheet-table.d.ts +13 -0
  5. package/dist/fuma/mdx/cheet-table.js +295 -0
  6. package/dist/fuma/mdx/cheet-table.mjs +293 -0
  7. package/dist/fuma/mdx/index.d.ts +1 -0
  8. package/dist/fuma/mdx/index.js +2 -0
  9. package/dist/fuma/mdx/index.mjs +1 -0
  10. package/dist/fuma/server/features/widgets.js +2 -0
  11. package/dist/fuma/server/features/widgets.mjs +2 -0
  12. package/dist/lib/fuma-schema-check-util.d.ts +1 -1
  13. package/dist/main/404-page.d.ts +12 -0
  14. package/dist/main/404-page.js +66 -0
  15. package/dist/main/404-page.mjs +64 -0
  16. package/dist/main/alert-dialog/confirm-dialog.js +1 -1
  17. package/dist/main/alert-dialog/confirm-dialog.mjs +2 -2
  18. package/dist/main/alert-dialog/dialog-loading-action.js +5 -2
  19. package/dist/main/alert-dialog/dialog-loading-action.mjs +5 -2
  20. package/dist/main/alert-dialog/dialog-styles.d.ts +4 -2
  21. package/dist/main/alert-dialog/dialog-styles.js +8 -4
  22. package/dist/main/alert-dialog/dialog-styles.mjs +7 -5
  23. package/dist/main/alert-dialog/high-priority-confirm-dialog.js +5 -5
  24. package/dist/main/alert-dialog/high-priority-confirm-dialog.mjs +6 -6
  25. package/dist/main/alert-dialog/info-dialog.js +1 -1
  26. package/dist/main/alert-dialog/info-dialog.mjs +2 -2
  27. package/dist/main/alert-dialog/undoable-confirm-dialog.js +2 -2
  28. package/dist/main/alert-dialog/undoable-confirm-dialog.mjs +3 -3
  29. package/dist/main/anime/anime-404-page.d.ts +14 -0
  30. package/dist/main/anime/anime-404-page.js +197 -0
  31. package/dist/main/anime/anime-404-page.mjs +195 -0
  32. package/dist/main/anime/anime-beam-frame.d.ts +3 -0
  33. package/dist/main/anime/anime-beam-frame.js +63 -0
  34. package/dist/main/anime/anime-beam-frame.mjs +61 -0
  35. package/dist/main/anime/anime-not-found-page.d.ts +7 -0
  36. package/dist/main/anime/anime-not-found-page.js +142 -0
  37. package/dist/main/anime/anime-not-found-page.mjs +140 -0
  38. package/dist/main/anime/anime-spiral-loading.d.ts +10 -0
  39. package/dist/main/anime/anime-spiral-loading.js +77 -0
  40. package/dist/main/anime/anime-spiral-loading.mjs +75 -0
  41. package/dist/main/anime/index.d.ts +3 -0
  42. package/dist/main/anime/index.js +12 -0
  43. package/dist/main/anime/index.mjs +4 -0
  44. package/dist/main/beam-frame/animate.d.ts +3 -0
  45. package/dist/main/beam-frame/animate.js +63 -0
  46. package/dist/main/beam-frame/animate.mjs +61 -0
  47. package/dist/main/beam-frame/beam-frame.d.ts +4 -0
  48. package/dist/main/beam-frame/beam-frame.js +262 -0
  49. package/dist/main/beam-frame/beam-frame.mjs +258 -0
  50. package/dist/main/beam-frame/index.d.ts +4 -0
  51. package/dist/main/beam-frame/index.js +11 -0
  52. package/dist/main/beam-frame/index.mjs +3 -0
  53. package/dist/main/beam-frame/motion.d.ts +3 -0
  54. package/dist/main/beam-frame/motion.js +61 -0
  55. package/dist/main/beam-frame/motion.mjs +59 -0
  56. package/dist/main/beam-frame/share-config.d.ts +54 -0
  57. package/dist/main/beam-frame/share-config.js +161 -0
  58. package/dist/main/beam-frame/share-config.mjs +152 -0
  59. package/dist/main/beam-frame-config.d.ts +54 -0
  60. package/dist/main/beam-frame-config.js +161 -0
  61. package/dist/main/beam-frame-config.mjs +152 -0
  62. package/dist/main/calendar/random-date-range-dialog.js +177 -51
  63. package/dist/main/calendar/random-date-range-dialog.mjs +178 -52
  64. package/dist/main/cta.js +17 -1
  65. package/dist/main/cta.mjs +18 -2
  66. package/dist/main/delayed-img.d.ts +1 -1
  67. package/dist/main/delayed-img.js +8 -5
  68. package/dist/main/delayed-img.mjs +8 -5
  69. package/dist/main/index.d.ts +1 -0
  70. package/dist/main/index.js +2 -0
  71. package/dist/main/index.mjs +1 -0
  72. package/dist/main/info-tooltip.js +70 -9
  73. package/dist/main/info-tooltip.mjs +70 -9
  74. package/dist/main/loading-frame/index.d.ts +1 -0
  75. package/dist/main/loading.d.ts +2 -1
  76. package/dist/main/loading.js +64 -26
  77. package/dist/main/loading.mjs +64 -26
  78. package/dist/main/motion/creative-left-panel.d.ts +7 -0
  79. package/dist/main/motion/creative-left-panel.js +11 -0
  80. package/dist/main/motion/creative-left-panel.mjs +9 -0
  81. package/dist/main/motion/creative-right-panel.d.ts +7 -0
  82. package/dist/main/motion/creative-right-panel.js +11 -0
  83. package/dist/main/motion/creative-right-panel.mjs +9 -0
  84. package/dist/main/motion/index.d.ts +1 -0
  85. package/dist/main/motion/index.js +9 -0
  86. package/dist/main/motion/index.mjs +2 -0
  87. package/dist/main/motion/motion-beam-frame.d.ts +3 -0
  88. package/dist/main/motion/motion-beam-frame.js +61 -0
  89. package/dist/main/motion/motion-beam-frame.mjs +59 -0
  90. package/dist/main/snake-loading-frame.d.ts +7 -3
  91. package/dist/main/snake-loading-frame.js +45 -252
  92. package/dist/main/snake-loading-frame.mjs +47 -254
  93. package/package.json +16 -5
  94. package/src/fuma/fuma-page-genarator.tsx +2 -22
  95. package/src/fuma/mdx/cheet-table.tsx +650 -0
  96. package/src/fuma/mdx/index.ts +1 -0
  97. package/src/fuma/server/features/widgets.tsx +2 -0
  98. package/src/main/404-page.tsx +162 -0
  99. package/src/main/alert-dialog/confirm-dialog.tsx +2 -1
  100. package/src/main/alert-dialog/dialog-loading-action.tsx +7 -5
  101. package/src/main/alert-dialog/dialog-styles.ts +13 -3
  102. package/src/main/alert-dialog/high-priority-confirm-dialog.tsx +26 -23
  103. package/src/main/alert-dialog/info-dialog.tsx +2 -1
  104. package/src/main/alert-dialog/undoable-confirm-dialog.tsx +18 -17
  105. package/src/main/anime/anime-404-page.tsx +344 -0
  106. package/src/main/anime/anime-beam-frame.tsx +128 -0
  107. package/src/main/anime/anime-spiral-loading.tsx +123 -0
  108. package/src/main/anime/index.ts +10 -0
  109. package/src/main/beam-frame-config.tsx +341 -0
  110. package/src/main/calendar/random-date-range-dialog.tsx +225 -69
  111. package/src/main/cta.tsx +50 -21
  112. package/src/main/delayed-img.tsx +9 -4
  113. package/src/main/index.ts +1 -0
  114. package/src/main/info-tooltip.tsx +116 -20
  115. package/src/main/loading-frame/index.ts +4 -0
  116. package/src/main/loading.tsx +75 -24
  117. package/src/main/motion/index.ts +8 -0
  118. package/src/main/motion/motion-beam-frame.tsx +137 -0
  119. package/src/main/snake-loading-frame.tsx +95 -496
  120. package/src/styles/cta.css +21 -4
  121. package/src/styles/third-ui.css +0 -20
package/src/main/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  // Lightweight common client components.
4
4
  export * from './go-to-top';
5
+ export * from './404-page';
5
6
  export * from './loading';
6
7
  export * from './nprogress-bar';
7
8
  export * from './rich-text-expert'
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useRef, useState } from 'react';
4
+ import { createPortal } from 'react-dom';
4
5
  import { CircleQuestionMarkIcon } from '@windrun-huaiin/base-ui/icons';
5
6
  import { themeBorderColor, themeIconColor, themeRingColor } from '@windrun-huaiin/base-ui/lib';
6
7
  import { cn } from '@windrun-huaiin/lib/utils';
@@ -12,6 +13,67 @@ type InfoTooltipProps = {
12
13
  desktopSide?: 'right' | 'bottom';
13
14
  };
14
15
 
16
+ type TooltipPosition = {
17
+ left: number;
18
+ top: number;
19
+ maxWidth: number;
20
+ side: 'bottom' | 'inline';
21
+ };
22
+
23
+ const TOOLTIP_MARGIN = 12;
24
+ const TOOLTIP_GAP = 8;
25
+ const TOOLTIP_MAX_WIDTH = 288;
26
+
27
+ function clamp(value: number, min: number, max: number) {
28
+ return Math.min(Math.max(value, min), max);
29
+ }
30
+
31
+ function getTooltipPosition(
32
+ target: HTMLElement,
33
+ align: NonNullable<InfoTooltipProps['align']>,
34
+ desktopSide: NonNullable<InfoTooltipProps['desktopSide']>,
35
+ ): TooltipPosition {
36
+ const rect = target.getBoundingClientRect();
37
+ const viewportWidth = window.innerWidth;
38
+ const viewportHeight = window.innerHeight;
39
+ const maxWidth = Math.min(
40
+ TOOLTIP_MAX_WIDTH,
41
+ Math.max(160, viewportWidth - TOOLTIP_MARGIN * 2),
42
+ );
43
+ const useInlineSide = desktopSide === 'right' && viewportWidth >= 768;
44
+
45
+ if (useInlineSide) {
46
+ const rightSpace = viewportWidth - rect.right - TOOLTIP_GAP - TOOLTIP_MARGIN;
47
+ const leftSpace = rect.left - TOOLTIP_GAP - TOOLTIP_MARGIN;
48
+ const placeRight = rightSpace >= Math.min(220, maxWidth) || rightSpace >= leftSpace;
49
+ const preferredLeft = placeRight
50
+ ? rect.right + TOOLTIP_GAP
51
+ : rect.left - maxWidth - TOOLTIP_GAP;
52
+
53
+ return {
54
+ left: clamp(preferredLeft, TOOLTIP_MARGIN, viewportWidth - maxWidth - TOOLTIP_MARGIN),
55
+ top: clamp(
56
+ rect.top + rect.height / 2,
57
+ TOOLTIP_MARGIN + 40,
58
+ viewportHeight - TOOLTIP_MARGIN - 40,
59
+ ),
60
+ maxWidth,
61
+ side: 'inline',
62
+ };
63
+ }
64
+
65
+ const preferredLeft = align === 'start'
66
+ ? rect.left
67
+ : rect.right - maxWidth;
68
+
69
+ return {
70
+ left: clamp(preferredLeft, TOOLTIP_MARGIN, viewportWidth - maxWidth - TOOLTIP_MARGIN),
71
+ top: Math.min(rect.bottom + TOOLTIP_GAP, viewportHeight - TOOLTIP_MARGIN),
72
+ maxWidth,
73
+ side: 'bottom',
74
+ };
75
+ }
76
+
15
77
  export function InfoTooltip({
16
78
  content,
17
79
  className,
@@ -20,7 +82,17 @@ export function InfoTooltip({
20
82
  }: InfoTooltipProps) {
21
83
  const normalizedContent = content.trim();
22
84
  const containerRef = useRef<HTMLSpanElement | null>(null);
85
+ const tooltipRef = useRef<HTMLSpanElement | null>(null);
23
86
  const [open, setOpen] = useState(false);
87
+ const [position, setPosition] = useState<TooltipPosition | null>(null);
88
+
89
+ const updatePosition = () => {
90
+ if (!containerRef.current) {
91
+ return;
92
+ }
93
+
94
+ setPosition(getTooltipPosition(containerRef.current, align, desktopSide));
95
+ };
24
96
 
25
97
  useEffect(() => {
26
98
  function handlePointerDown(event: MouseEvent | TouchEvent) {
@@ -29,7 +101,11 @@ export function InfoTooltip({
29
101
  }
30
102
 
31
103
  const target = event.target;
32
- if (target instanceof Node && !containerRef.current.contains(target)) {
104
+ if (
105
+ target instanceof Node &&
106
+ !containerRef.current.contains(target) &&
107
+ !tooltipRef.current?.contains(target)
108
+ ) {
33
109
  setOpen(false);
34
110
  }
35
111
  }
@@ -43,6 +119,23 @@ export function InfoTooltip({
43
119
  };
44
120
  }, []);
45
121
 
122
+ useEffect(() => {
123
+ if (!open) {
124
+ setPosition(null);
125
+ return;
126
+ }
127
+
128
+ updatePosition();
129
+
130
+ window.addEventListener('resize', updatePosition);
131
+ window.addEventListener('scroll', updatePosition, true);
132
+
133
+ return () => {
134
+ window.removeEventListener('resize', updatePosition);
135
+ window.removeEventListener('scroll', updatePosition, true);
136
+ };
137
+ }, [align, desktopSide, open]);
138
+
46
139
  if (!normalizedContent) {
47
140
  return null;
48
141
  }
@@ -50,7 +143,7 @@ export function InfoTooltip({
50
143
  return (
51
144
  <span
52
145
  ref={containerRef}
53
- className={cn('relative inline-flex h-5 w-5 shrink-0 align-middle', className)}
146
+ className={cn('inline-flex h-5 w-5 shrink-0 align-middle', className)}
54
147
  onMouseLeave={() => setOpen(false)}
55
148
  >
56
149
  <button
@@ -76,24 +169,27 @@ export function InfoTooltip({
76
169
  >
77
170
  <CircleQuestionMarkIcon className="h-4 w-4" />
78
171
  </button>
79
- <span
80
- className={cn(
81
- 'pointer-events-none absolute top-full z-50 mt-2 w-[min(18rem,calc(100vw-2rem))] rounded-2xl border bg-white/95 px-3 py-2 text-xs leading-5 text-slate-600 shadow-xl backdrop-blur-sm dark:bg-slate-950/95 dark:text-slate-300',
82
- align === 'start' ? 'left-0 right-auto' : 'right-0 left-auto',
83
- desktopSide === 'right'
84
- ? align === 'start'
85
- ? 'sm:left-0 sm:right-auto md:left-full md:right-auto md:top-1/2 md:mt-0 md:ml-2 md:-translate-y-1/2'
86
- : 'sm:right-0 sm:left-auto md:left-full md:right-auto md:top-1/2 md:mt-0 md:ml-2 md:-translate-y-1/2'
87
- : align === 'start'
88
- ? 'md:left-0 md:right-auto md:top-full md:mt-2 md:ml-0 md:translate-y-0'
89
- : 'md:right-0 md:left-auto md:top-full md:mt-2 md:ml-0 md:translate-y-0',
90
- open ? 'block' : 'hidden',
91
- themeBorderColor,
92
- )}
93
- role="tooltip"
94
- >
95
- {normalizedContent}
96
- </span>
172
+ {open && position
173
+ ? createPortal(
174
+ <span
175
+ ref={tooltipRef}
176
+ className={cn(
177
+ 'pointer-events-none fixed z-50 max-h-[calc(100vh-1.5rem)] overflow-y-auto rounded-2xl border bg-white/95 px-3 py-2 text-xs leading-5 text-slate-600 shadow-xl backdrop-blur-sm dark:bg-slate-950/95 dark:text-slate-300',
178
+ position.side === 'inline' && '-translate-y-1/2',
179
+ themeBorderColor,
180
+ )}
181
+ style={{
182
+ left: position.left,
183
+ top: position.top,
184
+ width: position.maxWidth,
185
+ }}
186
+ role="tooltip"
187
+ >
188
+ {normalizedContent}
189
+ </span>,
190
+ document.body,
191
+ )
192
+ : null}
97
193
  </span>
98
194
  );
99
195
  }
@@ -1,3 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  export { SnakeLoadingFrame, SnakeLoadingPreview } from '../snake-loading-frame';
4
+ export type {
5
+ SnakeLoadingFrameProps,
6
+ SnakeLoadingPreviewProps,
7
+ } from '../snake-loading-frame';
@@ -1,7 +1,9 @@
1
1
  'use client';
2
2
 
3
+ import { useEffect, useMemo, useRef } from 'react';
3
4
  import { cn } from '@windrun-huaiin/lib/utils';
4
5
  import { themeSvgIconColor } from '@windrun-huaiin/base-ui/lib';
6
+ import { animate, type JSAnimation } from 'animejs';
5
7
 
6
8
  const NUM_ROWS = 15;
7
9
  const NUM_COLS = 15;
@@ -66,6 +68,7 @@ interface LoadingProps {
66
68
  className?: string;
67
69
  label?: string;
68
70
  labelClassName?: string;
71
+ paused?: boolean;
69
72
  }
70
73
 
71
74
  export function Loading({
@@ -74,32 +77,82 @@ export function Loading({
74
77
  className,
75
78
  label = 'Loading...',
76
79
  labelClassName,
80
+ paused = false,
77
81
  }: LoadingProps = {}) {
78
- const colors = createLoadingPalette(themeColor);
79
- const dots = [];
82
+ const gridRef = useRef<HTMLDivElement | null>(null);
83
+ const animationRef = useRef<JSAnimation | null>(null);
84
+ const pausedRef = useRef(paused);
85
+ const colors = useMemo(() => createLoadingPalette(themeColor), [themeColor]);
80
86
  const centerX = (NUM_COLS - 1) / 2;
81
87
  const centerY = (NUM_ROWS - 1) / 2;
82
-
83
- for (let i = 0; i < NUM_ROWS; i++) {
84
- for (let j = 0; j < NUM_COLS; j++) {
85
- // Calculate distance from the center of the grid
86
- const distance = Math.sqrt(Math.pow(i - centerY, 2) + Math.pow(j - centerX, 2));
87
- dots.push({
88
- id: `${i}-${j}`,
89
- row: i,
90
- col: j,
91
- // Animation delay based on distance, creating a ripple effect
92
- delay: distance * STAGGER_DELAY_FACTOR,
93
- // Color selection based on distance rings
94
- color: colors[Math.floor(distance) % colors.length],
95
- });
88
+ const dots = useMemo(() => {
89
+ const nextDots = [];
90
+
91
+ for (let i = 0; i < NUM_ROWS; i++) {
92
+ for (let j = 0; j < NUM_COLS; j++) {
93
+ const distance = Math.sqrt(Math.pow(i - centerY, 2) + Math.pow(j - centerX, 2));
94
+ nextDots.push({
95
+ id: `${i}-${j}`,
96
+ row: i,
97
+ col: j,
98
+ delay: distance * STAGGER_DELAY_FACTOR,
99
+ color: colors[Math.floor(distance) % colors.length],
100
+ });
101
+ }
96
102
  }
97
- }
103
+
104
+ return nextDots;
105
+ }, [centerX, centerY, colors]);
98
106
 
99
107
  // Calculate the total width and height of the dot container
100
108
  const containerWidth = (NUM_COLS - 1) * SPACING + DOT_SIZE;
101
109
  const containerHeight = (NUM_ROWS - 1) * SPACING + DOT_SIZE;
102
110
 
111
+ pausedRef.current = paused;
112
+
113
+ useEffect(() => {
114
+ const grid = gridRef.current;
115
+
116
+ if (!grid) {
117
+ return undefined;
118
+ }
119
+
120
+ const dotNodes = Array.from(grid.querySelectorAll<HTMLElement>('[data-loading-dot]'));
121
+
122
+ animationRef.current?.revert();
123
+ animationRef.current = animate(dotNodes, {
124
+ opacity: [0, 1, 0.7, 0],
125
+ scale: [0.2, 1.2, 0.8, 0.2],
126
+ duration: ANIMATION_DURATION * 1000,
127
+ delay: (target?: unknown) => Number((target as HTMLElement | undefined)?.dataset.loadingDelay ?? 0) * 1000,
128
+ ease: 'inOutSine',
129
+ loop: true,
130
+ });
131
+
132
+ if (pausedRef.current) {
133
+ animationRef.current.pause();
134
+ }
135
+
136
+ return () => {
137
+ animationRef.current?.revert();
138
+ animationRef.current = null;
139
+ };
140
+ }, [dots]);
141
+
142
+ useEffect(() => {
143
+ const animation = animationRef.current;
144
+
145
+ if (!animation) {
146
+ return;
147
+ }
148
+
149
+ if (paused) {
150
+ animation.pause();
151
+ } else {
152
+ animation.play();
153
+ }
154
+ }, [paused]);
155
+
103
156
  return (
104
157
  <div
105
158
  className={cn(
@@ -109,6 +162,7 @@ export function Loading({
109
162
  )}
110
163
  >
111
164
  <div
165
+ ref={gridRef}
112
166
  style={{
113
167
  width: containerWidth,
114
168
  height: containerHeight,
@@ -120,6 +174,8 @@ export function Loading({
120
174
  {dots.map(dot => (
121
175
  <div
122
176
  key={dot.id}
177
+ data-loading-dot=""
178
+ data-loading-delay={dot.delay}
123
179
  style={{
124
180
  position: 'absolute',
125
181
  left: dot.col * SPACING,
@@ -128,13 +184,8 @@ export function Loading({
128
184
  height: DOT_SIZE,
129
185
  backgroundColor: dot.color,
130
186
  borderRadius: '50%',
131
- animationName: 'loading-dot-pulse',
132
- animationDuration: `${ANIMATION_DURATION}s`,
133
- animationTimingFunction: 'cubic-bezier(0.45, 0, 0.55, 1)',
134
- animationIterationCount: 'infinite',
135
- animationDelay: `${dot.delay}s`,
136
- opacity: 0,
137
- transform: 'scale(0)',
187
+ opacity: 0.35,
188
+ transform: 'scale(0.2)',
138
189
  }}
139
190
  />
140
191
  ))}
@@ -0,0 +1,8 @@
1
+ 'use client';
2
+
3
+ export {
4
+ MotionBeamFrame,
5
+ MotionBeamFrame as BeamFrame,
6
+ type BeamFrameProps,
7
+ type BeamFrameTone,
8
+ } from './motion-beam-frame';
@@ -0,0 +1,137 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useId, useRef } from 'react';
4
+ import { animate, useReducedMotion } from 'motion/react';
5
+ import {
6
+ BASE_DURATION_SECONDS,
7
+ BeamFrameShell,
8
+ BeamSvgLayer,
9
+ normalizeDuration,
10
+ useInteractiveRunning,
11
+ useMeasuredFrameSize,
12
+ type BeamFrameProps,
13
+ type FrameSize,
14
+ } from '../beam-frame-config';
15
+
16
+ export type { BeamFrameProps, BeamFrameTone } from '../beam-frame-config';
17
+
18
+ type PlaybackControls = {
19
+ speed: number;
20
+ play: () => void;
21
+ pause: () => void;
22
+ stop: () => void;
23
+ };
24
+
25
+ function MotionAroundBeam({
26
+ isRunning,
27
+ duration,
28
+ radius,
29
+ size,
30
+ }: {
31
+ isRunning: boolean;
32
+ duration: number;
33
+ radius?: number;
34
+ size: FrameSize;
35
+ }) {
36
+ const auraGradientId = useId().replace(/:/g, '');
37
+ const softGlowFilterId = useId().replace(/:/g, '');
38
+ const beamGroupRef = useRef<SVGGElement | null>(null);
39
+ const controlsRef = useRef<PlaybackControls | null>(null);
40
+ const hasStartedRef = useRef(false);
41
+
42
+ useEffect(() => {
43
+ const node = beamGroupRef.current;
44
+
45
+ if (!node) {
46
+ return undefined;
47
+ }
48
+
49
+ if (isRunning) {
50
+ hasStartedRef.current = true;
51
+ }
52
+
53
+ node.style.opacity = isRunning || hasStartedRef.current ? 'var(--beam-frame-beam-opacity)' : '0';
54
+
55
+ if (!isRunning) {
56
+ controlsRef.current?.pause();
57
+ return undefined;
58
+ }
59
+
60
+ if (controlsRef.current) {
61
+ controlsRef.current.speed = BASE_DURATION_SECONDS / duration;
62
+ controlsRef.current.play();
63
+ return undefined;
64
+ }
65
+
66
+ controlsRef.current = animate(
67
+ node,
68
+ { strokeDashoffset: [0, -1] },
69
+ {
70
+ duration: BASE_DURATION_SECONDS,
71
+ repeat: Infinity,
72
+ ease: 'linear',
73
+ },
74
+ );
75
+ controlsRef.current.speed = BASE_DURATION_SECONDS / duration;
76
+
77
+ return undefined;
78
+ }, [duration, isRunning]);
79
+
80
+ useEffect(() => {
81
+ return () => {
82
+ controlsRef.current?.stop();
83
+ controlsRef.current = null;
84
+ };
85
+ }, []);
86
+
87
+ return (
88
+ <BeamSvgLayer
89
+ beamRef={beamGroupRef}
90
+ auraGradientId={auraGradientId}
91
+ softGlowFilterId={softGlowFilterId}
92
+ radius={radius}
93
+ size={size}
94
+ />
95
+ );
96
+ }
97
+
98
+ export function MotionBeamFrame(props: BeamFrameProps) {
99
+ const {
100
+ children,
101
+ active = false,
102
+ interactive = true,
103
+ tone = 'theme',
104
+ duration = BASE_DURATION_SECONDS,
105
+ radius,
106
+ className,
107
+ } = props;
108
+ const prefersReducedMotion = useReducedMotion();
109
+ const { isRunning, interactionProps } = useInteractiveRunning(active, interactive);
110
+ const shouldRun = isRunning && !prefersReducedMotion;
111
+ const normalizedDuration = normalizeDuration(duration);
112
+ const { ref, size } = useMeasuredFrameSize<HTMLDivElement>();
113
+
114
+ return (
115
+ <BeamFrameShell
116
+ active={active}
117
+ interactive={interactive}
118
+ tone={tone}
119
+ duration={normalizedDuration}
120
+ radius={radius}
121
+ className={className}
122
+ isRunning={shouldRun}
123
+ interactionProps={interactionProps}
124
+ rootRef={ref}
125
+ renderBeam={() => (
126
+ <MotionAroundBeam
127
+ isRunning={shouldRun}
128
+ duration={normalizedDuration}
129
+ radius={radius}
130
+ size={size}
131
+ />
132
+ )}
133
+ >
134
+ {children}
135
+ </BeamFrameShell>
136
+ );
137
+ }