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.
- package/lib/manifest.combobox.css +14 -25
- package/lib/manifest.combobox.js +189 -16
- package/lib/manifest.css +24 -26
- package/lib/manifest.integrity.json +2 -2
- package/lib/manifest.min.css +1 -1
- package/lib/manifest.table.css +10 -1
- package/lib/manifest.virtual.js +434 -190
- package/package.json +1 -1
package/lib/manifest.virtual.js
CHANGED
|
@@ -1,54 +1,61 @@
|
|
|
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 'rows' | 'gallery' | 'masonry'. Auto-detected if omitted
|
|
26
|
+
* (masonry must be set explicitly).
|
|
21
27
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
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 }, {
|
|
46
|
+
Alpine.directive('virtual', (el, { expression }, { evaluate, evaluateLater, effect, cleanup }) => {
|
|
40
47
|
|
|
41
|
-
// --- Find
|
|
42
|
-
const template = el.querySelector(':scope
|
|
43
|
-
if (!template) {
|
|
44
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
// ---
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
//
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
let
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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] <=
|
|
231
|
+
if (cumulative[mid + 1] <= pos) lo = mid + 1;
|
|
132
232
|
else hi = mid;
|
|
133
233
|
}
|
|
134
|
-
return
|
|
234
|
+
return lo;
|
|
135
235
|
}
|
|
136
236
|
|
|
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
|
-
}
|
|
237
|
+
// ---- Render ----
|
|
143
238
|
|
|
144
|
-
function
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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;
|
|
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;
|
|
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
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
//
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
//
|
|
227
|
-
|
|
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
|
-
|
|
435
|
+
getSource((value) => {
|
|
230
436
|
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();
|
|
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
|
-
//
|
|
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
|
-
|
|
454
|
+
scrollEl.addEventListener('scroll', onScroll, { passive: true });
|
|
259
455
|
|
|
260
|
-
|
|
261
|
-
ro
|
|
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
|
-
|
|
465
|
+
scrollEl.removeEventListener('scroll', onScroll);
|
|
265
466
|
ro.disconnect();
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
//
|
|
275
|
-
|
|
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 {
|