@humanspeak/svelte-virtual-list 0.4.0 β†’ 0.4.2

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/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2024-2025 Humanspeak, Inc.
1
+ Copyright (c) 2024-2026 Humanspeak, Inc.
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
package/README.md CHANGED
@@ -31,108 +31,20 @@ A high-performance virtual list component for Svelte 5 applications that efficie
31
31
  - πŸ•ΉοΈ Programmatic scrolling with `scroll`
32
32
  - ♾️ Infinite scroll support with `onLoadMore`
33
33
 
34
- ## scroll: Programmatic Scrolling
34
+ ## Requirements
35
35
 
36
- You can now programmatically scroll to any item in the list using the `scroll` method. This is useful for chat apps, jump-to-item navigation, and more. You can check the usage in `src/routes/tests/scroll`. Thank you for the feature request!
37
-
38
- ### Usage Example
39
-
40
- ```svelte
41
- <script lang="ts">
42
- import SvelteVirtualList from '@humanspeak/svelte-virtual-list'
43
- let listRef
44
- const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` }))
45
-
46
- function goToItem5000() {
47
- // Scroll to item 5000 with smooth scrolling and auto alignment
48
- listRef.scroll({ index: 5000, smoothScroll: true, align: 'auto' })
49
- }
50
- </script>
51
-
52
- <button on:click={goToItem5000}> Scroll to item 5000 </button>
53
- <SvelteVirtualList {items} bind:this={listRef}>
54
- {#snippet renderItem(item)}
55
- <div>{item.text}</div>
56
- {/snippet}
57
- </SvelteVirtualList>
58
- ```
59
-
60
- ### API
61
-
62
- - `scroll(options: { index: number; smoothScroll?: boolean; shouldThrowOnBounds?: boolean; align?: 'auto' | 'top' | 'bottom' | 'nearest' })`
63
- - `index`: The item index to scroll to (0-based)
64
- - `smoothScroll`: If true, uses smooth scrolling (default: true)
65
- - `shouldThrowOnBounds`: If true, throws if index is out of bounds (default: true)
66
- - `align`: Where to align the item in the viewport:
67
- - `'auto'` (default): Only scroll if not visible, align to top or bottom as appropriate
68
- - `'top'`: Always align to the top
69
- - `'bottom'`: Always align to the bottom
70
- - `'nearest'`: Scroll as little as possible to bring the item into view (like native scrollIntoView({ block: 'nearest' }))
71
-
72
- #### Usage Examples
73
-
74
- ```svelte
75
- <button on:click={() => listRef.scroll({ index: 5000, align: 'nearest' })}>
76
- Scroll to item 5000 (nearest)
77
- </button>
78
- ```
79
-
80
- ## Infinite Scroll
81
-
82
- Load more data automatically as users scroll near the end of the list. Perfect for paginated APIs, infinite feeds, and chat applications.
83
-
84
- ```svelte
85
- <script lang="ts">
86
- import SvelteVirtualList from '@humanspeak/svelte-virtual-list'
87
-
88
- let items = $state([...initialItems])
89
- let hasMore = $state(true)
90
-
91
- async function loadMore() {
92
- const newItems = await fetchMoreItems()
93
- items = [...items, ...newItems]
94
- if (newItems.length === 0) {
95
- hasMore = false
96
- }
97
- }
98
- </script>
99
-
100
- <SvelteVirtualList {items} onLoadMore={loadMore} loadMoreThreshold={20} {hasMore}>
101
- {#snippet renderItem(item)}
102
- <div>{item.text}</div>
103
- {/snippet}
104
- </SvelteVirtualList>
105
- ```
106
-
107
- ### Infinite Scroll Props
108
-
109
- | Prop | Type | Default | Description |
110
- | ------------------- | ----------------------------- | ------- | ---------------------------------------------------- |
111
- | `onLoadMore` | `() => void \| Promise<void>` | - | Callback when more data is needed (supports async) |
112
- | `loadMoreThreshold` | `number` | `20` | Number of items from the end to trigger `onLoadMore` |
113
- | `hasMore` | `boolean` | `true` | Set to `false` when all data has been loaded |
114
-
115
- ### Infinite Scroll Behavior
116
-
117
- - Triggers when scrolling near the end of the list
118
- - Automatically triggers on mount if initial items are below threshold
119
- - Prevents concurrent `onLoadMore` calls while loading
120
- - Works with both sync and async callbacks
121
- - Supports both `topToBottom` and `bottomToTop` modes
122
-
123
- ### Integration Guides
124
-
125
- - [Infinite Scroll with Convex](documentation/CONVEX_INFINITE_SCROLL.md) - Real-time data + pagination with Convex backend
36
+ - Svelte 5
37
+ - Node.js 18+
126
38
 
127
39
  ## Installation
128
40
 
129
41
  ```bash
130
- # Using npm
131
- npm install @humanspeak/svelte-virtual-list
132
-
133
42
  # Using pnpm (recommended)
134
43
  pnpm add @humanspeak/svelte-virtual-list
135
44
 
45
+ # Using npm
46
+ npm install @humanspeak/svelte-virtual-list
47
+
136
48
  # Using yarn
137
49
  yarn add @humanspeak/svelte-virtual-list
138
50
  ```
@@ -156,9 +68,27 @@ yarn add @humanspeak/svelte-virtual-list
156
68
  </SvelteVirtualList>
157
69
  ```
158
70
 
159
- ## Advanced Features
71
+ ## Props
160
72
 
161
- ### Chat Application Example
73
+ | Prop | Type | Default | Description |
74
+ | ---------------------------- | -------------------------------- | --------------- | ----------------------------------------------------------------------------- |
75
+ | `items` | `T[]` | Required | Array of items to render |
76
+ | `defaultEstimatedItemHeight` | `number` | `40` | Initial height estimate used until items are measured |
77
+ | `mode` | `'topToBottom' \| 'bottomToTop'` | `'topToBottom'` | Scroll direction and anchoring behavior |
78
+ | `bufferSize` | `number` | `20` | Number of items rendered outside the viewport |
79
+ | `debug` | `boolean` | `false` | Enable debug logging and visualizations |
80
+ | `containerClass` | `string` | `''` | Class for outer container |
81
+ | `viewportClass` | `string` | `''` | Class for scrollable viewport |
82
+ | `contentClass` | `string` | `''` | Class for content wrapper |
83
+ | `itemsClass` | `string` | `''` | Class for items container |
84
+ | `testId` | `string` | `''` | Base test id used in internal test hooks (useful for E2E/tests and debugging) |
85
+ | `onLoadMore` | `() => void \| Promise<void>` | - | Callback when more data is needed for infinite scroll |
86
+ | `loadMoreThreshold` | `number` | `20` | Items from end to trigger `onLoadMore` |
87
+ | `hasMore` | `boolean` | `true` | Set to `false` when all data has been loaded |
88
+
89
+ ## Bottom-to-Top Mode
90
+
91
+ Use `mode="bottomToTop"` for chat-like lists anchored to the bottom:
162
92
 
163
93
  ```svelte
164
94
  <script lang="ts">
@@ -178,7 +108,7 @@ yarn add @humanspeak/svelte-virtual-list
178
108
  </script>
179
109
 
180
110
  <div style="height: 500px;">
181
- <SvelteVirtualList items={messages} mode="bottomToTop" debug>
111
+ <SvelteVirtualList items={messages} mode="bottomToTop">
182
112
  {#snippet renderItem(message)}
183
113
  <div class="message-container">
184
114
  <p>{message.text}</p>
@@ -191,40 +121,100 @@ yarn add @humanspeak/svelte-virtual-list
191
121
  </div>
192
122
  ```
193
123
 
194
- ### Bottom-to-top mode
124
+ ## Programmatic Scrolling
195
125
 
196
- Use `mode="bottomToTop"` for chat-like lists anchored to the bottom. Programmatic scrolling uses the same API as top-to-bottom lists:
126
+ Scroll to any item in the list using the `scroll` method. Useful for chat apps, jump-to-item navigation, and more.
197
127
 
198
128
  ```svelte
199
129
  <script lang="ts">
200
130
  import SvelteVirtualList from '@humanspeak/svelte-virtual-list'
201
131
  let listRef
202
- const messages = Array.from({ length: 2000 }, (_, i) => ({ id: i, text: `Msg ${i}` }))
132
+ const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, text: `Item ${i}` }))
133
+
134
+ function goToItem5000() {
135
+ listRef.scroll({ index: 5000, smoothScroll: true, align: 'auto' })
136
+ }
203
137
  </script>
204
138
 
139
+ <button onclick={goToItem5000}> Scroll to item 5000 </button>
140
+ <SvelteVirtualList {items} bind:this={listRef}>
141
+ {#snippet renderItem(item)}
142
+ <div>{item.text}</div>
143
+ {/snippet}
144
+ </SvelteVirtualList>
145
+ ```
146
+
147
+ ### scroll() Options
148
+
149
+ | Option | Type | Default | Description |
150
+ | --------------------- | ------------------------------------------ | -------- | --------------------------------------- |
151
+ | `index` | `number` | Required | The item index to scroll to (0-based) |
152
+ | `smoothScroll` | `boolean` | `true` | Use smooth scrolling animation |
153
+ | `shouldThrowOnBounds` | `boolean` | `true` | Throw if index is out of bounds |
154
+ | `align` | `'auto' \| 'top' \| 'bottom' \| 'nearest'` | `'auto'` | Where to align the item in the viewport |
155
+
156
+ Alignment options:
157
+
158
+ - `'auto'` - Only scroll if not visible, align to nearest edge
159
+ - `'top'` - Always align to the top
160
+ - `'bottom'` - Always align to the bottom
161
+ - `'nearest'` - Scroll as little as possible to bring the item into view
162
+
163
+ Works with both `topToBottom` and `bottomToTop` modes:
164
+
165
+ ```svelte
205
166
  <SvelteVirtualList items={messages} mode="bottomToTop" bind:this={listRef} />
206
- <button on:click={() => listRef.scroll({ index: messages.length - 1, align: 'bottom' })}>
167
+ <button onclick={() => listRef.scroll({ index: messages.length - 1, align: 'bottom' })}>
207
168
  Jump to latest
208
169
  </button>
209
170
  ```
210
171
 
211
- ## Props
172
+ ## Infinite Scroll
212
173
 
213
- | Prop | Type | Default | Description |
214
- | ---------------------------- | -------------------------------- | --------------- | ----------------------------------------------------------------------------- |
215
- | `items` | `T[]` | Required | Array of items to render |
216
- | `defaultEstimatedItemHeight` | `number` | `40` | Initial height estimate used until items are measured |
217
- | `mode` | `'topToBottom' \| 'bottomToTop'` | `'topToBottom'` | Scroll direction and anchoring behavior |
218
- | `bufferSize` | `number` | `20` | Number of items rendered outside the viewport |
219
- | `debug` | `boolean` | `false` | Enable debug logging and visualizations |
220
- | `containerClass` | `string` | `''` | Class for outer container |
221
- | `viewportClass` | `string` | `''` | Class for scrollable viewport |
222
- | `contentClass` | `string` | `''` | Class for content wrapper |
223
- | `itemsClass` | `string` | `''` | Class for items container |
224
- | `testId` | `string` | `''` | Base test id used in internal test hooks (useful for E2E/tests and debugging) |
225
- | `onLoadMore` | `() => void \| Promise<void>` | - | Callback when more data is needed for infinite scroll |
226
- | `loadMoreThreshold` | `number` | `20` | Items from end to trigger `onLoadMore` |
227
- | `hasMore` | `boolean` | `true` | Set to `false` when all data has been loaded |
174
+ Load more data automatically as users scroll near the end of the list. Perfect for paginated APIs, infinite feeds, and chat applications.
175
+
176
+ ```svelte
177
+ <script lang="ts">
178
+ import SvelteVirtualList from '@humanspeak/svelte-virtual-list'
179
+
180
+ let items = $state([...initialItems])
181
+ let hasMore = $state(true)
182
+
183
+ async function loadMore() {
184
+ const newItems = await fetchMoreItems()
185
+ items = [...items, ...newItems]
186
+ if (newItems.length === 0) {
187
+ hasMore = false
188
+ }
189
+ }
190
+ </script>
191
+
192
+ <SvelteVirtualList {items} onLoadMore={loadMore} loadMoreThreshold={20} {hasMore}>
193
+ {#snippet renderItem(item)}
194
+ <div>{item.text}</div>
195
+ {/snippet}
196
+ </SvelteVirtualList>
197
+ ```
198
+
199
+ ### Infinite Scroll Behavior
200
+
201
+ - Triggers when scrolling near the end of the list
202
+ - Automatically triggers on mount if initial items are below threshold
203
+ - Prevents concurrent `onLoadMore` calls while loading
204
+ - Works with both sync and async callbacks
205
+ - Supports both `topToBottom` and `bottomToTop` modes
206
+
207
+ ### Integration Guides
208
+
209
+ - [Infinite Scroll with Convex](documentation/CONVEX_INFINITE_SCROLL.md) - Real-time data + pagination with Convex backend
210
+
211
+ ## Performance Considerations
212
+
213
+ - The `bufferSize` prop affects memory usage and scroll smoothness
214
+ - Items are measured and cached for optimal performance
215
+ - Dynamic height calculations happen automatically
216
+ - Resize observers handle container/content changes
217
+ - Virtual DOM updates are batched for efficiency
228
218
 
229
219
  ## Testing
230
220
 
@@ -254,33 +244,6 @@ npx playwright test tests/docs-visit.spec.ts --project=chromium
254
244
  npx playwright test --debug
255
245
  ```
256
246
 
257
- ### Development Commands
258
-
259
- ```bash
260
- # Start development server
261
- pnpm dev
262
-
263
- # Start both package and docs
264
- pnpm run dev:all
265
-
266
- # Check TypeScript/Svelte
267
- pnpm run check
268
-
269
- # Build package
270
- pnpm run build
271
-
272
- # Format and lint code
273
- pnpm run lint:fix
274
- ```
275
-
276
- ## Performance Considerations
277
-
278
- - The `bufferSize` prop affects memory usage and scroll smoothness
279
- - Items are measured and cached for optimal performance
280
- - Dynamic height calculations happen automatically
281
- - Resize observers handle container/content changes
282
- - Virtual DOM updates are batched for efficiency
283
-
284
247
  ## Project Structure
285
248
 
286
249
  This is a **PNPM workspace** with two packages:
@@ -288,22 +251,34 @@ This is a **PNPM workspace** with two packages:
288
251
  1. **`./`** - Main Svelte Virtual List component package
289
252
  2. **`./docs`** - Documentation site with live demos and examples
290
253
 
291
- ### Development Workflow
254
+ ### Development Commands
292
255
 
293
256
  ```bash
294
257
  # Install dependencies for both packages
295
258
  pnpm install
296
259
 
297
- # Run development servers simultaneously
260
+ # Start development server
261
+ pnpm dev
262
+
263
+ # Start both package and docs
298
264
  pnpm run dev:all
299
265
 
300
- # Build both packages
266
+ # Build package
301
267
  pnpm run build
302
268
 
303
- # Run tests across the workspace
269
+ # Check TypeScript/Svelte
270
+ pnpm run check
271
+
272
+ # Format and lint code (uses Trunk)
273
+ trunk fmt
274
+ trunk check
275
+
276
+ # Run all tests
304
277
  pnpm test:all
305
278
  ```
306
279
 
280
+ This project uses [Trunk](https://trunk.io) for formatting and linting. Trunk manages tool versions and runs checks automatically via pre-commit hooks.
281
+
307
282
  ## License
308
283
 
309
284
  MIT Β© [Humanspeak, Inc.](LICENSE)
@@ -260,7 +260,7 @@
260
260
  const blockSums = buildBlockSums(cache, est, items.length)
261
261
  const offsetToIndex = getScrollOffsetForIndex(cache, est, anchorIndex, blockSums)
262
262
  const currentTop = heightManager.viewport.scrollTop
263
- let offsetWithin = 0
263
+ let offsetWithin: number
264
264
  if (mode === 'bottomToTop') {
265
265
  // Convert distance-from-end to distance-from-start
266
266
  const distanceFromStart = maxScrollTop - currentTop
@@ -598,6 +598,7 @@
598
598
  // Keep height manager synchronized with items length
599
599
  $effect(() => {
600
600
  heightManager.updateItemLength(items.length)
601
+ stabilizedContentHeight = 0
601
602
  })
602
603
 
603
604
  // Infinite scroll: trigger onLoadMore when approaching end of list
@@ -1015,8 +1016,31 @@
1015
1016
  * Computed content height for the virtual list.
1016
1017
  * Uses the maximum of container height and total content height to ensure
1017
1018
  * proper scrolling behavior.
1019
+ *
1020
+ * In bottomToTop mode during active scroll, contentHeight is "ratcheted" β€”
1021
+ * it can grow but never shrink. This prevents a feedback loop where
1022
+ * averageHeight oscillation causes scrollHeight to bounce, triggering
1023
+ * browser scrollTop adjustments that fire new scroll events.
1024
+ * When scrolling stops (isScrolling goes false), it snaps to the true value.
1018
1025
  */
1019
- const contentHeight = $derived(Math.max(height, totalHeight))
1026
+ let stabilizedContentHeight = 0
1027
+
1028
+ const contentHeight = $derived.by(() => {
1029
+ const raw = Math.max(height, totalHeight)
1030
+
1031
+ if (mode !== 'bottomToTop' || !isScrolling) {
1032
+ stabilizedContentHeight = raw
1033
+ return raw
1034
+ }
1035
+
1036
+ // During active scroll in bottomToTop: only allow growth (ratchet)
1037
+ // Prevents shrink β†’ scrollTop adjust β†’ new scroll event feedback loop
1038
+ if (raw > stabilizedContentHeight) {
1039
+ stabilizedContentHeight = raw
1040
+ }
1041
+
1042
+ return stabilizedContentHeight
1043
+ })
1020
1044
 
1021
1045
  /**
1022
1046
  * Computed transform Y value for positioning the visible items.
@@ -1029,7 +1053,9 @@
1029
1053
  // Avoid synchronous DOM reads here; fall back once if height is 0
1030
1054
  const effectiveHeight = viewportHeight === 0 ? 400 : viewportHeight
1031
1055
 
1032
- // Use precise offset for topToBottom using measured heights when available
1056
+ // Use precise offset using measured heights when available.
1057
+ // For bottomToTop, pass ratcheted contentHeight so the transform stays
1058
+ // stable while scrollHeight is stabilized (prevents visual shift).
1033
1059
  return Math.round(
1034
1060
  calculateTransformY(
1035
1061
  mode,
@@ -1038,7 +1064,7 @@
1038
1064
  visibleRange.start,
1039
1065
  heightManager.averageHeight,
1040
1066
  effectiveHeight,
1041
- totalHeight,
1067
+ mode === 'bottomToTop' ? contentHeight : totalHeight,
1042
1068
  heightManager.getHeightCache(),
1043
1069
  measuredFallbackHeight
1044
1070
  )
@@ -144,8 +144,17 @@ export const calculateTransformY = (mode, totalItems, visibleEnd, visibleStart,
144
144
  if (mode === 'bottomToTop') {
145
145
  // In bottomToTop mode, position items so they stack from bottom up
146
146
  const actualTotalHeight = totalContentHeight ?? totalItems * itemHeight;
147
- // Calculate transform to position visible items correctly
148
- const basicTransform = (totalItems - visibleEnd) * itemHeight;
147
+ // Calculate transform to position visible items correctly.
148
+ // Use measured heights when available to avoid oscillation caused by
149
+ // averageHeight changes shifting (totalItems - visibleEnd) * avg.
150
+ let basicTransform;
151
+ if (heightCache) {
152
+ const offsetToVisibleEnd = getScrollOffsetForIndex(heightCache, itemHeight, visibleEnd);
153
+ basicTransform = actualTotalHeight - offsetToVisibleEnd;
154
+ }
155
+ else {
156
+ basicTransform = (totalItems - visibleEnd) * itemHeight;
157
+ }
149
158
  // When content is smaller than viewport, push to bottom
150
159
  const bottomOffset = Math.max(0, effectiveViewport - actualTotalHeight);
151
160
  // Snap to integer pixels to avoid subpixel oscillation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanspeak/svelte-virtual-list",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",
@@ -65,19 +65,18 @@
65
65
  "@playwright/cli": "^0.1.1",
66
66
  "@playwright/test": "^1.58.2",
67
67
  "@sveltejs/adapter-auto": "^7.0.1",
68
- "@sveltejs/kit": "^2.52.0",
68
+ "@sveltejs/kit": "^2.53.4",
69
69
  "@sveltejs/package": "^2.5.7",
70
70
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
71
- "@tailwindcss/vite": "^4.1.18",
71
+ "@tailwindcss/vite": "^4.2.1",
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.2.3",
76
- "@typescript-eslint/eslint-plugin": "^8.56.0",
77
- "@typescript-eslint/parser": "^8.56.0",
75
+ "@types/node": "^25.3.2",
76
+ "@typescript-eslint/eslint-plugin": "^8.56.1",
77
+ "@typescript-eslint/parser": "^8.56.1",
78
78
  "@vitest/coverage-v8": "^4.0.18",
79
- "concurrently": "^9.2.1",
80
- "eslint": "^10.0.0",
79
+ "eslint": "^10.0.2",
81
80
  "eslint-config-prettier": "^10.1.8",
82
81
  "eslint-plugin-import": "^2.32.0",
83
82
  "eslint-plugin-svelte": "^3.15.0",
@@ -85,18 +84,18 @@
85
84
  "globals": "^17.3.0",
86
85
  "husky": "^9.1.7",
87
86
  "jsdom": "^28.1.0",
87
+ "mprocs": "^0.8.3",
88
88
  "prettier": "^3.8.1",
89
89
  "prettier-plugin-organize-imports": "^4.3.0",
90
- "prettier-plugin-sort-json": "^4.2.0",
91
- "prettier-plugin-svelte": "^3.4.1",
90
+ "prettier-plugin-svelte": "^3.5.0",
92
91
  "prettier-plugin-tailwindcss": "^0.7.2",
93
92
  "publint": "^0.3.17",
94
- "svelte": "^5.51.2",
95
- "svelte-check": "^4.4.0",
96
- "tailwindcss": "^4.1.18",
93
+ "svelte": "^5.53.6",
94
+ "svelte-check": "^4.4.4",
95
+ "tailwindcss": "^4.2.1",
97
96
  "tw-animate-css": "^1.4.0",
98
97
  "typescript": "^5.9.3",
99
- "typescript-eslint": "^8.56.0",
98
+ "typescript-eslint": "^8.56.1",
100
99
  "vite": "^7.3.1",
101
100
  "vitest": "^4.0.18"
102
101
  },
@@ -124,7 +123,7 @@
124
123
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
125
124
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
126
125
  "dev": "vite dev",
127
- "dev:all": "concurrently -k -n pkg,docs,sitemap -c green,cyan,magenta \"pnpm -w -r --filter @humanspeak/svelte-virtual-list run dev\" \"pnpm --filter docs run dev\" \"pnpm --filter docs run sitemap:watch\"",
126
+ "dev:all": "mprocs",
128
127
  "dev:pkg": "svelte-kit sync && svelte-package --watch",
129
128
  "format": "prettier --write .",
130
129
  "lint": "prettier --check . && eslint .",