mnfst 0.5.157 → 0.5.158

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,58 @@
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 'table' | 'grid' | 'flex' | 'block'. Auto-detected if omitted.
21
26
  *
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.
27
+ * Modes:
28
+ * block — one item per line, full width (default for stacked divs).
29
+ * table — native <table>; spacer is a <tr>. One item per line.
30
+ * grid — .grid-table (display:grid + display:contents rows); spacer spans
31
+ * grid-column 1 / -1. One logical row per line.
32
+ * flex — flex-wrap gallery; items pack N per line, whole lines render so
33
+ * flex sizing stays correct. Ragged width + height supported.
27
34
  *
28
35
  * 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.
36
+ * - Column widths should be content-independent (table-layout: fixed, fixed/%
37
+ * grid tracks) or columns shift as the window changes. Stripe by data index,
38
+ * not :nth-child. See the docs for these table caveats.
35
39
  */
36
40
 
37
41
  function initializeVirtualPlugin() {
38
42
 
39
- Alpine.directive('virtual', (el, { expression }, { effect, evaluate, evaluateLater, cleanup }) => {
43
+ Alpine.directive('virtual', (el, { expression }, { evaluate, evaluateLater, effect, cleanup }) => {
40
44
 
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">…');
45
+ // --- Find the template (may be nested, e.g. table > tbody > template) ---
46
+ const template = el.querySelector('template[x-for]') || el.querySelector(':scope template');
47
+ if (!template || !template.getAttribute('x-for')) {
48
+ // Stay quiet on re-init of an already-virtualized container (x-for was
49
+ // stripped on the first pass); only warn on genuine misuse.
50
+ if (!el.querySelector('[data-virtual-spacer]')) {
51
+ console.warn('[x-virtual] expects a descendant <template> with x-for, e.g. <template x-for="row in $x.items" :key="row.id">…');
52
+ }
45
53
  return;
46
54
  }
47
55
  const forExpr = template.getAttribute('x-for');
48
- if (!forExpr) {
49
- console.warn('[x-virtual] child <template> must have x-for');
50
- return;
51
- }
52
56
  const m = /^\s*(\S+|\(\s*\S+\s*,\s*\S+\s*\))\s+(?:in|of)\s+(.+?)\s*$/.exec(forExpr);
53
57
  if (!m) {
54
58
  console.warn('[x-virtual] could not parse x-for expression: ' + forExpr);
@@ -56,230 +60,462 @@ function initializeVirtualPlugin() {
56
60
  }
57
61
  const itemName = m[1].trim();
58
62
  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.
63
+ const keyExpr = template.getAttribute(':key') || template.getAttribute('x-bind:key') || `${itemName}.id`;
64
+
65
+ // Strip x-for/:key so Alpine doesn't render the full list; keep the
66
+ // template in the DOM as our clone source.
67
67
  template.removeAttribute('x-for');
68
68
  template.removeAttribute(':key');
69
69
  template.removeAttribute('x-bind:key');
70
70
 
71
71
  // --- Options ---
72
- const options = expression ? evaluate(expression) || {} : {};
73
- const initialEstimate = Number(options.estimate) > 0 ? Number(options.estimate) : 50;
72
+ const options = expression ? (evaluate(expression) || {}) : {};
73
+ const est = options.estimate;
74
+ const estimateH = (est && typeof est === 'object') ? (Number(est.height) || 50) : (Number(est) > 0 ? Number(est) : 50);
75
+ const estimateW = (est && typeof est === 'object') ? (Number(est.width) || 120) : 120;
74
76
  const overscan = Number.isFinite(options.overscan) && options.overscan >= 0 ? Number(options.overscan) : 3;
75
77
 
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';
78
+ // --- Elements & mode ---
79
+ const scrollEl = el; // the bounded scroll viewport
80
+ const host = template.parentElement; // where spacers + rows live
81
+ const cs = getComputedStyle(scrollEl);
82
+ if (cs.overflow === 'visible' && cs.overflowY === 'visible') scrollEl.style.overflow = 'auto';
80
83
 
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);
84
+ const mode = options.mode || detectMode(host);
85
+ const isFlex = mode === 'flex';
86
+ const isMasonry = mode === 'masonry';
87
+
88
+ // Masonry options: fixed `columns` or target `columnWidth`; optional
89
+ // per-item `span` (cols) and `height` (px) fns from data for ~zero
90
+ // settling; `gap` px (falls back to CSS gap).
91
+ const colsOpt = Number(options.columns) > 0 ? Math.floor(options.columns) : 0;
92
+ const colWidthOpt = Number(options.columnWidth) > 0 ? Number(options.columnWidth) : 0;
93
+ const spanFn = typeof options.span === 'function' ? options.span : null;
94
+ const heightFn = typeof options.height === 'function' ? options.height : null;
95
+ const masonryGap = Number.isFinite(options.gap) ? Number(options.gap) : (parseFloat(cs.gap) || 0);
96
+ if (isMasonry && cs.position === 'static') scrollEl.style.position = 'relative';
97
+
98
+ // Column count for table spacer colspan (so it doesn't disturb columns).
99
+ const colCount = template.content.firstElementChild
100
+ ? template.content.firstElementChild.children.length : 1;
101
+
102
+ // --- Spacers (line modes) / sizer (masonry) ---
103
+ let topSpacer, botSpacer, sizer;
104
+ if (isMasonry) {
105
+ sizer = document.createElement('div');
106
+ sizer.dataset.virtualSpacer = '';
107
+ sizer.setAttribute('aria-hidden', 'true');
108
+ sizer.style.cssText = 'position:absolute;top:0;left:0;width:1px;height:0;pointer-events:none';
109
+ host.appendChild(sizer);
110
+ } else {
111
+ topSpacer = makeSpacer(mode, colCount);
112
+ botSpacer = makeSpacer(mode, colCount);
113
+ host.insertBefore(topSpacer, template);
114
+ host.insertBefore(botSpacer, template);
115
+ }
116
+ const anchor = isMasonry ? sizer : botSpacer; // rows insert before this
89
117
 
90
118
  // --- 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
119
+ const heights = new Map(); // key -> measured line/row height
120
+ const widths = new Map(); // key -> measured item width (flex only)
121
+ let measuredSum = 0, measuredCount = 0;
122
+ const rendered = new Map(); // key -> { node, scope, removeScope }
99
123
  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();
124
+ let cumulative = new Float64Array(1); // line-top offsets; last = total
125
+ let lines = []; // flex: [{ start, end, height }]
126
+ let pos = []; // masonry: per-index { x, y, w, h }
127
+ let sortedTop = []; // masonry: indices sorted by y
128
+ let totalHeight = 0; // masonry: packed content height
129
+ let maxItemH = 0; // masonry: back-scan bound
130
+ let ready = false; // becomes true once container is sized
106
131
 
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
132
  const keyFn = buildKeyFn(itemName, keyExpr);
133
+ const avgH = () => (measuredCount > 0 ? measuredSum / measuredCount : estimateH);
134
+ const heightFor = (k) => heights.get(k) ?? avgH();
135
+ const widthFor = (k) => widths.get(k) ?? estimateW;
136
+ const heightForItem = (item) => heightFn ? (Number(heightFn(item)) || estimateH) : heightFor(keyFn(item));
111
137
 
112
- function rebuildCumulative() {
138
+ // ---- Cumulative offset model ----
139
+
140
+ function rebuild() {
141
+ if (isMasonry) rebuildMasonry();
142
+ else if (isFlex) rebuildFlex();
143
+ else rebuildSingle();
144
+ }
145
+
146
+ // Skyline packer: place each item in the column band (of `span` width)
147
+ // with the lowest top. Sizes are independent; only positions depend on
148
+ // order — so the whole layout is computed here in JS, no rendering.
149
+ function rebuildMasonry() {
150
+ const cw = contentWidth();
151
+ const g = masonryGap;
152
+ let cols = colsOpt || (colWidthOpt ? Math.max(1, Math.floor((cw + g) / (colWidthOpt + g))) : 3);
153
+ const colW = (cw - (cols - 1) * g) / cols;
154
+ const bottom = new Float64Array(cols);
155
+ const n = data.length;
156
+ pos = new Array(n);
157
+ maxItemH = 0;
158
+ for (let i = 0; i < n; i++) {
159
+ const item = data[i];
160
+ let span = spanFn ? (spanFn(item) | 0) : 1;
161
+ if (span < 1) span = 1; else if (span > cols) span = cols;
162
+ const h = heightForItem(item);
163
+ let bestC = 0, bestY = Infinity;
164
+ for (let c = 0; c + span <= cols; c++) {
165
+ let y = 0;
166
+ for (let d = 0; d < span; d++) if (bottom[c + d] > y) y = bottom[c + d];
167
+ if (y < bestY) { bestY = y; bestC = c; }
168
+ }
169
+ const w = span * colW + (span - 1) * g;
170
+ pos[i] = { x: bestC * (colW + g), y: bestY, w, h };
171
+ const nb = bestY + h + g;
172
+ for (let d = 0; d < span; d++) bottom[bestC + d] = nb;
173
+ if (h > maxItemH) maxItemH = h;
174
+ }
175
+ let t = 0;
176
+ for (let c = 0; c < cols; c++) if (bottom[c] > t) t = bottom[c];
177
+ totalHeight = t > 0 ? t - g : 0;
178
+ sortedTop = Array.from({ length: n }, (_, i) => i).sort((a, b) => pos[a].y - pos[b].y);
179
+ }
180
+
181
+ function rebuildSingle() {
113
182
  const n = data.length;
114
183
  cumulative = new Float64Array(n + 1);
115
184
  let y = 0;
116
185
  for (let i = 0; i < n; i++) {
117
186
  cumulative[i] = y;
118
- const k = keyFn(data[i]);
119
- y += rowHeightFor(k);
187
+ y += heightFor(keyFn(data[i]));
120
188
  }
121
189
  cumulative[n] = y;
122
- spacer.style.height = y + 'px';
123
190
  }
124
191
 
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;
192
+ function rebuildFlex() {
193
+ const cw = contentWidth();
194
+ const gx = gapX(), gy = gapY();
195
+ lines = [];
196
+ let i = 0;
197
+ const n = data.length;
198
+ while (i < n) {
199
+ const start = i;
200
+ let lineW = 0, h = 0;
201
+ while (i < n) {
202
+ const k = keyFn(data[i]);
203
+ const w = widthFor(k);
204
+ const add = lineW === 0 ? w : gx + w;
205
+ if (lineW > 0 && lineW + add > cw) break; // wrap
206
+ lineW += add;
207
+ h = Math.max(h, heightFor(k));
208
+ i++;
209
+ }
210
+ if (i === start) i++; // item wider than row
211
+ lines.push({ start, end: i, height: h });
212
+ }
213
+ cumulative = new Float64Array(lines.length + 1);
214
+ let y = 0;
215
+ for (let l = 0; l < lines.length; l++) {
216
+ cumulative[l] = y;
217
+ y += lines[l].height + gy;
218
+ }
219
+ cumulative[lines.length] = lines.length ? y - gy : 0;
220
+ }
221
+
222
+ // Binary search: first line/row whose END offset exceeds `pos`.
223
+ function findStart(pos, count) {
224
+ let lo = 0, hi = count;
129
225
  while (lo < hi) {
130
226
  const mid = (lo + hi) >>> 1;
131
- if (cumulative[mid + 1] <= scrollTop) lo = mid + 1;
227
+ if (cumulative[mid + 1] <= pos) lo = mid + 1;
132
228
  else hi = mid;
133
229
  }
134
- return Math.max(0, lo - overscan);
230
+ return lo;
135
231
  }
136
232
 
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
- }
233
+ // ---- Render ----
143
234
 
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++) {
235
+ function render() {
236
+ if (isMasonry) return renderMasonry();
237
+ const total = isFlex ? lines.length : data.length;
238
+ if (!total) { clearRows(); setHeight(topSpacer, 0); setHeight(botSpacer, 0); return; }
239
+ const vh = scrollEl.clientHeight;
240
+ if (!vh) return; // defer until sized
241
+ ready = true;
242
+
243
+ const eff = Math.max(0, scrollEl.scrollTop - regionTop());
244
+ let startUnit = findStart(eff, total) - overscan;
245
+ if (startUnit < 0) startUnit = 0;
246
+ let endUnit = findStart(eff + vh, total) + 1 + overscan;
247
+ if (endUnit > total) endUnit = total;
248
+
249
+ setHeight(topSpacer, cumulative[startUnit]);
250
+ setHeight(botSpacer, cumulative[total] - cumulative[endUnit]);
251
+
252
+ // Resolve the item range for these units.
253
+ const startItem = isFlex ? lines[startUnit].start : startUnit;
254
+ const endItem = isFlex ? lines[endUnit - 1].end : endUnit;
255
+
256
+ const visible = new Set();
257
+ const keys = [];
258
+ for (let i = startItem; i < endItem; i++) {
158
259
  const item = data[i];
159
260
  if (item == null) continue;
160
261
  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;
262
+ if (key == null) continue;
263
+ visible.add(key);
264
+ keys.push(key);
265
+ let entry = rendered.get(key);
266
+ if (!entry) entry = mountRow(item, key);
267
+ else if (entry.scope[itemName] !== item) entry.scope[itemName] = item; // rebind by key
268
+ host.insertBefore(entry.node, botSpacer); // keep DOM order = data order
269
+ }
270
+ for (const [key, entry] of rendered) {
271
+ if (!visible.has(key)) removeRow(key, entry);
179
272
  }
180
273
 
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
- }
274
+ // Measure anything not yet measured, then reconcile offsets once.
275
+ scheduleMeasure(keys);
276
+ }
277
+
278
+ // Masonry: render items whose packed box intersects the viewport,
279
+ // absolutely positioned at their computed (x, y, w).
280
+ function renderMasonry() {
281
+ if (!data.length) { clearRows(); sizer.style.height = '0px'; return; }
282
+ const vh = scrollEl.clientHeight;
283
+ if (!vh) return; // defer until sized
284
+ ready = true;
285
+ sizer.style.height = totalHeight + 'px';
286
+
287
+ const opx = overscan * avgH();
288
+ const winTop = scrollEl.scrollTop - opx;
289
+ const winBot = scrollEl.scrollTop + vh + opx;
290
+ // First candidate whose top could reach the window (bounded by tallest item).
291
+ let lo = 0, hi = sortedTop.length;
292
+ const target = winTop - maxItemH;
293
+ while (lo < hi) { const mid = (lo + hi) >>> 1; if (pos[sortedTop[mid]].y < target) lo = mid + 1; else hi = mid; }
294
+
295
+ const visible = new Set();
296
+ const keys = [];
297
+ for (let k = lo; k < sortedTop.length; k++) {
298
+ const i = sortedTop[k];
299
+ const p = pos[i];
300
+ if (p.y > winBot) break;
301
+ if (p.y + p.h < winTop) continue;
302
+ const item = data[i];
303
+ if (item == null) continue;
304
+ const key = keyFn(item);
305
+ if (key == null) continue;
306
+ visible.add(key);
307
+ keys.push(key);
308
+ let entry = rendered.get(key);
309
+ if (!entry) entry = mountRow(item, key);
310
+ else if (entry.scope[itemName] !== item) entry.scope[itemName] = item;
311
+ const s = entry.node.style;
312
+ s.position = 'absolute';
313
+ s.left = p.x + 'px';
314
+ s.top = p.y + 'px';
315
+ s.width = p.w + 'px';
316
+ }
317
+ for (const [key, entry] of rendered) {
318
+ if (!visible.has(key)) removeRow(key, entry);
187
319
  }
320
+ scheduleMeasure(keys);
188
321
  }
189
322
 
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;
323
+ function mountRow(item, key) {
324
+ const tpl = template.content.firstElementChild;
325
+ const node = tpl.cloneNode(true);
326
+ host.insertBefore(node, anchor); // connect before init
327
+ const scope = Alpine.reactive({ [itemName]: item });
328
+ const removeScope = Alpine.addScopeToNode(node, scope);
329
+ Alpine.initTree(node);
330
+ const entry = { node, scope, removeScope };
331
+ rendered.set(key, entry);
332
+ return entry;
333
+ }
334
+
335
+ function removeRow(key, entry) {
336
+ entry.removeScope && entry.removeScope();
337
+ Alpine.destroyTree && Alpine.destroyTree(entry.node);
338
+ entry.node.remove();
339
+ rendered.delete(key);
340
+ }
341
+
342
+ function clearRows() {
343
+ for (const [key, entry] of rendered) removeRow(key, entry);
344
+ }
345
+
346
+ // ---- Measurement ----
347
+
348
+ let measureScheduled = false;
349
+ function scheduleMeasure(keys) {
350
+ if (isMasonry && heightFn) return; // heights known from data
351
+ // Only measure rows we haven't sized yet.
352
+ const pending = [];
353
+ for (const key of keys) {
354
+ const need = isFlex ? (!heights.has(key) || !widths.has(key)) : !heights.has(key);
355
+ if (need && rendered.has(key)) pending.push(key);
356
+ }
357
+ if (!pending.length || measureScheduled) return;
358
+ measureScheduled = true;
359
+ requestAnimationFrame(() => {
360
+ measureScheduled = false;
361
+ let changed = false;
362
+ for (const key of pending) {
363
+ const entry = rendered.get(key);
364
+ if (entry && measureRow(key, entry.node)) changed = true;
365
+ }
366
+ if (changed) { rebuild(); render(); }
367
+ });
208
368
  }
209
369
 
210
370
  function measureRow(key, node) {
211
- if (!node.isConnected) return;
212
- const h = node.offsetHeight;
213
- if (!h) return;
371
+ if (!node.isConnected) return false;
372
+ const h = measureHeight(node);
373
+ if (!h) return false;
374
+ let changed = false;
214
375
  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();
376
+ if (prev !== h) {
377
+ if (prev !== undefined) measuredSum -= prev;
378
+ else measuredCount++;
379
+ measuredSum += h;
380
+ heights.set(key, h);
381
+ changed = true;
382
+ }
383
+ if (isFlex) {
384
+ const w = node.offsetWidth;
385
+ if (w && widths.get(key) !== w) { widths.set(key, w); changed = true; }
386
+ }
387
+ return changed;
388
+ }
389
+
390
+ // display:contents rows (grid-table) have no box — measure the union of
391
+ // their cells. Everything else uses offsetHeight.
392
+ function measureHeight(node) {
393
+ if (getComputedStyle(node).display === 'contents') {
394
+ let top = Infinity, bottom = -Infinity;
395
+ for (const c of node.children) {
396
+ const r = c.getBoundingClientRect();
397
+ if (r.top < top) top = r.top;
398
+ if (r.bottom > bottom) bottom = r.bottom;
399
+ }
400
+ return bottom > top ? bottom - top : 0;
401
+ }
402
+ return node.offsetHeight;
403
+ }
404
+
405
+ // ---- Geometry helpers ----
406
+
407
+ // Scroll-content position where the virtualized region begins (accounts
408
+ // for a static/sticky header above the rows).
409
+ function regionTop() {
410
+ const s = scrollEl.getBoundingClientRect();
411
+ const t = topSpacer.getBoundingClientRect();
412
+ return t.top - s.top + scrollEl.scrollTop;
413
+ }
414
+ function contentWidth() {
415
+ const c = getComputedStyle(scrollEl);
416
+ return scrollEl.clientWidth - parseFloat(c.paddingLeft || 0) - parseFloat(c.paddingRight || 0);
224
417
  }
418
+ function gapX() {
419
+ const g = getComputedStyle(scrollEl);
420
+ return parseFloat(g.columnGap || g.gap || 0) || 0;
421
+ }
422
+ function gapY() {
423
+ const g = getComputedStyle(scrollEl);
424
+ return parseFloat(g.rowGap || g.gap || 0) || 0;
425
+ }
426
+
427
+ // ---- Reactive source ----
225
428
 
226
- // --- Reactive data source subscription ---
227
- const sourceGetter = evaluateLater(sourceExpr);
429
+ const getSource = evaluateLater(sourceExpr);
228
430
  effect(() => {
229
- sourceGetter((value) => {
431
+ getSource((value) => {
230
432
  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();
433
+ // Drop rendered rows whose key no longer exists.
434
+ const valid = new Set();
435
+ for (const item of data) if (item != null) valid.add(keyFn(item));
436
+ for (const [key, entry] of rendered) if (!valid.has(key)) removeRow(key, entry);
437
+ rebuild();
438
+ render();
245
439
  });
246
440
  });
247
441
 
248
- // --- Scroll + resize handlers ---
442
+ // ---- Scroll / resize ----
443
+
249
444
  let scrollScheduled = false;
250
445
  const onScroll = () => {
251
446
  if (scrollScheduled) return;
252
447
  scrollScheduled = true;
253
- requestAnimationFrame(() => {
254
- scrollScheduled = false;
255
- renderVisible();
256
- });
448
+ requestAnimationFrame(() => { scrollScheduled = false; render(); });
257
449
  };
258
- el.addEventListener('scroll', onScroll, { passive: true });
450
+ scrollEl.addEventListener('scroll', onScroll, { passive: true });
259
451
 
260
- const ro = new ResizeObserver(() => renderVisible());
261
- ro.observe(el);
452
+ let resizeTimer = null;
453
+ const ro = new ResizeObserver(() => {
454
+ if (!ready) { render(); return; } // first paint once sized
455
+ clearTimeout(resizeTimer);
456
+ resizeTimer = setTimeout(() => { rebuild(); render(); }, 100); // debounced re-pack
457
+ });
458
+ ro.observe(scrollEl);
262
459
 
263
460
  cleanup(() => {
264
- el.removeEventListener('scroll', onScroll);
461
+ scrollEl.removeEventListener('scroll', onScroll);
265
462
  ro.disconnect();
266
- for (const [, node] of rendered) node.remove();
267
- rendered.clear();
268
- spacer.remove();
463
+ clearTimeout(resizeTimer);
464
+ clearRows();
465
+ if (isMasonry) sizer.remove();
466
+ else { topSpacer.remove(); botSpacer.remove(); }
269
467
  });
270
468
  });
271
469
 
272
470
  }
273
471
 
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.
472
+ // ---- Module helpers ----
473
+
474
+ function detectMode(host) {
475
+ const tag = host.tagName;
476
+ if (tag === 'TBODY' || tag === 'THEAD' || tag === 'TFOOT' || tag === 'TABLE') return 'table';
477
+ const disp = getComputedStyle(host).display;
478
+ if (disp.includes('grid')) return 'grid';
479
+ if (disp.includes('flex')) return 'flex';
480
+ return 'block';
481
+ }
482
+
483
+ function makeSpacer(mode, colCount) {
484
+ let s;
485
+ if (mode === 'table') {
486
+ s = document.createElement('tr');
487
+ const td = document.createElement('td');
488
+ if (colCount > 1) td.colSpan = colCount;
489
+ td.style.cssText = 'padding:0;border:0;height:0';
490
+ s.appendChild(td);
491
+ } else {
492
+ s = document.createElement('div');
493
+ if (mode === 'grid') s.style.gridColumn = '1 / -1';
494
+ else if (mode === 'flex') s.style.flex = '0 0 100%';
495
+ s.style.width = '100%';
496
+ }
497
+ s.dataset.virtualSpacer = '';
498
+ s.setAttribute('aria-hidden', 'true');
499
+ s.style.height = '0px';
500
+ s.style.padding = '0';
501
+ s.style.margin = '0';
502
+ s.style.border = '0';
503
+ s.style.background = 'none';
504
+ s.style.pointerEvents = 'none';
505
+ return s;
506
+ }
507
+
508
+ function setHeight(spacer, px) {
509
+ const h = Math.max(0, px) + 'px';
510
+ spacer.style.height = h;
511
+ if (spacer.firstElementChild && spacer.tagName === 'TR') spacer.firstElementChild.style.height = h;
512
+ }
513
+
514
+ // keyFn(item) returns the row's key. Falls back to identity on failure.
276
515
  function buildKeyFn(itemName, keyExpr) {
277
516
  try {
278
- // eslint-disable-next-line no-new-func
279
517
  const fn = new Function(itemName, `return (${keyExpr});`);
280
- return (item) => {
281
- try { return fn(item); } catch { return item; }
282
- };
518
+ return (item) => { try { return fn(item); } catch { return item; } };
283
519
  } catch {
284
520
  return (item) => item;
285
521
  }
@@ -295,17 +531,14 @@ function ensureVirtualPluginInitialized() {
295
531
  initializeVirtualPlugin();
296
532
  }
297
533
 
298
- // Expose on window for loader to call if needed
299
534
  window.ensureVirtualPluginInitialized = ensureVirtualPluginInitialized;
300
535
 
301
- // Handle both DOMContentLoaded and alpine:init
302
536
  if (document.readyState === 'loading') {
303
537
  document.addEventListener('DOMContentLoaded', ensureVirtualPluginInitialized);
304
538
  }
305
539
 
306
540
  document.addEventListener('alpine:init', ensureVirtualPluginInitialized);
307
541
 
308
- // If Alpine is already initialized when this script loads, initialize immediately
309
542
  if (window.Alpine && typeof window.Alpine.directive === 'function') {
310
543
  setTimeout(ensureVirtualPluginInitialized, 0);
311
544
  } else {