@pdanpdan/virtual-scroll 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,82 +1,32 @@
1
1
  <script setup lang="ts" generic="T">
2
+ /**
3
+ * A high-performance virtual scrolling component for Vue 3.
4
+ * Supports large lists and grids by only rendering visible items and using coordinate scaling.
5
+ * Features include sticky headers/footers, RTL support, custom scrollbars, and scroll restoration.
6
+ */
2
7
  import type {
3
8
  RenderedItem,
4
9
  ScrollAlignment,
5
- ScrollAlignmentOptions,
10
+ ScrollbarSlotProps,
6
11
  ScrollDetails,
12
+ ScrollToIndexOptions,
13
+ VirtualScrollbarProps,
14
+ VirtualScrollComponentProps,
7
15
  VirtualScrollProps,
8
- } from '../composables/useVirtualScroll';
16
+ } from '../types';
9
17
  import type { VNodeChild } from 'vue';
10
18
 
11
- import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
19
+ import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, useId, watch } from 'vue';
12
20
 
13
21
  import {
14
- DEFAULT_ITEM_SIZE,
15
22
  useVirtualScroll,
16
23
  } from '../composables/useVirtualScroll';
24
+ import { useVirtualScrollbar } from '../composables/useVirtualScrollbar';
17
25
  import { getPaddingX, getPaddingY } from '../utils/scroll';
26
+ import { calculateItemStyle, displayToVirtual } from '../utils/virtual-scroll-logic';
27
+ import VirtualScrollbar from './VirtualScrollbar.vue';
18
28
 
19
- export interface Props<T = unknown> {
20
- /** Array of items to be virtualized. */
21
- items: T[];
22
- /** Fixed size of each item or a function that returns the size of an item. Pass 0, null or undefined for dynamic size detection. */
23
- itemSize?: number | ((item: T, index: number) => number) | null;
24
- /** Direction of the scroll: 'vertical', 'horizontal', or 'both' (grid). */
25
- direction?: 'vertical' | 'horizontal' | 'both';
26
- /** Number of items to render before the visible viewport. */
27
- bufferBefore?: number;
28
- /** Number of items to render after the visible viewport. */
29
- bufferAfter?: number;
30
- /** The scrollable container element or window. If not provided, the host element is used. */
31
- container?: HTMLElement | Window | null;
32
- /** Range of items to render for SSR. */
33
- ssrRange?: {
34
- start: number;
35
- end: number;
36
- colStart?: number;
37
- colEnd?: number;
38
- };
39
- /** Number of columns for bidirectional (grid) scroll. */
40
- columnCount?: number;
41
- /** Fixed width of columns or an array/function for column widths. Pass 0, null or undefined for dynamic width. */
42
- columnWidth?: number | number[] | ((index: number) => number) | null;
43
- /** The HTML tag to use for the container. */
44
- containerTag?: string;
45
- /** The HTML tag to use for the items wrapper. */
46
- wrapperTag?: string;
47
- /** The HTML tag to use for each item. */
48
- itemTag?: string;
49
- /** Padding at the start of the scroll container. */
50
- scrollPaddingStart?: number | { x?: number; y?: number; };
51
- /** Padding at the end of the scroll container. */
52
- scrollPaddingEnd?: number | { x?: number; y?: number; };
53
- /** Whether the header slot content is sticky and should be accounted for in scroll padding. If true, header size is automatically measured. */
54
- stickyHeader?: boolean;
55
- /** Whether the footer slot content is sticky and should be accounted for in scroll padding. If true, footer size is automatically measured. */
56
- stickyFooter?: boolean;
57
- /** Gap between items in pixels (vertical). */
58
- gap?: number;
59
- /** Gap between columns in pixels (horizontal/grid). */
60
- columnGap?: number;
61
- /** Indices of items that should stick to the top/start. Supports iOS-style pushing effect. */
62
- stickyIndices?: number[];
63
- /** Distance from the end of the scrollable area to trigger 'load' event in pixels. */
64
- loadDistance?: number;
65
- /** Whether items are currently being loaded. Prevents multiple 'load' events and shows 'loading' slot. */
66
- loading?: boolean;
67
- /** Whether to automatically restore scroll position when items are prepended to the list. */
68
- restoreScrollOnPrepend?: boolean;
69
- /** Initial scroll index to jump to on mount. */
70
- initialScrollIndex?: number;
71
- /** Alignment for the initial scroll index. */
72
- initialScrollAlign?: ScrollAlignment | ScrollAlignmentOptions;
73
- /** Default size for items before they are measured. */
74
- defaultItemSize?: number;
75
- /** Default width for columns before they are measured. */
76
- defaultColumnWidth?: number;
77
- /** Whether to show debug information (buffers and offsets). */
78
- debug?: boolean;
79
- }
29
+ export interface Props<T = unknown> extends VirtualScrollComponentProps<T> {}
80
30
 
81
31
  const props = withDefaults(defineProps<Props<T>>(), {
82
32
  direction: 'vertical',
@@ -97,6 +47,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
97
47
  loading: false,
98
48
  restoreScrollOnPrepend: false,
99
49
  debug: false,
50
+ virtualScrollbar: false,
100
51
  });
101
52
 
