Skip to main content

max / goingson

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