@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.
- package/dist/index.mjs +2719 -742
- package/package.json +3 -1
- package/src/components/calendar-pro/index.tsx +129 -24
- package/src/components/github-stars/github-api.ts +413 -0
- package/src/components/github-stars/hooks.ts +304 -0
- package/src/components/github-stars/index.tsx +215 -288
- package/src/components/github-stars/types.ts +146 -0
- package/src/components/github-stars/variants.tsx +380 -0
- package/src/components/lazy-component/index.tsx +567 -85
- package/src/components/memory-efficient-data/index.tsx +730 -66
- package/src/components/virtual-list/index.tsx +335 -35
- 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,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
|
-
}, [
|
|
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
|
-
|
|
144
|
+
let baseHeight = itemPositions.length > 0
|
|
74
145
|
? itemPositions[itemPositions.length - 1].top + itemPositions[itemPositions.length - 1].height
|
|
75
146
|
: 0
|
|
76
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
{
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|