@humanspeak/svelte-virtual-list 0.4.5 → 0.5.0
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 +16 -63
- package/dist/SvelteVirtualList.svelte +33 -757
- package/dist/SvelteVirtualList.svelte.d.ts +9 -58
- package/dist/index.d.ts +2 -2
- package/dist/reactive-list-manager/INTEGRATION_EXAMPLE.md +0 -5
- package/dist/types.d.ts +0 -11
- package/dist/utils/heightCalculation.d.ts +1 -2
- package/dist/utils/heightCalculation.js +2 -2
- package/dist/utils/scrollCalculation.d.ts +3 -9
- package/dist/utils/scrollCalculation.js +13 -72
- package/dist/utils/types.d.ts +0 -3
- package/dist/utils/virtualList.d.ts +38 -26
- package/dist/utils/virtualList.js +73 -138
- package/package.json +29 -29
|
@@ -56,165 +56,111 @@ export const calculateScrollPosition = (totalItems, itemHeight, containerHeight)
|
|
|
56
56
|
/**
|
|
57
57
|
* Determines the range of items that should be rendered in the virtual list.
|
|
58
58
|
*
|
|
59
|
-
*
|
|
60
|
-
* viewport size
|
|
59
|
+
* Calculates which items should be visible based on the current scroll position
|
|
60
|
+
* and viewport size. Includes a buffer zone to enable smooth scrolling
|
|
61
61
|
* and prevent visible gaps during rapid scroll movements.
|
|
62
62
|
*
|
|
63
|
-
* @param
|
|
64
|
-
* @
|
|
65
|
-
*
|
|
66
|
-
* @
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
63
|
+
* @param options - Inputs used to compute the visible range (see {@link VisibleRangeOptions}).
|
|
64
|
+
* @returns Range of indices to render.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* const range = calculateVisibleRange({
|
|
69
|
+
* scrollTop: 200,
|
|
70
|
+
* viewportHeight: 400,
|
|
71
|
+
* itemHeight: 40,
|
|
72
|
+
* totalItems: 1000,
|
|
73
|
+
* bufferSize: 2
|
|
74
|
+
* })
|
|
75
|
+
* // range => { start: 3, end: 15 }
|
|
76
|
+
* ```
|
|
75
77
|
*/
|
|
76
|
-
export const calculateVisibleRange = (scrollTop, viewportHeight, itemHeight, totalItems, bufferSize,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// Safeguard: handle edge cases
|
|
88
|
-
if (startIndex < 0) {
|
|
89
|
-
// We're scrolled beyond the maximum (showing first items)
|
|
90
|
-
const start = 0;
|
|
91
|
-
const end = Math.min(totalItems, visibleCount + bufferSize * 2);
|
|
92
|
-
return { start, end };
|
|
93
|
-
}
|
|
94
|
-
// Add buffer to both ends
|
|
95
|
-
const start = Math.max(0, startIndex - bufferSize);
|
|
96
|
-
const end = Math.min(totalItems, startIndex + visibleCount + bufferSize);
|
|
97
|
-
return { start, end };
|
|
78
|
+
export const calculateVisibleRange = ({ scrollTop, viewportHeight, itemHeight, totalItems, bufferSize, totalContentHeight, heightCache }) => {
|
|
79
|
+
// Walk forward through measured heights to find the correct start index
|
|
80
|
+
// instead of dividing by average height (which is wrong for variable-height items).
|
|
81
|
+
let start = 0;
|
|
82
|
+
let acc = 0;
|
|
83
|
+
while (start < totalItems) {
|
|
84
|
+
const h = getValidHeight(heightCache?.[start], itemHeight);
|
|
85
|
+
if (acc + h > scrollTop)
|
|
86
|
+
break;
|
|
87
|
+
acc += h;
|
|
88
|
+
start++;
|
|
98
89
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const maxScrollTop = Math.max(0, totalHeight - viewportHeight);
|
|
122
|
-
// Use strict tolerance to avoid premature bottom anchoring that leaves a visible gap
|
|
123
|
-
const tolerance = Math.max(1, Math.floor(itemHeight * BOTTOM_TOLERANCE_FACTOR));
|
|
124
|
-
const isAtBottom = Math.abs(scrollTop - maxScrollTop) <= tolerance;
|
|
125
|
-
if (isAtBottom) {
|
|
126
|
-
// Pack from the end using measured heights when available: walk backward until viewport filled
|
|
127
|
-
const adjustedEnd = totalItems;
|
|
128
|
-
let startCore = adjustedEnd;
|
|
129
|
-
let backAcc = 0;
|
|
130
|
-
while (startCore > 0 && backAcc < viewportHeight) {
|
|
131
|
-
backAcc += getValidHeight(heightCache?.[startCore - 1], itemHeight);
|
|
132
|
-
startCore -= 1;
|
|
133
|
-
}
|
|
134
|
-
return {
|
|
135
|
-
start: Math.max(0, startCore - bufferSize),
|
|
136
|
-
end: adjustedEnd
|
|
137
|
-
};
|
|
90
|
+
// Walk forward from start to find end
|
|
91
|
+
let end = start;
|
|
92
|
+
let viewAcc = 0;
|
|
93
|
+
while (end < totalItems && viewAcc < viewportHeight) {
|
|
94
|
+
viewAcc += getValidHeight(heightCache?.[end], itemHeight);
|
|
95
|
+
end++;
|
|
96
|
+
}
|
|
97
|
+
end = Math.min(totalItems, end + 1); // +1 to ensure partial items are visible
|
|
98
|
+
// Safeguard: ensure last item is fully visible when at max scroll
|
|
99
|
+
const totalHeight = totalContentHeight ?? totalItems * itemHeight;
|
|
100
|
+
const maxScrollTop = Math.max(0, totalHeight - viewportHeight);
|
|
101
|
+
// Use strict tolerance to avoid prematurely treating the list as scrolled to the end.
|
|
102
|
+
const tolerance = Math.max(1, Math.floor(itemHeight * BOTTOM_TOLERANCE_FACTOR));
|
|
103
|
+
const isAtBottom = Math.abs(scrollTop - maxScrollTop) <= tolerance;
|
|
104
|
+
if (isAtBottom) {
|
|
105
|
+
// Pack from the end using measured heights when available: walk backward until viewport filled
|
|
106
|
+
const adjustedEnd = totalItems;
|
|
107
|
+
let startCore = adjustedEnd;
|
|
108
|
+
let backAcc = 0;
|
|
109
|
+
while (startCore > 0 && backAcc < viewportHeight) {
|
|
110
|
+
backAcc += getValidHeight(heightCache?.[startCore - 1], itemHeight);
|
|
111
|
+
startCore -= 1;
|
|
138
112
|
}
|
|
139
|
-
// Add buffer to both ends
|
|
140
|
-
const finalStart = Math.max(0, start - bufferSize);
|
|
141
|
-
const finalEnd = Math.min(totalItems, end + bufferSize);
|
|
142
113
|
return {
|
|
143
|
-
start:
|
|
144
|
-
end:
|
|
114
|
+
start: Math.max(0, startCore - bufferSize),
|
|
115
|
+
end: adjustedEnd
|
|
145
116
|
};
|
|
146
117
|
}
|
|
118
|
+
// Add buffer to both ends
|
|
119
|
+
const finalStart = Math.max(0, start - bufferSize);
|
|
120
|
+
const finalEnd = Math.min(totalItems, end + bufferSize);
|
|
121
|
+
return {
|
|
122
|
+
start: finalStart,
|
|
123
|
+
end: finalEnd
|
|
124
|
+
};
|
|
147
125
|
};
|
|
148
126
|
/**
|
|
149
127
|
* Calculates the CSS transform value for positioning the virtual list items.
|
|
150
128
|
*
|
|
151
129
|
* This function determines the vertical offset needed to position the visible items
|
|
152
|
-
* correctly within the viewport
|
|
153
|
-
* visible range.
|
|
130
|
+
* correctly within the viewport.
|
|
154
131
|
*
|
|
155
|
-
* @param {SvelteVirtualListMode} mode - Scroll direction mode
|
|
156
132
|
* @param {number} totalItems - Total number of items in the list
|
|
157
|
-
* @param {number} visibleEnd - Index of the last visible item
|
|
158
133
|
* @param {number} visibleStart - Index of the first visible item
|
|
159
134
|
* @param {number} itemHeight - Height of each list item in pixels
|
|
160
|
-
* @param {number}
|
|
135
|
+
* @param {Record<number, number>} [heightCache] - Cache of measured item heights
|
|
161
136
|
* @returns {number} The calculated transform Y value in pixels
|
|
162
137
|
*/
|
|
163
|
-
export const calculateTransformY = (
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// Calculate transform to position visible items correctly.
|
|
169
|
-
// Use measured heights when available to avoid oscillation caused by
|
|
170
|
-
// averageHeight changes shifting (totalItems - visibleEnd) * avg.
|
|
171
|
-
let basicTransform;
|
|
172
|
-
if (heightCache) {
|
|
173
|
-
const offsetToVisibleEnd = getScrollOffsetForIndex(heightCache, itemHeight, visibleEnd);
|
|
174
|
-
basicTransform = actualTotalHeight - offsetToVisibleEnd;
|
|
175
|
-
}
|
|
176
|
-
else {
|
|
177
|
-
basicTransform = (totalItems - visibleEnd) * itemHeight;
|
|
178
|
-
}
|
|
179
|
-
// When content is smaller than viewport, push to bottom
|
|
180
|
-
const bottomOffset = Math.max(0, effectiveViewport - actualTotalHeight);
|
|
181
|
-
// Snap to integer pixels to avoid subpixel oscillation
|
|
182
|
-
return Math.round(basicTransform + bottomOffset);
|
|
183
|
-
}
|
|
184
|
-
else {
|
|
185
|
-
// For topToBottom, prefer precise offset using measured heights when available
|
|
186
|
-
if (heightCache) {
|
|
187
|
-
const offset = getScrollOffsetForIndex(heightCache, itemHeight, visibleStart);
|
|
188
|
-
return Math.max(0, Math.round(offset));
|
|
189
|
-
}
|
|
190
|
-
return Math.round(visibleStart * itemHeight);
|
|
138
|
+
export const calculateTransformY = (totalItems, visibleStart, itemHeight, heightCache) => {
|
|
139
|
+
// Prefer precise offset using measured heights when available
|
|
140
|
+
if (heightCache) {
|
|
141
|
+
const offset = getScrollOffsetForIndex(heightCache, itemHeight, visibleStart);
|
|
142
|
+
return Math.max(0, Math.round(offset));
|
|
191
143
|
}
|
|
144
|
+
return Math.round(visibleStart * itemHeight);
|
|
192
145
|
};
|
|
193
146
|
/**
|
|
194
147
|
* Updates the virtual list's height and scroll position when necessary.
|
|
195
148
|
*
|
|
196
149
|
* This function handles dynamic updates to the virtual list's dimensions and scroll
|
|
197
|
-
* position, particularly important when the container size changes
|
|
198
|
-
*
|
|
199
|
-
* height and scroll position.
|
|
150
|
+
* position, particularly important when the container size changes. When immediate
|
|
151
|
+
* is true, it forces an immediate update of the height and scroll position.
|
|
200
152
|
*
|
|
201
153
|
* @param {VirtualListState} state - Current state of the virtual list
|
|
202
154
|
* @param {VirtualListSetters} setters - State setters for updating list properties
|
|
203
155
|
* @param {boolean} immediate - Whether to perform the update immediately
|
|
204
156
|
*/
|
|
205
157
|
export const updateHeightAndScroll = (state, setters, immediate = false) => {
|
|
206
|
-
const { initialized,
|
|
207
|
-
const { setHeight
|
|
158
|
+
const { initialized, containerElement, viewportElement } = state;
|
|
159
|
+
const { setHeight } = setters;
|
|
208
160
|
if (immediate) {
|
|
209
161
|
if (containerElement && viewportElement && initialized) {
|
|
210
162
|
const newHeight = containerElement.getBoundingClientRect().height;
|
|
211
163
|
setHeight(newHeight);
|
|
212
|
-
if (mode === 'bottomToTop') {
|
|
213
|
-
const visibleIndex = Math.floor(scrollTop / calculatedItemHeight);
|
|
214
|
-
const newScrollTop = visibleIndex * calculatedItemHeight;
|
|
215
|
-
viewportElement.scrollTop = newScrollTop;
|
|
216
|
-
setScrollTop(newScrollTop);
|
|
217
|
-
}
|
|
218
164
|
}
|
|
219
165
|
}
|
|
220
166
|
};
|
|
@@ -245,7 +191,7 @@ export const updateHeightAndScroll = (state, setters, immediate = false) => {
|
|
|
245
191
|
* 40
|
|
246
192
|
* )
|
|
247
193
|
*/
|
|
248
|
-
export const calculateAverageHeight = (itemElements, visibleRange, heightCache, currentItemHeight, dirtyItems, currentTotalHeight = 0, currentValidCount = 0
|
|
194
|
+
export const calculateAverageHeight = (itemElements, visibleRange, heightCache, currentItemHeight, dirtyItems, currentTotalHeight = 0, currentValidCount = 0) => {
|
|
249
195
|
const validElements = itemElements.filter((el) => el);
|
|
250
196
|
if (validElements.length === 0) {
|
|
251
197
|
return {
|
|
@@ -269,16 +215,7 @@ export const calculateAverageHeight = (itemElements, visibleRange, heightCache,
|
|
|
269
215
|
// Process only dirty items
|
|
270
216
|
dirtyItems.forEach((itemIndex) => {
|
|
271
217
|
// Map original item index to position in itemElements array
|
|
272
|
-
|
|
273
|
-
if (mode === 'bottomToTop') {
|
|
274
|
-
// In bottomToTop, itemElements is reversed relative to the visible range
|
|
275
|
-
// elementIndex should be based on position within the actual array, not theoretical end
|
|
276
|
-
elementIndex = validElements.length - 1 - (itemIndex - visibleRange.start);
|
|
277
|
-
}
|
|
278
|
-
else {
|
|
279
|
-
// In topToBottom, itemElements is normal: [item0, item1, ..., item44, item45]
|
|
280
|
-
elementIndex = itemIndex - visibleRange.start;
|
|
281
|
-
}
|
|
218
|
+
const elementIndex = itemIndex - visibleRange.start;
|
|
282
219
|
const element = validElements[elementIndex];
|
|
283
220
|
if (element && elementIndex >= 0 && elementIndex < validElements.length) {
|
|
284
221
|
try {
|
|
@@ -326,9 +263,7 @@ export const calculateAverageHeight = (itemElements, visibleRange, heightCache,
|
|
|
326
263
|
else {
|
|
327
264
|
// Original behavior: process all visible items
|
|
328
265
|
validElements.forEach((el, i) => {
|
|
329
|
-
const itemIndex =
|
|
330
|
-
? Math.max(0, (visibleRange.end ?? visibleRange.start + validElements.length) - 1 - i)
|
|
331
|
-
: visibleRange.start + i;
|
|
266
|
+
const itemIndex = visibleRange.start + i;
|
|
332
267
|
if (!newHeightCache[itemIndex]) {
|
|
333
268
|
try {
|
|
334
269
|
const height = el.getBoundingClientRect().height;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanspeak/svelte-virtual-list",
|
|
3
|
-
"version": "0.
|
|
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,
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
7
7
|
"virtual-list",
|
|
@@ -59,51 +59,51 @@
|
|
|
59
59
|
"esm-env": "^1.2.2"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@eslint/compat": "^2.0
|
|
62
|
+
"@eslint/compat": "^2.1.0",
|
|
63
63
|
"@eslint/js": "^10.0.1",
|
|
64
64
|
"@faker-js/faker": "^10.4.0",
|
|
65
|
-
"@playwright/cli": "^0.1.
|
|
66
|
-
"@playwright/test": "^1.
|
|
65
|
+
"@playwright/cli": "^0.1.13",
|
|
66
|
+
"@playwright/test": "^1.60.0",
|
|
67
67
|
"@sveltejs/adapter-auto": "^7.0.1",
|
|
68
|
-
"@sveltejs/kit": "^2.
|
|
68
|
+
"@sveltejs/kit": "^2.61.1",
|
|
69
69
|
"@sveltejs/package": "^2.5.7",
|
|
70
|
-
"@sveltejs/vite-plugin-svelte": "^7.
|
|
71
|
-
"@tailwindcss/vite": "^4.
|
|
70
|
+
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
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.
|
|
76
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
77
|
-
"@typescript-eslint/parser": "^8.
|
|
78
|
-
"@vitest/coverage-v8": "^4.1.
|
|
79
|
-
"eslint": "^10.
|
|
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",
|
|
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.17.1",
|
|
83
83
|
"eslint-plugin-unused-imports": "^4.4.1",
|
|
84
|
-
"globals": "^17.
|
|
84
|
+
"globals": "^17.6.0",
|
|
85
85
|
"husky": "^9.1.7",
|
|
86
|
-
"jsdom": "^29.
|
|
87
|
-
"mprocs": "^0.9.
|
|
88
|
-
"prettier": "^3.8.
|
|
86
|
+
"jsdom": "^29.1.1",
|
|
87
|
+
"mprocs": "^0.9.3",
|
|
88
|
+
"prettier": "^3.8.3",
|
|
89
89
|
"prettier-plugin-organize-imports": "^4.3.0",
|
|
90
|
-
"prettier-plugin-svelte": "^
|
|
91
|
-
"prettier-plugin-tailwindcss": "^0.
|
|
92
|
-
"publint": "^0.3.
|
|
93
|
-
"svelte": "^5.55.
|
|
94
|
-
"svelte-check": "^4.4.
|
|
95
|
-
"tailwindcss": "^4.
|
|
90
|
+
"prettier-plugin-svelte": "^4.0.1",
|
|
91
|
+
"prettier-plugin-tailwindcss": "^0.8.0",
|
|
92
|
+
"publint": "^0.3.21",
|
|
93
|
+
"svelte": "^5.55.9",
|
|
94
|
+
"svelte-check": "^4.4.8",
|
|
95
|
+
"tailwindcss": "^4.3.0",
|
|
96
96
|
"tw-animate-css": "^1.4.0",
|
|
97
|
-
"typescript": "^
|
|
98
|
-
"typescript-eslint": "^8.
|
|
99
|
-
"vite": "^8.0.
|
|
100
|
-
"vitest": "^4.1.
|
|
97
|
+
"typescript": "^6.0.3",
|
|
98
|
+
"typescript-eslint": "^8.60.0",
|
|
99
|
+
"vite": "^8.0.14",
|
|
100
|
+
"vitest": "^4.1.7"
|
|
101
101
|
},
|
|
102
102
|
"peerDependencies": {
|
|
103
103
|
"svelte": "^5.0.0"
|
|
104
104
|
},
|
|
105
105
|
"volta": {
|
|
106
|
-
"node": "24.
|
|
106
|
+
"node": "24.15.0"
|
|
107
107
|
},
|
|
108
108
|
"publishConfig": {
|
|
109
109
|
"access": "public"
|