@pdanpdan/virtual-scroll 0.9.1 → 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,61 +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
630
  const lastItemsMap = new Map<number, RenderedItem<T>>();
762
631
  for (const item of lastRenderedItems) {
763
632
  lastItemsMap.set(item.index, item);
764
633
  }
765
-
766
- // Optimization: Cache sequential queries to avoid O(log N) tree traversal for every item
767
634
  let lastIndexX = -1;
768
635
  let lastOffsetX = 0;
769
636
  let lastIndexY = -1;
770
637
  let lastOffsetY = 0;
771
-
772
638
  const queryXCached = (idx: number) => {
773
639
  if (idx === lastIndexX + 1) {
774
640
  lastOffsetX += itemSizesX.get(lastIndexX);
@@ -779,7 +645,6 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
779
645
  lastIndexX = idx;
780
646
  return lastOffsetX;
781
647
  };
782
-
783
648
  const queryYCached = (idx: number) => {
784
649
  if (idx === lastIndexY + 1) {
785
650
  lastOffsetY += itemSizesY.get(lastIndexY);
@@ -790,91 +655,30 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
790
655
  lastIndexY = idx;
791
656
  return lastOffsetY;
792
657
  };
793
-
794
658
  const itemsStartVU_X = flowStartX.value + stickyStartX.value + paddingStartX.value;
795
659
  const itemsStartVU_Y = flowStartY.value + stickyStartY.value + paddingStartY.value;
796
660
  const wrapperStartDU_X = flowStartX.value + stickyStartX.value;
797
661
  const wrapperStartDU_Y = flowStartY.value + stickyStartY.value;
798
-
799
662
  const colRange = columnRange.value;
800
-
801
- // Optimization: track sticky index pointer
802
663
  let currentStickyIndexPtr = 0;
803
-
804
664
  for (const i of sortedIndices) {
805
665
  const item = props.value.items[ i ];
806
666
  if (item === undefined) {
807
667
  continue;
808
668
  }
809
-
810
- const { x, y, width, height } = calculateItemPosition({
811
- index: i,
812
- direction: direction.value,
813
- fixedSize: fixedItemSize.value,
814
- gap: props.value.gap || 0,
815
- columnGap: props.value.columnGap || 0,
816
- usableWidth: usableWidth.value,
817
- usableHeight: usableHeight.value,
818
- totalWidth: totalSize.value.width,
819
- queryY: queryYCached,
820
- queryX: queryXCached,
821
- getSizeY: (idx) => itemSizesY.get(idx),
822
- getSizeX: (idx) => itemSizesX.get(idx),
823
- columnRange: colRange,
824
- });
825
-
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 });
826
670
  const isSticky = stickySet.has(i);
827
671
  const originalX = x;
828
672
  const originalY = y;
829
-
830
- // Find next sticky index for optimization
831
673
  while (currentStickyIndexPtr < stickyIndices.length && stickyIndices[ currentStickyIndexPtr ]! <= i) {
832
674
  currentStickyIndexPtr++;
833
675
  }
834
676
  const nextStickyIndex = currentStickyIndexPtr < stickyIndices.length ? stickyIndices[ currentStickyIndexPtr ] : undefined;
835
-
836
- const { isStickyActive, isStickyActiveX, isStickyActiveY, stickyOffset } = calculateStickyItem({
837
- index: i,
838
- isSticky,
839
- direction: direction.value,
840
- relativeScrollX: relativeScrollX.value,
841
- relativeScrollY: relativeScrollY.value,
842
- originalX,
843
- originalY,
844
- width,
845
- height,
846
- stickyIndices,
847
- fixedSize: fixedItemSize.value,
848
- fixedWidth: fixedColumnWidth.value,
849
- gap: props.value.gap || 0,
850
- columnGap: props.value.columnGap || 0,
851
- getItemQueryY: (idx) => itemSizesY.query(idx),
852
- getItemQueryX: (idx) => itemSizesX.query(idx),
853
- nextStickyIndex,
854
- });
855
-
856
- const offsetX = isHydrated.value
857
- ? (internalScrollX.value / scaleX.value + (x + itemsStartVU_X - internalScrollX.value)) - wrapperStartDU_X
858
- : (x - ssrOffsetX);
859
- const offsetY = isHydrated.value
860
- ? (internalScrollY.value / scaleY.value + (y + itemsStartVU_Y - internalScrollY.value)) - wrapperStartDU_Y
861
- : (y - ssrOffsetY);
862
-
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);
863
680
  const last = lastItemsMap.get(i);
