@pdanpdan/virtual-scroll 0.4.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.
@@ -7,13 +7,13 @@ 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
15
  import { FenwickTree } from '../utils/fenwick-tree';
16
- import { getPaddingX, getPaddingY, isElement, isScrollableElement, isScrollToIndexOptions } from '../utils/scroll';
16
+ import { BROWSER_MAX_SIZE, getPaddingX, getPaddingY, isElement, isScrollableElement, isScrollToIndexOptions, isWindowLike } from '../utils/scroll';
17
17
  import {
18
18
  calculateColumnRange,
19
19
  calculateItemPosition,
@@ -21,6 +21,9 @@ import {
21
21
  calculateScrollTarget,
22
22
  calculateStickyItem,
23
23
  calculateTotalSize,
24
+ displayToVirtual,
25
+ findPrevStickyIndex,
26
+ virtualToDisplay,
24
27
  } from '../utils/virtual-scroll-logic';
25
28
 
26
29
  export {
@@ -41,10 +44,12 @@ export const DEFAULT_BUFFER = 5;
41
44
  * Composable for virtual scrolling logic.
42
45
  * Handles calculation of visible items, scroll events, dynamic item sizes, and programmatic scrolling.
43
46
  *
44
- * @param props - A Ref to the configuration properties.
47
+ * @param propsInput - The configuration properties. Can be a plain object, a Ref, or a getter function.
45
48
  * @see VirtualScrollProps
46
49
  */
47
- 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
+
48
53
  // --- State ---
49
54
  const scrollX = ref(0);
50
55
  const scrollY = ref(0);
@@ -52,12 +57,38 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
52
57
  const isHydrated = ref(false);
53
58
  const isHydrating = ref(false);
54
59
  const isMounted = ref(false);
60
+ const isRtl = ref(false);
55
61
  const viewportWidth = ref(0);
56
62
  const viewportHeight = ref(0);
57
63
  const hostOffset = reactive({ x: 0, y: 0 });
64
+ const hostRefOffset = reactive({ x: 0, y: 0 });
58
65
  let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
59
66
 
60
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
+ };
61
92
 
62
93
  // --- Fenwick Trees for efficient size and offset management ---
63
94
  const itemSizesX = new FenwickTree(props.value.items?.length || 0);
@@ -82,6 +113,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
82
113
  let lastItems: T[] = [];
83
114
 
84
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
+
85
118
  const isDynamicItemSize = computed(() =>
86
119
  props.value.itemSize === undefined || props.value.itemSize === null || props.value.itemSize === 0,
87
120
  );
@@ -111,47 +144,79 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
111
144
  const paddingStartY = computed(() => getPaddingY(props.value.scrollPaddingStart, props.value.direction));
112
145
  const paddingEndY = computed(() => getPaddingY(props.value.scrollPaddingEnd, props.value.direction));
113
146
 
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
- });
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));
118
151
 
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
- });
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));
123
160
 
124
161
  // --- Size Calculations ---
125
162
  /**
126
- * Total width of all items in the scrollable area.
163
+ * Total size (width and height) of all items in the scrollable area.
127
164
  */
128
- const totalWidth = computed(() => {
165
+ const totalSize = computed(() => {
129
166
  // eslint-disable-next-line ts/no-unused-expressions
130
167
  treeUpdateFlag.value;
131
168
 
132
169
  if (!isHydrated.value && props.value.ssrRange && !isMounted.value) {
133
170
  const { start = 0, end = 0, colStart = 0, colEnd = 0 } = props.value.ssrRange;
134
171
  const colCount = props.value.columnCount || 0;
135
- if (props.value.direction === 'both') {
136
- if (colCount <= 0) {
137
- 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));
138
183
  }
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
184
  if (fixedItemSize.value !== null) {
145
185
  const len = end - start;
146
- 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));
147
209
  }
148
- const total = itemSizesX.query(end) - itemSizesX.query(start);
149
- return Math.max(0, total - (end > start ? (props.value.columnGap || 0) : 0));
150
210
  }
211
+
212
+ return {
213
+ width: Math.max(width, usableWidth.value),
214
+ height: Math.max(height, usableHeight.value),
215
+ };
151
216
  }
152
217
 
153
218
  return calculateTotalSize({
154
- direction: props.value.direction || 'vertical',
219
+ direction: direction.value,
155
220
  itemsLength: props.value.items.length,
156
221
  columnCount: props.value.columnCount || 0,
157
222
  fixedSize: fixedItemSize.value,
@@ -163,60 +228,74 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
163
228
  queryY: (idx) => itemSizesY.query(idx),
164
229
  queryX: (idx) => itemSizesX.query(idx),
165
230
  queryColumn: (idx) => columnSizes.query(idx),
166
- }).width;
231
+ });
167
232
  });
