|
1 |
+ |
/**
|
|
2 |
+ |
* GoingsOn - CardGridScroller
|
|
3 |
+ |
* Virtual scrolling for multi-column card grids.
|
|
4 |
+ |
*
|
|
5 |
+ |
* VirtualScroller windows a single stack of rows, so pointing it straight at a
|
|
6 |
+ |
* card grid would window one card at a time and collapse the grid to a column.
|
|
7 |
+ |
* This wraps it and windows a grid ROW instead: cards are chunked into groups
|
|
8 |
+ |
* of N, each group renders as one grid element, and the scroller windows those.
|
|
9 |
+ |
* The multi-column layout survives untouched.
|
|
10 |
+ |
*
|
|
11 |
+ |
* N is measured, not recomputed. A hidden probe carrying the grid's own class
|
|
12 |
+ |
* is laid out and its resolved `grid-template-columns` counted, so the
|
|
13 |
+ |
* stylesheet stays the single source of truth for column count -- media
|
|
14 |
+ |
* queries and the mobile one-column rule included -- instead of a JS copy of
|
|
15 |
+ |
* the auto-fill math that drifts the first time a breakpoint moves.
|
|
16 |
+ |
*
|
|
17 |
+ |
* Row gaps live in the row's padding-bottom rather than the grid `gap`, because
|
|
18 |
+ |
* the scroller sizes its spacers from `offsetHeight`, which counts padding and
|
|
19 |
+ |
* ignores margin.
|
|
20 |
+ |
*/
|
|
21 |
+ |
|
|
22 |
+ |
(function() {
|
|
23 |
+ |
'use strict';
|
|
24 |
+ |
|
|
25 |
+ |
class CardGridScroller {
|
|
26 |
+ |
/**
|
|
27 |
+ |
* @param {Object} config
|
|
28 |
+ |
* @param {HTMLElement} config.container - Scrollable grid container
|
|
29 |
+ |
* @param {Function} config.getItems - () => array of items (cards, not rows)
|
|
30 |
+ |
* @param {Function} config.renderCard - (item, index) => HTML string for one card
|
|
31 |
+ |
* @param {string} config.probeClass - Class whose grid-template-columns defines the layout
|
|
32 |
+ |
* @param {string} config.rowClass - Class applied to each rendered grid row
|
|
33 |
+ |
* @param {number} config.estimatedRowHeight - Starting guess for a row's height in px
|
|
34 |
+ |
* @param {number} [config.overscan] - Buffer rows above/below the viewport
|
|
35 |
+ |
* @param {Function} [config.onRender] - Called after each render, no arguments
|
|
36 |
+ |
*/
|
|
37 |
+ |
constructor(config) {
|
|
38 |
+ |
this.container = config.container;
|
|
39 |
+ |
this.getItems = config.getItems;
|
|
40 |
+ |
this.renderCard = config.renderCard;
|
|
41 |
+ |
this.probeClass = config.probeClass;
|
|
42 |
+ |
this.rowClass = config.rowClass;
|
|
43 |
+ |
this.onRender = config.onRender || null;
|
|
44 |
+ |
|
|
45 |
+ |
this.columns = 1;
|
|
46 |
+ |
this.rows = [];
|
|
47 |
+ |
this.isDestroyed = false;
|
|
48 |
+ |
this._lastWidth = this.container.clientWidth;
|
|
49 |
+ |
|
|
50 |
+ |
this._chunk();
|
|
51 |
+ |
|
|
52 |
+ |
this.scroller = new GoingsOn.VirtualScroller({
|
|
53 |
+ |
container: this.container,
|
|
54 |
+ |
renderItem: (row) => this._renderRow(row),
|
|
55 |
+ |
getItems: () => this.rows,
|
|
56 |
+ |
rowHeight: { estimated: config.estimatedRowHeight || 150, measure: true },
|
|
57 |
+ |
overscan: config.overscan ?? 2,
|
|
58 |
+ |
onRender: () => { if (this.onRender) this.onRender(); },
|
|
59 |
+ |
});
|
|
60 |
+ |
|
|
61 |
+ |
// Width changes can change the column count, which re-chunks every row.
|
|
62 |
+ |
// The scroller's own observer only cares about height.
|
|
63 |
+ |
this._handleResize = this._handleResize.bind(this);
|
|
64 |
+ |
this.resizeObserver = new ResizeObserver(this._handleResize);
|
|
65 |
+ |
this.resizeObserver.observe(this.container);
|
|
66 |
+ |
}
|
|
67 |
+ |
|
|
68 |
+ |
/**
|
|
69 |
+ |
* Count the columns the stylesheet would lay out at the current width.
|
|
70 |
+ |
* @private
|
|
71 |
+ |
* @returns {number|null} Column count, or null when the grid is not
|
|
72 |
+ |
* laid out (hidden view, zero width) and the answer is meaningless.
|
|
73 |
+ |
*/
|
|
74 |
+ |
_measureColumns() {
|
|
75 |
+ |
if (this.container.clientWidth === 0) return null;
|
|
76 |
+ |
|
|
77 |
+ |
// Measured in normal flow, so the probe's content width is the width
|
|
78 |
+ |
// the rows themselves get, scrollbar gutter and all. An empty grid has
|
|
79 |
+ |
// no height and the probe is gone before the frame paints.
|
|
80 |
+ |
const probe = document.createElement('div');
|
|
81 |
+ |
probe.className = this.probeClass;
|
|
82 |
+ |
probe.style.visibility = 'hidden';
|
|
83 |
+ |
this.container.appendChild(probe);
|
|
84 |
+ |
const tracks = getComputedStyle(probe).gridTemplateColumns;
|
|
85 |
+ |
probe.remove();
|
|
86 |
+ |
|
|
87 |
+ |
// An unlaid-out grid reports the specified value (`repeat(auto-fill,
|
|
88 |
+ |
// minmax(...))`) rather than a resolved track list. Counting words in
|
|
89 |
+ |
// that would invent a column count, so treat it as unmeasurable.
|
|
90 |
+ |
if (!tracks || tracks === 'none' || tracks.includes('(')) return null;
|
|
91 |
+ |
|
|
92 |
+ |
return Math.max(1, tracks.split(/\s+/).filter(Boolean).length);
|
|
93 |
+ |
}
|
|
94 |
+ |
|
|
95 |
+ |
/**
|
|
96 |
+ |
* Group the flat item list into rows of `columns` items.
|
|
97 |
+ |
* @private
|
|
98 |
+ |
*/
|
|
99 |
+ |
_chunk() {
|
|
100 |
+ |
const measured = this._measureColumns();
|
|
101 |
+ |
if (measured !== null) this.columns = measured;
|
|
102 |
+ |
|
|
103 |
+ |
const items = this.getItems() || [];
|
|
104 |
+ |
const rows = [];
|
|
105 |
+ |
for (let i = 0; i < items.length; i += this.columns) {
|
|
106 |
+ |
const cards = items.slice(i, i + this.columns);
|
|
107 |
+ |
rows.push({
|
|
108 |
+ |
// Row identity keys the scroller's height cache. The first
|
|
109 |
+ |
// card's id is stable while the column count holds; a column
|
|
110 |
+ |
// change re-chunks every row and clears the cache anyway.
|
|
111 |
+ |
id: `row:${cards[0].id ?? i}`,
|
|
112 |
+ |
cards,
|
|
113 |
+ |
startIndex: i,
|
|
114 |
+ |
});
|
|
115 |
+ |
}
|
|
116 |
+ |
this.rows = rows;
|
|
117 |
+ |
}
|
|
118 |
+ |
|
|
119 |
+ |
/**
|
|
120 |
+ |
* @private
|
|
121 |
+ |
* @param {{cards: Array, startIndex: number}} row
|
|
122 |
+ |
* @returns {string} HTML for one grid row
|
|
123 |
+ |
*/
|
|
124 |
+ |
_renderRow(row) {
|
|
125 |
+ |
const cards = row.cards
|
|
126 |
+ |
.map((item, i) => this.renderCard(item, row.startIndex + i))
|
|
127 |
+ |
.join('');
|
|
128 |
+ |
// A partial last row keeps the full track template so its cards match
|
|
129 |
+ |
// the width of the cards above them instead of stretching.
|
|
130 |
+ |
return `<div class="${this.rowClass}" style="grid-template-columns: repeat(${this.columns}, minmax(0, 1fr));">${cards}</div>`;
|
|
131 |
+ |
}
|
|
132 |
+ |
|
|
133 |
+ |
/**
|
|
134 |
+ |
* @private
|
|
135 |
+ |
* @param {ResizeObserverEntry[]} entries
|
|
136 |
+ |
*/
|
|
137 |
+ |
_handleResize(entries) {
|
|
138 |
+ |
if (this.isDestroyed) return;
|
|
139 |
+ |
|
|
140 |
+ |
const width = entries[0] ? entries[0].contentRect.width : this.container.clientWidth;
|
|
141 |
+ |
if (width === this._lastWidth) return;
|
|
142 |
+ |
this._lastWidth = width;
|
|
143 |
+ |
|
|
144 |
+ |
// Card height depends on width (wrapped text), and a column change
|
|
145 |
+ |
// rewrites every row outright, so no cached height survives this.
|
|
146 |
+ |
this._chunk();
|
|
147 |
+ |
this.scroller.clearHeightCache();
|
|
148 |
+ |
}
|
|
149 |
+ |
|
|
150 |
+ |
/**
|
|
151 |
+ |
* Re-read the items and re-render. Call after the data changes.
|
|
152 |
+ |
*/
|
|
153 |
+ |
refresh() {
|
|
154 |
+ |
if (this.isDestroyed) return;
|
|
155 |
+ |
this._chunk();
|
|
156 |
+ |
this.scroller.refresh();
|
|
157 |
+ |
}
|
|
158 |
+ |
|
|
159 |
+ |
/**
|
|
160 |
+ |
* Tear down and hand the container back empty.
|
|
161 |
+ |
*/
|
|
162 |
+ |
destroy() {
|
|
163 |
+ |
this.isDestroyed = true;
|
|
164 |
+ |
if (this.resizeObserver) this.resizeObserver.disconnect();
|
|
165 |
+ |
this.scroller.destroy();
|
|
166 |
+ |
this.rows = [];
|
|
167 |
+ |
this.container.innerHTML = '';
|
|
168 |
+ |
}
|
|
169 |
+ |
}
|
|
170 |
+ |
|
|
171 |
+ |
// Populate GoingsOn Namespace
|
|
172 |
+ |
|
|
173 |
+ |
if (window.GoingsOn) {
|
|
174 |
+ |
GoingsOn.CardGridScroller = CardGridScroller;
|
|
175 |
+ |
}
|
|
176 |
+ |
|
|
177 |
+ |
})();
|