@pdanpdan/virtual-scroll 0.3.0 → 0.4.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,12 @@
1
+ import type {
2
+ RenderedItem,
3
+ ScrollAlignment,
4
+ ScrollAlignmentOptions,
5
+ ScrollDetails,
6
+ ScrollDirection,
7
+ ScrollToIndexOptions,
8
+ VirtualScrollProps,
9
+ } from '../types';
1
10
  import type { Ref } from 'vue';
2
11
 
3
12
  /* global ScrollToOptions */
@@ -5,138 +14,35 @@ import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, reactiv
5
14
 
6
15
  import { FenwickTree } from '../utils/fenwick-tree';
7
16
  import { getPaddingX, getPaddingY, isElement, isScrollableElement, isScrollToIndexOptions } from '../utils/scroll';
17
+ import {
18
+ calculateColumnRange,
19
+ calculateItemPosition,
20
+ calculateRange,
21
+ calculateScrollTarget,
22
+ calculateStickyItem,
23
+ calculateTotalSize,
24
+ } from '../utils/virtual-scroll-logic';
25
+
26
+ export {
27
+ type RenderedItem,
28
+ type ScrollAlignment,
29
+ type ScrollAlignmentOptions,
30
+ type ScrollDetails,
31
+ type ScrollDirection,
32
+ type ScrollToIndexOptions,
33
+ type VirtualScrollProps,
34
+ };
8
35
 
9
36
  export const DEFAULT_ITEM_SIZE = 40;
10
37
  export const DEFAULT_COLUMN_WIDTH = 100;
11
38
  export const DEFAULT_BUFFER = 5;
12
39
 
13
- export type ScrollDirection = 'vertical' | 'horizontal' | 'both';
14
- export type ScrollAlignment = 'start' | 'center' | 'end' | 'auto';
15
-
16
- /** Options for scroll alignment in a single axis or both axes. */
17
- export interface ScrollAlignmentOptions {
18
- /** Alignment on the X axis. */
19
- x?: ScrollAlignment;
20
- /** Alignment on the Y axis. */
21
- y?: ScrollAlignment;
22
- }
23
-
24
- /** Options for the scrollToIndex method. */
25
- export interface ScrollToIndexOptions {
26
- /** Where to align the item in the viewport. */
27
- align?: ScrollAlignment | ScrollAlignmentOptions;
28
- /** Scroll behavior. */
29
- behavior?: 'auto' | 'smooth';
30
- /** Internal flag for recursive correction calls. */
31
- isCorrection?: boolean;
32
- }
33
-
34
- /** Configuration properties for the useVirtualScroll composable. */
35
- export interface VirtualScrollProps<T = unknown> {
36
- /** Array of items to be virtualized. */
37
- items: T[];
38
- /** Fixed size of each item or a function that returns the size of an item. */
39
- itemSize?: number | ((item: T, index: number) => number) | undefined;
40
- /** Direction of the scroll: 'vertical', 'horizontal', or 'both'. */
41
- direction?: ScrollDirection | undefined;
42
- /** Number of items to render before the visible viewport. */
43
- bufferBefore?: number | undefined;
44
- /** Number of items to render after the visible viewport. */
45
- bufferAfter?: number | undefined;
46
- /** The scrollable container element or window. */
47
- container?: HTMLElement | Window | null | undefined;
48
- /** The host element that contains the items. */
49
- hostElement?: HTMLElement | null | undefined;
50
- /** Range of items to render for SSR. */
51
- ssrRange?: {
52
- start: number;
53
- end: number;
54
- colStart?: number;
55
- colEnd?: number;
56
- } | undefined;
57
- /** Number of columns for bidirectional scroll. */
58
- columnCount?: number | undefined;
59
- /** Fixed width of columns or an array/function for column widths. */
60
- columnWidth?: number | number[] | ((index: number) => number) | undefined;
61
- /** Padding at the start of the scroll container. */
62
- scrollPaddingStart?: number | { x?: number; y?: number; } | undefined;
63
- /** Padding at the end of the scroll container. */
64
- scrollPaddingEnd?: number | { x?: number; y?: number; } | undefined;
65
- /** Gap between items in pixels (vertical). */
66
- gap?: number | undefined;
67
- /** Gap between columns in pixels (horizontal/grid). */
68
- columnGap?: number | undefined;
69
- /** Indices of items that should stick to the top/start. */
70
- stickyIndices?: number[] | undefined;
71
- /** Distance from the end of the scrollable area to trigger 'load' event. */
72
- loadDistance?: number | undefined;
73
- /** Whether items are currently being loaded. */
74
- loading?: boolean | undefined;
75
- /** Whether to restore scroll position when items are prepended. */
76
- restoreScrollOnPrepend?: boolean | undefined;
77
- /** Initial scroll index to jump to on mount. */
78
- initialScrollIndex?: number | undefined;
79
- /** Alignment for the initial scroll index. */
80
- initialScrollAlign?: ScrollAlignment | ScrollAlignmentOptions | undefined;
81
- /** Default size for items before they are measured. */
82
- defaultItemSize?: number | undefined;
83
- /** Default width for columns before they are measured. */
84
- defaultColumnWidth?: number | undefined;
85
- /** Whether to enable debug mode. */
86
- debug?: boolean | undefined;
87
- }
88
-
89
- /** Represents an item currently rendered in the virtual scroll area. */
90
- export interface RenderedItem<T = unknown> {
91
- /** The original data item. */
92
- item: T;
93
- /** The index of the item in the original array. */
94
- index: number;
95
- /** The calculated offset relative to the host element. */
96
- offset: { x: number; y: number; };
97
- /** The current measured or estimated size. */
98
- size: { width: number; height: number; };
99
- /** The original X offset before sticky adjustments. */
100
- originalX: number;
101
- /** The original Y offset before sticky adjustments. */
102
- originalY: number;
103
- /** Whether this item is configured to be sticky. */
104
- isSticky?: boolean;
105
- /** Whether this item is currently stuck at the threshold. */
106
- isStickyActive?: boolean;
107
- /** The offset applied for the sticky pushing effect. */
108
- stickyOffset: { x: number; y: number; };
109
- }
110
-
111
- /** Comprehensive state of the virtual scroll system. */
112
- export interface ScrollDetails<T = unknown> {
113
- /** List of items currently rendered. */
114
- items: RenderedItem<T>[];
115
- /** Index of the first item partially or fully visible in the viewport. */
116
- currentIndex: number;
117
- /** Index of the first column partially or fully visible. */
118
- currentColIndex: number;
119
- /** Current scroll position relative to content start. */
120
- scrollOffset: { x: number; y: number; };
121
- /** Dimensions of the visible viewport. */
122
- viewportSize: { width: number; height: number; };
123
- /** Total calculated size of all items and gaps. */
124
- totalSize: { width: number; height: number; };
125
- /** Whether the container is currently being scrolled. */
126
- isScrolling: boolean;
127
- /** Whether the current scroll was initiated by a method call. */
128
- isProgrammaticScroll: boolean;
129
- /** Range of items currently being rendered. */
130
- range: { start: number; end: number; };
131
- /** Range of columns currently being rendered (for grid mode). */
132
- columnRange: { start: number; end: number; padStart: number; padEnd: number; };
133
- }
134
-
135
40
  /**
136
41
  * Composable for virtual scrolling logic.
137
- * Handles calculation of visible items, scroll events, and dynamic item sizes.
42
+ * Handles calculation of visible items, scroll events, dynamic item sizes, and programmatic scrolling.
138
43
  *
139
- * @param props - Reactive properties for virtual scroll configuration
44
+ * @param props - A Ref to the configuration properties.
45
+ * @see VirtualScrollProps
140
46
  */
141
47
  export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>) {
142
48
  // --- State ---
@@ -154,8 +60,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
154
60
  const isProgrammaticScroll = ref(false);
155
61
 
156
62
  // --- Fenwick Trees for efficient size and offset management ---
157
- const itemSizesX = new FenwickTree(props.value.items.length);
158
- const itemSizesY = new FenwickTree(props.value.items.length);
63
+ const itemSizesX = new FenwickTree(props.value.items?.length || 0);
64
+ const itemSizesY = new FenwickTree(props.value.items?.length || 0);
159
65
  const columnSizes = new FenwickTree(props.value.columnCount || 0);
160
66
 
161
67
  const treeUpdateFlag = ref(0);
@@ -188,12 +94,33 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
188
94
  (typeof props.value.itemSize === 'number' && props.value.itemSize > 0) ? props.value.itemSize : null,
189
95
  );
