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,525 @@
1
+ /**
2
+ * vlist - Grid Renderer
3
+ * Renders items in a 2D grid layout within the virtual scroll container.
4
+ *
5
+ * Extends the base renderer pattern but positions items using both
6
+ * row offsets (translateY from the size cache) and column offsets
7
+ * (translateX calculated from column index and container width).
8
+ *
9
+ * Key differences from the list renderer:
10
+ * - Items are positioned with translate(x, y) instead of just translateY(y)
11
+ * - Item width is set to columnWidth (containerWidth / columns - gaps)
12
+ * - The "index" in the rendered map is the FLAT ITEM INDEX (not row index)
13
+ * - Row offsets come from the size cache (which operates on row indices)
14
+ * - Column offsets are calculated from itemIndex % columns
15
+ *
16
+ * Performance:
17
+ * - Element pooling avoids createElement cost
18
+ * - Template re-evaluation skipped when item data + state unchanged (change tracking)
19
+ * - Position update skipped when coordinates unchanged (position tracking)
20
+ * - O(1) Set-based visibility diffing (not O(n) .some())
21
+ * - Release grace period prevents boundary thrashing (hover blink, transition replay)
22
+ * - Released elements removed from DOM immediately
23
+ * - DocumentFragment batched insertion for new elements
24
+ */
25
+ import { PLACEHOLDER_ID_PREFIX } from "../../constants";
26
+ import { claimPlaceholderSelection } from "../selection/state";
27
+ import { calculateCompressedItemPosition, } from "../../rendering/scale";
28
+ import { isGroupHeader } from "../groups/types";
29
+ import { sortRenderedDOM } from "../../rendering/sort";
30
+ const createElementPool = (maxSize = 200) => {
31
+ const pool = [];
32
+ const acquire = () => {
33
+ const element = pool.pop();
34
+ if (element) {
35
+ return element;
36
+ }
37
+ const newElement = document.createElement("div");
38
+ newElement.setAttribute("role", "option");
39
+ return newElement;
40
+ };
41
+ const release = (element) => {
42
+ // Remove from DOM immediately — prevents blank divs in the container
43
+ element.remove();
44
+ if (pool.length < maxSize) {
45
+ element.className = "";
46
+ element.textContent = "";
47
+ element.removeAttribute("style");
48
+ element.removeAttribute("data-index");
49
+ element.removeAttribute("data-id");
50
+ element.removeAttribute("data-row");
51
+ element.removeAttribute("data-col");
52
+ pool.push(element);
53
+ }
54
+ };
55
+ const clear = () => {
56
+ pool.length = 0;
57
+ };
58
+ return { acquire, release, clear };
59
+ };
60
+ // =============================================================================
61
+ // Release grace period
62
+ // =============================================================================
63
+ /**
64
+ * Number of render cycles to keep an item alive after it leaves the visible set.
65
+ * Prevents boundary thrashing: items near the overscan edge aren't recycled
66
+ * on small scroll deltas, preserving DOM element hover state and avoiding
67
+ * CSS transition replays.
68
+ */
69
+ const RELEASE_GRACE = 1;
70
+ // =============================================================================
71
+ // Grid Renderer Factory
72
+ // =============================================================================
73
+ /**
74
+ * Create a grid renderer for managing DOM elements in a 2D layout.
75
+ *
76
+ * The grid renderer receives flat item ranges (not row ranges) and
77
+ * positions each item at the correct (row, col) coordinate.
78
+ *
79
+ * @param itemsContainer - The DOM element that holds rendered items
80
+ * @param template - Item template function
81
+ * @param sizeCache - Size cache operating on ROW indices
82
+ * @param gridLayout - Grid layout for row/col calculations
83
+ * @param classPrefix - CSS class prefix
84
+ * @param initialContainerWidth - Initial container width for column sizing
85
+ * @param totalItemsGetter - Optional getter for total item count (for aria-setsize)
86
+ * @param ariaIdPrefix - Optional unique prefix for element IDs (for aria-activedescendant)
87
+ * @param isHorizontal - Whether layout is horizontal (scrolls right)
88
+ */
89
+ export const createGridRenderer = (itemsContainer, template, sizeCache, gridLayout, classPrefix, initialContainerWidth, totalItemsGetter, ariaIdPrefix, isHorizontal = false, ariaPosInSetGetter, interactive) => {
90
+ const pool = createElementPool();
91
+ const rendered = new Map();
92
+ let containerWidth = initialContainerWidth;
93
+ // Track if groups are active (affects size cache indexing)
94
+ let groupsActive = false;
95
+ // ── Frame counter for release grace period ──
96
+ let frameCounter = 0;
97
+ // Track aria-setsize to avoid redundant updates on existing items
98
+ let lastAriaSetSize = "";
99
+ let lastAriaTotal = -1;
100
+ // Reusable item state to avoid allocation per render
101
+ const reusableItemState = { selected: false, focused: false };
102
+ const getItemState = (isSelected, isFocused) => {
103
+ reusableItemState.selected = isSelected;
104
+ reusableItemState.focused = isFocused;
105
+ return reusableItemState;
106
+ };
107
+ // Pre-computed class names
108
+ const baseClass = `${classPrefix}-item ${classPrefix}-grid-item`;
109
+ const groupHeaderClass = `${classPrefix}-group-header`;
110
+ const selectedClass = `${classPrefix}-item--selected`;
111
+ const focusedClass = `${classPrefix}-item--focused`;
112
+ const placeholderClass = `${classPrefix}-item--placeholder`;
113
+ const replacedClass = `${classPrefix}-item--replaced`;
114
+ /**
115
+ * Apply template result to element
116
+ */
117
+ const applyTemplate = (element, result) => {
118
+ if (typeof result === "string") {
119
+ element.innerHTML = result;
120
+ }
121
+ else {
122
+ element.replaceChildren(result);
123
+ }
124
+ };
125
+ /**
126
+ * Apply state-dependent classes
127
+ */
128
+ const applyClasses = (element, isSelected, isFocused) => {
129
+ element.classList.toggle(selectedClass, isSelected);
130
+ element.classList.toggle(focusedClass, isFocused);
131
+ };
132
+ /**
133
+ * Calculate the Y offset for an item (based on its row).
134
+ * Uses compression-aware positioning for large grids.
135
+ */
136
+ const calculateRowOffset = (itemIndex, compressionCtx) => {
137
+ const row = gridLayout.getRow(itemIndex);
138
+ if (compressionCtx?.compression?.isCompressed) {
139
+ return calculateCompressedItemPosition(row, compressionCtx.scrollPosition, sizeCache, compressionCtx.totalItems, compressionCtx.containerSize, compressionCtx.compression, compressionCtx.rangeStart);
140
+ }
141
+ // Normal positioning: row offset from size cache
142
+ return sizeCache.getOffset(row);
143
+ };
144
+ /**
145
+ * Build the transform string for an element at the given item index.
146
+ * Group headers are positioned at x=0 to span full width.
147
+ */
148
+ const buildTransform = (itemIndex, compressionCtx) => {
149
+ // Check if this is a group header - position at full width
150
+ const itemCol = gridLayout.getCol(itemIndex);
151
+ const isHeader = groupsActive && itemCol === 0;
152
+ const x = isHeader ? 0 : gridLayout.getColumnOffset(itemCol, containerWidth);
153
+ // Y position: when groups are active, calculate by summing each row's height once
154
+ let y;
155
+ if (groupsActive) {
156
+ // Grouped grid: sum the height of each row before this item's row
157
+ const itemRow = gridLayout.getRow(itemIndex);
158
+ let offset = 0;
159
+ let lastRow = -1;
160
+ for (let i = 0; i < itemIndex; i++) {
161
+ const prevItemRow = gridLayout.getRow(i);
162
+ if (prevItemRow < itemRow && prevItemRow !== lastRow) {
163
+ const height = sizeCache.getSize(i);
164
+ offset += height;
165
+ lastRow = prevItemRow;
166
+ }
167
+ }
168
+ y = offset;
169
+ }
170
+ else {
171
+ y = calculateRowOffset(itemIndex, compressionCtx);
172
+ }
173
+ // Swap axes for horizontal orientation
174
+ if (isHorizontal) {
175
+ return `translate(${Math.round(y)}px, ${Math.round(x)}px)`;
176
+ }
177
+ return `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
178
+ };
179
+ /**
180
+ * Apply size styles to an element (width from column, height from row height).
181
+ * Group headers get full container width instead of column width.
182
+ */
183
+ const applySizeStyles = (element, itemIndex) => {
184
+ // Check if this is a group header - use full width
185
+ const isHeader = element.classList.contains(groupHeaderClass);
186
+ const colWidth = isHeader
187
+ ? containerWidth
188
+ : gridLayout.getColumnWidth(containerWidth);
189
+ // Height lookup depends on whether groups are active
190
+ // Grouped grids: size cache uses ITEM indices
191
+ // Regular grids: size cache uses ROW indices
192
+ let itemHeight;
193
+ if (groupsActive || isHeader) {
194
+ // Grouped grid: size cache is item-based
195
+ itemHeight = sizeCache.getSize(itemIndex) - gridLayout.gap;
196
+ }
197
+ else {
198
+ // Regular grid: size cache is row-based
199
+ const row = gridLayout.getRow(itemIndex);
200
+ itemHeight = sizeCache.getSize(row) - gridLayout.gap;
201
+ }
202
+ if (isHorizontal) {
203
+ // In horizontal mode, swap CSS dimensions so they match their visual role:
204
+ // style.width → horizontal extent (scroll direction = main axis)
205
+ // style.height → vertical extent (cross axis)
206
+ element.style.width = `${itemHeight}px`;
207
+ element.style.height = `${colWidth}px`;
208
+ }
209
+ else {
210
+ element.style.width = `${colWidth}px`;
211
+ element.style.height = `${itemHeight}px`;
212
+ }
213
+ };
214
+ /**
215
+ * Render a single grid item (new element from pool)
216
+ */
217
+ const renderItem = (itemIndex, item, isSelected, isFocused, transform) => {
218
+ const element = pool.acquire();
219
+ const isGH = !!item.__groupHeader;
220
+ const state = getItemState(isSelected, isFocused);
221
+ // Group headers get a distinct class and role
222
+ element.className = isGH ? groupHeaderClass : baseClass;
223
+ // Set data attributes
224
+ element.dataset.index = String(itemIndex);
225
+ element.dataset.id = String(item.id);
226
+ element.dataset.row = String(gridLayout.getRow(itemIndex));
227
+ element.dataset.col = String(gridLayout.getCol(itemIndex));
228
+ if (isGH) {
229
+ element.setAttribute("role", "presentation");
230
+ element.removeAttribute("aria-selected");
231
+ element.removeAttribute("aria-setsize");
232
+ element.removeAttribute("aria-posinset");
233
+ element.removeAttribute("id");
234
+ }
235
+ else if (interactive !== false) {
236
+ element.setAttribute("role", "option");
237
+ element.ariaSelected = String(isSelected);
238
+ if (ariaIdPrefix) {
239
+ element.id = `${ariaIdPrefix}-item-${itemIndex}`;
240
+ }
241
+ if (totalItemsGetter) {
242
+ const total = totalItemsGetter();
243
+ if (total !== lastAriaTotal) {
244
+ lastAriaTotal = total;
245
+ lastAriaSetSize = String(total);
246
+ }
247
+ element.setAttribute("aria-setsize", lastAriaSetSize);
248
+ const posInSet = ariaPosInSetGetter ? ariaPosInSetGetter(itemIndex) : itemIndex + 1;
249
+ element.setAttribute("aria-posinset", String(posInSet));
250
+ }
251
+ }
252
+ else {
253
+ element.setAttribute("role", "listitem");
254
+ element.removeAttribute("aria-selected");
255
+ if (totalItemsGetter) {
256
+ const total = totalItemsGetter();
257
+ if (total !== lastAriaTotal) {
258
+ lastAriaTotal = total;
259
+ lastAriaSetSize = String(total);
260
+ }
261
+ element.setAttribute("aria-setsize", lastAriaSetSize);
262
+ const posInSet = ariaPosInSetGetter ? ariaPosInSetGetter(itemIndex) : itemIndex + 1;
263
+ element.setAttribute("aria-posinset", String(posInSet));
264
+ }
265
+ }
266
+ // Apply sizing
267
+ applySizeStyles(element, itemIndex);
268
+ // Apply template
269
+ const result = template(item, itemIndex, state);
270
+ applyTemplate(element, result);
271
+ // Placeholder class — detected via ID prefix
272
+ const isPlaceholder = String(item.id).startsWith(PLACEHOLDER_ID_PREFIX);
273
+ if (isPlaceholder)
274
+ element.classList.add(placeholderClass);
275
+ // Apply state classes and position
276
+ applyClasses(element, isSelected, isFocused);
277
+ element.style.transform = transform;
278
+ return {
279
+ element,
280
+ lastItemId: item.id,
281
+ lastSelected: isSelected,
282
+ lastFocused: isFocused,
283
+ lastTransform: transform,
284
+ lastSeenFrame: frameCounter,
285
+ };
286
+ };
287
+ /**
288
+ * Render items for a flat item range, positioned in a 2D grid.
289
+ *
290
+ * The range is in flat item indices (not row indices).
291
+ * Items are positioned using translate(colOffset, rowOffset).
292
+ *
293
+ * Performance characteristics:
294
+ * - Skips template re-evaluation when item id + state unchanged (change tracking)
295
+ * - Skips position update when transform string unchanged (position tracking)
296
+ * - Release grace period prevents boundary thrashing (hover blink, transition replay)
297
+ * - Released elements removed from DOM immediately
298
+ * - DocumentFragment batched insertion for new elements
299
+ */
300
+ const render = (items, range, selectedIds, focusedIndex, compressionCtx) => {
301
+ frameCounter++;
302
+ // Detect if groups are active by checking if ANY item in the dataset is a header
303
+ // Don't check items[0] because it's relative to the render range, not the full dataset
304
+ // Instead, check if the first item in the full range is a header
305
+ if (range.start === 0 && items.length) {
306
+ groupsActive = isGroupHeader(items[0]);
307
+ }
308
+ // Once groupsActive is true, it stays true (groups don't disappear mid-scroll)
309
+ // Release items outside the new range, with grace period to prevent
310
+ // boundary thrashing (hover blink, CSS transition replay).
311
+ // Items that just left the visible range keep their DOM element for
312
+ // RELEASE_GRACE extra render cycles — if they re-enter, the same
313
+ // element is reused with :hover state intact.
314
+ for (const [index, tracked] of rendered) {
315
+ if (index >= range.start && index <= range.end) {
316
+ tracked.lastSeenFrame = frameCounter;
317
+ }
318
+ else if (frameCounter - tracked.lastSeenFrame > RELEASE_GRACE) {
319
+ pool.release(tracked.element);
320
+ rendered.delete(index);
321
+ }
322
+ }
323
+ // Check if aria-setsize changed (total items mutated) — update existing items only when needed
324
+ let setSizeChanged = false;
325
+ if (totalItemsGetter) {
326
+ const total = totalItemsGetter();
327
+ if (total !== lastAriaTotal) {
328
+ lastAriaTotal = total;
329
+ lastAriaSetSize = String(total);
330
+ setSizeChanged = true;
331
+ }
332
+ }
333
+ // DocumentFragment for batched DOM insertion of new elements
334
+ let fragment = null;
335
+ // Add/update items in range
336
+ for (let i = range.start; i <= range.end; i++) {
337
+ // Items array is 0-indexed relative to range.start
338
+ const itemIndex = i - range.start;
339
+ const item = items[itemIndex];
340
+ if (!item)
341
+ continue;
342
+ let isSelected = selectedIds.has(item.id);
343
+ const isFocused = i === focusedIndex;
344
+ const existing = rendered.get(i);
345
+ if (existing) {
346
+ // ── Fast path: skip work when nothing changed ──
347
+ const idChanged = existing.lastItemId !== item.id;
348
+ const selectedChanged = existing.lastSelected !== isSelected;
349
+ const focusedChanged = existing.lastFocused !== isFocused;
350
+ // Template re-evaluation when item data changes
351
+ if (idChanged) {
352
+ const existingId = String(existing.lastItemId);
353
+ const newId = String(item.id);
354
+ const wasPlaceholder = existingId.startsWith(PLACEHOLDER_ID_PREFIX);
355
+ const isPlaceholder = newId.startsWith(PLACEHOLDER_ID_PREFIX);
356
+ // Transfer selection from placeholder → real item ID (async loading)
357
+ if (!isPlaceholder && claimPlaceholderSelection(selectedIds, i, item.id)) {
358
+ isSelected = true;
359
+ }
360
+ const state = getItemState(isSelected, isFocused);
361
+ const result = template(item, i, state);
362
+ applyTemplate(existing.element, result);
363
+ existing.element.dataset.id = newId;
364
+ existing.element.dataset.row = String(gridLayout.getRow(i));
365
+ existing.element.dataset.col = String(gridLayout.getCol(i));
366
+ applySizeStyles(existing.element, i);
367
+ // Toggle placeholder class
368
+ existing.element.classList.toggle(placeholderClass, isPlaceholder);
369
+ // Fade-in animation when placeholder is replaced with real data
370
+ if (wasPlaceholder && !isPlaceholder) {
371
+ existing.element.classList.add(replacedClass);
372
+ setTimeout(() => existing.element.classList.remove(replacedClass), 300);
373
+ }
374
+ existing.lastItemId = item.id;
375
+ // Refresh aria-posinset when element is reused for a different item
376
+ const isGH = !!item.__groupHeader;
377
+ if (!isGH) {
378
+ const posInSet = ariaPosInSetGetter ? ariaPosInSetGetter(i) : i + 1;
379
+ existing.element.setAttribute("aria-posinset", String(posInSet));
380
+ }
381
+ }
382
+ // Class + aria updates only when selection/focus changed
383
+ if (idChanged || selectedChanged || focusedChanged) {
384
+ applyClasses(existing.element, isSelected, isFocused);
385
+ existing.element.ariaSelected = String(isSelected);
386
+ existing.lastSelected = isSelected;
387
+ existing.lastFocused = isFocused;
388
+ }
389
+ // Position update only when transform changed
390
+ const transform = buildTransform(i, compressionCtx);
391
+ if (existing.lastTransform !== transform) {
392
+ existing.element.style.transform = transform;
393
+ existing.lastTransform = transform;
394
+ }
395
+ // Update aria-setsize on existing items only when total changed (rare)
396
+ if (setSizeChanged && !item.__groupHeader) {
397
+ existing.element.setAttribute("aria-setsize", lastAriaSetSize);
398
+ }
399
+ }
400
+ else {
401
+ // Transfer selection from placeholder → real item ID (async loading)
402
+ if (claimPlaceholderSelection(selectedIds, i, item.id)) {
403
+ isSelected = true;
404
+ }
405
+ // Render new item — collect in fragment for batched insertion
406
+ const transform = buildTransform(i, compressionCtx);
407
+ const tracked = renderItem(i, item, isSelected, isFocused, transform);
408
+ if (!fragment)
409
+ fragment = document.createDocumentFragment();
410
+ fragment.appendChild(tracked.element);
411
+ rendered.set(i, tracked);
412
+ }
413
+ }
414
+ // Single DOM insertion for all new elements — minimizes reflows
415
+ if (fragment)
416
+ itemsContainer.appendChild(fragment);
417
+ };
418
+ /**
419
+ * Update positions of all rendered items (for compressed scrolling)
420
+ */
421
+ const updatePositions = (compressionCtx) => {
422
+ for (const [index, tracked] of rendered) {
423
+ const transform = buildTransform(index, compressionCtx);
424
+ if (tracked.lastTransform !== transform) {
425
+ tracked.element.style.transform = transform;
426
+ tracked.lastTransform = transform;
427
+ }
428
+ }
429
+ };
430
+ /**
431
+ * Update a single item (explicit API call).
432
+ * Always re-applies the template because the caller signals that the item
433
+ * data has changed — even when the id stays the same (e.g. cover update).
434
+ * Updates TrackedItem fields so subsequent scroll frames skip redundant work.
435
+ */
436
+ const updateItem = (index, item, isSelected, isFocused) => {
437
+ const existing = rendered.get(index);
438
+ if (!existing)
439
+ return;
440
+ const state = getItemState(isSelected, isFocused);
441
+ const result = template(item, index, state);
442
+ applyTemplate(existing.element, result);
443
+ applyClasses(existing.element, isSelected, isFocused);
444
+ existing.element.dataset.id = String(item.id);
445
+ existing.element.ariaSelected = String(isSelected);
446
+ applySizeStyles(existing.element, index);
447
+ existing.lastItemId = item.id;
448
+ existing.lastSelected = isSelected;
449
+ existing.lastFocused = isFocused;
450
+ };
451
+ /**
452
+ * Update only CSS classes on a rendered item (no template re-evaluation).
453
+ * Leverages change tracking — skips work when state is already current.
454
+ */
455
+ const updateItemClasses = (index, isSelected, isFocused) => {
456
+ const existing = rendered.get(index);
457
+ if (!existing)
458
+ return;
459
+ const selectedChanged = existing.lastSelected !== isSelected;
460
+ const focusedChanged = existing.lastFocused !== isFocused;
461
+ if (selectedChanged || focusedChanged) {
462
+ applyClasses(existing.element, isSelected, isFocused);
463
+ existing.lastSelected = isSelected;
464
+ existing.lastFocused = isFocused;
465
+ }
466
+ };
467
+ /**
468
+ * Get element by flat item index
469
+ */
470
+ const getElement = (index) => {
471
+ return rendered.get(index)?.element;
472
+ };
473
+ /**
474
+ * Update container width (call on resize).
475
+ * Re-sizes and repositions all rendered items.
476
+ */
477
+ const updateContainerWidth = (width) => {
478
+ if (Math.abs(width - containerWidth) < 1)
479
+ return;
480
+ containerWidth = width;
481
+ // Update size and position of all rendered elements
482
+ for (const [index, tracked] of rendered) {
483
+ applySizeStyles(tracked.element, index);
484
+ const transform = buildTransform(index);
485
+ tracked.element.style.transform = transform;
486
+ tracked.lastTransform = transform;
487
+ }
488
+ };
489
+ /**
490
+ * Reorder DOM children so they follow logical data-index order.
491
+ * Called on scroll idle for accessibility — screen readers traverse
492
+ * DOM order, not visual (transform) order. Since items are
493
+ * position:absolute, this has zero visual impact.
494
+ */
495
+ const sortDOM = () => {
496
+ sortRenderedDOM(itemsContainer, rendered.keys(), (key) => rendered.get(key)?.element);
497
+ };
498
+ /**
499
+ * Clear all rendered items
500
+ */
501
+ const clear = () => {
502
+ for (const [, tracked] of rendered) {
503
+ pool.release(tracked.element);
504
+ }
505
+ rendered.clear();
506
+ };
507
+ /**
508
+ * Destroy renderer and cleanup
509
+ */
510
+ const destroy = () => {
511
+ clear();
512
+ pool.clear();
513
+ };
514
+ return {
515
+ render,
516
+ updatePositions,
517
+ updateItem,
518
+ updateItemClasses,
519
+ getElement,
520
+ sortDOM,
521
+ updateContainerWidth,
522
+ clear,
523
+ destroy,
524
+ };
525
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * vlist - Grid Types
3
+ * Types for grid/card layout mode
4
+ *
5
+ * Grid layout transforms a flat list of items into a 2D grid where:
6
+ * - Virtualization operates on ROWS (not individual items)
7
+ * - Each row contains `columns` items side by side
8
+ * - Items are positioned using row/column coordinates
9
+ * - Compression applies to row count, not item count
10
+ */
11
+ export {};