@pdanpdan/virtual-scroll 0.2.1 → 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,111 +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';
8
-
9
- export const DEFAULT_ITEM_SIZE = 50;
10
- export const DEFAULT_COLUMN_WIDTH = 150;
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
+ };
35
+
36
+ export const DEFAULT_ITEM_SIZE = 40;
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
- export interface ScrollAlignmentOptions {
17
- x?: ScrollAlignment;
18
- y?: ScrollAlignment;
19
- }
20
-
21
- export interface ScrollToIndexOptions {
22
- align?: ScrollAlignment | ScrollAlignmentOptions;
23
- behavior?: 'auto' | 'smooth';
24
- isCorrection?: boolean;
25
- }
26
-
27
- export interface VirtualScrollProps<T = unknown> {
28
- /** Array of items to be virtualized */
29
- items: T[];
30
- /** Fixed size of each item or a function that returns the size of an item */
31
- itemSize?: number | ((item: T, index: number) => number) | undefined;
32
- /** Direction of the scroll: 'vertical', 'horizontal', or 'both' */
33
- direction?: ScrollDirection | undefined;
34
- /** Number of items to render before the visible viewport */
35
- bufferBefore?: number | undefined;
36
- /** Number of items to render after the visible viewport */
37
- bufferAfter?: number | undefined;
38
- /** The scrollable container element or window */
39
- container?: HTMLElement | Window | null | undefined;
40
- /** The host element that contains the items */
41
- hostElement?: HTMLElement | null | undefined;
42
- /** Range of items to render for SSR */
43
- ssrRange?: {
44
- start: number;
45
- end: number;
46
- colStart?: number;
47
- colEnd?: number;
48
- } | undefined;
49
- /** Number of columns for bidirectional scroll */
50
- columnCount?: number | undefined;
51
- /** Fixed width of columns or an array of widths for alternating columns */
52
- columnWidth?: number | number[] | ((index: number) => number) | undefined;
53
- /** Padding at the start of the scroll container (e.g. for sticky headers) */
54
- scrollPaddingStart?: number | { x?: number; y?: number; } | undefined;
55
- /** Padding at the end of the scroll container */
56
- scrollPaddingEnd?: number | { x?: number; y?: number; } | undefined;
57
- /** Gap between items in pixels (vertical) */
58
- gap?: number | undefined;
59
- /** Gap between columns in pixels (horizontal/grid) */
60
- columnGap?: number | undefined;
61
- /** Indices of items that should stick to the top/start */
62
- stickyIndices?: number[] | undefined;
63
- /** Distance from the end of the scrollable area to trigger 'load' event */
64
- loadDistance?: number | undefined;
65
- /** Whether items are currently being loaded */
66
- loading?: boolean | undefined;
67
- /** Whether to restore scroll position when items are prepended */
68
- restoreScrollOnPrepend?: boolean | undefined;
69
- /** Initial scroll index to jump to on mount */
70
- initialScrollIndex?: number | undefined;
71
- /** Alignment for the initial scroll index */
72
- initialScrollAlign?: ScrollAlignment | ScrollAlignmentOptions | undefined;
73
- /** Default size for items before they are measured */
74
- defaultItemSize?: number | undefined;
75
- /** Default width for columns before they are measured */
76
- defaultColumnWidth?: number | undefined;
77
- /** Whether to enable debug mode (e.g. showing offsets) */
78
- debug?: boolean | undefined;
79
- }
80
-
81
- export interface RenderedItem<T = unknown> {
82
- item: T;
83
- index: number;
84
- offset: { x: number; y: number; };
85
- size: { width: number; height: number; };
86
- originalX: number;
87
- originalY: number;
88
- isSticky?: boolean;
89
- isStickyActive?: boolean;
90
- stickyOffset: { x: number; y: number; };
91
- }
92
-
93
- export interface ScrollDetails<T = unknown> {
94
- items: RenderedItem<T>[];
95
- currentIndex: number;
96
- currentColIndex: number;
97
- scrollOffset: { x: number; y: number; };
98
- viewportSize: { width: number; height: number; };
99
- totalSize: { width: number; height: number; };
100
- isScrolling: boolean;
101
- isProgrammaticScroll: boolean;
102
- /** Range of items currently being rendered */
103
- range: { start: number; end: number; };
104
- /** Range of columns currently being rendered (for grid mode) */
105
- columnRange: { start: number; end: number; padStart: number; padEnd: number; };
106
- }
107
-
108
40
  /**
109
41
  * Composable for virtual scrolling logic.
110
- * 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.
111
43
  *
112
- * @param props - Reactive properties for virtual scroll configuration
44
+ * @param props - A Ref to the configuration properties.
45
+ * @see VirtualScrollProps
113
46
  */
114
47
  export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>) {
115
48
  // --- State ---
@@ -127,8 +60,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
127
60
  const isProgrammaticScroll = ref(false);
128
61
 
129
62
  // --- Fenwick Trees for efficient size and offset management ---
130
- const itemSizesX = new FenwickTree(props.value.items.length);
131
- 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);
132
65
  const columnSizes = new FenwickTree(props.value.columnCount || 0);
133
66
 
134
67
  const treeUpdateFlag = ref(0);
@@ -161,12 +94,33 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
161
94
  (typeof props.value.itemSize === 'number' && props.value.itemSize > 0) ? props.value.itemSize : null,
162
95
  );
163
96
 
164
- const defaultSize = computed(() => fixedItemSize.value || props.value.defaultItemSize || DEFAULT_ITEM_SIZE);
97
+ const fixedColumnWidth = computed(() =>
98
+ (typeof props.value.columnWidth === 'number' && props.value.columnWidth > 0) ? props.value.columnWidth : null,
99
+ );
100
+
101
+ const defaultSize = computed(() => props.value.defaultItemSize || fixedItemSize.value || DEFAULT_ITEM_SIZE);
165
102
 
166
103
  const sortedStickyIndices = computed(() =>
167
104
  [ ...(props.value.stickyIndices || []) ].sort((a, b) => a - b),
168
105
  );
