@humanspeak/svelte-virtual-list 0.5.2 → 0.5.4

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
@@ -232,6 +232,25 @@ pnpm test:all
232
232
 
233
233
  This project uses [Trunk](https://trunk.io) for formatting and linting. Trunk manages tool versions and runs checks automatically via pre-commit hooks.
234
234
 
235
+ <!-- docs-kit:ecosystem start -->
236
+
237
+ ## Svelte 5 ecosystem
238
+
239
+ Part of the [Humanspeak](https://humanspeak.com) family of runes-native Svelte 5 packages:
240
+
241
+ | Package | Description |
242
+ | --- | --- |
243
+ | [@humanspeak/svelte-markdown](https://markdown.svelte.page) | Runtime markdown renderer for Svelte |
244
+ | **[@humanspeak/svelte-virtual-list](https://virtuallist.svelte.page)** — _this package_ | Virtual scrolling for Svelte |
245
+ | [@humanspeak/svelte-motion](https://motion.svelte.page) | Framer Motion for Svelte 5 |
246
+ | [@humanspeak/svelte-headless-table](https://table.svelte.page) | Headless data tables for Svelte |
247
+ | [@humanspeak/svelte-diff-match-patch](https://diff.svelte.page) | Diff comparison for Svelte |
248
+ | [@humanspeak/svelte-purify](https://purify.svelte.page) | HTML sanitisation for Svelte |
249
+ | [@humanspeak/svelte-virtual-chat](https://virtualchat.svelte.page) | Virtual chat viewport for Svelte 5 |
250
+ | [@humanspeak/memory-cache](https://memory.svelte.page) | In-memory cache for TypeScript |
251
+ | [@humanspeak/svelte-json-view-lite](https://jsonview.svelte.page) | JSON tree viewer for Svelte 5 |
252
+ | [@humanspeak/svelte-scoped-props](https://scoped.svelte.page) | Scoped class props for Svelte |
253
+
235
254
  ## License
236
255
 
237
256
  MIT © [Humanspeak, Inc.](LICENSE)
@@ -239,3 +258,5 @@ MIT © [Humanspeak, Inc.](LICENSE)
239
258
  ## Credits
240
259
 
241
260
  Made with ❤️ by [Humanspeak](https://humanspeak.com)
261
+
262
+ <!-- docs-kit:ecosystem end -->
@@ -161,6 +161,7 @@
161
161
  } from './utils/virtualList.js'
162
162
  import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
163
163
  import { calculateScrollTarget } from './utils/scrollCalculation.js'
164
+ import { waitForScrollEnd } from './utils/scrollEnd.js'
164
165
  import { createAdvancedThrottledCallback } from './utils/throttle.js'
165
166
  import { ReactiveListManager } from './index.js'
166
167
  import { BROWSER } from 'esm-env'
@@ -222,6 +223,7 @@
222
223
  let heightUpdateTimeout: ReturnType<typeof setTimeout> | null = null // Debounce timer for height updates
223
224
  let resizeObserver: ResizeObserver | null = null // Watches for container size changes
224
225
  let itemResizeObserver: ResizeObserver | null = null // Watches for individual item size changes
226
+ let scrollAbortController: AbortController | null = null // Cancels an in-flight programmatic scroll wait
225
227
 
226
228
  /**
227
229
  * Performance Optimization State
@@ -686,6 +688,8 @@
686
688
  if (itemResizeObserver) {
687
689
  itemResizeObserver.disconnect()
688
690
  }
691
+ // Abort any pending scroll wait so its timers/listeners are torn down.
692
+ scrollAbortController?.abort()
689
693
  }
690
694
  }
691
695
  })
@@ -758,14 +762,14 @@
758
762
  * {/snippet}
759
763
  * </SvelteVirtualList>
760
764
  *
761
- * @returns {void}
765
+ * @returns {Promise<void>} Promise that resolves when scrolling is complete
762
766
  * @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
763
767
  */
764
768
  export const scrollToIndex = (
765
769
  index: number,
766
770
  smoothScroll = true,
767
771
  shouldThrowOnBounds = true
768
- ): void => {
772
+ ): Promise<void> => {
769
773
  // Deprecation warning
770
774
  console.warn(
771
775
  'SvelteVirtualList: scrollToIndex is deprecated and will be removed in a future version. ' +
@@ -773,7 +777,7 @@
773
777
  )
774
778
 
775
779
  // Call the new scroll function with the provided parameters
776
- scroll({ index, smoothScroll, shouldThrowOnBounds })
780
+ return scroll({ index, smoothScroll, shouldThrowOnBounds })
777
781
  }
778
782
 
779
783
  /**
@@ -801,90 +805,130 @@
801
805
  * @returns {Promise<void>} Promise that resolves when scrolling is complete
802
806
  * @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
803
807
  */
804
- export const scroll = async (options: SvelteVirtualListScrollOptions): Promise<void> => {
808
+ export const scroll = (options: SvelteVirtualListScrollOptions): Promise<void> => {
805
809
  const { index, smoothScroll, shouldThrowOnBounds, align } = {
806
810
  ...DEFAULT_SCROLL_OPTIONS,
807
811
  ...options
808
812
  }
809
813
 
810
- if (!items.length) return
811
- if (!heightManager.viewportElement) {
812
- tick().then(() => {
813
- if (!heightManager.viewportElement) return
814
- scroll({ index, smoothScroll, shouldThrowOnBounds, align })
815
- })
816
- return
817
- }
818
-
819
- // Bounds checking
820
- let targetIndex = index
821
- if (targetIndex < 0 || targetIndex >= items.length) {
822
- if (shouldThrowOnBounds) {
823
- throw new Error(
824
- `scroll: index ${targetIndex} is out of bounds (0-${items.length - 1})`
825
- )
826
- } else {
827
- targetIndex = clampValue(targetIndex, 0, items.length - 1)
814
+ // Cancel any scroll wait still in progress so its promise can't resolve
815
+ // against a stale target, and start a fresh cancellation scope.
816
+ scrollAbortController?.abort()
817
+ const abortController = new AbortController()
818
+ scrollAbortController = abortController
819
+ const { signal } = abortController
820
+
821
+ return new Promise<void>((resolve, reject) => {
822
+ if (!items.length) {
823
+ resolve()
824
+ return
828
825
  }
829
- }
830
826
 
831
- const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems
827
+ // Viewport not mounted yet: retry on the next tick and chain the result.
828
+ if (!heightManager.viewportElement) {
829
+ tick().then(() => {
830
+ // A newer scroll() may have superseded this one while we waited;
831
+ // bail out instead of re-invoking and clobbering the newer target.
832
+ if (signal.aborted) {
833
+ resolve()
834
+ return
835
+ }
836
+ if (!heightManager.viewportElement) {
837
+ resolve()
838
+ return
839
+ }
840
+ scroll({ index, smoothScroll, shouldThrowOnBounds, align }).then(
841
+ resolve,
842
+ reject
843
+ )
844
+ })
845
+ return
846
+ }
832
847
 
833
- // Use extracted scroll calculation utility
834
- const scrollTarget = calculateScrollTarget({
835
- align: align || 'auto',
836
- targetIndex,
837
- itemsLength: items.length,
838
- calculatedItemHeight: heightManager.averageHeight, // Use dynamic average from ReactiveListManager
839
- height,
840
- scrollTop: heightManager.scrollTop,
841
- firstVisibleIndex,
842
- lastVisibleIndex,
843
- heightCache: heightManager.getHeightCache()
844
- })
848
+ // Bounds checking
849
+ let targetIndex = index
850
+ if (targetIndex < 0 || targetIndex >= items.length) {
851
+ if (shouldThrowOnBounds) {
852
+ reject(
853
+ new Error(
854
+ `scroll: index ${targetIndex} is out of bounds (0-${items.length - 1})`
855
+ )
856
+ )
857
+ return
858
+ } else {
859
+ targetIndex = clampValue(targetIndex, 0, items.length - 1)
860
+ }
861
+ }
845
862
 
846
- // Handle early return for 'nearest' alignment when item is already visible
847
- if (scrollTarget === null) {
848
- return
849
- }
863
+ const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems
850
864
 
851
- if (INTERNAL_DEBUG && heightManager.viewportElement) {
852
- const domMax = Math.max(
853
- 0,
854
- heightManager.viewport.scrollHeight - heightManager.viewport.clientHeight
855
- )
856
- console.info('[SVL] scroll-intent', {
857
- targetIndex,
865
+ // Use extracted scroll calculation utility
866
+ const scrollTarget = calculateScrollTarget({
858
867
  align: align || 'auto',
868
+ targetIndex,
869
+ itemsLength: items.length,
870
+ calculatedItemHeight: heightManager.averageHeight, // Use dynamic average from ReactiveListManager
871
+ height,
872
+ scrollTop: heightManager.scrollTop,
859
873
  firstVisibleIndex,
860
874
  lastVisibleIndex,
861
- currentScrollTop: heightManager.scrollTop,
862
- scrollTarget,
863
- domMaxScrollTop: domMax
875
+ heightCache: heightManager.getHeightCache()
864
876
  })
865
- }
866
877
 
867
- heightManager.viewport.scrollTo({
868
- top: scrollTarget,
869
- behavior: smoothScroll ? 'smooth' : 'auto'
870
- })
878
+ // Handle early return for 'nearest' alignment when item is already visible
879
+ if (scrollTarget === null) {
880
+ resolve()
881
+ return
882
+ }
871
883
 
872
- // Update scrollTop state in next frame to avoid synchronous re-renders
873
- requestAnimationFrame(() => {
874
- heightManager.scrollTop = scrollTarget
875
884
  if (INTERNAL_DEBUG && heightManager.viewportElement) {
876
885
  const domMax = Math.max(
877
886
  0,
878
887
  heightManager.viewport.scrollHeight - heightManager.viewport.clientHeight
879
888
  )
880
- console.info('[SVL] scroll-after-call', {
881
- scrollTop: heightManager.scrollTop,
889
+ console.info('[SVL] scroll-intent', {
890
+ targetIndex,
891
+ align: align || 'auto',
892
+ firstVisibleIndex,
893
+ lastVisibleIndex,
894
+ currentScrollTop: heightManager.scrollTop,
895
+ scrollTarget,
882
896
  domMaxScrollTop: domMax
883
897
  })
884
898
  }
885
- })
886
899
 
887
- // No extra alignment step here; allow native smooth scroll to reach DOM max scrollTop
900
+ heightManager.viewport.scrollTo({
901
+ top: scrollTarget,
902
+ behavior: smoothScroll ? 'smooth' : 'auto'
903
+ })
904
+
905
+ // Update scrollTop state in next frame to avoid synchronous re-renders
906
+ requestAnimationFrame(() => {
907
+ heightManager.scrollTop = scrollTarget
908
+ if (INTERNAL_DEBUG && heightManager.viewportElement) {
909
+ const domMax = Math.max(
910
+ 0,
911
+ heightManager.viewport.scrollHeight - heightManager.viewport.clientHeight
912
+ )
913
+ console.info('[SVL] scroll-after-call', {
914
+ scrollTop: heightManager.scrollTop,
915
+ domMaxScrollTop: domMax
916
+ })
917
+ }
918
+ })
919
+
920
+ // Resolve only once the scroll has visually finished AND the virtual
921
+ // list has re-rendered for the new position.
922
+ waitForScrollEnd(
923
+ heightManager.viewport,
924
+ scrollTarget,
925
+ smoothScroll ?? true,
926
+ signal
927
+ ).then(async () => {
928
+ await tick()
929
+ resolve()
930
+ }, reject)
931
+ })
888
932
  }
889
933
 
890
934
  /**
@@ -114,9 +114,9 @@ declare function $$render<TItem = unknown>(): {
114
114
  * {/snippet}
115
115
  * </SvelteVirtualList>
116
116
  *
117
- * @returns {void}
117
+ * @returns {Promise<void>} Promise that resolves when scrolling is complete
118
118
  * @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
119
- */ scrollToIndex: (index: number, smoothScroll?: boolean, shouldThrowOnBounds?: boolean) => void;
119
+ */ scrollToIndex: (index: number, smoothScroll?: boolean, shouldThrowOnBounds?: boolean) => Promise<void>;
120
120
  /**
121
121
  * Scrolls the virtual list to the item at the given index using a type-based options approach.
122
122
  *
@@ -181,9 +181,9 @@ declare class __sveltets_Render<TItem = unknown> {
181
181
  * {/snippet}
182
182
  * </SvelteVirtualList>
183
183
  *
184
- * @returns {void}
184
+ * @returns {Promise<void>} Promise that resolves when scrolling is complete
185
185
  * @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
186
- */ scrollToIndex: (index: number, smoothScroll?: boolean, shouldThrowOnBounds?: boolean) => void;
186
+ */ scrollToIndex: (index: number, smoothScroll?: boolean, shouldThrowOnBounds?: boolean) => Promise<void>;
187
187
  /**
188
188
  * Scrolls the virtual list to the item at the given index using a type-based options approach.
189
189
  *
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Utilities to detect when a programmatic scroll has visually finished.
3
+ *
4
+ * The browser's `scrollTo` returns immediately — it does not wait for a smooth
5
+ * animation to complete. These helpers bridge that gap so callers can await the
6
+ * real end of a scroll before reading layout or resolving a promise.
7
+ */
8
+ /**
9
+ * Resolves once a programmatic scroll on `viewport` has visually finished.
10
+ *
11
+ * Resolution strategy:
12
+ * - Instant scroll (or already at target): resolves on the next animation frame,
13
+ * once the browser has applied the new `scrollTop`.
14
+ * - Smooth scroll with native `scrollend` support: resolves on the `scrollend` event.
15
+ * - Smooth scroll without support (e.g. Safari): polls `scrollTop` until it
16
+ * stabilizes for several consecutive frames or reaches the target.
17
+ *
18
+ * A safety timeout always resolves the promise to avoid hanging if neither the
19
+ * event nor the poll ever settles (e.g. an unreachable target or an interrupted
20
+ * animation). Passing an already-aborted (or later aborted) `signal` resolves
21
+ * immediately and tears down all listeners/timers.
22
+ *
23
+ * @example
24
+ * // Scroll smoothly and await the visual end, cancelling on a newer scroll:
25
+ * import { waitForScrollEnd } from './scrollEnd.js';
26
+ *
27
+ * const controller = new AbortController();
28
+ * const target = 1200;
29
+ * viewport.scrollTo({ top: target, behavior: 'smooth' });
30
+ * await waitForScrollEnd(viewport, target, true, controller.signal);
31
+ * // ...later, to abandon the wait (e.g. a newer scroll started):
32
+ * controller.abort();
33
+ *
34
+ * @param viewport The scrollable element being animated.
35
+ * @param target The desired final `scrollTop` value.
36
+ * @param smooth Whether the scroll was initiated with `behavior: 'smooth'`.
37
+ * @param signal Optional AbortSignal to cancel waiting (e.g. a newer scroll).
38
+ * @returns A promise that resolves when the scroll has finished (or is cancelled).
39
+ */
40
+ export declare const waitForScrollEnd: (viewport: HTMLElement, target: number, smooth: boolean, signal?: AbortSignal) => Promise<void>;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Utilities to detect when a programmatic scroll has visually finished.
3
+ *
4
+ * The browser's `scrollTo` returns immediately — it does not wait for a smooth
5
+ * animation to complete. These helpers bridge that gap so callers can await the
6
+ * real end of a scroll before reading layout or resolving a promise.
7
+ */
8
+ /** Hard safety timeout (ms): always resolve so a promise can never hang. */
9
+ const SCROLL_END_TIMEOUT_MS = 1200;
10
+ /** Number of consecutive stable frames required to consider a poll settled. */
11
+ const STABLE_FRAMES = 3;
12
+ /** Pixel tolerance when comparing scroll positions. */
13
+ const POSITION_TOLERANCE = 1;
14
+ /**
15
+ * Resolves once a programmatic scroll on `viewport` has visually finished.
16
+ *
17
+ * Resolution strategy:
18
+ * - Instant scroll (or already at target): resolves on the next animation frame,
19
+ * once the browser has applied the new `scrollTop`.
20
+ * - Smooth scroll with native `scrollend` support: resolves on the `scrollend` event.
21
+ * - Smooth scroll without support (e.g. Safari): polls `scrollTop` until it
22
+ * stabilizes for several consecutive frames or reaches the target.
23
+ *
24
+ * A safety timeout always resolves the promise to avoid hanging if neither the
25
+ * event nor the poll ever settles (e.g. an unreachable target or an interrupted
26
+ * animation). Passing an already-aborted (or later aborted) `signal` resolves
27
+ * immediately and tears down all listeners/timers.
28
+ *
29
+ * @example
30
+ * // Scroll smoothly and await the visual end, cancelling on a newer scroll:
31
+ * import { waitForScrollEnd } from './scrollEnd.js';
32
+ *
33
+ * const controller = new AbortController();
34
+ * const target = 1200;
35
+ * viewport.scrollTo({ top: target, behavior: 'smooth' });
36
+ * await waitForScrollEnd(viewport, target, true, controller.signal);
37
+ * // ...later, to abandon the wait (e.g. a newer scroll started):
38
+ * controller.abort();
39
+ *
40
+ * @param viewport The scrollable element being animated.
41
+ * @param target The desired final `scrollTop` value.
42
+ * @param smooth Whether the scroll was initiated with `behavior: 'smooth'`.
43
+ * @param signal Optional AbortSignal to cancel waiting (e.g. a newer scroll).
44
+ * @returns A promise that resolves when the scroll has finished (or is cancelled).
45
+ */
46
+ export const waitForScrollEnd = (viewport, target, smooth, signal) => {
47
+ return new Promise((resolve) => {
48
+ if (signal?.aborted) {
49
+ resolve();
50
+ return;
51
+ }
52
+ const alreadyThere = Math.abs(viewport.scrollTop - target) <= POSITION_TOLERANCE;
53
+ const shouldWaitForSmoothScroll = smooth && !alreadyThere;
54
+ let rafId = 0;
55
+ let onScrollEnd;
56
+ function cleanup() {
57
+ if (rafId)
58
+ cancelAnimationFrame(rafId);
59
+ if (timeoutId !== undefined)
60
+ clearTimeout(timeoutId);
61
+ if (onScrollEnd)
62
+ viewport.removeEventListener('scrollend', onScrollEnd);
63
+ signal?.removeEventListener('abort', onAbort);
64
+ }
65
+ function finish() {
66
+ cleanup();
67
+ resolve();
68
+ }
69
+ const onAbort = finish;
70
+ const timeoutId = shouldWaitForSmoothScroll
71
+ ? setTimeout(finish, SCROLL_END_TIMEOUT_MS)
72
+ : undefined;
73
+ // Instant scroll or no-op (already at target): a smooth `scrollTo` to the
74
+ // current position never fires `scrollend`, so handle it as instant.
75
+ if (!shouldWaitForSmoothScroll) {
76
+ rafId = requestAnimationFrame(() => {
77
+ rafId = 0;
78
+ finish();
79
+ });
80
+ return;
81
+ }
82
+ signal?.addEventListener('abort', onAbort, { once: true });
83
+ // Smooth scroll with native `scrollend` support (Chrome, Firefox).
84
+ if (typeof window !== 'undefined' && 'onscrollend' in window) {
85
+ onScrollEnd = finish;
86
+ viewport.addEventListener('scrollend', onScrollEnd, { once: true });
87
+ return;
88
+ }
89
+ // Fallback (e.g. Safari): poll until `scrollTop` stabilizes or reaches target.
90
+ let lastTop = viewport.scrollTop;
91
+ let stableFrames = 0;
92
+ let hasMoved = false;
93
+ const poll = () => {
94
+ const top = viewport.scrollTop;
95
+ const delta = Math.abs(top - lastTop);
96
+ if (delta > POSITION_TOLERANCE) {
97
+ hasMoved = true;
98
+ stableFrames = 0;
99
+ }
100
+ else {
101
+ stableFrames += 1;
102
+ }
103
+ lastTop = top;
104
+ const nearTarget = Math.abs(top - target) <= POSITION_TOLERANCE;
105
+ if (nearTarget || (hasMoved && stableFrames >= STABLE_FRAMES)) {
106
+ rafId = 0;
107
+ finish();
108
+ return;
109
+ }
110
+ rafId = requestAnimationFrame(poll);
111
+ };
112
+ rafId = requestAnimationFrame(poll);
113
+ });
114
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-virtual-list",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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, activity feeds, and any application requiring the rendering of thousands of items without compromising performance. Zero dependencies and fully customizable.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -65,39 +65,39 @@
65
65
  "@playwright/cli": "^0.1.13",
66
66
  "@playwright/test": "^1.60.0",
67
67
  "@sveltejs/adapter-auto": "^7.0.1",
68
- "@sveltejs/kit": "^2.61.1",
69
- "@sveltejs/package": "^2.5.7",
68
+ "@sveltejs/kit": "^2.64.0",
69
+ "@sveltejs/package": "^2.5.8",
70
70
  "@sveltejs/vite-plugin-svelte": "^7.1.2",
71
71
  "@tailwindcss/vite": "^4.3.0",
72
72
  "@testing-library/jest-dom": "^6.9.1",
73
73
  "@testing-library/svelte": "^5.3.1",
74
74
  "@testing-library/user-event": "^14.6.1",
75
- "@types/node": "^25.9.1",
76
- "@typescript-eslint/eslint-plugin": "^8.60.0",
77
- "@typescript-eslint/parser": "^8.60.0",
78
- "@vitest/coverage-v8": "^4.1.7",
79
- "eslint": "^10.4.0",
75
+ "@types/node": "^25.9.2",
76
+ "@typescript-eslint/eslint-plugin": "^8.61.0",
77
+ "@typescript-eslint/parser": "^8.61.0",
78
+ "@vitest/coverage-v8": "^4.1.8",
79
+ "eslint": "^10.4.1",
80
80
  "eslint-config-prettier": "^10.1.8",
81
81
  "eslint-plugin-import": "^2.32.0",
82
- "eslint-plugin-svelte": "^3.17.1",
82
+ "eslint-plugin-svelte": "^3.19.0",
83
83
  "eslint-plugin-unused-imports": "^4.4.1",
84
84
  "globals": "^17.6.0",
85
85
  "husky": "^9.1.7",
86
86
  "jsdom": "^29.1.1",
87
- "mprocs": "^0.9.3",
88
- "prettier": "^3.8.3",
87
+ "mprocs": "^0.9.6",
88
+ "prettier": "^3.8.4",
89
89
  "prettier-plugin-organize-imports": "^4.3.0",
90
- "prettier-plugin-svelte": "^4.0.1",
90
+ "prettier-plugin-svelte": "^4.1.0",
91
91
  "prettier-plugin-tailwindcss": "^0.8.0",
92
92
  "publint": "^0.3.21",
93
- "svelte": "^5.55.9",
94
- "svelte-check": "^4.4.8",
93
+ "svelte": "^5.56.3",
94
+ "svelte-check": "^4.6.0",
95
95
  "tailwindcss": "^4.3.0",
96
96
  "tw-animate-css": "^1.4.0",
97
97
  "typescript": "^6.0.3",
98
- "typescript-eslint": "^8.60.0",
99
- "vite": "^8.0.14",
100
- "vitest": "^4.1.7"
98
+ "typescript-eslint": "^8.61.0",
99
+ "vite": "^8.0.16",
100
+ "vitest": "^4.1.8"
101
101
  },
102
102
  "peerDependencies": {
103
103
  "svelte": "^5.0.0"