@humanspeak/svelte-virtual-list 0.2.4 → 0.2.6-beta.0
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 +1 -1
- package/README.md +48 -0
- package/dist/SvelteVirtualList.svelte +420 -121
- package/dist/SvelteVirtualList.svelte.d.ts +186 -32
- package/dist/index.d.ts +2 -2
- package/dist/types.d.ts +70 -16
- package/dist/types.js +8 -1
- package/dist/utils/heightCalculation.d.ts +77 -0
- package/dist/utils/heightCalculation.js +91 -0
- package/dist/utils/raf.d.ts +29 -5
- package/dist/utils/raf.js +45 -19
- package/dist/utils/virtualList.d.ts +43 -13
- package/dist/utils/virtualList.js +85 -13
- package/package.json +45 -36
|
@@ -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,19 +140,27 @@
|
|
|
122
140
|
* - Progressive size adjustment system
|
|
123
141
|
*/
|
|
124
142
|
|
|
125
|
-
import {
|
|
126
|
-
|
|
127
|
-
|
|
143
|
+
import {
|
|
144
|
+
DEFAULT_SCROLL_OPTIONS,
|
|
145
|
+
type SvelteVirtualListPreviousVisibleRange,
|
|
146
|
+
type SvelteVirtualListProps,
|
|
147
|
+
type SvelteVirtualListScrollOptions
|
|
148
|
+
} from './types.js'
|
|
149
|
+
import { calculateAverageHeightDebounced } from './utils/heightCalculation.js'
|
|
150
|
+
import { createRafScheduler } from './utils/raf.js'
|
|
128
151
|
import {
|
|
129
152
|
calculateScrollPosition,
|
|
130
|
-
calculateVisibleRange,
|
|
131
153
|
calculateTransformY,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
processChunked
|
|
154
|
+
calculateVisibleRange,
|
|
155
|
+
getScrollOffsetForIndex,
|
|
156
|
+
processChunked,
|
|
157
|
+
updateHeightAndScroll as utilsUpdateHeightAndScroll
|
|
135
158
|
} from './utils/virtualList.js'
|
|
136
|
-
import {
|
|
137
|
-
import {
|
|
159
|
+
import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
|
|
160
|
+
import { BROWSER } from 'esm-env'
|
|
161
|
+
import { onMount, tick } from 'svelte'
|
|
162
|
+
|
|
163
|
+
const rafSchedule = createRafScheduler()
|
|
138
164
|
|
|
139
165
|
/**
|
|
140
166
|
* Core configuration props with default values
|
|
@@ -182,90 +208,43 @@
|
|
|
182
208
|
*/
|
|
183
209
|
let heightUpdateTimeout: ReturnType<typeof setTimeout> | null = null // Debounce timer for height updates
|
|
184
210
|
let resizeObserver: ResizeObserver | null = null // Watches for container size changes
|
|
211
|
+
let itemResizeObserver: ResizeObserver | null = null // Watches for individual item size changes
|
|
185
212
|
|
|
186
213
|
/**
|
|
187
214
|
* Performance Optimization State
|
|
188
215
|
*/
|
|
189
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
|
|
190
218
|
const chunkSize = $state(50) // Number of items to process in each chunk
|
|
191
219
|
let processedItems = $state(0) // Number of items processed during initialization
|
|
192
220
|
|
|
193
|
-
let prevVisibleRange = $state<
|
|
221
|
+
let prevVisibleRange = $state<SvelteVirtualListPreviousVisibleRange | null>(null)
|
|
194
222
|
let prevHeight = $state<number>(0)
|
|
195
223
|
|
|
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
224
|
// Trigger height calculation when items are rendered
|
|
263
225
|
$effect(() => {
|
|
264
226
|
if (BROWSER && itemElements.length > 0 && !isCalculatingHeight) {
|
|
265
|
-
|
|
227
|
+
updateHeight()
|
|
266
228
|
}
|
|
267
229
|
})
|
|
268
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
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
269
248
|
// Add new effect to handle height changes
|
|
270
249
|
$effect(() => {
|
|
271
250
|
if (BROWSER && initialized && mode === 'bottomToTop' && viewportElement) {
|
|
@@ -305,7 +284,7 @@
|
|
|
305
284
|
const targetScrollTop = Math.max(0, totalHeight - height)
|
|
306
285
|
|
|
307
286
|
// Add delay to ensure layout is complete
|
|
308
|
-
|
|
287
|
+
tick().then(() => {
|
|
309
288
|
if (viewportElement) {
|
|
310
289
|
// Start at the bottom for bottom-to-top mode
|
|
311
290
|
viewportElement.scrollTop = targetScrollTop
|
|
@@ -320,7 +299,7 @@
|
|
|
320
299
|
initialized = true
|
|
321
300
|
})
|
|
322
301
|
}
|
|
323
|
-
}
|
|
302
|
+
})
|
|
324
303
|
}
|
|
325
304
|
})
|
|
326
305
|
|
|
@@ -341,10 +320,10 @@
|
|
|
341
320
|
* console.log(`Rendering items from ${range.start} to ${range.end}`)
|
|
342
321
|
* ```
|
|
343
322
|
*
|
|
344
|
-
* @returns {
|
|
323
|
+
* @returns {SvelteVirtualListPreviousVisibleRange} Object containing start and end indices of visible items
|
|
345
324
|
*/
|
|
346
|
-
const visibleItems = $derived(() => {
|
|
347
|
-
if (!items.length) return { start: 0, end: 0 }
|
|
325
|
+
const visibleItems = $derived((): SvelteVirtualListPreviousVisibleRange => {
|
|
326
|
+
if (!items.length) return { start: 0, end: 0 } as SvelteVirtualListPreviousVisibleRange
|
|
348
327
|
const viewportHeight = height || 0
|
|
349
328
|
|
|
350
329
|
return calculateVisibleRange(
|
|
@@ -406,12 +385,12 @@
|
|
|
406
385
|
*/
|
|
407
386
|
const updateHeightAndScroll = (immediate = false) => {
|
|
408
387
|
if (!initialized && mode === 'bottomToTop') {
|
|
409
|
-
|
|
388
|
+
tick().then(() => {
|
|
410
389
|
if (containerElement) {
|
|
411
390
|
const initialHeight = containerElement.getBoundingClientRect().height
|
|
412
391
|
height = initialHeight
|
|
413
392
|
|
|
414
|
-
|
|
393
|
+
tick().then(() => {
|
|
415
394
|
if (containerElement && viewportElement) {
|
|
416
395
|
const finalHeight = containerElement.getBoundingClientRect().height
|
|
417
396
|
height = finalHeight
|
|
@@ -438,9 +417,9 @@
|
|
|
438
417
|
}
|
|
439
418
|
})
|
|
440
419
|
}
|
|
441
|
-
}
|
|
420
|
+
})
|
|
442
421
|
}
|
|
443
|
-
}
|
|
422
|
+
})
|
|
444
423
|
return
|
|
445
424
|
}
|
|
446
425
|
|
|
@@ -510,6 +489,44 @@
|
|
|
510
489
|
}
|
|
511
490
|
})
|
|
512
491
|
|
|
492
|
+
// Create itemResizeObserver immediately when in browser
|
|
493
|
+
if (BROWSER) {
|
|
494
|
+
// Watch for individual item size changes
|
|
495
|
+
itemResizeObserver = new ResizeObserver((entries) => {
|
|
496
|
+
let shouldRecalculate = false
|
|
497
|
+
|
|
498
|
+
if (debug) {
|
|
499
|
+
console.log(`ResizeObserver fired for ${entries.length} entries`)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
for (const entry of entries) {
|
|
503
|
+
const element = entry.target as HTMLElement
|
|
504
|
+
const elementIndex = itemElements.indexOf(element)
|
|
505
|
+
|
|
506
|
+
if (elementIndex !== -1) {
|
|
507
|
+
const actualIndex = visibleItems().start + elementIndex
|
|
508
|
+
|
|
509
|
+
// ResizeObserver fired = element resized, so add to dirty queue
|
|
510
|
+
dirtyItems.add(actualIndex)
|
|
511
|
+
shouldRecalculate = true
|
|
512
|
+
|
|
513
|
+
if (debug) {
|
|
514
|
+
console.log(
|
|
515
|
+
`Item ${actualIndex} marked dirty (resized), queue size: ${dirtyItems.size}`
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (shouldRecalculate) {
|
|
522
|
+
// Trigger virtual list recalculation
|
|
523
|
+
rafSchedule(() => {
|
|
524
|
+
updateHeight()
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
|
|
513
530
|
// Setup and cleanup
|
|
514
531
|
onMount(() => {
|
|
515
532
|
if (BROWSER) {
|
|
@@ -530,6 +547,9 @@
|
|
|
530
547
|
if (resizeObserver) {
|
|
531
548
|
resizeObserver.disconnect()
|
|
532
549
|
}
|
|
550
|
+
if (itemResizeObserver) {
|
|
551
|
+
itemResizeObserver.disconnect()
|
|
552
|
+
}
|
|
533
553
|
}
|
|
534
554
|
}
|
|
535
555
|
})
|
|
@@ -541,6 +561,279 @@
|
|
|
541
561
|
prevHeight = calculatedItemHeight
|
|
542
562
|
}
|
|
543
563
|
})
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Scrolls the virtual list to the item at the given index.
|
|
567
|
+
*
|
|
568
|
+
* @deprecated This function is deprecated and will be removed in a future version.
|
|
569
|
+
* Use the new scroll method from the component instance instead.
|
|
570
|
+
*
|
|
571
|
+
* @function scrollToIndex
|
|
572
|
+
* @param index The index of the item to scroll to.
|
|
573
|
+
* @param smoothScroll (default: true) Whether to use smooth scrolling.
|
|
574
|
+
* @param shouldThrowOnBounds (default: true) Whether to throw an error if the index is out of bounds.
|
|
575
|
+
*
|
|
576
|
+
* @example
|
|
577
|
+
* // Svelte usage:
|
|
578
|
+
* // In your <script> block:
|
|
579
|
+
* import SvelteVirtualList from '@humanspeak/svelte-virtual-list';
|
|
580
|
+
* let virtualList;
|
|
581
|
+
* const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` }));
|
|
582
|
+
*
|
|
583
|
+
* // In your markup:
|
|
584
|
+
* <button onclick={() => virtualList.scrollToIndex(5000)}>
|
|
585
|
+
* Scroll to 5000
|
|
586
|
+
* </button>
|
|
587
|
+
* <SvelteVirtualList {items} bind:this={virtualList}>
|
|
588
|
+
* {#snippet renderItem(item)}
|
|
589
|
+
* <div>{item.text}</div>
|
|
590
|
+
* {/snippet}
|
|
591
|
+
* </SvelteVirtualList>
|
|
592
|
+
*
|
|
593
|
+
* @returns {void}
|
|
594
|
+
* @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
|
|
595
|
+
*/
|
|
596
|
+
export const scrollToIndex = (
|
|
597
|
+
index: number,
|
|
598
|
+
smoothScroll = true,
|
|
599
|
+
shouldThrowOnBounds = true
|
|
600
|
+
): void => {
|
|
601
|
+
// Deprecation warning
|
|
602
|
+
console.warn(
|
|
603
|
+
'SvelteVirtualList: scrollToIndex is deprecated and will be removed in a future version. ' +
|
|
604
|
+
'Use the new scroll method from the component instance instead.'
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
// Call the new scroll function with the provided parameters
|
|
608
|
+
scroll({ index, smoothScroll, shouldThrowOnBounds })
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Scrolls the virtual list to the item at the given index using a type-based options approach.
|
|
613
|
+
*
|
|
614
|
+
* @function scroll
|
|
615
|
+
* @param options Configuration options for scrolling behavior.
|
|
616
|
+
*
|
|
617
|
+
* @example
|
|
618
|
+
* // Svelte usage:
|
|
619
|
+
* // In your <script> block:
|
|
620
|
+
* import SvelteVirtualList from './index.js';
|
|
621
|
+
* let virtualList;
|
|
622
|
+
* const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` }));
|
|
623
|
+
*
|
|
624
|
+
* <button onclick={() => virtualList.scroll({ index: 5000 })}>
|
|
625
|
+
* Scroll to 5000
|
|
626
|
+
* </button>
|
|
627
|
+
* <SvelteVirtualList {items} bind:this={virtualList}>
|
|
628
|
+
* {#snippet renderItem(item)}
|
|
629
|
+
* <div>{item.text}</div>
|
|
630
|
+
* {/snippet}
|
|
631
|
+
* </SvelteVirtualList>
|
|
632
|
+
*
|
|
633
|
+
* @returns {void}
|
|
634
|
+
* @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
|
|
635
|
+
*/
|
|
636
|
+
export const scroll = (options: SvelteVirtualListScrollOptions): void => {
|
|
637
|
+
const { index, smoothScroll, shouldThrowOnBounds, align } = {
|
|
638
|
+
...DEFAULT_SCROLL_OPTIONS,
|
|
639
|
+
...options
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (!items.length) return
|
|
643
|
+
if (!viewportElement) {
|
|
644
|
+
tick().then(() => {
|
|
645
|
+
if (!viewportElement) return
|
|
646
|
+
scroll({ index, smoothScroll, shouldThrowOnBounds, align })
|
|
647
|
+
})
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Bounds checking
|
|
652
|
+
let targetIndex = index
|
|
653
|
+
if (targetIndex < 0 || targetIndex >= items.length) {
|
|
654
|
+
if (shouldThrowOnBounds) {
|
|
655
|
+
throw new Error(
|
|
656
|
+
`scroll: index ${targetIndex} is out of bounds (0-${items.length - 1})`
|
|
657
|
+
)
|
|
658
|
+
} else {
|
|
659
|
+
targetIndex = Math.max(0, Math.min(targetIndex, items.length - 1))
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems()
|
|
664
|
+
let scrollTarget: number | null = null
|
|
665
|
+
|
|
666
|
+
if (mode === 'bottomToTop') {
|
|
667
|
+
const totalHeight = items.length * calculatedItemHeight
|
|
668
|
+
const itemOffset = targetIndex * calculatedItemHeight
|
|
669
|
+
const itemHeight = calculatedItemHeight
|
|
670
|
+
if (align === 'auto') {
|
|
671
|
+
// If item is above the viewport, align to top
|
|
672
|
+
if (targetIndex < firstVisibleIndex) {
|
|
673
|
+
scrollTarget = Math.max(0, totalHeight - (itemOffset + itemHeight))
|
|
674
|
+
// If item is below the viewport, align to bottom
|
|
675
|
+
} else if (targetIndex > lastVisibleIndex - 1) {
|
|
676
|
+
scrollTarget = Math.max(0, totalHeight - itemOffset - height)
|
|
677
|
+
} else {
|
|
678
|
+
// Item is visible but not aligned: align to nearest edge
|
|
679
|
+
// Calculate the offset of the item relative to the viewport
|
|
680
|
+
const itemTop = totalHeight - (itemOffset + itemHeight)
|
|
681
|
+
const itemBottom = totalHeight - itemOffset
|
|
682
|
+
const distanceToTop = Math.abs(scrollTop - itemTop)
|
|
683
|
+
const distanceToBottom = Math.abs(scrollTop + height - itemBottom)
|
|
684
|
+
if (distanceToTop < distanceToBottom) {
|
|
685
|
+
// Closer to top, align to top
|
|
686
|
+
scrollTarget = itemTop
|
|
687
|
+
} else {
|
|
688
|
+
// Closer to bottom, align to bottom
|
|
689
|
+
scrollTarget = Math.max(0, itemBottom - height)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
} else if (align === 'top') {
|
|
693
|
+
// Align to top
|
|
694
|
+
scrollTarget = Math.max(0, totalHeight - (itemOffset + itemHeight))
|
|
695
|
+
} else if (align === 'bottom') {
|
|
696
|
+
// Align to bottom
|
|
697
|
+
scrollTarget = Math.max(0, totalHeight - itemOffset - height)
|
|
698
|
+
} else if (align === 'nearest') {
|
|
699
|
+
// If not visible, align to nearest edge; if visible, do nothing
|
|
700
|
+
const itemTop = totalHeight - (itemOffset + itemHeight)
|
|
701
|
+
const itemBottom = totalHeight - itemOffset
|
|
702
|
+
if (itemBottom <= scrollTop || itemTop >= scrollTop + height) {
|
|
703
|
+
// Not visible, align to nearest edge
|
|
704
|
+
const distanceToTop = Math.abs(scrollTop - itemTop)
|
|
705
|
+
const distanceToBottom = Math.abs(scrollTop + height - itemBottom)
|
|
706
|
+
if (distanceToTop < distanceToBottom) {
|
|
707
|
+
scrollTarget = itemTop
|
|
708
|
+
} else {
|
|
709
|
+
scrollTarget = Math.max(0, itemBottom - height)
|
|
710
|
+
}
|
|
711
|
+
} else {
|
|
712
|
+
// Already visible, do nothing
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
} else {
|
|
717
|
+
// topToBottom (default)
|
|
718
|
+
if (align === 'auto') {
|
|
719
|
+
// If item is above the viewport, align to top
|
|
720
|
+
if (targetIndex < firstVisibleIndex) {
|
|
721
|
+
scrollTarget = getScrollOffsetForIndex(
|
|
722
|
+
heightCache,
|
|
723
|
+
calculatedItemHeight,
|
|
724
|
+
targetIndex
|
|
725
|
+
)
|
|
726
|
+
// If item is below the viewport, align to bottom
|
|
727
|
+
} else if (targetIndex > lastVisibleIndex - 1) {
|
|
728
|
+
const itemBottom = getScrollOffsetForIndex(
|
|
729
|
+
heightCache,
|
|
730
|
+
calculatedItemHeight,
|
|
731
|
+
targetIndex + 1
|
|
732
|
+
)
|
|
733
|
+
scrollTarget = Math.max(0, itemBottom - height)
|
|
734
|
+
} else {
|
|
735
|
+
// Item is visible but not aligned: align to nearest edge
|
|
736
|
+
const itemTop = getScrollOffsetForIndex(
|
|
737
|
+
heightCache,
|
|
738
|
+
calculatedItemHeight,
|
|
739
|
+
targetIndex
|
|
740
|
+
)
|
|
741
|
+
const itemBottom = getScrollOffsetForIndex(
|
|
742
|
+
heightCache,
|
|
743
|
+
calculatedItemHeight,
|
|
744
|
+
targetIndex + 1
|
|
745
|
+
)
|
|
746
|
+
const distanceToTop = Math.abs(scrollTop - itemTop)
|
|
747
|
+
const distanceToBottom = Math.abs(scrollTop + height - itemBottom)
|
|
748
|
+
if (distanceToTop < distanceToBottom) {
|
|
749
|
+
// Closer to top, align to top
|
|
750
|
+
scrollTarget = itemTop
|
|
751
|
+
} else {
|
|
752
|
+
// Closer to bottom, align to bottom
|
|
753
|
+
scrollTarget = Math.max(0, itemBottom - height)
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
} else if (align === 'top') {
|
|
757
|
+
scrollTarget = getScrollOffsetForIndex(
|
|
758
|
+
heightCache,
|
|
759
|
+
calculatedItemHeight,
|
|
760
|
+
targetIndex
|
|
761
|
+
)
|
|
762
|
+
} else if (align === 'bottom') {
|
|
763
|
+
const itemBottom = getScrollOffsetForIndex(
|
|
764
|
+
heightCache,
|
|
765
|
+
calculatedItemHeight,
|
|
766
|
+
targetIndex + 1
|
|
767
|
+
)
|
|
768
|
+
scrollTarget = Math.max(0, itemBottom - height)
|
|
769
|
+
} else if (align === 'nearest') {
|
|
770
|
+
const itemTop = getScrollOffsetForIndex(
|
|
771
|
+
heightCache,
|
|
772
|
+
calculatedItemHeight,
|
|
773
|
+
targetIndex
|
|
774
|
+
)
|
|
775
|
+
const itemBottom = getScrollOffsetForIndex(
|
|
776
|
+
heightCache,
|
|
777
|
+
calculatedItemHeight,
|
|
778
|
+
targetIndex + 1
|
|
779
|
+
)
|
|
780
|
+
if (itemBottom <= scrollTop || itemTop >= scrollTop + height) {
|
|
781
|
+
// Not visible, align to nearest edge
|
|
782
|
+
const distanceToTop = Math.abs(scrollTop - itemTop)
|
|
783
|
+
const distanceToBottom = Math.abs(scrollTop + height - itemBottom)
|
|
784
|
+
if (distanceToTop < distanceToBottom) {
|
|
785
|
+
scrollTarget = itemTop
|
|
786
|
+
} else {
|
|
787
|
+
scrollTarget = Math.max(0, itemBottom - height)
|
|
788
|
+
}
|
|
789
|
+
} else {
|
|
790
|
+
// Already visible, do nothing
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (scrollTarget !== null) {
|
|
797
|
+
viewportElement.scrollTo({
|
|
798
|
+
top: scrollTarget,
|
|
799
|
+
behavior: smoothScroll ? 'smooth' : 'auto'
|
|
800
|
+
})
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Custom Svelte action to automatically observe item elements for size changes.
|
|
806
|
+
* This action is applied to each item element to detect when its dimensions change.
|
|
807
|
+
*
|
|
808
|
+
* @param element - The HTML element to observe
|
|
809
|
+
* @returns {{ destroy: () => void }} Object with destroy method for cleanup
|
|
810
|
+
*/
|
|
811
|
+
function autoObserveItemResize(element: HTMLElement) {
|
|
812
|
+
if (itemResizeObserver) {
|
|
813
|
+
itemResizeObserver.observe(element)
|
|
814
|
+
if (debug) {
|
|
815
|
+
console.log(
|
|
816
|
+
'Started observing element:',
|
|
817
|
+
element,
|
|
818
|
+
'Current height:',
|
|
819
|
+
element.getBoundingClientRect().height
|
|
820
|
+
)
|
|
821
|
+
}
|
|
822
|
+
} else if (debug) {
|
|
823
|
+
console.log('itemResizeObserver not available for element:', element)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return {
|
|
827
|
+
destroy() {
|
|
828
|
+
if (itemResizeObserver) {
|
|
829
|
+
itemResizeObserver.unobserve(element)
|
|
830
|
+
if (debug) {
|
|
831
|
+
console.log('Stopped observing element:', element)
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
544
837
|
</script>
|
|
545
838
|
|
|
546
839
|
<!--
|
|
@@ -597,10 +890,10 @@
|
|
|
597
890
|
)}
|
|
598
891
|
{debugFunction
|
|
599
892
|
? debugFunction(debugInfo)
|
|
600
|
-
: console.
|
|
893
|
+
: console.info('Virtual List Debug:', debugInfo)}
|
|
601
894
|
{/if}
|
|
602
895
|
<!-- Render each visible item -->
|
|
603
|
-
<div bind:this={itemElements[i]}>
|
|
896
|
+
<div bind:this={itemElements[i]} use:autoObserveItemResize>
|
|
604
897
|
{@render renderItem(
|
|
605
898
|
currentItem,
|
|
606
899
|
mode === 'bottomToTop'
|
|
@@ -648,4 +941,10 @@
|
|
|
648
941
|
left: 0;
|
|
649
942
|
top: 0;
|
|
650
943
|
}
|
|
944
|
+
|
|
945
|
+
/* Item wrapper divs should size to their content */
|
|
946
|
+
.virtual-list-items > div {
|
|
947
|
+
width: 100%;
|
|
948
|
+
display: block;
|
|
949
|
+
}
|
|
651
950
|
</style>
|