@pdanpdan/virtual-scroll 0.3.0 → 0.5.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,144 +1,55 @@
1
- import type { Ref } from 'vue';
1
+ import type {
2
+ RenderedItem,
3
+ ScrollAlignment,
4
+ ScrollAlignmentOptions,
5
+ ScrollDetails,
6
+ ScrollDirection,
7
+ ScrollToIndexOptions,
8
+ VirtualScrollProps,
9
+ } from '../types';
10
+ import type { MaybeRefOrGetter } from 'vue';
2
11
 
3
12
  /* global ScrollToOptions */
4
- import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
13
+ import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, reactive, ref, toValue, watch } from 'vue';
5
14
 
6
15
  import { FenwickTree } from '../utils/fenwick-tree';
7
- import { getPaddingX, getPaddingY, isElement, isScrollableElement, isScrollToIndexOptions } from '../utils/scroll';
16
+ import { BROWSER_MAX_SIZE, getPaddingX, getPaddingY, isElement, isScrollableElement, isScrollToIndexOptions, isWindowLike } from '../utils/scroll';
17
+ import {
18
+ calculateColumnRange,
19
+ calculateItemPosition,
20
+ calculateRange,
21
+ calculateScrollTarget,
22
+ calculateStickyItem,
23
+ calculateTotalSize,
24
+ displayToVirtual,
25
+ findPrevStickyIndex,
26
+ virtualToDisplay,
27
+ } from '../utils/virtual-scroll-logic';
28
+
29
+ export {
30
+ type RenderedItem,
31
+ type ScrollAlignment,
32
+ type ScrollAlignmentOptions,
33
+ type ScrollDetails,
34
+ type ScrollDirection,
35
+ type ScrollToIndexOptions,
36
+ type VirtualScrollProps,
37
+ };
8
38
 
9
39
  export const DEFAULT_ITEM_SIZE = 40;
10
40
  export const DEFAULT_COLUMN_WIDTH = 100;
11
41
  export const DEFAULT_BUFFER = 5;
12
42
 
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
43
  /**
136
44
  * Composable for virtual scrolling logic.
137
- * Handles calculation of visible items, scroll events, and dynamic item sizes.
45
+ * Handles calculation of visible items, scroll events, dynamic item sizes, and programmatic scrolling.
138
46
  *
139
- * @param props - Reactive properties for virtual scroll configuration
47
+ * @param propsInput - The configuration properties. Can be a plain object, a Ref, or a getter function.
48
+ * @see VirtualScrollProps
140
49
  */
141
- export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>) {
50
+ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<VirtualScrollProps<T>>) {
51
+ const props = computed(() => toValue(propsInput));
52
+
142
53
  // --- State ---
143
54
  const scrollX = ref(0);
144
55
  const scrollY = ref(0);
@@ -146,16 +57,42 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
146
57
  const isHydrated = ref(false);
147
58
  const isHydrating = ref(false);
148
59
  const isMounted = ref(false);
60
+ const isRtl = ref(false);
149
61
  const viewportWidth = ref(0);
150
62
  const viewportHeight = ref(0);
151
63
  const hostOffset = reactive({ x: 0, y: 0 });
64
+ const hostRefOffset = reactive({ x: 0, y: 0 });
152
65
  let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
153
66
 
154
67
  const isProgrammaticScroll = ref(false);
68
+ const internalScrollX = ref(0);
69
+ const internalScrollY = ref(0);
70
+
71
+ let computedStyle: CSSStyleDeclaration | null = null;
72
+
73
+ /**
74
+ * Detects the current direction (LTR/RTL) of the scroll container.
75
+ */
76
+ const updateDirection = () => {
77
+ if (typeof window === 'undefined') {
78
+ return;
79
+ }
80
+ const container = props.value.container || props.value.hostRef || window;
81
+ const el = isElement(container) ? container : document.documentElement;
82
+
83
+ if (!computedStyle || !('direction' in computedStyle)) {
84
+ computedStyle = window.getComputedStyle(el);
85
+ }
86
+
87
+ const newRtl = computedStyle.direction === 'rtl';
88
+ if (isRtl.value !== newRtl) {
89
+ isRtl.value = newRtl;
90
+ }
91
+ };
155
92
 
156
93
  // --- 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);
94
+ const itemSizesX = new FenwickTree(props.value.items?.length || 0);
95
+ const itemSizesY = new FenwickTree(props.value.items?.length || 0);
159
96
  const columnSizes = new FenwickTree(props.value.columnCount || 0);
160
97
 
161
98
  const treeUpdateFlag = ref(0);
@@ -176,6 +113,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
176
113
  let lastItems: T[] = [];
177
114
 
178
115
  // --- Computed Config ---
116
+ const direction = computed(() => [ 'vertical', 'horizontal', 'both' ].includes(props.value.direction as string) ? props.value.direction as ScrollDirection : 'vertical' as ScrollDirection);
117
+
179
118
  const isDynamicItemSize = computed(() =>
180
119
  props.value.itemSize === undefined || props.value.itemSize === null || props.value.itemSize === 0,
181
120
  );
@@ -188,108 +127,175 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
188
127
  (typeof props.value.itemSize === 'number' && props.value.itemSize > 0) ? props.value.itemSize : null,
189
128
  );
190
129
 
130
+ const fixedColumnWidth = computed(() =>
131
+ (typeof props.value.columnWidth === 'number' && props.value.columnWidth > 0) ? props.value.columnWidth : null,
132
+ );
133
+
191
134
  const defaultSize = computed(() => props.value.defaultItemSize || fixedItemSize.value || DEFAULT_ITEM_SIZE);
192
135
 
193
136
  const sortedStickyIndices = computed(() =>
194
137
  [ ...(props.value.stickyIndices || []) ].sort((a, b) => a - b),
195
138
  );
196
139
 
140
+ const stickyIndicesSet = computed(() => new Set(sortedStickyIndices.value));
141
+
142
+ const paddingStartX = computed(() => getPaddingX(props.value.scrollPaddingStart, props.value.direction));
143
+ const paddingEndX = computed(() => getPaddingX(props.value.scrollPaddingEnd, props.value.direction));
144
+ const paddingStartY = computed(() => getPaddingY(props.value.scrollPaddingStart, props.value.direction));
145
+ const paddingEndY = computed(() => getPaddingY(props.value.scrollPaddingEnd, props.value.direction));
146
+
147
+ const stickyStartX = computed(() => getPaddingX(props.value.stickyStart, props.value.direction));
148
+ const stickyEndX = computed(() => getPaddingX(props.value.stickyEnd, props.value.direction));
149
+ const stickyStartY = computed(() => getPaddingY(props.value.stickyStart, props.value.direction));
150
+ const stickyEndY = computed(() => getPaddingY(props.value.stickyEnd, props.value.direction));
151
+
152
+ const flowStartX = computed(() => getPaddingX(props.value.flowPaddingStart, props.value.direction));
153
+ const flowEndX = computed(() => getPaddingX(props.value.flowPaddingEnd, props.value.direction));
154
+ const flowStartY = computed(() => getPaddingY(props.value.flowPaddingStart, props.value.direction));
155
+ const flowEndY = computed(() => getPaddingY(props.value.flowPaddingEnd, props.value.direction));
156
+
157
+ const usableWidth = computed(() => viewportWidth.value - (direction.value !== 'vertical' ? (stickyStartX.value + stickyEndX.value) : 0));
158
+
159
+ const usableHeight = computed(() => viewportHeight.value - (direction.value !== 'horizontal' ? (stickyStartY.value + stickyEndY.value) : 0));
160
+
197
161
  // --- Size Calculations ---
198
162
  /**
199
- * Total width of all items in the scrollable area.
163
+ * Total size (width and height) of all items in the scrollable area.
200
164
  */
201
- const totalWidth = computed(() => {
165
+ const totalSize = computed(() => {
202
166
  // eslint-disable-next-line ts/no-unused-expressions
203
167
  treeUpdateFlag.value;
204
168
 
205
169
  if (!isHydrated.value && props.value.ssrRange && !isMounted.value) {
206
170
  const { start = 0, end = 0, colStart = 0, colEnd = 0 } = props.value.ssrRange;
207
171
  const colCount = props.value.columnCount || 0;
208
- if (props.value.direction === 'both') {
209
- if (colCount <= 0) {
210
- return 0;
172
+ const gap = props.value.gap || 0;
173
+ const columnGap = props.value.columnGap || 0;
174
+
175
+ let width = 0;
176
+ let height = 0;
177
+
178
+ if (direction.value === 'both') {
179
+ if (colCount > 0) {
180
+ const effectiveColEnd = colEnd || colCount;
181
+ const total = columnSizes.query(effectiveColEnd) - columnSizes.query(colStart);
182
+ width = Math.max(0, total - (effectiveColEnd > colStart ? columnGap : 0));
211
183
  }
212
- const effectiveColEnd = colEnd || colCount;
213
- const total = columnSizes.query(effectiveColEnd) - columnSizes.query(colStart);
214
- return Math.max(0, total - (effectiveColEnd > colStart ? (props.value.columnGap || 0) : 0));
215
- }
216
- /* v8 ignore else -- @preserve */
217
- if (props.value.direction === 'horizontal') {
218
184
  if (fixedItemSize.value !== null) {
219
185
  const len = end - start;
220
- return Math.max(0, len * (fixedItemSize.value + (props.value.columnGap || 0)) - (len > 0 ? (props.value.columnGap || 0) : 0));
186
+ height = Math.max(0, len * (fixedItemSize.value + gap) - (len > 0 ? gap : 0));
187
+ } else {
188
+ const total = itemSizesY.query(end) - itemSizesY.query(start);
189
+ height = Math.max(0, total - (end > start ? gap : 0));
190
+ }
191
+ } else if (direction.value === 'horizontal') {
192
+ if (fixedItemSize.value !== null) {
193
+ const len = end - start;
194
+ width = Math.max(0, len * (fixedItemSize.value + columnGap) - (len > 0 ? columnGap : 0));
195
+ } else {
196
+ const total = itemSizesX.query(end) - itemSizesX.query(start);
197
+ width = Math.max(0, total - (end > start ? columnGap : 0));
198
+ }
199
+ height = usableHeight.value;
200
+ } else {
201
+ // vertical
202
+ width = usableWidth.value;
203
+ if (fixedItemSize.value !== null) {
204
+ const len = end - start;
205
+ height = Math.max(0, len * (fixedItemSize.value + gap) - (len > 0 ? gap : 0));
206
+ } else {
207
+ const total = itemSizesY.query(end) - itemSizesY.query(start);
208
+ height = Math.max(0, total - (end > start ? gap : 0));
221
209
  }
222
- const total = itemSizesX.query(end) - itemSizesX.query(start);
223
- return Math.max(0, total - (end > start ? (props.value.columnGap || 0) : 0));
224
210
  }
225
- }
226
211
 
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));
212
+ return {
213
+ width: Math.max(width, usableWidth.value),
214
+ height: Math.max(height, usableHeight.value),
215
+ };
241
216
  }
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));
217
+
218
+ return calculateTotalSize({
219
+ direction: direction.value,
220
+ itemsLength: props.value.items.length,
221
+ columnCount: props.value.columnCount || 0,
222
+ fixedSize: fixedItemSize.value,
223
+ fixedWidth: fixedColumnWidth.value,
224
+ gap: props.value.gap || 0,
225
+ columnGap: props.value.columnGap || 0,
226
+ usableWidth: usableWidth.value,
227
+ usableHeight: usableHeight.value,
228
+ queryY: (idx) => itemSizesY.query(idx),
229
+ queryX: (idx) => itemSizesX.query(idx),
230
+ queryColumn: (idx) => columnSizes.query(idx),
231
+ });
244
232
  });