864
- if (
865
- last
866
- && last.item === item
867
- && last.offset.x === offsetX
868
- && last.offset.y === offsetY
869
- && last.size.width === width
870
- && last.size.height === height
871
- && last.isSticky === isSticky
872
- && last.isStickyActive === isStickyActive
873
- && last.isStickyActiveX === isStickyActiveX
874
- && last.isStickyActiveY === isStickyActiveY
875
- && last.stickyOffset.x === stickyOffset.x
876
- && last.stickyOffset.y === stickyOffset.y
877
- ) {
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) {
878
682
  items.push(last);
879
683
  } else {
880
684
  items.push({
@@ -892,53 +696,38 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
892
696
  });
893
697
  }
894
698
  }
895
-
896
- lastRenderedItems = items;
897
-
898
- 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;
899
707
  });
900
708
 
901
- const scrollDetails = computed<ScrollDetails<T>>(() => {
709
+ const computedScrollDetails = computed<ScrollDetails<T>>(() => {
902
710
  // eslint-disable-next-line ts/no-unused-expressions
903
711
  treeUpdateFlag.value;
904
-
905
712
  const currentScrollX = relativeScrollX.value + stickyStartX.value;
906
713
  const currentScrollY = relativeScrollY.value + stickyStartY.value;
907
-
908
714
  const currentEndScrollX = relativeScrollX.value + (viewportWidth.value - stickyEndX.value) - 1;
909
715
  const currentEndScrollY = relativeScrollY.value + (viewportHeight.value - stickyEndY.value) - 1;
910
-
911
716
  const currentColIndex = getColIndexAt(currentScrollX);
912
717
  const currentRowIndex = getRowIndexAt(currentScrollY);
913
718
  const currentEndIndex = getRowIndexAt(direction.value === 'horizontal' ? currentEndScrollX : currentEndScrollY);
914
719
  const currentEndColIndex = getColIndexAt(currentEndScrollX);
915
-
916
720
  return {
917
721
  items: renderedItems.value,
918
722
  currentIndex: currentRowIndex,
919
723
  currentColIndex,
920
724
  currentEndIndex,
921
725
  currentEndColIndex,
922
- scrollOffset: {
923
- x: internalScrollX.value,
924
- y: internalScrollY.value,
925
- },
926
- displayScrollOffset: {
927
- x: isRtl.value ? Math.abs(scrollX.value + hostRefOffset.x) : Math.max(0, scrollX.value - hostRefOffset.x),
928
- y: Math.max(0, scrollY.value - hostRefOffset.y),
929
- },
930
- viewportSize: {
931
- width: viewportWidth.value,
932
- height: viewportHeight.value,
933
- },
934
- displayViewportSize: {
935
- width: viewportWidth.value,
936
- height: viewportHeight.value,
937
- },
938
- totalSize: {
939
- width: totalWidth.value,
940
- height: totalHeight.value,
941
- },
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 },
942
731
  isScrolling: isScrolling.value,
943
732
  isProgrammaticScroll: isProgrammaticScroll.value,
944
733
  range: range.value,
@@ -946,26 +735,16 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
946
735
  };
947
736
  });
948
737
 
949
- // --- Event Handlers & Lifecycle ---
950
- /**
951
- * Stops any currently active programmatic scroll and clears pending corrections.
952
- */
953
- const stopProgrammaticScroll = () => {
954
- isProgrammaticScroll.value = false;
955
- pendingScroll.value = null;
956
- };
738
+ ctx.scrollDetails = computedScrollDetails;
739
+
740
+ extensions.forEach((ext) => ext.onInit?.(ctx));
957
741
 
958
- /**
959
- * Event handler for scroll events.
960
- */
961
742
  const handleScroll = (e: Event) => {
962
743
  const target = e.target;
963
744
  if (typeof window === 'undefined') {
964
745
  return;
965
746
  }
966
-
967
747
  updateDirection();
968
-
969
748
  if (target === window || target === document) {
970
749
  scrollX.value = window.scrollX;
971
750
  scrollY.value = window.scrollY;
@@ -977,228 +756,127 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
977
756
  viewportWidth.value = target.clientWidth;
978
757
  viewportHeight.value = target.clientHeight;
979
758
  }
980
-
981
759
  const scrollValueX = isRtl.value ? Math.abs(scrollX.value) : scrollX.value;
982
760
  const virtualX = displayToVirtual(scrollValueX, componentOffset.x, scaleX.value);
983
761
  const virtualY = displayToVirtual(scrollY.value, componentOffset.y, scaleY.value);
984
-
985
762
  if (Math.abs(virtualX - lastInternalX) > 0.5) {
986
- scrollDirectionX = virtualX > lastInternalX ? 'end' : 'start';
763
+ scrollDirectionX.value = virtualX > lastInternalX ? 'end' : 'start';
987
764
  lastInternalX = virtualX;
988
765
  }
989
766
  if (Math.abs(virtualY - lastInternalY) > 0.5) {
990
- scrollDirectionY = virtualY > lastInternalY ? 'end' : 'start';
767
+ scrollDirectionY.value = virtualY > lastInternalY ? 'end' : 'start';
991
768
  lastInternalY = virtualY;
992
769
  }
993
-
994
770
  internalScrollX.value = virtualX;
995
771
  internalScrollY.value = virtualY;
996
-
997
772
  if (!isProgrammaticScroll.value) {
998
773
  pendingScroll.value = null;
999
774
  }
1000
-
1001
775
  if (!isScrolling.value) {
1002
776
  isScrolling.value = true;
1003
777
  }
778
+ extensions.forEach((ext) => ext.onScroll?.(ctx, e));
1004
779
  clearTimeout(scrollTimeout);
1005
780
  scrollTimeout = setTimeout(() => {
1006
- const wasProgrammatic = isProgrammaticScroll.value;
1007
781
  isScrolling.value = false;
1008
- isProgrammaticScroll.value = false;
1009
-
1010
- // Only perform snapping if enabled and the last scroll was user-initiated
1011
- if (props.value.snap && !wasProgrammatic) {
1012
- const snapProp = props.value.snap;
1013
- const snapMode = snapProp === true ? 'auto' : snapProp;
1014
- const details = scrollDetails.value;
1015
- const itemsLen = props.value.items.length;
1016
-
1017
- let targetRow: number | null = details.currentIndex;
1018
- let targetCol: number | null = details.currentColIndex;
1019
- let alignY: ScrollAlignment = 'start';
1020
- let alignX: ScrollAlignment = 'start';
1021
- let shouldSnap = false;
1022
-
1023
- // Handle Y Axis (Vertical)
1024
- if (direction.value !== 'horizontal') {
1025
- const res = resolveSnap(
1026
- snapMode,
1027
- scrollDirectionY,
1028
- details.currentIndex,
1029
- details.currentEndIndex,
1030
- relativeScrollY.value,
1031
- viewportHeight.value,
1032
- itemsLen,
1033
- (i) => itemSizesY.get(i),
1034
- (i) => itemSizesY.query(i),
1035
- getRowIndexAt,
1036
- );
1037
- if (res) {
1038
- targetRow = res.index;
1039
- alignY = res.align;
1040
- shouldSnap = true;
1041
- }
1042
- }
1043
-
1044
- // Handle X Axis (Horizontal)
1045
- if (direction.value !== 'vertical') {
1046
- const isGrid = direction.value === 'both';
1047
- const colCount = isGrid ? (props.value.columnCount || 0) : itemsLen;
1048
- const res = resolveSnap(
1049
- snapMode,
1050
- scrollDirectionX,
1051
- details.currentColIndex,
1052
- details.currentEndColIndex,
1053
- relativeScrollX.value,
1054
- viewportWidth.value,
1055
- colCount,
1056
- (i) => (isGrid ? columnSizes.get(i) : itemSizesX.get(i)),
1057
- (i) => (isGrid ? columnSizes.query(i) : itemSizesX.query(i)),
1058
- getColIndexAt,
1059
- );
1060
- if (res) {
1061
- targetCol = res.index;
1062
- alignX = res.align;
1063
- shouldSnap = true;
1064
- }
1065
- }
1066
-
1067
- if (shouldSnap) {
1068
- scrollToIndex(targetRow, targetCol, {
1069
- align: { x: alignX, y: alignY },
1070
- behavior: 'smooth',
1071
- });
1072
- }
782
+ extensions.forEach((ext) => ext.onScrollEnd?.(ctx));
783
+ if (programmaticScrollTimer === undefined) {
784
+ isProgrammaticScroll.value = false;
1073
785
  }
1074
- }, 250);
786
+ }, 150);
1075
787
  };
