@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,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
+ }