@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 = Math.max(0, totalHeight() - (height || 0))
293
+ const maxScrollTop = clampValue(totalHeight() - (height || 0), 0, Infinity)
294
294
  let targetTop: number
295
295
  if (mode === 'bottomToTop') {
296
- const distanceFromStart = Math.max(0, offsetToIndex + lastAnchorOffset)
297
- targetTop = Math.max(
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.max(
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
- heightManager.viewport.scrollTop = targetTop
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
- heightManager.viewport.scrollTop = approximateScrollTop
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 = Math.min(
576
- maxScrollTop,
577
- Math.max(0, currentScrollTop + heightChangeAboveViewport)
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 = Math.round(
671
- Math.min(maxScrollTop, Math.max(0, currentScrollTop + deltaTotal))
680
+ const adjusted = clampValue(
681
+ currentScrollTop + deltaTotal,
682
+ 0,
683
+ maxScrollTop
672
684
  )
673
- heightManager.viewport.scrollTop = adjusted
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
- const roundedTargetScrollTop = Math.round(targetScrollTop)
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 unclamped = currentScrollTop + deltaMax
856
- const newScrollTop = Math.max(0, Math.min(nextMaxScrollTop, unclamped))
857
- heightManager.viewport.scrollTop = newScrollTop
858
- heightManager.scrollTop = newScrollTop
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 = Math.max(0, totalHeight() - height)
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 = Math.max(
891
+ const desiredScrollTop = clampValue(
892
+ newScrollTop + reconciledDeltaMaxChange,
878
893
  0,
879
- Math.min(reconciledNextMax, newScrollTop + reconciledDeltaMaxChange)
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 = Math.max(
886
- 0,
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, don't calculate visible range until properly initialized
967
- // This prevents showing wrong items when scrollTop starts at 0
968
- if (
969
- mode === 'bottomToTop' &&
970
- !heightManager.initialized &&
971
- heightManager.scrollTop === 0 &&
972
- viewportHeight > 0
973
- ) {
974
- // Calculate what the correct scroll position should be
975
- const targetScrollTop = Math.max(0, totalHeight() - viewportHeight)
976
-
977
- // Use the target scroll position for visible range calculation
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
- // Deterministic init order: double RAF + microtask, then apply bottom anchoring
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
- const targetScrollTop = calculateScrollPosition(
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
- log('b2t-init', { measuredHeight, targetScrollTop, jitterMs })
1119
+
1125
1120
  setTimeout(() => {
1126
- heightManager.viewport.scrollTop = targetScrollTop
1127
- heightManager.scrollTop = targetScrollTop
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
- // Guard: only transition false -> true to avoid invariant error
1130
- if (!heightManager.initialized) heightManager.initialized = true
1131
- // Post-init verification: ensure item 0 bottom aligns; fallback to native
1132
- tick().then(() => {
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
- if (!el) return
1137
- const cont = heightManager.viewport.getBoundingClientRect()
1138
- const r = el.getBoundingClientRect()
1139
- const tol = 4
1140
- const aligned =
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 = Math.max(0, Math.min(targetIndex, items.length - 1))
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 Math.max(0, totalHeight - (itemOffset + itemHeight));
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 Math.max(0, totalHeight - itemOffset - height);
149
+ return alignToEdge(itemTop, itemBottom, scrollTop, height, 'bottom');
85
150
  }
86
151
  else {
87
- const itemTop = totalHeight - (itemOffset + itemHeight);
88
- const itemBottom = totalHeight - itemOffset;
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
- else if (align === 'top') {
95
- return Math.max(0, totalHeight - (itemOffset + itemHeight));
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
- const scrollTarget = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
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
- const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
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 but not aligned: align to nearest edge
143
- const itemTop = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
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
- else if (align === 'top') {
156
- const scrollTarget = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
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
- const raw = heightCache[i];
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
- const raw = heightCache[i];
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
- const raw = heightCache[i];
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.8",
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",