mnfst 0.5.157 → 0.5.159

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,54 +1,61 @@
1
- /* Manifest Virtual — variable-height list virtualization for Alpine.
1
+ /* Manifest Virtual — in-flow list/table/grid/gallery virtualization for Alpine.
2
2
  *
3
3
  * Renders only the rows visible in the scroll viewport (plus an overscan
4
- * buffer), so a list of tens of thousands of rows can scroll smoothly with
5
- * a low DOM count. Row heights are measured on render and the spacer
6
- * recalculates, so authors aren't bound to a fixed row height.
4
+ * buffer). Rows stay in NORMAL FLOW between a top + bottom spacer whose heights
5
+ * stand in for the rows above/below the window so the same markup + CSS works
6
+ * virtualized or not. No absolute positioning, so shared columns (native
7
+ * <table> and .grid-table), sticky cells, and flex-wrap all behave normally.
7
8
  *
8
- * Usage — wrap an x-for template with x-virtual on the scrolling container:
9
+ * Usage — wrap an x-for template in the scrolling container:
9
10
  *
10
11
  * <div x-virtual style="height: 600px; overflow: auto">
11
12
  * <template x-for="row in $x.customers" :key="row.id">
12
- * <div class="row">
13
- * <span x-text="row.name"></span>
14
- * </div>
13
+ * <div class="grid-row"> … cells … </div>
15
14
  * </template>
16
15
  * </div>
17
16
  *
17
+ * The template may be nested (e.g. inside <table><tbody>); the plugin finds it
18
+ * and hosts spacers/rows in its parent.
19
+ *
18
20
  * Options (object expression on the directive):
19
21
  *
20
- * <div x-virtual="{ estimate: 48, overscan: 5 }" style="height: 600px">
22
+ * estimate Initial per-row height in px (default 50), or { width, height }
23
+ * for galleries. Used for unmeasured rows; closer = less drift.
24
+ * overscan Rows/lines to render above/below the window (default 3).
25
+ * mode 'rows' | 'gallery' | 'masonry'. Auto-detected if omitted
26
+ * (masonry must be set explicitly).
21
27
  *
22
- * estimate Initial per-row height in px (default 50). Used for rows
23
- * that haven't been measured yet. Closer estimates mean
24
- * less scroll-position drift on first render.
25
- * overscan Rows to render above/below the visible window (default 3).
26
- * Higher = smoother scroll, more DOM.
28
+ * Modes (how items fill lines the spacer element is auto-detected separately):
29
+ * rows — one item per line: stacked lists, native <table>, and
30
+ * .grid-table. The spacer is a div, a <tr>, or a grid cell
31
+ * depending on the container.
32
+ * gallery — many items per line (flex-wrap); items pack N per line and
33
+ * whole lines render so flex sizing stays correct. Ragged
34
+ * width + height supported.
35
+ * masonry — independent-size items packed into the shortest column
36
+ * (absolute positioning); set explicitly with mode: 'masonry'.
27
37
  *
28
38
  * Notes:
29
- *
30
- * - Only one template child is supported. It must have x-for and :key.
31
- * - The container element must have a bounded height (CSS height /
32
- * max-height) and scroll. The plugin sets overflow: auto + position:
33
- * relative if not already set.
34
- * - Heights are remeasured automatically if a row's content changes.
39
+ * - Column widths should be content-independent (table-layout: fixed, fixed/%
40
+ * grid tracks) or columns shift as the window changes. Stripe by data index,
41
+ * not :nth-child. See the docs for these table caveats.
35
42
  */
36
43
 