1076
788
 
1077
- /**
1078
- * Updates the size of multiple items in the Fenwick tree.
1079
- *
1080
- * @param updates - Array of updates
1081
- */
1082
789
  const updateItemSizes = (updates: Array<{ index: number; inlineSize: number; blockSize: number; element?: HTMLElement | undefined; }>) => {
1083
- coreUpdateItemSizes(
1084
- updates,
1085
- getRowIndexAt,
1086
- getColIndexAt,
1087
- relativeScrollX.value,
1088
- relativeScrollY.value,
1089
- (dx, dy) => {
1090
- const hasPendingScroll = pendingScroll.value !== null || isProgrammaticScroll.value;
1091
- if (!hasPendingScroll) {
1092
- handleScrollCorrection(dx, dy);
1093
- }
1094
- },
1095
- );
790
+ coreUpdateItemSizes(updates, getRowIndexAt, getColIndexAt, relativeScrollX.value, relativeScrollY.value, (dx, dy) => {
791
+ if (!pendingScroll.value && !isProgrammaticScroll.value) {
792
+ handleScrollCorrection(dx, dy);
793
+ }
794
+ });
1096
795
  };
1097
796
 
1098
- /**
1099
- * Updates the size of a specific item in the Fenwick tree.
1100
- *
1101
- * @param index - Index of the item
1102
- * @param inlineSize - New inlineSize
1103
- * @param blockSize - New blockSize
1104
- * @param element - The element that was measured (optional)
1105
- */
1106
797
  const updateItemSize = (index: number, inlineSize: number, blockSize: number, element?: HTMLElement) => {
1107
798
  updateItemSizes([ { index, inlineSize, blockSize, element } ]);
1108
799
  };
