@moontra/moonui-pro 2.17.4 → 2.18.0

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,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
4
+ import { motion, AnimatePresence } from 'framer-motion'
4
5
  import { cn } from '../../lib/utils'
5
6
 
6
7
  // Virtual List Props
@@ -14,6 +15,27 @@ export interface VirtualListProps<T = any> {
14
15
  renderItem: (item: T, index: number) => React.ReactNode
15
16
  onScroll?: (scrollTop: number) => void
16
17
  className?: string
18
+ // Infinite scrolling props
19
+ hasNextPage?: boolean
20
+ isLoadingNextPage?: boolean
21
+ onLoadMore?: () => void | Promise<void>
22
+ loadMoreThreshold?: number
23
+ renderLoader?: () => React.ReactNode
24
+ renderEndMessage?: () => React.ReactNode
25
+ // Animation props
26
+ enableAnimations?: boolean
27
+ itemEnterAnimation?: {
28
+ initial?: object
29
+ animate?: object
30
+ exit?: object
31
+ transition?: object
32
+ }
33
+ listAnimation?: {
34
+ initial?: object
35
+ animate?: object
36
+ transition?: object
37
+ }
38
+ staggerDelay?: number
17
39
  }
18
40
 
19
41
  // Item bilgilerini saklamak için
@@ -33,7 +55,28 @@ export function VirtualList<T = any>({
33
55
  overscan = 5,
34
56
  renderItem,
35
57
  onScroll,
36
- className
58
+ className,
59
+ // Infinite scrolling props
60
+ hasNextPage = false,
61
+ isLoadingNextPage = false,
62
+ onLoadMore,
63
+ loadMoreThreshold = 500,
64
+ renderLoader,
65
+ renderEndMessage,
66
+ // Animation props
67
+ enableAnimations = false,
68
+ itemEnterAnimation = {
69
+ initial: { opacity: 0, y: 20 },
70
+ animate: { opacity: 1, y: 0 },
71
+ exit: { opacity: 0, y: -20 },
72
+ transition: { duration: 0.2 }
73
+ },
74
+ listAnimation = {
75
+ initial: { opacity: 0 },
76
+ animate: { opacity: 1 },
77
+ transition: { duration: 0.3 }
78
+ },
79
+ staggerDelay = 0.05
37
80
  }: VirtualListProps<T>) {
38
81
  // State yönetimi
39
82
  const [scrollTop, setScrollTop] = useState(0)
@@ -46,10 +89,32 @@ export function VirtualList<T = any>({
46
89
  const scrollTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
47
90
  const isScrollingTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
48
91
 
92
+ // Container dimensions tracking
93
+ const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 })
94
+
95
+ useEffect(() => {
96
+ if (!containerRef.current) return
97
+
98
+ const resizeObserver = new ResizeObserver((entries) => {
99
+ const entry = entries[0]
100
+ if (entry) {
101
+ setContainerDimensions({
102
+ width: entry.contentRect.width,
103
+ height: entry.contentRect.height
104
+ })
105
+ }
106
+ })
107
+
108
+ resizeObserver.observe(containerRef.current)
109
+ return () => resizeObserver.disconnect()
110
+ }, [])
111
+
49
112
  // Item pozisyonlarını hesapla
50
113
  const itemPositions = useMemo(() => {
114
+
51
115
  const positions: ItemInfo[] = []
52
116
  let currentTop = 0
117
+ let currentLeft = 0
53
118
 
54
119
  for (let i = 0; i < items.length; i++) {
55
120
  const currentHeight = variableHeight
@@ -66,14 +131,29 @@ export function VirtualList<T = any>({
66
131
  }
67
132
 
68
133
  return positions
69
- }, [items.length, itemHeight, estimatedItemHeight, variableHeight, itemHeights])
134
+ }, [
135
+ items.length,
136
+ itemHeight,
137
+ estimatedItemHeight,
138
+ variableHeight,
139
+ itemHeights
140
+ ])
70
141
 
71
142
  // Toplam içerik yüksekliği
72
143
  const totalHeight = useMemo(() => {
73
- return itemPositions.length > 0
144
+ let baseHeight = itemPositions.length > 0
74
145
  ? itemPositions[itemPositions.length - 1].top + itemPositions[itemPositions.length - 1].height
75
146
  : 0
76
- }, [itemPositions])
147
+
148
+ // Infinite scrolling için ek yükseklik ekle
149
+ if (hasNextPage && isLoadingNextPage) {
150
+ baseHeight += 50 // Loader yüksekliği
151
+ } else if (!hasNextPage && items.length > 0) {
152
+ baseHeight += 40 // End message yüksekliği
153
+ }
154
+
155
+ return baseHeight
156
+ }, [itemPositions, hasNextPage, isLoadingNextPage, items.length])
77
157
 
