@pdanpdan/virtual-scroll 0.7.0 → 0.9.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.
@@ -11,7 +11,6 @@ import type {
11
11
  ScrollbarSlotProps,
12
12
  ScrollDetails,
13
13
  ScrollToIndexOptions,
14
- VirtualScrollbarProps,
15
14
  VirtualScrollComponentProps,
16
15
  VirtualScrollProps,
17
16
  } from '../types';
@@ -23,8 +22,13 @@ import {
23
22
  useVirtualScroll,
24
23
  } from '../composables/useVirtualScroll';
25
24
  import { useVirtualScrollbar } from '../composables/useVirtualScrollbar';
26
- import { getPaddingX, getPaddingY } from '../utils/scroll';
27
- import { calculateItemStyle, displayToVirtual } from '../utils/virtual-scroll-logic';
25
+ import { getPaddingX, getPaddingY, isWindowLike } from '../utils/scroll';
26
+ import {
27
+ calculateInertiaStep,
28
+ calculateInstantaneousVelocity,
29
+ calculateItemStyle,
30
+ displayToVirtual,
31
+ } from '../utils/virtual-scroll-logic';
28
32
  import VirtualScrollbar from './VirtualScrollbar.vue';
29
33
 
30
34
  export interface Props<T = unknown> extends VirtualScrollComponentProps<T> {}
