|
8 | 8 | SCROLL_PROP, |
9 | 9 | SCROLL_PROP_LEGACY |
10 | 10 | } from './constants.js'; |
11 | | - import { ListState } from './utils/ListState.svelte.js'; |
12 | | - import { ListProps } from './utils/ListProps.svelte.js'; |
13 | 11 | /** @import { VirtualListProps, VirtualListEvents, VirtualListSnippets } from './types.js'; */ |
14 | 12 |
|
15 | 13 | /** @type {VirtualListProps & VirtualListEvents & VirtualListSnippets} */ |
|
21 | 19 |
|
22 | 20 | itemCount, |
23 | 21 | itemSize, |
24 | | - estimatedItemSize = 0, |
| 22 | + estimatedItemSize: optEstimatedItemSize, |
25 | 23 | stickyIndices = [], |
26 | 24 | getKey, |
27 | 25 |
|
28 | 26 | scrollDirection = DIRECTION.VERTICAL, |
29 | | - scrollOffset = 0, |
30 | | - scrollToIndex = -1, |
| 27 | + scrollOffset, |
| 28 | + scrollToIndex, |
31 | 29 | scrollToAlignment = ALIGNMENT.START, |
32 | 30 | scrollToBehaviour = 'instant', |
33 | 31 |
|
|
49 | 47 | footer: footerSnippet |
50 | 48 | } = $props(); |
51 | 49 |
|
| 50 | + let estimatedItemSize = $derived( |
| 51 | + optEstimatedItemSize || (typeof itemSize === 'number' && itemSize) || 50 |
| 52 | + ); |
| 53 | + const sizeAndPositionManager = new SizeAndPositionManager(itemSize, itemCount, estimatedItemSize); |
| 54 | +
|
52 | 55 | /** @type {HTMLDivElement} */ |
53 | 56 | let wrapper; |
54 | | -
|
55 | | - /** @type {Record<number, string>} */ |
56 | | - let styleCache = $state({}); |
57 | | - let wrapperStyle = $state.raw(''); |
58 | | - let innerStyle = $state.raw(''); |
59 | | -
|
60 | 57 | let wrapperHeight = $state(400); |
61 | 58 | let wrapperWidth = $state(400); |
62 | | -
|
63 | 59 | /** @type {{ index: number, style: string }[]} */ |
64 | 60 | let items = $state.raw([]); |
65 | 61 |
|
66 | | - const _props = new ListProps( |
67 | | - scrollToIndex, |
68 | | - scrollToAlignment, |
69 | | - scrollOffset, |
70 | | - itemCount, |
71 | | - itemSize, |
72 | | - estimatedItemSize, |
73 | | - Number.isFinite(height) ? height : 400, |
74 | | - Number.isFinite(width) ? width : 400, |
75 | | - stickyIndices |
76 | | - ); |
77 | | -
|
78 | | - const _state = new ListState(scrollOffset || 0); |
| 62 | + /** @type {{ offset: number, changeReason: number }} */ |
| 63 | + let scroll = $state.raw({ |
| 64 | + offset: scrollOffset || (scrollToIndex !== undefined && getOffsetForIndex(scrollToIndex)) || 0, |
| 65 | + changeReason: SCROLL_CHANGE_REASON.REQUESTED |
| 66 | + }); |
| 67 | + let prevScroll = $state.snapshot(scroll); |
| 68 | +
|
| 69 | + let heightNumber = $derived(Number.isFinite(height) ? Number(height) : wrapperHeight); |
| 70 | + let widthNumber = $derived(Number.isFinite(width) ? Number(width) : wrapperWidth); |
| 71 | + let prevProps = { |
| 72 | + scrollToIndex: $state.snapshot(scrollToIndex), |
| 73 | + scrollToAlignment: $state.snapshot(scrollToAlignment), |
| 74 | + scrollOffset: $state.snapshot(scrollOffset), |
| 75 | + itemCount: $state.snapshot(itemCount), |
| 76 | + itemSize: typeof itemSize === 'function' ? itemSize : $state.snapshot(itemSize), |
| 77 | + estimatedItemSize: $state.snapshot(estimatedItemSize), |
| 78 | + heightNumber: $state.snapshot(heightNumber), |
| 79 | + widthNumber: $state.snapshot(widthNumber), |
| 80 | + stickyIndices: $state.snapshot(stickyIndices) |
| 81 | + }; |
79 | 82 |
|
80 | | - const sizeAndPositionManager = new SizeAndPositionManager( |
81 | | - itemSize, |
82 | | - itemCount, |
83 | | - _props.estimatedItemSize |
84 | | - ); |
| 83 | + /** @type {Record<number, string>} */ |
| 84 | + let styleCache = $state({}); |
| 85 | + let wrapperStyle = $state.raw(''); |
| 86 | + let innerStyle = $state.raw(''); |
85 | 87 |
|
86 | 88 | // Effect 0: Event listener |
87 | 89 | $effect(() => { |
| 90 | + /** @type {number | undefined} */ |
| 91 | + let frame; |
| 92 | + /** @param {Event} event */ |
| 93 | + const handleScrollAsync = (event) => { |
| 94 | + if (frame !== undefined) { |
| 95 | + cancelAnimationFrame(frame); |
| 96 | + } |
| 97 | + frame = requestAnimationFrame(() => { |
| 98 | + handleScroll(event); |
| 99 | + frame = undefined; |
| 100 | + }); |
| 101 | + }; |
| 102 | +
|
88 | 103 | const options = { passive: true }; |
89 | | - wrapper.addEventListener('scroll', handleScroll, options); |
| 104 | + wrapper.addEventListener('scroll', handleScrollAsync, options); |
90 | 105 |
|
91 | 106 | return () => { |
92 | 107 | // @ts-expect-error because options is not really needed, but maybe in the future |
93 | | - wrapper.removeEventListener('scroll', handleScroll, options); |
| 108 | + wrapper.removeEventListener('scroll', handleScrollAsync, options); |
94 | 109 | }; |
95 | 110 | }); |
96 | 111 |
|
97 | 112 | // Effect 1: Update props from user provided props |
98 | 113 | $effect(() => { |
99 | | - _props.listen( |
100 | | - scrollToIndex, |
101 | | - scrollToAlignment, |
102 | | - scrollOffset, |
103 | | - itemCount, |
104 | | - itemSize, |
105 | | - estimatedItemSize, |
106 | | - Number.isFinite(height) ? height : wrapperHeight, |
107 | | - Number.isFinite(width) ? width : wrapperWidth, |
108 | | - stickyIndices |
109 | | - ); |
| 114 | + scrollToIndex; |
| 115 | + scrollToAlignment; |
| 116 | + scrollOffset; |
| 117 | + itemCount; |
| 118 | + itemSize; |
| 119 | + estimatedItemSize; |
| 120 | + heightNumber; |
| 121 | + widthNumber; |
| 122 | + stickyIndices; |
| 123 | +
|
| 124 | + untrack(propsUpdated); |
| 125 | + }); |
110 | 126 |
|
111 | | - untrack(() => { |
112 | | - let doRecomputeSizes = false; |
| 127 | + // Effect 2: Update scroll |
| 128 | + $effect(() => { |
| 129 | + scroll; |
113 | 130 |
|
114 | | - if (_props.haveSizesChanged) { |
115 | | - sizeAndPositionManager.updateConfig(itemSize, itemCount, _props.estimatedItemSize); |
116 | | - doRecomputeSizes = true; |
117 | | - } |
| 131 | + untrack(scrollUpdated); |
| 132 | + }); |
118 | 133 |
|
119 | | - if (_props.hasScrollOffsetChanged) |
120 | | - _state.listen(_props.scrollOffset, SCROLL_CHANGE_REASON.REQUESTED); |
121 | | - else if (_props.hasScrollIndexChanged) |
122 | | - _state.listen( |
123 | | - getOffsetForIndex(scrollToIndex, scrollToAlignment), |
124 | | - SCROLL_CHANGE_REASON.REQUESTED |
125 | | - ); |
| 134 | + function propsUpdated() { |
| 135 | + const scrollPropsHaveChanged = |
| 136 | + prevProps.scrollToIndex !== scrollToIndex || |
| 137 | + prevProps.scrollToAlignment !== scrollToAlignment; |
| 138 | + const itemPropsHaveChanged = |
| 139 | + prevProps.itemCount !== itemCount || |
| 140 | + prevProps.itemSize !== itemSize || |
| 141 | + prevProps.estimatedItemSize !== estimatedItemSize; |
126 | 142 |
|
127 | | - if (_props.haveDimsOrStickyIndicesChanged || doRecomputeSizes) recomputeSizes(); |
| 143 | + let forceRecomputeSizes = false; |
| 144 | + if (itemPropsHaveChanged) { |
| 145 | + sizeAndPositionManager.updateConfig(itemSize, itemCount, estimatedItemSize); |
128 | 146 |
|
129 | | - _props.update(); |
130 | | - }); |
131 | | - }); |
| 147 | + forceRecomputeSizes = true; |
| 148 | + } |
132 | 149 |
|
133 | | - // Effect 2: Update UI from state |
134 | | - $effect(() => { |
135 | | - _state.offset; |
| 150 | + if (prevProps.scrollOffset !== scrollOffset) { |
| 151 | + scroll = { |
| 152 | + offset: scrollOffset || 0, |
| 153 | + changeReason: SCROLL_CHANGE_REASON.REQUESTED |
| 154 | + }; |
| 155 | + } else if ( |
| 156 | + typeof scrollToIndex === 'number' && |
| 157 | + (scrollPropsHaveChanged || itemPropsHaveChanged) |
| 158 | + ) { |
| 159 | + scroll = { |
| 160 | + offset: getOffsetForIndex(scrollToIndex), |
| 161 | + changeReason: SCROLL_CHANGE_REASON.REQUESTED |
| 162 | + }; |
| 163 | + } |
136 | 164 |
|
137 | | - untrack(() => { |
138 | | - if (_state.doRefresh) refresh(); |
| 165 | + if ( |
| 166 | + forceRecomputeSizes || |
| 167 | + prevProps.heightNumber !== heightNumber || |
| 168 | + prevProps.widthNumber !== widthNumber || |
| 169 | + prevProps.stickyIndices.toString() !== $state.snapshot(stickyIndices).toString() |
| 170 | + ) { |
| 171 | + recomputeSizes(); |
| 172 | + } |
139 | 173 |
|
140 | | - if (_state.doScrollToOffset) scrollTo(_state.offset); |
| 174 | + prevProps = { |
| 175 | + scrollToIndex: $state.snapshot(scrollToIndex), |
| 176 | + scrollToAlignment: $state.snapshot(scrollToAlignment), |
| 177 | + scrollOffset: $state.snapshot(scrollOffset), |
| 178 | + itemCount: $state.snapshot(itemCount), |
| 179 | + itemSize: typeof itemSize === 'function' ? itemSize : $state.snapshot(itemSize), |
| 180 | + estimatedItemSize: $state.snapshot(estimatedItemSize), |
| 181 | + heightNumber: $state.snapshot(heightNumber), |
| 182 | + widthNumber: $state.snapshot(widthNumber), |
| 183 | + stickyIndices: $state.snapshot(stickyIndices) |
| 184 | + }; |
| 185 | + } |
141 | 186 |
|
142 | | - _state.update(); |
143 | | - }); |
144 | | - }); |
| 187 | + function scrollUpdated() { |
| 188 | + if (prevScroll.offset !== scroll.offset || prevScroll.changeReason !== scroll.changeReason) { |
| 189 | + refresh(); |
| 190 | + } |
| 191 | +
|
| 192 | + if ( |
| 193 | + prevScroll.offset !== scroll.offset && |
| 194 | + scroll.changeReason === SCROLL_CHANGE_REASON.REQUESTED |
| 195 | + ) { |
| 196 | + wrapper.scroll({ |
| 197 | + [SCROLL_PROP[scrollDirection]]: scroll.offset, |
| 198 | + behavior: scrollToBehaviour |
| 199 | + }); |
| 200 | + } |
| 201 | +
|
| 202 | + prevScroll = $state.snapshot(scroll); |
| 203 | + } |
145 | 204 |
|
146 | 205 | /** |
147 | 206 | * Recomputes the sizes of the items and updates the visible items. |
148 | 207 | */ |
149 | | - const refresh = () => { |
| 208 | + function refresh() { |
150 | 209 | const { start, end } = sizeAndPositionManager.getVisibleRange( |
151 | | - scrollDirection === DIRECTION.VERTICAL ? _props.height : _props.width, |
152 | | - _state.offset, |
| 210 | + scrollDirection === DIRECTION.VERTICAL ? heightNumber : widthNumber, |
| 211 | + scroll.offset, |
153 | 212 | overscanCount |
154 | 213 | ); |
155 | 214 |
|
|
160 | 219 | const heightUnit = typeof height === 'number' ? 'px' : ''; |
161 | 220 | const widthUnit = typeof width === 'number' ? 'px' : ''; |
162 | 221 |
|
| 222 | + wrapperStyle = `height:${height}${heightUnit};width:${width}${widthUnit};`; |
163 | 223 | if (scrollDirection === DIRECTION.VERTICAL) { |
164 | | - wrapperStyle = `height:${height}${heightUnit};width:${width}${widthUnit};`; |
165 | 224 | innerStyle = `flex-direction:column;height:${totalSize}px;`; |
166 | 225 | } else { |
167 | | - wrapperStyle = `height:${height}${heightUnit};width:${width}${widthUnit};`; |
168 | 226 | innerStyle = `min-height:100%;width:${totalSize}px;`; |
169 | 227 | } |
170 | 228 |
|
|
193 | 251 | } |
194 | 252 |
|
195 | 253 | items = visibleItems; |
196 | | - }; |
197 | | -
|
198 | | - /** |
199 | | - * Scrolls the list to a specific coordinate. |
200 | | - * @param {number} value |
201 | | - */ |
202 | | - const scrollTo = (value) => { |
203 | | - wrapper.scroll({ |
204 | | - [SCROLL_PROP[scrollDirection]]: value, |
205 | | - behavior: scrollToBehaviour |
206 | | - }); |
207 | | - }; |
| 254 | + } |
208 | 255 |
|
209 | 256 | /** |
210 | 257 | * Recomputes the sizes of the items in the list. |
211 | | - * @param {number} startIndex |
212 | 258 | */ |
213 | | - export const recomputeSizes = (startIndex = scrollToIndex) => { |
| 259 | + export function recomputeSizes(startIndex = scrollToIndex) { |
214 | 260 | styleCache = {}; |
215 | | - if (startIndex >= 0) sizeAndPositionManager.resetItem(startIndex); |
| 261 | + if (startIndex !== undefined && startIndex >= 0) { |
| 262 | + sizeAndPositionManager.resetItem(startIndex); |
| 263 | + } |
216 | 264 | refresh(); |
217 | | - }; |
| 265 | + } |
218 | 266 |
|
219 | 267 | /** |
220 | 268 | * Calculates the offset for a given index based on the scroll direction and alignment. |
221 | 269 | * @param {number} index |
222 | | - * @param {import('./types.js').Alignment} align |
223 | 270 | */ |
224 | | - const getOffsetForIndex = (index, align = scrollToAlignment) => { |
| 271 | + function getOffsetForIndex(index) { |
225 | 272 | if (index < 0 || index >= itemCount) index = 0; |
226 | 273 |
|
227 | 274 | return sizeAndPositionManager.getUpdatedOffsetForIndex( |
228 | | - align, |
229 | | - scrollDirection === DIRECTION.VERTICAL ? _props.height : _props.width, |
230 | | - _state.offset || 0, |
| 275 | + scrollToAlignment, |
| 276 | + scrollDirection === DIRECTION.VERTICAL ? heightNumber : widthNumber, |
| 277 | + scroll.offset || 0, |
231 | 278 | index |
232 | 279 | ); |
233 | | - }; |
| 280 | + } |
234 | 281 |
|
235 | 282 | /** |
236 | 283 | * Handles the scroll event on the wrapper element. |
237 | 284 | * @param {Event} event |
238 | 285 | */ |
239 | | - const handleScroll = (event) => { |
240 | | - const offset = getWrapperOffset(); |
| 286 | + function handleScroll(event) { |
| 287 | + const offset = wrapper[SCROLL_PROP_LEGACY[scrollDirection]]; |
241 | 288 |
|
242 | | - if (offset < 0 || _state.offset === offset || event.target !== wrapper) return null; |
| 289 | + if (offset < 0 || scroll.offset === offset || event.target !== wrapper) return; |
243 | 290 |
|
244 | | - _state.listen(offset, SCROLL_CHANGE_REASON.OBSERVED); |
| 291 | + scroll = { offset, changeReason: SCROLL_CHANGE_REASON.OBSERVED }; |
245 | 292 |
|
246 | 293 | if (handleAfterScroll) handleAfterScroll({ offset, event }); |
247 | | - }; |
248 | | -
|
249 | | - /** |
250 | | - * Returns the current scroll offset of the wrapper element. |
251 | | - * @returns {number} |
252 | | - */ |
253 | | - const getWrapperOffset = () => { |
254 | | - return wrapper[SCROLL_PROP_LEGACY[scrollDirection]]; |
255 | | - }; |
| 294 | + } |
256 | 295 |
|
257 | 296 | /** |
258 | 297 | * Returns the style for a given item index. |
259 | 298 | * @param {number} index The index of the item |
260 | 299 | * @param {boolean} sticky Whether the item should be sticky or not |
261 | 300 | */ |
262 | | - const getStyle = (index, sticky) => { |
| 301 | + function getStyle(index, sticky) { |
263 | 302 | if (styleCache[index]) return styleCache[index]; |
264 | 303 |
|
265 | 304 | const { size, offset } = sizeAndPositionManager.getSizeAndPositionForIndex(index); |
266 | 305 |
|
267 | 306 | let style; |
268 | | -
|
269 | 307 | if (scrollDirection === DIRECTION.VERTICAL) { |
270 | 308 | style = `left:0;width:100%;height:${size}px;`; |
271 | 309 |
|
272 | | - if (sticky) |
| 310 | + if (sticky) { |
273 | 311 | style += `position:sticky;flex-grow:0;z-index:1;top:0;margin-top:${offset}px;margin-bottom:${-(offset + size)}px;`; |
274 | | - else style += `position:absolute;top:${offset}px;`; |
| 312 | + } else { |
| 313 | + style += `position:absolute;top:${offset}px;`; |
| 314 | + } |
275 | 315 | } else { |
276 | 316 | style = `top:0;width:${size}px;`; |
277 | 317 |
|
278 | | - if (sticky) |
| 318 | + if (sticky) { |
279 | 319 | style += `position:sticky;z-index:1;left:0;margin-left:${offset}px;margin-right:${-(offset + size)}px;`; |
280 | | - else style += `position:absolute;height:100%;left:${offset}px;`; |
| 320 | + } else { |
| 321 | + style += `position:absolute;height:100%;left:${offset}px;`; |
| 322 | + } |
281 | 323 | } |
282 | 324 |
|
283 | 325 | styleCache[index] = style; |
284 | | -
|
285 | 326 | return styleCache[index]; |
286 | | - }; |
| 327 | + } |
287 | 328 | </script> |
288 | 329 |
|
289 | 330 | <div |
|
0 commit comments