vlist 1.9.1 → 2.0.0

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.
Files changed (182) hide show
  1. package/README.github.md +104 -97
  2. package/README.md +46 -33
  3. package/dist/constants.d.ts +11 -6
  4. package/dist/constants.js +83 -0
  5. package/dist/core/create.d.ts +10 -0
  6. package/dist/core/create.js +740 -0
  7. package/dist/core/dom.d.ts +8 -0
  8. package/dist/core/dom.js +47 -0
  9. package/dist/core/hooks.d.ts +16 -0
  10. package/dist/core/hooks.js +67 -0
  11. package/dist/core/index.d.ts +17 -0
  12. package/dist/core/index.js +13 -0
  13. package/dist/core/pipeline.d.ts +51 -0
  14. package/dist/core/pipeline.js +307 -0
  15. package/dist/core/pool.d.ts +9 -0
  16. package/dist/core/pool.js +42 -0
  17. package/dist/core/scroll.d.ts +32 -0
  18. package/dist/core/scroll.js +137 -0
  19. package/dist/core/sizes.d.ts +8 -0
  20. package/dist/core/sizes.js +6 -0
  21. package/dist/core/state.d.ts +47 -0
  22. package/dist/core/state.js +56 -0
  23. package/dist/core/types.d.ts +187 -0
  24. package/dist/core/types.js +7 -0
  25. package/dist/{builder → core}/velocity.d.ts +1 -1
  26. package/dist/core/velocity.js +33 -0
  27. package/dist/events/emitter.js +60 -0
  28. package/dist/events/index.js +6 -0
  29. package/dist/index.d.ts +28 -19
  30. package/dist/index.js +28 -1
  31. package/dist/internals.d.ts +11 -7
  32. package/dist/internals.js +60 -1
  33. package/dist/plugins/a11y/index.d.ts +2 -0
  34. package/dist/plugins/a11y/index.js +1 -0
  35. package/dist/plugins/a11y/plugin.d.ts +13 -0
  36. package/dist/plugins/a11y/plugin.js +259 -0
  37. package/dist/{features → plugins}/async/index.d.ts +1 -1
  38. package/dist/plugins/async/index.js +12 -0
  39. package/dist/{features → plugins}/async/manager.d.ts +5 -1
  40. package/dist/plugins/async/manager.js +568 -0
  41. package/dist/plugins/async/placeholder.js +154 -0
  42. package/dist/plugins/async/plugin.d.ts +48 -0
  43. package/dist/plugins/async/plugin.js +311 -0
  44. package/dist/plugins/async/sparse.js +540 -0
  45. package/dist/plugins/autosize/index.d.ts +5 -0
  46. package/dist/plugins/autosize/index.js +4 -0
  47. package/dist/plugins/autosize/plugin.d.ts +19 -0
  48. package/dist/plugins/autosize/plugin.js +185 -0
  49. package/dist/plugins/grid/index.d.ts +7 -0
  50. package/dist/plugins/grid/index.js +5 -0
  51. package/dist/plugins/grid/layout.js +275 -0
  52. package/dist/plugins/grid/plugin.d.ts +23 -0
  53. package/dist/plugins/grid/plugin.js +347 -0
  54. package/dist/plugins/grid/renderer.js +525 -0
  55. package/dist/plugins/grid/types.js +11 -0
  56. package/dist/plugins/groups/async-bridge.js +246 -0
  57. package/dist/{features → plugins}/groups/index.d.ts +1 -1
  58. package/dist/plugins/groups/index.js +13 -0
  59. package/dist/plugins/groups/layout.js +294 -0
  60. package/dist/plugins/groups/plugin.d.ts +22 -0
  61. package/dist/plugins/groups/plugin.js +571 -0
  62. package/dist/plugins/groups/sticky.js +255 -0
  63. package/dist/plugins/groups/types.js +12 -0
  64. package/dist/plugins/masonry/index.d.ts +8 -0
  65. package/dist/plugins/masonry/index.js +6 -0
  66. package/dist/plugins/masonry/layout.js +261 -0
  67. package/dist/plugins/masonry/plugin.d.ts +32 -0
  68. package/dist/plugins/masonry/plugin.js +381 -0
  69. package/dist/plugins/masonry/renderer.js +354 -0
  70. package/dist/plugins/masonry/types.js +9 -0
  71. package/dist/plugins/page/index.d.ts +5 -0
  72. package/dist/plugins/page/index.js +5 -0
  73. package/dist/plugins/page/plugin.d.ts +21 -0
  74. package/dist/plugins/page/plugin.js +166 -0
  75. package/dist/plugins/scale/index.d.ts +5 -0
  76. package/dist/plugins/scale/index.js +4 -0
  77. package/dist/plugins/scale/plugin.d.ts +24 -0
  78. package/dist/plugins/scale/plugin.js +507 -0
  79. package/dist/plugins/scrollbar/controller.js +574 -0
  80. package/dist/plugins/scrollbar/index.d.ts +7 -0
  81. package/dist/plugins/scrollbar/index.js +6 -0
  82. package/dist/plugins/scrollbar/plugin.d.ts +20 -0
  83. package/dist/plugins/scrollbar/plugin.js +93 -0
  84. package/dist/plugins/scrollbar/scrollbar.js +556 -0
  85. package/dist/plugins/selection/index.d.ts +6 -0
  86. package/dist/plugins/selection/index.js +7 -0
  87. package/dist/plugins/selection/plugin.d.ts +16 -0
  88. package/dist/plugins/selection/plugin.js +601 -0
  89. package/dist/{features → plugins}/selection/state.d.ts +8 -0
  90. package/dist/plugins/selection/state.js +332 -0
  91. package/dist/plugins/snapshots/index.d.ts +5 -0
  92. package/dist/plugins/snapshots/index.js +5 -0
  93. package/dist/plugins/snapshots/plugin.d.ts +17 -0
  94. package/dist/plugins/snapshots/plugin.js +301 -0
  95. package/dist/plugins/sortable/index.d.ts +6 -0
  96. package/dist/plugins/sortable/index.js +6 -0
  97. package/dist/plugins/sortable/plugin.d.ts +34 -0
  98. package/dist/plugins/sortable/plugin.js +753 -0
  99. package/dist/plugins/table/header.js +501 -0
  100. package/dist/{features → plugins}/table/index.d.ts +1 -1
  101. package/dist/plugins/table/index.js +12 -0
  102. package/dist/plugins/table/layout.js +211 -0
  103. package/dist/plugins/table/plugin.d.ts +20 -0
  104. package/dist/plugins/table/plugin.js +391 -0
  105. package/dist/plugins/table/renderer.js +625 -0
  106. package/dist/plugins/table/types.js +12 -0
  107. package/dist/plugins/transition/index.d.ts +5 -0
  108. package/dist/plugins/transition/index.js +5 -0
  109. package/dist/plugins/transition/plugin.d.ts +22 -0
  110. package/dist/plugins/transition/plugin.js +405 -0
  111. package/dist/rendering/aria.js +23 -0
  112. package/dist/rendering/index.js +18 -0
  113. package/dist/rendering/measured.js +98 -0
  114. package/dist/rendering/renderer.js +586 -0
  115. package/dist/rendering/scale.js +267 -0
  116. package/dist/rendering/scroll.js +71 -0
  117. package/dist/rendering/sizes.js +193 -0
  118. package/dist/rendering/sort.js +65 -0
  119. package/dist/rendering/viewport.js +268 -0
  120. package/dist/size.json +1 -1
  121. package/dist/types.js +5 -0
  122. package/dist/utils/padding.d.ts +2 -4
  123. package/dist/utils/padding.js +49 -0
  124. package/dist/utils/stats.js +124 -0
  125. package/dist/vlist-grid.css +1 -1
  126. package/dist/vlist-masonry.css +1 -1
  127. package/dist/vlist-table.css +1 -1
  128. package/dist/vlist.css +1 -1
  129. package/package.json +9 -4
  130. package/dist/builder/a11y.d.ts +0 -16
  131. package/dist/builder/api.d.ts +0 -21
  132. package/dist/builder/context.d.ts +0 -36
  133. package/dist/builder/core.d.ts +0 -16
  134. package/dist/builder/data.d.ts +0 -71
  135. package/dist/builder/dom.d.ts +0 -15
  136. package/dist/builder/index.d.ts +0 -25
  137. package/dist/builder/materialize.d.ts +0 -166
  138. package/dist/builder/pool.d.ts +0 -10
  139. package/dist/builder/range.d.ts +0 -10
  140. package/dist/builder/scroll.d.ts +0 -24
  141. package/dist/builder/types.d.ts +0 -512
  142. package/dist/features/async/feature.d.ts +0 -72
  143. package/dist/features/autosize/feature.d.ts +0 -34
  144. package/dist/features/autosize/index.d.ts +0 -2
  145. package/dist/features/grid/feature.d.ts +0 -48
  146. package/dist/features/grid/index.d.ts +0 -9
  147. package/dist/features/groups/feature.d.ts +0 -75
  148. package/dist/features/masonry/feature.d.ts +0 -45
  149. package/dist/features/masonry/index.d.ts +0 -9
  150. package/dist/features/page/feature.d.ts +0 -109
  151. package/dist/features/page/index.d.ts +0 -9
  152. package/dist/features/scale/feature.d.ts +0 -42
  153. package/dist/features/scale/index.d.ts +0 -10
  154. package/dist/features/scrollbar/feature.d.ts +0 -81
  155. package/dist/features/scrollbar/index.d.ts +0 -8
  156. package/dist/features/selection/feature.d.ts +0 -91
  157. package/dist/features/selection/index.d.ts +0 -7
  158. package/dist/features/snapshots/feature.d.ts +0 -79
  159. package/dist/features/snapshots/index.d.ts +0 -9
  160. package/dist/features/sortable/feature.d.ts +0 -101
  161. package/dist/features/sortable/index.d.ts +0 -6
  162. package/dist/features/table/feature.d.ts +0 -67
  163. package/dist/features/transition/feature.d.ts +0 -30
  164. package/dist/features/transition/index.d.ts +0 -9
  165. /package/dist/{features → plugins}/async/placeholder.d.ts +0 -0
  166. /package/dist/{features → plugins}/async/sparse.d.ts +0 -0
  167. /package/dist/{features → plugins}/grid/layout.d.ts +0 -0
  168. /package/dist/{features → plugins}/grid/renderer.d.ts +0 -0
  169. /package/dist/{features → plugins}/grid/types.d.ts +0 -0
  170. /package/dist/{features → plugins}/groups/async-bridge.d.ts +0 -0
  171. /package/dist/{features → plugins}/groups/layout.d.ts +0 -0
  172. /package/dist/{features → plugins}/groups/sticky.d.ts +0 -0
  173. /package/dist/{features → plugins}/groups/types.d.ts +0 -0
  174. /package/dist/{features → plugins}/masonry/layout.d.ts +0 -0
  175. /package/dist/{features → plugins}/masonry/renderer.d.ts +0 -0
  176. /package/dist/{features → plugins}/masonry/types.d.ts +0 -0
  177. /package/dist/{features → plugins}/scrollbar/controller.d.ts +0 -0
  178. /package/dist/{features → plugins}/scrollbar/scrollbar.d.ts +0 -0
  179. /package/dist/{features → plugins}/table/header.d.ts +0 -0
  180. /package/dist/{features → plugins}/table/layout.d.ts +0 -0
  181. /package/dist/{features → plugins}/table/renderer.d.ts +0 -0
  182. /package/dist/{features → plugins}/table/types.d.ts +0 -0
@@ -0,0 +1,571 @@
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
+ }