@humanspeak/svelte-virtual-list 0.3.5 β†’ 0.3.8

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 CHANGED
@@ -29,6 +29,7 @@ A high-performance virtual list component for Svelte 5 applications that efficie
29
29
  - πŸ§ͺ Comprehensive test coverage (vitest and playwright)
30
30
  - πŸš€ Progressive initialization for large datasets
31
31
  - πŸ•ΉοΈ Programmatic scrolling with `scroll`
32
+ - ♾️ Infinite scroll support with `onLoadMore`
32
33
 
33
34
  ## scroll: Programmatic Scrolling
34
35
 
@@ -76,6 +77,53 @@ You can now programmatically scroll to any item in the list using the `scroll` m
76
77
  </button>
77
78
  ```
78
79
 
80
+ ## Infinite Scroll
81
+
82
+ Load more data automatically as users scroll near the end of the list. Perfect for paginated APIs, infinite feeds, and chat applications.
83
+
84
+ ```svelte
85
+ <script lang="ts">
86
+ import SvelteVirtualList from '@humanspeak/svelte-virtual-list'
87
+
88
+ let items = $state([...initialItems])
89
+ let hasMore = $state(true)
90
+
91
+ async function loadMore() {
92
+ const newItems = await fetchMoreItems()
93
+ items = [...items, ...newItems]
94
+ if (newItems.length === 0) {
95
+ hasMore = false
96
+ }
97
+ }
98
+ </script>
99
+
100
+ <SvelteVirtualList {items} onLoadMore={loadMore} loadMoreThreshold={20} {hasMore}>
101
+ {#snippet renderItem(item)}
102
+ <div>{item.text}</div>
103
+ {/snippet}
104
+ </SvelteVirtualList>
105
+ ```
106
+
107
+ ### Infinite Scroll Props
108
+
109
+ | Prop | Type | Default | Description |
110
+ | ------------------- | ----------------------------- | ------- | ---------------------------------------------------- |
111
+ | `onLoadMore` | `() => void \| Promise<void>` | - | Callback when more data is needed (supports async) |
112
+ | `loadMoreThreshold` | `number` | `20` | Number of items from the end to trigger `onLoadMore` |
113
+ | `hasMore` | `boolean` | `true` | Set to `false` when all data has been loaded |
114
+
115
+ ### Infinite Scroll Behavior
116
+
117
+ - Triggers when scrolling near the end of the list
118
+ - Automatically triggers on mount if initial items are below threshold
119
+ - Prevents concurrent `onLoadMore` calls while loading
120
+ - Works with both sync and async callbacks
121
+ - Supports both `topToBottom` and `bottomToTop` modes
122
+
123
+ ### Integration Guides
124
+
125
+ - [Infinite Scroll with Convex](documentation/CONVEX_INFINITE_SCROLL.md) - Real-time data + pagination with Convex backend
126
+
79
127
  ## Installation
80
128
 
81
129
  ```bash
@@ -174,6 +222,9 @@ Use `mode="bottomToTop"` for chat-like lists anchored to the bottom. Programmati
174
222
  | `contentClass` | `string` | `''` | Class for content wrapper |
175
223
  | `itemsClass` | `string` | `''` | Class for items container |
176
224
  | `testId` | `string` | `''` | Base test id used in internal test hooks (useful for E2E/tests and debugging) |
225
+ | `onLoadMore` | `() => void \| Promise<void>` | - | Callback when more data is needed for infinite scroll |
226
+ | `loadMoreThreshold` | `number` | `20` | Items from end to trigger `onLoadMore` |
227
+ | `hasMore` | `boolean` | `true` | Set to `false` when all data has been loaded |
177
228
 
178
229
  ## Testing
179
230
 
@@ -166,7 +166,9 @@
166
166
  calculateScrollPosition,
167
167
  calculateTransformY,
168
168
  calculateVisibleRange,
169
- updateHeightAndScroll as utilsUpdateHeightAndScroll
169
+ updateHeightAndScroll as utilsUpdateHeightAndScroll,
170
+ getScrollOffsetForIndex,
171
+ buildBlockSums
170
172
  } from './utils/virtualList.js'
171
173
  import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
172
174
  import { calculateScrollTarget } from './utils/scrollCalculation.js'
@@ -184,8 +186,22 @@
184
186
  // Avoid SvelteKit-only $env imports so library works in non-Kit/Vitest contexts
185
187
  const INTERNAL_DEBUG = Boolean(
186
188
  typeof process !== 'undefined' &&
187
- (process?.env?.PUBLIC_SVELTE_VIRTUAL_LIST_DEBUG === 'true' ||
188
- process?.env?.SVELTE_VIRTUAL_LIST_DEBUG === 'true')
189
+ (process?.env?.PUBLIC_SVELTE_VIRTUAL_LIST_DEBUG === 'true' ||
190
+ process?.env?.SVELTE_VIRTUAL_LIST_DEBUG === 'true')
191
+ )
192
+ // Feature flags - default off; enable via env for incremental rollout
193
+ const anchorModeEnabled = Boolean(
194
+ typeof process !== 'undefined' &&
195
+ (process?.env?.PUBLIC_SVL_ANCHOR_MODE === 'true' ||
196
+ process?.env?.SVL_ANCHOR_MODE === 'true')
197
+ )
198
+ const idleCorrectionsOnly = Boolean(
199
+ typeof process !== 'undefined' &&
200
+ (process?.env?.PUBLIC_SVL_IDLE_ONLY === 'true' || process?.env?.SVL_IDLE_ONLY === 'true')
201
+ )
202
+ const batchUpdatesEnabled = Boolean(
203
+ typeof process !== 'undefined' &&
204
+ (process?.env?.PUBLIC_SVL_BATCH === 'true' || process?.env?.SVL_BATCH === 'true')
189
205
  )
190
206
  /**
191
207
  * Core configuration props with default values
@@ -203,7 +219,10 @@
203
219
  debugFunction, // Custom debug logging function
204
220
  mode = 'topToBottom', // Scroll direction mode
205
221
  bufferSize = 20, // Number of items to render outside visible area
206
- testId // Base test ID for component elements (undefined = no data-testid attributes)
222
+ testId, // Base test ID for component elements (undefined = no data-testid attributes)
223
+ onLoadMore, // Callback when more data needed (supports sync and async)
224
+ loadMoreThreshold = 20, // Items from end to trigger load
225
+ hasMore = true // Set false when all data loaded
207
226
  }: SvelteVirtualListProps<TItem> = $props()
208
227
 
209
228
  /**
@@ -221,7 +240,108 @@
221
240
  */
222
241
 
223
242
  const isCalculatingHeight = $state(false) // Prevents concurrent height calculations
