@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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/package.json +106 -0
  4. package/src/design/ColorPalette/ColorPalette.tsx +129 -0
  5. package/src/design/ColorPalette/README.md +34 -0
  6. package/src/design/ColorPalette/color-palette.stories.tsx +95 -0
  7. package/src/design/ColorPalette/components/Swatch.tsx +102 -0
  8. package/src/design/ColorPalette/hooks/useCopyToClipboard.ts +41 -0
  9. package/src/design/ColorPalette/index.ts +12 -0
  10. package/src/design/ColorPalette/lazy.tsx +21 -0
  11. package/src/design/ColorPalette/types.ts +63 -0
  12. package/src/design/ColorPalette/utils.ts +114 -0
  13. package/src/design/ColorPicker/ColorPicker.tsx +257 -0
  14. package/src/design/ColorPicker/color-picker.stories.tsx +113 -0
  15. package/src/design/ColorPicker/context/ColorPickerContext.tsx +28 -0
  16. package/src/design/ColorPicker/context/ColorPickerStore.tsx +166 -0
  17. package/src/design/ColorPicker/context/index.ts +2 -0
  18. package/src/design/ColorPicker/index.ts +15 -0
  19. package/src/design/ColorPicker/lazy.tsx +24 -0
  20. package/src/design/ColorPicker/lib/color-utils.ts +323 -0
  21. package/src/design/ColorPicker/parts/ColorPickerAlphaSlider.tsx +72 -0
  22. package/src/design/ColorPicker/parts/ColorPickerArea.tsx +155 -0
  23. package/src/design/ColorPicker/parts/ColorPickerEyeDropper.tsx +73 -0
  24. package/src/design/ColorPicker/parts/ColorPickerFormatSelect.tsx +64 -0
  25. package/src/design/ColorPicker/parts/ColorPickerHueSlider.tsx +64 -0
  26. package/src/design/ColorPicker/parts/ColorPickerInput.tsx +512 -0
  27. package/src/design/ColorPicker/parts/ColorPickerSwatch.tsx +63 -0
  28. package/src/design/ColorPicker/parts/index.ts +7 -0
  29. package/src/design/ColorPicker/types.ts +77 -0
  30. package/src/gallery/components/Gallery.tsx +182 -0
  31. package/src/gallery/components/compact/GalleryCompact.tsx +396 -0
  32. package/src/gallery/components/compact/index.ts +1 -0
  33. package/src/gallery/components/index.ts +21 -0
  34. package/src/gallery/components/lightbox/GalleryLightbox.tsx +426 -0
  35. package/src/gallery/components/lightbox/index.ts +1 -0
  36. package/src/gallery/components/media/GalleryImage.tsx +105 -0
  37. package/src/gallery/components/media/GalleryMedia.tsx +70 -0
  38. package/src/gallery/components/media/GalleryVideo.tsx +241 -0
  39. package/src/gallery/components/media/index.ts +3 -0
  40. package/src/gallery/components/preview/GalleryCarousel.tsx +374 -0
  41. package/src/gallery/components/preview/GalleryGrid.tsx +519 -0
  42. package/src/gallery/components/preview/index.ts +2 -0
  43. package/src/gallery/components/shared/ImageSpinner.tsx +37 -0
  44. package/src/gallery/components/shared/index.ts +1 -0
  45. package/src/gallery/components/thumbnails/GalleryThumbnails.tsx +198 -0
  46. package/src/gallery/components/thumbnails/GalleryThumbnailsVirtual.tsx +164 -0
  47. package/src/gallery/components/thumbnails/index.ts +2 -0
  48. package/src/gallery/gallery.stories.tsx +182 -0
  49. package/src/gallery/hooks/index.ts +23 -0
  50. package/src/gallery/hooks/useGallery.ts +138 -0
  51. package/src/gallery/hooks/useImageDimensions.ts +224 -0
  52. package/src/gallery/hooks/usePinchZoom.ts +239 -0
  53. package/src/gallery/hooks/usePreloadImages.ts +119 -0
  54. package/src/gallery/hooks/useSwipe.ts +87 -0
  55. package/src/gallery/hooks/useVirtualList.ts +129 -0
  56. package/src/gallery/hooks/useZoom.ts +321 -0
  57. package/src/gallery/index.ts +66 -0
  58. package/src/gallery/thumbnails.stories.tsx +127 -0
  59. package/src/gallery/types.ts +185 -0
  60. package/src/gallery/utils/imageAnalysis.ts +52 -0
  61. package/src/gallery/utils/index.ts +16 -0
  62. package/src/gallery/utils/normalizeUrl.ts +31 -0
  63. package/src/index.ts +18 -0
  64. package/src/lottie/LottiePlayer.client.tsx +319 -0
  65. package/src/lottie/index.tsx +66 -0
  66. package/src/lottie/lazy.tsx +65 -0
  67. package/src/lottie/lottie-player.stories.tsx +173 -0
  68. package/src/lottie/types.ts +138 -0
  69. package/src/lottie/useLottie.ts +187 -0
  70. package/src/lottie/usePrefersReducedMotion.ts +46 -0
  71. package/src/marquee/Marquee.tsx +637 -0
  72. package/src/marquee/index.ts +7 -0
  73. package/src/marquee/lazy.tsx +14 -0
  74. package/src/marquee/marquee.stories.tsx +118 -0
  75. package/src/marquee/types.ts +40 -0
  76. package/src/qrcode/QRCode.tsx +468 -0
  77. package/src/qrcode/index.ts +10 -0
  78. package/src/qrcode/lazy.tsx +19 -0
  79. package/src/qrcode/qr-code.stories.tsx +102 -0
  80. package/src/qrcode/types.ts +57 -0
