vlist 2.0.0 → 2.0.1
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/dist/index.js +1 -28
- package/dist/internals.js +1 -60
- package/package.json +1 -1
- package/dist/constants.js +0 -83
- package/dist/core/create.js +0 -740
- package/dist/core/dom.js +0 -47
- package/dist/core/hooks.js +0 -67
- package/dist/core/index.js +0 -13
- package/dist/core/pipeline.js +0 -307
- package/dist/core/pool.js +0 -42
- package/dist/core/scroll.js +0 -137
- package/dist/core/sizes.js +0 -6
- package/dist/core/state.js +0 -56
- package/dist/core/types.js +0 -7
- package/dist/core/velocity.js +0 -33
- package/dist/events/emitter.js +0 -60
- package/dist/events/index.js +0 -6
- package/dist/plugins/a11y/index.js +0 -1
- package/dist/plugins/a11y/plugin.js +0 -259
- package/dist/plugins/async/index.js +0 -12
- package/dist/plugins/async/manager.js +0 -568
- package/dist/plugins/async/placeholder.js +0 -154
- package/dist/plugins/async/plugin.js +0 -311
- package/dist/plugins/async/sparse.js +0 -540
- package/dist/plugins/autosize/index.js +0 -4
- package/dist/plugins/autosize/plugin.js +0 -185
- package/dist/plugins/grid/index.js +0 -5
- package/dist/plugins/grid/layout.js +0 -275
- package/dist/plugins/grid/plugin.js +0 -347
- package/dist/plugins/grid/renderer.js +0 -525
- package/dist/plugins/grid/types.js +0 -11
- package/dist/plugins/groups/async-bridge.js +0 -246
- package/dist/plugins/groups/index.js +0 -13
- package/dist/plugins/groups/layout.js +0 -294
- package/dist/plugins/groups/plugin.js +0 -571
- package/dist/plugins/groups/sticky.js +0 -255
- package/dist/plugins/groups/types.js +0 -12
- package/dist/plugins/masonry/index.js +0 -6
- package/dist/plugins/masonry/layout.js +0 -261
- package/dist/plugins/masonry/plugin.js +0 -381
- package/dist/plugins/masonry/renderer.js +0 -354
- package/dist/plugins/masonry/types.js +0 -9
- package/dist/plugins/page/index.js +0 -5
- package/dist/plugins/page/plugin.js +0 -166
- package/dist/plugins/scale/index.js +0 -4
- package/dist/plugins/scale/plugin.js +0 -507
- package/dist/plugins/scrollbar/controller.js +0 -574
- package/dist/plugins/scrollbar/index.js +0 -6
- package/dist/plugins/scrollbar/plugin.js +0 -93
- package/dist/plugins/scrollbar/scrollbar.js +0 -556
- package/dist/plugins/selection/index.js +0 -7
- package/dist/plugins/selection/plugin.js +0 -601
- package/dist/plugins/selection/state.js +0 -332
- package/dist/plugins/snapshots/index.js +0 -5
- package/dist/plugins/snapshots/plugin.js +0 -301
- package/dist/plugins/sortable/index.js +0 -6
- package/dist/plugins/sortable/plugin.js +0 -753
- package/dist/plugins/table/header.js +0 -501
- package/dist/plugins/table/index.js +0 -12
- package/dist/plugins/table/layout.js +0 -211
- package/dist/plugins/table/plugin.js +0 -391
- package/dist/plugins/table/renderer.js +0 -625
- package/dist/plugins/table/types.js +0 -12
- package/dist/plugins/transition/index.js +0 -5
- package/dist/plugins/transition/plugin.js +0 -405
- package/dist/rendering/aria.js +0 -23
- package/dist/rendering/index.js +0 -18
- package/dist/rendering/measured.js +0 -98
- package/dist/rendering/renderer.js +0 -586
- package/dist/rendering/scale.js +0 -267
- package/dist/rendering/scroll.js +0 -71
- package/dist/rendering/sizes.js +0 -193
- package/dist/rendering/sort.js +0 -65
- package/dist/rendering/viewport.js +0 -268
- package/dist/types.js +0 -5
- package/dist/utils/padding.js +0 -49
- package/dist/utils/stats.js +0 -124
|
@@ -1,625 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* vlist/table - Renderer
|
|
3
|
-
* Renders virtualized rows with cell-based layout within the virtual scroll container.
|
|
4
|
-
*
|
|
5
|
-
* Each row is an absolutely positioned element (like the list renderer),
|
|
6
|
-
* containing child elements for each cell. Cells are sized and positioned
|
|
7
|
-
* according to the resolved column layout from TableLayout.
|
|
8
|
-
*
|
|
9
|
-
* Key design decisions:
|
|
10
|
-
* - Rows are the unit of virtualization (same as list mode — 1:1 with items)
|
|
11
|
-
* - Each row contains N cell elements (one per column)
|
|
12
|
-
* - Row positioning is translateY-based (from the size cache)
|
|
13
|
-
* - Cell positioning uses absolute left + width from the column layout
|
|
14
|
-
* - Element pooling avoids createElement cost (row-level pooling)
|
|
15
|
-
* - Change tracking skips template re-evaluation when data + state unchanged
|
|
16
|
-
* - Release grace period prevents boundary thrashing (hover blink, transition replay)
|
|
17
|
-
* - DocumentFragment batched insertion for new elements
|
|
18
|
-
*
|
|
19
|
-
* Performance:
|
|
20
|
-
* - O(1) Set-based visibility diffing (not O(n) .some())
|
|
21
|
-
* - Template re-evaluation skipped when item id + selection/focus state unchanged
|
|
22
|
-
* - Position update skipped when coordinates unchanged (position tracking)
|
|
23
|
-
* - Cell widths are only updated when column layout changes (not every scroll frame)
|
|
24
|
-
* - Released elements removed from DOM immediately, pooled for reuse
|
|
25
|
-
*
|
|
26
|
-
* DOM structure per row:
|
|
27
|
-
* .vlist-item.vlist-table-row (position: absolute, translateY)
|
|
28
|
-
* ├── .vlist-table-cell [col 0] (position: absolute, left, width)
|
|
29
|
-
* ├── .vlist-table-cell [col 1]
|
|
30
|
-
* └── ...
|
|
31
|
-
*/
|
|
32
|
-
import { calculateCompressedItemPosition } from "../../rendering/scale";
|
|
33
|
-
import { claimPlaceholderSelection } from "../../plugins/selection/state";
|
|
34
|
-
// =============================================================================
|
|
35
|
-
// Element Pool (row-level)
|
|
36
|
-
// =============================================================================
|
|
37
|
-
const createElementPool = () => {
|
|
38
|
-
const pool = [];
|
|
39
|
-
return {
|
|
40
|
-
acquire: () => pool.pop() || document.createElement("div"),
|
|
41
|
-
release: (el) => {
|
|
42
|
-
el.parentNode?.removeChild(el);
|
|
43
|
-
if (pool.length < 200) {
|
|
44
|
-
el.className = "";
|
|
45
|
-
el.removeAttribute("data-id");
|
|
46
|
-
el.removeAttribute("data-index");
|
|
47
|
-
el.removeAttribute("aria-selected");
|
|
48
|
-
el.removeAttribute("aria-rowindex");
|
|
49
|
-
el.removeAttribute("role");
|
|
50
|
-
el.style.cssText = "";
|
|
51
|
-
el.textContent = "";
|
|
52
|
-
pool.push(el);
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
clear: () => { pool.length = 0; },
|
|
56
|
-
};
|
|
57
|
-
};
|
|
58
|
-
// =============================================================================
|
|
59
|
-
// Factory
|
|
60
|
-
// =============================================================================
|
|
61
|
-
/**
|
|
62
|
-
* Create a TableRenderer instance.
|
|
63
|
-
*
|
|
64
|
-
* @param container - The .vlist-items container element
|
|
65
|
-
* @param sizeCache - Size cache for row offset lookups
|
|
66
|
-
* @param layout - Table layout for column widths/offsets
|
|
67
|
-
* @param columns - Column definitions (for cell templates)
|
|
68
|
-
* @param classPrefix - CSS class prefix
|
|
69
|
-
* @param ariaIdPrefix - Prefix for ARIA IDs
|
|
70
|
-
* @param columnBorders - Whether to show vertical borders between cells
|
|
71
|
-
* @param rowBorders - Whether to show horizontal borders between rows
|
|
72
|
-
* @param getTotalItems - Function to get total item count (for ARIA)
|
|
73
|
-
* @returns TableRendererInstance
|
|
74
|
-
*/
|
|
75
|
-
export const createTableRenderer = (container, getSizeCache, layout, _columns, classPrefix, ariaIdPrefix, getTotalItems, striped, stripeIndexFn) => {
|
|
76
|
-
const pool = createElementPool();
|
|
77
|
-
const rendered = new Map();
|
|
78
|
-
let lastAriaSetSize = -1;
|
|
79
|
-
let currentLayout = layout;
|
|
80
|
-
// Cached stripe index function — resolved once per render frame, not per row
|
|
81
|
-
let cachedStripeFn = null;
|
|
82
|
-
// ── Group header support ──
|
|
83
|
-
// When groups are active, the renderer needs to handle group header
|
|
84
|
-
// pseudo-items differently: full-width row, no cells, custom template.
|
|
85
|
-
let groupHeaderFn = null;
|
|
86
|
-
let groupHeaderTemplate = null;
|
|
87
|
-
const setGroupHeaderFn = (fn, template) => {
|
|
88
|
-
groupHeaderFn = fn;
|
|
89
|
-
groupHeaderTemplate = template;
|
|
90
|
-
};
|
|
91
|
-
// =========================================================================
|
|
92
|
-
// Helpers
|
|
93
|
-
// =========================================================================
|
|
94
|
-
/** Toggle aria-selected attribute */
|
|
95
|
-
const setAriaSelected = (el, selected) => {
|
|
96
|
-
selected ? el.setAttribute("aria-selected", "true") : el.removeAttribute("aria-selected");
|
|
97
|
-
};
|
|
98
|
-
/** Set common row data attributes */
|
|
99
|
-
const setRowAttrs = (el, role, id, index) => {
|
|
100
|
-
el.setAttribute("role", role);
|
|
101
|
-
el.setAttribute("data-id", String(id));
|
|
102
|
-
el.setAttribute("data-index", String(index));
|
|
103
|
-
};
|
|
104
|
-
/** Check if an item id represents a placeholder (async loading) */
|
|
105
|
-
const isPH = (id) => String(id).startsWith("__placeholder_");
|
|
106
|
-
// =========================================================================
|
|
107
|
-
// CSS Classes (precomputed)
|
|
108
|
-
// =========================================================================
|
|
109
|
-
const rowClass = `${classPrefix}-item ${classPrefix}-table-row`;
|
|
110
|
-
const selectedClass = `${classPrefix}-item--selected`;
|
|
111
|
-
const focusedClass = `${classPrefix}-item--focused`;
|
|
112
|
-
const cellClass = `${classPrefix}-table-cell`;
|
|
113
|
-
const cellCenterClass = `${classPrefix}-table-cell--center`;
|
|
114
|
-
const cellRightClass = `${classPrefix}-table-cell--right`;
|
|
115
|
-
const oddClass = `${classPrefix}-item--odd`;
|
|
116
|
-
const placeholderClass = `${classPrefix}-item--placeholder`;
|
|
117
|
-
const replacedClass = `${classPrefix}-item--replaced`;
|
|
118
|
-
const groupHeaderRowClass = `${classPrefix}-item ${classPrefix}-table-row ${classPrefix}-table-group-header`;
|
|
119
|
-
const groupHeaderContentClass = `${classPrefix}-table-group-header-content`;
|
|
120
|
-
// =========================================================================
|
|
121
|
-
// Cell Template Application
|
|
122
|
-
// =========================================================================
|
|
123
|
-
/**
|
|
124
|
-
* Render a cell's content using the column's cell template or default accessor.
|
|
125
|
-
*/
|
|
126
|
-
const applyCellTemplate = (cell, item, col, rowIndex, isPlaceholder = false) => {
|
|
127
|
-
if (col.def.cell) {
|
|
128
|
-
const result = col.def.cell(item, col.def, rowIndex);
|
|
129
|
-
if (typeof result === "string") {
|
|
130
|
-
cell.innerHTML = result;
|
|
131
|
-
}
|
|
132
|
-
else {
|
|
133
|
-
cell.replaceChildren(result);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
else {
|
|
137
|
-
// Default: show item[column.key] as text
|
|
138
|
-
const value = item[col.def.key];
|
|
139
|
-
const text = value != null ? String(value) : "";
|
|
140
|
-
if (isPlaceholder && text) {
|
|
141
|
-
// Wrap in <span> so CSS skeleton styling can target the element
|
|
142
|
-
// (bare text nodes can't be styled with background/border-radius)
|
|
143
|
-
cell.innerHTML = `<span>${text}</span>`;
|
|
144
|
-
}
|
|
145
|
-
else {
|
|
146
|
-
cell.textContent = text;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
};
|
|
150
|
-
/**
|
|
151
|
-
* Apply CSS classes to a row element based on state.
|
|
152
|
-
*/
|
|
153
|
-
const applyRowClasses = (element, index, isSelected, isFocused, isPlaceholder = false) => {
|
|
154
|
-
let className = rowClass;
|
|
155
|
-
if (striped) {
|
|
156
|
-
if (cachedStripeFn) {
|
|
157
|
-
const si = cachedStripeFn(index);
|
|
158
|
-
if (si >= 0 && (si & 1) === 1)
|
|
159
|
-
className += ` ${oddClass}`;
|
|
160
|
-
}
|
|
161
|
-
else if ((index & 1) === 1) {
|
|
162
|
-
className += ` ${oddClass}`;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
if (isPlaceholder)
|
|
166
|
-
className += ` ${placeholderClass}`;
|
|
167
|
-
if (isSelected)
|
|
168
|
-
className += ` ${selectedClass}`;
|
|
169
|
-
if (isFocused)
|
|
170
|
-
className += ` ${focusedClass}`;
|
|
171
|
-
element.className = className;
|
|
172
|
-
};
|
|
173
|
-
// =========================================================================
|
|
174
|
-
// Row Positioning (compression-aware)
|
|
175
|
-
// =========================================================================
|
|
176
|
-
/**
|
|
177
|
-
* Calculate the Y offset for a row.
|
|
178
|
-
* Uses compression-aware positioning for large datasets (withScale).
|
|
179
|
-
*/
|
|
180
|
-
const calculateRowOffset = (index, sc, compressionCtx) => {
|
|
181
|
-
if (compressionCtx?.compression?.isCompressed) {
|
|
182
|
-
return Math.round(calculateCompressedItemPosition(index, compressionCtx.scrollPosition, sc, compressionCtx.totalItems, compressionCtx.containerSize, compressionCtx.compression, compressionCtx.rangeStart));
|
|
183
|
-
}
|
|
184
|
-
return sc.getOffset(index);
|
|
185
|
-
};
|
|
186
|
-
// =========================================================================
|
|
187
|
-
// Cell Sizing & Positioning
|
|
188
|
-
// =========================================================================
|
|
189
|
-
/**
|
|
190
|
-
* Apply alignment style to a cell based on column definition.
|
|
191
|
-
*/
|
|
192
|
-
const applyCellAlign = (cell, col) => {
|
|
193
|
-
const align = col.def.align;
|
|
194
|
-
if (align === "center") {
|
|
195
|
-
cell.classList.add(cellCenterClass);
|
|
196
|
-
cell.classList.remove(cellRightClass);
|
|
197
|
-
}
|
|
198
|
-
else if (align === "right") {
|
|
199
|
-
cell.classList.add(cellRightClass);
|
|
200
|
-
cell.classList.remove(cellCenterClass);
|
|
201
|
-
}
|
|
202
|
-
else {
|
|
203
|
-
cell.classList.remove(cellCenterClass, cellRightClass);
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
// =========================================================================
|
|
207
|
-
// Row Building
|
|
208
|
-
// =========================================================================
|
|
209
|
-
/**
|
|
210
|
-
* Create or reuse cells for a row element, matching the current column count.
|
|
211
|
-
*/
|
|
212
|
-
const ensureCells = (rowElement, existingCells) => {
|
|
213
|
-
const cols = currentLayout.columns;
|
|
214
|
-
const targetCount = cols.length;
|
|
215
|
-
// Reuse existing cells where possible
|
|
216
|
-
if (existingCells.length === targetCount) {
|
|
217
|
-
return existingCells;
|
|
218
|
-
}
|
|
219
|
-
const cells = [];
|
|
220
|
-
for (let i = 0; i < targetCount; i++) {
|
|
221
|
-
let cell;
|
|
222
|
-
if (i < existingCells.length) {
|
|
223
|
-
cell = existingCells[i];
|
|
224
|
-
}
|
|
225
|
-
else {
|
|
226
|
-
cell = document.createElement("div");
|
|
227
|
-
cell.className = cellClass;
|
|
228
|
-
rowElement.appendChild(cell);
|
|
229
|
-
}
|
|
230
|
-
cells.push(cell);
|
|
231
|
-
}
|
|
232
|
-
// Remove excess cells
|
|
233
|
-
for (let i = targetCount; i < existingCells.length; i++) {
|
|
234
|
-
existingCells[i].remove();
|
|
235
|
-
}
|
|
236
|
-
return cells;
|
|
237
|
-
};
|
|
238
|
-
/**
|
|
239
|
-
* Render a group header row: full-width, no cells, custom template.
|
|
240
|
-
*/
|
|
241
|
-
const renderGroupHeaderRow = (item, index, sc, compressionCtx) => {
|
|
242
|
-
const element = pool.acquire();
|
|
243
|
-
const headerItem = item;
|
|
244
|
-
const height = sc.getSize(index);
|
|
245
|
-
const offset = calculateRowOffset(index, sc, compressionCtx);
|
|
246
|
-
// Set all styles in one operation (element was reset by pool.release)
|
|
247
|
-
element.style.cssText = `width:${currentLayout.totalWidth}px;height:${height}px;transform:translateY(${offset}px)`;
|
|
248
|
-
element.className = groupHeaderRowClass;
|
|
249
|
-
// ARIA — group header is presentational, not a data row
|
|
250
|
-
setRowAttrs(element, "presentation", item.id, index);
|
|
251
|
-
element.removeAttribute("aria-selected");
|
|
252
|
-
element.removeAttribute("aria-rowindex");
|
|
253
|
-
// Clear any leftover cells from pooled element reuse
|
|
254
|
-
element.replaceChildren();
|
|
255
|
-
// Create the single content container
|
|
256
|
-
const content = document.createElement("div");
|
|
257
|
-
content.className = groupHeaderContentClass;
|
|
258
|
-
if (groupHeaderTemplate) {
|
|
259
|
-
const result = groupHeaderTemplate(headerItem.groupKey, headerItem.groupIndex);
|
|
260
|
-
if (typeof result === "string") {
|
|
261
|
-
content.innerHTML = result;
|
|
262
|
-
}
|
|
263
|
-
else {
|
|
264
|
-
content.appendChild(result);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
element.appendChild(content);
|
|
268
|
-
return {
|
|
269
|
-
element,
|
|
270
|
-
cells: [], // No cells for group headers
|
|
271
|
-
index,
|
|
272
|
-
isGroupHeader: true,
|
|
273
|
-
lastItemId: item.id,
|
|
274
|
-
lastSelected: false,
|
|
275
|
-
lastFocused: false,
|
|
276
|
-
lastOffset: offset,
|
|
277
|
-
lastHeight: height,
|
|
278
|
-
};
|
|
279
|
-
};
|
|
280
|
-
/**
|
|
281
|
-
* Render a full row: create element, set cells, apply state.
|
|
282
|
-
* Returns a TrackedRow for the rendered map.
|
|
283
|
-
*/
|
|
284
|
-
const renderRow = (item, index, isSelected, isFocused, sc, compressionCtx) => {
|
|
285
|
-
const element = pool.acquire();
|
|
286
|
-
const height = sc.getSize(index);
|
|
287
|
-
const offset = calculateRowOffset(index, sc, compressionCtx);
|
|
288
|
-
const isPlaceholder = isPH(item.id);
|
|
289
|
-
// Set all row styles in one operation (element was reset by pool.release)
|
|
290
|
-
element.style.cssText = `width:${currentLayout.totalWidth}px;height:${height}px;transform:translateY(${offset}px)`;
|
|
291
|
-
applyRowClasses(element, index, isSelected, isFocused, isPlaceholder);
|
|
292
|
-
// ARIA attributes
|
|
293
|
-
setRowAttrs(element, "row", item.id, index);
|
|
294
|
-
element.id = `${ariaIdPrefix}-item-${index}`;
|
|
295
|
-
element.setAttribute("aria-rowindex", String(index + 2)); // +2: header is row 1
|
|
296
|
-
setAriaSelected(element, isSelected);
|
|
297
|
-
// Create cells
|
|
298
|
-
const cells = ensureCells(element, []);
|
|
299
|
-
const cols = currentLayout.columns;
|
|
300
|
-
for (let i = 0; i < cells.length && i < cols.length; i++) {
|
|
301
|
-
const cell = cells[i];
|
|
302
|
-
const col = cols[i];
|
|
303
|
-
cell.style.cssText = `left:${col.offset}px;width:${col.width}px`;
|
|
304
|
-
cell.setAttribute("role", "gridcell");
|
|
305
|
-
cell.setAttribute("aria-colindex", String(i + 1));
|
|
306
|
-
applyCellAlign(cell, col);
|
|
307
|
-
applyCellTemplate(cell, item, col, index, isPlaceholder);
|
|
308
|
-
}
|
|
309
|
-
return {
|
|
310
|
-
element,
|
|
311
|
-
cells,
|
|
312
|
-
index,
|
|
313
|
-
isGroupHeader: false,
|
|
314
|
-
lastItemId: item.id,
|
|
315
|
-
lastSelected: isSelected,
|
|
316
|
-
lastFocused: isFocused,
|
|
317
|
-
lastOffset: offset,
|
|
318
|
-
lastHeight: height,
|
|
319
|
-
};
|
|
320
|
-
};
|
|
321
|
-
// =========================================================================
|
|
322
|
-
// Main Render
|
|
323
|
-
// =========================================================================
|
|
324
|
-
/**
|
|
325
|
-
* Render rows for a range of items.
|
|
326
|
-
*
|
|
327
|
-
* Called by the feature's tableRenderIfNeeded() on each scroll frame.
|
|
328
|
-
*
|
|
329
|
-
* Performs incremental updates:
|
|
330
|
-
* - New rows: created and positioned
|
|
331
|
-
* - Existing rows with same item: state/position update only
|
|
332
|
-
* - Existing rows with different item: full template re-evaluation
|
|
333
|
-
* - Rows outside range: released after grace period
|
|
334
|
-
*/
|
|
335
|
-
const render = (items, range, selectedIds, focusedIndex, compressionCtx) => {
|
|
336
|
-
// Release items outside the new range immediately.
|
|
337
|
-
// Tables don't need a grace period — row hover is a simple background
|
|
338
|
-
// change with no CSS transitions to preserve, and each graced row
|
|
339
|
-
// carries N cell elements so the DOM cost is high.
|
|
340
|
-
for (const [index, tracked] of rendered) {
|
|
341
|
-
if (index < range.start || index > range.end) {
|
|
342
|
-
pool.release(tracked.element);
|
|
343
|
-
rendered.delete(index);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
// Check if aria-setsize changed
|
|
347
|
-
let setSizeChanged = false;
|
|
348
|
-
const total = getTotalItems();
|
|
349
|
-
if (total !== lastAriaSetSize) {
|
|
350
|
-
lastAriaSetSize = total;
|
|
351
|
-
setSizeChanged = true;
|
|
352
|
-
}
|
|
353
|
-
// DocumentFragment for batched DOM insertion of new elements
|
|
354
|
-
let fragment = null;
|
|
355
|
-
// Resolve size cache and stripe function once per frame (not per item)
|
|
356
|
-
const sc = getSizeCache();
|
|
357
|
-
cachedStripeFn = (typeof striped === "string" && stripeIndexFn) ? stripeIndexFn() : null;
|
|
358
|
-
// Render each item in range
|
|
359
|
-
for (let i = range.start; i <= range.end; i++) {
|
|
360
|
-
// Items array is 0-indexed relative to range.start
|
|
361
|
-
const itemIndex = i - range.start;
|
|
362
|
-
const item = items[itemIndex];
|
|
363
|
-
if (!item)
|
|
364
|
-
continue;
|
|
365
|
-
let isSelected = selectedIds.has(item.id);
|
|
366
|
-
const isFocused = i === focusedIndex;
|
|
367
|
-
// ── Check if this item is a group header ──
|
|
368
|
-
const isHeader = groupHeaderFn ? groupHeaderFn(item) : false;
|
|
369
|
-
const existing = rendered.get(i);
|
|
370
|
-
if (existing) {
|
|
371
|
-
// ── Check if row type changed (data row ↔ group header) ──
|
|
372
|
-
if (existing.isGroupHeader !== isHeader) {
|
|
373
|
-
// Type changed — release old element, create new one
|
|
374
|
-
pool.release(existing.element);
|
|
375
|
-
rendered.delete(i);
|
|
376
|
-
const tracked = isHeader
|
|
377
|
-
? renderGroupHeaderRow(item, i, sc, compressionCtx)
|
|
378
|
-
: renderRow(item, i, isSelected, isFocused, sc, compressionCtx);
|
|
379
|
-
rendered.set(i, tracked);
|
|
380
|
-
if (!fragment)
|
|
381
|
-
fragment = document.createDocumentFragment();
|
|
382
|
-
fragment.appendChild(tracked.element);
|
|
383
|
-
continue;
|
|
384
|
-
}
|
|
385
|
-
if (isHeader) {
|
|
386
|
-
// ── Group header fast path ──
|
|
387
|
-
const idChanged = existing.lastItemId !== item.id;
|
|
388
|
-
if (idChanged) {
|
|
389
|
-
// Different group header — re-render content
|
|
390
|
-
const headerItem = item;
|
|
391
|
-
const content = existing.element.firstElementChild;
|
|
392
|
-
if (content && groupHeaderTemplate) {
|
|
393
|
-
const result = groupHeaderTemplate(headerItem.groupKey, headerItem.groupIndex);
|
|
394
|
-
if (typeof result === "string") {
|
|
395
|
-
content.innerHTML = result;
|
|
396
|
-
}
|
|
397
|
-
else {
|
|
398
|
-
content.replaceChildren(result);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
existing.element.setAttribute("data-id", String(item.id));
|
|
402
|
-
existing.lastItemId = item.id;
|
|
403
|
-
}
|
|
404
|
-
// Position update (compression-aware)
|
|
405
|
-
const offset = calculateRowOffset(i, sc, compressionCtx);
|
|
406
|
-
if (existing.lastOffset !== offset) {
|
|
407
|
-
existing.lastOffset = offset;
|
|
408
|
-
existing.element.style.transform = `translateY(${offset}px)`;
|
|
409
|
-
}
|
|
410
|
-
// Height update (only when changed)
|
|
411
|
-
const height = sc.getSize(i);
|
|
412
|
-
if (existing.lastHeight !== height) {
|
|
413
|
-
existing.lastHeight = height;
|
|
414
|
-
existing.element.style.height = `${height}px`;
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
else {
|
|
418
|
-
// ── Data row path (existing logic) ──
|
|
419
|
-
const idChanged = existing.lastItemId !== item.id;
|
|
420
|
-
const selectedChanged = existing.lastSelected !== isSelected;
|
|
421
|
-
const focusedChanged = existing.lastFocused !== isFocused;
|
|
422
|
-
if (idChanged) {
|
|
423
|
-
// Different item at this index — full re-render of cells
|
|
424
|
-
const wasPlaceholder = existing.lastItemId != null && isPH(existing.lastItemId);
|
|
425
|
-
const isPlaceholder = isPH(item.id);
|
|
426
|
-
// Transfer selection from placeholder → real item ID (async loading)
|
|
427
|
-
if (!isPlaceholder && claimPlaceholderSelection(selectedIds, i, item.id)) {
|
|
428
|
-
isSelected = true;
|
|
429
|
-
}
|
|
430
|
-
const cols = currentLayout.columns;
|
|
431
|
-
for (let c = 0; c < existing.cells.length && c < cols.length; c++) {
|
|
432
|
-
applyCellTemplate(existing.cells[c], item, cols[c], i, isPlaceholder);
|
|
433
|
-
}
|
|
434
|
-
applyRowClasses(existing.element, i, isSelected, isFocused, isPlaceholder);
|
|
435
|
-
existing.element.setAttribute("data-id", String(item.id));
|
|
436
|
-
setAriaSelected(existing.element, isSelected);
|
|
437
|
-
// Fade-in animation when placeholder is replaced with real data
|
|
438
|
-
if (wasPlaceholder && !isPlaceholder) {
|
|
439
|
-
existing.element.classList.add(replacedClass);
|
|
440
|
-
setTimeout(() => {
|
|
441
|
-
existing.element.classList.remove(replacedClass);
|
|
442
|
-
}, 300);
|
|
443
|
-
}
|
|
444
|
-
existing.lastItemId = item.id;
|
|
445
|
-
existing.lastSelected = isSelected;
|
|
446
|
-
existing.lastFocused = isFocused;
|
|
447
|
-
}
|
|
448
|
-
else if (selectedChanged || focusedChanged) {
|
|
449
|
-
// Same item — only update classes/aria if state changed
|
|
450
|
-
applyRowClasses(existing.element, i, isSelected, isFocused, isPH(item.id));
|
|
451
|
-
setAriaSelected(existing.element, isSelected);
|
|
452
|
-
existing.lastSelected = isSelected;
|
|
453
|
-
existing.lastFocused = isFocused;
|
|
454
|
-
}
|
|
455
|
-
// Position update only when offset changed (compression-aware)
|
|
456
|
-
const offset = calculateRowOffset(i, sc, compressionCtx);
|
|
457
|
-
if (existing.lastOffset !== offset) {
|
|
458
|
-
existing.lastOffset = offset;
|
|
459
|
-
existing.element.style.transform = `translateY(${offset}px)`;
|
|
460
|
-
}
|
|
461
|
-
// Update row height only when changed
|
|
462
|
-
const height = sc.getSize(i);
|
|
463
|
-
if (existing.lastHeight !== height) {
|
|
464
|
-
existing.lastHeight = height;
|
|
465
|
-
existing.element.style.height = `${height}px`;
|
|
466
|
-
}
|
|
467
|
-
// Update ARIA set size if changed
|
|
468
|
-
if (setSizeChanged) {
|
|
469
|
-
existing.element.setAttribute("aria-rowindex", String(i + 2));
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
else {
|
|
474
|
-
// Transfer selection from placeholder → real item ID (async loading)
|
|
475
|
-
if (claimPlaceholderSelection(selectedIds, i, item.id)) {
|
|
476
|
-
isSelected = true;
|
|
477
|
-
}
|
|
478
|
-
// New row — create and collect in fragment for batched insertion
|
|
479
|
-
const tracked = isHeader
|
|
480
|
-
? renderGroupHeaderRow(item, i, sc, compressionCtx)
|
|
481
|
-
: renderRow(item, i, isSelected, isFocused, sc, compressionCtx);
|
|
482
|
-
rendered.set(i, tracked);
|
|
483
|
-
if (!fragment)
|
|
484
|
-
fragment = document.createDocumentFragment();
|
|
485
|
-
fragment.appendChild(tracked.element);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
// Single DOM insertion for all new elements — minimizes reflows
|
|
489
|
-
if (fragment) {
|
|
490
|
-
container.appendChild(fragment);
|
|
491
|
-
}
|
|
492
|
-
};
|
|
493
|
-
// =========================================================================
|
|
494
|
-
// Position Update (compressed scrolling)
|
|
495
|
-
// =========================================================================
|
|
496
|
-
/**
|
|
497
|
-
* Update positions of all rendered rows (for compressed scrolling).
|
|
498
|
-
* Called when the scroll position changed but the visible range didn't —
|
|
499
|
-
* in compressed mode, items are positioned relative to the viewport so
|
|
500
|
-
* they must be repositioned on every scroll frame.
|
|
501
|
-
*/
|
|
502
|
-
const updatePositions = (compressionCtx) => {
|
|
503
|
-
const sc = getSizeCache();
|
|
504
|
-
for (const [index, tracked] of rendered) {
|
|
505
|
-
const offset = calculateRowOffset(index, sc, compressionCtx);
|
|
506
|
-
if (tracked.lastOffset !== offset) {
|
|
507
|
-
tracked.lastOffset = offset;
|
|
508
|
-
tracked.element.style.transform = `translateY(${offset}px)`;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
};
|
|
512
|
-
// =========================================================================
|
|
513
|
-
// Single Item Update
|
|
514
|
-
// =========================================================================
|
|
515
|
-
/**
|
|
516
|
-
* Update a single row (explicit API call).
|
|
517
|
-
* Always re-applies cell templates because the caller signals that the item
|
|
518
|
-
* data has changed — even when the id stays the same (e.g. cover update).
|
|
519
|
-
* Updates TrackedItem fields so subsequent scroll frames skip redundant work.
|
|
520
|
-
*/
|
|
521
|
-
const updateItem = (index, item, isSelected, isFocused) => {
|
|
522
|
-
const existing = rendered.get(index);
|
|
523
|
-
if (!existing)
|
|
524
|
-
return;
|
|
525
|
-
// Group headers are not selectable — skip state updates
|
|
526
|
-
if (existing.isGroupHeader)
|
|
527
|
-
return;
|
|
528
|
-
const cols = currentLayout.columns;
|
|
529
|
-
for (let c = 0; c < existing.cells.length && c < cols.length; c++) {
|
|
530
|
-
applyCellTemplate(existing.cells[c], item, cols[c], index);
|
|
531
|
-
}
|
|
532
|
-
existing.element.setAttribute("data-id", String(item.id));
|
|
533
|
-
existing.lastItemId = item.id;
|
|
534
|
-
applyRowClasses(existing.element, index, isSelected, isFocused);
|
|
535
|
-
setAriaSelected(existing.element, isSelected);
|
|
536
|
-
existing.lastSelected = isSelected;
|
|
537
|
-
existing.lastFocused = isFocused;
|
|
538
|
-
};
|
|
539
|
-
/**
|
|
540
|
-
* Update only CSS classes on a rendered row (no template re-evaluation).
|
|
541
|
-
*/
|
|
542
|
-
const updateItemClasses = (index, isSelected, isFocused) => {
|
|
543
|
-
const existing = rendered.get(index);
|
|
544
|
-
if (!existing)
|
|
545
|
-
return;
|
|
546
|
-
// Group headers are not selectable — skip state updates
|
|
547
|
-
if (existing.isGroupHeader)
|
|
548
|
-
return;
|
|
549
|
-
const selectedChanged = existing.lastSelected !== isSelected;
|
|
550
|
-
const focusedChanged = existing.lastFocused !== isFocused;
|
|
551
|
-
if (selectedChanged || focusedChanged) {
|
|
552
|
-
applyRowClasses(existing.element, index, isSelected, isFocused);
|
|
553
|
-
setAriaSelected(existing.element, isSelected);
|
|
554
|
-
existing.lastSelected = isSelected;
|
|
555
|
-
existing.lastFocused = isFocused;
|
|
556
|
-
}
|
|
557
|
-
};
|
|
558
|
-
// =========================================================================
|
|
559
|
-
// Column Layout Update
|
|
560
|
-
// =========================================================================
|
|
561
|
-
/**
|
|
562
|
-
* Update cell positions and widths for all rendered rows.
|
|
563
|
-
* Called after column resize or layout change.
|
|
564
|
-
*/
|
|
565
|
-
const updateColumnLayout = (layout) => {
|
|
566
|
-
currentLayout = layout;
|
|
567
|
-
const cols = layout.columns;
|
|
568
|
-
for (const [, tracked] of rendered) {
|
|
569
|
-
// Update row width (applies to both data rows and group headers)
|
|
570
|
-
tracked.element.style.width = `${layout.totalWidth}px`;
|
|
571
|
-
// Update each cell's position and width (skip group headers — no cells)
|
|
572
|
-
if (tracked.cells.length > 0) {
|
|
573
|
-
for (let i = 0; i < tracked.cells.length && i < cols.length; i++) {
|
|
574
|
-
const cell = tracked.cells[i];
|
|
575
|
-
const col = cols[i];
|
|
576
|
-
cell.style.left = `${col.offset}px`;
|
|
577
|
-
cell.style.width = `${col.width}px`;
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
};
|
|
582
|
-
// =========================================================================
|
|
583
|
-
// Accessors
|
|
584
|
-
// =========================================================================
|
|
585
|
-
/**
|
|
586
|
-
* Get a rendered row element by item index.
|
|
587
|
-
*/
|
|
588
|
-
const getElement = (index) => {
|
|
589
|
-
return rendered.get(index)?.element;
|
|
590
|
-
};
|
|
591
|
-
// =========================================================================
|
|
592
|
-
// Clear & Destroy
|
|
593
|
-
// =========================================================================
|
|
594
|
-
/**
|
|
595
|
-
* Clear all rendered rows — return them to the pool.
|
|
596
|
-
*/
|
|
597
|
-
const clear = () => {
|
|
598
|
-
for (const [, tracked] of rendered) {
|
|
599
|
-
pool.release(tracked.element);
|
|
600
|
-
}
|
|
601
|
-
rendered.clear();
|
|
602
|
-
lastAriaSetSize = -1;
|
|
603
|
-
};
|
|
604
|
-
/**
|
|
605
|
-
* Destroy renderer and cleanup all resources.
|
|
606
|
-
*/
|
|
607
|
-
const destroy = () => {
|
|
608
|
-
clear();
|
|
609
|
-
pool.clear();
|
|
610
|
-
};
|
|
611
|
-
// =========================================================================
|
|
612
|
-
// Return
|
|
613
|
-
// =========================================================================
|
|
614
|
-
return {
|
|
615
|
-
render,
|
|
616
|
-
updatePositions,
|
|
617
|
-
updateItem,
|
|
618
|
-
updateItemClasses,
|
|
619
|
-
getElement,
|
|
620
|
-
updateColumnLayout,
|
|
621
|
-
setGroupHeaderFn,
|
|
622
|
-
clear,
|
|
623
|
-
destroy,
|
|
624
|
-
};
|
|
625
|
-
};
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* vlist/table - Types
|
|
3
|
-
* Types for data table layout with columns, resizable headers, and cell rendering.
|
|
4
|
-
*
|
|
5
|
-
* A table transforms a flat list of items into rows of cells:
|
|
6
|
-
* - Each row corresponds to one item (1:1 mapping, unlike grid)
|
|
7
|
-
* - Each cell is positioned horizontally according to its column definition
|
|
8
|
-
* - A sticky header row displays column labels and resize handles
|
|
9
|
-
* - Columns can be resized by dragging header borders
|
|
10
|
-
* - Variable row heights are supported (Mode A and Mode B)
|
|
11
|
-
*/
|
|
12
|
-
export {};
|