1109
800
 
1110
- // --- Scroll Queue / Correction Watchers ---
1111
801
  function checkPendingScroll() {
1112
802
  if (pendingScroll.value && !isHydrating.value) {
1113
803
  const { rowIndex, colIndex, options } = pendingScroll.value;
1114
-
1115
804
  const isSmooth = isScrollToIndexOptions(options) && options.behavior === 'smooth';
1116
-
1117
- // If it's a smooth scroll, we wait until it's finished before correcting.
1118
- if (isSmooth && isScrolling.value) {
805
+ if (isSmooth && (isScrolling.value || isProgrammaticScroll.value)) {
1119
806
  return;
1120
807
  }
1121
-
1122
808
  const container = props.value.container || window;
1123
809
  const actualScrollX = (typeof window !== 'undefined' && container === window ? window.scrollX : (container as HTMLElement).scrollLeft);
1124
810
  const actualScrollY = (typeof window !== 'undefined' && container === window ? window.scrollY : (container as HTMLElement).scrollTop);
1125
-
1126
811
  const scrollValueX = isRtl.value ? Math.abs(actualScrollX) : actualScrollX;
1127
812
  const scrollValueY = actualScrollY;
1128
-
1129
813
  const currentRelX = displayToVirtual(scrollValueX, 0, scaleX.value);
1130
814
  const currentRelY = displayToVirtual(scrollValueY, 0, scaleY.value);
1131
-
1132
- const { targetX, targetY } = calculateScrollTarget({
1133
- rowIndex,
1134
- colIndex,
1135
- options,
1136
- direction: direction.value,
1137
- viewportWidth: viewportWidth.value,
1138
- viewportHeight: viewportHeight.value,
1139
- totalWidth: virtualWidth.value,
1140
- totalHeight: virtualHeight.value,
1141
- gap: props.value.gap || 0,
1142
- columnGap: props.value.columnGap || 0,
1143
- fixedSize: fixedItemSize.value,
1144
- fixedWidth: fixedColumnWidth.value,
1145
- relativeScrollX: currentRelX,
1146
- relativeScrollY: currentRelY,
1147
- getItemSizeY: (idx) => itemSizesY.get(idx),
1148
- getItemSizeX: (idx) => itemSizesX.get(idx),
1149
- getItemQueryY: (idx) => itemSizesY.query(idx),
1150
- getItemQueryX: (idx) => itemSizesX.query(idx),
1151
- getColumnSize: (idx) => columnSizes.get(idx),
1152
- getColumnQuery: (idx) => columnSizes.query(idx),
1153
- scaleX: scaleX.value,
1154
- scaleY: scaleY.value,
1155
- hostOffsetX: componentOffset.x,
1156
- hostOffsetY: componentOffset.y,
1157
- stickyIndices: sortedStickyIndices.value,
1158
- stickyStartX: stickyStartX.value,
1159
- stickyStartY: stickyStartY.value,
1160
- stickyEndX: stickyEndX.value,
1161
- stickyEndY: stickyEndY.value,
1162
- flowPaddingStartX: flowStartX.value,
1163
- flowPaddingStartY: flowStartY.value,
1164
- paddingStartX: paddingStartX.value,
1165
- paddingStartY: paddingStartY.value,
1166
- paddingEndX: paddingEndX.value,
1167
- paddingEndY: paddingEndY.value,
1168
- });
1169
-
1170
- const toleranceX = 2;
1171
- 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;
1172
818
  const reachedX = (colIndex === null || colIndex === undefined) || Math.abs(currentRelX - targetX) < toleranceX;
1173
819
  const reachedY = (rowIndex === null || rowIndex === undefined) || Math.abs(currentRelY - targetY) < toleranceY;
1174
-
1175
- const isMeasuredX = colIndex == null || colIndex === undefined || measuredColumns.value[ colIndex ] === 1;
1176
- const isMeasuredY = rowIndex == null || rowIndex === undefined || measuredItemsY.value[ rowIndex ] === 1;
1177
-
1178
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;
1179
823
  if (isMeasuredX && isMeasuredY && !isScrolling.value && !isProgrammaticScroll.value) {
1180
824
  pendingScroll.value = null;
1181
825
  }
1182
826
  } else {
1183
- const correctionOptions: ScrollToIndexOptions = isScrollToIndexOptions(options)
1184
- ? { ...options, isCorrection: true }
1185
- : { align: options as ScrollAlignment | ScrollAlignmentOptions, isCorrection: true };
827
+ const correctionOptions: ScrollToIndexOptions = isScrollToIndexOptions(options) ? { ...options, isCorrection: true } : { align: options as ScrollAlignment | ScrollAlignmentOptions, isCorrection: true };
1186
828
  scrollToIndex(rowIndex, colIndex, correctionOptions);
1187
829
  }
1188
830
  }
1189
831
  }