169
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
+
170
124
  // --- Size Calculations ---
171
125
  /**
172
126
  * Total width of all items in the scrollable area.
@@ -178,28 +132,38 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
178
132
  if (!isHydrated.value && props.value.ssrRange && !isMounted.value) {
179
133
  const { start = 0, end = 0, colStart = 0, colEnd = 0 } = props.value.ssrRange;
180
134
  const colCount = props.value.columnCount || 0;
181
- if (props.value.direction === 'both' && colCount > 0) {
182
- return columnSizes.query(colEnd || colCount) - columnSizes.query(colStart);
135
+ if (props.value.direction === 'both') {
136
+ if (colCount <= 0) {
137
+ return 0;
138
+ }
139
+ const effectiveColEnd = colEnd || colCount;
140
+ const total = columnSizes.query(effectiveColEnd) - columnSizes.query(colStart);
141
+ return Math.max(0, total - (effectiveColEnd > colStart ? (props.value.columnGap || 0) : 0));
183
142
  }
184
- /* v8 ignore else -- @preserve */
185
143
  if (props.value.direction === 'horizontal') {
186
144
  if (fixedItemSize.value !== null) {
187
- return (end - start) * (fixedItemSize.value + (props.value.columnGap || 0));
145
+ const len = end - start;
146
+ return Math.max(0, len * (fixedItemSize.value + (props.value.columnGap || 0)) - (len > 0 ? (props.value.columnGap || 0) : 0));
188
147
  }
189
- return itemSizesX.query(end) - itemSizesX.query(start);
148
+ const total = itemSizesX.query(end) - itemSizesX.query(start);
149
+ return Math.max(0, total - (end > start ? (props.value.columnGap || 0) : 0));
190
150
  }
191
151
  }
192
152
 
193
- if (props.value.direction === 'both' && props.value.columnCount) {
194
- return columnSizes.query(props.value.columnCount);
195
- }
196
- if (props.value.direction === 'vertical') {
197
- return 0;
198
- }
199
- if (fixedItemSize.value !== null) {
200
- return props.value.items.length * (fixedItemSize.value + (props.value.columnGap || 0));
201
- }
202
- return itemSizesX.query(props.value.items.length);
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;
203
167
  });
204
168
 
205
169
  /**
@@ -211,22 +175,30 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
211
175
 
212
176
  if (!isHydrated.value && props.value.ssrRange && !isMounted.value) {
213
177
  const { start, end } = props.value.ssrRange;
214
- /* v8 ignore else -- @preserve */
215
178
  if (props.value.direction === 'vertical' || props.value.direction === 'both') {
216
179
  if (fixedItemSize.value !== null) {
217
- return (end - start) * (fixedItemSize.value + (props.value.gap || 0));
180
+ const len = end - start;
181
+ return Math.max(0, len * (fixedItemSize.value + (props.value.gap || 0)) - (len > 0 ? (props.value.gap || 0) : 0));
218
182
  }
219
- return itemSizesY.query(end) - itemSizesY.query(start);
183
+ const total = itemSizesY.query(end) - itemSizesY.query(start);
184
+ return Math.max(0, total - (end > start ? (props.value.gap || 0) : 0));
220
185
  }
221
186
  }
222
187
 
223
- if (props.value.direction === 'horizontal') {
224
- return 0;
225
- }
226
- if (fixedItemSize.value !== null) {
227
- return props.value.items.length * (fixedItemSize.value + (props.value.gap || 0));
228
- }
229
- return itemSizesY.query(props.value.items.length);
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;
230
202
  });
231
203
 
