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