1190
832
 
1191
833
  watch([ treeUpdateFlag, viewportWidth, viewportHeight ], checkPendingScroll);
1192
-
1193
834
  watch(isScrolling, (scrolling) => {
1194
835
  if (!scrolling) {
1195
836
  checkPendingScroll();
1196
837
  }
1197
838
  });
1198
839
 
1199
- let resizeObserver: ResizeObserver | null = null;
1200
- let directionObserver: MutationObserver | null = null;
1201
- 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
+ };
1202
880
 
1203
881
  const attachEvents = (container: HTMLElement | Window | null) => {
1204
882
  if (typeof window === 'undefined') {
@@ -1206,25 +884,19 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1206
884
  }
1207
885
  const effectiveContainer = container || window;
1208
886
  const scrollTarget = (effectiveContainer === window || (isElement(effectiveContainer) && effectiveContainer === document.documentElement)) ? document : effectiveContainer;
1209
-
1210
887
  scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
1211
-
1212
- computedStyle = null;
1213
888
  updateDirection();
1214
-
889
+ let directionObserver: MutationObserver | null = null;
1215
890
  if (isElement(effectiveContainer)) {
1216
891
  directionObserver = new MutationObserver(() => updateDirection());
1217
892
  directionObserver.observe(effectiveContainer, { attributes: true, attributeFilter: [ 'dir', 'style' ] });
1218
893
  }
1219
-
1220
- directionInterval = setInterval(updateDirection, 1000);
1221
-
894
+ const directionInterval = setInterval(updateDirection, 1000);
1222
895
  if (effectiveContainer === window) {
1223
896
  viewportWidth.value = document.documentElement.clientWidth;
1224
897
  viewportHeight.value = document.documentElement.clientHeight;
1225
898
  scrollX.value = window.scrollX;
1226
899
  scrollY.value = window.scrollY;
1227
-
1228
900
  const onResize = () => {
1229
901
  updateDirection();
1230
902
  viewportWidth.value = document.documentElement.clientWidth;
@@ -1232,66 +904,51 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1232
904
  updateHostOffset();
1233
905
  };
1234
906
  window.addEventListener('resize', onResize);
1235
-
1236
907
  return () => {
1237
908
  scrollTarget.removeEventListener('scroll', handleScroll);
1238
909
  window.removeEventListener('resize', onResize);
1239
910
  directionObserver?.disconnect();
1240
911
  clearInterval(directionInterval);
1241
- computedStyle = null;
1242
912
  };
1243
913
  } else {
1244
914
  viewportWidth.value = (effectiveContainer as HTMLElement).clientWidth;
1245
915
  viewportHeight.value = (effectiveContainer as HTMLElement).clientHeight;
1246
916
  scrollX.value = (effectiveContainer as HTMLElement).scrollLeft;
1247
917
  scrollY.value = (effectiveContainer as HTMLElement).scrollTop;
1248
-
1249
- resizeObserver = new ResizeObserver(() => {
918
+ const resizeObserver = new ResizeObserver(() => {
1250
919
  updateDirection();
1251
920
  viewportWidth.value = (effectiveContainer as HTMLElement).clientWidth;
1252
921
  viewportHeight.value = (effectiveContainer as HTMLElement).clientHeight;
1253
922
  updateHostOffset();
1254
923
  });
1255
924
  resizeObserver.observe(effectiveContainer as HTMLElement);
1256
-
1257
925
  return () => {
1258
926
  scrollTarget.removeEventListener('scroll', handleScroll);
1259
- resizeObserver?.disconnect();
927
+ resizeObserver.disconnect();
1260
928
  directionObserver?.disconnect();
1261
929
  clearInterval(directionInterval);
1262
- computedStyle = null;
1263
930
  };
1264
931
  }
1265
932
  };
1266
933
 
1267
934
  let cleanup: (() => void) | undefined;
1268
-
1269
935
  if (getCurrentInstance()) {
1270
936
  onMounted(() => {
1271
937
  isMounted.value = true;
1272
938
  updateDirection();
1273
-
1274
939
  watch(() => props.value.container, (newContainer) => {
1275
940
  cleanup?.();
1276
941
  cleanup = attachEvents(newContainer || null);
1277
942
  }, { immediate: true });
1278
-
1279
943
  updateHostOffset();
1280
-
1281
- // Ensure we have a layout cycle before considering it hydrated
1282
- // and starting virtualization. This avoids issues with 0-size viewports.
1283
944
  nextTick(() => {
1284
945
  updateHostOffset();
1285
946
  if (props.value.ssrRange || props.value.initialScrollIndex !== undefined) {
1286
- const initialIndex = props.value.initialScrollIndex !== undefined
1287
- ? props.value.initialScrollIndex
1288
- : props.value.ssrRange?.start;
947
+ const initialIndex = props.value.initialScrollIndex !== undefined ? props.value.initialScrollIndex : props.value.ssrRange?.start;
1289
948
  const initialAlign = props.value.initialScrollAlign || 'start';
1290
-
1291
949
  if (initialIndex !== undefined && initialIndex !== null) {
1292
950
  scrollToIndex(initialIndex, props.value.ssrRange?.colStart, { align: initialAlign, behavior: 'auto' });
1293
951
  }
1294
-
1295
952
  isHydrated.value = true;
1296
953
  isHydrating.value = true;
1297
954
  nextTick(() => {
@@ -1302,88 +959,81 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1302
959
  }
1303
960
  });
1304
961
  });
1305
-
1306
962
  onUnmounted(() => {
1307
963
  cleanup?.();
1308
964
  });
1309
965
  }
1310
966
 
1311
- /**
1312
- * The list of items currently rendered in the DOM.
1313
- */
1314
- /**
1315
- * Resets all dynamic measurements and re-initializes from current props.
1316
- * Useful if item source data has changed in a way that affects sizes without changing the items array reference.
1317
- */
1318
- const refresh = () => {
1319
- coreRefresh(handleScrollCorrection);
1320
- };
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
+ });
1321
1016
 
1322
1017
  return {
1323
- /**
1324
- * Array of items currently rendered in the DOM with their calculated offsets and sizes.
1325
- * Offsets are in Display Units (DU), sizes are in Virtual Units (VU).
1326
- * @see RenderedItem
1327
- */
1018
+ /** Reactive list of items to render in the current viewport. */
1328
1019
  renderedItems,
1329
-
1330
- /**
1331
- * Total calculated width of all items including gaps (in VU).
1332
- */
1020
+ /** Total calculated width of the scrollable content area (DU). */
1333
1021
  totalWidth,
1334
-
1335
- /**
1336
- * Total calculated height of all items including gaps (in VU).
1337
- */
1022
+ /** Total calculated height of the scrollable content area (DU). */
1338
1023
  totalHeight,
1339
-
1340
- /**
1341
- * Total width to be rendered in the DOM (clamped to browser limits, in DU).
1342
- */
1024
+ /** Physical width of the content in the DOM (clamped to browser limits). */
1343
1025
  renderedWidth,
1344
-
1345
- /**
1346
- * Total height to be rendered in the DOM (clamped to browser limits, in DU).
1347
- */
1026
+ /** Physical height of the content in the DOM (clamped to browser limits). */
1348
1027
  renderedHeight,
1349
-
1350
- /**
1351
- * Detailed information about the current scroll state.
1352
- * Includes currentIndex, scrollOffset (VU), displayScrollOffset (DU), viewportSize (DU), totalSize (VU), and scrolling status.
1353
- * @see ScrollDetails
1354
- */
1355
- scrollDetails,
1356
-
1357
- /**
1358
- * Helper to get the height of a specific row based on current configuration and measurements.
1359
- *
1360
- * @param index - The row index.
1361
- * @returns The height in VU (excluding gap).
1362
- */
1028
+ /** Detailed information about the current scroll state. */
1029
+ scrollDetails: computedScrollDetails,
1030
+ /** Helper to get the height of a specific row. */
1363
1031
  getRowHeight,
1364
-
1365
- /**
1366
- * Helper to get the width of a specific column based on current configuration and measurements.
1367
- *
1368
- * @param index - The column index.
1369
- * @returns The width in VU (excluding gap).
1370
- */
1032
+ /** Helper to get the width of a specific column. */
1371
1033
  getColumnWidth,
1372
-
1373
- /**
1374
- * Helper to get the virtual offset of a specific row.
1375
- *
1376
- * @param index - The row index.
1377
- * @returns The virtual offset in VU.
1378
- */
1034
+ /** Helper to get the virtual offset of a specific row. */
1379
1035
  getRowOffset: (index: number) => (flowStartY.value + stickyStartY.value + paddingStartY.value) + calculateOffsetAt(index, fixedItemSize.value, props.value.gap || 0, (idx) => itemSizesY.query(idx)),
1380
-
1381
- /**
1382
- * Helper to get the virtual offset of a specific column.
1383
- *
1384
- * @param index - The column index.
1385
- * @returns The virtual offset in VU.
1386
- */
1036
+ /** Helper to get the virtual offset of a specific column. */
1387
1037
  getColumnOffset: (index: number) => {
1388
1038
  const itemsStartVU_X = flowStartX.value + stickyStartX.value + paddingStartX.value;
1389
1039
  if (direction.value === 'both') {
@@ -1391,138 +1041,49 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1391
1041
  }
1392
1042
  return itemsStartVU_X + calculateOffsetAt(index, fixedItemSize.value, props.value.columnGap || 0, (idx) => itemSizesX.query(idx));
1393
1043
  },
1394
-
1395
- /**
1396
- * Helper to get the virtual offset of a specific item along the scroll axis.
1397
- *
1398
- * @param index - The item index.
1399
- * @returns The virtual offset in VU.
1400
- */
1401
- 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))),
1402
-
1403
- /**
1404
- * Helper to get the size of a specific item along the scroll axis.
1405
- *
1406
- * @param index - The item index.
1407
- * @returns The size in VU (excluding gap).
1408
- */
1409
- getItemSize: (index: number) => (direction.value === 'horizontal'
1410
- ? getColumnWidth(index)
1411
- : getRowHeight(index)),
1412
-
1413
- /**
1414
- * Programmatically scroll to a specific row and/or column.
1415
- *
1416
- * @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
1417
- * @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
1418
- * @param options - Alignment and behavior options.
1419
- * @see ScrollAlignment
1420
- * @see ScrollToIndexOptions
1421
- */
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. */
1422
1049
  scrollToIndex,