190
96
 
97
+ const fixedColumnWidth = computed(() =>
98
+ (typeof props.value.columnWidth === 'number' && props.value.columnWidth > 0) ? props.value.columnWidth : null,
99
+ );
100
+
191
101
  const defaultSize = computed(() => props.value.defaultItemSize || fixedItemSize.value || DEFAULT_ITEM_SIZE);
192
102
 
193
103
  const sortedStickyIndices = computed(() =>
194
104
  [ ...(props.value.stickyIndices || []) ].sort((a, b) => a - b),
195
105
  );
196
106
 
107
+ const stickyIndicesSet = computed(() => new Set(sortedStickyIndices.value));
108
+
109
+ const paddingStartX = computed(() => getPaddingX(props.value.scrollPaddingStart, props.value.direction));
110
+ const paddingEndX = computed(() => getPaddingX(props.value.scrollPaddingEnd, props.value.direction));
111
+ const paddingStartY = computed(() => getPaddingY(props.value.scrollPaddingStart, props.value.direction));
112
+ const paddingEndY = computed(() => getPaddingY(props.value.scrollPaddingEnd, props.value.direction));
113
+
114
+ const usableWidth = computed(() => {
115
+ const isHorizontal = props.value.direction === 'horizontal' || props.value.direction === 'both';
116
+ return viewportWidth.value - (isHorizontal ? (paddingStartX.value + paddingEndX.value) : 0);
117
+ });
118
+
119
+ const usableHeight = computed(() => {
120
+ const isVertical = props.value.direction === 'vertical' || props.value.direction === 'both';
121
+ return viewportHeight.value - (isVertical ? (paddingStartY.value + paddingEndY.value) : 0);
122
+ });
123
+
197
124
  // --- Size Calculations ---
198
125
  /**
199
126
  * Total width of all items in the scrollable area.
@@ -213,7 +140,6 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
213
140
  const total = columnSizes.query(effectiveColEnd) - columnSizes.query(colStart);
214
141
  return Math.max(0, total - (effectiveColEnd > colStart ? (props.value.columnGap || 0) : 0));
215
142
  }
216
- /* v8 ignore else -- @preserve */
217
143
  if (props.value.direction === 'horizontal') {
218
144
  if (fixedItemSize.value !== null) {
219
145
  const len = end - start;
@@ -224,23 +150,20 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
224
150
  }
225
151
  }
226
152
 
227
- if (props.value.direction === 'both') {
228
- const colCount = props.value.columnCount || 0;
229
- if (colCount <= 0) {
230
- return 0;
231
- }
232
- const total = columnSizes.query(colCount);
233
- return Math.max(0, total - (props.value.columnGap || 0));
234
- }
235
- if (props.value.direction === 'vertical') {
236
- return 0;
237
- }
238
- if (fixedItemSize.value !== null) {
239
- const len = props.value.items.length;
240
- return Math.max(0, len * (fixedItemSize.value + (props.value.columnGap || 0)) - (len > 0 ? (props.value.columnGap || 0) : 0));
241
- }
242
- const total = itemSizesX.query(props.value.items.length);
243
- return Math.max(0, total - (props.value.items.length > 0 ? (props.value.columnGap || 0) : 0));
153
+ return calculateTotalSize({
154
+ direction: props.value.direction || 'vertical',
155
+ itemsLength: props.value.items.length,
156
+ columnCount: props.value.columnCount || 0,
157
+ fixedSize: fixedItemSize.value,
158
+ fixedWidth: fixedColumnWidth.value,
159
+ gap: props.value.gap || 0,
160
+ columnGap: props.value.columnGap || 0,
161
+ usableWidth: usableWidth.value,
162
+ usableHeight: usableHeight.value,
163
+ queryY: (idx) => itemSizesY.query(idx),
164
+ queryX: (idx) => itemSizesX.query(idx),
165
+ queryColumn: (idx) => columnSizes.query(idx),
166
+ }).width;
244
167
  });
245
168
 
246
169
  /**
@@ -252,7 +175,6 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
252
175
 
253
176
  if (!isHydrated.value && props.value.ssrRange && !isMounted.value) {
254
177
  const { start, end } = props.value.ssrRange;
255
- /* v8 ignore else -- @preserve */
256
178
  if (props.value.direction === 'vertical' || props.value.direction === 'both') {
257
179
  if (fixedItemSize.value !== null) {
258
180
  const len = end - start;
@@ -263,15 +185,20 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
263
185
  }
264
186
  }
265
187
 
266
- if (props.value.direction === 'horizontal') {
267
- return 0;
268
- }
269
- if (fixedItemSize.value !== null) {
270
- const len = props.value.items.length;
271
- return Math.max(0, len * (fixedItemSize.value + (props.value.gap || 0)) - (len > 0 ? (props.value.gap || 0) : 0));
272
- }
273
- const total = itemSizesY.query(props.value.items.length);
274
- return Math.max(0, total - (props.value.items.length > 0 ? (props.value.gap || 0) : 0));
188
+ return calculateTotalSize({
189
+ direction: props.value.direction || 'vertical',
190
+ itemsLength: props.value.items.length,
191
+ columnCount: props.value.columnCount || 0,
192
+ fixedSize: fixedItemSize.value,
193
+ fixedWidth: fixedColumnWidth.value,
194
+ gap: props.value.gap || 0,
195
+ columnGap: props.value.columnGap || 0,
196
+ usableWidth: usableWidth.value,
197
+ usableHeight: usableHeight.value,
198
+ queryY: (idx) => itemSizesY.query(idx),
199
+ queryX: (idx) => itemSizesX.query(idx),
200
+ queryColumn: (idx) => columnSizes.query(idx),
201
+ }).height;
275
202
  });
276
203
 
