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,625 @@
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
+ };
@@ -0,0 +1,12 @@
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 {};
@@ -0,0 +1,5 @@
1
+ /**
2
+ * vlist/transition — FLIP-based enter/exit animations
3
+ */
4
+ export { transition, type TransitionPluginConfig, type TransitionTiming } from "./plugin";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,5 @@
1
+ /**
2
+ * vlist/transition — FLIP-based enter/exit animations
3
+ */
4
+ // v2 plugin
5
+ export { transition } from "./plugin";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * vlist v2 — Transition Plugin
3
+ *
4
+ * FLIP-based enter/exit animations for insertItem and removeItem.
5
+ * Without this plugin, insert/remove are instantaneous.
6
+ *
7
+ * Priority: 45 (before selection, after most layout plugins)
8
+ */
9
+ import type { VListItem } from "../../types";
10
+ import type { VListPlugin } from "../../core/types";
11
+ export interface TransitionTiming {
12
+ duration?: number;
13
+ easing?: string;
14
+ }
15
+ export interface TransitionPluginConfig {
16
+ duration?: number;
17
+ easing?: string;
18
+ insert?: TransitionTiming | false;
19
+ remove?: TransitionTiming | false;
20
+ }
21
+ export declare function transition<T extends VListItem = VListItem>(config?: TransitionPluginConfig): VListPlugin<T>;
22
+ //# sourceMappingURL=plugin.d.ts.map