78
158
  // Görünür aralığı hesapla
79
159
  const visibleRange = useMemo(() => {
@@ -91,6 +171,7 @@ export function VirtualList<T = any>({
91
171
  }
92
172
  }, [scrollTop, height, itemPositions, overscan, items.length, itemHeight])
93
173
 
174
+
94
175
  // Görünür itemlar
95
176
  const visibleItems = useMemo(() => {
96
177
  const result: Array<{ item: T; index: number; style: React.CSSProperties }> = []
@@ -119,6 +200,9 @@ export function VirtualList<T = any>({
119
200
  // Scroll handler
120
201
  const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
121
202
  const scrollTop = e.currentTarget.scrollTop
203
+ const scrollHeight = e.currentTarget.scrollHeight
204
+ const clientHeight = e.currentTarget.clientHeight
205
+
122
206
  setScrollTop(scrollTop)
123
207
  setIsScrolling(true)
124
208
 
@@ -127,6 +211,16 @@ export function VirtualList<T = any>({
127
211
  onScroll(scrollTop)
128
212
  }
129
213
 
214
+ // Infinite scrolling check
215
+ if (
216
+ hasNextPage &&
217
+ !isLoadingNextPage &&
218
+ onLoadMore &&
219
+ scrollHeight - (scrollTop + clientHeight) <= loadMoreThreshold
220
+ ) {
221
+ onLoadMore()
222
+ }
223
+
130
224
  // Scroll timeout'ını temizle ve yenisini ayarla
131
225
  if (scrollTimeoutRef.current) {
132
226
  clearTimeout(scrollTimeoutRef.current)
@@ -140,7 +234,7 @@ export function VirtualList<T = any>({
140
234
  isScrollingTimeoutRef.current = setTimeout(() => {
141
235
  setIsScrolling(false)
142
236
  }, 150)
143
- }, [onScroll])
237
+ }, [onScroll, hasNextPage, isLoadingNextPage, onLoadMore, loadMoreThreshold])
144
238
 
145
239
  // Variable height için resize observer
146
240
  useEffect(() => {
@@ -217,8 +311,12 @@ export function VirtualList<T = any>({
217
311
  }
218
312
  }, [itemHeight, height, totalHeight])
219
313
 
314
+ const ContainerComponent = enableAnimations ? motion.div : 'div'
315
+ const ContentComponent = enableAnimations ? motion.div : 'div'
316
+ const ItemComponent = enableAnimations ? motion.div : 'div'
317
+
220
318
  return (
221
- <div
319
+ <ContainerComponent
222
320
  ref={containerRef}
223
321
  className={cn(
224
322
  "relative overflow-hidden border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring",
@@ -228,50 +326,132 @@ export function VirtualList<T = any>({
228
326
  tabIndex={0}
229
327
  role="listbox"
230
328
  aria-label={`Virtual list with ${items.length} items`}
329
+ {...(enableAnimations && listAnimation)}
231
330
  >
232
331
  <div
233
332
  ref={scrollElementRef}
234
333
  className="h-full overflow-auto scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent"
235
334
  onScroll={handleScroll}
236
335
  >
237
- <div
336
+ <ContentComponent
238
337
  style={{
239
338
  height: totalHeight,
240
339
  position: 'relative'
241
340
  }}
341
+ {...(enableAnimations && {
342
+ initial: { opacity: 0 },
343
+ animate: { opacity: 1 },
344
+ transition: { duration: 0.2 }
345
+ })}
242
346
  >
243
- {visibleItems.map(({ item, index, style }) => (
244
- <div
245
- key={index}
246
- data-index={index}
247
- style={style}
248
- role="option"
249
- aria-selected="false"
250
- className="outline-none"
347
+ <AnimatePresence mode="popLayout">
348
+ {visibleItems.map(({ item, index, style }, itemIndex) => (
349
+ <ItemComponent
350
+ key={`${index}-${JSON.stringify(item)}`}
351
+ data-index={index}
352
+ style={style}
353
+ role="option"
354
+ aria-selected="false"
355
+ className="outline-none"
356
+ {...(enableAnimations && {
357
+ ...itemEnterAnimation,
358
+ transition: {
359
+ ...itemEnterAnimation.transition,
360
+ delay: itemIndex * staggerDelay
361
+ }
362
+ })}
363
+ >
364
+ {renderItem(item, index)}
365
+ </ItemComponent>
366
+ ))}
367
+ </AnimatePresence>
368
+
369
+ {/* Infinite scrolling loader */}
370
+ {hasNextPage && isLoadingNextPage && (
371
+ <motion.div
372
+ style={{
373
+ position: 'absolute',
374
+ top: totalHeight,
375
+ left: 0,
376
+ right: 0,
377
+ minHeight: 50
378
+ }}
379
+ className="flex items-center justify-center py-4"
380
+ initial={{ opacity: 0, y: 20 }}
381
+ animate={{ opacity: 1, y: 0 }}
382
+ exit={{ opacity: 0, y: -20 }}
383
+ transition={{ duration: 0.3 }}
384
+ >
385
+ {renderLoader ? renderLoader() : (
386
+ <div className="flex items-center gap-2 text-muted-foreground">
387
+ <motion.div
388
+ className="rounded-full h-4 w-4 border-2 border-primary border-t-transparent"
389
+ animate={{ rotate: 360 }}
390
+ transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
391
+ />
392
+ <span className="text-sm">Loading more...</span>
393
+ </div>
394
+ )}
395
+ </motion.div>
396
+ )}
397
+
398
+ {/* End message */}
399
+ {!hasNextPage && items.length > 0 && (
400
+ <motion.div
401
+ style={{
402
+ position: 'absolute',
403
+ top: totalHeight,
404
+ left: 0,
405
+ right: 0,
406
+ minHeight: 40
407
+ }}
408
+ className="flex items-center justify-center py-3"
409
+ initial={{ opacity: 0, scale: 0.9 }}
410
+ animate={{ opacity: 1, scale: 1 }}
411
+ transition={{ duration: 0.2 }}
251
412
  >
252
- {renderItem(item, index)}
253
- </div>
254
- ))}
255
- </div>
413
+ {renderEndMessage ? renderEndMessage() : (
414
+ <div className="text-sm text-muted-foreground">
415
+ No more items to load
416
+ </div>
417
+ )}
418
+ </motion.div>
419
+ )}
420
+ </ContentComponent>
256
421
  </div>
257
422
 
258
423
  {/* Loading indicator for scrolling */}
259
- {isScrolling && (
260
- <div className="absolute top-2 right-2 bg-muted/80 text-muted-foreground px-2 py-1 rounded text-xs">
261
- Scrolling...
262
- </div>
263
- )}
424
+ <AnimatePresence>
425
+ {isScrolling && (
426
+ <motion.div
427
+ className="absolute top-2 right-2 bg-muted/80 text-muted-foreground px-2 py-1 rounded text-xs"
428
+ initial={{ opacity: 0, scale: 0.8 }}
429
+ animate={{ opacity: 1, scale: 1 }}
430
+ exit={{ opacity: 0, scale: 0.8 }}
431
+ transition={{ duration: 0.15 }}
432
+ >
433
+ Scrolling...
434
+ </motion.div>
435
+ )}
436
+ </AnimatePresence>
264
437
 
265
438
  {/* Scroll position indicator */}
266
439
  {items.length > 100 && (
267
- <div className="absolute bottom-2 right-2 bg-muted/80 text-muted-foreground px-2 py-1 rounded text-xs">
440
+ <motion.div
441
+ className="absolute bottom-2 right-2 bg-muted/80 text-muted-foreground px-2 py-1 rounded text-xs"
442
+ initial={{ opacity: 0, y: 10 }}
443
+ animate={{ opacity: 1, y: 0 }}
444
+ transition={{ duration: 0.2, delay: 0.5 }}
445
+ >
268
446
  {Math.round((scrollTop / Math.max(totalHeight - height, 1)) * 100)}%
269
- </div>
447
+ </motion.div>
270
448
  )}
271
- </div>
449
+
450
+ </ContainerComponent>
272
451
  )
273
452
  }
274
453
 
454
+
275
455
  // Utility hook for virtual list state management
276
456
  export function useVirtualList<T>(
277
457
  items: T[],
@@ -279,6 +459,7 @@ export function useVirtualList<T>(
279
459
  ) {
280
460
  const [config, setConfig] = useState(initialConfig)
281
461
  const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set())
462
+ const [focusedIndex, setFocusedIndex] = useState<number>(-1)
282
463
 
283
464
  const updateConfig = useCallback((newConfig: Partial<VirtualListProps<T>>) => {
284
465
  setConfig(prev => ({ ...prev, ...newConfig }))
@@ -296,17 +477,66 @@ export function useVirtualList<T>(
296
477
  })
297
478
  }, [])
298
479
 
480
+ const toggleItem = useCallback((index: number) => {
481
+ setSelectedItems(prev => {
482
+ const newSet = new Set(prev)
483
+ if (newSet.has(index)) {
484
+ newSet.delete(index)
485
+ } else {
486
+ newSet.add(index)
487
+ }
488
+ return newSet
489
+ })
490
+ }, [])
491
+
492
+ const selectRange = useCallback((startIndex: number, endIndex: number) => {
493
+ setSelectedItems(prev => {
494
+ const newSet = new Set(prev)
495
+ const start = Math.min(startIndex, endIndex)
496
+ const end = Math.max(startIndex, endIndex)
497
+
498
+ for (let i = start; i <= end; i++) {
499
+ newSet.add(i)
500
+ }
501
+ return newSet
502
+ })
503
+ }, [])
504
+
505
+ const selectAll = useCallback(() => {
506
+ setSelectedItems(new Set(Array.from({ length: items.length }, (_, i) => i)))
507
+ }, [items.length])
508
+
299
509
  const clearSelection = useCallback(() => {
300
510
  setSelectedItems(new Set())
301
511
  }, [])
302
512
 
513
+ const isSelected = useCallback((index: number) => {
514
+ return selectedItems.has(index)
515
+ }, [selectedItems])
516
+
517
+ const getSelectedItems = useCallback(() => {
518
+ return Array.from(selectedItems).map(index => items[index]).filter(Boolean)
519
+ }, [selectedItems, items])
520
+
521
+ const getSelectedCount = useCallback(() => {
522
+ return selectedItems.size
523
+ }, [selectedItems])
524
+
303
525
  return {
304
526
  config,
305
527
  updateConfig,
306
528
  selectedItems,
529
+ focusedIndex,
530
+ setFocusedIndex,
307
531
  selectItem,
308
532
  deselectItem,
309
- clearSelection
533
+ toggleItem,
534
+ selectRange,
535
+ selectAll,
536
+ clearSelection,
537
+ isSelected,
538
+ getSelectedItems,
539
+ getSelectedCount
310
540
  }
311
541
  }
312
542
 
@@ -316,6 +546,12 @@ export interface SelectableVirtualListProps<T = any> extends VirtualListProps<T>
316
546
  multiSelect?: boolean
317
547
  selectedItems?: Set<number>
318
548
  onSelectionChange?: (selectedItems: Set<number>) => void
549
+ focusedIndex?: number
550
+ onFocusChange?: (focusedIndex: number) => void
551
+ selectAllEnabled?: boolean
552
+ onSelectAll?: () => void
553
+ onDeselectAll?: () => void
554
+ getItemId?: (item: T, index: number) => string | number
319
555
  }
320
556
 
321
557
  export function SelectableVirtualList<T = any>({
@@ -323,44 +559,108 @@ export function SelectableVirtualList<T = any>({
323
559
  multiSelect = false,
324
560
  selectedItems = new Set(),
325
561
  onSelectionChange,
562
+ focusedIndex = -1,
563
+ onFocusChange,
564
+ selectAllEnabled = false,
565
+ onSelectAll,
566
+ onDeselectAll,
567
+ getItemId,
326
568
  renderItem: originalRenderItem,
327
569
  ...props
328
570
  }: SelectableVirtualListProps<T>) {
329
- const handleItemClick = useCallback((index: number) => {
571
+ const [lastSelectedIndex, setLastSelectedIndex] = useState<number>(-1)
572
+
573
+ const handleItemClick = useCallback((index: number, event?: React.MouseEvent) => {
330
574
  if (!selectable || !onSelectionChange) return
331
575
 
332
576
  const newSelection = new Set(selectedItems)
333
577
 
334
- if (multiSelect) {
578
+ if (multiSelect && event?.shiftKey && lastSelectedIndex !== -1) {
579
+ // Shift+click için range selection
580
+ const start = Math.min(lastSelectedIndex, index)
581
+ const end = Math.max(lastSelectedIndex, index)
582
+
583
+ for (let i = start; i <= end; i++) {
584
+ newSelection.add(i)
585
+ }
586
+ } else if (multiSelect && (event?.ctrlKey || event?.metaKey)) {
587
+ // Ctrl/Cmd+click için toggle
588
+ if (newSelection.has(index)) {
589
+ newSelection.delete(index)
590
+ } else {
591
+ newSelection.add(index)
592
+ }
593
+ } else if (multiSelect) {
594
+ // Normal click in multi-select mode
335
595
  if (newSelection.has(index)) {
336
596
  newSelection.delete(index)
337
597
  } else {
338
598
  newSelection.add(index)
339
599
  }
340
600
  } else {
601
+ // Single select mode
341
602
  newSelection.clear()
342
603
  newSelection.add(index)
343
604
  }
344
605
 
606
+ setLastSelectedIndex(index)
345
607
  onSelectionChange(newSelection)
346
- }, [selectable, multiSelect, selectedItems, onSelectionChange])
608
+ onFocusChange?.(index)
609
+ }, [selectable, multiSelect, selectedItems, onSelectionChange, lastSelectedIndex, onFocusChange])
610
+
611
+ const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
612
+ if (!selectable) return
613
+
614
+ switch (event.key) {
615
+ case 'Enter':
616
+ case ' ':
617
+ event.preventDefault()
618
+ if (focusedIndex >= 0 && focusedIndex < props.items.length) {
619
+ handleItemClick(focusedIndex)
620
+ }
621
+ break
622
+ case 'a':
623
+ if ((event.ctrlKey || event.metaKey) && multiSelect && selectAllEnabled) {
624
+ event.preventDefault()
625
+ if (onSelectAll) {
626
+ onSelectAll()
627
+ }
628
+ }
629
+ break
630
+ case 'Escape':
631
+ if (multiSelect && onDeselectAll) {
632
+ event.preventDefault()
633
+ onDeselectAll()
634
+ }
635
+ break
636
+ }
637
+ }, [selectable, focusedIndex, props.items.length, handleItemClick, multiSelect, selectAllEnabled, onSelectAll, onDeselectAll])
347
638
 
348
639
  const renderItem = useCallback((item: T, index: number) => {
349
640
  const isSelected = selectedItems.has(index)
641
+ const isFocused = focusedIndex === index
642
+ const itemId = getItemId ? getItemId(item, index) : index
350
643
 
351
644
  return (
352
645
  <div
646
+ key={itemId}
353
647
  className={cn(
354
- "transition-colors",
648
+ "transition-colors outline-none",
355
649
  selectable && "cursor-pointer hover:bg-muted/50",
356
- isSelected && "bg-primary/10 border-l-4 border-primary"
650
+ isSelected && "bg-primary/10 border-l-4 border-primary",
651
+ isFocused && "ring-2 ring-ring ring-offset-2"
357
652
  )}
358
- onClick={() => selectable && handleItemClick(index)}
653
+ onClick={(e) => selectable && handleItemClick(index, e)}
654
+ onKeyDown={handleKeyDown}
655
+ tabIndex={selectable ? 0 : undefined}
656
+ role={selectable ? "option" : undefined}
657
+ aria-selected={selectable ? isSelected : undefined}
658
+ aria-label={selectable ? `Item ${index + 1}${isSelected ? ', selected' : ''}` : undefined}
359
659
  >
360
660
  {originalRenderItem(item, index)}
361
661
  </div>
362
662
  )
363
- }, [originalRenderItem, selectable, selectedItems, handleItemClick])
663
+ }, [originalRenderItem, selectable, selectedItems, focusedIndex, handleItemClick, handleKeyDown, getItemId])
364
664
 
365
665
  return <VirtualList {...props} renderItem={renderItem} />
366
666
  }