@@ -0,0 +1,185 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /**
4
+ * Media type for gallery items
5
+ */
6
+ export type GalleryMediaType = 'image' | 'video'
7
+
8
+ /**
9
+ * Single item in the gallery (image or video)
10
+ */
11
+ export interface GalleryMediaItem {
12
+ /** Unique identifier */
13
+ id: string
14
+ /** Full-size image/poster URL */
15
+ src: string
16
+ /** Thumbnail URL (optional, falls back to src) */
17
+ thumbnail?: string
18
+ /** Alt text for accessibility */
19
+ alt?: string
20
+ /** Image width (optional, for aspect ratio) */
21
+ width?: number
22
+ /** Image height (optional, for aspect ratio) */
23
+ height?: number
24
+ /** Media type (default: 'image') */
25
+ type?: GalleryMediaType
26
+ /** Video URL (required if type is 'video') */
27
+ videoSrc?: string
28
+ /** Video MIME type */
29
+ videoType?: string
30
+ }
31
+
32
+ /**
33
+ * Gallery state
34
+ */
35
+ export interface GalleryState {
36
+ /** Currently selected image index */
37
+ currentIndex: number
38
+ /** Whether lightbox is open */
39
+ isLightboxOpen: boolean
40
+ /** Whether image is zoomed in lightbox */
41
+ isZoomed: boolean
42
+ /** Whether current image is loading */
43
+ isLoading: boolean
44
+ }
45
+
46
+ /**
47
+ * Gallery actions
48
+ */
49
+ export interface GalleryActions {
50
+ /** Go to specific image by index */
51
+ goTo: (index: number) => void
52
+ /** Go to next image */
53
+ next: () => void
54
+ /** Go to previous image */
55
+ prev: () => void
56
+ /** Open lightbox at specific index */
57
+ openLightbox: (index?: number) => void
58
+ /** Close lightbox */
59
+ closeLightbox: () => void
60
+ /** Toggle zoom in lightbox */
61
+ toggleZoom: () => void
62
+ /** Set loading state */
63
+ setLoading: (loading: boolean) => void
64
+ }
65
+
66
+ /**
67
+ * Gallery context value
68
+ */
69
+ export interface GalleryContextValue extends GalleryState, GalleryActions {
70
+ /** All images in gallery */
71
+ images: GalleryMediaItem[]
72
+ /** Current image */
73
+ currentImage: GalleryMediaItem | null
74
+ /** Total number of images */
75
+ total: number
76
+ /** Whether there are multiple images */
77
+ hasMultiple: boolean
78
+ }
79
+
80
+ /**
81
+ * Preview mode for gallery
82
+ */
83
+ export type GalleryPreviewMode = 'carousel' | 'grid'
84
+
85
+ /**
86
+ * Props for Gallery component
87
+ */
88
+ export interface GalleryProps {
89
+ /** Array of images to display */
90
+ images: GalleryMediaItem[]
91
+ /** Initial image index */
92
+ initialIndex?: number
93
+ /** Preview mode: carousel (default) or grid */
94
+ previewMode?: GalleryPreviewMode
95
+ /** Number of images to show in grid preview (default: 5) */
96
+ previewCount?: number
97
+ /** Show thumbnail strip (carousel mode only) */
98
+ showThumbnails?: boolean
99
+ /** Show navigation controls (carousel mode only) */
100
+ showControls?: boolean
101
+ /** Show image counter */
102
+ showCounter?: boolean
103
+ /** Aspect ratio for main image (e.g., 16/9, 4/3) */
104
+ aspectRatio?: number
105
+ /** Enable lightbox on click */
106
+ enableLightbox?: boolean
107
+ /** Enable keyboard navigation */
108
+ enableKeyboard?: boolean
109
+ /** Callback when image changes */
110
+ onImageChange?: (index: number, image: GalleryMediaItem) => void
111
+ /** Callback when lightbox opens */
112
+ onLightboxOpen?: () => void
113
+ /** Callback when lightbox closes */
114
+ onLightboxClose?: () => void
115
+ /** Custom empty state */
116
+ emptyState?: ReactNode
117
+ /** Custom loading placeholder */
118
+ loadingPlaceholder?: ReactNode
119
+ /** Additional CSS class */
120
+ className?: string
121
+ }
122
+
123
+ /**
124
+ * Props for GalleryImage component
125
+ */
126
+ export interface GalleryImageProps {
127
+ /** Image data */
128
+ image: GalleryMediaItem
129
+ /** Whether to show loading skeleton */
130
+ showLoading?: boolean
131
+ /** On image load callback */
132
+ onLoad?: () => void
133
+ /** On image error callback */
134
+ onError?: () => void
135
+ /** On click callback */
136
+ onClick?: () => void
137
+ /** Priority loading */
138
+ priority?: boolean
139
+ /** Object fit mode: cover for thumbnails/grid, contain for lightbox */
140
+ objectFit?: 'cover' | 'contain'
141
+ /** Additional CSS class */
142
+ className?: string
143
+ }
144
+
145
+ /**
146
+ * Props for GalleryThumbnails component
147
+ */
148
+ export interface GalleryThumbnailsProps {
149
+ /** All images */
150
+ images: GalleryMediaItem[]
151
+ /** Currently selected index */
152
+ currentIndex: number
153
+ /** On thumbnail click */
154
+ onSelect: (index: number) => void
155
+ /** Thumbnail size */
156
+ size?: 'sm' | 'md' | 'lg'
157
+ /** Additional CSS class */
158
+ className?: string
159
+ }
160
+
161
+ /**
162
+ * Props for GalleryLightbox component
163
+ */
164
+ export interface GalleryLightboxProps {
165
+ /** Whether lightbox is open */
166
+ open: boolean
167
+ /** On close callback */
168
+ onClose: () => void
169
+ /** All images */
170
+ images: GalleryMediaItem[]
171
+ /** Current image index */
172
+ currentIndex: number
173
+ /** On index change */
174
+ onIndexChange: (index: number) => void
175
+ /** Show thumbnails in lightbox */
176
+ showThumbnails?: boolean
177
+ /** Enable zoom */
178
+ enableZoom?: boolean
179
+ /** Enable download button */
180
+ enableDownload?: boolean
181
+ /** Enable share button */
182
+ enableShare?: boolean
183
+ /** Title to display */
184
+ title?: string
185
+ }
@@ -0,0 +1,52 @@
1
+ import type { GalleryMediaItem } from '../types'
2
+
3
+ /**
4
+ * Image orientation types
5
+ */
6
+ export type ImageOrientation = 'landscape' | 'portrait' | 'square'
7
+
8
+ /**
9
+ * Analyzed image with orientation info
10
+ */
11
+ export interface AnalyzedImage extends GalleryMediaItem {
12
+ orientation: ImageOrientation
13
+ aspectRatio: number
14
+ }
15
+
16
+ /**
17
+ * Get orientation from width/height
18
+ */
19
+ export function getOrientation(width?: number, height?: number): ImageOrientation {
20
+ if (!width || !height) return 'landscape' // default assumption
21
+
22
+ const ratio = width / height
23
+ if (ratio > 1.2) return 'landscape'
24
+ if (ratio < 0.8) return 'portrait'
25
+ return 'square'
26
+ }
27
+
28
+ /**
29
+ * Calculate aspect ratio from width/height
30
+ */
31
+ export function getAspectRatio(width?: number, height?: number): number {
32
+ if (!width || !height) return 16 / 9 // default
33
+ return width / height
34
+ }
35
+
36
+ /**
37
+ * Analyze single image
38
+ */
39
+ export function analyzeImage(image: GalleryMediaItem): AnalyzedImage {
40
+ return {
41
+ ...image,
42
+ orientation: getOrientation(image.width, image.height),
43
+ aspectRatio: getAspectRatio(image.width, image.height),
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Analyze array of images
49
+ */
50
+ export function analyzeImages(images: GalleryMediaItem[]): AnalyzedImage[] {
51
+ return images.map(analyzeImage)
52
+ }
@@ -0,0 +1,16 @@
1
+ export {
2
+ getOrientation,
3
+ getAspectRatio,
4
+ analyzeImage,
5
+ analyzeImages,
6
+ } from './imageAnalysis'
7
+
8
+ export type {
9
+ ImageOrientation,
10
+ AnalyzedImage,
11
+ } from './imageAnalysis'
12
+
13
+ export {
14
+ normalizeImageUrl,
15
+ normalizeImageUrls,
16
+ } from './normalizeUrl'
@@ -0,0 +1,31 @@
1
+ /**
2
+ * URL normalization utilities for Gallery
3
+ *
4
+ * Handles Mixed Content issues by upgrading HTTP to HTTPS
5
+ * when the page is served over HTTPS.
6
+ */
7
+
8
+ /**
9
+ * Normalize image URL to prevent Mixed Content errors
10
+ * Upgrades HTTP to HTTPS when page is served over HTTPS
11
+ */
12
+ export function normalizeImageUrl(url: string | undefined): string {
13
+ if (!url) return ''
14
+
15
+ // Check if we're on HTTPS page
16
+ const isSecurePage = typeof window !== 'undefined' && window.location.protocol === 'https:'
17
+
18
+ // Upgrade HTTP to HTTPS if on secure page
19
+ if (isSecurePage && url.startsWith('http://')) {
20
+ return url.replace('http://', 'https://')
21
+ }
22
+
23
+ return url
24
+ }
25
+
26
+ /**
27
+ * Normalize array of URLs
28
+ */
29
+ export function normalizeImageUrls(urls: (string | undefined)[]): string[] {
30
+ return urls.map(normalizeImageUrl).filter(Boolean) as string[]
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Presentation pieces: colour, motion, a code symbol, and two ways to show
3
+ * media that are not a viewer.
4
+ *
5
+ * The residue of the retired ui-tools' `visual/` and `media/` folders after the parts with
6
+ * a real home left — the image viewer and the players are
7
+ * `@djangocfg/widget-media` and `@djangocfg/widget-player`, the charts are
8
+ * `@djangocfg/widget-charts`, the overlays `@djangocfg/widget-overlay`.
9
+ *
10
+ * What is left genuinely belongs together: each puts something on screen for
11
+ * its own sake, and none owns state a host has to drive.
12
+ */
13
+ export * from './design/ColorPicker';
14
+ export * from './design/ColorPalette';
15
+ export * from './marquee';
16
+ export * from './qrcode';
17
+ export * from './gallery';
18
+ export * from './lottie';
@@ -0,0 +1,319 @@
1
+ /**
2
+ * LottiePlayer Component
3
+ *
4
+ * Universal Lottie animation player component
5
+ */
6
+
7
+ 'use client';
8
+
9
+ import React from 'react';
10
+ import Lottie from 'react-lottie-player';
11
+
12
+ import { LottiePlayerProps } from './types';
13
+ import { useLottie } from './useLottie';
14
+ import { usePrefersReducedMotion } from './usePrefersReducedMotion';
15
+
16
+ // Size presets mapping
17
+ const SIZE_PRESETS = {
18
+ xs: { width: 64, height: 64 },
19
+ sm: { width: 128, height: 128 },
20
+ md: { width: 256, height: 256 },
21
+ lg: { width: 384, height: 384 },
22
+ xl: { width: 512, height: 512 },
23
+ full: { width: '100%', height: '100%' },
24
+ } as const;
25
+
26
+ const SPEED_STEPS = [0.5, 1, 1.5, 2] as const;
27
+
28
+ /**
29
+ * LottiePlayer component for displaying Lottie animations
30
+ *
31
+ * Features:
32
+ * - Loads animations from URLs or objects
33
+ * - Size presets or custom dimensions
34
+ * - Interactive playback controls (play/pause, loop, speed)
35
+ * - Segment playback and hover-to-play
36
+ * - Loading and error states
37
+ * - Respects `prefers-reduced-motion`
38
+ * - Event callbacks
39
+ *
40
+ * Usage:
41
+ * ```tsx
42
+ * // From URL with size preset
43
+ * <LottiePlayer src="https://example.com/animation.json" size="md" autoplay loop />
44
+ *
45
+ * // Interactive controls
46
+ * <LottiePlayer src={animationData} controls />
47
+ *
48
+ * // Play a frame range only
49
+ * <LottiePlayer src={animationData} segment={[30, 90]} />
50
+ *
51
+ * // Play on hover
52
+ * <LottiePlayer src={animationData} hoverToPlay autoplay={false} />
53
+ * ```
54
+ */
55
+ export function LottiePlayer({
56
+ src,
57
+ size = 'md',
58
+ width,
59
+ height,
60
+ autoplay = true,
61
+ loop = true,
62
+ speed = 1,
63
+ direction = 1,
64
+ controls = false,
65
+ segment,
66
+ hoverToPlay = false,
67
+ background,
68
+ className,
69
+ ariaLabel,
70
+ respectReducedMotion = true,
71
+ showLoading = true,
72
+ onComplete,
73
+ onLoad,
74
+ onError,
75
+ }: LottiePlayerProps) {
76
+ // Load animation data using our custom hook
77
+ const { animationData, isLoading, error, retry } = useLottie({ src });
78
+ const prefersReducedMotion = usePrefersReducedMotion();
79
+ const reduceMotion = respectReducedMotion && prefersReducedMotion;
80
+
81
+ // Interactive playback state (only used when `controls` is enabled).
82
+ const [isPlaying, setIsPlaying] = React.useState(autoplay);
83
+ const [loopEnabled, setLoopEnabled] = React.useState(Boolean(loop));
84
+ const [currentSpeed, setCurrentSpeed] = React.useState<number>(speed);
85
+ const [hovered, setHovered] = React.useState(false);
86
+
87
+ // Keep internal state in sync if controlling props change.
88
+ React.useEffect(() => setIsPlaying(autoplay), [autoplay]);
89
+ React.useEffect(() => setLoopEnabled(Boolean(loop)), [loop]);
90
+ React.useEffect(() => setCurrentSpeed(speed), [speed]);
91
+
92
+ // Notify parent about load state once per loaded animation.
93
+ const notifiedLoadRef = React.useRef<object | null>(null);
94
+ React.useEffect(() => {
95
+ if (animationData && notifiedLoadRef.current !== animationData) {
96
+ notifiedLoadRef.current = animationData;
97
+ onLoad?.();
98
+ }
99
+ }, [animationData, onLoad]);
100
+
101
+ // Notify parent about errors once per error instance.
102
+ const notifiedErrorRef = React.useRef<Error | null>(null);
103
+ React.useEffect(() => {
104
+ if (error && notifiedErrorRef.current !== error) {
105
+ notifiedErrorRef.current = error;
106
+ onError?.(error);
107
+ }
108
+ if (!error) {
109
+ notifiedErrorRef.current = null;
110
+ }
111
+ }, [error, onError]);
112
+
113
+ // Determine dimensions
114
+ const dimensions = React.useMemo(() => {
115
+ // Custom dimensions override size preset
116
+ if (width !== undefined || height !== undefined) {
117
+ return {
118
+ width: width ?? SIZE_PRESETS[size].width,
119
+ height: height ?? SIZE_PRESETS[size].height,
120
+ };
121
+ }
122
+ return SIZE_PRESETS[size];
123
+ }, [size, width, height]);
124
+
125
+ // Handle complete event
126
+ const handleComplete = React.useCallback(() => {
127
+ // When not looping, reflect the finished state in the controls.
128
+ if (!loopEnabled) {
129
+ setIsPlaying(false);
130
+ }
131
+ onComplete?.();
132
+ }, [loopEnabled, onComplete]);
133
+
134
+ const togglePlay = React.useCallback(() => {
135
+ setIsPlaying((prev) => !prev);
136
+ }, []);
137
+
138
+ const cycleSpeed = React.useCallback(() => {
139
+ setCurrentSpeed((prev) => {
140
+ const idx = SPEED_STEPS.indexOf(prev as (typeof SPEED_STEPS)[number]);
141
+ // The modulo keeps the index in range (also when `prev` is unknown and idx is -1).
142
+ return SPEED_STEPS[(idx + 1) % SPEED_STEPS.length] ?? prev;
143
+ });
144
+ }, []);
145
+
146
+ // Resolve the effective play / loop / speed values.
147
+ // Reduced motion forces a paused, non-looping static frame.
148
+ const effectivePlaying = reduceMotion
149
+ ? false
150
+ : hoverToPlay
151
+ ? hovered
152
+ : controls
153
+ ? isPlaying
154
+ : autoplay;
155
+ const effectiveLoop = reduceMotion ? false : controls ? loopEnabled : loop;
156
+ const effectiveSpeed = controls ? currentSpeed : speed;
157
+
158
+ // Segment playback: react-lottie-player expects [start, end].
159
+ const segments = React.useMemo(
160
+ () => (segment ? ([segment[0], segment[1]] as [number, number]) : undefined),
161
+ [segment]
162
+ );
163
+
164
+ const containerStyle = React.useMemo<React.CSSProperties>(
165
+ () => ({
166
+ width: dimensions.width,
167
+ height: dimensions.height,
168
+ background: background || 'transparent',
169
+ }),
170
+ [dimensions.width, dimensions.height, background]
171
+ );
172
+
173
+ // Loading state
174
+ if (isLoading && showLoading) {
175
+ return (
176
+ <div
177
+ className={`flex items-center justify-center ${className || ''}`}
178
+ style={containerStyle}
179
+ role="status"
180
+ aria-live="polite"
181
+ >
182
+ <div className="flex flex-col items-center gap-2">
183
+ <div
184
+ className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-foreground motion-reduce:animate-[spin_1.5s_linear_infinite]"
185
+ aria-hidden="true"
186
+ />
187
+ <span className="text-sm text-muted-foreground">Loading animation...</span>
188
+ </div>
189
+ </div>
190
+ );
191
+ }
192
+
193
+ // Error state
194
+ if (error) {
195
+ return (
196
+ <div
197
+ className={`flex items-center justify-center ${className || ''}`}
198
+ style={containerStyle}
199
+ role="alert"
200
+ >
201
+ <div className="flex flex-col items-center gap-2 p-4 text-center">
202
+ <svg
203
+ className="h-8 w-8 text-destructive"
204
+ fill="none"
205
+ stroke="currentColor"
206
+ viewBox="0 0 24 24"
207
+ aria-hidden="true"
208
+ >
209
+ <path
210
+ strokeLinecap="round"
211
+ strokeLinejoin="round"
212
+ strokeWidth={2}
213
+ d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
214
+ />
215
+ </svg>
216
+ <div className="text-sm text-destructive">{error.message}</div>
217
+ <button
218
+ type="button"
219
+ onClick={retry}
220
+ className="rounded bg-destructive/10 px-3 py-1 text-sm text-destructive transition-colors hover:bg-destructive/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
221
+ >
222
+ Retry
223
+ </button>
224
+ </div>
225
+ </div>
226
+ );
227
+ }
228
+
229
+ // No animation data
230
+ if (!animationData) {
231
+ return null;
232
+ }
233
+
234
+ // Render the Lottie player
235
+ return (
236
+ <div
237
+ className={className}
238
+ role="img"
239
+ aria-label={ariaLabel ?? 'Animation'}
240
+ onMouseEnter={hoverToPlay ? () => setHovered(true) : undefined}
241
+ onMouseLeave={hoverToPlay ? () => setHovered(false) : undefined}
242
+ >
243
+ <div style={containerStyle}>
244
+ <Lottie
245
+ animationData={animationData}
246
+ play={effectivePlaying}
247
+ loop={effectiveLoop}
248
+ speed={effectiveSpeed}
249
+ direction={direction}
250
+ segments={segments}
251
+ goTo={reduceMotion ? (segment ? segment[0] : 0) : undefined}
252
+ style={{ width: '100%', height: '100%' }}
253
+ onComplete={handleComplete}
254
+ rendererSettings={{
255
+ preserveAspectRatio: 'xMidYMid meet',
256
+ }}
257
+ />
258
+ </div>
259
+ {controls && (
260
+ <div className="mt-2 flex items-center justify-center gap-1">
261
+ <button
262
+ type="button"
263
+ onClick={togglePlay}
264
+ disabled={reduceMotion}
265
+ aria-label={effectivePlaying ? 'Pause animation' : 'Play animation'}
266
+ aria-pressed={effectivePlaying}
267
+ className="inline-flex h-7 w-7 items-center justify-center rounded text-foreground transition-colors hover:bg-muted disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
268
+ >
269
+ {effectivePlaying ? (
270
+ <svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
271
+ <rect x="6" y="5" width="4" height="14" rx="1" />
272
+ <rect x="14" y="5" width="4" height="14" rx="1" />
273
+ </svg>
274
+ ) : (
275
+ <svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
276
+ <path d="M8 5v14l11-7z" />
277
+ </svg>
278
+ )}
279
+ </button>
280
+ <button
281
+ type="button"
282
+ onClick={() => setLoopEnabled((prev) => !prev)}
283
+ aria-label={loopEnabled ? 'Disable loop' : 'Enable loop'}
284
+ aria-pressed={loopEnabled}
285
+ className={`inline-flex h-7 w-7 items-center justify-center rounded transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
286
+ loopEnabled ? 'text-foreground' : 'text-muted-foreground'
287
+ }`}
288
+ >
289
+ <svg
290
+ className="h-4 w-4"
291
+ viewBox="0 0 24 24"
292
+ fill="none"
293
+ stroke="currentColor"
294
+ strokeWidth={2}
295
+ aria-hidden="true"
296
+ >
297
+ <path
298
+ strokeLinecap="round"
299
+ strokeLinejoin="round"
300
+ d="M17 1l4 4-4 4M3 11V9a4 4 0 014-4h14M7 23l-4-4 4-4M21 13v2a4 4 0 01-4 4H3"
301
+ />
302
+ </svg>
303
+ </button>
304
+ <button
305
+ type="button"
306
+ onClick={cycleSpeed}
307
+ aria-label={`Playback speed ${currentSpeed}x, click to change`}
308
+ className="inline-flex h-7 min-w-9 items-center justify-center rounded px-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
309
+ >
310
+ {currentSpeed}x
311
+ </button>
312
+ {reduceMotion && (
313
+ <span className="ml-1 text-xs text-muted-foreground">Reduced motion</span>
314
+ )}
315
+ </div>
316
+ )}
317
+ </div>
318
+ );
319
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * LottiePlayer - Dynamic Import Wrapper
3
+ *
4
+ * Lazy loads the LottiePlayer component for optimal bundle size
5
+ */
6
+
7
+ 'use client';
8
+
9
+ import React, { lazy, Suspense } from 'react';
10
+ import { LottiePlayerProps } from './types';
11
+
12
+ // Lazy load the client component
13
+ const LottiePlayerClient = lazy(() =>
14
+ import('./LottiePlayer.client').then((mod) => ({ default: mod.LottiePlayer }))
15
+ );
16
+
17
+ // Loading fallback component
18
+ const LoadingFallback = () => (
19
+ <div className="flex items-center justify-center p-8" role="status" aria-live="polite">
20
+ <div className="flex flex-col items-center gap-2">
21
+ <div
22
+ className="h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-foreground motion-reduce:animate-[spin_1.5s_linear_infinite]"
23
+ aria-hidden="true"
24
+ />
25
+ <span className="text-sm text-muted-foreground">Loading player...</span>
26
+ </div>
27
+ </div>
28
+ );
29
+
30
+ /**
31
+ * LottiePlayer component wrapper with dynamic import
32
+ *
33
+ * This component automatically handles code splitting and lazy loading
34
+ * of the Lottie player to optimize bundle size.
35
+ *
36
+ * Usage:
37
+ * ```tsx
38
+ * import { LottiePlayer } from '@djangocfg/ui-core/tools';
39
+ *
40
+ * <LottiePlayer
41
+ * src="https://example.com/animation.json"
42
+ * size="md"
43
+ * autoplay
44
+ * loop
45
+ * />
46
+ * ```
47
+ */
48
+ export function LottiePlayer(props: LottiePlayerProps) {
49
+ return (
50
+ <Suspense fallback={<LoadingFallback />}>
51
+ <LottiePlayerClient {...props} />
52
+ </Suspense>
53
+ );
54
+ }
55
+
56
+ // Re-export types for convenience
57
+ export type {
58
+ LottiePlayerProps,
59
+ LottieSize,
60
+ LottieSpeed,
61
+ LottieDirection,
62
+ LottieSegment,
63
+ } from './types';
64
+ export { useLottie } from './useLottie';
65
+ export type { UseLottieOptions, UseLottieReturn } from './useLottie';
66
+ export { usePrefersReducedMotion } from './usePrefersReducedMotion';