@windrun-huaiin/third-ui 29.2.0 → 30.0.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 (102) hide show
  1. package/dist/fuma/mdx/cheet-table.d.ts +13 -0
  2. package/dist/fuma/mdx/cheet-table.js +295 -0
  3. package/dist/fuma/mdx/cheet-table.mjs +293 -0
  4. package/dist/fuma/mdx/index.d.ts +1 -0
  5. package/dist/fuma/mdx/index.js +2 -0
  6. package/dist/fuma/mdx/index.mjs +1 -0
  7. package/dist/fuma/server/features/widgets.js +2 -0
  8. package/dist/fuma/server/features/widgets.mjs +2 -0
  9. package/dist/lib/fuma-schema-check-util.d.ts +1 -1
  10. package/dist/main/alert-dialog/confirm-dialog.d.ts +2 -1
  11. package/dist/main/alert-dialog/confirm-dialog.js +3 -3
  12. package/dist/main/alert-dialog/confirm-dialog.mjs +4 -4
  13. package/dist/main/alert-dialog/dialog-loading-action.d.ts +2 -1
  14. package/dist/main/alert-dialog/dialog-loading-action.js +6 -3
  15. package/dist/main/alert-dialog/dialog-loading-action.mjs +6 -3
  16. package/dist/main/alert-dialog/dialog-styles.d.ts +4 -2
  17. package/dist/main/alert-dialog/dialog-styles.js +8 -4
  18. package/dist/main/alert-dialog/dialog-styles.mjs +7 -5
  19. package/dist/main/alert-dialog/high-priority-confirm-dialog.d.ts +2 -1
  20. package/dist/main/alert-dialog/high-priority-confirm-dialog.js +7 -7
  21. package/dist/main/alert-dialog/high-priority-confirm-dialog.mjs +8 -8
  22. package/dist/main/alert-dialog/info-dialog.d.ts +2 -1
  23. package/dist/main/alert-dialog/info-dialog.js +3 -3
  24. package/dist/main/alert-dialog/info-dialog.mjs +4 -4
  25. package/dist/main/alert-dialog/undoable-confirm-dialog.d.ts +2 -1
  26. package/dist/main/alert-dialog/undoable-confirm-dialog.js +4 -4
  27. package/dist/main/alert-dialog/undoable-confirm-dialog.mjs +5 -5
  28. package/dist/main/anime/anime-beam-frame.d.ts +3 -0
  29. package/dist/main/anime/anime-beam-frame.js +63 -0
  30. package/dist/main/anime/anime-beam-frame.mjs +61 -0
  31. package/dist/main/anime/anime-spiral-loading.d.ts +10 -0
  32. package/dist/main/anime/anime-spiral-loading.js +77 -0
  33. package/dist/main/anime/anime-spiral-loading.mjs +75 -0
  34. package/dist/main/anime/index.d.ts +2 -0
  35. package/dist/main/anime/index.js +10 -0
  36. package/dist/main/anime/index.mjs +3 -0
  37. package/dist/main/beam-frame/animate.d.ts +3 -0
  38. package/dist/main/beam-frame/animate.js +63 -0
  39. package/dist/main/beam-frame/animate.mjs +61 -0
  40. package/dist/main/beam-frame/beam-frame.d.ts +4 -0
  41. package/dist/main/beam-frame/beam-frame.js +262 -0
  42. package/dist/main/beam-frame/beam-frame.mjs +258 -0
  43. package/dist/main/beam-frame/index.d.ts +4 -0
  44. package/dist/main/beam-frame/index.js +11 -0
  45. package/dist/main/beam-frame/index.mjs +3 -0
  46. package/dist/main/beam-frame/motion.d.ts +3 -0
  47. package/dist/main/beam-frame/motion.js +61 -0
  48. package/dist/main/beam-frame/motion.mjs +59 -0
  49. package/dist/main/beam-frame/share-config.d.ts +54 -0
  50. package/dist/main/beam-frame/share-config.js +161 -0
  51. package/dist/main/beam-frame/share-config.mjs +152 -0
  52. package/dist/main/beam-frame-config.d.ts +54 -0
  53. package/dist/main/beam-frame-config.js +161 -0
  54. package/dist/main/beam-frame-config.mjs +152 -0
  55. package/dist/main/calendar/random-date-range-dialog.d.ts +5 -2
  56. package/dist/main/calendar/random-date-range-dialog.js +239 -109
  57. package/dist/main/calendar/random-date-range-dialog.mjs +242 -112
  58. package/dist/main/cta.js +17 -1
  59. package/dist/main/cta.mjs +18 -2
  60. package/dist/main/delayed-img.d.ts +1 -1
  61. package/dist/main/delayed-img.js +8 -5
  62. package/dist/main/delayed-img.mjs +8 -5
  63. package/dist/main/info-tooltip.js +70 -9
  64. package/dist/main/info-tooltip.mjs +70 -9
  65. package/dist/main/loading-frame/index.d.ts +1 -0
  66. package/dist/main/loading.d.ts +2 -1
  67. package/dist/main/loading.js +64 -26
  68. package/dist/main/loading.mjs +64 -26
  69. package/dist/main/motion/index.d.ts +1 -0
  70. package/dist/main/motion/index.js +9 -0
  71. package/dist/main/motion/index.mjs +2 -0
  72. package/dist/main/motion/motion-beam-frame.d.ts +3 -0
  73. package/dist/main/motion/motion-beam-frame.js +61 -0
  74. package/dist/main/motion/motion-beam-frame.mjs +59 -0
  75. package/dist/main/snake-loading-frame.d.ts +7 -3
  76. package/dist/main/snake-loading-frame.js +44 -252
  77. package/dist/main/snake-loading-frame.mjs +46 -254
  78. package/package.json +16 -5
  79. package/src/fuma/mdx/cheet-table.tsx +650 -0
  80. package/src/fuma/mdx/index.ts +1 -0
  81. package/src/fuma/server/features/widgets.tsx +2 -0
  82. package/src/main/alert-dialog/confirm-dialog.tsx +5 -2
  83. package/src/main/alert-dialog/dialog-loading-action.tsx +22 -5
  84. package/src/main/alert-dialog/dialog-styles.ts +13 -3
  85. package/src/main/alert-dialog/high-priority-confirm-dialog.tsx +29 -24
  86. package/src/main/alert-dialog/info-dialog.tsx +5 -2
  87. package/src/main/alert-dialog/undoable-confirm-dialog.tsx +21 -18
  88. package/src/main/anime/anime-beam-frame.tsx +128 -0
  89. package/src/main/anime/anime-spiral-loading.tsx +123 -0
  90. package/src/main/anime/index.ts +9 -0
  91. package/src/main/beam-frame-config.tsx +341 -0
  92. package/src/main/calendar/random-date-range-dialog.tsx +242 -74
  93. package/src/main/cta.tsx +50 -21
  94. package/src/main/delayed-img.tsx +9 -4
  95. package/src/main/info-tooltip.tsx +116 -20
  96. package/src/main/loading-frame/index.ts +4 -0
  97. package/src/main/loading.tsx +75 -24
  98. package/src/main/motion/index.ts +8 -0
  99. package/src/main/motion/motion-beam-frame.tsx +137 -0
  100. package/src/main/snake-loading-frame.tsx +95 -496
  101. package/src/styles/cta.css +21 -4
  102. package/src/styles/third-ui.css +0 -20
