@humanspeak/svelte-virtual-list 0.2.5 → 0.2.6-beta.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,45 +1,63 @@
1
1
  <!--
2
- @component
3
- A high-performance virtualized list component that efficiently renders large datasets
4
- by only mounting DOM nodes for visible items and a small buffer. Optimized for handling
5
- lists of 10k+ items through chunked processing and progressive initialization.
6
-
7
- Props:
8
- - `items` - Array of items to render
9
- - `defaultEstimatedItemHeight` - Initial height estimate for items (default: 40px)
10
- - `mode` - Scroll direction: 'topToBottom' or 'bottomToTop' (default: 'topToBottom')
11
- - `debug` - Enable debug logging (default: false)
12
- - `bufferSize` - Number of items to render outside visible area (default: 20)
13
- - `containerClass` - Custom class for container element
14
- - `viewportClass` - Custom class for viewport element
15
- - `contentClass` - Custom class for content wrapper
16
- - `itemsClass` - Custom class for items wrapper
17
- - `debugFunction` - Custom debug logging function
18
- - `testId` - Base test ID for component elements
19
-
20
- Usage:
2
+ @component SvelteVirtualList
3
+
4
+ A high-performance, memory-efficient virtualized list component for Svelte 5.
5
+ Renders only visible items plus a buffer, supporting dynamic item heights,
6
+ bi-directional (top-to-bottom and bottom-to-top) scrolling, and programmatic control.
7
+
8
+ =============================
9
+ == Key Features ==
10
+ =============================
11
+ - Dynamic item height support (no fixed height required)
12
+ - Top-to-bottom and bottom-to-top (chat-style) scrolling
13
+ - Programmatic scrolling with flexible alignment (top, bottom, auto)
14
+ - Smooth scrolling and buffer size configuration
15
+ - SSR compatible and hydration-friendly
16
+ - TypeScript and Svelte 5 runes/snippets support
17
+ - Customizable styling via class props
18
+ - Debug mode for development and testing
19
+ - Optimized for large lists (10k+ items)
20
+ - Comprehensive test coverage (unit and E2E)
21
+
22
+ =============================
23
+ == Usage Example ==
24
+ =============================
21
25
  ```svelte
22
26
  <SvelteVirtualList
23
27
  items={data}
24
- defaultEstimatedItemHeight={40}
25
- mode="topToBottom"
28
+ mode="bottomToTop"
29
+ bind:this={listRef}
26
30
  >
27
- {#snippet renderItem(item, index)}
28
- <div class="item">{item.text}</div>
31
+ {#snippet renderItem(item)}
32
+ <div>{item.text}</div>
29
33
  {/snippet}
30
34
  </SvelteVirtualList>
31
35
  ```
32
36
 
33
- Features:
34
- - Dynamic height calculation
35
- - Bidirectional scrolling
36
- - Configurable buffer size
37
- - Debug mode
38
- - Custom styling
39
- - Progressive initialization for large datasets
40
- - Memory-optimized for 10k+ items
41
- - Chunked processing for smooth performance
42
- - Progress tracking during initialization
37
+ =============================
38
+ == Architecture Notes ==
39
+ =============================
40
+ - Uses a four-layer DOM structure for optimal performance
41
+ - Only visible items + buffer are mounted in the DOM
42
+ - Height caching and estimation for dynamic content
43
+ - Handles resize events and dynamic content changes
44
+ - Supports chunked initialization for very large lists
45
+ - All scrolling logic is centralized in the scroll() method
46
+ - Bi-directional support: mode="topToBottom" or "bottomToTop"
47
+ - Designed for extensibility and easy debugging
48
+
49
+ =============================
50
+ == For Contributors ==
51
+ =============================
52
+ - Please keep all scrolling logic in the scroll() method
53
+ - Add new features behind feature flags or as optional props
54
+ - Write tests for all new features (see /test and /tests/scroll)
55
+ - Use TypeScript and Svelte 5 runes for all new code
56
+ - Document all exported functions and props with JSDoc
57
+ - See README.md for API and usage details
58
+ - For questions, open an issue or discussion on GitHub
59
+
60
+ MIT License © Humanspeak, Inc.
43
61
  -->
44
62
 
45
63
  <script lang="ts">
@@ -122,23 +140,28 @@
122
140
  * - Progressive size adjustment system
123
141
  */
124
142
 