@@ -110,9 +114,7 @@ const effectiveContainer = computed(() => (props.container === undefined ? hostR
110
114
 
111
115
  const isHeaderFooterInsideContainer = computed(() => {
112
116
  const container = effectiveContainer.value;
113
-
114
- return container === hostRef.value
115
- || (typeof window !== 'undefined' && (container === window || container === null));
117
+ return container === hostRef.value || isWindowLike(container);
116
118
  });
117
119
 
118
120
  const virtualScrollProps = computed(() => {
@@ -167,6 +169,7 @@ const virtualScrollProps = computed(() => {
167
169
  defaultItemSize: props.defaultItemSize,
168
170
  defaultColumnWidth: props.defaultColumnWidth,
169
171
  debug: props.debug,
172
+ snap: props.snap,
170
173
  } as VirtualScrollProps<T>;
171
174
  });
172
175
 
@@ -197,6 +200,8 @@ const {
197
200
  componentOffset,
198
201
  renderedVirtualWidth,
199
202
  renderedVirtualHeight,
203
+ getRowIndexAt,
204
+ getColIndexAt,
200
205
  } = useVirtualScroll(virtualScrollProps);
201
206
 
202
207
  const useVirtualScrolling = computed(() => scaleX.value !== 1 || scaleY.value !== 1);
@@ -230,25 +235,25 @@ function handleHorizontalScrollbarScrollToOffset(offset: number) {
230
235
  }
231
236
  }
232
237
 
233
- const verticalScrollbar = useVirtualScrollbar({
234
- axis: 'vertical',
235
- totalSize: renderedHeight,
236
- position: computed(() => scrollDetails.value.displayScrollOffset.y),
237
- viewportSize: computed(() => scrollDetails.value.displayViewportSize.height),
238
+ const verticalScrollbar = useVirtualScrollbar(computed(() => ({
239
+ axis: 'vertical' as const,
240
+ totalSize: renderedHeight.value,
241
+ position: scrollDetails.value.displayScrollOffset.y,
242
+ viewportSize: scrollDetails.value.displayViewportSize.height,
238
243
  scrollToOffset: handleVerticalScrollbarScrollToOffset,
239
- containerId,
240
- isRtl,
241
- });
242
-
243
- const horizontalScrollbar = useVirtualScrollbar({
244
- axis: 'horizontal',
245
- totalSize: renderedWidth,
246
- position: computed(() => scrollDetails.value.displayScrollOffset.x),
247
- viewportSize: computed(() => scrollDetails.value.displayViewportSize.width),
244
+ containerId: containerId.value,
245
+ isRtl: isRtl.value,
246
+ })));
247
+
248
+ const horizontalScrollbar = useVirtualScrollbar(computed(() => ({
249
+ axis: 'horizontal' as const,
250
+ totalSize: renderedWidth.value,
251
+ position: scrollDetails.value.displayScrollOffset.x,
252
+ viewportSize: scrollDetails.value.displayViewportSize.width,
248
253
  scrollToOffset: handleHorizontalScrollbarScrollToOffset,
249
- containerId,
250
- isRtl,
251
- });
254
+ containerId: containerId.value,
255
+ isRtl: isRtl.value,
256
+ })));
252
257
 
253
258
  const slotColumnRange = computed(() => {
254
259
  if (props.direction !== 'both') {
@@ -414,10 +419,7 @@ onMounted(() => {
414
419
 
415
420
  // Re-observe items that were set before observer was ready
416
421
  for (const el of itemRefs.values()) {
417
- itemResizeObserver?.observe(el);
418
- if (props.direction === 'both') {
419
- el.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
420
- }
422
+ observeItem(el, true);
421
423
  }
422
424
  });
423
425
 
@@ -440,6 +442,20 @@ watch([ hostRef, useVirtualScrolling ], ([ host, virtual ], [ oldHost, oldVirtua
440
442
  }
441
443
  }, { immediate: true });
442
444
 
445
+ /**
446
+ * Helper to manage ResizeObserver for an item and its optional cells.
447
+ *
448
+ * @param el - The item element.
449
+ * @param isObserve - True to observe, false to unobserve.
450
+ */
451
+ function observeItem(el: HTMLElement, isObserve: boolean) {
452
+ const method = isObserve ? 'observe' : 'unobserve';
453
+ itemResizeObserver?.[ method ](el);
454
+ if (props.direction === 'both' && el.children.length > 0) {
455
+ el.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.[ method ](c));
456
+ }
457
+ }
458
+
443
459
  /**
444
460
  * Callback ref to track and measure item elements.
445
461
  *
@@ -448,19 +464,13 @@ watch([ hostRef, useVirtualScrolling ], ([ host, virtual ], [ oldHost, oldVirtua
448
464
  */
449
465
  function setItemRef(el: unknown, index: number) {
450
466
  if (el) {
451
- itemRefs.set(index, el as HTMLElement);
452
- itemResizeObserver?.observe(el as HTMLElement);
453
-
454
- if (props.direction === 'both') {
455
- (el as HTMLElement).querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
456
- }
467
+ const htmlEl = el as HTMLElement;
468
+ itemRefs.set(index, htmlEl);
469
+ observeItem(htmlEl, true);
457
470
  } else {
458
471
  const oldEl = itemRefs.get(index);
459
472
  if (oldEl) {
460
- itemResizeObserver?.unobserve(oldEl);
461
- if (props.direction === 'both') {
462
- oldEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.unobserve(c));
463
- }
473
+ observeItem(oldEl, false);
464
474
  itemRefs.delete(index);
465
475
  }
466
476
  }
@@ -487,18 +497,17 @@ const MIN_VELOCITY = 0.1;
487
497
  */
488
498
  function startInertiaAnimation() {
489
499
  const step = () => {
490
- // Apply friction to the velocity
491
- velocity.x *= FRICTION;
492
- velocity.y *= FRICTION;
500
+ const { nextVelocity, delta } = calculateInertiaStep(velocity, FRICTION);
501
+ velocity.x = nextVelocity.x;
502
+ velocity.y = nextVelocity.y;
493
503
 
494
504
  // Calculate the new scroll offset
495
- const currentX = scrollDetails.value.scrollOffset.x;
496
- const currentY = scrollDetails.value.scrollOffset.y;
505
+ const { x: currentX, y: currentY } = scrollDetails.value.scrollOffset;
497
506
 
498
507
  // Move the scroll position by the current velocity
499
508
  scrollToOffset(
500
- currentX + velocity.x * 16, // Assuming ~60fps (16ms per frame)
501
- currentY + velocity.y * 16,
509
+ currentX + delta.x,
510
+ currentY + delta.y,
502
511
  { behavior: 'auto' },
503
512
  );
504
513
 
@@ -569,12 +578,11 @@ function handlePointerMove(event: PointerEvent) {
569
578
 
570
579
  if (dt > 0) {
571
580
  // Calculate instantaneous velocity (pixels per millisecond)
572
- const instantVelocityX = (lastPointerPos.x - event.clientX) / dt;
573
- const instantVelocityY = (lastPointerPos.y - event.clientY) / dt;
581
+ const instantVelocity = calculateInstantaneousVelocity(lastPointerPos, { x: event.clientX, y: event.clientY }, dt);
574
582
 
575
583
  // Use a moving average for smoother velocity tracking
576
- velocity.x = velocity.x * 0.2 + instantVelocityX * 0.8;
577
- velocity.y = velocity.y * 0.2 + instantVelocityY * 0.8;
584
+ velocity.x = velocity.x * 0.2 + instantVelocity.x * 0.8;
585
+ velocity.y = velocity.y * 0.2 + instantVelocity.y * 0.8;
578
586
  }
579
587
 
580
588
  lastPointerPos = { x: event.clientX, y: event.clientY };
@@ -632,18 +640,14 @@ function handleWheel(event: WheelEvent) {
632
640
  event.preventDefault();
633
641
 
634
642
  // For large content we manually scroll to keep precision/control
635
- let deltaX = event.deltaX;
636
- let deltaY = event.deltaY;
643
+ let { deltaX, deltaY } = event;
637
644
 
638
645
  if (event.shiftKey && deltaX === 0) {
639
646
  deltaX = deltaY;
640
647
  deltaY = 0;
641
648
  }
642
649
 
643
- const targetX = scrollOffset.x + deltaX;
644
- const targetY = scrollOffset.y + deltaY;
645
-
646
- scrollToOffset(targetX, targetY, { behavior: 'auto' });
650
+ scrollToOffset(scrollOffset.x + deltaX, scrollOffset.y + deltaY, { behavior: 'auto' });
647
651
  }
648
652
  }
649
653
 
@@ -657,8 +661,149 @@ function handleKeyDown(event: KeyboardEvent) {
657
661
  const isHorizontal = props.direction !== 'vertical';
658
662
  const isVertical = props.direction !== 'horizontal';
659
663
 
660
- const sStart = virtualScrollProps.value.stickyStart as { x: number; y: number; };
661
- const sEnd = virtualScrollProps.value.stickyEnd as { x: number; y: number; };
664
+ const vProps = virtualScrollProps.value;
665
+ const sStart = vProps.stickyStart as { x: number; y: number; };
666
+ const sEnd = vProps.stickyEnd as { x: number; y: number; };
667
+ const pStart = vProps.scrollPaddingStart as { x: number; y: number; };
668
+ const pEnd = vProps.scrollPaddingEnd as { x: number; y: number; };
669
+
670
+ const snapModeProp = props.snap === true ? 'auto' : props.snap;
671
+ const snapMode = (snapModeProp && snapModeProp !== 'auto')
672
+ ? snapModeProp as 'start' | 'center' | 'end'
673
+ : null;
674
+
675
+ /**
676
+ * Helper to find center index.
677
+ */
678
+ const getCenterIndex = (isX: boolean) => {
679
+ const centerPos = (isX ? scrollOffset.x : scrollOffset.y) + (isX ? viewportSize.width : viewportSize.height) / 2;
680
+ return isX ? getColIndexAt(centerPos) : getRowIndexAt(centerPos);
681
+ };
682
+
683
+ const { currentIndex, currentEndIndex, currentColIndex, currentEndColIndex } = scrollDetails.value;
684
+
685
+ /**
686
+ * Helper to calculate the target index for PageUp/PageDown.
687
+ *
688
+ * @param isVerticalAxis - True for vertical, false for horizontal.
689
+ * @param isForward - True for forward (PageDown), false for backward (PageUp).
690
+ */
691
+ const getPageTarget = (isVerticalAxis: boolean, isForward: boolean) => {
692
+ const isHorizontalAxis = !isVerticalAxis;
693
+ const startIdx = isVerticalAxis ? currentIndex : currentColIndex;
694
+ const endIdx = isVerticalAxis ? currentEndIndex : currentEndColIndex;
695
+ const pageSize = Math.max(1, endIdx - startIdx);
696
+ const maxIdx = isVerticalAxis
697
+ ? props.items.length - 1
698
+ : (props.columnCount ? props.columnCount - 1 : props.items.length - 1);
699
+
700
+ if (isForward) {
701
+ if (snapMode === 'center') {
702
+ return Math.min(maxIdx, getCenterIndex(isHorizontalAxis) + pageSize);
703
+ }
704
+ if (snapMode === 'end') {
705
+ return Math.min(maxIdx, endIdx + pageSize);
706
+ }
707
+ return endIdx; // default or snapMode === 'start'
708
+ } else {
709
+ // backward
710
+ if (snapMode === 'center') {
711
+ return Math.max(0, getCenterIndex(isHorizontalAxis) - pageSize);
712
+ }
713
+ if (snapMode === 'start') {
714
+ return Math.max(0, startIdx - pageSize);
715
+ }
716
+ return startIdx; // default or snapMode === 'end'
717
+ }
718
+ };
719
+
720
+ /**
721
+ * Performs keyboard navigation for arrow keys.
722
+ *
723
+ * @param isVerticalAxis - True for vertical, false for horizontal.
724
+ * @param isForward - True for forward direction (Down/Right), false for backward.
725
+ */
726
+ const navigate = (isVerticalAxis: boolean, isForward: boolean) => {
727
+ const isHorizontalAxis = !isVerticalAxis;
728
+
729
+ if (snapMode === 'center') {
730
+ const centerIdx = getCenterIndex(isHorizontalAxis);
731
+ const maxIdx = isHorizontalAxis
732
+ ? (props.columnCount ? props.columnCount - 1 : props.items.length - 1)
733
+ : props.items.length - 1;
734
+ const targetIdx = isForward ? Math.min(maxIdx, centerIdx + 1) : Math.max(0, centerIdx - 1);
735
+ scrollToIndex(isVerticalAxis ? targetIdx : null, isHorizontalAxis ? targetIdx : null, { align: 'center' });
736
+ return;
737
+ }
738
+
739
+ if (isVerticalAxis) {
740
+ if (isForward) {
741
+ if (snapMode === 'start') {
742
+ scrollToIndex(Math.min(props.items.length - 1, currentIndex + 1), null, { align: 'start' });
743
+ } else {
744
+ const align = snapMode || 'end';
745
+ const viewportBottom = scrollOffset.y + viewportSize.height - (sEnd.y + pEnd.y);
746
+ const itemBottom = getRowOffset(currentEndIndex) + getRowHeight(currentEndIndex);
747
+
748
+ if (itemBottom > viewportBottom + 1) {
749
+ scrollToIndex(currentEndIndex, null, { align });
750
+ } else if (currentEndIndex < props.items.length - 1) {
751
+ scrollToIndex(currentEndIndex + 1, null, { align });
752
+ }
753
+ }
754
+ } else {
755
+ // backward
756
+ if (snapMode === 'end') {
757
+ scrollToIndex(Math.max(0, currentEndIndex - 1), null, { align: 'end' });
758
+ } else {
759
+ const align = snapMode || 'start';
760
+ const viewportTop = scrollOffset.y + sStart.y + pStart.y;
761
+ const itemPos = getRowOffset(currentIndex);
762
+
763
+ if (itemPos < viewportTop - 1) {
764
+ scrollToIndex(currentIndex, null, { align });
765
+ } else if (currentIndex > 0) {
766
+ scrollToIndex(currentIndex - 1, null, { align });
767
+ }
768
+ }
769
+ }
770
+ } else {
771
+ // Horizontal axis
772
+ const maxColIdx = props.columnCount ? props.columnCount - 1 : props.items.length - 1;
773
+ const isLogicalForward = isRtl.value ? !isForward : isForward;
774
+
775
+ if (isLogicalForward) {
776
+ if (snapMode === 'start') {
777
+ scrollToIndex(null, Math.min(maxColIdx, currentColIndex + 1), { align: 'start' });
778
+ } else {
779
+ const align = snapMode || 'end';
780
+ const viewportRight = scrollOffset.x + viewportSize.width - (sEnd.x + pEnd.x);
781
+ const colEndPos = (props.columnCount ? getColumnOffset(currentEndColIndex) + getColumnWidth(currentEndColIndex) : getItemOffset(currentEndColIndex) + getItemSize(currentEndColIndex));
782
+
783
+ if (colEndPos > viewportRight + 1) {
784
+ scrollToIndex(null, currentEndColIndex, { align });
785
+ } else if (currentEndColIndex < maxColIdx) {
786
+ scrollToIndex(null, currentEndColIndex + 1, { align });
787
+ }
788
+ }
789
+ } else {
790
+ // backward
791
+ if (snapMode === 'end') {
792
+ scrollToIndex(null, Math.max(0, currentEndColIndex - 1), { align: 'end' });
793
+ } else {
794
+ const align = snapMode || 'start';
795
+ const viewportLeft = scrollOffset.x + sStart.x + pStart.x;
796
+ const colStartPos = (props.columnCount ? getColumnOffset(currentColIndex) : getItemOffset(currentColIndex));
797
+
798
+ if (colStartPos < viewportLeft - 1) {
799
+ scrollToIndex(null, currentColIndex, { align });
800
+ } else if (currentColIndex > 0) {
801
+ scrollToIndex(null, currentColIndex - 1, { align });
802
+ }
803
+ }
804
+ }
805
+ }
806
+ };
662
807
 
663
808
  switch (event.key) {
664
809
  case 'Home': {
@@ -696,127 +841,51 @@ function handleKeyDown(event: KeyboardEvent) {
696
841
  }
697
842
  break;
698
843
  }
699
- case 'ArrowUp': {
844
+ case 'ArrowUp':
700
845
  event.preventDefault();
701
846
  stopProgrammaticScroll();
702
- if (!isVertical) {
703
- return;
704
- }
705
-
706
- const { currentIndex, scrollOffset } = scrollDetails.value;
707
- const viewportTop = scrollOffset.y + sStart.y + (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).y;
708
- const itemPos = getRowOffset(currentIndex);
709
-
710
- if (itemPos < viewportTop - 1) {
711
- scrollToIndex(currentIndex, null, { align: 'start' });
712
- } else if (currentIndex > 0) {
713
- scrollToIndex(currentIndex - 1, null, { align: 'start' });
847
+ if (isVertical) {
848
+ navigate(true, false);
714
849
  }
715
850
  break;
716
- }
717
- case 'ArrowDown': {
851
+ case 'ArrowDown':
718
852
  event.preventDefault();
719
853
  stopProgrammaticScroll();
720
- if (!isVertical) {
721
- return;
722
- }
723
-
724
- const { currentEndIndex } = scrollDetails.value;
725
- const viewportBottom = scrollOffset.y + viewportSize.height - (sEnd.y + (virtualScrollProps.value.scrollPaddingEnd as { x: number; y: number; }).y);
726
- const itemBottom = getRowOffset(currentEndIndex) + getRowHeight(currentEndIndex);
727
-
728
- if (itemBottom > viewportBottom + 1) {
729
- scrollToIndex(currentEndIndex, null, { align: 'end' });
730
- } else if (currentEndIndex < props.items.length - 1) {
731
- scrollToIndex(currentEndIndex + 1, null, { align: 'end' });
854
+ if (isVertical) {
855
+ navigate(true, true);
732
856
  }
733
857
  break;
734
- }
735
- case 'ArrowLeft': {
858
+ case 'ArrowLeft':
736
859
  event.preventDefault();
737
860
  stopProgrammaticScroll();
738
- if (!isHorizontal) {
739
- return;
740
- }
741
-
742
- const { currentColIndex, currentEndColIndex } = scrollDetails.value;
743
-
744
- if (isRtl.value) {
745
- // RTL ArrowLeft -> towards logical END (Left)
746
- const viewportLeft = scrollOffset.x + viewportSize.width - (sEnd.x + (virtualScrollProps.value.scrollPaddingEnd as { x: number; y: number; }).x);
747
- const colEndPos = (props.columnCount ? getColumnOffset(currentEndColIndex) + getColumnWidth(currentEndColIndex) : getItemOffset(currentEndColIndex) + getItemSize(currentEndColIndex));
748
-
749
- if (colEndPos > viewportLeft + 1) {
750
- scrollToIndex(null, currentEndColIndex, { align: 'end' });
751
- } else {
752
- const maxColIdx = props.columnCount ? props.columnCount - 1 : props.items.length - 1;
753
- if (currentEndColIndex < maxColIdx) {
754
- scrollToIndex(null, currentEndColIndex + 1, { align: 'end' });
755
- }
756
- }
757
- } else {
758
- // LTR ArrowLeft -> towards logical START (Left)
759
- const viewportLeft = scrollOffset.x + sStart.x + (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x;
760
- const colStartPos = (props.columnCount ? getColumnOffset(currentColIndex) : getItemOffset(currentColIndex));
761
-
762
- if (colStartPos < viewportLeft - 1) {
763
- scrollToIndex(null, currentColIndex, { align: 'start' });
764
- } else if (currentColIndex > 0) {
765
- scrollToIndex(null, currentColIndex - 1, { align: 'start' });
766
- }
861
+ if (isHorizontal) {
862
+ navigate(false, false);
767
863
  }
768
864
  break;
769
- }
770
- case 'ArrowRight': {
865
+ case 'ArrowRight':
771
866
  event.preventDefault();
772
867
  stopProgrammaticScroll();
773
- if (!isHorizontal) {
774
- return;
775
- }
776
-
777
- const { currentColIndex, currentEndColIndex } = scrollDetails.value;
778
-
779
- if (isRtl.value) {
780
- // RTL ArrowRight -> towards logical START (Right)
781
- const viewportRight = scrollOffset.x + sStart.x + (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x;
782
- const colStartPos = (props.columnCount ? getColumnOffset(currentColIndex) : getItemOffset(currentColIndex));
783
-
784
- if (colStartPos < viewportRight - 1) {
785
- scrollToIndex(null, currentColIndex, { align: 'start' });
786
- } else if (currentColIndex > 0) {
787
- scrollToIndex(null, currentColIndex - 1, { align: 'start' });
788
- }
789
- } else {
790
- // LTR ArrowRight -> towards logical END (Right)
791
- const viewportRight = scrollOffset.x + viewportSize.width - (sEnd.x + (virtualScrollProps.value.scrollPaddingEnd as { x: number; y: number; }).x);
792
- const colEndPos = (props.columnCount ? getColumnOffset(currentEndColIndex) + getColumnWidth(currentEndColIndex) : getItemOffset(currentEndColIndex) + getItemSize(currentEndColIndex));
793
-
794
- if (colEndPos > viewportRight + 1) {
795
- scrollToIndex(null, currentEndColIndex, { align: 'end' });
796
- } else {
797
- const maxColIdx = props.columnCount ? props.columnCount - 1 : props.items.length - 1;
798
- if (currentEndColIndex < maxColIdx) {
799
- scrollToIndex(null, currentEndColIndex + 1, { align: 'end' });
800
- }
801
- }
868
+ if (isHorizontal) {
869
+ navigate(false, true);
802
870
  }
803
871
  break;
804
- }
805
872
  case 'PageUp':
806
873
  event.preventDefault();
807
874
  stopProgrammaticScroll();
808
- scrollToOffset(
809
- !isVertical && isHorizontal ? scrollOffset.x - viewportSize.width : null,
810
- isVertical ? scrollOffset.y - viewportSize.height : null,
811
- );
875
+ if (props.direction === 'horizontal') {
876
+ scrollToIndex(null, getPageTarget(false, false), { align: snapMode || 'end' });
877
+ } else {
878
+ scrollToIndex(getPageTarget(true, false), null, { align: snapMode || 'end' });
879
+ }
812
880
  break;
813
881
  case 'PageDown':
814
882
  event.preventDefault();
815
883
  stopProgrammaticScroll();
816
- scrollToOffset(
817
- !isVertical && isHorizontal ? scrollOffset.x + viewportSize.width : null,
818
- isVertical ? scrollOffset.y + viewportSize.height : null,
819
- );
884
+ if (props.direction === 'horizontal') {
885
+ scrollToIndex(null, getPageTarget(false, true), { align: snapMode || 'start' });
886
+ } else {
887
+ scrollToIndex(getPageTarget(true, true), null, { align: snapMode || 'start' });
888
+ }
820
889
  break;
821
890
  }
822
891
  }
@@ -855,70 +924,79 @@ const containerStyle = computed(() => {
855
924
  return base;
856
925
  });
857
926
 
858
- const verticalScrollbarProps = computed<ScrollbarSlotProps | null>(() => {
859
- if (props.direction === 'horizontal') {
860
- return null;
861
- }
862
- const { displayViewportSize, displayScrollOffset } = scrollDetails.value;
863
- if (renderedHeight.value <= displayViewportSize.height) {
927
+ /**
928
+ * Internal helper to generate consistent ScrollbarSlotProps.
929
+ *
930
+ * @param axis - The scroll axis.
931
+ * @param totalSize - Total scrollable size (DU).
932
+ * @param position - Current scroll position (DU).
933
+ * @param viewportSize - Current viewport size (DU).
934
+ * @param scrollToOffsetCallback - Callback to perform scroll.
935
+ * @param scrollbar - Scrollbar state from useVirtualScrollbar.
936
+ * @returns Props for the scrollbar slot or null if content fits.
937
+ */
938
+ function getScrollbarSlotProps(
939
+ axis: 'vertical' | 'horizontal',
940
+ totalSize: number,
941
+ position: number,
942
+ viewportSize: number,
943
+ scrollToOffsetCallback: (offset: number) => void,
944
+ scrollbar: ReturnType<typeof useVirtualScrollbar>,
945
+ ): ScrollbarSlotProps | null {
946
+ if (totalSize <= viewportSize) {
864
947
  return null;
865
948
  }
866
949
 
867
- const scrollbarProps: VirtualScrollbarProps = {
868
- axis: 'vertical',
869
- totalSize: renderedHeight.value,
870
- position: displayScrollOffset.y,
871
- viewportSize: displayViewportSize.height,
872
- scrollToOffset: handleVerticalScrollbarScrollToOffset,
873
- containerId: containerId.value,
874
- isRtl: isRtl.value,
875
- ariaLabel: 'Vertical scroll',
876
- };
877
-
878
950
  return {
879
- axis: 'vertical',
880
- positionPercent: verticalScrollbar.positionPercent.value,
881
- viewportPercent: verticalScrollbar.viewportPercent.value,
882
- thumbSizePercent: verticalScrollbar.thumbSizePercent.value,
883
- thumbPositionPercent: verticalScrollbar.thumbPositionPercent.value,
884
- trackProps: verticalScrollbar.trackProps.value,
885
- thumbProps: verticalScrollbar.thumbProps.value,
886
- scrollbarProps,
887
- isDragging: verticalScrollbar.isDragging.value,
951
+ axis,
952
+ positionPercent: scrollbar.positionPercent.value,
953
+ viewportPercent: scrollbar.viewportPercent.value,
954
+ thumbSizePercent: scrollbar.thumbSizePercent.value,
955
+ thumbPositionPercent: scrollbar.thumbPositionPercent.value,
956
+ trackProps: scrollbar.trackProps.value,
957
+ thumbProps: scrollbar.thumbProps.value,
958
+ scrollbarProps: {
959
+ axis,
960
+ totalSize,
961
+ position,
962
+ viewportSize,
963
+ scrollToOffset: scrollToOffsetCallback,
964
+ containerId: containerId.value,
965
+ isRtl: isRtl.value,
966
+ ariaLabel: `${ axis === 'vertical' ? 'Vertical' : 'Horizontal' } scroll`,
967
+ },
968
+ isDragging: scrollbar.isDragging.value,
888
969
  };
889
- });
970
+ }
890
971
 
891
- const horizontalScrollbarProps = computed<ScrollbarSlotProps | null>(() => {
892
- if (props.direction === 'vertical') {
972
+ const verticalScrollbarProps = computed(() => {
973
+ if (props.direction === 'horizontal') {
893
974
  return null;
894
975
  }
895
976
  const { displayViewportSize, displayScrollOffset } = scrollDetails.value;
896
- if (renderedWidth.value <= displayViewportSize.width) {
977
+ return getScrollbarSlotProps(
978
+ 'vertical',
979
+ renderedHeight.value,
980
+ displayScrollOffset.y,
981
+ displayViewportSize.height,
982
+ handleVerticalScrollbarScrollToOffset,
983
+ verticalScrollbar,
984
+ );
985
+ });
986
+
987
+ const horizontalScrollbarProps = computed(() => {
988
+ if (props.direction === 'vertical') {
897
989
  return null;
898
990
  }
899
-
900
- const scrollbarProps: VirtualScrollbarProps = {
901
- axis: 'horizontal',
902
- totalSize: renderedWidth.value,
903
- position: displayScrollOffset.x,
904
- viewportSize: displayViewportSize.width,
905
- scrollToOffset: handleHorizontalScrollbarScrollToOffset,
906
- containerId: containerId.value,
907
- isRtl: isRtl.value,
908
- ariaLabel: 'Horizontal scroll',
909
- };
910
-
911
- return {
912
- axis: 'horizontal',
913
- positionPercent: horizontalScrollbar.positionPercent.value,
914
- viewportPercent: horizontalScrollbar.viewportPercent.value,
915
- thumbSizePercent: horizontalScrollbar.thumbSizePercent.value,
916
- thumbPositionPercent: horizontalScrollbar.thumbPositionPercent.value,
917
- trackProps: horizontalScrollbar.trackProps.value,
918
- thumbProps: horizontalScrollbar.thumbProps.value,
919
- scrollbarProps,
920
- isDragging: horizontalScrollbar.isDragging.value,
921
- };
991
+ const { displayViewportSize, displayScrollOffset } = scrollDetails.value;
992
+ return getScrollbarSlotProps(
993
+ 'horizontal',
994
+ renderedWidth.value,
995
+ displayScrollOffset.x,
996
+ displayViewportSize.width,
997
+ handleHorizontalScrollbarScrollToOffset,
998
+ horizontalScrollbar,
999
+ );
922
1000
  });
923
1001
 
924
1002
  const wrapperStyle = computed(() => {
@@ -1169,11 +1247,25 @@ defineExpose({
1169
1247
  */
1170
1248
  getItemSize,
1171
1249
 
1250
+ /**
1251
+ * Helper to get the row (or item) index at a specific vertical (or horizontal in horizontal mode) virtual offset (VU).
1252
+ * @param offset - The virtual pixel offset.
1253
+ * @see useVirtualScroll
1254
+ */
1255
+ getRowIndexAt,
1256
+
1257
+ /**
1258
+ * Helper to get the column index at a specific horizontal virtual offset (VU).
1259
+ * @param offset - The virtual pixel offset.
1260
+ * @see useVirtualScroll
1261
+ */
1262
+ getColIndexAt,
1263
+
1172
1264
  /**
1173
1265
  * Programmatically scroll to a specific row and/or column.
1174
1266
  *
1175
- * @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
1176
- * @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
1267
+ * @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally. Optional.
1268
+ * @param colIndex - The column index to scroll to. Pass null to only scroll vertically. Optional.
1177
1269
  * @param options - Alignment and behavior options. Defaults to { align: 'auto', behavior: 'auto' }.
1178
1270
  * @see ScrollAlignment
1179
1271
  * @see ScrollToIndexOptions