vlist 2.0.0 → 2.0.2
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/README.github.md +2 -2
- package/README.md +2 -2
- package/dist/core/dom.d.ts +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/pipeline.d.ts +2 -2
- package/dist/core/scroll.d.ts +1 -1
- package/dist/core/types.d.ts +7 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -28
- package/dist/internals.js +1 -60
- package/dist/plugins/scrollbar/controller.d.ts +3 -3
- package/dist/plugins/scrollbar/scrollbar.d.ts +2 -2
- package/dist/rendering/renderer.d.ts +2 -2
- package/dist/rendering/viewport.d.ts +1 -1
- package/dist/size.json +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/dist/constants.js +0 -83
- package/dist/core/create.js +0 -740
- package/dist/core/dom.js +0 -47
- package/dist/core/hooks.js +0 -67
- package/dist/core/index.js +0 -13
- package/dist/core/pipeline.js +0 -307
- package/dist/core/pool.js +0 -42
- package/dist/core/scroll.js +0 -137
- package/dist/core/sizes.js +0 -6
- package/dist/core/state.js +0 -56
- package/dist/core/types.js +0 -7
- package/dist/core/velocity.js +0 -33
- package/dist/events/emitter.js +0 -60
- package/dist/events/index.js +0 -6
- package/dist/plugins/a11y/index.js +0 -1
- package/dist/plugins/a11y/plugin.js +0 -259
- package/dist/plugins/async/index.js +0 -12
- package/dist/plugins/async/manager.js +0 -568
- package/dist/plugins/async/placeholder.js +0 -154
- package/dist/plugins/async/plugin.js +0 -311
- package/dist/plugins/async/sparse.js +0 -540
- package/dist/plugins/autosize/index.js +0 -4
- package/dist/plugins/autosize/plugin.js +0 -185
- package/dist/plugins/grid/index.js +0 -5
- package/dist/plugins/grid/layout.js +0 -275
- package/dist/plugins/grid/plugin.js +0 -347
- package/dist/plugins/grid/renderer.js +0 -525
- package/dist/plugins/grid/types.js +0 -11
- package/dist/plugins/groups/async-bridge.js +0 -246
- package/dist/plugins/groups/index.js +0 -13
- package/dist/plugins/groups/layout.js +0 -294
- package/dist/plugins/groups/plugin.js +0 -571
- package/dist/plugins/groups/sticky.js +0 -255
- package/dist/plugins/groups/types.js +0 -12
- package/dist/plugins/masonry/index.js +0 -6
- package/dist/plugins/masonry/layout.js +0 -261
- package/dist/plugins/masonry/plugin.js +0 -381
- package/dist/plugins/masonry/renderer.js +0 -354
- package/dist/plugins/masonry/types.js +0 -9
- package/dist/plugins/page/index.js +0 -5
- package/dist/plugins/page/plugin.js +0 -166
- package/dist/plugins/scale/index.js +0 -4
- package/dist/plugins/scale/plugin.js +0 -507
- package/dist/plugins/scrollbar/controller.js +0 -574
- package/dist/plugins/scrollbar/index.js +0 -6
- package/dist/plugins/scrollbar/plugin.js +0 -93
- package/dist/plugins/scrollbar/scrollbar.js +0 -556
- package/dist/plugins/selection/index.js +0 -7
- package/dist/plugins/selection/plugin.js +0 -601
- package/dist/plugins/selection/state.js +0 -332
- package/dist/plugins/snapshots/index.js +0 -5
- package/dist/plugins/snapshots/plugin.js +0 -301
- package/dist/plugins/sortable/index.js +0 -6
- package/dist/plugins/sortable/plugin.js +0 -753
- package/dist/plugins/table/header.js +0 -501
- package/dist/plugins/table/index.js +0 -12
- package/dist/plugins/table/layout.js +0 -211
- package/dist/plugins/table/plugin.js +0 -391
- package/dist/plugins/table/renderer.js +0 -625
- package/dist/plugins/table/types.js +0 -12
- package/dist/plugins/transition/index.js +0 -5
- package/dist/plugins/transition/plugin.js +0 -405
- package/dist/rendering/aria.js +0 -23
- package/dist/rendering/index.js +0 -18
- package/dist/rendering/measured.js +0 -98
- package/dist/rendering/renderer.js +0 -586
- package/dist/rendering/scale.js +0 -267
- package/dist/rendering/scroll.js +0 -71
- package/dist/rendering/sizes.js +0 -193
- package/dist/rendering/sort.js +0 -65
- package/dist/rendering/viewport.js +0 -268
- package/dist/types.js +0 -5
- package/dist/utils/padding.js +0 -49
- package/dist/utils/stats.js +0 -124
|
@@ -1,571 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* vlist v2 — Groups Plugin
|
|
3
|
-
*
|
|
4
|
-
* Adds grouped lists with sticky headers.
|
|
5
|
-
* Priority 10 — runs before selection (50).
|
|
6
|
-
*
|
|
7
|
-
* Architecture:
|
|
8
|
-
* - Transforms items list: inserts group header pseudo-items at group boundaries
|
|
9
|
-
* - Replaces size function: headers use header height, items use item height
|
|
10
|
-
* - Replaces render pipeline: handles grouped layout rendering
|
|
11
|
-
* - Sticky header: floating header that updates on scroll
|
|
12
|
-
* - CSS class: adds .vlist--grouped to root
|
|
13
|
-
*
|
|
14
|
-
* Restrictions:
|
|
15
|
-
* - Items must be pre-sorted by group
|
|
16
|
-
*/
|
|
17
|
-
import { createGroupLayout, } from "./layout";
|
|
18
|
-
import { createStickyHeader, createStickyContainer } from "./sticky";
|
|
19
|
-
const itemState = { selected: false, focused: false };
|
|
20
|
-
const DEBUG = typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
|
|
21
|
-
export function groups(config) {
|
|
22
|
-
if (!config.getGroupForIndex) {
|
|
23
|
-
throw new Error("[vlist] groups: getGroupForIndex is required");
|
|
24
|
-
}
|
|
25
|
-
const headerHeightRaw = config.header?.height ?? config.header?.width ?? config.headerHeight;
|
|
26
|
-
if (headerHeightRaw == null || (typeof headerHeightRaw === "number" && headerHeightRaw <= 0)) {
|
|
27
|
-
throw new Error("[vlist] groups: header height/width must be a positive number or function");
|
|
28
|
-
}
|
|
29
|
-
const rawHeaderTemplate = config.header?.template ?? config.headerTemplate;
|
|
30
|
-
if (!rawHeaderTemplate) {
|
|
31
|
-
throw new Error("[vlist] groups: header.template is required");
|
|
32
|
-
}
|
|
33
|
-
const headerTemplate = rawHeaderTemplate;
|
|
34
|
-
let layout;
|
|
35
|
-
let stickyHeader = null;
|
|
36
|
-
let sizeCache;
|
|
37
|
-
let engineState;
|
|
38
|
-
let pool;
|
|
39
|
-
let contentElement;
|
|
40
|
-
let rootElement;
|
|
41
|
-
let userTemplate;
|
|
42
|
-
let ctxGetItem;
|
|
43
|
-
let getLoadedItem = null;
|
|
44
|
-
let horizontal;
|
|
45
|
-
let classPrefix;
|
|
46
|
-
let overscan;
|
|
47
|
-
let resolveItemState = null;
|
|
48
|
-
let groupItemClass;
|
|
49
|
-
let groupHeaderClass;
|
|
50
|
-
let getMethod = null;
|
|
51
|
-
let interactive;
|
|
52
|
-
const rendered = new Map();
|
|
53
|
-
// Track which layout indices currently show placeholder content
|
|
54
|
-
const placeholderIndices = new Set();
|
|
55
|
-
// Elements detached during boundary changes, keyed by data-id.
|
|
56
|
-
// Reused in the next render pass to avoid pool round-trip (which clears innerHTML).
|
|
57
|
-
const detached = new Map();
|
|
58
|
-
let lastScrollPosition = -1;
|
|
59
|
-
let lastContainerSize = -1;
|
|
60
|
-
let forceNextRender = true;
|
|
61
|
-
let lastDataCount = -1;
|
|
62
|
-
let lastRebuildLoadedCount = 0;
|
|
63
|
-
let getLoadedCount = null;
|
|
64
|
-
let origSizeCacheRebuild;
|
|
65
|
-
function getLayoutItemCount() {
|
|
66
|
-
return layout.totalEntries;
|
|
67
|
-
}
|
|
68
|
-
// Only rebuilds layout when the data total changes (initial load, reload).
|
|
69
|
-
// Item content updates (placeholder → real) are handled by the render loop
|
|
70
|
-
// updating existing DOM elements in-place — no layout rebuild needed.
|
|
71
|
-
function syncLayoutIfNeeded() {
|
|
72
|
-
const dataCount = engineState.totalItems;
|
|
73
|
-
if (dataCount === lastDataCount)
|
|
74
|
-
return;
|
|
75
|
-
if (DEBUG) {
|
|
76
|
-
console.log(`[groups] syncLayout: ${lastDataCount} → ${dataCount}`);
|
|
77
|
-
}
|
|
78
|
-
const wasLoaded = lastDataCount > 0;
|
|
79
|
-
lastDataCount = dataCount;
|
|
80
|
-
layout.rebuild(dataCount, getLoadedItem ?? ctxGetItem);
|
|
81
|
-
origSizeCacheRebuild(layout.totalEntries);
|
|
82
|
-
const totalSize = sizeCache.getTotalSize();
|
|
83
|
-
contentElement.style[horizontal ? "width" : "height"] = totalSize + "px";
|
|
84
|
-
if (stickyHeader) {
|
|
85
|
-
stickyHeader.refresh();
|
|
86
|
-
stickyHeader.update(engineState.scrollPosition);
|
|
87
|
-
}
|
|
88
|
-
// Layout indices shifted — detach elements (keyed by data-id) so the
|
|
89
|
-
// render loop can reclaim them without a pool round-trip that clears innerHTML.
|
|
90
|
-
if (wasLoaded) {
|
|
91
|
-
detachAll();
|
|
92
|
-
}
|
|
93
|
-
const getSb = getMethod?.("_scrollbar:getInstance");
|
|
94
|
-
if (getSb) {
|
|
95
|
-
const sb = getSb();
|
|
96
|
-
sb?.updateBounds(totalSize, engineState.containerSize);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
function buildTransform(layoutIndex) {
|
|
100
|
-
const offset = sizeCache.getOffset(layoutIndex);
|
|
101
|
-
if (horizontal) {
|
|
102
|
-
return `translate(${Math.round(offset)}px, 0)`;
|
|
103
|
-
}
|
|
104
|
-
return `translate(0, ${Math.round(offset)}px)`;
|
|
105
|
-
}
|
|
106
|
-
function applySizeStyles(element, layoutIndex) {
|
|
107
|
-
const size = sizeCache.getSize(layoutIndex);
|
|
108
|
-
if (horizontal) {
|
|
109
|
-
element.style.width = `${size}px`;
|
|
110
|
-
}
|
|
111
|
-
else {
|
|
112
|
-
element.style.height = `${size}px`;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
function detachAll() {
|
|
116
|
-
rendered.forEach((element) => {
|
|
117
|
-
element.remove();
|
|
118
|
-
const id = element.getAttribute("data-id");
|
|
119
|
-
if (id) {
|
|
120
|
-
detached.set(id, element);
|
|
121
|
-
}
|
|
122
|
-
else {
|
|
123
|
-
pool.release(element);
|
|
124
|
-
}
|
|
125
|
-
});
|
|
126
|
-
rendered.clear();
|
|
127
|
-
placeholderIndices.clear();
|
|
128
|
-
}
|
|
129
|
-
function drainDetached() {
|
|
130
|
-
if (detached.size > 0) {
|
|
131
|
-
detached.forEach((element) => pool.release(element));
|
|
132
|
-
detached.clear();
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
function renderItemContent(element, entry, isf, layoutIndex) {
|
|
136
|
-
if (entry.type === "header") {
|
|
137
|
-
const headerId = `__group_header_${entry.group.groupIndex}`;
|
|
138
|
-
if (element.getAttribute("data-id") === headerId) {
|
|
139
|
-
return true;
|
|
140
|
-
}
|
|
141
|
-
element.className = groupHeaderClass;
|
|
142
|
-
element.setAttribute("role", "presentation");
|
|
143
|
-
element.removeAttribute("aria-selected");
|
|
144
|
-
element.setAttribute("data-id", headerId);
|
|
145
|
-
const content = headerTemplate(entry.group.key, entry.group.groupIndex);
|
|
146
|
-
if (typeof content === "string") {
|
|
147
|
-
element.innerHTML = content;
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
element.innerHTML = "";
|
|
151
|
-
element.appendChild(content);
|
|
152
|
-
}
|
|
153
|
-
return true;
|
|
154
|
-
}
|
|
155
|
-
// Data item
|
|
156
|
-
const dataIndex = entry.dataIndex;
|
|
157
|
-
const item = ctxGetItem(dataIndex);
|
|
158
|
-
if (!item) {
|
|
159
|
-
element.innerHTML = "";
|
|
160
|
-
return false;
|
|
161
|
-
}
|
|
162
|
-
const isPlaceholder = item._isPlaceholder === true;
|
|
163
|
-
const itemId = String(item.id);
|
|
164
|
-
// Element already shows this item with real content — skip template
|
|
165
|
-
if (!isPlaceholder && element.getAttribute("data-id") === itemId) {
|
|
166
|
-
if (interactive) {
|
|
167
|
-
element.id = `${classPrefix}-item-${layoutIndex}`;
|
|
168
|
-
element.setAttribute("aria-posinset", String(dataIndex + 1));
|
|
169
|
-
element.setAttribute("aria-setsize", String(engineState.totalItems));
|
|
170
|
-
}
|
|
171
|
-
return true;
|
|
172
|
-
}
|
|
173
|
-
element.className = groupItemClass;
|
|
174
|
-
element.setAttribute("role", "option");
|
|
175
|
-
element.setAttribute("data-id", itemId);
|
|
176
|
-
if (interactive) {
|
|
177
|
-
element.id = `${classPrefix}-item-${layoutIndex}`;
|
|
178
|
-
element.setAttribute("aria-posinset", String(dataIndex + 1));
|
|
179
|
-
element.setAttribute("aria-setsize", String(engineState.totalItems));
|
|
180
|
-
}
|
|
181
|
-
if (isPlaceholder) {
|
|
182
|
-
element.classList.add(`${classPrefix}-item--placeholder`);
|
|
183
|
-
}
|
|
184
|
-
if (isf)
|
|
185
|
-
isf(layoutIndex, itemState);
|
|
186
|
-
else {
|
|
187
|
-
itemState.selected = false;
|
|
188
|
-
itemState.focused = false;
|
|
189
|
-
}
|
|
190
|
-
const content = userTemplate(item, dataIndex, itemState);
|
|
191
|
-
if (typeof content === "string") {
|
|
192
|
-
element.innerHTML = content;
|
|
193
|
-
}
|
|
194
|
-
else {
|
|
195
|
-
element.innerHTML = "";
|
|
196
|
-
element.appendChild(content);
|
|
197
|
-
}
|
|
198
|
-
return !isPlaceholder;
|
|
199
|
-
}
|
|
200
|
-
function groupsRenderIfNeeded() {
|
|
201
|
-
if (engineState.destroyed)
|
|
202
|
-
return;
|
|
203
|
-
syncLayoutIfNeeded();
|
|
204
|
-
const scrollPos = engineState.scrollPosition;
|
|
205
|
-
const cs = engineState.containerSize;
|
|
206
|
-
if (!forceNextRender && scrollPos === lastScrollPosition && cs === lastContainerSize) {
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
lastScrollPosition = scrollPos;
|
|
210
|
-
lastContainerSize = cs;
|
|
211
|
-
const isForced = forceNextRender;
|
|
212
|
-
forceNextRender = false;
|
|
213
|
-
const totalItems = getLayoutItemCount();
|
|
214
|
-
if (cs <= 0 || totalItems === 0) {
|
|
215
|
-
if (rendered.size > 0) {
|
|
216
|
-
rendered.forEach((element) => {
|
|
217
|
-
element.remove();
|
|
218
|
-
pool.release(element);
|
|
219
|
-
});
|
|
220
|
-
rendered.clear();
|
|
221
|
-
placeholderIndices.clear();
|
|
222
|
-
}
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
let visStart = sizeCache.indexAtOffset(scrollPos);
|
|
226
|
-
let visEnd = sizeCache.indexAtOffset(scrollPos + cs);
|
|
227
|
-
if (visEnd < totalItems - 1)
|
|
228
|
-
visEnd++;
|
|
229
|
-
visStart = Math.max(0, visStart);
|
|
230
|
-
visEnd = Math.min(totalItems - 1, Math.max(0, visEnd));
|
|
231
|
-
const renderStart = Math.max(0, visStart - overscan);
|
|
232
|
-
const renderEnd = Math.min(totalItems - 1, visEnd + overscan);
|
|
233
|
-
if (renderStart === engineState.prevRangeStart && renderEnd === engineState.prevRangeEnd && !engineState.renderPending) {
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
// Recycle elements outside the new range
|
|
237
|
-
rendered.forEach((element, idx) => {
|
|
238
|
-
if (idx < renderStart || idx > renderEnd) {
|
|
239
|
-
element.remove();
|
|
240
|
-
pool.release(element);
|
|
241
|
-
rendered.delete(idx);
|
|
242
|
-
placeholderIndices.delete(idx);
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
const isf = resolveItemState?.() ?? null;
|
|
246
|
-
const selClass = isf ? `${classPrefix}-item--selected` : "";
|
|
247
|
-
const focClass = isf ? `${classPrefix}-item--focused` : "";
|
|
248
|
-
let fragment = null;
|
|
249
|
-
for (let i = renderStart; i <= renderEnd; i++) {
|
|
250
|
-
let element = rendered.get(i);
|
|
251
|
-
let isHeader = false;
|
|
252
|
-
if (element === undefined) {
|
|
253
|
-
// ── New element ──
|
|
254
|
-
const entry = layout.getEntry(i);
|
|
255
|
-
isHeader = entry.type === "header";
|
|
256
|
-
// Try to reclaim a detached element with matching data-id
|
|
257
|
-
// (avoids pool round-trip that destroys innerHTML / images)
|
|
258
|
-
let expectedId;
|
|
259
|
-
if (entry.type === "header") {
|
|
260
|
-
expectedId = `__group_header_${entry.group.groupIndex}`;
|
|
261
|
-
}
|
|
262
|
-
else {
|
|
263
|
-
const item = ctxGetItem(entry.dataIndex);
|
|
264
|
-
if (item && item._isPlaceholder !== true) {
|
|
265
|
-
expectedId = String(item.id);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
if (expectedId !== undefined) {
|
|
269
|
-
const reused = detached.get(expectedId);
|
|
270
|
-
if (reused) {
|
|
271
|
-
detached.delete(expectedId);
|
|
272
|
-
element = reused;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
if (!element) {
|
|
276
|
-
element = pool.acquire();
|
|
277
|
-
}
|
|
278
|
-
element.setAttribute("data-index", String(i));
|
|
279
|
-
const hasContent = renderItemContent(element, entry, isf, i);
|
|
280
|
-
if (hasContent)
|
|
281
|
-
placeholderIndices.delete(i);
|
|
282
|
-
else
|
|
283
|
-
placeholderIndices.add(i);
|
|
284
|
-
applySizeStyles(element, i);
|
|
285
|
-
element.style.transform = buildTransform(i);
|
|
286
|
-
rendered.set(i, element);
|
|
287
|
-
if (!fragment)
|
|
288
|
-
fragment = document.createDocumentFragment();
|
|
289
|
-
fragment.appendChild(element);
|
|
290
|
-
}
|
|
291
|
-
else if (isForced && placeholderIndices.has(i)) {
|
|
292
|
-
// ── Existing placeholder — check if real data arrived ──
|
|
293
|
-
const entry = layout.getEntry(i);
|
|
294
|
-
if (entry.type === "item") {
|
|
295
|
-
const hasContent = renderItemContent(element, entry, isf, i);
|
|
296
|
-
if (hasContent)
|
|
297
|
-
placeholderIndices.delete(i);
|
|
298
|
-
else
|
|
299
|
-
placeholderIndices.add(i);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
else {
|
|
303
|
-
// ── Existing unchanged element — fast path ──
|
|
304
|
-
isHeader = element.classList.contains(groupHeaderClass);
|
|
305
|
-
}
|
|
306
|
-
if (!isHeader && isf) {
|
|
307
|
-
isf(i, itemState);
|
|
308
|
-
element.classList.toggle(selClass, itemState.selected);
|
|
309
|
-
element.classList.toggle(focClass, itemState.focused);
|
|
310
|
-
if (itemState.selected)
|
|
311
|
-
element.setAttribute("aria-selected", "true");
|
|
312
|
-
else
|
|
313
|
-
element.removeAttribute("aria-selected");
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
if (fragment) {
|
|
317
|
-
contentElement.appendChild(fragment);
|
|
318
|
-
}
|
|
319
|
-
drainDetached();
|
|
320
|
-
const totalSize = sizeCache.getTotalSize();
|
|
321
|
-
contentElement.style[horizontal ? "width" : "height"] = totalSize + "px";
|
|
322
|
-
engineState.prevRangeStart = renderStart;
|
|
323
|
-
engineState.prevRangeEnd = renderEnd;
|
|
324
|
-
engineState.renderPending = false;
|
|
325
|
-
// Fill engine state with DATA indices so the async plugin loads
|
|
326
|
-
// the correct items (it reads startIndex/visibleCount as data indices).
|
|
327
|
-
let dataFillCount = 0;
|
|
328
|
-
let firstDataIndex = 0;
|
|
329
|
-
let foundFirst = false;
|
|
330
|
-
for (let i = renderStart; i <= renderEnd && dataFillCount < engineState.capacity; i++) {
|
|
331
|
-
const entry = layout.getEntry(i);
|
|
332
|
-
if (entry.type === "item") {
|
|
333
|
-
if (!foundFirst) {
|
|
334
|
-
firstDataIndex = entry.dataIndex;
|
|
335
|
-
foundFirst = true;
|
|
336
|
-
}
|
|
337
|
-
engineState.visibleIndices[dataFillCount] = entry.dataIndex;
|
|
338
|
-
engineState.visibleOffsets[dataFillCount] = sizeCache.getOffset(i);
|
|
339
|
-
engineState.visibleSizes[dataFillCount] = sizeCache.getSize(i);
|
|
340
|
-
dataFillCount++;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
engineState.visibleCount = dataFillCount;
|
|
344
|
-
engineState.startIndex = firstDataIndex;
|
|
345
|
-
}
|
|
346
|
-
function groupsForceRender() {
|
|
347
|
-
if (engineState.destroyed)
|
|
348
|
-
return;
|
|
349
|
-
if (DEBUG) {
|
|
350
|
-
console.log(`[groups] forceRender, placeholders: ${placeholderIndices.size}, rendered: ${rendered.size}`);
|
|
351
|
-
}
|
|
352
|
-
// When async data arrives, group boundaries may change. But forceRender
|
|
353
|
-
// is also called on every scale-plugin lerp tick (60fps), so we must NOT
|
|
354
|
-
// run the expensive layout.rebuild on every call. O(1) check: compare
|
|
355
|
-
// loaded item count — only changes when async plugin delivers new data.
|
|
356
|
-
const currentLoaded = getLoadedCount?.() ?? 0;
|
|
357
|
-
if (currentLoaded !== lastRebuildLoadedCount) {
|
|
358
|
-
lastRebuildLoadedCount = currentLoaded;
|
|
359
|
-
const prevGroups = layout.groups;
|
|
360
|
-
const prevGroupCount = prevGroups.length;
|
|
361
|
-
layout.rebuild(lastDataCount, getLoadedItem ?? ctxGetItem);
|
|
362
|
-
const newGroups = layout.groups;
|
|
363
|
-
let boundariesChanged = newGroups.length !== prevGroupCount;
|
|
364
|
-
if (!boundariesChanged) {
|
|
365
|
-
for (let i = 0; i < prevGroupCount; i++) {
|
|
366
|
-
if (newGroups[i].headerLayoutIndex !== prevGroups[i].headerLayoutIndex) {
|
|
367
|
-
boundariesChanged = true;
|
|
368
|
-
break;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
if (boundariesChanged) {
|
|
373
|
-
origSizeCacheRebuild(layout.totalEntries);
|
|
374
|
-
const totalSize = sizeCache.getTotalSize();
|
|
375
|
-
contentElement.style[horizontal ? "width" : "height"] = totalSize + "px";
|
|
376
|
-
if (stickyHeader) {
|
|
377
|
-
stickyHeader.refresh();
|
|
378
|
-
stickyHeader.update(engineState.scrollPosition);
|
|
379
|
-
}
|
|
380
|
-
// Only clear DOM if the boundary shift affects the rendered range.
|
|
381
|
-
// Preload-ahead data often creates new groups far from the viewport
|
|
382
|
-
// — no reason to destroy visible elements for that.
|
|
383
|
-
let firstShift = Infinity;
|
|
384
|
-
const minG = Math.min(prevGroupCount, newGroups.length);
|
|
385
|
-
for (let g = 0; g < minG; g++) {
|
|
386
|
-
if (newGroups[g].headerLayoutIndex !== prevGroups[g].headerLayoutIndex) {
|
|
387
|
-
firstShift = Math.min(newGroups[g].headerLayoutIndex, prevGroups[g].headerLayoutIndex);
|
|
388
|
-
break;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
if (newGroups.length > prevGroupCount && minG < newGroups.length) {
|
|
392
|
-
firstShift = Math.min(firstShift, newGroups[minG].headerLayoutIndex);
|
|
393
|
-
}
|
|
394
|
-
else if (prevGroupCount > newGroups.length && minG < prevGroupCount) {
|
|
395
|
-
firstShift = Math.min(firstShift, prevGroups[minG].headerLayoutIndex);
|
|
396
|
-
}
|
|
397
|
-
if (firstShift <= engineState.prevRangeEnd) {
|
|
398
|
-
detachAll();
|
|
399
|
-
}
|
|
400
|
-
const getSb = getMethod?.("_scrollbar:getInstance");
|
|
401
|
-
if (getSb) {
|
|
402
|
-
const sb = getSb();
|
|
403
|
-
sb?.updateBounds(totalSize, engineState.containerSize);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
engineState.prevRangeStart = -1;
|
|
408
|
-
engineState.prevRangeEnd = -1;
|
|
409
|
-
engineState.renderPending = true;
|
|
410
|
-
forceNextRender = true;
|
|
411
|
-
groupsRenderIfNeeded();
|
|
412
|
-
}
|
|
413
|
-
return {
|
|
414
|
-
name: "groups",
|
|
415
|
-
priority: 10,
|
|
416
|
-
setup(ctx) {
|
|
417
|
-
sizeCache = ctx.sizeCache;
|
|
418
|
-
engineState = ctx.getState();
|
|
419
|
-
pool = ctx.pool;
|
|
420
|
-
contentElement = ctx.dom.content;
|
|
421
|
-
rootElement = ctx.dom.root;
|
|
422
|
-
userTemplate = ctx.template;
|
|
423
|
-
horizontal = ctx.config.horizontal;
|
|
424
|
-
classPrefix = ctx.config.classPrefix;
|
|
425
|
-
overscan = ctx.config.overscan;
|
|
426
|
-
ctxGetItem = ctx.getItem.bind(ctx);
|
|
427
|
-
resolveItemState = () => ctx.getItemStateFn();
|
|
428
|
-
getMethod = ctx.getMethod.bind(ctx);
|
|
429
|
-
interactive = ctx.config.interactive;
|
|
430
|
-
groupItemClass = `${classPrefix}-item`;
|
|
431
|
-
groupHeaderClass = `${classPrefix}-group-header`;
|
|
432
|
-
// Resolve raw storage accessor (async plugin) — returns undefined for
|
|
433
|
-
// unloaded items without generating placeholder objects. Used in
|
|
434
|
-
// buildGroups to skip unloaded items efficiently.
|
|
435
|
-
queueMicrotask(() => {
|
|
436
|
-
const fn = getMethod?.("_getLoadedItem");
|
|
437
|
-
if (fn)
|
|
438
|
-
getLoadedItem = fn;
|
|
439
|
-
const countFn = getMethod?.("_getLoadedCount");
|
|
440
|
-
if (countFn)
|
|
441
|
-
getLoadedCount = countFn;
|
|
442
|
-
});
|
|
443
|
-
const dataCount = engineState.totalItems;
|
|
444
|
-
layout = createGroupLayout(dataCount, config, getLoadedItem ?? ctxGetItem);
|
|
445
|
-
lastDataCount = dataCount;
|
|
446
|
-
const getHeaderHeight = typeof headerHeightRaw === "number"
|
|
447
|
-
? (_groupIndex) => headerHeightRaw
|
|
448
|
-
: (groupIndex) => {
|
|
449
|
-
const group = layout.groups[groupIndex];
|
|
450
|
-
if (!group)
|
|
451
|
-
return 0;
|
|
452
|
-
return headerHeightRaw(group.key, groupIndex);
|
|
453
|
-
};
|
|
454
|
-
const origGetSize = sizeCache.getSize;
|
|
455
|
-
const groupedSizeFn = (layoutIndex) => {
|
|
456
|
-
const entry = layout.getEntry(layoutIndex);
|
|
457
|
-
if (entry.type === "header") {
|
|
458
|
-
if (config.sticky !== false && entry.group.groupIndex === 0)
|
|
459
|
-
return 0;
|
|
460
|
-
return getHeaderHeight(entry.group.groupIndex);
|
|
461
|
-
}
|
|
462
|
-
return origGetSize(entry.dataIndex);
|
|
463
|
-
};
|
|
464
|
-
ctx.setSizeConfig(groupedSizeFn);
|
|
465
|
-
// Intercept sizeCache.rebuild: external callers (async plugin) pass
|
|
466
|
-
// data count, but groups needs layout count. Always use layout.totalEntries
|
|
467
|
-
// to keep prefix sums consistent with the grouped layout.
|
|
468
|
-
origSizeCacheRebuild = sizeCache.rebuild;
|
|
469
|
-
sizeCache.rebuild = (_n) => {
|
|
470
|
-
origSizeCacheRebuild(layout.totalEntries);
|
|
471
|
-
};
|
|
472
|
-
sizeCache.rebuild(layout.totalEntries);
|
|
473
|
-
ctx.setVirtualTotalFn(() => layout.totalEntries);
|
|
474
|
-
rootElement.classList.add(`${classPrefix}--grouped`);
|
|
475
|
-
if (config.sticky !== false) {
|
|
476
|
-
const renderInto = (slot, groupIndex) => {
|
|
477
|
-
const group = layout.groups[groupIndex];
|
|
478
|
-
if (!group)
|
|
479
|
-
return;
|
|
480
|
-
const result = headerTemplate(group.key, groupIndex);
|
|
481
|
-
if (typeof result === "string") {
|
|
482
|
-
slot.innerHTML = result;
|
|
483
|
-
}
|
|
484
|
-
else {
|
|
485
|
-
slot.replaceChildren(result);
|
|
486
|
-
}
|
|
487
|
-
};
|
|
488
|
-
const headerH = layout.getHeaderHeight(0);
|
|
489
|
-
const stickyContainer = createStickyContainer(rootElement, classPrefix, horizontal, headerH);
|
|
490
|
-
stickyHeader = createStickyHeader(rootElement, layout, sizeCache, renderInto, classPrefix, horizontal, 0, undefined, stickyContainer);
|
|
491
|
-
stickyHeader.update(engineState.scrollPosition);
|
|
492
|
-
if (!horizontal) {
|
|
493
|
-
ctx.dom.viewport.style.height = `calc(100% - ${headerH}px)`;
|
|
494
|
-
}
|
|
495
|
-
else {
|
|
496
|
-
ctx.dom.viewport.style.width = `calc(100% - ${headerH}px)`;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
ctx.setRenderFn(groupsRenderIfNeeded, groupsForceRender);
|
|
500
|
-
ctx.registerMethod("getGroupLayout", () => layout);
|
|
501
|
-
ctx.registerMethod("_dataToLayoutIndex", (dataIndex) => layout.dataToLayoutIndex(dataIndex));
|
|
502
|
-
ctx.registerMethod("_layoutToDataIndex", (layoutIndex) => layout.layoutToDataIndex(layoutIndex));
|
|
503
|
-
ctx.registerMethod("_getRenderedElement", (layoutIndex) => rendered.get(layoutIndex) ?? null);
|
|
504
|
-
ctx.registerMethod("_isGroupHeader", (layoutIndex) => {
|
|
505
|
-
const entry = layout.getEntry(layoutIndex);
|
|
506
|
-
return entry.type === "header";
|
|
507
|
-
});
|
|
508
|
-
ctx.registerMethod("scrollToIndex", (index, alignOrOptions = "start") => {
|
|
509
|
-
const layoutIndex = layout.dataToLayoutIndex(index);
|
|
510
|
-
const totalLayout = layout.totalEntries;
|
|
511
|
-
if (totalLayout === 0)
|
|
512
|
-
return;
|
|
513
|
-
const clamped = Math.max(0, Math.min(layoutIndex, totalLayout - 1));
|
|
514
|
-
const offset = sizeCache.getOffset(clamped);
|
|
515
|
-
const itemSize = sizeCache.getSize(clamped);
|
|
516
|
-
const cs = engineState.containerSize;
|
|
517
|
-
const totalSize = sizeCache.getTotalSize();
|
|
518
|
-
const maxScroll = Math.max(0, totalSize - cs);
|
|
519
|
-
const align = typeof alignOrOptions === "string" ? alignOrOptions : (alignOrOptions.align ?? "start");
|
|
520
|
-
const behavior = typeof alignOrOptions === "object" ? alignOrOptions.behavior : undefined;
|
|
521
|
-
const duration = typeof alignOrOptions === "object" ? alignOrOptions.duration : undefined;
|
|
522
|
-
let pos;
|
|
523
|
-
switch (align) {
|
|
524
|
-
case "center":
|
|
525
|
-
pos = offset - (cs - itemSize) / 2;
|
|
526
|
-
break;
|
|
527
|
-
case "end":
|
|
528
|
-
pos = offset - cs + itemSize;
|
|
529
|
-
break;
|
|
530
|
-
default:
|
|
531
|
-
pos = offset;
|
|
532
|
-
}
|
|
533
|
-
pos = Math.max(0, Math.min(pos, maxScroll));
|
|
534
|
-
if (behavior === "smooth" && duration && duration > 0) {
|
|
535
|
-
ctx.smoothScrollTo(pos, duration);
|
|
536
|
-
}
|
|
537
|
-
else {
|
|
538
|
-
ctx.scrollTo(pos);
|
|
539
|
-
}
|
|
540
|
-
});
|
|
541
|
-
ctx.registerDestroyHandler(() => {
|
|
542
|
-
for (const [, element] of rendered) {
|
|
543
|
-
element.remove();
|
|
544
|
-
}
|
|
545
|
-
rendered.clear();
|
|
546
|
-
placeholderIndices.clear();
|
|
547
|
-
if (stickyHeader) {
|
|
548
|
-
stickyHeader.destroy();
|
|
549
|
-
stickyHeader = null;
|
|
550
|
-
}
|
|
551
|
-
rootElement.classList.remove(`${classPrefix}--grouped`);
|
|
552
|
-
});
|
|
553
|
-
},
|
|
554
|
-
hooks: {
|
|
555
|
-
onAfterScroll(scrollPosition, _direction) {
|
|
556
|
-
if (stickyHeader) {
|
|
557
|
-
stickyHeader.update(scrollPosition);
|
|
558
|
-
}
|
|
559
|
-
},
|
|
560
|
-
},
|
|
561
|
-
destroy() {
|
|
562
|
-
if (stickyHeader) {
|
|
563
|
-
stickyHeader.destroy();
|
|
564
|
-
stickyHeader = null;
|
|
565
|
-
}
|
|
566
|
-
rendered.clear();
|
|
567
|
-
placeholderIndices.clear();
|
|
568
|
-
detached.clear();
|
|
569
|
-
},
|
|
570
|
-
};
|
|
571
|
-
}
|