125
- import type { SvelteVirtualListProps } from './types.js'
143
+ import {
144
+ DEFAULT_SCROLL_OPTIONS,
145
+ type SvelteVirtualListPreviousVisibleRange,
146
+ type SvelteVirtualListProps,
147
+ type SvelteVirtualListScrollOptions
148
+ } from './types.js'
126
149
  import { calculateAverageHeightDebounced } from './utils/heightCalculation.js'
127
150
  import { createRafScheduler } from './utils/raf.js'
128
151
  import {
129
152
  calculateScrollPosition,
130
153
  calculateTransformY,
131
154
  calculateVisibleRange,
155
+ getScrollOffsetForIndex,
132
156
  processChunked,
133
- updateHeightAndScroll as utilsUpdateHeightAndScroll,
134
- getScrollOffsetForIndex
157
+ updateHeightAndScroll as utilsUpdateHeightAndScroll
135
158
  } from './utils/virtualList.js'
136
159
  import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
137
160
  import { BROWSER } from 'esm-env'
138
161
  import { onMount, tick } from 'svelte'
139
162
 
140
163
  const rafSchedule = createRafScheduler()
141
-
164
+ const INTERNAL_DEBUG = true
142
165
  /**
143
166
  * Core configuration props with default values
144
167
  * @type {SvelteVirtualListProps}
@@ -185,52 +208,107 @@
185
208
  */
186
209
  let heightUpdateTimeout: ReturnType<typeof setTimeout> | null = null // Debounce timer for height updates
187
210
  let resizeObserver: ResizeObserver | null = null // Watches for container size changes
211
+ let itemResizeObserver: ResizeObserver | null = null // Watches for individual item size changes
188
212
 
189
213
  /**
190
214
  * Performance Optimization State
191
215
  */
192
216
  let heightCache = $state<Record<number, number>>({}) // Cache of measured item heights
217
+ let dirtyItems = $state(new Set<number>()) // Set of item indices that need height recalculation
193
218
  const chunkSize = $state(50) // Number of items to process in each chunk
194
219
  let processedItems = $state(0) // Number of items processed during initialization
195
220
 
196
- let prevVisibleRange = $state<{ start: number; end: number } | null>(null)
221
+ let prevVisibleRange = $state<SvelteVirtualListPreviousVisibleRange | null>(null)
197
222
  let prevHeight = $state<number>(0)
198
223
 
199
224
  // Trigger height calculation when items are rendered
200
225
  $effect(() => {
201
226
  if (BROWSER && itemElements.length > 0 && !isCalculatingHeight) {
202
- heightUpdateTimeout = calculateAverageHeightDebounced(
203
- isCalculatingHeight,
204
- heightUpdateTimeout,
205
- visibleItems,
206
- itemElements,
207
- heightCache,
208
- lastMeasuredIndex,
209
- calculatedItemHeight,
210
- (result) => {
211
- calculatedItemHeight = result.newHeight
212
- lastMeasuredIndex = result.newLastMeasuredIndex
213
- heightCache = result.updatedHeightCache
214
- }
215
- )
227
+ updateHeight()
216
228
  }
217
229
  })
218
230
 
231
+ const updateHeight = () => {
232
+ heightUpdateTimeout = calculateAverageHeightDebounced(
233
+ isCalculatingHeight,
234
+ heightUpdateTimeout,
235
+ visibleItems,
236
+ itemElements,
237
+ heightCache,
238
+ lastMeasuredIndex,
239
+ calculatedItemHeight,
240
+ (result) => {
241
+ calculatedItemHeight = result.newHeight
242
+ lastMeasuredIndex = result.newLastMeasuredIndex
243
+ heightCache = result.updatedHeightCache
244
+
245
+ // Update running totals for precise height calculation (only when significant changes)
246
+ if (result.clearedDirtyItems.size > 10) {
247
+ const heights = Object.values(heightCache)
248
+ totalMeasuredHeight = heights.reduce((sum, h) => sum + h, 0)
249
+ measuredCount = heights.length
250
+ }
251
+
252
+ // Clear processed dirty items
253
+ result.clearedDirtyItems.forEach((index) => {
254
+ dirtyItems.delete(index)
255
+ })
256
+
257
+ if (INTERNAL_DEBUG && result.clearedDirtyItems.size > 0) {
258
+ console.log(
259
+ `Cleared ${result.clearedDirtyItems.size} dirty items:`,
260
+ Array.from(result.clearedDirtyItems)
261
+ )
262
+ }
263
+ },
264
+ 100, // debounceTime
265
+ dirtyItems // Pass dirty items for processing
266
+ )
267
+ }
268
+
219
269
  // Add new effect to handle height changes
