@pdanpdan/virtual-scroll 0.9.0 → 0.10.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.
@@ -1,3 +1,4 @@
1
+ import type { ExtensionContext, VirtualScrollExtension } from '../extensions';
1
2
  import type {
2
3
  RenderedItem,
3
4
  ScrollAlignment,
@@ -7,9 +8,9 @@ import type {
7
8
  ScrollToIndexOptions,
8
9
  VirtualScrollProps,
9
10
  } from '../types';
10
- import type { MaybeRefOrGetter } from 'vue';
11
-
12
11
  /* global ScrollToOptions */
12
+ import type { Ref } from 'vue';
13
+
13
14
  import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, reactive, ref, toValue, watch } from 'vue';
14
15
 
15
16
  import {
@@ -26,14 +27,12 @@ import {
26
27
  calculateRange,
27
28
  calculateRangeSize,
28
29
  calculateRenderedSize,
29
- calculateScale,
30
30
  calculateScrollTarget,
31
31
  calculateSSROffsets,
32
32
  calculateStickyItem,
33
33
  calculateTotalSize,
34
34
  displayToVirtual,
35
35
  findPrevStickyIndex,
36
- resolveSnap,
37
36
  virtualToDisplay,
38
37
  } from '../utils/virtual-scroll-logic';
39
38
  import { useVirtualScrollSizes } from './useVirtualScrollSizes';
@@ -43,79 +42,107 @@ import { useVirtualScrollSizes } from './useVirtualScrollSizes';
43
42
  * Handles calculation of visible items, scroll events, dynamic item sizes, and programmatic scrolling.
44
43
  *
45
44
  * @param propsInput - The configuration properties. Can be a plain object, a Ref, or a getter function.
45
+ * @param extensions - Optional list of extensions to enhance functionality (RTL, Snapping, Sticky, etc.).
46
46
  * @see VirtualScrollProps
47
47
  */
48
- export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<VirtualScrollProps<T>>) {
48
+ export function useVirtualScroll<T = unknown>(
49
+ propsInput: Ref<VirtualScrollProps<T>> | (() => VirtualScrollProps<T>),
50
+ extensions: VirtualScrollExtension<T>[] = [],
51
+ ) {
49
52
  const props = computed(() => toValue(propsInput));
50
53
 
51
54
  // --- State ---
55
+ /** Current horizontal display scroll position (DU). */
52
56
  const scrollX = ref(0);
57
+ /** Current vertical display scroll position (DU). */
53
58
  const scrollY = ref(0);
59
+ /** Current horizontal virtual scroll position (VU). */
60
+ const internalScrollX = ref(0);
61
+ /** Current vertical virtual scroll position (VU). */
62
+ const internalScrollY = ref(0);
63
+ /** Whether the container is currently being scrolled. */
54
64
  const isScrolling = ref(false);
65
+ /** Whether the component has finished its first client-side mount. */
55
66
  const isHydrated = ref(false);
67
+ /** Whether the component is in the process of initial hydration. */
56
68
  const isHydrating = ref(false);
69
+ /** Whether the component is currently mounted in the DOM. */
57
70
  const isMounted = ref(false);
71
+ /** Whether the current text direction is Right-to-Left. */
58
72
  const isRtl = ref(false);
73
+ /** Current physical width of the visible viewport area (DU). */
59
74
  const viewportWidth = ref(0);
75
+ /** Current physical height of the visible viewport area (DU). */
60
76
  const viewportHeight = ref(0);
77
+ /** Current offset of the items wrapper relative to the scroll container (DU). */
61
78
  const hostOffset = reactive({ x: 0, y: 0 });
79
+ /** Current offset of the root host element relative to the scroll container (DU). */
62
80
  const hostRefOffset = reactive({ x: 0, y: 0 });
81
+ /** Timeout handle for the scroll end detection. */
63
82
  let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
64
83
 
65
- const isProgrammaticScroll = ref(false);
66
- const internalScrollX = ref(0);
67
- const internalScrollY = ref(0);
68
- /** The last recorded virtual X position, used to detect scroll direction for snapping. */
69
- let lastInternalX = 0;
70
- /** The last recorded virtual Y position, used to detect scroll direction for snapping. */
71
- let lastInternalY = 0;
72
- /** The current horizontal scroll direction ('start' towards left/logical start, 'end' towards right/logical end). */
73
- let scrollDirectionX: 'start' | 'end' | null = null;
74
- /** The current vertical scroll direction ('start' towards top, 'end' towards bottom). */
75
- let scrollDirectionY: 'start' | 'end' | null = null;
84
+ /** Scaling factor for horizontal virtual coordinates. */
85
+ const scaleX = ref(1);
86
+ /** Scaling factor for vertical virtual coordinates. */
87
+ const scaleY = ref(1);
76
88
 
77
- let computedStyle: CSSStyleDeclaration | null = null;
89
+ /** Current horizontal scroll direction. */
90
+ const scrollDirectionX = ref<'start' | 'end' | null>(null);
91
+ /** Current vertical scroll direction. */
92
+ const scrollDirectionY = ref<'start' | 'end' | null>(null);
78
93
 
79
- /**
80
- * Detects the current direction (LTR/RTL) of the scroll container.
81
- */
82
- const updateDirection = () => {
83
- if (typeof window === 'undefined') {
84
- return;
85
- }
86
- const container = props.value.container || props.value.hostRef || window;
87
- const el = isElement(container) ? container : document.documentElement;
94
+ /** Information about a scroll operation that is waiting for measurements. */
95
+ const pendingScroll = ref<{
96
+ rowIndex: number | null | undefined;
97
+ colIndex: number | null | undefined;
98
+ options: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions | undefined;
99
+ } | null>(null);
88
100
 
89
- computedStyle = window.getComputedStyle(el);
101
+ /** Whether the current scroll operation was initiated programmatically. */
102
+ const isProgrammaticScroll = ref(false);
103
+ /** Timeout handle for smooth programmatic scroll completion. */
104
+ let programmaticScrollTimer: ReturnType<typeof setTimeout> | undefined;
90
105
 
91
- const newRtl = computedStyle.direction === 'rtl';
92
- if (isRtl.value !== newRtl) {
93
- isRtl.value = newRtl;
94
- }
106
+ /**
107
+ * Immediately stops any currently active smooth scroll animation and clears pending corrections.
108
+ */
109
+ const stopProgrammaticScroll = () => {
110
+ isProgrammaticScroll.value = false;
111
+ clearTimeout(programmaticScrollTimer);
112
+ pendingScroll.value = null;
95
113
  };
96
114
 
115
+ /** Previous internal horizontal virtual position. */
116
+ let lastInternalX = 0;
117
+ /** Previous internal vertical virtual position. */
118
+ let lastInternalY = 0;
119
+
97
120
  // --- Computed Config ---
121
+ /** Validated scroll direction. */
98
122
  const direction = computed(() => [ 'vertical', 'horizontal', 'both' ].includes(props.value.direction as string) ? props.value.direction as ScrollDirection : 'vertical' as ScrollDirection);
99
123
 
124
+ /** Whether the items have dynamic height or width. */
100
125
  const isDynamicItemSize = computed(() =>
101
126
  props.value.itemSize === undefined || props.value.itemSize === null || props.value.itemSize === 0,
102
127
  );
103
128
 
129
+ /** Whether the columns have dynamic widths. */
104
130
  const isDynamicColumnWidth = computed(() =>
105
131
  props.value.columnWidth === undefined || props.value.columnWidth === null || props.value.columnWidth === 0,
106
132
  );
107
133
 
134
+ /** Fixed pixel size of items if configured as a number. */
108
135
  const fixedItemSize = computed(() =>
109
136
  (typeof props.value.itemSize === 'number' && props.value.itemSize > 0) ? props.value.itemSize : null,
110
137
  );
111
138
 
139
+ /** Fixed pixel width of columns if configured as a number. */
112
140
  const fixedColumnWidth = computed(() =>
113
141
  (typeof props.value.columnWidth === 'number' && props.value.columnWidth > 0) ? props.value.columnWidth : null,
114
142
  );
115
143
 
144
+ /** Fallback size for items before they are measured. */
116
145
  const defaultSize = computed(() => props.value.defaultItemSize || fixedItemSize.value || DEFAULT_ITEM_SIZE);
117
-
118
- // --- Size Management ---
119
146
  const {
120
147
  itemSizesX,
121
148
  itemSizesY,
@@ -124,6 +151,7 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
124
151
  measuredItemsY,
125
152
  treeUpdateFlag,
126
153
  getSizeAt,
154
+ getItemBaseSize,
127
155
  initializeSizes,
128
156
  updateItemSizes: coreUpdateItemSizes,
129
157
  refresh: coreRefresh,
@@ -136,19 +164,6 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
136
164
  direction: direction.value,
137
165
  })));
138
166
 
139
- // --- Scroll Queue / Correction ---
140
- const pendingScroll = ref<{
141
- rowIndex: number | null | undefined;
142
- colIndex: number | null | undefined;
143
- options: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions | undefined;
144
- } | null>(null);
145
-
146
- const sortedStickyIndices = computed(() =>
147
- [ ...(props.value.stickyIndices || []) ].sort((a, b) => a - b),
148
- );
149
-
150
- const stickyIndicesSet = computed(() => new Set(sortedStickyIndices.value));
151
-
152
167
  const paddingStartX = computed(() => getPaddingX(props.value.scrollPaddingStart, props.value.direction));
153
168
  const paddingEndX = computed(() => getPaddingX(props.value.scrollPaddingEnd, props.value.direction));
154
169
  const paddingStartY = computed(() => getPaddingY(props.value.scrollPaddingStart, props.value.direction));
@@ -165,13 +180,8 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
165
180
  const flowEndY = computed(() => getPaddingY(props.value.flowPaddingEnd, props.value.direction));
166
181
 
167
182
  const usableWidth = computed(() => viewportWidth.value - (direction.value !== 'vertical' ? (stickyStartX.value + stickyEndX.value) : 0));
168
-
169
183
  const usableHeight = computed(() => viewportHeight.value - (direction.value !== 'horizontal' ? (stickyStartY.value + stickyEndY.value) : 0));
