@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.
- package/README.md +90 -12
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +174 -154
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +863 -742
- package/dist/index.mjs.map +1 -1
- package/dist/virtual-scroll.css +1 -1
- package/package.json +1 -1
- package/src/components/VirtualScroll.vue +30 -20
- package/src/composables/useVirtualScroll.ts +374 -800
- package/src/composables/useVirtualScrollSizes.ts +144 -142
- package/src/composables/useVirtualScrollbar.ts +16 -0
- package/src/extensions/all.ts +7 -0
- package/src/extensions/coordinate-scaling.ts +30 -0
- package/src/extensions/index.ts +88 -0
- package/src/extensions/infinite-loading.ts +47 -0
- package/src/extensions/prepend-restoration.ts +49 -0
- package/src/extensions/rtl.ts +42 -0
- package/src/extensions/snapping.ts +82 -0
- package/src/extensions/sticky.ts +43 -0
- package/src/types.ts +27 -7
- package/src/utils/scroll.ts +1 -1
- package/src/utils/virtual-scroll-logic.ts +44 -2
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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. */
|
package/src/utils/scroll.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|