102
53
  const emit = defineEmits<{
@@ -106,27 +57,66 @@ const emit = defineEmits<{
106
57
  }>();
107
58
 
108
59
  const slots = defineSlots<{
109
- /** Content rendered at the top of the scrollable area. Can be made sticky. */
60
+ /**
61
+ * Content rendered at the top of the scrollable area.
62
+ * Can be made sticky using the `stickyHeader` prop.
63
+ */
110
64
  header?: (props: Record<string, never>) => VNodeChild;
111
- /** Slot for rendering each individual item. */
65
+
66
+ /**
67
+ * Scoped slot for rendering each individual item.
68
+ */
112
69
  item?: (props: {
113
- /** The data item being rendered. */
70
+ /** The original data item from the `items` array. */
114
71
  item: T;
115
- /** The index of the item in the items array. */
72
+ /** The original index of the item in the `items` array. */
116
73
  index: number;
117
- /** The current visible range of columns (for grid mode). */
118
- columnRange: { start: number; end: number; padStart: number; padEnd: number; };
119
- /** Function to get the width of a specific column. */
74
+ /**
75
+ * Information about the current visible range of columns (for grid mode).
76
+ * @see ColumnRange
77
+ */
78
+ columnRange: {
79
+ /** Index of the first rendered column. */
80
+ start: number;
81
+ /** Index of the last rendered column (exclusive). */
82
+ end: number;
83
+ /** Pixel offset from the start of the row to the first rendered cell. */
84
+ padStart: number;
85
+ /** Pixel offset from the last rendered cell to the end of the row. */
86
+ padEnd: number;
87
+ };
88
+ /**
89
+ * Helper function to get the width of a specific column.
90
+ * Useful for setting consistent widths in grid mode.
91
+ */
120
92
  getColumnWidth: (index: number) => number;
121
- /** Whether this item is configured to be sticky. */
93
+ /** Vertical gap between items. */
94
+ gap: number;
95
+ /** Horizontal gap between columns. */
96
+ columnGap: number;
97
+ /** Whether this item is configured to be sticky via `stickyIndices`. */
122
98
  isSticky?: boolean | undefined;
123
- /** Whether this item is currently in a sticky state. */
99
+ /** Whether this item is currently in a sticky state (stuck at the top/start). */
124
100
  isStickyActive?: boolean | undefined;
125
101
  }) => VNodeChild;
126
- /** Content shown when `loading` prop is true. */
102
+
103
+ /**
104
+ * Content shown at the end of the list when the `loading` prop is true.
105
+ * Also prevents additional 'load' events from triggering while visible.
106
+ */
127
107
  loading?: (props: Record<string, never>) => VNodeChild;
128
- /** Content rendered at the bottom of the scrollable area. Can be made sticky. */
108
+
109
+ /**
110
+ * Content rendered at the bottom of the scrollable area.
111
+ * Can be made sticky using the `stickyFooter` prop.
112
+ */
129
113
  footer?: (props: Record<string, never>) => VNodeChild;
114
+
115
+ /**
116
+ * Scoped slot for rendering custom scrollbars.
117
+ * If provided, the default VirtualScrollbar is not rendered.
118
+ */
119
+ scrollbar?: (props: ScrollbarSlotProps) => VNodeChild;
130
120
  }>();
131
121
 
132
122
  const hostRef = ref<HTMLElement | null>(null);
@@ -135,37 +125,30 @@ const headerRef = ref<HTMLElement | null>(null);
135
125
  const footerRef = ref<HTMLElement | null>(null);
136
126
  const itemRefs = new Map<number, HTMLElement>();
137
127
 
128
+ const instanceId = useId();
129
+
130
+ /**
131
+ * Unique ID for the scrollable container.
132
+ * Used for accessibility (aria-controls) and to target the element in DOM.
133
+ */
134
+ const containerId = computed(() => `vs-container-${ instanceId }`);
135
+
138
136
  const measuredPaddingStart = ref(0);
139
137
  const measuredPaddingEnd = ref(0);
140
138
 
139
+ const effectiveContainer = computed(() => (props.container === undefined ? hostRef.value : props.container));
140
+
141
141
  const isHeaderFooterInsideContainer = computed(() => {
142
- const container = props.container === undefined
143
- ? hostRef.value
144
- : props.container;
142
+ const container = effectiveContainer.value;
145
143
 
146
144
  return container === hostRef.value
147
145
  || (typeof window !== 'undefined' && (container === window || container === null));
148
146
  });
149
147
 
150
148
  const virtualScrollProps = computed(() => {
151
- const pStart = props.scrollPaddingStart;
152
- const pEnd = props.scrollPaddingEnd;
153
-
154
- /* v8 ignore start -- @preserve */
155
- const startX = typeof pStart === 'object'
156
- ? (pStart.x || 0)
157
- : (props.direction === 'horizontal' ? (pStart || 0) : 0);
158
- const startY = typeof pStart === 'object'
159
- ? (pStart.y || 0)
160
- : (props.direction !== 'horizontal' ? (pStart || 0) : 0);
161
-
162
- const endX = typeof pEnd === 'object'
163
- ? (pEnd.x || 0)
164
- : (props.direction === 'horizontal' ? (pEnd || 0) : 0);
165
- const endY = typeof pEnd === 'object'
166
- ? (pEnd.y || 0)
167
- : (props.direction !== 'horizontal' ? (pEnd || 0) : 0);
168
- /* v8 ignore stop -- @preserve */
149
+ /* Trigger re-evaluation on items array mutations */
150
+ // eslint-disable-next-line ts/no-unused-expressions
151
+ props.items.length;
169
152
 
170
153
  return {
171
154
  items: props.items,
@@ -173,20 +156,35 @@ const virtualScrollProps = computed(() => {
173
156
  direction: props.direction,
174
157
  bufferBefore: props.bufferBefore,
175
158
  bufferAfter: props.bufferAfter,
176
- container: props.container === undefined
177
- ? hostRef.value
178
- : props.container,
159
+ container: effectiveContainer.value,
179
160
  hostElement: wrapperRef.value,
161
+ hostRef: hostRef.value,
180
162
  ssrRange: props.ssrRange,
181
163
  columnCount: props.columnCount,
182
164
  columnWidth: props.columnWidth,
183
165
  scrollPaddingStart: {
184
- x: startX,
185
- y: startY + (props.stickyHeader && isHeaderFooterInsideContainer.value ? measuredPaddingStart.value : 0),
166
+ x: getPaddingX(props.scrollPaddingStart, props.direction),
167
+ y: getPaddingY(props.scrollPaddingStart, props.direction),
186
168
  },
187
169
  scrollPaddingEnd: {
188
- x: endX,
189
- y: endY + (props.stickyFooter && isHeaderFooterInsideContainer.value ? measuredPaddingEnd.value : 0),
170
+ x: getPaddingX(props.scrollPaddingEnd, props.direction),
171
+ y: getPaddingY(props.scrollPaddingEnd, props.direction),
172
+ },
173
+ flowPaddingStart: {
174
+ x: 0,
175
+ y: props.stickyHeader ? 0 : measuredPaddingStart.value,
176
+ },
177
+ flowPaddingEnd: {
178
+ x: 0,
179
+ y: props.stickyFooter ? 0 : measuredPaddingEnd.value,
180
+ },
181
+ stickyStart: {
182
+ x: 0,
183
+ y: props.stickyHeader && isHeaderFooterInsideContainer.value ? measuredPaddingStart.value : 0,
184
+ },
185
+ stickyEnd: {
186
+ x: 0,
187
+ y: props.stickyFooter && isHeaderFooterInsideContainer.value ? measuredPaddingEnd.value : 0,
190
188
  },
191
189
  gap: props.gap,
192
190
  columnGap: props.columnGap,
@@ -204,31 +202,106 @@ const virtualScrollProps = computed(() => {
204
202
 
205
203
  const {
206
204
  isHydrated,
205
+ isRtl,
207
206
  columnRange,
208
207
  renderedItems,
209
208
  scrollDetails,
210
- totalHeight,
211
- totalWidth,
209
+ renderedHeight,
210
+ renderedWidth,
212
211
  getColumnWidth,
212
+ getRowHeight,
213
213
  scrollToIndex,
214
214
  scrollToOffset,
215
215
  updateHostOffset,
216
216
  updateItemSizes,
217
+ updateDirection,
218
+ getItemOffset,
219
+ getRowOffset,
220
+ getColumnOffset,
221
+ getItemSize,
217
222
  refresh: coreRefresh,
218
223
  stopProgrammaticScroll,
224
+ scaleX,
225
+ scaleY,
226
+ isWindowContainer,
227
+ componentOffset,
228
+ renderedVirtualWidth,
229
+ renderedVirtualHeight,
219
230
  } = useVirtualScroll(virtualScrollProps);
220
231
 
232
+ const useVirtualScrolling = computed(() => scaleX.value !== 1 || scaleY.value !== 1);
233
+
234
+ const showVirtualScrollbars = computed(() => {
235
+ if (isWindowContainer.value) {
236
+ return false;
237
+ }
238
+ return props.virtualScrollbar === true || scaleX.value !== 1 || scaleY.value !== 1;
239
+ });
240
+
241
+ function handleVerticalScrollbarScrollToOffset(offset: number) {
242
+ const { displayViewportSize } = scrollDetails.value;
243
+ const scrollableRange = renderedHeight.value - displayViewportSize.height;
244
+ if (offset >= scrollableRange - 0.5) {
245
+ scrollToOffset(null, Number.POSITIVE_INFINITY);
246
+ } else {
247
+ const virtualOffset = displayToVirtual(offset, componentOffset.y, scaleY.value);
248
+ scrollToOffset(null, virtualOffset);
249
+ }
250
+ }
251
+
252
+ function handleHorizontalScrollbarScrollToOffset(offset: number) {
253
+ const { displayViewportSize } = scrollDetails.value;
254
+ const scrollableRange = renderedWidth.value - displayViewportSize.width;
255
+ if (offset >= scrollableRange - 0.5) {
256
+ scrollToOffset(Number.POSITIVE_INFINITY, null);
257
+ } else {
258
+ const virtualOffset = displayToVirtual(offset, componentOffset.x, scaleX.value);
259
+ scrollToOffset(virtualOffset, null);
260
+ }
261
+ }
262
+
263
+ const verticalScrollbar = useVirtualScrollbar({
264
+ axis: 'vertical',
265
+ totalSize: renderedHeight,
266
+ position: computed(() => scrollDetails.value.displayScrollOffset.y),
267
+ viewportSize: computed(() => scrollDetails.value.displayViewportSize.height),
268
+ scrollToOffset: handleVerticalScrollbarScrollToOffset,
269
+ containerId,
270
+ isRtl,
271
+ });
272
+
273
+ const horizontalScrollbar = useVirtualScrollbar({
274
+ axis: 'horizontal',
275
+ totalSize: renderedWidth,
276
+ position: computed(() => scrollDetails.value.displayScrollOffset.x),
277
+ viewportSize: computed(() => scrollDetails.value.displayViewportSize.width),
278
+ scrollToOffset: handleHorizontalScrollbarScrollToOffset,
279
+ containerId,
280
+ isRtl,
281
+ });
282
+
283
+ const slotColumnRange = computed(() => {
284
+ if (props.direction !== 'both') {
285
+ return columnRange.value;
286
+ }
287
+ return {
288
+ ...columnRange.value,
289
+ padStart: 0,
290
+ padEnd: 0,
291
+ };
292
+ });
293
+
221
294
  /**
222
295
  * Resets all dynamic measurements and re-initializes from props.
223
296
  * Also triggers manual re-measurement of all currently rendered items.
224
297
  */
225
298
  function refresh() {
226
299
  coreRefresh();
300
+ updateDirection();
227
301
  nextTick(() => {
228
302
  const updates: { index: number; inlineSize: number; blockSize: number; element?: HTMLElement; }[] = [];
229
303
 
230
304
  for (const [ index, el ] of itemRefs.entries()) {
231
- /* v8 ignore else -- @preserve */
232
305
  if (el) {
233
306
  updates.push({
234
307
  index,
@@ -247,13 +320,15 @@ function refresh() {
247
320
 
248
321
  // Watch for scroll details and emit event
249
322
  watch(scrollDetails, (details, oldDetails) => {
250
- if (!isHydrated.value) {
323
+ if (!isHydrated.value || !details) {
251
324
  return;
252
325
  }
253
326
  emit('scroll', details);
254
327
 
255
328
  if (
256
329
  !oldDetails
330
+ || !oldDetails.range
331
+ || !oldDetails.columnRange
257
332
  || details.range.start !== oldDetails.range.start
258
333
  || details.range.end !== oldDetails.range.end
259
334
  || details.columnRange.start !== oldDetails.columnRange.start
@@ -272,14 +347,14 @@ watch(scrollDetails, (details, oldDetails) => {
272
347
  }
273
348
 
274
349
  // vertical or both
275
- if (props.direction !== 'horizontal') {
350
+ if (props.direction !== 'horizontal' && details.totalSize) {
276
351
  const remaining = details.totalSize.height - (details.scrollOffset.y + details.viewportSize.height);
277
352
  if (remaining <= props.loadDistance) {
278
353
  emit('load', 'vertical');
279
354
  }
280
355
  }
281
356
  // horizontal or both
282
- if (props.direction !== 'vertical') {
357
+ if (props.direction !== 'vertical' && details.totalSize) {
283
358
  const remaining = details.totalSize.width - (details.scrollOffset.x + details.viewportSize.width);
284
359
  if (remaining <= props.loadDistance) {
285
360
  emit('load', 'horizontal');
@@ -288,8 +363,7 @@ watch(scrollDetails, (details, oldDetails) => {
288
363
  });
289
364
 
290
365
  watch(isHydrated, (hydrated) => {
291
- /* v8 ignore else -- @preserve */
292
- if (hydrated) {
366
+ if (hydrated && scrollDetails.value?.range && scrollDetails.value?.columnRange) {
293
367
  emit('visibleRangeChange', {
294
368
  start: scrollDetails.value.range.start,
295
369
  end: scrollDetails.value.range.end,
@@ -299,12 +373,10 @@ watch(isHydrated, (hydrated) => {
299
373
  }
300
374
  }, { once: true });
301
375
 
302
- /* v8 ignore next 2 -- @preserve */
303
376
  const hostResizeObserver = typeof window === 'undefined'
304
377
  ? null
305
378
  : new ResizeObserver(updateHostOffset);
306
379
 
307
- /* v8 ignore next 2 -- @preserve */
308
380
  const itemResizeObserver = typeof window === 'undefined'
309
381
  ? null
310
382
  : new ResizeObserver((entries) => {
@@ -315,34 +387,32 @@ const itemResizeObserver = typeof window === 'undefined'
315
387
  const index = Number(target.dataset.index);
316
388
  const colIndex = target.dataset.colIndex;
317
389
 
390
+ let inlineSize = entry.contentRect.width;
391
+ let blockSize = entry.contentRect.height;
392
+
393
+ if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
394
+ inlineSize = entry.borderBoxSize[ 0 ]!.inlineSize;
395
+ blockSize = entry.borderBoxSize[ 0 ]!.blockSize;
396
+ } else {
397
+ // Fallback for older browsers or if borderBoxSize is missing
398
+ inlineSize = target.offsetWidth;
399
+ blockSize = target.offsetHeight;
400
+ }
401
+
318
402
  if (colIndex !== undefined) {
319
403
  // It's a cell measurement. row index is not strictly needed for column width.
320
404
  // We use -1 as a placeholder for row index if it's a cell measurement.
321
- updates.push({ index: -1, inlineSize: 0, blockSize: 0, element: target });
405
+ updates.push({ index: -1, inlineSize, blockSize, element: target });
322
406
  } else if (!Number.isNaN(index)) {
323
- let inlineSize = entry.contentRect.width;
324
- let blockSize = entry.contentRect.height;
325
-
326
- if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
327
- inlineSize = entry.borderBoxSize[ 0 ]!.inlineSize;
328
- blockSize = entry.borderBoxSize[ 0 ]!.blockSize;
329
- } else {
330
- // Fallback for older browsers or if borderBoxSize is missing
331
- inlineSize = target.offsetWidth;
332
- blockSize = target.offsetHeight;
333
- }
334
-
335
407
  updates.push({ index, inlineSize, blockSize, element: target });
336
408
  }
337
409
  }
338
410
 
339
- /* v8 ignore else -- @preserve */
340
411
  if (updates.length > 0) {
341
412
  updateItemSizes(updates);
342
413
  }
343
414
  });
344
415
 
345
- /* v8 ignore next 2 -- @preserve */
346
416
  const extraResizeObserver = typeof window === 'undefined'
347
417
  ? null
348
418
  : new ResizeObserver(() => {
@@ -352,47 +422,28 @@ const extraResizeObserver = typeof window === 'undefined'
352
422
  });
353
423
 
354
424
  watch(headerRef, (newEl, oldEl) => {
355
- /* v8 ignore if -- @preserve */
356
425
  if (oldEl) {
357
426
  extraResizeObserver?.unobserve(oldEl);
358
427
  }
359
428
  if (newEl) {
360
429
  extraResizeObserver?.observe(newEl);
430
+ } else {
431
+ measuredPaddingStart.value = 0;
361
432
  }
362
433
  }, { immediate: true });
363
434
 
364
435
  watch(footerRef, (newEl, oldEl) => {
365
- /* v8 ignore if -- @preserve */
366
436
  if (oldEl) {
367
437
  extraResizeObserver?.unobserve(oldEl);
368
438
  }
369
439
  if (newEl) {
370
440
  extraResizeObserver?.observe(newEl);
441
+ } else {
442
+ measuredPaddingEnd.value = 0;
371
443
  }
372
444
  }, { immediate: true });
373
445
 
374
- const firstRenderedIndex = computed(() => renderedItems.value[ 0 ]?.index);
375
- watch(firstRenderedIndex, (newIdx, oldIdx) => {
376
- if (props.direction === 'both') {
377
- /* v8 ignore else -- @preserve */
378
- if (oldIdx !== undefined) {
379
- const oldEl = itemRefs.get(oldIdx);
380
- if (oldEl) {
381
- oldEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.unobserve(c));
382
- }
383
- }
384
- if (newIdx !== undefined) {
385
- const newEl = itemRefs.get(newIdx);
386
- /* v8 ignore else -- @preserve */
387
- if (newEl) {
388
- newEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
389
- }
390
- }
391
- }
392
- }, { flush: 'post' });
393
-
394
446
  onMounted(() => {
395
- /* v8 ignore else -- @preserve */
396
447
  if (hostRef.value) {
397
448
  hostResizeObserver?.observe(hostRef.value);
398
449
  }
@@ -400,14 +451,7 @@ onMounted(() => {
400
451
  // Re-observe items that were set before observer was ready
401
452
  for (const el of itemRefs.values()) {
402
453
  itemResizeObserver?.observe(el);
403
- }
404
-
405
- // Observe cells of the first rendered item
406
- /* v8 ignore else -- @preserve */
407
- if (firstRenderedIndex.value !== undefined) {
408
- const el = itemRefs.get(firstRenderedIndex.value);
409
- /* v8 ignore else -- @preserve */
410
- if (el) {
454
+ if (props.direction === 'both') {
411
455
  el.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
412
456
  }
413
457
  }
@@ -422,81 +466,394 @@ watch([ hostRef, wrapperRef ], ([ newHost ], [ oldHost ]) => {
422
466
  }
423
467
  });
424
468
 
469
+ watch([ hostRef, useVirtualScrolling ], ([ host, virtual ], [ oldHost, oldVirtual ]) => {
470
+ const needsUpdate = host !== oldHost || virtual !== oldVirtual;
471
+ if (oldHost && needsUpdate) {
472
+ oldHost.removeEventListener('wheel', handleWheel);
473
+ }
474
+ if (host && needsUpdate) {
475
+ host.addEventListener('wheel', handleWheel, { passive: !virtual });
476
+ }
477
+ }, { immediate: true });
478
+
479
+ /**
480
+ * Callback ref to track and measure item elements.
481
+ *
482
+ * @param el - The element or null if unmounting.
483
+ * @param index - The original index of the item.
484
+ */
425
485
  function setItemRef(el: unknown, index: number) {
426
486
  if (el) {
427
487
  itemRefs.set(index, el as HTMLElement);
428
488
  itemResizeObserver?.observe(el as HTMLElement);
489
+
490
+ if (props.direction === 'both') {
491
+ (el as HTMLElement).querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
492
+ }
429
493
  } else {
430
494
  const oldEl = itemRefs.get(index);
431
- /* v8 ignore else -- @preserve */
432
495
  if (oldEl) {
433
496
  itemResizeObserver?.unobserve(oldEl);
497
+ if (props.direction === 'both') {
498
+ oldEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.unobserve(c));
499
+ }
434
500
  itemRefs.delete(index);
435
501
  }
436
502
  }
437
503
  }
438
504
 
439
- function handleKeyDown(event: KeyboardEvent) {
440
- stopProgrammaticScroll();
441
- const { viewportSize, scrollOffset } = scrollDetails.value;
442
- const isHorizontal = props.direction !== 'vertical';
443
- const isVertical = props.direction !== 'horizontal';
505
+ /**
506
+ * State for inertia scrolling
507
+ */
508
+ const isPointerScrolling = ref(false);
509
+ let startPointerPos = { x: 0, y: 0 };
510
+ let startScrollOffset = { x: 0, y: 0 };
511
+ let lastPointerPos = { x: 0, y: 0 };
512
+ let lastPointerTime = 0;
513
+ let velocity = { x: 0, y: 0 };
514
+ let inertiaAnimationFrame: number | null = null;
515
+
516
+ // Friction constant (0.9 to 0.98 is usually best)
517
+ const FRICTION = 0.95;
518
+ // Minimum velocity to continue the animation
519
+ const MIN_VELOCITY = 0.1;
444
520
 
445
- if (event.key === 'Home') {
446
- event.preventDefault();
447
- scrollToIndex(0, 0, 'start');
448
- return;
449
- }
450
- if (event.key === 'End') {
451
- event.preventDefault();
452
- const lastItemIndex = props.items.length - 1;
453
- const lastColIndex = (props.columnCount || 0) > 0 ? props.columnCount - 1 : 0;
521
+ /**
522
+ * Recursively animates the scroll offset based on velocity and friction.
523
+ */
524
+ function startInertiaAnimation() {
525
+ const step = () => {
526
+ // Apply friction to the velocity
527
+ velocity.x *= FRICTION;
528
+ velocity.y *= FRICTION;
454
529
 
455
- if (isHorizontal) {
456
- if (isVertical) {
457
- scrollToIndex(lastItemIndex, lastColIndex, 'end');
458
- } else {
459
- scrollToIndex(0, lastItemIndex, 'end');
460
- }
530
+ // Calculate the new scroll offset
531
+ const currentX = scrollDetails.value.scrollOffset.x;
532
+ const currentY = scrollDetails.value.scrollOffset.y;
533
+
534
+ // Move the scroll position by the current velocity
535
+ scrollToOffset(
536
+ currentX + velocity.x * 16, // Assuming ~60fps (16ms per frame)
537
+ currentY + velocity.y * 16,
538
+ { behavior: 'auto' },
539
+ );
540
+
541
+ // Continue animation if we haven't slowed down to a halt
542
+ if (Math.abs(velocity.x) > MIN_VELOCITY || Math.abs(velocity.y) > MIN_VELOCITY) {
543
+ inertiaAnimationFrame = requestAnimationFrame(step);
461
544
  } else {
462
- scrollToIndex(lastItemIndex, 0, 'end');
545
+ stopInertia();
463
546
  }
464
- return;
547
+ };
548
+
549
+ inertiaAnimationFrame = requestAnimationFrame(step);
550
+ }
551
+
552
+ /**
553
+ * Stops any ongoing inertia animation
554
+ */
555
+ function stopInertia() {
556
+ if (inertiaAnimationFrame !== null) {
557
+ cancelAnimationFrame(inertiaAnimationFrame);
558
+ inertiaAnimationFrame = null;
465
559
  }
466
- if (event.key === 'ArrowUp') {
467
- event.preventDefault();
468
- scrollToOffset(null, scrollOffset.y - DEFAULT_ITEM_SIZE);
560
+ velocity = { x: 0, y: 0 };
561
+ }
562
+
563
+ /**
564
+ * Handles pointer down events on the container to start emulated scrolling when scaling is active.
565
+ *
566
+ * @param event - The pointer down event.
567
+ */
568
+ function handlePointerDown(event: PointerEvent) {
569
+ stopProgrammaticScroll();
570
+ stopInertia(); // Stop any existing momentum
571
+
572
+ if (!useVirtualScrolling.value) {
469
573
  return;
470
574
  }
471
- if (event.key === 'ArrowDown') {
472
- event.preventDefault();
473
- scrollToOffset(null, scrollOffset.y + DEFAULT_ITEM_SIZE);
575
+
576
+ // Only handle primary button or touch
577
+ if (event.pointerType === 'mouse' && event.button !== 0) {
474
578
  return;
475
579
  }
476
- if (event.key === 'ArrowLeft') {
477
- event.preventDefault();
478
- scrollToOffset(scrollOffset.x - DEFAULT_ITEM_SIZE, null);
580
+
581
+ isPointerScrolling.value = true;
582
+ startPointerPos = { x: event.clientX, y: event.clientY };
583
+ lastPointerPos = { x: event.clientX, y: event.clientY };
584
+ lastPointerTime = performance.now();
585
+ startScrollOffset = {
586
+ x: scrollDetails.value.scrollOffset.x,
587
+ y: scrollDetails.value.scrollOffset.y,
588
+ };
589
+
590
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
591
+ }
592
+
593
+ /**
594
+ * Handles pointer move events on the container to perform emulated scrolling.
595
+ *
596
+ * @param event - The pointer move event.
597
+ */
598
+ function handlePointerMove(event: PointerEvent) {
599
+ if (!isPointerScrolling.value) {
479
600
  return;
480
601
  }
481
- if (event.key === 'ArrowRight') {
482
- event.preventDefault();
483
- scrollToOffset(scrollOffset.x + DEFAULT_ITEM_SIZE, null);
484
- return;
602
+
603
+ const now = performance.now();
604
+ const dt = now - lastPointerTime;
605
+
606
+ if (dt > 0) {
607
+ // Calculate instantaneous velocity (pixels per millisecond)
608
+ const instantVelocityX = (lastPointerPos.x - event.clientX) / dt;
609
+ const instantVelocityY = (lastPointerPos.y - event.clientY) / dt;
610
+
611
+ // Use a moving average for smoother velocity tracking
612
+ velocity.x = velocity.x * 0.2 + instantVelocityX * 0.8;
613
+ velocity.y = velocity.y * 0.2 + instantVelocityY * 0.8;
485
614
  }
486
- if (event.key === 'PageUp') {
487
- event.preventDefault();
615
+
616
+ lastPointerPos = { x: event.clientX, y: event.clientY };
617
+ lastPointerTime = now;
618
+
619
+ const deltaX = startPointerPos.x - event.clientX;
620
+ const deltaY = startPointerPos.y - event.clientY;
621
+
622
+ requestAnimationFrame(() => {
488
623
  scrollToOffset(
489
- !isVertical && isHorizontal ? scrollOffset.x - viewportSize.width : null,
490
- isVertical ? scrollOffset.y - viewportSize.height : null,
624
+ startScrollOffset.x + deltaX,
625
+ startScrollOffset.y + deltaY,
626
+ { behavior: 'auto' },
491
627
  );
628
+ });
629
+ }
630
+
631
+ /**
632
+ * Handles pointer up and cancel events to end emulated scrolling.
633
+ *
634
+ * @param event - The pointer event.
635
+ */
636
+ function handlePointerUp(event: PointerEvent) {
637
+ if (!isPointerScrolling.value) {
492
638
  return;
493
639
  }
494
- if (event.key === 'PageDown') {
640
+
641
+ isPointerScrolling.value = false;
642
+ (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
643
+
644
+ // If the user was moving fast enough, start the inertia loop
645
+ if (Math.abs(velocity.x) > MIN_VELOCITY || Math.abs(velocity.y) > MIN_VELOCITY) {
646
+ // avoid unwanted cross-axis drift
647
+ if (Math.abs(velocity.x) > 4 * Math.abs(velocity.y)) {
648
+ velocity.y = 0;
649
+ } else if (Math.abs(velocity.y) > 4 * Math.abs(velocity.x)) {
650
+ velocity.x = 0;
651
+ }
652
+
653
+ startInertiaAnimation();
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Handles mouse wheel events to support high-precision scrolling for large content or virtual scrollbars.
659
+ *
660
+ * @param event - The wheel event.
661
+ */
662
+ function handleWheel(event: WheelEvent) {
663
+ const { scrollOffset } = scrollDetails.value;
664
+ stopProgrammaticScroll();
665
+
666
+ if (useVirtualScrolling.value) {
667
+ // Prevent default browser scroll as we are handling it manually
495
668
  event.preventDefault();
496
- scrollToOffset(
497
- !isVertical && isHorizontal ? scrollOffset.x + viewportSize.width : null,
498
- isVertical ? scrollOffset.y + viewportSize.height : null,
499
- );
669
+
670
+ // For large content we manually scroll to keep precision/control
671
+ let deltaX = event.deltaX;
672
+ let deltaY = event.deltaY;
673
+
674
+ if (event.shiftKey && deltaX === 0) {
675
+ deltaX = deltaY;
676
+ deltaY = 0;
677
+ }
678
+
679
+ const targetX = scrollOffset.x + deltaX;
680
+ const targetY = scrollOffset.y + deltaY;
681
+
682
+ scrollToOffset(targetX, targetY, { behavior: 'auto' });
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Handles keyboard events for navigation (Home, End, Arrows, PageUp/Down).
688
+ *
689
+ * @param event - The keyboard event.
690
+ */
691
+ function handleKeyDown(event: KeyboardEvent) {
692
+ const { viewportSize, scrollOffset } = scrollDetails.value;
693
+ const isHorizontal = props.direction !== 'vertical';
694
+ const isVertical = props.direction !== 'horizontal';
695
+
696
+ const sStart = virtualScrollProps.value.stickyStart as { x: number; y: number; };
697
+ const sEnd = virtualScrollProps.value.stickyEnd as { x: number; y: number; };
698
+
699
+ switch (event.key) {
700
+ case 'Home': {
701
+ event.preventDefault();
702
+ stopProgrammaticScroll();
703
+ const distance = Math.max(scrollOffset.x, scrollOffset.y);
704
+ const viewport = props.direction === 'horizontal' ? viewportSize.width : viewportSize.height;
705
+ const behavior = distance > 10 * viewport ? 'auto' : 'smooth';
706
+
707
+ scrollToIndex(0, 0, { behavior, align: 'start' });
708
+ break;
709
+ }
710
+ case 'End': {
711
+ event.preventDefault();
712
+ stopProgrammaticScroll();
713
+ const lastItemIndex = props.items.length - 1;
714
+ const lastColIndex = (props.columnCount || 0) > 0 ? props.columnCount - 1 : 0;
715
+
716
+ const { totalSize } = scrollDetails.value;
717
+ const distance = Math.max(
718
+ totalSize.width - scrollOffset.x - viewportSize.width,
719
+ totalSize.height - scrollOffset.y - viewportSize.height,
720
+ );
721
+ const viewport = props.direction === 'horizontal' ? viewportSize.width : viewportSize.height;
722
+ const behavior = distance > 10 * viewport ? 'auto' : 'smooth';
723
+
724
+ if (props.direction === 'both') {
725
+ scrollToIndex(lastItemIndex, lastColIndex, { behavior, align: 'end' });
726
+ } else {
727
+ scrollToIndex(
728
+ props.direction === 'vertical' ? lastItemIndex : 0,
729
+ props.direction === 'horizontal' ? lastItemIndex : 0,
730
+ { behavior, align: 'end' },
731
+ );
732
+ }
733
+ break;
734
+ }
735
+ case 'ArrowUp': {
736
+ event.preventDefault();
737
+ stopProgrammaticScroll();
738
+ if (!isVertical) {
739
+ return;
740
+ }
741
+
742
+ const { currentIndex, scrollOffset } = scrollDetails.value;
743
+ const viewportTop = scrollOffset.y + sStart.y + (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).y;
744
+ const itemPos = getRowOffset(currentIndex);
745
+
746
+ if (itemPos < viewportTop - 1) {
747
+ scrollToIndex(currentIndex, null, { align: 'start' });
748
+ } else if (currentIndex > 0) {
749
+ scrollToIndex(currentIndex - 1, null, { align: 'start' });
750
+ }
751
+ break;
752
+ }
753
+ case 'ArrowDown': {
754
+ event.preventDefault();
755
+ stopProgrammaticScroll();
756
+ if (!isVertical) {
757
+ return;
758
+ }
759
+
760
+ const { currentEndIndex } = scrollDetails.value;
761
+ const viewportBottom = scrollOffset.y + viewportSize.height - (sEnd.y + (virtualScrollProps.value.scrollPaddingEnd as { x: number; y: number; }).y);
762
+ const itemBottom = getRowOffset(currentEndIndex) + getRowHeight(currentEndIndex);
763
+
764
+ if (itemBottom > viewportBottom + 1) {
765
+ scrollToIndex(currentEndIndex, null, { align: 'end' });
766
+ } else if (currentEndIndex < props.items.length - 1) {
767
+ scrollToIndex(currentEndIndex + 1, null, { align: 'end' });
768
+ }
769
+ break;
770
+ }
771
+ case 'ArrowLeft': {
772
+ event.preventDefault();
773
+ stopProgrammaticScroll();
774
+ if (!isHorizontal) {
775
+ return;
776
+ }
777
+
778
+ const { currentColIndex, currentEndColIndex } = scrollDetails.value;
779
+
780
+ if (isRtl.value) {
781
+ // RTL ArrowLeft -> towards logical END (Left)
782
+ const viewportLeft = scrollOffset.x + viewportSize.width - (sEnd.x + (virtualScrollProps.value.scrollPaddingEnd as { x: number; y: number; }).x);
783
+ const colEndPos = (props.columnCount ? getColumnOffset(currentEndColIndex) + getColumnWidth(currentEndColIndex) : getItemOffset(currentEndColIndex) + getItemSize(currentEndColIndex));
784
+
785
+ if (colEndPos > viewportLeft + 1) {
786
+ scrollToIndex(null, currentEndColIndex, { align: 'end' });
787
+ } else {
788
+ const maxColIdx = props.columnCount ? props.columnCount - 1 : props.items.length - 1;
789
+ if (currentEndColIndex < maxColIdx) {
790
+ scrollToIndex(null, currentEndColIndex + 1, { align: 'end' });
791
+ }
792
+ }
793
+ } else {
794
+ // LTR ArrowLeft -> towards logical START (Left)
795
+ const viewportLeft = scrollOffset.x + sStart.x + (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x;
796
+ const colStartPos = (props.columnCount ? getColumnOffset(currentColIndex) : getItemOffset(currentColIndex));
797
+
798
+ if (colStartPos < viewportLeft - 1) {
799
+ scrollToIndex(null, currentColIndex, { align: 'start' });
800
+ } else if (currentColIndex > 0) {
801
+ scrollToIndex(null, currentColIndex - 1, { align: 'start' });
802
+ }
803
+ }
804
+ break;
805
+ }
806
+ case 'ArrowRight': {
807
+ event.preventDefault();
808
+ stopProgrammaticScroll();
809
+ if (!isHorizontal) {
810
+ return;
811
+ }
812
+
813
+ const { currentColIndex, currentEndColIndex } = scrollDetails.value;
814
+
815
+ if (isRtl.value) {
816
+ // RTL ArrowRight -> towards logical START (Right)
817
+ const viewportRight = scrollOffset.x + sStart.x + (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x;
818
+ const colStartPos = (props.columnCount ? getColumnOffset(currentColIndex) : getItemOffset(currentColIndex));
819
+
820
+ if (colStartPos < viewportRight - 1) {
821
+ scrollToIndex(null, currentColIndex, { align: 'start' });
822
+ } else if (currentColIndex > 0) {
823
+ scrollToIndex(null, currentColIndex - 1, { align: 'start' });
824
+ }
825
+ } else {
826
+ // LTR ArrowRight -> towards logical END (Right)
827
+ const viewportRight = scrollOffset.x + viewportSize.width - (sEnd.x + (virtualScrollProps.value.scrollPaddingEnd as { x: number; y: number; }).x);
828
+ const colEndPos = (props.columnCount ? getColumnOffset(currentEndColIndex) + getColumnWidth(currentEndColIndex) : getItemOffset(currentEndColIndex) + getItemSize(currentEndColIndex));
829
+
830
+ if (colEndPos > viewportRight + 1) {
831
+ scrollToIndex(null, currentEndColIndex, { align: 'end' });
832
+ } else {
833
+ const maxColIdx = props.columnCount ? props.columnCount - 1 : props.items.length - 1;
834
+ if (currentEndColIndex < maxColIdx) {
835
+ scrollToIndex(null, currentEndColIndex + 1, { align: 'end' });
836
+ }
837
+ }
838
+ }
839
+ break;
840
+ }
841
+ case 'PageUp':
842
+ event.preventDefault();
843
+ stopProgrammaticScroll();
844
+ scrollToOffset(
845
+ !isVertical && isHorizontal ? scrollOffset.x - viewportSize.width : null,
846
+ isVertical ? scrollOffset.y - viewportSize.height : null,
847
+ );
848
+ break;
849
+ case 'PageDown':
850
+ event.preventDefault();
851
+ stopProgrammaticScroll();
852
+ scrollToOffset(
853
+ !isVertical && isHorizontal ? scrollOffset.x + viewportSize.width : null,
854
+ isVertical ? scrollOffset.y + viewportSize.height : null,
855
+ );
856
+ break;
500
857
  }
501
858
  }
502
859
 
@@ -506,99 +863,155 @@ onUnmounted(() => {
506
863
  extraResizeObserver?.disconnect();
507
864
  });
508
865
 
509
- const isWindowContainer = computed(() => {
510
- const c = props.container;
511
- if (
512
- c === null
513
- // window
514
- || (typeof window !== 'undefined' && c === window)
515
- ) {
516
- return true;
517
- }
866
+ const containerStyle = computed(() => {
867
+ const base: Record<string, string | number | undefined> = {
868
+ ...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
869
+ };
518
870
 
519
- // body
520
- if (c && typeof c === 'object' && 'tagName' in c) {
521
- return (c as HTMLElement).tagName === 'BODY';
871
+ if (showVirtualScrollbars.value) {
872
+ base.overflow = 'auto';
522
873
  }
523
874
 
524
- return false;
525
- });
875
+ if (useVirtualScrolling.value) {
876
+ base.touchAction = 'none';
877
+ }
526
878
 
527
- const containerStyle = computed(() => {
528
879
  if (isWindowContainer.value) {
529
- return {
530
- ...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
531
- };
880
+ return base;
532
881
  }
533
882
 
534
883
  if (props.containerTag === 'table') {
535
884
  return {
885
+ ...base,
536
886
  minInlineSize: props.direction === 'vertical' ? '100%' : 'auto',
537
887
  };
538
888
  }
539
889
 
890
+ return base;
891
+ });
892
+
893
+ const verticalScrollbarProps = computed<ScrollbarSlotProps | null>(() => {
894
+ if (props.direction === 'horizontal') {
895
+ return null;
896
+ }
897
+ const { displayViewportSize, displayScrollOffset } = scrollDetails.value;
898
+ if (renderedHeight.value <= displayViewportSize.height) {
899
+ return null;
900
+ }
901
+
902
+ const scrollbarProps: VirtualScrollbarProps = {
903
+ axis: 'vertical',
904
+ totalSize: renderedHeight.value,
905
+ position: displayScrollOffset.y,
906
+ viewportSize: displayViewportSize.height,
907
+ scrollToOffset: handleVerticalScrollbarScrollToOffset,
908
+ containerId: containerId.value,
909
+ isRtl: isRtl.value,
910
+ };
911
+
540
912
  return {
541
- ...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
913
+ positionPercent: verticalScrollbar.positionPercent.value,
914
+ viewportPercent: verticalScrollbar.viewportPercent.value,
915
+ thumbSizePercent: verticalScrollbar.thumbSizePercent.value,
916
+ thumbPositionPercent: verticalScrollbar.thumbPositionPercent.value,
917
+ trackProps: verticalScrollbar.trackProps.value,
918
+ thumbProps: verticalScrollbar.thumbProps.value,
919
+ scrollbarProps,
920
+ isDragging: verticalScrollbar.isDragging.value,
542
921
  };
543
922
  });
544
923
 
545
- const wrapperStyle = computed(() => ({
546
- inlineSize: props.direction === 'vertical' ? '100%' : `${ totalWidth.value }px`,
547
- blockSize: props.direction === 'horizontal' ? '100%' : `${ totalHeight.value }px`,
548
- }));
924
+ const horizontalScrollbarProps = computed<ScrollbarSlotProps | null>(() => {
925
+ if (props.direction === 'vertical') {
926
+ return null;
927
+ }
928
+ const { displayViewportSize, displayScrollOffset } = scrollDetails.value;
929
+ if (renderedWidth.value <= displayViewportSize.width) {
930
+ return null;
931
+ }
549
932
 
550
- const loadingStyle = computed(() => {
551
- const isHorizontal = props.direction === 'horizontal';
933
+ const scrollbarProps: VirtualScrollbarProps = {
934
+ axis: 'horizontal',
935
+ totalSize: renderedWidth.value,
936
+ position: displayScrollOffset.x,
937
+ viewportSize: displayViewportSize.width,
938
+ scrollToOffset: handleHorizontalScrollbarScrollToOffset,
939
+ containerId: containerId.value,
940
+ isRtl: isRtl.value,
941
+ };
552
942
 
553
943
  return {
554
- display: isHorizontal ? 'inline-block' : 'block',
555
- ...(isHorizontal ? { blockSize: '100%', verticalAlign: 'top' } : { inlineSize: '100%' }),
944
+ positionPercent: horizontalScrollbar.positionPercent.value,
945
+ viewportPercent: horizontalScrollbar.viewportPercent.value,
946
+ thumbSizePercent: horizontalScrollbar.thumbSizePercent.value,
947
+ thumbPositionPercent: horizontalScrollbar.thumbPositionPercent.value,
948
+ trackProps: horizontalScrollbar.trackProps.value,
949
+ thumbProps: horizontalScrollbar.thumbProps.value,
950
+ scrollbarProps,
951
+ isDragging: horizontalScrollbar.isDragging.value,
556
952
  };
557
953
  });
558
954
 
559
- const spacerStyle = computed(() => ({
560
- inlineSize: props.direction === 'vertical' ? '1px' : `${ totalWidth.value }px`,
561
- blockSize: props.direction === 'horizontal' ? '1px' : `${ totalHeight.value }px`,
562
- }));
563
-
564
- function getItemStyle(item: RenderedItem<T>) {
565
- const isVertical = props.direction === 'vertical';
955
+ const wrapperStyle = computed(() => {
566
956
  const isHorizontal = props.direction === 'horizontal';
957
+ const isVertical = props.direction === 'vertical';
567
958
  const isBoth = props.direction === 'both';
568
- const isDynamic = props.itemSize === undefined || props.itemSize === null || props.itemSize === 0;
569
959
 
570
960
  const style: Record<string, string | number | undefined> = {
571
- blockSize: isHorizontal ? '100%' : (!isDynamic ? `${ item.size.height }px` : 'auto'),
961
+ inlineSize: isVertical ? '100%' : `${ renderedVirtualWidth.value }px`,
962
+ blockSize: isHorizontal ? '100%' : `${ renderedVirtualHeight.value }px`,
572
963
  };
573
964
 
574
- if (isVertical && props.containerTag === 'table') {
575
- style.minInlineSize = '100%';
576
- } else {
577
- style.inlineSize = isVertical ? '100%' : (!isDynamic ? `${ item.size.width }px` : 'auto');
578
- }
579
-
580
- if (isDynamic) {
581
- if (!isVertical) {
582
- style.minInlineSize = '1px';
965
+ if (!isHydrated.value) {
966
+ style.display = 'flex';
967
+ style.flexDirection = isHorizontal ? 'row' : 'column';
968
+ if ((isHorizontal || isBoth) && props.columnGap) {
969
+ style.columnGap = `${ props.columnGap }px`;
583
970
  }
584
- if (!isHorizontal) {
585
- style.minBlockSize = '1px';
971
+ if ((isVertical || isBoth) && props.gap) {
972
+ style.rowGap = `${ props.gap }px`;
586
973
  }
587
974
  }
588
975
 
589
- if (isHydrated.value) {
590
- if (item.isStickyActive) {
591
- if (isVertical || isBoth) {
592
- style.insetBlockStart = `${ getPaddingY(props.scrollPaddingStart, props.direction) }px`;
593
- }
976
+ return style;
977
+ });
594
978
 
595
- if (isHorizontal || isBoth) {
596
- style.insetInlineStart = `${ getPaddingX(props.scrollPaddingStart, props.direction) }px`;
597
- }
979
+ const loadingStyle = computed(() => {
980
+ const isHorizontal = props.direction === 'horizontal';
598
981
 
599
- style.transform = `translate(${ item.stickyOffset.x }px, ${ item.stickyOffset.y }px)`;
600
- } else {
601
- style.transform = `translate(${ item.offset.x }px, ${ item.offset.y }px)`;
982
+ return {
983
+ display: isHorizontal ? 'inline-block' : 'block',
984
+ ...(isHorizontal ? { blockSize: '100%', verticalAlign: 'top' } : { inlineSize: '100%' }),
985
+ };
986
+ });
987
+
988
+ const spacerStyle = computed(() => ({
989
+ inlineSize: props.direction === 'vertical' ? '1px' : `${ renderedVirtualWidth.value }px`,
990
+ blockSize: props.direction === 'horizontal' ? '1px' : `${ renderedVirtualHeight.value }px`,
991
+ }));
992
+
993
+ /**
994
+ * Calculates the final style object for an item, including position and dimensions.
995
+ *
996
+ * @param item - The rendered item state.
997
+ * @returns CSS style object.
998
+ */
999
+ function getItemStyle(item: RenderedItem<T>) {
1000
+ const style = calculateItemStyle({
1001
+ containerTag: props.containerTag,
1002
+ direction: props.direction,
1003
+ isHydrated: isHydrated.value,
1004
+ item,
1005
+ itemSize: props.itemSize,
1006
+ paddingStartX: (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x,
1007
+ paddingStartY: (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).y,
1008
+ isRtl: isRtl.value,
1009
+ });
1010
+
1011
+ if (!isHydrated.value && props.direction === 'both') {
1012
+ style.display = 'flex';
1013
+ if (props.columnGap) {
1014
+ style.columnGap = `${ props.columnGap }px`;
602
1015
  }
603
1016
  }
604
1017
 
@@ -611,19 +1024,159 @@ const headerTag = computed(() => isTable.value ? 'thead' : 'div');
611
1024
  const footerTag = computed(() => isTable.value ? 'tfoot' : 'div');
612
1025
 
613
1026
  defineExpose({
1027
+ ...toRefs(props),
1028
+
1029
+ /**
1030
+ * Detailed information about the current scroll state.
1031
+ * @see ScrollDetails
1032
+ * @see useVirtualScroll
1033
+ */
614
1034
  scrollDetails,
1035
+
1036
+ /**
1037
+ * Information about the current visible range of columns.
1038
+ * @see ColumnRange
1039
+ * @see useVirtualScroll
1040
+ */
615
1041
  columnRange,
1042
+
1043
+ /**
1044
+ * Helper to get the width of a specific column.
1045
+ * @param index - The column index.
1046
+ * @see useVirtualScroll
1047
+ */
616
1048
  getColumnWidth,
1049
+
1050
+ /**
1051
+ * Helper to get the height of a specific row.
1052
+ * @param index - The row index.
1053
+ * @see useVirtualScroll
1054
+ */
1055
+ getRowHeight,
1056
+
1057
+ /**
1058
+ * Helper to get the virtual offset of a specific row.
1059
+ * @param index - The row index.
1060
+ * @see useVirtualScroll
1061
+ */
1062
+ getRowOffset,
1063
+
1064
+ /**
1065
+ * Helper to get the virtual offset of a specific column.
1066
+ * @param index - The column index.
1067
+ * @see useVirtualScroll
1068
+ */
1069
+ getColumnOffset,
1070
+
1071
+ /**
1072
+ * Helper to get the virtual offset of a specific item.
1073
+ * @param index - The item index.
1074
+ * @see useVirtualScroll
1075
+ */
1076
+ getItemOffset,
1077
+
1078
+ /**
1079
+ * Helper to get the size of a specific item along the scroll axis.
1080
+ * @param index - The item index.
1081
+ * @see useVirtualScroll
1082
+ */
1083
+ getItemSize,
1084
+
1085
+ /**
1086
+ * Programmatically scroll to a specific row and/or column.
1087
+ *
1088
+ * @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
1089
+ * @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
1090
+ * @param options - Alignment and behavior options. Defaults to { align: 'auto', behavior: 'auto' }.
1091
+ * @see ScrollAlignment
1092
+ * @see ScrollToIndexOptions
1093
+ * @see useVirtualScroll
1094
+ */
617
1095
  scrollToIndex,
1096
+
1097
+ /**
1098
+ * Programmatically scroll to a specific pixel offset.
1099
+ *
1100
+ * @param x - The pixel offset to scroll to on the X axis. Pass null to keep current position.
1101
+ * @param y - The pixel offset to scroll to on the Y axis. Pass null to keep current position.
1102
+ * @param options - Scroll options (behavior). Defaults to { behavior: 'auto' }.
1103
+ * @see useVirtualScroll
1104
+ */
618
1105
  scrollToOffset,
1106
+
1107
+ /**
1108
+ * Resets all dynamic measurements and re-initializes from props.
1109
+ * @see useVirtualScroll
1110
+ */
619
1111
  refresh,
620
- stopProgrammaticScroll,
1112
+
1113
+ /**
1114
+ * Immediately stops any currently active smooth scroll animation and clears pending corrections.
1115
+ * @see useVirtualScroll
1116
+ */
1117
+ stopProgrammaticScroll: () => {
1118
+ stopProgrammaticScroll();
1119
+ stopInertia();
1120
+ },
1121
+
1122
+ /**
1123
+ * Detects the current direction (LTR/RTL) of the scroll container.
1124
+ */
1125
+ updateDirection,
1126
+
1127
+ /**
1128
+ * Whether the scroll container is in Right-to-Left (RTL) mode.
1129
+ */
1130
+ isRtl,
1131
+
1132
+ /**
1133
+ * Whether the component has finished its first client-side mount and hydration.
1134
+ */
1135
+ isHydrated,
1136
+
1137
+ /**
1138
+ * Coordinate scaling factor for X axis.
1139
+ */
1140
+ scaleX,
1141
+
1142
+ /**
1143
+ * Coordinate scaling factor for Y axis.
1144
+ */
1145
+ scaleY,
1146
+
1147
+ /**
1148
+ * Physical width of the content in the DOM (clamped to browser limits).
1149
+ */
1150
+ renderedWidth,
1151
+
1152
+ /**
1153
+ * Physical height of the content in the DOM (clamped to browser limits).
1154
+ */
1155
+ renderedHeight,
1156
+
1157
+ /**
1158
+ * Absolute offset of the component within its container.
1159
+ */
1160
+ componentOffset,
1161
+
1162
+ /**
1163
+ * Properties for the vertical scrollbar.
1164
+ * Useful when building custom scrollbar interfaces.
1165
+ */
1166
+ scrollbarPropsVertical: verticalScrollbarProps,
1167
+
1168
+ /**
1169
+ * Properties for the horizontal scrollbar.
1170
+ * Useful when building custom scrollbar interfaces.
1171
+ */
1172
+ scrollbarPropsHorizontal: horizontalScrollbarProps,
621
1173
  });
622
1174
  </script>
623
1175
 
624
1176
  <template>
625
1177
  <component
626
1178
  :is="containerTag"
1179
+ :id="containerId"
627
1180
  ref="hostRef"
628
1181
  class="virtual-scroll-container"
629
1182
  :class="[
@@ -632,16 +1185,37 @@ defineExpose({
632
1185
  'virtual-scroll--hydrated': isHydrated,
633
1186
  'virtual-scroll--window': isWindowContainer,
634
1187
  'virtual-scroll--table': isTable,
1188
+ 'virtual-scroll--hide-scrollbar': showVirtualScrollbars,
635
1189
  },
636
1190
  ]"
637
1191
  :style="containerStyle"
638
1192
  tabindex="0"
639
1193
  @keydown="handleKeyDown"
640
- @wheel.passive="stopProgrammaticScroll"
641
- @pointerdown.passive="stopProgrammaticScroll"
642
- @touchstart.passive="stopProgrammaticScroll"
1194
+ @pointerdown="handlePointerDown"
1195
+ @pointermove="handlePointerMove"
1196
+ @pointerup="handlePointerUp"
1197
+ @pointercancel="handlePointerUp"
643
1198
  >
644
- <!-- v8 ignore start -->
1199
+ <div
1200
+ v-if="showVirtualScrollbars"
1201
+ class="virtual-scroll-scrollbar-container"
1202
+ >
1203
+ <div
1204
+ class="virtual-scroll-scrollbar-viewport"
1205
+ :style="{
1206
+ 'inlineSize': `${ scrollDetails.displayViewportSize.width }px`,
1207
+ 'blockSize': `${ scrollDetails.displayViewportSize.height }px`,
1208
+ '--vsi-scrollbar-has-cross-gap': direction === 'both' ? 1 : 0,
1209
+ }"
1210
+ >
1211
+ <slot v-if="slots.scrollbar && verticalScrollbarProps" name="scrollbar" v-bind="verticalScrollbarProps" />
1212
+ <VirtualScrollbar v-else-if="verticalScrollbarProps" v-bind="verticalScrollbarProps.scrollbarProps" />
1213
+
1214
+ <slot v-if="slots.scrollbar && horizontalScrollbarProps" name="scrollbar" v-bind="horizontalScrollbarProps" />
1215
+ <VirtualScrollbar v-else-if="horizontalScrollbarProps" v-bind="horizontalScrollbarProps.scrollbarProps" />
1216
+ </div>
1217
+ </div>
1218
+
645
1219
  <component
646
1220
  :is="headerTag"
647
1221
  v-if="slots.header"
@@ -651,7 +1225,6 @@ defineExpose({
651
1225
  >
652
1226
  <slot name="header" />
653
1227
  </component>
654
- <!-- v8 ignore stop -->
655
1228
 
656
1229
  <component
657
1230
  :is="wrapperTag"
@@ -660,7 +1233,6 @@ defineExpose({
660
1233
  :style="wrapperStyle"
661
1234
  >
662
1235
  <!-- Phantom element to push scroll height -->
663
- <!-- v8 ignore start -->
664
1236
  <component
665
1237
  :is="itemTag"
666
1238
  v-if="isTable"
@@ -669,7 +1241,6 @@ defineExpose({
669
1241
  >
670
1242
  <td style="padding: 0; border: none; block-size: inherit;" />
671
1243
  </component>
672
- <!-- v8 ignore stop -->
673
1244
 
674
1245
  <component
675
1246
  :is="itemTag"
@@ -688,8 +1259,10 @@ defineExpose({
688
1259
  name="item"
689
1260
  :item="renderedItem.item"
690
1261
  :index="renderedItem.index"
691
- :column-range="columnRange"
1262
+ :column-range="slotColumnRange"
692
1263
  :get-column-width="getColumnWidth"
1264
+ :gap="props.gap"
1265
+ :column-gap="props.columnGap"
693
1266
  :is-sticky="renderedItem.isSticky"
694
1267
  :is-sticky-active="renderedItem.isStickyActive"
695
1268
  />
@@ -699,7 +1272,6 @@ defineExpose({
699
1272
  </component>
700
1273
  </component>
701
1274
 
702
- <!-- v8 ignore start -->
703
1275
  <div
704
1276
  v-if="loading && slots.loading"
705
1277
  class="virtual-scroll-loading"
@@ -717,113 +1289,143 @@ defineExpose({
717
1289
  >
718
1290
  <slot name="footer" />
719
1291
  </component>
720
- <!-- v8 ignore stop -->
721
1292
  </component>
722
1293
  </template>
723
1294
 
724
1295
  <style scoped>
725
- .virtual-scroll-container {
726
- position: relative;
727
- block-size: 100%;
728
- inline-size: 100%;
729
- outline-offset: 1px;
1296
+ @layer components {
1297
+ .virtual-scroll-container {
1298
+ position: relative;
1299
+ block-size: 100%;
1300
+ inline-size: 100%;
1301
+ outline-offset: 1px;
1302
+
1303
+ &:not(.virtual-scroll--window) {
1304
+ overflow: auto;
1305
+ overscroll-behavior: contain;
1306
+ }
730
1307
 
731
- &:not(.virtual-scroll--window) {
732
- overflow: auto;
733
- overscroll-behavior: contain;
734
- }
1308
+ &.virtual-scroll--table {
1309
+ display: block;
1310
+ }
735
1311
 
736
- &.virtual-scroll--table {
737
- display: block;
738
- }
739
- }
1312
+ &.virtual-scroll--hide-scrollbar {
1313
+ scrollbar-width: none;
1314
+ -ms-overflow-style: none;
740
1315
 
741
- .virtual-scroll--horizontal {
742
- white-space: nowrap;
743
- }
1316
+ &::-webkit-scrollbar {
1317
+ display: none;
1318
+ }
1319
+ }
744
1320
 
745
- .virtual-scroll-wrapper {
746
- contain: layout;
747
- position: relative;
1321
+ &.virtual-scroll--horizontal,
1322
+ &.virtual-scroll--both {
1323
+ white-space: nowrap;
1324
+ }
1325
+ }
748
1326
 
749
- :where(.virtual-scroll--hydrated > & > .virtual-scroll-item) {
750
- position: absolute;
1327
+ .virtual-scroll-scrollbar-container {
1328
+ position: sticky;
751
1329
  inset-block-start: 0;
752
1330
  inset-inline-start: 0;
1331
+ inline-size: 100%;
1332
+ block-size: 0;
1333
+ z-index: 30;
1334
+ pointer-events: none;
1335
+ overflow: visible;
753
1336
  }
754
- }
755
1337
 
756
- .virtual-scroll-item {
757
- box-sizing: border-box;
758
- will-change: transform;
1338
+ .virtual-scroll-scrollbar-viewport {
1339
+ position: absolute;
1340
+ inset-block-start: 0;
1341
+ inset-inline-start: 0;
1342
+ pointer-events: none;
1343
+ }
759
1344
 
760
- &:where(.virtual-scroll--debug) {
761
- outline: 1px dashed rgba(255, 0, 0, 0.5);
762
- background-color: rgba(255, 0, 0, 0.05);
1345
+ .virtual-scroll-wrapper {
1346
+ contain: layout;
1347
+ position: relative;
763
1348
 
764
- &:where(:hover) {
765
- background-color: rgba(255, 0, 0, 0.1);
766
- z-index: 100;
1349
+ :where(.virtual-scroll--hydrated > & > .virtual-scroll-item) {
1350
+ position: absolute;
1351
+ inset-block-start: 0;
1352
+ inset-inline-start: 0;
767
1353
  }
768
1354
  }
769
- }
770
1355
 
771
- .virtual-scroll-debug-info {
772
- position: absolute;
773
- inset-block-start: 2px;
774
- inset-inline-end: 2px;
775
- background: rgba(0, 0, 0, 0.7);
776
- color: white;
777
- font-size: 10px;
778
- padding: 2px 4px;
779
- border-radius: 4px;
780
- pointer-events: none;
781
- z-index: 100;
782
- font-family: monospace;
783
- }
1356
+ .virtual-scroll-item {
1357
+ display: grid;
1358
+ box-sizing: border-box;
1359
+ will-change: transform;
784
1360
 
785
- .virtual-scroll-spacer {
786
- pointer-events: none;
787
- }
1361
+ &:where(.virtual-scroll--debug) {
1362
+ outline: 1px dashed rgba(255, 0, 0, 0.5);
1363
+ background-color: rgba(255, 0, 0, 0.05);
788
1364
 
789
- .virtual-scroll-header,
790
- .virtual-scroll-footer {
791
- position: relative;
792
- z-index: 20;
793
- }
1365
+ &:where(:hover) {
1366
+ background-color: rgba(255, 0, 0, 0.1);
1367
+ z-index: 100;
1368
+ }
1369
+ }
1370
+ }
794
1371
 
795
- .virtual-scroll--sticky {
796
- position: sticky;
1372
+ .virtual-scroll-debug-info {
1373
+ position: absolute;
1374
+ inset-block-start: 2px;
1375
+ inset-inline-end: 2px;
1376
+ background: rgba(0, 0, 0, 0.7);
1377
+ color: white;
1378
+ font-size: 10px;
1379
+ padding: 2px 4px;
1380
+ border-radius: 4px;
1381
+ pointer-events: none;
1382
+ z-index: 100;
1383
+ font-family: monospace;
1384
+ }
797
1385
 
798
- &:where(.virtual-scroll-header) {
799
- inset-block-start: 0;
800
- inset-inline-start: 0;
801
- min-inline-size: 100%;
802
- box-sizing: border-box;
1386
+ .virtual-scroll-spacer {
1387
+ pointer-events: none;
803
1388
  }
804
1389
 
805
- &:where(.virtual-scroll-footer) {
806
- inset-block-end: 0;
807
- inset-inline-start: 0;
808
- min-inline-size: 100%;
809
- box-sizing: border-box;
1390
+ .virtual-scroll-header,
1391
+ .virtual-scroll-footer {
1392
+ position: relative;
1393
+ z-index: 20;
810
1394
  }
811
1395
 
812
- &:where(.virtual-scroll-item) {
813
- z-index: 10;
1396
+ .virtual-scroll--sticky {
1397
+ position: sticky;
1398
+
1399
+ &:where(.virtual-scroll-header) {
1400
+ inset-block-start: 0;
1401
+ inset-inline-start: 0;
1402
+ min-inline-size: 100%;
1403
+ box-sizing: border-box;
1404
+ }
1405
+
1406
+ &:where(.virtual-scroll-footer) {
1407
+ inset-block-end: 0;
1408
+ inset-inline-start: 0;
1409
+ min-inline-size: 100%;
1410
+ box-sizing: border-box;
1411
+ }
1412
+
1413
+ &:where(.virtual-scroll-item) {
1414
+ z-index: 10;
1415
+ }
814
1416
  }
815
- }
816
1417
 
817
- :is(tbody.virtual-scroll-wrapper, thead.virtual-scroll-header, tfoot.virtual-scroll-footer) {
818
- display: inline-flex;
819
- min-inline-size: 100%;
820
- & > :deep(tr) {
1418
+ :is(tbody.virtual-scroll-wrapper, thead.virtual-scroll-header, tfoot.virtual-scroll-footer) {
821
1419
  display: inline-flex;
822
1420
  min-inline-size: 100%;
1421
+ & > :deep(tr) {
1422
+ display: inline-flex;
1423
+ min-inline-size: 100%;
823
1424
 
824
- & > :is(td, th) {
825
- display: inline-block;
826
- align-items: center;
1425
+ & > :is(td, th) {
1426
+ display: inline-block;
1427
+ align-items: center;
1428
+ }
827
1429
  }
828
1430
  }
829
1431
  }