1423
-
1424
- /**
1425
- * Programmatically scroll to a specific pixel offset relative to the content start.
1426
- *
1427
- * @param x - The pixel offset to scroll to on the X axis (VU). Pass null to keep current position.
1428
- * @param y - The pixel offset to scroll to on the Y axis (VU). Pass null to keep current position.
1429
- * @param options - Scroll options (behavior).
1430
- */
1050
+ /** Programmatically scroll to a specific virtual pixel offset. */
1431
1051
  scrollToOffset,
1432
-
1433
- /**
1434
- * Stops any currently active smooth scroll animation and clears pending corrections.
1435
- */
1052
+ /** Immediately stops any currently active smooth scroll animation and clears pending corrections. */
1436
1053
  stopProgrammaticScroll,
1437
-
1438
- /**
1439
- * Updates the stored size of an item. Should be called when an item is measured (e.g., via ResizeObserver).
1440
- *
1441
- * @param index - The item index.
1442
- * @param width - The measured inlineSize (width in DU).
1443
- * @param height - The measured blockSize (height in DU).
1444
- * @param element - The measured element (optional, used for robust grid column detection).
1445
- */
1054
+ /** Adjusts the scroll position to compensate for measurement changes. */
1055
+ handleScrollCorrection,
1056
+ /** Updates the size of a single item from measurements. */
1446
1057
  updateItemSize,