245
233
 
246
- /**
247
- * Total height of all items in the scrollable area.
248
- */
249
- const totalHeight = computed(() => {
250
- // eslint-disable-next-line ts/no-unused-expressions
251
- treeUpdateFlag.value;
234
+ const isWindowContainer = computed(() => isWindowLike(props.value.container));
252
235
 
253
- if (!isHydrated.value && props.value.ssrRange && !isMounted.value) {
254
- const { start, end } = props.value.ssrRange;
255
- /* v8 ignore else -- @preserve */
256
- if (props.value.direction === 'vertical' || props.value.direction === 'both') {
257
- if (fixedItemSize.value !== null) {
258
- const len = end - start;
259
- return Math.max(0, len * (fixedItemSize.value + (props.value.gap || 0)) - (len > 0 ? (props.value.gap || 0) : 0));
260
- }
261
- const total = itemSizesY.query(end) - itemSizesY.query(start);
262
- return Math.max(0, total - (end > start ? (props.value.gap || 0) : 0));
263
- }
264
- }
236
+ const virtualWidth = computed(() => totalSize.value.width + paddingStartX.value + paddingEndX.value);
237
+ const virtualHeight = computed(() => totalSize.value.height + paddingStartY.value + paddingEndY.value);
265
238
 
266
- if (props.value.direction === 'horizontal') {
267
- return 0;
239
+ const totalWidth = computed(() => (flowStartX.value + stickyStartX.value + stickyEndX.value + flowEndX.value + virtualWidth.value));
240
+
241
+ const totalHeight = computed(() => (flowStartY.value + stickyStartY.value + stickyEndY.value + flowEndY.value + virtualHeight.value));
242
+
243
+ const componentOffset = reactive({
244
+ x: computed(() => Math.max(0, hostOffset.x - (flowStartX.value + stickyStartX.value))),
245
+ y: computed(() => Math.max(0, hostOffset.y - (flowStartY.value + stickyStartY.value))),
246
+ });
247
+
248
+ const renderedWidth = computed(() => (isWindowContainer.value ? totalWidth.value : Math.min(totalWidth.value, BROWSER_MAX_SIZE)));
249
+ const renderedHeight = computed(() => (isWindowContainer.value ? totalHeight.value : Math.min(totalHeight.value, BROWSER_MAX_SIZE)));
250
+
251
+ const renderedVirtualWidth = computed(() => (isWindowContainer.value ? virtualWidth.value : Math.max(0, renderedWidth.value - (flowStartX.value + stickyStartX.value + stickyEndX.value + flowEndX.value))));
252
+ const renderedVirtualHeight = computed(() => (isWindowContainer.value ? virtualHeight.value : Math.max(0, renderedHeight.value - (flowStartY.value + stickyStartY.value + stickyEndY.value + flowEndY.value))));
253
+
254
+ const scaleX = computed(() => {
255
+ if (isWindowContainer.value || totalWidth.value <= BROWSER_MAX_SIZE) {
256
+ return 1;
268
257
  }
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));
258
+ const realRange = totalWidth.value - viewportWidth.value;
259
+ const displayRange = renderedWidth.value - viewportWidth.value;
260
+ return displayRange > 0 ? realRange / displayRange : 1;
261
+ });
262
+
263
+ const scaleY = computed(() => {
264
+ if (isWindowContainer.value || totalHeight.value <= BROWSER_MAX_SIZE) {
265
+ return 1;
272
266
  }
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));
267
+ const realRange = totalHeight.value - viewportHeight.value;
268
+ const displayRange = renderedHeight.value - viewportHeight.value;
269
+ return displayRange > 0 ? realRange / displayRange : 1;
275
270
  });
276
271
 
277
272
  const relativeScrollX = computed(() => {
278
- const isHorizontal = props.value.direction === 'horizontal' || props.value.direction === 'both';
279
- const padding = isHorizontal ? getPaddingX(props.value.scrollPaddingStart, props.value.direction) : 0;
280
- return Math.max(0, scrollX.value + padding - hostOffset.x);
273
+ if (direction.value === 'vertical') {
274
+ return 0;
275
+ }
276
+ const flowPaddingX = flowStartX.value + stickyStartX.value + paddingStartX.value;
277
+ return internalScrollX.value - flowPaddingX;
281
278
  });
279
+
282
280
  const relativeScrollY = computed(() => {
283
- const isVertical = props.value.direction === 'vertical' || props.value.direction === 'both';
284
- const padding = isVertical ? getPaddingY(props.value.scrollPaddingStart, props.value.direction) : 0;
285
- return Math.max(0, scrollY.value + padding - hostOffset.y);
281
+ if (direction.value === 'horizontal') {
282
+ return 0;
283
+ }
284
+ const flowPaddingY = flowStartY.value + stickyStartY.value + paddingStartY.value;
285
+ return internalScrollY.value - flowPaddingY;
286
286
  });
287
287
 
288
- // --- Scroll Helpers ---
288
+ /**
289
+ * Returns the currently calculated width for a specific column index, taking measurements and gaps into account.
290
+ *
291
+ * @param index - The column index.
292
+ * @returns The width in pixels (excluding gap).
293
+ */
289
294
  const getColumnWidth = (index: number) => {
290
295
  // eslint-disable-next-line ts/no-unused-expressions
291
296
  treeUpdateFlag.value;
292
297
 
298
+ const columnGap = props.value.columnGap || 0;
293
299
  const cw = props.value.columnWidth;
294
300
  if (typeof cw === 'number' && cw > 0) {
295
301
  return cw;
@@ -298,11 +304,39 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
298
304
  const val = cw[ index % cw.length ];
299
305
  return (val != null && val > 0) ? val : (props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH);
300
306
  }
301
- /* v8 ignore else -- @preserve */
302
307
  if (typeof cw === 'function') {
303
308
  return cw(index);
304
309
  }
305
- return columnSizes.get(index) || props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
310
+ const val = columnSizes.get(index);
311
+ return val > 0 ? val - columnGap : (props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH);
312
+ };
313
+
314
+ /**
315
+ * Returns the currently calculated height for a specific row index, taking measurements and gaps into account.
316
+ *
317
+ * @param index - The row index.
318
+ * @returns The height in pixels (excluding gap).
319
+ */
320
+ const getRowHeight = (index: number) => {
321
+ // eslint-disable-next-line ts/no-unused-expressions
322
+ treeUpdateFlag.value;
323
+
324
+ if (direction.value === 'horizontal') {
325
+ return usableHeight.value;
326
+ }
327
+
328
+ const gap = props.value.gap || 0;
329
+ const itemSize = props.value.itemSize;
330
+ if (typeof itemSize === 'number' && itemSize > 0) {
331
+ return itemSize;
332
+ }
333
+ if (typeof itemSize === 'function') {
334
+ const item = props.value.items[ index ];
335
+ return item !== undefined ? itemSize(item, index) : (props.value.defaultItemSize || DEFAULT_ITEM_SIZE);
336
+ }
337
+
338
+ const val = itemSizesY.get(index);
339
+ return val > 0 ? val - gap : (props.value.defaultItemSize || DEFAULT_ITEM_SIZE);
306
340
  };
307
341
 
308
342
  // --- Public Scroll API ---
@@ -312,172 +346,80 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
312
346
  * @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
313
347
  * @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
314
348
  * @param options - Scroll options including alignment ('start', 'center', 'end', 'auto') and behavior ('auto', 'smooth').
349
+ * Defaults to { align: 'auto', behavior: 'auto' }.
315
350
  */
316
- const scrollToIndex = (
351
+ function scrollToIndex(
317
352
  rowIndex: number | null | undefined,
318
353
  colIndex: number | null | undefined,
319
354
  options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions,
320
- ) => {
355
+ ) {
321
356
  const isCorrection = typeof options === 'object' && options !== null && 'isCorrection' in options
322
357
  ? options.isCorrection
323
358
  : false;
324
359
 
325
- if (!isCorrection) {
326
- pendingScroll.value = { rowIndex, colIndex, options };
327
- }
328
-
329
360
  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
-
352
- const isVertical = props.value.direction === 'vertical' || props.value.direction === 'both';
353
- const isHorizontal = props.value.direction === 'horizontal' || props.value.direction === 'both';
354
-
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
361
 
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
- }
362
+ const { targetX, targetY, effectiveAlignX, effectiveAlignY } = calculateScrollTarget({
363
+ rowIndex,
364
+ colIndex,
365
+ options,
366
+ direction: direction.value,
367
+ viewportWidth: viewportWidth.value,
368
+ viewportHeight: viewportHeight.value,
369
+ totalWidth: totalWidth.value,
370
+ totalHeight: totalHeight.value,
371
+ gap: props.value.gap || 0,
372
+ columnGap: props.value.columnGap || 0,
373
+ fixedSize: fixedItemSize.value,
374
+ fixedWidth: fixedColumnWidth.value,
375
+ relativeScrollX: relativeScrollX.value,
376
+ relativeScrollY: relativeScrollY.value,
377
+ getItemSizeY: (idx) => itemSizesY.get(idx),
378
+ getItemSizeX: (idx) => itemSizesX.get(idx),
379
+ getItemQueryY: (idx) => itemSizesY.query(idx),
380
+ getItemQueryX: (idx) => itemSizesX.query(idx),
381
+ getColumnSize: (idx) => columnSizes.get(idx),
382
+ getColumnQuery: (idx) => columnSizes.query(idx),
383
+ scaleX: scaleX.value,
384
+ scaleY: scaleY.value,
385
+ hostOffsetX: componentOffset.x,
386
+ hostOffsetY: componentOffset.y,
387
+ stickyIndices: sortedStickyIndices.value,
388
+ stickyStartX: stickyStartX.value,
389
+ stickyStartY: stickyStartY.value,
390
+ stickyEndX: stickyEndX.value,
391
+ stickyEndY: stickyEndY.value,
392
+ flowPaddingStartX: flowStartX.value,
393
+ flowPaddingStartY: flowStartY.value,
394
+ flowPaddingEndX: flowEndX.value,
395
+ flowPaddingEndY: flowEndY.value,
396
+ paddingStartX: paddingStartX.value,
397
+ paddingStartY: paddingStartY.value,
398
+ paddingEndX: paddingEndX.value,
399
+ paddingEndY: paddingEndY.value,
400
+ });
405
401
 
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
- }
402
+ if (!isCorrection) {
403
+ const behavior = isScrollToIndexOptions(options) ? options.behavior : undefined;
404
+ pendingScroll.value = {
405
+ rowIndex,
406
+ colIndex,
407
+ options: {
408
+ align: { x: effectiveAlignX, y: effectiveAlignY },
409
+ ...(behavior != null ? { behavior } : {}),
410
+ },
411
+ };
424
412
  }
425
413
 
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
- }
414
+ const displayTargetX = virtualToDisplay(targetX, componentOffset.x, scaleX.value);
415
+ const displayTargetY = virtualToDisplay(targetY, componentOffset.y, scaleY.value);
463
416
 
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
- }
417
+ const finalX = isRtl.value ? -displayTargetX : displayTargetX;
418
+ const finalY = displayTargetY;
472
419
 
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
- }
420
+ let behavior: 'auto' | 'smooth' | undefined;
421
+ if (isScrollToIndexOptions(options)) {
422
+ behavior = options.behavior;
481
423
  }
482
424
 
