@humanspeak/svelte-virtual-list 0.4.6 → 0.5.1
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 +26 -746
- 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 +12 -18
- package/dist/utils/virtualList.js +60 -128
- package/package.json +29 -29
|
@@ -56,8 +56,8 @@ 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
|
-
* Calculates which items should be visible based on the current scroll position
|
|
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
63
|
* @param options - Inputs used to compute the visible range (see {@link VisibleRangeOptions}).
|
|
@@ -70,154 +70,97 @@ export const calculateScrollPosition = (totalItems, itemHeight, containerHeight)
|
|
|
70
70
|
* viewportHeight: 400,
|
|
71
71
|
* itemHeight: 40,
|
|
72
72
|
* totalItems: 1000,
|
|
73
|
-
* bufferSize: 2
|
|
74
|
-
* mode: 'topToBottom'
|
|
73
|
+
* bufferSize: 2
|
|
75
74
|
* })
|
|
76
75
|
* // range => { start: 3, end: 15 }
|
|
77
76
|
* ```
|
|
78
77
|
*/
|
|
79
|
-
export const calculateVisibleRange = ({ scrollTop, viewportHeight, itemHeight, totalItems, bufferSize,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// Safeguard: handle edge cases
|
|
91
|
-
if (startIndex < 0) {
|
|
92
|
-
// We're scrolled beyond the maximum (showing first items)
|
|
93
|
-
const start = 0;
|
|
94
|
-
const end = Math.min(totalItems, visibleCount + bufferSize * 2);
|
|
95
|
-
return { start, end };
|
|
96
|
-
}
|
|
97
|
-
// Add buffer to both ends
|
|
98
|
-
const start = Math.max(0, startIndex - bufferSize);
|
|
99
|
-
const end = Math.min(totalItems, startIndex + visibleCount + bufferSize);
|
|
100
|
-
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++;
|
|
101
89
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const maxScrollTop = Math.max(0, totalHeight - viewportHeight);
|
|
125
|
-
// Use strict tolerance to avoid premature bottom anchoring that leaves a visible gap
|
|
126
|
-
const tolerance = Math.max(1, Math.floor(itemHeight * BOTTOM_TOLERANCE_FACTOR));
|
|
127
|
-
const isAtBottom = Math.abs(scrollTop - maxScrollTop) <= tolerance;
|
|
128
|
-
if (isAtBottom) {
|
|
129
|
-
// Pack from the end using measured heights when available: walk backward until viewport filled
|
|
130
|
-
const adjustedEnd = totalItems;
|
|
131
|
-
let startCore = adjustedEnd;
|
|
132
|
-
let backAcc = 0;
|
|
133
|
-
while (startCore > 0 && backAcc < viewportHeight) {
|
|
134
|
-
backAcc += getValidHeight(heightCache?.[startCore - 1], itemHeight);
|
|
135
|
-
startCore -= 1;
|
|
136
|
-
}
|
|
137
|
-
return {
|
|
138
|
-
start: Math.max(0, startCore - bufferSize),
|
|
139
|
-
end: adjustedEnd
|
|
140
|
-
};
|
|
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;
|
|
141
112
|
}
|
|
142
|
-
// Add buffer to both ends
|
|
143
|
-
const finalStart = Math.max(0, start - bufferSize);
|
|
144
|
-
const finalEnd = Math.min(totalItems, end + bufferSize);
|
|
145
113
|
return {
|
|
146
|
-
start:
|
|
147
|
-
end:
|
|
114
|
+
start: Math.max(0, startCore - bufferSize),
|
|
115
|
+
end: adjustedEnd
|
|
148
116
|
};
|
|
149
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
|
+
};
|
|
150
125
|
};
|
|
151
126
|
/**
|
|
152
127
|
* Calculates the CSS transform value for positioning the virtual list items.
|
|
153
128
|
*
|
|
154
129
|
* This function determines the vertical offset needed to position the visible items
|
|
155
|
-
* correctly within the viewport
|
|
156
|
-
* visible range.
|
|
130
|
+
* correctly within the viewport.
|
|
157
131
|
*
|
|
158
|
-
* @param {SvelteVirtualListMode} mode - Scroll direction mode
|
|
159
132
|
* @param {number} totalItems - Total number of items in the list
|
|
160
|
-
* @param {number} visibleEnd - Index of the last visible item
|
|
161
133
|
* @param {number} visibleStart - Index of the first visible item
|
|
162
134
|
* @param {number} itemHeight - Height of each list item in pixels
|
|
163
|
-
* @param {number}
|
|
135
|
+
* @param {Record<number, number>} [heightCache] - Cache of measured item heights
|
|
164
136
|
* @returns {number} The calculated transform Y value in pixels
|
|
165
137
|
*/
|
|
166
|
-
export const calculateTransformY = (
|
|
167
|
-
|
|
168
|
-
if (
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// Calculate transform to position visible items correctly.
|
|
172
|
-
// Use measured heights when available to avoid oscillation caused by
|
|
173
|
-
// averageHeight changes shifting (totalItems - visibleEnd) * avg.
|
|
174
|
-
let basicTransform;
|
|
175
|
-
if (heightCache) {
|
|
176
|
-
const offsetToVisibleEnd = getScrollOffsetForIndex(heightCache, itemHeight, visibleEnd);
|
|
177
|
-
basicTransform = actualTotalHeight - offsetToVisibleEnd;
|
|
178
|
-
}
|
|
179
|
-
else {
|
|
180
|
-
basicTransform = (totalItems - visibleEnd) * itemHeight;
|
|
181
|
-
}
|
|
182
|
-
// When content is smaller than viewport, push to bottom
|
|
183
|
-
const bottomOffset = Math.max(0, effectiveViewport - actualTotalHeight);
|
|
184
|
-
// Snap to integer pixels to avoid subpixel oscillation
|
|
185
|
-
return Math.round(basicTransform + bottomOffset);
|
|
186
|
-
}
|
|
187
|
-
else {
|
|
188
|
-
// For topToBottom, prefer precise offset using measured heights when available
|
|
189
|
-
if (heightCache) {
|
|
190
|
-
const offset = getScrollOffsetForIndex(heightCache, itemHeight, visibleStart);
|
|
191
|
-
return Math.max(0, Math.round(offset));
|
|
192
|
-
}
|
|
193
|
-
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));
|
|
194
143
|
}
|
|
144
|
+
return Math.round(visibleStart * itemHeight);
|
|
195
145
|
};
|
|
196
146
|
/**
|
|
197
147
|
* Updates the virtual list's height and scroll position when necessary.
|
|
198
148
|
*
|
|
199
149
|
* This function handles dynamic updates to the virtual list's dimensions and scroll
|
|
200
|
-
* position, particularly important when the container size changes
|
|
201
|
-
*
|
|
202
|
-
* 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.
|
|
203
152
|
*
|
|
204
153
|
* @param {VirtualListState} state - Current state of the virtual list
|
|
205
154
|
* @param {VirtualListSetters} setters - State setters for updating list properties
|
|
206
155
|
* @param {boolean} immediate - Whether to perform the update immediately
|
|
207
156
|
*/
|
|
208
157
|
export const updateHeightAndScroll = (state, setters, immediate = false) => {
|
|
209
|
-
const { initialized,
|
|
210
|
-
const { setHeight
|
|
158
|
+
const { initialized, containerElement, viewportElement } = state;
|
|
159
|
+
const { setHeight } = setters;
|
|
211
160
|
if (immediate) {
|
|
212
161
|
if (containerElement && viewportElement && initialized) {
|
|
213
162
|
const newHeight = containerElement.getBoundingClientRect().height;
|
|
214
163
|
setHeight(newHeight);
|
|
215
|
-
if (mode === 'bottomToTop') {
|
|
216
|
-
const visibleIndex = Math.floor(scrollTop / calculatedItemHeight);
|
|
217
|
-
const newScrollTop = visibleIndex * calculatedItemHeight;
|
|
218
|
-
viewportElement.scrollTop = newScrollTop;
|
|
219
|
-
setScrollTop(newScrollTop);
|
|
220
|
-
}
|
|
221
164
|
}
|
|
222
165
|
}
|
|
223
166
|
};
|
|
@@ -248,7 +191,7 @@ export const updateHeightAndScroll = (state, setters, immediate = false) => {
|
|
|
248
191
|
* 40
|
|
249
192
|
* )
|
|
250
193
|
*/
|
|
251
|
-
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) => {
|
|
252
195
|
const validElements = itemElements.filter((el) => el);
|
|
253
196
|
if (validElements.length === 0) {
|
|
254
197
|
return {
|
|
@@ -272,16 +215,7 @@ export const calculateAverageHeight = (itemElements, visibleRange, heightCache,
|
|
|
272
215
|
// Process only dirty items
|
|
273
216
|
dirtyItems.forEach((itemIndex) => {
|
|
274
217
|
// Map original item index to position in itemElements array
|
|
275
|
-
|
|
276
|
-
if (mode === 'bottomToTop') {
|
|
277
|
-
// In bottomToTop, itemElements is reversed relative to the visible range
|
|
278
|
-
// elementIndex should be based on position within the actual array, not theoretical end
|
|
279
|
-
elementIndex = validElements.length - 1 - (itemIndex - visibleRange.start);
|
|
280
|
-
}
|
|
281
|
-
else {
|
|
282
|
-
// In topToBottom, itemElements is normal: [item0, item1, ..., item44, item45]
|
|
283
|
-
elementIndex = itemIndex - visibleRange.start;
|
|
284
|
-
}
|
|
218
|
+
const elementIndex = itemIndex - visibleRange.start;
|
|
285
219
|
const element = validElements[elementIndex];
|
|
286
220
|
if (element && elementIndex >= 0 && elementIndex < validElements.length) {
|
|
287
221
|
try {
|
|
@@ -329,9 +263,7 @@ export const calculateAverageHeight = (itemElements, visibleRange, heightCache,
|
|
|
329
263
|
else {
|
|
330
264
|
// Original behavior: process all visible items
|
|
331
265
|
validElements.forEach((el, i) => {
|
|
332
|
-
const itemIndex =
|
|
333
|
-
? Math.max(0, (visibleRange.end ?? visibleRange.start + validElements.length) - 1 - i)
|
|
334
|
-
: visibleRange.start + i;
|
|
266
|
+
const itemIndex = visibleRange.start + i;
|
|
335
267
|
if (!newHeightCache[itemIndex]) {
|
|
336
268
|
try {
|
|
337
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.1",
|
|
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"
|