|
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( |
| 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.raw(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 = $state.raw({ |
67 | 72 | scrollToIndex,
|
68 | 73 | scrollToAlignment,
|
69 | 74 | scrollOffset,
|
70 | 75 | itemCount,
|
71 | 76 | itemSize,
|
72 | 77 | estimatedItemSize,
|
73 |
| - Number.isFinite(height) ? height : 400, |
74 |
| - Number.isFinite(width) ? width : 400, |
| 78 | + heightNumber, |
| 79 | + widthNumber, |
75 | 80 | stickyIndices
|
76 |
| - ); |
77 |
| -
|
78 |
| - const _state = new ListState(scrollOffset || 0); |
| 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 | + } |
| 164 | +
|
| 165 | + if ( |
| 166 | + forceRecomputeSizes || |
| 167 | + prevProps.heightNumber !== heightNumber || |
| 168 | + prevProps.widthNumber !== widthNumber || |
| 169 | + prevProps.stickyIndices.toString() !== $state.snapshot(stickyIndices).toString() |
| 170 | + ) { |
| 171 | + recomputeSizes(); |
| 172 | + } |
| 173 | +
|
| 174 | + prevProps = { |
| 175 | + scrollToIndex: $state.snapshot(scrollToIndex), |
| 176 | + scrollToAlignment: $state.snapshot(scrollToAlignment), |
| 177 | + scrollOffset: $state.snapshot(scrollOffset), |
| 178 | + itemCount: $state.snapshot(itemCount), |
| 179 | + // @ts-expect-error since snapshot does not support functions properly |
| 180 | + itemSize: $state.snapshot(itemSize), |
| 181 | + estimatedItemSize: $state.snapshot(estimatedItemSize), |
| 182 | + heightNumber: $state.snapshot(heightNumber), |
| 183 | + widthNumber: $state.snapshot(widthNumber), |
| 184 | + stickyIndices: $state.snapshot(stickyIndices) |
| 185 | + }; |
| 186 | + } |
136 | 187 |
|
137 |
| - untrack(() => { |
138 |
| - if (_state.doRefresh) refresh(); |
| 188 | + function scrollUpdated() { |
| 189 | + if (prevScroll.offset !== scroll.offset || prevScroll.changeReason !== scroll.changeReason) { |
| 190 | + refresh(); |
| 191 | + } |
139 | 192 |
|
140 |
| - if (_state.doScrollToOffset) scrollTo(_state.offset); |
| 193 | + if ( |
| 194 | + prevScroll.offset !== scroll.offset && |
| 195 | + scroll.changeReason === SCROLL_CHANGE_REASON.REQUESTED |
| 196 | + ) { |
| 197 | + wrapper.scroll({ |
| 198 | + [SCROLL_PROP[scrollDirection]]: scroll.offset, |
| 199 | + behavior: scrollToBehaviour |
| 200 | + }); |
| 201 | + } |
141 | 202 |
|
142 |
| - _state.update(); |
143 |
| - }); |
144 |
| - }); |
| 203 | + prevScroll = scroll; |
| 204 | + } |
145 | 205 |
|
146 | 206 | /**
|
147 | 207 | * Recomputes the sizes of the items and updates the visible items.
|
148 | 208 | */
|
149 |
| - const refresh = () => { |
| 209 | + function refresh() { |
150 | 210 | const { start, end } = sizeAndPositionManager.getVisibleRange(
|
151 |
| - scrollDirection === DIRECTION.VERTICAL ? _props.height : _props.width, |
152 |
| - _state.offset, |
| 211 | + scrollDirection === DIRECTION.VERTICAL ? heightNumber : widthNumber, |
| 212 | + scroll.offset, |
153 | 213 | overscanCount
|
154 | 214 | );
|
155 | 215 |
|
|
160 | 220 | const heightUnit = typeof height === 'number' ? 'px' : '';
|
161 | 221 | const widthUnit = typeof width === 'number' ? 'px' : '';
|
162 | 222 |
|
| 223 | + wrapperStyle = `height:${height}${heightUnit};width:${width}${widthUnit};`; |
163 | 224 | if (scrollDirection === DIRECTION.VERTICAL) {
|
164 |
| - wrapperStyle = `height:${height}${heightUnit};width:${width}${widthUnit};`; |
165 | 225 | innerStyle = `flex-direction:column;height:${totalSize}px;`;
|
166 | 226 | } else {
|
167 |
| - wrapperStyle = `height:${height}${heightUnit};width:${width}${widthUnit};`; |
168 | 227 | innerStyle = `min-height:100%;width:${totalSize}px;`;
|
169 | 228 | }
|
170 | 229 |
|
|
193 | 252 | }
|
194 | 253 |
|
195 | 254 | 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 |
| - }; |
| 255 | + } |
208 | 256 |
|
209 | 257 | /**
|
210 | 258 | * Recomputes the sizes of the items in the list.
|
211 |
| - * @param {number} startIndex |
212 | 259 | */
|
213 |
| - export const recomputeSizes = (startIndex = scrollToIndex) => { |
| 260 | + export function recomputeSizes(startIndex = scrollToIndex) { |
214 | 261 | styleCache = {};
|
215 |
| - if (startIndex >= 0) sizeAndPositionManager.resetItem(startIndex); |
| 262 | + if (startIndex !== undefined && startIndex >= 0) { |
| 263 | + sizeAndPositionManager.resetItem(startIndex); |
| 264 | + } |
216 | 265 | refresh();
|
217 |
| - }; |
| 266 | + } |
218 | 267 |
|
219 | 268 | /**
|
220 | 269 | * Calculates the offset for a given index based on the scroll direction and alignment.
|
221 | 270 | * @param {number} index
|
222 |
| - * @param {import('./types.js').Alignment} align |
223 | 271 | */
|
224 |
| - const getOffsetForIndex = (index, align = scrollToAlignment) => { |
| 272 | + function getOffsetForIndex(index) { |
225 | 273 | if (index < 0 || index >= itemCount) index = 0;
|
226 | 274 |
|
227 | 275 | return sizeAndPositionManager.getUpdatedOffsetForIndex(
|
228 |
| - align, |
229 |
| - scrollDirection === DIRECTION.VERTICAL ? _props.height : _props.width, |
230 |
| - _state.offset || 0, |
| 276 | + scrollToAlignment, |
| 277 | + scrollDirection === DIRECTION.VERTICAL ? heightNumber : widthNumber, |
| 278 | + scroll.offset || 0, |
231 | 279 | index
|
232 | 280 | );
|
233 |
| - }; |
| 281 | + } |
234 | 282 |
|
235 | 283 | /**
|
236 | 284 | * Handles the scroll event on the wrapper element.
|
237 | 285 | * @param {Event} event
|
238 | 286 | */
|
239 |
| - const handleScroll = (event) => { |
240 |
| - const offset = getWrapperOffset(); |
| 287 | + function handleScroll(event) { |
| 288 | + const offset = wrapper[SCROLL_PROP_LEGACY[scrollDirection]]; |
241 | 289 |
|
242 |
| - if (offset < 0 || _state.offset === offset || event.target !== wrapper) return null; |
| 290 | + if (offset < 0 || scroll.offset === offset || event.target !== wrapper) return; |
243 | 291 |
|
244 |
| - _state.listen(offset, SCROLL_CHANGE_REASON.OBSERVED); |
| 292 | + scroll = { offset, changeReason: SCROLL_CHANGE_REASON.OBSERVED }; |
245 | 293 |
|
246 | 294 | 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 |
| - }; |
| 295 | + } |
256 | 296 |
|
257 | 297 | /**
|
258 | 298 | * Returns the style for a given item index.
|
259 | 299 | * @param {number} index The index of the item
|
260 | 300 | * @param {boolean} sticky Whether the item should be sticky or not
|
261 | 301 | */
|
262 |
| - const getStyle = (index, sticky) => { |
| 302 | + function getStyle(index, sticky) { |
263 | 303 | if (styleCache[index]) return styleCache[index];
|
264 | 304 |
|
265 | 305 | const { size, offset } = sizeAndPositionManager.getSizeAndPositionForIndex(index);
|
266 | 306 |
|
267 | 307 | let style;
|
268 |
| -
|
269 | 308 | if (scrollDirection === DIRECTION.VERTICAL) {
|
270 | 309 | style = `left:0;width:100%;height:${size}px;`;
|
271 | 310 |
|
272 |
| - if (sticky) |
| 311 | + if (sticky) { |
273 | 312 | 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;`; |
| 313 | + } else { |
| 314 | + style += `position:absolute;top:${offset}px;`; |
| 315 | + } |
275 | 316 | } else {
|
276 | 317 | style = `top:0;width:${size}px;`;
|
277 | 318 |
|
278 |
| - if (sticky) |
| 319 | + if (sticky) { |
279 | 320 | 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;`; |
| 321 | + } else { |
| 322 | + style += `position:absolute;height:100%;left:${offset}px;`; |
| 323 | + } |
281 | 324 | }
|
282 | 325 |
|
283 | 326 | styleCache[index] = style;
|
284 |
| -
|
285 | 327 | return styleCache[index];
|
286 |
| - }; |
| 328 | + } |
287 | 329 | </script>
|
288 | 330 |
|
289 | 331 | <div
|
|
0 commit comments