@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,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';
|