@humanspeak/svelte-virtual-list 0.3.8 → 0.3.9
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.
|
@@ -163,9 +163,9 @@
|
|
|
163
163
|
import { createRafScheduler } from './utils/raf.js'
|
|
164
164
|
import { isSignificantHeightChange } from './utils/heightChangeDetection.js'
|
|
165
165
|
import {
|
|
166
|
-
calculateScrollPosition,
|
|
167
166
|
calculateTransformY,
|
|
168
167
|
calculateVisibleRange,
|
|
168
|
+
clampValue,
|
|
169
169
|
updateHeightAndScroll as utilsUpdateHeightAndScroll,
|
|
170
170
|
getScrollOffsetForIndex,
|
|
171
171
|
buildBlockSums
|
|
@@ -290,23 +290,16 @@
|
|
|
290
290
|
Math.max(0, lastAnchorIndex),
|
|
291
291
|
blockSums
|
|
292
292
|
)
|
|
293
|
-
const maxScrollTop =
|
|
293
|
+
const maxScrollTop = clampValue(totalHeight() - (height || 0), 0, Infinity)
|
|
294
294
|
let targetTop: number
|
|
295
295
|
if (mode === 'bottomToTop') {
|
|
296
|
-
const distanceFromStart =
|
|
297
|
-
targetTop = Math.
|
|
298
|
-
0,
|
|
299
|
-
Math.min(maxScrollTop, Math.round(maxScrollTop - distanceFromStart))
|
|
300
|
-
)
|
|
296
|
+
const distanceFromStart = clampValue(offsetToIndex + lastAnchorOffset, 0, Infinity)
|
|
297
|
+
targetTop = clampValue(Math.round(maxScrollTop - distanceFromStart), 0, maxScrollTop)
|
|
301
298
|
} else {
|
|
302
|
-
targetTop = Math.
|
|
303
|
-
0,
|
|
304
|
-
Math.min(maxScrollTop, Math.round(offsetToIndex + lastAnchorOffset))
|
|
305
|
-
)
|
|
299
|
+
targetTop = clampValue(Math.round(offsetToIndex + lastAnchorOffset), 0, maxScrollTop)
|
|
306
300
|
}
|
|
307
301
|
if (Math.abs(heightManager.viewport.scrollTop - targetTop) >= 2) {
|
|
308
|
-
|
|
309
|
-
heightManager.scrollTop = targetTop
|
|
302
|
+
syncScrollTop(targetTop)
|
|
310
303
|
}
|
|
311
304
|
pendingAnchorReconcile = false
|
|
312
305
|
}
|
|
@@ -387,6 +380,23 @@
|
|
|
387
380
|
}
|
|
388
381
|
}
|
|
389
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Synchronizes the scroll position between the viewport element and internal state.
|
|
385
|
+
*
|
|
386
|
+
* This helper consolidates the repeated pattern of updating both
|
|
387
|
+
* heightManager.viewport.scrollTop and heightManager.scrollTop together,
|
|
388
|
+
* ensuring they stay in sync.
|
|
389
|
+
*
|
|
390
|
+
* @param {number} value - The scroll position to set
|
|
391
|
+
* @param {boolean} round - Whether to round the value to the nearest integer (default: false)
|
|
392
|
+
*/
|
|
393
|
+
const syncScrollTop = (value: number, round = false) => {
|
|
394
|
+
if (!heightManager.viewportElement) return
|
|
395
|
+
const scrollValue = round ? Math.round(value) : value
|
|
396
|
+
heightManager.viewport.scrollTop = scrollValue
|
|
397
|
+
heightManager.scrollTop = scrollValue
|
|
398
|
+
}
|
|
399
|
+
|
|
390
400
|
// Dynamic update coordination to avoid UA scroll anchoring interference
|
|
391
401
|
let suppressBottomAnchoringUntilMs = $state(0)
|
|
392
402
|
|
|
@@ -508,8 +518,7 @@
|
|
|
508
518
|
// Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
|
|
509
519
|
const approximateScrollTop = Math.max(0, totalHeight() - height)
|
|
510
520
|
log('[SVL] b2t-correction-approx', { approximateScrollTop })
|
|
511
|
-
|
|
512
|
-
heightManager.scrollTop = approximateScrollTop
|
|
521
|
+
syncScrollTop(approximateScrollTop)
|
|
513
522
|
|
|
514
523
|
// Step 2: Use native scrollIntoView for precise bottom-edge positioning after DOM updates
|
|
515
524
|
tick().then(() => {
|
|
@@ -572,13 +581,12 @@
|
|
|
572
581
|
}
|
|
573
582
|
}
|
|
574
583
|
if (Math.abs(heightChangeAboveViewport) > 2) {
|
|
575
|
-
const newScrollTop =
|
|
576
|
-
|
|
577
|
-
|
|
584
|
+
const newScrollTop = clampValue(
|
|
585
|
+
currentScrollTop + heightChangeAboveViewport,
|
|
586
|
+
0,
|
|
587
|
+
maxScrollTop
|
|
578
588
|
)
|
|
579
|
-
|
|
580
|
-
heightManager.viewport.scrollTop = newScrollTop
|
|
581
|
-
heightManager.scrollTop = newScrollTop
|
|
589
|
+
syncScrollTop(newScrollTop)
|
|
582
590
|
}
|
|
583
591
|
}
|
|
584
592
|
|
|
@@ -613,6 +621,8 @@
|
|
|
613
621
|
// Infinite scroll: trigger onLoadMore when approaching end of list
|
|
614
622
|
$effect(() => {
|
|
615
623
|
if (!BROWSER || !onLoadMore || !hasMore || isLoadingMore) return
|
|
624
|
+
// Skip loading during bottomToTop initialization (init path renders all items artificially)
|
|
625
|
+
if (mode === 'bottomToTop' && !bottomToTopScrollComplete) return
|
|
616
626
|
|
|
617
627
|
const range = visibleItems()
|
|
618
628
|
const atLoadingEdge = range.end >= items.length - loadMoreThreshold
|
|
@@ -667,11 +677,12 @@
|
|
|
667
677
|
const isAtBottom = Math.abs(currentScrollTop - maxScrollTop) <= tolerance
|
|
668
678
|
if (isAtBottom) {
|
|
669
679
|
// Adjust scrollTop by total height delta to hold bottom anchor
|
|
670
|
-
const adjusted =
|
|
671
|
-
|
|
680
|
+
const adjusted = clampValue(
|
|
681
|
+
currentScrollTop + deltaTotal,
|
|
682
|
+
0,
|
|
683
|
+
maxScrollTop
|
|
672
684
|
)
|
|
673
|
-
|
|
674
|
-
heightManager.scrollTop = adjusted
|
|
685
|
+
syncScrollTop(adjusted, true)
|
|
675
686
|
}
|
|
676
687
|
}
|
|
677
688
|
}
|
|
@@ -703,6 +714,9 @@
|
|
|
703
714
|
let lastItemsLength = $state(0)
|
|
704
715
|
// Track last observed total height to compute precise deltas on item count changes
|
|
705
716
|
let lastTotalHeightObserved = $state(0)
|
|
717
|
+
// For bottomToTop mode: keep init path active until scroll positioning is complete
|
|
718
|
+
// This ensures Item 0 stays in the DOM throughout initialization
|
|
719
|
+
let bottomToTopScrollComplete = $state(false)
|
|
706
720
|
|
|
707
721
|
/**
|
|
708
722
|
* CRITICAL: O(1) Reactive Total Height Calculation
|
|
@@ -794,9 +808,7 @@
|
|
|
794
808
|
|
|
795
809
|
if (shouldCorrect) {
|
|
796
810
|
// Round to avoid subpixel positioning issues in bottomToTop mode
|
|
797
|
-
|
|
798
|
-
heightManager.viewport.scrollTop = roundedTargetScrollTop
|
|
799
|
-
heightManager.scrollTop = roundedTargetScrollTop
|
|
811
|
+
syncScrollTop(targetScrollTop, true)
|
|
800
812
|
}
|
|
801
813
|
|
|
802
814
|
// Track if user has scrolled significantly away from bottom
|
|
@@ -852,10 +864,12 @@
|
|
|
852
864
|
// If near the bottom, this naturally pins to the new max; otherwise it preserves the current content.
|
|
853
865
|
programmaticScrollInProgress = true
|
|
854
866
|
void heightManager.runDynamicUpdate(() => {
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
867
|
+
const newScrollTop = clampValue(
|
|
868
|
+
currentScrollTop + deltaMax,
|
|
869
|
+
0,
|
|
870
|
+
nextMaxScrollTop
|
|
871
|
+
)
|
|
872
|
+
syncScrollTop(newScrollTop)
|
|
859
873
|
log('[SVL] items-length-change:applied', {
|
|
860
874
|
instanceId,
|
|
861
875
|
previousScrollTop: currentScrollTop,
|
|
@@ -871,23 +885,20 @@
|
|
|
871
885
|
// Reconcile on next frame in case measured heights adjust totals
|
|
872
886
|
requestAnimationFrame(() => {
|
|
873
887
|
const beforeReconcileScrollTop = heightManager.viewport.scrollTop
|
|
874
|
-
const reconciledNextMax =
|
|
888
|
+
const reconciledNextMax = clampValue(totalHeight() - height, 0, Infinity)
|
|
875
889
|
const reconciledDeltaMaxChange = reconciledNextMax - nextMaxScrollTop
|
|
876
890
|
// Desired position is to maintain distance-from-end; equivalently keep (max - scrollTop) constant.
|
|
877
|
-
const desiredScrollTop =
|
|
891
|
+
const desiredScrollTop = clampValue(
|
|
892
|
+
newScrollTop + reconciledDeltaMaxChange,
|
|
878
893
|
0,
|
|
879
|
-
|
|
894
|
+
reconciledNextMax
|
|
880
895
|
)
|
|
881
896
|
// Snap to integer pixels to prevent oscillation due to subpixel rounding
|
|
882
897
|
const desiredRounded = Math.round(desiredScrollTop)
|
|
883
898
|
const diffToDesired = desiredRounded - heightManager.viewport.scrollTop
|
|
884
899
|
if (Math.abs(diffToDesired) >= 2) {
|
|
885
|
-
const adjusted =
|
|
886
|
-
|
|
887
|
-
Math.min(reconciledNextMax, desiredRounded)
|
|
888
|
-
)
|
|
889
|
-
heightManager.viewport.scrollTop = adjusted
|
|
890
|
-
heightManager.scrollTop = adjusted
|
|
900
|
+
const adjusted = clampValue(desiredRounded, 0, reconciledNextMax)
|
|
901
|
+
syncScrollTop(adjusted)
|
|
891
902
|
log('[SVL] items-length-change:reconciled', {
|
|
892
903
|
instanceId,
|
|
893
904
|
beforeReconcileScrollTop,
|
|
@@ -963,31 +974,18 @@
|
|
|
963
974
|
if (!items.length) return { start: 0, end: 0 } as SvelteVirtualListPreviousVisibleRange
|
|
964
975
|
const viewportHeight = height || 0
|
|
965
976
|
|
|
966
|
-
// For bottomToTop mode,
|
|
967
|
-
// This
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
viewportHeight
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
lastVisibleRange = calculateVisibleRange(
|
|
979
|
-
targetScrollTop,
|
|
980
|
-
viewportHeight,
|
|
981
|
-
heightManager.averageHeight,
|
|
982
|
-
items.length,
|
|
983
|
-
bufferSize,
|
|
984
|
-
mode,
|
|
985
|
-
atBottom,
|
|
986
|
-
wasAtBottomBeforeHeightChange,
|
|
987
|
-
lastVisibleRange,
|
|
988
|
-
totalHeight(),
|
|
989
|
-
heightManager.getHeightCache()
|
|
990
|
-
)
|
|
977
|
+
// For bottomToTop mode, always render items starting from index 0 during initialization
|
|
978
|
+
// This ensures Item 0 is in the DOM so we can use scrollIntoView for precise positioning
|
|
979
|
+
// The scrollIntoView in updateHeightAndScroll will handle correct alignment after heights are measured
|
|
980
|
+
// Use bottomToTopScrollComplete (not just initialized) to keep init path active until scroll is done
|
|
981
|
+
if (mode === 'bottomToTop' && !bottomToTopScrollComplete) {
|
|
982
|
+
// Use a reasonable default if viewport height isn't measured yet
|
|
983
|
+
const effectiveViewport = viewportHeight || 400
|
|
984
|
+
const visibleCount = Math.ceil(effectiveViewport / heightManager.averageHeight) + 1
|
|
985
|
+
lastVisibleRange = {
|
|
986
|
+
start: 0,
|
|
987
|
+
end: Math.min(items.length, visibleCount + bufferSize * 2)
|
|
988
|
+
} as SvelteVirtualListPreviousVisibleRange
|
|
991
989
|
|
|
992
990
|
return lastVisibleRange
|
|
993
991
|
}
|
|
@@ -1099,7 +1097,8 @@
|
|
|
1099
1097
|
mode
|
|
1100
1098
|
})
|
|
1101
1099
|
if (!heightManager.initialized && mode === 'bottomToTop') {
|
|
1102
|
-
//
|
|
1100
|
+
// bottomToTop initialization: use scrollIntoView on Item 0 for precise positioning
|
|
1101
|
+
// visibleItems() guarantees Item 0 is rendered during initialization
|
|
1103
1102
|
tick().then(() => {
|
|
1104
1103
|
requestAnimationFrame(() => {
|
|
1105
1104
|
requestAnimationFrame(() => {
|
|
@@ -1107,11 +1106,7 @@
|
|
|
1107
1106
|
const measuredHeight =
|
|
1108
1107
|
heightManager.container.getBoundingClientRect().height
|
|
1109
1108
|
height = measuredHeight
|
|
1110
|
-
|
|
1111
|
-
items.length,
|
|
1112
|
-
heightManager.averageHeight,
|
|
1113
|
-
measuredHeight
|
|
1114
|
-
)
|
|
1109
|
+
|
|
1115
1110
|
// Instance jitter to avoid same-frame collisions when two lists init together
|
|
1116
1111
|
const cleanedId = String(instanceId)
|
|
1117
1112
|
.toLowerCase()
|
|
@@ -1121,32 +1116,48 @@
|
|
|
1121
1116
|
const jitterMs = Number.isNaN(parsed)
|
|
1122
1117
|
? Math.floor(Math.random() * 3)
|
|
1123
1118
|
: parsed % 3
|
|
1124
|
-
|
|
1119
|
+
|
|
1125
1120
|
setTimeout(() => {
|
|
1126
|
-
|
|
1127
|
-
|
|
1121
|
+
// Step 1: Set initialized (for other purposes like scroll event handling)
|
|
1122
|
+
// The init path in visibleItems() stays active until bottomToTopScrollComplete
|
|
1123
|
+
if (!heightManager.initialized) {
|
|
1124
|
+
heightManager.initialized = true
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Step 2: Use scrollIntoView on Item 0 for precise positioning
|
|
1128
|
+
// Use double RAF to ensure heights are measured and layout is stable
|
|
1128
1129
|
requestAnimationFrame(() => {
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1130
|
+
requestAnimationFrame(() => {
|
|
1131
|
+
// Item 0 is guaranteed to be in DOM due to init path
|
|
1132
|
+
// Skip if user has already scrolled (scrollTop significantly != 0)
|
|
1133
|
+
const currentScroll = heightManager.viewport.scrollTop
|
|
1134
|
+
const userHasScrolled =
|
|
1135
|
+
currentScroll > heightManager.averageHeight
|
|
1133
1136
|
const el = heightManager.viewport.querySelector(
|
|
1134
1137
|
'[data-original-index="0"]'
|
|
1135
1138
|
) as HTMLElement | null
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
Math.abs(cont.y + cont.height - (r.y + r.height)) <= tol
|
|
1142
|
-
if (!aligned) {
|
|
1143
|
-
el.scrollIntoView({ block: 'end', inline: 'nearest' })
|
|
1144
|
-
heightManager.scrollTop = heightManager.viewport.scrollTop
|
|
1145
|
-
log('b2t-init-native-fallback', {
|
|
1146
|
-
containerBottom: cont.y + cont.height,
|
|
1147
|
-
itemBottom: r.y + r.height
|
|
1139
|
+
|
|
1140
|
+
if (el && !userHasScrolled) {
|
|
1141
|
+
el.scrollIntoView({
|
|
1142
|
+
block: 'end',
|
|
1143
|
+
inline: 'nearest'
|
|
1148
1144
|
})
|
|
1145
|
+
heightManager.scrollTop = heightManager.viewport.scrollTop
|
|
1146
|
+
} else if (userHasScrolled) {
|
|
1147
|
+
// Sync internal state with current scroll
|
|
1148
|
+
heightManager.scrollTop = currentScroll
|
|
1149
1149
|
}
|
|
1150
|
+
|
|
1151
|
+
// Step 3: Mark scroll complete - switches visibleItems to normal mode
|
|
1152
|
+
requestAnimationFrame(() => {
|
|
1153
|
+
bottomToTopScrollComplete = true
|
|
1154
|
+
// Reset bottom-anchoring flag to prevent stale state from init
|
|
1155
|
+
// affecting later operations (e.g., adding items while scrolled away)
|
|
1156
|
+
wasAtBottomBeforeHeightChange = false
|
|
1157
|
+
// Suppress bottom-anchoring briefly to let heights stabilize
|
|
1158
|
+
// after switching to normal mode
|
|
1159
|
+
suppressBottomAnchoringUntilMs = performance.now() + 200
|
|
1160
|
+
})
|
|
1150
1161
|
})
|
|
1151
1162
|
})
|
|
1152
1163
|
}, jitterMs)
|
|
@@ -1371,7 +1382,7 @@
|
|
|
1371
1382
|
`scroll: index ${targetIndex} is out of bounds (0-${items.length - 1})`
|
|
1372
1383
|
)
|
|
1373
1384
|
} else {
|
|
1374
|
-
targetIndex =
|
|
1385
|
+
targetIndex = clampValue(targetIndex, 0, items.length - 1)
|
|
1375
1386
|
}
|
|
1376
1387
|
}
|
|
1377
1388
|
|
|
@@ -1527,7 +1538,6 @@
|
|
|
1527
1538
|
id="virtual-list-items"
|
|
1528
1539
|
{...testId ? { 'data-testid': `${testId}-items` } : {}}
|
|
1529
1540
|
class={itemsClass ?? 'virtual-list-items'}
|
|
1530
|
-
style:visibility={height === 0 && mode === 'bottomToTop' ? 'hidden' : 'visible'}
|
|
1531
1541
|
style:transform="translateY({(() => {
|
|
1532
1542
|
const viewportHeight = height || measuredFallbackHeight || 0
|
|
1533
1543
|
const visibleRange = visibleItems()
|
|
@@ -1,4 +1,39 @@
|
|
|
1
1
|
import type { SvelteVirtualListMode, SvelteVirtualListScrollAlign } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Calculates the scroll target for aligning an item to a specific edge.
|
|
4
|
+
*
|
|
5
|
+
* This helper consolidates the shared alignment logic between bottomToTop
|
|
6
|
+
* and topToBottom scroll calculations, reducing code duplication.
|
|
7
|
+
*
|
|
8
|
+
* @param {number} itemTop - The top position of the item in pixels
|
|
9
|
+
* @param {number} itemBottom - The bottom position of the item in pixels
|
|
10
|
+
* @param {number} scrollTop - Current scroll position in pixels
|
|
11
|
+
* @param {number} viewportHeight - Height of the viewport in pixels
|
|
12
|
+
* @param {'top' | 'bottom' | 'nearest'} align - The alignment mode
|
|
13
|
+
* @returns {number | null} The scroll target position, or null if item is already visible (for 'nearest')
|
|
14
|
+
*/
|
|
15
|
+
export declare const alignToEdge: (itemTop: number, itemBottom: number, scrollTop: number, viewportHeight: number, align: "top" | "bottom" | "nearest") => number | null;
|
|
16
|
+
/**
|
|
17
|
+
* Calculates the scroll target for aligning a visible item to its nearest edge.
|
|
18
|
+
*
|
|
19
|
+
* Unlike alignToEdge with 'nearest', this always returns a scroll position
|
|
20
|
+
* even when the item is visible. Used for 'auto' alignment mode when item
|
|
21
|
+
* is within the visible range.
|
|
22
|
+
*
|
|
23
|
+
* @param {number} itemTop - The top position of the item in pixels
|
|
24
|
+
* @param {number} itemBottom - The bottom position of the item in pixels
|
|
25
|
+
* @param {number} scrollTop - Current scroll position in pixels
|
|
26
|
+
* @param {number} viewportHeight - Height of the viewport in pixels
|
|
27
|
+
* @returns {number} The scroll target position aligned to nearest edge
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* // For a visible item, align to whichever edge is closer
|
|
32
|
+
* const scrollTarget = alignVisibleToNearestEdge(400, 450, 200, 400)
|
|
33
|
+
* viewportElement.scrollTo({ top: scrollTarget })
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare const alignVisibleToNearestEdge: (itemTop: number, itemBottom: number, scrollTop: number, viewportHeight: number) => number;
|
|
2
37
|
/**
|
|
3
38
|
* Parameters for calculating scroll target position
|
|
4
39
|
*/
|
|
@@ -1,4 +1,66 @@
|
|
|
1
|
-
import { getScrollOffsetForIndex } from './virtualList.js';
|
|
1
|
+
import { clampValue, getScrollOffsetForIndex } from './virtualList.js';
|
|
2
|
+
/**
|
|
3
|
+
* Calculates the scroll target for aligning an item to a specific edge.
|
|
4
|
+
*
|
|
5
|
+
* This helper consolidates the shared alignment logic between bottomToTop
|
|
6
|
+
* and topToBottom scroll calculations, reducing code duplication.
|
|
7
|
+
*
|
|
8
|
+
* @param {number} itemTop - The top position of the item in pixels
|
|
9
|
+
* @param {number} itemBottom - The bottom position of the item in pixels
|
|
10
|
+
* @param {number} scrollTop - Current scroll position in pixels
|
|
11
|
+
* @param {number} viewportHeight - Height of the viewport in pixels
|
|
12
|
+
* @param {'top' | 'bottom' | 'nearest'} align - The alignment mode
|
|
13
|
+
* @returns {number | null} The scroll target position, or null if item is already visible (for 'nearest')
|
|
14
|
+
*/
|
|
15
|
+
export const alignToEdge = (itemTop, itemBottom, scrollTop, viewportHeight, align) => {
|
|
16
|
+
if (align === 'top') {
|
|
17
|
+
return itemTop;
|
|
18
|
+
}
|
|
19
|
+
if (align === 'bottom') {
|
|
20
|
+
return clampValue(itemBottom - viewportHeight, 0, Infinity);
|
|
21
|
+
}
|
|
22
|
+
// 'nearest' alignment
|
|
23
|
+
const viewportBottom = scrollTop + viewportHeight;
|
|
24
|
+
const isVisible = itemTop < viewportBottom && itemBottom > scrollTop;
|
|
25
|
+
if (isVisible) {
|
|
26
|
+
// Already visible, no scroll needed
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
// Not visible - align to nearest edge
|
|
30
|
+
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
31
|
+
const distanceToBottom = Math.abs(viewportBottom - itemBottom);
|
|
32
|
+
return distanceToTop < distanceToBottom
|
|
33
|
+
? itemTop
|
|
34
|
+
: clampValue(itemBottom - viewportHeight, 0, Infinity);
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Calculates the scroll target for aligning a visible item to its nearest edge.
|
|
38
|
+
*
|
|
39
|
+
* Unlike alignToEdge with 'nearest', this always returns a scroll position
|
|
40
|
+
* even when the item is visible. Used for 'auto' alignment mode when item
|
|
41
|
+
* is within the visible range.
|
|
42
|
+
*
|
|
43
|
+
* @param {number} itemTop - The top position of the item in pixels
|
|
44
|
+
* @param {number} itemBottom - The bottom position of the item in pixels
|
|
45
|
+
* @param {number} scrollTop - Current scroll position in pixels
|
|
46
|
+
* @param {number} viewportHeight - Height of the viewport in pixels
|
|
47
|
+
* @returns {number} The scroll target position aligned to nearest edge
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```typescript
|
|
51
|
+
* // For a visible item, align to whichever edge is closer
|
|
52
|
+
* const scrollTarget = alignVisibleToNearestEdge(400, 450, 200, 400)
|
|
53
|
+
* viewportElement.scrollTo({ top: scrollTarget })
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export const alignVisibleToNearestEdge = (itemTop, itemBottom, scrollTop, viewportHeight) => {
|
|
57
|
+
const viewportBottom = scrollTop + viewportHeight;
|
|
58
|
+
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
59
|
+
const distanceToBottom = Math.abs(viewportBottom - itemBottom);
|
|
60
|
+
return distanceToTop < distanceToBottom
|
|
61
|
+
? itemTop
|
|
62
|
+
: clampValue(itemBottom - viewportHeight, 0, Infinity);
|
|
63
|
+
};
|
|
2
64
|
/**
|
|
3
65
|
* Calculates the target scroll position for scrolling to a specific item index.
|
|
4
66
|
*
|
|
@@ -74,42 +136,25 @@ const calculateBottomToTopScrollTarget = (params) => {
|
|
|
74
136
|
const totalHeight = getScrollOffsetForIndex(heightCache, calculatedItemHeight, itemsLength);
|
|
75
137
|
const itemOffset = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
|
|
76
138
|
const itemHeight = calculatedItemHeight;
|
|
139
|
+
// Calculate item boundaries in bottomToTop coordinate space
|
|
140
|
+
const itemTop = totalHeight - (itemOffset + itemHeight);
|
|
141
|
+
const itemBottom = totalHeight - itemOffset;
|
|
77
142
|
if (align === 'auto') {
|
|
78
143
|
// If item is above the viewport, align to top
|
|
79
144
|
if (targetIndex < firstVisibleIndex) {
|
|
80
|
-
return
|
|
145
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, 'top');
|
|
81
146
|
}
|
|
82
147
|
else if (targetIndex > lastVisibleIndex - 1) {
|
|
83
148
|
// In bottomToTop, "below" means higher indices that need HIGHER scrollTop
|
|
84
|
-
return
|
|
149
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, 'bottom');
|
|
85
150
|
}
|
|
86
151
|
else {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
90
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
|
|
91
|
-
return distanceToTop < distanceToBottom ? itemTop : Math.max(0, itemBottom - height);
|
|
152
|
+
// Item is visible - align to nearest edge (always returns a value)
|
|
153
|
+
return alignVisibleToNearestEdge(itemTop, itemBottom, scrollTop, height);
|
|
92
154
|
}
|
|
93
155
|
}
|
|
94
|
-
|
|
95
|
-
return
|
|
96
|
-
}
|
|
97
|
-
else if (align === 'bottom') {
|
|
98
|
-
return Math.max(0, totalHeight - itemOffset - height);
|
|
99
|
-
}
|
|
100
|
-
else if (align === 'nearest') {
|
|
101
|
-
const itemTop = totalHeight - (itemOffset + itemHeight);
|
|
102
|
-
const itemBottom = totalHeight - itemOffset;
|
|
103
|
-
if (itemBottom <= scrollTop || itemTop >= scrollTop + height) {
|
|
104
|
-
// Not visible, align to nearest edge
|
|
105
|
-
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
106
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
|
|
107
|
-
return distanceToTop < distanceToBottom ? itemTop : Math.max(0, itemBottom - height);
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
// Already visible, do nothing
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
156
|
+
if (align === 'top' || align === 'bottom' || align === 'nearest') {
|
|
157
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, align);
|
|
113
158
|
}
|
|
114
159
|
return null;
|
|
115
160
|
};
|
|
@@ -126,58 +171,25 @@ const calculateBottomToTopScrollTarget = (params) => {
|
|
|
126
171
|
*/
|
|
127
172
|
const calculateTopToBottomScrollTarget = (params) => {
|
|
128
173
|
const { align, targetIndex, calculatedItemHeight, height, scrollTop, firstVisibleIndex, lastVisibleIndex, heightCache } = params;
|
|
174
|
+
// Calculate item boundaries
|
|
175
|
+
const itemTop = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
|
|
176
|
+
const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
|
|
129
177
|
if (align === 'auto') {
|
|
130
178
|
// If item is above the viewport, align to top
|
|
131
179
|
if (targetIndex < firstVisibleIndex) {
|
|
132
|
-
|
|
133
|
-
return scrollTarget;
|
|
180
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, 'top');
|
|
134
181
|
}
|
|
135
182
|
// If item is below the viewport, align to bottom
|
|
136
183
|
else if (targetIndex > lastVisibleIndex - 1) {
|
|
137
|
-
|
|
138
|
-
const scrollTarget = Math.max(0, itemBottom - height);
|
|
139
|
-
return scrollTarget;
|
|
184
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, 'bottom');
|
|
140
185
|
}
|
|
141
186
|
else {
|
|
142
|
-
// Item is visible
|
|
143
|
-
|
|
144
|
-
const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
|
|
145
|
-
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
146
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
|
|
147
|
-
if (distanceToTop < distanceToBottom) {
|
|
148
|
-
return itemTop;
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
return Math.max(0, itemBottom - height);
|
|
152
|
-
}
|
|
187
|
+
// Item is visible - align to nearest edge (always returns a value)
|
|
188
|
+
return alignVisibleToNearestEdge(itemTop, itemBottom, scrollTop, height);
|
|
153
189
|
}
|
|
154
190
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return scrollTarget;
|
|
158
|
-
}
|
|
159
|
-
else if (align === 'bottom') {
|
|
160
|
-
const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
|
|
161
|
-
return Math.max(0, itemBottom - height);
|
|
162
|
-
}
|
|
163
|
-
else if (align === 'nearest') {
|
|
164
|
-
const itemTop = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
|
|
165
|
-
const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
|
|
166
|
-
if (itemBottom <= scrollTop || itemTop >= scrollTop + height) {
|
|
167
|
-
// Not visible, align to nearest edge
|
|
168
|
-
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
169
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
|
|
170
|
-
if (distanceToTop < distanceToBottom) {
|
|
171
|
-
return itemTop;
|
|
172
|
-
}
|
|
173
|
-
else {
|
|
174
|
-
return Math.max(0, itemBottom - height);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
else {
|
|
178
|
-
// Already visible, do nothing
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
191
|
+
if (align === 'top' || align === 'bottom' || align === 'nearest') {
|
|
192
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, align);
|
|
181
193
|
}
|
|
182
194
|
return null;
|
|
183
195
|
};
|
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
import type { SvelteVirtualListMode, SvelteVirtualListPreviousVisibleRange } from '../types.js';
|
|
2
2
|
import type { VirtualListSetters, VirtualListState } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Validates a height value and returns it if valid, otherwise returns the fallback.
|
|
5
|
+
*
|
|
6
|
+
* A height is considered valid if it is a finite number greater than 0.
|
|
7
|
+
* This utility consolidates the repeated pattern of height validation
|
|
8
|
+
* found throughout the virtual list codebase.
|
|
9
|
+
*
|
|
10
|
+
* @param {unknown} height - The height value to validate
|
|
11
|
+
* @param {number} fallback - The fallback value to use if height is invalid
|
|
12
|
+
* @returns {number} The validated height or the fallback value
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const height = getValidHeight(heightCache[i], calculatedItemHeight)
|
|
17
|
+
* // Returns heightCache[i] if valid, otherwise calculatedItemHeight
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare const getValidHeight: (height: unknown, fallback: number) => number;
|
|
21
|
+
/**
|
|
22
|
+
* Clamps a numeric value to be within a specified range.
|
|
23
|
+
*
|
|
24
|
+
* This utility consolidates the repeated `Math.max(min, Math.min(max, value))`
|
|
25
|
+
* pattern used throughout scroll calculations and positioning logic.
|
|
26
|
+
*
|
|
27
|
+
* @param {number} value - The value to clamp
|
|
28
|
+
* @param {number} min - The minimum allowed value
|
|
29
|
+
* @param {number} max - The maximum allowed value
|
|
30
|
+
* @returns {number} The clamped value
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* const scrollTop = clampValue(targetScrollTop, 0, maxScrollTop)
|
|
35
|
+
* // Ensures scrollTop is between 0 and maxScrollTop
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export declare const clampValue: (value: number, min: number, max: number) => number;
|
|
3
39
|
/**
|
|
4
40
|
* Calculates the maximum scroll position for a virtual list.
|
|
5
41
|
*
|
|
@@ -1,3 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates a height value and returns it if valid, otherwise returns the fallback.
|
|
3
|
+
*
|
|
4
|
+
* A height is considered valid if it is a finite number greater than 0.
|
|
5
|
+
* This utility consolidates the repeated pattern of height validation
|
|
6
|
+
* found throughout the virtual list codebase.
|
|
7
|
+
*
|
|
8
|
+
* @param {unknown} height - The height value to validate
|
|
9
|
+
* @param {number} fallback - The fallback value to use if height is invalid
|
|
10
|
+
* @returns {number} The validated height or the fallback value
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* const height = getValidHeight(heightCache[i], calculatedItemHeight)
|
|
15
|
+
* // Returns heightCache[i] if valid, otherwise calculatedItemHeight
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export const getValidHeight = (height, fallback) => Number.isFinite(height) && height > 0 ? height : fallback;
|
|
19
|
+
/**
|
|
20
|
+
* Clamps a numeric value to be within a specified range.
|
|
21
|
+
*
|
|
22
|
+
* This utility consolidates the repeated `Math.max(min, Math.min(max, value))`
|
|
23
|
+
* pattern used throughout scroll calculations and positioning logic.
|
|
24
|
+
*
|
|
25
|
+
* @param {number} value - The value to clamp
|
|
26
|
+
* @param {number} min - The minimum allowed value
|
|
27
|
+
* @param {number} max - The maximum allowed value
|
|
28
|
+
* @returns {number} The clamped value
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* const scrollTop = clampValue(targetScrollTop, 0, maxScrollTop)
|
|
33
|
+
* // Ensures scrollTop is between 0 and maxScrollTop
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export const clampValue = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
1
37
|
/**
|
|
2
38
|
* Calculates the maximum scroll position for a virtual list.
|
|
3
39
|
*
|
|
@@ -68,10 +104,7 @@ export const calculateVisibleRange = (scrollTop, viewportHeight, itemHeight, tot
|
|
|
68
104
|
const adjustedEnd = totalItems;
|
|
69
105
|
let startCore = adjustedEnd;
|
|
70
106
|
let acc = 0;
|
|
71
|
-
const getH = (i) =>
|
|
72
|
-
const v = heightCache ? heightCache[i] : undefined;
|
|
73
|
-
return Number.isFinite(v) && v > 0 ? v : itemHeight;
|
|
74
|
-
};
|
|
107
|
+
const getH = (i) => getValidHeight(heightCache ? heightCache[i] : undefined, itemHeight);
|
|
75
108
|
while (startCore > 0 && acc < viewportHeight) {
|
|
76
109
|
const h = getH(startCore - 1);
|
|
77
110
|
acc += h;
|
|
@@ -366,9 +399,7 @@ export const getScrollOffsetForIndex = (heightCache, calculatedItemHeight, idx,
|
|
|
366
399
|
// Fallback: O(n) for a single query
|
|
367
400
|
let offset = 0;
|
|
368
401
|
for (let i = 0; i < safeIdx; i++) {
|
|
369
|
-
|
|
370
|
-
const height = Number.isFinite(raw) && raw > 0 ? raw : calculatedItemHeight;
|
|
371
|
-
offset += height;
|
|
402
|
+
offset += getValidHeight(heightCache[i], calculatedItemHeight);
|
|
372
403
|
}
|
|
373
404
|
return offset;
|
|
374
405
|
}
|
|
@@ -381,9 +412,7 @@ export const getScrollOffsetForIndex = (heightCache, calculatedItemHeight, idx,
|
|
|
381
412
|
let offset = offsetBase;
|
|
382
413
|
const start = blockIdx * blockSize;
|
|
383
414
|
for (let i = start; i < safeIdx; i++) {
|
|
384
|
-
|
|
385
|
-
const height = Number.isFinite(raw) && raw > 0 ? raw : calculatedItemHeight;
|
|
386
|
-
offset += height;
|
|
415
|
+
offset += getValidHeight(heightCache[i], calculatedItemHeight);
|
|
387
416
|
}
|
|
388
417
|
return offset;
|
|
389
418
|
};
|
|
@@ -422,9 +451,7 @@ export const buildBlockSums = (heightCache, calculatedItemHeight, totalItems, bl
|
|
|
422
451
|
const start = b * blockSize;
|
|
423
452
|
const end = start + blockSize;
|
|
424
453
|
for (let i = start; i < end; i++) {
|
|
425
|
-
|
|
426
|
-
const h = Number.isFinite(raw) && raw > 0 ? raw : calculatedItemHeight;
|
|
427
|
-
running += h;
|
|
454
|
+
running += getValidHeight(heightCache[i], calculatedItemHeight);
|
|
428
455
|
}
|
|
429
456
|
sums[b] = running;
|
|
430
457
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanspeak/svelte-virtual-list",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
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",
|