Skip to main content

max / goingson

13.5 KB · 425 lines History Blame Raw
1 /**
2 * GoingsOn - VirtualScroller
3 * High-performance virtual scrolling for large lists.
4 * Only renders visible DOM nodes + overscan buffer.
5 */
6
7 (function() {
8 'use strict';
9
10 class VirtualScroller {
11 /**
12 * Create a virtual scroller instance.
13 * @param {Object} config - Configuration options
14 * @param {HTMLElement} config.container - Scrollable container element
15 * @param {Function} config.renderItem - (item, index) => HTML string
16 * @param {Function} config.getItems - () => array of items
17 * @param {Object} config.rowHeight - { estimated: number, measure?: boolean }
18 * @param {number} config.overscan - Number of buffer rows above/below viewport (default: 5)
19 * @param {Function} config.onRender - Optional callback after render (visibleItems, startIndex)
20 */
21 constructor(config) {
22 this.container = config.container;
23 this.renderItem = config.renderItem;
24 this.getItems = config.getItems;
25 this.estimatedRowHeight = config.rowHeight?.estimated || 52;
26 this.shouldMeasure = config.rowHeight?.measure !== false;
27 this.overscan = config.overscan ?? 5;
28 this.onRender = config.onRender || null;
29 // Optional infinite-scroll hook. Fired once (until refresh()) when the
30 // rendered window comes within needMoreThreshold rows of the end, so the
31 // caller can fetch and append the next page. The caller re-arms by
32 // calling refresh() after appending; if there is no more data it simply
33 // returns without refreshing and the trigger stays disarmed.
34 this.onNeedMore = config.onNeedMore || null;
35 this.needMoreThreshold = config.needMoreThreshold ?? (this.overscan * 4);
36 this._loadingMore = false;
37
38 // State
39 this.items = [];
40 this.heightCache = new Map(); // itemId -> measured height
41 this.scrollTop = 0;
42 this.viewportHeight = 0;
43 this.startIndex = 0;
44 this.endIndex = 0;
45 this.isDestroyed = false;
46
47 // Create DOM structure
48 this._createStructure();
49
50 // Bind event handlers
51 this._handleScroll = this._handleScroll.bind(this);
52 this._handleResize = this._handleResize.bind(this);
53
54 // Attach listeners
55 this.container.addEventListener('scroll', this._handleScroll, { passive: true });
56 this.resizeObserver = new ResizeObserver(this._handleResize);
57 this.resizeObserver.observe(this.container);
58
59 // Initial render
60 this.refresh();
61 }
62
63 /**
64 * Create the internal DOM structure for virtual scrolling.
65 * @private
66 */
67 _createStructure() {
68 // Wrapper for content
69 this.wrapper = document.createElement('div');
70 this.wrapper.className = 'virtual-scroller-wrapper';
71
72 // Top spacer
73 this.topSpacer = document.createElement('div');
74 this.topSpacer.className = 'virtual-scroller-spacer';
75
76 // Content area where visible items are rendered
77 this.content = document.createElement('div');
78 this.content.className = 'virtual-scroller-content';
79
80 // Bottom spacer
81 this.bottomSpacer = document.createElement('div');
82 this.bottomSpacer.className = 'virtual-scroller-spacer';
83
84 this.wrapper.appendChild(this.topSpacer);
85 this.wrapper.appendChild(this.content);
86 this.wrapper.appendChild(this.bottomSpacer);
87
88 this.container.innerHTML = '';
89 this.container.appendChild(this.wrapper);
90 }
91
92 /**
93 * Get the height for an item (measured or estimated).
94 * @private
95 * @param {*} item - The item
96 * @param {number} index - Item index
97 * @returns {number} Height in pixels
98 */
99 _getItemHeight(item, index) {
100 const id = item.id ?? index;
101 if (this.heightCache.has(id)) {
102 return this.heightCache.get(id);
103 }
104 return this.estimatedRowHeight;
105 }
106
107 /**
108 * Calculate total content height.
109 * @private
110 * @returns {number} Total height in pixels
111 */
112 _getTotalHeight() {
113 let total = 0;
114 for (let i = 0; i < this.items.length; i++) {
115 total += this._getItemHeight(this.items[i], i);
116 }
117 return total;
118 }
119
120 /**
121 * Calculate which items should be visible.
122 * @private
123 * @returns {{ startIndex: number, endIndex: number, topOffset: number }}
124 */
125 _calculateVisibleRange() {
126 if (this.items.length === 0) {
127 return { startIndex: 0, endIndex: 0, topOffset: 0 };
128 }
129
130 const scrollTop = this.container.scrollTop;
131 const viewportHeight = this.container.clientHeight;
132
133 let accumulatedHeight = 0;
134 let startIndex = 0;
135 let topOffset = 0;
136
137 // Find start index
138 for (let i = 0; i < this.items.length; i++) {
139 const height = this._getItemHeight(this.items[i], i);
140 if (accumulatedHeight + height > scrollTop) {
141 startIndex = i;
142 topOffset = accumulatedHeight;
143 break;
144 }
145 accumulatedHeight += height;
146 if (i === this.items.length - 1) {
147 startIndex = this.items.length;
148 topOffset = accumulatedHeight;
149 }
150 }
151
152 // Apply overscan to start
153 startIndex = Math.max(0, startIndex - this.overscan);
154
155 // Recalculate topOffset for adjusted start
156 topOffset = 0;
157 for (let i = 0; i < startIndex; i++) {
158 topOffset += this._getItemHeight(this.items[i], i);
159 }
160
161 // Find end index
162 let endIndex = startIndex;
163 let renderedHeight = 0;
164 const targetHeight = viewportHeight + (this.overscan * 2 * this.estimatedRowHeight);
165
166 for (let i = startIndex; i < this.items.length && renderedHeight < targetHeight; i++) {
167 renderedHeight += this._getItemHeight(this.items[i], i);
168 endIndex = i + 1;
169 }
170
171 // Apply overscan to end
172 endIndex = Math.min(this.items.length, endIndex + this.overscan);
173
174 return { startIndex, endIndex, topOffset };
175 }
176
177 /**
178 * Measure rendered items and update height cache.
179 * @private
180 * @returns {boolean} true if any cached height changed (caller should recompute spacers)
181 */
182 _measureRenderedItems() {
183 if (!this.shouldMeasure) return false;
184
185 const rows = this.content.children;
186 let changed = false;
187 for (let i = 0; i < rows.length; i++) {
188 const row = rows[i];
189 const itemIndex = this.startIndex + i;
190 if (itemIndex >= this.items.length) break;
191
192 const item = this.items[itemIndex];
193 const id = item.id ?? itemIndex;
194 const height = row.offsetHeight;
195
196 if (height > 0) {
197 const prev = this.heightCache.get(id);
198 if (prev !== height) {
199 this.heightCache.set(id, height);
200 changed = true;
201 }
202 }
203 }
204 return changed;
205 }
206
207 /**
208 * Render visible items.
209 * @private
210 * @param {boolean} forceRender - Skip the range-unchanged short-circuit (use after data refresh)
211 */
212 _render(forceRender = false) {
213 if (this.isDestroyed) return;
214
215 this.items = this.getItems() || [];
216
217 if (this.items.length === 0) {
218 this.topSpacer.style.height = '0px';
219 this.bottomSpacer.style.height = '0px';
220 this.content.innerHTML = '<div class="empty-state empty-state--compact">No items to display</div>';
221 this.startIndex = 0;
222 this.endIndex = 0;
223 return;
224 }
225
226 const { startIndex, endIndex, topOffset } = this._calculateVisibleRange();
227
228 // Short-circuit: if the visible range hasn't changed since last render,
229 // skip the innerHTML thrash. This is the hot path on touch scroll —
230 // scroll events fire at 60Hz+ but most don't cross row boundaries.
231 if (!forceRender && startIndex === this.startIndex && endIndex === this.endIndex && this._hasRendered) {
232 return;
233 }
234
235 this.startIndex = startIndex;
236 this.endIndex = endIndex;
237
238 // Render visible items
239 const html = [];
240 for (let i = startIndex; i < endIndex; i++) {
241 html.push(this.renderItem(this.items[i], i));
242 }
243 this.content.innerHTML = html.join('');
244 this._hasRendered = true;
245
246 // Set spacers from the values we just computed — no need to recompute.
247 this.topSpacer.style.height = `${topOffset}px`;
248 let bottomHeight = 0;
249 for (let i = endIndex; i < this.items.length; i++) {
250 bottomHeight += this._getItemHeight(this.items[i], i);
251 }
252 this.bottomSpacer.style.height = `${bottomHeight}px`;
253
254 // Measure after layout. Only re-update spacers if measurement
255 // actually changed cached heights for items above the viewport
256 // (those shift topOffset and need correction).
257 requestAnimationFrame(() => {
258 if (this.isDestroyed) return;
259 const changed = this._measureRenderedItems();
260 if (changed) this._updateSpacers();
261 });
262
263 // Callback
264 if (this.onRender) {
265 const visibleItems = this.items.slice(startIndex, endIndex);
266 this.onRender(visibleItems, startIndex);
267 }
268
269 // Infinite-scroll: if we are rendering near the tail of the loaded data,
270 // ask the caller for the next page. Disarm immediately so the request
271 // fires at most once until refresh() re-arms it.
272 if (this.onNeedMore && !this._loadingMore &&
273 endIndex >= this.items.length - this.needMoreThreshold) {
274 this._loadingMore = true;
275 this.onNeedMore();
276 }
277 }
278
279 /**
280 * Recompute and apply spacer heights. Called after measurement if a row's
281 * height changed from its previous cached/estimated value.
282 * @private
283 */
284 _updateSpacers() {
285 let topHeight = 0;
286 for (let i = 0; i < this.startIndex; i++) {
287 topHeight += this._getItemHeight(this.items[i], i);
288 }
289 this.topSpacer.style.height = `${topHeight}px`;
290
291 let bottomHeight = 0;
292 for (let i = this.endIndex; i < this.items.length; i++) {
293 bottomHeight += this._getItemHeight(this.items[i], i);
294 }
295 this.bottomSpacer.style.height = `${bottomHeight}px`;
296 }
297
298 /**
299 * Handle scroll events.
300 * @private
301 */
302 _handleScroll() {
303 if (this.isDestroyed) return;
304
305 // Use requestAnimationFrame for smooth scrolling
306 if (this._scrollRaf) {
307 cancelAnimationFrame(this._scrollRaf);
308 }
309
310 this._scrollRaf = requestAnimationFrame(() => {
311 this._render();
312 });
313 }
314
315 /**
316 * Handle container resize.
317 * @private
318 */
319 _handleResize(entries) {
320 if (this.isDestroyed) return;
321
322 const entry = entries[0];
323 if (entry) {
324 this.viewportHeight = entry.contentRect.height;
325 this._render();
326 }
327 }
328
329 /**
330 * Refresh the scroller (call after data changes).
331 */
332 refresh() {
333 if (this.isDestroyed) return;
334 // Re-arm the infinite-scroll trigger: the data set just changed (likely
335 // a freshly appended page), so a new near-tail render may need more.
336 this._loadingMore = false;
337 this._render(true);
338 }
339
340 /**
341 * Scroll to a specific item index.
342 * @param {number} index - Item index to scroll to
343 * @param {string} align - 'start', 'center', or 'end' (default: 'start')
344 */
345 scrollToIndex(index, align = 'start') {
346 if (this.isDestroyed) return;
347 if (index < 0 || index >= this.items.length) return;
348
349 let targetScrollTop = 0;
350 for (let i = 0; i < index; i++) {
351 targetScrollTop += this._getItemHeight(this.items[i], i);
352 }
353
354 const itemHeight = this._getItemHeight(this.items[index], index);
355 const viewportHeight = this.container.clientHeight;
356
357 switch (align) {
358 case 'center':
359 targetScrollTop -= (viewportHeight - itemHeight) / 2;
360 break;
361 case 'end':
362 targetScrollTop -= viewportHeight - itemHeight;
363 break;
364 // 'start' - no adjustment needed
365 }
366
367 targetScrollTop = Math.max(0, targetScrollTop);
368 this.container.scrollTop = targetScrollTop;
369 }
370
371 /**
372 * Get the currently visible items.
373 * @returns {Array} Array of visible items
374 */
375 getVisibleItems() {
376 return this.items.slice(this.startIndex, this.endIndex);
377 }
378
379 /**
380 * Get the index of an item by its ID.
381 * @param {string} id - Item ID
382 * @returns {number} Index or -1 if not found
383 */
384 getIndexById(id) {
385 return this.items.findIndex(item => item.id === id);
386 }
387
388 /**
389 * Clear the height cache (useful after style changes).
390 */
391 clearHeightCache() {
392 this.heightCache.clear();
393 this._hasRendered = false;
394 this.refresh();
395 }
396
397 /**
398 * Destroy the scroller and clean up.
399 */
400 destroy() {
401 this.isDestroyed = true;
402
403 if (this._scrollRaf) {
404 cancelAnimationFrame(this._scrollRaf);
405 }
406
407 this.container.removeEventListener('scroll', this._handleScroll);
408
409 if (this.resizeObserver) {
410 this.resizeObserver.disconnect();
411 }
412
413 this.heightCache.clear();
414 this.items = [];
415 }
416 }
417
418 // ============ Populate GoingsOn Namespace ============
419
420 if (window.GoingsOn) {
421 GoingsOn.VirtualScroller = VirtualScroller;
422 }
423
424 })();
425