1447
-
1448
- /**
1449
- * Updates the stored size of multiple items simultaneously.
1450
- *
1451
- * @param updates - Array of measurement updates (sizes in DU).
1452
- */
1058
+ /** Updates the size of multiple items from measurements. */
1453
1059
  updateItemSizes,
1454
-
1455
- /**
1456
- * Recalculates the host element's offset relative to the scroll container.
1457
- * Useful if the container or host moves without a resize event.
1458
- */
1060
+ /** Updates the physical offset of the component relative to its scroll container. */
1459
1061
  updateHostOffset,
1460
-
1461
- /**
1462
- * Detects the current direction (LTR/RTL) of the scroll container.
1463
- */
1062
+ /** Detects the current direction (LTR/RTL) of the scroll container. */
1464
1063
  updateDirection,
1465
-
1466
- /**
1467
- * Information about the current visible range of columns and their paddings.
1468
- * @see ColumnRange
1469
- */
1064
+ /** Information about the currently visible range of columns. */
1470
1065
  columnRange,
1471
-
1472
- /**
1473
- * Resets all dynamic measurements and re-initializes from props.
1474
- * Useful if item sizes have changed externally.
1475
- */
1476
- refresh,
1477
-
1478
- /**
1479
- * Whether the component has finished its first client-side mount and hydration.
1480
- */
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. */
1481
1069
  isHydrated,