170
184
 
171
- // --- Size Calculations ---
172
- /**
173
- * Total size (width and height) of all items in the scrollable area.
174
- */
175
185
  const totalSize = computed(() => {
176
186
  // eslint-disable-next-line ts/no-unused-expressions
177
187
  treeUpdateFlag.value;
@@ -193,12 +203,9 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
193
203
  });
194
204
 
195
205
  const isWindowContainer = computed(() => isWindowLike(props.value.container));
196
-
197
206
  const virtualWidth = computed(() => totalSize.value.width + paddingStartX.value + paddingEndX.value);
198
207
  const virtualHeight = computed(() => totalSize.value.height + paddingStartY.value + paddingEndY.value);
199
-
200
208
  const totalWidth = computed(() => (flowStartX.value + stickyStartX.value + stickyEndX.value + flowEndX.value + virtualWidth.value));
201
-
202
209
  const totalHeight = computed(() => (flowStartY.value + stickyStartY.value + stickyEndY.value + flowEndY.value + virtualHeight.value));
203
210
 
204
211
  const componentOffset = reactive({
@@ -208,13 +215,9 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
208
215
 
209
216
  const renderedWidth = computed(() => calculateRenderedSize(isWindowContainer.value, totalWidth.value));
210
217
  const renderedHeight = computed(() => calculateRenderedSize(isWindowContainer.value, totalHeight.value));
211
-
212
218
  const renderedVirtualWidth = computed(() => calculateRenderedSize(isWindowContainer.value, virtualWidth.value));
213
219
  const renderedVirtualHeight = computed(() => calculateRenderedSize(isWindowContainer.value, virtualHeight.value));
214
220
 
215
- const scaleX = computed(() => calculateScale(isWindowContainer.value, totalWidth.value, viewportWidth.value));
216
- const scaleY = computed(() => calculateScale(isWindowContainer.value, totalHeight.value, viewportHeight.value));
217
-
218
221
  const relativeScrollX = computed(() => {
219
222
  if (direction.value === 'vertical') {
220
223
  return 0;
@@ -232,10 +235,41 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
232
235
  });
233
236
 
234
237
  /**
235
- * Returns the currently calculated width for a specific column index, taking measurements and gaps into account.
236
- *
238
+ * Helper to get the row (or item) index at a specific vertical (or horizontal in horizontal mode) virtual offset (VU).
239
+ * @param offset - The virtual pixel offset.
240
+ */
241
+ const getRowIndexAt = (offset: number) => {
242
+ const isHorizontal = direction.value === 'horizontal';
243
+ return calculateIndexAt(
244
+ offset,
245
+ fixedItemSize.value,
246
+ isHorizontal ? (props.value.columnGap || 0) : (props.value.gap || 0),
247
+ (off) => (isHorizontal ? itemSizesX.findLowerBound(off) : itemSizesY.findLowerBound(off)),
248
+ );
249
+ };
250
+
251
+ /**
252
+ * Helper to get the column index at a specific horizontal virtual offset (VU).
253
+ * @param offset - The virtual pixel offset.
254
+ */
255
+ const getColIndexAt = (offset: number) => {
256
+ if (direction.value === 'both') {
257
+ return calculateIndexAt(
258
+ offset,
259
+ fixedColumnWidth.value,
260
+ props.value.columnGap || 0,
261
+ (off) => columnSizes.findLowerBound(off),
262
+ );
263
+ }
264
+ if (direction.value === 'horizontal') {
265
+ return getRowIndexAt(offset);
266
+ }
267
+ return 0;
268
+ };
269
+
270
+ /**
271
+ * Helper to get the width of a specific column.
237
272
  * @param index - The column index.
238
- * @returns The width in pixels (excluding gap).
239
273
  */
240
274
  const getColumnWidth = (index: number) => {
241
275
  if (direction.value === 'both') {
@@ -259,16 +293,13 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
259
293
  };
260
294
 
261
295
  /**
262
- * Returns the currently calculated height for a specific row index, taking measurements and gaps into account.
263
- *
296
+ * Helper to get the height of a specific row.
264
297
  * @param index - The row index.
265
- * @returns The height in pixels (excluding gap).
266
298
  */
267
299
  const getRowHeight = (index: number) => {
268
300
  if (direction.value === 'horizontal') {
269
301
  return usableHeight.value;
270
302
  }
271
-
272
303
  return getSizeAt(
273
304
  index,
274
305
  props.value.itemSize,
@@ -279,21 +310,46 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
279
310
  );
280
311
  };
281
312
 
282
- // --- Public Scroll API ---
283
313
  /**
284
- * Scrolls to a specific row and column index.
285
- *
286
- * @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally. Optional.
287
- * @param colIndex - The column index to scroll to. Pass null to only scroll vertically. Optional.
288
- * @param options - Scroll options including alignment ('start', 'center', 'end', 'auto') and behavior ('auto', 'smooth').
289
- * Defaults to { align: 'auto', behavior: 'auto' }.
314
+ * Helper to get the virtual offset of a specific item.
315
+ * @param index - The item index.
290
316
  */
317
+ const getItemOffset = (index: number) => (direction.value === 'horizontal' ? (flowStartX.value + stickyStartX.value + paddingStartX.value) + calculateOffsetAt(index, fixedItemSize.value, props.value.columnGap || 0, (idx) => itemSizesX.query(idx)) : (flowStartY.value + stickyStartY.value + paddingStartY.value) + calculateOffsetAt(index, fixedItemSize.value, props.value.gap || 0, (idx) => itemSizesY.query(idx)));
318
+
319
+ /**
320
+ * Helper to get the size of a specific item along the scroll axis.
321
+ * @param index - The item index.
322
+ */
323
+ const getItemSize = (index: number) => (direction.value === 'horizontal' ? getColumnWidth(index) : getRowHeight(index));
324
+ const updateDirection = () => {
325
+ if (typeof window === 'undefined') {
326
+ return;
327
+ }
328
+ const container = props.value.container || props.value.hostRef || window;
329
+ const el = isElement(container) ? container : document.documentElement;
330
+ const computedStyle = window.getComputedStyle(el);
331
+ const newRtl = computedStyle.direction === 'rtl';
332
+ if (isRtl.value !== newRtl) {
333
+ isRtl.value = newRtl;
334
+ }
335
+ };
336
+
337
+ const handleScrollCorrection = (addedX: number, addedY: number) => {
338
+ nextTick(() => {
339
+ scrollToOffset(
340
+ addedX > 0 ? relativeScrollX.value + addedX : null,
341
+ addedY > 0 ? relativeScrollY.value + addedY : null,
342
+ { behavior: 'auto' },
343
+ );
344
+ });
345
+ };
346
+
291
347
  function scrollToIndex(
292
348
  rowIndex?: number | null,
293
349
  colIndex?: number | null,
294
350
  options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions,
295
351
  ) {
296
- const isCorrection = typeof options === 'object' && options !== null && 'isCorrection' in options
352
+ const isCorrection = (typeof options === 'object' && options !== null && 'isCorrection' in options)
297
353
  ? options.isCorrection
298
354
  : false;
299
355
 
@@ -324,7 +380,7 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
324
380
  scaleY: scaleY.value,
325
381
  hostOffsetX: componentOffset.x,
326
382
  hostOffsetY: componentOffset.y,
327
- stickyIndices: sortedStickyIndices.value,
383
+ stickyIndices: (props.value.stickyIndices || []),
328
384
  stickyStartX: stickyStartX.value,
329
385
  stickyStartY: stickyStartY.value,
330
386
  stickyEndX: stickyEndX.value,
@@ -351,7 +407,6 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
351
407
 
352
408
  const displayTargetX = virtualToDisplay(targetX, componentOffset.x, scaleX.value);
353
409
  const displayTargetY = virtualToDisplay(targetY, componentOffset.y, scaleY.value);
354
-
355
410
  const finalX = isRtl.value ? -displayTargetX : displayTargetX;
356
411
  const finalY = displayTargetY;
357
412
 
@@ -359,20 +414,25 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
359
414
  if (isScrollToIndexOptions(options)) {
360
415
  behavior = options.behavior;
361
416
  }
362
-
363
417
  const scrollBehavior = isCorrection ? 'auto' : (behavior || 'smooth');
418
+
364
419
  isProgrammaticScroll.value = true;
420
+ clearTimeout(programmaticScrollTimer);
421
+ if (scrollBehavior === 'smooth') {
422
+ programmaticScrollTimer = setTimeout(() => {
423
+ isProgrammaticScroll.value = false;
424
+ programmaticScrollTimer = undefined;
425
+ checkPendingScroll();
426
+ }, 500);
427
+ }
365
428
 
366
- const scrollOptions: ScrollToOptions = {
367
- behavior: scrollBehavior,
368
- };
429
+ const scrollOptions: ScrollToOptions = { behavior: scrollBehavior };
369
430
  if (colIndex !== null && colIndex !== undefined) {
370
431
  scrollOptions.left = isRtl.value ? finalX : Math.max(0, finalX);
371
432
  }
372
433
  if (rowIndex !== null && rowIndex !== undefined) {
373
434
  scrollOptions.top = Math.max(0, finalY);
374
435
  }
375
-
376
436
  scrollTo(container, scrollOptions);
377
437
 
378
438
  if (scrollBehavior === 'auto' || scrollBehavior === undefined) {
@@ -384,35 +444,24 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
384
444
  scrollY.value = Math.max(0, finalY);
385
445
  internalScrollY.value = targetY;
386
446
  }
387
-
388
- if (pendingScroll.value) {
389
- const currentOptions = pendingScroll.value.options;
390
- if (isScrollToIndexOptions(currentOptions)) {
391
- currentOptions.behavior = 'auto';
392
- }
393
- }
394
447
  }
395
448
  }
396
449
 
397
- /**
398
- * Programmatically scroll to a specific pixel offset relative to the content start.
399
- *
400
- * @param x - The pixel offset to scroll to on the X axis. Pass null to keep current position.
401
- * @param y - The pixel offset to scroll to on the Y axis. Pass null to keep current position.
402
- * @param options - Scroll options (behavior).
403
- * @param options.behavior - The scroll behavior ('auto' | 'smooth'). Defaults to 'auto'.
404
- */
405
- const scrollToOffset = (x?: number | null, y?: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => {
450
+ function scrollToOffset(x?: number | null, y?: number | null, options?: { behavior?: 'auto' | 'smooth'; }) {
406
451
  const container = props.value.container || window;
407
452
  isProgrammaticScroll.value = true;
453
+ clearTimeout(programmaticScrollTimer);
454
+ if (options?.behavior === 'smooth') {
455
+ programmaticScrollTimer = setTimeout(() => {
456
+ isProgrammaticScroll.value = false;
457
+ programmaticScrollTimer = undefined;
458
+ checkPendingScroll();
459
+ }, 500);
460
+ }
408
461
  pendingScroll.value = null;
409
462
 
410
- const clampedX = (x !== null && x !== undefined)
411
- ? Math.max(0, Math.min(x, totalWidth.value - viewportWidth.value))
412
- : null;
413
- const clampedY = (y !== null && y !== undefined)
414
- ? Math.max(0, Math.min(y, totalHeight.value - viewportHeight.value))
415
- : null;
463
+ const clampedX = (x !== null && x !== undefined) ? Math.max(0, Math.min(x, totalWidth.value - viewportWidth.value)) : null;
464
+ const clampedY = (y !== null && y !== undefined) ? Math.max(0, Math.min(y, totalHeight.value - viewportHeight.value)) : null;
416
465
 
417
466
  if (clampedX !== null) {
418
467
  internalScrollX.value = clampedX;
@@ -423,25 +472,18 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
423
472
 
424
473
  const currentX = (typeof window !== 'undefined' && container === window ? window.scrollX : (container as HTMLElement).scrollLeft);
425
474
  const currentY = (typeof window !== 'undefined' && container === window ? window.scrollY : (container as HTMLElement).scrollTop);
426
-
427
475
  const displayTargetX = (clampedX !== null) ? virtualToDisplay(clampedX, componentOffset.x, scaleX.value) : null;
428
476
  const displayTargetY = (clampedY !== null) ? virtualToDisplay(clampedY, componentOffset.y, scaleY.value) : null;
429
-
430
- const targetX = (displayTargetX !== null)
431
- ? (isRtl.value ? -displayTargetX : displayTargetX)
432
- : currentX;
477
+ const targetX = (displayTargetX !== null) ? (isRtl.value ? -displayTargetX : displayTargetX) : currentX;
433
478
  const targetY = (displayTargetY !== null) ? displayTargetY : currentY;
434
479
 
435
- const scrollOptions: ScrollToOptions = {
436
- behavior: options?.behavior || 'auto',
437
- };
480
+ const scrollOptions: ScrollToOptions = { behavior: options?.behavior || 'auto' };
438
481
  if (x !== null && x !== undefined) {
439
482
  scrollOptions.left = targetX;
440
483
  }
441
484
  if (y !== null && y !== undefined) {
442
485
  scrollOptions.top = targetY;
443
486
  }
444
-
445
487
  scrollTo(container, scrollOptions);
446
488
 
447
489
  if (options?.behavior === 'auto' || options?.behavior === undefined) {
@@ -452,193 +494,16 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
452
494
  scrollY.value = targetY;
453
495
  }
454
496
  }
455
- };
456
-
457
- // --- Measurement & Initialization ---
458
- const handleScrollCorrection = (addedX: number, addedY: number) => {
459
- nextTick(() => {
460
- scrollToOffset(
461
- addedX > 0 ? relativeScrollX.value + addedX : null,
462
- addedY > 0 ? relativeScrollY.value + addedY : null,
463
- { behavior: 'auto', isCorrection: true } as ScrollToIndexOptions,
464
- );
465
- });
466
- };
467
-
468
- const initialize = () => initializeSizes(handleScrollCorrection);
469
-
470
- /**
471
- * Updates the host element's offset relative to the scroll container.
472
- */
473
- const updateHostOffset = () => {
474
- if (typeof window === 'undefined') {
475
- return;
476
- }
477
- const container = props.value.container || window;
478
-
479
- const calculateOffset = (el: HTMLElement) => {
480
- const rect = el.getBoundingClientRect();
481
- if (container === window) {
482
- return {
483
- x: isRtl.value
484
- ? document.documentElement.clientWidth - rect.right - window.scrollX
485
- : rect.left + window.scrollX,
486
- y: rect.top + window.scrollY,
487
- };
488
- }
489
- if (container === el) {
490
- return { x: 0, y: 0 };
491
- }
492
- if (isElement(container)) {
493
- const containerRect = container.getBoundingClientRect();
494
- return {
495
- x: isRtl.value
496
- ? containerRect.right - rect.right - container.scrollLeft
497
- : rect.left - containerRect.left + container.scrollLeft,
498
- y: rect.top - containerRect.top + container.scrollTop,
499
- };
500
- }
501
- return { x: 0, y: 0 };
502
- };
503
-
504
- if (props.value.hostElement) {
505
- const newOffset = calculateOffset(props.value.hostElement);
506
- if (Math.abs(hostOffset.x - newOffset.x) > 0.1 || Math.abs(hostOffset.y - newOffset.y) > 0.1) {
507
- hostOffset.x = newOffset.x;
508
- hostOffset.y = newOffset.y;
509
- }
510
- }
511
-
512
- if (props.value.hostRef) {
513
- const newOffset = calculateOffset(props.value.hostRef);
514
- if (Math.abs(hostRefOffset.x - newOffset.x) > 0.1 || Math.abs(hostRefOffset.y - newOffset.y) > 0.1) {
515
- hostRefOffset.x = newOffset.x;
516
- hostRefOffset.y = newOffset.y;
517
- }
518
- }
519
- };
520
-
521
- watch([
522
- () => props.value.items,
523
- () => props.value.items.length,
524
- () => props.value.direction,
525
- () => props.value.columnCount,
526
- () => props.value.columnWidth,
527
- () => props.value.itemSize,
528
- () => props.value.gap,
529
- () => props.value.columnGap,
530
- () => props.value.defaultItemSize,
531
- () => props.value.defaultColumnWidth,
532
- ], initialize, { immediate: true });
533
-
534
- watch(() => [ props.value.container, props.value.hostElement ], () => {
535
- updateHostOffset();
536
- });
537
-
538
- watch(isRtl, (newRtl, oldRtl) => {
539
- if (oldRtl === undefined || newRtl === oldRtl || !isMounted.value) {
540
- return;
541
- }
542
-
543
- // Use the oldRtl to correctly interpret the current scrollX
544
- if (direction.value === 'vertical') {
545
- updateHostOffset();
546
- return;
547
- }
548
-
549
- const scrollValue = oldRtl ? Math.abs(scrollX.value) : scrollX.value;
550
- const oldRelativeScrollX = displayToVirtual(scrollValue, hostOffset.x, scaleX.value);
551
-
552
- // Update host offset for the new direction
553
- updateHostOffset();
554
-
555
- // Maintain logical horizontal position when direction changes
556
- scrollToOffset(oldRelativeScrollX, null, { behavior: 'auto' });
557
- }, { flush: 'sync' });
558
-
559
- watch([ scaleX, scaleY ], () => {
560
- if (!isMounted.value || isScrolling.value || isProgrammaticScroll.value) {
561
- return;
562
- }
563
- // Sync display scroll to maintain logical position
564
- scrollToOffset(internalScrollX.value, internalScrollY.value, { behavior: 'auto' });
565
- });
566
-
567
- watch([ () => props.value.items.length, () => props.value.columnCount ], ([ newLen, newColCount ], [ oldLen, oldColCount ]) => {
568
- nextTick(() => {
569
- const maxRelX = Math.max(0, totalWidth.value - viewportWidth.value);
570
- const maxRelY = Math.max(0, totalHeight.value - viewportHeight.value);
571
-
572
- if (internalScrollX.value > maxRelX || internalScrollY.value > maxRelY) {
573
- scrollToOffset(
574
- Math.min(internalScrollX.value, maxRelX),
575
- Math.min(internalScrollY.value, maxRelY),
576
- { behavior: 'auto' },
577
- );
578
- } else if ((newLen !== oldLen && scaleY.value !== 1) || (newColCount !== oldColCount && scaleX.value !== 1)) {
579
- // Even if within bounds, we must sync the display scroll position
580
- // because the coordinate scaling factor changed.
581
- scrollToOffset(internalScrollX.value, internalScrollY.value, { behavior: 'auto' });
582
- }
583
- updateHostOffset();
584
- });
585
- });
586
-
587
- // --- Range & Visible Items ---
588
- /**
589
- * Helper to get the row index (or item index in list mode) at a specific virtual offset.
590
- *
591
- * @param offset - The virtual pixel offset (VU).
592
- * @returns The index at that position.
593
- */
594
- const getRowIndexAt = (offset: number) => {
595
- const isHorizontal = direction.value === 'horizontal';
596
- return calculateIndexAt(
597
- offset,
598
- fixedItemSize.value,
599
- isHorizontal ? (props.value.columnGap || 0) : (props.value.gap || 0),
600
- (off) => (isHorizontal ? itemSizesX.findLowerBound(off) : itemSizesY.findLowerBound(off)),
601
- );
602
- };
603
-
604
- /**
605
- * Helper to get the column index at a specific virtual offset.
606
- *
607
- * @param offset - The virtual pixel offset (VU).
608
- * @returns The column index at that position.
609
- */
610
- const getColIndexAt = (offset: number) => {
611
- if (direction.value === 'both') {
612
- return calculateIndexAt(
613
- offset,
614
- fixedColumnWidth.value,
615
- props.value.columnGap || 0,
616
- (off) => columnSizes.findLowerBound(off),
617
- );
618
- }
619
- if (direction.value === 'horizontal') {
620
- return getRowIndexAt(offset);
621
- }
622
- return 0;
623
- };
497
+ }
624
498
 
625
- /**
626
- * Current range of items that should be rendered.
627
- */
628
499
  const range = computed(() => {
629
500
  // eslint-disable-next-line ts/no-unused-expressions
630
501
  treeUpdateFlag.value;
631
-
632
502
  if ((!isHydrated.value || isHydrating.value) && props.value.ssrRange) {
633
- return {
634
- start: props.value.ssrRange.start,
635
- end: props.value.ssrRange.end,
636
- };
503
+ return { start: props.value.ssrRange.start, end: props.value.ssrRange.end };
637
504
  }
638
-
639
505
  const bufferBefore = (props.value.ssrRange && !isScrolling.value) ? 0 : (props.value.bufferBefore ?? DEFAULT_BUFFER);
640
506
  const bufferAfter = props.value.bufferAfter ?? DEFAULT_BUFFER;
641
-
642
507
  return calculateRange({
643
508
  direction: direction.value,
644
509
  relativeScrollX: relativeScrollX.value,
@@ -658,13 +523,9 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
658
523
  });
659
524
  });
660
525
 
661
- /**
662
- * Index of the first visible item in the viewport.
663
- */
664
526
  const currentIndex = computed(() => {
665
527
  // eslint-disable-next-line ts/no-unused-expressions
666
528
  treeUpdateFlag.value;
667
-
668
529
  const offsetX = relativeScrollX.value + stickyStartX.value;
669
530
  const offsetY = relativeScrollY.value + stickyStartY.value;
670
531
  const offset = direction.value === 'horizontal' ? offsetX : offsetY;
@@ -674,18 +535,14 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
674
535
  const columnRange = computed(() => {
675
536
  // eslint-disable-next-line ts/no-unused-expressions
676
537
  treeUpdateFlag.value;
677
-
678
538
  const totalCols = props.value.columnCount || 0;
679
-
680
539
  if (!totalCols) {
681
540
  return { start: 0, end: 0, padStart: 0, padEnd: 0 };
682
541
  }
683
-
684
542
  if ((!isHydrated.value || isHydrating.value) && props.value.ssrRange) {
685
543
  const { colStart = 0, colEnd = 0 } = props.value.ssrRange;
686
544
  const safeStart = Math.max(0, colStart);
687
545
  const safeEnd = Math.min(totalCols, colEnd || totalCols);
688
-
689
546
  return calculateColumnRange({
690
547
  columnCount: totalCols,
691
548
  relativeScrollX: calculateOffsetAt(safeStart, fixedColumnWidth.value, props.value.columnGap || 0, (idx) => columnSizes.query(idx)),
@@ -698,9 +555,7 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
698
555
  totalColsQuery: () => columnSizes.query(totalCols),
699
556
  });
700
557
  }
701
-
702
558
  const colBuffer = (props.value.ssrRange && !isScrolling.value) ? 0 : 2;
703
-
704
559
  return calculateColumnRange({
705
560
  columnCount: totalCols,
706
561
  relativeScrollX: relativeScrollX.value,
@@ -714,58 +569,72 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
714
569
  });
715
570
  });
716
571
 
717
- /**
718
- * List of items to be rendered with their calculated offsets and sizes.
719
- */
572
+ const ctx: ExtensionContext<T> = {
573
+ props,
574
+ scrollDetails: null as unknown as Ref<ScrollDetails<T>>,
575
+ totalSize: computed(() => ({ width: totalWidth.value, height: totalHeight.value })),
576
+ range,
577
+ currentIndex,
578
+ internalState: {
579
+ scrollX,
580
+ scrollY,
581
+ internalScrollX,
582
+ internalScrollY,
583
+ isRtl,
584
+ isScrolling,
585
+ isProgrammaticScroll,
586
+ viewportWidth,
587
+ viewportHeight,
588
+ scaleX,
589
+ scaleY,
590
+ scrollDirectionX,
591
+ scrollDirectionY,
592
+ relativeScrollX,
593
+ relativeScrollY,
594
+ },
595
+ methods: {
596
+ scrollToIndex,
597
+ scrollToOffset,
598
+ updateDirection,
599
+ getRowIndexAt,
600
+ getColIndexAt,
601
+ getItemSize,
602
+ getItemBaseSize,
603
+ getItemOffset,
604
+ handleScrollCorrection,
605
+ },
606
+ };
720
607
 
721
608
  let lastRenderedItems: RenderedItem<T>[] = [];
722
-
723
609
  const renderedItems = computed<RenderedItem<T>[]>(() => {
724
610
  // eslint-disable-next-line ts/no-unused-expressions
725
611
  treeUpdateFlag.value;
726
-
727
612
  const { start, end } = range.value;
728
613
  const items: RenderedItem<T>[] = [];
729
- const stickyIndices = sortedStickyIndices.value;
730
- const stickySet = stickyIndicesSet.value;
731
-
614
+ const stickyIndices = [ ...(props.value.stickyIndices || []) ].sort((a, b) => a - b);
615
+ const stickySet = new Set(stickyIndices);
732
616
  const sortedIndices: number[] = [];
733
-
734
617
  if (isHydrated.value || !props.value.ssrRange) {
735
618
  const activeIdx = currentIndex.value;
736
619
  const prevStickyIdx = findPrevStickyIndex(stickyIndices, activeIdx);
737
-
738
620
  if (prevStickyIdx !== undefined && prevStickyIdx < start) {
739
621
  sortedIndices.push(prevStickyIdx);
740
622
  }
741
623
  }
742
-
743
624
  for (let i = start; i < end; i++) {
744
625
  sortedIndices.push(i);
745
626
  }
746
-
747
627
  const { x: ssrOffsetX, y: ssrOffsetY } = (!isHydrated.value && props.value.ssrRange)
748
- ? calculateSSROffsets(
749
- direction.value,
750
- props.value.ssrRange,
751
- fixedItemSize.value,
752
- fixedColumnWidth.value,
753
- props.value.gap || 0,
754
- props.value.columnGap || 0,
755
- (idx) => itemSizesY.query(idx),
756
- (idx) => itemSizesX.query(idx),
757
- (idx) => columnSizes.query(idx),
758
- )
628
+ ? calculateSSROffsets(direction.value, props.value.ssrRange, fixedItemSize.value, fixedColumnWidth.value, props.value.gap || 0, props.value.columnGap || 0, (idx) => itemSizesY.query(idx), (idx) => itemSizesX.query(idx), (idx) => columnSizes.query(idx))
759
629
  : { x: 0, y: 0 };
760
-
761
- const lastItemsMap = new Map(lastRenderedItems.map((it) => [ it.index, it ]));
762
-
763
- // Optimization: Cache sequential queries to avoid O(log N) tree traversal for every item
630
+ const lastItemsMap = new Map<number, RenderedItem<T>>();
631
+ for (const item of lastRenderedItems) {
632
+ lastItemsMap.set(item.index, item);
633
+ }
764
634
  let lastIndexX = -1;
765
635
  let lastOffsetX = 0;
766
636
  let lastIndexY = -1;
767
637
  let lastOffsetY = 0;
768
-
769
638
  const queryXCached = (idx: number) => {
770
639
  if (idx === lastIndexX + 1) {
771
640
  lastOffsetX += itemSizesX.get(lastIndexX);
@@ -776,7 +645,6 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
776
645
  lastIndexX = idx;
777
646
  return lastOffsetX;
778
647
  };
779
-
780
648
  const queryYCached = (idx: number) => {
781
649
  if (idx === lastIndexY + 1) {
782
650
  lastOffsetY += itemSizesY.get(lastIndexY);
@@ -787,81 +655,30 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
787
655
  lastIndexY = idx;
788
656
  return lastOffsetY;
789
657
  };
790
-
791
658
  const itemsStartVU_X = flowStartX.value + stickyStartX.value + paddingStartX.value;
792
659
  const itemsStartVU_Y = flowStartY.value + stickyStartY.value + paddingStartY.value;
793
660
  const wrapperStartDU_X = flowStartX.value + stickyStartX.value;
794
661
  const wrapperStartDU_Y = flowStartY.value + stickyStartY.value;
795
-
796
662
  const colRange = columnRange.value;
797
-
663
+ let currentStickyIndexPtr = 0;
798
664
  for (const i of sortedIndices) {
799
665
  const item = props.value.items[ i ];
800
666
  if (item === undefined) {
801
667
  continue;
802
668
  }
803
-
804
- const { x, y, width, height } = calculateItemPosition({
805
- index: i,
806
- direction: direction.value,
807
- fixedSize: fixedItemSize.value,
808
- gap: props.value.gap || 0,
809
- columnGap: props.value.columnGap || 0,
810
- usableWidth: usableWidth.value,
811
- usableHeight: usableHeight.value,
812
- totalWidth: totalSize.value.width,
813
- queryY: queryYCached,
814
- queryX: queryXCached,
815
- getSizeY: (idx) => itemSizesY.get(idx),
816
- getSizeX: (idx) => itemSizesX.get(idx),
817
- columnRange: colRange,
818
- });
819
-
669
+ const { x, y, width, height } = calculateItemPosition({ index: i, direction: direction.value, fixedSize: fixedItemSize.value, gap: props.value.gap || 0, columnGap: props.value.columnGap || 0, usableWidth: usableWidth.value, usableHeight: usableHeight.value, totalWidth: totalSize.value.width, queryY: queryYCached, queryX: queryXCached, getSizeY: (idx) => itemSizesY.get(idx), getSizeX: (idx) => itemSizesX.get(idx), columnRange: colRange });
820
670
  const isSticky = stickySet.has(i);
821
671
  const originalX = x;
822
672
  const originalY = y;
823
-
824
- const { isStickyActive, isStickyActiveX, isStickyActiveY, stickyOffset } = calculateStickyItem({
825
- index: i,
826
- isSticky,
827
- direction: direction.value,
828
- relativeScrollX: relativeScrollX.value,
829
- relativeScrollY: relativeScrollY.value,
830
- originalX,
831
- originalY,
832
- width,
833
- height,
834
- stickyIndices,
835
- fixedSize: fixedItemSize.value,
836
- fixedWidth: fixedColumnWidth.value,
837
- gap: props.value.gap || 0,
838
- columnGap: props.value.columnGap || 0,
839
- getItemQueryY: queryYCached,
840
- getItemQueryX: queryXCached,
841
- });
842
-
843
- const offsetX = isHydrated.value
844
- ? (internalScrollX.value / scaleX.value + (x + itemsStartVU_X - internalScrollX.value)) - wrapperStartDU_X
845
- : (x - ssrOffsetX);
846
- const offsetY = isHydrated.value
847
- ? (internalScrollY.value / scaleY.value + (y + itemsStartVU_Y - internalScrollY.value)) - wrapperStartDU_Y
848
- : (y - ssrOffsetY);
849
-
673
+ while (currentStickyIndexPtr < stickyIndices.length && stickyIndices[ currentStickyIndexPtr ]! <= i) {
674
+ currentStickyIndexPtr++;
675
+ }
676
+ const nextStickyIndex = currentStickyIndexPtr < stickyIndices.length ? stickyIndices[ currentStickyIndexPtr ] : undefined;
677
+ const { isStickyActive, isStickyActiveX, isStickyActiveY, stickyOffset } = calculateStickyItem({ index: i, isSticky, direction: direction.value, relativeScrollX: relativeScrollX.value, relativeScrollY: relativeScrollY.value, originalX, originalY, width, height, stickyIndices, fixedSize: fixedItemSize.value, fixedWidth: fixedColumnWidth.value, gap: props.value.gap || 0, columnGap: props.value.columnGap || 0, getItemQueryY: (idx) => itemSizesY.query(idx), getItemQueryX: (idx) => itemSizesX.query(idx), nextStickyIndex });
678
+ const offsetX = isHydrated.value ? (internalScrollX.value / scaleX.value + (x + itemsStartVU_X - internalScrollX.value)) - wrapperStartDU_X : (x - ssrOffsetX);
679
+ const offsetY = isHydrated.value ? (internalScrollY.value / scaleY.value + (y + itemsStartVU_Y - internalScrollY.value)) - wrapperStartDU_Y : (y - ssrOffsetY);
850
680
  const last = lastItemsMap.get(i);
851
- if (
852
- last
853
- && last.item === item
854
- && last.offset.x === offsetX
855
- && last.offset.y === offsetY
856
- && last.size.width === width
857
- && last.size.height === height
858
- && last.isSticky === isSticky
859
- && last.isStickyActive === isStickyActive
860
- && last.isStickyActiveX === isStickyActiveX
861
- && last.isStickyActiveY === isStickyActiveY
862
- && last.stickyOffset.x === stickyOffset.x
863
- && last.stickyOffset.y === stickyOffset.y
864
- ) {
681
+ if (last && last.item === item && last.offset.x === offsetX && last.offset.y === offsetY && last.size.width === width && last.size.height === height && last.isSticky === isSticky && last.isStickyActive === isStickyActive && last.isStickyActiveX === isStickyActiveX && last.isStickyActiveY === isStickyActiveY && last.stickyOffset.x === stickyOffset.x && last.stickyOffset.y === stickyOffset.y) {
865
682
  items.push(last);
866
683
  } else {
867
684
  items.push({
@@ -879,53 +696,38 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
879
696
  });
880
697
  }
881
698
  }
882
-
883
- lastRenderedItems = items;
884
-
885
- return items;
699
+ let finalItems = items;
700
+ extensions.forEach((ext) => {
701
+ if (ext.transformRenderedItems) {
702
+ finalItems = ext.transformRenderedItems(finalItems, ctx);
703
+ }
704
+ });
705
+ lastRenderedItems = finalItems;
706
+ return finalItems;
886
707
  });
887
708
 
888
- const scrollDetails = computed<ScrollDetails<T>>(() => {
709
+ const computedScrollDetails = computed<ScrollDetails<T>>(() => {
889
710
  // eslint-disable-next-line ts/no-unused-expressions
890
711
  treeUpdateFlag.value;
891
-
892
712
  const currentScrollX = relativeScrollX.value + stickyStartX.value;
893
713
  const currentScrollY = relativeScrollY.value + stickyStartY.value;
894
-
895
714
  const currentEndScrollX = relativeScrollX.value + (viewportWidth.value - stickyEndX.value) - 1;
896
715
  const currentEndScrollY = relativeScrollY.value + (viewportHeight.value - stickyEndY.value) - 1;
897
-
898
716
  const currentColIndex = getColIndexAt(currentScrollX);
899
717
  const currentRowIndex = getRowIndexAt(currentScrollY);
900
718
  const currentEndIndex = getRowIndexAt(direction.value === 'horizontal' ? currentEndScrollX : currentEndScrollY);
901
719
  const currentEndColIndex = getColIndexAt(currentEndScrollX);
902
-
903
720
  return {
904
721
  items: renderedItems.value,
905
722
  currentIndex: currentRowIndex,
906
723
  currentColIndex,
907
724
  currentEndIndex,
908
725
  currentEndColIndex,
909
- scrollOffset: {
910
- x: internalScrollX.value,
911
- y: internalScrollY.value,
912
- },
913
- displayScrollOffset: {
914
- x: isRtl.value ? Math.abs(scrollX.value + hostRefOffset.x) : Math.max(0, scrollX.value - hostRefOffset.x),
915
- y: Math.max(0, scrollY.value - hostRefOffset.y),
916
- },
917
- viewportSize: {
918
- width: viewportWidth.value,
919
- height: viewportHeight.value,
920
- },
921
- displayViewportSize: {
922
- width: viewportWidth.value,
923
- height: viewportHeight.value,
924
- },
925
- totalSize: {
926
- width: totalWidth.value,
927
- height: totalHeight.value,
928
- },
726
+ scrollOffset: { x: internalScrollX.value, y: internalScrollY.value },
727
+ displayScrollOffset: { x: isRtl.value ? Math.abs(scrollX.value + hostRefOffset.x) : Math.max(0, scrollX.value - hostRefOffset.x), y: Math.max(0, scrollY.value - hostRefOffset.y) },
728
+ viewportSize: { width: viewportWidth.value, height: viewportHeight.value },
729
+ displayViewportSize: { width: viewportWidth.value, height: viewportHeight.value },
730
+ totalSize: { width: totalWidth.value, height: totalHeight.value },
929
731
  isScrolling: isScrolling.value,
930
732
  isProgrammaticScroll: isProgrammaticScroll.value,
931
733
  range: range.value,
@@ -933,26 +735,16 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
933
735
  };
934
736
  });
935
737
 
936
- // --- Event Handlers & Lifecycle ---
937
- /**
938
- * Stops any currently active programmatic scroll and clears pending corrections.
939
- */
940
- const stopProgrammaticScroll = () => {
941
- isProgrammaticScroll.value = false;
942
- pendingScroll.value = null;
943
- };
738
+ ctx.scrollDetails = computedScrollDetails;
739
+
740
+ extensions.forEach((ext) => ext.onInit?.(ctx));
944
741
 
945
- /**
946
- * Event handler for scroll events.
947
- */
948
742
  const handleScroll = (e: Event) => {
949
743
  const target = e.target;
950
744
  if (typeof window === 'undefined') {
951
745
  return;
952
746
  }
953
-
954
747
  updateDirection();
955
-
956
748
  if (target === window || target === document) {
957
749
  scrollX.value = window.scrollX;
958
750
  scrollY.value = window.scrollY;
@@ -964,228 +756,127 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
964
756
  viewportWidth.value = target.clientWidth;
965
757
  viewportHeight.value = target.clientHeight;
966
758
  }
967
-
968
759
  const scrollValueX = isRtl.value ? Math.abs(scrollX.value) : scrollX.value;
969
760
  const virtualX = displayToVirtual(scrollValueX, componentOffset.x, scaleX.value);
970
761
  const virtualY = displayToVirtual(scrollY.value, componentOffset.y, scaleY.value);
971
-
972
762
  if (Math.abs(virtualX - lastInternalX) > 0.5) {
973
- scrollDirectionX = virtualX > lastInternalX ? 'end' : 'start';
763
+ scrollDirectionX.value = virtualX > lastInternalX ? 'end' : 'start';
974
764
  lastInternalX = virtualX;
975
765
  }
976
766
  if (Math.abs(virtualY - lastInternalY) > 0.5) {
977
- scrollDirectionY = virtualY > lastInternalY ? 'end' : 'start';
767
+ scrollDirectionY.value = virtualY > lastInternalY ? 'end' : 'start';
978
768
  lastInternalY = virtualY;
979
769
  }
980
-
981
770
  internalScrollX.value = virtualX;
982
771
  internalScrollY.value = virtualY;
983
-
984
772
  if (!isProgrammaticScroll.value) {
985
773
  pendingScroll.value = null;
986
774
  }
987
-
988
775
  if (!isScrolling.value) {
989
776
  isScrolling.value = true;
990
777
  }
778
+ extensions.forEach((ext) => ext.onScroll?.(ctx, e));
991
779
  clearTimeout(scrollTimeout);
992
780
  scrollTimeout = setTimeout(() => {
993
- const wasProgrammatic = isProgrammaticScroll.value;
994
781
  isScrolling.value = false;
995
- isProgrammaticScroll.value = false;
996
-
997
- // Only perform snapping if enabled and the last scroll was user-initiated
998
- if (props.value.snap && !wasProgrammatic) {
999
- const snapProp = props.value.snap;
1000
- const snapMode = snapProp === true ? 'auto' : snapProp;
1001
- const details = scrollDetails.value;
1002
- const itemsLen = props.value.items.length;
1003
-
1004
- let targetRow: number | null = details.currentIndex;
1005
- let targetCol: number | null = details.currentColIndex;
1006
- let alignY: ScrollAlignment = 'start';
1007
- let alignX: ScrollAlignment = 'start';
1008
- let shouldSnap = false;
1009
-
1010
- // Handle Y Axis (Vertical)
1011
- if (direction.value !== 'horizontal') {
1012
- const res = resolveSnap(
1013
- snapMode,
1014
- scrollDirectionY,
1015
- details.currentIndex,
1016
- details.currentEndIndex,
1017
- relativeScrollY.value,
1018
- viewportHeight.value,
1019
- itemsLen,
1020
- (i) => itemSizesY.get(i),
1021
- (i) => itemSizesY.query(i),
1022
- getRowIndexAt,
1023
- );
1024
- if (res) {
1025
- targetRow = res.index;
1026
- alignY = res.align;
1027
- shouldSnap = true;
1028
- }
1029
- }
1030
-
1031
- // Handle X Axis (Horizontal)
1032
- if (direction.value !== 'vertical') {
1033
- const isGrid = direction.value === 'both';
1034
- const colCount = isGrid ? (props.value.columnCount || 0) : itemsLen;
1035
- const res = resolveSnap(
1036
- snapMode,
1037
- scrollDirectionX,
1038
- details.currentColIndex,
1039
- details.currentEndColIndex,
1040
- relativeScrollX.value,
1041
- viewportWidth.value,
1042
- colCount,
1043
- (i) => (isGrid ? columnSizes.get(i) : itemSizesX.get(i)),
1044
- (i) => (isGrid ? columnSizes.query(i) : itemSizesX.query(i)),
1045
- getColIndexAt,
1046
- );
1047
- if (res) {
1048
- targetCol = res.index;
1049
- alignX = res.align;
1050
- shouldSnap = true;
1051
- }
1052
- }
1053
-
1054
- if (shouldSnap) {
1055
- scrollToIndex(targetRow, targetCol, {
1056
- align: { x: alignX, y: alignY },
1057
- behavior: 'smooth',
1058
- });
1059
- }
782
+ extensions.forEach((ext) => ext.onScrollEnd?.(ctx));
783
+ if (programmaticScrollTimer === undefined) {
784
+ isProgrammaticScroll.value = false;
1060
785
  }
1061
- }, 250);
786
+ }, 150);
1062
787
  };
1063
788
 
1064
- /**
1065
- * Updates the size of multiple items in the Fenwick tree.
1066
- *
1067
- * @param updates - Array of updates
1068
- */
1069
789
  const updateItemSizes = (updates: Array<{ index: number; inlineSize: number; blockSize: number; element?: HTMLElement | undefined; }>) => {
1070
- coreUpdateItemSizes(
1071
- updates,
1072
- getRowIndexAt,
1073
- getColIndexAt,
1074
- relativeScrollX.value,
1075
- relativeScrollY.value,
1076
- (dx, dy) => {
1077
- const hasPendingScroll = pendingScroll.value !== null || isProgrammaticScroll.value;
1078
- if (!hasPendingScroll) {
1079
- handleScrollCorrection(dx, dy);
1080
- }
1081
- },
1082
- );
790
+ coreUpdateItemSizes(updates, getRowIndexAt, getColIndexAt, relativeScrollX.value, relativeScrollY.value, (dx, dy) => {
791
+ if (!pendingScroll.value && !isProgrammaticScroll.value) {
792
+ handleScrollCorrection(dx, dy);
793
+ }
794
+ });
1083
795
  };
1084
796
 
1085
- /**
1086
- * Updates the size of a specific item in the Fenwick tree.
1087
- *
1088
- * @param index - Index of the item
1089
- * @param inlineSize - New inlineSize
1090
- * @param blockSize - New blockSize
1091
- * @param element - The element that was measured (optional)
1092
- */
1093
797
  const updateItemSize = (index: number, inlineSize: number, blockSize: number, element?: HTMLElement) => {
1094
798
  updateItemSizes([ { index, inlineSize, blockSize, element } ]);
1095
799
  };
1096
800
 
1097
- // --- Scroll Queue / Correction Watchers ---
1098
801
  function checkPendingScroll() {
1099
802
  if (pendingScroll.value && !isHydrating.value) {
1100
803
  const { rowIndex, colIndex, options } = pendingScroll.value;
1101
-
1102
804
  const isSmooth = isScrollToIndexOptions(options) && options.behavior === 'smooth';
1103
-
1104
- // If it's a smooth scroll, we wait until it's finished before correcting.
1105
- if (isSmooth && isScrolling.value) {
805
+ if (isSmooth && (isScrolling.value || isProgrammaticScroll.value)) {
1106
806
  return;
1107
807
  }
1108
-
1109
808
  const container = props.value.container || window;
1110
809
  const actualScrollX = (typeof window !== 'undefined' && container === window ? window.scrollX : (container as HTMLElement).scrollLeft);
1111
810
  const actualScrollY = (typeof window !== 'undefined' && container === window ? window.scrollY : (container as HTMLElement).scrollTop);
1112
-
1113
811
  const scrollValueX = isRtl.value ? Math.abs(actualScrollX) : actualScrollX;
1114
812
  const scrollValueY = actualScrollY;
1115
-
1116
813
  const currentRelX = displayToVirtual(scrollValueX, 0, scaleX.value);
1117
814
  const currentRelY = displayToVirtual(scrollValueY, 0, scaleY.value);
1118
-
1119
- const { targetX, targetY } = calculateScrollTarget({
1120
- rowIndex,
1121
- colIndex,
1122
- options,
1123
- direction: direction.value,
1124
- viewportWidth: viewportWidth.value,
1125
- viewportHeight: viewportHeight.value,
1126
- totalWidth: virtualWidth.value,
1127
- totalHeight: virtualHeight.value,
1128
- gap: props.value.gap || 0,
1129
- columnGap: props.value.columnGap || 0,
1130
- fixedSize: fixedItemSize.value,
1131
- fixedWidth: fixedColumnWidth.value,
1132
- relativeScrollX: currentRelX,
1133
- relativeScrollY: currentRelY,
1134
- getItemSizeY: (idx) => itemSizesY.get(idx),
1135
- getItemSizeX: (idx) => itemSizesX.get(idx),
1136
- getItemQueryY: (idx) => itemSizesY.query(idx),
1137
- getItemQueryX: (idx) => itemSizesX.query(idx),
1138
- getColumnSize: (idx) => columnSizes.get(idx),
1139
- getColumnQuery: (idx) => columnSizes.query(idx),
1140
- scaleX: scaleX.value,
1141
- scaleY: scaleY.value,
1142
- hostOffsetX: componentOffset.x,
1143
- hostOffsetY: componentOffset.y,
1144
- stickyIndices: sortedStickyIndices.value,
1145
- stickyStartX: stickyStartX.value,
1146
- stickyStartY: stickyStartY.value,
1147
- stickyEndX: stickyEndX.value,
1148
- stickyEndY: stickyEndY.value,
1149
- flowPaddingStartX: flowStartX.value,
1150
- flowPaddingStartY: flowStartY.value,
1151
- paddingStartX: paddingStartX.value,
1152
- paddingStartY: paddingStartY.value,
1153
- paddingEndX: paddingEndX.value,
1154
- paddingEndY: paddingEndY.value,
1155
- });
1156
-
1157
- const toleranceX = 2;
1158
- const toleranceY = 2;
815
+ const { targetX, targetY } = calculateScrollTarget({ rowIndex, colIndex, options, direction: direction.value, viewportWidth: viewportWidth.value, viewportHeight: viewportHeight.value, totalWidth: virtualWidth.value, totalHeight: virtualHeight.value, gap: props.value.gap || 0, columnGap: props.value.columnGap || 0, fixedSize: fixedItemSize.value, fixedWidth: fixedColumnWidth.value, relativeScrollX: currentRelX, relativeScrollY: currentRelY, getItemSizeY: (idx) => itemSizesY.get(idx), getItemSizeX: (idx) => itemSizesX.get(idx), getItemQueryY: (idx) => itemSizesY.query(idx), getItemQueryX: (idx) => itemSizesX.query(idx), getColumnSize: (idx) => columnSizes.get(idx), getColumnQuery: (idx) => columnSizes.query(idx), scaleX: scaleX.value, scaleY: scaleY.value, hostOffsetX: componentOffset.x, hostOffsetY: componentOffset.y, stickyIndices: (props.value.stickyIndices || []), stickyStartX: stickyStartX.value, stickyStartY: stickyStartY.value, stickyEndX: stickyEndX.value, stickyEndY: stickyEndY.value, flowPaddingStartX: flowStartX.value, flowPaddingStartY: flowStartY.value, paddingStartX: paddingStartX.value, paddingStartY: paddingStartY.value, paddingEndX: paddingEndX.value, paddingEndY: paddingEndY.value });
816
+ const toleranceX = 2 * scaleX.value;
817
+ const toleranceY = 2 * scaleY.value;
1159
818
  const reachedX = (colIndex === null || colIndex === undefined) || Math.abs(currentRelX - targetX) < toleranceX;
1160
819
  const reachedY = (rowIndex === null || rowIndex === undefined) || Math.abs(currentRelY - targetY) < toleranceY;
1161
-
1162
- const isMeasuredX = colIndex == null || colIndex === undefined || measuredColumns.value[ colIndex ] === 1;
1163
- const isMeasuredY = rowIndex == null || rowIndex === undefined || measuredItemsY.value[ rowIndex ] === 1;
1164
-
1165
820
  if (reachedX && reachedY) {
821
+ const isMeasuredX = colIndex == null || colIndex === undefined || measuredColumns.value[ colIndex ] === 1;
822
+ const isMeasuredY = rowIndex == null || rowIndex === undefined || measuredItemsY.value[ rowIndex ] === 1;
1166
823
  if (isMeasuredX && isMeasuredY && !isScrolling.value && !isProgrammaticScroll.value) {
1167
824
  pendingScroll.value = null;
1168
825
  }
1169
826
  } else {
1170
- const correctionOptions: ScrollToIndexOptions = isScrollToIndexOptions(options)
1171
- ? { ...options, isCorrection: true }
1172
- : { align: options as ScrollAlignment | ScrollAlignmentOptions, isCorrection: true };
827
+ const correctionOptions: ScrollToIndexOptions = isScrollToIndexOptions(options) ? { ...options, isCorrection: true } : { align: options as ScrollAlignment | ScrollAlignmentOptions, isCorrection: true };
1173
828
  scrollToIndex(rowIndex, colIndex, correctionOptions);
1174
829
  }
1175
830
  }
1176
831
  }
1177
832
 
1178
833
  watch([ treeUpdateFlag, viewportWidth, viewportHeight ], checkPendingScroll);
1179
-
1180
834
  watch(isScrolling, (scrolling) => {
1181
835
  if (!scrolling) {
1182
836
  checkPendingScroll();
1183
837
  }
1184
838
  });
1185
839
 
1186
- let resizeObserver: ResizeObserver | null = null;
1187
- let directionObserver: MutationObserver | null = null;
1188
- let directionInterval: ReturnType<typeof setInterval> | undefined;
840
+ const updateHostOffset = () => {
841
+ if (typeof window === 'undefined') {
842
+ return;
843
+ }
844
+ const container = props.value.container || window;
845
+ const calculateOffset = (el: HTMLElement) => {
846
+ const rect = el.getBoundingClientRect();
847
+ if (container === window) {
848
+ return {
849
+ x: isRtl.value ? document.documentElement.clientWidth - rect.right - window.scrollX : rect.left + window.scrollX,
850
+ y: rect.top + window.scrollY,
851
+ };
852
+ }
853
+ if (container === el) {
854
+ return { x: 0, y: 0 };
855
+ }
856
+ if (isElement(container)) {
857
+ const containerRect = container.getBoundingClientRect();
858
+ return {
859
+ x: isRtl.value ? containerRect.right - rect.right - container.scrollLeft : rect.left - containerRect.left + container.scrollLeft,
860
+ y: rect.top - containerRect.top + container.scrollTop,
861
+ };
862
+ }
863
+ return { x: 0, y: 0 };
864
+ };
865
+ if (props.value.hostElement) {
866
+ const newOffset = calculateOffset(props.value.hostElement);
867
+ if (Math.abs(hostOffset.x - newOffset.x) > 0.1 || Math.abs(hostOffset.y - newOffset.y) > 0.1) {
868
+ hostOffset.x = newOffset.x;
869
+ hostOffset.y = newOffset.y;
870
+ }
871
+ }
872
+ if (props.value.hostRef) {
873
+ const newOffset = calculateOffset(props.value.hostRef);
874
+ if (Math.abs(hostRefOffset.x - newOffset.x) > 0.1 || Math.abs(hostRefOffset.y - newOffset.y) > 0.1) {
875
+ hostRefOffset.x = newOffset.x;
876
+ hostRefOffset.y = newOffset.y;
877
+ }
878
+ }
879
+ };
1189
880
 
1190
881
  const attachEvents = (container: HTMLElement | Window | null) => {
1191
882
  if (typeof window === 'undefined') {
@@ -1193,25 +884,19 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1193
884
  }
1194
885
  const effectiveContainer = container || window;
1195
886
  const scrollTarget = (effectiveContainer === window || (isElement(effectiveContainer) && effectiveContainer === document.documentElement)) ? document : effectiveContainer;
1196
-
1197
887
  scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
1198
-
1199
- computedStyle = null;
1200
888
  updateDirection();
1201
-
889
+ let directionObserver: MutationObserver | null = null;
1202
890
  if (isElement(effectiveContainer)) {
1203
891
  directionObserver = new MutationObserver(() => updateDirection());
1204
892
  directionObserver.observe(effectiveContainer, { attributes: true, attributeFilter: [ 'dir', 'style' ] });
1205
893
  }
1206
-
1207
- directionInterval = setInterval(updateDirection, 1000);
1208
-
894
+ const directionInterval = setInterval(updateDirection, 1000);
1209
895
  if (effectiveContainer === window) {
1210
896
  viewportWidth.value = document.documentElement.clientWidth;
1211
897
  viewportHeight.value = document.documentElement.clientHeight;
1212
898
  scrollX.value = window.scrollX;
1213
899
  scrollY.value = window.scrollY;
1214
-
1215
900
  const onResize = () => {
1216
901
  updateDirection();
1217
902
  viewportWidth.value = document.documentElement.clientWidth;
@@ -1219,66 +904,51 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1219
904
  updateHostOffset();
1220
905
  };
1221
906
  window.addEventListener('resize', onResize);
1222
-
1223
907
  return () => {
1224
908
  scrollTarget.removeEventListener('scroll', handleScroll);
1225
909
  window.removeEventListener('resize', onResize);
1226
910
  directionObserver?.disconnect();
1227
911
  clearInterval(directionInterval);
1228
- computedStyle = null;
1229
912
  };
1230
913
  } else {
1231
914
  viewportWidth.value = (effectiveContainer as HTMLElement).clientWidth;
1232
915
  viewportHeight.value = (effectiveContainer as HTMLElement).clientHeight;
1233
916
  scrollX.value = (effectiveContainer as HTMLElement).scrollLeft;
1234
917
  scrollY.value = (effectiveContainer as HTMLElement).scrollTop;
1235
-
1236
- resizeObserver = new ResizeObserver(() => {
918
+ const resizeObserver = new ResizeObserver(() => {
1237
919
  updateDirection();
1238
920
  viewportWidth.value = (effectiveContainer as HTMLElement).clientWidth;
1239
921
  viewportHeight.value = (effectiveContainer as HTMLElement).clientHeight;
1240
922
  updateHostOffset();
1241
923
  });
1242
924
  resizeObserver.observe(effectiveContainer as HTMLElement);
1243
-
1244
925
  return () => {
1245
926
  scrollTarget.removeEventListener('scroll', handleScroll);
1246
- resizeObserver?.disconnect();
927
+ resizeObserver.disconnect();
1247
928
  directionObserver?.disconnect();
1248
929
  clearInterval(directionInterval);
1249
- computedStyle = null;
1250
930
  };
1251
931
  }
1252
932
  };
1253
933
 
1254
934
  let cleanup: (() => void) | undefined;
1255
-
1256
935
  if (getCurrentInstance()) {
1257
936
  onMounted(() => {
1258
937
  isMounted.value = true;
1259
938
  updateDirection();
1260
-
1261
939
  watch(() => props.value.container, (newContainer) => {
1262
940
  cleanup?.();
1263
941
  cleanup = attachEvents(newContainer || null);
1264
942
  }, { immediate: true });
1265
-
1266
943
  updateHostOffset();
1267
-
1268
- // Ensure we have a layout cycle before considering it hydrated
1269
- // and starting virtualization. This avoids issues with 0-size viewports.
1270
944
  nextTick(() => {
1271
945
  updateHostOffset();
1272
946
  if (props.value.ssrRange || props.value.initialScrollIndex !== undefined) {
1273
- const initialIndex = props.value.initialScrollIndex !== undefined
1274
- ? props.value.initialScrollIndex
1275
- : props.value.ssrRange?.start;
947
+ const initialIndex = props.value.initialScrollIndex !== undefined ? props.value.initialScrollIndex : props.value.ssrRange?.start;
1276
948
  const initialAlign = props.value.initialScrollAlign || 'start';
1277
-
1278
949
  if (initialIndex !== undefined && initialIndex !== null) {
1279
950
  scrollToIndex(initialIndex, props.value.ssrRange?.colStart, { align: initialAlign, behavior: 'auto' });
1280
951
  }
1281
-
1282
952
  isHydrated.value = true;
1283
953
  isHydrating.value = true;
1284
954
  nextTick(() => {
@@ -1289,88 +959,81 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1289
959
  }
1290
960
  });
1291
961
  });
1292
-
1293
962
  onUnmounted(() => {
1294
963
  cleanup?.();
1295
964
  });
1296
965
  }
1297
966
 
1298
- /**
1299
- * The list of items currently rendered in the DOM.
1300
- */
1301
- /**
1302
- * Resets all dynamic measurements and re-initializes from current props.
1303
- * Useful if item source data has changed in a way that affects sizes without changing the items array reference.
1304
- */
1305
- const refresh = () => {
1306
- coreRefresh(handleScrollCorrection);
1307
- };
967
+ watch([
968
+ () => props.value.items,
969
+ () => props.value.items.length,
970
+ () => props.value.direction,
971
+ () => props.value.columnCount,
972
+ () => props.value.columnWidth,
973
+ () => props.value.itemSize,
974
+ () => props.value.gap,
975
+ () => props.value.columnGap,
976
+ () => props.value.defaultItemSize,
977
+ () => props.value.defaultColumnWidth,
978
+ ], () => initializeSizes(), { immediate: true });
979
+
980
+ watch(() => [ props.value.container, props.value.hostElement ], () => {
981
+ updateHostOffset();
982
+ });
983
+ watch(isRtl, (newRtl, oldRtl) => {
984
+ if (oldRtl === undefined || newRtl === oldRtl || !isMounted.value) {
985
+ return;
986
+ }
987
+ if (direction.value === 'vertical') {
988
+ updateHostOffset();
989
+ return;
990
+ }
991
+ const scrollValue = oldRtl ? Math.abs(scrollX.value) : scrollX.value;
992
+ const oldRelativeScrollX = displayToVirtual(scrollValue, hostOffset.x, scaleX.value);
993
+ updateHostOffset();
994
+ scrollToOffset(oldRelativeScrollX, null, { behavior: 'auto' });
995
+ }, { flush: 'sync' });
996
+
997
+ watch([ scaleX, scaleY ], () => {
998
+ if (!isMounted.value || isScrolling.value || isProgrammaticScroll.value) {
999
+ return;
1000
+ }
1001
+ scrollToOffset(internalScrollX.value, internalScrollY.value, { behavior: 'auto' });
1002
+ });
1003
+
1004
+ watch([ () => props.value.items.length, () => props.value.columnCount ], ([ newLen, newColCount ], [ oldLen, oldColCount ]) => {
1005
+ nextTick(() => {
1006
+ const maxRelX = Math.max(0, totalWidth.value - viewportWidth.value);
1007
+ const maxRelY = Math.max(0, totalHeight.value - viewportHeight.value);
1008
+ if (internalScrollX.value > maxRelX || internalScrollY.value > maxRelY) {
1009
+ scrollToOffset(Math.min(internalScrollX.value, maxRelX), Math.min(internalScrollY.value, maxRelY), { behavior: 'auto' });
1010
+ } else if ((newLen !== oldLen && scaleY.value !== 1) || (newColCount !== oldColCount && scaleX.value !== 1)) {
1011
+ scrollToOffset(internalScrollX.value, internalScrollY.value, { behavior: 'auto' });
1012
+ }
1013
+ updateHostOffset();
1014
+ });
1015
+ });
1308
1016
 
1309
1017
  return {
1310
- /**
1311
- * Array of items currently rendered in the DOM with their calculated offsets and sizes.
1312
- * Offsets are in Display Units (DU), sizes are in Virtual Units (VU).
1313
- * @see RenderedItem
1314
- */
1018
+ /** Reactive list of items to render in the current viewport. */
1315
1019
  renderedItems,
1316
-
1317
- /**
1318
- * Total calculated width of all items including gaps (in VU).
1319
- */
1020
+ /** Total calculated width of the scrollable content area (DU). */
1320
1021
  totalWidth,
1321
-
1322
- /**
1323
- * Total calculated height of all items including gaps (in VU).
1324
- */
1022
+ /** Total calculated height of the scrollable content area (DU). */
1325
1023
  totalHeight,
1326
-
1327
- /**
1328
- * Total width to be rendered in the DOM (clamped to browser limits, in DU).
1329
- */
1024
+ /** Physical width of the content in the DOM (clamped to browser limits). */
1330
1025
  renderedWidth,
1331
-
1332
- /**
1333
- * Total height to be rendered in the DOM (clamped to browser limits, in DU).
1334
- */
1026
+ /** Physical height of the content in the DOM (clamped to browser limits). */
1335
1027
  renderedHeight,
1336
-
1337
- /**
1338
- * Detailed information about the current scroll state.
1339
- * Includes currentIndex, scrollOffset (VU), displayScrollOffset (DU), viewportSize (DU), totalSize (VU), and scrolling status.
1340
- * @see ScrollDetails
1341
- */
1342
- scrollDetails,
1343
-
1344
- /**
1345
- * Helper to get the height of a specific row based on current configuration and measurements.
1346
- *
1347
- * @param index - The row index.
1348
- * @returns The height in VU (excluding gap).
1349
- */
1028
+ /** Detailed information about the current scroll state. */
1029
+ scrollDetails: computedScrollDetails,
1030
+ /** Helper to get the height of a specific row. */
1350
1031
  getRowHeight,
1351
-
1352
- /**
1353
- * Helper to get the width of a specific column based on current configuration and measurements.
1354
- *
1355
- * @param index - The column index.
1356
- * @returns The width in VU (excluding gap).
1357
- */
1032
+ /** Helper to get the width of a specific column. */
1358
1033
  getColumnWidth,
1359
-
1360
- /**
1361
- * Helper to get the virtual offset of a specific row.
1362
- *
1363
- * @param index - The row index.
1364
- * @returns The virtual offset in VU.
1365
- */
1034
+ /** Helper to get the virtual offset of a specific row. */
1366
1035
  getRowOffset: (index: number) => (flowStartY.value + stickyStartY.value + paddingStartY.value) + calculateOffsetAt(index, fixedItemSize.value, props.value.gap || 0, (idx) => itemSizesY.query(idx)),
1367
-
1368
- /**
1369
- * Helper to get the virtual offset of a specific column.
1370
- *
1371
- * @param index - The column index.
1372
- * @returns The virtual offset in VU.
1373
- */
1036
+ /** Helper to get the virtual offset of a specific column. */
1374
1037
  getColumnOffset: (index: number) => {
1375
1038
  const itemsStartVU_X = flowStartX.value + stickyStartX.value + paddingStartX.value;
1376
1039
  if (direction.value === 'both') {
@@ -1378,138 +1041,49 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1378
1041
  }
1379
1042
  return itemsStartVU_X + calculateOffsetAt(index, fixedItemSize.value, props.value.columnGap || 0, (idx) => itemSizesX.query(idx));
1380
1043
  },
1381
-
1382
- /**
1383
- * Helper to get the virtual offset of a specific item along the scroll axis.
1384
- *
1385
- * @param index - The item index.
1386
- * @returns The virtual offset in VU.
1387
- */
1388
- getItemOffset: (index: number) => (direction.value === 'horizontal' ? (flowStartX.value + stickyStartX.value + paddingStartX.value) + calculateOffsetAt(index, fixedItemSize.value, props.value.columnGap || 0, (idx) => itemSizesX.query(idx)) : (flowStartY.value + stickyStartY.value + paddingStartY.value) + calculateOffsetAt(index, fixedItemSize.value, props.value.gap || 0, (idx) => itemSizesY.query(idx))),
1389
-
1390
- /**
1391
- * Helper to get the size of a specific item along the scroll axis.
1392
- *
1393
- * @param index - The item index.
1394
- * @returns The size in VU (excluding gap).
1395
- */
1396
- getItemSize: (index: number) => (direction.value === 'horizontal'
1397
- ? getColumnWidth(index)
1398
- : getRowHeight(index)),
1399
-
1400
- /**
1401
- * Programmatically scroll to a specific row and/or column.
1402
- *
1403
- * @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
1404
- * @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
1405
- * @param options - Alignment and behavior options.
1406
- * @see ScrollAlignment
1407
- * @see ScrollToIndexOptions
1408
- */
1044
+ /** Helper to get the virtual offset of a specific item. */
1045
+ getItemOffset,
1046
+ /** Helper to get the size of a specific item along the scroll axis. */
1047
+ getItemSize,
1048
+ /** Programmatically scroll to a specific row and/or column. */
1409
1049
  scrollToIndex,
1410
-
1411
- /**
1412
- * Programmatically scroll to a specific pixel offset relative to the content start.
1413
- *
1414
- * @param x - The pixel offset to scroll to on the X axis (VU). Pass null to keep current position.
1415
- * @param y - The pixel offset to scroll to on the Y axis (VU). Pass null to keep current position.
1416
- * @param options - Scroll options (behavior).
1417
- */
1050
+ /** Programmatically scroll to a specific virtual pixel offset. */
1418
1051
  scrollToOffset,
1419
-
1420
- /**
1421
- * Stops any currently active smooth scroll animation and clears pending corrections.
1422
- */
1052
+ /** Immediately stops any currently active smooth scroll animation and clears pending corrections. */
1423
1053
  stopProgrammaticScroll,
1424
-
1425
- /**
1426
- * Updates the stored size of an item. Should be called when an item is measured (e.g., via ResizeObserver).
1427
- *
1428
- * @param index - The item index.
1429
- * @param width - The measured inlineSize (width in DU).
1430
- * @param height - The measured blockSize (height in DU).
1431
- * @param element - The measured element (optional, used for robust grid column detection).
1432
- */
1054
+ /** Adjusts the scroll position to compensate for measurement changes. */
1055
+ handleScrollCorrection,
1056
+ /** Updates the size of a single item from measurements. */
1433
1057
  updateItemSize,
1434
-
1435
- /**
1436
- * Updates the stored size of multiple items simultaneously.
1437
- *
1438
- * @param updates - Array of measurement updates (sizes in DU).
1439
- */
1058
+ /** Updates the size of multiple items from measurements. */
1440
1059
  updateItemSizes,
1441
-
1442
- /**
1443
- * Recalculates the host element's offset relative to the scroll container.
1444
- * Useful if the container or host moves without a resize event.
1445
- */
1060
+ /** Updates the physical offset of the component relative to its scroll container. */
1446
1061
  updateHostOffset,
1447
-
1448
- /**
1449
- * Detects the current direction (LTR/RTL) of the scroll container.
1450
- */
1062
+ /** Detects the current direction (LTR/RTL) of the scroll container. */
1451
1063
  updateDirection,
1452
-
1453
- /**
1454
- * Information about the current visible range of columns and their paddings.
1455
- * @see ColumnRange
1456
- */
1064
+ /** Information about the currently visible range of columns. */
1457
1065
  columnRange,
1458
-
1459
- /**
1460
- * Resets all dynamic measurements and re-initializes from props.
1461
- * Useful if item sizes have changed externally.
1462
- */
1463
- refresh,
1464
-
1465
- /**
1466
- * Whether the component has finished its first client-side mount and hydration.
1467
- */
1066
+ /** Resets all dynamic measurements and re-initializes from current props. */
1067
+ refresh: () => coreRefresh(),
1068
+ /** Whether the component has finished its first client-side mount. */
1468
1069
  isHydrated,
1469
-
1470
- /**
1471
- * Whether the container is the window or body.
1472
- */
1070
+ /** Whether the scroll container is the window object. */
1473
1071
  isWindowContainer,
1474
-
1475
- /**
1476
- * Whether the scroll container is in Right-to-Left (RTL) mode.
1477
- */
1072
+ /** Whether the scroll container is in Right-to-Left (RTL) mode. */
1478
1073
  isRtl,
1479
-
1480
- /**
1481
- * Coordinate scaling factor for X axis (VU/DU).
1482
- */
1074
+ /** Coordinate scaling factor for X axis. */
1483
1075
  scaleX,
1484
-
1485
- /**
1486
- * Coordinate scaling factor for Y axis (VU/DU).
1487
- */
1076
+ /** Coordinate scaling factor for Y axis. */
1488
1077
  scaleY,
1489
-
1490
- /**
1491
- * Absolute offset of the component within its container (DU).
1492
- */
1078
+ /** Absolute offset of the component within its container. */
1493
1079
  componentOffset,
1494
-
1495
- /**
1496
- * Physical width of the items wrapper in the DOM (clamped to browser limits, in DU).
1497
- */
1080
+ /** Physical width of the virtualized content area (clamped). */
1498
1081
  renderedVirtualWidth,
1499
-
1500
- /**
1501
- * Physical height of the items wrapper in the DOM (clamped to browser limits, in DU).
1502
- */
1082
+ /** Physical height of the virtualized content area (clamped). */
1503
1083
  renderedVirtualHeight,
1504
-
1505
- /**
1506
- * Helper to get the row index at a specific virtual offset.
1507
- */
1084
+ /** Helper to get the row (or item) index at a specific virtual offset (VU). */
1508
1085
  getRowIndexAt,
1509
-
1510
- /**
1511
- * Helper to get the column index at a specific virtual offset.
1512
- */
1086
+ /** Helper to get the column index at a specific virtual offset (VU). */
1513
1087
  getColIndexAt,
1514
1088
  };
1515
1089
  }