@pdanpdan/virtual-scroll 0.10.0 → 0.10.1

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pdanpdan/virtual-scroll",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.10.1",
5
5
  "description": "A high-performance virtual scroll component for Vue 3",
6
6
  "author": "",
7
7
  "license": "MIT",
@@ -6,6 +6,7 @@ import type {
6
6
  ScrollDetails,
7
7
  ScrollDirection,
8
8
  ScrollToIndexOptions,
9
+ ScrollToIndexResult,
9
10
  VirtualScrollProps,
10
11
  } from '../types';
11
12
  /* global ScrollToOptions */
@@ -112,10 +113,10 @@ export function useVirtualScroll<T = unknown>(
112
113
  pendingScroll.value = null;
113
114
  };
114
115
 
115
- /** Previous internal horizontal virtual position. */
116
- let lastInternalX = 0;
117
- /** Previous internal vertical virtual position. */
118
- let lastInternalY = 0;
116
+ /** Horizontal virtual scroll position at the start of the current scroll interaction (VU). */
117
+ let scrollStartX = 0;
118
+ /** Vertical virtual scroll position at the start of the current scroll interaction (VU). */
119
+ let scrollStartY = 0;
119
120
 
120
121
  // --- Computed Config ---
121
122
  /** Validated scroll direction. */
@@ -348,10 +349,9 @@ export function useVirtualScroll<T = unknown>(
348
349
  rowIndex?: number | null,
349
350
  colIndex?: number | null,
350
351
  options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions,
351
- ) {
352
- const isCorrection = (typeof options === 'object' && options !== null && 'isCorrection' in options)
353
- ? options.isCorrection
354
- : false;
352
+ ): ScrollToIndexResult {
353
+ const isCorrection = isScrollToIndexOptions(options) ? options.isCorrection : false;
354
+ const dryRun = isScrollToIndexOptions(options) ? options.dryRun : false;
355
355
 
356
356
  const container = props.value.container || window;
357
357
 
@@ -393,7 +393,7 @@ export function useVirtualScroll<T = unknown>(
393
393
  paddingEndY: paddingEndY.value,
394
394
  });
395
395
 
