@humanspeak/svelte-virtual-list 0.2.3 → 0.2.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/{LICENSE.md → LICENSE} +1 -1
- package/README.md +37 -2
- package/dist/SvelteVirtualList.svelte +111 -81
- package/dist/SvelteVirtualList.svelte.d.ts +108 -1
- package/dist/types.d.ts +40 -16
- package/dist/utils/heightCalculation.d.ts +77 -0
- package/dist/utils/heightCalculation.js +90 -0
- package/dist/utils/raf.d.ts +29 -5
- package/dist/utils/raf.js +45 -19
- package/dist/utils/types.d.ts +6 -0
- package/dist/utils/virtualList.d.ts +37 -4
- package/dist/utils/virtualList.js +81 -9
- package/package.json +37 -35
package/{LICENSE.md → LICENSE}
RENAMED
package/README.md
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@humanspeak/svelte-virtual-list)
|
|
4
4
|
[](https://github.com/humanspeak/svelte-virtual-list/actions/workflows/npm-publish.yml)
|
|
5
5
|
[](https://coveralls.io/github/humanspeak/svelte-virtual-list?branch=main)
|
|
6
|
-
[](https://github.com/humanspeak/svelte-virtual-list/blob/main/LICENSE
|
|
6
|
+
[](https://github.com/humanspeak/svelte-virtual-list/blob/main/LICENSE)
|
|
7
7
|
[](https://www.npmjs.com/package/@humanspeak/svelte-virtual-list)
|
|
8
8
|
[](https://github.com/humanspeak/svelte-virtual-list/actions/workflows/codeql.yml)
|
|
9
|
+
[](https://packagephobia.com/result?p=@humanspeak/svelte-virtual-list)
|
|
9
10
|
[](https://trunk.io)
|
|
10
11
|
[](http://www.typescriptlang.org/)
|
|
11
12
|
[](https://www.npmjs.com/package/@humanspeak/svelte-virtual-list)
|
|
@@ -27,6 +28,40 @@ 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 `scrollToIndex`
|
|
32
|
+
|
|
33
|
+
## scrollToIndex: Programmatic Scrolling
|
|
34
|
+
|
|
35
|
+
You can now programmatically scroll to any item in the list using the `scrollToIndex` method. This is useful for chat apps, jump-to-item navigation, and more. 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
|
|
47
|
+
listRef.scrollToIndex(5000, true)
|
|
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
|
+
- `scrollToIndex(index: number, smoothScroll = true, shouldThrowOnBounds = true)`
|
|
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)
|
|
30
65
|
|
|
31
66
|
## Installation
|
|
32
67
|
|
|
@@ -112,7 +147,7 @@ npm install @humanspeak/svelte-virtual-list
|
|
|
112
147
|
|
|
113
148
|
## License
|
|
114
149
|
|
|
115
|
-
MIT © [Humanspeak, Inc.](LICENSE
|
|
150
|
+
MIT © [Humanspeak, Inc.](LICENSE)
|
|
116
151
|
|
|
117
152
|
## Credits
|
|
118
153
|
|
|
@@ -122,19 +122,22 @@
|
|
|
122
122
|
* - Progressive size adjustment system
|
|
123
123
|
*/
|
|
124
124
|
|
|
125
|
-
import { onMount } from 'svelte'
|
|
126
|
-
import { BROWSER } from 'esm-env'
|
|
127
125
|
import type { SvelteVirtualListProps } from './types.js'
|
|
126
|
+
import { calculateAverageHeightDebounced } from './utils/heightCalculation.js'
|
|
127
|
+
import { createRafScheduler } from './utils/raf.js'
|
|
128
128
|
import {
|
|
129
129
|
calculateScrollPosition,
|
|
130
|
-
calculateVisibleRange,
|
|
131
130
|
calculateTransformY,
|
|
131
|
+
calculateVisibleRange,
|
|
132
|
+
processChunked,
|
|
132
133
|
updateHeightAndScroll as utilsUpdateHeightAndScroll,
|
|
133
|
-
|
|
134
|
-
processChunked
|
|
134
|
+
getScrollOffsetForIndex
|
|
135
135
|
} from './utils/virtualList.js'
|
|
136
|
-
import {
|
|
137
|
-
import {
|
|
136
|
+
import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
|
|
137
|
+
import { BROWSER } from 'esm-env'
|
|
138
|
+
import { onMount, tick } from 'svelte'
|
|
139
|
+
|
|
140
|
+
const rafSchedule = createRafScheduler()
|
|
138
141
|
|
|
139
142
|
/**
|
|
140
143
|
* Core configuration props with default values
|
|
@@ -193,76 +196,23 @@
|
|
|
193
196
|
let prevVisibleRange = $state<{ start: number; end: number } | null>(null)
|
|
194
197
|
let prevHeight = $state<number>(0)
|
|
195
198
|
|
|
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
199
|
// Trigger height calculation when items are rendered
|
|
263
200
|
$effect(() => {
|
|
264
201
|
if (BROWSER && itemElements.length > 0 && !isCalculatingHeight) {
|
|
265
|
-
calculateAverageHeightDebounced(
|
|
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
|
+
)
|
|
266
216
|
}
|
|
267
217
|
})
|
|
268
218
|
|
|
@@ -305,7 +255,7 @@
|
|
|
305
255
|
const targetScrollTop = Math.max(0, totalHeight - height)
|
|
306
256
|
|
|
307
257
|
// Add delay to ensure layout is complete
|
|
308
|
-
|
|
258
|
+
tick().then(() => {
|
|
309
259
|
if (viewportElement) {
|
|
310
260
|
// Start at the bottom for bottom-to-top mode
|
|
311
261
|
viewportElement.scrollTop = targetScrollTop
|
|
@@ -320,7 +270,7 @@
|
|
|
320
270
|
initialized = true
|
|
321
271
|
})
|
|
322
272
|
}
|
|
323
|
-
}
|
|
273
|
+
})
|
|
324
274
|
}
|
|
325
275
|
})
|
|
326
276
|
|
|
@@ -406,12 +356,12 @@
|
|
|
406
356
|
*/
|
|
407
357
|
const updateHeightAndScroll = (immediate = false) => {
|
|
408
358
|
if (!initialized && mode === 'bottomToTop') {
|
|
409
|
-
|
|
359
|
+
tick().then(() => {
|
|
410
360
|
if (containerElement) {
|
|
411
361
|
const initialHeight = containerElement.getBoundingClientRect().height
|
|
412
362
|
height = initialHeight
|
|
413
363
|
|
|
414
|
-
|
|
364
|
+
tick().then(() => {
|
|
415
365
|
if (containerElement && viewportElement) {
|
|
416
366
|
const finalHeight = containerElement.getBoundingClientRect().height
|
|
417
367
|
height = finalHeight
|
|
@@ -438,9 +388,9 @@
|
|
|
438
388
|
}
|
|
439
389
|
})
|
|
440
390
|
}
|
|
441
|
-
}
|
|
391
|
+
})
|
|
442
392
|
}
|
|
443
|
-
}
|
|
393
|
+
})
|
|
444
394
|
return
|
|
445
395
|
}
|
|
446
396
|
|
|
@@ -541,6 +491,86 @@
|
|
|
541
491
|
prevHeight = calculatedItemHeight
|
|
542
492
|
}
|
|
543
493
|
})
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Scrolls the virtual list to the item at the given index.
|
|
497
|
+
*
|
|
498
|
+
* @function scrollToIndex
|
|
499
|
+
* @param index The index of the item to scroll to.
|
|
500
|
+
* @param smoothScroll (default: true) Whether to use smooth scrolling.
|
|
501
|
+
* @param shouldThrowOnBounds (default: true) Whether to throw an error if the index is out of bounds.
|
|
502
|
+
*
|
|
503
|
+
* @example
|
|
504
|
+
* // Svelte usage:
|
|
505
|
+
* // In your <script> block:
|
|
506
|
+
* import SvelteVirtualList from '@humanspeak/svelte-virtual-list';
|
|
507
|
+
* let virtualList;
|
|
508
|
+
* const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` }));
|
|
509
|
+
*
|
|
510
|
+
* // In your markup:
|
|
511
|
+
* <button onclick={() => virtualList.scrollToIndex(5000)}>
|
|
512
|
+
* Scroll to 5000
|
|
513
|
+
* </button>
|
|
514
|
+
* <SvelteVirtualList {items} bind:this={virtualList}>
|
|
515
|
+
* {#snippet renderItem(item)}
|
|
516
|
+
* <div>{item.text}</div>
|
|
517
|
+
* {/snippet}
|
|
518
|
+
* </SvelteVirtualList>
|
|
519
|
+
*
|
|
520
|
+
* @returns {void}
|
|
521
|
+
* @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
|
|
522
|
+
*/
|
|
523
|
+
export const scrollToIndex = (
|
|
524
|
+
index: number,
|
|
525
|
+
smoothScroll = true,
|
|
526
|
+
shouldThrowOnBounds = true
|
|
527
|
+
): void => {
|
|
528
|
+
if (!items.length) return
|
|
529
|
+
if (!viewportElement) {
|
|
530
|
+
tick().then(() => {
|
|
531
|
+
if (!viewportElement) return
|
|
532
|
+
doScroll()
|
|
533
|
+
})
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
doScroll()
|
|
537
|
+
|
|
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) {
|
|
542
|
+
throw new Error(
|
|
543
|
+
`scrollToIndex: index ${target} is out of bounds (0-${items.length - 1})`
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
if (mode === 'topToBottom') {
|
|
547
|
+
const scrollTopTarget = getScrollOffsetForIndex(
|
|
548
|
+
heightCache,
|
|
549
|
+
calculatedItemHeight,
|
|
550
|
+
clampedIndex
|
|
551
|
+
)
|
|
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
|
|
559
|
+
const itemBottom = getScrollOffsetForIndex(
|
|
560
|
+
heightCache,
|
|
561
|
+
calculatedItemHeight,
|
|
562
|
+
reversedIndex + 1
|
|
563
|
+
)
|
|
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)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
544
574
|
</script>
|
|
545
575
|
|
|
546
576
|
<!--
|
|
@@ -597,7 +627,7 @@
|
|
|
597
627
|
)}
|
|
598
628
|
{debugFunction
|
|
599
629
|
? debugFunction(debugInfo)
|
|
600
|
-
: console.
|
|
630
|
+
: console.info('Virtual List Debug:', debugInfo)}
|
|
601
631
|
{/if}
|
|
602
632
|
<!-- Render each visible item -->
|
|
603
633
|
<div bind:this={itemElements[i]}>
|
|
@@ -1,3 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SvelteVirtualList Implementation Journey
|
|
3
|
+
*
|
|
4
|
+
* Evolution & Architecture:
|
|
5
|
+
* 1. Initial Implementation ✓
|
|
6
|
+
* - Basic virtual scrolling with fixed height items
|
|
7
|
+
* - Single direction scrolling (top-to-bottom)
|
|
8
|
+
* - Simple viewport calculations
|
|
9
|
+
*
|
|
10
|
+
* 2. Dynamic Height Enhancement ✓
|
|
11
|
+
* - Added dynamic height calculation system
|
|
12
|
+
* - Implemented debounced measurements
|
|
13
|
+
* - Created height averaging mechanism for performance
|
|
14
|
+
*
|
|
15
|
+
* 3. Bidirectional Scrolling ✓
|
|
16
|
+
* - Added bottomToTop mode
|
|
17
|
+
* - Solved complex initialization issues with flexbox
|
|
18
|
+
* - Implemented careful scroll position management
|
|
19
|
+
*
|
|
20
|
+
* 4. Performance Optimizations ✓
|
|
21
|
+
* - Added element recycling through keyed each blocks
|
|
22
|
+
* - Implemented RAF for smooth animations
|
|
23
|
+
* - Optimized DOM updates with transform translations
|
|
24
|
+
*
|
|
25
|
+
* 5. Stability Improvements ✓
|
|
26
|
+
* - Added ResizeObserver for responsive updates
|
|
27
|
+
* - Implemented proper cleanup on component destruction
|
|
28
|
+
* - Added debug mode for development assistance
|
|
29
|
+
*
|
|
30
|
+
* 6. Large Dataset Optimizations ✓
|
|
31
|
+
* - Implemented chunked processing for 10k+ items
|
|
32
|
+
* - Added progressive initialization system
|
|
33
|
+
* - Deferred height calculations for better initial load
|
|
34
|
+
* - Optimized memory usage for large lists
|
|
35
|
+
* - Added progress tracking for initialization
|
|
36
|
+
*
|
|
37
|
+
* 7. Size Management Improvements ✓
|
|
38
|
+
* - Implemented height caching system for measured items
|
|
39
|
+
* - Added smart height estimation for unmeasured items
|
|
40
|
+
* - Optimized resize handling with debouncing
|
|
41
|
+
* - Added height recalculation on content changes
|
|
42
|
+
* - Implemented progressive height adjustments
|
|
43
|
+
*
|
|
44
|
+
* 8. Code Quality & Maintainability ✓
|
|
45
|
+
* - Extracted debug utilities for better testing
|
|
46
|
+
* - Improved type safety throughout
|
|
47
|
+
* - Added comprehensive documentation
|
|
48
|
+
* - Optimized debug output to reduce noise
|
|
49
|
+
*
|
|
50
|
+
* 9. Future Improvements (Planned)
|
|
51
|
+
* - Add horizontal scrolling support
|
|
52
|
+
* - Implement variable-sized item caching
|
|
53
|
+
* - Add keyboard navigation support
|
|
54
|
+
* - Support for dynamic item updates
|
|
55
|
+
* - Add accessibility enhancements
|
|
56
|
+
*
|
|
57
|
+
* Technical Challenges Solved:
|
|
58
|
+
* - Bottom-to-top scrolling in flexbox layouts
|
|
59
|
+
* - Dynamic height calculations without layout thrashing
|
|
60
|
+
* - Smooth scrolling on various devices
|
|
61
|
+
* - Memory management for large lists
|
|
62
|
+
* - Browser compatibility issues
|
|
63
|
+
* - Performance optimization for 10k+ items
|
|
64
|
+
* - Progressive initialization for large datasets
|
|
65
|
+
* - Debug output optimization
|
|
66
|
+
* - Accurate size calculations with caching
|
|
67
|
+
* - Responsive size adjustments
|
|
68
|
+
*
|
|
69
|
+
* Current Architecture:
|
|
70
|
+
* - Four-layer DOM structure for optimal performance
|
|
71
|
+
* - State management using Svelte 5's $state
|
|
72
|
+
* - Reactive height and scroll calculations
|
|
73
|
+
* - Configurable buffer zones for smooth scrolling
|
|
74
|
+
* - Chunked processing system for large datasets
|
|
75
|
+
* - Separated debug utilities for better testing
|
|
76
|
+
* - Height caching and estimation system
|
|
77
|
+
* - Progressive size adjustment system
|
|
78
|
+
*/
|
|
1
79
|
import type { SvelteVirtualListProps } from './types.js';
|
|
2
80
|
/**
|
|
3
81
|
* A high-performance virtualized list component that efficiently renders large datasets
|
|
@@ -41,6 +119,35 @@ import type { SvelteVirtualListProps } from './types.js';
|
|
|
41
119
|
* - Chunked processing for smooth performance
|
|
42
120
|
* - Progress tracking during initialization
|
|
43
121
|
*/
|
|
44
|
-
declare const SvelteVirtualList: import("svelte").Component<SvelteVirtualListProps, {
|
|
122
|
+
declare const SvelteVirtualList: import("svelte").Component<SvelteVirtualListProps, {
|
|
123
|
+
/**
|
|
124
|
+
* Scrolls the virtual list to the item at the given index.
|
|
125
|
+
*
|
|
126
|
+
* @function scrollToIndex
|
|
127
|
+
* @param index The index of the item to scroll to.
|
|
128
|
+
* @param smoothScroll (default: true) Whether to use smooth scrolling.
|
|
129
|
+
* @param shouldThrowOnBounds (default: true) Whether to throw an error if the index is out of bounds.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* // Svelte usage:
|
|
133
|
+
* // In your <script> block:
|
|
134
|
+
* import SvelteVirtualList from '@humanspeak/svelte-virtual-list';
|
|
135
|
+
* let virtualList;
|
|
136
|
+
* const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` }));
|
|
137
|
+
*
|
|
138
|
+
* // In your markup:
|
|
139
|
+
* <button onclick={() => virtualList.scrollToIndex(5000)}>
|
|
140
|
+
* Scroll to 5000
|
|
141
|
+
* </button>
|
|
142
|
+
* <SvelteVirtualList {items} bind:this={virtualList}>
|
|
143
|
+
* {#snippet renderItem(item)}
|
|
144
|
+
* <div>{item.text}</div>
|
|
145
|
+
* {/snippet}
|
|
146
|
+
* </SvelteVirtualList>
|
|
147
|
+
*
|
|
148
|
+
* @returns {void}
|
|
149
|
+
* @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
|
|
150
|
+
*/ scrollToIndex: (index: number, smoothScroll?: boolean, shouldThrowOnBounds?: boolean) => void;
|
|
151
|
+
}, "">;
|
|
45
152
|
type SvelteVirtualList = ReturnType<typeof SvelteVirtualList>;
|
|
46
153
|
export default SvelteVirtualList;
|
package/dist/types.d.ts
CHANGED
|
@@ -9,35 +9,59 @@ export type SvelteVirtualListMode = 'topToBottom' | 'bottomToTop';
|
|
|
9
9
|
* Configuration properties for the SvelteVirtualList component.
|
|
10
10
|
*
|
|
11
11
|
* @typedef {Object} SvelteVirtualListProps
|
|
12
|
-
* @property {number} [bufferSize] - Number of items to render outside the visible viewport
|
|
13
|
-
* for smooth scrolling.
|
|
14
|
-
* @property {string} [containerClass] - CSS class to apply to the outer container element.
|
|
15
|
-
* @property {string} [contentClass] - CSS class to apply to the content wrapper element.
|
|
16
|
-
* @property {number} [defaultEstimatedItemHeight] - Initial height estimate for each item in pixels.
|
|
17
|
-
* Used for optimization before actual measurements are available.
|
|
18
|
-
* @property {boolean} [debug] - When true, enables debug mode with additional logging and information.
|
|
19
|
-
* @property {Function} [debugFunction] - Custom callback to handle debug information.
|
|
20
|
-
* Receives a {@link SvelteVirtualListDebugInfo} object.
|
|
21
|
-
* @property {Array<any>} items - The complete array of items to be virtualized.
|
|
22
|
-
* @property {string} [itemsClass] - CSS class to apply to individual item containers.
|
|
23
|
-
* @property {SvelteVirtualListMode} [mode='topToBottom'] - Determines the scroll and render direction.
|
|
24
|
-
* @property {Snippet<[item: any, index: number]>} renderItem - Svelte snippet function that defines
|
|
25
|
-
* how each item should be rendered. Receives the item and its index as arguments.
|
|
26
|
-
* @property {string} [testId] - Base test ID for component elements to facilitate testing.
|
|
27
|
-
* @property {string} [viewportClass] - CSS class to apply to the scrollable viewport element.
|
|
28
12
|
*/
|
|
29
13
|
export type SvelteVirtualListProps = {
|
|
14
|
+
/**
|
|
15
|
+
* Number of items to render outside the visible viewport for smooth scrolling.
|
|
16
|
+
* @default 20
|
|
17
|
+
*/
|
|
30
18
|
bufferSize?: number;
|
|
19
|
+
/**
|
|
20
|
+
* CSS class to apply to the outer container element.
|
|
21
|
+
*/
|
|
31
22
|
containerClass?: string;
|
|
23
|
+
/**
|
|
24
|
+
* CSS class to apply to the content wrapper element.
|
|
25
|
+
*/
|
|
32
26
|
contentClass?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Initial height estimate for each item in pixels. Used for optimization before actual measurements are available.
|
|
29
|
+
* @default 40
|
|
30
|
+
*/
|
|
33
31
|
defaultEstimatedItemHeight?: number;
|
|
32
|
+
/**
|
|
33
|
+
* When true, enables debug mode with additional logging and information.
|
|
34
|
+
* @default false
|
|
35
|
+
*/
|
|
34
36
|
debug?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Custom callback to handle debug information. Receives a SvelteVirtualListDebugInfo object.
|
|
39
|
+
*/
|
|
35
40
|
debugFunction?: (_info: SvelteVirtualListDebugInfo) => void;
|
|
41
|
+
/**
|
|
42
|
+
* The complete array of items to be virtualized.
|
|
43
|
+
*/
|
|
36
44
|
items: any[];
|
|
45
|
+
/**
|
|
46
|
+
* CSS class to apply to individual item containers.
|
|
47
|
+
*/
|
|
37
48
|
itemsClass?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Determines the scroll and render direction.
|
|
51
|
+
* @default 'topToBottom'
|
|
52
|
+
*/
|
|
38
53
|
mode?: SvelteVirtualListMode;
|
|
54
|
+
/**
|
|
55
|
+
* Svelte snippet function that defines how each item should be rendered. Receives the item and its index as arguments.
|
|
56
|
+
*/
|
|
39
57
|
renderItem: Snippet<[item: any, index: number]>;
|
|
58
|
+
/**
|
|
59
|
+
* Base test ID for component elements to facilitate testing.
|
|
60
|
+
*/
|
|
40
61
|
testId?: string;
|
|
62
|
+
/**
|
|
63
|
+
* CSS class to apply to the scrollable viewport element.
|
|
64
|
+
*/
|
|
41
65
|
viewportClass?: string;
|
|
42
66
|
};
|
|
43
67
|
/**
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { HeightCache } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Calculates and updates the average height of visible items with debouncing.
|
|
4
|
+
*
|
|
5
|
+
* This function optimizes performance by:
|
|
6
|
+
* - Debouncing calculations to prevent excessive DOM reads (200ms default)
|
|
7
|
+
* - Caching item heights to minimize recalculations
|
|
8
|
+
* - Only updating when significant changes are detected (>1px difference)
|
|
9
|
+
* - Early returns to prevent unnecessary processing
|
|
10
|
+
*
|
|
11
|
+
* Implementation details:
|
|
12
|
+
* - Uses a debounce timeout to batch height calculations
|
|
13
|
+
* - Tracks calculation state to prevent concurrent updates
|
|
14
|
+
* - Caches heights in heightCache for reuse
|
|
15
|
+
* - Validates browser environment and calculation state
|
|
16
|
+
* - Checks for meaningful height changes before updates
|
|
17
|
+
*
|
|
18
|
+
* State interactions:
|
|
19
|
+
* - Updates calculatedItemHeight when significant changes occur
|
|
20
|
+
* - Updates lastMeasuredIndex to track progress
|
|
21
|
+
* - Modifies heightCache to store measured heights
|
|
22
|
+
* - Uses isCalculatingHeight flag for concurrency control
|
|
23
|
+
*
|
|
24
|
+
* Guard clauses:
|
|
25
|
+
* - Returns null if not in browser environment
|
|
26
|
+
* - Returns null if calculation is already in progress
|
|
27
|
+
* - Returns null if update timeout is pending
|
|
28
|
+
* - Returns null if current index matches last measured
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* // Automatically called when items are rendered
|
|
33
|
+
* $effect(() => {
|
|
34
|
+
* if (BROWSER && itemElements.length > 0 && !isCalculatingHeight) {
|
|
35
|
+
* calculateAverageHeightDebounced(
|
|
36
|
+
* false,
|
|
37
|
+
* null,
|
|
38
|
+
* () => getVisibleRange(),
|
|
39
|
+
* itemElements,
|
|
40
|
+
* heightCache,
|
|
41
|
+
* lastMeasuredIndex,
|
|
42
|
+
* currentHeight,
|
|
43
|
+
* handleUpdate
|
|
44
|
+
* )
|
|
45
|
+
* }
|
|
46
|
+
* })
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* Change History:
|
|
50
|
+
*
|
|
51
|
+
* 2025-01-22
|
|
52
|
+
* - Added comprehensive test coverage for all guard clauses
|
|
53
|
+
* - Improved browser environment detection
|
|
54
|
+
* - Enhanced debounce timing precision
|
|
55
|
+
* - Added proper cleanup for timeouts
|
|
56
|
+
* - Documented all edge cases and failure modes
|
|
57
|
+
*
|
|
58
|
+
*
|
|
59
|
+
* @param isCalculatingHeight - Flag to prevent concurrent calculations
|
|
60
|
+
* @param heightUpdateTimeout - Reference to existing update timeout
|
|
61
|
+
* @param visibleItemsGetter - Function to get current visible range
|
|
62
|
+
* @param itemElements - Array of DOM elements to measure
|
|
63
|
+
* @param heightCache - Cache of previously measured heights
|
|
64
|
+
* @param lastMeasuredIndex - Index of last measured element
|
|
65
|
+
* @param calculatedItemHeight - Current average height
|
|
66
|
+
* @param onUpdate - Callback for height updates
|
|
67
|
+
* @param debounceTime - Time to wait between calculations (default: 200ms)
|
|
68
|
+
* @returns Timeout object or null if calculation was skipped
|
|
69
|
+
*/
|
|
70
|
+
export declare const calculateAverageHeightDebounced: (isCalculatingHeight: boolean, heightUpdateTimeout: ReturnType<typeof setTimeout> | null, visibleItemsGetter: () => {
|
|
71
|
+
start: number;
|
|
72
|
+
end: number;
|
|
73
|
+
}, itemElements: HTMLElement[], heightCache: HeightCache, lastMeasuredIndex: number, calculatedItemHeight: number, onUpdate: (result: {
|
|
74
|
+
newHeight: number;
|
|
75
|
+
newLastMeasuredIndex: number;
|
|
76
|
+
updatedHeightCache: HeightCache;
|
|
77
|
+
}) => void, debounceTime?: number) => NodeJS.Timeout | null;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { BROWSER } from 'esm-env';
|
|
2
|
+
import { calculateAverageHeight } from './virtualList.js';
|
|
3
|
+
/**
|
|
4
|
+
* Calculates and updates the average height of visible items with debouncing.
|
|
5
|
+
*
|
|
6
|
+
* This function optimizes performance by:
|
|
7
|
+
* - Debouncing calculations to prevent excessive DOM reads (200ms default)
|
|
8
|
+
* - Caching item heights to minimize recalculations
|
|
9
|
+
* - Only updating when significant changes are detected (>1px difference)
|
|
10
|
+
* - Early returns to prevent unnecessary processing
|
|
11
|
+
*
|
|
12
|
+
* Implementation details:
|
|
13
|
+
* - Uses a debounce timeout to batch height calculations
|
|
14
|
+
* - Tracks calculation state to prevent concurrent updates
|
|
15
|
+
* - Caches heights in heightCache for reuse
|
|
16
|
+
* - Validates browser environment and calculation state
|
|
17
|
+
* - Checks for meaningful height changes before updates
|
|
18
|
+
*
|
|
19
|
+
* State interactions:
|
|
20
|
+
* - Updates calculatedItemHeight when significant changes occur
|
|
21
|
+
* - Updates lastMeasuredIndex to track progress
|
|
22
|
+
* - Modifies heightCache to store measured heights
|
|
23
|
+
* - Uses isCalculatingHeight flag for concurrency control
|
|
24
|
+
*
|
|
25
|
+
* Guard clauses:
|
|
26
|
+
* - Returns null if not in browser environment
|
|
27
|
+
* - Returns null if calculation is already in progress
|
|
28
|
+
* - Returns null if update timeout is pending
|
|
29
|
+
* - Returns null if current index matches last measured
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* // Automatically called when items are rendered
|
|
34
|
+
* $effect(() => {
|
|
35
|
+
* if (BROWSER && itemElements.length > 0 && !isCalculatingHeight) {
|
|
36
|
+
* calculateAverageHeightDebounced(
|
|
37
|
+
* false,
|
|
38
|
+
* null,
|
|
39
|
+
* () => getVisibleRange(),
|
|
40
|
+
* itemElements,
|
|
41
|
+
* heightCache,
|
|
42
|
+
* lastMeasuredIndex,
|
|
43
|
+
* currentHeight,
|
|
44
|
+
* handleUpdate
|
|
45
|
+
* )
|
|
46
|
+
* }
|
|
47
|
+
* })
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* Change History:
|
|
51
|
+
*
|
|
52
|
+
* 2025-01-22
|
|
53
|
+
* - Added comprehensive test coverage for all guard clauses
|
|
54
|
+
* - Improved browser environment detection
|
|
55
|
+
* - Enhanced debounce timing precision
|
|
56
|
+
* - Added proper cleanup for timeouts
|
|
57
|
+
* - Documented all edge cases and failure modes
|
|
58
|
+
*
|
|
59
|
+
*
|
|
60
|
+
* @param isCalculatingHeight - Flag to prevent concurrent calculations
|
|
61
|
+
* @param heightUpdateTimeout - Reference to existing update timeout
|
|
62
|
+
* @param visibleItemsGetter - Function to get current visible range
|
|
63
|
+
* @param itemElements - Array of DOM elements to measure
|
|
64
|
+
* @param heightCache - Cache of previously measured heights
|
|
65
|
+
* @param lastMeasuredIndex - Index of last measured element
|
|
66
|
+
* @param calculatedItemHeight - Current average height
|
|
67
|
+
* @param onUpdate - Callback for height updates
|
|
68
|
+
* @param debounceTime - Time to wait between calculations (default: 200ms)
|
|
69
|
+
* @returns Timeout object or null if calculation was skipped
|
|
70
|
+
*/
|
|
71
|
+
export const calculateAverageHeightDebounced = (isCalculatingHeight, heightUpdateTimeout, visibleItemsGetter, itemElements, heightCache, lastMeasuredIndex, calculatedItemHeight,
|
|
72
|
+
/* trunk-ignore(eslint/no-unused-vars) */
|
|
73
|
+
onUpdate, debounceTime = 200) => {
|
|
74
|
+
if (!BROWSER || isCalculatingHeight || heightUpdateTimeout)
|
|
75
|
+
return null;
|
|
76
|
+
const visibleRange = visibleItemsGetter();
|
|
77
|
+
const currentIndex = visibleRange.start;
|
|
78
|
+
if (currentIndex === lastMeasuredIndex)
|
|
79
|
+
return null;
|
|
80
|
+
return setTimeout(() => {
|
|
81
|
+
const { newHeight, newLastMeasuredIndex, updatedHeightCache } = calculateAverageHeight(itemElements, visibleRange, heightCache, calculatedItemHeight);
|
|
82
|
+
if (Math.abs(newHeight - calculatedItemHeight) > 1) {
|
|
83
|
+
onUpdate({
|
|
84
|
+
newHeight,
|
|
85
|
+
newLastMeasuredIndex,
|
|
86
|
+
updatedHeightCache
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}, debounceTime);
|
|
90
|
+
};
|
package/dist/utils/raf.d.ts
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* If a function is already scheduled, the new function will replace it.
|
|
4
|
-
* This helps prevent multiple RAF calls and ensures smooth animations.
|
|
2
|
+
* Creates a requestAnimationFrame (RAF) scheduler for debouncing animation frame callbacks.
|
|
5
3
|
*
|
|
6
|
-
*
|
|
4
|
+
* This factory returns a function that schedules a callback to run on the next animation frame.
|
|
5
|
+
* If multiple calls are made before the frame executes, only the last callback is executed.
|
|
6
|
+
*
|
|
7
|
+
* This is ideal for scenarios where you want to batch or debounce UI updates, such as scroll or resize handlers,
|
|
8
|
+
* without risking global state leaks or cross-component interference.
|
|
9
|
+
*
|
|
10
|
+
* ### Why use this?
|
|
11
|
+
* - Prevents redundant RAF calls and excessive re-renders.
|
|
12
|
+
* - Ensures only the latest callback is executed per frame.
|
|
13
|
+
* - Each scheduler instance is independent—no global state is shared.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* // Svelte usage example:
|
|
17
|
+
* <script lang="ts">
|
|
18
|
+
* import { createRafScheduler } from './raf.js';
|
|
19
|
+
* const rafSchedule = createRafScheduler();
|
|
20
|
+
* function onScroll() {
|
|
21
|
+
* rafSchedule(() => {
|
|
22
|
+
* // Perform expensive DOM measurement or update
|
|
23
|
+
* });
|
|
24
|
+
* }
|
|
25
|
+
* </script>
|
|
26
|
+
* <div on:scroll={onScroll}> ... </div>
|
|
27
|
+
*
|
|
28
|
+
* @returns {(fn: () => void) => void} A scheduler function. Call with a callback to schedule it for the next animation frame.
|
|
29
|
+
*
|
|
30
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame
|
|
7
31
|
*/
|
|
8
|
-
export declare
|
|
32
|
+
export declare function createRafScheduler(): (fn: () => void) => void;
|
package/dist/utils/raf.js
CHANGED
|
@@ -1,22 +1,48 @@
|
|
|
1
|
-
let scheduled = false;
|
|
2
|
-
let callback = null;
|
|
3
1
|
/**
|
|
4
|
-
*
|
|
5
|
-
* If a function is already scheduled, the new function will replace it.
|
|
6
|
-
* This helps prevent multiple RAF calls and ensures smooth animations.
|
|
2
|
+
* Creates a requestAnimationFrame (RAF) scheduler for debouncing animation frame callbacks.
|
|
7
3
|
*
|
|
8
|
-
*
|
|
4
|
+
* This factory returns a function that schedules a callback to run on the next animation frame.
|
|
5
|
+
* If multiple calls are made before the frame executes, only the last callback is executed.
|
|
6
|
+
*
|
|
7
|
+
* This is ideal for scenarios where you want to batch or debounce UI updates, such as scroll or resize handlers,
|
|
8
|
+
* without risking global state leaks or cross-component interference.
|
|
9
|
+
*
|
|
10
|
+
* ### Why use this?
|
|
11
|
+
* - Prevents redundant RAF calls and excessive re-renders.
|
|
12
|
+
* - Ensures only the latest callback is executed per frame.
|
|
13
|
+
* - Each scheduler instance is independent—no global state is shared.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* // Svelte usage example:
|
|
17
|
+
* <script lang="ts">
|
|
18
|
+
* import { createRafScheduler } from './raf.js';
|
|
19
|
+
* const rafSchedule = createRafScheduler();
|
|
20
|
+
* function onScroll() {
|
|
21
|
+
* rafSchedule(() => {
|
|
22
|
+
* // Perform expensive DOM measurement or update
|
|
23
|
+
* });
|
|
24
|
+
* }
|
|
25
|
+
* </script>
|
|
26
|
+
* <div on:scroll={onScroll}> ... </div>
|
|
27
|
+
*
|
|
28
|
+
* @returns {(fn: () => void) => void} A scheduler function. Call with a callback to schedule it for the next animation frame.
|
|
29
|
+
*
|
|
30
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame
|
|
9
31
|
*/
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
32
|
+
export function createRafScheduler() {
|
|
33
|
+
let scheduled = false;
|
|
34
|
+
let callback = null;
|
|
35
|
+
return (fn) => {
|
|
36
|
+
callback = fn;
|
|
37
|
+
if (!scheduled) {
|
|
38
|
+
scheduled = true;
|
|
39
|
+
requestAnimationFrame(() => {
|
|
40
|
+
scheduled = false;
|
|
41
|
+
if (callback) {
|
|
42
|
+
callback();
|
|
43
|
+
callback = null;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
package/dist/utils/types.d.ts
CHANGED
|
@@ -39,3 +39,9 @@ export type VirtualListSetters = {
|
|
|
39
39
|
setScrollTop: (scrollTop: number) => void;
|
|
40
40
|
setInitialized: (initialized: boolean) => void;
|
|
41
41
|
};
|
|
42
|
+
/**
|
|
43
|
+
* Cache for storing measured item heights
|
|
44
|
+
* - Key: Item index in the list
|
|
45
|
+
* - Value: Measured height in pixels
|
|
46
|
+
*/
|
|
47
|
+
export type HeightCache = Record<number, number>;
|
|
@@ -71,7 +71,6 @@ export declare const updateHeightAndScroll: (state: VirtualListState, setters: V
|
|
|
71
71
|
* @param {HTMLElement[]} itemElements - Array of currently rendered item elements
|
|
72
72
|
* @param {{ start: number }} visibleRange - Object containing the start index of visible items
|
|
73
73
|
* @param {Record<number, number>} heightCache - Cache of previously measured item heights
|
|
74
|
-
* @param {number} lastMeasuredIndex - Index of the last measured item
|
|
75
74
|
* @param {number} currentItemHeight - Current average item height being used
|
|
76
75
|
*
|
|
77
76
|
* @returns {{
|
|
@@ -85,13 +84,12 @@ export declare const updateHeightAndScroll: (state: VirtualListState, setters: V
|
|
|
85
84
|
* itemElements,
|
|
86
85
|
* { start: 0 },
|
|
87
86
|
* {},
|
|
88
|
-
* -1,
|
|
89
87
|
* 40
|
|
90
88
|
* )
|
|
91
89
|
*/
|
|
92
90
|
export declare const calculateAverageHeight: (itemElements: HTMLElement[], visibleRange: {
|
|
93
91
|
start: number;
|
|
94
|
-
}, heightCache: Record<number, number>,
|
|
92
|
+
}, heightCache: Record<number, number>, currentItemHeight: number) => {
|
|
95
93
|
newHeight: number;
|
|
96
94
|
newLastMeasuredIndex: number;
|
|
97
95
|
updatedHeightCache: Record<number, number>;
|
|
@@ -120,4 +118,39 @@ export declare const calculateAverageHeight: (itemElements: HTMLElement[], visib
|
|
|
120
118
|
* () => console.log('All items processed')
|
|
121
119
|
* )
|
|
122
120
|
*/
|
|
123
|
-
export declare const processChunked: (items: any[],
|
|
121
|
+
export declare const processChunked: (items: any[], // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
122
|
+
chunkSize: number, onProgress: (processed: number) => void, // eslint-disable-line no-unused-vars
|
|
123
|
+
onComplete: () => void) => Promise<void>;
|
|
124
|
+
/**
|
|
125
|
+
* Builds a block sum array for fast offset calculation in large virtual lists.
|
|
126
|
+
* Each entry in the array is the total height up to the end of that block (exclusive).
|
|
127
|
+
*
|
|
128
|
+
* @param {Record<number, number>} heightCache - Map of measured item heights
|
|
129
|
+
* @param {number} calculatedItemHeight - Estimated height for unmeasured items
|
|
130
|
+
* @param {number} totalItems - Total number of items in the list
|
|
131
|
+
* @param {number} blockSize - Number of items per block
|
|
132
|
+
* @returns {number[]} Array of prefix sums at each block boundary
|
|
133
|
+
*/
|
|
134
|
+
export declare const buildBlockSums: (heightCache: Record<number, number>, calculatedItemHeight: number, totalItems: number, blockSize?: number) => number[];
|
|
135
|
+
/**
|
|
136
|
+
* Calculates the scroll offset (in pixels) needed to bring a specific item into view in a virtual list.
|
|
137
|
+
*
|
|
138
|
+
* Uses block memoization for efficient O(b) offset calculation, where b = block size (default 1000).
|
|
139
|
+
* For very large lists, this avoids O(n) iteration for every scroll.
|
|
140
|
+
*
|
|
141
|
+
* - For indices >= blockSize, sums the block prefix, then only iterates the tail within the block.
|
|
142
|
+
* - For small indices, falls back to the original logic.
|
|
143
|
+
*
|
|
144
|
+
* @param {Record<number, number>} heightCache - Map of measured item heights
|
|
145
|
+
* @param {number} calculatedItemHeight - Estimated height for unmeasured items
|
|
146
|
+
* @param {number} idx - The index to scroll to (exclusive)
|
|
147
|
+
* @param {number[]} [blockSums] - Optional precomputed block sums (for repeated queries)
|
|
148
|
+
* @param {number} [blockSize=1000] - Block size for memoization
|
|
149
|
+
* @returns {number} The total offset in pixels from the top of the list to the start of the item at idx.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* // For best performance with repeated queries:
|
|
153
|
+
* const blockSums = buildBlockSums(heightCache, calculatedItemHeight, items.length);
|
|
154
|
+
* const offset = getScrollOffsetForIndex(heightCache, calculatedItemHeight, 12345, blockSums);
|
|
155
|
+
*/
|
|
156
|
+
export declare const getScrollOffsetForIndex: (heightCache: Record<number, number>, calculatedItemHeight: number, idx: number, blockSums?: number[], blockSize?: number) => number;
|
|
@@ -108,7 +108,6 @@ export const updateHeightAndScroll = (state, setters, immediate = false) => {
|
|
|
108
108
|
* @param {HTMLElement[]} itemElements - Array of currently rendered item elements
|
|
109
109
|
* @param {{ start: number }} visibleRange - Object containing the start index of visible items
|
|
110
110
|
* @param {Record<number, number>} heightCache - Cache of previously measured item heights
|
|
111
|
-
* @param {number} lastMeasuredIndex - Index of the last measured item
|
|
112
111
|
* @param {number} currentItemHeight - Current average item height being used
|
|
113
112
|
*
|
|
114
113
|
* @returns {{
|
|
@@ -122,16 +121,15 @@ export const updateHeightAndScroll = (state, setters, immediate = false) => {
|
|
|
122
121
|
* itemElements,
|
|
123
122
|
* { start: 0 },
|
|
124
123
|
* {},
|
|
125
|
-
* -1,
|
|
126
124
|
* 40
|
|
127
125
|
* )
|
|
128
126
|
*/
|
|
129
|
-
export const calculateAverageHeight = (itemElements, visibleRange, heightCache,
|
|
127
|
+
export const calculateAverageHeight = (itemElements, visibleRange, heightCache, currentItemHeight) => {
|
|
130
128
|
const validElements = itemElements.filter((el) => el);
|
|
131
129
|
if (validElements.length === 0) {
|
|
132
130
|
return {
|
|
133
131
|
newHeight: currentItemHeight,
|
|
134
|
-
newLastMeasuredIndex:
|
|
132
|
+
newLastMeasuredIndex: visibleRange.start,
|
|
135
133
|
updatedHeightCache: heightCache
|
|
136
134
|
};
|
|
137
135
|
}
|
|
@@ -140,14 +138,23 @@ export const calculateAverageHeight = (itemElements, visibleRange, heightCache,
|
|
|
140
138
|
validElements.forEach((el, i) => {
|
|
141
139
|
const itemIndex = visibleRange.start + i;
|
|
142
140
|
if (!newHeightCache[itemIndex]) {
|
|
143
|
-
|
|
141
|
+
try {
|
|
142
|
+
const height = el.getBoundingClientRect().height;
|
|
143
|
+
if (Number.isFinite(height) && height > 0) {
|
|
144
|
+
newHeightCache[itemIndex] = height;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Skip invalid measurements
|
|
149
|
+
}
|
|
144
150
|
}
|
|
145
151
|
});
|
|
146
|
-
// Calculate average from cached heights
|
|
147
|
-
const
|
|
148
|
-
const averageHeight = heights.reduce((sum, h) => sum + h, 0) / heights.length;
|
|
152
|
+
// Calculate average from valid cached heights
|
|
153
|
+
const validHeights = Object.values(newHeightCache).filter((h) => Number.isFinite(h) && h > 0);
|
|
149
154
|
return {
|
|
150
|
-
newHeight:
|
|
155
|
+
newHeight: validHeights.length > 0
|
|
156
|
+
? validHeights.reduce((sum, h) => sum + h, 0) / validHeights.length
|
|
157
|
+
: currentItemHeight,
|
|
151
158
|
newLastMeasuredIndex: visibleRange.start,
|
|
152
159
|
updatedHeightCache: newHeightCache
|
|
153
160
|
};
|
|
@@ -195,3 +202,68 @@ onComplete) => {
|
|
|
195
202
|
};
|
|
196
203
|
await processChunk(0);
|
|
197
204
|
};
|
|
205
|
+
/**
|
|
206
|
+
* Builds a block sum array for fast offset calculation in large virtual lists.
|
|
207
|
+
* Each entry in the array is the total height up to the end of that block (exclusive).
|
|
208
|
+
*
|
|
209
|
+
* @param {Record<number, number>} heightCache - Map of measured item heights
|
|
210
|
+
* @param {number} calculatedItemHeight - Estimated height for unmeasured items
|
|
211
|
+
* @param {number} totalItems - Total number of items in the list
|
|
212
|
+
* @param {number} blockSize - Number of items per block
|
|
213
|
+
* @returns {number[]} Array of prefix sums at each block boundary
|
|
214
|
+
*/
|
|
215
|
+
export const buildBlockSums = (heightCache, calculatedItemHeight, totalItems, blockSize = 1000) => {
|
|
216
|
+
const blockSums = [];
|
|
217
|
+
let sum = 0;
|
|
218
|
+
for (let i = 0; i < totalItems; i++) {
|
|
219
|
+
sum += heightCache[i] ?? calculatedItemHeight;
|
|
220
|
+
if ((i + 1) % blockSize === 0) {
|
|
221
|
+
blockSums.push(sum);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Push the last partial block if needed
|
|
225
|
+
if (totalItems % blockSize !== 0) {
|
|
226
|
+
blockSums.push(sum);
|
|
227
|
+
}
|
|
228
|
+
return blockSums;
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* Calculates the scroll offset (in pixels) needed to bring a specific item into view in a virtual list.
|
|
232
|
+
*
|
|
233
|
+
* Uses block memoization for efficient O(b) offset calculation, where b = block size (default 1000).
|
|
234
|
+
* For very large lists, this avoids O(n) iteration for every scroll.
|
|
235
|
+
*
|
|
236
|
+
* - For indices >= blockSize, sums the block prefix, then only iterates the tail within the block.
|
|
237
|
+
* - For small indices, falls back to the original logic.
|
|
238
|
+
*
|
|
239
|
+
* @param {Record<number, number>} heightCache - Map of measured item heights
|
|
240
|
+
* @param {number} calculatedItemHeight - Estimated height for unmeasured items
|
|
241
|
+
* @param {number} idx - The index to scroll to (exclusive)
|
|
242
|
+
* @param {number[]} [blockSums] - Optional precomputed block sums (for repeated queries)
|
|
243
|
+
* @param {number} [blockSize=1000] - Block size for memoization
|
|
244
|
+
* @returns {number} The total offset in pixels from the top of the list to the start of the item at idx.
|
|
245
|
+
*
|
|
246
|
+
* @example
|
|
247
|
+
* // For best performance with repeated queries:
|
|
248
|
+
* const blockSums = buildBlockSums(heightCache, calculatedItemHeight, items.length);
|
|
249
|
+
* const offset = getScrollOffsetForIndex(heightCache, calculatedItemHeight, 12345, blockSums);
|
|
250
|
+
*/
|
|
251
|
+
export const getScrollOffsetForIndex = (heightCache, calculatedItemHeight, idx, blockSums, blockSize = 1000) => {
|
|
252
|
+
if (idx <= 0)
|
|
253
|
+
return 0;
|
|
254
|
+
if (!blockSums) {
|
|
255
|
+
// Fallback: O(n) for a single query
|
|
256
|
+
let offset = 0;
|
|
257
|
+
for (let i = 0; i < idx; i++) {
|
|
258
|
+
offset += heightCache[i] ?? calculatedItemHeight;
|
|
259
|
+
}
|
|
260
|
+
return offset;
|
|
261
|
+
}
|
|
262
|
+
const blockIdx = Math.floor(idx / blockSize);
|
|
263
|
+
let offset = blockIdx > 0 ? blockSums[blockIdx - 1] : 0;
|
|
264
|
+
const start = blockIdx * blockSize;
|
|
265
|
+
for (let i = start; i < idx; i++) {
|
|
266
|
+
offset += heightCache[i] ?? calculatedItemHeight;
|
|
267
|
+
}
|
|
268
|
+
return offset;
|
|
269
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanspeak/svelte-virtual-list",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "A lightweight, high-performance virtual list component for Svelte 5 that renders large datasets with minimal memory usage. Features include dynamic height support, smooth scrolling, TypeScript support, and efficient DOM recycling. Ideal for infinite scrolling lists, data tables, chat interfaces, and any application requiring the rendering of thousands of items without compromising performance. Zero dependencies and fully customizable.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"svelte",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"prepublishOnly": "npm run package",
|
|
58
58
|
"preview": "vite preview",
|
|
59
59
|
"test": "vitest run --coverage",
|
|
60
|
+
"test:all": "npm run test && npm run test:e2e",
|
|
60
61
|
"test:e2e": "playwright test",
|
|
61
62
|
"test:e2e:debug": "playwright test --debug",
|
|
62
63
|
"test:e2e:report": "playwright show-report",
|
|
@@ -64,56 +65,57 @@
|
|
|
64
65
|
"test:only": "vitest run",
|
|
65
66
|
"test:watch": "vitest"
|
|
66
67
|
},
|
|
68
|
+
"overrides": {
|
|
69
|
+
"@sveltejs/kit": {
|
|
70
|
+
"cookie": "^0.7.0"
|
|
71
|
+
}
|
|
72
|
+
},
|
|
67
73
|
"dependencies": {
|
|
68
74
|
"esm-env": "^1.2.2"
|
|
69
75
|
},
|
|
70
76
|
"devDependencies": {
|
|
71
|
-
"@eslint/
|
|
72
|
-
"@
|
|
73
|
-
"@
|
|
74
|
-
"@
|
|
75
|
-
"@sveltejs/
|
|
77
|
+
"@eslint/compat": "^1.2.9",
|
|
78
|
+
"@eslint/js": "^9.28.0",
|
|
79
|
+
"@faker-js/faker": "^9.8.0",
|
|
80
|
+
"@playwright/test": "^1.52.0",
|
|
81
|
+
"@sveltejs/adapter-auto": "^6.0.1",
|
|
82
|
+
"@sveltejs/kit": "^2.21.1",
|
|
83
|
+
"@sveltejs/package": "^2.3.11",
|
|
76
84
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
|
77
85
|
"@testing-library/jest-dom": "^6.6.3",
|
|
78
|
-
"@testing-library/svelte": "^5.2.
|
|
79
|
-
"@testing-library/user-event": "^14.
|
|
80
|
-
"@types/node": "^22.
|
|
81
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
82
|
-
"@typescript-eslint/parser": "^8.
|
|
83
|
-
"@vitest/coverage-v8": "^3.
|
|
84
|
-
"eslint": "^9.
|
|
85
|
-
"eslint-config-prettier": "^10.
|
|
86
|
-
"eslint-plugin-
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
86
|
+
"@testing-library/svelte": "^5.2.8",
|
|
87
|
+
"@testing-library/user-event": "^14.6.1",
|
|
88
|
+
"@types/node": "^22.15.29",
|
|
89
|
+
"@typescript-eslint/eslint-plugin": "^8.33.0",
|
|
90
|
+
"@typescript-eslint/parser": "^8.33.0",
|
|
91
|
+
"@vitest/coverage-v8": "^3.1.4",
|
|
92
|
+
"eslint": "^9.28.0",
|
|
93
|
+
"eslint-config-prettier": "^10.1.5",
|
|
94
|
+
"eslint-plugin-import": "^2.31.0",
|
|
95
|
+
"eslint-plugin-svelte": "^3.9.0",
|
|
96
|
+
"eslint-plugin-unused-imports": "^4.1.4",
|
|
97
|
+
"globals": "^16.2.0",
|
|
98
|
+
"jsdom": "^26.1.0",
|
|
99
|
+
"prettier": "^3.5.3",
|
|
90
100
|
"prettier-plugin-organize-imports": "^4.1.0",
|
|
91
|
-
"prettier-plugin-svelte": "^3.
|
|
92
|
-
"publint": "^0.3.
|
|
93
|
-
"svelte": "^5.
|
|
94
|
-
"svelte-check": "^4.1
|
|
95
|
-
"typescript": "^5.
|
|
96
|
-
"
|
|
97
|
-
"
|
|
101
|
+
"prettier-plugin-svelte": "^3.4.0",
|
|
102
|
+
"publint": "^0.3.12",
|
|
103
|
+
"svelte": "^5.33.12",
|
|
104
|
+
"svelte-check": "^4.2.1",
|
|
105
|
+
"typescript": "^5.8.3",
|
|
106
|
+
"typescript-eslint": "^8.33.0",
|
|
107
|
+
"vite": "^6.3.5",
|
|
108
|
+
"vitest": "^3.1.4"
|
|
98
109
|
},
|
|
99
110
|
"peerDependencies": {
|
|
100
111
|
"svelte": "^5.0.0"
|
|
101
112
|
},
|
|
102
113
|
"volta": {
|
|
103
|
-
"node": "22.
|
|
114
|
+
"node": "22.16.0"
|
|
104
115
|
},
|
|
105
116
|
"publishConfig": {
|
|
106
117
|
"access": "public"
|
|
107
118
|
},
|
|
108
|
-
"overrides": {
|
|
109
|
-
"@sveltejs/kit": {
|
|
110
|
-
"cookie": "^0.7.0"
|
|
111
|
-
},
|
|
112
|
-
"jsdom": {
|
|
113
|
-
"cssstyle": "2.3.0",
|
|
114
|
-
"@asamuzakjp/css-color": "1.0.0"
|
|
115
|
-
}
|
|
116
|
-
},
|
|
117
119
|
"tags": [
|
|
118
120
|
"svelte",
|
|
119
121
|
"virtual-list",
|