277
204
  const relativeScrollX = computed(() => {
@@ -298,7 +225,6 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
298
225
  const val = cw[ index % cw.length ];
299
226
  return (val != null && val > 0) ? val : (props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH);
300
227
  }
301
- /* v8 ignore else -- @preserve */
302
228
  if (typeof cw === 'function') {
303
229
  return cw(index);
304
230
  }
@@ -312,6 +238,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
312
238
  * @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
313
239
  * @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
314
240
  * @param options - Scroll options including alignment ('start', 'center', 'end', 'auto') and behavior ('auto', 'smooth').
241
+ * Defaults to { align: 'auto', behavior: 'auto' }.
315
242
  */
316
243
  const scrollToIndex = (
317
244
  rowIndex: number | null | undefined,
@@ -322,162 +249,55 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
322
249
  ? options.isCorrection
323
250
  : false;
324
251
 
325
- if (!isCorrection) {
326
- pendingScroll.value = { rowIndex, colIndex, options };
327
- }
328
-
329
252
  const container = props.value.container || window;
330
- const fixedSize = fixedItemSize.value;
331
- const gap = props.value.gap || 0;
332
- const columnGap = props.value.columnGap || 0;
333
-
334
- let align: ScrollAlignment | ScrollAlignmentOptions | undefined;
335
- let behavior: 'auto' | 'smooth' | undefined;
336
-
337
- if (isScrollToIndexOptions(options)) {
338
- align = options.align;
339
- behavior = options.behavior;
340
- } else {
341
- align = options as ScrollAlignment | ScrollAlignmentOptions;
342
- }
343
-
344
- const alignX = (typeof align === 'object' ? align.x : align) || 'auto';
345
- const alignY = (typeof align === 'object' ? align.y : align) || 'auto';
346
-
347
- const paddingStartX = getPaddingX(props.value.scrollPaddingStart, props.value.direction);
348
- const paddingEndX = getPaddingX(props.value.scrollPaddingEnd, props.value.direction);
349
- const paddingStartY = getPaddingY(props.value.scrollPaddingStart, props.value.direction);
350
- const paddingEndY = getPaddingY(props.value.scrollPaddingEnd, props.value.direction);
351
253
 
352
254
  const isVertical = props.value.direction === 'vertical' || props.value.direction === 'both';
353
255
  const isHorizontal = props.value.direction === 'horizontal' || props.value.direction === 'both';
354
256
 
355
- const usableWidth = viewportWidth.value - (isHorizontal ? (paddingStartX + paddingEndX) : 0);
356
- const usableHeight = viewportHeight.value - (isVertical ? (paddingStartY + paddingEndY) : 0);
357
-
358
- let targetX = relativeScrollX.value;
359
- let targetY = relativeScrollY.value;
360
- let itemWidth = 0;
361
- let itemHeight = 0;
362
-
363
- // Y calculation
364
- if (rowIndex !== null && rowIndex !== undefined) {
365
- if (rowIndex >= props.value.items.length) {
366
- targetY = totalHeight.value;
367
- itemHeight = 0;
368
- } else {
369
- targetY = fixedSize !== null ? rowIndex * (fixedSize + gap) : itemSizesY.query(rowIndex);
370
- itemHeight = fixedSize !== null ? fixedSize : itemSizesY.get(rowIndex) - gap;
371
- }
372
-
373
- // Apply Y Alignment
374
- if (alignY === 'start') {
375
- // targetY is already at the start of the list
376
- } else if (alignY === 'center') {
377
- targetY -= (usableHeight - itemHeight) / 2;
378
- } else if (alignY === 'end') {
379
- targetY -= (usableHeight - itemHeight);
380
- } else {
381
- const isVisibleY = targetY >= relativeScrollY.value && (targetY + itemHeight) <= (relativeScrollY.value + usableHeight);
382
- if (!isVisibleY) {
383
- if (targetY < relativeScrollY.value) {
384
- // keep targetY at start
385
- } else {
386
- targetY -= (usableHeight - itemHeight);
387
- }
388
- }
389
- }
390
- }
391
-
392
- // X calculation
393
- if (colIndex !== null && colIndex !== undefined) {
394
- const totalCols = props.value.columnCount || 0;
395
- if (colIndex >= totalCols && totalCols > 0) {
396
- targetX = totalWidth.value;
397
- itemWidth = 0;
398
- } else if (props.value.direction === 'horizontal') {
399
- targetX = fixedSize !== null ? colIndex * (fixedSize + columnGap) : itemSizesX.query(colIndex);
400
- itemWidth = fixedSize !== null ? fixedSize : itemSizesX.get(colIndex) - columnGap;
401
- } else {
402
- targetX = columnSizes.query(colIndex);
403
- itemWidth = columnSizes.get(colIndex) - columnGap;
404
- }
257
+ const { targetX, targetY, effectiveAlignX, effectiveAlignY } = calculateScrollTarget({
258
+ rowIndex,
259
+ colIndex,
260
+ options,
261
+ itemsLength: props.value.items.length,
262
+ columnCount: props.value.columnCount || 0,
263
+ direction: props.value.direction || 'vertical',
264
+ usableWidth: usableWidth.value,
265
+ usableHeight: usableHeight.value,
266
+ totalWidth: totalWidth.value,
267
+ totalHeight: totalHeight.value,
268
+ gap: props.value.gap || 0,
269
+ columnGap: props.value.columnGap || 0,
270
+ fixedSize: fixedItemSize.value,
271
+ fixedWidth: fixedColumnWidth.value,
272
+ relativeScrollX: relativeScrollX.value,
273
+ relativeScrollY: relativeScrollY.value,
274
+ getItemSizeY: (idx) => itemSizesY.get(idx),
275
+ getItemSizeX: (idx) => itemSizesX.get(idx),
276
+ getItemQueryY: (idx) => itemSizesY.query(idx),
277
+ getItemQueryX: (idx) => itemSizesX.query(idx),
278
+ getColumnSize: (idx) => columnSizes.get(idx),
279
+ getColumnQuery: (idx) => columnSizes.query(idx),
280
+ stickyIndices: sortedStickyIndices.value,
281
+ });
405
282
 
406
- // Apply X Alignment
407
- if (alignX === 'start') {
408
- // targetX is already at the start of the list
409
- } else if (alignX === 'center') {
410
- targetX -= (usableWidth - itemWidth) / 2;
411
- } else if (alignX === 'end') {
412
- targetX -= (usableWidth - itemWidth);
413
- } else {
414
- const isVisibleX = targetX >= relativeScrollX.value && (targetX + itemWidth) <= (relativeScrollX.value + usableWidth);
415
- if (!isVisibleX) {
416
- /* v8 ignore if -- @preserve */
417
- if (targetX < relativeScrollX.value) {
418
- // keep targetX at start
419
- } else {
420
- targetX -= (usableWidth - itemWidth);
421
- }
422
- }
423
- }
283
+ if (!isCorrection) {
284
+ const behavior = isScrollToIndexOptions(options) ? options.behavior : undefined;
285
+ pendingScroll.value = {
286
+ rowIndex,
287
+ colIndex,
288
+ options: {
289
+ align: { x: effectiveAlignX, y: effectiveAlignY },
290
+ ...(behavior != null ? { behavior } : {}),
291
+ },
292
+ };
424
293
  }
425
294
 
426
- // Clamp to valid range
427
- targetX = Math.max(0, Math.min(targetX, Math.max(0, totalWidth.value - usableWidth)));
428
- targetY = Math.max(0, Math.min(targetY, Math.max(0, totalHeight.value - usableHeight)));
429
-
430
- const finalX = targetX + hostOffset.x - (isHorizontal ? paddingStartX : 0);
431
- const finalY = targetY + hostOffset.y - (isVertical ? paddingStartY : 0);
432
-
433
- // Check if we reached the target
434
- const tolerance = 1;
435
- let reachedX = (colIndex === null || colIndex === undefined) || Math.abs(relativeScrollX.value - targetX) < tolerance;
436
- let reachedY = (rowIndex === null || rowIndex === undefined) || Math.abs(relativeScrollY.value - targetY) < tolerance;
437
-
438
- if (!reachedX || !reachedY) {
439
- let curX = 0;
440
- let curY = 0;
441
- let maxW = 0;
442
- let maxH = 0;
443
- let viewW = 0;
444
- let viewH = 0;
445
-
446
- /* v8 ignore else -- @preserve */
447
- if (typeof window !== 'undefined') {
448
- if (container === window) {
449
- curX = window.scrollX;
450
- curY = window.scrollY;
451
- maxW = document.documentElement.scrollWidth;
452
- maxH = document.documentElement.scrollHeight;
453
- viewW = window.innerWidth;
454
- viewH = window.innerHeight;
455
- } else if (isElement(container)) {
456
- curX = container.scrollLeft;
457
- curY = container.scrollTop;
458
- maxW = container.scrollWidth;
459
- maxH = container.scrollHeight;
460
- viewW = container.clientWidth;
461
- viewH = container.clientHeight;
462
- }
463
-
464
- if (!reachedX && colIndex !== null && colIndex !== undefined) {
465
- const atLeft = curX <= tolerance && finalX <= tolerance;
466
- const atRight = curX >= maxW - viewW - tolerance && finalX >= maxW - viewW - tolerance;
467
- /* v8 ignore else -- @preserve */
468
- if (atLeft || atRight) {
469
- reachedX = true;
470
- }
471
- }
295
+ const finalX = targetX + hostOffset.x - (isHorizontal ? paddingStartX.value : 0);
296
+ const finalY = targetY + hostOffset.y - (isVertical ? paddingStartY.value : 0);
472
297
 
473
- if (!reachedY && rowIndex !== null && rowIndex !== undefined) {
474
- const atTop = curY <= tolerance && finalY <= tolerance;
475
- const atBottom = curY >= maxH - viewH - tolerance && finalY >= maxH - viewH - tolerance;
476
- if (atTop || atBottom) {
477
- reachedY = true;
478
- }
479
- }
480
- }
298
+ let behavior: 'auto' | 'smooth' | undefined;
299
+ if (isScrollToIndexOptions(options)) {
300
+ behavior = options.behavior;
481
301
  }
482
302
 
483
303
  const scrollBehavior = isCorrection ? 'auto' : (behavior || 'smooth');
@@ -522,9 +342,9 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
522
342
  }
523
343
  }
524
344
 
525
- if (reachedX && reachedY && !isScrolling.value) {
526
- pendingScroll.value = null;
527
- }
345
+ // We do NOT clear pendingScroll here anymore.
346
+ // It will be cleared in checkPendingScroll when fully stable,
347
+ // or in handleScroll if the user manually scrolls.
528
348
  };
529
349
 
530
350
  /**
@@ -533,7 +353,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
533
353
  * @param x - The pixel offset to scroll to on the X axis. Pass null to keep current position.
534
354
  * @param y - The pixel offset to scroll to on the Y axis. Pass null to keep current position.
535
355
  * @param options - Scroll options (behavior)
536
- * @param options.behavior - The scroll behavior ('auto' | 'smooth')
356
+ * @param options.behavior - The scroll behavior ('auto' | 'smooth'). Defaults to 'auto'.
537
357
  */
538
358
  const scrollToOffset = (x?: number | null, y?: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => {
539
359
  const container = props.value.container || window;
@@ -542,26 +362,18 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
542
362
  const isVertical = props.value.direction === 'vertical' || props.value.direction === 'both';
543
363
  const isHorizontal = props.value.direction === 'horizontal' || props.value.direction === 'both';
544
364
 
545
- const paddingStartX = getPaddingX(props.value.scrollPaddingStart, props.value.direction);
546
- const paddingStartY = getPaddingY(props.value.scrollPaddingStart, props.value.direction);
547
- const paddingEndX = getPaddingX(props.value.scrollPaddingEnd, props.value.direction);
548
- const paddingEndY = getPaddingY(props.value.scrollPaddingEnd, props.value.direction);
549
-
550
- const usableWidth = viewportWidth.value - (isHorizontal ? (paddingStartX + paddingEndX) : 0);
551
- const usableHeight = viewportHeight.value - (isVertical ? (paddingStartY + paddingEndY) : 0);
552
-
553
365
  const clampedX = (x !== null && x !== undefined)
554
- ? (isHorizontal ? Math.max(0, Math.min(x, Math.max(0, totalWidth.value - usableWidth))) : Math.max(0, x))
366
+ ? (isHorizontal ? Math.max(0, Math.min(x, Math.max(0, totalWidth.value - usableWidth.value))) : Math.max(0, x))
555
367
  : null;
556
368
  const clampedY = (y !== null && y !== undefined)
557
- ? (isVertical ? Math.max(0, Math.min(y, Math.max(0, totalHeight.value - usableHeight))) : Math.max(0, y))
369
+ ? (isVertical ? Math.max(0, Math.min(y, Math.max(0, totalHeight.value - usableHeight.value))) : Math.max(0, y))
558
370
  : null;
559
371
 
560
372
  const currentX = (typeof window !== 'undefined' && container === window ? window.scrollX : (container as HTMLElement).scrollLeft);
561
373
  const currentY = (typeof window !== 'undefined' && container === window ? window.scrollY : (container as HTMLElement).scrollTop);
562
374
 
563
- const targetX = (clampedX !== null) ? clampedX + hostOffset.x - (isHorizontal ? paddingStartX : 0) : currentX;
564
- const targetY = (clampedY !== null) ? clampedY + hostOffset.y - (isVertical ? paddingStartY : 0) : currentY;
375
+ const targetX = (clampedX !== null) ? clampedX + hostOffset.x - (isHorizontal ? paddingStartX.value : 0) : currentX;
376
+ const targetY = (clampedY !== null) ? clampedY + hostOffset.y - (isVertical ? paddingStartY.value : 0) : currentY;
565
377
 
566
378
  if (typeof window !== 'undefined' && container === window) {
567
379
  window.scrollTo({
@@ -632,7 +444,6 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
632
444
  let prependCount = 0;
633
445
  if (props.value.restoreScrollOnPrepend && lastItems.length > 0 && len > lastItems.length) {
634
446
  const oldFirstItem = lastItems[ 0 ];
635
- /* v8 ignore else -- @preserve */
636
447
  if (oldFirstItem !== undefined) {
637
448
  for (let i = 1; i <= len - lastItems.length; i++) {
638
449
  if (newItems[ i ] === oldFirstItem) {
@@ -676,7 +487,6 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
676
487
  }
677
488
  }
678
489
 
679
- /* v8 ignore else -- @preserve */
680
490
  if (addedX > 0 || addedY > 0) {
681
491
  nextTick(() => {
682
492
  scrollToOffset(
@@ -688,18 +498,29 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
688
498
  }
689
499
  }
690
500
 
691
- // Initialize columns if fixed width is provided
501
+ // Initialize columns
692
502
  if (colCount > 0) {
693
503
  const columnGap = props.value.columnGap || 0;
694
504
  let colNeedsRebuild = false;
505
+ const cw = props.value.columnWidth;
506
+
695
507
  for (let i = 0; i < colCount; i++) {
696
- const width = getColumnWidth(i);
697
508
  const currentW = columnSizes.get(i);
698
509
  const isMeasured = measuredColumns[ i ] === 1;
699
510
 
700
- // If fixed/function, or if dynamic but not measured yet
701
- if (!isDynamicColumnWidth.value || !isMeasured || currentW === 0) {
702
- const targetW = width + columnGap;
511
+ if (!isDynamicColumnWidth.value || (!isMeasured && currentW === 0)) {
512
+ let baseWidth = 0;
513
+ if (typeof cw === 'number' && cw > 0) {
514
+ baseWidth = cw;
515
+ } else if (Array.isArray(cw) && cw.length > 0) {
516
+ baseWidth = cw[ i % cw.length ] || props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
517
+ } else if (typeof cw === 'function') {
518
+ baseWidth = cw(i);
519
+ } else {
520
+ baseWidth = props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
521
+ }
522
+
523
+ const targetW = baseWidth + columnGap;
703
524
  if (Math.abs(currentW - targetW) > 0.5) {
704
525
  columnSizes.set(i, targetW);
705
526
  measuredColumns[ i ] = isDynamicColumnWidth.value ? 0 : 1;
@@ -723,24 +544,21 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
723
544
  const currentX = itemSizesX.get(i);
724
545
  const currentY = itemSizesY.get(i);
725
546
 
726
- const size = typeof props.value.itemSize === 'function'
727
- ? props.value.itemSize(item as T, i)
728
- : defaultSize.value;
729
-
730
547
  const isVertical = props.value.direction === 'vertical';
731
548
  const isHorizontal = props.value.direction === 'horizontal';
732
549
  const isBoth = props.value.direction === 'both';
733
550
 
734
- const targetX = isHorizontal ? size + columnGap : 0;
735
- const targetY = (isVertical || isBoth) ? size + gap : 0;
736
-
737
551
  const isMeasuredX = measuredItemsX[ i ] === 1;
738
552
  const isMeasuredY = measuredItemsY[ i ] === 1;
739
553
 
740
554
  // Logic for X
741
555
  if (isHorizontal) {
742
- // If fixed/function, or if dynamic but not measured yet
743
- if (!isDynamicItemSize.value || !isMeasuredX || currentX === 0) {
556
+ if (!isDynamicItemSize.value || (!isMeasuredX && currentX === 0)) {
557
+ const baseSize = typeof props.value.itemSize === 'function'
558
+ ? props.value.itemSize(item as T, i)
559
+ : defaultSize.value;
560
+ const targetX = baseSize + columnGap;
561
+
744
562
  if (Math.abs(currentX - targetX) > 0.5) {
745
563
  itemSizesX.set(i, targetX);
746
564
  measuredItemsX[ i ] = isDynamicItemSize.value ? 0 : 1;
@@ -757,7 +575,12 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
757
575
 
758
576
  // Logic for Y
759
577
  if (isVertical || isBoth) {
760
- if (!isDynamicItemSize.value || !isMeasuredY || currentY === 0) {
578
+ if (!isDynamicItemSize.value || (!isMeasuredY && currentY === 0)) {
579
+ const baseSize = typeof props.value.itemSize === 'function'
580
+ ? props.value.itemSize(item as T, i)
581
+ : defaultSize.value;
582
+ const targetY = baseSize + gap;
583
+
761
584
  if (Math.abs(currentY - targetY) > 0.5) {
762
585
  itemSizesY.set(i, targetY);
763
586
  measuredItemsY[ i ] = isDynamicItemSize.value ? 0 : 1;
@@ -815,6 +638,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
815
638
 
816
639
  watch([
817
640
  () => props.value.items,
641
+ () => props.value.items.length,
818
642
  () => props.value.direction,
819
643
  () => props.value.columnCount,
820
644
  () => props.value.columnWidth,
@@ -844,58 +668,26 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
844
668
  };
845
669
  }
846
670
 
847
- const direction = props.value.direction || 'vertical';
848
671
  const bufferBefore = (props.value.ssrRange && !isScrolling.value) ? 0 : (props.value.bufferBefore ?? DEFAULT_BUFFER);
849
672
  const bufferAfter = props.value.bufferAfter ?? DEFAULT_BUFFER;
850
- const gap = props.value.gap || 0;
851
- const columnGap = props.value.columnGap || 0;
852
- const fixedSize = fixedItemSize.value;
853
- const paddingStartX = getPaddingX(props.value.scrollPaddingStart, direction);
854
- const paddingEndX = getPaddingX(props.value.scrollPaddingEnd, direction);
855
- const paddingStartY = getPaddingY(props.value.scrollPaddingStart, direction);
856
- const paddingEndY = getPaddingY(props.value.scrollPaddingEnd, direction);
857
-
858
- const isVertical = direction === 'vertical' || direction === 'both';
859
- const isHorizontal = direction === 'horizontal' || direction === 'both';
860
673
 
861
- const usableWidth = viewportWidth.value - (isHorizontal ? (paddingStartX + paddingEndX) : 0);
862
- const usableHeight = viewportHeight.value - (isVertical ? (paddingStartY + paddingEndY) : 0);
863
-
864
- let start = 0;
865
- let end = props.value.items.length;
866
-
867
- if (isVertical) {
868
- if (fixedSize !== null) {
869
- start = Math.floor(relativeScrollY.value / (fixedSize + gap));
870
- end = Math.ceil((relativeScrollY.value + usableHeight) / (fixedSize + gap));
871
- } else {
872
- start = itemSizesY.findLowerBound(relativeScrollY.value);
873
- let currentY = itemSizesY.query(start);
874
- let i = start;
875
- while (i < props.value.items.length && currentY < relativeScrollY.value + usableHeight) {
876
- currentY = itemSizesY.query(++i);
877
- }
878
- end = i;
879
- }
880
- } else {
881
- if (fixedSize !== null) {
882
- start = Math.floor(relativeScrollX.value / (fixedSize + columnGap));
883
- end = Math.ceil((relativeScrollX.value + usableWidth) / (fixedSize + columnGap));
884
- } else {
885
- start = itemSizesX.findLowerBound(relativeScrollX.value);
886
- let currentX = itemSizesX.query(start);
887
- let i = start;
888
- while (i < props.value.items.length && currentX < relativeScrollX.value + usableWidth) {
889
- currentX = itemSizesX.query(++i);
890
- }
891
- end = i;
892
- }
893
- }
894
-
895
- return {
896
- start: Math.max(0, start - bufferBefore),
897
- end: Math.min(props.value.items.length, end + bufferAfter),
898
- };
674
+ return calculateRange({
675
+ direction: props.value.direction || 'vertical',
676
+ relativeScrollX: relativeScrollX.value,
677
+ relativeScrollY: relativeScrollY.value,
678
+ usableWidth: usableWidth.value,
679
+ usableHeight: usableHeight.value,
680
+ itemsLength: props.value.items.length,
681
+ bufferBefore,
682
+ bufferAfter,
683
+ gap: props.value.gap || 0,
684
+ columnGap: props.value.columnGap || 0,
685
+ fixedSize: fixedItemSize.value,
686
+ findLowerBoundY: (offset) => itemSizesY.findLowerBound(offset),
687
+ findLowerBoundX: (offset) => itemSizesX.findLowerBound(offset),
688
+ queryY: (idx) => itemSizesY.query(idx),
689
+ queryX: (idx) => itemSizesX.query(idx),
690
+ });
899
691
  });
900
692
 
901
693
  /**
@@ -924,6 +716,9 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
924
716
  /**
925
717
  * List of items to be rendered with their calculated offsets and sizes.
926
718
  */
719
+
720
+ let lastRenderedItems: RenderedItem<T>[] = [];
721
+
927
722
  const renderedItems = computed<RenderedItem<T>[]>(() => {
928
723
  // eslint-disable-next-line ts/no-unused-expressions
929
724
  treeUpdateFlag.value;
@@ -934,6 +729,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
934
729
  const gap = props.value.gap || 0;
935
730
  const columnGap = props.value.columnGap || 0;
936
731
  const stickyIndices = sortedStickyIndices.value;
732
+ const stickySet = stickyIndicesSet.value;
937
733
 
938
734
  // Always include relevant sticky items
939
735
  const indicesToRender = new Set<number>();
@@ -961,8 +757,27 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
961
757
  indicesToRender.add(prevStickyIdx);
962
758
  }
963
759
 
964
- for (const idx of stickyIndices) {
965
- if (idx >= start && idx < end) {
760
+ // Optimize: Use binary search to find the first sticky index in range
761
+ let stickyLow = 0;
762
+ let stickyHigh = stickyIndices.length - 1;
763
+ let firstInRange = -1;
764
+
765
+ while (stickyLow <= stickyHigh) {
766
+ const mid = (stickyLow + stickyHigh) >>> 1;
767
+ if (stickyIndices[ mid ]! >= start) {
768
+ firstInRange = mid;
769
+ stickyHigh = mid - 1;
770
+ } else {
771
+ stickyLow = mid + 1;
772
+ }
773
+ }
774
+
775
+ if (firstInRange !== -1) {
776
+ for (let i = firstInRange; i < stickyIndices.length; i++) {
777
+ const idx = stickyIndices[ i ]!;
778
+ if (idx >= end) {
779
+ break;
780
+ }
966
781
  indicesToRender.add(idx);
967
782
  }
968
783
  }
@@ -988,102 +803,86 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
988
803
  }
989
804
  }
990
805
 
806
+ const lastItemsMap = new Map(lastRenderedItems.map((it) => [ it.index, it ]));
807
+
991
808
  for (const i of sortedIndices) {
992
809
  const item = props.value.items[ i ];
993
810
  if (item === undefined) {
994
811
  continue;
995
812
  }
996
813
 
997
- let x = 0;
998
- let y = 0;
999
- let width = 0;
1000
- let height = 0;
1001
-
1002
- if (props.value.direction === 'horizontal') {
1003
- x = fixedSize !== null ? i * (fixedSize + columnGap) : itemSizesX.query(i);
1004
- width = fixedSize !== null ? fixedSize : itemSizesX.get(i) - columnGap;
1005
- height = viewportHeight.value;
1006
- } else {
1007
- // vertical or both
1008
- y = (props.value.direction === 'vertical' || props.value.direction === 'both') && fixedSize !== null ? i * (fixedSize + gap) : itemSizesY.query(i);
1009
- height = fixedSize !== null ? fixedSize : itemSizesY.get(i) - gap;
1010
- width = props.value.direction === 'both' ? totalWidth.value : viewportWidth.value;
1011
- }
814
+ const { x, y, width, height } = calculateItemPosition({
815
+ index: i,
816
+ direction: props.value.direction || 'vertical',
817
+ fixedSize: fixedItemSize.value,
818
+ gap: props.value.gap || 0,
819
+ columnGap: props.value.columnGap || 0,
820
+ usableWidth: usableWidth.value,
821
+ usableHeight: usableHeight.value,
822
+ totalWidth: totalWidth.value,
823
+ queryY: (idx) => itemSizesY.query(idx),
824
+ queryX: (idx) => itemSizesX.query(idx),
825
+ getSizeY: (idx) => itemSizesY.get(idx),
826
+ getSizeX: (idx) => itemSizesX.get(idx),
827
+ });
1012
828
 
1013
- const isSticky = stickyIndices.includes(i);
829
+ const isSticky = stickySet.has(i);
1014
830
  const originalX = x;
1015
831
  const originalY = y;
1016
- let isStickyActive = false;
1017
- const stickyOffset = { x: 0, y: 0 };
1018
-
1019
- if (isSticky) {
1020
- if (props.value.direction === 'vertical' || props.value.direction === 'both') {
1021
- if (relativeScrollY.value > originalY) {
1022
- isStickyActive = true;
1023
- // Check if next sticky item pushes this one
1024
- let nextStickyIdx: number | undefined;
1025
- let low = 0;
1026
- let high = stickyIndices.length - 1;
1027
- while (low <= high) {
1028
- const mid = (low + high) >>> 1;
1029
- if (stickyIndices[ mid ]! > i) {
1030
- nextStickyIdx = stickyIndices[ mid ];
1031
- high = mid - 1;
1032
- } else {
1033
- low = mid + 1;
1034
- }
1035
- }
1036
832
 
1037
- if (nextStickyIdx !== undefined) {
1038
- const nextStickyY = fixedSize !== null ? nextStickyIdx * (fixedSize + gap) : itemSizesY.query(nextStickyIdx);
1039
- const distance = nextStickyY - relativeScrollY.value;
1040
- /* v8 ignore else -- @preserve */
1041
- if (distance < height) {
1042
- stickyOffset.y = -(height - distance);
1043
- }
1044
- }
1045
- }
1046
- } else if (props.value.direction === 'horizontal') {
1047
- if (relativeScrollX.value > originalX) {
1048
- isStickyActive = true;
1049
- // Check if next sticky item pushes this one
1050
- let nextStickyIdx: number | undefined;
1051
- let low = 0;
1052
- let high = stickyIndices.length - 1;
1053
- while (low <= high) {
1054
- const mid = (low + high) >>> 1;
1055
- if (stickyIndices[ mid ]! > i) {
1056
- nextStickyIdx = stickyIndices[ mid ];
1057
- high = mid - 1;
1058
- } else {
1059
- low = mid + 1;
1060
- }
1061
- }
1062
-
1063
- if (nextStickyIdx !== undefined) {
1064
- const nextStickyX = fixedSize !== null ? nextStickyIdx * (fixedSize + columnGap) : itemSizesX.query(nextStickyIdx);
1065
- const distance = nextStickyX - relativeScrollX.value;
1066
- /* v8 ignore else -- @preserve */
1067
- if (distance < width) {
1068
- stickyOffset.x = -(width - distance);
1069
- }
1070
- }
1071
- }
1072
- }
1073
- }
1074
-
1075
- items.push({
1076
- item,
833
+ const { isStickyActive, stickyOffset } = calculateStickyItem({
1077
834
  index: i,
1078
- offset: { x: originalX - ssrOffsetX, y: originalY - ssrOffsetY },
1079
- size: { width, height },
835
+ isSticky,
836
+ direction: props.value.direction || 'vertical',
837
+ relativeScrollX: relativeScrollX.value,
838
+ relativeScrollY: relativeScrollY.value,
1080
839
  originalX,
1081
840
  originalY,
1082
- isSticky,
1083
- isStickyActive,
1084
- stickyOffset,
841
+ width,
842
+ height,
843
+ stickyIndices,
844
+ fixedSize: fixedItemSize.value,
845
+ fixedWidth: fixedColumnWidth.value,
846
+ gap: props.value.gap || 0,
847
+ columnGap: props.value.columnGap || 0,
848
+ getItemQueryY: (idx) => itemSizesY.query(idx),
849
+ getItemQueryX: (idx) => itemSizesX.query(idx),
1085
850
  });
851
+
852
+ const offsetX = originalX - ssrOffsetX;
853
+ const offsetY = originalY - ssrOffsetY;
854
+ const last = lastItemsMap.get(i);
855
+
856
+ if (
857
+ last
858
+ && last.item === item
859
+ && last.offset.x === offsetX
860
+ && last.offset.y === offsetY
861
+ && last.size.width === width
862
+ && last.size.height === height
863
+ && last.isSticky === isSticky
864
+ && last.isStickyActive === isStickyActive
865
+ && last.stickyOffset.x === stickyOffset.x
866
+ && last.stickyOffset.y === stickyOffset.y
867
+ ) {
868
+ items.push(last);
869
+ } else {
870
+ items.push({
871
+ item,
872
+ index: i,
873
+ offset: { x: offsetX, y: offsetY },
874
+ size: { width, height },
875
+ originalX,
876
+ originalY,
877
+ isSticky,
878
+ isStickyActive,
879
+ stickyOffset,
880
+ });
881
+ }
1086
882
  }
883
+
884
+ lastRenderedItems = items;
885
+
1087
886
  return items;
1088
887
  });
1089
888
 
@@ -1109,28 +908,19 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1109
908
  };
1110
909
  }
1111
910
 
1112
- const start = columnSizes.findLowerBound(relativeScrollX.value);
1113
- let currentX = columnSizes.query(start);
1114
- let end = start;
1115
-
1116
- while (end < totalCols && currentX < relativeScrollX.value + viewportWidth.value) {
1117
- currentX = columnSizes.query(++end);
1118
- }
1119
-
1120
911
  const colBuffer = (props.value.ssrRange && !isScrolling.value) ? 0 : 2;
1121
912
 
1122
- // Add buffer of columns
1123
- const safeStart = Math.max(0, start - colBuffer);
1124
- const safeEnd = Math.min(totalCols, end + colBuffer);
1125
-
1126
- const padStart = columnSizes.query(safeStart);
1127
-
1128
- return {
1129
- start: safeStart,
1130
- end: safeEnd,
1131
- padStart,
1132
- padEnd: columnSizes.query(totalCols) - columnSizes.query(safeEnd),
1133
- };
913
+ return calculateColumnRange({
914
+ columnCount: totalCols,
915
+ relativeScrollX: relativeScrollX.value,
916
+ usableWidth: usableWidth.value,
917
+ colBuffer,
918
+ fixedWidth: fixedColumnWidth.value,
919
+ columnGap: props.value.columnGap || 0,
920
+ findLowerBound: (offset) => columnSizes.findLowerBound(offset),
921
+ query: (idx) => columnSizes.query(idx),
922
+ totalColsQuery: () => columnSizes.query(totalCols),
923
+ });
1134
924
  });
1135
925
 
1136
926
  /**
@@ -1144,12 +934,14 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1144
934
  const columnGap = props.value.columnGap || 0;
1145
935
 
1146
936
  let currentColIndex = 0;
1147
- if (props.value.direction === 'horizontal' || props.value.direction === 'both') {
937
+ if (props.value.direction === 'horizontal') {
1148
938
  if (fixedSize !== null) {
1149
939
  currentColIndex = Math.floor(relativeScrollX.value / (fixedSize + columnGap));
1150
940
  } else {
1151
941
  currentColIndex = itemSizesX.findLowerBound(relativeScrollX.value);
1152
942
  }
943
+ } else if (props.value.direction === 'both') {
944
+ currentColIndex = columnSizes.findLowerBound(relativeScrollX.value);
1153
945
  }
1154
946
 
1155
947
  return {
@@ -1187,9 +979,13 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1187
979
  if (target === window || target === document) {
1188
980
  scrollX.value = window.scrollX;
1189
981
  scrollY.value = window.scrollY;
982
+ viewportWidth.value = document.documentElement.clientWidth;
983
+ viewportHeight.value = document.documentElement.clientHeight;
1190
984
  } else if (isScrollableElement(target)) {
1191
985
  scrollX.value = target.scrollLeft;
1192
986
  scrollY.value = target.scrollTop;
987
+ viewportWidth.value = target.clientWidth;
988
+ viewportHeight.value = target.clientHeight;
1193
989
  }
1194
990
 
1195
991
  if (!isScrolling.value) {
@@ -1212,41 +1008,61 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1212
1008
  */
1213
1009
  const updateItemSizes = (updates: Array<{ index: number; inlineSize: number; blockSize: number; element?: HTMLElement | undefined; }>) => {
1214
1010
  let needUpdate = false;
1011
+ let deltaX = 0;
1012
+ let deltaY = 0;
1215
1013
  const gap = props.value.gap || 0;
1216
1014
  const columnGap = props.value.columnGap || 0;
1217
1015
 
1016
+ const currentRelX = relativeScrollX.value;
1017
+ const currentRelY = relativeScrollY.value;
1018
+ const firstRowIndex = props.value.direction === 'horizontal'
1019
+ ? (fixedItemSize.value !== null ? Math.floor(currentRelX / (fixedItemSize.value + columnGap)) : itemSizesX.findLowerBound(currentRelX))
1020
+ : (fixedItemSize.value !== null ? Math.floor(currentRelY / (fixedItemSize.value + gap)) : itemSizesY.findLowerBound(currentRelY));
1021
+ const firstColIndex = props.value.direction === 'both'
1022
+ ? columnSizes.findLowerBound(currentRelX)
1023
+ : (props.value.direction === 'horizontal' ? firstRowIndex : 0);
1024
+
1025
+ const isHorizontalMode = props.value.direction === 'horizontal';
1026
+ const isVerticalMode = props.value.direction === 'vertical';
1027
+ const isBothMode = props.value.direction === 'both';
1028
+
1029
+ const processedRows = new Set<number>();
1030
+ const processedCols = new Set<number>();
1031
+
1218
1032
  for (const { index, inlineSize, blockSize, element } of updates) {
1033
+ // Ignore 0-size measurements as they usually indicate hidden/detached elements
1034
+ if (inlineSize <= 0 && blockSize <= 0) {
1035
+ continue;
1036
+ }
1037
+
1219
1038
  const isMeasurable = isDynamicItemSize.value || typeof props.value.itemSize === 'function';
1220
- if (isMeasurable && index >= 0) {
1221
- if (props.value.direction === 'horizontal') {
1039
+ if (index >= 0 && !processedRows.has(index) && isMeasurable && blockSize > 0) {
1040
+ processedRows.add(index);
1041
+ if (isHorizontalMode && inlineSize > 0) {
1222
1042
  const oldWidth = itemSizesX.get(index);
1223
1043
  const targetWidth = inlineSize + columnGap;
1224
- // Apply if:
1225
- // 1. It's the first measurement (measuredItemsX[index] is 0)
1226
- // 2. It's a significant change (> 0.5px)
1227
- /* v8 ignore else -- @preserve */
1228
- if (!measuredItemsX[ index ] || Math.abs(targetWidth - oldWidth) > 0.5) {
1229
- itemSizesX.update(index, targetWidth - oldWidth);
1044
+ if (!measuredItemsX[ index ] || Math.abs(targetWidth - oldWidth) > 0.1) {
1045
+ const d = targetWidth - oldWidth;
1046
+ itemSizesX.update(index, d);
1230
1047
  measuredItemsX[ index ] = 1;
1231
1048
  needUpdate = true;
1049
+ if (index < firstRowIndex) {
1050
+ deltaX += d;
1051
+ }
1232
1052
  }
1233
1053
  }
1234
- if (props.value.direction === 'vertical' || props.value.direction === 'both') {
1054
+ if (isVerticalMode || isBothMode) {
1235
1055
  const oldHeight = itemSizesY.get(index);
1236
1056
  const targetHeight = blockSize + gap;
1237
1057
 
1238
- if (props.value.direction === 'both') {
1239
- // For grid, we should be careful with decreases because a row height is the max of all its cells.
1240
- /* v8 ignore else -- @preserve */
1241
- if (!measuredItemsY[ index ] || Math.abs(targetHeight - oldHeight) > 0.5) {
1242
- itemSizesY.update(index, targetHeight - oldHeight);
1243
- measuredItemsY[ index ] = 1;
1244
- needUpdate = true;
1245
- }
1246
- } else if (!measuredItemsY[ index ] || Math.abs(targetHeight - oldHeight) > 0.5) {
1247
- itemSizesY.update(index, targetHeight - oldHeight);
1058
+ if (!measuredItemsY[ index ] || Math.abs(targetHeight - oldHeight) > 0.1) {
1059
+ const d = targetHeight - oldHeight;
1060
+ itemSizesY.update(index, d);
1248
1061
  measuredItemsY[ index ] = 1;
1249
1062
  needUpdate = true;
1063
+ if (index < firstRowIndex) {
1064
+ deltaY += d;
1065
+ }
1250
1066
  }
1251
1067
  }
1252
1068
  }
@@ -1254,27 +1070,58 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1254
1070
  // Dynamic column width measurement
1255
1071
  const isColMeasurable = isDynamicColumnWidth.value || typeof props.value.columnWidth === 'function';
1256
1072
  if (
1257
- props.value.direction === 'both'
1073
+ isBothMode
1258
1074
  && element
1259
1075
  && props.value.columnCount
1260
1076
  && isColMeasurable
1077
+ && (inlineSize > 0 || element.dataset.colIndex === undefined)
1261
1078
  ) {
1262
- const cells = element.dataset.colIndex !== undefined
1263
- ? [ element ]
1264
- : Array.from(element.querySelectorAll('[data-col-index]')) as HTMLElement[];
1265
-
1266
- for (const child of cells) {
1267
- const colIndex = Number.parseInt(child.dataset.colIndex!, 10);
1268
-
1269
- /* v8 ignore else -- @preserve */
1270
- if (colIndex >= 0 && colIndex < (props.value.columnCount || 0)) {
1271
- const w = child.offsetWidth;
1079
+ const colIndexAttr = element.dataset.colIndex;
1080
+ if (colIndexAttr != null) {
1081
+ const colIndex = Number.parseInt(colIndexAttr, 10);
1082
+ if (colIndex >= 0 && colIndex < (props.value.columnCount || 0) && !processedCols.has(colIndex)) {
1083
+ processedCols.add(colIndex);
1272
1084
  const oldW = columnSizes.get(colIndex);
1273
- const targetW = w + columnGap;
1274
- if (Math.abs(oldW - targetW) > 0.5) {
1275
- columnSizes.update(colIndex, targetW - oldW);
1085
+ const targetW = inlineSize + columnGap;
1086
+
1087
+ if (!measuredColumns[ colIndex ] || Math.abs(oldW - targetW) > 0.1) {
1088
+ const d = targetW - oldW;
1089
+ if (Math.abs(d) > 0.1) {
1090
+ columnSizes.update(colIndex, d);
1091
+ needUpdate = true;
1092
+ if (colIndex < firstColIndex) {
1093
+ deltaX += d;
1094
+ }
1095
+ }
1276
1096
  measuredColumns[ colIndex ] = 1;
1277
- needUpdate = true;
1097
+ }
1098
+ }
1099
+ } else {
1100
+ // If the element is a row, try to find cells with data-col-index
1101
+ const cells = element.dataset.colIndex !== undefined
1102
+ ? [ element ]
1103
+ : Array.from(element.querySelectorAll('[data-col-index]')) as HTMLElement[];
1104
+
1105
+ for (const child of cells) {
1106
+ const colIndex = Number.parseInt(child.dataset.colIndex!, 10);
1107
+
1108
+ if (colIndex >= 0 && colIndex < (props.value.columnCount || 0) && !processedCols.has(colIndex)) {
1109
+ processedCols.add(colIndex);
1110
+ const rect = child.getBoundingClientRect();
1111
+ const w = rect.width;
1112
+ const oldW = columnSizes.get(colIndex);
1113
+ const targetW = w + columnGap;
1114
+ if (!measuredColumns[ colIndex ] || Math.abs(oldW - targetW) > 0.1) {
1115
+ const d = targetW - oldW;
1116
+ if (Math.abs(d) > 0.1) {
1117
+ columnSizes.update(colIndex, d);
1118
+ needUpdate = true;
1119
+ if (colIndex < firstColIndex) {
1120
+ deltaX += d;
1121
+ }
1122
+ }
1123
+ measuredColumns[ colIndex ] = 1;
1124
+ }
1278
1125
  }
1279
1126
  }
1280
1127
  }
@@ -1283,6 +1130,17 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1283
1130
 
1284
1131
  if (needUpdate) {
1285
1132
  treeUpdateFlag.value++;
1133
+ // Only compensate if not in a programmatic scroll,
1134
+ // as it would interrupt the browser animation or explicit alignment.
1135
+ const hasPendingScroll = pendingScroll.value !== null || isProgrammaticScroll.value;
1136
+
1137
+ if (!hasPendingScroll && (deltaX !== 0 || deltaY !== 0)) {
1138
+ scrollToOffset(
1139
+ deltaX !== 0 ? currentRelX + deltaX : null,
1140
+ deltaY !== 0 ? currentRelY + deltaY : null,
1141
+ { behavior: 'auto' },
1142
+ );
1143
+ }
1286
1144
  }
1287
1145
  };
1288
1146
 
@@ -1302,14 +1160,61 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1302
1160
  const checkPendingScroll = () => {
1303
1161
  if (pendingScroll.value && !isHydrating.value) {
1304
1162
  const { rowIndex, colIndex, options } = pendingScroll.value;
1305
- const correctionOptions: ScrollToIndexOptions = isScrollToIndexOptions(options)
1306
- ? { ...options, isCorrection: true }
1307
- : { align: options as ScrollAlignment | ScrollAlignmentOptions, isCorrection: true };
1308
- scrollToIndex(rowIndex, colIndex, correctionOptions);
1163
+
1164
+ const isSmooth = isScrollToIndexOptions(options) && options.behavior === 'smooth';
1165
+
1166
+ // If it's a smooth scroll, we wait until it's finished before correcting.
1167
+ if (isSmooth && isScrolling.value) {
1168
+ return;
1169
+ }
1170
+
1171
+ const { targetX, targetY } = calculateScrollTarget({
1172
+ rowIndex,
1173
+ colIndex,
1174
+ options,
1175
+ itemsLength: props.value.items.length,
1176
+ columnCount: props.value.columnCount || 0,
1177
+ direction: props.value.direction || 'vertical',
1178
+ usableWidth: usableWidth.value,
1179
+ usableHeight: usableHeight.value,
1180
+ totalWidth: totalWidth.value,
1181
+ totalHeight: totalHeight.value,
1182
+ gap: props.value.gap || 0,
1183
+ columnGap: props.value.columnGap || 0,
1184
+ fixedSize: fixedItemSize.value,
1185
+ fixedWidth: fixedColumnWidth.value,
1186
+ relativeScrollX: relativeScrollX.value,
1187
+ relativeScrollY: relativeScrollY.value,
1188
+ getItemSizeY: (idx) => itemSizesY.get(idx),
1189
+ getItemSizeX: (idx) => itemSizesX.get(idx),
1190
+ getItemQueryY: (idx) => itemSizesY.query(idx),
1191
+ getItemQueryX: (idx) => itemSizesX.query(idx),
1192
+ getColumnSize: (idx) => columnSizes.get(idx),
1193
+ getColumnQuery: (idx) => columnSizes.query(idx),
1194
+ stickyIndices: sortedStickyIndices.value,
1195
+ });
1196
+
1197
+ const tolerance = 1;
1198
+ const reachedX = (colIndex === null || colIndex === undefined) || Math.abs(relativeScrollX.value - targetX) < tolerance;
1199
+ const reachedY = (rowIndex === null || rowIndex === undefined) || Math.abs(relativeScrollY.value - targetY) < tolerance;
1200
+
1201
+ const isMeasuredX = colIndex == null || colIndex === undefined || measuredColumns[ colIndex ] === 1;
1202
+ const isMeasuredY = rowIndex == null || rowIndex === undefined || measuredItemsY[ rowIndex ] === 1;
1203
+
1204
+ if (reachedX && reachedY) {
1205
+ if (isMeasuredX && isMeasuredY) {
1206
+ pendingScroll.value = null;
1207
+ }
1208
+ } else {
1209
+ const correctionOptions: ScrollToIndexOptions = isScrollToIndexOptions(options)
1210
+ ? { ...options, isCorrection: true }
1211
+ : { align: options as ScrollAlignment | ScrollAlignmentOptions, isCorrection: true };
1212
+ scrollToIndex(rowIndex, colIndex, correctionOptions);
1213
+ }
1309
1214
  }
1310
1215
  };
1311
1216
 
1312
- watch(treeUpdateFlag, checkPendingScroll);
1217
+ watch([ treeUpdateFlag, viewportWidth, viewportHeight ], checkPendingScroll);
1313
1218
 
1314
1219
  watch(isScrolling, (scrolling) => {
1315
1220
  if (!scrolling) {
@@ -1327,14 +1232,14 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1327
1232
  scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
1328
1233
 
1329
1234
  if (container === window) {
1330
- viewportWidth.value = window.innerWidth;
1331
- viewportHeight.value = window.innerHeight;
1235
+ viewportWidth.value = document.documentElement.clientWidth;
1236
+ viewportHeight.value = document.documentElement.clientHeight;
1332
1237
  scrollX.value = window.scrollX;
1333
1238
  scrollY.value = window.scrollY;
1334
1239
 
1335
1240
  const onResize = () => {
1336
- viewportWidth.value = window.innerWidth;
1337
- viewportHeight.value = window.innerHeight;
1241
+ viewportWidth.value = document.documentElement.clientWidth;
1242
+ viewportHeight.value = document.documentElement.clientHeight;
1338
1243
  updateHostOffset();
1339
1244
  };
1340
1245
  window.addEventListener('resize', onResize);
@@ -1350,7 +1255,6 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1350
1255
 
1351
1256
  resizeObserver = new ResizeObserver((entries) => {
1352
1257
  for (const entry of entries) {
1353
- /* v8 ignore else -- @preserve */
1354
1258
  if (entry.target === container) {
1355
1259
  viewportWidth.value = (container as HTMLElement).clientWidth;
1356
1260
  viewportHeight.value = (container as HTMLElement).clientHeight;
@@ -1387,7 +1291,6 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1387
1291
  : props.value.ssrRange?.start;
1388
1292
  const initialAlign = props.value.initialScrollAlign || 'start';
1389
1293
 
1390
- /* v8 ignore else -- @preserve */
1391
1294
  if (initialIndex !== undefined && initialIndex !== null) {
1392
1295
  scrollToIndex(initialIndex, props.value.ssrRange?.colStart, { align: initialAlign, behavior: 'auto' });
1393
1296
  }
@@ -1423,70 +1326,95 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1423
1326
 
1424
1327
  return {
1425
1328
  /**
1426
- * Array of items to be rendered with their calculated offsets and sizes.
1329
+ * Array of items currently rendered in the DOM with their calculated offsets and sizes.
1330
+ * @see RenderedItem
1427
1331
  */
1428
1332
  renderedItems,
1333
+
1429
1334
  /**
1430
- * Total calculated width of all items including gaps.
1335
+ * Total calculated width of all items including gaps (in pixels).
1431
1336
  */
1432
1337
  totalWidth,
1338
+
1433
1339
  /**
1434
- * Total calculated height of all items including gaps.
1340
+ * Total calculated height of all items including gaps (in pixels).
1435
1341
  */
1436
1342
  totalHeight,
1343
+
1437
1344
  /**
1438
1345
  * Detailed information about the current scroll state.
1439
- * Includes currentIndex, scrollOffset, viewportSize, totalSize, and isScrolling.
1346
+ * Includes currentIndex, scrollOffset, viewportSize, totalSize, and scrolling status.
1347
+ * @see ScrollDetails
1440
1348
  */
1441
1349
  scrollDetails,
1350
+
1442
1351
  /**
1443
1352
  * Programmatically scroll to a specific row and/or column.
1444
- * @param rowIndex - The row index to scroll to
1445
- * @param colIndex - The column index to scroll to
1446
- * @param options - Alignment and behavior options
1353
+ *
1354
+ * @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
1355
+ * @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
1356
+ * @param options - Alignment and behavior options.
1357
+ * @see ScrollAlignment
1358
+ * @see ScrollToIndexOptions
1447
1359
  */
1448
1360
  scrollToIndex,
1361
+
1449
1362
  /**
1450
- * Programmatically scroll to a specific pixel offset.
1451
- * @param x - The pixel offset to scroll to on the X axis
1452
- * @param y - The pixel offset to scroll to on the Y axis
1453
- * @param options - Behavior options
1363
+ * Programmatically scroll to a specific pixel offset relative to the content start.
1364
+ *
1365
+ * @param x - The pixel offset to scroll to on the X axis. Pass null to keep current position.
1366
+ * @param y - The pixel offset to scroll to on the Y axis. Pass null to keep current position.
1367
+ * @param options - Scroll options (behavior).
1454
1368
  */
1455
1369
  scrollToOffset,
1370
+
1456
1371
  /**
1457
- * Stops any currently active programmatic scroll and clears pending corrections.
1372
+ * Stops any currently active smooth scroll animation and clears pending corrections.
1458
1373
  */
1459
1374
  stopProgrammaticScroll,
1375
+
1460
1376
  /**
1461
1377
  * Updates the stored size of an item. Should be called when an item is measured (e.g., via ResizeObserver).
1462
- * @param index - The item index
1463
- * @param width - The measured width
1464
- * @param height - The measured height
1465
- * @param element - The measured element (optional, used for grid column detection)
1378
+ *
1379
+ * @param index - The item index.
1380
+ * @param width - The measured inlineSize (width).
1381
+ * @param height - The measured blockSize (height).
1382
+ * @param element - The measured element (optional, used for robust grid column detection).
1466
1383
  */
1467
1384
  updateItemSize,
1385
+
1468
1386
  /**
1469
- * Updates the stored size of multiple items. Should be called when items are measured (e.g., via ResizeObserver).
1470
- * @param updates - Array of item updates
1387
+ * Updates the stored size of multiple items simultaneously.
1388
+ *
1389
+ * @param updates - Array of measurement updates.
1471
1390
  */
1472
1391
  updateItemSizes,
1392
+
1473
1393
  /**
1474
1394
  * Recalculates the host element's offset relative to the scroll container.
1395
+ * Useful if the container or host moves without a resize event.
1475
1396
  */
1476
1397
  updateHostOffset,
1398
+
1477
1399
  /**
1478
- * Information about the current visible range of columns.
1400
+ * Information about the current visible range of columns and their paddings.
1401
+ * @see ColumnRange
1479
1402
  */
1480
1403
  columnRange,
1404
+
1481
1405
  /**
1482
- * Helper to get the width of a specific column based on current configuration.
1483
- * @param index - The column index
1406
+ * Helper to get the width of a specific column based on current configuration and measurements.
1407
+ *
1408
+ * @param index - The column index.
1484
1409
  */
1485
1410
  getColumnWidth,
1411
+
1486
1412
  /**
1487
1413
  * Resets all dynamic measurements and re-initializes from props.
1414
+ * Useful if item sizes have changed externally.
1488
1415
  */
1489
1416
  refresh,
1417
+
1490
1418
  /**
1491
1419
  * Whether the component has finished its first client-side mount and hydration.
1492
1420
  */