396
- if (!isCorrection) {
396
+ if (!isCorrection && !dryRun) {
397
397
  const behavior = isScrollToIndexOptions(options) ? options.behavior : undefined;
398
398
  pendingScroll.value = {
399
399
  rowIndex,
@@ -416,14 +416,18 @@ export function useVirtualScroll<T = unknown>(
416
416
  }
417
417
  const scrollBehavior = isCorrection ? 'auto' : (behavior || 'smooth');
418
418
 
419
- isProgrammaticScroll.value = true;
420
- clearTimeout(programmaticScrollTimer);
421
- if (scrollBehavior === 'smooth') {
422
- programmaticScrollTimer = setTimeout(() => {
423
- isProgrammaticScroll.value = false;
424
- programmaticScrollTimer = undefined;
425
- checkPendingScroll();
426
- }, 500);
419
+ if (!dryRun) {
420
+ if (scrollBehavior === 'smooth' || !isCorrection) {
421
+ isProgrammaticScroll.value = true;
422
+ clearTimeout(programmaticScrollTimer);
423
+ if (scrollBehavior === 'smooth') {
424
+ programmaticScrollTimer = setTimeout(() => {
425
+ isProgrammaticScroll.value = false;
426
+ programmaticScrollTimer = undefined;
427
+ checkPendingScroll();
428
+ }, 1000);
429
+ }
430
+ }
427
431
  }
428
432
 
429
433
  const scrollOptions: ScrollToOptions = { behavior: scrollBehavior };
@@ -433,9 +437,12 @@ export function useVirtualScroll<T = unknown>(
433
437
  if (rowIndex !== null && rowIndex !== undefined) {
434
438
  scrollOptions.top = Math.max(0, finalY);
435
439
  }
436
- scrollTo(container, scrollOptions);
437
440
 
438
- if (scrollBehavior === 'auto' || scrollBehavior === undefined) {
441
+ if (!isCorrection && !dryRun) {
442
+ scrollTo(container, scrollOptions);
443
+ }
444
+
445
+ if (!dryRun && (scrollBehavior === 'auto' || scrollBehavior === undefined)) {
439
446
  if (colIndex !== null && colIndex !== undefined) {
440
447
  scrollX.value = (isRtl.value ? finalX : Math.max(0, finalX));
441
448
  internalScrollX.value = targetX;
@@ -445,6 +452,8 @@ export function useVirtualScroll<T = unknown>(
445
452
  internalScrollY.value = targetY;
446
453
  }
447
454
  }
455
+
456
+ return { targetX, targetY, displayTargetX, displayTargetY };
448
457
  }
449
458
 
450
459
  function scrollToOffset(x?: number | null, y?: number | null, options?: { behavior?: 'auto' | 'smooth'; }) {
@@ -456,7 +465,7 @@ export function useVirtualScroll<T = unknown>(
456
465
  isProgrammaticScroll.value = false;
457
466
  programmaticScrollTimer = undefined;
458
467
  checkPendingScroll();
459
- }, 500);
468
+ }, 1000);
460
469
  }
461
470
  pendingScroll.value = null;
462
471
 
@@ -759,14 +768,23 @@ export function useVirtualScroll<T = unknown>(
759
768
  const scrollValueX = isRtl.value ? Math.abs(scrollX.value) : scrollX.value;
760
769
  const virtualX = displayToVirtual(scrollValueX, componentOffset.x, scaleX.value);
761
770
  const virtualY = displayToVirtual(scrollY.value, componentOffset.y, scaleY.value);
762
- if (Math.abs(virtualX - lastInternalX) > 0.5) {
763
- scrollDirectionX.value = virtualX > lastInternalX ? 'end' : 'start';
764
- lastInternalX = virtualX;
765
- }
766
- if (Math.abs(virtualY - lastInternalY) > 0.5) {
767
- scrollDirectionY.value = virtualY > lastInternalY ? 'end' : 'start';
768
- lastInternalY = virtualY;
771
+
772
+ if (!isProgrammaticScroll.value) {
773
+ if (!isScrolling.value) {
774
+ scrollStartX = internalScrollX.value;
775
+ scrollStartY = internalScrollY.value;
776
+ }
777
+ const deltaX = virtualX - scrollStartX;
778
+ const deltaY = virtualY - scrollStartY;
779
+
780
+ if (Math.abs(deltaX) > 0.5) {
781
+ scrollDirectionX.value = deltaX > 0 ? 'end' : 'start';
782
+ }
783
+ if (Math.abs(deltaY) > 0.5) {
784
+ scrollDirectionY.value = deltaY > 0 ? 'end' : 'start';
785
+ }
769
786
  }
787
+
770
788
  internalScrollX.value = virtualX;
771
789
  internalScrollY.value = virtualY;
772
790
  if (!isProgrammaticScroll.value) {
@@ -1085,5 +1103,7 @@ export function useVirtualScroll<T = unknown>(
1085
1103
  getRowIndexAt,
1086
1104
  /** Helper to get the column index at a specific virtual offset (VU). */
1087
1105
  getColIndexAt,
1106
+ /** @internal */
1107
+ __internalState: ctx.internalState,
1088
1108
  };
1089
1109
  }
@@ -1,4 +1,4 @@
1
- import type { RenderedItem, ScrollAlignment, ScrollAlignmentOptions, ScrollDetails, ScrollToIndexOptions, Size, VirtualScrollProps } from '../types';
1
+ import type { RenderedItem, ScrollAlignment, ScrollAlignmentOptions, ScrollDetails, ScrollToIndexOptions, ScrollToIndexResult, Size, VirtualScrollProps } from '../types';
2
2
  import type { Ref } from 'vue';
3
3
 
4
4
  /**
@@ -51,7 +51,7 @@ export interface ExtensionContext<T = unknown> {
51
51
  /** Direct access to core component methods. */
52
52
  methods: {
53
53
  /** Scroll to a specific row and/or column. */
54
- scrollToIndex: (rowIndex?: number | null, colIndex?: number | null, options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions) => void;
54
+ scrollToIndex: (rowIndex?: number | null, colIndex?: number | null, options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions) => ScrollToIndexResult;
55
55
  /** Scroll to a specific virtual pixel offset. */
56
56
  scrollToOffset: (x?: number | null, y?: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => void;
57
57
  /** Detect and update text direction. */
@@ -72,10 +72,23 @@ export function useSnappingExtension<T = unknown>(): VirtualScrollExtension<T> {
72
72
  }
73
73
 
74
74
  if (shouldSnap) {
75
- ctx.methods.scrollToIndex(targetRow, targetCol, {
75
+ const { targetX, targetY } = ctx.methods.scrollToIndex(targetRow, targetCol, {
76
76
  align: { x: alignX, y: alignY },
77
- behavior: 'smooth',
77
+ dryRun: true,
78
78
  });
79
+
80
+ const currentX = ctx.internalState.internalScrollX.value;
81
+ const currentY = ctx.internalState.internalScrollY.value;
82
+
83
+ const diffX = (targetCol !== null) ? Math.abs(targetX - currentX) : 0;
84
+ const diffY = (targetRow !== null) ? Math.abs(targetY - currentY) : 0;
85
+
86
+ if (diffX > 0.5 || diffY > 0.5) {
87
+ ctx.methods.scrollToIndex(targetRow, targetCol, {
88
+ align: { x: alignX, y: alignY },
89
+ behavior: 'smooth',
90
+ });
91
+ }
79
92
  }
80
93
  },
81
94
  };
package/src/types.ts CHANGED
@@ -87,6 +87,25 @@ export interface ScrollToIndexOptions {
87
87
  * @internal
88
88
  */
89
89
  isCorrection?: boolean;
90
+
91
+ /**
92
+ * If true, only calculates the target position without performing the actual scroll.
93
+ * Useful for extensions that need to validate if a snap is necessary.
94
+ * @default false
95
+ */
96
+ dryRun?: boolean;
97
+ }
98
+
99
+ /** Result of the `scrollToIndex` method. */
100
+ export interface ScrollToIndexResult {
101
+ /** Target relative horizontal position in virtual units (VU). */
102
+ targetX: number;
103
+ /** Target relative vertical position in virtual units (VU). */
104
+ targetY: number;
105
+ /** Target display horizontal position (DU). */
106
+ displayTargetX: number;
107
+ /** Target display vertical position (DU). */
108
+ displayTargetY: number;
90
109
  }
91
110
 
92
111
  /** Represents an item currently rendered in the virtual scroll area. */
@@ -566,7 +585,7 @@ export interface VirtualScrollInstance<T = unknown> extends VirtualScrollCompone
566
585
  /** The tag used for rendering items. */
567
586
  itemTag: string;
568
587
  /** Programmatically scroll to a specific row and/or column. */
569
- scrollToIndex: (rowIndex?: number | null, colIndex?: number | null, options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions) => void;
588
+ scrollToIndex: (rowIndex?: number | null, colIndex?: number | null, options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions) => ScrollToIndexResult;
570
589
  /** Programmatically scroll to a specific pixel offset. */
571
590
  scrollToOffset: (x?: number | null, y?: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => void;
572
591
  /** Resets all dynamic measurements and re-initializes from props. */
@@ -95,7 +95,7 @@ export function scrollTo(container: HTMLElement | Window | null | undefined, opt
95
95
  * @returns `true` if the options object contains scroll-to-index specific properties.
96
96
  */
97
97
  export function isScrollToIndexOptions(options: unknown): options is ScrollToIndexOptions {
98
- return typeof options === 'object' && options != null && ('align' in options || 'behavior' in options || 'isCorrection' in options);
98
+ return typeof options === 'object' && options != null && ('align' in options || 'behavior' in options || 'isCorrection' in options || 'dryRun' in options);
99
99
  }
100
100
 
101
101
  /**
@@ -1190,35 +1190,44 @@ export function resolveSnap(
1190
1190
  }
1191
1191
 
1192
1192
  if (mode === 'next') {
1193
- if (dir === 'start') {
1194
- effectiveMode = 'end';
1195
- } else if (dir === 'end') {
1196
- effectiveMode = 'start';
1197
- } else {
1198
- return null;
1199
- }
1200
-
1201
- if (effectiveMode === 'start') {
1202
- // Scrolling towards end (dir === 'end') -> snap to NEXT item start
1193
+ if (dir === 'end') {
1194
+ // Scrolling items UP (towards end of list) -> snap to NEXT item start
1203
1195
  const size = getSize(currentIdx);
1204
1196
  if (size > viewSize) {
1205
1197
  return null;
1206
1198
  }
1199
+ const scrolledOut = relScroll - getQuery(currentIdx);
1200
+ const threshold = Math.min(5, size * 0.1);
1201
+ if (scrolledOut <= threshold) {
1202
+ return {
1203
+ index: currentIdx,
1204
+ align: 'start' as const,
1205
+ };
1206
+ }
1207
1207
  return {
1208
1208
  index: Math.min(count - 1, currentIdx + 1),
1209
1209
  align: 'start' as const,
1210
1210
  };
1211
- }
1212
- if (effectiveMode === 'end') {
1213
- // Scrolling towards start (dir === 'start') -> snap to PREVIOUS item end
1211
+ } else if (dir === 'start') {
1212
+ // Scrolling items DOWN (towards start of list) -> snap to PREVIOUS item end
1214
1213
  const size = getSize(currentEndIdx);
1215
1214
  if (size > viewSize) {
1216
1215
  return null;
1217
1216
  }
1217
+ const scrolledOutBottom = (getQuery(currentEndIdx) + size) - (relScroll + viewSize);
1218
+ const threshold = Math.min(5, size * 0.1);
1219
+ if (scrolledOutBottom <= threshold) {
1220
+ return {
1221
+ index: currentEndIdx,
1222
+ align: 'end' as const,
1223
+ };
1224
+ }
1218
1225
  return {
1219
1226
  index: Math.max(0, currentEndIdx - 1),
1220
1227
  align: 'end' as const,
1221
1228
  };
1229
+ } else {
1230
+ return null;
1222
1231
  }
1223
1232
  }
1224
1233