@djangocfg/widget-visual 0.1.1
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/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +106 -0
- package/src/design/ColorPalette/ColorPalette.tsx +129 -0
- package/src/design/ColorPalette/README.md +34 -0
- package/src/design/ColorPalette/color-palette.stories.tsx +95 -0
- package/src/design/ColorPalette/components/Swatch.tsx +102 -0
- package/src/design/ColorPalette/hooks/useCopyToClipboard.ts +41 -0
- package/src/design/ColorPalette/index.ts +12 -0
- package/src/design/ColorPalette/lazy.tsx +21 -0
- package/src/design/ColorPalette/types.ts +63 -0
- package/src/design/ColorPalette/utils.ts +114 -0
- package/src/design/ColorPicker/ColorPicker.tsx +257 -0
- package/src/design/ColorPicker/color-picker.stories.tsx +113 -0
- package/src/design/ColorPicker/context/ColorPickerContext.tsx +28 -0
- package/src/design/ColorPicker/context/ColorPickerStore.tsx +166 -0
- package/src/design/ColorPicker/context/index.ts +2 -0
- package/src/design/ColorPicker/index.ts +15 -0
- package/src/design/ColorPicker/lazy.tsx +24 -0
- package/src/design/ColorPicker/lib/color-utils.ts +323 -0
- package/src/design/ColorPicker/parts/ColorPickerAlphaSlider.tsx +72 -0
- package/src/design/ColorPicker/parts/ColorPickerArea.tsx +155 -0
- package/src/design/ColorPicker/parts/ColorPickerEyeDropper.tsx +73 -0
- package/src/design/ColorPicker/parts/ColorPickerFormatSelect.tsx +64 -0
- package/src/design/ColorPicker/parts/ColorPickerHueSlider.tsx +64 -0
- package/src/design/ColorPicker/parts/ColorPickerInput.tsx +512 -0
- package/src/design/ColorPicker/parts/ColorPickerSwatch.tsx +63 -0
- package/src/design/ColorPicker/parts/index.ts +7 -0
- package/src/design/ColorPicker/types.ts +77 -0
- package/src/gallery/components/Gallery.tsx +182 -0
- package/src/gallery/components/compact/GalleryCompact.tsx +396 -0
- package/src/gallery/components/compact/index.ts +1 -0
- package/src/gallery/components/index.ts +21 -0
- package/src/gallery/components/lightbox/GalleryLightbox.tsx +426 -0
- package/src/gallery/components/lightbox/index.ts +1 -0
- package/src/gallery/components/media/GalleryImage.tsx +105 -0
- package/src/gallery/components/media/GalleryMedia.tsx +70 -0
- package/src/gallery/components/media/GalleryVideo.tsx +241 -0
- package/src/gallery/components/media/index.ts +3 -0
- package/src/gallery/components/preview/GalleryCarousel.tsx +374 -0
- package/src/gallery/components/preview/GalleryGrid.tsx +519 -0
- package/src/gallery/components/preview/index.ts +2 -0
- package/src/gallery/components/shared/ImageSpinner.tsx +37 -0
- package/src/gallery/components/shared/index.ts +1 -0
- package/src/gallery/components/thumbnails/GalleryThumbnails.tsx +198 -0
- package/src/gallery/components/thumbnails/GalleryThumbnailsVirtual.tsx +164 -0
- package/src/gallery/components/thumbnails/index.ts +2 -0
- package/src/gallery/gallery.stories.tsx +182 -0
- package/src/gallery/hooks/index.ts +23 -0
- package/src/gallery/hooks/useGallery.ts +138 -0
- package/src/gallery/hooks/useImageDimensions.ts +224 -0
- package/src/gallery/hooks/usePinchZoom.ts +239 -0
- package/src/gallery/hooks/usePreloadImages.ts +119 -0
- package/src/gallery/hooks/useSwipe.ts +87 -0
- package/src/gallery/hooks/useVirtualList.ts +129 -0
- package/src/gallery/hooks/useZoom.ts +321 -0
- package/src/gallery/index.ts +66 -0
- package/src/gallery/thumbnails.stories.tsx +127 -0
- package/src/gallery/types.ts +185 -0
- package/src/gallery/utils/imageAnalysis.ts +52 -0
- package/src/gallery/utils/index.ts +16 -0
- package/src/gallery/utils/normalizeUrl.ts +31 -0
- package/src/index.ts +18 -0
- package/src/lottie/LottiePlayer.client.tsx +319 -0
- package/src/lottie/index.tsx +66 -0
- package/src/lottie/lazy.tsx +65 -0
- package/src/lottie/lottie-player.stories.tsx +173 -0
- package/src/lottie/types.ts +138 -0
- package/src/lottie/useLottie.ts +187 -0
- package/src/lottie/usePrefersReducedMotion.ts +46 -0
- package/src/marquee/Marquee.tsx +637 -0
- package/src/marquee/index.ts +7 -0
- package/src/marquee/lazy.tsx +14 -0
- package/src/marquee/marquee.stories.tsx +118 -0
- package/src/marquee/types.ts +40 -0
- package/src/qrcode/QRCode.tsx +468 -0
- package/src/qrcode/index.ts +10 -0
- package/src/qrcode/lazy.tsx +19 -0
- package/src/qrcode/qr-code.stories.tsx +102 -0
- package/src/qrcode/types.ts +57 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lazy-loaded LottiePlayer Component
|
|
5
|
+
*
|
|
6
|
+
* Lottie library (~200KB) is loaded only when component is rendered.
|
|
7
|
+
* Use this for automatic code-splitting with Suspense fallback.
|
|
8
|
+
*
|
|
9
|
+
* For direct imports without lazy loading, use:
|
|
10
|
+
* import { LottiePlayer } from '@djangocfg/widget-visual/lottie-player'
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createLazyComponent } from '@djangocfg/widget-kit/lazy';
|
|
14
|
+
import type { LottiePlayerProps } from './types';
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Re-export types
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
export type {
|
|
21
|
+
LottiePlayerProps,
|
|
22
|
+
LottieSize,
|
|
23
|
+
LottieSpeed,
|
|
24
|
+
LottieDirection,
|
|
25
|
+
LottieSegment,
|
|
26
|
+
} from './types';
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Lottie Loading Fallback
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
function LottieLoadingFallback() {
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
className="flex items-center justify-center p-8"
|
|
36
|
+
role="status"
|
|
37
|
+
aria-live="polite"
|
|
38
|
+
>
|
|
39
|
+
<div className="flex flex-col items-center gap-2">
|
|
40
|
+
<div
|
|
41
|
+
className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary motion-reduce:animate-[spin_1.5s_linear_infinite]"
|
|
42
|
+
aria-hidden="true"
|
|
43
|
+
/>
|
|
44
|
+
<span className="text-sm text-muted-foreground">Loading animation...</span>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Lazy Component
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* LazyLottiePlayer - Lazy-loaded Lottie animation player
|
|
56
|
+
*
|
|
57
|
+
* Automatically shows loading state while Lottie loads (~200KB)
|
|
58
|
+
*/
|
|
59
|
+
export const LazyLottiePlayer = createLazyComponent<LottiePlayerProps>(
|
|
60
|
+
() => import('./LottiePlayer.client').then((mod) => ({ default: mod.LottiePlayer })),
|
|
61
|
+
{
|
|
62
|
+
displayName: 'LazyLottiePlayer',
|
|
63
|
+
fallback: <LottieLoadingFallback />,
|
|
64
|
+
}
|
|
65
|
+
);
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { LazyLottiePlayer as LottiePlayer } from './lazy';
|
|
3
|
+
|
|
4
|
+
// Public CC0 Lottie animations (stable hosts).
|
|
5
|
+
const ANIMATIONS = {
|
|
6
|
+
loadingDots: 'https://assets1.lottiefiles.com/packages/lf20_iv4dsx3q.json',
|
|
7
|
+
successCheck:
|
|
8
|
+
'https://lottie.host/4db68bbd-31f6-4cd8-84eb-189de081159a/IGmMCqhzpt.json',
|
|
9
|
+
rocket: 'https://assets3.lottiefiles.com/packages/lf20_x62chJ.json',
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
const DEFAULT_SRC = ANIMATIONS.rocket;
|
|
13
|
+
|
|
14
|
+
const meta = {
|
|
15
|
+
title: 'Widgets/Visual/Lottie',
|
|
16
|
+
component: LottiePlayer,
|
|
17
|
+
tags: ['autodocs'],
|
|
18
|
+
parameters: { layout: 'centered' },
|
|
19
|
+
argTypes: {
|
|
20
|
+
src: {
|
|
21
|
+
control: 'select',
|
|
22
|
+
options: Object.values(ANIMATIONS),
|
|
23
|
+
table: { category: 'Source' },
|
|
24
|
+
description: 'Animation JSON URL or inline data object.',
|
|
25
|
+
},
|
|
26
|
+
size: {
|
|
27
|
+
control: 'select',
|
|
28
|
+
options: ['xs', 'sm', 'md', 'lg', 'xl', 'full'],
|
|
29
|
+
table: { category: 'Layout' },
|
|
30
|
+
},
|
|
31
|
+
autoplay: {
|
|
32
|
+
control: 'boolean',
|
|
33
|
+
table: { category: 'Playback', defaultValue: { summary: 'true' } },
|
|
34
|
+
},
|
|
35
|
+
loop: {
|
|
36
|
+
control: 'boolean',
|
|
37
|
+
table: { category: 'Playback', defaultValue: { summary: 'true' } },
|
|
38
|
+
},
|
|
39
|
+
speed: {
|
|
40
|
+
control: { type: 'select' },
|
|
41
|
+
options: [0.5, 1, 1.5, 2],
|
|
42
|
+
table: { category: 'Playback', defaultValue: { summary: '1' } },
|
|
43
|
+
},
|
|
44
|
+
direction: {
|
|
45
|
+
control: { type: 'inline-radio' },
|
|
46
|
+
options: [1, -1],
|
|
47
|
+
table: { category: 'Playback', defaultValue: { summary: '1' } },
|
|
48
|
+
},
|
|
49
|
+
controls: {
|
|
50
|
+
control: 'boolean',
|
|
51
|
+
table: { category: 'UI', defaultValue: { summary: 'false' } },
|
|
52
|
+
},
|
|
53
|
+
background: {
|
|
54
|
+
control: 'color',
|
|
55
|
+
table: { category: 'UI' },
|
|
56
|
+
},
|
|
57
|
+
showLoading: {
|
|
58
|
+
control: 'boolean',
|
|
59
|
+
table: { category: 'UI', defaultValue: { summary: 'true' } },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
args: {
|
|
63
|
+
src: DEFAULT_SRC,
|
|
64
|
+
size: 'md',
|
|
65
|
+
autoplay: true,
|
|
66
|
+
loop: true,
|
|
67
|
+
speed: 1,
|
|
68
|
+
direction: 1,
|
|
69
|
+
controls: false,
|
|
70
|
+
showLoading: true,
|
|
71
|
+
},
|
|
72
|
+
render: (args) => (
|
|
73
|
+
<div className="h-64 w-64">
|
|
74
|
+
<LottiePlayer {...args} />
|
|
75
|
+
</div>
|
|
76
|
+
),
|
|
77
|
+
} satisfies Meta<typeof LottiePlayer>;
|
|
78
|
+
|
|
79
|
+
export default meta;
|
|
80
|
+
|
|
81
|
+
type Story = StoryObj<typeof meta>;
|
|
82
|
+
|
|
83
|
+
// -----------------------------------------------------------------------------
|
|
84
|
+
// Playground — all controls wired up
|
|
85
|
+
// -----------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export const Playground: Story = {};
|
|
88
|
+
|
|
89
|
+
// -----------------------------------------------------------------------------
|
|
90
|
+
// Curated presets
|
|
91
|
+
// -----------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
export const FromUrl: Story = {
|
|
94
|
+
args: {
|
|
95
|
+
src: ANIMATIONS.loadingDots,
|
|
96
|
+
autoplay: true,
|
|
97
|
+
loop: true,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const Paused: Story = {
|
|
102
|
+
args: {
|
|
103
|
+
src: ANIMATIONS.rocket,
|
|
104
|
+
autoplay: false,
|
|
105
|
+
controls: true,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const Slow: Story = {
|
|
110
|
+
args: {
|
|
111
|
+
src: ANIMATIONS.rocket,
|
|
112
|
+
speed: 0.5,
|
|
113
|
+
loop: true,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const Fast: Story = {
|
|
118
|
+
args: {
|
|
119
|
+
src: ANIMATIONS.rocket,
|
|
120
|
+
speed: 2,
|
|
121
|
+
loop: true,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const Reverse: Story = {
|
|
126
|
+
args: {
|
|
127
|
+
src: ANIMATIONS.rocket,
|
|
128
|
+
direction: -1,
|
|
129
|
+
loop: true,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const Looped: Story = {
|
|
134
|
+
args: {
|
|
135
|
+
src: ANIMATIONS.successCheck,
|
|
136
|
+
loop: true,
|
|
137
|
+
autoplay: true,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// -----------------------------------------------------------------------------
|
|
142
|
+
// Interactive controls — play/pause, loop toggle, speed cycle
|
|
143
|
+
// -----------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
export const InteractiveControls: Story = {
|
|
146
|
+
args: {
|
|
147
|
+
src: ANIMATIONS.rocket,
|
|
148
|
+
controls: true,
|
|
149
|
+
autoplay: true,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// -----------------------------------------------------------------------------
|
|
154
|
+
// Hover to play — animation runs only while the pointer is over it
|
|
155
|
+
// -----------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
export const HoverToPlay: Story = {
|
|
158
|
+
args: {
|
|
159
|
+
src: ANIMATIONS.rocket,
|
|
160
|
+
hoverToPlay: true,
|
|
161
|
+
autoplay: false,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// -----------------------------------------------------------------------------
|
|
166
|
+
// Error state — invalid URL surfaces a retryable error
|
|
167
|
+
// -----------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
export const ErrorState: Story = {
|
|
170
|
+
args: {
|
|
171
|
+
src: 'https://lottie.host/this-animation-does-not-exist.json',
|
|
172
|
+
},
|
|
173
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LottiePlayer Types
|
|
3
|
+
*
|
|
4
|
+
* Type definitions for the Lottie animation player component
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type LottieSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
8
|
+
|
|
9
|
+
export type LottieSpeed = 0.5 | 1 | 1.5 | 2;
|
|
10
|
+
|
|
11
|
+
export type LottieDirection = 1 | -1;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Inclusive [start, end] frame range for segment playback.
|
|
15
|
+
*/
|
|
16
|
+
export type LottieSegment = [number, number];
|
|
17
|
+
|
|
18
|
+
export interface LottiePlayerProps {
|
|
19
|
+
/**
|
|
20
|
+
* Animation data (JSON object) or URL to load from
|
|
21
|
+
*/
|
|
22
|
+
src: string | object;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Size preset for the player
|
|
26
|
+
* @default 'md'
|
|
27
|
+
*/
|
|
28
|
+
size?: LottieSize;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Custom width (overrides size preset)
|
|
32
|
+
*/
|
|
33
|
+
width?: number | string;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Custom height (overrides size preset)
|
|
37
|
+
*/
|
|
38
|
+
height?: number | string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Autoplay animation
|
|
42
|
+
* @default true
|
|
43
|
+
*/
|
|
44
|
+
autoplay?: boolean;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Loop animation
|
|
48
|
+
* @default true
|
|
49
|
+
*/
|
|
50
|
+
loop?: boolean | number;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Playback speed
|
|
54
|
+
* @default 1
|
|
55
|
+
*/
|
|
56
|
+
speed?: LottieSpeed;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Direction (1 = forward, -1 = reverse)
|
|
60
|
+
* @default 1
|
|
61
|
+
*/
|
|
62
|
+
direction?: LottieDirection;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Show interactive playback controls (play/pause, loop, speed)
|
|
66
|
+
* @default false
|
|
67
|
+
*/
|
|
68
|
+
controls?: boolean;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Play only a frame range, e.g. [30, 90]. Overrides full-clip playback.
|
|
72
|
+
*/
|
|
73
|
+
segment?: LottieSegment;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Play the animation only while the pointer is over it. When the
|
|
77
|
+
* pointer leaves, playback pauses. Ignored when `controls` drive playback.
|
|
78
|
+
* @default false
|
|
79
|
+
*/
|
|
80
|
+
hoverToPlay?: boolean;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Background color
|
|
84
|
+
*/
|
|
85
|
+
background?: string;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* CSS class name
|
|
89
|
+
*/
|
|
90
|
+
className?: string;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Accessible label describing the animation for screen readers.
|
|
94
|
+
*/
|
|
95
|
+
ariaLabel?: string;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Respect the user's `prefers-reduced-motion` setting. When the user
|
|
99
|
+
* prefers reduced motion the animation renders a static first frame
|
|
100
|
+
* instead of playing.
|
|
101
|
+
* @default true
|
|
102
|
+
*/
|
|
103
|
+
respectReducedMotion?: boolean;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Show loading state
|
|
107
|
+
* @default true
|
|
108
|
+
*/
|
|
109
|
+
showLoading?: boolean;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Callback when animation completes
|
|
113
|
+
*/
|
|
114
|
+
onComplete?: () => void;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Callback when animation loads
|
|
118
|
+
*/
|
|
119
|
+
onLoad?: () => void;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Callback on error
|
|
123
|
+
*/
|
|
124
|
+
onError?: (error: Error) => void;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface LottieAnimationData {
|
|
128
|
+
v: string;
|
|
129
|
+
fr: number;
|
|
130
|
+
ip: number;
|
|
131
|
+
op: number;
|
|
132
|
+
w: number;
|
|
133
|
+
h: number;
|
|
134
|
+
nm: string;
|
|
135
|
+
ddd: number;
|
|
136
|
+
assets: any[];
|
|
137
|
+
layers: any[];
|
|
138
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLottie Hook
|
|
3
|
+
*
|
|
4
|
+
* Hook for loading and managing Lottie animation data
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use client';
|
|
8
|
+
|
|
9
|
+
import { useEffect, useRef, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
export interface UseLottieOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Animation data (JSON object) or URL to load from
|
|
14
|
+
*/
|
|
15
|
+
src: string | object;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Enable caching of loaded animations
|
|
19
|
+
* @default true
|
|
20
|
+
*/
|
|
21
|
+
cache?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseLottieReturn {
|
|
25
|
+
/**
|
|
26
|
+
* Loaded animation data
|
|
27
|
+
*/
|
|
28
|
+
animationData: object | null;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Loading state
|
|
32
|
+
*/
|
|
33
|
+
isLoading: boolean;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Error state
|
|
37
|
+
*/
|
|
38
|
+
error: Error | null;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Retry loading the animation
|
|
42
|
+
*/
|
|
43
|
+
retry: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Simple in-memory cache for loaded animations
|
|
47
|
+
const animationCache = new Map<string, object>();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Hook for loading Lottie animations from URLs or objects
|
|
51
|
+
*
|
|
52
|
+
* Features:
|
|
53
|
+
* - Loads animations from URLs or accepts animation objects directly
|
|
54
|
+
* - Caching support to prevent re-fetching the same animation
|
|
55
|
+
* - Aborts in-flight requests on unmount / src change
|
|
56
|
+
* - Error handling with retry capability
|
|
57
|
+
* - Loading states
|
|
58
|
+
*
|
|
59
|
+
* Usage:
|
|
60
|
+
* ```tsx
|
|
61
|
+
* const { animationData, isLoading, error, retry } = useLottie({
|
|
62
|
+
* src: 'https://example.com/animation.json'
|
|
63
|
+
* });
|
|
64
|
+
*
|
|
65
|
+
* if (isLoading) return <div>Loading...</div>;
|
|
66
|
+
* if (error) return <div>Error: {error.message} <button onClick={retry}>Retry</button></div>;
|
|
67
|
+
* if (!animationData) return null;
|
|
68
|
+
*
|
|
69
|
+
* return <LottiePlayer animationData={animationData} />;
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export function useLottie(options: UseLottieOptions): UseLottieReturn {
|
|
73
|
+
const { src, cache = true } = options;
|
|
74
|
+
|
|
75
|
+
const [animationData, setAnimationData] = useState<object | null>(null);
|
|
76
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
77
|
+
const [error, setError] = useState<Error | null>(null);
|
|
78
|
+
const [retryCount, setRetryCount] = useState(0);
|
|
79
|
+
|
|
80
|
+
// Track if component is mounted to prevent state updates on unmounted component
|
|
81
|
+
const isMountedRef = useRef(true);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
isMountedRef.current = true;
|
|
85
|
+
return () => {
|
|
86
|
+
isMountedRef.current = false;
|
|
87
|
+
};
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
// If src is already an object, use it directly.
|
|
92
|
+
// Only update state when the reference actually changes so that
|
|
93
|
+
// callers passing an inline object literal do not trigger an
|
|
94
|
+
// infinite render loop.
|
|
95
|
+
if (typeof src === 'object' && src !== null) {
|
|
96
|
+
setAnimationData((prev) => (prev === src ? prev : src));
|
|
97
|
+
setIsLoading(false);
|
|
98
|
+
setError(null);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// If src is a string (URL), fetch it
|
|
103
|
+
if (typeof src === 'string') {
|
|
104
|
+
const abortController = new AbortController();
|
|
105
|
+
|
|
106
|
+
const loadAnimation = async () => {
|
|
107
|
+
// Check cache first
|
|
108
|
+
if (cache && animationCache.has(src)) {
|
|
109
|
+
if (isMountedRef.current) {
|
|
110
|
+
setAnimationData(animationCache.get(src)!);
|
|
111
|
+
setIsLoading(false);
|
|
112
|
+
setError(null);
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Load from URL
|
|
118
|
+
if (isMountedRef.current) {
|
|
119
|
+
setIsLoading(true);
|
|
120
|
+
setError(null);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const response = await fetch(src, { signal: abortController.signal });
|
|
125
|
+
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
throw new Error(`Failed to load animation: ${response.status} ${response.statusText}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let data: unknown;
|
|
131
|
+
try {
|
|
132
|
+
data = await response.json();
|
|
133
|
+
} catch {
|
|
134
|
+
throw new Error('Animation file is not valid JSON');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Validate that it's a valid Lottie animation
|
|
138
|
+
if (
|
|
139
|
+
!data ||
|
|
140
|
+
typeof data !== 'object' ||
|
|
141
|
+
!('v' in data) ||
|
|
142
|
+
!('layers' in data) ||
|
|
143
|
+
!Array.isArray((data as { layers: unknown }).layers)
|
|
144
|
+
) {
|
|
145
|
+
throw new Error('Invalid Lottie animation data');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Cache the loaded animation
|
|
149
|
+
if (cache) {
|
|
150
|
+
animationCache.set(src, data as object);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (isMountedRef.current) {
|
|
154
|
+
setAnimationData(data as object);
|
|
155
|
+
setIsLoading(false);
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
// Ignore aborts triggered by unmount / src change.
|
|
159
|
+
if (abortController.signal.aborted) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (isMountedRef.current) {
|
|
163
|
+
setError(err instanceof Error ? err : new Error('Failed to load animation'));
|
|
164
|
+
setIsLoading(false);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
loadAnimation();
|
|
170
|
+
|
|
171
|
+
return () => {
|
|
172
|
+
abortController.abort();
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}, [src, cache, retryCount]);
|
|
176
|
+
|
|
177
|
+
const retry = () => {
|
|
178
|
+
setRetryCount((prev) => prev + 1);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
animationData,
|
|
183
|
+
isLoading,
|
|
184
|
+
error,
|
|
185
|
+
retry,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePrefersReducedMotion Hook
|
|
3
|
+
*
|
|
4
|
+
* Tracks the `prefers-reduced-motion` media query so animations can be
|
|
5
|
+
* suppressed for users who request reduced motion (a11y).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import { useEffect, useState } from 'react';
|
|
11
|
+
|
|
12
|
+
const QUERY = '(prefers-reduced-motion: reduce)';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns `true` when the user has requested reduced motion.
|
|
16
|
+
*
|
|
17
|
+
* SSR-safe: returns `false` on the server and during the first client
|
|
18
|
+
* render, then syncs with the actual media query after mount.
|
|
19
|
+
*/
|
|
20
|
+
export function usePrefersReducedMotion(): boolean {
|
|
21
|
+
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (typeof window === 'undefined' || !window.matchMedia) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const mediaQuery = window.matchMedia(QUERY);
|
|
29
|
+
setPrefersReducedMotion(mediaQuery.matches);
|
|
30
|
+
|
|
31
|
+
const handleChange = (event: MediaQueryListEvent) => {
|
|
32
|
+
setPrefersReducedMotion(event.matches);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Safari < 14 only supports the deprecated addListener API.
|
|
36
|
+
if (typeof mediaQuery.addEventListener === 'function') {
|
|
37
|
+
mediaQuery.addEventListener('change', handleChange);
|
|
38
|
+
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
mediaQuery.addListener(handleChange);
|
|
42
|
+
return () => mediaQuery.removeListener(handleChange);
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
return prefersReducedMotion;
|
|
46
|
+
}
|