483
425
  const scrollBehavior = isCorrection ? 'auto' : (behavior || 'smooth');
@@ -485,7 +427,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
485
427
 
486
428
  if (typeof window !== 'undefined' && container === window) {
487
429
  window.scrollTo({
488
- left: (colIndex === null || colIndex === undefined) ? undefined : Math.max(0, finalX),
430
+ left: (colIndex === null || colIndex === undefined) ? undefined : (isRtl.value ? finalX : Math.max(0, finalX)),
489
431
  top: (rowIndex === null || rowIndex === undefined) ? undefined : Math.max(0, finalY),
490
432
  behavior: scrollBehavior,
491
433
  } as ScrollToOptions);
@@ -495,7 +437,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
495
437
  };
496
438
 
497
439
  if (colIndex !== null && colIndex !== undefined) {
498
- scrollOptions.left = Math.max(0, finalX);
440
+ scrollOptions.left = (isRtl.value ? finalX : Math.max(0, finalX));
499
441
  }
500
442
  if (rowIndex !== null && rowIndex !== undefined) {
501
443
  scrollOptions.top = Math.max(0, finalY);
@@ -515,53 +457,65 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
515
457
 
516
458
  if (scrollBehavior === 'auto' || scrollBehavior === undefined) {
517
459
  if (colIndex !== null && colIndex !== undefined) {
518
- scrollX.value = Math.max(0, finalX);
460
+ scrollX.value = (isRtl.value ? finalX : Math.max(0, finalX));
461
+ internalScrollX.value = targetX;
519
462
  }
520
463
  if (rowIndex !== null && rowIndex !== undefined) {
521
464
  scrollY.value = Math.max(0, finalY);
465
+ internalScrollY.value = targetY;
522
466
  }
523
- }
524
467
 
525
- if (reachedX && reachedY && !isScrolling.value) {
526
- pendingScroll.value = null;
468
+ if (pendingScroll.value) {
469
+ const currentOptions = pendingScroll.value.options;
470
+ if (isScrollToIndexOptions(currentOptions)) {
471
+ currentOptions.behavior = 'auto';
472
+ } else {
473
+ pendingScroll.value.options = {
474
+ align: currentOptions as ScrollAlignment | ScrollAlignmentOptions,
475
+ behavior: 'auto',
476
+ };
477
+ }
478
+ }
527
479
  }
528
- };
480
+ }
529
481
 
530
482
  /**
531
483
  * Programmatically scroll to a specific pixel offset relative to the content start.
532
484
  *
533
485
  * @param x - The pixel offset to scroll to on the X axis. Pass null to keep current position.
534
486
  * @param y - The pixel offset to scroll to on the Y axis. Pass null to keep current position.
535
- * @param options - Scroll options (behavior)
536
- * @param options.behavior - The scroll behavior ('auto' | 'smooth')
487
+ * @param options - Scroll options (behavior).
488
+ * @param options.behavior - The scroll behavior ('auto' | 'smooth'). Defaults to 'auto'.
537
489
  */
