@pdanpdan/virtual-scroll 0.9.1 → 0.10.1

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