@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.
- package/README.md +19 -6
- package/dist/SvelteVirtualList.svelte +471 -102
- package/dist/SvelteVirtualList.svelte.d.ts +78 -31
- package/dist/index.d.ts +2 -2
- package/dist/types.d.ts +30 -0
- package/dist/types.js +8 -1
- package/dist/utils/heightCalculation.d.ts +9 -8
- package/dist/utils/heightCalculation.js +14 -10
- package/dist/utils/types.d.ts +0 -6
- package/dist/utils/virtualList.d.ts +10 -12
- package/dist/utils/virtualList.js +113 -29
- package/package.json +33 -27
|
@@ -1,45 +1,63 @@
|
|
|
1
1
|
<!--
|
|
2
|
-
@component
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
28
|
+
mode="bottomToTop"
|
|
29
|
+
bind:this={listRef}
|
|
26
30
|
>
|
|
27
|
-
{#snippet renderItem(item
|
|
28
|
-
<div
|
|
31
|
+
{#snippet renderItem(item)}
|
|
32
|
+
<div>{item.text}</div>
|
|
29
33
|
{/snippet}
|
|
30
34
|
</SvelteVirtualList>
|
|
31
35
|
```
|
|
32
36
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
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
|
|
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<
|
|
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
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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 {
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
734
|
+
scroll({ index, smoothScroll, shouldThrowOnBounds, align })
|
|
533
735
|
})
|
|
534
736
|
return
|
|
535
737
|
}
|
|
536
|
-
doScroll()
|
|
537
738
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
if (
|
|
739
|
+
// Bounds checking
|
|
740
|
+
let targetIndex = index
|
|
741
|
+
if (targetIndex < 0 || targetIndex >= items.length) {
|
|
742
|
+
if (shouldThrowOnBounds) {
|
|
542
743
|
throw new Error(
|
|
543
|
-
`
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
848
|
+
targetIndex
|
|
551
849
|
)
|
|
552
|
-
|
|
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
|
-
|
|
854
|
+
targetIndex + 1
|
|
563
855
|
)
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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="{
|
|
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({
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
|
618
|
-
|
|
619
|
-
|
|
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>
|