168
233
 
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;
234
+ const isWindowContainer = computed(() => isWindowLike(props.value.container));
175
235
 
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
- }
236
+ const virtualWidth = computed(() => totalSize.value.width + paddingStartX.value + paddingEndX.value);
237
+ const virtualHeight = computed(() => totalSize.value.height + paddingStartY.value + paddingEndY.value);
238
+
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;
186
257
  }
258
+ const realRange = totalWidth.value - viewportWidth.value;
259
+ const displayRange = renderedWidth.value - viewportWidth.value;
260
+ return displayRange > 0 ? realRange / displayRange : 1;
261
+ });
187
262
 
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;
263
+ const scaleY = computed(() => {
264
+ if (isWindowContainer.value || totalHeight.value <= BROWSER_MAX_SIZE) {
265
+ return 1;
266
+ }
267
+ const realRange = totalHeight.value - viewportHeight.value;
268
+ const displayRange = renderedHeight.value - viewportHeight.value;
269
+ return displayRange > 0 ? realRange / displayRange : 1;
202
270
  });
203
271
 
204
272
  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);
273
+ if (direction.value === 'vertical') {
274
+ return 0;
275
+ }
276
+ const flowPaddingX = flowStartX.value + stickyStartX.value + paddingStartX.value;
277
+ return internalScrollX.value - flowPaddingX;
208
278
  });
279
+
209
280
  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);
281
+ if (direction.value === 'horizontal') {
282
+ return 0;
283
+ }
284
+ const flowPaddingY = flowStartY.value + stickyStartY.value + paddingStartY.value;
285
+ return internalScrollY.value - flowPaddingY;
213
286
  });
214
287
 
215
- // --- 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
+ */
216
294
  const getColumnWidth = (index: number) => {
217
295
  // eslint-disable-next-line ts/no-unused-expressions
218
296
  treeUpdateFlag.value;
219
297
 
298
+ const columnGap = props.value.columnGap || 0;
220
299
  const cw = props.value.columnWidth;
221
300
  if (typeof cw === 'number' && cw > 0) {
222
301
  return cw;
@@ -228,7 +307,36 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
228
307
  if (typeof cw === 'function') {
229
308
  return cw(index);
230
309
  }
231
- 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);
232
340
  };
233
341
 
234
342
  // --- Public Scroll API ---
@@ -240,29 +348,24 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
240
348
  * @param options - Scroll options including alignment ('start', 'center', 'end', 'auto') and behavior ('auto', 'smooth').
241
349
  * Defaults to { align: 'auto', behavior: 'auto' }.
242
350
  */