270
+ // Track if user has scrolled away from bottom to prevent snap-back
271
+ let userHasScrolledAway = $state(false)
272
+ let lastCalculatedHeight = $state(0)
273
+
220
274
  $effect(() => {
221
275
  if (BROWSER && initialized && mode === 'bottomToTop' && viewportElement) {
222
276
  const totalHeight = Math.max(0, items.length * calculatedItemHeight)
223
277
  const targetScrollTop = Math.max(0, totalHeight - height)
278
+ const currentScrollTop = viewportElement.scrollTop
279
+ const scrollDifference = Math.abs(currentScrollTop - targetScrollTop)
224
280
 
225
- // Only update if the difference is significant
226
- if (Math.abs(viewportElement.scrollTop - targetScrollTop) > calculatedItemHeight) {
227
- requestAnimationFrame(() => {
228
- if (viewportElement) {
229
- viewportElement.scrollTop = targetScrollTop
230
- scrollTop = targetScrollTop
231
- }
232
- })
281
+ // Only correct scroll if:
282
+ // 1. Item height changed significantly (not just user scrolling)
283
+ // 2. User hasn't intentionally scrolled away from bottom
284
+ // 3. We're significantly off target
285
+ const heightChanged = Math.abs(calculatedItemHeight - lastCalculatedHeight) > 1
286
+ const shouldCorrect =
287
+ heightChanged && !userHasScrolledAway && scrollDifference > calculatedItemHeight * 3
288
+
289
+ if (shouldCorrect) {
290
+ if (INTERNAL_DEBUG) {
291
+ console.log(
292
+ '🔄 Correcting scroll position from',
293
+ currentScrollTop,
294
+ 'to',
295
+ targetScrollTop,
296
+ 'diff:',
297
+ scrollDifference,
298
+ 'heightChanged:',
299
+ heightChanged
300
+ )
301
+ }
302
+ viewportElement.scrollTop = targetScrollTop
303
+ scrollTop = targetScrollTop
304
+ }
305
+
306
+ // Track if user has scrolled significantly away from bottom
307
+ if (scrollDifference > calculatedItemHeight * 5) {
308
+ userHasScrolledAway = true
233
309
  }
310
+
311
+ lastCalculatedHeight = calculatedItemHeight
234
312
  }
235
313
  })
236
314
 
@@ -274,6 +352,23 @@
274
352
  }
275
353
  })
276
354
 
355
+ /**
356
+ * Calculate precise item height based on actual measurements when available
357
+ */
358
+ // Running totals for efficient precise height calculation
359
+ let totalMeasuredHeight = $state(0)
360
+ let measuredCount = $state(0)
361
+ const preciseItemHeight = $derived(() => {
362
+ if (measuredCount > 100) {
363
+ const avgHeight = totalMeasuredHeight / measuredCount
364
+ // Only use if the difference is significant (more than 0.5px)
365
+ if (Math.abs(avgHeight - calculatedItemHeight) > 0.5) {
366
+ return avgHeight
367
+ }
368
+ }
369
+ return calculatedItemHeight
370
+ })
371
+
277
372
  /**
278
373
  * Calculates the range of items that should be rendered based on current scroll position.
279
374
  *
@@ -291,13 +386,33 @@
291
386
  * console.log(`Rendering items from ${range.start} to ${range.end}`)
292
387
  * ```
293
388
  *
294
- * @returns {{ start: number, end: number }} Object containing start and end indices of visible items
389
+ * @returns {SvelteVirtualListPreviousVisibleRange} Object containing start and end indices of visible items
295
390
  */
