@pdanpdan/virtual-scroll 0.5.0 → 0.6.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 +73 -174
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +192 -348
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1691 -2198
- package/dist/index.mjs.map +1 -1
- package/dist/virtual-scroll.css +2 -1
- package/package.json +4 -2
- package/src/components/VirtualScroll.test.ts +36 -0
- package/src/components/VirtualScroll.vue +25 -55
- package/src/composables/useVirtualScroll.ts +81 -145
- package/src/composables/useVirtualScrollbar.test.ts +14 -14
- package/src/composables/useVirtualScrollbar.ts +5 -0
- package/src/index.ts +7 -0
- package/src/types.ts +132 -170
- package/src/utils/scroll.test.ts +64 -10
- package/src/utils/scroll.ts +31 -0
- package/src/utils/virtual-scroll-logic.test.ts +48 -31
- package/src/utils/virtual-scroll-logic.ts +82 -49
package/dist/virtual-scroll.css
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
@layer components{.virtual-scrollbar-track{--vsi-scrollbar-bg:
|
|
1
|
+
@layer components{.virtual-scrollbar-track{--vsi-scrollbar-bg:var(--vs-scrollbar-bg,var(--lightningcss-light,#e6e6e6e6)var(--lightningcss-dark,#1e1e1ee6));--vsi-scrollbar-thumb-bg:var(--vs-scrollbar-thumb-bg,var(--lightningcss-light,#0000004d)var(--lightningcss-dark,#ffffff4d));--vsi-scrollbar-thumb-hover-bg:var(--vs-scrollbar-thumb-hover-bg,var(--lightningcss-light,#0009)var(--lightningcss-dark,#fff9));--vsi-scrollbar-radius:var(--vs-scrollbar-radius,4px);--vsi-scrollbar-size:var(--vs-scrollbar-size,8px);contain:layout;background-color:var(--vsi-scrollbar-bg);border-radius:var(--vsi-scrollbar-radius);z-index:30;-webkit-user-select:none;user-select:none;pointer-events:auto;transition:opacity .2s;position:absolute}.virtual-scrollbar-track.virtual-scrollbar-track--vertical{inline-size:var(--vsi-scrollbar-size);inset-block-start:2px;inset-inline-end:2px}.virtual-scrollbar-track.virtual-scrollbar-track--horizontal{block-size:var(--vsi-scrollbar-size);inset-block-end:2px;inset-inline-start:2px}.virtual-scrollbar-thumb{background-color:var(--vsi-scrollbar-thumb-bg);border-radius:var(--vsi-scrollbar-radius);touch-action:none;pointer-events:auto;cursor:pointer;position:absolute}.virtual-scrollbar-thumb:hover,.virtual-scrollbar-thumb:active,.virtual-scrollbar-thumb.virtual-scrollbar-thumb--active{background-color:var(--vsi-scrollbar-thumb-hover-bg)}.virtual-scrollbar-thumb.virtual-scrollbar-thumb--vertical{inline-size:100%}.virtual-scrollbar-thumb.virtual-scrollbar-thumb--horizontal{block-size:100%}.virtual-scroll-container[data-v-408dd72c]{outline-offset:1px;block-size:100%;inline-size:100%;position:relative}.virtual-scroll-container[data-v-408dd72c]:not(.virtual-scroll--window){overscroll-behavior:contain;overflow:auto}.virtual-scroll-container.virtual-scroll--table[data-v-408dd72c]{display:block}.virtual-scroll-container.virtual-scroll--hide-scrollbar[data-v-408dd72c]{scrollbar-width:none;-ms-overflow-style:none}.virtual-scroll-container.virtual-scroll--hide-scrollbar[data-v-408dd72c]::-webkit-scrollbar{display:none}.virtual-scroll-container.virtual-scroll--horizontal[data-v-408dd72c],.virtual-scroll-container.virtual-scroll--both[data-v-408dd72c]{white-space:nowrap}.virtual-scroll-scrollbar-container[data-v-408dd72c]{z-index:30;pointer-events:none;block-size:0;inline-size:100%;position:sticky;inset-block-start:0;inset-inline-start:0;overflow:visible}.virtual-scroll-scrollbar-viewport[data-v-408dd72c]{pointer-events:none;position:absolute;inset-block-start:0;inset-inline-start:0}.virtual-scroll-wrapper[data-v-408dd72c]{contain:layout;position:relative}:where(.virtual-scroll--hydrated>.virtual-scroll-wrapper>.virtual-scroll-item[data-v-408dd72c]){position:absolute;inset-block-start:0;inset-inline-start:0}.virtual-scroll-item[data-v-408dd72c]{box-sizing:border-box;will-change:transform;display:grid}.virtual-scroll-item:where(.virtual-scroll--debug)[data-v-408dd72c]{background-color:#ff00000d;outline:1px dashed #ff000080}.virtual-scroll-item:where(.virtual-scroll--debug)[data-v-408dd72c]:where(:hover){z-index:100;background-color:#ff00001a}.virtual-scroll-debug-info[data-v-408dd72c]{color:#fff;pointer-events:none;z-index:100;background:#000000b3;border-radius:4px;padding:2px 4px;font-family:monospace;font-size:10px;position:absolute;inset-block-start:2px;inset-inline-end:2px}.virtual-scroll-spacer[data-v-408dd72c]{pointer-events:none}.virtual-scroll-header[data-v-408dd72c],.virtual-scroll-footer[data-v-408dd72c]{z-index:20;position:relative}.virtual-scroll--sticky[data-v-408dd72c]{position:sticky}.virtual-scroll--sticky[data-v-408dd72c]:where(.virtual-scroll-header){box-sizing:border-box;min-inline-size:100%;inset-block-start:0;inset-inline-start:0}.virtual-scroll--sticky[data-v-408dd72c]:where(.virtual-scroll-footer){box-sizing:border-box;min-inline-size:100%;inset-block-end:0;inset-inline-start:0}.virtual-scroll--sticky[data-v-408dd72c]:where(.virtual-scroll-item){z-index:10}:is(tbody.virtual-scroll-wrapper,thead.virtual-scroll-header,tfoot.virtual-scroll-footer)[data-v-408dd72c]{min-inline-size:100%;display:inline-flex}:is(tbody.virtual-scroll-wrapper,thead.virtual-scroll-header,tfoot.virtual-scroll-footer)[data-v-408dd72c]>tr{min-inline-size:100%;display:inline-flex}:is(tbody.virtual-scroll-wrapper,thead.virtual-scroll-header,tfoot.virtual-scroll-footer)[data-v-408dd72c]>tr>:is(td,th){align-items:center;display:inline-block}}
|
|
2
|
+
/*$vite$:1*/
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pdanpdan/virtual-scroll",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"description": "A high-performance virtual scroll component for Vue 3",
|
|
6
6
|
"author": "",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"homepage": "https://github.com/pdanpdan/virtual-scroll#readme",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
|
-
"url": "https://github.com/pdanpdan/virtual-scroll",
|
|
11
|
+
"url": "git+https://github.com/pdanpdan/virtual-scroll.git",
|
|
12
12
|
"directory": "packages/virtual-scroll"
|
|
13
13
|
},
|
|
14
14
|
"bugs": {
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
"jsdelivr": "./dist/index.js",
|
|
39
39
|
"types": "./dist/index.d.ts",
|
|
40
40
|
"files": [
|
|
41
|
+
"!src/**/*.test.ts",
|
|
42
|
+
"!src/**/__tests__",
|
|
41
43
|
"dist",
|
|
42
44
|
"src"
|
|
43
45
|
],
|
|
@@ -2328,5 +2328,41 @@ describe('virtualScroll', () => {
|
|
|
2328
2328
|
|
|
2329
2329
|
expect(wrapper.findAll('.virtual-scroll-item').length).toBe(15);
|
|
2330
2330
|
});
|
|
2331
|
+
|
|
2332
|
+
describe('table virtualization', () => {
|
|
2333
|
+
it('correctly virtualizes when using table tags and constrained height', async () => {
|
|
2334
|
+
const items = Array.from({ length: 1000 }, (_, i) => ({ id: i }));
|
|
2335
|
+
const wrapper = mount(VirtualScroll, {
|
|
2336
|
+
props: {
|
|
2337
|
+
items,
|
|
2338
|
+
itemSize: 40,
|
|
2339
|
+
containerTag: 'table',
|
|
2340
|
+
wrapperTag: 'tbody',
|
|
2341
|
+
itemTag: 'tr',
|
|
2342
|
+
style: { height: '400px', display: 'block' },
|
|
2343
|
+
},
|
|
2344
|
+
slots: {
|
|
2345
|
+
item: '<td class="item">{{ index }}</td>',
|
|
2346
|
+
},
|
|
2347
|
+
});
|
|
2348
|
+
|
|
2349
|
+
await nextTick();
|
|
2350
|
+
// Since it's mounted in JSDOM, we need to mock clientHeight/clientWidth if they are 0
|
|
2351
|
+
const el = wrapper.element as HTMLElement;
|
|
2352
|
+
Object.defineProperty(el, 'clientHeight', { value: 400, configurable: true });
|
|
2353
|
+
Object.defineProperty(el, 'clientWidth', { value: 800, configurable: true });
|
|
2354
|
+
|
|
2355
|
+
// Trigger resize observation
|
|
2356
|
+
const vs = wrapper.vm as unknown as VirtualScrollInstance<MockItem>;
|
|
2357
|
+
vs.refresh();
|
|
2358
|
+
await nextTick();
|
|
2359
|
+
await nextTick();
|
|
2360
|
+
|
|
2361
|
+
// 400px height / 40px itemSize = 10 items + buffer
|
|
2362
|
+
const renderedCount = wrapper.findAll('tr.virtual-scroll-item').length;
|
|
2363
|
+
expect(renderedCount).toBeLessThan(30);
|
|
2364
|
+
expect(renderedCount).toBeGreaterThan(10);
|
|
2365
|
+
});
|
|
2366
|
+
});
|
|
2331
2367
|
});
|
|
2332
2368
|
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Features include sticky headers/footers, RTL support, custom scrollbars, and scroll restoration.
|
|
6
6
|
*/
|
|
7
7
|
import type {
|
|
8
|
+
ItemSlotProps,
|
|
8
9
|
RenderedItem,
|
|
9
10
|
ScrollAlignment,
|
|
10
11
|
ScrollbarSlotProps,
|
|
@@ -66,39 +67,7 @@ const slots = defineSlots<{
|
|
|
66
67
|
/**
|
|
67
68
|
* Scoped slot for rendering each individual item.
|
|
68
69
|
*/
|
|
69
|
-
item?: (props:
|
|
70
|
-
/** The original data item from the `items` array. */
|
|
71
|
-
item: T;
|
|
72
|
-
/** The original index of the item in the `items` array. */
|
|
73
|
-
index: number;
|
|
74
|
-
/**
|
|
75
|
-
* Information about the current visible range of columns (for grid mode).
|
|
76
|
-
* @see ColumnRange
|
|
77
|
-
*/
|
|
78
|
-
columnRange: {
|
|
79
|
-
/** Index of the first rendered column. */
|
|
80
|
-
start: number;
|
|
81
|
-
/** Index of the last rendered column (exclusive). */
|
|
82
|
-
end: number;
|
|
83
|
-
/** Pixel offset from the start of the row to the first rendered cell. */
|
|
84
|
-
padStart: number;
|
|
85
|
-
/** Pixel offset from the last rendered cell to the end of the row. */
|
|
86
|
-
padEnd: number;
|
|
87
|
-
};
|
|
88
|
-
/**
|
|
89
|
-
* Helper function to get the width of a specific column.
|
|
90
|
-
* Useful for setting consistent widths in grid mode.
|
|
91
|
-
*/
|
|
92
|
-
getColumnWidth: (index: number) => number;
|
|
93
|
-
/** Vertical gap between items. */
|
|
94
|
-
gap: number;
|
|
95
|
-
/** Horizontal gap between columns. */
|
|
96
|
-
columnGap: number;
|
|
97
|
-
/** Whether this item is configured to be sticky via `stickyIndices`. */
|
|
98
|
-
isSticky?: boolean | undefined;
|
|
99
|
-
/** Whether this item is currently in a sticky state (stuck at the top/start). */
|
|
100
|
-
isStickyActive?: boolean | undefined;
|
|
101
|
-
}) => VNodeChild;
|
|
70
|
+
item?: (props: ItemSlotProps<T>) => VNodeChild;
|
|
102
71
|
|
|
103
72
|
/**
|
|
104
73
|
* Content shown at the end of the list when the `loading` prop is true.
|
|
@@ -421,27 +390,21 @@ const extraResizeObserver = typeof window === 'undefined'
|
|
|
421
390
|
updateHostOffset();
|
|
422
391
|
});
|
|
423
392
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
393
|
+
function watchExtraRef(refEl: typeof headerRef, measuredValue: typeof measuredPaddingStart) {
|
|
394
|
+
watch(refEl, (newEl, oldEl) => {
|
|
395
|
+
if (oldEl) {
|
|
396
|
+
extraResizeObserver?.unobserve(oldEl);
|
|
397
|
+
}
|
|
398
|
+
if (newEl) {
|
|
399
|
+
extraResizeObserver?.observe(newEl);
|
|
400
|
+
} else {
|
|
401
|
+
measuredValue.value = 0;
|
|
402
|
+
}
|
|
403
|
+
}, { immediate: true });
|
|
404
|
+
}
|
|
434
405
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
extraResizeObserver?.unobserve(oldEl);
|
|
438
|
-
}
|
|
439
|
-
if (newEl) {
|
|
440
|
-
extraResizeObserver?.observe(newEl);
|
|
441
|
-
} else {
|
|
442
|
-
measuredPaddingEnd.value = 0;
|
|
443
|
-
}
|
|
444
|
-
}, { immediate: true });
|
|
406
|
+
watchExtraRef(headerRef, measuredPaddingStart);
|
|
407
|
+
watchExtraRef(footerRef, measuredPaddingEnd);
|
|
445
408
|
|
|
446
409
|
onMounted(() => {
|
|
447
410
|
if (hostRef.value) {
|
|
@@ -868,7 +831,7 @@ const containerStyle = computed(() => {
|
|
|
868
831
|
...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
|
|
869
832
|
};
|
|
870
833
|
|
|
871
|
-
if (showVirtualScrollbars.value) {
|
|
834
|
+
if (showVirtualScrollbars.value || !isWindowContainer.value) {
|
|
872
835
|
base.overflow = 'auto';
|
|
873
836
|
}
|
|
874
837
|
|
|
@@ -883,6 +846,7 @@ const containerStyle = computed(() => {
|
|
|
883
846
|
if (props.containerTag === 'table') {
|
|
884
847
|
return {
|
|
885
848
|
...base,
|
|
849
|
+
display: 'block',
|
|
886
850
|
minInlineSize: props.direction === 'vertical' ? '100%' : 'auto',
|
|
887
851
|
};
|
|
888
852
|
}
|
|
@@ -910,6 +874,7 @@ const verticalScrollbarProps = computed<ScrollbarSlotProps | null>(() => {
|
|
|
910
874
|
};
|
|
911
875
|
|
|
912
876
|
return {
|
|
877
|
+
axis: 'vertical',
|
|
913
878
|
positionPercent: verticalScrollbar.positionPercent.value,
|
|
914
879
|
viewportPercent: verticalScrollbar.viewportPercent.value,
|
|
915
880
|
thumbSizePercent: verticalScrollbar.thumbSizePercent.value,
|
|
@@ -941,6 +906,7 @@ const horizontalScrollbarProps = computed<ScrollbarSlotProps | null>(() => {
|
|
|
941
906
|
};
|
|
942
907
|
|
|
943
908
|
return {
|
|
909
|
+
axis: 'horizontal',
|
|
944
910
|
positionPercent: horizontalScrollbar.positionPercent.value,
|
|
945
911
|
viewportPercent: horizontalScrollbar.viewportPercent.value,
|
|
946
912
|
thumbSizePercent: horizontalScrollbar.thumbSizePercent.value,
|
|
@@ -998,7 +964,7 @@ const spacerStyle = computed(() => ({
|
|
|
998
964
|
*/
|
|
999
965
|
function getItemStyle(item: RenderedItem<T>) {
|
|
1000
966
|
const style = calculateItemStyle({
|
|
1001
|
-
containerTag: props.containerTag,
|
|
967
|
+
containerTag: props.containerTag || 'div',
|
|
1002
968
|
direction: props.direction,
|
|
1003
969
|
isHydrated: isHydrated.value,
|
|
1004
970
|
item,
|
|
@@ -1265,7 +1231,11 @@ defineExpose({
|
|
|
1265
1231
|
:column-gap="props.columnGap"
|
|
1266
1232
|
:is-sticky="renderedItem.isSticky"
|
|
1267
1233
|
:is-sticky-active="renderedItem.isStickyActive"
|
|
1234
|
+
:is-sticky-active-x="renderedItem.isStickyActiveX"
|
|
1235
|
+
:is-sticky-active-y="renderedItem.isStickyActiveY"
|
|
1236
|
+
:offset="renderedItem.offset"
|
|
1268
1237
|
/>
|
|
1238
|
+
|
|
1269
1239
|
<div v-if="isDebug" class="virtual-scroll-debug-info">
|
|
1270
1240
|
#{{ renderedItem.index }} ({{ Math.round(renderedItem.offset.x) }}, {{ Math.round(renderedItem.offset.y) }})
|
|
1271
1241
|
</div>
|
|
@@ -12,8 +12,13 @@ import type { MaybeRefOrGetter } from 'vue';
|
|
|
12
12
|
/* global ScrollToOptions */
|
|
13
13
|
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, reactive, ref, toValue, watch } from 'vue';
|
|
14
14
|
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_BUFFER,
|
|
17
|
+
DEFAULT_COLUMN_WIDTH,
|
|
18
|
+
DEFAULT_ITEM_SIZE,
|
|
19
|
+
} from '../types';
|
|
15
20
|
import { FenwickTree } from '../utils/fenwick-tree';
|
|
16
|
-
import { BROWSER_MAX_SIZE, getPaddingX, getPaddingY, isElement, isScrollableElement, isScrollToIndexOptions, isWindowLike } from '../utils/scroll';
|
|
21
|
+
import { BROWSER_MAX_SIZE, getPaddingX, getPaddingY, isElement, isScrollableElement, isScrollToIndexOptions, isWindowLike, scrollTo } from '../utils/scroll';
|
|
17
22
|
import {
|
|
18
23
|
calculateColumnRange,
|
|
19
24
|
calculateItemPosition,
|
|
@@ -26,20 +31,6 @@ import {
|
|
|
26
31
|
virtualToDisplay,
|
|
27
32
|
} from '../utils/virtual-scroll-logic';
|
|
28
33
|
|
|
29
|
-
export {
|
|
30
|
-
type RenderedItem,
|
|
31
|
-
type ScrollAlignment,
|
|
32
|
-
type ScrollAlignmentOptions,
|
|
33
|
-
type ScrollDetails,
|
|
34
|
-
type ScrollDirection,
|
|
35
|
-
type ScrollToIndexOptions,
|
|
36
|
-
type VirtualScrollProps,
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
export const DEFAULT_ITEM_SIZE = 40;
|
|
40
|
-
export const DEFAULT_COLUMN_WIDTH = 100;
|
|
41
|
-
export const DEFAULT_BUFFER = 5;
|
|
42
|
-
|
|
43
34
|
/**
|
|
44
35
|
* Composable for virtual scrolling logic.
|
|
45
36
|
* Handles calculation of visible items, scroll events, dynamic item sizes, and programmatic scrolling.
|
|
@@ -391,8 +382,6 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
|
|
|
391
382
|
stickyEndY: stickyEndY.value,
|
|
392
383
|
flowPaddingStartX: flowStartX.value,
|
|
393
384
|
flowPaddingStartY: flowStartY.value,
|
|
394
|
-
flowPaddingEndX: flowEndX.value,
|
|
395
|
-
flowPaddingEndY: flowEndY.value,
|
|
396
385
|
paddingStartX: paddingStartX.value,
|
|
397
386
|
paddingStartY: paddingStartY.value,
|
|
398
387
|
paddingEndX: paddingEndX.value,
|
|
@@ -425,36 +414,18 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
|
|
|
425
414
|
const scrollBehavior = isCorrection ? 'auto' : (behavior || 'smooth');
|
|
426
415
|
isProgrammaticScroll.value = true;
|
|
427
416
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
behavior: scrollBehavior,
|
|
437
|
-
};
|
|
438
|
-
|
|
439
|
-
if (colIndex !== null && colIndex !== undefined) {
|
|
440
|
-
scrollOptions.left = (isRtl.value ? finalX : Math.max(0, finalX));
|
|
441
|
-
}
|
|
442
|
-
if (rowIndex !== null && rowIndex !== undefined) {
|
|
443
|
-
scrollOptions.top = Math.max(0, finalY);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
if (typeof container.scrollTo === 'function') {
|
|
447
|
-
container.scrollTo(scrollOptions);
|
|
448
|
-
} else {
|
|
449
|
-
if (scrollOptions.left !== undefined) {
|
|
450
|
-
container.scrollLeft = scrollOptions.left;
|
|
451
|
-
}
|
|
452
|
-
if (scrollOptions.top !== undefined) {
|
|
453
|
-
container.scrollTop = scrollOptions.top;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
417
|
+
const scrollOptions: ScrollToOptions = {
|
|
418
|
+
behavior: scrollBehavior,
|
|
419
|
+
};
|
|
420
|
+
if (colIndex !== null && colIndex !== undefined) {
|
|
421
|
+
scrollOptions.left = isRtl.value ? finalX : Math.max(0, finalX);
|
|
422
|
+
}
|
|
423
|
+
if (rowIndex !== null && rowIndex !== undefined) {
|
|
424
|
+
scrollOptions.top = Math.max(0, finalY);
|
|
456
425
|
}
|
|
457
426
|
|
|
427
|
+
scrollTo(container, scrollOptions);
|
|
428
|
+
|
|
458
429
|
if (scrollBehavior === 'auto' || scrollBehavior === undefined) {
|
|
459
430
|
if (colIndex !== null && colIndex !== undefined) {
|
|
460
431
|
scrollX.value = (isRtl.value ? finalX : Math.max(0, finalX));
|
|
@@ -517,36 +488,18 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
|
|
|
517
488
|
: currentX;
|
|
518
489
|
const targetY = (displayTargetY !== null) ? displayTargetY : currentY;
|
|
519
490
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
behavior: options?.behavior || 'auto',
|
|
529
|
-
};
|
|
530
|
-
|
|
531
|
-
if (x !== null && x !== undefined) {
|
|
532
|
-
scrollOptions.left = targetX;
|
|
533
|
-
}
|
|
534
|
-
if (y !== null && y !== undefined) {
|
|
535
|
-
scrollOptions.top = targetY;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
if (typeof container.scrollTo === 'function') {
|
|
539
|
-
container.scrollTo(scrollOptions);
|
|
540
|
-
} else {
|
|
541
|
-
if (scrollOptions.left !== undefined) {
|
|
542
|
-
container.scrollLeft = scrollOptions.left;
|
|
543
|
-
}
|
|
544
|
-
if (scrollOptions.top !== undefined) {
|
|
545
|
-
container.scrollTop = scrollOptions.top;
|
|
546
|
-
}
|
|
547
|
-
}
|
|
491
|
+
const scrollOptions: ScrollToOptions = {
|
|
492
|
+
behavior: options?.behavior || 'auto',
|
|
493
|
+
};
|
|
494
|
+
if (x !== null && x !== undefined) {
|
|
495
|
+
scrollOptions.left = targetX;
|
|
496
|
+
}
|
|
497
|
+
if (y !== null && y !== undefined) {
|
|
498
|
+
scrollOptions.top = targetY;
|
|
548
499
|
}
|
|
549
500
|
|
|
501
|
+
scrollTo(container, scrollOptions);
|
|
502
|
+
|
|
550
503
|
if (options?.behavior === 'auto' || options?.behavior === undefined) {
|
|
551
504
|
if (x !== null && x !== undefined) {
|
|
552
505
|
scrollX.value = targetX;
|
|
@@ -1103,7 +1056,7 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
|
|
|
1103
1056
|
const originalX = x;
|
|
1104
1057
|
const originalY = y;
|
|
1105
1058
|
|
|
1106
|
-
const { isStickyActive, stickyOffset } = calculateStickyItem({
|
|
1059
|
+
const { isStickyActive, isStickyActiveX, isStickyActiveY, stickyOffset } = calculateStickyItem({
|
|
1107
1060
|
index: i,
|
|
1108
1061
|
isSticky,
|
|
1109
1062
|
direction: direction.value,
|
|
@@ -1118,18 +1071,18 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
|
|
|
1118
1071
|
fixedWidth: fixedColumnWidth.value,
|
|
1119
1072
|
gap: props.value.gap || 0,
|
|
1120
1073
|
columnGap: props.value.columnGap || 0,
|
|
1121
|
-
getItemQueryY:
|
|
1122
|
-
getItemQueryX:
|
|
1074
|
+
getItemQueryY: queryYCached,
|
|
1075
|
+
getItemQueryX: queryXCached,
|
|
1123
1076
|
});
|
|
1124
1077
|
|
|
1125
1078
|
const offsetX = isHydrated.value
|
|
1126
|
-
? (internalScrollX.value / scaleX.value + (
|
|
1127
|
-
: (
|
|
1079
|
+
? (internalScrollX.value / scaleX.value + (x + itemsStartVU_X - internalScrollX.value)) - wrapperStartDU_X
|
|
1080
|
+
: (x - ssrOffsetX);
|
|
1128
1081
|
const offsetY = isHydrated.value
|
|
1129
|
-
? (internalScrollY.value / scaleY.value + (
|
|
1130
|
-
: (
|
|
1131
|
-
const last = lastItemsMap.get(i);
|
|
1082
|
+
? (internalScrollY.value / scaleY.value + (y + itemsStartVU_Y - internalScrollY.value)) - wrapperStartDU_Y
|
|
1083
|
+
: (y - ssrOffsetY);
|
|
1132
1084
|
|
|
1085
|
+
const last = lastItemsMap.get(i);
|
|
1133
1086
|
if (
|
|
1134
1087
|
last
|
|
1135
1088
|
&& last.item === item
|
|
@@ -1139,6 +1092,8 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
|
|
|
1139
1092
|
&& last.size.height === height
|
|
1140
1093
|
&& last.isSticky === isSticky
|
|
1141
1094
|
&& last.isStickyActive === isStickyActive
|
|
1095
|
+
&& last.isStickyActiveX === isStickyActiveX
|
|
1096
|
+
&& last.isStickyActiveY === isStickyActiveY
|
|
1142
1097
|
&& last.stickyOffset.x === stickyOffset.x
|
|
1143
1098
|
&& last.stickyOffset.y === stickyOffset.y
|
|
1144
1099
|
) {
|
|
@@ -1153,10 +1108,9 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
|
|
|
1153
1108
|
originalY,
|
|
1154
1109
|
isSticky,
|
|
1155
1110
|
isStickyActive,
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
},
|
|
1111
|
+
isStickyActiveX,
|
|
1112
|
+
isStickyActiveY,
|
|
1113
|
+
stickyOffset,
|
|
1160
1114
|
});
|
|
1161
1115
|
}
|
|
1162
1116
|
}
|
|
@@ -1287,6 +1241,26 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
|
|
|
1287
1241
|
const processedRows = new Set<number>();
|
|
1288
1242
|
const processedCols = new Set<number>();
|
|
1289
1243
|
|
|
1244
|
+
const tryUpdateColumn = (colIdx: number, width: number) => {
|
|
1245
|
+
if (colIdx >= 0 && colIdx < (props.value.columnCount || 0) && !processedCols.has(colIdx)) {
|
|
1246
|
+
processedCols.add(colIdx);
|
|
1247
|
+
const oldW = columnSizes.get(colIdx);
|
|
1248
|
+
const targetW = width + columnGap;
|
|
1249
|
+
|
|
1250
|
+
if (!measuredColumns[ colIdx ] || Math.abs(oldW - targetW) > 0.1) {
|
|
1251
|
+
const d = targetW - oldW;
|
|
1252
|
+
if (Math.abs(d) > 0.1) {
|
|
1253
|
+
columnSizes.update(colIdx, d);
|
|
1254
|
+
needUpdate = true;
|
|
1255
|
+
if (colIdx < firstColIndex) {
|
|
1256
|
+
deltaX += d;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
measuredColumns[ colIdx ] = 1;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1290
1264
|
for (const { index, inlineSize, blockSize, element } of updates) {
|
|
1291
1265
|
// Ignore 0-size measurements as they usually indicate hidden/detached elements
|
|
1292
1266
|
if (inlineSize <= 0 && blockSize <= 0) {
|
|
@@ -1336,51 +1310,14 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
|
|
|
1336
1310
|
) {
|
|
1337
1311
|
const colIndexAttr = element.dataset.colIndex;
|
|
1338
1312
|
if (colIndexAttr != null) {
|
|
1339
|
-
|
|
1340
|
-
if (colIndex >= 0 && colIndex < (props.value.columnCount || 0) && !processedCols.has(colIndex)) {
|
|
1341
|
-
processedCols.add(colIndex);
|
|
1342
|
-
const oldW = columnSizes.get(colIndex);
|
|
1343
|
-
const targetW = inlineSize + columnGap;
|
|
1344
|
-
|
|
1345
|
-
if (!measuredColumns[ colIndex ] || Math.abs(oldW - targetW) > 0.1) {
|
|
1346
|
-
const d = targetW - oldW;
|
|
1347
|
-
if (Math.abs(d) > 0.1) {
|
|
1348
|
-
columnSizes.update(colIndex, d);
|
|
1349
|
-
needUpdate = true;
|
|
1350
|
-
if (colIndex < firstColIndex) {
|
|
1351
|
-
deltaX += d;
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
measuredColumns[ colIndex ] = 1;
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1313
|
+
tryUpdateColumn(Number.parseInt(colIndexAttr, 10), inlineSize);
|
|
1357
1314
|
} else {
|
|
1358
1315
|
// If the element is a row, try to find cells with data-col-index
|
|
1359
|
-
const cells = element.
|
|
1360
|
-
? [ element ]
|
|
1361
|
-
: Array.from(element.querySelectorAll('[data-col-index]')) as HTMLElement[];
|
|
1316
|
+
const cells = Array.from(element.querySelectorAll('[data-col-index]')) as HTMLElement[];
|
|
1362
1317
|
|
|
1363
1318
|
for (const child of cells) {
|
|
1364
1319
|
const colIndex = Number.parseInt(child.dataset.colIndex!, 10);
|
|
1365
|
-
|
|
1366
|
-
if (colIndex >= 0 && colIndex < (props.value.columnCount || 0) && !processedCols.has(colIndex)) {
|
|
1367
|
-
processedCols.add(colIndex);
|
|
1368
|
-
const rect = child.getBoundingClientRect();
|
|
1369
|
-
const w = rect.width;
|
|
1370
|
-
const oldW = columnSizes.get(colIndex);
|
|
1371
|
-
const targetW = w + columnGap;
|
|
1372
|
-
if (!measuredColumns[ colIndex ] || Math.abs(oldW - targetW) > 0.1) {
|
|
1373
|
-
const d = targetW - oldW;
|
|
1374
|
-
if (Math.abs(d) > 0.1) {
|
|
1375
|
-
columnSizes.update(colIndex, d);
|
|
1376
|
-
needUpdate = true;
|
|
1377
|
-
if (colIndex < firstColIndex) {
|
|
1378
|
-
deltaX += d;
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
measuredColumns[ colIndex ] = 1;
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1320
|
+
tryUpdateColumn(colIndex, child.getBoundingClientRect().width);
|
|
1384
1321
|
}
|
|
1385
1322
|
}
|
|
1386
1323
|
}
|
|
@@ -1470,8 +1407,6 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
|
|
|
1470
1407
|
stickyEndY: stickyEndY.value,
|
|
1471
1408
|
flowPaddingStartX: flowStartX.value,
|
|
1472
1409
|
flowPaddingStartY: flowStartY.value,
|
|
1473
|
-
flowPaddingEndX: flowEndX.value,
|
|
1474
|
-
flowPaddingEndY: flowEndY.value,
|
|
1475
1410
|
paddingStartX: paddingStartX.value,
|
|
1476
1411
|
paddingStartY: paddingStartY.value,
|
|
1477
1412
|
paddingEndX: paddingEndX.value,
|
|
@@ -1512,23 +1447,25 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
|
|
|
1512
1447
|
let directionInterval: ReturnType<typeof setInterval> | undefined;
|
|
1513
1448
|
|
|
1514
1449
|
const attachEvents = (container: HTMLElement | Window | null) => {
|
|
1515
|
-
if (
|
|
1450
|
+
if (typeof window === 'undefined') {
|
|
1516
1451
|
return;
|
|
1517
1452
|
}
|
|
1518
|
-
const
|
|
1453
|
+
const effectiveContainer = container || window;
|
|
1454
|
+
const scrollTarget = (effectiveContainer === window || (isElement(effectiveContainer) && effectiveContainer === document.documentElement)) ? document : effectiveContainer;
|
|
1455
|
+
|
|
1519
1456
|
scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
|
|
1520
1457
|
|
|
1521
1458
|
computedStyle = null;
|
|
1522
1459
|
updateDirection();
|
|
1523
1460
|
|
|
1524
|
-
if (isElement(
|
|
1461
|
+
if (isElement(effectiveContainer)) {
|
|
1525
1462
|
directionObserver = new MutationObserver(() => updateDirection());
|
|
1526
|
-
directionObserver.observe(
|
|
1463
|
+
directionObserver.observe(effectiveContainer, { attributes: true, attributeFilter: [ 'dir', 'style' ] });
|
|
1527
1464
|
}
|
|
1528
1465
|
|
|
1529
1466
|
directionInterval = setInterval(updateDirection, 1000);
|
|
1530
1467
|
|
|
1531
|
-
if (
|
|
1468
|
+
if (effectiveContainer === window) {
|
|
1532
1469
|
viewportWidth.value = document.documentElement.clientWidth;
|
|
1533
1470
|
viewportHeight.value = document.documentElement.clientHeight;
|
|
1534
1471
|
scrollX.value = window.scrollX;
|
|
@@ -1541,29 +1478,28 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
|
|
|
1541
1478
|
updateHostOffset();
|
|
1542
1479
|
};
|
|
1543
1480
|
window.addEventListener('resize', onResize);
|
|
1481
|
+
|
|
1544
1482
|
return () => {
|
|
1545
1483
|
scrollTarget.removeEventListener('scroll', handleScroll);
|
|
1546
1484
|
window.removeEventListener('resize', onResize);
|
|
1485
|
+
directionObserver?.disconnect();
|
|
1547
1486
|
clearInterval(directionInterval);
|
|
1548
1487
|
computedStyle = null;
|
|
1549
1488
|
};
|
|
1550
1489
|
} else {
|
|
1551
|
-
viewportWidth.value = (
|
|
1552
|
-
viewportHeight.value = (
|
|
1553
|
-
scrollX.value = (
|
|
1554
|
-
scrollY.value = (
|
|
1490
|
+
viewportWidth.value = (effectiveContainer as HTMLElement).clientWidth;
|
|
1491
|
+
viewportHeight.value = (effectiveContainer as HTMLElement).clientHeight;
|
|
1492
|
+
scrollX.value = (effectiveContainer as HTMLElement).scrollLeft;
|
|
1493
|
+
scrollY.value = (effectiveContainer as HTMLElement).scrollTop;
|
|
1555
1494
|
|
|
1556
|
-
resizeObserver = new ResizeObserver((
|
|
1495
|
+
resizeObserver = new ResizeObserver(() => {
|
|
1557
1496
|
updateDirection();
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
viewportHeight.value = (container as HTMLElement).clientHeight;
|
|
1562
|
-
updateHostOffset();
|
|
1563
|
-
}
|
|
1564
|
-
}
|
|
1497
|
+
viewportWidth.value = (effectiveContainer as HTMLElement).clientWidth;
|
|
1498
|
+
viewportHeight.value = (effectiveContainer as HTMLElement).clientHeight;
|
|
1499
|
+
updateHostOffset();
|
|
1565
1500
|
});
|
|
1566
|
-
resizeObserver.observe(
|
|
1501
|
+
resizeObserver.observe(effectiveContainer as HTMLElement);
|
|
1502
|
+
|
|
1567
1503
|
return () => {
|
|
1568
1504
|
scrollTarget.removeEventListener('scroll', handleScroll);
|
|
1569
1505
|
resizeObserver?.disconnect();
|
|
@@ -440,9 +440,7 @@ describe('useVirtualScrollbar', () => {
|
|
|
440
440
|
await thumb.trigger('mousedown');
|
|
441
441
|
expect(scrollToOffset).not.toHaveBeenCalled();
|
|
442
442
|
});
|
|
443
|
-
});
|
|
444
443
|
|
|
445
|
-
describe('edge cases & cleanup', () => {
|
|
446
444
|
it('handles scrollabletrackrange <= 0', async () => {
|
|
447
445
|
const scrollToOffset = vi.fn();
|
|
448
446
|
const { wrapper } = setup({
|
|
@@ -469,18 +467,6 @@ describe('useVirtualScrollbar', () => {
|
|
|
469
467
|
expect(scrollToOffset).not.toHaveBeenCalled();
|
|
470
468
|
});
|
|
471
469
|
|
|
472
|
-
it('handles missing track element during move', () => {
|
|
473
|
-
const { wrapper } = setup({
|
|
474
|
-
axis: 'vertical',
|
|
475
|
-
totalSize: 1000,
|
|
476
|
-
position: 0,
|
|
477
|
-
viewportSize: 200,
|
|
478
|
-
scrollToOffset: vi.fn(),
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
wrapper.unmount();
|
|
482
|
-
});
|
|
483
|
-
|
|
484
470
|
it('handles move with missing parent track', async () => {
|
|
485
471
|
const scrollToOffset = vi.fn();
|
|
486
472
|
const { wrapper } = setup({
|
|
@@ -523,4 +509,18 @@ describe('useVirtualScrollbar', () => {
|
|
|
523
509
|
expect(thumb.element.releasePointerCapture).not.toHaveBeenCalled();
|
|
524
510
|
});
|
|
525
511
|
});
|
|
512
|
+
|
|
513
|
+
describe('lifecycle', () => {
|
|
514
|
+
it('handles missing track element during move', () => {
|
|
515
|
+
const { wrapper } = setup({
|
|
516
|
+
axis: 'vertical',
|
|
517
|
+
totalSize: 1000,
|
|
518
|
+
position: 0,
|
|
519
|
+
viewportSize: 200,
|
|
520
|
+
scrollToOffset: vi.fn(),
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
wrapper.unmount();
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
526
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pdanpdan/virtual-scroll
|
|
3
|
+
*
|
|
4
|
+
* A high-performance, flexible virtual scrolling library for Vue 3.
|
|
5
|
+
* Supports massive lists and grids with coordinate scaling to bypass browser scroll limits.
|
|
6
|
+
*/
|
|
7
|
+
|
|
1
8
|
export { default as VirtualScroll } from './components/VirtualScroll.vue';
|
|
2
9
|
export { default as VirtualScrollbar } from './components/VirtualScrollbar.vue';
|
|
3
10
|
export * from './composables/useVirtualScroll';
|