243
+ let isLoadingMore = $state(false) // Prevents concurrent onLoadMore calls
224
244
  let isScrolling = $state(false) // Tracks active scrolling state
245
+ let scrollIdleTimer: number | null = null
246
+ // Anchor state (read-only capture; used when anchorModeEnabled)
247
+ let lastAnchorIndex = $state(0)
248
+ let lastAnchorOffset = $state(0) // offset within anchored item (px)
249
+ let pendingAnchorReconcile = $state(false)
250
+ let batchDepth = $state(0)
251
+
252
+ const captureAnchor = () => {
253
+ if (!heightManager.viewportElement) return
254
+ const vr = visibleItems()
255
+ const anchorIndex = Math.max(0, vr.start)
256
+ const cache = heightManager.getHeightCache()
257
+ const est = heightManager.averageHeight
258
+ const maxScrollTop = Math.max(0, totalHeight() - (height || 0))
259
+ // Offset from start to anchored item
260
+ const blockSums = buildBlockSums(cache, est, items.length)
261
+ const offsetToIndex = getScrollOffsetForIndex(cache, est, anchorIndex, blockSums)
262
+ const currentTop = heightManager.viewport.scrollTop
263
+ let offsetWithin = 0
264
+ if (mode === 'bottomToTop') {
265
+ // Convert distance-from-end to distance-from-start
266
+ const distanceFromStart = maxScrollTop - currentTop
267
+ offsetWithin = distanceFromStart - offsetToIndex
268
+ } else {
269
+ offsetWithin = currentTop - offsetToIndex
270
+ }
271
+ lastAnchorIndex = anchorIndex
272
+ lastAnchorOffset = Math.max(0, Math.round(offsetWithin))
273
+ // Expose for tests
274
+ ;(heightManager.viewport as unknown as Record<string, unknown>).__svlAnchor = {
275
+ index: lastAnchorIndex,
276
+ offset: lastAnchorOffset
277
+ }
278
+ pendingAnchorReconcile = true
279
+ }
280
+
281
+ const reconcileToAnchorIfEnabled = () => {
282
+ if (!anchorModeEnabled || !heightManager.viewportElement) return
283
+ if (!pendingAnchorReconcile) return
284
+ const cache = heightManager.getHeightCache()
285
+ const est = heightManager.averageHeight
286
+ const blockSums = buildBlockSums(cache, est, items.length)
287
+ const offsetToIndex = getScrollOffsetForIndex(
288
+ cache,
289
+ est,
290
+ Math.max(0, lastAnchorIndex),
291
+ blockSums
292
+ )
293
+ const maxScrollTop = Math.max(0, totalHeight() - (height || 0))
294
+ let targetTop: number
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
+ )
301
+ } else {
302
+ targetTop = Math.max(
303
+ 0,
304
+ Math.min(maxScrollTop, Math.round(offsetToIndex + lastAnchorOffset))
305
+ )
306
+ }
307
+ if (Math.abs(heightManager.viewport.scrollTop - targetTop) >= 2) {
308
+ heightManager.viewport.scrollTop = targetTop
309
+ heightManager.scrollTop = targetTop
310
+ }
311
+ pendingAnchorReconcile = false
312
+ }
313
+
314
+ /**
315
+ * Runs a batch of updates with scroll corrections coalesced until the batch completes.
316
+ *
317
+ * Use this method when making multiple changes to the items array to prevent
318
+ * intermediate scroll corrections. The scroll position reconciliation is deferred
319
+ * until the batch exits, ensuring smooth visual updates.
320
+ *
321
+ * @param {() => void} fn - The function containing batch updates to execute.
322
+ * @returns {void}
323
+ *
324
+ * @example
325
+ * ```typescript
326
+ * // Add multiple items without intermediate scroll corrections
327
+ * list.runInBatch(() => {
328
+ * items.push(newItem1);
329
+ * items.push(newItem2);
330
+ * items.push(newItem3);
331
+ * });
332
+ * ```
333
+ */
334
+ export const runInBatch = (fn: () => void): void => {
335
+ batchDepth += 1
336
+ try {
337
+ fn()
338
+ } finally {
339
+ batchDepth = Math.max(0, batchDepth - 1)
340
+ if (batchUpdatesEnabled && batchDepth === 0) {
341
+ reconcileToAnchorIfEnabled()
342
+ }
343
+ }
344
+ }
225
345
  let lastMeasuredIndex = $state(-1) // Index of last measured item
226
346
  let lastScrollTopSnapshot = $state(0) // Previous scroll position snapshot
227
347
 
@@ -306,6 +426,25 @@
306
426
  if (!heightManager.viewportElement || !heightManager.initialized || userHasScrolledAway) {
307
427
  return
308
428
  }
429
+ // Coalesce adjustments during active scroll; apply on idle
430
+ if (isScrolling) {
431
+ // Accumulate net change above viewport and defer application
432
+ let pending = 0
433
+ const currentVisibleRange = visibleItems()
434
+ for (const change of heightChanges) {
435
+ if (change.index < currentVisibleRange.start) pending += change.delta
436
+ }
437
+ if (pending !== 0) {
438
+ // Store on the viewport element to avoid extra module globals
439
+ const key = '__svl_pendingHeightAdj__' as unknown as keyof HTMLElement
440
+ const prev = (heightManager.viewport as unknown as Record<string, number>)[
441
+ key as string
442
+ ] as number | undefined
443
+ ;(heightManager.viewport as unknown as Record<string, number>)[key as string] =
444
+ (prev ?? 0) + pending
445
+ }
446
+ return
447
+ }
309
448
 