232
204
  const relativeScrollX = computed(() => {
@@ -250,7 +222,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
250
222
  return cw;
251
223
  }
252
224
  if (Array.isArray(cw) && cw.length > 0) {
253
- return cw[ index % cw.length ] || DEFAULT_COLUMN_WIDTH;
225
+ const val = cw[ index % cw.length ];
226
+ return (val != null && val > 0) ? val : (props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH);
254
227
  }
255
228
  if (typeof cw === 'function') {
256
229
  return cw(index);
@@ -265,6 +238,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
265
238
  * @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
266
239
  * @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
267
240
  * @param options - Scroll options including alignment ('start', 'center', 'end', 'auto') and behavior ('auto', 'smooth').
241
+ * Defaults to { align: 'auto', behavior: 'auto' }.
268
242
  */
269
243
  const scrollToIndex = (
270
244
  rowIndex: number | null | undefined,
@@ -275,162 +249,55 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
275
249
  ? options.isCorrection
276
250
  : false;
277
251
 
278
- if (!isCorrection) {
279
- pendingScroll.value = { rowIndex, colIndex, options };
280
- }
281
-
282
252
  const container = props.value.container || window;
283
- const fixedSize = fixedItemSize.value;
284
- const gap = props.value.gap || 0;
285
- const columnGap = props.value.columnGap || 0;
286
-
287
- let align: ScrollAlignment | ScrollAlignmentOptions | undefined;
288
- let behavior: 'auto' | 'smooth' | undefined;
289
-
290
- if (isScrollToIndexOptions(options)) {
291
- align = options.align;
292
- behavior = options.behavior;
293
- } else {
294
- align = options as ScrollAlignment | ScrollAlignmentOptions;
295
- }
296
-
297
- const alignX = (typeof align === 'object' ? align.x : align) || 'auto';
298
- const alignY = (typeof align === 'object' ? align.y : align) || 'auto';
299
-
300
- const paddingStartX = getPaddingX(props.value.scrollPaddingStart, props.value.direction);
301
- const paddingEndX = getPaddingX(props.value.scrollPaddingEnd, props.value.direction);
302
- const paddingStartY = getPaddingY(props.value.scrollPaddingStart, props.value.direction);
303
- const paddingEndY = getPaddingY(props.value.scrollPaddingEnd, props.value.direction);
304
253
 
305
254
  const isVertical = props.value.direction === 'vertical' || props.value.direction === 'both';
306
255
  const isHorizontal = props.value.direction === 'horizontal' || props.value.direction === 'both';
307
256
 
308
- const usableWidth = viewportWidth.value - (isHorizontal ? (paddingStartX + paddingEndX) : 0);
309
- const usableHeight = viewportHeight.value - (isVertical ? (paddingStartY + paddingEndY) : 0);
310
-
311
- let targetX = relativeScrollX.value;
312
- let targetY = relativeScrollY.value;
313
- let itemWidth = 0;
314
- let itemHeight = 0;
315
-
316
- // Y calculation
317
- if (rowIndex !== null && rowIndex !== undefined) {
318
- if (rowIndex >= props.value.items.length) {
319
- targetY = totalHeight.value;
320
- itemHeight = 0;
321
- } else {
322
- targetY = fixedSize !== null ? rowIndex * (fixedSize + gap) : itemSizesY.query(rowIndex);
323
- itemHeight = fixedSize !== null ? fixedSize : itemSizesY.get(rowIndex) - gap;
324
- }
325
-
326
- // Apply Y Alignment
327
- if (alignY === 'start') {
328
- // targetY is already at the start of the list
329
- } else if (alignY === 'center') {
330
- targetY -= (usableHeight - itemHeight) / 2;
331
- } else if (alignY === 'end') {
332
- targetY -= (usableHeight - itemHeight);
333
- } else {
334
- const isVisibleY = targetY >= relativeScrollY.value && (targetY + itemHeight) <= (relativeScrollY.value + usableHeight);
335
- if (!isVisibleY) {
336
- if (targetY < relativeScrollY.value) {
337
- // keep targetY at start
338
- } else {
339
- targetY -= (usableHeight - itemHeight);
340
- }
341
- }
342
- }
343
- }
344
-
345
- // X calculation
346
- if (colIndex !== null && colIndex !== undefined) {
347
- const totalCols = props.value.columnCount || 0;
348
- if (colIndex >= totalCols && totalCols > 0) {
349
- targetX = totalWidth.value;
350
- itemWidth = 0;
351
- } else if (props.value.direction === 'horizontal') {
352
- targetX = fixedSize !== null ? colIndex * (fixedSize + columnGap) : itemSizesX.query(colIndex);
353
- itemWidth = fixedSize !== null ? fixedSize : itemSizesX.get(colIndex) - columnGap;
354
- } else {
355
- targetX = columnSizes.query(colIndex);
356
- itemWidth = columnSizes.get(colIndex);
357
- }
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
+ });
358
282
 
359
- // Apply X Alignment
360
- if (alignX === 'start') {
361
- // targetX is already at the start of the list
362
- } else if (alignX === 'center') {
363
- targetX -= (usableWidth - itemWidth) / 2;
364
- } else if (alignX === 'end') {
365
- targetX -= (usableWidth - itemWidth);
366
- } else {
367
- const isVisibleX = targetX >= relativeScrollX.value && (targetX + itemWidth) <= (relativeScrollX.value + usableWidth);
368
- if (!isVisibleX) {
369
- /* v8 ignore if -- @preserve */
370
- if (targetX < relativeScrollX.value) {
371
- // keep targetX at start
372
- } else {
373
- targetX -= (usableWidth - itemWidth);
374
- }
375
- }
376
- }
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
+ };
377
293
  }
378
294
 
379
- // Clamp to valid range
380
- targetX = Math.max(0, Math.min(targetX, Math.max(0, totalWidth.value - usableWidth)));
381
- targetY = Math.max(0, Math.min(targetY, Math.max(0, totalHeight.value - usableHeight)));
382
-
383
- const finalX = targetX + hostOffset.x - (isHorizontal ? paddingStartX : 0);
384
- const finalY = targetY + hostOffset.y - (isVertical ? paddingStartY : 0);
385
-
386
- // Check if we reached the target
387
- const tolerance = 1;
388
- let reachedX = (colIndex === null || colIndex === undefined) || Math.abs(relativeScrollX.value - targetX) < tolerance;
389
- let reachedY = (rowIndex === null || rowIndex === undefined) || Math.abs(relativeScrollY.value - targetY) < tolerance;
390
-
391
- if (!reachedX || !reachedY) {
392
- let curX = 0;
393
- let curY = 0;
394
- let maxW = 0;
395
- let maxH = 0;
396
- let viewW = 0;
397
- let viewH = 0;
398
-
399
- /* v8 ignore else -- @preserve */
400
- if (typeof window !== 'undefined') {
401
- if (container === window) {
402
- curX = window.scrollX;
403
- curY = window.scrollY;
404
- maxW = document.documentElement.scrollWidth;
405
- maxH = document.documentElement.scrollHeight;
406
- viewW = window.innerWidth;
407
- viewH = window.innerHeight;
408
- } else if (isElement(container)) {
409
- curX = container.scrollLeft;
410
- curY = container.scrollTop;
411
- maxW = container.scrollWidth;
412
- maxH = container.scrollHeight;
413
- viewW = container.clientWidth;
414
- viewH = container.clientHeight;
415
- }
295
+ const finalX = targetX + hostOffset.x - (isHorizontal ? paddingStartX.value : 0);
296
+ const finalY = targetY + hostOffset.y - (isVertical ? paddingStartY.value : 0);
416
297
 
417
- if (!reachedX && colIndex !== null && colIndex !== undefined) {
418
- const atLeft = curX <= tolerance && finalX <= tolerance;
419
- const atRight = curX >= maxW - viewW - tolerance && finalX >= maxW - viewW - tolerance;
420
- /* v8 ignore else -- @preserve */
421
- if (atLeft || atRight) {
422
- reachedX = true;
423
- }
424
- }
425
-
426
- if (!reachedY && rowIndex !== null && rowIndex !== undefined) {
427
- const atTop = curY <= tolerance && finalY <= tolerance;
428
- const atBottom = curY >= maxH - viewH - tolerance && finalY >= maxH - viewH - tolerance;
429
- if (atTop || atBottom) {
430
- reachedY = true;
431
- }
432
- }
433
- }
298
+ let behavior: 'auto' | 'smooth' | undefined;
299
+ if (isScrollToIndexOptions(options)) {
300
+ behavior = options.behavior;
434
301
  }
435
302
 
436
303
  const scrollBehavior = isCorrection ? 'auto' : (behavior || 'smooth');
@@ -475,9 +342,9 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
475
342
  }