243
- const scrollToIndex = (
351
+ function scrollToIndex(
244
352
  rowIndex: number | null | undefined,
245
353
  colIndex: number | null | undefined,
246
354
  options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions,
247
- ) => {
355
+ ) {
248
356
  const isCorrection = typeof options === 'object' && options !== null && 'isCorrection' in options
249
357
  ? options.isCorrection
250
358
  : false;
251
359
 
252
360
  const container = props.value.container || window;
253
361
 
254
- const isVertical = props.value.direction === 'vertical' || props.value.direction === 'both';
255
- const isHorizontal = props.value.direction === 'horizontal' || props.value.direction === 'both';
256
-
257
362
  const { targetX, targetY, effectiveAlignX, effectiveAlignY } = calculateScrollTarget({
258
363
  rowIndex,
259
364
  colIndex,
260
365
  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,
366
+ direction: direction.value,
367
+ viewportWidth: viewportWidth.value,
368
+ viewportHeight: viewportHeight.value,
266
369
  totalWidth: totalWidth.value,
267
370
  totalHeight: totalHeight.value,
268
371
  gap: props.value.gap || 0,
@@ -277,7 +380,23 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
277
380
  getItemQueryX: (idx) => itemSizesX.query(idx),
278
381
  getColumnSize: (idx) => columnSizes.get(idx),
279
382
  getColumnQuery: (idx) => columnSizes.query(idx),
383
+ scaleX: scaleX.value,
384
+ scaleY: scaleY.value,
385
+ hostOffsetX: componentOffset.x,
386
+ hostOffsetY: componentOffset.y,
280
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,
281
400
  });
282
401
 
283
402
  if (!isCorrection) {
@@ -292,8 +411,11 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
292
411
  };
293
412
  }
294
413
 
295
- const finalX = targetX + hostOffset.x - (isHorizontal ? paddingStartX.value : 0);
296
- const finalY = targetY + hostOffset.y - (isVertical ? paddingStartY.value : 0);
414
+ const displayTargetX = virtualToDisplay(targetX, componentOffset.x, scaleX.value);
415
+ const displayTargetY = virtualToDisplay(targetY, componentOffset.y, scaleY.value);
416
+
417
+ const finalX = isRtl.value ? -displayTargetX : displayTargetX;
418
+ const finalY = displayTargetY;
297
419
 
298
420
  let behavior: 'auto' | 'smooth' | undefined;
299
421
  if (isScrollToIndexOptions(options)) {
@@ -305,7 +427,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
305
427
 
306
428
  if (typeof window !== 'undefined' && container === window) {
307
429
  window.scrollTo({
308
- left: (colIndex === null || colIndex === undefined) ? undefined : Math.max(0, finalX),
430
+ left: (colIndex === null || colIndex === undefined) ? undefined : (isRtl.value ? finalX : Math.max(0, finalX)),
309
431
  top: (rowIndex === null || rowIndex === undefined) ? undefined : Math.max(0, finalY),
310
432
  behavior: scrollBehavior,
311
433
  } as ScrollToOptions);
@@ -315,7 +437,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
315
437
  };
316
438
 
317
439
  if (colIndex !== null && colIndex !== undefined) {
318
- scrollOptions.left = Math.max(0, finalX);
440
+ scrollOptions.left = (isRtl.value ? finalX : Math.max(0, finalX));
319
441
  }
320
442
  if (rowIndex !== null && rowIndex !== undefined) {
321
443
  scrollOptions.top = Math.max(0, finalY);
@@ -335,45 +457,65 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
335
457
 
336
458
  if (scrollBehavior === 'auto' || scrollBehavior === undefined) {
337
459
  if (colIndex !== null && colIndex !== undefined) {
338
- scrollX.value = Math.max(0, finalX);
460
+ scrollX.value = (isRtl.value ? finalX : Math.max(0, finalX));
461
+ internalScrollX.value = targetX;
339
462
  }
340
463
  if (rowIndex !== null && rowIndex !== undefined) {
341
464
  scrollY.value = Math.max(0, finalY);
465
+ internalScrollY.value = targetY;
342
466
  }
343
- }
344
467
 
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
- };
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
+ }
479
+ }
480
+ }
349
481
 
350
482
  /**
351
483
  * Programmatically scroll to a specific pixel offset relative to the content start.
352
484
  *
353
485
  * @param x - The pixel offset to scroll to on the X axis. Pass null to keep current position.
354
486
  * @param y - The pixel offset to scroll to on the Y axis. Pass null to keep current position.
355
- * @param options - Scroll options (behavior)
487
+ * @param options - Scroll options (behavior).
356
488
  * @param options.behavior - The scroll behavior ('auto' | 'smooth'). Defaults to 'auto'.
357
489
  */
358
490
  const scrollToOffset = (x?: number | null, y?: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => {
359
491
  const container = props.value.container || window;
360
492
  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';
493
+ pendingScroll.value = null;
364
494
 
365
495
  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))
496
+ ? Math.max(0, Math.min(x, totalWidth.value - viewportWidth.value))
367
497
  : null;
368
498
  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))
499
+ ? Math.max(0, Math.min(y, totalHeight.value - viewportHeight.value))
370
500
  : null;
371
501
 
502
+ if (clampedX !== null) {
503
+ internalScrollX.value = clampedX;
504
+ }
505
+ if (clampedY !== null) {
506
+ internalScrollY.value = clampedY;
507
+ }
508
+
372
509
  const currentX = (typeof window !== 'undefined' && container === window ? window.scrollX : (container as HTMLElement).scrollLeft);
373
510
  const currentY = (typeof window !== 'undefined' && container === window ? window.scrollY : (container as HTMLElement).scrollTop);
374
511
 
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;
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;
377
519
 
378
520
  if (typeof window !== 'undefined' && container === window) {
379
521
  window.scrollTo({
@@ -416,11 +558,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
416
558
  };
417
559
 
418
560
  // --- Measurement & Initialization ---
419
- const initializeSizes = () => {
420
- const newItems = props.value.items;
421
- const len = newItems.length;
422
- const colCount = props.value.columnCount || 0;
423
-
561
+ const resizeMeasurements = (len: number, colCount: number) => {
424
562
  itemSizesX.resize(len);
425
563
  itemSizesY.resize(len);
426
564
  columnSizes.resize(colCount);
@@ -440,70 +578,21 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
440
578
  newMeasuredCols.set(measuredColumns.subarray(0, Math.min(colCount, measuredColumns.length)));
441
579
  measuredColumns = newMeasuredCols;
442
580
  }
581
+ };
443
582
 
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
- }
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;
489
590
 
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
- }
591
+ let colNeedsRebuild = false;
592
+ let itemsNeedRebuild = false;
500
593
 