310
449
  /**
311
450
  * CRITICAL: BottomToTop Mode Height Change Fix
@@ -368,7 +507,7 @@
368
507
 
369
508
  // Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
370
509
  const approximateScrollTop = Math.max(0, totalHeight() - height)
371
- log('b2t-correction-approx', { approximateScrollTop })
510
+ log('[SVL] b2t-correction-approx', { approximateScrollTop })
372
511
  heightManager.viewport.scrollTop = approximateScrollTop
373
512
  heightManager.scrollTop = approximateScrollTop
374
513
 
@@ -392,7 +531,7 @@
392
531
  behavior: 'smooth', // Smooth animation for better UX
393
532
  inline: 'nearest' // Minimal horizontal adjustment
394
533
  })
395
- log('b2t-correction-native', {
534
+ log('[SVL] b2t-correction-native', {
396
535
  containerBottom: contRect.y + contRect.height,
397
536
  itemBottom: itemRect.y + itemRect.height
398
537
  })
@@ -422,7 +561,17 @@
422
561
  }
423
562
 
424
563
  // If there are height changes above the viewport, adjust scroll to maintain position
425
- if (Math.abs(heightChangeAboveViewport) > 1) {
564
+ // Include any pending coalesced delta (when scrolling)
565
+ {
566
+ const key = '__svl_pendingHeightAdj__' as unknown as keyof HTMLElement
567
+ const pending =
568
+ (heightManager.viewport as unknown as Record<string, number>)[key as string] ?? 0
569
+ if (pending) {
570
+ heightChangeAboveViewport += pending
571
+ ;(heightManager.viewport as unknown as Record<string, number>)[key as string] = 0
572
+ }
573
+ }
574
+ if (Math.abs(heightChangeAboveViewport) > 2) {
426
575
  const newScrollTop = Math.min(
427
576
  maxScrollTop,
428
577
  Math.max(0, currentScrollTop + heightChangeAboveViewport)
@@ -461,6 +610,22 @@
461
610
  heightManager.updateItemLength(items.length)
462
611
  })
463
612
 
613
+ // Infinite scroll: trigger onLoadMore when approaching end of list
614
+ $effect(() => {
615
+ if (!BROWSER || !onLoadMore || !hasMore || isLoadingMore) return
616
+
617
+ const range = visibleItems()
618
+ const atLoadingEdge = range.end >= items.length - loadMoreThreshold
619
+ const insufficientItems = items.length < loadMoreThreshold && heightManager.initialized
620
+
621
+ if (atLoadingEdge || insufficientItems) {
622
+ isLoadingMore = true
623
+ Promise.resolve(onLoadMore()).finally(() => {
624
+ isLoadingMore = false
625
+ })
626
+ }
627
+ })
628
+
464
629
  const updateHeight = () => {
465
630
  // Capture previous total height for scroll correction (topToBottom anchoring)
466
631
  prevTotalHeightForScrollCorrection = heightManager.totalHeight
@@ -502,9 +667,8 @@
502
667
  const isAtBottom = Math.abs(currentScrollTop - maxScrollTop) <= tolerance
503
668
  if (isAtBottom) {
504
669
  // Adjust scrollTop by total height delta to hold bottom anchor
505
- const adjusted = Math.min(
506
- maxScrollTop,
507
- Math.max(0, currentScrollTop + deltaTotal)
670
+ const adjusted = Math.round(
671
+ Math.min(maxScrollTop, Math.max(0, currentScrollTop + deltaTotal))
508
672
  )
509
673
  heightManager.viewport.scrollTop = adjusted
510
674
  heightManager.scrollTop = adjusted
@@ -537,6 +701,8 @@
537
701
  let programmaticScrollInProgress = $state(false) // Prevent bottom-anchoring during programmatic scrolls
538
702
  let lastCalculatedHeight = $state(0)
539
703
  let lastItemsLength = $state(0)
704
+ // Track last observed total height to compute precise deltas on item count changes
705
+ let lastTotalHeightObserved = $state(0)
540
706
 
541
707
  /**
542
708
  * CRITICAL: O(1) Reactive Total Height Calculation
@@ -620,6 +786,7 @@
620
786
  heightChanged &&
621
787
  !userHasScrolledAway &&
622
788
  !isAtBottom && // Don't apply aggressive correction when at bottom
789
+ !isScrolling && // Skip aggressive corrections during active scroll
623
790
  !programmaticScrollInProgress && // Don't interfere with programmatic scrolls
624
791
  performance.now() >= suppressBottomAnchoringUntilMs &&
625
792
  !heightManager.isDynamicUpdateInProgress &&
@@ -661,34 +828,96 @@
661
828
  const currentCalculatedItemHeight = heightManager.averageHeight
662
829
  const currentHeight = height
663
830
  const currentTotalHeight = totalHeight()
664
- const maxScrollTop = Math.max(0, currentTotalHeight - currentHeight)
831
+ const prevTotalHeight =
832
+ lastTotalHeightObserved ||
833
+ currentTotalHeight - itemsAdded * currentCalculatedItemHeight
834
+ const prevMaxScrollTop = Math.max(0, prevTotalHeight - currentHeight)
835
+ const nextMaxScrollTop = Math.max(0, currentTotalHeight - currentHeight)
836
+ const deltaMax = nextMaxScrollTop - prevMaxScrollTop
837
+ log('[SVL] items-length-change:before', {
838
+ instanceId,
839
+ itemsAdded,
840
+ lastItemsLength,
841
+ currentItemsLength,
842
+ currentScrollTop,
843
+ prevTotalHeight,
844
+ currentTotalHeight,
845
+ prevMaxScrollTop,
846
+ nextMaxScrollTop,
847
+ deltaMax,
848
+ averageItemHeight: currentCalculatedItemHeight
849
+ })
850
+
851
+ // Maintain visual position for ALL cases by advancing scrollTop by deltaMax.
852
+ // If near the bottom, this naturally pins to the new max; otherwise it preserves the current content.
853
+ programmaticScrollInProgress = true
854
+ 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
859
+ log('[SVL] items-length-change:applied', {
860
+ instanceId,
861
+ previousScrollTop: currentScrollTop,
862
+ appliedScrollTop: newScrollTop,
863
+ prevMaxScrollTop,
864
+ nextMaxScrollTop,
865
+ deltaMax
866
+ })
665
867
 
666
- // Check if user was at/near the bottom before items were added
667
- const wasNearBottom =
668
- Math.abs(
669
- currentScrollTop -
670
- Math.max(
868
+ // We are explicitly managing position; consider this a programmatic action.
869
+ // Do not flip userHasScrolledAway here; it should reflect user intent only.
870
+
871
+ // Reconcile on next frame in case measured heights adjust totals
872
+ requestAnimationFrame(() => {
873
+ const beforeReconcileScrollTop = heightManager.viewport.scrollTop
874
+ const reconciledNextMax = Math.max(0, totalHeight() - height)
875
+ const reconciledDeltaMaxChange = reconciledNextMax - nextMaxScrollTop
876
+ // Desired position is to maintain distance-from-end; equivalently keep (max - scrollTop) constant.
877
+ const desiredScrollTop = Math.max(
878
+ 0,
879
+ Math.min(reconciledNextMax, newScrollTop + reconciledDeltaMaxChange)
880
+ )
881
+ // Snap to integer pixels to prevent oscillation due to subpixel rounding
882
+ const desiredRounded = Math.round(desiredScrollTop)
883
+ const diffToDesired = desiredRounded - heightManager.viewport.scrollTop
884
+ if (Math.abs(diffToDesired) >= 2) {
885
+ const adjusted = Math.max(
671
886
  0,
672
- lastItemsLength * currentCalculatedItemHeight - currentHeight
887
+ Math.min(reconciledNextMax, desiredRounded)
673
888
  )
674
- ) <
675
- currentCalculatedItemHeight * 2
676
-
677
- if (wasNearBottom || currentScrollTop === 0) {
678
- // User was at bottom, keep them at bottom after new items are added
679
- void heightManager.runDynamicUpdate(() => {
680
- const newScrollTop = maxScrollTop
681
- heightManager.viewport.scrollTop = newScrollTop
682
- heightManager.scrollTop = newScrollTop
683
-
684
- // Reset the "scrolled away" flag since we're actively managing position
685
- userHasScrolledAway = false
889
+ heightManager.viewport.scrollTop = adjusted
890
+ heightManager.scrollTop = adjusted
891
+ log('[SVL] items-length-change:reconciled', {
892
+ instanceId,
893
+ beforeReconcileScrollTop,
894
+ adjustedScrollTop: adjusted,
895
+ reconciledNextMax,
896
+ reconciledDeltaMaxChange,
897
+ desiredScrollTop,
898
+ desiredRounded,
899
+ diffToDesired
900
+ })
901
+ } else {
902
+ log('[SVL] items-length-change:reconciled-skip', {
903
+ instanceId,
904
+ beforeReconcileScrollTop,
905
+ reconciledNextMax,
906
+ reconciledDeltaMaxChange,
907
+ desiredScrollTop,
908
+ desiredRounded,
909
+ diffToDesired
910
+ })
911
+ }
912
+ programmaticScrollInProgress = false
686
913
  })
687
- }
914
+ })
688
915
  }
689
916
  }
690
917
 
691
918
  lastItemsLength = currentItemsLength
919
+ // Update last observed total height at the end of the effect
920
+ lastTotalHeightObserved = totalHeight()
692
921
  })
693
922
 
694
923
  // Update container height continuously to reflect layout changes that
@@ -804,35 +1033,49 @@
804
1033
  const handleScroll = () => {
805
1034
  if (!BROWSER || !heightManager.viewportElement) return
806
1035
 
807
- if (!isScrolling) {
808
- isScrolling = true
809
- rafSchedule(() => {
810
- const current = heightManager.viewport.scrollTop
811
- if (mode === 'bottomToTop') {
812
- const delta = lastScrollTopSnapshot - current
813
- if (delta > 0.5) {
814
- // Widen suppression to avoid fighting peer instance corrections
815
- suppressBottomAnchoringUntilMs = performance.now() + 450
816
- userHasScrolledAway = true
817
- }
818
- }
819
- lastScrollTopSnapshot = current
820
- heightManager.scrollTop = current
821
- updateDebugTailDistance()
822
- if (INTERNAL_DEBUG) {
823
- const vr = visibleItems()
824
- log('scroll', {
825
- mode,
826
- scrollTop: heightManager.scrollTop,
827
- height,
828
- totalHeight: totalHeight(),
829
- averageItemHeight: heightManager.averageHeight,
830
- visibleRange: vr
831
- })
832
- }
833
- isScrolling = false
834
- })
1036
+ // Mark active scrolling and debounce idle transition (~120ms)
1037
+ isScrolling = true
1038
+ if (scrollIdleTimer) {
1039
+ clearTimeout(scrollIdleTimer)
1040
+ scrollIdleTimer = null
835
1041
  }
1042
+ scrollIdleTimer = window.setTimeout(() => {
1043
+ isScrolling = false
1044
+ // Apply deferred anchor correction on idle
1045
+ if (idleCorrectionsOnly || anchorModeEnabled) {
1046
+ reconcileToAnchorIfEnabled()
1047
+ }
1048
+ }, 250)
1049
+
1050
+ rafSchedule(() => {
1051
+ const current = heightManager.viewport.scrollTop
1052
+ if (mode === 'bottomToTop') {
1053
+ const delta = lastScrollTopSnapshot - current
1054
+ if (delta > 0.5) {
1055
+ // Widen suppression to avoid fighting peer instance corrections
1056
+ suppressBottomAnchoringUntilMs = performance.now() + 450
1057
+ userHasScrolledAway = true
1058
+ }
1059
+ }
1060
+ lastScrollTopSnapshot = current
1061
+ heightManager.scrollTop = current
1062
+ updateDebugTailDistance()
1063
+ if (anchorModeEnabled) {
1064
+ captureAnchor()
1065
+ }
1066
+ if (INTERNAL_DEBUG) {
1067
+ const vr = visibleItems()
1068
+ log('[SVL] scroll', {
1069
+ mode,
1070
+ scrollTop: heightManager.scrollTop,
1071
+ height,
1072
+ totalHeight: totalHeight(),
1073
+ averageItemHeight: heightManager.averageHeight,
1074
+ visibleRange: vr
1075
+ })
1076
+ }
1077
+ // isScrolling cleared by idle timer
1078
+ })
836
1079
  }
837
1080
 
838
1081
  /**
@@ -92,6 +92,26 @@ import { type SvelteVirtualListProps, type SvelteVirtualListScrollOptions } from
92
92
  declare function $$render<TItem = unknown>(): {
93
93
  props: SvelteVirtualListProps<TItem>;
94
94
  exports: {
95
+ /**
96
+ * Runs a batch of updates with scroll corrections coalesced until the batch completes.
97
+ *
98
+ * Use this method when making multiple changes to the items array to prevent
99
+ * intermediate scroll corrections. The scroll position reconciliation is deferred
100
+ * until the batch exits, ensuring smooth visual updates.
101
+ *
102
+ * @param {() => void} fn - The function containing batch updates to execute.
103
+ * @returns {void}
104
+ *
105
+ * @example
106
+ * ```typescript
107
+ * // Add multiple items without intermediate scroll corrections
108
+ * list.runInBatch(() => {
109
+ * items.push(newItem1);
110
+ * items.push(newItem2);
111
+ * items.push(newItem3);
112
+ * });
113
+ * ```
114
+ */ runInBatch: (fn: () => void) => void;
95
115
  /**
96
116
  * Scrolls the virtual list to the item at the given index.
97
117
  *
@@ -159,6 +179,26 @@ declare class __sveltets_Render<TItem = unknown> {
159
179
  slots(): ReturnType<typeof $$render<TItem>>['slots'];
160
180
  bindings(): "";
161
181
  exports(): {
182
+ /**
183
+ * Runs a batch of updates with scroll corrections coalesced until the batch completes.
184
+ *
185
+ * Use this method when making multiple changes to the items array to prevent
186
+ * intermediate scroll corrections. The scroll position reconciliation is deferred
187
+ * until the batch exits, ensuring smooth visual updates.
188
+ *
189
+ * @param {() => void} fn - The function containing batch updates to execute.
190
+ * @returns {void}
191
+ *
192
+ * @example
193
+ * ```typescript
194
+ * // Add multiple items without intermediate scroll corrections
195
+ * list.runInBatch(() => {
196
+ * items.push(newItem1);
197
+ * items.push(newItem2);
198
+ * items.push(newItem3);
199
+ * });
200
+ * ```
201
+ */ runInBatch: (fn: () => void) => void;
162
202
  /**
163
203
  * Scrolls the virtual list to the item at the given index.
164
204
  *
@@ -1,13 +1,91 @@
1
+ /**
2
+ * Scheduler that coalesces recompute requests to the next animation frame.
3
+ *
4
+ * This class provides efficient batching of recompute operations by scheduling
5
+ * them to run on the next animation frame in browser environments. In non-browser
6
+ * or jsdom environments, it falls back to setTimeout(0) for deterministic testing.
7
+ *
8
+ * Key features:
9
+ * - Coalesces multiple schedule() calls into a single recompute
10
+ * - Supports temporary blocking during critical sections
11
+ * - Handles nested block/unblock calls with depth tracking
12
+ * - Environment-aware: uses RAF in browsers, setTimeout in tests
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const scheduler = new RecomputeScheduler(() => {
17
+ * console.log('Recomputing derived state');
18
+ * });
19
+ *
20
+ * // Multiple calls within the same frame are coalesced
21
+ * scheduler.schedule();
22
+ * scheduler.schedule();
23
+ * scheduler.schedule(); // Only one recompute will run
24
+ *
25
+ * // Block during critical sections
26
+ * scheduler.block();
27
+ * scheduler.schedule(); // Marked as pending, won't run yet
28
+ * scheduler.unblock(); // Runs immediately if pending
29
+ * ```
30
+ *
31
+ * @class
32
+ */
1
33
  export declare class RecomputeScheduler {
34
+ /** Callback function to execute on recompute. */
2
35
  private onRecompute;
36
+ /** Whether a recompute is currently scheduled. */
3
37
  private isScheduled;
38
+ /** Whether a recompute is pending due to blocking. */
4
39
  private isPending;
40
+ /** Current nesting depth of block() calls. */
5
41
  private blockDepth;
42
+ /** ID of the pending setTimeout (non-browser fallback). */
6
43
  private timeoutId;
44
+ /** ID of the pending requestAnimationFrame. */
7
45
  private rafId;
46
+ /**
47
+ * Creates a new RecomputeScheduler instance.
48
+ *
49
+ * @param {() => void} onRecompute - Callback function to execute when recompute runs.
50
+ */
8
51
  constructor(onRecompute: () => void);
52
+ /**
53
+ * Schedules a recompute for the next animation frame.
54
+ *
55
+ * If the scheduler is blocked, the request is marked as pending and will
56
+ * execute when unblocked. Multiple calls while a recompute is already
57
+ * scheduled are coalesced into a single execution.
58
+ *
59
+ * @returns {void}
60
+ */
9
61
  schedule: () => void;
62
+ /**
63
+ * Temporarily blocks recompute execution.
64
+ *
65
+ * Cancels any in-flight timers and marks any pending recompute request.
66
+ * Block calls can be nested; the scheduler remains blocked until all
67
+ * corresponding unblock() calls are made.
68
+ *
69
+ * @returns {void}
70
+ */
10
71
  block: () => void;
72
+ /**
73
+ * Unblocks the scheduler and runs pending recompute if any.
74
+ *
75
+ * Decrements the block depth counter. When the depth reaches zero and
76
+ * a recompute was pending, it executes immediately (synchronously).
77
+ * Guards against underflow if unblock is called without matching block.
78
+ *
79
+ * @returns {void}
80
+ */
11
81
  unblock: () => void;
82
+ /**
83
+ * Cancels any scheduled or pending recompute.
84
+ *
85
+ * Clears all timers (setTimeout and RAF) and resets the scheduled
86
+ * and pending flags. Does not affect the block depth.
87
+ *
88
+ * @returns {void}
89
+ */
12
90
  cancel: () => void;
13
91
  }
