@humanspeak/svelte-virtual-list 0.2.6-beta.6 → 0.2.6-beta.7

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.
@@ -31,31 +31,63 @@ export const calculateScrollPosition = (totalItems, itemHeight, containerHeight)
31
31
  * @param {SvelteVirtualListMode} mode - Scroll direction mode
32
32
  * @returns {SvelteVirtualListPreviousVisibleRange} Range of indices to render
33
33
  */
34
- export const calculateVisibleRange = (scrollTop, viewportHeight, itemHeight, totalItems, bufferSize, mode) => {
34
+ export const calculateVisibleRange = (scrollTop, viewportHeight, itemHeight, totalItems, bufferSize, mode, atBottom, wasAtBottomBeforeHeightChange, lastVisibleRange, totalContentHeight) => {
35
35
  if (mode === 'bottomToTop') {
36
+ // if (wasAtBottomBeforeHeightChange && lastVisibleRange) {
37
+ // // console.log('calculateVisibleRange:wasAtBottomBeforeHeightChange', {
38
+ // // lastVisibleRange,
39
+ // // atBottom,
40
+ // // wasAtBottomBeforeHeightChange
41
+ // // })
42
+ // return lastVisibleRange
43
+ // }
36
44
  const visibleCount = Math.ceil(viewportHeight / itemHeight) + 1;
37
- const bottomIndex = totalItems - Math.floor(scrollTop / itemHeight);
38
- // Safeguard: if bottomIndex is negative, it means scrollTop is too large for current itemHeight
39
- // This can happen when itemHeight changes but scrollTop hasn't been corrected yet
40
- if (bottomIndex < 0) {
41
- // Calculate what scrollTop should be and use that for visible range
42
- const totalHeight = totalItems * itemHeight;
43
- const correctedScrollTop = Math.max(0, totalHeight - viewportHeight);
44
- const correctedBottomIndex = totalItems - Math.floor(correctedScrollTop / itemHeight);
45
- const start = Math.max(0, correctedBottomIndex - visibleCount - bufferSize);
46
- const end = Math.min(totalItems, correctedBottomIndex + bufferSize);
45
+ // In bottomToTop mode, scrollTop represents distance from the total content end
46
+ // scrollTop = 0 means we're at the beginning (showing first items)
47
+ // scrollTop = maxScrollTop means we're at the end (showing last items)
48
+ const totalHeight = totalContentHeight ?? totalItems * itemHeight;
49
+ const maxScrollTop = Math.max(0, totalHeight - viewportHeight);
50
+ // Convert scrollTop to "distance from start" for bottomToTop
51
+ const distanceFromStart = maxScrollTop - scrollTop;
52
+ const startIndex = Math.floor(distanceFromStart / itemHeight);
53
+ // console.log(
54
+ // `[DEBUG] calculateVisibleRange bottomToTop: scrollTop=${scrollTop}, maxScrollTop=${maxScrollTop}, distanceFromStart=${distanceFromStart}, startIndex=${startIndex}`
55
+ // )
56
+ // Safeguard: handle edge cases
57
+ if (startIndex < 0) {
58
+ // We're scrolled beyond the maximum (showing first items)
59
+ const start = 0;
60
+ const end = Math.min(totalItems, visibleCount + bufferSize * 2);
61
+ // console.log(
62
+ // `[DEBUG] calculateVisibleRange (startIndex < 0): start=${start}, end=${end}`
63
+ // )
64
+ // console.log('calculateVisibleRange:startIndex < 0', {
65
+ // start,
66
+ // end,
67
+ // atBottom,
68
+ // wasAtBottomBeforeHeightChange,
69
+ // lastVisibleRange
70
+ // })
47
71
  return { start, end };
48
72
  }
49
73
  // Add buffer to both ends
50
- const start = Math.max(0, bottomIndex - visibleCount - bufferSize);
51
- const end = Math.min(totalItems, bottomIndex + bufferSize);
74
+ const start = Math.max(0, startIndex - bufferSize);
75
+ const end = Math.min(totalItems, startIndex + visibleCount + bufferSize);
76
+ // console.log(`[DEBUG] calculateVisibleRange result: start=${start}, end=${end}`)
77
+ // console.log('calculateVisibleRange:startIndex >= 0', {
78
+ // start,
79
+ // end,
80
+ // atBottom,
81
+ // wasAtBottomBeforeHeightChange,
82
+ // lastVisibleRange
83
+ // })
52
84
  return { start, end };
53
85
  }
54
86
  else {
55
87
  const start = Math.floor(scrollTop / itemHeight);
56
88
  const end = Math.min(totalItems, start + Math.ceil(viewportHeight / itemHeight) + 1);
57
89
  // Safeguard for topToBottom: ensure last item is fully visible when at max scroll
58
- const totalHeight = totalItems * itemHeight;
90
+ const totalHeight = totalContentHeight ?? totalItems * itemHeight;
59
91
  const maxScrollTop = Math.max(0, totalHeight - viewportHeight);
60
92
  // Add dynamic tolerance based on item height for browser rendering precision
61
93
  const tolerance = Math.max(itemHeight, 10); // At least one full item height or 10px minimum
@@ -65,16 +97,24 @@ export const calculateVisibleRange = (scrollTop, viewportHeight, itemHeight, tot
65
97
  const adjustedEnd = totalItems;
66
98
  const visibleItemCount = Math.ceil(viewportHeight / itemHeight) + bufferSize + 1;
67
99
  const adjustedStart = Math.max(0, adjustedEnd - visibleItemCount);
68
- // TopToBottom safeguard is now active
69
100
  return {
70
101
  start: adjustedStart,
71
102
  end: adjustedEnd
72
103
  };
73
104
  }
105
+ // console.log('calculateVisibleRange:isNotAtBottom', {
106
+ // start: Math.max(0, start - bufferSize),
107
+ // end: Math.min(totalItems, end + bufferSize),
108
+ // atBottom,
109
+ // wasAtBottomBeforeHeightChange,
110
+ // lastVisibleRange
111
+ // })
74
112
  // Add buffer to both ends
113
+ const finalStart = Math.max(0, start - bufferSize);
114
+ const finalEnd = Math.min(totalItems, end + bufferSize);
75
115
  return {
76
- start: Math.max(0, start - bufferSize),
77
- end: Math.min(totalItems, end + bufferSize)
116
+ start: finalStart,
117
+ end: finalEnd
78
118
  };
79
119
  }
80
120
  };
@@ -90,14 +130,18 @@ export const calculateVisibleRange = (scrollTop, viewportHeight, itemHeight, tot
90
130
  * @param {number} visibleEnd - Index of the last visible item
91
131
  * @param {number} visibleStart - Index of the first visible item
92
132
  * @param {number} itemHeight - Height of each list item in pixels
133
+ * @param {number} viewportHeight - Height of the viewport in pixels
93
134
  * @returns {number} The calculated transform Y value in pixels
94
135
  */
95
- export const calculateTransformY = (mode, totalItems, visibleEnd, visibleStart, itemHeight) => {
136
+ export const calculateTransformY = (mode, totalItems, visibleEnd, visibleStart, itemHeight, viewportHeight, totalContentHeight) => {
96
137
  if (mode === 'bottomToTop') {
97
- // In bottomToTop mode, we need to position the container so that
98
- // the first visible item (visibleStart) aligns with its correct position
99
- // from the bottom of the total content
100
- return (totalItems - visibleEnd) * itemHeight;
138
+ // In bottomToTop mode, position items so they stack from bottom up
139
+ const actualTotalHeight = totalContentHeight ?? totalItems * itemHeight;
140
+ // Calculate transform to position visible items correctly
141
+ const basicTransform = (totalItems - visibleEnd) * itemHeight;
142
+ // When content is smaller than viewport, push to bottom
143
+ const bottomOffset = Math.max(0, viewportHeight - actualTotalHeight);
144
+ return basicTransform + bottomOffset;
101
145
  }
102
146
  else {
103
147
  return visibleStart * itemHeight;
@@ -158,7 +202,7 @@ export const updateHeightAndScroll = (state, setters, immediate = false) => {
158
202
  * 40
159
203
  * )
160
204
  */
161
- export const calculateAverageHeight = (itemElements, visibleRange, heightCache, currentItemHeight, dirtyItems, currentTotalHeight = 0, currentValidCount = 0) => {
205
+ export const calculateAverageHeight = (itemElements, visibleRange, heightCache, currentItemHeight, dirtyItems, currentTotalHeight = 0, currentValidCount = 0, mode = 'topToBottom') => {
162
206
  const validElements = itemElements.filter((el) => el);
163
207
  if (validElements.length === 0) {
164
208
  return {
@@ -167,11 +211,13 @@ export const calculateAverageHeight = (itemElements, visibleRange, heightCache,
167
211
  updatedHeightCache: heightCache,
168
212
  clearedDirtyItems: new Set(),
169
213
  newTotalHeight: currentTotalHeight,
170
- newValidCount: currentValidCount
214
+ newValidCount: currentValidCount,
215
+ heightChanges: []
171
216
  };
172
217
  }
173
218
  const newHeightCache = { ...heightCache };
174
219
  const clearedDirtyItems = new Set();
220
+ const heightChanges = [];
175
221
  // Start with current running totals (O(1) instead of O(n))
176
222
  let totalValidHeight = currentTotalHeight;
177
223
  let validHeightCount = currentValidCount;
@@ -179,15 +225,36 @@ export const calculateAverageHeight = (itemElements, visibleRange, heightCache,
179
225
  if (dirtyItems.size > 0) {
180
226
  // Process only dirty items
181
227
  dirtyItems.forEach((itemIndex) => {
182
- const elementIndex = itemIndex - visibleRange.start;
228
+ // Map original item index to position in itemElements array
229
+ let elementIndex;
230
+ if (mode === 'bottomToTop') {
231
+ // In bottomToTop, itemElements is reversed relative to the visible range
232
+ // elementIndex should be based on position within the actual array, not theoretical end
233
+ elementIndex = validElements.length - 1 - (itemIndex - visibleRange.start);
234
+ }
235
+ else {
236
+ // In topToBottom, itemElements is normal: [item0, item1, ..., item44, item45]
237
+ elementIndex = itemIndex - visibleRange.start;
238
+ }
183
239
  const element = validElements[elementIndex];
184
240
  if (element && elementIndex >= 0 && elementIndex < validElements.length) {
185
241
  try {
242
+ // await tick()
243
+ void element.offsetHeight;
186
244
  const height = element.getBoundingClientRect().height;
245
+ const oldHeight = newHeightCache[itemIndex];
187
246
  if (Number.isFinite(height) && height > 0) {
188
- const oldHeight = newHeightCache[itemIndex];
189
247
  // Only update if height actually changed (use smaller tolerance for precision)
190
248
  if (!oldHeight || Math.abs(oldHeight - height) >= 0.1) {
249
+ // Track the height change for scroll correction
250
+ const actualOldHeight = oldHeight || currentItemHeight;
251
+ const delta = height - actualOldHeight;
252
+ heightChanges.push({
253
+ index: itemIndex,
254
+ oldHeight: actualOldHeight,
255
+ newHeight: height,
256
+ delta
257
+ });
191
258
  // Update running totals
192
259
  if (oldHeight && Number.isFinite(oldHeight) && oldHeight > 0) {
193
260
  // Replace old height with new height in running total
@@ -208,6 +275,9 @@ export const calculateAverageHeight = (itemElements, visibleRange, heightCache,
208
275
  clearedDirtyItems.add(itemIndex);
209
276
  }
210
277
  }
278
+ else {
279
+ clearedDirtyItems.add(itemIndex); // Still clear it from dirty items
280
+ }
211
281
  });
212
282
  }
213
283
  else {
@@ -237,7 +307,8 @@ export const calculateAverageHeight = (itemElements, visibleRange, heightCache,
237
307
  updatedHeightCache: newHeightCache,
238
308
  clearedDirtyItems,
239
309
  newTotalHeight: totalValidHeight,
240
- newValidCount: validHeightCount
310
+ newValidCount: validHeightCount,
311
+ heightChanges
241
312
  };
242
313
  };
243
314
  /**
@@ -283,31 +354,6 @@ onComplete) => {
283
354
  };
284
355
  await processChunk(0);
285
356
  };
286
- /**
287
- * Builds a block sum array for fast offset calculation in large virtual lists.
288
- * Each entry in the array is the total height up to the end of that block (exclusive).
289
- *
290
- * @param {HeightCache} heightCache - Map of measured item heights with dirty tracking
291
- * @param {number} calculatedItemHeight - Estimated height for unmeasured items
292
- * @param {number} totalItems - Total number of items in the list
293
- * @param {number} blockSize - Number of items per block
294
- * @returns {number[]} Array of prefix sums at each block boundary
295
- */
296
- export const buildBlockSums = (heightCache, calculatedItemHeight, totalItems, blockSize = 1000) => {
297
- const blockSums = [];
298
- let sum = 0;
299
- for (let i = 0; i < totalItems; i++) {
300
- sum += heightCache[i] ?? calculatedItemHeight;
301
- if ((i + 1) % blockSize === 0) {
302
- blockSums.push(sum);
303
- }
304
- }
305
- // Push the last partial block if needed
306
- if (totalItems % blockSize !== 0) {
307
- blockSums.push(sum);
308
- }
309
- return blockSums;
310
- };
311
357
  /**
312
358
  * Calculates the scroll offset (in pixels) needed to bring a specific item into view in a virtual list.
313
359
  *
@@ -336,7 +382,8 @@ export const getScrollOffsetForIndex = (heightCache, calculatedItemHeight, idx,
336
382
  // Fallback: O(n) for a single query
337
383
  let offset = 0;
338
384
  for (let i = 0; i < idx; i++) {
339
- offset += heightCache[i] ?? calculatedItemHeight;
385
+ const height = heightCache[i] ?? calculatedItemHeight;
386
+ offset += height;
340
387
  }
341
388
  return offset;
342
389
  }
@@ -74,4 +74,4 @@ export declare function shouldShowDebugInfo(prevRange: {
74
74
  export declare function createDebugInfo(visibleRange: {
75
75
  start: number;
76
76
  end: number;
77
- }, totalItems: number, processedItems: number, averageItemHeight: number, scrollTop: number, viewportHeight: number): SvelteVirtualListDebugInfo;
77
+ }, totalItems: number, processedItems: number, averageItemHeight: number, scrollTop: number, viewportHeight: number, totalHeight: number): SvelteVirtualListDebugInfo;
@@ -70,8 +70,7 @@ export function shouldShowDebugInfo(prevRange, currentRange, prevHeight, current
70
70
  *
71
71
  * @throws {Error} Will throw if end index is less than start index in visibleRange
72
72
  */
73
- export function createDebugInfo(visibleRange, totalItems, processedItems, averageItemHeight, scrollTop, viewportHeight) {
74
- const totalHeight = totalItems * averageItemHeight;
73
+ export function createDebugInfo(visibleRange, totalItems, processedItems, averageItemHeight, scrollTop, viewportHeight, totalHeight) {
75
74
  const atTop = scrollTop <= 1; // Small tolerance for floating point precision
76
75
  const atBottom = scrollTop >= totalHeight - viewportHeight - 1; // Small tolerance
77
76
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-virtual-list",
3
- "version": "0.2.6-beta.6",
3
+ "version": "0.2.6-beta.7",
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, chat interfaces, and any application requiring the rendering of thousands of items without compromising performance. Zero dependencies and fully customizable.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -44,7 +44,8 @@
44
44
  "dist",
45
45
  "!dist/**/*.test.*",
46
46
  "!dist/**/*.spec.*",
47
- "!dist/test/**/*"
47
+ "!dist/test/**/*",
48
+ "!dist/reactive-height-manager/test/**/*"
48
49
  ],
49
50
  "scripts": {
50
51
  "build": "vite build && npm run package",
@@ -58,14 +59,14 @@
58
59
  "prepare": "husky",
59
60
  "prepublishOnly": "npm run package",
60
61
  "preview": "vite preview",
61
- "test": "vitest run --coverage",
62
+ "test": "vitest run --coverage --",
62
63
  "test:all": "npm run test && npm run test:e2e",
63
- "test:e2e": "playwright test",
64
- "test:e2e:debug": "playwright test --debug",
64
+ "test:e2e": "playwright test --",
65
+ "test:e2e:debug": "playwright test --debug --",
65
66
  "test:e2e:report": "playwright show-report",
66
- "test:e2e:ui": "playwright test --ui",
67
- "test:only": "vitest run",
68
- "test:watch": "vitest"
67
+ "test:e2e:ui": "playwright test --ui --",
68
+ "test:only": "vitest run --",
69
+ "test:watch": "vitest --"
69
70
  },
70
71
  "overrides": {
71
72
  "@sveltejs/kit": {
@@ -79,17 +80,17 @@
79
80
  "@eslint/compat": "^1.3.1",
80
81
  "@eslint/js": "^9.32.0",
81
82
  "@faker-js/faker": "^9.9.0",
82
- "@playwright/test": "^1.54.1",
83
- "@sveltejs/adapter-auto": "^6.0.1",
84
- "@sveltejs/kit": "^2.26.1",
85
- "@sveltejs/package": "^2.4.0",
83
+ "@playwright/test": "^1.54.2",
84
+ "@sveltejs/adapter-auto": "^6.0.2",
85
+ "@sveltejs/kit": "^2.27.3",
86
+ "@sveltejs/package": "^2.4.1",
86
87
  "@sveltejs/vite-plugin-svelte": "^6.1.0",
87
88
  "@testing-library/jest-dom": "^6.6.4",
88
89
  "@testing-library/svelte": "^5.2.8",
89
90
  "@testing-library/user-event": "^14.6.1",
90
- "@types/node": "^24.1.0",
91
- "@typescript-eslint/eslint-plugin": "^8.38.0",
92
- "@typescript-eslint/parser": "^8.38.0",
91
+ "@types/node": "^24.2.0",
92
+ "@typescript-eslint/eslint-plugin": "^8.39.0",
93
+ "@typescript-eslint/parser": "^8.39.0",
93
94
  "@vitest/coverage-v8": "^3.2.4",
94
95
  "eslint": "^9.32.0",
95
96
  "eslint-config-prettier": "^10.1.8",
@@ -105,11 +106,11 @@
105
106
  "prettier-plugin-svelte": "^3.4.0",
106
107
  "prettier-plugin-tailwindcss": "^0.6.14",
107
108
  "publint": "^0.3.12",
108
- "svelte": "^5.37.1",
109
- "svelte-check": "^4.3.0",
110
- "typescript": "^5.8.3",
111
- "typescript-eslint": "^8.38.0",
112
- "vite": "^7.0.6",
109
+ "svelte": "^5.38.0",
110
+ "svelte-check": "^4.3.1",
111
+ "typescript": "^5.9.2",
112
+ "typescript-eslint": "^8.39.0",
113
+ "vite": "^7.1.0",
113
114
  "vitest": "^3.2.4"
114
115
  },
115
116
  "peerDependencies": {