501
594
  // Initialize columns
502
595
  if (colCount > 0) {
503
- const columnGap = props.value.columnGap || 0;
504
- let colNeedsRebuild = false;
505
- const cw = props.value.columnWidth;
506
-
507
596
  for (let i = 0; i < colCount; i++) {
508
597
  const currentW = columnSizes.get(i);
509
598
  const isMeasured = measuredColumns[ i ] === 1;
@@ -530,35 +619,20 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
530
619
  }
531
620
  }
532
621
  }
533
- if (colNeedsRebuild) {
534
- columnSizes.rebuild();
535
- }
536
622
  }
537
623
 
538
- const gap = props.value.gap || 0;
539
- const columnGap = props.value.columnGap || 0;
540
- let itemsNeedRebuild = false;
541
-
624
+ // Initialize items
542
625
  for (let i = 0; i < len; i++) {
543
626
  const item = props.value.items[ i ];
544
627
  const currentX = itemSizesX.get(i);
545
628
  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
629
  const isMeasuredX = measuredItemsX[ i ] === 1;
552
630
  const isMeasuredY = measuredItemsY[ i ] === 1;
553
631
 
554
- // Logic for X
555
- if (isHorizontal) {
632
+ if (direction.value === 'horizontal') {
556
633
  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;
634
+ const baseSize = typeof props.value.itemSize === 'function' ? props.value.itemSize(item as T, i) : defaultSize.value;
560
635
  const targetX = baseSize + columnGap;
561
-
562
636
  if (Math.abs(currentX - targetX) > 0.5) {
563
637
  itemSizesX.set(i, targetX);
564
638
  measuredItemsX[ i ] = isDynamicItemSize.value ? 0 : 1;
@@ -573,14 +647,10 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
573
647
  itemsNeedRebuild = true;
574
648
  }
575
649
 
576
- // Logic for Y
577
- if (isVertical || isBoth) {
650
+ if (direction.value !== 'horizontal') {
578
651
  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;
652
+ const baseSize = typeof props.value.itemSize === 'function' ? props.value.itemSize(item as T, i) : defaultSize.value;
582
653
  const targetY = baseSize + gap;
583
-
584
654
  if (Math.abs(currentY - targetY) > 0.5) {
585
655
  itemSizesY.set(i, targetY);
586
656
  measuredItemsY[ i ] = isDynamicItemSize.value ? 0 : 1;
@@ -596,10 +666,75 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
596
666
  }
597
667
  }
598
668
 
669
+ if (colNeedsRebuild) {
670
+ columnSizes.rebuild();
671
+ }
599
672
  if (itemsNeedRebuild) {
600
673
  itemSizesX.rebuild();
601
674
  itemSizesY.rebuild();
602
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();
603
738
 
604
739
  lastItems = [ ...newItems ];
605
740
  sizesInitialized.value = true;
@@ -610,28 +745,49 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
610
745
  * Updates the host element's offset relative to the scroll container.
611
746
  */
612
747
  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;
748
+ if (typeof window === 'undefined') {
749
+ return;
750
+ }
751
+ const container = props.value.container || window;
619
752
 
753
+ const calculateOffset = (el: HTMLElement) => {
754
+ const rect = el.getBoundingClientRect();
620
755
  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)) {
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)) {
627
767
  const containerRect = container.getBoundingClientRect();
628
- newX = rect.left - containerRect.left + container.scrollLeft;
629
- 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
+ };
774
+ }
775
+ return { x: 0, y: 0 };
776
+ };
777
+
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;
630
783
  }
784
+ }
631
785
 
632
- if (Math.abs(hostOffset.x - newX) > 0.1 || Math.abs(hostOffset.y - newY) > 0.1) {
633
- hostOffset.x = newX;
634
- hostOffset.y = newY;
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;
635
791
  }
636
792
  }
637
793
  };
@@ -653,7 +809,85 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
653
809
  updateHostOffset();
654
810
  });
655
811
 
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
+
656
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
+
657
891
  /**
658
892
  * Current range of items that should be rendered.
659
893
  */
@@ -672,7 +906,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
672
906
  const bufferAfter = props.value.bufferAfter ?? DEFAULT_BUFFER;
673
907
 
674
908
  return calculateRange({
675
- direction: props.value.direction || 'vertical',
909
+ direction: direction.value,
676
910
  relativeScrollX: relativeScrollX.value,
677
911
  relativeScrollY: relativeScrollY.value,
678
912
  usableWidth: usableWidth.value,
@@ -697,20 +931,61 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
697
931
  // eslint-disable-next-line ts/no-unused-expressions
698
932
  treeUpdateFlag.value;
699
933
 
700
- const fixedSize = fixedItemSize.value;
701
- const gap = props.value.gap || 0;
702
- 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
+ });
703
939
 
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);
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 };
709
948
  }