1482
-
1483
- /**
1484
- * Whether the container is the window or body.
1485
- */
1070
+ /** Whether the scroll container is the window object. */
1486
1071
  isWindowContainer,
1487
-
1488
- /**
1489
- * Whether the scroll container is in Right-to-Left (RTL) mode.
1490
- */
1072
+ /** Whether the scroll container is in Right-to-Left (RTL) mode. */
1491
1073
  isRtl,
1492
-
1493
- /**
1494
- * Coordinate scaling factor for X axis (VU/DU).
1495
- */
1074
+ /** Coordinate scaling factor for X axis. */
1496
1075
  scaleX,
1497
-
1498
- /**
1499
- * Coordinate scaling factor for Y axis (VU/DU).
1500
- */
1076
+ /** Coordinate scaling factor for Y axis. */
1501
1077
  scaleY,
1502
-
1503
- /**
1504
- * Absolute offset of the component within its container (DU).
1505
- */
1078
+ /** Absolute offset of the component within its container. */
1506
1079
  componentOffset,
1507
-
1508
- /**
1509
- * Physical width of the items wrapper in the DOM (clamped to browser limits, in DU).
1510
- */
1080
+ /** Physical width of the virtualized content area (clamped). */
1511
1081
  renderedVirtualWidth,
1512
-
1513
- /**
1514
- * Physical height of the items wrapper in the DOM (clamped to browser limits, in DU).
1515
- */
1082
+ /** Physical height of the virtualized content area (clamped). */
1516
1083
  renderedVirtualHeight,
1517
-
1518
- /**
1519
- * Helper to get the row index at a specific virtual offset.
1520
- */
1084
+ /** Helper to get the row (or item) index at a specific virtual offset (VU). */
1521
1085
  getRowIndexAt,
1522
-
1523
- /**
1524
- * Helper to get the column index at a specific virtual offset.
1525
- */
1086
+ /** Helper to get the column index at a specific virtual offset (VU). */
1526
1087
  getColIndexAt,
1527
1088
  };
1528
1089
  }