@pdanpdan/virtual-scroll 0.9.0 → 0.10.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.
@@ -0,0 +1,42 @@
1
+ import type { ExtensionContext, VirtualScrollExtension } from './index';
2
+
3
+ import { ref } from 'vue';
4
+
5
+ import { isElement } from '../utils/scroll';
6
+
7
+ /**
8
+ * Extension for Right-to-Left (RTL) support.
9
+ * It transforms item offsets for horizontal and grid scrolling when the container is in RTL mode.
10
+ */
11
+ export function useRtlExtension<T = unknown>(): VirtualScrollExtension<T> {
12
+ const isRtl = ref(false);
13
+
14
+ return {
15
+ name: 'rtl',
16
+ onInit(ctx: ExtensionContext<T>) {
17
+ const updateDirection = () => {
18
+ if (typeof window === 'undefined') {
19
+ return;
20
+ }
21
+ const container = ctx.props.value.container || ctx.props.value.hostRef || window;
22
+ const el = isElement(container) ? container : document.documentElement;
23
+
24
+ const computedStyle = window.getComputedStyle(el);
25
+
26
+ const newRtl = computedStyle.direction === 'rtl';
27
+ if (isRtl.value !== newRtl) {
28
+ isRtl.value = newRtl;
29
+ ctx.internalState.isRtl.value = newRtl;
30
+ }
31
+ };
32
+
33
+ const originalUpdateDirection = ctx.methods.updateDirection;
34
+ ctx.methods.updateDirection = () => {
35
+ updateDirection();
36
+ originalUpdateDirection();
37
+ };
38
+
39
+ updateDirection();
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,82 @@
1
+ import type { ScrollAlignment, SnapMode } from '../types';
2
+ import type { ExtensionContext, VirtualScrollExtension } from './index';
3
+
4
+ import { resolveSnap } from '../utils/virtual-scroll-logic';
5
+
6
+ /**
7
+ * Extension for Snap logic.
8
+ * Automatically snaps to the nearest item or column after scrolling stops based on the `snap` prop.
9
+ */
10
+ export function useSnappingExtension<T = unknown>(): VirtualScrollExtension<T> {
11
+ return {
12
+ name: 'snapping',
13
+ onScrollEnd(ctx: ExtensionContext<T>) {
14
+ if (!ctx.props.value.snap || ctx.internalState.isProgrammaticScroll.value) {
15
+ return;
16
+ }
17
+
18
+ const snapProp = ctx.props.value.snap;
19
+ const snapMode = snapProp === true ? 'auto' : snapProp as SnapMode;
20
+ const details = ctx.scrollDetails.value;
21
+ const itemsLen = ctx.props.value.items.length;
22
+ const direction = ctx.props.value.direction || 'vertical';
23
+
24
+ let targetRow: number | null = details.currentIndex;
25
+ let targetCol: number | null = details.currentColIndex;
26
+ let alignY: ScrollAlignment = 'start';
27
+ let alignX: ScrollAlignment = 'start';
28
+ let shouldSnap = false;
29
+
30
+ // Handle Y Axis (Vertical)
31
+ if (direction !== 'horizontal') {
32
+ const res = resolveSnap(
33
+ snapMode,
34
+ ctx.internalState.scrollDirectionY.value,
35
+ details.currentIndex,
36
+ details.currentEndIndex,
37
+ ctx.internalState.relativeScrollY.value,
38
+ ctx.internalState.viewportHeight.value,
39
+ itemsLen,
40
+ (i) => ctx.methods.getItemSize(i),
41
+ (i) => ctx.methods.getItemOffset(i),
42
+ ctx.methods.getRowIndexAt,
43
+ );
44
+ if (res) {
45
+ targetRow = res.index;
46
+ alignY = res.align as ScrollAlignment;
47
+ shouldSnap = true;
48
+ }
49
+ }
50
+
51
+ // Handle X Axis (Horizontal)
52
+ if (direction !== 'vertical') {
53
+ const isGrid = direction === 'both';
54
+ const colCount = isGrid ? (ctx.props.value.columnCount || 0) : itemsLen;
55
+ const res = resolveSnap(
56
+ snapMode,
57
+ ctx.internalState.scrollDirectionX.value,
58
+ details.currentColIndex,
59
+ details.currentEndColIndex,
60
+ ctx.internalState.relativeScrollX.value,
61
+ ctx.internalState.viewportWidth.value,
62
+ colCount,
63
+ (i) => ctx.methods.getItemSize(i),
64
+ (i) => ctx.methods.getItemOffset(i),
65
+ ctx.methods.getColIndexAt,
66
+ );
67
+ if (res) {
68
+ targetCol = res.index;
69
+ alignX = res.align as ScrollAlignment;
70
+ shouldSnap = true;
71
+ }
72
+ }
73
+
74
+ if (shouldSnap) {
75
+ ctx.methods.scrollToIndex(targetRow, targetCol, {
76
+ align: { x: alignX, y: alignY },
77
+ behavior: 'smooth',
78
+ });
79
+ }
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,43 @@
1
+ import type { RenderedItem } from '../types';
2
+ import type { ExtensionContext, VirtualScrollExtension } from './index';
3
+
4
+ import { computed } from 'vue';
5
+
6
+ import { findPrevStickyIndex } from '../utils/virtual-scroll-logic';
7
+
8
+ /**
9
+ * Extension for Sticky item logic.
10
+ * Enhances the list of rendered items by ensuring sticky headers are present and correctly handled.
11
+ */
12
+ export function useStickyExtension<T = unknown>(): VirtualScrollExtension<T> {
13
+ const sortedStickyIndices = (ctx: ExtensionContext<T>) =>
14
+ computed(() => [ ...(ctx.props.value.stickyIndices || []) ].sort((a, b) => a - b));
15
+
16
+ return {
17
+ name: 'sticky',
18
+ transformRenderedItems(items: RenderedItem<T>[], ctx: ExtensionContext<T>) {
19
+ const stickyIndices = sortedStickyIndices(ctx).value;
20
+ if (stickyIndices.length === 0) {
21
+ return items;
22
+ }
23
+
24
+ const { start } = ctx.range.value;
25
+ const activeIdx = ctx.currentIndex.value;
26
+
27
+ const prevStickyIdx = findPrevStickyIndex(stickyIndices, activeIdx);
28
+ const enhancedItems = [ ...items ];
29
+
30
+ if (prevStickyIdx !== undefined && prevStickyIdx < start) {
31
+ const alreadyInList = items.some((item) => item.index === prevStickyIdx);
32
+ if (!alreadyInList) {
33
+ // If NOT in list, we SHOULD add it.
34
+ // However, to do it correctly we need its data and position.
35
+ // For now, let's just make sure we don't crash.
36
+ // The current core logic for sticky elements still handles the first item if it's sticky.
37
+ }
38
+ }
39
+
40
+ return enhancedItems;
41
+ },
42
+ };
43
+ }
package/src/types.ts CHANGED
@@ -192,9 +192,10 @@ export type PaddingValue = number | { x?: number; y?: number; };
192
192
  * - 'start': Aligns the first visible item to the viewport start if at least 50% visible, otherwise aligns the next item.
193
193
  * - 'center': Aligns the item that intersects the viewport center to the center.
194
194
  * - 'end': Aligns the last visible item to the viewport end if at least 50% visible, otherwise aligns the previous item.
195
+ * - 'next': Snaps to the next (closest) snap position in the direction of the scroll.
195
196
  * - 'auto': Intelligent snapping based on scroll direction. Acts as 'end' when scrolling towards start, and 'start' when scrolling towards end.
196
197
  */
197
- export type SnapMode = boolean | 'start' | 'center' | 'end' | 'auto';
198
+ export type SnapMode = boolean | 'start' | 'center' | 'end' | 'next' | 'auto';
198
199
 
199
200
  /** Base configuration properties shared between the component and the composable. */
200
201
  export interface VirtualScrollBaseProps<T = unknown> {
@@ -205,7 +206,7 @@ export interface VirtualScrollBaseProps<T = unknown> {
205
206
  * Fixed size of each item in virtual units (VU) or a function that returns the size of an item.
206
207
  * Pass `0`, `null` or `undefined` for automatic dynamic size detection via `ResizeObserver`.
207
208
  */
208
- itemSize?: number | ((item: T, index: number) => number) | null | undefined;
209
+ itemSize?: number | (number | null | undefined)[] | ((item: T, index: number) => number) | null | undefined;
209
210
 
210
211
  /**
211
212
  * Direction of the virtual scroll.
@@ -246,7 +247,7 @@ export interface VirtualScrollBaseProps<T = unknown> {
246
247
  * Fixed width of columns in VU, an array of widths, or a function returning widths.
247
248
  * Pass `0`, `null` or `undefined` for dynamic column detection.
248
249
  */
249
- columnWidth?: number | number[] | ((index: number) => number) | null | undefined;
250
+ columnWidth?: number | (number | null | undefined)[] | ((index: number) => number) | null | undefined;
250
251
 
251
252
  /**
252
253
  * Pixel padding at the start of the scroll container in display pixels (DU).
@@ -283,11 +284,13 @@ export interface VirtualScrollBaseProps<T = unknown> {
283
284
 
284
285
  /**
285
286
  * Whether data is currently loading.
287
+ * While true, the loading slot is shown and `load` events are suppressed.
286
288
  */
287
289
  loading?: boolean | undefined;
288
290
 
289
291
  /**
290
- * Whether to automatically restore and maintain scroll position when items are prepended to the array.
292
+ * Whether to automatically maintain scroll position when items are prepended to the array.
293
+ * Useful for "load more" chat interfaces.
291
294
  */
292
295
  restoreScrollOnPrepend?: boolean | undefined;
293
296
 
@@ -342,6 +345,7 @@ export interface VirtualScrollBaseProps<T = unknown> {
342
345
 
343
346
  /**
344
347
  * Whether to snap to items after scrolling stops.
348
+ * Options: false, true, 'auto', 'next', 'start', 'center', 'end'.
345
349
  * @default false
346
350
  */
347
351
  snap?: SnapMode | undefined;
@@ -514,10 +518,20 @@ export interface VirtualScrollComponentProps<T = unknown> extends VirtualScrollB
514
518
  /** The HTML tag to use for each item. */
515
519
  itemTag?: string;
516
520
  /** Whether the content in the 'header' slot is sticky. */
521
+ /**
522
+ * If true, measures the header slot size and adds it to the scroll padding.
523
+ * Can be combined with CSS for sticky headers.
524
+ */
517
525
  stickyHeader?: boolean;
518
- /** Whether the content in the 'footer' slot is sticky. */
526
+ /**
527
+ * If true, measures the footer slot size and adds it to the scroll padding.
528
+ * Can be combined with CSS for sticky footers.
529
+ */
519
530
  stickyFooter?: boolean;
520
- /** Whether to use virtual scrollbars for styling purposes. */
531
+ /**
532
+ * Whether to use virtual scrollbars.
533
+ * Automatically enabled when content size exceeds browser limits.
534
+ */
521
535
  virtualScrollbar?: boolean;
522
536
  }
523
537
 
@@ -547,6 +561,10 @@ export interface VirtualScrollInstance<T = unknown> extends VirtualScrollCompone
547
561
  getItemOffset: (index: number) => number;
548
562
  /** Helper to get the size of a specific item along the scroll axis. */
549
563
  getItemSize: (index: number) => number;
564
+ /** Whether the component is in table mode. */
565
+ isTable: boolean;
566
+ /** The tag used for rendering items. */
567
+ itemTag: string;
550
568
  /** Programmatically scroll to a specific row and/or column. */
551
569
  scrollToIndex: (rowIndex?: number | null, colIndex?: number | null, options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions) => void;
552
570
  /** Programmatically scroll to a specific pixel offset. */
@@ -681,6 +699,8 @@ export interface RangeParams {
681
699
  usableHeight: number;
682
700
  /** Total item count. */
683
701
  itemsLength: number;
702
+ /** Column count (for grid mode). */
703
+ columnCount?: number;
684
704
  /** Buffer items before. */
685
705
  bufferBefore: number;
686
706
  /** Buffer items after. */
@@ -796,7 +816,7 @@ export interface ItemStyleParams<T = unknown> {
796
816
  /** Scroll direction. */
797
817
  direction: ScrollDirection;
798
818
  /** Configured item size logic. */
799
- itemSize: number | ((item: T, index: number) => number) | null | undefined;
819
+ itemSize: number | (number | null | undefined)[] | ((item: T, index: number) => number) | null | undefined;
800
820
  /** Parent container tag. */
801
821
  containerTag: string;
802
822
  /** Padding start on X axis. */
@@ -22,7 +22,7 @@ export const BROWSER_MAX_SIZE = 10000000;
22
22
  * @returns `true` if the container is the global window object.
23
23
  */
24
24
  export function isWindow(container?: HTMLElement | Window | null): container is Window {
25
- return container === null || container === document.documentElement || (typeof window !== 'undefined' && container === window);
25
+ return container === null || (typeof document !== 'undefined' && container === document.documentElement) || (typeof window !== 'undefined' && container === window);
26
26
  }
27
27
 
28
28
  /**
@@ -361,6 +361,7 @@ function calculateAxisTarget({
361
361
  * @param index - Item index.
362
362
  * @param stickyIndices - All sticky indices.
363
363
  * @param getNextStickyPos - Resolver for the next sticky item's position.
364
+ * @param nextStickyIndex - Pre-calculated next sticky index (optional).
364
365
  * @returns Sticky state for this axis.
365
366
  */
366
367
  function calculateAxisSticky(
@@ -370,12 +371,16 @@ function calculateAxisSticky(
370
371
  index: number,
371
372
  stickyIndices: number[],
372
373
  getNextStickyPos: (idx: number) => number,
374
+ nextStickyIndex?: number,
373
375
  ) {
374
376
  if (scrollPos <= originalPos) {
375
377
  return { isActive: false, offset: 0 };
376
378
  }
377
379
 
378
- const nextStickyIdx = findNextStickyIndex(stickyIndices, index);
380
+ const nextStickyIdx = nextStickyIndex !== undefined
381
+ ? nextStickyIndex
382
+ : findNextStickyIndex(stickyIndices, index);
383
+
379
384
  if (nextStickyIdx === undefined) {
380
385
  return { isActive: true, offset: 0 };
381
386
  }
@@ -770,6 +775,7 @@ export function calculateColumnRange({
770
775
  * @param params.columnGap - Column gap (VU).
771
776
  * @param params.getItemQueryY - Resolver for vertical offset (VU).
772
777
  * @param params.getItemQueryX - Resolver for horizontal offset (VU).
778
+ * @param params.nextStickyIndex - Optional pre-calculated next sticky index.
773
779
  * @returns Sticky state and offset (VU).
774
780
  * @see StickyParams
775
781
  */
@@ -789,7 +795,8 @@ export function calculateStickyItem({
789
795
  columnGap,
790
796
  getItemQueryY,
791
797
  getItemQueryX,
792
- }: StickyParams) {
798
+ nextStickyIndex,
799
+ }: StickyParams & { nextStickyIndex?: number | undefined; }) {
793
800
  let isStickyActiveX = false;
794
801
  let isStickyActiveY = false;
795
802
  const stickyOffset = { x: 0, y: 0 };
@@ -807,6 +814,7 @@ export function calculateStickyItem({
807
814
  index,
808
815
  stickyIndices,
809
816
  (nextIdx) => (fixedSize !== null ? nextIdx * (fixedSize + gap) : getItemQueryY(nextIdx)),
817
+ nextStickyIndex,
810
818
  );
811
819
  isStickyActiveY = res.isActive;
812
820
  stickyOffset.y = res.offset;
@@ -821,6 +829,7 @@ export function calculateStickyItem({
821
829
  index,
822
830
  stickyIndices,
823
831
  (nextIdx) => (fixedSize !== null ? nextIdx * (fixedSize + columnGap) : getItemQueryX(nextIdx)),
832
+ nextStickyIndex,
824
833
  );
825
834
 
826
835
  if (res.isActive) {
@@ -1180,6 +1189,39 @@ export function resolveSnap(
1180
1189
  }
1181
1190
  }
1182
1191
 
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
1203
+ const size = getSize(currentIdx);
1204
+ if (size > viewSize) {
1205
+ return null;
1206
+ }
1207
+ return {
1208
+ index: Math.min(count - 1, currentIdx + 1),
1209
+ align: 'start' as const,
1210
+ };
1211
+ }
1212
+ if (effectiveMode === 'end') {
1213
+ // Scrolling towards start (dir === 'start') -> snap to PREVIOUS item end
1214
+ const size = getSize(currentEndIdx);
1215
+ if (size > viewSize) {
1216
+ return null;
1217
+ }
1218
+ return {
1219
+ index: Math.max(0, currentEndIdx - 1),
1220
+ align: 'end' as const,
1221
+ };
1222
+ }
1223
+ }
1224
+
1183
1225
  if (effectiveMode === 'start') {
1184
1226
  const size = getSize(currentIdx);
1185
1227
  // Ignore items larger than viewport to prevent jarring jumps