476
343
  }
477
344
 
478
- if (reachedX && reachedY && !isScrolling.value) {
479
- pendingScroll.value = null;
480
- }
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.
481
348
  };
482
349
 
483
350
  /**
@@ -486,7 +353,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
486
353
  * @param x - The pixel offset to scroll to on the X axis. Pass null to keep current position.
487
354
  * @param y - The pixel offset to scroll to on the Y axis. Pass null to keep current position.
488
355
  * @param options - Scroll options (behavior)
489
- * @param options.behavior - The scroll behavior ('auto' | 'smooth')
356
+ * @param options.behavior - The scroll behavior ('auto' | 'smooth'). Defaults to 'auto'.
490
357
  */
491
358
  const scrollToOffset = (x?: number | null, y?: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => {
492
359
  const container = props.value.container || window;
@@ -495,26 +362,18 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
495
362
  const isVertical = props.value.direction === 'vertical' || props.value.direction === 'both';
496
363
  const isHorizontal = props.value.direction === 'horizontal' || props.value.direction === 'both';
497
364
 
498
- const paddingStartX = getPaddingX(props.value.scrollPaddingStart, props.value.direction);
499
- const paddingStartY = getPaddingY(props.value.scrollPaddingStart, props.value.direction);
500
- const paddingEndX = getPaddingX(props.value.scrollPaddingEnd, props.value.direction);
501
- const paddingEndY = getPaddingY(props.value.scrollPaddingEnd, props.value.direction);
502
-
503
- const usableWidth = viewportWidth.value - (isHorizontal ? (paddingStartX + paddingEndX) : 0);
504
- const usableHeight = viewportHeight.value - (isVertical ? (paddingStartY + paddingEndY) : 0);
505
-
506
365
  const clampedX = (x !== null && x !== undefined)
507
- ? (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))
508
367
  : null;
509
368
  const clampedY = (y !== null && y !== undefined)
510
- ? (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))
511
370
  : null;
512
371
 
513
372
  const currentX = (typeof window !== 'undefined' && container === window ? window.scrollX : (container as HTMLElement).scrollLeft);
514
373
  const currentY = (typeof window !== 'undefined' && container === window ? window.scrollY : (container as HTMLElement).scrollTop);
515
374
 
516
- const targetX = (clampedX !== null) ? clampedX + hostOffset.x - (isHorizontal ? paddingStartX : 0) : currentX;
517
- 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;
518
377
 
