@pdanpdan/virtual-scroll 0.4.0 → 0.6.0

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