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,267 @@
1
+ /**
2
+ * vlist - Compression Module
3
+ * Pure functions for handling large lists that exceed browser size limits
4
+ *
5
+ * When a list's total size (sum of all item sizes) exceeds the browser's
6
+ * maximum element size (~16.7M pixels), we "compress" the virtual scroll space.
7
+ *
8
+ * Key concepts:
9
+ * - actualSize: The true size if all items were rendered
10
+ * - virtualSize: The capped size used for the scroll container (≤ MAX_VIRTUAL_SIZE)
11
+ * - compressionRatio: virtualSize / actualSize (1 = no compression, <1 = compressed)
12
+ *
13
+ * When compressed:
14
+ * - Scroll position maps to item index via ratio, not pixel math
15
+ * - Item positions are calculated relative to a "virtual index" at current scroll
16
+ * - Near-bottom interpolation ensures the last items are reachable
17
+ */
18
+ import { MAX_VIRTUAL_SIZE } from "../constants";
19
+ import { countVisibleItems, } from "./sizes";
20
+ // Re-export for convenience
21
+ export { MAX_VIRTUAL_SIZE };
22
+ /**
23
+ * Calculate compression state for a list
24
+ * Pure function - no side effects
25
+ *
26
+ * @param _totalItems - Total number of items
27
+ * @param sizeCache - Size cache for item sizes/offsets
28
+ * @param force - When true, enables compressed mode even if total size is under the limit.
29
+ * Useful for testing, consistent UX, or preemptively enabling compression
30
+ * before the list grows past the browser limit.
31
+ */
32
+ export const getCompressionState = (_totalItems, sizeCache, force) => {
33
+ const actualSize = sizeCache.getTotalSize();
34
+ const isCompressed = force === true || actualSize > MAX_VIRTUAL_SIZE;
35
+ const virtualSize = actualSize > MAX_VIRTUAL_SIZE ? MAX_VIRTUAL_SIZE : actualSize;
36
+ const ratio = actualSize > 0 ? virtualSize / actualSize : 1;
37
+ return {
38
+ isCompressed,
39
+ actualSize,
40
+ virtualSize,
41
+ ratio,
42
+ };
43
+ };
44
+ // =============================================================================
45
+ // Range Calculations (Compressed)
46
+ // =============================================================================
47
+ /**
48
+ * Calculate visible range with compression support
49
+ * Pure function - no side effects
50
+ *
51
+ * @param scrollTop - Current scroll position
52
+ * @param containerHeight - Viewport container height
53
+ * @param sizeCache - Size cache for item sizes/offsets
54
+ * @param totalItems - Total number of items
55
+ * @param compression - Compression state
56
+ * @param out - Output range to mutate (avoids allocation on hot path)
57
+ */
58
+ export const calculateCompressedVisibleRange = (scrollPosition, containerHeight, sizeCache, totalItems, compression, out) => {
59
+ if (totalItems === 0 || containerHeight === 0) {
60
+ out.start = 0;
61
+ out.end = -1;
62
+ return out;
63
+ }
64
+ if (!compression.isCompressed || compression.ratio === 1) {
65
+ // Normal calculation using size cache.
66
+ // Also used when ratio === 1 (force mode, no actual compression) —
67
+ // the scroll infrastructure is active but positions map 1:1,
68
+ // so offset-based math is exact and avoids interpolation drift.
69
+ const start = sizeCache.indexAtOffset(scrollPosition);
70
+ // Find the last item that is at least partially visible
71
+ // Add 1 to match the fixed-height ceil() behavior (safe overshoot)
72
+ let end = sizeCache.indexAtOffset(scrollPosition + containerHeight);
73
+ if (end < totalItems - 1)
74
+ end++;
75
+ out.start = Math.max(0, start);
76
+ out.end = Math.min(totalItems - 1, Math.max(0, end));
77
+ return out;
78
+ }
79
+ // Compressed calculation — map scrollTop to actual space, then use
80
+ // sizeCache prefix sums to find the correct index. This handles mixed
81
+ // item sizes (e.g. group headers shorter than data items) correctly,
82
+ // matching how calculateCompressedItemPosition maps positions.
83
+ const { virtualSize } = compression;
84
+ const scrollRatio = scrollPosition / virtualSize;
85
+ const actualSize = sizeCache.getTotalSize();
86
+ const actualScrollOffset = scrollRatio * actualSize;
87
+ const start = sizeCache.indexAtOffset(actualScrollOffset);
88
+ const visibleCount = countVisibleItems(sizeCache, Math.max(0, start), containerHeight, totalItems);
89
+ const end = start + visibleCount;
90
+ out.start = Math.max(0, start);
91
+ out.end = Math.min(totalItems - 1, Math.max(0, end));
92
+ return out;
93
+ };
94
+ /**
95
+ * Calculate render range with compression support (adds overscan)
96
+ * Pure function - no side effects
97
+ *
98
+ * @param out - Output range to mutate (avoids allocation on hot path)
99
+ */
100
+ export const calculateCompressedRenderRange = (visibleRange, overscan, totalItems, out) => {
101
+ if (totalItems === 0) {
102
+ out.start = 0;
103
+ out.end = -1;
104
+ return out;
105
+ }
106
+ out.start = Math.max(0, visibleRange.start - overscan);
107
+ out.end = Math.min(totalItems - 1, visibleRange.end + overscan);
108
+ return out;
109
+ };
110
+ // =============================================================================
111
+ // Item Positioning (Compressed)
112
+ // =============================================================================
113
+ /**
114
+ * Calculate item position (translateY) with compression support
115
+ * Pure function - no side effects
116
+ *
117
+ * In compressed mode (manual wheel scrolling, overflow: hidden), items are
118
+ * positioned RELATIVE TO THE VIEWPORT. The scroll container doesn't actually
119
+ * scroll - we intercept wheel events and manually position items.
120
+ *
121
+ * Key insight:
122
+ * - Calculate a "virtual scroll index" from the scroll ratio
123
+ * - Items are positioned relative to this virtual index using actual heights
124
+ * - Each item keeps its full height for proper rendering
125
+ *
126
+ * @param index - Item index
127
+ * @param scrollTop - Current (virtual) scroll position
128
+ * @param sizeCache - Size cache for item sizes/offsets
129
+ * @param totalItems - Total number of items
130
+ * @param containerHeight - Viewport container height
131
+ * @param compression - Compression state
132
+ */
133
+ export const calculateCompressedItemPosition = (index, scrollPosition, sizeCache, totalItems, _containerHeight, compression, _rangeStart) => {
134
+ if (!compression.isCompressed || totalItems === 0) {
135
+ // Normal: absolute position in content space (scroll handled by container)
136
+ return sizeCache.getOffset(index);
137
+ }
138
+ // When ratio === 1 (force mode, no actual compression), virtualSize === actualSize
139
+ // so scroll position maps 1:1 to pixel offset. Use simple subtraction to avoid
140
+ // near-bottom interpolation drift that causes a gap at the bottom.
141
+ if (compression.ratio === 1) {
142
+ return sizeCache.getOffset(index) - scrollPosition;
143
+ }
144
+ const { virtualSize } = compression;
145
+ // Normal compressed positioning
146
+ //
147
+ // Map scrollTop to an actual-space offset via the compression ratio,
148
+ // then position the item relative to that offset.
149
+ // With compression slack on the content div the linear formula is valid
150
+ // for ALL scroll positions — no near-bottom interpolation needed.
151
+ const scrollRatio = scrollPosition / virtualSize;
152
+ const actualSize = sizeCache.getTotalSize();
153
+ const virtualScrollOffset = scrollRatio * actualSize;
154
+ return sizeCache.getOffset(index) - virtualScrollOffset;
155
+ };
156
+ // =============================================================================
157
+ // Scroll Position Calculations (Compressed)
158
+ // =============================================================================
159
+ /**
160
+ * Calculate scroll position to bring an index into view (with compression)
161
+ * Pure function - no side effects
162
+ *
163
+ * @param index - Target item index
164
+ * @param sizeCache - Size cache for item sizes/offsets
165
+ * @param containerHeight - Viewport container height
166
+ * @param totalItems - Total number of items
167
+ * @param compression - Compression state
168
+ * @param align - Alignment within viewport
169
+ */
170
+ export const calculateCompressedScrollToIndex = (index, sizeCache, containerHeight, totalItems, compression, align = "start") => {
171
+ if (totalItems === 0)
172
+ return 0;
173
+ let targetPosition;
174
+ if (compression.isCompressed && compression.ratio !== 1) {
175
+ // Map index to compressed scroll position using linear formula.
176
+ // With compression slack on the content div the linear mapping is valid
177
+ // for ALL indices — no special-case needed for the last item.
178
+ const indexRatio = index / totalItems;
179
+ targetPosition = indexRatio * compression.virtualSize;
180
+ // Alignment adjustment must be scaled by the compression ratio.
181
+ // The viewport shows containerHeight/itemSize items regardless of
182
+ // compression, but each scroll-pixel maps to 1/ratio actual pixels.
183
+ // Without scaling, the pixel offset overshoots by 1/ratio items.
184
+ const itemSize = sizeCache.getSize(index);
185
+ switch (align) {
186
+ case "center":
187
+ targetPosition -= (containerHeight - itemSize) / 2 * compression.ratio;
188
+ break;
189
+ case "end":
190
+ targetPosition -= (containerHeight - itemSize) * compression.ratio;
191
+ break;
192
+ }
193
+ // NOTE: no maxScroll clamp here — the caller (withScale) manages
194
+ // maxScroll with compression slack included. Clamping to
195
+ // virtualSize − containerHeight would prevent reaching the last items.
196
+ return Math.max(0, targetPosition);
197
+ }
198
+ // Direct calculation using actual offset.
199
+ // Also used when ratio === 1 (force mode) — positions map 1:1.
200
+ targetPosition = sizeCache.getOffset(index);
201
+ // Adjust for alignment using the specific item's size
202
+ const itemSize = sizeCache.getSize(index);
203
+ switch (align) {
204
+ case "center":
205
+ targetPosition -= (containerHeight - itemSize) / 2;
206
+ break;
207
+ case "end":
208
+ targetPosition -= containerHeight - itemSize;
209
+ break;
210
+ }
211
+ // Clamp to valid range
212
+ const maxScroll = compression.virtualSize - containerHeight;
213
+ return Math.max(0, Math.min(targetPosition, maxScroll));
214
+ };
215
+ /**
216
+ * Calculate the approximate item index at a given scroll position
217
+ * Useful for debugging and scroll position restoration
218
+ * Pure function - no side effects
219
+ */
220
+ export const calculateIndexFromScrollPosition = (scrollPosition, sizeCache, totalItems, compression) => {
221
+ if (totalItems === 0)
222
+ return 0;
223
+ if (compression.isCompressed && compression.ratio !== 1) {
224
+ const scrollRatio = scrollPosition / compression.virtualSize;
225
+ return Math.floor(scrollRatio * totalItems);
226
+ }
227
+ // Direct lookup — also used when ratio === 1 (force mode, no actual compression)
228
+ return sizeCache.indexAtOffset(scrollPosition);
229
+ };
230
+ // =============================================================================
231
+ // Utility Functions
232
+ // =============================================================================
233
+ /**
234
+ * Check if compression is needed for a list configuration
235
+ * Pure function - no side effects
236
+ *
237
+ * Note: This overload accepts a HeightCache for variable heights.
238
+ * For simple fixed-height checks, use needsCompressionFixed().
239
+ */
240
+ export const needsCompression = (totalItems, heightOrCache) => {
241
+ if (typeof heightOrCache === "number") {
242
+ return totalItems * heightOrCache > MAX_VIRTUAL_SIZE;
243
+ }
244
+ return heightOrCache.getTotalSize() > MAX_VIRTUAL_SIZE;
245
+ };
246
+ /**
247
+ * Calculate maximum items supported without compression
248
+ * Only meaningful for fixed-height items
249
+ * Pure function - no side effects
250
+ */
251
+ export const getMaxItemsWithoutCompression = (itemSize) => {
252
+ if (itemSize <= 0)
253
+ return 0;
254
+ return Math.floor(MAX_VIRTUAL_SIZE / itemSize);
255
+ };
256
+ /**
257
+ * Get human-readable compression info for debugging
258
+ * Pure function - no side effects
259
+ */
260
+ export const getCompressionInfo = (totalItems, sizeCache, force) => {
261
+ const compression = getCompressionState(totalItems, sizeCache, force);
262
+ if (!compression.isCompressed) {
263
+ return `No compression needed (${totalItems} items, ${(compression.actualSize / 1000000).toFixed(2)}M px)`;
264
+ }
265
+ const ratioPercent = (compression.ratio * 100).toFixed(1);
266
+ return `Compressed to ${ratioPercent}% (${totalItems} items, ${(compression.actualSize / 1000000).toFixed(1)}M px → ${(compression.virtualSize / 1000000).toFixed(1)}M px virtual)`;
267
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * vlist - Smart Edge Scroll
3
+ * Shared scroll utility used by both core baseline and withSelection feature.
4
+ * Only scrolls when the target item is outside the viewport; aligns to nearest edge.
5
+ *
6
+ * Split into two functions for tree-shaking:
7
+ * - scrollToFocusSimple: normal mode only (used by base builder)
8
+ * - scrollToFocus: handles both normal and compressed modes (used by features)
9
+ */
10
+ /**
11
+ * Simple scroll-to-focus: normal (non-compressed) mode only.
12
+ * Padding-aware: accounts for CSS padding on the content element.
13
+ */
14
+ export const scrollToFocusSimple = (index, sizeCache, scrollPosition, containerSize, startPadding = 0, endPadding = 0) => {
15
+ const itemOffset = sizeCache.getOffset(index);
16
+ const itemSize = sizeCache.getSize(index);
17
+ const adjustedTop = itemOffset + startPadding;
18
+ const adjustedBottom = adjustedTop + itemSize;
19
+ const viewportBottom = scrollPosition + containerSize;
20
+ if (adjustedTop < scrollPosition)
21
+ return Math.max(0, itemOffset);
22
+ if (adjustedBottom > viewportBottom)
23
+ return adjustedBottom + endPadding - containerSize;
24
+ return scrollPosition;
25
+ };
26
+ /**
27
+ * Full scroll-to-focus: handles both normal and compressed (withScale) modes.
28
+ * Used by withSelection feature which must work with compression.
29
+ */
30
+ export const scrollToFocus = (index, sizeCache, scrollPosition, containerSize, startPadding = 0, endPadding = 0, compression, totalItems, visibleRange) => {
31
+ const isCompressed = compression != null &&
32
+ compression.isCompressed &&
33
+ compression.ratio !== 1;
34
+ if (!isCompressed) {
35
+ return scrollToFocusSimple(index, sizeCache, scrollPosition, containerSize, startPadding, endPadding);
36
+ }
37
+ // ── Compressed: prefix-sum positioning ──
38
+ // Use actual offsets from the size cache instead of linear index math.
39
+ // Linear math assumes uniform item sizes, but group headers are shorter
40
+ // than data items, causing the focused item to land short of the edge.
41
+ const { virtualSize } = compression;
42
+ const itemSize = sizeCache.getSize(Math.max(0, index));
43
+ const effectiveSize = containerSize - startPadding - endPadding;
44
+ const fullyVisible = Math.max(1, Math.floor(effectiveSize / itemSize));
45
+ const totalActualSize = sizeCache.getTotalSize();
46
+ if (visibleRange) {
47
+ if (index >= visibleRange.start + fullyVisible) {
48
+ const itemBottom = sizeCache.getOffset(index) + itemSize;
49
+ const targetActualTop = itemBottom + endPadding - containerSize;
50
+ return Math.max(0, (targetActualTop / totalActualSize) * virtualSize);
51
+ }
52
+ if (index <= visibleRange.start) {
53
+ const targetActualTop = sizeCache.getOffset(index) - startPadding;
54
+ return Math.max(0, (targetActualTop / totalActualSize) * virtualSize);
55
+ }
56
+ return scrollPosition;
57
+ }
58
+ // No visible range — fallback
59
+ const currentIndex = (scrollPosition / virtualSize) * totalItems;
60
+ const currentEnd = currentIndex + fullyVisible;
61
+ if (index >= currentEnd) {
62
+ const itemBottom = sizeCache.getOffset(index) + itemSize;
63
+ const targetActualTop = itemBottom + endPadding - containerSize;
64
+ return Math.max(0, (targetActualTop / totalActualSize) * virtualSize);
65
+ }
66
+ if (index < currentIndex) {
67
+ const targetActualTop = sizeCache.getOffset(index) - startPadding;
68
+ return Math.max(0, (targetActualTop / totalActualSize) * virtualSize);
69
+ }
70
+ return scrollPosition;
71
+ };
@@ -0,0 +1,193 @@
1
+ /**
2
+ * vlist - Size Cache
3
+ * Efficient size management for fixed and variable item sizes
4
+ *
5
+ * Provides two implementations:
6
+ * - Fixed: O(1) operations using multiplication (zero overhead, matches existing behavior)
7
+ * - Variable: O(1) offset lookup via prefix sums, O(log n) binary search for index-at-offset
8
+ *
9
+ * The SizeCache abstraction allows all virtual scrolling and compression code
10
+ * to work identically with both fixed and variable sizes, for both vertical and horizontal scrolling.
11
+ */
12
+ // =============================================================================
13
+ // Fixed Size Cache
14
+ // =============================================================================
15
+ /**
16
+ * Create a fixed-size cache
17
+ * All operations are O(1) using simple multiplication — zero overhead
18
+ */
19
+ const createFixedSizeCache = (size, initialTotal) => {
20
+ let total = initialTotal;
21
+ return {
22
+ getOffset: (index) => index * size,
23
+ getSize: (_index) => size,
24
+ indexAtOffset: (offset) => {
25
+ if (total === 0 || size === 0)
26
+ return 0;
27
+ return Math.max(0, Math.min(Math.floor(offset / size), total - 1));
28
+ },
29
+ getTotalSize: () => total * size,
30
+ getTotal: () => total,
31
+ rebuild: (newTotal) => {
32
+ total = newTotal;
33
+ },
34
+ isVariable: () => false,
35
+ };
36
+ };
37
+ // =============================================================================
38
+ // Variable Size Cache
39
+ // =============================================================================
40
+ /**
41
+ * Create a variable-size cache using prefix sums
42
+ *
43
+ * Prefix sums array: prefixSums[i] = sum of sizes for items 0..i-1
44
+ * prefixSums[0] = 0
45
+ * prefixSums[1] = size(0)
46
+ * prefixSums[n] = total size of all n items
47
+ *
48
+ * This enables:
49
+ * getOffset(i) = prefixSums[i] — O(1)
50
+ * getTotalSize() = prefixSums[n] — O(1)
51
+ * indexAtOffset(y) = binary search — O(log n)
52
+ */
53
+ const createVariableSizeCache = (sizeFn, initialTotal) => {
54
+ let total = initialTotal;
55
+ let prefixSums = new Float64Array(0);
56
+ /**
57
+ * Build prefix sums from the size function
58
+ * O(n) — only called on data changes, never on scroll
59
+ */
60
+ const build = (n) => {
61
+ total = n;
62
+ prefixSums = new Float64Array(n + 1);
63
+ prefixSums[0] = 0;
64
+ for (let i = 0; i < n; i++) {
65
+ prefixSums[i + 1] = prefixSums[i] + sizeFn(i);
66
+ }
67
+ };
68
+ // Initial build
69
+ build(initialTotal);
70
+ /**
71
+ * Binary search: find the largest index i where prefixSums[i] <= offset
72
+ * This gives the item that contains the given scroll offset
73
+ */
74
+ const binarySearch = (offset) => {
75
+ if (total === 0)
76
+ return 0;
77
+ // Clamp to valid range
78
+ if (offset <= 0)
79
+ return 0;
80
+ if (offset >= prefixSums[total])
81
+ return total - 1;
82
+ let lo = 0;
83
+ let hi = total - 1;
84
+ while (lo < hi) {
85
+ const mid = (lo + hi + 1) >>> 1;
86
+ if (prefixSums[mid] <= offset) {
87
+ lo = mid;
88
+ }
89
+ else {
90
+ hi = mid - 1;
91
+ }
92
+ }
93
+ return lo;
94
+ };
95
+ return {
96
+ getOffset: (index) => {
97
+ if (index <= 0)
98
+ return 0;
99
+ if (index >= total)
100
+ return prefixSums[total];
101
+ return prefixSums[index];
102
+ },
103
+ getSize: (index) => sizeFn(index),
104
+ indexAtOffset: (offset) => binarySearch(offset),
105
+ getTotalSize: () => prefixSums[total] ?? 0,
106
+ getTotal: () => total,
107
+ rebuild: (newTotal) => build(newTotal),
108
+ isVariable: () => true,
109
+ };
110
+ };
111
+ // =============================================================================
112
+ // Factory
113
+ // =============================================================================
114
+ /**
115
+ * Create a size cache — returns fixed or variable implementation
116
+ *
117
+ * When size is a number, returns a zero-overhead fixed implementation.
118
+ * When size is a function, builds a prefix-sum array for efficient lookups.
119
+ */
120
+ export const createSizeCache = (size, initialTotal) => {
121
+ if (typeof size === "number") {
122
+ return createFixedSizeCache(size, initialTotal);
123
+ }
124
+ return createVariableSizeCache(size, initialTotal);
125
+ };
126
+ // =============================================================================
127
+ // Helpers
128
+ // =============================================================================
129
+ /**
130
+ * Count how many items fit in a given container size starting from startIndex
131
+ * Used for compressed mode visible range calculations
132
+ *
133
+ * For fixed sizes: O(1) via division
134
+ * For variable sizes: O(k) where k = visible item count (typically 10-50)
135
+ */
136
+ export const countVisibleItems = (sizeCache, startIndex, containerSize, totalItems) => {
137
+ if (totalItems === 0)
138
+ return 0;
139
+ if (!sizeCache.isVariable()) {
140
+ return Math.ceil(containerSize / sizeCache.getSize(0));
141
+ }
142
+ let count = 0;
143
+ let accumulated = 0;
144
+ let idx = startIndex;
145
+ while (idx < totalItems && accumulated < containerSize) {
146
+ accumulated += sizeCache.getSize(idx);
147
+ count++;
148
+ idx++;
149
+ }
150
+ return Math.max(1, count);
151
+ };
152
+ /**
153
+ * Count how many items fit starting from the bottom of the list
154
+ * Used for near-bottom interpolation in compressed mode
155
+ *
156
+ * For fixed sizes: O(1) via division
157
+ * For variable sizes: O(k) where k = items fitting (typically 10-50)
158
+ */
159
+ export const countItemsFittingFromBottom = (sizeCache, containerSize, totalItems) => {
160
+ if (totalItems === 0)
161
+ return 0;
162
+ if (!sizeCache.isVariable()) {
163
+ return Math.floor(containerSize / sizeCache.getSize(0));
164
+ }
165
+ let count = 0;
166
+ let accumulated = 0;
167
+ for (let i = totalItems - 1; i >= 0; i--) {
168
+ const s = sizeCache.getSize(i);
169
+ if (accumulated + s > containerSize)
170
+ break;
171
+ accumulated += s;
172
+ count++;
173
+ }
174
+ return Math.max(count, 1);
175
+ };
176
+ /**
177
+ * Calculate the pixel offset for a fractional virtual scroll index
178
+ *
179
+ * In compressed mode, the scroll position maps to a fractional item index
180
+ * (e.g., 5.3 means 30% into item 5). This function calculates the actual
181
+ * pixel offset for such a fractional position using variable sizes.
182
+ *
183
+ * For fixed sizes this reduces to: virtualIndex * itemSize
184
+ * For variable sizes: offset(floor) + frac * size(floor)
185
+ */
186
+ export const getOffsetForVirtualIndex = (sizeCache, virtualIndex, totalItems) => {
187
+ if (totalItems === 0)
188
+ return 0;
189
+ const intPart = Math.floor(virtualIndex);
190
+ const fracPart = virtualIndex - intPart;
191
+ const safeInt = Math.max(0, Math.min(intPart, totalItems - 1));
192
+ return sizeCache.getOffset(safeInt) + fracPart * sizeCache.getSize(safeInt);
193
+ };
@@ -0,0 +1,65 @@
1
+ // src/rendering/sort.ts
2
+ /**
3
+ * Shared DOM sort utility for accessibility.
4
+ *
5
+ * Virtual list renderers append new elements at the end of the container
6
+ * for performance (batched DocumentFragment insertion). After scrolling,
7
+ * DOM order diverges from logical item order. Screen readers traverse
8
+ * DOM order, so items are encountered in a nonsensical sequence.
9
+ *
10
+ * This utility reorders DOM children to match logical index order.
11
+ * Called on scroll idle — zero cost during scroll, single lightweight
12
+ * reflow when idle (items are position:absolute, no geometry change).
13
+ *
14
+ * **Minimal-move approach**: walks sorted elements and current DOM children
15
+ * in parallel. Elements already at the correct position are never touched
16
+ * — preserving browser :hover state and avoiding CSS transition replays
17
+ * on elements under the cursor.
18
+ *
19
+ * Used by: core renderer, grid renderer, masonry renderer, and core.ts
20
+ * inlined render path.
21
+ */
22
+ /**
23
+ * Reorder DOM children so they follow logical data-index order.
24
+ *
25
+ * Only elements that are out of position are moved via `insertBefore`.
26
+ * Elements already in the correct spot are skipped entirely (no DOM
27
+ * mutation), which preserves :hover state and CSS transitions.
28
+ *
29
+ * @param container - The DOM element that holds rendered items
30
+ * @param keys - The rendered Map's keys (item indices)
31
+ * @param getElement - Lookup function: index → HTMLElement | undefined
32
+ */
33
+ export const sortRenderedDOM = (container, keys, getElement) => {
34
+ // Collect and sort logical indices
35
+ const sorted = Array.from(keys).sort((a, b) => a - b);
36
+ if (sorted.length <= 1)
37
+ return;
38
+ // Resolve to elements in target (sorted) order, skip undefined
39
+ const elements = [];
40
+ for (let i = 0; i < sorted.length; i++) {
41
+ const el = getElement(sorted[i]);
42
+ if (el)
43
+ elements.push(el);
44
+ }
45
+ if (elements.length <= 1)
46
+ return;
47
+ // Walk sorted elements against current DOM children in parallel.
48
+ // `cursor` tracks our position in the DOM child list.
49
+ // For each target element:
50
+ // - if it matches the cursor → already in place, advance cursor
51
+ // - if not → insertBefore(cursor) to put it in the right spot
52
+ let cursor = container.firstChild;
53
+ for (let i = 0; i < elements.length; i++) {
54
+ const el = elements[i];
55
+ if (el === cursor) {
56
+ // Already in the correct position — skip, no DOM mutation
57
+ cursor = cursor.nextSibling;
58
+ }
59
+ else {
60
+ // Out of place — move it before the current cursor position.
61
+ // insertBefore(el, null) is equivalent to appendChild.
62
+ container.insertBefore(el, cursor);
63
+ }
64
+ }
65
+ };