@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 +21 -0
- package/dist/SvelteVirtualList.svelte +106 -62
- package/dist/SvelteVirtualList.svelte.d.ts +4 -4
- package/dist/utils/scrollEnd.d.ts +40 -0
- package/dist/utils/scrollEnd.js +114 -0
- package/package.json +17 -17
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 =
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
847
|
-
if (scrollTarget === null) {
|
|
848
|
-
return
|
|
849
|
-
}
|
|
863
|
+
const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems
|
|
850
864
|
|
|
851
|
-
|
|
852
|
-
const
|
|
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
|
-
|
|
862
|
-
scrollTarget,
|
|
863
|
-
domMaxScrollTop: domMax
|
|
875
|
+
heightCache: heightManager.getHeightCache()
|
|
864
876
|
})
|
|
865
|
-
}
|
|
866
877
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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-
|
|
881
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
69
|
-
"@sveltejs/package": "^2.5.
|
|
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.
|
|
76
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
77
|
-
"@typescript-eslint/parser": "^8.
|
|
78
|
-
"@vitest/coverage-v8": "^4.1.
|
|
79
|
-
"eslint": "^10.4.
|
|
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.
|
|
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.
|
|
88
|
-
"prettier": "^3.8.
|
|
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
|
|
90
|
+
"prettier-plugin-svelte": "^4.1.0",
|
|
91
91
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
|
92
92
|
"publint": "^0.3.21",
|
|
93
|
-
"svelte": "^5.
|
|
94
|
-
"svelte-check": "^4.
|
|
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.
|
|
99
|
-
"vite": "^8.0.
|
|
100
|
-
"vitest": "^4.1.
|
|
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"
|