Skip to content

Commit 07db4d6

Browse files
committed
refactor: get rid of ListProps and ListState classes by putting it directly in component
1 parent d20cdfb commit 07db4d6

File tree

3 files changed

+152
-284
lines changed

3 files changed

+152
-284
lines changed

src/lib/VirtualList.svelte

Lines changed: 152 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
SCROLL_PROP,
99
SCROLL_PROP_LEGACY
1010
} from './constants.js';
11-
import { ListState } from './utils/ListState.svelte.js';
12-
import { ListProps } from './utils/ListProps.svelte.js';
1311
/** @import { VirtualListProps, VirtualListEvents, VirtualListSnippets } from './types.js'; */
1412
1513
/** @type {VirtualListProps & VirtualListEvents & VirtualListSnippets} */
@@ -21,13 +19,13 @@
2119
2220
itemCount,
2321
itemSize,
24-
estimatedItemSize = 0,
22+
estimatedItemSize: optEstimatedItemSize,
2523
stickyIndices = [],
2624
getKey,
2725
2826
scrollDirection = DIRECTION.VERTICAL,
29-
scrollOffset = 0,
30-
scrollToIndex = -1,
27+
scrollOffset,
28+
scrollToIndex,
3129
scrollToAlignment = ALIGNMENT.START,
3230
scrollToBehaviour = 'instant',
3331
@@ -49,107 +47,169 @@
4947
footer: footerSnippet
5048
} = $props();
5149
50+
let estimatedItemSize = $derived(
51+
optEstimatedItemSize || (typeof itemSize === 'number' && itemSize) || 50
52+
);
53+
const sizeAndPositionManager = new SizeAndPositionManager(itemSize, itemCount, estimatedItemSize);
54+
5255
/** @type {HTMLDivElement} */
5356
let wrapper;
54-
55-
/** @type {Record<number, string>} */
56-
let styleCache = $state({});
57-
let wrapperStyle = $state.raw('');
58-
let innerStyle = $state.raw('');
59-
6057
let wrapperHeight = $state(400);
6158
let wrapperWidth = $state(400);
62-
6359
/** @type {{ index: number, style: string }[]} */
6460
let items = $state.raw([]);
6561
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({
6772
scrollToIndex,
6873
scrollToAlignment,
6974
scrollOffset,
7075
itemCount,
7176
itemSize,
7277
estimatedItemSize,
73-
Number.isFinite(height) ? height : 400,
74-
Number.isFinite(width) ? width : 400,
78+
heightNumber,
79+
widthNumber,
7580
stickyIndices
76-
);
77-
78-
const _state = new ListState(scrollOffset || 0);
81+
});
7982
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('');
8587
8688
// Effect 0: Event listener
8789
$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+
88103
const options = { passive: true };
89-
wrapper.addEventListener('scroll', handleScroll, options);
104+
wrapper.addEventListener('scroll', handleScrollAsync, options);
90105
91106
return () => {
92107
// @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);
94109
};
95110
});
96111
97112
// Effect 1: Update props from user provided props
98113
$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+
});
110126
111-
untrack(() => {
112-
let doRecomputeSizes = false;
127+
// Effect 2: Update scroll
128+
$effect(() => {
129+
scroll;
113130
114-
if (_props.haveSizesChanged) {
115-
sizeAndPositionManager.updateConfig(itemSize, itemCount, _props.estimatedItemSize);
116-
doRecomputeSizes = true;
117-
}
131+
untrack(scrollUpdated);
132+
});
118133
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;
126142
127-
if (_props.haveDimsOrStickyIndicesChanged || doRecomputeSizes) recomputeSizes();
143+
let forceRecomputeSizes = false;
144+
if (itemPropsHaveChanged) {
145+
sizeAndPositionManager.updateConfig(itemSize, itemCount, estimatedItemSize);
128146
129-
_props.update();
130-
});
131-
});
147+
forceRecomputeSizes = true;
148+
}
132149
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+
}
136187
137-
untrack(() => {
138-
if (_state.doRefresh) refresh();
188+
function scrollUpdated() {
189+
if (prevScroll.offset !== scroll.offset || prevScroll.changeReason !== scroll.changeReason) {
190+
refresh();
191+
}
139192
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+
}
141202
142-
_state.update();
143-
});
144-
});
203+
prevScroll = scroll;
204+
}
145205
146206
/**
147207
* Recomputes the sizes of the items and updates the visible items.
148208
*/
149-
const refresh = () => {
209+
function refresh() {
150210
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,
153213
overscanCount
154214
);
155215
@@ -160,11 +220,10 @@
160220
const heightUnit = typeof height === 'number' ? 'px' : '';
161221
const widthUnit = typeof width === 'number' ? 'px' : '';
162222
223+
wrapperStyle = `height:${height}${heightUnit};width:${width}${widthUnit};`;
163224
if (scrollDirection === DIRECTION.VERTICAL) {
164-
wrapperStyle = `height:${height}${heightUnit};width:${width}${widthUnit};`;
165225
innerStyle = `flex-direction:column;height:${totalSize}px;`;
166226
} else {
167-
wrapperStyle = `height:${height}${heightUnit};width:${width}${widthUnit};`;
168227
innerStyle = `min-height:100%;width:${totalSize}px;`;
169228
}
170229
@@ -193,97 +252,80 @@
193252
}
194253
195254
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+
}
208256
209257
/**
210258
* Recomputes the sizes of the items in the list.
211-
* @param {number} startIndex
212259
*/
213-
export const recomputeSizes = (startIndex = scrollToIndex) => {
260+
export function recomputeSizes(startIndex = scrollToIndex) {
214261
styleCache = {};
215-
if (startIndex >= 0) sizeAndPositionManager.resetItem(startIndex);
262+
if (startIndex !== undefined && startIndex >= 0) {
263+
sizeAndPositionManager.resetItem(startIndex);
264+
}
216265
refresh();
217-
};
266+
}
218267
219268
/**
220269
* Calculates the offset for a given index based on the scroll direction and alignment.
221270
* @param {number} index
222-
* @param {import('./types.js').Alignment} align
223271
*/
224-
const getOffsetForIndex = (index, align = scrollToAlignment) => {
272+
function getOffsetForIndex(index) {
225273
if (index < 0 || index >= itemCount) index = 0;
226274
227275
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,
231279
index
232280
);
233-
};
281+
}
234282
235283
/**
236284
* Handles the scroll event on the wrapper element.
237285
* @param {Event} event
238286
*/
239-
const handleScroll = (event) => {
240-
const offset = getWrapperOffset();
287+
function handleScroll(event) {
288+
const offset = wrapper[SCROLL_PROP_LEGACY[scrollDirection]];
241289
242-
if (offset < 0 || _state.offset === offset || event.target !== wrapper) return null;
290+
if (offset < 0 || scroll.offset === offset || event.target !== wrapper) return;
243291
244-
_state.listen(offset, SCROLL_CHANGE_REASON.OBSERVED);
292+
scroll = { offset, changeReason: SCROLL_CHANGE_REASON.OBSERVED };
245293
246294
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+
}
256296
257297
/**
258298
* Returns the style for a given item index.
259299
* @param {number} index The index of the item
260300
* @param {boolean} sticky Whether the item should be sticky or not
261301
*/
262-
const getStyle = (index, sticky) => {
302+
function getStyle(index, sticky) {
263303
if (styleCache[index]) return styleCache[index];
264304
265305
const { size, offset } = sizeAndPositionManager.getSizeAndPositionForIndex(index);
266306
267307
let style;
268-
269308
if (scrollDirection === DIRECTION.VERTICAL) {
270309
style = `left:0;width:100%;height:${size}px;`;
271310
272-
if (sticky)
311+
if (sticky) {
273312
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+
}
275316
} else {
276317
style = `top:0;width:${size}px;`;
277318
278-
if (sticky)
319+
if (sticky) {
279320
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+
}
281324
}
282325
283326
styleCache[index] = style;
284-
285327
return styleCache[index];
286-
};
328+
}
287329
</script>
288330

289331
<div

0 commit comments

Comments
 (0)