@@ -3,17 +3,20 @@
3
3
  import React from 'react';
4
4
  import { createPortal } from 'react-dom';
5
5
  import { Loading } from '../loading';
6
+ import { dialogLoadingContentClass } from './dialog-styles';
6
7
 
7
8
  export type DialogLoadingAction = 'cancel' | 'confirm' | 'undo';
8
9
  export type DialogActionHandler = () => void | Promise<void>;
9
10
 
10
11
  interface UseDialogLoadingActionOptions {
11
12
  loadingActions?: readonly DialogLoadingAction[];
13
+ loadingFullPage?: boolean;
12
14
  onOpenChange: (open: boolean) => void;
13
15
  }
14
16
 
15
17
  export function useDialogLoadingAction({
16
18
  loadingActions,
19
+ loadingFullPage = false,
17
20
  onOpenChange,
18
21
  }: UseDialogLoadingActionOptions) {
19
22
  const [mounted, setMounted] = React.useState(false);
@@ -27,18 +30,19 @@ export function useDialogLoadingAction({
27
30
  action: DialogLoadingAction,
28
31
  handler?: DialogActionHandler
29
32
  ) => {
30
- onOpenChange(false);
31
-
32
33
  if (!handler) {
34
+ onOpenChange(false);
33
35
  return;
34
36
  }
35
37
 
36
38
  if (!loadingActions?.includes(action)) {
39
+ onOpenChange(false);
37
40
  await handler();
38
41
  return;
39
42
  }
40
43
 
41
44
  setLoading(true);
45
+ onOpenChange(false);
42
46
 
43
47
  try {
44
48
  await handler();
@@ -49,9 +53,22 @@ export function useDialogLoadingAction({
49
53
 
50
54
  const dialogLoading = mounted && loading
51
55
  ? createPortal(
52
- <div className="fixed inset-0 z-10000">
53
- <Loading className="h-full w-full" />
54
- </div>,
56
+ loadingFullPage ? (
57
+ <div className="fixed inset-0 z-10000">
58
+ <Loading className="h-full w-full" />
59
+ </div>
60
+ ) : (
61
+ <div className="fixed inset-0 z-10000">
62
+ <div className={dialogLoadingContentClass}>
63
+ <Loading
64
+ compact
65
+ label="Loading"
66
+ className="min-h-[220px] w-fit max-w-full rounded-none bg-transparent px-0 py-0 dark:bg-transparent"
67
+ labelClassName="text-foreground"
68
+ />
69
+ </div>
70
+ </div>
71
+ ),
55
72
  document.body
56
73
  )
57
74
  : null;
@@ -12,7 +12,7 @@ import {
12
12
  import { cn } from '@windrun-huaiin/lib/utils';
13
13
 
14
14
  export const dialogSurfaceClass = cn(
15
- 'w-[calc(100vw-2rem)] max-w-md rounded-2xl border bg-white p-5 text-neutral-950 shadow-2xl outline-none dark:bg-neutral-950 dark:text-neutral-50',
15
+ 'min-h-[240px] w-[calc(100vw-2rem)] max-w-md rounded-2xl border bg-white p-5 text-neutral-950 shadow-2xl outline-none dark:bg-neutral-950 dark:text-neutral-50',
16
16
  'border-neutral-200 dark:border-neutral-800'
17
17
  );
18
18
 
@@ -26,13 +26,22 @@ export const dialogContentClass = cn(
26
26
  dialogSurfaceClass
27
27
  );
28
28
 
29
+ export const dialogLoadingContentClass = cn(
30
+ 'fixed left-1/2 top-1/2 z-10000 -translate-x-1/2 -translate-y-1/2',
31
+ dialogSurfaceClass,
32
+ 'flex items-center justify-center overflow-hidden animate-in fade-in-0 zoom-in-95 duration-150'
33
+ );
34
+
29
35
  export const dialogHeaderClass = 'flex items-start justify-between gap-4';
30
36
 
31
37
  export const dialogTitleClass =
32
- 'flex min-w-0 items-center gap-2 text-lg font-bold leading-tight text-neutral-950 dark:text-neutral-50';
38
+ 'flex min-w-0 flex-1 items-center gap-2 text-lg font-bold leading-tight text-neutral-950 dark:text-neutral-50';
39
+
40
+ export const dialogTitleTextClass =
41
+ 'min-w-0 flex-1 line-clamp-2 break-words';
33
42
 
34
43
  export const dialogDescriptionClass =
35
- 'mt-3 text-sm font-medium leading-relaxed text-neutral-600 dark:text-neutral-300';
44
+ 'mt-3 break-words text-sm font-medium leading-relaxed text-neutral-600 dark:text-neutral-300';
36
45
 
37
46
  export const dialogFooterClass = 'mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end';
38
47
 
@@ -67,6 +76,7 @@ export const highPriorityTitleClass = cn(
67
76
 
68
77
  export const highPrioritySurfaceClass = cn(
69
78
  dialogSurfaceClass,
79
+ 'flex flex-col',
70
80
  'backdrop-blur-md ring-4 animate-in zoom-in-95 duration-300',
71
81
  themeBorderColor,
72
82
  themeRingColor
@@ -11,6 +11,7 @@ import {
11
11
  dialogHeaderClass,
12
12
  highPrioritySurfaceClass,
13
13
  highPriorityTitleClass,
14
+ dialogTitleTextClass,
14
15
  primaryButtonClass,
15
16
  secondaryButtonClass,
16
17
  } from "./dialog-styles";
@@ -27,6 +28,7 @@ interface HighPriorityConfirmDialogProps {
27
28
  confirmText?: string;
28
29
  cancelText?: string;
29
30
  loadingActions?: readonly DialogLoadingAction[];
31
+ loadingFullPage?: boolean;
30
32
  }
31
33
 
32
34
  export function HighPriorityConfirmDialog({
@@ -39,9 +41,10 @@ export function HighPriorityConfirmDialog({
39
41
  confirmText = "Confirm",
40
42
  cancelText = "Cancel",
41
43
  loadingActions,
44
+ loadingFullPage,
42
45
  }: HighPriorityConfirmDialogProps) {
43
46
  const [mounted, setMounted] = useState(false);
44
- const { dialogLoading, runDialogAction } = useDialogLoadingAction({ loadingActions, onOpenChange });
47
+ const { dialogLoading, runDialogAction } = useDialogLoadingAction({ loadingActions, loadingFullPage, onOpenChange });
45
48
 
46
49
  useEffect(() => {
47
50
  // Ensure portal target exists and prevent hydration mismatch
@@ -69,7 +72,7 @@ export function HighPriorityConfirmDialog({
69
72
  <span className={cn('inline-flex size-9 shrink-0 items-center justify-center rounded-full ring-1', themeBgColor, themeBorderColor)}>
70
73
  <FAQSIcon className={cn('size-5', themeIconColor)} />
71
74
  </span>
72
- <span className="min-w-0 truncate">{title}</span>
75
+ <span className={dialogTitleTextClass}>{title}</span>
73
76
  </h3>
74
77
  <button
75
78
  type="button"
@@ -80,28 +83,30 @@ export function HighPriorityConfirmDialog({
80
83
  <XIcon className="size-4" />
81
84
  </button>
82
85
  </div>
83
- <div className={dialogDescriptionClass}>
84
- {description}
85
- </div>
86
- <div className={dialogFooterClass}>
87
- <button
88
- type="button"
89
- onClick={() => {
90
- void runDialogAction('cancel', onCancel);
91
- }}
92
- className={secondaryButtonClass}
93
- >
94
- {cancelText}
95
- </button>
96
- <button
97
- type="button"
98
- onClick={() => {
99
- void runDialogAction('confirm', onConfirm);
100
- }}
101
- className={cn(primaryButtonClass, "hover:scale-105 active:scale-95")}
102
- >
103
- {confirmText}
104
- </button>
86
+ <div className="flex min-h-0 flex-1 flex-col">
87
+ <div className={dialogDescriptionClass}>
88
+ {description}
89
+ </div>
90
+ <div className={cn(dialogFooterClass, 'mt-auto')}>
91
+ <button
92
+ type="button"
93
+ onClick={() => {
94
+ void runDialogAction('cancel', onCancel);
95
+ }}
96
+ className={secondaryButtonClass}
97
+ >
98
+ {cancelText}
99
+ </button>
100
+ <button
101
+ type="button"
102
+ onClick={() => {
103
+ void runDialogAction('confirm', onConfirm);
104
+ }}
105
+ className={cn(primaryButtonClass, "hover:scale-105 active:scale-95")}
106
+ >
107
+ {confirmText}
108
+ </button>
109
+ </div>
105
110
  </div>
106
111
  </div>
107
112
  </div>,
@@ -24,6 +24,7 @@ import {
24
24
  dialogHeaderClass,
25
25
  dialogThemedOverlayClass,
26
26
  dialogTitleClass,
27
+ dialogTitleTextClass,
27
28
  } from './dialog-styles';
28
29
  import { DialogLoadingAction, DialogActionHandler, useDialogLoadingAction } from './dialog-loading-action';
29
30
 
@@ -38,6 +39,7 @@ interface InfoDialogProps {
38
39
  description: React.ReactNode;
39
40
  confirmText?: string;
40
41
  loadingActions?: readonly DialogLoadingAction[];
42
+ loadingFullPage?: boolean;
41
43
  onConfirm?: DialogActionHandler;
42
44
  }
43
45
 
@@ -86,12 +88,13 @@ export function InfoDialog({
86
88
  description,
87
89
  confirmText = 'OK',
88
90
  loadingActions,
91
+ loadingFullPage,
89
92
  onConfirm,
90
93
  }: InfoDialogProps) {
91
94
  const typeClass = infoTypeClassMap[type];
92
95
  const Icon = typeClass.Icon;
93
96
  const handleClose = () => onOpenChange(false);
94
- const { dialogLoading, runDialogAction } = useDialogLoadingAction({ loadingActions, onOpenChange });
97
+ const { dialogLoading, runDialogAction } = useDialogLoadingAction({ loadingActions, loadingFullPage, onOpenChange });
95
98
 
96
99
  return (
97
100
  <>
@@ -107,7 +110,7 @@ export function InfoDialog({
107
110
  <span className={cn('inline-flex size-9 shrink-0 items-center justify-center rounded-full ring-1', typeClass.iconWrap)}>
108
111
  <Icon className={cn('size-5', typeClass.icon)} />
109
112
  </span>
110
- <span className="min-w-0 truncate">{title}</span>
113
+ <span className={dialogTitleTextClass}>{title}</span>
111
114
  </div>
112
115
  </AlertDialogTitle>
113
116
  <button
@@ -19,6 +19,7 @@ import {
19
19
  dialogHeaderClass,
20
20
  dialogThemedOverlayClass,
21
21
  dialogTitleClass,
22
+ dialogTitleTextClass,
22
23
  secondaryButtonClass,
23
24
  } from './dialog-styles';
24
25
  import type { ConfirmDialogEmphasis } from './confirm-dialog';
@@ -37,6 +38,7 @@ export interface UndoableConfirmDialogProps {
37
38
  emphasis?: ConfirmDialogEmphasis;
38
39
  countdownSeconds?: number;
39
40
  loadingActions?: readonly DialogLoadingAction[];
41
+ loadingFullPage?: boolean;
40
42
  onCancel?: DialogActionHandler;
41
43
  onConfirm: DialogActionHandler;
42
44
  onUndo?: DialogActionHandler;
@@ -55,6 +57,7 @@ export function UndoableConfirmDialog({
55
57
  emphasis = 'confirm',
56
58
  countdownSeconds = 5,
57
59
  loadingActions,
60
+ loadingFullPage,
58
61
  onCancel,
59
62
  onConfirm,
60
63
  onUndo,
@@ -67,7 +70,7 @@ export function UndoableConfirmDialog({
67
70
  const intervalRef = React.useRef<number | null>(null);
68
71
  const cancelButtonClass = emphasis === 'cancel' ? dangerButtonClass : secondaryButtonClass;
69
72
  const confirmButtonClass = emphasis === 'cancel' ? secondaryButtonClass : dangerButtonClass;
70
- const { dialogLoading, runDialogAction } = useDialogLoadingAction({ loadingActions, onOpenChange });
73
+ const { dialogLoading, runDialogAction } = useDialogLoadingAction({ loadingActions, loadingFullPage, onOpenChange });
71
74
 
72
75
  const clearTimers = React.useCallback(() => {
73
76
  if (timeoutRef.current) {
@@ -161,7 +164,7 @@ export function UndoableConfirmDialog({
161
164
  <span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-600 ring-1 ring-red-200 dark:bg-red-950 dark:text-red-300 dark:ring-red-900">
162
165
  {pending ? <Trash2Icon className="size-5" /> : <CircleAlertIcon className="size-5" />}
163
166
  </span>
164
- <span className="min-w-0 truncate">{displayTitle}</span>
167
+ <span className={dialogTitleTextClass}>{displayTitle}</span>
165
168
  </div>
166
169
  </AlertDialogTitle>
167
170
  <button
@@ -197,7 +200,7 @@ export function UndoableConfirmDialog({
197
200
  onClick={() => {
198
201
  void handleUndo();
199
202
  }}
200
- className={secondaryButtonClass}
203
+ className={cn(secondaryButtonClass, 'w-full sm:w-auto')}
201
204
  disabled={confirming}
202
205
  >
203
206
  <Undo2Icon className="mr-1.5 size-4" />
@@ -205,21 +208,21 @@ export function UndoableConfirmDialog({
205
208
  </button>
206
209
  ) : (
207
210
  <>
208
- <button
209
- type="button"
210
- onClick={handleCancel}
211
- className={cancelButtonClass}
212
- >
213
- {cancelText}
214
- </button>
215
- <button
216
- type="button"
217
- onClick={startCountdown}
218
- className={confirmButtonClass}
219
- >
220
- {confirmText}
221
- </button>
222
- </>
211
+ <button
212
+ type="button"
213
+ onClick={handleCancel}
214
+ className={cn(cancelButtonClass, 'w-full sm:w-auto')}
215
+ >
216
+ {cancelText}
217
+ </button>
218
+ <button
219
+ type="button"
220
+ onClick={startCountdown}
221
+ className={cn(confirmButtonClass, 'w-full sm:w-auto')}
222
+ >
223
+ {confirmText}
224
+ </button>
225
+ </>
223
226
  )}
224
227
  </div>
225
228
  </AlertDialogContent>
@@ -0,0 +1,128 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useId, useRef } from 'react';
4
+ import { type WAAPIAnimation, waapi } from 'animejs';
5
+ import { useReducedMotion } from 'motion/react';
6
+ import {
7
+ BASE_DURATION_SECONDS,
8
+ BeamFrameShell,
9
+ BeamSvgLayer,
10
+ normalizeDuration,
11
+ useInteractiveRunning,
12
+ useMeasuredFrameSize,
13
+ type BeamFrameProps,
14
+ type FrameSize,
15
+ } from '../beam-frame-config';
16
+
17
+ export type { BeamFrameProps, BeamFrameTone } from '../beam-frame-config';
18
+
19
+ function AnimeBeamLayer({
20
+ isRunning,
21
+ duration,
22
+ radius,
23
+ size,
24
+ }: {
25
+ isRunning: boolean;
26
+ duration: number;
27
+ radius?: number;
28
+ size: FrameSize;
29
+ }) {
30
+ const aroundBeamRef = useRef<SVGGElement | null>(null);
31
+ const animationRef = useRef<WAAPIAnimation | null>(null);
32
+ const hasStartedRef = useRef(false);
33
+ const auraGradientId = useId().replace(/:/g, '');
34
+ const softGlowFilterId = useId().replace(/:/g, '');
35
+
36
+ useEffect(() => {
37
+ const node = aroundBeamRef.current;
38
+
39
+ if (!node) {
40
+ return undefined;
41
+ }
42
+
43
+ if (isRunning) {
44
+ hasStartedRef.current = true;
45
+ }
46
+
47
+ node.style.opacity = isRunning || hasStartedRef.current ? 'var(--beam-frame-beam-opacity)' : '0';
48
+
49
+ if (!isRunning) {
50
+ animationRef.current?.pause();
51
+ return undefined;
52
+ }
53
+
54
+ if (animationRef.current) {
55
+ animationRef.current.speed = BASE_DURATION_SECONDS / duration;
56
+ animationRef.current.play();
57
+ return undefined;
58
+ }
59
+
60
+ animationRef.current = waapi.animate(node, {
61
+ strokeDashoffset: [0, -1],
62
+ loop: true,
63
+ duration: BASE_DURATION_SECONDS * 1000,
64
+ ease: 'linear',
65
+ });
66
+ animationRef.current.speed = BASE_DURATION_SECONDS / duration;
67
+
68
+ return undefined;
69
+ }, [duration, isRunning]);
70
+
71
+ useEffect(() => {
72
+ return () => {
73
+ animationRef.current?.revert();
74
+ animationRef.current = null;
75
+ };
76
+ }, []);
77
+
78
+ return (
79
+ <BeamSvgLayer
80
+ beamRef={aroundBeamRef}
81
+ auraGradientId={auraGradientId}
82
+ softGlowFilterId={softGlowFilterId}
83
+ radius={radius}
84
+ size={size}
85
+ />
86
+ );
87
+ }
88
+
89
+ export function AnimeBeamFrame(props: BeamFrameProps) {
90
+ const {
91
+ children,
92
+ active = false,
93
+ interactive = true,
94
+ tone = 'theme',
95
+ duration = BASE_DURATION_SECONDS,
96
+ radius,
97
+ className,
98
+ } = props;
99
+ const prefersReducedMotion = useReducedMotion();
100
+ const { isRunning, interactionProps } = useInteractiveRunning(active, interactive);
101
+ const shouldRun = isRunning && !prefersReducedMotion;
102
+ const normalizedDuration = normalizeDuration(duration);
103
+ const { ref, size } = useMeasuredFrameSize<HTMLDivElement>();
104
+
105
+ return (
106
+ <BeamFrameShell
107
+ active={active}
108
+ interactive={interactive}
109
+ tone={tone}
110
+ duration={normalizedDuration}
111
+ radius={radius}
112
+ className={className}
113
+ isRunning={shouldRun}
114
+ interactionProps={interactionProps}
115
+ rootRef={ref}
116
+ renderBeam={() => (
117
+ <AnimeBeamLayer
118
+ isRunning={shouldRun}
119
+ duration={normalizedDuration}
120
+ radius={radius}
121
+ size={size}
122
+ />
123
+ )}
124
+ >
125
+ {children}
126
+ </BeamFrameShell>
127
+ );
128
+ }
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useRef } from 'react';
4
+ import { createTimeline, stagger, type Timeline, utils } from 'animejs';
5
+ import { cn } from '@windrun-huaiin/lib/utils';
6
+
7
+ const DEFAULT_DOT_COUNT = 2024;
8
+ const DEFAULT_DURATION = 10000;
9
+ const DEFAULT_DISTANCE_REM = 20;
10
+ const DEFAULT_DOT_SIZE_EM = 1;
11
+ const DEFAULT_FONT_SIZE = 20;
12
+
13
+ export interface AnimeSpiralLoadingProps {
14
+ className?: string;
15
+ dotCount?: number;
16
+ duration?: number;
17
+ distanceRem?: number;
18
+ dotSizeEm?: number;
19
+ fontSize?: number;
20
+ paused?: boolean;
21
+ }
22
+
23
+ export function AnimeSpiralLoading({
24
+ className,
25
+ dotCount = DEFAULT_DOT_COUNT,
26
+ duration = DEFAULT_DURATION,
27
+ distanceRem = DEFAULT_DISTANCE_REM,
28
+ dotSizeEm = DEFAULT_DOT_SIZE_EM,
29
+ fontSize = DEFAULT_FONT_SIZE,
30
+ paused = false,
31
+ }: AnimeSpiralLoadingProps) {
32
+ const rootRef = useRef<HTMLDivElement | null>(null);
33
+ const timelineRef = useRef<Timeline | null>(null);
34
+ const pausedRef = useRef(paused);
35
+ const safeDotCount = Math.max(1, Math.floor(dotCount));
36
+ const safeDuration = Math.max(1, duration);
37
+ const angle = useMemo(
38
+ () => (index: number) => utils.mapRange(index, 0, safeDotCount, 0, Math.PI * 100),
39
+ [safeDotCount]
40
+ );
41
+ const dots = useMemo(
42
+ () => Array.from({ length: safeDotCount }, (_, index) => {
43
+ const hue = utils.round((360 / safeDotCount) * index, 0);
44
+
45
+ return {
46
+ id: index,
47
+ background: `hsl(${hue}, 60%, 60%)`,
48
+ };
49
+ }),
50
+ [safeDotCount]
51
+ );
52
+
53
+ pausedRef.current = paused;
54
+
55
+ useEffect(() => {
56
+ const root = rootRef.current;
57
+
58
+ if (!root) {
59
+ return undefined;
60
+ }
61
+
62
+ const dotNodes = Array.from(root.querySelectorAll<HTMLElement>('[data-anime-spiral-dot]'));
63
+
64
+ timelineRef.current?.revert();
65
+ timelineRef.current = createTimeline()
66
+ .add(dotNodes, {
67
+ x: (_target: unknown, i: number) => `${Math.sin(angle(i)) * distanceRem}rem`,
68
+ y: (_target: unknown, i: number) => `${Math.cos(angle(i)) * distanceRem}rem`,
69
+ scale: [0, 0.4, 0.2, 0.9, 0],
70
+ playbackEase: 'inOutSine',
71
+ loop: true,
72
+ duration: safeDuration,
73
+ }, stagger([0, safeDuration]))
74
+ .init()
75
+ .seek(safeDuration);
76
+
77
+ if (pausedRef.current) {
78
+ timelineRef.current.pause();
79
+ }
80
+
81
+ return () => {
82
+ timelineRef.current?.revert();
83
+ timelineRef.current = null;
84
+ };
85
+ }, [angle, distanceRem, safeDuration, dots]);
86
+
87
+ useEffect(() => {
88
+ const timeline = timelineRef.current;
89
+
90
+ if (!timeline) {
91
+ return;
92
+ }
93
+
94
+ if (paused) {
95
+ timeline.pause();
96
+ } else {
97
+ timeline.play();
98
+ }
99
+ }, [paused]);
100
+
101
+ return (
102
+ <div
103
+ ref={rootRef}
104
+ className={cn('relative h-dvh w-full overflow-hidden', className)}
105
+ style={{ fontSize }}
106
+ aria-hidden="true"
107
+ >
108
+ {dots.map(dot => (
109
+ <div
110
+ key={dot.id}
111
+ data-anime-spiral-dot=""
112
+ className="absolute left-1/2 top-1/2 rounded-full"
113
+ style={{
114
+ width: `${dotSizeEm}em`,
115
+ height: `${dotSizeEm}em`,
116
+ margin: `${dotSizeEm / -2}em 0 0 ${dotSizeEm / -2}em`,
117
+ backgroundColor: dot.background,
118
+ }}
119
+ />
120
+ ))}
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,9 @@
1
+ 'use client';
2
+
3
+ export {
4
+ AnimeBeamFrame,
5
+ type BeamFrameProps,
6
+ type BeamFrameTone,
7
+ } from './anime-beam-frame';
8
+
9
+ export { AnimeSpiralLoading, type AnimeSpiralLoadingProps } from './anime-spiral-loading';