@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.
@@ -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
- * This function calculates which items should be visible based on the current scroll position,
60
- * viewport size, and scroll direction. It 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
- * @param {number} scrollTop - Current scroll position in pixels
64
- * @param {number} viewportHeight - Height of the visible area in pixels
65
- * @param {number} itemHeight - Height of each list item in pixels
66
- * @param {number} totalItems - Total number of items in the list
67
- * @param {number} bufferSize - Number of items to render outside the visible area
68
- * @param {SvelteVirtualListMode} mode - Scroll direction mode
69
- * @param {boolean} atBottom - Whether the list is scrolled to the bottom (unused, legacy parameter)
70
- * @param {boolean} wasAtBottomBeforeHeightChange - Whether the list was at bottom before a height change (unused, legacy parameter)
71
- * @param {SvelteVirtualListPreviousVisibleRange | null} lastVisibleRange - Previous visible range (unused, legacy parameter)
72
- * @param {number} [totalContentHeight] - Pre-calculated total content height; defaults to totalItems * itemHeight
73
- * @param {Record<number, number>} [heightCache] - Cache of measured item heights keyed by index, used in topToBottom mode to walk actual heights instead of dividing by average
74
- * @returns {SvelteVirtualListPreviousVisibleRange} Range of indices to render
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, mode, atBottom, wasAtBottomBeforeHeightChange, lastVisibleRange, totalContentHeight, heightCache) => {
77
- if (mode === 'bottomToTop') {
78
- const visibleCount = Math.ceil(viewportHeight / itemHeight) + 1;
79
- // In bottomToTop mode, scrollTop represents distance from the total content end
80
- // scrollTop = 0 means we're at the beginning (showing first items)
81
- // scrollTop = maxScrollTop means we're at the end (showing last items)
82
- const totalHeight = totalContentHeight ?? totalItems * itemHeight;
83
- const maxScrollTop = Math.max(0, totalHeight - viewportHeight);
84
- // Convert scrollTop to "distance from start" for bottomToTop
85
- const distanceFromStart = maxScrollTop - scrollTop;
86
- const startIndex = Math.floor(distanceFromStart / itemHeight);
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
- else {
100
- // Walk forward through measured heights to find the correct start index
101
- // instead of dividing by average height (which is wrong for variable-height items).
102
- let start = 0;
103
- let acc = 0;
104
- while (start < totalItems) {
105
- const h = getValidHeight(heightCache?.[start], itemHeight);
106
- if (acc + h > scrollTop)
107
- break;
108
- acc += h;
109
- start++;
110
- }
111
- // Walk forward from start to find end
112
- let end = start;
113
- let viewAcc = 0;
114
- while (end < totalItems && viewAcc < viewportHeight) {
115
- viewAcc += getValidHeight(heightCache?.[end], itemHeight);
116
- end++;
117
- }
118
- end = Math.min(totalItems, end + 1); // +1 to ensure partial items are visible
119
- // Safeguard for topToBottom: ensure last item is fully visible when at max scroll
120
- const totalHeight = totalContentHeight ?? totalItems * itemHeight;
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: finalStart,
144
- end: finalEnd
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, accounting for the scroll direction and current
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} viewportHeight - Height of the viewport in pixels
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 = (mode, totalItems, visibleEnd, visibleStart, itemHeight, viewportHeight, totalContentHeight, heightCache, measuredFallbackHeight) => {
164
- const effectiveViewport = viewportHeight || measuredFallbackHeight || 0;
165
- if (mode === 'bottomToTop') {
166
- // In bottomToTop mode, position items so they stack from bottom up
167
- const actualTotalHeight = totalContentHeight ?? totalItems * itemHeight;
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 or when switching
198
- * scroll directions. When immediate is true, it forces an immediate update of the
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, mode, containerElement, viewportElement, calculatedItemHeight, scrollTop } = state;
207
- const { setHeight, setScrollTop } = setters;
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, mode = 'topToBottom') => {
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
- let elementIndex;
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 = mode === 'bottomToTop'
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.5",
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.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.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.57.2",
77
- "@typescript-eslint/parser": "^8.57.2",
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.0",
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.57.2",
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"