519
378
  if (typeof window !== 'undefined' && container === window) {
520
379
  window.scrollTo({
@@ -585,7 +444,6 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
585
444
  let prependCount = 0;
586
445
  if (props.value.restoreScrollOnPrepend && lastItems.length > 0 && len > lastItems.length) {
587
446
  const oldFirstItem = lastItems[ 0 ];
588
- /* v8 ignore else -- @preserve */
589
447
  if (oldFirstItem !== undefined) {
590
448
  for (let i = 1; i <= len - lastItems.length; i++) {
591
449
  if (newItems[ i ] === oldFirstItem) {
@@ -629,7 +487,6 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
629
487
  }
630
488
  }
631
489
 
632
- /* v8 ignore else -- @preserve */
633
490
  if (addedX > 0 || addedY > 0) {
634
491
  nextTick(() => {
635
492
  scrollToOffset(
@@ -641,22 +498,35 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
641
498
  }
642
499
  }
643
500
 
644
- // Initialize columns if fixed width is provided
501
+ // Initialize columns
645
502
  if (colCount > 0) {
646
503
  const columnGap = props.value.columnGap || 0;
647
504
  let colNeedsRebuild = false;
505
+ const cw = props.value.columnWidth;
506
+
648
507
  for (let i = 0; i < colCount; i++) {
649
- const width = getColumnWidth(i);
650
508
  const currentW = columnSizes.get(i);
509
+ const isMeasured = measuredColumns[ i ] === 1;
510
+
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
+ }
651
522
 
652
- // Only initialize from getColumnWidth if it's not dynamic,
653
- // OR if it's dynamic but we don't have a measurement yet.
654
- if (!isDynamicColumnWidth.value || currentW === 0) {
655
- const targetW = width + columnGap;
656
- /* v8 ignore else -- @preserve */
523
+ const targetW = baseWidth + columnGap;
657
524
  if (Math.abs(currentW - targetW) > 0.5) {
658
525
  columnSizes.set(i, targetW);
526
+ measuredColumns[ i ] = isDynamicColumnWidth.value ? 0 : 1;
659
527
  colNeedsRebuild = true;
528
+ } else if (!isDynamicColumnWidth.value) {
529
+ measuredColumns[ i ] = 1;
660
530
  }
661
531
  }
662
532
  }
@@ -674,28 +544,54 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
674
544
  const currentX = itemSizesX.get(i);
675
545
  const currentY = itemSizesY.get(i);
676
546
 
677
- // If it's dynamic and already has a measurement, keep it.
678
- if (isDynamicItemSize.value && (currentX > 0 || currentY > 0)) {
679
- continue;
680
- }
681
-
682
- const size = typeof props.value.itemSize === 'function'
683
- ? props.value.itemSize(item as T, i)
684
- : defaultSize.value;
685
-
686
547
  const isVertical = props.value.direction === 'vertical';
687
548
  const isHorizontal = props.value.direction === 'horizontal';
688
549
  const isBoth = props.value.direction === 'both';
689
550
 
690
- const targetX = isHorizontal ? size + columnGap : 0;
691
- const targetY = (isVertical || isBoth) ? size + gap : 0;
692
-
693
- if (Math.abs(currentX - targetX) > 0.5) {
694
- itemSizesX.set(i, targetX);
551
+ const isMeasuredX = measuredItemsX[ i ] === 1;
552
+ const isMeasuredY = measuredItemsY[ i ] === 1;
553
+
554
+ // Logic for X
555
+ if (isHorizontal) {
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
+
562
+ if (Math.abs(currentX - targetX) > 0.5) {
563
+ itemSizesX.set(i, targetX);
564
+ measuredItemsX[ i ] = isDynamicItemSize.value ? 0 : 1;
565
+ itemsNeedRebuild = true;
566
+ } else if (!isDynamicItemSize.value) {
567
+ measuredItemsX[ i ] = 1;
568
+ }
569
+ }
570
+ } else if (currentX !== 0) {
571
+ itemSizesX.set(i, 0);
572
+ measuredItemsX[ i ] = 0;
695
573
  itemsNeedRebuild = true;
696
574
  }
697
- if (Math.abs(currentY - targetY) > 0.5) {
698
- itemSizesY.set(i, targetY);
575
+
576
+ // Logic for Y
577
+ if (isVertical || isBoth) {
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
+
584
+ if (Math.abs(currentY - targetY) > 0.5) {
585
+ itemSizesY.set(i, targetY);
586
+ measuredItemsY[ i ] = isDynamicItemSize.value ? 0 : 1;
587
+ itemsNeedRebuild = true;
588
+ } else if (!isDynamicItemSize.value) {
589
+ measuredItemsY[ i ] = 1;
590
+ }
591
+ }
592
+ } else if (currentY !== 0) {
593
+ itemSizesY.set(i, 0);
594
+ measuredItemsY[ i ] = 0;
699
595
  itemsNeedRebuild = true;
700
596
  }
701
597
  }
@@ -741,12 +637,16 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
741
637
  };
742
638
 
743
639
  watch([
640
+ () => props.value.items,
744
641
  () => props.value.items.length,
642
+ () => props.value.direction,
745
643
  () => props.value.columnCount,
746
644
  () => props.value.columnWidth,
747
645
  () => props.value.itemSize,
748
646
  () => props.value.gap,
749
647
  () => props.value.columnGap,
648
+ () => props.value.defaultItemSize,
649
+ () => props.value.defaultColumnWidth,
750
650
  ], initializeSizes, { immediate: true });
751
651
 
752
652
  watch(() => [ props.value.container, props.value.hostElement ], () => {
@@ -768,58 +668,26 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
768
668
  };
769
669
  }
770
670
 
771
- const direction = props.value.direction || 'vertical';
772
671
  const bufferBefore = (props.value.ssrRange && !isScrolling.value) ? 0 : (props.value.bufferBefore ?? DEFAULT_BUFFER);
773
672
  const bufferAfter = props.value.bufferAfter ?? DEFAULT_BUFFER;
774
- const gap = props.value.gap || 0;
775
- const columnGap = props.value.columnGap || 0;
776
- const fixedSize = fixedItemSize.value;
777
- const paddingStartX = getPaddingX(props.value.scrollPaddingStart, direction);
778
- const paddingEndX = getPaddingX(props.value.scrollPaddingEnd, direction);
779
- const paddingStartY = getPaddingY(props.value.scrollPaddingStart, direction);
780
- const paddingEndY = getPaddingY(props.value.scrollPaddingEnd, direction);
781
-
782
- const isVertical = direction === 'vertical' || direction === 'both';
783
- const isHorizontal = direction === 'horizontal' || direction === 'both';
784
-
785
- const usableWidth = viewportWidth.value - (isHorizontal ? (paddingStartX + paddingEndX) : 0);
786
- const usableHeight = viewportHeight.value - (isVertical ? (paddingStartY + paddingEndY) : 0);
787
-
788
- let start = 0;
789
- let end = props.value.items.length;
790
-
791
- if (isVertical) {
792
- if (fixedSize !== null) {
793
- start = Math.floor(relativeScrollY.value / (fixedSize + gap));
794
- end = Math.ceil((relativeScrollY.value + usableHeight) / (fixedSize + gap));
795
- } else {
796
- start = itemSizesY.findLowerBound(relativeScrollY.value);
797
- let currentY = itemSizesY.query(start);
798
- let i = start;
799
- while (i < props.value.items.length && currentY < relativeScrollY.value + usableHeight) {
800
- currentY = itemSizesY.query(++i);
801
- }
802
- end = i;
803
- }
804
- } else {
805
- if (fixedSize !== null) {
806
- start = Math.floor(relativeScrollX.value / (fixedSize + columnGap));
807
- end = Math.ceil((relativeScrollX.value + usableWidth) / (fixedSize + columnGap));
808
- } else {
809
- start = itemSizesX.findLowerBound(relativeScrollX.value);
810
- let currentX = itemSizesX.query(start);
811
- let i = start;
812
- while (i < props.value.items.length && currentX < relativeScrollX.value + usableWidth) {
813
- currentX = itemSizesX.query(++i);
814
- }
815
- end = i;
816
- }
817
- }
818
673
 
819
- return {
820
- start: Math.max(0, start - bufferBefore),
821
- end: Math.min(props.value.items.length, end + bufferAfter),
822
- };
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
+ });
823
691
  });
824
692
 
825
693
  /**
@@ -848,6 +716,9 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
848
716
  /**
849
717
  * List of items to be rendered with their calculated offsets and sizes.
850
718
  */
719
+
720
+ let lastRenderedItems: RenderedItem<T>[] = [];
721
+
851
722
  const renderedItems = computed<RenderedItem<T>[]>(() => {
852
723
  // eslint-disable-next-line ts/no-unused-expressions
853
724
  treeUpdateFlag.value;
@@ -858,6 +729,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
858
729
  const gap = props.value.gap || 0;
859
730
  const columnGap = props.value.columnGap || 0;
860
731
  const stickyIndices = sortedStickyIndices.value;
732
+ const stickySet = stickyIndicesSet.value;
861
733
 
862
734
  // Always include relevant sticky items
863
735
  const indicesToRender = new Set<number>();
@@ -885,8 +757,27 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
885
757
  indicesToRender.add(prevStickyIdx);
886
758
  }
887
759
 
888
- for (const idx of stickyIndices) {
889
- 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
+ }
890
781
  indicesToRender.add(idx);
891
782
  }
892
783
  }
