@humanspeak/svelte-virtual-list 0.3.13 → 0.4.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 +123 -148
- package/dist/SvelteVirtualList.svelte +80 -54
- package/dist/reactive-list-manager/INTEGRATION_EXAMPLE.md +2 -2
- package/dist/reactive-list-manager/README.md +9 -9
- package/dist/reactive-list-manager/ReactiveListManager.svelte.d.ts +1 -1
- package/dist/reactive-list-manager/ReactiveListManager.svelte.js +1 -1
- package/dist/reactive-list-manager/index.d.ts +1 -1
- package/dist/reactive-list-manager/index.js +1 -1
- package/dist/utils/heightCalculation.d.ts +3 -3
- package/dist/utils/heightCalculation.js +5 -6
- package/dist/utils/virtualList.js +11 -2
- package/package.json +2 -2
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
|
-
##
|
|
34
|
+
## Requirements
|
|
35
35
|
|
|
36
|
-
|
|
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
|
-
##
|
|
71
|
+
## Props
|
|
160
72
|
|
|
161
|
-
|
|
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"
|
|
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
|
-
|
|
124
|
+
## Programmatic Scrolling
|
|
195
125
|
|
|
196
|
-
|
|
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
|
|
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
|
|
167
|
+
<button onclick={() => listRef.scroll({ index: messages.length - 1, align: 'bottom' })}>
|
|
207
168
|
Jump to latest
|
|
208
169
|
</button>
|
|
209
170
|
```
|
|
210
171
|
|
|
211
|
-
##
|
|
172
|
+
## Infinite Scroll
|
|
212
173
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
254
|
+
### Development Commands
|
|
292
255
|
|
|
293
256
|
```bash
|
|
294
257
|
# Install dependencies for both packages
|
|
295
258
|
pnpm install
|
|
296
259
|
|
|
297
|
-
#
|
|
260
|
+
# Start development server
|
|
261
|
+
pnpm dev
|
|
262
|
+
|
|
263
|
+
# Start both package and docs
|
|
298
264
|
pnpm run dev:all
|
|
299
265
|
|
|
300
|
-
# Build
|
|
266
|
+
# Build package
|
|
301
267
|
pnpm run build
|
|
302
268
|
|
|
303
|
-
#
|
|
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)
|
|
@@ -251,16 +251,16 @@
|
|
|
251
251
|
|
|
252
252
|
const captureAnchor = () => {
|
|
253
253
|
if (!heightManager.viewportElement) return
|
|
254
|
-
const vr = visibleItems
|
|
254
|
+
const vr = visibleItems
|
|
255
255
|
const anchorIndex = Math.max(0, vr.start)
|
|
256
256
|
const cache = heightManager.getHeightCache()
|
|
257
257
|
const est = heightManager.averageHeight
|
|
258
|
-
const maxScrollTop = Math.max(0, totalHeight
|
|
258
|
+
const maxScrollTop = Math.max(0, totalHeight - (height || 0))
|
|
259
259
|
// Offset from start to anchored item
|
|
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
|
|
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
|
|
@@ -290,7 +290,7 @@
|
|
|
290
290
|
Math.max(0, lastAnchorIndex),
|
|
291
291
|
blockSums
|
|
292
292
|
)
|
|
293
|
-
const maxScrollTop = clampValue(totalHeight
|
|
293
|
+
const maxScrollTop = clampValue(totalHeight - (height || 0), 0, Infinity)
|
|
294
294
|
let targetTop: number
|
|
295
295
|
if (mode === 'bottomToTop') {
|
|
296
296
|
const distanceFromStart = clampValue(offsetToIndex + lastAnchorOffset, 0, Infinity)
|
|
@@ -402,23 +402,6 @@
|
|
|
402
402
|
// Dynamic update coordination to avoid UA scroll anchoring interference
|
|
403
403
|
let suppressBottomAnchoringUntilMs = $state(0)
|
|
404
404
|
|
|
405
|
-
const displayItems = $derived(() => {
|
|
406
|
-
const visibleRange = visibleItems()
|
|
407
|
-
const slice =
|
|
408
|
-
mode === 'bottomToTop'
|
|
409
|
-
? items.slice(visibleRange.start, visibleRange.end).reverse()
|
|
410
|
-
: items.slice(visibleRange.start, visibleRange.end)
|
|
411
|
-
|
|
412
|
-
return slice.map((item, sliceIndex) => ({
|
|
413
|
-
item,
|
|
414
|
-
originalIndex:
|
|
415
|
-
mode === 'bottomToTop'
|
|
416
|
-
? visibleRange.end - 1 - sliceIndex
|
|
417
|
-
: visibleRange.start + sliceIndex,
|
|
418
|
-
sliceIndex
|
|
419
|
-
}))
|
|
420
|
-
})
|
|
421
|
-
|
|
422
405
|
/**
|
|
423
406
|
* Handles scroll position corrections when item heights change, ensuring proper positioning
|
|
424
407
|
* relative to the user's scroll context. This function calculates the cumulative impact of
|
|
@@ -442,7 +425,7 @@
|
|
|
442
425
|
if (isScrolling) {
|
|
443
426
|
// Accumulate net change above viewport and defer application
|
|
444
427
|
let pending = 0
|
|
445
|
-
const currentVisibleRange = visibleItems
|
|
428
|
+
const currentVisibleRange = visibleItems
|
|
446
429
|
for (const change of heightChanges) {
|
|
447
430
|
if (change.index < currentVisibleRange.start) pending += change.delta
|
|
448
431
|
}
|
|
@@ -488,7 +471,7 @@
|
|
|
488
471
|
*
|
|
489
472
|
* Dependencies:
|
|
490
473
|
* - wasAtBottomBeforeHeightChange: Set to true when first item marked dirty, prevents cascading corrections
|
|
491
|
-
* - totalHeight
|
|
474
|
+
* - totalHeight: Uses actual heightCache measurements instead of skewed averages
|
|
492
475
|
* - Aggressive scroll correction: Blocked when wasAtBottomBeforeHeightChange=true
|
|
493
476
|
*
|
|
494
477
|
* ⚠️ DO NOT MODIFY WITHOUT EXTENSIVE TESTING ⚠️
|
|
@@ -518,7 +501,7 @@
|
|
|
518
501
|
lastCorrectionTimestampByViewport.set(viewportEl, now)
|
|
519
502
|
|
|
520
503
|
// Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
|
|
521
|
-
const approximateScrollTop = Math.max(0, totalHeight
|
|
504
|
+
const approximateScrollTop = Math.max(0, totalHeight - height)
|
|
522
505
|
log('[SVL] b2t-correction-approx', { approximateScrollTop })
|
|
523
506
|
syncScrollTop(approximateScrollTop)
|
|
524
507
|
|
|
@@ -555,11 +538,11 @@
|
|
|
555
538
|
}
|
|
556
539
|
|
|
557
540
|
const currentScrollTop = heightManager.viewport.scrollTop
|
|
558
|
-
const maxScrollTop = Math.max(0, totalHeight
|
|
541
|
+
const maxScrollTop = Math.max(0, totalHeight - height)
|
|
559
542
|
|
|
560
543
|
// Calculate total height change impact above current visible area
|
|
561
544
|
let heightChangeAboveViewport = 0
|
|
562
|
-
const currentVisibleRange = visibleItems
|
|
545
|
+
const currentVisibleRange = visibleItems
|
|
563
546
|
|
|
564
547
|
for (const change of heightChanges) {
|
|
565
548
|
// Only consider items that are above the current visible range
|
|
@@ -615,6 +598,7 @@
|
|
|
615
598
|
// Keep height manager synchronized with items length
|
|
616
599
|
$effect(() => {
|
|
617
600
|
heightManager.updateItemLength(items.length)
|
|
601
|
+
stabilizedContentHeight = 0
|
|
618
602
|
})
|
|
619
603
|
|
|
620
604
|
// Infinite scroll: trigger onLoadMore when approaching end of list
|
|
@@ -623,7 +607,7 @@
|
|
|
623
607
|
// Skip loading during bottomToTop initialization (init path renders all items artificially)
|
|
624
608
|
if (mode === 'bottomToTop' && !bottomToTopScrollComplete) return
|
|
625
609
|
|
|
626
|
-
const range = visibleItems
|
|
610
|
+
const range = visibleItems
|
|
627
611
|
const atLoadingEdge = range.end >= items.length - loadMoreThreshold
|
|
628
612
|
const insufficientItems = items.length < loadMoreThreshold && heightManager.initialized
|
|
629
613
|
|
|
@@ -755,9 +739,9 @@
|
|
|
755
739
|
* This getter is reactive and updates whenever heightManager's internal state changes.
|
|
756
740
|
* Used by: atBottom calculation, scroll corrections, maxScrollTop calculations
|
|
757
741
|
*/
|
|
758
|
-
const totalHeight = $derived(
|
|
742
|
+
const totalHeight = $derived(heightManager.totalHeight)
|
|
759
743
|
|
|
760
|
-
const atBottom = $derived(heightManager.scrollTop >= totalHeight
|
|
744
|
+
const atBottom = $derived(heightManager.scrollTop >= totalHeight - height - 1)
|
|
761
745
|
let wasAtBottomBeforeHeightChange = false
|
|
762
746
|
let lastVisibleRange: SvelteVirtualListPreviousVisibleRange | null = null
|
|
763
747
|
|
|
@@ -787,7 +771,7 @@
|
|
|
787
771
|
mode === 'bottomToTop' &&
|
|
788
772
|
heightManager.viewportElement
|
|
789
773
|
) {
|
|
790
|
-
const targetScrollTop = Math.max(0, totalHeight
|
|
774
|
+
const targetScrollTop = Math.max(0, totalHeight - height)
|
|
791
775
|
const currentScrollTop = heightManager.viewport.scrollTop
|
|
792
776
|
const scrollDifference = Math.abs(currentScrollTop - targetScrollTop)
|
|
793
777
|
|
|
@@ -797,7 +781,7 @@
|
|
|
797
781
|
// 3. We're significantly off target
|
|
798
782
|
// 4. We're not at the bottom (where height changes should be handled more carefully)
|
|
799
783
|
const heightChanged = Math.abs(heightManager.averageHeight - lastCalculatedHeight) > 1
|
|
800
|
-
const maxScrollTop = Math.max(0, totalHeight
|
|
784
|
+
const maxScrollTop = Math.max(0, totalHeight - height)
|
|
801
785
|
|
|
802
786
|
// In bottomToTop mode, we're "at bottom" when scroll is at max position
|
|
803
787
|
const isAtBottom =
|
|
@@ -845,7 +829,7 @@
|
|
|
845
829
|
const currentScrollTop = heightManager.viewport.scrollTop
|
|
846
830
|
const currentCalculatedItemHeight = heightManager.averageHeight
|
|
847
831
|
const currentHeight = height
|
|
848
|
-
const currentTotalHeight = totalHeight
|
|
832
|
+
const currentTotalHeight = totalHeight
|
|
849
833
|
const prevTotalHeight =
|
|
850
834
|
lastTotalHeightObserved ||
|
|
851
835
|
currentTotalHeight - itemsAdded * currentCalculatedItemHeight
|
|
@@ -891,7 +875,7 @@
|
|
|
891
875
|
// Reconcile on next frame in case measured heights adjust totals
|
|
892
876
|
requestAnimationFrame(() => {
|
|
893
877
|
const beforeReconcileScrollTop = heightManager.viewport.scrollTop
|
|
894
|
-
const reconciledNextMax = clampValue(totalHeight
|
|
878
|
+
const reconciledNextMax = clampValue(totalHeight - height, 0, Infinity)
|
|
895
879
|
const reconciledDeltaMaxChange = reconciledNextMax - nextMaxScrollTop
|
|
896
880
|
// Desired position is to maintain distance-from-end; equivalently keep (max - scrollTop) constant.
|
|
897
881
|
const desiredScrollTop = clampValue(
|
|
@@ -934,7 +918,7 @@
|
|
|
934
918
|
|
|
935
919
|
lastItemsLength = currentItemsLength
|
|
936
920
|
// Update last observed total height at the end of the effect
|
|
937
|
-
lastTotalHeightObserved = totalHeight
|
|
921
|
+
lastTotalHeightObserved = totalHeight
|
|
938
922
|
})
|
|
939
923
|
|
|
940
924
|
// Update container height continuously to reflect layout changes that
|
|
@@ -970,13 +954,13 @@
|
|
|
970
954
|
*
|
|
971
955
|
* @example
|
|
972
956
|
* ```typescript
|
|
973
|
-
* const range = visibleItems
|
|
957
|
+
* const range = visibleItems
|
|
974
958
|
* console.info(`Rendering items from ${range.start} to ${range.end}`)
|
|
975
959
|
* ```
|
|
976
960
|
*
|
|
977
961
|
* @returns {SvelteVirtualListPreviousVisibleRange} Object containing start and end indices of visible items
|
|
978
962
|
*/
|
|
979
|
-
const visibleItems = $derived((): SvelteVirtualListPreviousVisibleRange => {
|
|
963
|
+
const visibleItems = $derived.by((): SvelteVirtualListPreviousVisibleRange => {
|
|
980
964
|
if (!items.length) return { start: 0, end: 0 } as SvelteVirtualListPreviousVisibleRange
|
|
981
965
|
const viewportHeight = height || 0
|
|
982
966
|
|
|
@@ -1021,7 +1005,7 @@
|
|
|
1021
1005
|
atBottom,
|
|
1022
1006
|
wasAtBottomBeforeHeightChange,
|
|
1023
1007
|
lastVisibleRange,
|
|
1024
|
-
totalHeight
|
|
1008
|
+
totalHeight,
|
|
1025
1009
|
heightManager.getHeightCache()
|
|
1026
1010
|
)
|
|
1027
1011
|
|
|
@@ -1032,21 +1016,46 @@
|
|
|
1032
1016
|
* Computed content height for the virtual list.
|
|
1033
1017
|
* Uses the maximum of container height and total content height to ensure
|
|
1034
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.
|
|
1035
1025
|
*/
|
|
1036
|
-
|
|
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
|
+
})
|
|
1037
1044
|
|
|
1038
1045
|
/**
|
|
1039
1046
|
* Computed transform Y value for positioning the visible items.
|
|
1040
1047
|
* Extracted from inline IIFE for better performance and readability.
|
|
1041
1048
|
*/
|
|
1042
|
-
const transformY = $derived(() => {
|
|
1049
|
+
const transformY = $derived.by(() => {
|
|
1043
1050
|
const viewportHeight = height || measuredFallbackHeight || 0
|
|
1044
|
-
const visibleRange = visibleItems
|
|
1051
|
+
const visibleRange = visibleItems
|
|
1045
1052
|
|
|
1046
1053
|
// Avoid synchronous DOM reads here; fall back once if height is 0
|
|
1047
1054
|
const effectiveHeight = viewportHeight === 0 ? 400 : viewportHeight
|
|
1048
1055
|
|
|
1049
|
-
// Use precise offset
|
|
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).
|
|
1050
1059
|
return Math.round(
|
|
1051
1060
|
calculateTransformY(
|
|
1052
1061
|
mode,
|
|
@@ -1055,13 +1064,30 @@
|
|
|
1055
1064
|
visibleRange.start,
|
|
1056
1065
|
heightManager.averageHeight,
|
|
1057
1066
|
effectiveHeight,
|
|
1058
|
-
totalHeight
|
|
1067
|
+
mode === 'bottomToTop' ? contentHeight : totalHeight,
|
|
1059
1068
|
heightManager.getHeightCache(),
|
|
1060
1069
|
measuredFallbackHeight
|
|
1061
1070
|
)
|
|
1062
1071
|
)
|
|
1063
1072
|
})
|
|
1064
1073
|
|
|
1074
|
+
const displayItems = $derived.by(() => {
|
|
1075
|
+
const visibleRange = visibleItems
|
|
1076
|
+
const slice =
|
|
1077
|
+
mode === 'bottomToTop'
|
|
1078
|
+
? items.slice(visibleRange.start, visibleRange.end).reverse()
|
|
1079
|
+
: items.slice(visibleRange.start, visibleRange.end)
|
|
1080
|
+
|
|
1081
|
+
return slice.map((item, sliceIndex) => ({
|
|
1082
|
+
item,
|
|
1083
|
+
originalIndex:
|
|
1084
|
+
mode === 'bottomToTop'
|
|
1085
|
+
? visibleRange.end - 1 - sliceIndex
|
|
1086
|
+
: visibleRange.start + sliceIndex,
|
|
1087
|
+
sliceIndex
|
|
1088
|
+
}))
|
|
1089
|
+
})
|
|
1090
|
+
|
|
1065
1091
|
/**
|
|
1066
1092
|
* Handles scroll events in the viewport using requestAnimationFrame for performance.
|
|
1067
1093
|
*
|
|
@@ -1124,12 +1150,12 @@
|
|
|
1124
1150
|
captureAnchor()
|
|
1125
1151
|
}
|
|
1126
1152
|
if (INTERNAL_DEBUG) {
|
|
1127
|
-
const vr = visibleItems
|
|
1153
|
+
const vr = visibleItems
|
|
1128
1154
|
log('[SVL] scroll', {
|
|
1129
1155
|
mode,
|
|
1130
1156
|
scrollTop: heightManager.scrollTop,
|
|
1131
1157
|
height,
|
|
1132
|
-
totalHeight: totalHeight
|
|
1158
|
+
totalHeight: totalHeight,
|
|
1133
1159
|
averageItemHeight: heightManager.averageHeight,
|
|
1134
1160
|
visibleRange: vr
|
|
1135
1161
|
})
|
|
@@ -1160,7 +1186,7 @@
|
|
|
1160
1186
|
})
|
|
1161
1187
|
if (!heightManager.initialized && mode === 'bottomToTop') {
|
|
1162
1188
|
// bottomToTop initialization: use scrollIntoView on Item 0 for precise positioning
|
|
1163
|
-
// visibleItems
|
|
1189
|
+
// visibleItems guarantees Item 0 is rendered during initialization
|
|
1164
1190
|
tick().then(() => {
|
|
1165
1191
|
requestAnimationFrame(() => {
|
|
1166
1192
|
requestAnimationFrame(() => {
|
|
@@ -1181,7 +1207,7 @@
|
|
|
1181
1207
|
|
|
1182
1208
|
setTimeout(() => {
|
|
1183
1209
|
// Step 1: Set initialized (for other purposes like scroll event handling)
|
|
1184
|
-
// The init path in visibleItems
|
|
1210
|
+
// The init path in visibleItems stays active until bottomToTopScrollComplete
|
|
1185
1211
|
if (!heightManager.initialized) {
|
|
1186
1212
|
heightManager.initialized = true
|
|
1187
1213
|
}
|
|
@@ -1265,7 +1291,7 @@
|
|
|
1265
1291
|
rafSchedule(() => {
|
|
1266
1292
|
log('item-resize-observer', { entries: entries.length })
|
|
1267
1293
|
let shouldRecalculate = false
|
|
1268
|
-
void visibleItems
|
|
1294
|
+
void visibleItems // Cache once to avoid reactive loops
|
|
1269
1295
|
|
|
1270
1296
|
for (const entry of entries) {
|
|
1271
1297
|
const element = entry.target as HTMLElement
|
|
@@ -1349,7 +1375,7 @@
|
|
|
1349
1375
|
// Add the effect in the script section
|
|
1350
1376
|
$effect(() => {
|
|
1351
1377
|
if (INTERNAL_DEBUG) {
|
|
1352
|
-
prevVisibleRange = visibleItems
|
|
1378
|
+
prevVisibleRange = visibleItems
|
|
1353
1379
|
prevHeight = heightManager.averageHeight
|
|
1354
1380
|
}
|
|
1355
1381
|
})
|
|
@@ -1358,7 +1384,7 @@
|
|
|
1358
1384
|
// the callback writes to $state (which is forbidden during render effects)
|
|
1359
1385
|
$effect(() => {
|
|
1360
1386
|
if (!debug) return
|
|
1361
|
-
const currentVisibleRange = visibleItems
|
|
1387
|
+
const currentVisibleRange = visibleItems
|
|
1362
1388
|
if (
|
|
1363
1389
|
!shouldShowDebugInfo(
|
|
1364
1390
|
prevVisibleRange,
|
|
@@ -1376,7 +1402,7 @@
|
|
|
1376
1402
|
heightManager.averageHeight,
|
|
1377
1403
|
heightManager.scrollTop,
|
|
1378
1404
|
height || 0,
|
|
1379
|
-
totalHeight
|
|
1405
|
+
totalHeight
|
|
1380
1406
|
)
|
|
1381
1407
|
|
|
1382
1408
|
if (debugFunction) {
|
|
@@ -1484,7 +1510,7 @@
|
|
|
1484
1510
|
}
|
|
1485
1511
|
}
|
|
1486
1512
|
|
|
1487
|
-
const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems
|
|
1513
|
+
const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems
|
|
1488
1514
|
|
|
1489
1515
|
// Use extracted scroll calculation utility
|
|
1490
1516
|
const scrollTarget = calculateScrollTarget({
|
|
@@ -1630,16 +1656,16 @@
|
|
|
1630
1656
|
id="virtual-list-content"
|
|
1631
1657
|
{...testId ? { 'data-testid': `${testId}-content` } : {}}
|
|
1632
1658
|
class={contentClass ?? 'virtual-list-content'}
|
|
1633
|
-
style:height="{contentHeight
|
|
1659
|
+
style:height="{contentHeight}px"
|
|
1634
1660
|
>
|
|
1635
1661
|
<!-- Items container is translated to show correct items -->
|
|
1636
1662
|
<div
|
|
1637
1663
|
id="virtual-list-items"
|
|
1638
1664
|
{...testId ? { 'data-testid': `${testId}-items` } : {}}
|
|
1639
1665
|
class={itemsClass ?? 'virtual-list-items'}
|
|
1640
|
-
style:transform="translateY({transformY
|
|
1666
|
+
style:transform="translateY({transformY}px)"
|
|
1641
1667
|
>
|
|
1642
|
-
{#each displayItems
|
|
1668
|
+
{#each displayItems as currentItemWithIndex, _i (currentItemWithIndex.originalIndex)}
|
|
1643
1669
|
<!-- Render each visible item -->
|
|
1644
1670
|
<div
|
|
1645
1671
|
bind:this={itemElements[currentItemWithIndex.sliceIndex]}
|
|
@@ -71,7 +71,7 @@ const updateHeight = () => {
|
|
|
71
71
|
|
|
72
72
|
```typescript
|
|
73
73
|
// OLD: O(n) calculation every time
|
|
74
|
-
// let totalHeight = $derived(() => {
|
|
74
|
+
// let totalHeight = $derived.by(() => {
|
|
75
75
|
// let total = 0
|
|
76
76
|
// for (let i = 0; i < items.length; i++) {
|
|
77
77
|
// total += heightCache[i] || calculatedItemHeight
|
|
@@ -80,7 +80,7 @@ const updateHeight = () => {
|
|
|
80
80
|
// })
|
|
81
81
|
|
|
82
82
|
// NEW: O(1) reactive calculation 🚀
|
|
83
|
-
let totalHeight = $derived(
|
|
83
|
+
let totalHeight = $derived(heightManager.totalHeight)
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
## Performance Benefits
|
|
@@ -32,7 +32,7 @@ ReactiveListManager processes only **dirty/changed items**:
|
|
|
32
32
|
```typescript
|
|
33
33
|
// ✅ O(dirty items) - Fast and reactive
|
|
34
34
|
manager.processDirtyHeights(changedItems)
|
|
35
|
-
const totalHeight = manager.
|
|
35
|
+
const totalHeight = manager.totalHeight
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
## 📦 Installation
|
|
@@ -82,7 +82,7 @@ new ReactiveListManager(config: ListManagerConfig)
|
|
|
82
82
|
**Parameters:**
|
|
83
83
|
|
|
84
84
|
- `config.itemLength` - Total number of items
|
|
85
|
-
- `config.
|
|
85
|
+
- `config.itemHeight` - Default height for unmeasured items
|
|
86
86
|
|
|
87
87
|
### Core Methods
|
|
88
88
|
|
|
@@ -154,7 +154,7 @@ Check if manager has sufficient measurement data.
|
|
|
154
154
|
// Create manager
|
|
155
155
|
const heightManager = new ReactiveListManager({
|
|
156
156
|
itemLength: items.length,
|
|
157
|
-
|
|
157
|
+
itemHeight: defaultEstimatedItemHeight
|
|
158
158
|
})
|
|
159
159
|
|
|
160
160
|
// Update on items change
|
|
@@ -182,7 +182,7 @@ $effect(() => {
|
|
|
182
182
|
})
|
|
183
183
|
|
|
184
184
|
// Reactive total height (automatically updates)
|
|
185
|
-
let totalHeight = $derived(
|
|
185
|
+
let totalHeight = $derived(heightManager.totalHeight)
|
|
186
186
|
```
|
|
187
187
|
|
|
188
188
|
### Standalone Usage
|
|
@@ -190,7 +190,7 @@ let totalHeight = $derived(() => heightManager.totalHeight)
|
|
|
190
190
|
```typescript
|
|
191
191
|
import { ReactiveListManager, benchmarkHeightManager } from './reactive-list-manager'
|
|
192
192
|
|
|
193
|
-
const manager = new ReactiveListManager({ itemLength: 1000,
|
|
193
|
+
const manager = new ReactiveListManager({ itemLength: 1000, itemHeight: 50 })
|
|
194
194
|
|
|
195
195
|
// Performance monitoring
|
|
196
196
|
const results = benchmarkHeightManager(10000, 1000, 100)
|
|
@@ -264,7 +264,7 @@ npm run test -- --grep "Performance Tests"
|
|
|
264
264
|
|
|
265
265
|
```text
|
|
266
266
|
|
|
267
|
-
Height Changes → processDirtyHeights() → Update State →
|
|
267
|
+
Height Changes → processDirtyHeights() → Update State → totalHeight → Reactive UI
|
|
268
268
|
|
|
269
269
|
```
|
|
270
270
|
|
|
@@ -274,7 +274,7 @@ Height Changes → processDirtyHeights() → Update State → getDerivedTotalHei
|
|
|
274
274
|
private _totalMeasuredHeight = $state(0) // Sum of all measured heights
|
|
275
275
|
private _measuredCount = $state(0) // Count of measured items
|
|
276
276
|
private _itemLength = $state(0) // Total items
|
|
277
|
-
private
|
|
277
|
+
private _itemHeight = $state(40) // Default estimate
|
|
278
278
|
```
|
|
279
279
|
|
|
280
280
|
## 🔧 Types
|
|
@@ -288,7 +288,7 @@ interface HeightChange {
|
|
|
288
288
|
|
|
289
289
|
interface ListManagerConfig {
|
|
290
290
|
itemLength: number
|
|
291
|
-
|
|
291
|
+
itemHeight: number
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
interface ListManagerDebugInfo {
|
|
@@ -296,7 +296,7 @@ interface ListManagerDebugInfo {
|
|
|
296
296
|
measuredCount: number
|
|
297
297
|
itemLength: number
|
|
298
298
|
coveragePercent: number
|
|
299
|
-
|
|
299
|
+
itemHeight: number
|
|
300
300
|
}
|
|
301
301
|
```
|
|
302
302
|
|
|
@@ -10,7 +10,7 @@ import type { HeightChange, ListManagerConfig, ListManagerDebugInfo } from './ty
|
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* ```typescript
|
|
13
|
-
* const manager = new ReactiveListManager({ itemLength: 10000,
|
|
13
|
+
* const manager = new ReactiveListManager({ itemLength: 10000, itemHeight: 40 })
|
|
14
14
|
*
|
|
15
15
|
* // Process height changes incrementally
|
|
16
16
|
* manager.processDirtyHeights(dirtyResults)
|
|
@@ -10,7 +10,7 @@ import { RecomputeScheduler } from './RecomputeScheduler.js';
|
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* ```typescript
|
|
13
|
-
* const manager = new ReactiveListManager({ itemLength: 10000,
|
|
13
|
+
* const manager = new ReactiveListManager({ itemLength: 10000, itemHeight: 40 })
|
|
14
14
|
*
|
|
15
15
|
* // Process height changes incrementally
|
|
16
16
|
* manager.processDirtyHeights(dirtyResults)
|
|
@@ -35,7 +35,7 @@ import type { SvelteVirtualListMode } from '../types.js';
|
|
|
35
35
|
* calculateAverageHeightDebounced(
|
|
36
36
|
* false,
|
|
37
37
|
* null,
|
|
38
|
-
*
|
|
38
|
+
* visibleRange,
|
|
39
39
|
* itemElements,
|
|
40
40
|
* heightCache,
|
|
41
41
|
* lastMeasuredIndex,
|
|
@@ -59,7 +59,7 @@ import type { SvelteVirtualListMode } from '../types.js';
|
|
|
59
59
|
*
|
|
60
60
|
* @param isCalculatingHeight - Flag to prevent concurrent calculations
|
|
61
61
|
* @param heightUpdateTimeout - Reference to existing update timeout
|
|
62
|
-
* @param
|
|
62
|
+
* @param visibleItems - Current visible range
|
|
63
63
|
* @param itemElements - Array of DOM elements to measure
|
|
64
64
|
* @param heightCache - Cache of previously measured heights with dirty tracking
|
|
65
65
|
* @param lastMeasuredIndex - Index of last measured element
|
|
@@ -68,7 +68,7 @@ import type { SvelteVirtualListMode } from '../types.js';
|
|
|
68
68
|
* @param debounceTime - Time to wait between calculations (default: 200ms)
|
|
69
69
|
* @returns Timeout object or null if calculation was skipped
|
|
70
70
|
*/
|
|
71
|
-
export declare const calculateAverageHeightDebounced: (isCalculatingHeight: boolean, heightUpdateTimeout: ReturnType<typeof setTimeout> | null,
|
|
71
|
+
export declare const calculateAverageHeightDebounced: (isCalculatingHeight: boolean, heightUpdateTimeout: ReturnType<typeof setTimeout> | null, visibleItems: {
|
|
72
72
|
start: number;
|
|
73
73
|
end: number;
|
|
74
74
|
}, itemElements: HTMLElement[], heightCache: Record<number, number>, lastMeasuredIndex: number, calculatedItemHeight: number, onUpdate: (result: {
|
|
@@ -36,7 +36,7 @@ import { BROWSER } from 'esm-env';
|
|
|
36
36
|
* calculateAverageHeightDebounced(
|
|
37
37
|
* false,
|
|
38
38
|
* null,
|
|
39
|
-
*
|
|
39
|
+
* visibleRange,
|
|
40
40
|
* itemElements,
|
|
41
41
|
* heightCache,
|
|
42
42
|
* lastMeasuredIndex,
|
|
@@ -60,7 +60,7 @@ import { BROWSER } from 'esm-env';
|
|
|
60
60
|
*
|
|
61
61
|
* @param isCalculatingHeight - Flag to prevent concurrent calculations
|
|
62
62
|
* @param heightUpdateTimeout - Reference to existing update timeout
|
|
63
|
-
* @param
|
|
63
|
+
* @param visibleItems - Current visible range
|
|
64
64
|
* @param itemElements - Array of DOM elements to measure
|
|
65
65
|
* @param heightCache - Cache of previously measured heights with dirty tracking
|
|
66
66
|
* @param lastMeasuredIndex - Index of last measured element
|
|
@@ -69,19 +69,18 @@ import { BROWSER } from 'esm-env';
|
|
|
69
69
|
* @param debounceTime - Time to wait between calculations (default: 200ms)
|
|
70
70
|
* @returns Timeout object or null if calculation was skipped
|
|
71
71
|
*/
|
|
72
|
-
export const calculateAverageHeightDebounced = (isCalculatingHeight, heightUpdateTimeout,
|
|
72
|
+
export const calculateAverageHeightDebounced = (isCalculatingHeight, heightUpdateTimeout, visibleItems, itemElements, heightCache, lastMeasuredIndex, calculatedItemHeight,
|
|
73
73
|
/* trunk-ignore(eslint/no-unused-vars) */
|
|
74
74
|
onUpdate, debounceTime, dirtyItems, currentTotalHeight = 0, currentValidCount = 0, mode = 'topToBottom') => {
|
|
75
75
|
if (!BROWSER || isCalculatingHeight)
|
|
76
76
|
return null;
|
|
77
|
-
const
|
|
78
|
-
const currentIndex = visibleRange.start;
|
|
77
|
+
const currentIndex = visibleItems.start;
|
|
79
78
|
if (currentIndex === lastMeasuredIndex && dirtyItems.size === 0)
|
|
80
79
|
return null;
|
|
81
80
|
if (heightUpdateTimeout)
|
|
82
81
|
clearTimeout(heightUpdateTimeout);
|
|
83
82
|
return setTimeout(() => {
|
|
84
|
-
const { newHeight, newLastMeasuredIndex, updatedHeightCache, clearedDirtyItems, newTotalHeight, newValidCount, heightChanges } = calculateAverageHeight(itemElements,
|
|
83
|
+
const { newHeight, newLastMeasuredIndex, updatedHeightCache, clearedDirtyItems, newTotalHeight, newValidCount, heightChanges } = calculateAverageHeight(itemElements, visibleItems, heightCache, calculatedItemHeight, dirtyItems, currentTotalHeight, currentValidCount, mode);
|
|
85
84
|
if (Math.abs(newHeight - calculatedItemHeight) > 1 || dirtyItems.size > 0) {
|
|
86
85
|
onUpdate({
|
|
87
86
|
newHeight,
|
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "0.4.1",
|
|
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",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"prettier-plugin-svelte": "^3.4.1",
|
|
92
92
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
93
93
|
"publint": "^0.3.17",
|
|
94
|
-
"svelte": "^5.51.
|
|
94
|
+
"svelte": "^5.51.3",
|
|
95
95
|
"svelte-check": "^4.4.0",
|
|
96
96
|
"tailwindcss": "^4.1.18",
|
|
97
97
|
"tw-animate-css": "^1.4.0",
|