296
- const visibleItems = $derived(() => {
297
- if (!items.length) return { start: 0, end: 0 }
391
+ const visibleItems = $derived((): SvelteVirtualListPreviousVisibleRange => {
392
+ if (!items.length) return { start: 0, end: 0 } as SvelteVirtualListPreviousVisibleRange
298
393
  const viewportHeight = height || 0
299
394
 
300
- return calculateVisibleRange(
395
+ // For bottomToTop mode, don't calculate visible range until properly initialized
396
+ // This prevents showing wrong items when scrollTop starts at 0
397
+ if (mode === 'bottomToTop' && !initialized && scrollTop === 0 && viewportHeight > 0) {
398
+ // Calculate what the correct scroll position should be
399
+ const totalHeight = items.length * calculatedItemHeight
400
+ const targetScrollTop = Math.max(0, totalHeight - viewportHeight)
401
+
402
+ // Use the target scroll position for visible range calculation
403
+ const result = calculateVisibleRange(
404
+ targetScrollTop,
405
+ viewportHeight,
406
+ calculatedItemHeight,
407
+ items.length,
408
+ bufferSize,
409
+ mode
410
+ )
411
+
412
+ return result
413
+ }
414
+
415
+ const result = calculateVisibleRange(
301
416
  scrollTop,
302
417
  viewportHeight,
303
418
  calculatedItemHeight,
@@ -305,6 +420,8 @@
305
420
  bufferSize,
306
421
  mode
307
422
  )
423
+
424
+ return result
308
425
  })
309
426
 
310
427
  /**
@@ -460,6 +577,44 @@
460
577
  }
461
578
  })
462
579
 
580
+ // Create itemResizeObserver immediately when in browser
581
+ if (BROWSER) {
582
+ // Watch for individual item size changes
583
+ itemResizeObserver = new ResizeObserver((entries) => {
584
+ let shouldRecalculate = false
585
+
586
+ if (INTERNAL_DEBUG) {
587
+ console.log(`ResizeObserver fired for ${entries.length} entries`)
588
+ }
589
+
590
+ for (const entry of entries) {
591
+ const element = entry.target as HTMLElement
592
+ const elementIndex = itemElements.indexOf(element)
593
+
594
+ if (elementIndex !== -1) {
595
+ const actualIndex = visibleItems().start + elementIndex
596
+
597
+ // ResizeObserver fired = element resized, so add to dirty queue
598
+ dirtyItems.add(actualIndex)
599
+ shouldRecalculate = true
600
+
601
+ if (INTERNAL_DEBUG) {
602
+ console.log(
603
+ `Item ${actualIndex} marked dirty (resized), queue size: ${dirtyItems.size}`
604
+ )
605
+ }
606
+ }
607
+ }
608
+
609
+ if (shouldRecalculate) {
610
+ // Trigger virtual list recalculation
611
+ rafSchedule(() => {
612
+ updateHeight()
613
+ })
614
+ }
615
+ })
616
+ }
617
+
463
618
  // Setup and cleanup
464
619
  onMount(() => {
465
620
  if (BROWSER) {
@@ -480,13 +635,16 @@
480
635
  if (resizeObserver) {
481
636
  resizeObserver.disconnect()
482
637
  }
638
+ if (itemResizeObserver) {
639
+ itemResizeObserver.disconnect()
640
+ }
483
641
  }
484
642
  }
485
643
  })
486
644
 
487
645
  // Add the effect in the script section
488
646
  $effect(() => {
489
- if (debug) {
647
+ if (INTERNAL_DEBUG) {
490
648
  prevVisibleRange = visibleItems()
491
649
  prevHeight = calculatedItemHeight
492
650
  }
@@ -495,6 +653,9 @@
495
653
  /**
496
654
  * Scrolls the virtual list to the item at the given index.
497
655
  *
656
+ * @deprecated This function is deprecated and will be removed in a future version.
657
+ * Use the new scroll method from the component instance instead.
658
+ *
498
659
  * @function scrollToIndex
499
660
  * @param index The index of the item to scroll to.
500
661
  * @param smoothScroll (default: true) Whether to use smooth scrolling.
@@ -525,49 +686,239 @@
525
686
  smoothScroll = true,
526
687
  shouldThrowOnBounds = true
527
688
  ): void => {
689
+ // Deprecation warning
690
+ console.warn(
691
+ 'SvelteVirtualList: scrollToIndex is deprecated and will be removed in a future version. ' +
692
+ 'Use the new scroll method from the component instance instead.'
693
+ )
694
+
695
+ // Call the new scroll function with the provided parameters
696
+ scroll({ index, smoothScroll, shouldThrowOnBounds })
697
+ }
698
+
699
+ /**
700
+ * Scrolls the virtual list to the item at the given index using a type-based options approach.
701
+ *
702
+ * @function scroll
703
+ * @param options Configuration options for scrolling behavior.
704
+ *
705
+ * @example
706
+ * // Svelte usage:
707
+ * // In your <script> block:
708
+ * import SvelteVirtualList from './index.js';
709
+ * let virtualList;
710
+ * const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` }));
711
+ *
712
+ * <button onclick={() => virtualList.scroll({ index: 5000 })}>
713
+ * Scroll to 5000
714
+ * </button>
715
+ * <SvelteVirtualList {items} bind:this={virtualList}>
716
+ * {#snippet renderItem(item)}
717
+ * <div>{item.text}</div>
718
+ * {/snippet}
719
+ * </SvelteVirtualList>
720
+ *
721
+ * @returns {void}
722
+ * @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
723
+ */
724
+ export const scroll = (options: SvelteVirtualListScrollOptions): void => {
725
+ const { index, smoothScroll, shouldThrowOnBounds, align } = {
726
+ ...DEFAULT_SCROLL_OPTIONS,
727
+ ...options
728
+ }
729
+
528
730
  if (!items.length) return
529
731
  if (!viewportElement) {
530
732
  tick().then(() => {
531
733
  if (!viewportElement) return
532
- doScroll()
734
+ scroll({ index, smoothScroll, shouldThrowOnBounds, align })
533
735
  })
534
736
  return
535
737
  }
536
- doScroll()
537
738
 
538
- function doScroll() {
539
- const target = Number.isFinite(index) ? Math.trunc(index) : 0
540
- const clampedIndex = Math.max(0, Math.min(target, items.length - 1))
541
- if ((target < 0 || target >= items.length) && shouldThrowOnBounds) {
739
+ // Bounds checking
740
+ let targetIndex = index
741
+ if (targetIndex < 0 || targetIndex >= items.length) {
742
+ if (shouldThrowOnBounds) {
542
743
  throw new Error(
543
- `scrollToIndex: index ${target} is out of bounds (0-${items.length - 1})`
744
+ `scroll: index ${targetIndex} is out of bounds (0-${items.length - 1})`
544
745
  )
746
+ } else {
747
+ targetIndex = Math.max(0, Math.min(targetIndex, items.length - 1))
748
+ }
749
+ }
750
+
751
+ const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems()
752
+ let scrollTarget: number | null = null
753
+
754
+ if (mode === 'bottomToTop') {
755
+ const totalHeight = items.length * calculatedItemHeight
756
+ const itemOffset = targetIndex * calculatedItemHeight
757
+ const itemHeight = calculatedItemHeight
758
+ if (align === 'auto') {
759
+ // If item is above the viewport, align to top
760
+ if (targetIndex < firstVisibleIndex) {
761
+ scrollTarget = Math.max(0, totalHeight - (itemOffset + itemHeight))
762
+ // If item is below the viewport, align to bottom
763
+ } else if (targetIndex > lastVisibleIndex - 1) {
764
+ scrollTarget = Math.max(0, totalHeight - itemOffset - height)
765
+ } else {
766
+ // Item is visible but not aligned: align to nearest edge
767
+ // Calculate the offset of the item relative to the viewport
768
+ const itemTop = totalHeight - (itemOffset + itemHeight)
769
+ const itemBottom = totalHeight - itemOffset
770
+ const distanceToTop = Math.abs(scrollTop - itemTop)
771
+ const distanceToBottom = Math.abs(scrollTop + height - itemBottom)
772
+ if (distanceToTop < distanceToBottom) {
773
+ // Closer to top, align to top
774
+ scrollTarget = itemTop
775
+ } else {
776
+ // Closer to bottom, align to bottom
777
+ scrollTarget = Math.max(0, itemBottom - height)
778
+ }
779
+ }
780
+ } else if (align === 'top') {
781
+ // Align to top
782
+ scrollTarget = Math.max(0, totalHeight - (itemOffset + itemHeight))
783
+ } else if (align === 'bottom') {
784
+ // Align to bottom
785
+ scrollTarget = Math.max(0, totalHeight - itemOffset - height)
786
+ } else if (align === 'nearest') {
787
+ // If not visible, align to nearest edge; if visible, do nothing
788
+ const itemTop = totalHeight - (itemOffset + itemHeight)
789
+ const itemBottom = totalHeight - itemOffset
790
+ if (itemBottom <= scrollTop || itemTop >= scrollTop + height) {
791
+ // Not visible, align to nearest edge
792
+ const distanceToTop = Math.abs(scrollTop - itemTop)
793
+ const distanceToBottom = Math.abs(scrollTop + height - itemBottom)
794
+ if (distanceToTop < distanceToBottom) {
795
+ scrollTarget = itemTop
796
+ } else {
797
+ scrollTarget = Math.max(0, itemBottom - height)
798
+ }
799
+ } else {
800
+ // Already visible, do nothing
801
+ return
802
+ }
545
803
  }
546
- if (mode === 'topToBottom') {
547
- const scrollTopTarget = getScrollOffsetForIndex(
804
+ } else {
805
+ // topToBottom (default)
806
+ if (align === 'auto') {
807
+ // If item is above the viewport, align to top
808
+ if (targetIndex < firstVisibleIndex) {
809
+ scrollTarget = getScrollOffsetForIndex(
810
+ heightCache,
811
+ calculatedItemHeight,
812
+ targetIndex
813
+ )
814
+ // If item is below the viewport, align to bottom
815
+ } else if (targetIndex > lastVisibleIndex - 1) {
816
+ const itemBottom = getScrollOffsetForIndex(
817
+ heightCache,
818
+ calculatedItemHeight,
819
+ targetIndex + 1
820
+ )
821
+ scrollTarget = Math.max(0, itemBottom - height)
822
+ } else {
823
+ // Item is visible but not aligned: align to nearest edge
824
+ const itemTop = getScrollOffsetForIndex(
825
+ heightCache,
826
+ calculatedItemHeight,
827
+ targetIndex
828
+ )
829
+ const itemBottom = getScrollOffsetForIndex(
830
+ heightCache,
831
+ calculatedItemHeight,
832
+ targetIndex + 1
833
+ )
834
+ const distanceToTop = Math.abs(scrollTop - itemTop)
835
+ const distanceToBottom = Math.abs(scrollTop + height - itemBottom)
836
+ if (distanceToTop < distanceToBottom) {
837
+ // Closer to top, align to top
838
+ scrollTarget = itemTop
839
+ } else {
840
+ // Closer to bottom, align to bottom
841
+ scrollTarget = Math.max(0, itemBottom - height)
842
+ }
843
+ }
844
+ } else if (align === 'top') {
845
+ scrollTarget = getScrollOffsetForIndex(
548
846
  heightCache,
549
847
  calculatedItemHeight,
550
- clampedIndex
848
+ targetIndex
551
849
  )
552
- viewportElement.scrollTo({
553
- top: scrollTopTarget,
554
- behavior: smoothScroll ? 'smooth' : 'auto'
555
- })
556
- } else if (mode === 'bottomToTop') {
557
- // Invert the index for reversed rendering
558
- const reversedIndex = items.length - 1 - clampedIndex
850
+ } else if (align === 'bottom') {
559
851
  const itemBottom = getScrollOffsetForIndex(
560
852
  heightCache,
561
853
  calculatedItemHeight,
562
- reversedIndex + 1
854
+ targetIndex + 1
563
855
  )
564
- const scrollTopTarget = Math.max(0, itemBottom - height)
565
- viewportElement.scrollTo({
566
- top: scrollTopTarget,
567
- behavior: smoothScroll ? 'smooth' : 'auto'
568
- })
569
- } else {
570
- console.warn('scrollToIndex: unknown mode:', mode)
856
+ scrollTarget = Math.max(0, itemBottom - height)
857
+ } else if (align === 'nearest') {
858
+ const itemTop = getScrollOffsetForIndex(
859
+ heightCache,
860
+ calculatedItemHeight,
861
+ targetIndex
862
+ )
863
+ const itemBottom = getScrollOffsetForIndex(
864
+ heightCache,
865
+ calculatedItemHeight,
866
+ targetIndex + 1
867
+ )
868
+ if (itemBottom <= scrollTop || itemTop >= scrollTop + height) {
869
+ // Not visible, align to nearest edge
870
+ const distanceToTop = Math.abs(scrollTop - itemTop)
871
+ const distanceToBottom = Math.abs(scrollTop + height - itemBottom)
872
+ if (distanceToTop < distanceToBottom) {
873
+ scrollTarget = itemTop
874
+ } else {
875
+ scrollTarget = Math.max(0, itemBottom - height)
876
+ }
877
+ } else {
878
+ // Already visible, do nothing
879
+ return
880
+ }
881
+ }
882
+ }
883
+
884
+ if (scrollTarget !== null) {
885
+ viewportElement.scrollTo({
886
+ top: scrollTarget,
887
+ behavior: smoothScroll ? 'smooth' : 'auto'
888
+ })
889
+ }
890
+ }
891
+
892
+ /**
893
+ * Custom Svelte action to automatically observe item elements for size changes.
894
+ * This action is applied to each item element to detect when its dimensions change.
895
+ *
896
+ * @param element - The HTML element to observe
897
+ * @returns {{ destroy: () => void }} Object with destroy method for cleanup
898
+ */
899
+ function autoObserveItemResize(element: HTMLElement) {
900
+ if (itemResizeObserver) {
901
+ itemResizeObserver.observe(element)
902
+ if (INTERNAL_DEBUG) {
903
+ console.log(
904
+ 'Started observing element:',
905
+ element,
906
+ 'Current height:',
907
+ element.getBoundingClientRect().height
908
+ )
909
+ }
910
+ } else if (INTERNAL_DEBUG) {
911
+ console.log('itemResizeObserver not available for element:', element)
912
+ }
913
+
914
+ return {
915
+ destroy() {
916
+ if (itemResizeObserver) {
917
+ itemResizeObserver.unobserve(element)
918
+ if (INTERNAL_DEBUG) {
919
+ console.log('Stopped observing element:', element)
920
+ }
921
+ }
571
922
  }
572
923
  }
573
924
  }
@@ -599,24 +950,36 @@
599
950
  id="virtual-list-content"
600
951
  {...testId ? { 'data-testid': `${testId}-content` } : {}}
601
952
  class={contentClass ?? 'virtual-list-content'}
602
- style:height="{Math.max(height, items.length * calculatedItemHeight)}px"
953
+ style:height="{(() => {
954
+ // Use precise height when available for better cross-browser compatibility
955
+ const totalActualHeight = items.length * preciseItemHeight()
956
+ return Math.max(height, totalActualHeight)
957
+ })()}px"
603
958
  >
604
959
  <!-- Items container is translated to show correct items -->
605
960
  <div
606
961
  id="virtual-list-items"
607
962
  {...testId ? { 'data-testid': `${testId}-items` } : {}}
608
963
  class={itemsClass ?? 'virtual-list-items'}
609
- style:transform="translateY({calculateTransformY(
610
- mode,
611
- items.length,
612
- visibleItems().end,
613
- visibleItems().start,
614
- calculatedItemHeight
615
- )}px)"
964
+ style:transform="translateY({(() => {
965
+ const transform = calculateTransformY(
966
+ mode,
967
+ items.length,
968
+ visibleItems().end,
969
+ visibleItems().start,
970
+ calculatedItemHeight
971
+ )
972
+
973
+ return transform
974
+ })()}px)"
616
975
  >