@@ -912,102 +803,86 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
912
803
  }
913
804
  }
914
805
 
806
+ const lastItemsMap = new Map(lastRenderedItems.map((it) => [ it.index, it ]));
807
+
915
808
  for (const i of sortedIndices) {
916
809
  const item = props.value.items[ i ];
917
810
  if (item === undefined) {
918
811
  continue;
919
812
  }
920
813
 
921
- let x = 0;
922
- let y = 0;
923
- let width = 0;
924
- let height = 0;
925
-
926
- if (props.value.direction === 'horizontal') {
927
- x = fixedSize !== null ? i * (fixedSize + columnGap) : itemSizesX.query(i);
928
- width = fixedSize !== null ? fixedSize : itemSizesX.get(i) - columnGap;
929
- height = viewportHeight.value;
930
- } else {
931
- // vertical or both
932
- y = (props.value.direction === 'vertical' || props.value.direction === 'both') && fixedSize !== null ? i * (fixedSize + gap) : itemSizesY.query(i);
933
- height = fixedSize !== null ? fixedSize : itemSizesY.get(i) - gap;
934
- width = props.value.direction === 'both' ? totalWidth.value : viewportWidth.value;
935
- }
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
+ });
936
828
 
937
- const isSticky = stickyIndices.includes(i);
829
+ const isSticky = stickySet.has(i);
938
830
  const originalX = x;
939
831
  const originalY = y;
940
- let isStickyActive = false;
941
- const stickyOffset = { x: 0, y: 0 };
942
-
943
- if (isSticky) {
944
- if (props.value.direction === 'vertical' || props.value.direction === 'both') {
945
- if (relativeScrollY.value > originalY) {
946
- isStickyActive = true;
947
- // Check if next sticky item pushes this one
948
- let nextStickyIdx: number | undefined;
949
- let low = 0;
950
- let high = stickyIndices.length - 1;
951
- while (low <= high) {
952
- const mid = (low + high) >>> 1;
953
- if (stickyIndices[ mid ]! > i) {
954
- nextStickyIdx = stickyIndices[ mid ];
955
- high = mid - 1;
956
- } else {
957
- low = mid + 1;
958
- }
959
- }
960
832
 
961
- if (nextStickyIdx !== undefined) {
962
- const nextStickyY = fixedSize !== null ? nextStickyIdx * (fixedSize + gap) : itemSizesY.query(nextStickyIdx);
963
- const distance = nextStickyY - relativeScrollY.value;
964
- /* v8 ignore else -- @preserve */
965
- if (distance < height) {
966
- stickyOffset.y = -(height - distance);
967
- }
968
- }
969
- }
970
- } else if (props.value.direction === 'horizontal') {
971
- if (relativeScrollX.value > originalX) {
972
- isStickyActive = true;
973
- // Check if next sticky item pushes this one
974
- let nextStickyIdx: number | undefined;
975
- let low = 0;
976
- let high = stickyIndices.length - 1;
977
- while (low <= high) {
978
- const mid = (low + high) >>> 1;
979
- if (stickyIndices[ mid ]! > i) {
980
- nextStickyIdx = stickyIndices[ mid ];
981
- high = mid - 1;
982
- } else {
983
- low = mid + 1;
984
- }
985
- }
986
-
987
- if (nextStickyIdx !== undefined) {
988
- const nextStickyX = fixedSize !== null ? nextStickyIdx * (fixedSize + columnGap) : itemSizesX.query(nextStickyIdx);
989
- const distance = nextStickyX - relativeScrollX.value;
990
- /* v8 ignore else -- @preserve */
991
- if (distance < width) {
992
- stickyOffset.x = -(width - distance);
993
- }
994
- }
995
- }
996
- }
997
- }
998
-
999
- items.push({
1000
- item,
833
+ const { isStickyActive, stickyOffset } = calculateStickyItem({
1001
834
  index: i,
1002
- offset: { x: originalX - ssrOffsetX, y: originalY - ssrOffsetY },
1003
- size: { width, height },
835
+ isSticky,
836
+ direction: props.value.direction || 'vertical',
837
+ relativeScrollX: relativeScrollX.value,
838
+ relativeScrollY: relativeScrollY.value,
1004
839
  originalX,
1005
840
  originalY,
1006
- isSticky,
1007
- isStickyActive,
1008
- 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),
1009
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
+ }
1010
882
  }
883
+
884
+ lastRenderedItems = items;
885
+
1011
886
  return items;
1012
887
  });
1013
888
 
@@ -1033,26 +908,19 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1033
908
  };
1034
909
  }
1035
910
 
1036
- const start = columnSizes.findLowerBound(relativeScrollX.value);
1037
- let currentX = columnSizes.query(start);
1038
- let end = start;
1039
-
1040
- while (end < totalCols && currentX < relativeScrollX.value + viewportWidth.value) {
1041
- currentX = columnSizes.query(++end);
1042
- }
1043
-
1044
911
  const colBuffer = (props.value.ssrRange && !isScrolling.value) ? 0 : 2;
1045
912
 
1046
- // Add buffer of columns
1047
- const safeStart = Math.max(0, start - colBuffer);
1048
- const safeEnd = Math.min(totalCols, end + colBuffer);
1049
-
1050
- return {
1051
- start: safeStart,
1052
- end: safeEnd,
1053
- padStart: columnSizes.query(safeStart),
1054
- padEnd: columnSizes.query(totalCols) - columnSizes.query(safeEnd),
1055
- };
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
+ });
1056
924
  });
1057
925
 
