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,574 @@
1
+ /**
2
+ * vlist - Scroll Controller
3
+ * Handles both native scrolling and manual wheel-based scrolling for compressed lists
4
+ *
5
+ * When compression is active (large lists exceeding browser height limits),
6
+ * we switch from native scrolling to manual wheel event handling.
7
+ * This allows smooth scrolling through millions of items.
8
+ */
9
+ // =============================================================================
10
+ // Velocity Tracker (Circular Buffer)
11
+ // =============================================================================
12
+ /** Number of samples in circular buffer (avoids array allocation on every update) */
13
+ const VELOCITY_SAMPLE_COUNT = 8;
14
+ /** Minimum samples needed before velocity readings are considered reliable */
15
+ const MIN_RELIABLE_SAMPLES = 3;
16
+ /**
17
+ * Maximum time gap (ms) between samples before the buffer is considered stale.
18
+ * After a pause longer than this, previous samples no longer represent the
19
+ * current scroll gesture — we reset and start measuring fresh.
20
+ * Set below the idle timeout (150ms) so stale detection triggers before idle.
21
+ */
22
+ const STALE_GAP_MS = 100;
23
+ const createVelocityTracker = (initialPosition = 0) => {
24
+ // Pre-allocate sample array to avoid allocation during scrolling
25
+ const samples = new Array(VELOCITY_SAMPLE_COUNT);
26
+ for (let i = 0; i < VELOCITY_SAMPLE_COUNT; i++) {
27
+ samples[i] = { position: 0, time: 0 };
28
+ }
29
+ return {
30
+ velocity: 0,
31
+ lastPosition: initialPosition,
32
+ lastTime: performance.now(),
33
+ samples,
34
+ sampleIndex: 0,
35
+ sampleCount: 0,
36
+ };
37
+ };
38
+ const updateVelocityTracker = (tracker, newPosition) => {
39
+ const now = performance.now();
40
+ const timeDelta = now - tracker.lastTime;
41
+ if (timeDelta === 0)
42
+ return tracker;
43
+ // Stale gap detection: if too much time has passed since the last sample,
44
+ // the previous measurements belong to a different scroll gesture.
45
+ // Reset the buffer and record this position as the new baseline.
46
+ // Velocity stays at 0 (unreliable) until MIN_RELIABLE_SAMPLES accumulate.
47
+ if (timeDelta > STALE_GAP_MS) {
48
+ tracker.sampleCount = 0;
49
+ tracker.sampleIndex = 0;
50
+ tracker.velocity = 0;
51
+ // Record baseline — first real velocity will be computed on the next update
52
+ const baseline = tracker.samples[0];
53
+ baseline.position = newPosition;
54
+ baseline.time = now;
55
+ tracker.sampleIndex = 1;
56
+ tracker.sampleCount = 1;
57
+ tracker.lastPosition = newPosition;
58
+ tracker.lastTime = now;
59
+ return tracker;
60
+ }
61
+ // Write to current slot in circular buffer (no allocation)
62
+ const currentSample = tracker.samples[tracker.sampleIndex];
63
+ currentSample.position = newPosition;
64
+ currentSample.time = now;
65
+ // Advance index (wrap around)
66
+ tracker.sampleIndex = (tracker.sampleIndex + 1) % VELOCITY_SAMPLE_COUNT;
67
+ tracker.sampleCount = Math.min(tracker.sampleCount + 1, VELOCITY_SAMPLE_COUNT);
68
+ // Calculate average velocity from samples (only when we have enough data)
69
+ if (tracker.sampleCount >= 2) {
70
+ const oldestIndex = (tracker.sampleIndex - tracker.sampleCount + VELOCITY_SAMPLE_COUNT) %
71
+ VELOCITY_SAMPLE_COUNT;
72
+ const oldest = tracker.samples[oldestIndex];
73
+ const totalDistance = newPosition - oldest.position;
74
+ const totalTime = now - oldest.time;
75
+ tracker.velocity = totalTime > 0 ? totalDistance / totalTime : 0;
76
+ }
77
+ // sampleCount < 2: keep velocity at 0 (not enough data yet)
78
+ // Update position/time baseline (mutate in place to avoid allocation)
79
+ tracker.lastPosition = newPosition;
80
+ tracker.lastTime = now;
81
+ return tracker;
82
+ };
83
+ /** Check if the velocity tracker has accumulated enough samples for reliable readings */
84
+ const isVelocityTrackerReliable = (tracker) => tracker.sampleCount >= MIN_RELIABLE_SAMPLES;
85
+ // =============================================================================
86
+ // Scroll Controller Factory
87
+ // =============================================================================
88
+ /**
89
+ * Create a scroll controller for a viewport element
90
+ *
91
+ * Supports two modes:
92
+ * 1. Native scrolling (default) - uses browser's built-in scroll
93
+ * 2. Compressed scrolling - manual wheel handling for large lists
94
+ */
95
+ export const createScrollController = (viewport, config = {}) => {
96
+ const { wheel = true, sensitivity = 1, smoothing = false, idleTimeout: idleMs = 150, onScroll, onIdle, scrollElement, horizontal = false, } = config;
97
+ const windowMode = !!scrollElement;
98
+ // State
99
+ let scrollPosition = 0;
100
+ let maxScroll = 0;
101
+ let containerHeight = windowMode
102
+ ? horizontal
103
+ ? window.innerWidth
104
+ : window.innerHeight
105
+ : horizontal
106
+ ? viewport.clientWidth
107
+ : viewport.clientHeight;
108
+ let compressed = config.compressed ?? false;
109
+ let compression = config.compression;
110
+ let velocityTracker = createVelocityTracker();
111
+ let isScrolling = false;
112
+ let idleTimeout = null;
113
+ // =============================================================================
114
+ // Native Scroll Handling
115
+ // =============================================================================
116
+ const handleNativeScrollRaw = () => {
117
+ const newPosition = horizontal ? viewport.scrollLeft : viewport.scrollTop;
118
+ const direction = newPosition >= scrollPosition ? "down" : "up";
119
+ velocityTracker = updateVelocityTracker(velocityTracker, newPosition);
120
+ scrollPosition = newPosition;
121
+ if (onScroll) {
122
+ onScroll({
123
+ scrollTop: scrollPosition,
124
+ direction,
125
+ velocity: velocityTracker.velocity,
126
+ });
127
+ }
128
+ // Idle detection
129
+ scheduleIdleCheck();
130
+ };
131
+ // M1: RAF-throttle native scroll to guarantee at most one processing per frame
132
+ const handleNativeScroll = rafThrottle(handleNativeScrollRaw);
133
+ // =============================================================================
134
+ // Window Scroll Handling
135
+ // =============================================================================
136
+ const handleWindowScrollRaw = () => {
137
+ // Compute list-relative scroll position from the viewport's bounding rect.
138
+ // When the list's top edge is at the window's top, rect.top = 0, scrollTop = 0.
139
+ // When the list has scrolled 500px past, rect.top = -500, scrollTop = 500.
140
+ const rect = viewport.getBoundingClientRect();
141
+ const newPosition = horizontal
142
+ ? Math.max(0, -rect.left)
143
+ : Math.max(0, -rect.top);
144
+ const direction = newPosition >= scrollPosition ? "down" : "up";
145
+ velocityTracker = updateVelocityTracker(velocityTracker, newPosition);
146
+ scrollPosition = newPosition;
147
+ if (!isScrolling) {
148
+ isScrolling = true;
149
+ }
150
+ if (onScroll) {
151
+ onScroll({
152
+ scrollTop: scrollPosition,
153
+ direction,
154
+ velocity: velocityTracker.velocity,
155
+ });
156
+ }
157
+ // Idle detection
158
+ scheduleIdleCheck();
159
+ };
160
+ const handleWindowScroll = rafThrottle(handleWindowScrollRaw);
161
+ // =============================================================================
162
+ // Compressed (Manual) Scroll Handling
163
+ // =============================================================================
164
+ /** Block wheel events (used in native mode when wheel is disabled) */
165
+ const blockWheel = (event) => {
166
+ event.preventDefault();
167
+ };
168
+ /**
169
+ * Translate vertical wheel (deltaY) into horizontal scroll in native mode.
170
+ * Trackpad deltaX already causes native horizontal scroll, so only remap
171
+ * deltaY when there is no deltaX. This keeps trackpad gestures natural
172
+ * while letting a regular mouse wheel drive horizontal scrolling.
173
+ */
174
+ const handleHorizontalWheel = (event) => {
175
+ if (event.deltaX)
176
+ return; // native horizontal scroll handles it
177
+ event.preventDefault();
178
+ viewport.scrollLeft += event.deltaY;
179
+ };
180
+ const handleWheel = (event) => {
181
+ if (!compressed)
182
+ return;
183
+ event.preventDefault();
184
+ const delta = (horizontal ? event.deltaX || event.deltaY : event.deltaY) * sensitivity;
185
+ let newPosition = scrollPosition + delta;
186
+ // Apply smoothing if enabled
187
+ if (smoothing) {
188
+ newPosition = scrollPosition + delta * 0.3;
189
+ }
190
+ // Clamp to valid range
191
+ newPosition = Math.max(0, Math.min(newPosition, maxScroll));
192
+ if (newPosition !== scrollPosition) {
193
+ const previousPosition = scrollPosition;
194
+ const direction = newPosition >= previousPosition ? "down" : "up";
195
+ velocityTracker = updateVelocityTracker(velocityTracker, newPosition);
196
+ scrollPosition = newPosition;
197
+ if (!isScrolling) {
198
+ isScrolling = true;
199
+ }
200
+ if (onScroll) {
201
+ onScroll({
202
+ scrollTop: scrollPosition,
203
+ direction,
204
+ velocity: velocityTracker.velocity,
205
+ });
206
+ }
207
+ // Idle detection
208
+ scheduleIdleCheck();
209
+ }
210
+ };
211
+ // =============================================================================
212
+ // Idle Detection
213
+ // =============================================================================
214
+ const scheduleIdleCheck = () => {
215
+ if (idleTimeout) {
216
+ clearTimeout(idleTimeout);
217
+ }
218
+ idleTimeout = setTimeout(() => {
219
+ isScrolling = false;
220
+ // Reset velocity tracker with current scroll position to avoid
221
+ // calculating huge velocity on next scroll event
222
+ velocityTracker = createVelocityTracker(scrollPosition);
223
+ if (onIdle) {
224
+ onIdle();
225
+ }
226
+ }, idleMs);
227
+ };
228
+ // =============================================================================
229
+ // Mode Switching
230
+ // =============================================================================
231
+ const enableCompression = (newCompression) => {
232
+ if (compressed)
233
+ return;
234
+ compressed = true;
235
+ compression = newCompression;
236
+ maxScroll = newCompression.virtualSize - containerHeight;
237
+ // In window mode, compression is purely mathematical — the content div
238
+ // height is set to the virtual height by vlist.ts, and the browser scrolls
239
+ // natively. No overflow changes or wheel interception needed.
240
+ if (windowMode)
241
+ return;
242
+ // Remove native scroll listener and cancel pending RAF
243
+ handleNativeScroll.cancel();
244
+ viewport.removeEventListener("scroll", handleNativeScroll);
245
+ // Remove native-mode wheel listeners
246
+ if (!wheel) {
247
+ viewport.removeEventListener("wheel", blockWheel);
248
+ }
249
+ else if (horizontal) {
250
+ viewport.removeEventListener("wheel", handleHorizontalWheel);
251
+ }
252
+ // Switch to overflow hidden
253
+ if (horizontal) {
254
+ viewport.style.overflowX = "hidden";
255
+ }
256
+ else {
257
+ viewport.style.overflow = "hidden";
258
+ }
259
+ // Add wheel listener (only if wheel is enabled)
260
+ if (wheel) {
261
+ viewport.addEventListener("wheel", handleWheel, { passive: false });
262
+ }
263
+ // Convert current scroll position to compressed equivalent
264
+ const nativePos = horizontal ? viewport.scrollLeft : viewport.scrollTop;
265
+ if (nativePos > 0) {
266
+ const nativeMax = horizontal
267
+ ? (compression?.actualSize ?? viewport.scrollWidth)
268
+ : (compression?.actualSize ?? viewport.scrollHeight);
269
+ const ratio = nativePos / nativeMax;
270
+ scrollPosition = ratio * maxScroll;
271
+ }
272
+ // Reset native scroll
273
+ if (horizontal) {
274
+ viewport.scrollLeft = 0;
275
+ }
276
+ else {
277
+ viewport.scrollTop = 0;
278
+ }
279
+ };
280
+ const disableCompression = () => {
281
+ if (!compressed)
282
+ return;
283
+ compressed = false;
284
+ // In window mode, nothing to revert — compression was purely mathematical
285
+ if (windowMode) {
286
+ compression = undefined;
287
+ return;
288
+ }
289
+ // Remove wheel listener
290
+ viewport.removeEventListener("wheel", handleWheel);
291
+ // Restore native scrolling
292
+ if (horizontal) {
293
+ viewport.style.overflowX = "auto";
294
+ }
295
+ else {
296
+ viewport.style.overflow = "auto";
297
+ }
298
+ // Add native scroll listener
299
+ viewport.addEventListener("scroll", handleNativeScroll, { passive: true });
300
+ // Re-add native-mode wheel listeners
301
+ if (!wheel) {
302
+ viewport.addEventListener("wheel", blockWheel, { passive: false });
303
+ }
304
+ else if (horizontal) {
305
+ viewport.addEventListener("wheel", handleHorizontalWheel, {
306
+ passive: false,
307
+ });
308
+ }
309
+ // Restore scroll position
310
+ if (compression && scrollPosition > 0) {
311
+ const ratio = scrollPosition / maxScroll;
312
+ const restoredPos = ratio * (compression.actualSize - containerHeight);
313
+ if (horizontal) {
314
+ viewport.scrollLeft = restoredPos;
315
+ }
316
+ else {
317
+ viewport.scrollTop = restoredPos;
318
+ }
319
+ }
320
+ compression = undefined;
321
+ };
322
+ // =============================================================================
323
+ // Public API
324
+ // =============================================================================
325
+ const getScrollTop = () => {
326
+ // In window mode, scrollPosition is always the source of truth
327
+ // (viewport.scrollTop is 0 because overflow is visible).
328
+ // In compressed mode, scrollPosition is manually tracked.
329
+ // In native container mode, read from the DOM.
330
+ if (windowMode || compressed)
331
+ return scrollPosition;
332
+ return horizontal ? viewport.scrollLeft : viewport.scrollTop;
333
+ };
334
+ const scrollTo = (position, smooth = false) => {
335
+ const clampedPosition = Math.max(0, Math.min(position, maxScroll || Infinity));
336
+ if (windowMode) {
337
+ // Scroll the window so the desired list position is at the top of the viewport.
338
+ // listDocumentTop = the list's absolute position in the document.
339
+ const rect = viewport.getBoundingClientRect();
340
+ if (horizontal) {
341
+ const listDocumentLeft = rect.left + window.scrollX;
342
+ window.scrollTo({
343
+ left: listDocumentLeft + clampedPosition,
344
+ behavior: smooth ? "smooth" : "auto",
345
+ });
346
+ }
347
+ else {
348
+ const listDocumentTop = rect.top + window.scrollY;
349
+ window.scrollTo({
350
+ top: listDocumentTop + clampedPosition,
351
+ behavior: smooth ? "smooth" : "auto",
352
+ });
353
+ }
354
+ // The window scroll event will fire and update scrollPosition via handleWindowScroll
355
+ }
356
+ else if (compressed) {
357
+ if (clampedPosition === scrollPosition)
358
+ return;
359
+ const previousPosition = scrollPosition;
360
+ const direction = clampedPosition >= previousPosition ? "down" : "up";
361
+ velocityTracker = updateVelocityTracker(velocityTracker, clampedPosition);
362
+ scrollPosition = clampedPosition;
363
+ if (!isScrolling) {
364
+ isScrolling = true;
365
+ }
366
+ if (onScroll) {
367
+ onScroll({
368
+ scrollTop: scrollPosition,
369
+ direction,
370
+ velocity: velocityTracker.velocity,
371
+ });
372
+ }
373
+ scheduleIdleCheck();
374
+ }
375
+ else {
376
+ if (horizontal) {
377
+ viewport.scrollTo({
378
+ left: clampedPosition,
379
+ behavior: smooth ? "smooth" : "auto",
380
+ });
381
+ }
382
+ else {
383
+ viewport.scrollTo({
384
+ top: clampedPosition,
385
+ behavior: smooth ? "smooth" : "auto",
386
+ });
387
+ }
388
+ }
389
+ };
390
+ const scrollBy = (delta) => {
391
+ scrollTo(getScrollTop() + delta);
392
+ };
393
+ const isAtTop = () => {
394
+ return getScrollTop() <= 0;
395
+ };
396
+ const isAtBottom = (threshold = 0) => {
397
+ const scrollTop = getScrollTop();
398
+ // In window mode or compressed mode, use maxScroll (explicitly tracked).
399
+ // In native container mode, derive from the viewport's scroll geometry.
400
+ const max = windowMode || compressed
401
+ ? maxScroll
402
+ : horizontal
403
+ ? viewport.scrollWidth - viewport.clientWidth
404
+ : viewport.scrollHeight - viewport.clientHeight;
405
+ return scrollTop >= max - threshold;
406
+ };
407
+ const getScrollPercentage = () => {
408
+ const scrollTop = getScrollTop();
409
+ const max = windowMode || compressed
410
+ ? maxScroll
411
+ : horizontal
412
+ ? viewport.scrollWidth - viewport.clientWidth
413
+ : viewport.scrollHeight - viewport.clientHeight;
414
+ if (max <= 0)
415
+ return 0;
416
+ return Math.min(1, Math.max(0, scrollTop / max));
417
+ };
418
+ const updateConfig = (newConfig) => {
419
+ if (newConfig.compression) {
420
+ compression = newConfig.compression;
421
+ maxScroll = compression.virtualSize - containerHeight;
422
+ }
423
+ };
424
+ const isCompressedMode = () => compressed;
425
+ const getVelocityValue = () => Math.abs(velocityTracker.velocity);
426
+ const getIsTracking = () => isVelocityTrackerReliable(velocityTracker);
427
+ const getIsScrolling = () => isScrolling;
428
+ const getIsWindowMode = () => windowMode;
429
+ const updateContainerHeightFn = (height) => {
430
+ containerHeight = height;
431
+ // Recompute maxScroll if we have compression or are in window mode
432
+ if (compression) {
433
+ maxScroll = compression.virtualSize - containerHeight;
434
+ }
435
+ else if (windowMode) {
436
+ // In window mode without compression, maxScroll is derived from
437
+ // the content div height. vlist.ts calls updateConfig with compression
438
+ // when totalHeight changes, so this path handles the non-compressed case.
439
+ // We can't compute it here without knowing totalHeight, so leave it
440
+ // and let updateConfig handle it when compression state is updated.
441
+ }
442
+ };
443
+ const destroy = () => {
444
+ if (idleTimeout) {
445
+ clearTimeout(idleTimeout);
446
+ }
447
+ if (windowMode) {
448
+ handleWindowScroll.cancel();
449
+ window.removeEventListener("scroll", handleWindowScroll);
450
+ }
451
+ else {
452
+ handleNativeScroll.cancel();
453
+ viewport.removeEventListener("scroll", handleNativeScroll);
454
+ viewport.removeEventListener("wheel", handleWheel);
455
+ viewport.removeEventListener("wheel", blockWheel);
456
+ viewport.removeEventListener("wheel", handleHorizontalWheel);
457
+ }
458
+ };
459
+ // =============================================================================
460
+ // Initialization
461
+ // =============================================================================
462
+ if (windowMode) {
463
+ // Window scroll mode — listen to window, don't manage viewport overflow
464
+ if (compressed && compression) {
465
+ maxScroll = compression.virtualSize - containerHeight;
466
+ }
467
+ window.addEventListener("scroll", handleWindowScroll, { passive: true });
468
+ }
469
+ else if (compressed && compression) {
470
+ // Start in compressed mode
471
+ maxScroll = compression.virtualSize - containerHeight;
472
+ if (horizontal) {
473
+ viewport.style.overflowX = "hidden";
474
+ }
475
+ else {
476
+ viewport.style.overflow = "hidden";
477
+ }
478
+ if (wheel) {
479
+ viewport.addEventListener("wheel", handleWheel, { passive: false });
480
+ }
481
+ }
482
+ else {
483
+ // Start in native scroll mode
484
+ if (horizontal) {
485
+ viewport.style.overflowX = "auto";
486
+ viewport.style.overflowY = "hidden";
487
+ }
488
+ else {
489
+ viewport.style.overflow = "auto";
490
+ }
491
+ viewport.addEventListener("scroll", handleNativeScroll, { passive: true });
492
+ if (!wheel) {
493
+ viewport.addEventListener("wheel", blockWheel, { passive: false });
494
+ }
495
+ else if (horizontal) {
496
+ viewport.addEventListener("wheel", handleHorizontalWheel, {
497
+ passive: false,
498
+ });
499
+ }
500
+ }
501
+ return {
502
+ getScrollTop,
503
+ scrollTo,
504
+ scrollBy,
505
+ isAtTop,
506
+ isAtBottom,
507
+ getScrollPercentage,
508
+ getVelocity: getVelocityValue,
509
+ isTracking: getIsTracking,
510
+ isScrolling: getIsScrolling,
511
+ updateConfig,
512
+ enableCompression,
513
+ disableCompression,
514
+ isCompressed: isCompressedMode,
515
+ isWindowMode: getIsWindowMode,
516
+ updateContainerHeight: updateContainerHeightFn,
517
+ destroy,
518
+ };
519
+ };
520
+ // =============================================================================
521
+ // Utility Functions
522
+ // =============================================================================
523
+ /**
524
+ * Throttle scroll handler using requestAnimationFrame
525
+ */
526
+ export const rafThrottle = (fn) => {
527
+ let frameId = null;
528
+ let lastArgs = null;
529
+ const throttled = (...args) => {
530
+ lastArgs = args;
531
+ if (frameId === null) {
532
+ frameId = requestAnimationFrame(() => {
533
+ frameId = null;
534
+ if (lastArgs) {
535
+ fn(...lastArgs);
536
+ }
537
+ });
538
+ }
539
+ };
540
+ throttled.cancel = () => {
541
+ if (frameId !== null) {
542
+ cancelAnimationFrame(frameId);
543
+ frameId = null;
544
+ }
545
+ };
546
+ return throttled;
547
+ };
548
+ /**
549
+ * Check if scroll position is at bottom
550
+ */
551
+ export const isAtBottom = (scrollTop, scrollHeight, clientHeight, threshold = 0) => {
552
+ return scrollTop + clientHeight >= scrollHeight - threshold;
553
+ };
554
+ /**
555
+ * Check if scroll position is at top
556
+ */
557
+ export const isAtTop = (scrollTop, threshold = 0) => {
558
+ return scrollTop <= threshold;
559
+ };
560
+ /**
561
+ * Get scroll percentage (0-1)
562
+ */
563
+ export const getScrollPercentage = (scrollTop, scrollHeight, clientHeight) => {
564
+ const maxScroll = scrollHeight - clientHeight;
565
+ if (maxScroll <= 0)
566
+ return 0;
567
+ return Math.min(1, Math.max(0, scrollTop / maxScroll));
568
+ };
569
+ /**
570
+ * Check if a range is visible in the scroll viewport
571
+ */
572
+ export const isRangeVisible = (rangeStart, rangeEnd, visibleStart, visibleEnd) => {
573
+ return rangeStart <= visibleEnd && rangeEnd >= visibleStart;
574
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * vlist v2 — Scrollbar Plugin
3
+ */
4
+ export { scrollbar, type ScrollbarPluginConfig } from "./plugin";
5
+ export { createScrollbar, type Scrollbar, type ScrollbarConfig, type ScrollCallback, } from "./scrollbar";
6
+ export { createScrollController, rafThrottle, isAtBottom, isAtTop, getScrollPercentage, isRangeVisible, type ScrollController, } from "./controller";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * vlist v2 — Scrollbar Plugin
3
+ */
4
+ export { scrollbar } from "./plugin";
5
+ export { createScrollbar, } from "./scrollbar";
6
+ export { createScrollController, rafThrottle, isAtBottom, isAtTop, getScrollPercentage, isRangeVisible, } from "./controller";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * vlist v2 — Scrollbar Plugin
3
+ *
4
+ * Replaces the native scrollbar with a custom, cross-browser consistent scrollbar.
5
+ * Priority 15 — runs after layout plugins (10) but before selection (50).
6
+ *
7
+ * Uses the v1 scrollbar UI component directly — it's standalone DOM/events code
8
+ * with no framework dependencies. This plugin wires it into the v2 lifecycle:
9
+ * - onAfterScroll hook → update thumb position
10
+ * - onResize hook → update thumb/track bounds
11
+ * - destroy → cleanup
12
+ */
13
+ import type { VListItem } from "../../types";
14
+ import type { VListPlugin } from "../../core/types";
15
+ import { type ScrollbarConfig } from "./scrollbar";
16
+ export interface ScrollbarPluginConfig extends ScrollbarConfig {
17
+ gutter?: boolean;
18
+ }
19
+ export declare function scrollbar<T extends VListItem = VListItem>(config?: ScrollbarPluginConfig): VListPlugin<T>;
20
+ //# sourceMappingURL=plugin.d.ts.map