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.
- package/lib/manifest.combobox.css +6 -0
- package/lib/manifest.combobox.js +148 -16
- package/lib/manifest.css +6 -0
- package/lib/manifest.integrity.json +2 -2
- package/lib/manifest.min.css +1 -1
- package/lib/manifest.virtual.js +423 -190
- package/package.json +1 -1
package/lib/manifest.virtual.js
CHANGED
|
@@ -1,54 +1,58 @@
|
|
|
1
|
-
/* Manifest Virtual —
|
|
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)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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 }, {
|
|
43
|
+
Alpine.directive('virtual', (el, { expression }, { evaluate, evaluateLater, effect, cleanup }) => {
|
|
40
44
|
|
|
41
|
-
// --- Find
|
|
42
|
-
const template = el.querySelector(':scope
|
|
43
|
-
if (!template) {
|
|
44
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
-
// ---
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
let
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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] <=
|
|
227
|
+
if (cumulative[mid + 1] <= pos) lo = mid + 1;
|
|
132
228
|
else hi = mid;
|
|
133
229
|
}
|
|
134
|
-
return
|
|
230
|
+
return lo;
|
|
135
231
|
}
|
|
136
232
|
|
|
137
|
-
|
|
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
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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;
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
let
|
|
165
|
-
if (!
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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(
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
node
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
const sourceGetter = evaluateLater(sourceExpr);
|
|
429
|
+
const getSource = evaluateLater(sourceExpr);
|
|
228
430
|
effect(() => {
|
|
229
|
-
|
|
431
|
+
getSource((value) => {
|
|
230
432
|
data = Array.isArray(value) ? value : (value ? Array.from(value) : []);
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
for (const
|
|
235
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
450
|
+
scrollEl.addEventListener('scroll', onScroll, { passive: true });
|
|
259
451
|
|
|
260
|
-
|
|
261
|
-
ro
|
|
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
|
-
|
|
461
|
+
scrollEl.removeEventListener('scroll', onScroll);
|
|
265
462
|
ro.disconnect();
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
//
|
|
275
|
-
|
|
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 {
|