1058
926
  /**
@@ -1066,12 +934,14 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1066
934
  const columnGap = props.value.columnGap || 0;
1067
935
 
1068
936
  let currentColIndex = 0;
1069
- if (props.value.direction === 'horizontal' || props.value.direction === 'both') {
937
+ if (props.value.direction === 'horizontal') {
1070
938
  if (fixedSize !== null) {
1071
939
  currentColIndex = Math.floor(relativeScrollX.value / (fixedSize + columnGap));
1072
940
  } else {
1073
941
  currentColIndex = itemSizesX.findLowerBound(relativeScrollX.value);
1074
942
  }
943
+ } else if (props.value.direction === 'both') {
944
+ currentColIndex = columnSizes.findLowerBound(relativeScrollX.value);
1075
945
  }
1076
946
 
1077
947
  return {
@@ -1109,9 +979,13 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1109
979
  if (target === window || target === document) {
1110
980
  scrollX.value = window.scrollX;
1111
981
  scrollY.value = window.scrollY;
982
+ viewportWidth.value = document.documentElement.clientWidth;
983
+ viewportHeight.value = document.documentElement.clientHeight;
1112
984
  } else if (isScrollableElement(target)) {
1113
985
  scrollX.value = target.scrollLeft;
1114
986
  scrollY.value = target.scrollTop;
987
+ viewportWidth.value = target.clientWidth;
988
+ viewportHeight.value = target.clientHeight;
1115
989
  }
1116
990
 
1117
991
  if (!isScrolling.value) {
@@ -1134,62 +1008,120 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1134
1008
  */
1135
1009
  const updateItemSizes = (updates: Array<{ index: number; inlineSize: number; blockSize: number; element?: HTMLElement | undefined; }>) => {
1136
1010
  let needUpdate = false;
1011
+ let deltaX = 0;
1012
+ let deltaY = 0;
1137
1013
  const gap = props.value.gap || 0;
1138
1014
  const columnGap = props.value.columnGap || 0;
1139
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
+
1140
1032
  for (const { index, inlineSize, blockSize, element } of updates) {
1141
- if (isDynamicItemSize.value) {
1142
- if (props.value.direction === 'horizontal') {
1033
+ // Ignore 0-size measurements as they usually indicate hidden/detached elements
1034
+ if (inlineSize <= 0 && blockSize <= 0) {
1035
+ continue;
1036
+ }
1037
+
1038
+ const isMeasurable = isDynamicItemSize.value || typeof props.value.itemSize === 'function';
1039
+ if (index >= 0 && !processedRows.has(index) && isMeasurable && blockSize > 0) {
1040
+ processedRows.add(index);
1041
+ if (isHorizontalMode && inlineSize > 0) {
1143
1042
  const oldWidth = itemSizesX.get(index);
1144
1043
  const targetWidth = inlineSize + columnGap;
1145
- if (Math.abs(oldWidth - targetWidth) > 0.5 && (targetWidth > oldWidth || !measuredItemsX[ index ])) {
1146
- 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);
1147
1047
  measuredItemsX[ index ] = 1;
1148
1048
  needUpdate = true;
1049
+ if (index < firstRowIndex) {
1050
+ deltaX += d;
1051
+ }
1149
1052
  }
1150
1053
  }
1151
- if (props.value.direction === 'vertical' || props.value.direction === 'both') {
1054
+ if (isVerticalMode || isBothMode) {
1152
1055
  const oldHeight = itemSizesY.get(index);
1153
1056
  const targetHeight = blockSize + gap;
1154
- // For grid, keep max height encountered to avoid shrinking on horizontal scroll
1155
- if (props.value.direction === 'both') {
1156
- if (targetHeight > oldHeight || !measuredItemsY[ index ]) {
1157
- itemSizesY.update(index, targetHeight - oldHeight);
1158
- measuredItemsY[ index ] = 1;
1159
- needUpdate = true;
1160
- }
1161
- } else if (Math.abs(oldHeight - targetHeight) > 0.5 && (targetHeight > oldHeight || !measuredItemsY[ index ])) {
1162
- itemSizesY.update(index, targetHeight - oldHeight);
1057
+
1058
+ if (!measuredItemsY[ index ] || Math.abs(targetHeight - oldHeight) > 0.1) {
1059
+ const d = targetHeight - oldHeight;
1060
+ itemSizesY.update(index, d);
1163
1061
  measuredItemsY[ index ] = 1;
1164
1062
  needUpdate = true;
1063
+ if (index < firstRowIndex) {
1064
+ deltaY += d;
1065
+ }
1165
1066
  }
1166
1067
  }
1167
1068
  }
1168
1069
 
1169
1070
  // Dynamic column width measurement
1071
+ const isColMeasurable = isDynamicColumnWidth.value || typeof props.value.columnWidth === 'function';
1170
1072
  if (
1171
- props.value.direction === 'both'
1073
+ isBothMode
1172
1074
  && element
1173
1075
  && props.value.columnCount
1174
- && isDynamicColumnWidth.value
1076
+ && isColMeasurable
1077
+ && (inlineSize > 0 || element.dataset.colIndex === undefined)
1175
1078
  ) {
1176
- const cells = element.dataset.colIndex !== undefined
1177
- ? [ element ]
1178
- : Array.from(element.querySelectorAll('[data-col-index]')) as HTMLElement[];
1179
-
1180
- for (const child of cells) {
1181
- const colIndex = Number.parseInt(child.dataset.colIndex!, 10);
1182
-
1183
- /* v8 ignore else -- @preserve */
1184
- if (colIndex >= 0 && colIndex < (props.value.columnCount || 0)) {
1185
- 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);
1186
1084
  const oldW = columnSizes.get(colIndex);
1187
- const targetW = w + columnGap;
1188
- /* v8 ignore else -- @preserve */
1189
- if (targetW > oldW || !measuredColumns[ colIndex ]) {
1190
- 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
+ }
1191
1096
  measuredColumns[ colIndex ] = 1;
1192
- 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
+ }
1193
1125
  }
1194
1126
  }
1195
1127
  }
@@ -1198,6 +1130,17 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1198
1130
 
1199
1131
  if (needUpdate) {
1200
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
+ }
1201
1144
  }
1202
1145
  };
1203
1146
 
@@ -1217,14 +1160,61 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1217
1160
  const checkPendingScroll = () => {
1218
1161
  if (pendingScroll.value && !isHydrating.value) {
1219
1162
  const { rowIndex, colIndex, options } = pendingScroll.value;
1220
- const correctionOptions: ScrollToIndexOptions = isScrollToIndexOptions(options)
1221
- ? { ...options, isCorrection: true }
1222
- : { align: options as ScrollAlignment | ScrollAlignmentOptions, isCorrection: true };
1223
- 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
+ }
1224
1214
  }
1225
1215
  };
