@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.
@@ -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, and scroll direction. Includes a buffer zone to enable smooth scrolling
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, mode, totalContentHeight, heightCache }) => {
80
- if (mode === 'bottomToTop') {
81
- const visibleCount = Math.ceil(viewportHeight / itemHeight) + 1;
82
- // In bottomToTop mode, scrollTop represents distance from the total content end
83
- // scrollTop = 0 means we're at the beginning (showing first items)
84
- // scrollTop = maxScrollTop means we're at the end (showing last items)
85
- const totalHeight = totalContentHeight ?? totalItems * itemHeight;
86
- const maxScrollTop = Math.max(0, totalHeight - viewportHeight);
87
- // Convert scrollTop to "distance from start" for bottomToTop
88
- const distanceFromStart = maxScrollTop - scrollTop;
89
- const startIndex = Math.floor(distanceFromStart / itemHeight);
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
- else {
103
- // Walk forward through measured heights to find the correct start index
104
- // instead of dividing by average height (which is wrong for variable-height items).
105
- let start = 0;
106
- let acc = 0;
107
- while (start < totalItems) {
108
- const h = getValidHeight(heightCache?.[start], itemHeight);
109
- if (acc + h > scrollTop)
110
- break;
111
- acc += h;
112
- start++;
113
- }
114
- // Walk forward from start to find end
115
- let end = start;
116
- let viewAcc = 0;
117
- while (end < totalItems && viewAcc < viewportHeight) {
118
- viewAcc += getValidHeight(heightCache?.[end], itemHeight);
119
- end++;
120
- }
121
- end = Math.min(totalItems, end + 1); // +1 to ensure partial items are visible
122
- // Safeguard for topToBottom: ensure last item is fully visible when at max scroll
123
- const totalHeight = totalContentHeight ?? totalItems * itemHeight;
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: finalStart,
147
- end: finalEnd
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, accounting for the scroll direction and current
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} viewportHeight - Height of the viewport in pixels
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 = (mode, totalItems, visibleEnd, visibleStart, itemHeight, viewportHeight, totalContentHeight, heightCache, measuredFallbackHeight) => {
167
- const effectiveViewport = viewportHeight || measuredFallbackHeight || 0;
168
- if (mode === 'bottomToTop') {
169
- // In bottomToTop mode, position items so they stack from bottom up
170
- const actualTotalHeight = totalContentHeight ?? totalItems * itemHeight;
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 or when switching
201
- * scroll directions. When immediate is true, it forces an immediate update of the
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, mode, containerElement, viewportElement, calculatedItemHeight, scrollTop } = state;
210
- const { setHeight, setScrollTop } = setters;
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, mode = 'topToBottom') => {
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
- let elementIndex;
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 = mode === 'bottomToTop'
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.6",
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.",
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.3",
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.1",
66
- "@playwright/test": "^1.58.2",
65
+ "@playwright/cli": "^0.1.13",
66
+ "@playwright/test": "^1.60.0",
67
67
  "@sveltejs/adapter-auto": "^7.0.1",
68
- "@sveltejs/kit": "^2.55.0",
68
+ "@sveltejs/kit": "^2.61.1",
69
69
  "@sveltejs/package": "^2.5.7",
70
- "@sveltejs/vite-plugin-svelte": "^7.0.0",
71
- "@tailwindcss/vite": "^4.2.2",
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.5.0",
76
- "@typescript-eslint/eslint-plugin": "^8.58.0",
77
- "@typescript-eslint/parser": "^8.58.0",
78
- "@vitest/coverage-v8": "^4.1.2",
79
- "eslint": "^10.1.0",
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.16.0",
82
+ "eslint-plugin-svelte": "^3.17.1",
83
83
  "eslint-plugin-unused-imports": "^4.4.1",
84
- "globals": "^17.4.0",
84
+ "globals": "^17.6.0",
85
85
  "husky": "^9.1.7",
86
- "jsdom": "^29.0.1",
87
- "mprocs": "^0.9.2",
88
- "prettier": "^3.8.1",
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": "^3.5.1",
91
- "prettier-plugin-tailwindcss": "^0.7.2",
92
- "publint": "^0.3.18",
93
- "svelte": "^5.55.1",
94
- "svelte-check": "^4.4.5",
95
- "tailwindcss": "^4.2.2",
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": "^5.9.3",
98
- "typescript-eslint": "^8.58.0",
99
- "vite": "^8.0.3",
100
- "vitest": "^4.1.2"
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.13.0"
106
+ "node": "24.15.0"
107
107
  },
108
108
  "publishConfig": {
109
109
  "access": "public"