@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.
@@ -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
- top: currentTop
142
+ width: currentWidth,
143
+ top: direction === 'horizontal' ? 0 : currentTop,
144
+ left: direction === 'horizontal' ? currentLeft : 0
63
145
  })
64
146
 
65
- currentTop += currentHeight
147
+ if (direction === 'horizontal') {
148
+ currentLeft += currentWidth
149
+ } else {
150
+ currentTop += currentHeight
151
+ }
66
152
  }
67
153
 
68
154
  return positions
69
- }, [items.length, itemHeight, estimatedItemHeight, variableHeight, itemHeights])
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
- return itemPositions.length > 0
172
+ let baseHeight = itemPositions.length > 0
74
173
  ? itemPositions[itemPositions.length - 1].top + itemPositions[itemPositions.length - 1].height
75
174
  : 0
76
- }, [itemPositions])
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
- <div
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
- <div
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
- {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"
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
- {renderItem(item, index)}
253
- </div>
254
- ))}
255
- </div>
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
- {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
- )}
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 className="absolute bottom-2 right-2 bg-muted/80 text-muted-foreground px-2 py-1 rounded text-xs">
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
- </div>
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
- clearSelection
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 handleItemClick = useCallback((index: number) => {
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
- }, [selectable, multiSelect, selectedItems, onSelectionChange])
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
  }