@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,129 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+
5
+ export interface VirtualListOptions {
6
+ /** Total number of items */
7
+ itemCount: number
8
+ /** Width of each item in pixels */
9
+ itemWidth: number
10
+ /** Gap between items in pixels */
11
+ gap?: number
12
+ /** Number of items to render outside visible area */
13
+ overscan?: number
14
+ }
15
+
16
+ export interface VirtualListReturn {
17
+ /** Container ref to attach to scrollable element */
18
+ containerRef: React.RefObject<HTMLDivElement | null>
19
+ /** Total width of all items (for spacer) */
20
+ totalWidth: number
21
+ /** Visible items with their positions */
22
+ virtualItems: VirtualItem[]
23
+ /** Scroll to specific index */
24
+ scrollToIndex: (index: number, behavior?: ScrollBehavior) => void
25
+ }
26
+
27
+ export interface VirtualItem {
28
+ index: number
29
+ start: number
30
+ size: number
31
+ }
32
+
33
+ /**
34
+ * Hook for virtualizing horizontal list items
35
+ * Only renders items that are visible + overscan buffer
36
+ */
37
+ export function useVirtualList({
38
+ itemCount,
39
+ itemWidth,
40
+ gap = 8,
41
+ overscan = 3,
42
+ }: VirtualListOptions): VirtualListReturn {
43
+ const containerRef = useRef<HTMLDivElement>(null)
44
+ const [scrollLeft, setScrollLeft] = useState(0)
45
+ const [containerWidth, setContainerWidth] = useState(0)
46
+
47
+ // Calculate total width
48
+ const totalWidth = useMemo(() => {
49
+ if (itemCount === 0) return 0
50
+ return itemCount * itemWidth + (itemCount - 1) * gap
51
+ }, [itemCount, itemWidth, gap])
52
+
53
+ // Handle scroll
54
+ useEffect(() => {
55
+ const container = containerRef.current
56
+ if (!container) return
57
+
58
+ const handleScroll = () => {
59
+ setScrollLeft(container.scrollLeft)
60
+ }
61
+
62
+ const handleResize = () => {
63
+ setContainerWidth(container.clientWidth)
64
+ }
65
+
66
+ // Initial measurements
67
+ handleResize()
68
+ handleScroll()
69
+
70
+ container.addEventListener('scroll', handleScroll, { passive: true })
71
+
72
+ const resizeObserver = new ResizeObserver(handleResize)
73
+ resizeObserver.observe(container)
74
+
75
+ return () => {
76
+ container.removeEventListener('scroll', handleScroll)
77
+ resizeObserver.disconnect()
78
+ }
79
+ }, [])
80
+
81
+ // Calculate visible items
82
+ const virtualItems = useMemo((): VirtualItem[] => {
83
+ if (itemCount === 0 || containerWidth === 0) return []
84
+
85
+ const itemWithGap = itemWidth + gap
86
+
87
+ // Find start and end indices
88
+ const startIndex = Math.max(0, Math.floor(scrollLeft / itemWithGap) - overscan)
89
+ const endIndex = Math.min(
90
+ itemCount - 1,
91
+ Math.ceil((scrollLeft + containerWidth) / itemWithGap) + overscan
92
+ )
93
+
94
+ const items: VirtualItem[] = []
95
+ for (let i = startIndex; i <= endIndex; i++) {
96
+ items.push({
97
+ index: i,
98
+ start: i * itemWithGap,
99
+ size: itemWidth,
100
+ })
101
+ }
102
+
103
+ return items
104
+ }, [itemCount, itemWidth, gap, scrollLeft, containerWidth, overscan])
105
+
106
+ // Scroll to index
107
+ const scrollToIndex = useCallback(
108
+ (index: number, behavior: ScrollBehavior = 'smooth') => {
109
+ const container = containerRef.current
110
+ if (!container) return
111
+
112
+ const itemWithGap = itemWidth + gap
113
+ const targetScroll = index * itemWithGap - containerWidth / 2 + itemWidth / 2
114
+
115
+ container.scrollTo({
116
+ left: Math.max(0, targetScroll),
117
+ behavior,
118
+ })
119
+ },
120
+ [itemWidth, gap, containerWidth]
121
+ )
122
+
123
+ return {
124
+ containerRef,
125
+ totalWidth,
126
+ virtualItems,
127
+ scrollToIndex,
128
+ }
129
+ }
@@ -0,0 +1,321 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useRef, useState } from 'react';
4
+
5
+ export interface ZoomState {
6
+ scale: number
7
+ x: number
8
+ y: number
9
+ }
10
+
11
+ export interface UseZoomOptions {
12
+ minScale?: number
13
+ maxScale?: number
14
+ desktopScale?: number
15
+ onZoomChange?: (zoomed: boolean) => void
16
+ }
17
+
18
+ export interface UseZoomReturn {
19
+ state: ZoomState
20
+ isZoomed: boolean
21
+ isDragging: boolean
22
+ handlers: {
23
+ onTouchStart: (e: React.TouchEvent) => void
24
+ onTouchMove: (e: React.TouchEvent) => void
25
+ onTouchEnd: (e: React.TouchEvent) => void
26
+ onClick: (e: React.MouseEvent) => void
27
+ onMouseDown: (e: React.MouseEvent) => void
28
+ onMouseMove: (e: React.MouseEvent) => void
29
+ onMouseUp: () => void
30
+ onMouseLeave: () => void
31
+ }
32
+ reset: () => void
33
+ zoomTo: (scale: number, x?: number, y?: number) => void
34
+ toggleZoom: () => void
35
+ }
36
+
37
+ const INITIAL_STATE: ZoomState = { scale: 1, x: 0, y: 0 }
38
+ const ZOOM_THRESHOLD = 1.05
39
+
40
+ /**
41
+ * Unified zoom hook for both desktop (click/drag) and mobile (pinch/tap)
42
+ */
43
+ export function useZoom(options: UseZoomOptions = {}): UseZoomReturn {
44
+ const { minScale = 1, maxScale = 3, desktopScale = 2, onZoomChange } = options
45
+
46
+ const [state, setState] = useState<ZoomState>(INITIAL_STATE)
47
+ const [isDragging, setIsDragging] = useState(false)
48
+
49
+ const touchRef = useRef<{
50
+ // Touch tracking
51
+ initialDistance: number
52
+ initialScale: number
53
+ initialX: number
54
+ initialY: number
55
+ centerX: number
56
+ centerY: number
57
+ isPinching: boolean
58
+ lastTap: number
59
+ // Container dimensions
60
+ containerWidth: number
61
+ containerHeight: number
62
+ // Mouse drag tracking
63
+ dragStartX: number
64
+ dragStartY: number
65
+ }>({
66
+ initialDistance: 0,
67
+ initialScale: 1,
68
+ initialX: 0,
69
+ initialY: 0,
70
+ centerX: 0,
71
+ centerY: 0,
72
+ isPinching: false,
73
+ lastTap: 0,
74
+ containerWidth: 0,
75
+ containerHeight: 0,
76
+ dragStartX: 0,
77
+ dragStartY: 0,
78
+ })
79
+
80
+ const isZoomed = state.scale > ZOOM_THRESHOLD
81
+
82
+ // Calculate distance between two touch points
83
+ const getDistance = useCallback((touches: React.TouchList): number => {
84
+ const a = touches[0]
85
+ const b = touches[1]
86
+ if (!a || !b) return 0
87
+ const dx = a.clientX - b.clientX
88
+ const dy = a.clientY - b.clientY
89
+ return Math.sqrt(dx * dx + dy * dy)
90
+ }, [])
91
+
92
+ // Get center point between two touches
93
+ const getCenter = useCallback((touches: React.TouchList): { x: number; y: number } => {
94
+ const a = touches[0]
95
+ const b = touches[1]
96
+ if (!a) return { x: 0, y: 0 }
97
+ if (!b) return { x: a.clientX, y: a.clientY }
98
+ return {
99
+ x: (a.clientX + b.clientX) / 2,
100
+ y: (a.clientY + b.clientY) / 2,
101
+ }
102
+ }, [])
103
+
104
+ // Calculate max pan based on container size and scale
105
+ const getMaxPan = useCallback((scale: number) => {
106
+ const { containerWidth, containerHeight } = touchRef.current
107
+ return {
108
+ x: (containerWidth * (scale - 1)) / 2,
109
+ y: (containerHeight * (scale - 1)) / 2,
110
+ }
111
+ }, [])
112
+
113
+ // Clamp pan position within bounds
114
+ const clampPan = useCallback((x: number, y: number, scale: number) => {
115
+ const maxPan = getMaxPan(scale)
116
+ return {
117
+ x: Math.max(-maxPan.x, Math.min(maxPan.x, x)),
118
+ y: Math.max(-maxPan.y, Math.min(maxPan.y, y)),
119
+ }
120
+ }, [getMaxPan])
121
+
122
+ // ========== TOUCH HANDLERS (MOBILE) ==========
123
+
124
+ const handleTouchStart = useCallback((e: React.TouchEvent) => {
125
+ const touches = e.touches
126
+ const rect = e.currentTarget.getBoundingClientRect()
127
+ touchRef.current.containerWidth = rect.width
128
+ touchRef.current.containerHeight = rect.height
129
+
130
+ // Double tap to zoom
131
+ const touch0 = touches[0]
132
+ if (touches.length === 1 && touch0) {
133
+ const now = Date.now()
134
+ const timeSinceLastTap = now - touchRef.current.lastTap
135
+
136
+ if (timeSinceLastTap < 300) {
137
+ e.preventDefault()
138
+ if (isZoomed) {
139
+ setState(INITIAL_STATE)
140
+ onZoomChange?.(false)
141
+ } else {
142
+ const x = touch0.clientX - rect.left - rect.width / 2
143
+ const y = touch0.clientY - rect.top - rect.height / 2
144
+ setState({ scale: desktopScale, x: -x * 0.5, y: -y * 0.5 })
145
+ onZoomChange?.(true)
146
+ }
147
+ touchRef.current.lastTap = 0
148
+ return
149
+ }
150
+
151
+ touchRef.current.lastTap = now
152
+
153
+ // Single finger - start pan (if zoomed)
154
+ if (isZoomed) {
155
+ touchRef.current.initialX = touch0.clientX - state.x
156
+ touchRef.current.initialY = touch0.clientY - state.y
157
+ }
158
+ }
159
+
160
+ // Two finger pinch
161
+ if (touches.length === 2) {
162
+ e.preventDefault()
163
+ touchRef.current.isPinching = true
164
+ touchRef.current.initialDistance = getDistance(touches)
165
+ touchRef.current.initialScale = state.scale
166
+
167
+ const center = getCenter(touches)
168
+ touchRef.current.centerX = center.x
169
+ touchRef.current.centerY = center.y
170
+ touchRef.current.initialX = state.x
171
+ touchRef.current.initialY = state.y
172
+ }
173
+ }, [isZoomed, state, getDistance, getCenter, desktopScale, onZoomChange])
174
+
175
+ const handleTouchMove = useCallback((e: React.TouchEvent) => {
176
+ const touches = e.touches
177
+
178
+ // Pinch zoom
179
+ if (touches.length === 2 && touchRef.current.isPinching) {
180
+ e.preventDefault()
181
+
182
+ const currentDistance = getDistance(touches)
183
+ const scaleDelta = currentDistance / touchRef.current.initialDistance
184
+ let newScale = touchRef.current.initialScale * scaleDelta
185
+ newScale = Math.max(minScale, Math.min(maxScale, newScale))
186
+
187
+ const center = getCenter(touches)
188
+ const dx = center.x - touchRef.current.centerX
189
+ const dy = center.y - touchRef.current.centerY
190
+
191
+ const { x: clampedX, y: clampedY } = clampPan(
192
+ touchRef.current.initialX + dx,
193
+ touchRef.current.initialY + dy,
194
+ newScale
195
+ )
196
+
197
+ setState({ scale: newScale, x: clampedX, y: clampedY })
198
+
199
+ if (newScale > ZOOM_THRESHOLD !== isZoomed) {
200
+ onZoomChange?.(newScale > ZOOM_THRESHOLD)
201
+ }
202
+ }
203
+
204
+ // Pan when zoomed (single finger)
205
+ const panTouch = touches[0]
206
+ if (touches.length === 1 && panTouch && isZoomed && !touchRef.current.isPinching) {
207
+ e.preventDefault()
208
+
209
+ const newX = panTouch.clientX - touchRef.current.initialX
210
+ const newY = panTouch.clientY - touchRef.current.initialY
211
+ const { x: clampedX, y: clampedY } = clampPan(newX, newY, state.scale)
212
+
213
+ setState((prev) => ({ ...prev, x: clampedX, y: clampedY }))
214
+ }
215
+ }, [isZoomed, state.scale, minScale, maxScale, getDistance, getCenter, clampPan, onZoomChange])
216
+
217
+ const handleTouchEnd = useCallback((e: React.TouchEvent) => {
218
+ if (e.touches.length < 2) {
219
+ touchRef.current.isPinching = false
220
+ }
221
+
222
+ // Snap to 1 if close
223
+ if (state.scale < 1.1 && state.scale > 0.9) {
224
+ setState(INITIAL_STATE)
225
+ onZoomChange?.(false)
226
+ }
227
+ }, [state.scale, onZoomChange])
228
+
229
+ // ========== MOUSE HANDLERS (DESKTOP) ==========
230
+
231
+ const handleClick = useCallback((e: React.MouseEvent) => {
232
+ // Skip if we were dragging
233
+ if (isDragging) return
234
+
235
+ const rect = e.currentTarget.getBoundingClientRect()
236
+ touchRef.current.containerWidth = rect.width
237
+ touchRef.current.containerHeight = rect.height
238
+
239
+ if (isZoomed) {
240
+ setState(INITIAL_STATE)
241
+ onZoomChange?.(false)
242
+ } else {
243
+ // Zoom to click position
244
+ const x = ((e.clientX - rect.left) / rect.width - 0.5) * -100
245
+ const y = ((e.clientY - rect.top) / rect.height - 0.5) * -100
246
+ setState({ scale: desktopScale, x, y })
247
+ onZoomChange?.(true)
248
+ }
249
+ }, [isZoomed, isDragging, desktopScale, onZoomChange])
250
+
251
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
252
+ if (!isZoomed) return
253
+ e.preventDefault()
254
+ setIsDragging(true)
255
+ touchRef.current.dragStartX = e.clientX - state.x
256
+ touchRef.current.dragStartY = e.clientY - state.y
257
+
258
+ const rect = e.currentTarget.getBoundingClientRect()
259
+ touchRef.current.containerWidth = rect.width
260
+ touchRef.current.containerHeight = rect.height
261
+ }, [isZoomed, state.x, state.y])
262
+
263
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
264
+ if (!isDragging) return
265
+
266
+ const newX = e.clientX - touchRef.current.dragStartX
267
+ const newY = e.clientY - touchRef.current.dragStartY
268
+ const { x: clampedX, y: clampedY } = clampPan(newX, newY, state.scale)
269
+
270
+ setState((prev) => ({ ...prev, x: clampedX, y: clampedY }))
271
+ }, [isDragging, state.scale, clampPan])
272
+
273
+ const handleMouseUp = useCallback(() => {
274
+ // Small delay to prevent click from triggering after drag
275
+ setTimeout(() => setIsDragging(false), 10)
276
+ }, [])
277
+
278
+ // ========== COMMON ACTIONS ==========
279
+
280
+ const reset = useCallback(() => {
281
+ setState(INITIAL_STATE)
282
+ setIsDragging(false)
283
+ onZoomChange?.(false)
284
+ }, [onZoomChange])
285
+
286
+ const zoomTo = useCallback((scale: number, x = 0, y = 0) => {
287
+ const clampedScale = Math.max(minScale, Math.min(maxScale, scale))
288
+ const { x: clampedX, y: clampedY } = clampPan(x, y, clampedScale)
289
+ setState({ scale: clampedScale, x: clampedX, y: clampedY })
290
+ onZoomChange?.(clampedScale > ZOOM_THRESHOLD)
291
+ }, [minScale, maxScale, clampPan, onZoomChange])
292
+
293
+ const toggleZoom = useCallback(() => {
294
+ if (isZoomed) {
295
+ setState(INITIAL_STATE)
296
+ onZoomChange?.(false)
297
+ } else {
298
+ setState({ scale: desktopScale, x: 0, y: 0 })
299
+ onZoomChange?.(true)
300
+ }
301
+ }, [isZoomed, desktopScale, onZoomChange])
302
+
303
+ return {
304
+ state,
305
+ isZoomed,
306
+ isDragging,
307
+ handlers: {
308
+ onTouchStart: handleTouchStart,
309
+ onTouchMove: handleTouchMove,
310
+ onTouchEnd: handleTouchEnd,
311
+ onClick: handleClick,
312
+ onMouseDown: handleMouseDown,
313
+ onMouseMove: handleMouseMove,
314
+ onMouseUp: handleMouseUp,
315
+ onMouseLeave: handleMouseUp,
316
+ },
317
+ reset,
318
+ zoomTo,
319
+ toggleZoom,
320
+ }
321
+ }
@@ -0,0 +1,66 @@
1
+ // Components
2
+ export {
3
+ Gallery,
4
+ GalleryCompact,
5
+ GalleryGrid,
6
+ GalleryImage,
7
+ GalleryVideo,
8
+ GalleryMedia,
9
+ GalleryThumbnails,
10
+ GalleryThumbnailsVirtual,
11
+ GalleryLightbox,
12
+ } from './components'
13
+ export type { GalleryCompactProps, GalleryAspectRatio, GalleryGridProps, GalleryGridLayout } from './components'
14
+
15
+ // Hooks
16
+ export {
17
+ useGallery,
18
+ useSwipe,
19
+ usePreloadImages,
20
+ preloadImage,
21
+ preloadImages,
22
+ usePinchZoom,
23
+ useVirtualList,
24
+ useImageDimensions,
25
+ clearDimensionsCache,
26
+ getCachedDimensions,
27
+ } from './hooks'
28
+ export type {
29
+ UseGalleryOptions,
30
+ UseSwipeOptions,
31
+ PinchZoomState,
32
+ UsePinchZoomOptions,
33
+ UsePinchZoomReturn,
34
+ VirtualListOptions,
35
+ VirtualListReturn,
36
+ VirtualItem,
37
+ ImageWithDimensions,
38
+ UseImageDimensionsResult,
39
+ UseImageDimensionsOptions,
40
+ } from './hooks'
41
+
42
+ // Utils
43
+ export {
44
+ getOrientation,
45
+ getAspectRatio,
46
+ analyzeImage,
47
+ analyzeImages,
48
+ } from './utils'
49
+ export type {
50
+ ImageOrientation,
51
+ AnalyzedImage,
52
+ } from './utils'
53
+
54
+ // Types
55
+ export type {
56
+ GalleryMediaType,
57
+ GalleryPreviewMode,
58
+ GalleryMediaItem,
59
+ GalleryState,
60
+ GalleryActions,
61
+ GalleryContextValue,
62
+ GalleryProps,
63
+ GalleryImageProps,
64
+ GalleryThumbnailsProps,
65
+ GalleryLightboxProps,
66
+ } from './types'
@@ -0,0 +1,127 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import {
4
+ GalleryThumbnails,
5
+ GalleryThumbnailsVirtual,
6
+ type GalleryMediaItem,
7
+ } from '.';
8
+
9
+ const IMAGES: GalleryMediaItem[] = [
10
+ { id: '1', src: 'https://images.unsplash.com/photo-1518791841217-8f162f1e1131?w=900', alt: 'Cat' },
11
+ { id: '2', src: 'https://images.unsplash.com/photo-1546182990-dffeafbe841d?w=900', alt: 'Dog' },
12
+ { id: '3', src: 'https://images.unsplash.com/photo-1444464666168-49d633b86797?w=900', alt: 'Birds' },
13
+ { id: '4', src: 'https://images.unsplash.com/photo-1551279880-03041531948f?w=900', alt: 'Mountains' },
14
+ { id: '5', src: 'https://images.unsplash.com/photo-1502082553048-f009c37129b9?w=900', alt: 'Forest' },
15
+ { id: '6', src: 'https://images.unsplash.com/photo-1500382017468-9049fed747ef?w=900', alt: 'Field' },
16
+ ];
17
+
18
+ // Every index used below is in range; this only satisfies the compiler's
19
+ // `noUncheckedIndexedAccess` and is never reached at runtime.
20
+ const FALLBACK: GalleryMediaItem = { id: 'fallback', src: '', alt: '' };
21
+
22
+ // Synthesize a large list for the virtual variant by recycling the 6 fixtures
23
+ const MANY: GalleryMediaItem[] = Array.from({ length: 240 }, (_, i) => {
24
+ const base = IMAGES[i % IMAGES.length] ?? FALLBACK;
25
+ return { ...base, id: `${base.id}-${i}`, alt: `${base.alt} #${i + 1}` };
26
+ });
27
+
28
+ const meta = {
29
+ title: 'Widgets/Visual/Gallery Thumbnails',
30
+ component: GalleryThumbnails,
31
+ args: { images: IMAGES, currentIndex: 0, onSelect: () => {}, size: 'md' },
32
+ tags: ['autodocs'],
33
+ parameters: {
34
+ layout: 'centered',
35
+ docs: {
36
+ description: {
37
+ component:
38
+ 'Standalone thumbnail strips. `GalleryThumbnails` is a flex row sized for short lists; `GalleryThumbnailsVirtual` windowing-renders large lists (hundreds of items) via the internal `useVirtualList` hook.',
39
+ },
40
+ },
41
+ },
42
+ } satisfies Meta<typeof GalleryThumbnails>;
43
+
44
+ export default meta;
45
+
46
+ type Story = StoryObj<typeof meta>;
47
+
48
+ function ThumbnailsDemo({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
49
+ const [index, setIndex] = useState(0);
50
+ const current = IMAGES[index] ?? FALLBACK;
51
+ return (
52
+ <div className="w-[720px] space-y-3">
53
+ <div className="rounded-lg overflow-hidden border border-border aspect-[16/9] bg-muted">
54
+ <img
55
+ src={current.src}
56
+ alt={current.alt ?? ''}
57
+ className="w-full h-full object-cover"
58
+ />
59
+ </div>
60
+ <GalleryThumbnails
61
+ images={IMAGES}
62
+ currentIndex={index}
63
+ onSelect={setIndex}
64
+ size={size}
65
+ />
66
+ <p className="text-xs text-muted-foreground">
67
+ Selected #{index + 1} — {current.alt}
68
+ </p>
69
+ </div>
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Standard thumbnail strip — sized for small-to-medium image counts.
75
+ */
76
+ export const Standard: Story = {
77
+ args: { size: 'md' },
78
+ argTypes: {
79
+ size: {
80
+ control: 'inline-radio',
81
+ options: ['sm', 'md', 'lg'],
82
+ table: { category: 'Layout' },
83
+ },
84
+ },
85
+ render: (args) => <ThumbnailsDemo size={args.size} />,
86
+ };
87
+
88
+ function VirtualDemo({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
89
+ const [index, setIndex] = useState(0);
90
+ const current = MANY[index] ?? FALLBACK;
91
+ return (
92
+ <div className="w-[720px] space-y-3">
93
+ <div className="rounded-lg overflow-hidden border border-border aspect-[16/9] bg-muted">
94
+ <img
95
+ src={current.src}
96
+ alt={current.alt ?? ''}
97
+ className="w-full h-full object-cover"
98
+ />
99
+ </div>
100
+ <GalleryThumbnailsVirtual
101
+ images={MANY}
102
+ currentIndex={index}
103
+ onSelect={setIndex}
104
+ size={size}
105
+ />
106
+ <p className="text-xs text-muted-foreground">
107
+ {MANY.length} thumbnails virtualized — selected #{index + 1}
108
+ </p>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Virtualised variant — drop-in replacement when the image count would otherwise
115
+ * blow the DOM up. Same props as the standard component.
116
+ */
117
+ export const Virtualized: Story = {
118
+ args: { size: 'md' },
119
+ argTypes: {
120
+ size: {
121
+ control: 'inline-radio',
122
+ options: ['sm', 'md', 'lg'],
123
+ table: { category: 'Layout' },
124
+ },
125
+ },
126
+ render: (args) => <VirtualDemo size={args.size} />,
127
+ };