1226
1216
 
1227
- watch(treeUpdateFlag, checkPendingScroll);
1217
+ watch([ treeUpdateFlag, viewportWidth, viewportHeight ], checkPendingScroll);
1228
1218
 
1229
1219
  watch(isScrolling, (scrolling) => {
1230
1220
  if (!scrolling) {
@@ -1242,14 +1232,14 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1242
1232
  scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
1243
1233
 
1244
1234
  if (container === window) {
1245
- viewportWidth.value = window.innerWidth;
1246
- viewportHeight.value = window.innerHeight;
1235
+ viewportWidth.value = document.documentElement.clientWidth;
1236
+ viewportHeight.value = document.documentElement.clientHeight;
1247
1237
  scrollX.value = window.scrollX;
1248
1238
  scrollY.value = window.scrollY;
1249
1239
 
1250
1240
  const onResize = () => {
1251
- viewportWidth.value = window.innerWidth;
1252
- viewportHeight.value = window.innerHeight;
1241
+ viewportWidth.value = document.documentElement.clientWidth;
1242
+ viewportHeight.value = document.documentElement.clientHeight;
1253
1243
  updateHostOffset();
1254
1244
  };
1255
1245
  window.addEventListener('resize', onResize);
@@ -1265,7 +1255,6 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1265
1255
 
1266
1256
  resizeObserver = new ResizeObserver((entries) => {
1267
1257
  for (const entry of entries) {
1268
- /* v8 ignore else -- @preserve */
1269
1258
  if (entry.target === container) {
1270
1259
  viewportWidth.value = (container as HTMLElement).clientWidth;
1271
1260
  viewportHeight.value = (container as HTMLElement).clientHeight;
@@ -1302,7 +1291,6 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1302
1291
  : props.value.ssrRange?.start;
1303
1292
  const initialAlign = props.value.initialScrollAlign || 'start';
1304
1293
 
1305
- /* v8 ignore else -- @preserve */
1306
1294
  if (initialIndex !== undefined && initialIndex !== null) {
1307
1295
  scrollToIndex(initialIndex, props.value.ssrRange?.colStart, { align: initialAlign, behavior: 'auto' });
1308
1296
  }
@@ -1338,70 +1326,95 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1338
1326
 
1339
1327
  return {
1340
1328
  /**
1341
- * 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
1342
1331
  */
1343
1332
  renderedItems,
1333
+
1344
1334
  /**
1345
- * Total calculated width of all items including gaps.
1335
+ * Total calculated width of all items including gaps (in pixels).
1346
1336
  */
1347
1337
  totalWidth,
1338
+
1348
1339
  /**
1349
- * Total calculated height of all items including gaps.
1340
+ * Total calculated height of all items including gaps (in pixels).
1350
1341
  */
1351
1342
  totalHeight,
1343
+
1352
1344
  /**
1353
1345
  * Detailed information about the current scroll state.
1354
- * Includes currentIndex, scrollOffset, viewportSize, totalSize, and isScrolling.
1346
+ * Includes currentIndex, scrollOffset, viewportSize, totalSize, and scrolling status.
1347
+ * @see ScrollDetails
1355
1348
  */
1356
1349
  scrollDetails,
1350
+
1357
1351
  /**
1358
1352
  * Programmatically scroll to a specific row and/or column.
1359
- * @param rowIndex - The row index to scroll to
1360
- * @param colIndex - The column index to scroll to
1361
- * @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
1362
1359
  */
1363
1360
  scrollToIndex,
1361
+
1364
1362
  /**
1365
- * Programmatically scroll to a specific pixel offset.
1366
- * @param x - The pixel offset to scroll to on the X axis
1367
- * @param y - The pixel offset to scroll to on the Y axis
1368
- * @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).
1369
1368
  */
1370
1369
  scrollToOffset,
1370
+
1371
1371
  /**
1372
- * Stops any currently active programmatic scroll and clears pending corrections.
1372
+ * Stops any currently active smooth scroll animation and clears pending corrections.
1373
1373
  */
1374
1374
  stopProgrammaticScroll,
1375
+
1375
1376
  /**
1376
1377
  * Updates the stored size of an item. Should be called when an item is measured (e.g., via ResizeObserver).
1377
- * @param index - The item index
1378
- * @param width - The measured width
1379
- * @param height - The measured height
1380
- * @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).
1381
1383
  */
1382
1384
  updateItemSize,
1385
+
1383
1386
  /**
1384
- * Updates the stored size of multiple items. Should be called when items are measured (e.g., via ResizeObserver).
1385
- * @param updates - Array of item updates
1387
+ * Updates the stored size of multiple items simultaneously.
1388
+ *
1389
+ * @param updates - Array of measurement updates.
1386
1390
  */
1387
1391
  updateItemSizes,
1392
+
1388
1393
  /**
1389
1394
  * Recalculates the host element's offset relative to the scroll container.
1395
+ * Useful if the container or host moves without a resize event.
1390
1396
  */
1391
1397
  updateHostOffset,
1398
+
1392
1399
  /**
1393
- * Information about the current visible range of columns.
1400
+ * Information about the current visible range of columns and their paddings.
1401
+ * @see ColumnRange
1394
1402
  */
1395
1403
  columnRange,
1404
+
1396
1405
  /**
1397
- * Helper to get the width of a specific column based on current configuration.
1398
- * @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.
1399
1409
  */
1400
1410
  getColumnWidth,
1411
+
1401
1412
  /**
1402
1413
  * Resets all dynamic measurements and re-initializes from props.
1414
+ * Useful if item sizes have changed externally.
1403
1415
  */
1404
1416
  refresh,
1417
+
1405
1418
  /**
1406
1419
  * Whether the component has finished its first client-side mount and hydration.
1407
1420
  */