@pdanpdan/virtual-scroll 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,213 +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
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';
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;
24
+ import { useVirtualScrollbar } from '../composables/useVirtualScrollbar';
25
+ import { getPaddingX, getPaddingY } from '../utils/scroll';
26
+ import { calculateItemStyle, displayToVirtual } from '../utils/virtual-scroll-logic';
27
+ import VirtualScrollbar from './VirtualScrollbar.vue';
133
28
 
134
- /**
135
- * Whether the content in the 'footer' slot is sticky.
136
- * @default false
137
- */
138
- stickyFooter?: boolean;
139
-
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
- }
29
+ export interface Props<T = unknown> extends VirtualScrollComponentProps<T> {}
211
30
 
212
31
  const props = withDefaults(defineProps<Props<T>>(), {
213
32
  direction: 'vertical',
@@ -228,6 +47,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
228
47
  loading: false,
229
48
  restoreScrollOnPrepend: false,
230
49
  debug: false,
50
+ virtualScrollbar: false,
231
51
  });
232
52
 
233
53
  const emit = defineEmits<{
@@ -270,6 +90,10 @@ const slots = defineSlots<{
270
90
  * Useful for setting consistent widths in grid mode.
271
91
  */
272
92
  getColumnWidth: (index: number) => number;
93
+ /** Vertical gap between items. */
94
+ gap: number;
95
+ /** Horizontal gap between columns. */
96
+ columnGap: number;
273
97
  /** Whether this item is configured to be sticky via `stickyIndices`. */
274
98
  isSticky?: boolean | undefined;
275
99
  /** Whether this item is currently in a sticky state (stuck at the top/start). */
@@ -287,6 +111,12 @@ const slots = defineSlots<{
287
111
  * Can be made sticky using the `stickyFooter` prop.
288
112
  */
289
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;
290
120
  }>();
291
121
 
292
122
  const hostRef = ref<HTMLElement | null>(null);
@@ -295,60 +125,66 @@ const headerRef = ref<HTMLElement | null>(null);
295
125
  const footerRef = ref<HTMLElement | null>(null);
296
126
  const itemRefs = new Map<number, HTMLElement>();
297
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
+
298
136
  const measuredPaddingStart = ref(0);
299
137
  const measuredPaddingEnd = ref(0);
300
138
 
139
+ const effectiveContainer = computed(() => (props.container === undefined ? hostRef.value : props.container));
140
+
301
141
  const isHeaderFooterInsideContainer = computed(() => {
302
- const container = props.container === undefined
303
- ? hostRef.value
304
- : props.container;
142
+ const container = effectiveContainer.value;
305
143
 
306
144
  return container === hostRef.value
307
145
  || (typeof window !== 'undefined' && (container === window || container === null));
308
146
  });
309
147
 
310
148
  const virtualScrollProps = computed(() => {
311
- const pStart = props.scrollPaddingStart;
312
- const pEnd = props.scrollPaddingEnd;
313
-
314
149
  /* Trigger re-evaluation on items array mutations */
315
150
  // eslint-disable-next-line ts/no-unused-expressions
316
151
  props.items.length;
317
152
 
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
153
  return {
333
154
  items: props.items,
334
155
  itemSize: props.itemSize,
335
156
  direction: props.direction,
336
157
  bufferBefore: props.bufferBefore,
337
158
  bufferAfter: props.bufferAfter,
338
- container: props.container === undefined
339
- ? hostRef.value
340
- : props.container,
159
+ container: effectiveContainer.value,
341
160
  hostElement: wrapperRef.value,
161
+ hostRef: hostRef.value,
342
162
  ssrRange: props.ssrRange,
343
163
  columnCount: props.columnCount,
344
164
  columnWidth: props.columnWidth,
345
165
  scrollPaddingStart: {
346
- x: startX,
347
- y: startY + (props.stickyHeader && isHeaderFooterInsideContainer.value ? measuredPaddingStart.value : 0),
166
+ x: getPaddingX(props.scrollPaddingStart, props.direction),
167
+ y: getPaddingY(props.scrollPaddingStart, props.direction),
348
168
  },
349
169
  scrollPaddingEnd: {
350
- x: endX,
351
- 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,
352
188
  },
353
189
  gap: props.gap,
354
190
  columnGap: props.columnGap,
@@ -366,26 +202,102 @@ const virtualScrollProps = computed(() => {
366
202
 
367
203
  const {
368
204
  isHydrated,
205
+ isRtl,
369
206
  columnRange,
370
207
  renderedItems,
371
208
  scrollDetails,
372
- totalHeight,
373
- totalWidth,
209
+ renderedHeight,
210
+ renderedWidth,
374
211
  getColumnWidth,
212
+ getRowHeight,
375
213
  scrollToIndex,
376
214
  scrollToOffset,
377
215
  updateHostOffset,
378
216
  updateItemSizes,
217
+ updateDirection,
218
+ getItemOffset,
219
+ getRowOffset,
220
+ getColumnOffset,
221
+ getItemSize,
379
222
  refresh: coreRefresh,
380
223
  stopProgrammaticScroll,
224
+ scaleX,
225
+ scaleY,
226
+ isWindowContainer,
227
+ componentOffset,
228
+ renderedVirtualWidth,
229
+ renderedVirtualHeight,
381
230
  } = useVirtualScroll(virtualScrollProps);
382
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
+
383
294
  /**
384
295
  * Resets all dynamic measurements and re-initializes from props.
385
296
  * Also triggers manual re-measurement of all currently rendered items.
386
297
  */
387
298
  function refresh() {
388
299
  coreRefresh();
300
+ updateDirection();
389
301
  nextTick(() => {
390
302
  const updates: { index: number; inlineSize: number; blockSize: number; element?: HTMLElement; }[] = [];
391
303
 
@@ -408,13 +320,15 @@ function refresh() {
408
320
 
409
321
  // Watch for scroll details and emit event
410
322
  watch(scrollDetails, (details, oldDetails) => {
411
- if (!isHydrated.value) {
323
+ if (!isHydrated.value || !details) {
412
324
  return;
413
325
  }
414
326
  emit('scroll', details);
415
327
 
416
328
  if (
417
329
  !oldDetails
330
+ || !oldDetails.range
331
+ || !oldDetails.columnRange
418
332
  || details.range.start !== oldDetails.range.start
419
333
  || details.range.end !== oldDetails.range.end
420
334
  || details.columnRange.start !== oldDetails.columnRange.start
@@ -433,14 +347,14 @@ watch(scrollDetails, (details, oldDetails) => {
433
347
  }
434
348
 
435
349
  // vertical or both
436
- if (props.direction !== 'horizontal') {
350
+ if (props.direction !== 'horizontal' && details.totalSize) {
437
351
  const remaining = details.totalSize.height - (details.scrollOffset.y + details.viewportSize.height);
438
352
  if (remaining <= props.loadDistance) {
439
353
  emit('load', 'vertical');
440
354
  }
441
355
  }
442
356
  // horizontal or both
443
- if (props.direction !== 'vertical') {
357
+ if (props.direction !== 'vertical' && details.totalSize) {
444
358
  const remaining = details.totalSize.width - (details.scrollOffset.x + details.viewportSize.width);
445
359
  if (remaining <= props.loadDistance) {
446
360
  emit('load', 'horizontal');
@@ -449,7 +363,7 @@ watch(scrollDetails, (details, oldDetails) => {
449
363
  });
450
364
 
451
365
  watch(isHydrated, (hydrated) => {
452
- if (hydrated) {
366
+ if (hydrated && scrollDetails.value?.range && scrollDetails.value?.columnRange) {
453
367
  emit('visibleRangeChange', {
454
368
  start: scrollDetails.value.range.start,
455
369
  end: scrollDetails.value.range.end,
@@ -513,6 +427,8 @@ watch(headerRef, (newEl, oldEl) => {
513
427
  }
514
428
  if (newEl) {
515
429
  extraResizeObserver?.observe(newEl);
430
+ } else {
431
+ measuredPaddingStart.value = 0;
516
432
  }
517
433
  }, { immediate: true });
518
434
 
@@ -522,6 +438,8 @@ watch(footerRef, (newEl, oldEl) => {
522
438
  }
523
439
  if (newEl) {
524
440
  extraResizeObserver?.observe(newEl);
441
+ } else {
442
+ measuredPaddingEnd.value = 0;
525
443
  }
526
444
  }, { immediate: true });
527
445
 
@@ -548,6 +466,22 @@ watch([ hostRef, wrapperRef ], ([ newHost ], [ oldHost ]) => {
548
466
  }
549
467
  });
550
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
+ */
551
485
  function setItemRef(el: unknown, index: number) {
552
486
  if (el) {
553
487
  itemRefs.set(index, el as HTMLElement);
@@ -568,54 +502,342 @@ function setItemRef(el: unknown, index: number) {
568
502
  }
569
503
  }
570
504
 
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;
520
+
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;
529
+
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);
544
+ } else {
545
+ stopInertia();
546
+ }
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;
559
+ }
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) {
573
+ return;
574
+ }
575
+
576
+ // Only handle primary button or touch
577
+ if (event.pointerType === 'mouse' && event.button !== 0) {
578
+ return;
579
+ }
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) {
600
+ return;
601
+ }
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;
614
+ }
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(() => {
623
+ scrollToOffset(
624
+ startScrollOffset.x + deltaX,
625
+ startScrollOffset.y + deltaY,
626
+ { behavior: 'auto' },
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) {
638
+ return;
639
+ }
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
668
+ event.preventDefault();
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
+ */
571
691
  function handleKeyDown(event: KeyboardEvent) {
572
692
  const { viewportSize, scrollOffset } = scrollDetails.value;
573
693
  const isHorizontal = props.direction !== 'vertical';
574
694
  const isVertical = props.direction !== 'horizontal';
575
695
 
696
+ const sStart = virtualScrollProps.value.stickyStart as { x: number; y: number; };
697
+ const sEnd = virtualScrollProps.value.stickyEnd as { x: number; y: number; };
698
+
576
699
  switch (event.key) {
577
- case 'Home':
700
+ case 'Home': {
578
701
  event.preventDefault();
579
702
  stopProgrammaticScroll();
580
- scrollToIndex(0, 0, 'start');
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' });
581
708
  break;
709
+ }
582
710
  case 'End': {
583
711
  event.preventDefault();
584
712
  stopProgrammaticScroll();
585
713
  const lastItemIndex = props.items.length - 1;
586
714
  const lastColIndex = (props.columnCount || 0) > 0 ? props.columnCount - 1 : 0;
587
715
 
588
- if (isHorizontal) {
589
- if (isVertical) {
590
- scrollToIndex(lastItemIndex, lastColIndex, 'end');
591
- } else {
592
- scrollToIndex(0, lastItemIndex, 'end');
593
- }
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' });
594
726
  } else {
595
- scrollToIndex(lastItemIndex, 0, 'end');
727
+ scrollToIndex(
728
+ props.direction === 'vertical' ? lastItemIndex : 0,
729
+ props.direction === 'horizontal' ? lastItemIndex : 0,
730
+ { behavior, align: 'end' },
731
+ );
596
732
  }
597
733
  break;
598
734
  }
599
- case 'ArrowUp':
735
+ case 'ArrowUp': {
600
736
  event.preventDefault();
601
737
  stopProgrammaticScroll();
602
- scrollToOffset(null, scrollOffset.y - DEFAULT_ITEM_SIZE);
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
+ }
603
751
  break;
604
- case 'ArrowDown':
752
+ }
753
+ case 'ArrowDown': {
605
754
  event.preventDefault();
606
755
  stopProgrammaticScroll();
607
- scrollToOffset(null, scrollOffset.y + DEFAULT_ITEM_SIZE);
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
+ }
608
769
  break;
609
- case 'ArrowLeft':
770
+ }
771
+ case 'ArrowLeft': {
610
772
  event.preventDefault();
611
773
  stopProgrammaticScroll();
612
- scrollToOffset(scrollOffset.x - DEFAULT_ITEM_SIZE, null);
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
+ }
613
804
  break;
614
- case 'ArrowRight':
805
+ }
806
+ case 'ArrowRight': {
615
807
  event.preventDefault();
616
808
  stopProgrammaticScroll();
617
- scrollToOffset(scrollOffset.x + DEFAULT_ITEM_SIZE, null);
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
+ }
618
839
  break;
840
+ }
619
841
  case 'PageUp':
620
842
  event.preventDefault();
621
843
  stopProgrammaticScroll();
@@ -641,30 +863,118 @@ onUnmounted(() => {
641
863
  extraResizeObserver?.disconnect();
642
864
  });
643
865
 
644
- const isWindowContainer = computed(() => isWindowLike(props.container));
645
-
646
866
  const containerStyle = computed(() => {
867
+ const base: Record<string, string | number | undefined> = {
868
+ ...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
869
+ };
870
+
871
+ if (showVirtualScrollbars.value) {
872
+ base.overflow = 'auto';
873
+ }
874
+
875
+ if (useVirtualScrolling.value) {
876
+ base.touchAction = 'none';
877
+ }
878
+
647
879
  if (isWindowContainer.value) {
648
- return {
649
- ...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
650
- };
880
+ return base;
651
881
  }
652
882
 
653
883
  if (props.containerTag === 'table') {
654
884
  return {
885
+ ...base,
655
886
  minInlineSize: props.direction === 'vertical' ? '100%' : 'auto',
656
887
  };
657
888
  }
658
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
+
659
912
  return {
660
- ...(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,
661
921
  };
662
922
  });
663
923
 
664
- const wrapperStyle = computed(() => ({
665
- inlineSize: props.direction === 'vertical' ? '100%' : `${ totalWidth.value }px`,
666
- blockSize: props.direction === 'horizontal' ? '100%' : `${ totalHeight.value }px`,
667
- }));
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
+ }
932
+
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
+ };
942
+
943
+ return {
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,
952
+ };
953
+ });
954
+
955
+ const wrapperStyle = computed(() => {
956
+ const isHorizontal = props.direction === 'horizontal';
957
+ const isVertical = props.direction === 'vertical';
958
+ const isBoth = props.direction === 'both';
959
+
960
+ const style: Record<string, string | number | undefined> = {
961
+ inlineSize: isVertical ? '100%' : `${ renderedVirtualWidth.value }px`,
962
+ blockSize: isHorizontal ? '100%' : `${ renderedVirtualHeight.value }px`,
963
+ };
964
+
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`;
970
+ }
971
+ if ((isVertical || isBoth) && props.gap) {
972
+ style.rowGap = `${ props.gap }px`;
973
+ }
974
+ }
975
+
976
+ return style;
977
+ });
668
978
 
669
979
  const loadingStyle = computed(() => {
670
980
  const isHorizontal = props.direction === 'horizontal';
@@ -676,12 +986,18 @@ const loadingStyle = computed(() => {
676
986
  });
677
987
 
678
988
  const spacerStyle = computed(() => ({
679
- inlineSize: props.direction === 'vertical' ? '1px' : `${ totalWidth.value }px`,
680
- blockSize: props.direction === 'horizontal' ? '1px' : `${ totalHeight.value }px`,
989
+ inlineSize: props.direction === 'vertical' ? '1px' : `${ renderedVirtualWidth.value }px`,
990
+ blockSize: props.direction === 'horizontal' ? '1px' : `${ renderedVirtualHeight.value }px`,
681
991
  }));
682
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
+ */
683
999
  function getItemStyle(item: RenderedItem<T>) {
684
- return calculateItemStyle({
1000
+ const style = calculateItemStyle({
685
1001
  containerTag: props.containerTag,
686
1002
  direction: props.direction,
687
1003
  isHydrated: isHydrated.value,
@@ -689,7 +1005,17 @@ function getItemStyle(item: RenderedItem<T>) {
689
1005
  itemSize: props.itemSize,
690
1006
  paddingStartX: (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x,
691
1007
  paddingStartY: (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).y,
1008
+ isRtl: isRtl.value,
692
1009
  });
1010
+
1011
+ if (!isHydrated.value && props.direction === 'both') {
1012
+ style.display = 'flex';
1013
+ if (props.columnGap) {
1014
+ style.columnGap = `${ props.columnGap }px`;
1015
+ }
1016
+ }
1017
+
1018
+ return style;
693
1019
  }
694
1020
 
695
1021
  const isDebug = computed(() => props.debug);
@@ -698,6 +1024,8 @@ const headerTag = computed(() => isTable.value ? 'thead' : 'div');
698
1024
  const footerTag = computed(() => isTable.value ? 'tfoot' : 'div');
699
1025
 
700
1026
  defineExpose({
1027
+ ...toRefs(props),
1028
+
701
1029
  /**
702
1030
  * Detailed information about the current scroll state.
703
1031
  * @see ScrollDetails
@@ -719,6 +1047,41 @@ defineExpose({
719
1047
  */
720
1048
  getColumnWidth,
721
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
+
722
1085
  /**
723
1086
  * Programmatically scroll to a specific row and/or column.
724
1087
  *
@@ -751,13 +1114,69 @@ defineExpose({
751
1114
  * Immediately stops any currently active smooth scroll animation and clears pending corrections.
752
1115
  * @see useVirtualScroll
753
1116
  */
754
- stopProgrammaticScroll,
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,
755
1173
  });
756
1174
  </script>
757
1175
 
758
1176
  <template>
759
1177
  <component
760
1178
  :is="containerTag"
1179
+ :id="containerId"
761
1180
  ref="hostRef"
762
1181
  class="virtual-scroll-container"
763
1182
  :class="[
@@ -766,15 +1185,37 @@ defineExpose({
766
1185
  'virtual-scroll--hydrated': isHydrated,
767
1186
  'virtual-scroll--window': isWindowContainer,
768
1187
  'virtual-scroll--table': isTable,
1188
+ 'virtual-scroll--hide-scrollbar': showVirtualScrollbars,
769
1189
  },
770
1190
  ]"
771
1191
  :style="containerStyle"
772
1192
  tabindex="0"
773
1193
  @keydown="handleKeyDown"
774
- @wheel.passive="stopProgrammaticScroll"
775
- @pointerdown.passive="stopProgrammaticScroll"
776
- @touchstart.passive="stopProgrammaticScroll"
1194
+ @pointerdown="handlePointerDown"
1195
+ @pointermove="handlePointerMove"
1196
+ @pointerup="handlePointerUp"
1197
+ @pointercancel="handlePointerUp"
777
1198
  >
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
+
778
1219
  <component
779
1220
  :is="headerTag"
780
1221
  v-if="slots.header"
@@ -818,8 +1259,10 @@ defineExpose({
818
1259
  name="item"
819
1260
  :item="renderedItem.item"
820
1261
  :index="renderedItem.index"
821
- :column-range="columnRange"
1262
+ :column-range="slotColumnRange"
822
1263
  :get-column-width="getColumnWidth"
1264
+ :gap="props.gap"
1265
+ :column-gap="props.columnGap"
823
1266
  :is-sticky="renderedItem.isSticky"
824
1267
  :is-sticky-active="renderedItem.isStickyActive"
825
1268
  />
@@ -850,109 +1293,139 @@ defineExpose({
850
1293
  </template>
851
1294
 
852
1295
  <style scoped>
853
- .virtual-scroll-container {
854
- position: relative;
855
- block-size: 100%;
856
- inline-size: 100%;
857
- 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
+ }
858
1307
 
859
- &:not(.virtual-scroll--window) {
860
- overflow: auto;
861
- overscroll-behavior: contain;
862
- }
1308
+ &.virtual-scroll--table {
1309
+ display: block;
1310
+ }
863
1311
 
864
- &.virtual-scroll--table {
865
- display: block;
866
- }
867
- }
1312
+ &.virtual-scroll--hide-scrollbar {
1313
+ scrollbar-width: none;
1314
+ -ms-overflow-style: none;
868
1315
 
869
- .virtual-scroll--horizontal {
870
- white-space: nowrap;
871
- }
1316
+ &::-webkit-scrollbar {
1317
+ display: none;
1318
+ }
1319
+ }
872
1320
 
873
- .virtual-scroll-wrapper {
874
- contain: layout;
875
- position: relative;
1321
+ &.virtual-scroll--horizontal,
1322
+ &.virtual-scroll--both {
1323
+ white-space: nowrap;
1324
+ }
1325
+ }
876
1326
 
877
- :where(.virtual-scroll--hydrated > & > .virtual-scroll-item) {
878
- position: absolute;
1327
+ .virtual-scroll-scrollbar-container {
1328
+ position: sticky;
879
1329
  inset-block-start: 0;
880
1330
  inset-inline-start: 0;
1331
+ inline-size: 100%;
1332
+ block-size: 0;
1333
+ z-index: 30;
1334
+ pointer-events: none;
1335
+ overflow: visible;
881
1336
  }
882
- }
883
1337
 
884
- .virtual-scroll-item {
885
- display: grid;
886
- box-sizing: border-box;
887
- 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
+ }
888
1344
 
889
- &:where(.virtual-scroll--debug) {
890
- outline: 1px dashed rgba(255, 0, 0, 0.5);
891
- background-color: rgba(255, 0, 0, 0.05);
1345
+ .virtual-scroll-wrapper {
1346
+ contain: layout;
1347
+ position: relative;
892
1348
 
893
- &:where(:hover) {
894
- background-color: rgba(255, 0, 0, 0.1);
895
- z-index: 100;
1349
+ :where(.virtual-scroll--hydrated > & > .virtual-scroll-item) {
1350
+ position: absolute;
1351
+ inset-block-start: 0;
1352
+ inset-inline-start: 0;
896
1353
  }
897
1354
  }
898
- }
899
1355
 
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
- }
1356
+ .virtual-scroll-item {
1357
+ display: grid;
1358
+ box-sizing: border-box;
1359
+ will-change: transform;
913
1360
 
914
- .virtual-scroll-spacer {
915
- pointer-events: none;
916
- }
1361
+ &:where(.virtual-scroll--debug) {
1362
+ outline: 1px dashed rgba(255, 0, 0, 0.5);
1363
+ background-color: rgba(255, 0, 0, 0.05);
917
1364
 
918
- .virtual-scroll-header,
919
- .virtual-scroll-footer {
920
- position: relative;
921
- z-index: 20;
922
- }
1365
+ &:where(:hover) {
1366
+ background-color: rgba(255, 0, 0, 0.1);
1367
+ z-index: 100;
1368
+ }
1369
+ }
1370
+ }
923
1371
 
924
- .virtual-scroll--sticky {
925
- 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
+ }
926
1385
 
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;
1386
+ .virtual-scroll-spacer {
1387
+ pointer-events: none;
932
1388
  }
933
1389
 
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;
1390
+ .virtual-scroll-header,
1391
+ .virtual-scroll-footer {
1392
+ position: relative;
1393
+ z-index: 20;
939
1394
  }
940
1395
 
941
- &:where(.virtual-scroll-item) {
942
- 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
+ }
943
1416
  }
944
- }
945
1417
 
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) {
1418
+ :is(tbody.virtual-scroll-wrapper, thead.virtual-scroll-header, tfoot.virtual-scroll-footer) {
950
1419
  display: inline-flex;
951
1420
  min-inline-size: 100%;
1421
+ & > :deep(tr) {
1422
+ display: inline-flex;
1423
+ min-inline-size: 100%;
952
1424
 
953
- & > :is(td, th) {
954
- display: inline-block;
955
- align-items: center;
1425
+ & > :is(td, th) {
1426
+ display: inline-block;
1427
+ align-items: center;
1428
+ }
956
1429
  }
957
1430
  }
958
1431
  }