617
- {#each mode === 'bottomToTop' ? items
618
- .slice(visibleItems().start, visibleItems().end)
619
- .reverse() : items.slice(visibleItems().start, visibleItems().end) as currentItem, i (currentItem?.id ?? i)}
976
+ {#each (() => {
977
+ const slice = mode === 'bottomToTop' ? items
978
+ .slice(visibleItems().start, visibleItems().end)
979
+ .reverse() : items.slice(visibleItems().start, visibleItems().end)
980
+
981
+ return slice
982
+ })() as currentItem, i (currentItem?.id ?? i)}
620
983
  <!-- Only debug when visible range or average height changes -->
621
984
  {#if debug && i === 0 && shouldShowDebugInfo(prevVisibleRange, visibleItems(), prevHeight, calculatedItemHeight)}
622
985
  {@const debugInfo = createDebugInfo(
@@ -630,7 +993,7 @@
630
993
  : console.info('Virtual List Debug:', debugInfo)}
631
994
  {/if}
632
995
  <!-- Render each visible item -->
633
- <div bind:this={itemElements[i]}>
996
+ <div bind:this={itemElements[i]} use:autoObserveItemResize>
634
997
  {@render renderItem(
635
998
  currentItem,
636
999
  mode === 'bottomToTop'
@@ -678,4 +1041,10 @@
678
1041
  left: 0;
679
1042
  top: 0;
680
1043
  }
1044
+
1045
+ /* Item wrapper divs should size to their content */
1046
+ .virtual-list-items > div {
1047
+ width: 100%;
1048
+ display: block;
1049
+ }
681
1050
  </style>