@@ -1,19 +1,65 @@
1
- // RecomputeScheduler
2
- // -------------------
3
- // Coalesces recompute requests to the next animation frame in the browser.
4
- // Falls back to setTimeout(0) in non-browser/jsdom to preserve deterministic tests.
5
- // Supports temporary blocking to delay recomputation during critical sections.
1
+ /**
2
+ * Scheduler that coalesces recompute requests to the next animation frame.
3
+ *
4
+ * This class provides efficient batching of recompute operations by scheduling
5
+ * them to run on the next animation frame in browser environments. In non-browser
6
+ * or jsdom environments, it falls back to setTimeout(0) for deterministic testing.
7
+ *
8
+ * Key features:
9
+ * - Coalesces multiple schedule() calls into a single recompute
10
+ * - Supports temporary blocking during critical sections
11
+ * - Handles nested block/unblock calls with depth tracking
12
+ * - Environment-aware: uses RAF in browsers, setTimeout in tests
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const scheduler = new RecomputeScheduler(() => {
17
+ * console.log('Recomputing derived state');
18
+ * });
19
+ *
20
+ * // Multiple calls within the same frame are coalesced
21
+ * scheduler.schedule();
22
+ * scheduler.schedule();
23
+ * scheduler.schedule(); // Only one recompute will run
24
+ *
25
+ * // Block during critical sections
26
+ * scheduler.block();
27
+ * scheduler.schedule(); // Marked as pending, won't run yet
28
+ * scheduler.unblock(); // Runs immediately if pending
29
+ * ```
30
+ *
31
+ * @class
32
+ */
6
33
  export class RecomputeScheduler {
34
+ /** Callback function to execute on recompute. */
7
35
  onRecompute;
36
+ /** Whether a recompute is currently scheduled. */
8
37
  isScheduled = false;
38
+ /** Whether a recompute is pending due to blocking. */
9
39
  isPending = false;
40
+ /** Current nesting depth of block() calls. */
10
41
  blockDepth = 0;
42
+ /** ID of the pending setTimeout (non-browser fallback). */
11
43
  timeoutId = null;
44
+ /** ID of the pending requestAnimationFrame. */
12
45
  rafId = null;
46
+ /**
47
+ * Creates a new RecomputeScheduler instance.
48
+ *
49
+ * @param {() => void} onRecompute - Callback function to execute when recompute runs.
50
+ */
13
51
  constructor(onRecompute) {
14
52
  this.onRecompute = onRecompute;
15
53
  }
16
- // Request a recompute. If blocked, mark as pending; otherwise schedule for next frame.
54
+ /**
55
+ * Schedules a recompute for the next animation frame.
56
+ *
57
+ * If the scheduler is blocked, the request is marked as pending and will
58
+ * execute when unblocked. Multiple calls while a recompute is already
59
+ * scheduled are coalesced into a single execution.
60
+ *
61
+ * @returns {void}
62
+ */
17
63
  schedule = () => {
18
64
  if (this.blockDepth > 0) {
19
65
  this.isPending = true;
@@ -45,7 +91,15 @@ export class RecomputeScheduler {
45
91
  this.onRecompute();
46
92
  });
47
93
  };
48
- // Temporarily block recomputes; any in-flight timers are canceled and a recompute is marked pending.
94
+ /**
95
+ * Temporarily blocks recompute execution.
96
+ *
97
+ * Cancels any in-flight timers and marks any pending recompute request.
98
+ * Block calls can be nested; the scheduler remains blocked until all
99
+ * corresponding unblock() calls are made.
100
+ *
101
+ * @returns {void}
102
+ */
49
103
  block = () => {
50
104
  this.blockDepth += 1;
51
105
  if (this.timeoutId) {
@@ -61,7 +115,15 @@ export class RecomputeScheduler {
61
115
  this.isPending = true;
62
116
  }
63
117
  };
64
- // Unblock and run recompute immediately if one was pending.
118
+ /**
119
+ * Unblocks the scheduler and runs pending recompute if any.
120
+ *
121
+ * Decrements the block depth counter. When the depth reaches zero and
122
+ * a recompute was pending, it executes immediately (synchronously).
123
+ * Guards against underflow if unblock is called without matching block.
124
+ *
125
+ * @returns {void}
126
+ */
65
127
  unblock = () => {
66
128
  if (this.blockDepth === 0)
67
129
  return;
@@ -71,7 +133,14 @@ export class RecomputeScheduler {
71
133
  this.onRecompute();
72
134
  }
73
135
  };
74
- // Cancel any scheduled recompute and clear pending state.
136
+ /**
137
+ * Cancels any scheduled or pending recompute.
138
+ *
139
+ * Clears all timers (setTimeout and RAF) and resets the scheduled
140
+ * and pending flags. Does not affect the block depth.
141
+ *
142
+ * @returns {void}
143
+ */
75
144
  cancel = () => {
76
145
  if (this.timeoutId) {
77
146
  clearTimeout(this.timeoutId);
package/dist/types.d.ts CHANGED
@@ -63,6 +63,21 @@ export type SvelteVirtualListProps<TItem = any> = {
63
63
  * CSS class to apply to the scrollable viewport element.
64
64
  */
65
65
  viewportClass?: string;
66
+ /**
67
+ * Callback when more data is needed. Supports sync and async functions.
68
+ * Called when the user scrolls near the end of the list (based on loadMoreThreshold).
69
+ */
70
+ onLoadMore?: () => void | Promise<void>;
71
+ /**
72
+ * Number of items from the end to trigger onLoadMore.
73
+ * @default 20
74
+ */
75
+ loadMoreThreshold?: number;
76
+ /**
77
+ * Set to false when all data has been loaded to stop triggering onLoadMore.
78
+ * @default true
79
+ */
80
+ hasMore?: boolean;
66
81
  };
67
82
  /**
68
83
  * Debug information provided by the virtual list during rendering.
@@ -1,12 +1,37 @@
1
1
  /**
2
- * Utility functions for detecting significant height changes in virtual list items
2
+ * Utility functions for detecting significant height changes in virtual list items.
3
+ *
4
+ * @fileoverview Provides height change detection utilities for virtual list optimization.
5
+ * These functions help determine when item height changes are significant enough to
6
+ * trigger recalculations, preventing unnecessary updates for sub-pixel variations.
3
7
  */
4
8
  /**
5
- * Checks if a height change is significant enough to warrant marking an item as dirty
6
- * @param itemIndex - The index of the item
7
- * @param newHeight - The new measured height
8
- * @param heightCache - Existing height cache to compare against
9
- * @param marginOfError - Height difference threshold (default: 1px)
10
- * @returns true if the height change is significant
9
+ * Checks if a height change is significant enough to warrant marking an item as dirty.
10
+ *
11
+ * This function compares the new measured height against the cached height for an item
12
+ * and determines if the difference exceeds the specified margin of error. Items with
13
+ * no previous measurement are always considered significant.
14
+ *
15
+ * @param {number} itemIndex - The index of the item in the virtual list.
16
+ * @param {number} newHeight - The new measured height in pixels.
17
+ * @param {Record<number, number>} heightCache - Cache of previously measured item heights.
18
+ * @param {number} [marginOfError=1] - Height difference threshold in pixels. Changes
19
+ * smaller than this value are considered insignificant.
20
+ * @returns {boolean} Returns true if the height change exceeds the margin of error
21
+ * or if this is the first measurement for the item.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * const heightCache = { 0: 40, 1: 50 };
26
+ *
27
+ * // First-time measurement (no cache entry)
28
+ * isSignificantHeightChange(2, 45, heightCache); // true
29
+ *
30
+ * // Significant change (exceeds 1px threshold)
31
+ * isSignificantHeightChange(0, 45, heightCache); // true
32
+ *
33
+ * // Insignificant change (within 1px threshold)
34
+ * isSignificantHeightChange(0, 40.5, heightCache); // false
35
+ * ```
11
36
  */
12
37
  export declare const isSignificantHeightChange: (itemIndex: number, newHeight: number, heightCache: Record<number, number>, marginOfError?: number) => boolean;
@@ -1,13 +1,38 @@
1
1
  /**
2
- * Utility functions for detecting significant height changes in virtual list items
2
+ * Utility functions for detecting significant height changes in virtual list items.
3
+ *
4
+ * @fileoverview Provides height change detection utilities for virtual list optimization.
5
+ * These functions help determine when item height changes are significant enough to
6
+ * trigger recalculations, preventing unnecessary updates for sub-pixel variations.
3
7
  */
4
8
  /**
5
- * Checks if a height change is significant enough to warrant marking an item as dirty
6
- * @param itemIndex - The index of the item
7
- * @param newHeight - The new measured height
8
- * @param heightCache - Existing height cache to compare against
9
- * @param marginOfError - Height difference threshold (default: 1px)
10
- * @returns true if the height change is significant
9
+ * Checks if a height change is significant enough to warrant marking an item as dirty.
10
+ *
11
+ * This function compares the new measured height against the cached height for an item
12
+ * and determines if the difference exceeds the specified margin of error. Items with
13
+ * no previous measurement are always considered significant.
14
+ *
15
+ * @param {number} itemIndex - The index of the item in the virtual list.
16
+ * @param {number} newHeight - The new measured height in pixels.
17
+ * @param {Record<number, number>} heightCache - Cache of previously measured item heights.
18
+ * @param {number} [marginOfError=1] - Height difference threshold in pixels. Changes
19
+ * smaller than this value are considered insignificant.
20
+ * @returns {boolean} Returns true if the height change exceeds the margin of error
21
+ * or if this is the first measurement for the item.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * const heightCache = { 0: 40, 1: 50 };
26
+ *
27
+ * // First-time measurement (no cache entry)
28
+ * isSignificantHeightChange(2, 45, heightCache); // true
29
+ *
30
+ * // Significant change (exceeds 1px threshold)
31
+ * isSignificantHeightChange(0, 45, heightCache); // true
32
+ *
33
+ * // Insignificant change (within 1px threshold)
34
+ * isSignificantHeightChange(0, 40.5, heightCache); // false
35
+ * ```
11
36
  */
12
37
  export const isSignificantHeightChange = (itemIndex, newHeight, heightCache, marginOfError = 1) => {
13
38
  const previousHeight = heightCache[itemIndex];
@@ -58,7 +58,15 @@ export const calculateScrollTarget = (params) => {
58
58
  }
59
59
  };
60
60
  /**
61
- * Calculates scroll target for bottom-to-top mode
61
+ * Calculates the target scroll position for bottom-to-top mode.
62
+ *
63
+ * In bottom-to-top mode, items are rendered from the bottom of the viewport upward,
64
+ * which requires different scroll calculations than the standard top-to-bottom mode.
65
+ * This function handles the coordinate system translation and alignment logic.
66
+ *
67
+ * @param {BottomToTopScrollParams} params - Parameters for scroll calculation.
68
+ * @returns {number | null} The target scroll position in pixels, or null if no
69
+ * scroll is needed (item already visible with 'nearest' alignment).
62
70
  */
63
71
  const calculateBottomToTopScrollTarget = (params) => {
64
72
  const { align, targetIndex, itemsLength, calculatedItemHeight, height, scrollTop, firstVisibleIndex, lastVisibleIndex, heightCache } = params;
@@ -106,7 +114,15 @@ const calculateBottomToTopScrollTarget = (params) => {
106
114
  return null;
107
115
  };
108
116
  /**
109
- * Calculates scroll target for top-to-bottom mode
117
+ * Calculates the target scroll position for top-to-bottom mode.
118
+ *
119
+ * This is the standard scroll mode where items are rendered from the top of the
120
+ * viewport downward. The function calculates the optimal scroll position based
121
+ * on the alignment option and current viewport state.
122
+ *
123
+ * @param {TopToBottomScrollParams} params - Parameters for scroll calculation.
124
+ * @returns {number | null} The target scroll position in pixels, or null if no
125
+ * scroll is needed (item already visible with 'nearest' alignment).
110
126
  */
111
127
  const calculateTopToBottomScrollTarget = (params) => {
112
128
  const { align, targetIndex, calculatedItemHeight, height, scrollTop, firstVisibleIndex, lastVisibleIndex, heightCache } = params;
@@ -151,3 +151,31 @@ onComplete: () => void) => Promise<void>;
151
151
  * const offset = getScrollOffsetForIndex(heightCache, calculatedItemHeight, 12345, blockSums);
152
152
  */
153
153
  export declare const getScrollOffsetForIndex: (heightCache: Record<number, number>, calculatedItemHeight: number, idx: number, blockSums?: number[], blockSize?: number) => number;
154
+ /**
155
+ * Builds block prefix sums for heightCache to accelerate offset queries.
156
+ *
157
+ * This function precomputes cumulative height sums for blocks of items, enabling
158
+ * O(blockSize) offset calculations instead of O(n). The returned array contains
159
+ * the total height of all items up to and including each completed block.
160
+ *
161
+ * For example, with blockSize=1000:
162
+ * - Entry 0: sum of heights for items 0-999
163
+ * - Entry 1: sum of heights for items 0-1999
164
+ * - Entry 2: sum of heights for items 0-2999
165
+ *
166
+ * @param {Record<number, number>} heightCache - Cache of measured item heights.
167
+ * @param {number} calculatedItemHeight - Estimated height for unmeasured items.
168
+ * @param {number} totalItems - Total number of items in the list.
169
+ * @param {number} [blockSize=1000] - Number of items per block for memoization.
170
+ * @returns {number[]} Array of cumulative height sums for each completed block.
171
+ *
172
+ * @example
173
+ * ```typescript
174
+ * const heightCache = { 0: 40, 1: 50, 2: 45 };
175
+ * const blockSums = buildBlockSums(heightCache, 40, 5000, 1000);
176
+ *
177
+ * // Use with getScrollOffsetForIndex for efficient lookups
178
+ * const offset = getScrollOffsetForIndex(heightCache, 40, 2500, blockSums);
179
+ * ```
180
+ */
181
+ export declare const buildBlockSums: (heightCache: Record<number, number>, calculatedItemHeight: number, totalItems: number, blockSize?: number) => number[];
@@ -115,7 +115,8 @@ export const calculateTransformY = (mode, totalItems, visibleEnd, visibleStart,
115
115
  const basicTransform = (totalItems - visibleEnd) * itemHeight;
116
116
  // When content is smaller than viewport, push to bottom
117
117
  const bottomOffset = Math.max(0, effectiveViewport - actualTotalHeight);
118
- return basicTransform + bottomOffset;
118
+ // Snap to integer pixels to avoid subpixel oscillation
119
+ return Math.round(basicTransform + bottomOffset);
119
120
  }
120
121
  else {
121
122
  // For topToBottom, prefer precise offset using measured heights when available
@@ -123,7 +124,7 @@ export const calculateTransformY = (mode, totalItems, visibleEnd, visibleStart,
123
124
  const offset = getScrollOffsetForIndex(heightCache, itemHeight, visibleStart);
124
125
  return Math.max(0, Math.round(offset));
125
126
  }
126
- return visibleStart * itemHeight;
127
+ return Math.round(visibleStart * itemHeight);
127
128
  }
128
129
  };
129
130
  /**
@@ -386,3 +387,46 @@ export const getScrollOffsetForIndex = (heightCache, calculatedItemHeight, idx,
386
387
  }
387
388
  return offset;
388
389
  };
390
+ /**
391
+ * Builds block prefix sums for heightCache to accelerate offset queries.
392
+ *
393
+ * This function precomputes cumulative height sums for blocks of items, enabling
394
+ * O(blockSize) offset calculations instead of O(n). The returned array contains
395
+ * the total height of all items up to and including each completed block.
396
+ *
397
+ * For example, with blockSize=1000:
398
+ * - Entry 0: sum of heights for items 0-999
399
+ * - Entry 1: sum of heights for items 0-1999
400
+ * - Entry 2: sum of heights for items 0-2999
401
+ *
402
+ * @param {Record<number, number>} heightCache - Cache of measured item heights.
403
+ * @param {number} calculatedItemHeight - Estimated height for unmeasured items.
404
+ * @param {number} totalItems - Total number of items in the list.
405
+ * @param {number} [blockSize=1000] - Number of items per block for memoization.
406
+ * @returns {number[]} Array of cumulative height sums for each completed block.
407
+ *
408
+ * @example
409
+ * ```typescript
410
+ * const heightCache = { 0: 40, 1: 50, 2: 45 };
411
+ * const blockSums = buildBlockSums(heightCache, 40, 5000, 1000);
412
+ *
413
+ * // Use with getScrollOffsetForIndex for efficient lookups
414
+ * const offset = getScrollOffsetForIndex(heightCache, 40, 2500, blockSums);
415
+ * ```
416
+ */
417
+ export const buildBlockSums = (heightCache, calculatedItemHeight, totalItems, blockSize = 1000) => {
418
+ const blocks = Math.ceil(totalItems / blockSize);
419
+ const sums = new Array(Math.max(0, blocks - 1));
420
+ let running = 0;
421
+ for (let b = 0; b < blocks - 1; b++) {
422
+ const start = b * blockSize;
423
+ const end = start + blockSize;
424
+ 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;
428
+ }
429
+ sums[b] = running;
430
+ }
431
+ return sums;
432
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-virtual-list",
3
- "version": "0.3.5",
3
+ "version": "0.3.8",
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",
@@ -59,51 +59,51 @@
59
59
  "esm-env": "^1.2.2"
60
60
  },
61
61
  "devDependencies": {
62
- "@eslint/compat": "^1.4.0",
63
- "@eslint/js": "^9.37.0",
64
- "@faker-js/faker": "^10.0.0",
65
- "@playwright/test": "^1.56.0",
66
- "@sveltejs/adapter-auto": "^6.1.1",
67
- "@sveltejs/kit": "^2.46.4",
68
- "@sveltejs/package": "^2.5.4",
69
- "@sveltejs/vite-plugin-svelte": "^6.2.1",
70
- "@tailwindcss/vite": "^4.1.14",
62
+ "@eslint/compat": "^2.0.1",
63
+ "@eslint/js": "^9.39.2",
64
+ "@faker-js/faker": "^10.2.0",
65
+ "@playwright/test": "^1.58.0",
66
+ "@sveltejs/adapter-auto": "^7.0.0",
67
+ "@sveltejs/kit": "^2.50.1",
68
+ "@sveltejs/package": "^2.5.7",
69
+ "@sveltejs/vite-plugin-svelte": "^6.2.4",
70
+ "@tailwindcss/vite": "^4.1.18",
71
71
  "@testing-library/jest-dom": "^6.9.1",
72
- "@testing-library/svelte": "^5.2.8",
72
+ "@testing-library/svelte": "^5.3.1",
73
73
  "@testing-library/user-event": "^14.6.1",
74
- "@types/node": "^24.7.1",
75
- "@typescript-eslint/eslint-plugin": "^8.46.0",
76
- "@typescript-eslint/parser": "^8.46.0",
77
- "@vitest/coverage-v8": "^3.2.4",
74
+ "@types/node": "^25.1.0",
75
+ "@typescript-eslint/eslint-plugin": "^8.54.0",
76
+ "@typescript-eslint/parser": "^8.54.0",
77
+ "@vitest/coverage-v8": "^4.0.18",
78
78
  "concurrently": "^9.2.1",
79
- "eslint": "^9.37.0",
79
+ "eslint": "^9.39.2",
80
80
  "eslint-config-prettier": "^10.1.8",
81
81
  "eslint-plugin-import": "^2.32.0",
82
- "eslint-plugin-svelte": "^3.12.4",
83
- "eslint-plugin-unused-imports": "^4.2.0",
84
- "globals": "^16.4.0",
82
+ "eslint-plugin-svelte": "^3.14.0",
83
+ "eslint-plugin-unused-imports": "^4.3.0",
84
+ "globals": "^17.2.0",
85
85
  "husky": "^9.1.7",
86
- "jsdom": "^27.0.0",
87
- "prettier": "^3.6.2",
86
+ "jsdom": "^27.4.0",
87
+ "prettier": "^3.8.1",
88
88
  "prettier-plugin-organize-imports": "^4.3.0",
89
- "prettier-plugin-sort-json": "^4.1.1",
90
- "prettier-plugin-svelte": "^3.4.0",
91
- "prettier-plugin-tailwindcss": "^0.6.14",
92
- "publint": "^0.3.14",
93
- "svelte": "^5.39.11",
94
- "svelte-check": "^4.3.3",
95
- "tailwindcss": "^4.1.14",
89
+ "prettier-plugin-sort-json": "^4.2.0",
90
+ "prettier-plugin-svelte": "^3.4.1",
91
+ "prettier-plugin-tailwindcss": "^0.7.2",
92
+ "publint": "^0.3.17",
93
+ "svelte": "^5.48.5",
94
+ "svelte-check": "^4.3.5",
95
+ "tailwindcss": "^4.1.18",
96
96
  "tw-animate-css": "^1.4.0",
97
97
  "typescript": "^5.9.3",
98
- "typescript-eslint": "^8.46.0",
99
- "vite": "^7.1.9",
100
- "vitest": "^3.2.4"
98
+ "typescript-eslint": "^8.54.0",
99
+ "vite": "^7.3.1",
100
+ "vitest": "^4.0.18"
101
101
  },
102
102
  "peerDependencies": {
103
103
  "svelte": "^5.0.0"
104
104
  },
105
105
  "volta": {
106
- "node": "22.18.0"
106
+ "node": "24.13.0"
107
107
  },
108
108
  "publishConfig": {
109
109
  "access": "public"
@@ -123,7 +123,7 @@
123
123
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
124
124
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
125
125
  "dev": "vite dev",
126
- "dev:all": "concurrently -k -n pkg,docs,sitemap -c green,cyan,magenta \"pnpm -w -r --filter @humanspeak/svelte-virtual-list run dev:pkg\" \"pnpm --filter docs run dev\" \"pnpm --filter docs run sitemap:watch\"",
126
+ "dev:all": "concurrently -k -n pkg,docs,sitemap -c green,cyan,magenta \"pnpm -w -r --filter @humanspeak/svelte-virtual-list run dev\" \"pnpm --filter docs run dev\" \"pnpm --filter docs run sitemap:watch\"",
127
127
  "dev:pkg": "svelte-kit sync && svelte-package --watch",
128
128
  "format": "prettier --write .",
129
129
  "lint": "prettier --check . && eslint .",
@@ -138,6 +138,7 @@
138
138
  "test:e2e:report": "playwright show-report",
139
139
  "test:e2e:ui": "playwright test --ui",
140
140
  "test:only": "vitest run --",
141
+ "test:unit": "vitest run --coverage",
141
142
  "test:watch": "vitest --"
142
143
  }
143
144
  }