@moontra/moonui-pro 2.17.5 → 2.18.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.
@@ -1,12 +1,12 @@
1
1
  "use client"
2
2
 
3
- import React, { useState, useEffect, useRef, Suspense } from "react"
4
- import { motion } from "framer-motion"
3
+ import React, { useState, useEffect, useRef, Suspense, useCallback, useMemo } from "react"
4
+ import { motion, AnimatePresence } from "framer-motion"
5
5
  import { Card, CardContent } from "../ui/card"
6
6
  import { Button } from "../ui/button"
7
7
  import { MoonUISkeletonPro as Skeleton } from "../ui/skeleton"
8
8
  import { cn } from "../../lib/utils"
9
- import { Eye, Loader2, Lock, Sparkles, RefreshCw } from "lucide-react"
9
+ import { Eye, Loader2, Lock, Sparkles, RefreshCw, Activity, Zap, BarChart3, CheckCircle2, XCircle } from "lucide-react"
10
10
  import { useSubscription } from "../../hooks/use-subscription"
11
11
 
12
12
  interface LazyComponentProps {
@@ -18,9 +18,39 @@ interface LazyComponentProps {
18
18
  disabled?: boolean
19
19
  onLoad?: () => void
20
20
  onVisible?: () => void
21
+ onError?: (error: Error) => void
21
22
  showLoadingState?: boolean
22
23
  delay?: number
23
24
  className?: string
25
+ // Advanced features
26
+ priority?: 'high' | 'normal' | 'low'
27
+ retryCount?: number
28
+ retryDelay?: number
29
+ enableAnalytics?: boolean
30
+ analyticsCallback?: (metrics: LazyLoadMetrics) => void
31
+ prefetch?: boolean
32
+ prefetchDelay?: number
33
+ fadeInDuration?: number
34
+ blurDataURL?: string
35
+ enableProgressiveEnhancement?: boolean
36
+ }
37
+
38
+ interface LazyLoadMetrics {
39
+ loadTime: number
40
+ visibilityTime: number
41
+ retryAttempts: number
42
+ success: boolean
43
+ componentName?: string
44
+ timestamp: number
45
+ }
46
+
47
+ interface LoadingState {
48
+ isVisible: boolean
49
+ isLoaded: boolean
50
+ hasError: boolean
51
+ error?: Error
52
+ retryCount: number
53
+ metrics: LazyLoadMetrics
24
54
  }
25
55
 
26
56
  const LazyComponentInternal: React.FC<LazyComponentProps> = ({
@@ -32,47 +62,160 @@ const LazyComponentInternal: React.FC<LazyComponentProps> = ({
32
62
  disabled = false,
33
63
  onLoad,
34
64
  onVisible,
65
+ onError,
35
66
  showLoadingState = true,
36
67
  delay = 0,
37
- className
68
+ className,
69
+ priority = 'normal',
70
+ retryCount = 3,
71
+ retryDelay = 1000,
72
+ enableAnalytics = false,
73
+ analyticsCallback,
74
+ prefetch = false,
75
+ prefetchDelay = 2000,
76
+ fadeInDuration = 300,
77
+ blurDataURL,
78
+ enableProgressiveEnhancement = true
38
79
  }) => {
39
- const [isVisible, setIsVisible] = useState(disabled)
40
- const [isLoaded, setIsLoaded] = useState(disabled)
80
+ const [loadingState, setLoadingState] = useState<LoadingState>({
81
+ isVisible: disabled,
82
+ isLoaded: disabled,
83
+ hasError: false,
84
+ retryCount: 0,
85
+ metrics: {
86
+ loadTime: 0,
87
+ visibilityTime: 0,
88
+ retryAttempts: 0,
89
+ success: false,
90
+ timestamp: Date.now()
91
+ }
92
+ })
41
93
  const [hasTriggered, setHasTriggered] = useState(false)
42
94
  const containerRef = useRef<HTMLDivElement>(null)
95
+ const visibilityTimeRef = useRef<number>(0)
96
+ const loadStartTimeRef = useRef<number>(0)
97
+
98
+ // Retry logic
99
+ const handleRetry = useCallback(() => {
100
+ setLoadingState(prev => ({
101
+ ...prev,
102
+ hasError: false,
103
+ retryCount: prev.retryCount + 1
104
+ }))
105
+ loadStartTimeRef.current = Date.now()
106
+
107
+ // Simulate retry with delay
108
+ setTimeout(() => {
109
+ try {
110
+ setLoadingState(prev => ({
111
+ ...prev,
112
+ isLoaded: true,
113
+ hasError: false,
114
+ metrics: {
115
+ ...prev.metrics,
116
+ loadTime: Date.now() - loadStartTimeRef.current,
117
+ success: true,
118
+ retryAttempts: prev.retryCount
119
+ }
120
+ }))
121
+
122
+ if (enableAnalytics && analyticsCallback) {
123
+ analyticsCallback(loadingState.metrics)
124
+ }
125
+
126
+ onLoad?.()
127
+ } catch (error) {
128
+ if (loadingState.retryCount < retryCount) {
129
+ setTimeout(handleRetry, retryDelay)
130
+ } else {
131
+ setLoadingState(prev => ({
132
+ ...prev,
133
+ hasError: true,
134
+ error: error as Error
135
+ }))
136
+ onError?.(error as Error)
137
+ }
138
+ }
139
+ }, delay)
140
+ }, [delay, retryCount, retryDelay, onLoad, onError, enableAnalytics, analyticsCallback, loadingState.metrics, loadingState.retryCount])
141
+
142
+ // Prefetch logic
143
+ useEffect(() => {
144
+ if (prefetch && !disabled) {
145
+ const prefetchTimer = setTimeout(() => {
146
+ // Prefetch logic here
147
+ console.log('Prefetching component...')
148
+ }, prefetchDelay)
149
+
150
+ return () => clearTimeout(prefetchTimer)
151
+ }
152
+ }, [prefetch, prefetchDelay, disabled])
43
153
 
44
154
  useEffect(() => {
45
155
  if (disabled) {
46
- setIsVisible(true)
47
- setIsLoaded(true)
156
+ setLoadingState(prev => ({
157
+ ...prev,
158
+ isVisible: true,
159
+ isLoaded: true
160
+ }))
48
161
  return
49
162
  }
50
163
 
51
164
  const observer = new IntersectionObserver(
52
165
  ([entry]) => {
53
166
  if (entry.isIntersecting && (!triggerOnce || !hasTriggered)) {
167
+ visibilityTimeRef.current = Date.now()
168
+
54
169
  if (delay > 0) {
55
170
  setTimeout(() => {
56
- setIsVisible(true)
171
+ setLoadingState(prev => ({ ...prev, isVisible: true }))
57
172
  setHasTriggered(true)
58
173
  onVisible?.()
59
174
 
60
- // Simulate loading delay
175
+ loadStartTimeRef.current = Date.now()
176
+
177
+ // Simulate loading with priority
178
+ const loadDelay = priority === 'high' ? delay * 0.5 : priority === 'low' ? delay * 1.5 : delay
179
+
61
180
  setTimeout(() => {
62
- setIsLoaded(true)
63
- onLoad?.()
64
- }, delay)
181
+ try {
182
+ setLoadingState(prev => ({
183
+ ...prev,
184
+ isLoaded: true,
185
+ metrics: {
186
+ ...prev.metrics,
187
+ loadTime: Date.now() - loadStartTimeRef.current,
188
+ visibilityTime: Date.now() - visibilityTimeRef.current,
189
+ success: true
190
+ }
191
+ }))
192
+
193
+ if (enableAnalytics && analyticsCallback) {
194
+ analyticsCallback(loadingState.metrics)
195
+ }
196
+
197
+ onLoad?.()
198
+ } catch (error) {
199
+ handleRetry()
200
+ }
201
+ }, loadDelay)
65
202
  }, 100)
66
203
  } else {
67
- setIsVisible(true)
68
- setIsLoaded(true)
204
+ setLoadingState(prev => ({
205
+ ...prev,
206
+ isVisible: true,
207
+ isLoaded: true
208
+ }))
69
209
  setHasTriggered(true)
70
210
  onVisible?.()
71
211
  onLoad?.()
72
212
  }
73
213
  } else if (!entry.isIntersecting && !triggerOnce) {
74
- setIsVisible(false)
75
- setIsLoaded(false)
214
+ setLoadingState(prev => ({
215
+ ...prev,
216
+ isVisible: false,
217
+ isLoaded: false
218
+ }))
76
219
  }
77
220
  },
78
221
  {
@@ -90,9 +233,40 @@ const LazyComponentInternal: React.FC<LazyComponentProps> = ({
90
233
  observer.unobserve(containerRef.current)
91
234
  }
92
235
  }
93
- }, [threshold, rootMargin, triggerOnce, delay, disabled, hasTriggered, onLoad, onVisible])
236
+ }, [threshold, rootMargin, triggerOnce, delay, disabled, hasTriggered, onLoad, onVisible, priority, enableAnalytics, analyticsCallback, handleRetry])
94
237
 
95
238
  const renderFallback = () => {
239
+ if (loadingState.hasError) {
240
+ return (
241
+ <motion.div
242
+ initial={{ opacity: 0 }}
243
+ animate={{ opacity: 1 }}
244
+ className="flex items-center justify-center p-8 border border-destructive/50 rounded-lg bg-destructive/10"
245
+ >
246
+ <div className="text-center space-y-3">
247
+ <XCircle className="h-8 w-8 mx-auto text-destructive" />
248
+ <div>
249
+ <p className="text-sm font-medium">Failed to load component</p>
250
+ <p className="text-xs text-muted-foreground mt-1">
251
+ {loadingState.error?.message || 'An error occurred'}
252
+ </p>
253
+ </div>
254
+ {loadingState.retryCount < retryCount && (
255
+ <Button
256
+ size="sm"
257
+ variant="outline"
258
+ onClick={handleRetry}
259
+ className="mt-2"
260
+ >
261
+ <RefreshCw className="h-3 w-3 mr-2" />
262
+ Retry ({loadingState.retryCount}/{retryCount})
263
+ </Button>
264
+ )}
265
+ </div>
266
+ </motion.div>
267
+ )
268
+ }
269
+
96
270
  if (fallback) {
97
271
  return fallback
98
272
  }
@@ -102,18 +276,53 @@ const LazyComponentInternal: React.FC<LazyComponentProps> = ({
102
276
  <motion.div
103
277
  initial={{ opacity: 0 }}
104
278
  animate={{ opacity: 1 }}
105
- className="flex items-center justify-center p-8"
279
+ className="relative"
106
280
  >
107
- <div className="text-center space-y-2">
108
- {delay > 0 && isVisible && !isLoaded ? (
109
- <Loader2 className="h-6 w-6 animate-spin mx-auto text-muted-foreground" />
110
- ) : (
111
- <Eye className="h-6 w-6 mx-auto text-muted-foreground" />
112
- )}
113
- <p className="text-sm text-muted-foreground">
114
- {isVisible && !isLoaded ? "Loading..." : "Scroll to load content"}
115
- </p>
116
- </div>
281
+ {blurDataURL && enableProgressiveEnhancement ? (
282
+ <div className="relative overflow-hidden rounded-lg">
283
+ <img
284
+ src={blurDataURL}
285
+ alt="Loading placeholder"
286
+ className="w-full h-32 object-cover filter blur-xl"
287
+ />
288
+ <div className="absolute inset-0 bg-background/50 backdrop-blur-sm" />
289
+ </div>
290
+ ) : (
291
+ <div className="flex items-center justify-center p-8 bg-muted/50 rounded-lg">
292
+ <div className="text-center space-y-3">
293
+ {delay > 0 && loadingState.isVisible && !loadingState.isLoaded ? (
294
+ <>
295
+ <div className="relative">
296
+ <Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
297
+ {priority === 'high' && (
298
+ <Zap className="h-3 w-3 absolute -top-1 -right-1 text-yellow-500" />
299
+ )}
300
+ </div>
301
+ <div className="space-y-1">
302
+ <p className="text-sm font-medium">Loading component...</p>
303
+ <p className="text-xs text-muted-foreground">
304
+ Priority: {priority} • Attempt {loadingState.retryCount + 1}
305
+ </p>
306
+ </div>
307
+ </>
308
+ ) : (
309
+ <>
310
+ <Eye className="h-8 w-8 mx-auto text-muted-foreground" />
311
+ <p className="text-sm text-muted-foreground">
312
+ Scroll to load content
313
+ </p>
314
+ </>
315
+ )}
316
+ </div>
317
+ </div>
318
+ )}
319
+
320
+ {enableAnalytics && loadingState.isVisible && !loadingState.isLoaded && (
321
+ <div className="absolute top-2 right-2 flex items-center gap-2 text-xs text-muted-foreground bg-background/80 backdrop-blur-sm px-2 py-1 rounded">
322
+ <Activity className="h-3 w-3" />
323
+ <span>Tracking performance</span>
324
+ </div>
325
+ )}
117
326
  </motion.div>
118
327
  )
119
328
  }
@@ -123,17 +332,35 @@ const LazyComponentInternal: React.FC<LazyComponentProps> = ({
123
332
 
124
333
  return (
125
334
  <div ref={containerRef} className={cn("w-full", className)}>
126
- {isLoaded ? (
127
- <motion.div
128
- initial={{ opacity: 0, y: 20 }}
129
- animate={{ opacity: 1, y: 0 }}
130
- transition={{ duration: 0.3 }}
131
- >
132
- {children}
133
- </motion.div>
134
- ) : (
135
- renderFallback()
136
- )}
335
+ <AnimatePresence mode="wait">
336
+ {loadingState.isLoaded ? (
337
+ <motion.div
338
+ key="content"
339
+ initial={{ opacity: 0, y: 20 }}
340
+ animate={{ opacity: 1, y: 0 }}
341
+ exit={{ opacity: 0, y: -20 }}
342
+ transition={{ duration: fadeInDuration / 1000 }}
343
+ >
344
+ {enableAnalytics && (
345
+ <div className="absolute -top-8 right-0 flex items-center gap-2 text-xs text-muted-foreground bg-background/80 backdrop-blur-sm px-2 py-1 rounded">
346
+ <CheckCircle2 className="h-3 w-3 text-green-500" />
347
+ <span>Loaded in {loadingState.metrics.loadTime}ms</span>
348
+ </div>
349
+ )}
350
+ {children}
351
+ </motion.div>
352
+ ) : (
353
+ <motion.div
354
+ key="fallback"
355
+ initial={{ opacity: 0 }}
356
+ animate={{ opacity: 1 }}
357
+ exit={{ opacity: 0 }}
358
+ transition={{ duration: 0.2 }}
359
+ >
360
+ {renderFallback()}
361
+ </motion.div>
362
+ )}
363
+ </AnimatePresence>
137
364
  </div>
138
365
  )
139
366
  }
@@ -149,6 +376,17 @@ export interface LazyImageProps extends React.ImgHTMLAttributes<HTMLImageElement
149
376
  onLoad?: () => void
150
377
  onError?: () => void
151
378
  className?: string
379
+ // Advanced features
380
+ quality?: number
381
+ priority?: boolean
382
+ blur?: boolean
383
+ blurDataURL?: string
384
+ aspectRatio?: string
385
+ objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
386
+ sizes?: string
387
+ srcSet?: string
388
+ enableProgressiveLoading?: boolean
389
+ onProgress?: (progress: number) => void
152
390
  }
153
391
 
154
392
  export const LazyImage: React.FC<LazyImageProps> = ({
@@ -161,11 +399,23 @@ export const LazyImage: React.FC<LazyImageProps> = ({
161
399
  onLoad,
162
400
  onError,
163
401
  className,
402
+ quality = 85,
403
+ priority = false,
404
+ blur = true,
405
+ blurDataURL,
406
+ aspectRatio,
407
+ objectFit = 'cover',
408
+ sizes,
409
+ srcSet,
410
+ enableProgressiveLoading = false,
411
+ onProgress,
164
412
  ...props
165
413
  }) => {
166
- const [imageSrc, setImageSrc] = useState<string | null>(null)
414
+ const [imageSrc, setImageSrc] = useState<string | null>(priority ? src : null)
167
415
  const [imageError, setImageError] = useState(false)
168
- const [isVisible, setIsVisible] = useState(false)
416
+ const [isVisible, setIsVisible] = useState(priority)
417
+ const [loadProgress, setLoadProgress] = useState(0)
418
+ const [isLoading, setIsLoading] = useState(false)
169
419
  const imgRef = useRef<HTMLImageElement>(null)
170
420
  const containerRef = useRef<HTMLDivElement>(null)
171
421
 
@@ -188,42 +438,137 @@ export const LazyImage: React.FC<LazyImageProps> = ({
188
438
  }, [threshold, rootMargin])
189
439
 
190
440
  useEffect(() => {
191
- if (!isVisible) return
441
+ if (!isVisible || imageSrc) return
192
442
 
443
+ setIsLoading(true)
193
444
  const img = new Image()
194
- img.onload = () => {
195
- setImageSrc(src)
196
- onLoad?.()
445
+
446
+ // Progressive loading simulation
447
+ if (enableProgressiveLoading) {
448
+ let progress = 0
449
+ const progressInterval = setInterval(() => {
450
+ progress += Math.random() * 30
451
+ if (progress > 90) progress = 90
452
+ setLoadProgress(progress)
453
+ onProgress?.(progress)
454
+ }, 100)
455
+
456
+ img.onload = () => {
457
+ clearInterval(progressInterval)
458
+ setLoadProgress(100)
459
+ onProgress?.(100)
460
+ setImageSrc(src)
461
+ setIsLoading(false)
462
+ onLoad?.()
463
+ }
464
+ } else {
465
+ img.onload = () => {
466
+ setImageSrc(src)
467
+ setIsLoading(false)
468
+ onLoad?.()
469
+ }
197
470
  }
471
+
198
472
  img.onerror = () => {
199
473
  setImageError(true)
474
+ setIsLoading(false)
200
475
  if (fallbackSrc) {
201
476
  setImageSrc(fallbackSrc)
202
477
  }
203
478
  onError?.()
204
479
  }
205
- img.src = src
206
- }, [isVisible, src, fallbackSrc, onLoad, onError])
480
+
481
+ // Add quality parameter to image URL if supported
482
+ const qualityParam = src.includes('?') ? `&q=${quality}` : `?q=${quality}`
483
+ img.src = src + (src.includes('http') ? qualityParam : '')
484
+ }, [isVisible, src, fallbackSrc, onLoad, onError, quality, enableProgressiveLoading, onProgress, imageSrc])
207
485
 
208
486
  return (
209
- <div ref={containerRef} className={cn("relative overflow-hidden", className)}>
210
- {imageSrc ? (
211
- <motion.img
212
- ref={imgRef}
213
- src={imageSrc}
214
- alt={alt}
215
- initial={{ opacity: 0 }}
216
- animate={{ opacity: 1 }}
217
- transition={{ duration: 0.3 }}
218
- className="w-full h-full object-cover"
219
- />
220
- ) : showPlaceholder ? (
221
- <Skeleton className="w-full h-full min-h-32" />
222
- ) : null}
487
+ <div
488
+ ref={containerRef}
489
+ className={cn("relative overflow-hidden bg-muted", className)}
490
+ style={aspectRatio ? { aspectRatio } : undefined}
491
+ >
492
+ <AnimatePresence mode="wait">
493
+ {/* Blur placeholder */}
494
+ {blur && blurDataURL && !imageSrc && (
495
+ <motion.img
496
+ key="blur"
497
+ src={blurDataURL}
498
+ alt=""
499
+ initial={{ opacity: 0 }}
500
+ animate={{ opacity: 1 }}
501
+ exit={{ opacity: 0 }}
502
+ className="absolute inset-0 w-full h-full object-cover filter blur-xl scale-110"
503
+ />
504
+ )}
505
+
506
+ {/* Main image */}
507
+ {imageSrc && (
508
+ <motion.img
509
+ key="main"
510
+ ref={imgRef}
511
+ src={imageSrc}
512
+ alt={alt}
513
+ sizes={sizes}
514
+ srcSet={srcSet}
515
+ initial={{ opacity: 0, scale: 1.02 }}
516
+ animate={{ opacity: 1, scale: 1 }}
517
+ transition={{ duration: 0.4 }}
518
+ className={cn("w-full h-full", `object-${objectFit}`)}
519
+ {...props}
520
+ />
521
+ )}
522
+
523
+ {/* Loading state */}
524
+ {isLoading && showPlaceholder && (
525
+ <motion.div
526
+ key="loading"
527
+ initial={{ opacity: 0 }}
528
+ animate={{ opacity: 1 }}
529
+ exit={{ opacity: 0 }}
530
+ className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm"
531
+ >
532
+ <div className="text-center">
533
+ <Loader2 className="h-8 w-8 animate-spin mx-auto text-primary mb-2" />
534
+ {enableProgressiveLoading && (
535
+ <div className="w-32 h-1 bg-muted rounded-full overflow-hidden">
536
+ <motion.div
537
+ className="h-full bg-primary"
538
+ initial={{ width: 0 }}
539
+ animate={{ width: `${loadProgress}%` }}
540
+ />
541
+ </div>
542
+ )}
543
+ </div>
544
+ </motion.div>
545
+ )}
546
+
547
+ {/* Skeleton placeholder */}
548
+ {!imageSrc && !isLoading && showPlaceholder && (
549
+ <Skeleton className="absolute inset-0" />
550
+ )}
551
+
552
+ {/* Error state */}
553
+ {imageError && !fallbackSrc && (
554
+ <motion.div
555
+ initial={{ opacity: 0 }}
556
+ animate={{ opacity: 1 }}
557
+ className="absolute inset-0 flex items-center justify-center bg-muted"
558
+ >
559
+ <div className="text-center">
560
+ <XCircle className="h-8 w-8 mx-auto text-muted-foreground mb-2" />
561
+ <p className="text-sm text-muted-foreground">Failed to load image</p>
562
+ </div>
563
+ </motion.div>
564
+ )}
565
+ </AnimatePresence>
223
566
 
224
- {imageError && !fallbackSrc && (
225
- <div className="absolute inset-0 flex items-center justify-center bg-muted">
226
- <p className="text-sm text-muted-foreground">Failed to load image</p>
567
+ {/* Priority badge */}
568
+ {priority && (
569
+ <div className="absolute top-2 right-2 bg-yellow-500/90 text-yellow-900 text-xs px-2 py-1 rounded flex items-center gap-1">
570
+ <Zap className="h-3 w-3" />
571
+ Priority
227
572
  </div>
228
573
  )}
229
574
  </div>
@@ -238,6 +583,16 @@ export interface LazyListProps<T> {
238
583
  batchSize?: number
239
584
  threshold?: number
240
585
  className?: string
586
+ // Advanced features
587
+ enableVirtualization?: boolean
588
+ overscan?: number
589
+ showLoadingIndicator?: boolean
590
+ loadingIndicator?: React.ReactNode
591
+ emptyState?: React.ReactNode
592
+ onBatchLoad?: (startIndex: number, endIndex: number) => void
593
+ enableSmoothScroll?: boolean
594
+ staggerAnimation?: boolean
595
+ animationDelay?: number
241
596
  }
242
597
 
243
598
  export function LazyList<T>({
@@ -246,25 +601,63 @@ export function LazyList<T>({
246
601
  itemHeight = 100,
247
602
  batchSize = 10,
248
603
  threshold = 0.5,
249
- className
604
+ className,
605
+ enableVirtualization = false,
606
+ overscan = 3,
607
+ showLoadingIndicator = true,
608
+ loadingIndicator,
609
+ emptyState,
610
+ onBatchLoad,
611
+ enableSmoothScroll = true,
612
+ staggerAnimation = true,
613
+ animationDelay = 50
250
614
  }: LazyListProps<T>) {
251
615
  const [visibleItems, setVisibleItems] = useState<T[]>([])
252
616
  const [currentBatch, setCurrentBatch] = useState(0)
617
+ const [isLoading, setIsLoading] = useState(false)
253
618
  const loadingRef = useRef<HTMLDivElement>(null)
619
+ const containerRef = useRef<HTMLDivElement>(null)
620
+ const [scrollPosition, setScrollPosition] = useState(0)
254
621
 
255
622
  useEffect(() => {
256
623
  setVisibleItems(items.slice(0, batchSize))
257
624
  setCurrentBatch(1)
258
625
  }, [items, batchSize])
259
626
 
627
+ // Handle scroll for virtualization
628
+ const handleScroll = useCallback((e: Event) => {
629
+ if (enableVirtualization && containerRef.current) {
630
+ setScrollPosition((e.target as HTMLElement).scrollTop)
631
+ }
632
+ }, [enableVirtualization])
633
+
634
+ useEffect(() => {
635
+ const container = containerRef.current
636
+ if (enableVirtualization && container) {
637
+ container.addEventListener('scroll', handleScroll)
638
+ return () => container.removeEventListener('scroll', handleScroll)
639
+ }
640
+ }, [enableVirtualization, handleScroll])
641
+
260
642
  useEffect(() => {
261
643
  const observer = new IntersectionObserver(
262
644
  ([entry]) => {
263
- if (entry.isIntersecting && currentBatch * batchSize < items.length) {
264
- const nextBatch = currentBatch + 1
265
- const newItems = items.slice(0, nextBatch * batchSize)
266
- setVisibleItems(newItems)
267
- setCurrentBatch(nextBatch)
645
+ if (entry.isIntersecting && currentBatch * batchSize < items.length && !isLoading) {
646
+ setIsLoading(true)
647
+
648
+ // Simulate loading delay
649
+ setTimeout(() => {
650
+ const nextBatch = currentBatch + 1
651
+ const startIndex = currentBatch * batchSize
652
+ const endIndex = nextBatch * batchSize
653
+ const newItems = items.slice(0, endIndex)
654
+
655
+ setVisibleItems(newItems)
656
+ setCurrentBatch(nextBatch)
657
+ setIsLoading(false)
658
+
659
+ onBatchLoad?.(startIndex, endIndex)
660
+ }, 300)
268
661
  }
269
662
  },
270
663
  { threshold }
@@ -275,25 +668,114 @@ export function LazyList<T>({
275
668
  }
276
669
 
277
670
  return () => observer.disconnect()
278
- }, [currentBatch, items, batchSize, threshold])
671
+ }, [currentBatch, items, batchSize, threshold, isLoading, onBatchLoad])
672
+
673
+ // Calculate visible range for virtualization
674
+ const visibleRange = useMemo(() => {
675
+ if (!enableVirtualization) return { start: 0, end: visibleItems.length }
676
+
677
+ const start = Math.floor(scrollPosition / itemHeight) - overscan
678
+ const visibleCount = Math.ceil(400 / itemHeight) // Assuming container height of 400px
679
+ const end = start + visibleCount + overscan * 2
680
+
681
+ return {
682
+ start: Math.max(0, start),
683
+ end: Math.min(visibleItems.length, end)
684
+ }
685
+ }, [scrollPosition, itemHeight, overscan, visibleItems.length, enableVirtualization])
686
+
687
+ if (items.length === 0 && emptyState) {
688
+ return (
689
+ <div className={cn("flex items-center justify-center p-8", className)}>
690
+ {emptyState}
691
+ </div>
692
+ )
693
+ }
279
694
 
280
695
  return (
281
- <div className={cn("space-y-2", className)}>
282
- {visibleItems.map((item, index) => (
283
- <motion.div
284
- key={index}
285
- initial={{ opacity: 0, y: 20 }}
286
- animate={{ opacity: 1, y: 0 }}
287
- transition={{ delay: (index % batchSize) * 0.05 }}
288
- style={{ minHeight: itemHeight }}
289
- >
290
- {renderItem(item, index)}
291
- </motion.div>
292
- ))}
696
+ <div
697
+ ref={containerRef}
698
+ className={cn(
699
+ "relative",
700
+ enableVirtualization && "overflow-auto max-h-[400px]",
701
+ enableSmoothScroll && "scroll-smooth",
702
+ className
703
+ )}
704
+ >
705
+ {enableVirtualization ? (
706
+ // Virtualized rendering
707
+ <>
708
+ <div style={{ height: visibleItems.length * itemHeight }} />
709
+ <div className="absolute top-0 left-0 right-0">
710
+ {visibleItems.slice(visibleRange.start, visibleRange.end).map((item, index) => {
711
+ const actualIndex = visibleRange.start + index
712
+ return (
713
+ <motion.div
714
+ key={actualIndex}
715
+ initial={staggerAnimation ? { opacity: 0, x: -20 } : { opacity: 1 }}
716
+ animate={{ opacity: 1, x: 0 }}
717
+ transition={staggerAnimation ? {
718
+ delay: index * (animationDelay / 1000),
719
+ duration: 0.3
720
+ } : undefined}
721
+ style={{
722
+ position: 'absolute',
723
+ top: actualIndex * itemHeight,
724
+ left: 0,
725
+ right: 0,
726
+ height: itemHeight
727
+ }}
728
+ >
729
+ {renderItem(item, actualIndex)}
730
+ </motion.div>
731
+ )
732
+ })}
733
+ </div>
734
+ </>
735
+ ) : (
736
+ // Regular lazy loading
737
+ <div className="space-y-2">
738
+ {visibleItems.map((item, index) => (
739
+ <motion.div
740
+ key={index}
741
+ initial={staggerAnimation ? { opacity: 0, y: 20 } : { opacity: 1 }}
742
+ animate={{ opacity: 1, y: 0 }}
743
+ transition={staggerAnimation ? {
744
+ delay: (index % batchSize) * (animationDelay / 1000),
745
+ duration: 0.3
746
+ } : undefined}
747
+ style={{ minHeight: itemHeight }}
748
+ >
749
+ {renderItem(item, index)}
750
+ </motion.div>
751
+ ))}
752
+ </div>
753
+ )}
293
754
 
294
755
  {currentBatch * batchSize < items.length && (
295
756
  <div ref={loadingRef} className="flex justify-center py-4">
296
- <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
757
+ {showLoadingIndicator && (
758
+ loadingIndicator || (
759
+ <div className="flex items-center gap-2">
760
+ <Loader2 className="h-5 w-5 animate-spin text-primary" />
761
+ <span className="text-sm text-muted-foreground">
762
+ Loading more items...
763
+ </span>
764
+ </div>
765
+ )
766
+ )}
767
+ </div>
768
+ )}
769
+
770
+ {/* Progress indicator */}
771
+ {items.length > batchSize && (
772
+ <div className="sticky bottom-0 left-0 right-0 bg-gradient-to-t from-background to-transparent pt-8 pb-2">
773
+ <div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
774
+ <BarChart3 className="h-3 w-3" />
775
+ <span>
776
+ Showing {visibleItems.length} of {items.length} items
777
+ </span>
778
+ </div>
297
779
  </div>
298
780
  )}
299
781
  </div>