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.
- package/README.github.md +104 -97
- package/README.md +46 -33
- package/dist/constants.d.ts +11 -6
- package/dist/constants.js +83 -0
- package/dist/core/create.d.ts +10 -0
- package/dist/core/create.js +740 -0
- package/dist/core/dom.d.ts +8 -0
- package/dist/core/dom.js +47 -0
- package/dist/core/hooks.d.ts +16 -0
- package/dist/core/hooks.js +67 -0
- package/dist/core/index.d.ts +17 -0
- package/dist/core/index.js +13 -0
- package/dist/core/pipeline.d.ts +51 -0
- package/dist/core/pipeline.js +307 -0
- package/dist/core/pool.d.ts +9 -0
- package/dist/core/pool.js +42 -0
- package/dist/core/scroll.d.ts +32 -0
- package/dist/core/scroll.js +137 -0
- package/dist/core/sizes.d.ts +8 -0
- package/dist/core/sizes.js +6 -0
- package/dist/core/state.d.ts +47 -0
- package/dist/core/state.js +56 -0
- package/dist/core/types.d.ts +187 -0
- package/dist/core/types.js +7 -0
- package/dist/{builder → core}/velocity.d.ts +1 -1
- package/dist/core/velocity.js +33 -0
- package/dist/events/emitter.js +60 -0
- package/dist/events/index.js +6 -0
- package/dist/index.d.ts +28 -19
- package/dist/index.js +28 -1
- package/dist/internals.d.ts +11 -7
- package/dist/internals.js +60 -1
- package/dist/plugins/a11y/index.d.ts +2 -0
- package/dist/plugins/a11y/index.js +1 -0
- package/dist/plugins/a11y/plugin.d.ts +13 -0
- package/dist/plugins/a11y/plugin.js +259 -0
- package/dist/{features → plugins}/async/index.d.ts +1 -1
- package/dist/plugins/async/index.js +12 -0
- package/dist/{features → plugins}/async/manager.d.ts +5 -1
- package/dist/plugins/async/manager.js +568 -0
- package/dist/plugins/async/placeholder.js +154 -0
- package/dist/plugins/async/plugin.d.ts +48 -0
- package/dist/plugins/async/plugin.js +311 -0
- package/dist/plugins/async/sparse.js +540 -0
- package/dist/plugins/autosize/index.d.ts +5 -0
- package/dist/plugins/autosize/index.js +4 -0
- package/dist/plugins/autosize/plugin.d.ts +19 -0
- package/dist/plugins/autosize/plugin.js +185 -0
- package/dist/plugins/grid/index.d.ts +7 -0
- package/dist/plugins/grid/index.js +5 -0
- package/dist/plugins/grid/layout.js +275 -0
- package/dist/plugins/grid/plugin.d.ts +23 -0
- package/dist/plugins/grid/plugin.js +347 -0
- package/dist/plugins/grid/renderer.js +525 -0
- package/dist/plugins/grid/types.js +11 -0
- package/dist/plugins/groups/async-bridge.js +246 -0
- package/dist/{features → plugins}/groups/index.d.ts +1 -1
- package/dist/plugins/groups/index.js +13 -0
- package/dist/plugins/groups/layout.js +294 -0
- package/dist/plugins/groups/plugin.d.ts +22 -0
- package/dist/plugins/groups/plugin.js +571 -0
- package/dist/plugins/groups/sticky.js +255 -0
- package/dist/plugins/groups/types.js +12 -0
- package/dist/plugins/masonry/index.d.ts +8 -0
- package/dist/plugins/masonry/index.js +6 -0
- package/dist/plugins/masonry/layout.js +261 -0
- package/dist/plugins/masonry/plugin.d.ts +32 -0
- package/dist/plugins/masonry/plugin.js +381 -0
- package/dist/plugins/masonry/renderer.js +354 -0
- package/dist/plugins/masonry/types.js +9 -0
- package/dist/plugins/page/index.d.ts +5 -0
- package/dist/plugins/page/index.js +5 -0
- package/dist/plugins/page/plugin.d.ts +21 -0
- package/dist/plugins/page/plugin.js +166 -0
- package/dist/plugins/scale/index.d.ts +5 -0
- package/dist/plugins/scale/index.js +4 -0
- package/dist/plugins/scale/plugin.d.ts +24 -0
- package/dist/plugins/scale/plugin.js +507 -0
- package/dist/plugins/scrollbar/controller.js +574 -0
- package/dist/plugins/scrollbar/index.d.ts +7 -0
- package/dist/plugins/scrollbar/index.js +6 -0
- package/dist/plugins/scrollbar/plugin.d.ts +20 -0
- package/dist/plugins/scrollbar/plugin.js +93 -0
- package/dist/plugins/scrollbar/scrollbar.js +556 -0
- package/dist/plugins/selection/index.d.ts +6 -0
- package/dist/plugins/selection/index.js +7 -0
- package/dist/plugins/selection/plugin.d.ts +16 -0
- package/dist/plugins/selection/plugin.js +601 -0
- package/dist/{features → plugins}/selection/state.d.ts +8 -0
- package/dist/plugins/selection/state.js +332 -0
- package/dist/plugins/snapshots/index.d.ts +5 -0
- package/dist/plugins/snapshots/index.js +5 -0
- package/dist/plugins/snapshots/plugin.d.ts +17 -0
- package/dist/plugins/snapshots/plugin.js +301 -0
- package/dist/plugins/sortable/index.d.ts +6 -0
- package/dist/plugins/sortable/index.js +6 -0
- package/dist/plugins/sortable/plugin.d.ts +34 -0
- package/dist/plugins/sortable/plugin.js +753 -0
- package/dist/plugins/table/header.js +501 -0
- package/dist/{features → plugins}/table/index.d.ts +1 -1
- package/dist/plugins/table/index.js +12 -0
- package/dist/plugins/table/layout.js +211 -0
- package/dist/plugins/table/plugin.d.ts +20 -0
- package/dist/plugins/table/plugin.js +391 -0
- package/dist/plugins/table/renderer.js +625 -0
- package/dist/plugins/table/types.js +12 -0
- package/dist/plugins/transition/index.d.ts +5 -0
- package/dist/plugins/transition/index.js +5 -0
- package/dist/plugins/transition/plugin.d.ts +22 -0
- package/dist/plugins/transition/plugin.js +405 -0
- package/dist/rendering/aria.js +23 -0
- package/dist/rendering/index.js +18 -0
- package/dist/rendering/measured.js +98 -0
- package/dist/rendering/renderer.js +586 -0
- package/dist/rendering/scale.js +267 -0
- package/dist/rendering/scroll.js +71 -0
- package/dist/rendering/sizes.js +193 -0
- package/dist/rendering/sort.js +65 -0
- package/dist/rendering/viewport.js +268 -0
- package/dist/size.json +1 -1
- package/dist/types.js +5 -0
- package/dist/utils/padding.d.ts +2 -4
- package/dist/utils/padding.js +49 -0
- package/dist/utils/stats.js +124 -0
- package/dist/vlist-grid.css +1 -1
- package/dist/vlist-masonry.css +1 -1
- package/dist/vlist-table.css +1 -1
- package/dist/vlist.css +1 -1
- package/package.json +9 -4
- package/dist/builder/a11y.d.ts +0 -16
- package/dist/builder/api.d.ts +0 -21
- package/dist/builder/context.d.ts +0 -36
- package/dist/builder/core.d.ts +0 -16
- package/dist/builder/data.d.ts +0 -71
- package/dist/builder/dom.d.ts +0 -15
- package/dist/builder/index.d.ts +0 -25
- package/dist/builder/materialize.d.ts +0 -166
- package/dist/builder/pool.d.ts +0 -10
- package/dist/builder/range.d.ts +0 -10
- package/dist/builder/scroll.d.ts +0 -24
- package/dist/builder/types.d.ts +0 -512
- package/dist/features/async/feature.d.ts +0 -72
- package/dist/features/autosize/feature.d.ts +0 -34
- package/dist/features/autosize/index.d.ts +0 -2
- package/dist/features/grid/feature.d.ts +0 -48
- package/dist/features/grid/index.d.ts +0 -9
- package/dist/features/groups/feature.d.ts +0 -75
- package/dist/features/masonry/feature.d.ts +0 -45
- package/dist/features/masonry/index.d.ts +0 -9
- package/dist/features/page/feature.d.ts +0 -109
- package/dist/features/page/index.d.ts +0 -9
- package/dist/features/scale/feature.d.ts +0 -42
- package/dist/features/scale/index.d.ts +0 -10
- package/dist/features/scrollbar/feature.d.ts +0 -81
- package/dist/features/scrollbar/index.d.ts +0 -8
- package/dist/features/selection/feature.d.ts +0 -91
- package/dist/features/selection/index.d.ts +0 -7
- package/dist/features/snapshots/feature.d.ts +0 -79
- package/dist/features/snapshots/index.d.ts +0 -9
- package/dist/features/sortable/feature.d.ts +0 -101
- package/dist/features/sortable/index.d.ts +0 -6
- package/dist/features/table/feature.d.ts +0 -67
- package/dist/features/transition/feature.d.ts +0 -30
- package/dist/features/transition/index.d.ts +0 -9
- /package/dist/{features → plugins}/async/placeholder.d.ts +0 -0
- /package/dist/{features → plugins}/async/sparse.d.ts +0 -0
- /package/dist/{features → plugins}/grid/layout.d.ts +0 -0
- /package/dist/{features → plugins}/grid/renderer.d.ts +0 -0
- /package/dist/{features → plugins}/grid/types.d.ts +0 -0
- /package/dist/{features → plugins}/groups/async-bridge.d.ts +0 -0
- /package/dist/{features → plugins}/groups/layout.d.ts +0 -0
- /package/dist/{features → plugins}/groups/sticky.d.ts +0 -0
- /package/dist/{features → plugins}/groups/types.d.ts +0 -0
- /package/dist/{features → plugins}/masonry/layout.d.ts +0 -0
- /package/dist/{features → plugins}/masonry/renderer.d.ts +0 -0
- /package/dist/{features → plugins}/masonry/types.d.ts +0 -0
- /package/dist/{features → plugins}/scrollbar/controller.d.ts +0 -0
- /package/dist/{features → plugins}/scrollbar/scrollbar.d.ts +0 -0
- /package/dist/{features → plugins}/table/header.d.ts +0 -0
- /package/dist/{features → plugins}/table/layout.d.ts +0 -0
- /package/dist/{features → plugins}/table/renderer.d.ts +0 -0
- /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
|
+
};
|