37
44
  function initializeVirtualPlugin() {
38
45
 
39
- Alpine.directive('virtual', (el, { expression }, { effect, evaluate, evaluateLater, cleanup }) => {
46
+ Alpine.directive('virtual', (el, { expression }, { evaluate, evaluateLater, effect, cleanup }) => {
40
47
 
41
- // --- Find and parse the template ---
42
- const template = el.querySelector(':scope > template');
43
- if (!template) {
44
- console.warn('[x-virtual] expects a child <template> with x-for, e.g. <template x-for="row in $x.items" :key="row.id">…');
48
+ // --- Find the template (may be nested, e.g. table > tbody > template) ---
49
+ const template = el.querySelector('template[x-for]') || el.querySelector(':scope template');
50
+ if (!template || !template.getAttribute('x-for')) {
51
+ // Stay quiet on re-init of an already-virtualized container (x-for was
52
+ // stripped on the first pass); only warn on genuine misuse.
53
+ if (!el.querySelector('[data-virtual-spacer]')) {
54
+ console.warn('[x-virtual] expects a descendant <template> with x-for, e.g. <template x-for="row in $x.items" :key="row.id">…');
55
+ }
45
56
  return;
46
57
  }
47
58
  const forExpr = template.getAttribute('x-for');
48
- if (!forExpr) {
49
- console.warn('[x-virtual] child <template> must have x-for');
50
- return;
51
- }
52
59
  const m = /^\s*(\S+|\(\s*\S+\s*,\s*\S+\s*\))\s+(?:in|of)\s+(.+?)\s*$/.exec(forExpr);
53
60
  if (!m) {
54
61
  console.warn('[x-virtual] could not parse x-for expression: ' + forExpr);
@@ -56,230 +63,470 @@ function initializeVirtualPlugin() {
56
63
  }
57
64
  const itemName = m[1].trim();
58
65
  const sourceExpr = m[2].trim();
59
- const keyExpr =
60
- template.getAttribute(':key') ||
61
- template.getAttribute('x-bind:key') ||
62
- `${itemName}.id`;
63
-
64
- // Remove x-for/:key so Alpine doesn't try to render the full list, but
65
- // KEEP the template in the DOM as our render source. We'll clone its
66
- // contents per visible row.
66
+ const keyExpr = template.getAttribute(':key') || template.getAttribute('x-bind:key') || `${itemName}.id`;
67
+
68
+ // Strip x-for/:key so Alpine doesn't render the full list; keep the
69
+ // template in the DOM as our clone source.
67
70
  template.removeAttribute('x-for');
68
71
  template.removeAttribute(':key');
69
72
  template.removeAttribute('x-bind:key');
70
73
 
71
74
  // --- Options ---
72
- const options = expression ? evaluate(expression) || {} : {};
73
- const initialEstimate = Number(options.estimate) > 0 ? Number(options.estimate) : 50;
75
+ const options = expression ? (evaluate(expression) || {}) : {};
76
+ const est = options.estimate;
77
+ const estimateH = (est && typeof est === 'object') ? (Number(est.height) || 50) : (Number(est) > 0 ? Number(est) : 50);
78
+ const estimateW = (est && typeof est === 'object') ? (Number(est.width) || 120) : 120;
74
79
  const overscan = Number.isFinite(options.overscan) && options.overscan >= 0 ? Number(options.overscan) : 3;
75
80
 
76
- // --- Container setup ---
77
- const cs = getComputedStyle(el);
78
- if (cs.overflow === 'visible' && cs.overflowY === 'visible') el.style.overflow = 'auto';
79
- if (cs.position === 'static') el.style.position = 'relative';
81
+ // --- Elements & mode ---
82
+ const scrollEl = el; // the bounded scroll viewport
83
+ const host = template.parentElement; // where spacers + rows live
84
+ const cs = getComputedStyle(scrollEl);
85
+ if (cs.overflow === 'visible' && cs.overflowY === 'visible') scrollEl.style.overflow = 'auto';
80
86
 
81
- // The spacer holds the rendered (absolutely positioned) rows and sizes
82
- // itself to the total virtual height so the scrollbar is correct.
83
- const spacer = document.createElement('div');
84
- spacer.dataset.virtualSpacer = '';
85
- spacer.style.position = 'relative';
86
- spacer.style.width = '100%';
87
- spacer.style.height = '0px';
88
- el.appendChild(spacer);
87
+ const kind = detectKind(host); // spacer/geometry: table | grid | flex | block
88
+ const mode = options.mode || detectMode(kind, host); // windowing: rows | gallery | masonry
89
+ const isGallery = mode === 'gallery';
90
+ const isMasonry = mode === 'masonry';
91
+
92
+ // Masonry options: fixed `columns` or target `columnWidth`; optional
93
+ // per-item `span` (cols) and `height` (px) fns from data for ~zero
94
+ // settling; `gap` px (falls back to CSS gap).
95
+ const colsOpt = Number(options.columns) > 0 ? Math.floor(options.columns) : 0;
96
+ const colWidthOpt = Number(options.columnWidth) > 0 ? Number(options.columnWidth) : 0;
97
+ const spanFn = typeof options.span === 'function' ? options.span : null;
98
+ const heightFn = typeof options.height === 'function' ? options.height : null;
99
+ const masonryGap = Number.isFinite(options.gap) ? Number(options.gap) : (parseFloat(cs.gap) || 0);
100
+ if (isMasonry && cs.position === 'static') scrollEl.style.position = 'relative';
101
+
102
+ // Column count for table spacer colspan (so it doesn't disturb columns).
103
+ const colCount = template.content.firstElementChild
104
+ ? template.content.firstElementChild.children.length : 1;
105
+
106
+ // --- Spacers (line modes) / sizer (masonry) ---
107
+ let topSpacer, botSpacer, sizer;
108
+ if (isMasonry) {
109
+ sizer = document.createElement('div');
110
+ sizer.dataset.virtualSpacer = '';
111
+ sizer.setAttribute('aria-hidden', 'true');
112
+ sizer.style.cssText = 'position:absolute;top:0;left:0;width:1px;height:0;pointer-events:none';
113
+ host.appendChild(sizer);
114
+ } else {
115
+ topSpacer = makeSpacer(kind, colCount);
116
+ botSpacer = makeSpacer(kind, colCount);
117
+ host.insertBefore(topSpacer, template);
118
+ host.insertBefore(botSpacer, template);
119
+ }
120
+ const anchor = isMasonry ? sizer : botSpacer; // rows insert before this
89
121
 
90
122
  // --- State ---
91
- // heights: key -> measured pixel height (only for rows that have been
92
- // mounted at least once and measured).
93
- const heights = new Map();
94
- let measuredSum = 0;
95
- let measuredCount = 0;
96
- // rendered: key -> wrapper element currently in the DOM
97
- const rendered = new Map();
98
- // data: latest snapshot of the source array
123
+ const heights = new Map(); // key -> measured line/row height
124
+ const widths = new Map(); // key -> measured item width (gallery only)
125
+ let measuredSum = 0, measuredCount = 0;
126
+ const rendered = new Map(); // key -> { node, scope, removeScope }
99
127
  let data = [];
100
- // Cached cumulative offsets index i holds the sum of heights of rows
101
- // 0..(i-1). Length is data.length + 1; final entry is total height.
102
- let cumulative = new Float64Array(1);
103
-
104
- const getAvg = () => (measuredCount > 0 ? measuredSum / measuredCount : initialEstimate);
105
- const rowHeightFor = (key) => heights.get(key) ?? getAvg();
128
+ let cumulative = new Float64Array(1); // line-top offsets; last = total
129
+ let lines = []; // gallery: [{ start, end, height }]
130
+ let pos = []; // masonry: per-index { x, y, w, h }
131
+ let sortedTop = []; // masonry: indices sorted by y
132
+ let totalHeight = 0; // masonry: packed content height
133
+ let maxItemH = 0; // masonry: back-scan bound
134
+ let ready = false; // becomes true once container is sized
106
135
 
107
- // Evaluate the key expression against an item without going through
108
- // Alpine — `new Function` is fast and isolates from the surrounding
109
- // scope. Expression usually looks like `row.id` or `row.$id`.
110
136
  const keyFn = buildKeyFn(itemName, keyExpr);
137
+ const avgH = () => (measuredCount > 0 ? measuredSum / measuredCount : estimateH);
138
+ const heightFor = (k) => heights.get(k) ?? avgH();
139
+ const widthFor = (k) => widths.get(k) ?? estimateW;
140
+ const heightForItem = (item) => heightFn ? (Number(heightFn(item)) || estimateH) : heightFor(keyFn(item));
111
141
 
112
- function rebuildCumulative() {
142
+ // ---- Cumulative offset model ----
143
+
144
+ function rebuild() {
145
+ if (isMasonry) rebuildMasonry();
146
+ else if (isGallery) rebuildGallery();
147
+ else rebuildSingle();
148
+ }
149
+
150
+ // Skyline packer: place each item in the column band (of `span` width)
151
+ // with the lowest top. Sizes are independent; only positions depend on
152
+ // order — so the whole layout is computed here in JS, no rendering.
153
+ function rebuildMasonry() {
154
+ const cw = contentWidth();
155
+ const g = masonryGap;
156
+ let cols = colsOpt || (colWidthOpt ? Math.max(1, Math.floor((cw + g) / (colWidthOpt + g))) : 3);
157
+ const colW = (cw - (cols - 1) * g) / cols;
158
+ const bottom = new Float64Array(cols);
159
+ const n = data.length;
160
+ pos = new Array(n);
161
+ maxItemH = 0;
162
+ for (let i = 0; i < n; i++) {
163
+ const item = data[i];
164
+ let span = spanFn ? (spanFn(item) | 0) : 1;
165
+ if (span < 1) span = 1; else if (span > cols) span = cols;
166
+ const h = heightForItem(item);
167
+ let bestC = 0, bestY = Infinity;
168
+ for (let c = 0; c + span <= cols; c++) {
169
+ let y = 0;
170
+ for (let d = 0; d < span; d++) if (bottom[c + d] > y) y = bottom[c + d];
171
+ if (y < bestY) { bestY = y; bestC = c; }
172
+ }
173
+ const w = span * colW + (span - 1) * g;
174
+ pos[i] = { x: bestC * (colW + g), y: bestY, w, h };
175
+ const nb = bestY + h + g;
176
+ for (let d = 0; d < span; d++) bottom[bestC + d] = nb;
177
+ if (h > maxItemH) maxItemH = h;
178
+ }
179
+ let t = 0;
180
+ for (let c = 0; c < cols; c++) if (bottom[c] > t) t = bottom[c];
181
+ totalHeight = t > 0 ? t - g : 0;
182
+ sortedTop = Array.from({ length: n }, (_, i) => i).sort((a, b) => pos[a].y - pos[b].y);
183
+ }
184
+
185
+ function rebuildSingle() {
113
186
  const n = data.length;
114
187
  cumulative = new Float64Array(n + 1);
115
188
  let y = 0;
116
189
  for (let i = 0; i < n; i++) {
117
190
  cumulative[i] = y;
118
- const k = keyFn(data[i]);
119
- y += rowHeightFor(k);
191
+ y += heightFor(keyFn(data[i]));
120
192
  }
121
193
  cumulative[n] = y;
122
- spacer.style.height = y + 'px';
123
194
  }
124
195
 
125
- // Find the first index whose offset is >= scrollTop. Cumulative is
126
- // monotonic so binary search works.
127
- function findStartIndex(scrollTop) {
128
- let lo = 0, hi = data.length;
196
+ function rebuildGallery() {
197
+ const cw = contentWidth();
198
+ const gx = gapX(), gy = gapY();
199
+ lines = [];
200
+ let i = 0;
201
+ const n = data.length;
202
+ while (i < n) {
203
+ const start = i;
204
+ let lineW = 0, h = 0;
205
+ while (i < n) {
206
+ const k = keyFn(data[i]);
207
+ const w = widthFor(k);
208
+ const add = lineW === 0 ? w : gx + w;
209
+ if (lineW > 0 && lineW + add > cw) break; // wrap
210
+ lineW += add;
211
+ h = Math.max(h, heightFor(k));
212
+ i++;
213
+ }
214
+ if (i === start) i++; // item wider than row
215
+ lines.push({ start, end: i, height: h });
216
+ }
217
+ cumulative = new Float64Array(lines.length + 1);
218
+ let y = 0;
219
+ for (let l = 0; l < lines.length; l++) {
220
+ cumulative[l] = y;
221
+ y += lines[l].height + gy;
222
+ }
223
+ cumulative[lines.length] = lines.length ? y - gy : 0;
224
+ }
225
+
226
+ // Binary search: first line/row whose END offset exceeds `pos`.
227
+ function findStart(pos, count) {
228
+ let lo = 0, hi = count;
129
229
  while (lo < hi) {
130
230
  const mid = (lo + hi) >>> 1;
131
- if (cumulative[mid + 1] <= scrollTop) lo = mid + 1;
231
+ if (cumulative[mid + 1] <= pos) lo = mid + 1;
132
232
  else hi = mid;
133
233
  }
134
- return Math.max(0, lo - overscan);
234
+ return lo;
135
235
  }
136
236
 
137
- function findEndIndex(scrollBottom, startHint) {
138
- let i = startHint;
139
- const n = data.length;
140
- while (i < n && cumulative[i] < scrollBottom) i++;
141
- return Math.min(n, i + overscan);
142
- }
237
+ // ---- Render ----
143
238
 
144
- function renderVisible() {
145
- if (!data.length) {
146
- for (const [, node] of rendered) node.remove();
147
- rendered.clear();
148
- return;
149
- }
150
- const scrollTop = el.scrollTop;
151
- const viewportHeight = el.clientHeight;
152
- const start = findStartIndex(scrollTop);
153
- const end = findEndIndex(scrollTop + viewportHeight, start);
154
-
155
- // Track which keys remain visible
156
- const stillVisible = new Set();
157
- for (let i = start; i < end; i++) {
239
+ function render() {
240
+ if (isMasonry) return renderMasonry();
241
+ const total = isGallery ? lines.length : data.length;
242
+ if (!total) { clearRows(); setHeight(topSpacer, 0); setHeight(botSpacer, 0); return; }
243
+ const vh = scrollEl.clientHeight;
244
+ if (!vh) return; // defer until sized
245
+ ready = true;
246
+
247
+ const eff = Math.max(0, scrollEl.scrollTop - regionTop());
248
+ let startUnit = findStart(eff, total) - overscan;
249
+ if (startUnit < 0) startUnit = 0;
250
+ let endUnit = findStart(eff + vh, total) + 1 + overscan;
251
+ if (endUnit > total) endUnit = total;
252
+
253
+ setHeight(topSpacer, cumulative[startUnit]);
254
+ setHeight(botSpacer, cumulative[total] - cumulative[endUnit]);
255
+
256
+ // Resolve the item range for these units.
257
+ const startItem = isGallery ? lines[startUnit].start : startUnit;
258
+ const endItem = isGallery ? lines[endUnit - 1].end : endUnit;
259
+
260
+ const visible = new Set();
261
+ const keys = [];
262
+ for (let i = startItem; i < endItem; i++) {
158
263
  const item = data[i];
159
264
  if (item == null) continue;
160
265
  const key = keyFn(item);
161
- if (key == null) continue; // skip un-keyable rows
162
- stillVisible.add(key);
163
-
164
- let node = rendered.get(key);
165
- if (!node) {
166
- node = mountRow(i);
167
- if (!node) continue;
168
- rendered.set(key, node);
169
- spacer.appendChild(node);
170
- // x-data on the row needs the parent scope (where the
171
- // source array lives) to resolve, so we MUST init after
172
- // append, not before.
173
- Alpine.initTree(node);
174
- // Measure on next frame so Alpine has bound everything.
175
- requestAnimationFrame(() => measureRow(key, node));
176
- }
177
- node.style.top = cumulative[i] + 'px';
178
- node.dataset.virtualIndex = i;
266
+ if (key == null) continue;
267
+ visible.add(key);
268
+ keys.push(key);
269
+ let entry = rendered.get(key);
270
+ if (!entry) entry = mountRow(item, key);
271
+ else if (entry.scope[itemName] !== item) entry.scope[itemName] = item; // rebind by key
272
+ host.insertBefore(entry.node, botSpacer); // keep DOM order = data order
273
+ }
274
+ for (const [key, entry] of rendered) {
275
+ if (!visible.has(key)) removeRow(key, entry);
179
276
  }
180
277
 
181
- // Remove rows no longer in the window
182
- for (const [key, node] of rendered) {
183
- if (!stillVisible.has(key)) {
184
- node.remove();
185
- rendered.delete(key);
186
- }
278
+ // Measure anything not yet measured, then reconcile offsets once.
279
+ scheduleMeasure(keys);
280
+ }
281
+
282
+ // Masonry: render items whose packed box intersects the viewport,
283
+ // absolutely positioned at their computed (x, y, w).
284
+ function renderMasonry() {
285
+ if (!data.length) { clearRows(); sizer.style.height = '0px'; return; }
286
+ const vh = scrollEl.clientHeight;
287
+ if (!vh) return; // defer until sized
288
+ ready = true;
289
+ sizer.style.height = totalHeight + 'px';
290
+
291
+ const opx = overscan * avgH();
292
+ const winTop = scrollEl.scrollTop - opx;
293
+ const winBot = scrollEl.scrollTop + vh + opx;
294
+ // First candidate whose top could reach the window (bounded by tallest item).
295
+ let lo = 0, hi = sortedTop.length;
296
+ const target = winTop - maxItemH;
297
+ while (lo < hi) { const mid = (lo + hi) >>> 1; if (pos[sortedTop[mid]].y < target) lo = mid + 1; else hi = mid; }
298
+
299
+ const visible = new Set();
300
+ const keys = [];
301
+ for (let k = lo; k < sortedTop.length; k++) {
302
+ const i = sortedTop[k];
303
+ const p = pos[i];
304
+ if (p.y > winBot) break;
305
+ if (p.y + p.h < winTop) continue;
306
+ const item = data[i];
307
+ if (item == null) continue;
308
+ const key = keyFn(item);
309
+ if (key == null) continue;
310
+ visible.add(key);
311
+ keys.push(key);
312
+ let entry = rendered.get(key);
313
+ if (!entry) entry = mountRow(item, key);
314
+ else if (entry.scope[itemName] !== item) entry.scope[itemName] = item;
315
+ const s = entry.node.style;
316
+ s.position = 'absolute';
317
+ s.left = p.x + 'px';
318
+ s.top = p.y + 'px';
319
+ s.width = p.w + 'px';
320
+ }
321
+ for (const [key, entry] of rendered) {
322
+ if (!visible.has(key)) removeRow(key, entry);
187
323
  }
324
+ scheduleMeasure(keys);
325
+ }
326
+
327
+ function mountRow(item, key) {
328
+ const tpl = template.content.firstElementChild;
329
+ const node = tpl.cloneNode(true);
330
+ host.insertBefore(node, anchor); // connect before init
331
+ const scope = Alpine.reactive({ [itemName]: item });
332
+ const removeScope = Alpine.addScopeToNode(node, scope);
333
+ Alpine.initTree(node);
334
+ const entry = { node, scope, removeScope };
335
+ rendered.set(key, entry);
336
+ return entry;
337
+ }
338
+
339
+ function removeRow(key, entry) {
340
+ entry.removeScope && entry.removeScope();
341
+ Alpine.destroyTree && Alpine.destroyTree(entry.node);
342
+ entry.node.remove();
343
+ rendered.delete(key);
188
344
  }
189
345
 
190
- function mountRow(index) {
191
- const tplChild = template.content.firstElementChild;
192
- if (!tplChild) return null;
193
- const node = tplChild.cloneNode(true);
194
- node.style.position = 'absolute';
195
- node.style.left = '0';
196
- node.style.right = '0';
197
- // Inject a per-row Alpine scope. Because we reference the source
198
- // expression with the index baked in via a getter, Alpine tracks
199
- // the dependency and re-renders this row when its data updates.
200
- const scopeExpr = `{ get ${itemName}() { return (${sourceExpr})[${index}]; } }`;
201
- // Merge with any existing x-data on the cloned root.
202
- const existing = node.getAttribute('x-data');
203
- node.setAttribute('x-data', existing ? `Object.assign({}, ${scopeExpr}, ${existing})` : scopeExpr);
204
- // Note: caller must Alpine.initTree(node) AFTER appending to the
205
- // DOM, otherwise the scope can't resolve identifiers (e.g. the
206
- // source array) from outer x-data contexts.
207
- return node;
346
+ function clearRows() {
347
+ for (const [key, entry] of rendered) removeRow(key, entry);
348
+ }
349
+
350
+ // ---- Measurement ----
351
+
352
+ let measureScheduled = false;
353
+ function scheduleMeasure(keys) {
354
+ if (isMasonry && heightFn) return; // heights known from data
355
+ // Only measure rows we haven't sized yet.
356
+ const pending = [];
357
+ for (const key of keys) {
358
+ const need = isGallery ? (!heights.has(key) || !widths.has(key)) : !heights.has(key);
359
+ if (need && rendered.has(key)) pending.push(key);
360
+ }
361
+ if (!pending.length || measureScheduled) return;
362
+ measureScheduled = true;
363
+ requestAnimationFrame(() => {
364
+ measureScheduled = false;
365
+ let changed = false;
366
+ for (const key of pending) {
367
+ const entry = rendered.get(key);
368
+ if (entry && measureRow(key, entry.node)) changed = true;
369
+ }
370
+ if (changed) { rebuild(); render(); }
371
+ });
208
372
  }
209
373
 
210
374
  function measureRow(key, node) {
211
- if (!node.isConnected) return;
212
- const h = node.offsetHeight;
213
- if (!h) return;
375
+ if (!node.isConnected) return false;
376
+ const h = measureHeight(node);
377
+ if (!h) return false;
378
+ let changed = false;
214
379
  const prev = heights.get(key);
215
- if (prev === h) return;
216
- if (prev !== undefined) measuredSum -= prev;
217
- else measuredCount++;
218
- measuredSum += h;
219
- heights.set(key, h);
220
- // Recompute cumulative offsets and re-render so positions reflect
221
- // the new heights AND any rows now in/out of the visible window.
222
- rebuildCumulative();
223
- renderVisible();
380
+ if (prev !== h) {
381
+ if (prev !== undefined) measuredSum -= prev;
382
+ else measuredCount++;
383
+ measuredSum += h;
384
+ heights.set(key, h);
385
+ changed = true;
386
+ }
387
+ if (isGallery) {
388
+ const w = node.offsetWidth;
389
+ if (w && widths.get(key) !== w) { widths.set(key, w); changed = true; }
390
+ }
391
+ return changed;
224
392
  }
225
393
 
226
- // --- Reactive data source subscription ---
227
- const sourceGetter = evaluateLater(sourceExpr);
394
+ // display:contents rows (grid-table) have no box — measure the union of
395
+ // their cells. Everything else uses offsetHeight.
396
+ function measureHeight(node) {
397
+ if (getComputedStyle(node).display === 'contents') {
398
+ let top = Infinity, bottom = -Infinity;
399
+ for (const c of node.children) {
400
+ const r = c.getBoundingClientRect();
401
+ if (r.top < top) top = r.top;
402
+ if (r.bottom > bottom) bottom = r.bottom;
403
+ }
404
+ return bottom > top ? bottom - top : 0;
405
+ }
406
+ return node.offsetHeight;
407
+ }
408
+
409
+ // ---- Geometry helpers ----
410
+
411
+ // Scroll-content position where the virtualized region begins (accounts
412
+ // for a static/sticky header above the rows).
413
+ function regionTop() {
414
+ const s = scrollEl.getBoundingClientRect();
415
+ const t = topSpacer.getBoundingClientRect();
416
+ return t.top - s.top + scrollEl.scrollTop;
417
+ }
418
+ function contentWidth() {
419
+ const c = getComputedStyle(scrollEl);
420
+ return scrollEl.clientWidth - parseFloat(c.paddingLeft || 0) - parseFloat(c.paddingRight || 0);
421
+ }
422
+ function gapX() {
423
+ const g = getComputedStyle(scrollEl);
424
+ return parseFloat(g.columnGap || g.gap || 0) || 0;
425
+ }
426
+ function gapY() {
427
+ const g = getComputedStyle(scrollEl);
428
+ return parseFloat(g.rowGap || g.gap || 0) || 0;
429
+ }
430
+
431
+ // ---- Reactive source ----
432
+
433
+ const getSource = evaluateLater(sourceExpr);
228
434
  effect(() => {
229
- sourceGetter((value) => {
435
+ getSource((value) => {
230
436
  data = Array.isArray(value) ? value : (value ? Array.from(value) : []);
231
- // When the data identity or length changes, drop any rendered
232
- // rows whose keys no longer exist in the new data.
233
- const validKeys = new Set();
234
- for (const item of data) {
235
- if (item != null) validKeys.add(keyFn(item));
236
- }
237
- for (const [key, node] of rendered) {
238
- if (!validKeys.has(key)) {
239
- node.remove();
240
- rendered.delete(key);
241
- }
242
- }
243
- rebuildCumulative();
244
- renderVisible();
437
+ // Drop rendered rows whose key no longer exists.
438
+ const valid = new Set();
439
+ for (const item of data) if (item != null) valid.add(keyFn(item));
440
+ for (const [key, entry] of rendered) if (!valid.has(key)) removeRow(key, entry);
441
+ rebuild();
442
+ render();
245
443
  });
246
444
  });
247
445
 
248
- // --- Scroll + resize handlers ---
446
+ // ---- Scroll / resize ----
447
+
249
448
  let scrollScheduled = false;
250
449
  const onScroll = () => {
251
450
  if (scrollScheduled) return;
252
451
  scrollScheduled = true;
253
- requestAnimationFrame(() => {
254
- scrollScheduled = false;
255
- renderVisible();
256
- });
452
+ requestAnimationFrame(() => { scrollScheduled = false; render(); });
257
453
  };
258
- el.addEventListener('scroll', onScroll, { passive: true });
454
+ scrollEl.addEventListener('scroll', onScroll, { passive: true });
259
455
 
260
- const ro = new ResizeObserver(() => renderVisible());
261
- ro.observe(el);
456
+ let resizeTimer = null;
457
+ const ro = new ResizeObserver(() => {
458
+ if (!ready) { render(); return; } // first paint once sized
459
+ clearTimeout(resizeTimer);
460
+ resizeTimer = setTimeout(() => { rebuild(); render(); }, 100); // debounced re-pack
461
+ });
462
+ ro.observe(scrollEl);
262
463
 
263
464
  cleanup(() => {
264
- el.removeEventListener('scroll', onScroll);
465
+ scrollEl.removeEventListener('scroll', onScroll);
265
466
  ro.disconnect();
266
- for (const [, node] of rendered) node.remove();
267
- rendered.clear();
268
- spacer.remove();
467
+ clearTimeout(resizeTimer);
468
+ clearRows();
469
+ if (isMasonry) sizer.remove();
470
+ else { topSpacer.remove(); botSpacer.remove(); }
269
471
  });
270
472
  });
271
473
 
272
474
  }
273
475
 
274
- // Build a key-evaluator function for a given itemName + keyExpr.
275
- // `keyFn(item)` returns the row's key. Falls back to identity if it fails.
476
+ // ---- Module helpers ----
477
+
478
+ // Container kind — which spacer element + geometry the container needs.
479
+ function detectKind(host) {
480
+ const tag = host.tagName;
481
+ if (tag === 'TBODY' || tag === 'THEAD' || tag === 'TFOOT' || tag === 'TABLE') return 'table';
482
+ const disp = getComputedStyle(host).display;
483
+ if (disp.includes('grid')) return 'grid';
484
+ if (disp.includes('flex')) return 'flex';
485
+ return 'block';
486
+ }
487
+
488
+ // Windowing mode — how items fill lines. Masonry is never auto-detected.
489
+ function detectMode(kind, host) {
490
+ if (kind === 'flex' && (getComputedStyle(host).flexWrap || '').startsWith('wrap')) return 'gallery';
491
+ return 'rows';
492
+ }
493
+
494
+ function makeSpacer(kind, colCount) {
495
+ let s;
496
+ if (kind === 'table') {
497
+ s = document.createElement('tr');
498
+ const td = document.createElement('td');
499
+ if (colCount > 1) td.colSpan = colCount;
500
+ td.style.cssText = 'padding:0;border:0;height:0';
501
+ s.appendChild(td);
502
+ } else {
503
+ s = document.createElement('div');
504
+ if (kind === 'grid') s.style.gridColumn = '1 / -1';
505
+ else if (kind === 'flex') s.style.flex = '0 0 100%';
506
+ s.style.width = '100%';
507
+ }
508
+ s.dataset.virtualSpacer = '';
509
+ s.setAttribute('aria-hidden', 'true');
510
+ s.style.height = '0px';
511
+ s.style.padding = '0';
512
+ s.style.margin = '0';
513
+ s.style.border = '0';
514
+ s.style.background = 'none';
515
+ s.style.pointerEvents = 'none';
516
+ return s;
517
+ }
518
+
519
+ function setHeight(spacer, px) {
520
+ const h = Math.max(0, px) + 'px';
521
+ spacer.style.height = h;
522
+ if (spacer.firstElementChild && spacer.tagName === 'TR') spacer.firstElementChild.style.height = h;
523
+ }
524
+
525
+ // keyFn(item) returns the row's key. Falls back to identity on failure.
276
526
  function buildKeyFn(itemName, keyExpr) {
277
527
  try {
278
- // eslint-disable-next-line no-new-func
279
528
  const fn = new Function(itemName, `return (${keyExpr});`);
280
- return (item) => {
281
- try { return fn(item); } catch { return item; }
282
- };
529
+ return (item) => { try { return fn(item); } catch { return item; } };
283
530
  } catch {
284
531
  return (item) => item;
285
532
  }
@@ -295,17 +542,14 @@ function ensureVirtualPluginInitialized() {
295
542
  initializeVirtualPlugin();
296
543
  }
297
544
 
298
- // Expose on window for loader to call if needed
299
545
  window.ensureVirtualPluginInitialized = ensureVirtualPluginInitialized;
300
546
 
301
- // Handle both DOMContentLoaded and alpine:init
302
547
  if (document.readyState === 'loading') {
303
548
  document.addEventListener('DOMContentLoaded', ensureVirtualPluginInitialized);
304
549
  }
305
550
 
306
551
  document.addEventListener('alpine:init', ensureVirtualPluginInitialized);
307
552
 
308
- // If Alpine is already initialized when this script loads, initialize immediately
309
553
  if (window.Alpine && typeof window.Alpine.directive === 'function') {
310
554
  setTimeout(ensureVirtualPluginInitialized, 0);
311
555
  } else {