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,586 @@
1
+ /**
2
+ * vlist - DOM Rendering
3
+ * Efficient DOM rendering with element pooling
4
+ * Supports compression for large lists (1M+ items)
5
+ */
6
+ import { PLACEHOLDER_ID_PREFIX } from "../constants";
7
+ import { sortRenderedDOM } from "./sort";
8
+ // =============================================================================
9
+ // Element Pool
10
+ // =============================================================================
11
+ /**
12
+ * Create an element pool for recycling DOM elements
13
+ * Reduces garbage collection and improves performance
14
+ */
15
+ export const createElementPool = (tagName = "div", maxSize = 100) => {
16
+ const pool = [];
17
+ let created = 0;
18
+ let reused = 0;
19
+ const acquire = () => {
20
+ const element = pool.pop();
21
+ if (element) {
22
+ reused++;
23
+ return element;
24
+ }
25
+ created++;
26
+ const newElement = document.createElement(tagName);
27
+ // Set static attributes once per element lifetime (never change)
28
+ newElement.setAttribute("role", "option");
29
+ return newElement;
30
+ };
31
+ const release = (element) => {
32
+ if (pool.length < maxSize) {
33
+ // Reset element state
34
+ element.className = "";
35
+ element.textContent = "";
36
+ element.removeAttribute("style");
37
+ element.removeAttribute("data-index");
38
+ element.removeAttribute("data-id");
39
+ pool.push(element);
40
+ }
41
+ };
42
+ const clear = () => {
43
+ pool.length = 0;
44
+ };
45
+ const stats = () => ({
46
+ poolSize: pool.length,
47
+ created,
48
+ reused,
49
+ });
50
+ return { acquire, release, clear, stats };
51
+ };
52
+ // =============================================================================
53
+ // Release grace period
54
+ // =============================================================================
55
+ /**
56
+ * Number of render cycles to keep an item alive after it leaves the visible range.
57
+ * Prevents boundary thrashing: items near the overscan edge aren't recycled
58
+ * on small scroll deltas, preserving DOM element hover state and avoiding
59
+ * CSS transition replays.
60
+ */
61
+ const RELEASE_GRACE = 2;
62
+ // =============================================================================
63
+ // Renderer
64
+ // =============================================================================
65
+ /**
66
+ * Create a renderer for managing DOM elements
67
+ * Supports compression for large lists
68
+ */
69
+ export const createRenderer = (itemsContainer, template, sizeCache, classPrefix, totalItemsGetter, ariaIdPrefix, horizontal, crossAxisSize, compressionFns, striped, stripeIndexFn, ariaPosInSetGetter, interactive) => {
70
+ const pool = createElementPool("div");
71
+ const rendered = new Map();
72
+ // Cache compression state to avoid recalculating
73
+ let cachedCompression = null;
74
+ let cachedTotalItems = 0;
75
+ // Frame counter for release grace period
76
+ let frameCounter = 0;
77
+ // Cached stripe index function — resolved once per render frame, not per item
78
+ let cachedStripeFn = null;
79
+ // Track aria-setsize to avoid redundant updates on existing items
80
+ let lastAriaSetSize = "";
81
+ let lastAriaTotal = -1;
82
+ /**
83
+ * Get or update compression state.
84
+ * When compression functions are not injected, returns a trivial
85
+ * "not compressed" state — no compression module imported.
86
+ */
87
+ const getCompression = (totalItems) => {
88
+ if (cachedCompression && cachedTotalItems === totalItems) {
89
+ return cachedCompression;
90
+ }
91
+ if (compressionFns) {
92
+ cachedCompression = compressionFns.getState(totalItems, sizeCache);
93
+ }
94
+ else {
95
+ const h = sizeCache.getTotalSize();
96
+ cachedCompression = {
97
+ isCompressed: false,
98
+ actualSize: h,
99
+ virtualSize: h,
100
+ ratio: 1,
101
+ };
102
+ }
103
+ cachedTotalItems = totalItems;
104
+ return cachedCompression;
105
+ };
106
+ /**
107
+ * Reusable item state object to avoid allocation per render
108
+ * Note: Templates should not store or mutate this object reference
109
+ */
110
+ const reusableItemState = { selected: false, focused: false };
111
+ /**
112
+ * Get item state for template (reuses single object to reduce GC pressure)
113
+ */
114
+ const getItemState = (isSelected, isFocused) => {
115
+ reusableItemState.selected = isSelected;
116
+ reusableItemState.focused = isFocused;
117
+ return reusableItemState;
118
+ };
119
+ /**
120
+ * Apply template result to element
121
+ * Uses replaceChildren() for efficient HTMLElement replacement
122
+ */
123
+ const applyTemplate = (element, result) => {
124
+ if (typeof result === "string") {
125
+ element.innerHTML = result;
126
+ }
127
+ else {
128
+ // replaceChildren() is more efficient than innerHTML="" + appendChild()
129
+ // It's a single DOM operation instead of two
130
+ element.replaceChildren(result);
131
+ }
132
+ };
133
+ /**
134
+ * Apply static styles to an element (called once when element is created/recycled)
135
+ * Only sets height — position/top/left/right are already in .vlist-item CSS
136
+ * For variable heights, the height depends on the item index.
137
+ */
138
+ const applyStaticStyles = (element, index) => {
139
+ if (horizontal) {
140
+ element.style.width = `${sizeCache.getSize(index)}px`;
141
+ if (crossAxisSize != null) {
142
+ element.style.height = `${crossAxisSize}px`;
143
+ }
144
+ }
145
+ else {
146
+ element.style.height = `${sizeCache.getSize(index)}px`;
147
+ }
148
+ };
149
+ /**
150
+ * Calculate the offset for an element
151
+ * Uses compression-aware positioning for large lists
152
+ */
153
+ const calculateOffset = (index, compressionCtx) => {
154
+ if (compressionCtx) {
155
+ const compression = getCompression(compressionCtx.totalItems);
156
+ if (compression.isCompressed && compressionFns) {
157
+ // Use compression-aware positioning (injected)
158
+ return compressionFns.getPosition(index, compressionCtx.scrollPosition, sizeCache, compressionCtx.totalItems, compressionCtx.containerSize, compression, compressionCtx.rangeStart);
159
+ }
160
+ }
161
+ // Normal positioning (non-compressed or no context)
162
+ return sizeCache.getOffset(index);
163
+ };
164
+ // Pre-computed class names for toggle operations
165
+ const baseClass = `${classPrefix}-item`;
166
+ const groupHeaderClass = `${classPrefix}-group-header`;
167
+ const selectedClass = `${classPrefix}-item--selected`;
168
+ const focusedClass = `${classPrefix}-item--focused`;
169
+ const placeholderClass = `${classPrefix}-item--placeholder`;
170
+ const replacedClass = `${classPrefix}-item--replaced`;
171
+ const oddClass = `${classPrefix}-item--odd`;
172
+ /**
173
+ * Apply classes to element based on state
174
+ * Uses classList.toggle() for efficient incremental updates
175
+ */
176
+ const applyClasses = (element, isSelected, isFocused) => {
177
+ element.classList.toggle(selectedClass, isSelected);
178
+ element.classList.toggle(focusedClass, isFocused);
179
+ };
180
+ /**
181
+ * Render a single item — returns a TrackedItem for change tracking.
182
+ */
183
+ const renderItem = (index, item, isSelected, isFocused, compressionCtx) => {
184
+ const element = pool.acquire();
185
+ const isGH = !!item.__groupHeader;
186
+ const state = getItemState(isSelected, isFocused);
187
+ // Apply static styles once (position, dimensions)
188
+ applyStaticStyles(element, index);
189
+ // Group headers get a distinct class and role
190
+ element.className = isGH ? groupHeaderClass : baseClass;
191
+ // Set data attributes using dataset (faster than setAttribute)
192
+ element.dataset.index = String(index);
193
+ element.dataset.id = String(item.id);
194
+ if (isGH) {
195
+ element.setAttribute("role", "presentation");
196
+ element.removeAttribute("aria-selected");
197
+ element.removeAttribute("aria-setsize");
198
+ element.removeAttribute("aria-posinset");
199
+ element.removeAttribute("id");
200
+ }
201
+ else if (interactive !== false) {
202
+ element.setAttribute("role", "option");
203
+ element.ariaSelected = String(isSelected);
204
+ if (ariaIdPrefix) {
205
+ element.id = `${ariaIdPrefix}-item-${index}`;
206
+ }
207
+ if (totalItemsGetter) {
208
+ const total = totalItemsGetter();
209
+ if (total !== lastAriaTotal) {
210
+ lastAriaTotal = total;
211
+ lastAriaSetSize = String(total);
212
+ }
213
+ element.setAttribute("aria-setsize", lastAriaSetSize);
214
+ const posInSet = ariaPosInSetGetter ? ariaPosInSetGetter(index) : index + 1;
215
+ element.setAttribute("aria-posinset", String(posInSet));
216
+ }
217
+ }
218
+ else {
219
+ element.setAttribute("role", "listitem");
220
+ element.removeAttribute("aria-selected");
221
+ if (totalItemsGetter) {
222
+ const total = totalItemsGetter();
223
+ if (total !== lastAriaTotal) {
224
+ lastAriaTotal = total;
225
+ lastAriaSetSize = String(total);
226
+ }
227
+ element.setAttribute("aria-setsize", lastAriaSetSize);
228
+ const posInSet = ariaPosInSetGetter ? ariaPosInSetGetter(index) : index + 1;
229
+ element.setAttribute("aria-posinset", String(posInSet));
230
+ }
231
+ }
232
+ // Apply template
233
+ const result = template(item, index, state);
234
+ applyTemplate(element, result);
235
+ // Apply state-dependent classes (selected, focused)
236
+ applyClasses(element, isSelected, isFocused);
237
+ // Placeholder class — detected via ID prefix
238
+ if (String(item.id).startsWith(PLACEHOLDER_ID_PREFIX)) {
239
+ element.classList.add(placeholderClass);
240
+ }
241
+ // Striped: toggle odd class based on logical index (not DOM order)
242
+ // String modes ("data"/"even"/"odd"): use cachedStripeFn to map layout index → stripe index
243
+ if (striped) {
244
+ if (cachedStripeFn) {
245
+ const si = cachedStripeFn(index);
246
+ if (si < 0)
247
+ element.classList.remove(oddClass);
248
+ else
249
+ element.classList.toggle(oddClass, (si & 1) === 1);
250
+ }
251
+ else {
252
+ element.classList.toggle(oddClass, (index & 1) === 1);
253
+ }
254
+ }
255
+ const offset = calculateOffset(index, compressionCtx);
256
+ element.style.transform = horizontal
257
+ ? `translateX(${Math.round(offset)}px)`
258
+ : `translateY(${Math.round(offset)}px)`;
259
+ return {
260
+ element,
261
+ lastItemId: item.id,
262
+ lastSelected: isSelected,
263
+ lastFocused: isFocused,
264
+ lastOffset: offset,
265
+ lastSeenFrame: frameCounter,
266
+ };
267
+ };
268
+ /**
269
+ * Render items for a range.
270
+ *
271
+ * Optimizations vs. naive approach:
272
+ * - Release grace period: items outside the range keep their DOM element for
273
+ * RELEASE_GRACE extra render cycles, preventing hover blink and CSS
274
+ * transition replay on boundary items.
275
+ * - Change tracking: template re-evaluation, class toggles, position updates,
276
+ * and aria writes are all skipped when the tracked state hasn't changed.
277
+ * - Lazy DocumentFragment: only allocated when new items actually enter the
278
+ * viewport — zero allocation on scroll-only frames.
279
+ */
280
+ const render = (items, range, selectedIds, focusedIndex, compressionCtx) => {
281
+ frameCounter++;
282
+ // Release items outside the new range, with grace period to prevent
283
+ // boundary thrashing (hover blink, CSS transition replay).
284
+ // Items that just left the visible range keep their DOM element for
285
+ // RELEASE_GRACE extra render cycles — if they re-enter, the same
286
+ // element is reused with :hover state intact.
287
+ for (const [index, tracked] of rendered) {
288
+ if (index >= range.start && index <= range.end) {
289
+ tracked.lastSeenFrame = frameCounter;
290
+ }
291
+ else if (frameCounter - tracked.lastSeenFrame > RELEASE_GRACE) {
292
+ tracked.element.remove();
293
+ pool.release(tracked.element);
294
+ rendered.delete(index);
295
+ }
296
+ }
297
+ // Check if aria-setsize changed (total items mutated) — update existing items only when needed
298
+ let setSizeChanged = false;
299
+ if (totalItemsGetter) {
300
+ const total = totalItemsGetter();
301
+ if (total !== lastAriaTotal) {
302
+ lastAriaTotal = total;
303
+ lastAriaSetSize = String(total);
304
+ setSizeChanged = true;
305
+ }
306
+ }
307
+ // Resolve stripe function once per frame (not per item)
308
+ cachedStripeFn = (typeof striped === "string" && stripeIndexFn) ? stripeIndexFn() : null;
309
+ // DocumentFragment for batched DOM insertion — only allocated when needed
310
+ let fragment = null;
311
+ // Add/update items in range
312
+ for (let i = range.start; i <= range.end; i++) {
313
+ // Items array is 0-indexed relative to range.start
314
+ const itemIndex = i - range.start;
315
+ const item = items[itemIndex];
316
+ if (!item)
317
+ continue;
318
+ const isSelected = selectedIds.has(item.id);
319
+ const isFocused = i === focusedIndex;
320
+ const existing = rendered.get(i);
321
+ if (existing) {
322
+ // ── Fast path: skip work when nothing changed ──
323
+ const idChanged = existing.lastItemId !== item.id;
324
+ const selectedChanged = existing.lastSelected !== isSelected;
325
+ const focusedChanged = existing.lastFocused !== isFocused;
326
+ if (idChanged) {
327
+ const existingId = String(existing.lastItemId);
328
+ const newId = String(item.id);
329
+ const wasPlaceholder = existingId.startsWith(PLACEHOLDER_ID_PREFIX);
330
+ const isPlaceholder = newId.startsWith(PLACEHOLDER_ID_PREFIX);
331
+ // Re-apply template when item data changes (placeholder -> real data)
332
+ const state = getItemState(isSelected, isFocused);
333
+ const result = template(item, i, state);
334
+ applyTemplate(existing.element, result);
335
+ existing.element.dataset.id = newId;
336
+ // Update height in case variable heights differ for the new item
337
+ applyStaticStyles(existing.element, i);
338
+ // Toggle placeholder class
339
+ existing.element.classList.toggle(placeholderClass, isPlaceholder);
340
+ // Fade-in animation when placeholder is replaced with real data
341
+ if (wasPlaceholder && !isPlaceholder) {
342
+ existing.element.classList.add(replacedClass);
343
+ setTimeout(() => {
344
+ existing.element.classList.remove(replacedClass);
345
+ }, 300);
346
+ }
347
+ existing.lastItemId = item.id;
348
+ // Refresh aria-posinset when element is reused for a different item
349
+ const isGH = !!item.__groupHeader;
350
+ if (!isGH) {
351
+ const posInSet = ariaPosInSetGetter ? ariaPosInSetGetter(i) : i + 1;
352
+ existing.element.setAttribute("aria-posinset", String(posInSet));
353
+ }
354
+ }
355
+ // Class + aria updates only when selection/focus changed
356
+ if (idChanged || selectedChanged || focusedChanged) {
357
+ applyClasses(existing.element, isSelected, isFocused);
358
+ existing.element.ariaSelected = String(isSelected);
359
+ existing.lastSelected = isSelected;
360
+ existing.lastFocused = isFocused;
361
+ }
362
+ // Position update only when offset changed
363
+ const offset = calculateOffset(i, compressionCtx);
364
+ if (existing.lastOffset !== offset) {
365
+ existing.element.style.transform = horizontal
366
+ ? `translateX(${Math.round(offset)}px)`
367
+ : `translateY(${Math.round(offset)}px)`;
368
+ existing.lastOffset = offset;
369
+ }
370
+ // Update aria-setsize on existing items only when total changed (rare)
371
+ if (setSizeChanged && !item.__groupHeader) {
372
+ existing.element.setAttribute("aria-setsize", lastAriaSetSize);
373
+ }
374
+ }
375
+ else {
376
+ // Render new item — collect in fragment for batched insertion
377
+ const tracked = renderItem(i, item, isSelected, isFocused, compressionCtx);
378
+ if (!fragment)
379
+ fragment = document.createDocumentFragment();
380
+ fragment.appendChild(tracked.element);
381
+ rendered.set(i, tracked);
382
+ }
383
+ }
384
+ // Single DOM insertion for all new elements — minimizes reflows
385
+ if (fragment) {
386
+ itemsContainer.appendChild(fragment);
387
+ }
388
+ };
389
+ /**
390
+ * Update positions of all rendered items (for compressed scrolling).
391
+ * Leverages change tracking — skips items whose offset hasn't changed.
392
+ */
393
+ const updatePositions = (compressionCtx) => {
394
+ for (const [index, tracked] of rendered) {
395
+ const offset = calculateOffset(index, compressionCtx);
396
+ if (tracked.lastOffset !== offset) {
397
+ tracked.element.style.transform = horizontal
398
+ ? `translateX(${Math.round(offset)}px)`
399
+ : `translateY(${Math.round(offset)}px)`;
400
+ tracked.lastOffset = offset;
401
+ }
402
+ }
403
+ };
404
+ /**
405
+ * Update a single item (explicit API call).
406
+ * Always re-applies the template because the caller signals that the item
407
+ * data has changed — even when the id stays the same (e.g. name update).
408
+ * Updates TrackedItem fields so subsequent scroll frames skip redundant work.
409
+ */
410
+ const updateItem = (index, item, isSelected, isFocused) => {
411
+ const existing = rendered.get(index);
412
+ if (!existing)
413
+ return;
414
+ const state = getItemState(isSelected, isFocused);
415
+ const result = template(item, index, state);
416
+ applyTemplate(existing.element, result);
417
+ applyClasses(existing.element, isSelected, isFocused);
418
+ existing.element.dataset.id = String(item.id);
419
+ existing.element.ariaSelected = String(isSelected);
420
+ existing.lastItemId = item.id;
421
+ existing.lastSelected = isSelected;
422
+ existing.lastFocused = isFocused;
423
+ };
424
+ /**
425
+ * Update only CSS classes on a rendered item (no template re-evaluation).
426
+ * Leverages change tracking — skips work when state is already current.
427
+ */
428
+ const updateItemClasses = (index, isSelected, isFocused) => {
429
+ const existing = rendered.get(index);
430
+ if (!existing)
431
+ return;
432
+ const selectedChanged = existing.lastSelected !== isSelected;
433
+ const focusedChanged = existing.lastFocused !== isFocused;
434
+ if (selectedChanged || focusedChanged) {
435
+ applyClasses(existing.element, isSelected, isFocused);
436
+ existing.lastSelected = isSelected;
437
+ existing.lastFocused = isFocused;
438
+ }
439
+ };
440
+ /**
441
+ * Get element by index
442
+ */
443
+ const getElement = (index) => {
444
+ return rendered.get(index)?.element;
445
+ };
446
+ /**
447
+ * Reorder DOM children so they follow logical data-index order.
448
+ * Called on scroll idle for accessibility — screen readers traverse
449
+ * DOM order, not visual (transform) order. Since items are
450
+ * position:absolute, this has zero visual impact.
451
+ */
452
+ const sortDOM = () => {
453
+ sortRenderedDOM(itemsContainer, rendered.keys(), (key) => rendered.get(key)?.element);
454
+ };
455
+ /**
456
+ * Clear all rendered items
457
+ */
458
+ const clear = () => {
459
+ for (const [, tracked] of rendered) {
460
+ tracked.element.remove();
461
+ pool.release(tracked.element);
462
+ }
463
+ rendered.clear();
464
+ };
465
+ /**
466
+ * Destroy renderer
467
+ */
468
+ const destroy = () => {
469
+ clear();
470
+ pool.clear();
471
+ };
472
+ return {
473
+ render,
474
+ updatePositions,
475
+ updateItem,
476
+ updateItemClasses,
477
+ getElement,
478
+ sortDOM,
479
+ clear,
480
+ destroy,
481
+ };
482
+ };
483
+ // =============================================================================
484
+ // DOM Helpers
485
+ // =============================================================================
486
+ /**
487
+ * Create the vlist DOM structure
488
+ */
489
+ export const createDOMStructure = (container, classPrefix, ariaLabel, horizontal, interactive) => {
490
+ // Root element
491
+ const root = document.createElement("div");
492
+ root.className = `${classPrefix}`;
493
+ if (horizontal) {
494
+ root.classList.add(`${classPrefix}--horizontal`);
495
+ }
496
+ // Viewport (scrollable container)
497
+ const viewport = document.createElement("div");
498
+ viewport.className = `${classPrefix}-viewport`;
499
+ viewport.setAttribute("tabindex", "-1");
500
+ viewport.style.height = "100%";
501
+ viewport.style.width = "100%";
502
+ if (horizontal) {
503
+ viewport.style.overflowX = "auto";
504
+ viewport.style.overflowY = "hidden";
505
+ }
506
+ else {
507
+ viewport.style.overflow = "auto";
508
+ }
509
+ // Content (sets the total scrollable size)
510
+ const content = document.createElement("div");
511
+ content.className = `${classPrefix}-content`;
512
+ content.style.position = "relative";
513
+ if (horizontal) {
514
+ content.style.height = "100%";
515
+ // Width will be set by updateContentWidth
516
+ }
517
+ else {
518
+ content.style.width = "100%";
519
+ // Height will be set by updateContentHeight
520
+ }
521
+ // Items container (holds rendered items)
522
+ const items = document.createElement("div");
523
+ items.className = `${classPrefix}-items`;
524
+ items.setAttribute("role", interactive !== false ? "listbox" : "list");
525
+ if (interactive !== false)
526
+ items.setAttribute("tabindex", "0");
527
+ if (ariaLabel)
528
+ items.setAttribute("aria-label", ariaLabel);
529
+ if (horizontal)
530
+ items.setAttribute("aria-orientation", "horizontal");
531
+ items.style.position = "relative";
532
+ if (horizontal) {
533
+ items.style.height = "100%";
534
+ }
535
+ else {
536
+ items.style.width = "100%";
537
+ }
538
+ // Live region for screen reader announcements
539
+ const liveRegion = document.createElement("div");
540
+ liveRegion.className = `${classPrefix}-live`;
541
+ liveRegion.style.cssText =
542
+ "position:absolute;width:1px;height:1px;padding:0;margin:-1px;" +
543
+ "overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0";
544
+ liveRegion.setAttribute("aria-live", "polite");
545
+ liveRegion.setAttribute("aria-atomic", "true");
546
+ liveRegion.setAttribute("role", "status");
547
+ // Assemble structure
548
+ content.appendChild(items);
549
+ viewport.appendChild(content);
550
+ root.appendChild(liveRegion);
551
+ root.appendChild(viewport);
552
+ container.appendChild(root);
553
+ return { root, viewport, content, items, liveRegion };
554
+ };
555
+ /**
556
+ * Update content height for virtual scrolling
557
+ */
558
+ export const updateContentHeight = (content, totalHeight) => {
559
+ content.style.height = `${totalHeight}px`;
560
+ };
561
+ /**
562
+ * Update content width for horizontal virtual scrolling
563
+ */
564
+ export const updateContentWidth = (content, totalWidth) => {
565
+ content.style.width = `${totalWidth}px`;
566
+ };
567
+ /**
568
+ * Get container dimensions
569
+ */
570
+ export const getContainerDimensions = (viewport) => ({
571
+ width: viewport.clientWidth,
572
+ height: viewport.clientHeight,
573
+ });
574
+ /**
575
+ * Resolve container from selector or element
576
+ */
577
+ export const resolveContainer = (container) => {
578
+ if (typeof container === "string") {
579
+ const element = document.querySelector(container);
580
+ if (!element) {
581
+ throw new Error(`[vlist] Container not found: ${container}`);
582
+ }
583
+ return element;
584
+ }
585
+ return container;
586
+ };