538
490
  const scrollToOffset = (x?: number | null, y?: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => {
539
491
  const container = props.value.container || window;
540
492
  isProgrammaticScroll.value = true;
541
-
542
- const isVertical = props.value.direction === 'vertical' || props.value.direction === 'both';
543
- const isHorizontal = props.value.direction === 'horizontal' || props.value.direction === 'both';
544
-
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);
493
+ pendingScroll.value = null;
552
494
 
553
495
  const clampedX = (x !== null && x !== undefined)
554
- ? (isHorizontal ? Math.max(0, Math.min(x, Math.max(0, totalWidth.value - usableWidth))) : Math.max(0, x))
496
+ ? Math.max(0, Math.min(x, totalWidth.value - viewportWidth.value))
555
497
  : null;
556
498
  const clampedY = (y !== null && y !== undefined)
557
- ? (isVertical ? Math.max(0, Math.min(y, Math.max(0, totalHeight.value - usableHeight))) : Math.max(0, y))
499
+ ? Math.max(0, Math.min(y, totalHeight.value - viewportHeight.value))
558
500
  : null;
559
501
 
502
+ if (clampedX !== null) {
503
+ internalScrollX.value = clampedX;
504
+ }
505
+ if (clampedY !== null) {
506
+ internalScrollY.value = clampedY;
507
+ }
508
+
560
509
  const currentX = (typeof window !== 'undefined' && container === window ? window.scrollX : (container as HTMLElement).scrollLeft);
561
510
  const currentY = (typeof window !== 'undefined' && container === window ? window.scrollY : (container as HTMLElement).scrollTop);
562
511
 
563
- const targetX = (clampedX !== null) ? clampedX + hostOffset.x - (isHorizontal ? paddingStartX : 0) : currentX;
564
- const targetY = (clampedY !== null) ? clampedY + hostOffset.y - (isVertical ? paddingStartY : 0) : currentY;
512
+ const displayTargetX = (clampedX !== null) ? virtualToDisplay(clampedX, componentOffset.x, scaleX.value) : null;
513
+ const displayTargetY = (clampedY !== null) ? virtualToDisplay(clampedY, componentOffset.y, scaleY.value) : null;
514
+
515
+ const targetX = (displayTargetX !== null)
516
+ ? (isRtl.value ? -displayTargetX : displayTargetX)
517
+ : currentX;
518
+ const targetY = (displayTargetY !== null) ? displayTargetY : currentY;
565
519
 
566
520
  if (typeof window !== 'undefined' && container === window) {
567
521
  window.scrollTo({
@@ -604,11 +558,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
604
558
  };
605
559
 
606
560
  // --- Measurement & Initialization ---
607
- const initializeSizes = () => {
608
- const newItems = props.value.items;
609
- const len = newItems.length;
610
- const colCount = props.value.columnCount || 0;
611
-
561
+ const resizeMeasurements = (len: number, colCount: number) => {
612
562
  itemSizesX.resize(len);
613
563
  itemSizesY.resize(len);
614
564
  columnSizes.resize(colCount);
@@ -628,78 +578,38 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
628
578
  newMeasuredCols.set(measuredColumns.subarray(0, Math.min(colCount, measuredColumns.length)));
629
579
  measuredColumns = newMeasuredCols;
630
580
  }
581
+ };
631
582
 
632
- let prependCount = 0;
633
- if (props.value.restoreScrollOnPrepend && lastItems.length > 0 && len > lastItems.length) {
634
- const oldFirstItem = lastItems[ 0 ];
635
- /* v8 ignore else -- @preserve */
636
- if (oldFirstItem !== undefined) {
637
- for (let i = 1; i <= len - lastItems.length; i++) {
638
- if (newItems[ i ] === oldFirstItem) {
639
- prependCount = i;
640
- break;
641
- }
642
- }
643
- }
644
- }
645
-
646
- if (prependCount > 0) {
647
- itemSizesX.shift(prependCount);
648
- itemSizesY.shift(prependCount);
649
-
650
- if (pendingScroll.value && pendingScroll.value.rowIndex !== null && pendingScroll.value.rowIndex !== undefined) {
651
- pendingScroll.value.rowIndex += prependCount;
652
- }
653
-
654
- const newMeasuredX = new Uint8Array(len);
655
- const newMeasuredY = new Uint8Array(len);
656
- newMeasuredX.set(measuredItemsX.subarray(0, Math.min(len - prependCount, measuredItemsX.length)), prependCount);
657
- newMeasuredY.set(measuredItemsY.subarray(0, Math.min(len - prependCount, measuredItemsY.length)), prependCount);
658
- measuredItemsX = newMeasuredX;
659
- measuredItemsY = newMeasuredY;
660
-
661
- // Calculate added size
662
- const gap = props.value.gap || 0;
663
- const columnGap = props.value.columnGap || 0;
664
- let addedX = 0;
665
- let addedY = 0;
666
-
667
- for (let i = 0; i < prependCount; i++) {
668
- const size = typeof props.value.itemSize === 'function'
669
- ? props.value.itemSize(newItems[ i ] as T, i)
670
- : defaultSize.value;
671
-
672
- if (props.value.direction === 'horizontal') {
673
- addedX += size + columnGap;
674
- } else {
675
- addedY += size + gap;
676
- }
677
- }
583
+ const initializeMeasurements = () => {
584
+ const newItems = props.value.items;
585
+ const len = newItems.length;
586
+ const colCount = props.value.columnCount || 0;
587
+ const gap = props.value.gap || 0;
588
+ const columnGap = props.value.columnGap || 0;
589
+ const cw = props.value.columnWidth;
678
590
 
679
- /* v8 ignore else -- @preserve */
680
- if (addedX > 0 || addedY > 0) {
681
- nextTick(() => {
682
- scrollToOffset(
683
- addedX > 0 ? relativeScrollX.value + addedX : null,
684
- addedY > 0 ? relativeScrollY.value + addedY : null,
685
- { behavior: 'auto' },
686
- );
687
- });
688
- }
689
- }
591
+ let colNeedsRebuild = false;
592
+ let itemsNeedRebuild = false;
690
593
 
691
- // Initialize columns if fixed width is provided
594
+ // Initialize columns
692
595
  if (colCount > 0) {
693
- const columnGap = props.value.columnGap || 0;
694
- let colNeedsRebuild = false;
695
596
  for (let i = 0; i < colCount; i++) {
696
- const width = getColumnWidth(i);
697
597
  const currentW = columnSizes.get(i);
698
598
  const isMeasured = measuredColumns[ i ] === 1;
699
599
 
700
- // If fixed/function, or if dynamic but not measured yet
701
- if (!isDynamicColumnWidth.value || !isMeasured || currentW === 0) {
702
- const targetW = width + columnGap;
600
+ if (!isDynamicColumnWidth.value || (!isMeasured && currentW === 0)) {
601
+ let baseWidth = 0;
602
+ if (typeof cw === 'number' && cw > 0) {
603
+ baseWidth = cw;
604
+ } else if (Array.isArray(cw) && cw.length > 0) {
605
+ baseWidth = cw[ i % cw.length ] || props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
606
+ } else if (typeof cw === 'function') {
607
+ baseWidth = cw(i);
608
+ } else {
609
+ baseWidth = props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
610
+ }
611
+
612
+ const targetW = baseWidth + columnGap;
703
613
  if (Math.abs(currentW - targetW) > 0.5) {
704
614
  columnSizes.set(i, targetW);
705
615
  measuredColumns[ i ] = isDynamicColumnWidth.value ? 0 : 1;
@@ -709,38 +619,20 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
709
619
  }
710
620
  }
711
621
  }
712
- if (colNeedsRebuild) {
713
- columnSizes.rebuild();
714
- }
715
622
  }
716
623
 
717
- const gap = props.value.gap || 0;
718
- const columnGap = props.value.columnGap || 0;
719
- let itemsNeedRebuild = false;
720
-
624
+ // Initialize items
721
625
  for (let i = 0; i < len; i++) {
722
626
  const item = props.value.items[ i ];
723
627
  const currentX = itemSizesX.get(i);
724
628
  const currentY = itemSizesY.get(i);
725
-
726
- const size = typeof props.value.itemSize === 'function'
727
- ? props.value.itemSize(item as T, i)
728
- : defaultSize.value;
729
-
730
- const isVertical = props.value.direction === 'vertical';
731
- const isHorizontal = props.value.direction === 'horizontal';
732
- const isBoth = props.value.direction === 'both';
733
-
734
- const targetX = isHorizontal ? size + columnGap : 0;
735
- const targetY = (isVertical || isBoth) ? size + gap : 0;
736
-
737
629
  const isMeasuredX = measuredItemsX[ i ] === 1;
738
630
  const isMeasuredY = measuredItemsY[ i ] === 1;
739
631
 
740
- // Logic for X
741
- if (isHorizontal) {
742
- // If fixed/function, or if dynamic but not measured yet
743
- if (!isDynamicItemSize.value || !isMeasuredX || currentX === 0) {
632
+ if (direction.value === 'horizontal') {
633
+ if (!isDynamicItemSize.value || (!isMeasuredX && currentX === 0)) {
634
+ const baseSize = typeof props.value.itemSize === 'function' ? props.value.itemSize(item as T, i) : defaultSize.value;
635
+ const targetX = baseSize + columnGap;
744
636
  if (Math.abs(currentX - targetX) > 0.5) {
745
637
  itemSizesX.set(i, targetX);
746
638
  measuredItemsX[ i ] = isDynamicItemSize.value ? 0 : 1;
@@ -755,9 +647,10 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
755
647
  itemsNeedRebuild = true;
756
648
  }
757
649
 
758
- // Logic for Y
759
- if (isVertical || isBoth) {
760
- if (!isDynamicItemSize.value || !isMeasuredY || currentY === 0) {
650
+ if (direction.value !== 'horizontal') {
651
+ if (!isDynamicItemSize.value || (!isMeasuredY && currentY === 0)) {
652
+ const baseSize = typeof props.value.itemSize === 'function' ? props.value.itemSize(item as T, i) : defaultSize.value;
653
+ const targetY = baseSize + gap;
761
654
  if (Math.abs(currentY - targetY) > 0.5) {
762
655
  itemSizesY.set(i, targetY);
763
656
  measuredItemsY[ i ] = isDynamicItemSize.value ? 0 : 1;
@@ -773,10 +666,75 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
773
666
  }
774
667
  }
775
668
 
669
+ if (colNeedsRebuild) {
670
+ columnSizes.rebuild();
671
+ }
776
672
  if (itemsNeedRebuild) {
777
673
  itemSizesX.rebuild();
778
674
  itemSizesY.rebuild();
779
675
  }
676
+ };
677
+
678
+ const initializeSizes = () => {
679
+ const newItems = props.value.items;
680
+ const len = newItems.length;
681
+ const colCount = props.value.columnCount || 0;
682
+
683
+ resizeMeasurements(len, colCount);
684
+
685
+ let prependCount = 0;
686
+ if (props.value.restoreScrollOnPrepend && lastItems.length > 0 && len > lastItems.length) {
687
+ const oldFirstItem = lastItems[ 0 ];
688
+ if (oldFirstItem !== undefined) {
689
+ for (let i = 1; i <= len - lastItems.length; i++) {
690
+ if (newItems[ i ] === oldFirstItem) {
691
+ prependCount = i;
692
+ break;
693
+ }
694
+ }
695
+ }
696
+ }
697
+
698
+ if (prependCount > 0) {
699
+ itemSizesX.shift(prependCount);
700
+ itemSizesY.shift(prependCount);
701
+
702
+ if (pendingScroll.value && pendingScroll.value.rowIndex !== null && pendingScroll.value.rowIndex !== undefined) {
703
+ pendingScroll.value.rowIndex += prependCount;
704
+ }
705
+
706
+ const newMeasuredX = new Uint8Array(len);
707
+ const newMeasuredY = new Uint8Array(len);
708
+ newMeasuredX.set(measuredItemsX.subarray(0, Math.min(len - prependCount, measuredItemsX.length)), prependCount);
709
+ newMeasuredY.set(measuredItemsY.subarray(0, Math.min(len - prependCount, measuredItemsY.length)), prependCount);
710
+ measuredItemsX = newMeasuredX;
711
+ measuredItemsY = newMeasuredY;
712
+
713
+ // Calculate added size
714
+ const gap = props.value.gap || 0;
715
+ const columnGap = props.value.columnGap || 0;
716
+ let addedX = 0;
717
+ let addedY = 0;
718
+
719
+ for (let i = 0; i < prependCount; i++) {
720
+ const size = typeof props.value.itemSize === 'function' ? props.value.itemSize(newItems[ i ] as T, i) : defaultSize.value;
721
+ if (direction.value === 'horizontal') {
722
+ addedX += size + columnGap;
723
+ } else { addedY += size + gap; }
724
+ }
725
+
726
+ if (addedX > 0 || addedY > 0) {
727
+ nextTick(() => {
728
+ scrollToOffset(
729
+ addedX > 0 ? relativeScrollX.value + addedX : null,
730
+ addedY > 0 ? relativeScrollY.value + addedY : null,
731
+ { behavior: 'auto', isCorrection: true } as ScrollToIndexOptions,
732
+ );
733
+ });
734
+ }
735
+ }
736
+
737
+ initializeMeasurements();
780
738
 
781
739
  lastItems = [ ...newItems ];
782
740
  sizesInitialized.value = true;
@@ -787,34 +745,56 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
787
745
  * Updates the host element's offset relative to the scroll container.
788
746
  */
789
747
  const updateHostOffset = () => {
790
- if (props.value.hostElement && typeof window !== 'undefined') {
791
- const rect = props.value.hostElement.getBoundingClientRect();
792
- const container = props.value.container || window;
793
-
794
- let newX = 0;
795
- let newY = 0;
748
+ if (typeof window === 'undefined') {
749
+ return;
750
+ }
751
+ const container = props.value.container || window;
796
752
 
753
+ const calculateOffset = (el: HTMLElement) => {
754
+ const rect = el.getBoundingClientRect();
797
755
  if (container === window) {
798
- newX = rect.left + window.scrollX;
799
- newY = rect.top + window.scrollY;
800
- } else if (container === props.value.hostElement) {
801
- newX = 0;
802
- newY = 0;
803
- } else if (isElement(container)) {
756
+ return {
757
+ x: isRtl.value
758
+ ? document.documentElement.clientWidth - rect.right - window.scrollX
759
+ : rect.left + window.scrollX,
760
+ y: rect.top + window.scrollY,
761
+ };
762
+ }
763
+ if (container === el) {
764
+ return { x: 0, y: 0 };
765
+ }
766
+ if (isElement(container)) {
804
767
  const containerRect = container.getBoundingClientRect();
805
- newX = rect.left - containerRect.left + container.scrollLeft;
806
- newY = rect.top - containerRect.top + container.scrollTop;
768
+ return {
769
+ x: isRtl.value
770
+ ? containerRect.right - rect.right - container.scrollLeft
771
+ : rect.left - containerRect.left + container.scrollLeft,
772
+ y: rect.top - containerRect.top + container.scrollTop,
773
+ };
807
774
  }
775
+ return { x: 0, y: 0 };
776
+ };
808
777
 
809
- if (Math.abs(hostOffset.x - newX) > 0.1 || Math.abs(hostOffset.y - newY) > 0.1) {
810
- hostOffset.x = newX;
811
- hostOffset.y = newY;
778
+ if (props.value.hostElement) {
779
+ const newOffset = calculateOffset(props.value.hostElement);
780
+ if (Math.abs(hostOffset.x - newOffset.x) > 0.1 || Math.abs(hostOffset.y - newOffset.y) > 0.1) {
781
+ hostOffset.x = newOffset.x;
782
+ hostOffset.y = newOffset.y;
783
+ }
784
+ }
785
+
786
+ if (props.value.hostRef) {
787
+ const newOffset = calculateOffset(props.value.hostRef);
788
+ if (Math.abs(hostRefOffset.x - newOffset.x) > 0.1 || Math.abs(hostRefOffset.y - newOffset.y) > 0.1) {
789
+ hostRefOffset.x = newOffset.x;
790
+ hostRefOffset.y = newOffset.y;
812
791
  }
813
792
  }
814
793
  };
815
794
 
816
795
  watch([
817
796
  () => props.value.items,
797
+ () => props.value.items.length,
818
798
  () => props.value.direction,
819
799
  () => props.value.columnCount,
820
800
  () => props.value.columnWidth,
@@ -829,7 +809,85 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
829
809
  updateHostOffset();
830
810
  });
831
811
 
832
- // --- Range & Visible Items ---
812
+ watch(isRtl, (newRtl, oldRtl) => {
813
+ if (oldRtl === undefined || newRtl === oldRtl || !isMounted.value) {
814
+ return;
815
+ }
816
+
817
+ // Use the oldRtl to correctly interpret the current scrollX
818
+ if (direction.value === 'vertical') {
819
+ updateHostOffset();
820
+ return;
821
+ }
822
+
823
+ const scrollValue = oldRtl ? Math.abs(scrollX.value) : scrollX.value;
824
+ const oldRelativeScrollX = displayToVirtual(scrollValue, hostOffset.x, scaleX.value);
825
+
826
+ // Update host offset for the new direction
827
+ updateHostOffset();
828
+
829
+ // Maintain logical horizontal position when direction changes
830
+ scrollToOffset(oldRelativeScrollX, null, { behavior: 'auto' });
831
+ }, { flush: 'sync' });
832
+
833
+ watch([ scaleX, scaleY ], () => {
834
+ if (!isMounted.value || isScrolling.value || isProgrammaticScroll.value) {
835
+ return;
836
+ }
837
+ // Sync display scroll to maintain logical position
838
+ scrollToOffset(internalScrollX.value, internalScrollY.value, { behavior: 'auto' });
839
+ });
840
+
841
+ watch([ () => props.value.items.length, () => props.value.columnCount ], ([ newLen, newColCount ], [ oldLen, oldColCount ]) => {
842
+ nextTick(() => {
843
+ const maxRelX = Math.max(0, totalWidth.value - viewportWidth.value);
844
+ const maxRelY = Math.max(0, totalHeight.value - viewportHeight.value);
845
+
846
+ if (internalScrollX.value > maxRelX || internalScrollY.value > maxRelY) {
847
+ scrollToOffset(
848
+ Math.min(internalScrollX.value, maxRelX),
849
+ Math.min(internalScrollY.value, maxRelY),
850
+ { behavior: 'auto' },
851
+ );
852
+ } else if ((newLen !== oldLen && scaleY.value !== 1) || (newColCount !== oldColCount && scaleX.value !== 1)) {
853
+ // Even if within bounds, we must sync the display scroll position
854
+ // because the coordinate scaling factor changed.
855
+ scrollToOffset(internalScrollX.value, internalScrollY.value, { behavior: 'auto' });
856
+ }
857
+ updateHostOffset();
858
+ });
859
+ });
860
+
861
+ // --- Range & Visible Items ---
862
+ const getRowIndexAt = (offset: number) => {
863
+ const gap = props.value.gap || 0;
864
+ const columnGap = props.value.columnGap || 0;
865
+ const fixedSize = fixedItemSize.value;
866
+
867
+ if (direction.value === 'horizontal') {
868
+ const step = (fixedSize || 0) + columnGap;
869
+ if (fixedSize !== null && step > 0) {
870
+ return Math.floor(offset / step);
871
+ }
872
+ return itemSizesX.findLowerBound(offset);
873
+ }
874
+ const step = (fixedSize || 0) + gap;
875
+ if (fixedSize !== null && step > 0) {
876
+ return Math.floor(offset / step);
877
+ }
878
+ return itemSizesY.findLowerBound(offset);
879
+ };
880
+
881
+ const getColIndexAt = (offset: number) => {
882
+ if (direction.value === 'both') {
883
+ return columnSizes.findLowerBound(offset);
884
+ }
885
+ if (direction.value === 'horizontal') {
886
+ return getRowIndexAt(offset);
887
+ }
888
+ return 0;
889
+ };
890
+
833
891
  /**
834
892
  * Current range of items that should be rendered.
835
893
  */
@@ -844,58 +902,26 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
844
902
  };
845
903
  }
846
904
 
847
- const direction = props.value.direction || 'vertical';
848
905
  const bufferBefore = (props.value.ssrRange && !isScrolling.value) ? 0 : (props.value.bufferBefore ?? DEFAULT_BUFFER);
849
906
  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
-
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
907
 
895
- return {
896
- start: Math.max(0, start - bufferBefore),
897
- end: Math.min(props.value.items.length, end + bufferAfter),
898
- };
908
+ return calculateRange({
909
+ direction: direction.value,
910
+ relativeScrollX: relativeScrollX.value,
911
+ relativeScrollY: relativeScrollY.value,
912
+ usableWidth: usableWidth.value,
913
+ usableHeight: usableHeight.value,
914
+ itemsLength: props.value.items.length,
915
+ bufferBefore,
916
+ bufferAfter,
917
+ gap: props.value.gap || 0,
918
+ columnGap: props.value.columnGap || 0,
919
+ fixedSize: fixedItemSize.value,
920
+ findLowerBoundY: (offset) => itemSizesY.findLowerBound(offset),
921
+ findLowerBoundX: (offset) => itemSizesX.findLowerBound(offset),
922
+ queryY: (idx) => itemSizesY.query(idx),
923
+ queryX: (idx) => itemSizesX.query(idx),
924
+ });
899
925
  });
900
926
 
901
927
  /**
@@ -905,25 +931,69 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
905
931
  // eslint-disable-next-line ts/no-unused-expressions
906
932
  treeUpdateFlag.value;
907
933
 
908
- const fixedSize = fixedItemSize.value;
909
- const gap = props.value.gap || 0;
910
- const columnGap = props.value.columnGap || 0;
934
+ const offsetX = relativeScrollX.value + stickyStartX.value;
935
+ const offsetY = relativeScrollY.value + stickyStartY.value;
936
+ const offset = direction.value === 'horizontal' ? offsetX : offsetY;
937
+ return getRowIndexAt(offset);
938
+ });
911
939
 
912
- if (props.value.direction === 'horizontal') {
913
- if (fixedSize !== null) {
914
- return Math.floor(relativeScrollX.value / (fixedSize + columnGap));
915
- }
916
- return itemSizesX.findLowerBound(relativeScrollX.value);
940
+ const columnRange = computed(() => {
941
+ // eslint-disable-next-line ts/no-unused-expressions
942
+ treeUpdateFlag.value;
943
+
944
+ const totalCols = props.value.columnCount || 0;
945
+
946
+ if (!totalCols) {
947
+ return { start: 0, end: 0, padStart: 0, padEnd: 0 };
917
948
  }
918
- if (fixedSize !== null) {
919
- return Math.floor(relativeScrollY.value / (fixedSize + gap));
949
+
950
+ if ((!isHydrated.value || isHydrating.value) && props.value.ssrRange) {
951
+ const { colStart = 0, colEnd = 0 } = props.value.ssrRange;
952
+ const safeStart = Math.max(0, colStart);
953
+ const safeEnd = Math.min(totalCols, colEnd || totalCols);
954
+
955
+ const columnGap = props.value.columnGap || 0;
956
+ const padStart = fixedColumnWidth.value !== null
957
+ ? safeStart * (fixedColumnWidth.value + columnGap)
958
+ : columnSizes.query(safeStart);
959
+
960
+ const totalColWidth = fixedColumnWidth.value !== null
961
+ ? totalCols * (fixedColumnWidth.value + columnGap) - columnGap
962
+ : Math.max(0, columnSizes.query(totalCols) - columnGap);
963
+
964
+ const contentEnd = fixedColumnWidth.value !== null
965
+ ? (safeEnd * (fixedColumnWidth.value + columnGap) - (safeEnd > 0 ? columnGap : 0))
966
+ : (columnSizes.query(safeEnd) - (safeEnd > 0 ? columnGap : 0));
967
+
968
+ return {
969
+ start: safeStart,
970
+ end: safeEnd,
971
+ padStart,
972
+ padEnd: Math.max(0, totalColWidth - contentEnd),
973
+ };
920
974
  }
921
- return itemSizesY.findLowerBound(relativeScrollY.value);
975
+
976
+ const colBuffer = (props.value.ssrRange && !isScrolling.value) ? 0 : 2;
977
+
978
+ return calculateColumnRange({
979
+ columnCount: totalCols,
980
+ relativeScrollX: relativeScrollX.value,
981
+ usableWidth: usableWidth.value,
982
+ colBuffer,
983
+ fixedWidth: fixedColumnWidth.value,
984
+ columnGap: props.value.columnGap || 0,
985
+ findLowerBound: (offset) => columnSizes.findLowerBound(offset),
986
+ query: (idx) => columnSizes.query(idx),
987
+ totalColsQuery: () => columnSizes.query(totalCols),
988
+ });
922
989
  });
923
990
 
924
991
  /**
925
992
  * List of items to be rendered with their calculated offsets and sizes.
926
993
  */
994
+
995
+ let lastRenderedItems: RenderedItem<T>[] = [];
996
+
927
997
  const renderedItems = computed<RenderedItem<T>[]>(() => {
928
998
  // eslint-disable-next-line ts/no-unused-expressions
929
999
  treeUpdateFlag.value;
@@ -934,231 +1004,209 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
934
1004
  const gap = props.value.gap || 0;
935
1005
  const columnGap = props.value.columnGap || 0;
936
1006
  const stickyIndices = sortedStickyIndices.value;
1007
+ const stickySet = stickyIndicesSet.value;
937
1008
 
938
- // Always include relevant sticky items
939
- const indicesToRender = new Set<number>();
940
- for (let i = start; i < end; i++) {
941
- indicesToRender.add(i);
942
- }
1009
+ const sortedIndices: number[] = [];
943
1010
 
944
1011
  if (isHydrated.value || !props.value.ssrRange) {
945
1012
  const activeIdx = currentIndex.value;
946
- // find the largest index in stickyIndices that is < activeIdx
947
- let prevStickyIdx: number | undefined;
948
- let low = 0;
949
- let high = stickyIndices.length - 1;
950
- while (low <= high) {
951
- const mid = (low + high) >>> 1;
952
- if (stickyIndices[ mid ]! < activeIdx) {
953
- prevStickyIdx = stickyIndices[ mid ];
954
- low = mid + 1;
955
- } else {
956
- high = mid - 1;
957
- }
958
- }
959
-
960
- if (prevStickyIdx !== undefined) {
961
- indicesToRender.add(prevStickyIdx);
962
- }
1013
+ const prevStickyIdx = findPrevStickyIndex(stickyIndices, activeIdx);
963
1014
 
964
- for (const idx of stickyIndices) {
965
- if (idx >= start && idx < end) {
966
- indicesToRender.add(idx);
967
- }
1015
+ if (prevStickyIdx !== undefined && prevStickyIdx < start) {
1016
+ sortedIndices.push(prevStickyIdx);
968
1017
  }
969
1018
  }
970
1019
 
971
- const sortedIndices = Array.from(indicesToRender).sort((a, b) => a - b);
1020
+ for (let i = start; i < end; i++) {
1021
+ sortedIndices.push(i);
1022
+ }
972
1023
 
973
1024
  const ssrStartRow = props.value.ssrRange?.start || 0;
1025
+
974
1026
  const ssrStartCol = props.value.ssrRange?.colStart || 0;
975
1027
 
976
1028
  let ssrOffsetX = 0;
977
1029
  let ssrOffsetY = 0;
978
1030
 
979
1031
  if (!isHydrated.value && props.value.ssrRange) {
980
- ssrOffsetY = (props.value.direction === 'vertical' || props.value.direction === 'both')
1032
+ ssrOffsetY = (direction.value !== 'horizontal')
981
1033
  ? (fixedSize !== null ? ssrStartRow * (fixedSize + gap) : itemSizesY.query(ssrStartRow))
982
1034
  : 0;
983
1035
 
984
- if (props.value.direction === 'horizontal') {
1036
+ if (direction.value === 'horizontal') {
985
1037
  ssrOffsetX = fixedSize !== null ? ssrStartCol * (fixedSize + columnGap) : itemSizesX.query(ssrStartCol);
986
- } else if (props.value.direction === 'both') {
1038
+ } else if (direction.value === 'both') {
987
1039
  ssrOffsetX = columnSizes.query(ssrStartCol);
988
1040
  }
989
1041
  }
990
1042
 
1043
+ const lastItemsMap = new Map(lastRenderedItems.map((it) => [ it.index, it ]));
1044
+
1045
+ // Optimization: Cache sequential queries to avoid O(log N) tree traversal for every item
1046
+ let lastIndexX = -1;
1047
+ let lastOffsetX = 0;
1048
+ let lastIndexY = -1;
1049
+ let lastOffsetY = 0;
1050
+
1051
+ const queryXCached = (idx: number) => {
1052
+ if (idx === lastIndexX + 1) {
1053
+ lastOffsetX += itemSizesX.get(lastIndexX);
1054
+ lastIndexX = idx;
1055
+ return lastOffsetX;
1056
+ }
1057
+ lastOffsetX = itemSizesX.query(idx);
1058
+ lastIndexX = idx;
1059
+ return lastOffsetX;
1060
+ };
1061
+
1062
+ const queryYCached = (idx: number) => {
1063
+ if (idx === lastIndexY + 1) {
1064
+ lastOffsetY += itemSizesY.get(lastIndexY);
1065
+ lastIndexY = idx;
1066
+ return lastOffsetY;
1067
+ }
1068
+ lastOffsetY = itemSizesY.query(idx);
1069
+ lastIndexY = idx;
1070
+ return lastOffsetY;
1071
+ };
1072
+
1073
+ const itemsStartVU_X = flowStartX.value + stickyStartX.value + paddingStartX.value;
1074
+ const itemsStartVU_Y = flowStartY.value + stickyStartY.value + paddingStartY.value;
1075
+ const wrapperStartDU_X = flowStartX.value + stickyStartX.value;
1076
+ const wrapperStartDU_Y = flowStartY.value + stickyStartY.value;
1077
+
1078
+ const colRange = columnRange.value;
1079
+
991
1080
  for (const i of sortedIndices) {
992
1081
  const item = props.value.items[ i ];
993
1082
  if (item === undefined) {
994
1083
  continue;
995
1084
  }
996
1085
 
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
- }
1086
+ const { x, y, width, height } = calculateItemPosition({
1087
+ index: i,
1088
+ direction: direction.value,
1089
+ fixedSize: fixedItemSize.value,
1090
+ gap: props.value.gap || 0,
1091
+ columnGap: props.value.columnGap || 0,
1092
+ usableWidth: usableWidth.value,
1093
+ usableHeight: usableHeight.value,
1094
+ totalWidth: totalSize.value.width,
1095
+ queryY: queryYCached,
1096
+ queryX: queryXCached,
1097
+ getSizeY: (idx) => itemSizesY.get(idx),
1098
+ getSizeX: (idx) => itemSizesX.get(idx),
1099
+ columnRange: colRange,
1100
+ });
1012
1101
 
1013
- const isSticky = stickyIndices.includes(i);
1102
+ const isSticky = stickySet.has(i);
1014
1103
  const originalX = x;
1015
1104
  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
-
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
1105
 
1075
- items.push({
1076
- item,
1106
+ const { isStickyActive, stickyOffset } = calculateStickyItem({
1077
1107
  index: i,
1078
- offset: { x: originalX - ssrOffsetX, y: originalY - ssrOffsetY },
1079
- size: { width, height },
1108
+ isSticky,
1109
+ direction: direction.value,
1110
+ relativeScrollX: relativeScrollX.value,
1111
+ relativeScrollY: relativeScrollY.value,
1080
1112
  originalX,
1081
1113
  originalY,
1082
- isSticky,
1083
- isStickyActive,
1084
- stickyOffset,
1114
+ width,
1115
+ height,
1116
+ stickyIndices,
1117
+ fixedSize: fixedItemSize.value,
1118
+ fixedWidth: fixedColumnWidth.value,
1119
+ gap: props.value.gap || 0,
1120
+ columnGap: props.value.columnGap || 0,
1121
+ getItemQueryY: (idx) => itemSizesY.query(idx),
1122
+ getItemQueryX: (idx) => itemSizesX.query(idx),
1085
1123
  });
1086
- }
1087
- return items;
1088
- });
1089
-
1090
- const columnRange = computed(() => {
1091
- // eslint-disable-next-line ts/no-unused-expressions
1092
- treeUpdateFlag.value;
1093
-
1094
- const totalCols = props.value.columnCount || 0;
1095
-
1096
- if (!totalCols) {
1097
- return { start: 0, end: 0, padStart: 0, padEnd: 0 };
1098
- }
1099
-
1100
- if ((!isHydrated.value || isHydrating.value) && props.value.ssrRange) {
1101
- const { colStart = 0, colEnd = 0 } = props.value.ssrRange;
1102
- const safeStart = Math.max(0, colStart);
1103
- const safeEnd = Math.min(totalCols, colEnd || totalCols);
1104
- return {
1105
- start: safeStart,
1106
- end: safeEnd,
1107
- padStart: 0,
1108
- padEnd: 0,
1109
- };
1110
- }
1111
1124
 
1112
- const start = columnSizes.findLowerBound(relativeScrollX.value);
1113
- let currentX = columnSizes.query(start);
1114
- let end = start;
1125
+ const offsetX = isHydrated.value
1126
+ ? (internalScrollX.value / scaleX.value + (originalX + itemsStartVU_X - internalScrollX.value)) - wrapperStartDU_X
1127
+ : (originalX - ssrOffsetX);
1128
+ const offsetY = isHydrated.value
1129
+ ? (internalScrollY.value / scaleY.value + (originalY + itemsStartVU_Y - internalScrollY.value)) - wrapperStartDU_Y
1130
+ : (originalY - ssrOffsetY);
1131
+ const last = lastItemsMap.get(i);
1115
1132
 
1116
- while (end < totalCols && currentX < relativeScrollX.value + viewportWidth.value) {
1117
- currentX = columnSizes.query(++end);
1133
+ if (
1134
+ last
1135
+ && last.item === item
1136
+ && last.offset.x === offsetX
1137
+ && last.offset.y === offsetY
1138
+ && last.size.width === width
1139
+ && last.size.height === height
1140
+ && last.isSticky === isSticky
1141
+ && last.isStickyActive === isStickyActive
1142
+ && last.stickyOffset.x === stickyOffset.x
1143
+ && last.stickyOffset.y === stickyOffset.y
1144
+ ) {
1145
+ items.push(last);
1146
+ } else {
1147
+ items.push({
1148
+ item,
1149
+ index: i,
1150
+ offset: { x: offsetX, y: offsetY },
1151
+ size: { width, height },
1152
+ originalX,
1153
+ originalY,
1154
+ isSticky,
1155
+ isStickyActive,
1156
+ stickyOffset: {
1157
+ x: stickyOffset.x,
1158
+ y: stickyOffset.y,
1159
+ },
1160
+ });
1161
+ }
1118
1162
  }
1119
1163
 
1120
- const colBuffer = (props.value.ssrRange && !isScrolling.value) ? 0 : 2;
1121
-
1122
- // Add buffer of columns
1123
- const safeStart = Math.max(0, start - colBuffer);
1124
- const safeEnd = Math.min(totalCols, end + colBuffer);
1164
+ lastRenderedItems = items;
1125
1165
 
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
- };
1166
+ return items;
1134
1167
  });
1135
1168
 
1136
- /**
1137
- * Detailed information about the current scroll state.
1138
- */
1139
1169
  const scrollDetails = computed<ScrollDetails<T>>(() => {
1140
1170
  // eslint-disable-next-line ts/no-unused-expressions
1141
1171
  treeUpdateFlag.value;
1142
1172
 
1143
- const fixedSize = fixedItemSize.value;
1144
- const columnGap = props.value.columnGap || 0;
1173
+ const currentScrollX = relativeScrollX.value + stickyStartX.value;
1174
+ const currentScrollY = relativeScrollY.value + stickyStartY.value;
1145
1175
 
1146
- let currentColIndex = 0;
1147
- if (props.value.direction === 'horizontal' || props.value.direction === 'both') {
1148
- if (fixedSize !== null) {
1149
- currentColIndex = Math.floor(relativeScrollX.value / (fixedSize + columnGap));
1150
- } else {
1151
- currentColIndex = itemSizesX.findLowerBound(relativeScrollX.value);
1152
- }
1153
- }
1176
+ const currentEndScrollX = relativeScrollX.value + (viewportWidth.value - stickyEndX.value) - 1;
1177
+ const currentEndScrollY = relativeScrollY.value + (viewportHeight.value - stickyEndY.value) - 1;
1178
+
1179
+ const currentColIndex = getColIndexAt(currentScrollX);
1180
+ const currentRowIndex = getRowIndexAt(currentScrollY);
1181
+ const currentEndIndex = getRowIndexAt(direction.value === 'horizontal' ? currentEndScrollX : currentEndScrollY);
1182
+ const currentEndColIndex = getColIndexAt(currentEndScrollX);
1154
1183
 
1155
1184
  return {
1156
1185
  items: renderedItems.value,
1157
- currentIndex: currentIndex.value,
1186
+ currentIndex: currentRowIndex,
1158
1187
  currentColIndex,
1159
- scrollOffset: { x: relativeScrollX.value, y: relativeScrollY.value },
1160
- viewportSize: { width: viewportWidth.value, height: viewportHeight.value },
1161
- totalSize: { width: totalWidth.value, height: totalHeight.value },
1188
+ currentEndIndex,
1189
+ currentEndColIndex,
1190
+ scrollOffset: {
1191
+ x: internalScrollX.value,
1192
+ y: internalScrollY.value,
1193
+ },
1194
+ displayScrollOffset: {
1195
+ x: isRtl.value ? Math.abs(scrollX.value + hostRefOffset.x) : Math.max(0, scrollX.value - hostRefOffset.x),
1196
+ y: Math.max(0, scrollY.value - hostRefOffset.y),
1197
+ },
1198
+ viewportSize: {
1199
+ width: viewportWidth.value,
1200
+ height: viewportHeight.value,
1201
+ },
1202
+ displayViewportSize: {
1203
+ width: viewportWidth.value,
1204
+ height: viewportHeight.value,
1205
+ },
1206
+ totalSize: {
1207
+ width: totalWidth.value,
1208
+ height: totalHeight.value,
1209
+ },
1162
1210
  isScrolling: isScrolling.value,
1163
1211
  isProgrammaticScroll: isProgrammaticScroll.value,
1164
1212
  range: range.value,
@@ -1184,14 +1232,24 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1184
1232
  return;
1185
1233
  }
1186
1234
 
1235
+ updateDirection();
1236
+
1187
1237
  if (target === window || target === document) {
1188
1238
  scrollX.value = window.scrollX;
1189
1239
  scrollY.value = window.scrollY;
1240
+ viewportWidth.value = document.documentElement.clientWidth;
1241
+ viewportHeight.value = document.documentElement.clientHeight;
1190
1242
  } else if (isScrollableElement(target)) {
1191
1243
  scrollX.value = target.scrollLeft;
1192
1244
  scrollY.value = target.scrollTop;
1245
+ viewportWidth.value = target.clientWidth;
1246
+ viewportHeight.value = target.clientHeight;
1193
1247
  }
1194
1248
 
1249
+ const scrollValueX = isRtl.value ? Math.abs(scrollX.value) : scrollX.value;
1250
+ internalScrollX.value = displayToVirtual(scrollValueX, componentOffset.x, scaleX.value);
1251
+ internalScrollY.value = displayToVirtual(scrollY.value, componentOffset.y, scaleY.value);
1252
+
1195
1253
  if (!isScrolling.value) {
1196
1254
  if (!isProgrammaticScroll.value) {
1197
1255
  pendingScroll.value = null;
@@ -1212,41 +1270,57 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1212
1270
  */
1213
1271
  const updateItemSizes = (updates: Array<{ index: number; inlineSize: number; blockSize: number; element?: HTMLElement | undefined; }>) => {
1214
1272
  let needUpdate = false;
1273
+ let deltaX = 0;
1274
+ let deltaY = 0;
1215
1275
  const gap = props.value.gap || 0;
1216
1276
  const columnGap = props.value.columnGap || 0;
1217
1277
 
1278
+ const currentRelX = relativeScrollX.value;
1279
+ const currentRelY = relativeScrollY.value;
1280
+
1281
+ const firstRowIndex = getRowIndexAt(direction.value === 'horizontal' ? currentRelX : currentRelY);
1282
+ const firstColIndex = getColIndexAt(currentRelX);
1283
+
1284
+ const isHorizontalMode = direction.value === 'horizontal';
1285
+ const isBothMode = direction.value === 'both';
1286
+
1287
+ const processedRows = new Set<number>();
1288
+ const processedCols = new Set<number>();
1289
+
1218
1290
  for (const { index, inlineSize, blockSize, element } of updates) {
1291
+ // Ignore 0-size measurements as they usually indicate hidden/detached elements
1292
+ if (inlineSize <= 0 && blockSize <= 0) {
1293
+ continue;
1294
+ }
1295
+
1219
1296
  const isMeasurable = isDynamicItemSize.value || typeof props.value.itemSize === 'function';
1220
- if (isMeasurable && index >= 0) {
1221
- if (props.value.direction === 'horizontal') {
1297
+ if (index >= 0 && !processedRows.has(index) && isMeasurable && blockSize > 0) {
1298
+ processedRows.add(index);
1299
+ if (isHorizontalMode && inlineSize > 0) {
1222
1300
  const oldWidth = itemSizesX.get(index);
1223
1301
  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);
1302
+ if (!measuredItemsX[ index ] || Math.abs(targetWidth - oldWidth) > 0.1) {
1303
+ const d = targetWidth - oldWidth;
1304
+ itemSizesX.update(index, d);
1230
1305
  measuredItemsX[ index ] = 1;
1231
1306
  needUpdate = true;
1307
+ if (index < firstRowIndex) {
1308
+ deltaX += d;
1309
+ }
1232
1310
  }
1233
1311
  }
1234
- if (props.value.direction === 'vertical' || props.value.direction === 'both') {
1312
+ if (!isHorizontalMode) {
1235
1313
  const oldHeight = itemSizesY.get(index);
1236
1314
  const targetHeight = blockSize + gap;
1237
1315
 
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);
1316
+ if (!measuredItemsY[ index ] || Math.abs(targetHeight - oldHeight) > 0.1) {
1317
+ const d = targetHeight - oldHeight;
1318
+ itemSizesY.update(index, d);
1248
1319
  measuredItemsY[ index ] = 1;
1249
1320
  needUpdate = true;
1321
+ if (index < firstRowIndex) {
1322
+ deltaY += d;
1323
+ }
1250
1324
  }
1251
1325
  }
1252
1326
  }
@@ -1254,27 +1328,58 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1254
1328
  // Dynamic column width measurement
1255
1329
  const isColMeasurable = isDynamicColumnWidth.value || typeof props.value.columnWidth === 'function';
1256
1330
  if (
1257
- props.value.direction === 'both'
1331
+ isBothMode
1258
1332
  && element
1259
1333
  && props.value.columnCount
1260
1334
  && isColMeasurable
1335
+ && (inlineSize > 0 || element.dataset.colIndex === undefined)
1261
1336
  ) {
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;
1337
+ const colIndexAttr = element.dataset.colIndex;
1338
+ if (colIndexAttr != null) {
1339
+ const colIndex = Number.parseInt(colIndexAttr, 10);
1340
+ if (colIndex >= 0 && colIndex < (props.value.columnCount || 0) && !processedCols.has(colIndex)) {
1341
+ processedCols.add(colIndex);
1272
1342
  const oldW = columnSizes.get(colIndex);
1273
- const targetW = w + columnGap;
1274
- if (Math.abs(oldW - targetW) > 0.5) {
1275
- columnSizes.update(colIndex, targetW - oldW);
1343
+ const targetW = inlineSize + columnGap;
1344
+
1345
+ if (!measuredColumns[ colIndex ] || Math.abs(oldW - targetW) > 0.1) {
1346
+ const d = targetW - oldW;
1347
+ if (Math.abs(d) > 0.1) {
1348
+ columnSizes.update(colIndex, d);
1349
+ needUpdate = true;
1350
+ if (colIndex < firstColIndex) {
1351
+ deltaX += d;
1352
+ }
1353
+ }
1276
1354
  measuredColumns[ colIndex ] = 1;
1277
- needUpdate = true;
1355
+ }
1356
+ }
1357
+ } else {
1358
+ // If the element is a row, try to find cells with data-col-index
1359
+ const cells = element.dataset.colIndex !== undefined
1360
+ ? [ element ]
1361
+ : Array.from(element.querySelectorAll('[data-col-index]')) as HTMLElement[];
1362
+
1363
+ for (const child of cells) {
1364
+ const colIndex = Number.parseInt(child.dataset.colIndex!, 10);
1365
+
1366
+ if (colIndex >= 0 && colIndex < (props.value.columnCount || 0) && !processedCols.has(colIndex)) {
1367
+ processedCols.add(colIndex);
1368
+ const rect = child.getBoundingClientRect();
1369
+ const w = rect.width;
1370
+ const oldW = columnSizes.get(colIndex);
1371
+ const targetW = w + columnGap;
1372
+ if (!measuredColumns[ colIndex ] || Math.abs(oldW - targetW) > 0.1) {
1373
+ const d = targetW - oldW;
1374
+ if (Math.abs(d) > 0.1) {
1375
+ columnSizes.update(colIndex, d);
1376
+ needUpdate = true;
1377
+ if (colIndex < firstColIndex) {
1378
+ deltaX += d;
1379
+ }
1380
+ }
1381
+ measuredColumns[ colIndex ] = 1;
1382
+ }
1278
1383
  }
1279
1384
  }
1280
1385
  }
@@ -1283,6 +1388,19 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1283
1388
 
1284
1389
  if (needUpdate) {
1285
1390
  treeUpdateFlag.value++;
1391
+ // Only compensate if not in a programmatic scroll,
1392
+ // as it would interrupt the browser animation or explicit alignment.
1393
+ const hasPendingScroll = pendingScroll.value !== null || isProgrammaticScroll.value;
1394
+
1395
+ if (!hasPendingScroll && (deltaX !== 0 || deltaY !== 0)) {
1396
+ const contentStartLogicalX = flowStartX.value + stickyStartX.value + paddingStartX.value;
1397
+ const contentStartLogicalY = flowStartY.value + stickyStartY.value + paddingStartY.value;
1398
+ scrollToOffset(
1399
+ deltaX !== 0 ? currentRelX + deltaX + contentStartLogicalX : null,
1400
+ deltaY !== 0 ? currentRelY + deltaY + contentStartLogicalY : null,
1401
+ { behavior: 'auto' },
1402
+ );
1403
+ }
1286
1404
  }
1287
1405
  };
1288
1406
 
@@ -1299,17 +1417,89 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1299
1417
  };
1300
1418
 
1301
1419
  // --- Scroll Queue / Correction Watchers ---
1302
- const checkPendingScroll = () => {
1420
+ function checkPendingScroll() {
1303
1421
  if (pendingScroll.value && !isHydrating.value) {
1304
1422
  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);
1423
+
1424
+ const isSmooth = isScrollToIndexOptions(options) && options.behavior === 'smooth';
1425
+
1426
+ // If it's a smooth scroll, we wait until it's finished before correcting.
1427
+ if (isSmooth && isScrolling.value) {
1428
+ return;
1429
+ }
1430
+
1431
+ const container = props.value.container || window;
1432
+ const actualScrollX = (typeof window !== 'undefined' && container === window ? window.scrollX : (container as HTMLElement).scrollLeft);
1433
+ const actualScrollY = (typeof window !== 'undefined' && container === window ? window.scrollY : (container as HTMLElement).scrollTop);
1434
+
1435
+ const scrollValueX = isRtl.value ? Math.abs(actualScrollX) : actualScrollX;
1436
+ const scrollValueY = actualScrollY;
1437
+
1438
+ const currentRelX = displayToVirtual(scrollValueX, 0, scaleX.value);
1439
+ const currentRelY = displayToVirtual(scrollValueY, 0, scaleY.value);
1440
+
1441
+ const { targetX, targetY } = calculateScrollTarget({
1442
+ rowIndex,
1443
+ colIndex,
1444
+ options,
1445
+ direction: direction.value,
1446
+ viewportWidth: viewportWidth.value,
1447
+ viewportHeight: viewportHeight.value,
1448
+ totalWidth: virtualWidth.value,
1449
+ totalHeight: virtualHeight.value,
1450
+ gap: props.value.gap || 0,
1451
+ columnGap: props.value.columnGap || 0,
1452
+ fixedSize: fixedItemSize.value,
1453
+ fixedWidth: fixedColumnWidth.value,
1454
+ relativeScrollX: currentRelX,
1455
+ relativeScrollY: currentRelY,
1456
+ getItemSizeY: (idx) => itemSizesY.get(idx),
1457
+ getItemSizeX: (idx) => itemSizesX.get(idx),
1458
+ getItemQueryY: (idx) => itemSizesY.query(idx),
1459
+ getItemQueryX: (idx) => itemSizesX.query(idx),
1460
+ getColumnSize: (idx) => columnSizes.get(idx),
1461
+ getColumnQuery: (idx) => columnSizes.query(idx),
1462
+ scaleX: scaleX.value,
1463
+ scaleY: scaleY.value,
1464
+ hostOffsetX: componentOffset.x,
1465
+ hostOffsetY: componentOffset.y,
1466
+ stickyIndices: sortedStickyIndices.value,
1467
+ stickyStartX: stickyStartX.value,
1468
+ stickyStartY: stickyStartY.value,
1469
+ stickyEndX: stickyEndX.value,
1470
+ stickyEndY: stickyEndY.value,
1471
+ flowPaddingStartX: flowStartX.value,
1472
+ flowPaddingStartY: flowStartY.value,
1473
+ flowPaddingEndX: flowEndX.value,
1474
+ flowPaddingEndY: flowEndY.value,
1475
+ paddingStartX: paddingStartX.value,
1476
+ paddingStartY: paddingStartY.value,
1477
+ paddingEndX: paddingEndX.value,
1478
+ paddingEndY: paddingEndY.value,
1479
+ });
1480
+
1481
+ const toleranceX = 2;
1482
+ const toleranceY = 2;
1483
+ const reachedX = (colIndex === null || colIndex === undefined) || Math.abs(currentRelX - targetX) < toleranceX;
1484
+ const reachedY = (rowIndex === null || rowIndex === undefined) || Math.abs(currentRelY - targetY) < toleranceY;
1485
+
1486
+ const isMeasuredX = colIndex == null || colIndex === undefined || measuredColumns[ colIndex ] === 1;
1487
+ const isMeasuredY = rowIndex == null || rowIndex === undefined || measuredItemsY[ rowIndex ] === 1;
1488
+
1489
+ if (reachedX && reachedY) {
1490
+ if (isMeasuredX && isMeasuredY && !isScrolling.value && !isProgrammaticScroll.value) {
1491
+ pendingScroll.value = null;
1492
+ }
1493
+ } else {
1494
+ const correctionOptions: ScrollToIndexOptions = isScrollToIndexOptions(options)
1495
+ ? { ...options, isCorrection: true }
1496
+ : { align: options as ScrollAlignment | ScrollAlignmentOptions, isCorrection: true };
1497
+ scrollToIndex(rowIndex, colIndex, correctionOptions);
1498
+ }
1309
1499
  }
1310
- };
1500
+ }
1311
1501
 
1312
- watch(treeUpdateFlag, checkPendingScroll);
1502
+ watch([ treeUpdateFlag, viewportWidth, viewportHeight ], checkPendingScroll);
1313
1503
 
1314
1504
  watch(isScrolling, (scrolling) => {
1315
1505
  if (!scrolling) {
@@ -1318,6 +1508,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1318
1508
  });
1319
1509
 
1320
1510
  let resizeObserver: ResizeObserver | null = null;
1511
+ let directionObserver: MutationObserver | null = null;
1512
+ let directionInterval: ReturnType<typeof setInterval> | undefined;
1321
1513
 
1322
1514
  const attachEvents = (container: HTMLElement | Window | null) => {
1323
1515
  if (!container || typeof window === 'undefined') {
@@ -1326,21 +1518,34 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1326
1518
  const scrollTarget = container === window ? document : container;
1327
1519
  scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
1328
1520
 
1521
+ computedStyle = null;
1522
+ updateDirection();
1523
+
1524
+ if (isElement(container)) {
1525
+ directionObserver = new MutationObserver(() => updateDirection());
1526
+ directionObserver.observe(container, { attributes: true, attributeFilter: [ 'dir', 'style' ] });
1527
+ }
1528
+
1529
+ directionInterval = setInterval(updateDirection, 1000);
1530
+
1329
1531
  if (container === window) {
1330
- viewportWidth.value = window.innerWidth;
1331
- viewportHeight.value = window.innerHeight;
1532
+ viewportWidth.value = document.documentElement.clientWidth;
1533
+ viewportHeight.value = document.documentElement.clientHeight;
1332
1534
  scrollX.value = window.scrollX;
1333
1535
  scrollY.value = window.scrollY;
1334
1536
 
1335
1537
  const onResize = () => {
1336
- viewportWidth.value = window.innerWidth;
1337
- viewportHeight.value = window.innerHeight;
1538
+ updateDirection();
1539
+ viewportWidth.value = document.documentElement.clientWidth;
1540
+ viewportHeight.value = document.documentElement.clientHeight;
1338
1541
  updateHostOffset();
1339
1542
  };
1340
1543
  window.addEventListener('resize', onResize);
1341
1544
  return () => {
1342
1545
  scrollTarget.removeEventListener('scroll', handleScroll);
1343
1546
  window.removeEventListener('resize', onResize);
1547
+ clearInterval(directionInterval);
1548
+ computedStyle = null;
1344
1549
  };
1345
1550
  } else {
1346
1551
  viewportWidth.value = (container as HTMLElement).clientWidth;
@@ -1349,8 +1554,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1349
1554
  scrollY.value = (container as HTMLElement).scrollTop;
1350
1555
 
1351
1556
  resizeObserver = new ResizeObserver((entries) => {
1557
+ updateDirection();
1352
1558
  for (const entry of entries) {
1353
- /* v8 ignore else -- @preserve */
1354
1559
  if (entry.target === container) {
1355
1560
  viewportWidth.value = (container as HTMLElement).clientWidth;
1356
1561
  viewportHeight.value = (container as HTMLElement).clientHeight;
@@ -1362,6 +1567,9 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1362
1567
  return () => {
1363
1568
  scrollTarget.removeEventListener('scroll', handleScroll);
1364
1569
  resizeObserver?.disconnect();
1570
+ directionObserver?.disconnect();
1571
+ clearInterval(directionInterval);
1572
+ computedStyle = null;
1365
1573
  };
1366
1574
  }
1367
1575
  };
@@ -1371,6 +1579,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1371
1579
  if (getCurrentInstance()) {
1372
1580
  onMounted(() => {
1373
1581
  isMounted.value = true;
1582
+ updateDirection();
1374
1583
 
1375
1584
  watch(() => props.value.container, (newContainer) => {
1376
1585
  cleanup?.();
@@ -1379,15 +1588,16 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1379
1588
 
1380
1589
  updateHostOffset();
1381
1590
 
1382
- if (props.value.ssrRange || props.value.initialScrollIndex !== undefined) {
1383
- nextTick(() => {
1384
- updateHostOffset();
1591
+ // Ensure we have a layout cycle before considering it hydrated
1592
+ // and starting virtualization. This avoids issues with 0-size viewports.
1593
+ nextTick(() => {
1594
+ updateHostOffset();
1595
+ if (props.value.ssrRange || props.value.initialScrollIndex !== undefined) {
1385
1596
  const initialIndex = props.value.initialScrollIndex !== undefined
1386
1597
  ? props.value.initialScrollIndex
1387
1598
  : props.value.ssrRange?.start;
1388
1599
  const initialAlign = props.value.initialScrollAlign || 'start';
1389
1600
 
1390
- /* v8 ignore else -- @preserve */
1391
1601
  if (initialIndex !== undefined && initialIndex !== null) {
1392
1602
  scrollToIndex(initialIndex, props.value.ssrRange?.colStart, { align: initialAlign, behavior: 'auto' });
1393
1603
  }
@@ -1397,10 +1607,10 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1397
1607
  nextTick(() => {
1398
1608
  isHydrating.value = false;
1399
1609
  });
1400
- });
1401
- } else {
1402
- isHydrated.value = true;
1403
- }
1610
+ } else {
1611
+ isHydrated.value = true;
1612
+ }
1613
+ });
1404
1614
  });
1405
1615
 
1406
1616
  onUnmounted(() => {
@@ -1411,6 +1621,10 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1411
1621
  /**
1412
1622
  * The list of items currently rendered in the DOM.
1413
1623
  */
1624
+ /**
1625
+ * Resets all dynamic measurements and re-initializes from current props.
1626
+ * Useful if item source data has changed in a way that affects sizes without changing the items array reference.
1627
+ */
1414
1628
  const refresh = () => {
1415
1629
  itemSizesX.resize(0);
1416
1630
  itemSizesY.resize(0);
@@ -1423,73 +1637,203 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1423
1637
 
1424
1638
  return {
1425
1639
  /**
1426
- * Array of items to be rendered with their calculated offsets and sizes.
1640
+ * Array of items currently rendered in the DOM with their calculated offsets and sizes.
1641
+ * Offsets are in Display Units (DU), sizes are in Virtual Units (VU).
1642
+ * @see RenderedItem
1427
1643
  */
1428
1644
  renderedItems,
1645
+
1429
1646
  /**
1430
- * Total calculated width of all items including gaps.
1647
+ * Total calculated width of all items including gaps (in VU).
1431
1648
  */
1432
1649
  totalWidth,
1650
+
1433
1651
  /**
1434
- * Total calculated height of all items including gaps.
1652
+ * Total calculated height of all items including gaps (in VU).
1435
1653
  */
1436
1654
  totalHeight,
1655
+
1656
+ /**
1657
+ * Total width to be rendered in the DOM (clamped to browser limits, in DU).
1658
+ */
1659
+ renderedWidth,
1660
+
1661
+ /**
1662
+ * Total height to be rendered in the DOM (clamped to browser limits, in DU).
1663
+ */
1664
+ renderedHeight,
1665
+
1437
1666
  /**
1438
1667
  * Detailed information about the current scroll state.
1439
- * Includes currentIndex, scrollOffset, viewportSize, totalSize, and isScrolling.
1668
+ * Includes currentIndex, scrollOffset (VU), displayScrollOffset (DU), viewportSize (DU), totalSize (VU), and scrolling status.
1669
+ * @see ScrollDetails
1440
1670
  */
1441
1671
  scrollDetails,
1672
+
1673
+ /**
1674
+ * Helper to get the height of a specific row based on current configuration and measurements.
1675
+ *
1676
+ * @param index - The row index.
1677
+ * @returns The height in VU (excluding gap).
1678
+ */
1679
+ getRowHeight,
1680
+
1681
+ /**
1682
+ * Helper to get the width of a specific column based on current configuration and measurements.
1683
+ *
1684
+ * @param index - The column index.
1685
+ * @returns The width in VU (excluding gap).
1686
+ */
1687
+ getColumnWidth,
1688
+
1689
+ /**
1690
+ * Helper to get the virtual offset of a specific row.
1691
+ *
1692
+ * @param index - The row index.
1693
+ * @returns The virtual offset in VU.
1694
+ */
1695
+ getRowOffset: (index: number) => (flowStartY.value + stickyStartY.value + paddingStartY.value) + itemSizesY.query(index),
1696
+
1697
+ /**
1698
+ * Helper to get the virtual offset of a specific column.
1699
+ *
1700
+ * @param index - The column index.
1701
+ * @returns The virtual offset in VU.
1702
+ */
1703
+ getColumnOffset: (index: number) => (flowStartX.value + stickyStartX.value + paddingStartX.value) + columnSizes.query(index),
1704
+
1705
+ /**
1706
+ * Helper to get the virtual offset of a specific item along the scroll axis.
1707
+ *
1708
+ * @param index - The item index.
1709
+ * @returns The virtual offset in VU.
1710
+ */
1711
+ getItemOffset: (index: number) => (direction.value === 'horizontal' ? (flowStartX.value + stickyStartX.value + paddingStartX.value) + itemSizesX.query(index) : (flowStartY.value + stickyStartY.value + paddingStartY.value) + itemSizesY.query(index)),
1712
+
1713
+ /**
1714
+ * Helper to get the size of a specific item along the scroll axis.
1715
+ *
1716
+ * @param index - The item index.
1717
+ * @returns The size in VU (excluding gap).
1718
+ */
1719
+ getItemSize: (index: number) => {
1720
+ if (direction.value === 'horizontal') {
1721
+ return Math.max(0, itemSizesX.get(index) - (props.value.columnGap || 0));
1722
+ }
1723
+ const itemSize = props.value.itemSize;
1724
+ if (typeof itemSize === 'number' && itemSize > 0) {
1725
+ return itemSize;
1726
+ }
1727
+ if (typeof itemSize === 'function') {
1728
+ const item = props.value.items[ index ];
1729
+ return item !== undefined ? itemSize(item, index) : (props.value.defaultItemSize || DEFAULT_ITEM_SIZE);
1730
+ }
1731
+ return Math.max(0, itemSizesY.get(index) - (props.value.gap || 0));
1732
+ },
1733
+
1442
1734
  /**
1443
1735
  * 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
1736
+ *
1737
+ * @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
1738
+ * @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
1739
+ * @param options - Alignment and behavior options.
1740
+ * @see ScrollAlignment
1741
+ * @see ScrollToIndexOptions
1447
1742
  */
1448
1743
  scrollToIndex,
1744
+
1449
1745
  /**
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
1746
+ * Programmatically scroll to a specific pixel offset relative to the content start.
1747
+ *
1748
+ * @param x - The pixel offset to scroll to on the X axis (VU). Pass null to keep current position.
1749
+ * @param y - The pixel offset to scroll to on the Y axis (VU). Pass null to keep current position.
1750
+ * @param options - Scroll options (behavior).
1454
1751
  */
1455
1752
  scrollToOffset,
1753
+
1456
1754
  /**
1457
- * Stops any currently active programmatic scroll and clears pending corrections.
1755
+ * Stops any currently active smooth scroll animation and clears pending corrections.
1458
1756
  */
1459
1757
  stopProgrammaticScroll,
1758
+
1460
1759
  /**
1461
1760
  * 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)
1761
+ *
1762
+ * @param index - The item index.
1763
+ * @param width - The measured inlineSize (width in DU).
1764
+ * @param height - The measured blockSize (height in DU).
1765
+ * @param element - The measured element (optional, used for robust grid column detection).
1466
1766
  */
1467
1767
  updateItemSize,
1768
+
1468
1769
  /**
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
1770
+ * Updates the stored size of multiple items simultaneously.
1771
+ *
1772
+ * @param updates - Array of measurement updates (sizes in DU).
1471
1773
  */
1472
1774
  updateItemSizes,
1775
+
1473
1776
  /**
1474
1777
  * Recalculates the host element's offset relative to the scroll container.
1778
+ * Useful if the container or host moves without a resize event.
1475
1779
  */
1476
1780
  updateHostOffset,
1781
+
1477
1782
  /**
1478
- * Information about the current visible range of columns.
1783
+ * Detects the current direction (LTR/RTL) of the scroll container.
1479
1784
  */
1480
- columnRange,
1785
+ updateDirection,
1786
+
1481
1787
  /**
1482
- * Helper to get the width of a specific column based on current configuration.
1483
- * @param index - The column index
1788
+ * Information about the current visible range of columns and their paddings.
1789
+ * @see ColumnRange
1484
1790
  */
1485
- getColumnWidth,
1791
+ columnRange,
1792
+
1486
1793
  /**
1487
1794
  * Resets all dynamic measurements and re-initializes from props.
1795
+ * Useful if item sizes have changed externally.
1488
1796
  */
1489
1797
  refresh,
1798
+
1490
1799
  /**
1491
1800
  * Whether the component has finished its first client-side mount and hydration.
1492
1801
  */
1493
1802
  isHydrated,
1803
+
1804
+ /**
1805
+ * Whether the container is the window or body.
1806
+ */
1807
+ isWindowContainer,
1808
+
1809
+ /**
1810
+ * Whether the scroll container is in Right-to-Left (RTL) mode.
1811
+ */
1812
+ isRtl,
1813
+
1814
+ /**
1815
+ * Coordinate scaling factor for X axis (VU/DU).
1816
+ */
1817
+ scaleX,
1818
+
1819
+ /**
1820
+ * Coordinate scaling factor for Y axis (VU/DU).
1821
+ */
1822
+ scaleY,
1823
+
1824
+ /**
1825
+ * Absolute offset of the component within its container (DU).
1826
+ */
1827
+ componentOffset,
1828
+
1829
+ /**
1830
+ * Physical width of the items wrapper in the DOM (clamped to browser limits, in DU).
1831
+ */
1832
+ renderedVirtualWidth,
1833
+
1834
+ /**
1835
+ * Physical height of the items wrapper in the DOM (clamped to browser limits, in DU).
1836
+ */
1837
+ renderedVirtualHeight,
1494
1838
  };
1495
1839
  }