@moontra/moonui-pro 2.17.4 → 2.17.5
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/dist/index.mjs +1168 -274
- package/package.json +1 -1
- package/src/components/calendar-pro/index.tsx +129 -24
- package/src/components/memory-efficient-data/index.tsx +730 -66
- package/src/components/virtual-list/index.tsx +436 -37
- package/dist/index.d.ts +0 -2798
|
@@ -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,34 +89,99 @@ 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
|
+
useEffect(() => {
|
|
94
|
+
if (!containerRef.current) return
|
|
95
|
+
|
|
96
|
+
const resizeObserver = new ResizeObserver((entries) => {
|
|
97
|
+
const entry = entries[0]
|
|
98
|
+
if (entry) {
|
|
99
|
+
setContainerDimensions({
|
|
100
|
+
width: entry.contentRect.width,
|
|
101
|
+
height: entry.contentRect.height
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
resizeObserver.observe(containerRef.current)
|
|
107
|
+
return () => resizeObserver.disconnect()
|
|
108
|
+
}, [])
|
|
109
|
+
|
|
110
|
+
// Grid layout calculation
|
|
111
|
+
const gridPositions = useGridLayout(
|
|
112
|
+
items,
|
|
113
|
+
gridConfig,
|
|
114
|
+
direction,
|
|
115
|
+
containerDimensions.width || (typeof width === 'number' ? width : 800),
|
|
116
|
+
itemHeight,
|
|
117
|
+
itemWidth
|
|
118
|
+
)
|
|
119
|
+
|
|
49
120
|
// Item pozisyonlarını hesapla
|
|
50
121
|
const itemPositions = useMemo(() => {
|
|
122
|
+
if (direction === 'grid') {
|
|
123
|
+
return gridPositions
|
|
124
|
+
}
|
|
125
|
+
|
|
51
126
|
const positions: ItemInfo[] = []
|
|
52
127
|
let currentTop = 0
|
|
128
|
+
let currentLeft = 0
|
|
53
129
|
|
|
54
130
|
for (let i = 0; i < items.length; i++) {
|
|
55
131
|
const currentHeight = variableHeight
|
|
56
132
|
? (itemHeights.get(i) || estimatedItemHeight)
|
|
57
133
|
: itemHeight
|
|
134
|
+
|
|
135
|
+
const currentWidth = variableWidth
|
|
136
|
+
? (itemWidths.get(i) || estimatedItemWidth)
|
|
137
|
+
: (direction === 'horizontal' ? itemWidth : containerDimensions.width || 0)
|
|
58
138
|
|
|
59
139
|
positions.push({
|
|
60
140
|
index: i,
|
|
61
141
|
height: currentHeight,
|
|
62
|
-
|
|
142
|
+
width: currentWidth,
|
|
143
|
+
top: direction === 'horizontal' ? 0 : currentTop,
|
|
144
|
+
left: direction === 'horizontal' ? currentLeft : 0
|
|
63
145
|
})
|
|
64
146
|
|
|
65
|
-
|
|
147
|
+
if (direction === 'horizontal') {
|
|
148
|
+
currentLeft += currentWidth
|
|
149
|
+
} else {
|
|
150
|
+
currentTop += currentHeight
|
|
151
|
+
}
|
|
66
152
|
}
|
|
67
153
|
|
|
68
154
|
return positions
|
|
69
|
-
}, [
|
|
155
|
+
}, [
|
|
156
|
+
items.length,
|
|
157
|
+
itemHeight,
|
|
158
|
+
itemWidth,
|
|
159
|
+
estimatedItemHeight,
|
|
160
|
+
estimatedItemWidth,
|
|
161
|
+
variableHeight,
|
|
162
|
+
variableWidth,
|
|
163
|
+
itemHeights,
|
|
164
|
+
itemWidths,
|
|
165
|
+
direction,
|
|
166
|
+
containerDimensions.width,
|
|
167
|
+
gridPositions
|
|
168
|
+
])
|
|
70
169
|
|
|
71
170
|
// Toplam içerik yüksekliği
|
|
72
171
|
const totalHeight = useMemo(() => {
|
|
73
|
-
|
|
172
|
+
let baseHeight = itemPositions.length > 0
|
|
74
173
|
? itemPositions[itemPositions.length - 1].top + itemPositions[itemPositions.length - 1].height
|
|
75
174
|
: 0
|
|
76
|
-
|
|
175
|
+
|
|
176
|
+
// Infinite scrolling için ek yükseklik ekle
|
|
177
|
+
if (hasNextPage && isLoadingNextPage) {
|
|
178
|
+
baseHeight += 50 // Loader yüksekliği
|
|
179
|
+
} else if (!hasNextPage && items.length > 0) {
|
|
180
|
+
baseHeight += 40 // End message yüksekliği
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return baseHeight
|
|
184
|
+
}, [itemPositions, hasNextPage, isLoadingNextPage, items.length])
|
|
77
185
|
|
|
78
186
|
// Görünür aralığı hesapla
|
|
79
187
|
const visibleRange = useMemo(() => {
|
|
@@ -91,6 +199,15 @@ export function VirtualList<T = any>({
|
|
|
91
199
|
}
|
|
92
200
|
}, [scrollTop, height, itemPositions, overscan, items.length, itemHeight])
|
|
93
201
|
|
|
202
|
+
// Memory Management Hook
|
|
203
|
+
const {
|
|
204
|
+
metrics,
|
|
205
|
+
memoryCache,
|
|
206
|
+
updateScrollDistance,
|
|
207
|
+
optimizeGC,
|
|
208
|
+
trackRenderPerformance
|
|
209
|
+
} = useMemoryManagement(memoryManagement, visibleRange, items.length)
|
|
210
|
+
|
|
94
211
|
// Görünür itemlar
|
|
95
212
|
const visibleItems = useMemo(() => {
|
|
96
213
|
const result: Array<{ item: T; index: number; style: React.CSSProperties }> = []
|
|
@@ -119,14 +236,33 @@ export function VirtualList<T = any>({
|
|
|
119
236
|
// Scroll handler
|
|
120
237
|
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
|
121
238
|
const scrollTop = e.currentTarget.scrollTop
|
|
239
|
+
const scrollHeight = e.currentTarget.scrollHeight
|
|
240
|
+
const clientHeight = e.currentTarget.clientHeight
|
|
241
|
+
|
|
122
242
|
setScrollTop(scrollTop)
|
|
123
243
|
setIsScrolling(true)
|
|
124
244
|
|
|
245
|
+
// Memory management scroll tracking
|
|
246
|
+
updateScrollDistance(scrollTop)
|
|
247
|
+
|
|
248
|
+
// Garbage collection optimization
|
|
249
|
+
optimizeGC()
|
|
250
|
+
|
|
125
251
|
// Scroll callback
|
|
126
252
|
if (onScroll) {
|
|
127
253
|
onScroll(scrollTop)
|
|
128
254
|
}
|
|
129
255
|
|
|
256
|
+
// Infinite scrolling check
|
|
257
|
+
if (
|
|
258
|
+
hasNextPage &&
|
|
259
|
+
!isLoadingNextPage &&
|
|
260
|
+
onLoadMore &&
|
|
261
|
+
scrollHeight - (scrollTop + clientHeight) <= loadMoreThreshold
|
|
262
|
+
) {
|
|
263
|
+
onLoadMore()
|
|
264
|
+
}
|
|
265
|
+
|
|
130
266
|
// Scroll timeout'ını temizle ve yenisini ayarla
|
|
131
267
|
if (scrollTimeoutRef.current) {
|
|
132
268
|
clearTimeout(scrollTimeoutRef.current)
|
|
@@ -140,7 +276,7 @@ export function VirtualList<T = any>({
|
|
|
140
276
|
isScrollingTimeoutRef.current = setTimeout(() => {
|
|
141
277
|
setIsScrolling(false)
|
|
142
278
|
}, 150)
|
|
143
|
-
}, [onScroll])
|
|
279
|
+
}, [onScroll, hasNextPage, isLoadingNextPage, onLoadMore, loadMoreThreshold, updateScrollDistance, optimizeGC])
|
|
144
280
|
|
|
145
281
|
// Variable height için resize observer
|
|
146
282
|
useEffect(() => {
|
|
@@ -217,8 +353,12 @@ export function VirtualList<T = any>({
|
|
|
217
353
|
}
|
|
218
354
|
}, [itemHeight, height, totalHeight])
|
|
219
355
|
|
|
356
|
+
const ContainerComponent = enableAnimations ? motion.div : 'div'
|
|
357
|
+
const ContentComponent = enableAnimations ? motion.div : 'div'
|
|
358
|
+
const ItemComponent = enableAnimations ? motion.div : 'div'
|
|
359
|
+
|
|
220
360
|
return (
|
|
221
|
-
<
|
|
361
|
+
<ContainerComponent
|
|
222
362
|
ref={containerRef}
|
|
223
363
|
className={cn(
|
|
224
364
|
"relative overflow-hidden border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-ring",
|
|
@@ -228,47 +368,186 @@ export function VirtualList<T = any>({
|
|
|
228
368
|
tabIndex={0}
|
|
229
369
|
role="listbox"
|
|
230
370
|
aria-label={`Virtual list with ${items.length} items`}
|
|
371
|
+
{...(enableAnimations && listAnimation)}
|
|
231
372
|
>
|
|
232
373
|
<div
|
|
233
374
|
ref={scrollElementRef}
|
|
234
375
|
className="h-full overflow-auto scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent"
|
|
235
376
|
onScroll={handleScroll}
|
|
236
377
|
>
|
|
237
|
-
<
|
|
378
|
+
<ContentComponent
|
|
238
379
|
style={{
|
|
239
380
|
height: totalHeight,
|
|
240
381
|
position: 'relative'
|
|
241
382
|
}}
|
|
383
|
+
{...(enableAnimations && {
|
|
384
|
+
initial: { opacity: 0 },
|
|
385
|
+
animate: { opacity: 1 },
|
|
386
|
+
transition: { duration: 0.2 }
|
|
387
|
+
})}
|
|
242
388
|
>
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
389
|
+
<AnimatePresence mode="popLayout">
|
|
390
|
+
{visibleItems.map(({ item, index, style }, itemIndex) => (
|
|
391
|
+
<ItemComponent
|
|
392
|
+
key={`${index}-${JSON.stringify(item)}`}
|
|
393
|
+
data-index={index}
|
|
394
|
+
style={style}
|
|
395
|
+
role="option"
|
|
396
|
+
aria-selected="false"
|
|
397
|
+
className="outline-none"
|
|
398
|
+
{...(enableAnimations && {
|
|
399
|
+
...itemEnterAnimation,
|
|
400
|
+
transition: {
|
|
401
|
+
...itemEnterAnimation.transition,
|
|
402
|
+
delay: itemIndex * staggerDelay
|
|
403
|
+
}
|
|
404
|
+
})}
|
|
405
|
+
>
|
|
406
|
+
{renderItem(item, index)}
|
|
407
|
+
</ItemComponent>
|
|
408
|
+
))}
|
|
409
|
+
</AnimatePresence>
|
|
410
|
+
|
|
411
|
+
{/* Infinite scrolling loader */}
|
|
412
|
+
{hasNextPage && isLoadingNextPage && (
|
|
413
|
+
<motion.div
|
|
414
|
+
style={{
|
|
415
|
+
position: 'absolute',
|
|
416
|
+
top: totalHeight,
|
|
417
|
+
left: 0,
|
|
418
|
+
right: 0,
|
|
419
|
+
minHeight: 50
|
|
420
|
+
}}
|
|
421
|
+
className="flex items-center justify-center py-4"
|
|
422
|
+
initial={{ opacity: 0, y: 20 }}
|
|
423
|
+
animate={{ opacity: 1, y: 0 }}
|
|
424
|
+
exit={{ opacity: 0, y: -20 }}
|
|
425
|
+
transition={{ duration: 0.3 }}
|
|
251
426
|
>
|
|
252
|
-
{
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
427
|
+
{renderLoader ? renderLoader() : (
|
|
428
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
429
|
+
<motion.div
|
|
430
|
+
className="rounded-full h-4 w-4 border-2 border-primary border-t-transparent"
|
|
431
|
+
animate={{ rotate: 360 }}
|
|
432
|
+
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
|
433
|
+
/>
|
|
434
|
+
<span className="text-sm">Loading more...</span>
|
|
435
|
+
</div>
|
|
436
|
+
)}
|
|
437
|
+
</motion.div>
|
|
438
|
+
)}
|
|
439
|
+
|
|
440
|
+
{/* End message */}
|
|
441
|
+
{!hasNextPage && items.length > 0 && (
|
|
442
|
+
<motion.div
|
|
443
|
+
style={{
|
|
444
|
+
position: 'absolute',
|
|
445
|
+
top: totalHeight,
|
|
446
|
+
left: 0,
|
|
447
|
+
right: 0,
|
|
448
|
+
minHeight: 40
|
|
449
|
+
}}
|
|
450
|
+
className="flex items-center justify-center py-3"
|
|
451
|
+
initial={{ opacity: 0, scale: 0.9 }}
|
|
452
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
453
|
+
transition={{ duration: 0.2 }}
|
|
454
|
+
>
|
|
455
|
+
{renderEndMessage ? renderEndMessage() : (
|
|
456
|
+
<div className="text-sm text-muted-foreground">
|
|
457
|
+
No more items to load
|
|
458
|
+
</div>
|
|
459
|
+
)}
|
|
460
|
+
</motion.div>
|
|
461
|
+
)}
|
|
462
|
+
</ContentComponent>
|
|
256
463
|
</div>
|
|
257
464
|
|
|
258
465
|
{/* Loading indicator for scrolling */}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
466
|
+
<AnimatePresence>
|
|
467
|
+
{isScrolling && (
|
|
468
|
+
<motion.div
|
|
469
|
+
className="absolute top-2 right-2 bg-muted/80 text-muted-foreground px-2 py-1 rounded text-xs"
|
|
470
|
+
initial={{ opacity: 0, scale: 0.8 }}
|
|
471
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
472
|
+
exit={{ opacity: 0, scale: 0.8 }}
|
|
473
|
+
transition={{ duration: 0.15 }}
|
|
474
|
+
>
|
|
475
|
+
Scrolling...
|
|
476
|
+
</motion.div>
|
|
477
|
+
)}
|
|
478
|
+
</AnimatePresence>
|
|
264
479
|
|
|
265
480
|
{/* Scroll position indicator */}
|
|
266
481
|
{items.length > 100 && (
|
|
267
|
-
<div
|
|
482
|
+
<motion.div
|
|
483
|
+
className="absolute bottom-2 right-2 bg-muted/80 text-muted-foreground px-2 py-1 rounded text-xs"
|
|
484
|
+
initial={{ opacity: 0, y: 10 }}
|
|
485
|
+
animate={{ opacity: 1, y: 0 }}
|
|
486
|
+
transition={{ duration: 0.2, delay: 0.5 }}
|
|
487
|
+
>
|
|
268
488
|
{Math.round((scrollTop / Math.max(totalHeight - height, 1)) * 100)}%
|
|
269
|
-
</div>
|
|
489
|
+
</motion.div>
|
|
270
490
|
)}
|
|
271
|
-
|
|
491
|
+
|
|
492
|
+
{/* Performance Monitor */}
|
|
493
|
+
{enablePerformanceMonitoring && memoryManagement.enableDebugMode && (
|
|
494
|
+
<PerformanceMonitor metrics={metrics} />
|
|
495
|
+
)}
|
|
496
|
+
</ContainerComponent>
|
|
497
|
+
)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Performance Monitor Component
|
|
501
|
+
function PerformanceMonitor({ metrics }: { metrics: VirtualListPerformanceMetrics }) {
|
|
502
|
+
const formatMemory = (bytes: number) => {
|
|
503
|
+
const kb = bytes / 1024
|
|
504
|
+
const mb = kb / 1024
|
|
505
|
+
|
|
506
|
+
if (mb >= 1) return `${mb.toFixed(2)} MB`
|
|
507
|
+
if (kb >= 1) return `${kb.toFixed(2)} KB`
|
|
508
|
+
return `${bytes} B`
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return (
|
|
512
|
+
<motion.div
|
|
513
|
+
className="absolute top-2 left-2 bg-black/90 text-white p-3 rounded-lg text-xs font-mono shadow-lg"
|
|
514
|
+
initial={{ opacity: 0, x: -20 }}
|
|
515
|
+
animate={{ opacity: 1, x: 0 }}
|
|
516
|
+
transition={{ duration: 0.3 }}
|
|
517
|
+
>
|
|
518
|
+
<div className="space-y-1">
|
|
519
|
+
<div className="text-green-400 font-semibold">🚀 Performance Monitor</div>
|
|
520
|
+
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
|
521
|
+
<span>Renders:</span>
|
|
522
|
+
<span className="text-blue-300">{metrics.renderCount}</span>
|
|
523
|
+
|
|
524
|
+
<span>Avg Render:</span>
|
|
525
|
+
<span className="text-blue-300">{metrics.averageRenderTime.toFixed(2)}ms</span>
|
|
526
|
+
|
|
527
|
+
<span>Memory:</span>
|
|
528
|
+
<span className={`${metrics.memoryUsage > 5 * 1024 * 1024 ? 'text-red-400' : 'text-green-400'}`}>
|
|
529
|
+
{formatMemory(metrics.memoryUsage)}
|
|
530
|
+
</span>
|
|
531
|
+
|
|
532
|
+
<span>Visible Items:</span>
|
|
533
|
+
<span className="text-yellow-300">{metrics.visibleItemsCount}</span>
|
|
534
|
+
|
|
535
|
+
<span>Scroll FPS:</span>
|
|
536
|
+
<span className={`${metrics.scrollFPS < 50 ? 'text-red-400' : 'text-green-400'}`}>
|
|
537
|
+
{metrics.scrollFPS}
|
|
538
|
+
</span>
|
|
539
|
+
|
|
540
|
+
<span>Distance:</span>
|
|
541
|
+
<span className="text-purple-300">{Math.round(metrics.totalScrollDistance)}px</span>
|
|
542
|
+
</div>
|
|
543
|
+
|
|
544
|
+
{metrics.lastGCTime && (
|
|
545
|
+
<div className="text-orange-400 text-xs mt-2">
|
|
546
|
+
Last GC: {new Date(metrics.lastGCTime).toLocaleTimeString()}
|
|
547
|
+
</div>
|
|
548
|
+
)}
|
|
549
|
+
</div>
|
|
550
|
+
</motion.div>
|
|
272
551
|
)
|
|
273
552
|
}
|
|
274
553
|
|
|
@@ -279,6 +558,7 @@ export function useVirtualList<T>(
|
|
|
279
558
|
) {
|
|
280
559
|
const [config, setConfig] = useState(initialConfig)
|
|
281
560
|
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set())
|
|
561
|
+
const [focusedIndex, setFocusedIndex] = useState<number>(-1)
|
|
282
562
|
|
|
283
563
|
const updateConfig = useCallback((newConfig: Partial<VirtualListProps<T>>) => {
|
|
284
564
|
setConfig(prev => ({ ...prev, ...newConfig }))
|
|
@@ -296,17 +576,66 @@ export function useVirtualList<T>(
|
|
|
296
576
|
})
|
|
297
577
|
}, [])
|
|
298
578
|
|
|
579
|
+
const toggleItem = useCallback((index: number) => {
|
|
580
|
+
setSelectedItems(prev => {
|
|
581
|
+
const newSet = new Set(prev)
|
|
582
|
+
if (newSet.has(index)) {
|
|
583
|
+
newSet.delete(index)
|
|
584
|
+
} else {
|
|
585
|
+
newSet.add(index)
|
|
586
|
+
}
|
|
587
|
+
return newSet
|
|
588
|
+
})
|
|
589
|
+
}, [])
|
|
590
|
+
|
|
591
|
+
const selectRange = useCallback((startIndex: number, endIndex: number) => {
|
|
592
|
+
setSelectedItems(prev => {
|
|
593
|
+
const newSet = new Set(prev)
|
|
594
|
+
const start = Math.min(startIndex, endIndex)
|
|
595
|
+
const end = Math.max(startIndex, endIndex)
|
|
596
|
+
|
|
597
|
+
for (let i = start; i <= end; i++) {
|
|
598
|
+
newSet.add(i)
|
|
599
|
+
}
|
|
600
|
+
return newSet
|
|
601
|
+
})
|
|
602
|
+
}, [])
|
|
603
|
+
|
|
604
|
+
const selectAll = useCallback(() => {
|
|
605
|
+
setSelectedItems(new Set(Array.from({ length: items.length }, (_, i) => i)))
|
|
606
|
+
}, [items.length])
|
|
607
|
+
|
|
299
608
|
const clearSelection = useCallback(() => {
|
|
300
609
|
setSelectedItems(new Set())
|
|
301
610
|
}, [])
|
|
302
611
|
|
|
612
|
+
const isSelected = useCallback((index: number) => {
|
|
613
|
+
return selectedItems.has(index)
|
|
614
|
+
}, [selectedItems])
|
|
615
|
+
|
|
616
|
+
const getSelectedItems = useCallback(() => {
|
|
617
|
+
return Array.from(selectedItems).map(index => items[index]).filter(Boolean)
|
|
618
|
+
}, [selectedItems, items])
|
|
619
|
+
|
|
620
|
+
const getSelectedCount = useCallback(() => {
|
|
621
|
+
return selectedItems.size
|
|
622
|
+
}, [selectedItems])
|
|
623
|
+
|
|
303
624
|
return {
|
|
304
625
|
config,
|
|
305
626
|
updateConfig,
|
|
306
627
|
selectedItems,
|
|
628
|
+
focusedIndex,
|
|
629
|
+
setFocusedIndex,
|
|
307
630
|
selectItem,
|
|
308
631
|
deselectItem,
|
|
309
|
-
|
|
632
|
+
toggleItem,
|
|
633
|
+
selectRange,
|
|
634
|
+
selectAll,
|
|
635
|
+
clearSelection,
|
|
636
|
+
isSelected,
|
|
637
|
+
getSelectedItems,
|
|
638
|
+
getSelectedCount
|
|
310
639
|
}
|
|
311
640
|
}
|
|
312
641
|
|
|
@@ -316,6 +645,12 @@ export interface SelectableVirtualListProps<T = any> extends VirtualListProps<T>
|
|
|
316
645
|
multiSelect?: boolean
|
|
317
646
|
selectedItems?: Set<number>
|
|
318
647
|
onSelectionChange?: (selectedItems: Set<number>) => void
|
|
648
|
+
focusedIndex?: number
|
|
649
|
+
onFocusChange?: (focusedIndex: number) => void
|
|
650
|
+
selectAllEnabled?: boolean
|
|
651
|
+
onSelectAll?: () => void
|
|
652
|
+
onDeselectAll?: () => void
|
|
653
|
+
getItemId?: (item: T, index: number) => string | number
|
|
319
654
|
}
|
|
320
655
|
|
|
321
656
|
export function SelectableVirtualList<T = any>({
|
|
@@ -323,44 +658,108 @@ export function SelectableVirtualList<T = any>({
|
|
|
323
658
|
multiSelect = false,
|
|
324
659
|
selectedItems = new Set(),
|
|
325
660
|
onSelectionChange,
|
|
661
|
+
focusedIndex = -1,
|
|
662
|
+
onFocusChange,
|
|
663
|
+
selectAllEnabled = false,
|
|
664
|
+
onSelectAll,
|
|
665
|
+
onDeselectAll,
|
|
666
|
+
getItemId,
|
|
326
667
|
renderItem: originalRenderItem,
|
|
327
668
|
...props
|
|
328
669
|
}: SelectableVirtualListProps<T>) {
|
|
329
|
-
const
|
|
670
|
+
const [lastSelectedIndex, setLastSelectedIndex] = useState<number>(-1)
|
|
671
|
+
|
|
672
|
+
const handleItemClick = useCallback((index: number, event?: React.MouseEvent) => {
|
|
330
673
|
if (!selectable || !onSelectionChange) return
|
|
331
674
|
|
|
332
675
|
const newSelection = new Set(selectedItems)
|
|
333
676
|
|
|
334
|
-
if (multiSelect) {
|
|
677
|
+
if (multiSelect && event?.shiftKey && lastSelectedIndex !== -1) {
|
|
678
|
+
// Shift+click için range selection
|
|
679
|
+
const start = Math.min(lastSelectedIndex, index)
|
|
680
|
+
const end = Math.max(lastSelectedIndex, index)
|
|
681
|
+
|
|
682
|
+
for (let i = start; i <= end; i++) {
|
|
683
|
+
newSelection.add(i)
|
|
684
|
+
}
|
|
685
|
+
} else if (multiSelect && (event?.ctrlKey || event?.metaKey)) {
|
|
686
|
+
// Ctrl/Cmd+click için toggle
|
|
687
|
+
if (newSelection.has(index)) {
|
|
688
|
+
newSelection.delete(index)
|
|
689
|
+
} else {
|
|
690
|
+
newSelection.add(index)
|
|
691
|
+
}
|
|
692
|
+
} else if (multiSelect) {
|
|
693
|
+
// Normal click in multi-select mode
|
|
335
694
|
if (newSelection.has(index)) {
|
|
336
695
|
newSelection.delete(index)
|
|
337
696
|
} else {
|
|
338
697
|
newSelection.add(index)
|
|
339
698
|
}
|
|
340
699
|
} else {
|
|
700
|
+
// Single select mode
|
|
341
701
|
newSelection.clear()
|
|
342
702
|
newSelection.add(index)
|
|
343
703
|
}
|
|
344
704
|
|
|
705
|
+
setLastSelectedIndex(index)
|
|
345
706
|
onSelectionChange(newSelection)
|
|
346
|
-
|
|
707
|
+
onFocusChange?.(index)
|
|
708
|
+
}, [selectable, multiSelect, selectedItems, onSelectionChange, lastSelectedIndex, onFocusChange])
|
|
709
|
+
|
|
710
|
+
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
|
711
|
+
if (!selectable) return
|
|
712
|
+
|
|
713
|
+
switch (event.key) {
|
|
714
|
+
case 'Enter':
|
|
715
|
+
case ' ':
|
|
716
|
+
event.preventDefault()
|
|
717
|
+
if (focusedIndex >= 0 && focusedIndex < props.items.length) {
|
|
718
|
+
handleItemClick(focusedIndex)
|
|
719
|
+
}
|
|
720
|
+
break
|
|
721
|
+
case 'a':
|
|
722
|
+
if ((event.ctrlKey || event.metaKey) && multiSelect && selectAllEnabled) {
|
|
723
|
+
event.preventDefault()
|
|
724
|
+
if (onSelectAll) {
|
|
725
|
+
onSelectAll()
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
break
|
|
729
|
+
case 'Escape':
|
|
730
|
+
if (multiSelect && onDeselectAll) {
|
|
731
|
+
event.preventDefault()
|
|
732
|
+
onDeselectAll()
|
|
733
|
+
}
|
|
734
|
+
break
|
|
735
|
+
}
|
|
736
|
+
}, [selectable, focusedIndex, props.items.length, handleItemClick, multiSelect, selectAllEnabled, onSelectAll, onDeselectAll])
|
|
347
737
|
|
|
348
738
|
const renderItem = useCallback((item: T, index: number) => {
|
|
349
739
|
const isSelected = selectedItems.has(index)
|
|
740
|
+
const isFocused = focusedIndex === index
|
|
741
|
+
const itemId = getItemId ? getItemId(item, index) : index
|
|
350
742
|
|
|
351
743
|
return (
|
|
352
744
|
<div
|
|
745
|
+
key={itemId}
|
|
353
746
|
className={cn(
|
|
354
|
-
"transition-colors",
|
|
747
|
+
"transition-colors outline-none",
|
|
355
748
|
selectable && "cursor-pointer hover:bg-muted/50",
|
|
356
|
-
isSelected && "bg-primary/10 border-l-4 border-primary"
|
|
749
|
+
isSelected && "bg-primary/10 border-l-4 border-primary",
|
|
750
|
+
isFocused && "ring-2 ring-ring ring-offset-2"
|
|
357
751
|
)}
|
|
358
|
-
onClick={() => selectable && handleItemClick(index)}
|
|
752
|
+
onClick={(e) => selectable && handleItemClick(index, e)}
|
|
753
|
+
onKeyDown={handleKeyDown}
|
|
754
|
+
tabIndex={selectable ? 0 : undefined}
|
|
755
|
+
role={selectable ? "option" : undefined}
|
|
756
|
+
aria-selected={selectable ? isSelected : undefined}
|
|
757
|
+
aria-label={selectable ? `Item ${index + 1}${isSelected ? ', selected' : ''}` : undefined}
|
|
359
758
|
>
|
|
360
759
|
{originalRenderItem(item, index)}
|
|
361
760
|
</div>
|
|
362
761
|
)
|
|
363
|
-
}, [originalRenderItem, selectable, selectedItems, handleItemClick])
|
|
762
|
+
}, [originalRenderItem, selectable, selectedItems, focusedIndex, handleItemClick, handleKeyDown, getItemId])
|
|
364
763
|
|
|
365
764
|
return <VirtualList {...props} renderItem={renderItem} />
|
|
366
765
|
}
|