@humanspeak/svelte-virtual-list 0.2.4 โ†’ 0.2.6

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/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2024 Humanspeak, Inc.
1
+ Copyright (c) 2024-2025 Humanspeak, Inc.
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
package/README.md CHANGED
@@ -6,6 +6,7 @@
6
6
  [![License](https://img.shields.io/npm/l/@humanspeak/svelte-virtual-list.svg)](https://github.com/humanspeak/svelte-virtual-list/blob/main/LICENSE)
7
7
  [![Downloads](https://img.shields.io/npm/dm/@humanspeak/svelte-virtual-list.svg)](https://www.npmjs.com/package/@humanspeak/svelte-virtual-list)
8
8
  [![CodeQL](https://github.com/humanspeak/svelte-virtual-list/actions/workflows/codeql.yml/badge.svg)](https://github.com/humanspeak/svelte-virtual-list/actions/workflows/codeql.yml)
9
+ [![Install size](https://packagephobia.com/badge?p=@humanspeak/svelte-virtual-list)](https://packagephobia.com/result?p=@humanspeak/svelte-virtual-list)
9
10
  [![Code Style: Trunk](https://img.shields.io/badge/code%20style-trunk-blue.svg)](https://trunk.io)
10
11
  [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/)
11
12
  [![Types](https://img.shields.io/npm/types/@humanspeak/svelte-virtual-list.svg)](https://www.npmjs.com/package/@humanspeak/svelte-virtual-list)
@@ -27,6 +28,41 @@ A high-performance virtual list component for Svelte 5 applications that efficie
27
28
  - ๐Ÿง  Memory-optimized for 10k+ items
28
29
  - ๐Ÿงช Comprehensive test coverage (vitest and playwright)
29
30
  - ๐Ÿš€ Progressive initialization for large datasets
31
+ - ๐Ÿ•น๏ธ Programmatic scrolling with `scroll`
32
+
33
+ ## scroll: Programmatic Scrolling
34
+
35
+ You can now programmatically scroll to any item in the list using the `scroll` method. This is useful for chat apps, jump-to-item navigation, and more. You can check the usage in `src/routes/tests/scroll`. Thank you for the feature request!
36
+
37
+ ### Usage Example
38
+
39
+ ```svelte
40
+ <script lang="ts">
41
+ import SvelteVirtualList from '@humanspeak/svelte-virtual-list'
42
+ let listRef
43
+ const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` }))
44
+
45
+ function goToItem5000() {
46
+ // Scroll to item 5000 with smooth scrolling and auto alignment
47
+ listRef.scroll({ index: 5000, smoothScroll: true, align: 'auto' })
48
+ }
49
+ </script>
50
+
51
+ <button on:click={goToItem5000}> Scroll to item 5000 </button>
52
+ <SvelteVirtualList {items} bind:this={listRef}>
53
+ {#snippet renderItem(item)}
54
+ <div>{item.text}</div>
55
+ {/snippet}
56
+ </SvelteVirtualList>
57
+ ```
58
+
59
+ ### API
60
+
61
+ - `scroll(options: { index: number; smoothScroll?: boolean; shouldThrowOnBounds?: boolean; align?: 'auto' | 'top' | 'bottom' })`
62
+ - `index`: The item index to scroll to (0-based)
63
+ - `smoothScroll`: If true, uses smooth scrolling (default: true)
64
+ - `shouldThrowOnBounds`: If true, throws if index is out of bounds (default: true)
65
+ - `align`: Where to align the item in the viewport. `'auto'` (default) scrolls only if the item is out of view, aligning to top or bottom as needed. `'top'` always aligns to the top, `'bottom'` always aligns to the bottom.
30
66
 
31
67
  ## Installation
32
68
 
@@ -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,19 +140,26 @@
122
140
  * - Progressive size adjustment system
123
141
  */
124
142
 
125
- import { onMount } from 'svelte'
126
- import { BROWSER } from 'esm-env'
127
- import type { SvelteVirtualListProps } from './types.js'
143
+ import {
144
+ DEFAULT_SCROLL_OPTIONS,
145
+ type SvelteVirtualListProps,
146
+ type SvelteVirtualListScrollOptions
147
+ } from './types.js'
148
+ import { calculateAverageHeightDebounced } from './utils/heightCalculation.js'
149
+ import { createRafScheduler } from './utils/raf.js'
128
150
  import {
129
151
  calculateScrollPosition,
130
- calculateVisibleRange,
131
152
  calculateTransformY,
132
- updateHeightAndScroll as utilsUpdateHeightAndScroll,
133
- calculateAverageHeight,
134
- processChunked
153
+ calculateVisibleRange,
154
+ getScrollOffsetForIndex,
155
+ processChunked,
156
+ updateHeightAndScroll as utilsUpdateHeightAndScroll
135
157
  } from './utils/virtualList.js'
136
- import { rafSchedule } from './utils/raf.js'
137
- import { shouldShowDebugInfo, createDebugInfo } from './utils/virtualListDebug.js'
158
+ import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
159
+ import { BROWSER } from 'esm-env'
160
+ import { onMount, tick } from 'svelte'
161
+
162
+ const rafSchedule = createRafScheduler()
138
163
 
139
164
  /**
140
165
  * Core configuration props with default values
@@ -193,76 +218,23 @@
193
218
  let prevVisibleRange = $state<{ start: number; end: number } | null>(null)
194
219
  let prevHeight = $state<number>(0)
195
220
 
196
- /**
197
- * Calculates and updates the average height of visible items with debouncing.
198
- *
199
- * This function optimizes performance by:
200
- * - Debouncing calculations to prevent excessive DOM reads
201
- * - Caching item heights to minimize recalculations
202
- * - Only updating when significant changes are detected
203
- *
204
- * Implementation details:
205
- * - Uses a 200ms debounce timeout
206
- * - Tracks calculation state to prevent concurrent updates
207
- * - Caches heights in heightCache for reuse
208
- * - Only updates if height difference > 1px
209
- *
210
- * State interactions:
211
- * - Updates calculatedItemHeight
212
- * - Updates lastMeasuredIndex
213
- * - Modifies heightCache
214
- * - Uses/sets isCalculatingHeight flag
215
- *
216
- * @example
217
- * ```typescript
218
- * // Automatically called when items are rendered
219
- * $effect(() => {
220
- * if (BROWSER && itemElements.length > 0 && !isCalculatingHeight) {
221
- * calculateAverageHeightDebounced()
222
- * }
223
- * })
224
- * ```
225
- *
226
- * @returns {void}
227
- */
228
- const calculateAverageHeightDebounced = () => {
229
- if (!BROWSER || isCalculatingHeight || heightUpdateTimeout) return
230
- isCalculatingHeight = true
231
-
232
- if (heightUpdateTimeout) {
233
- clearTimeout(heightUpdateTimeout)
234
- }
235
-
236
- heightUpdateTimeout = setTimeout(() => {
237
- const visibleRange = visibleItems()
238
- const currentIndex = visibleRange.start
239
-
240
- if (currentIndex !== lastMeasuredIndex) {
241
- const { newHeight, newLastMeasuredIndex, updatedHeightCache } =
242
- calculateAverageHeight(
243
- itemElements,
244
- visibleRange,
245
- heightCache,
246
- lastMeasuredIndex,
247
- calculatedItemHeight
248
- )
249
-
250
- if (Math.abs(newHeight - calculatedItemHeight) > 1) {
251
- calculatedItemHeight = newHeight
252
- lastMeasuredIndex = newLastMeasuredIndex
253
- heightCache = updatedHeightCache
254
- }
255
- }
256
-
257
- isCalculatingHeight = false
258
- heightUpdateTimeout = null
259
- }, 200)
260
- }
261
-
262
221
  // Trigger height calculation when items are rendered
263
222
  $effect(() => {
264
223
  if (BROWSER && itemElements.length > 0 && !isCalculatingHeight) {
265
- calculateAverageHeightDebounced()
224
+ heightUpdateTimeout = calculateAverageHeightDebounced(
225
+ isCalculatingHeight,
226
+ heightUpdateTimeout,
227
+ visibleItems,
228
+ itemElements,
229
+ heightCache,
230
+ lastMeasuredIndex,
231
+ calculatedItemHeight,
232
+ (result) => {
233
+ calculatedItemHeight = result.newHeight
234
+ lastMeasuredIndex = result.newLastMeasuredIndex
235
+ heightCache = result.updatedHeightCache
236
+ }
237
+ )
266
238
  }
267
239
  })
268
240
 
@@ -305,7 +277,7 @@
305
277
  const targetScrollTop = Math.max(0, totalHeight - height)
306
278
 
307
279
  // Add delay to ensure layout is complete
308
- setTimeout(() => {
280
+ tick().then(() => {
309
281
  if (viewportElement) {
310
282
  // Start at the bottom for bottom-to-top mode
311
283
  viewportElement.scrollTop = targetScrollTop
@@ -320,7 +292,7 @@
320
292
  initialized = true
321
293
  })
322
294
  }
323
- }, 50)
295
+ })
324
296
  }
325
297
  })
326
298
 
@@ -406,12 +378,12 @@
406
378
  */
407
379
  const updateHeightAndScroll = (immediate = false) => {
408
380
  if (!initialized && mode === 'bottomToTop') {
409
- setTimeout(() => {
381
+ tick().then(() => {
410
382
  if (containerElement) {
411
383
  const initialHeight = containerElement.getBoundingClientRect().height
412
384
  height = initialHeight
413
385
 
414
- setTimeout(() => {
386
+ tick().then(() => {
415
387
  if (containerElement && viewportElement) {
416
388
  const finalHeight = containerElement.getBoundingClientRect().height
417
389
  height = finalHeight
@@ -438,9 +410,9 @@
438
410
  }
439
411
  })
440
412
  }
441
- }, 100)
413
+ })
442
414
  }
443
- }, 100)
415
+ })
444
416
  return
445
417
  }
446
418
 
@@ -541,6 +513,175 @@
541
513
  prevHeight = calculatedItemHeight
542
514
  }
543
515
  })
516
+
517
+ /**
518
+ * Scrolls the virtual list to the item at the given index.
519
+ *
520
+ * @deprecated This function is deprecated and will be removed in a future version.
521
+ * Use the new scroll method from the component instance instead.
522
+ *
523
+ * @function scrollToIndex
524
+ * @param index The index of the item to scroll to.
525
+ * @param smoothScroll (default: true) Whether to use smooth scrolling.
526
+ * @param shouldThrowOnBounds (default: true) Whether to throw an error if the index is out of bounds.
527
+ *
528
+ * @example
529
+ * // Svelte usage:
530
+ * // In your <script> block:
531
+ * import SvelteVirtualList from '@humanspeak/svelte-virtual-list';
532
+ * let virtualList;
533
+ * const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` }));
534
+ *
535
+ * // In your markup:
536
+ * <button onclick={() => virtualList.scrollToIndex(5000)}>
537
+ * Scroll to 5000
538
+ * </button>
539
+ * <SvelteVirtualList {items} bind:this={virtualList}>
540
+ * {#snippet renderItem(item)}
541
+ * <div>{item.text}</div>
542
+ * {/snippet}
543
+ * </SvelteVirtualList>
544
+ *
545
+ * @returns {void}
546
+ * @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
547
+ */
548
+ export const scrollToIndex = (
549
+ index: number,
550
+ smoothScroll = true,
551
+ shouldThrowOnBounds = true
552
+ ): void => {
553
+ // Deprecation warning
554
+ console.warn(
555
+ 'SvelteVirtualList: scrollToIndex is deprecated and will be removed in a future version. ' +
556
+ 'Use the new scroll method from the component instance instead.'
557
+ )
558
+
559
+ // Call the new scroll function with the provided parameters
560
+ scroll({ index, smoothScroll, shouldThrowOnBounds })
561
+ }
562
+
563
+ /**
564
+ * Scrolls the virtual list to the item at the given index using a type-based options approach.
565
+ *
566
+ * @function scroll
567
+ * @param options Configuration options for scrolling behavior.
568
+ *
569
+ * @example
570
+ * // Svelte usage:
571
+ * // In your <script> block:
572
+ * import SvelteVirtualList from './index.js';
573
+ * let virtualList;
574
+ * const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` }));
575
+ *
576
+ * <button onclick={() => virtualList.scroll({ index: 5000 })}>
577
+ * Scroll to 5000
578
+ * </button>
579
+ * <SvelteVirtualList {items} bind:this={virtualList}>
580
+ * {#snippet renderItem(item)}
581
+ * <div>{item.text}</div>
582
+ * {/snippet}
583
+ * </SvelteVirtualList>
584
+ *
585
+ * @returns {void}
586
+ * @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
587
+ */
588
+ export const scroll = (options: SvelteVirtualListScrollOptions): void => {
589
+ const { index, smoothScroll, shouldThrowOnBounds, align } = {
590
+ ...DEFAULT_SCROLL_OPTIONS,
591
+ ...options
592
+ }
593
+
594
+ if (!items.length) return
595
+ if (!viewportElement) {
596
+ tick().then(() => {
597
+ if (!viewportElement) return
598
+ scroll({ index, smoothScroll, shouldThrowOnBounds, align })
599
+ })
600
+ return
601
+ }
602
+
603
+ // Bounds checking
604
+ let targetIndex = index
605
+ if (targetIndex < 0 || targetIndex >= items.length) {
606
+ if (shouldThrowOnBounds) {
607
+ throw new Error(
608
+ `scroll: index ${targetIndex} is out of bounds (0-${items.length - 1})`
609
+ )
610
+ } else {
611
+ targetIndex = Math.max(0, Math.min(targetIndex, items.length - 1))
612
+ }
613
+ }
614
+
615
+ const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems()
616
+ let scrollTarget: number | null = null
617
+
618
+ if (mode === 'bottomToTop') {
619
+ const totalHeight = items.length * calculatedItemHeight
620
+ const itemOffset = targetIndex * calculatedItemHeight
621
+ const itemHeight = calculatedItemHeight
622
+ if (align === 'auto') {
623
+ if (targetIndex < firstVisibleIndex) {
624
+ // Align to top
625
+ scrollTarget = Math.max(0, totalHeight - (itemOffset + itemHeight))
626
+ } else if (targetIndex > lastVisibleIndex - 1) {
627
+ // Align to bottom
628
+ scrollTarget = Math.max(0, totalHeight - itemOffset - height)
629
+ } else {
630
+ // Already in view, do nothing
631
+ return
632
+ }
633
+ } else if (align === 'top') {
634
+ // Align to top
635
+ scrollTarget = Math.max(0, totalHeight - (itemOffset + itemHeight))
636
+ } else if (align === 'bottom') {
637
+ // Align to bottom
638
+ scrollTarget = Math.max(0, totalHeight - itemOffset - height)
639
+ }
640
+ } else {
641
+ // topToBottom (default)
642
+ if (align === 'auto') {
643
+ if (targetIndex < firstVisibleIndex) {
644
+ // Scroll so item is at the top
645
+ scrollTarget = getScrollOffsetForIndex(
646
+ heightCache,
647
+ calculatedItemHeight,
648
+ targetIndex
649
+ )
650
+ } else if (targetIndex > lastVisibleIndex - 1) {
651
+ // Scroll so item is at the bottom
652
+ const itemBottom = getScrollOffsetForIndex(
653
+ heightCache,
654
+ calculatedItemHeight,
655
+ targetIndex + 1
656
+ )
657
+ scrollTarget = Math.max(0, itemBottom - height)
658
+ } else {
659
+ // Already in view, do nothing
660
+ return
661
+ }
662
+ } else if (align === 'top') {
663
+ scrollTarget = getScrollOffsetForIndex(
664
+ heightCache,
665
+ calculatedItemHeight,
666
+ targetIndex
667
+ )
668
+ } else if (align === 'bottom') {
669
+ const itemBottom = getScrollOffsetForIndex(
670
+ heightCache,
671
+ calculatedItemHeight,
672
+ targetIndex + 1
673
+ )
674
+ scrollTarget = Math.max(0, itemBottom - height)
675
+ }
676
+ }
677
+
678
+ if (scrollTarget !== null) {
679
+ viewportElement.scrollTo({
680
+ top: scrollTarget,
681
+ behavior: smoothScroll ? 'smooth' : 'auto'
682
+ })
683
+ }
684
+ }
544
685
  </script>
545
686
 
546
687
  <!--
@@ -597,7 +738,7 @@
597
738
  )}
598
739
  {debugFunction
599
740
  ? debugFunction(debugInfo)
600
- : console.log('Virtual List Debug:', debugInfo)}
741
+ : console.info('Virtual List Debug:', debugInfo)}
601
742
  {/if}
602
743
  <!-- Render each visible item -->
603
744
  <div bind:this={itemElements[i]}>