710
- if (fixedSize !== null) {
711
- 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
+ };
712
974
  }
713
- 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
+ });
714
989
  });
715
990
 
716
991
  /**
@@ -731,80 +1006,77 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
731
1006
  const stickyIndices = sortedStickyIndices.value;
732
1007
  const stickySet = stickyIndicesSet.value;
733
1008
 
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
- }
1009
+ const sortedIndices: number[] = [];
739
1010
 
740
1011
  if (isHydrated.value || !props.value.ssrRange) {
741
1012
  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
- }
1013
+ const prevStickyIdx = findPrevStickyIndex(stickyIndices, activeIdx);
755
1014
 
756
- if (prevStickyIdx !== undefined) {
757
- indicesToRender.add(prevStickyIdx);
758
- }
759
-
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
- }
1015
+ if (prevStickyIdx !== undefined && prevStickyIdx < start) {
1016
+ sortedIndices.push(prevStickyIdx);
783
1017
  }
784
1018
  }
785
1019
 
786
- const sortedIndices = Array.from(indicesToRender).sort((a, b) => a - b);
1020
+ for (let i = start; i < end; i++) {
1021
+ sortedIndices.push(i);
1022
+ }
787
1023
 
788
1024
  const ssrStartRow = props.value.ssrRange?.start || 0;
1025
+
789
1026
  const ssrStartCol = props.value.ssrRange?.colStart || 0;
790
1027
 
791
1028
  let ssrOffsetX = 0;
792
1029
  let ssrOffsetY = 0;
793
1030
 
794
1031
  if (!isHydrated.value && props.value.ssrRange) {
795
- ssrOffsetY = (props.value.direction === 'vertical' || props.value.direction === 'both')
1032
+ ssrOffsetY = (direction.value !== 'horizontal')
796
1033
  ? (fixedSize !== null ? ssrStartRow * (fixedSize + gap) : itemSizesY.query(ssrStartRow))
797
1034
  : 0;
798
1035
 
799
- if (props.value.direction === 'horizontal') {
1036
+ if (direction.value === 'horizontal') {
800
1037
  ssrOffsetX = fixedSize !== null ? ssrStartCol * (fixedSize + columnGap) : itemSizesX.query(ssrStartCol);
801
- } else if (props.value.direction === 'both') {
1038
+ } else if (direction.value === 'both') {
802
1039
  ssrOffsetX = columnSizes.query(ssrStartCol);
803
1040
  }
804
1041
  }
805
1042
 
806
1043
  const lastItemsMap = new Map(lastRenderedItems.map((it) => [ it.index, it ]));
807
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
+
808
1080
  for (const i of sortedIndices) {
809
1081
  const item = props.value.items[ i ];
810
1082
  if (item === undefined) {
@@ -813,17 +1085,18 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
813
1085
 
814
1086
  const { x, y, width, height } = calculateItemPosition({
815
1087
  index: i,
816
- direction: props.value.direction || 'vertical',
1088
+ direction: direction.value,
817
1089
  fixedSize: fixedItemSize.value,
818
1090
  gap: props.value.gap || 0,
819
1091
  columnGap: props.value.columnGap || 0,
820
1092
  usableWidth: usableWidth.value,
821
1093
  usableHeight: usableHeight.value,
822
- totalWidth: totalWidth.value,
823
- queryY: (idx) => itemSizesY.query(idx),
824
- queryX: (idx) => itemSizesX.query(idx),
1094
+ totalWidth: totalSize.value.width,
1095
+ queryY: queryYCached,
1096
+ queryX: queryXCached,
825
1097
  getSizeY: (idx) => itemSizesY.get(idx),
826
1098
  getSizeX: (idx) => itemSizesX.get(idx),
1099
+ columnRange: colRange,
827
1100
  });
828
1101
 
829
1102
  const isSticky = stickySet.has(i);
@@ -833,7 +1106,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
833
1106
  const { isStickyActive, stickyOffset } = calculateStickyItem({
834
1107
  index: i,
835
1108
  isSticky,
836
- direction: props.value.direction || 'vertical',
1109
+ direction: direction.value,
837
1110
  relativeScrollX: relativeScrollX.value,
838
1111
  relativeScrollY: relativeScrollY.value,
839
1112
  originalX,
@@ -849,8 +1122,12 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
849
1122
  getItemQueryX: (idx) => itemSizesX.query(idx),
850
1123
  });
851
1124
 
852
- const offsetX = originalX - ssrOffsetX;
853
- const offsetY = originalY - ssrOffsetY;
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);
854
1131
  const last = lastItemsMap.get(i);
855
1132
 
856
1133
  if (
@@ -876,7 +1153,10 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
876
1153
  originalY,
877
1154
  isSticky,
878
1155
  isStickyActive,
879
- stickyOffset,
1156
+ stickyOffset: {
1157
+ x: stickyOffset.x,
1158
+ y: stickyOffset.y,
1159
+ },
880
1160
  });
881
1161
  }
882
1162
  }
@@ -886,71 +1166,47 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
886
1166
  return items;
887
1167
  });
888
1168
 
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
1169
  const scrollDetails = computed<ScrollDetails<T>>(() => {
930
1170
  // eslint-disable-next-line ts/no-unused-expressions
931
1171
  treeUpdateFlag.value;
932
1172
 
933
- const fixedSize = fixedItemSize.value;
934
- const columnGap = props.value.columnGap || 0;
1173
+ const currentScrollX = relativeScrollX.value + stickyStartX.value;
1174
+ const currentScrollY = relativeScrollY.value + stickyStartY.value;
935
1175
 
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
- }
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);
946
1183
 
947
1184
  return {
948
1185
  items: renderedItems.value,
949
- currentIndex: currentIndex.value,
1186
+ currentIndex: currentRowIndex,
950
1187
  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 },
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
+ },
954
1210
  isScrolling: isScrolling.value,
955
1211
  isProgrammaticScroll: isProgrammaticScroll.value,
956
1212
  range: range.value,
@@ -976,6 +1232,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
976
1232
  return;
977
1233
  }
978
1234
 
1235
+ updateDirection();
1236
+
979
1237
  if (target === window || target === document) {
980
1238
  scrollX.value = window.scrollX;
981
1239
  scrollY.value = window.scrollY;
@@ -988,6 +1246,10 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
988
1246
  viewportHeight.value = target.clientHeight;
989
1247
  }
990
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
+
991
1253
  if (!isScrolling.value) {
992
1254
  if (!isProgrammaticScroll.value) {
993
1255
  pendingScroll.value = null;
@@ -1015,16 +1277,12 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1015
1277
 
1016
1278
  const currentRelX = relativeScrollX.value;
1017
1279
  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
1280
 
1025
- const isHorizontalMode = props.value.direction === 'horizontal';
1026
- const isVerticalMode = props.value.direction === 'vertical';
1027
- const isBothMode = props.value.direction === 'both';
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';
1028
1286
 
1029
1287
  const processedRows = new Set<number>();
1030
1288
  const processedCols = new Set<number>();
@@ -1051,7 +1309,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1051
1309
  }
1052
1310
  }
1053
1311
  }
1054
- if (isVerticalMode || isBothMode) {
1312
+ if (!isHorizontalMode) {
1055
1313
  const oldHeight = itemSizesY.get(index);
1056
1314
  const targetHeight = blockSize + gap;
1057
1315
 
@@ -1135,9 +1393,11 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1135
1393
  const hasPendingScroll = pendingScroll.value !== null || isProgrammaticScroll.value;
1136
1394
 
1137
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;
1138
1398
  scrollToOffset(
1139
- deltaX !== 0 ? currentRelX + deltaX : null,
1140
- deltaY !== 0 ? currentRelY + deltaY : null,
1399
+ deltaX !== 0 ? currentRelX + deltaX + contentStartLogicalX : null,
1400
+ deltaY !== 0 ? currentRelY + deltaY + contentStartLogicalY : null,
1141
1401
  { behavior: 'auto' },
1142
1402
  );
1143
1403
  }
@@ -1157,7 +1417,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1157
1417
  };
1158
1418
 
1159
1419
  // --- Scroll Queue / Correction Watchers ---
1160
- const checkPendingScroll = () => {
1420
+ function checkPendingScroll() {
1161
1421
  if (pendingScroll.value && !isHydrating.value) {
1162
1422
  const { rowIndex, colIndex, options } = pendingScroll.value;
1163
1423
 
@@ -1168,41 +1428,66 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1168
1428
  return;
1169
1429
  }
1170
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
+
1171
1441
  const { targetX, targetY } = calculateScrollTarget({
1172
1442
  rowIndex,
1173
1443
  colIndex,
1174
1444
  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,
1445
+ direction: direction.value,
1446
+ viewportWidth: viewportWidth.value,
1447
+ viewportHeight: viewportHeight.value,
1448
+ totalWidth: virtualWidth.value,
1449
+ totalHeight: virtualHeight.value,
1182
1450
  gap: props.value.gap || 0,
1183
1451
  columnGap: props.value.columnGap || 0,
1184
1452
  fixedSize: fixedItemSize.value,
1185
1453
  fixedWidth: fixedColumnWidth.value,
1186
- relativeScrollX: relativeScrollX.value,
1187
- relativeScrollY: relativeScrollY.value,
1454
+ relativeScrollX: currentRelX,
1455
+ relativeScrollY: currentRelY,
1188
1456
  getItemSizeY: (idx) => itemSizesY.get(idx),
1189
1457
  getItemSizeX: (idx) => itemSizesX.get(idx),
1190
1458
  getItemQueryY: (idx) => itemSizesY.query(idx),
1191
1459
  getItemQueryX: (idx) => itemSizesX.query(idx),
1192
1460
  getColumnSize: (idx) => columnSizes.get(idx),
1193
1461
  getColumnQuery: (idx) => columnSizes.query(idx),
1462
+ scaleX: scaleX.value,
1463
+ scaleY: scaleY.value,
1464
+ hostOffsetX: componentOffset.x,
1465
+ hostOffsetY: componentOffset.y,
1194
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,
1195
1479
  });
1196
1480
 
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;
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;
1200
1485
 
1201
1486
  const isMeasuredX = colIndex == null || colIndex === undefined || measuredColumns[ colIndex ] === 1;
1202
1487
  const isMeasuredY = rowIndex == null || rowIndex === undefined || measuredItemsY[ rowIndex ] === 1;
1203
1488
 
1204
1489
  if (reachedX && reachedY) {
1205
- if (isMeasuredX && isMeasuredY) {
1490
+ if (isMeasuredX && isMeasuredY && !isScrolling.value && !isProgrammaticScroll.value) {
1206
1491
  pendingScroll.value = null;
1207
1492
  }
1208
1493
  } else {
@@ -1212,7 +1497,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1212
1497
  scrollToIndex(rowIndex, colIndex, correctionOptions);
1213
1498
  }
1214
1499
  }
1215
- };
1500
+ }
1216
1501
 
1217
1502
  watch([ treeUpdateFlag, viewportWidth, viewportHeight ], checkPendingScroll);
1218
1503
 
@@ -1223,6 +1508,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1223
1508
  });
1224
1509
 
1225
1510
  let resizeObserver: ResizeObserver | null = null;
1511
+ let directionObserver: MutationObserver | null = null;
1512
+ let directionInterval: ReturnType<typeof setInterval> | undefined;
1226
1513
 
1227
1514
  const attachEvents = (container: HTMLElement | Window | null) => {
1228
1515
  if (!container || typeof window === 'undefined') {
@@ -1231,6 +1518,16 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1231
1518
  const scrollTarget = container === window ? document : container;
1232
1519
  scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
1233
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
+
1234
1531
  if (container === window) {
1235
1532
  viewportWidth.value = document.documentElement.clientWidth;
1236
1533
  viewportHeight.value = document.documentElement.clientHeight;
@@ -1238,6 +1535,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1238
1535
  scrollY.value = window.scrollY;
1239
1536
 
1240
1537
  const onResize = () => {
1538
+ updateDirection();
1241
1539
  viewportWidth.value = document.documentElement.clientWidth;
1242
1540
  viewportHeight.value = document.documentElement.clientHeight;
1243
1541
  updateHostOffset();
@@ -1246,6 +1544,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1246
1544
  return () => {
1247
1545
  scrollTarget.removeEventListener('scroll', handleScroll);
1248
1546
  window.removeEventListener('resize', onResize);
1547
+ clearInterval(directionInterval);
1548
+ computedStyle = null;
1249
1549
  };
1250
1550
  } else {
1251
1551
  viewportWidth.value = (container as HTMLElement).clientWidth;
@@ -1254,6 +1554,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1254
1554
  scrollY.value = (container as HTMLElement).scrollTop;
1255
1555
 
1256
1556
  resizeObserver = new ResizeObserver((entries) => {
1557
+ updateDirection();
1257
1558
  for (const entry of entries) {
1258
1559
  if (entry.target === container) {
1259
1560
  viewportWidth.value = (container as HTMLElement).clientWidth;
@@ -1266,6 +1567,9 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1266
1567
  return () => {
1267
1568
  scrollTarget.removeEventListener('scroll', handleScroll);
1268
1569
  resizeObserver?.disconnect();
1570
+ directionObserver?.disconnect();
1571
+ clearInterval(directionInterval);
1572
+ computedStyle = null;
1269
1573
  };
1270
1574
  }
1271
1575
  };
@@ -1275,6 +1579,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1275
1579
  if (getCurrentInstance()) {
1276
1580
  onMounted(() => {
1277
1581
  isMounted.value = true;
1582
+ updateDirection();
1278
1583
 
1279
1584
  watch(() => props.value.container, (newContainer) => {
1280
1585
  cleanup?.();
@@ -1283,9 +1588,11 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1283
1588
 
1284
1589
  updateHostOffset();
1285
1590
 
1286
- if (props.value.ssrRange || props.value.initialScrollIndex !== undefined) {
1287
- nextTick(() => {
1288
- 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) {
1289
1596
  const initialIndex = props.value.initialScrollIndex !== undefined
1290
1597
  ? props.value.initialScrollIndex
1291
1598
  : props.value.ssrRange?.start;
@@ -1300,10 +1607,10 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1300
1607
  nextTick(() => {
1301
1608
  isHydrating.value = false;
1302
1609
  });
1303
- });
1304
- } else {
1305
- isHydrated.value = true;
1306
- }
1610
+ } else {
1611
+ isHydrated.value = true;
1612
+ }
1613
+ });
1307
1614
  });
1308
1615
 
1309
1616
  onUnmounted(() => {
@@ -1314,6 +1621,10 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1314
1621
  /**
1315
1622
  * The list of items currently rendered in the DOM.
1316
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
+ */
1317
1628
  const refresh = () => {
1318
1629
  itemSizesX.resize(0);
1319
1630
  itemSizesY.resize(0);
@@ -1327,27 +1638,99 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1327
1638
  return {
1328
1639
  /**
1329
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).
1330
1642
  * @see RenderedItem
1331
1643
  */
1332
1644
  renderedItems,
1333
1645
 
1334
1646
  /**
1335
- * Total calculated width of all items including gaps (in pixels).
1647
+ * Total calculated width of all items including gaps (in VU).
1336
1648
  */
1337
1649
  totalWidth,
1338
1650
 
1339
1651
  /**
1340
- * Total calculated height of all items including gaps (in pixels).
1652
+ * Total calculated height of all items including gaps (in VU).
1341
1653
  */
1342
1654
  totalHeight,
1343
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
+
1344
1666
  /**
1345
1667
  * Detailed information about the current scroll state.
1346
- * Includes currentIndex, scrollOffset, viewportSize, totalSize, and scrolling status.
1668
+ * Includes currentIndex, scrollOffset (VU), displayScrollOffset (DU), viewportSize (DU), totalSize (VU), and scrolling status.
1347
1669
  * @see ScrollDetails
1348
1670
  */
1349
1671
  scrollDetails,
1350
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
+
1351
1734
  /**
1352
1735
  * Programmatically scroll to a specific row and/or column.
1353
1736
  *
@@ -1362,8 +1745,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1362
1745
  /**
1363
1746
  * Programmatically scroll to a specific pixel offset relative to the content start.
1364
1747
  *
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.
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.
1367
1750
  * @param options - Scroll options (behavior).
1368
1751
  */
1369
1752
  scrollToOffset,
@@ -1377,8 +1760,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1377
1760
  * Updates the stored size of an item. Should be called when an item is measured (e.g., via ResizeObserver).
1378
1761
  *
1379
1762
  * @param index - The item index.
1380
- * @param width - The measured inlineSize (width).
1381
- * @param height - The measured blockSize (height).
1763
+ * @param width - The measured inlineSize (width in DU).
1764
+ * @param height - The measured blockSize (height in DU).
1382
1765
  * @param element - The measured element (optional, used for robust grid column detection).
1383
1766
  */
1384
1767
  updateItemSize,
@@ -1386,7 +1769,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1386
1769
  /**
1387
1770
  * Updates the stored size of multiple items simultaneously.
1388
1771
  *
1389
- * @param updates - Array of measurement updates.
1772
+ * @param updates - Array of measurement updates (sizes in DU).
1390
1773
  */
1391
1774
  updateItemSizes,
1392
1775
 
@@ -1397,17 +1780,15 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1397
1780
  updateHostOffset,
1398
1781
 
1399
1782
  /**
1400
- * Information about the current visible range of columns and their paddings.
1401
- * @see ColumnRange
1783
+ * Detects the current direction (LTR/RTL) of the scroll container.
1402
1784
  */
1403
- columnRange,
1785
+ updateDirection,
1404
1786
 
1405
1787
  /**
1406
- * Helper to get the width of a specific column based on current configuration and measurements.
1407
- *
1408
- * @param index - The column index.
1788
+ * Information about the current visible range of columns and their paddings.
1789
+ * @see ColumnRange
1409
1790
  */
1410
- getColumnWidth,
1791
+ columnRange,
1411
1792
 
1412
1793
  /**
1413
1794
  * Resets all dynamic measurements and re-initializes from props.
@@ -1419,5 +1800,40 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
1419
1800
  * Whether the component has finished its first client-side mount and hydration.
1420
1801
  */
1421
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,
1422
1838
  };
1423
1839
  }