@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
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from './virtual-scroll-logic';
|
|
14
14
|
|
|
15
15
|
describe('virtual-scroll-logic', () => {
|
|
16
|
-
describe('
|
|
16
|
+
describe('calculate total size', () => {
|
|
17
17
|
it('calculates vertical total size with fixed size', () => {
|
|
18
18
|
const result = calculateTotalSize({
|
|
19
19
|
columnCount: 0,
|
|
@@ -407,7 +407,7 @@ describe('virtual-scroll-logic', () => {
|
|
|
407
407
|
});
|
|
408
408
|
});
|
|
409
409
|
|
|
410
|
-
describe('
|
|
410
|
+
describe('calculate range', () => {
|
|
411
411
|
it('calculates vertical range with dynamic size', () => {
|
|
412
412
|
const result = calculateRange({
|
|
413
413
|
bufferAfter: 0,
|
|
@@ -519,7 +519,7 @@ describe('virtual-scroll-logic', () => {
|
|
|
519
519
|
});
|
|
520
520
|
});
|
|
521
521
|
|
|
522
|
-
describe('
|
|
522
|
+
describe('calculate scroll target', () => {
|
|
523
523
|
it('calculates target for horizontal end alignment', () => {
|
|
524
524
|
const result = calculateScrollTarget({
|
|
525
525
|
scaleX: 1,
|
|
@@ -1465,30 +1465,30 @@ describe('virtual-scroll-logic', () => {
|
|
|
1465
1465
|
expect(result.effectiveAlignY).toBe('start');
|
|
1466
1466
|
});
|
|
1467
1467
|
|
|
1468
|
-
it('
|
|
1468
|
+
it('does not account for non-sticky header (flowpaddingstarty) in scroll target calculation', () => {
|
|
1469
1469
|
const params = {
|
|
1470
1470
|
scaleX: 1,
|
|
1471
1471
|
scaleY: 1,
|
|
1472
1472
|
hostOffsetX: 0,
|
|
1473
1473
|
hostOffsetY: 0,
|
|
1474
|
-
rowIndex:
|
|
1474
|
+
rowIndex: 10,
|
|
1475
1475
|
viewportWidth: 500,
|
|
1476
1476
|
viewportHeight: 500,
|
|
1477
1477
|
colIndex: null,
|
|
1478
|
-
options: '
|
|
1479
|
-
itemsLength:
|
|
1478
|
+
options: 'end' as const,
|
|
1479
|
+
itemsLength: 100,
|
|
1480
1480
|
columnCount: 0,
|
|
1481
1481
|
direction: 'vertical' as const,
|
|
1482
1482
|
usableWidth: 1000,
|
|
1483
|
-
usableHeight:
|
|
1483
|
+
usableHeight: 1000,
|
|
1484
1484
|
totalWidth: 1000,
|
|
1485
|
-
totalHeight:
|
|
1485
|
+
totalHeight: 10000,
|
|
1486
1486
|
gap: 0,
|
|
1487
1487
|
columnGap: 0,
|
|
1488
1488
|
fixedSize: 100,
|
|
1489
1489
|
fixedWidth: null,
|
|
1490
1490
|
relativeScrollX: 0,
|
|
1491
|
-
relativeScrollY:
|
|
1491
|
+
relativeScrollY: 0,
|
|
1492
1492
|
getItemSizeY: () => 100,
|
|
1493
1493
|
getItemSizeX: () => 1000,
|
|
1494
1494
|
getItemQueryY: (idx: number) => idx * 100,
|
|
@@ -1496,37 +1496,37 @@ describe('virtual-scroll-logic', () => {
|
|
|
1496
1496
|
getColumnSize: () => 0,
|
|
1497
1497
|
getColumnQuery: () => 0,
|
|
1498
1498
|
stickyIndices: [],
|
|
1499
|
+
flowPaddingStartY: 150,
|
|
1499
1500
|
};
|
|
1500
1501
|
|
|
1501
1502
|
const result = calculateScrollTarget(params);
|
|
1502
|
-
expect(result.targetY).toBe(
|
|
1503
|
-
expect(result.effectiveAlignY).toBe('end');
|
|
1503
|
+
expect(result.targetY).toBe(750);
|
|
1504
1504
|
});
|
|
1505
1505
|
|
|
1506
|
-
it('
|
|
1506
|
+
it('aligns to end if partially visible at bottom (forward scroll effect)', () => {
|
|
1507
1507
|
const params = {
|
|
1508
1508
|
scaleX: 1,
|
|
1509
1509
|
scaleY: 1,
|
|
1510
1510
|
hostOffsetX: 0,
|
|
1511
1511
|
hostOffsetY: 0,
|
|
1512
|
-
rowIndex:
|
|
1512
|
+
rowIndex: 150,
|
|
1513
1513
|
viewportWidth: 500,
|
|
1514
1514
|
viewportHeight: 500,
|
|
1515
1515
|
colIndex: null,
|
|
1516
|
-
options: '
|
|
1517
|
-
itemsLength:
|
|
1516
|
+
options: 'auto' as const,
|
|
1517
|
+
itemsLength: 1000,
|
|
1518
1518
|
columnCount: 0,
|
|
1519
1519
|
direction: 'vertical' as const,
|
|
1520
1520
|
usableWidth: 1000,
|
|
1521
|
-
usableHeight:
|
|
1521
|
+
usableHeight: 800,
|
|
1522
1522
|
totalWidth: 1000,
|
|
1523
|
-
totalHeight:
|
|
1523
|
+
totalHeight: 100000,
|
|
1524
1524
|
gap: 0,
|
|
1525
1525
|
columnGap: 0,
|
|
1526
1526
|
fixedSize: 100,
|
|
1527
1527
|
fixedWidth: null,
|
|
1528
1528
|
relativeScrollX: 0,
|
|
1529
|
-
relativeScrollY:
|
|
1529
|
+
relativeScrollY: 14250,
|
|
1530
1530
|
getItemSizeY: () => 100,
|
|
1531
1531
|
getItemSizeX: () => 1000,
|
|
1532
1532
|
getItemQueryY: (idx: number) => idx * 100,
|
|
@@ -1534,12 +1534,11 @@ describe('virtual-scroll-logic', () => {
|
|
|
1534
1534
|
getColumnSize: () => 0,
|
|
1535
1535
|
getColumnQuery: () => 0,
|
|
1536
1536
|
stickyIndices: [],
|
|
1537
|
-
flowPaddingStartY: 150,
|
|
1538
|
-
flowPaddingEndY: 200,
|
|
1539
1537
|
};
|
|
1540
1538
|
|
|
1541
1539
|
const result = calculateScrollTarget(params);
|
|
1542
|
-
expect(result.targetY).toBe(
|
|
1540
|
+
expect(result.targetY).toBe(14600);
|
|
1541
|
+
expect(result.effectiveAlignY).toBe('end');
|
|
1543
1542
|
});
|
|
1544
1543
|
|
|
1545
1544
|
it('aligns large item correctly when scrolling forward (minimal movement)', () => {
|
|
@@ -1991,7 +1990,7 @@ describe('virtual-scroll-logic', () => {
|
|
|
1991
1990
|
});
|
|
1992
1991
|
});
|
|
1993
1992
|
|
|
1994
|
-
describe('
|
|
1993
|
+
describe('calculate column range', () => {
|
|
1995
1994
|
it('calculates column range with dynamic width and 0 columns', () => {
|
|
1996
1995
|
const result = calculateColumnRange({
|
|
1997
1996
|
colBuffer: 0,
|
|
@@ -2176,7 +2175,7 @@ describe('virtual-scroll-logic', () => {
|
|
|
2176
2175
|
});
|
|
2177
2176
|
});
|
|
2178
2177
|
|
|
2179
|
-
describe('
|
|
2178
|
+
describe('calculate item position', () => {
|
|
2180
2179
|
it('calculates position for vertical item with fixed size', () => {
|
|
2181
2180
|
const result = calculateItemPosition({
|
|
2182
2181
|
columnGap: 0,
|
|
@@ -2278,7 +2277,7 @@ describe('virtual-scroll-logic', () => {
|
|
|
2278
2277
|
});
|
|
2279
2278
|
});
|
|
2280
2279
|
|
|
2281
|
-
describe('
|
|
2280
|
+
describe('calculate sticky item', () => {
|
|
2282
2281
|
it('calculates sticky offset when pushing (vertical, dynamic size)', () => {
|
|
2283
2282
|
const result = calculateStickyItem({
|
|
2284
2283
|
columnGap: 0,
|
|
@@ -2374,7 +2373,7 @@ describe('virtual-scroll-logic', () => {
|
|
|
2374
2373
|
it('calculates sticky active state for both directions (horizontal first)', () => {
|
|
2375
2374
|
const result = calculateStickyItem({
|
|
2376
2375
|
columnGap: 0,
|
|
2377
|
-
direction: '
|
|
2376
|
+
direction: 'horizontal',
|
|
2378
2377
|
fixedSize: 50,
|
|
2379
2378
|
fixedWidth: 100,
|
|
2380
2379
|
gap: 0,
|
|
@@ -2391,6 +2390,7 @@ describe('virtual-scroll-logic', () => {
|
|
|
2391
2390
|
width: 100,
|
|
2392
2391
|
});
|
|
2393
2392
|
expect(result.isStickyActive).toBe(true);
|
|
2393
|
+
expect(result.isStickyActiveX).toBe(true);
|
|
2394
2394
|
expect(result.stickyOffset.x).toBe(0);
|
|
2395
2395
|
expect(result.stickyOffset.y).toBe(0);
|
|
2396
2396
|
});
|
|
@@ -2579,7 +2579,7 @@ describe('virtual-scroll-logic', () => {
|
|
|
2579
2579
|
});
|
|
2580
2580
|
});
|
|
2581
2581
|
|
|
2582
|
-
describe('
|
|
2582
|
+
describe('calculate item style', () => {
|
|
2583
2583
|
it('calculates style for table container', () => {
|
|
2584
2584
|
const result = calculateItemStyle({
|
|
2585
2585
|
containerTag: 'table',
|
|
@@ -2660,7 +2660,7 @@ describe('virtual-scroll-logic', () => {
|
|
|
2660
2660
|
paddingStartY: 10,
|
|
2661
2661
|
});
|
|
2662
2662
|
expect(result.insetBlockStart).toBe('10px');
|
|
2663
|
-
expect(result.insetInlineStart).
|
|
2663
|
+
expect(result.insetInlineStart).toBe('auto');
|
|
2664
2664
|
});
|
|
2665
2665
|
|
|
2666
2666
|
it('calculates style for sticky item (grid both directions)', () => {
|
|
@@ -2672,6 +2672,8 @@ describe('virtual-scroll-logic', () => {
|
|
|
2672
2672
|
item: {
|
|
2673
2673
|
index: 10,
|
|
2674
2674
|
isStickyActive: true,
|
|
2675
|
+
isStickyActiveX: true,
|
|
2676
|
+
isStickyActiveY: true,
|
|
2675
2677
|
offset: { x: 600, y: 600 },
|
|
2676
2678
|
size: { height: 50, width: 50 },
|
|
2677
2679
|
stickyOffset: { x: -10, y: -10 },
|
|
@@ -2693,6 +2695,8 @@ describe('virtual-scroll-logic', () => {
|
|
|
2693
2695
|
item: {
|
|
2694
2696
|
index: 10,
|
|
2695
2697
|
isStickyActive: true,
|
|
2698
|
+
isStickyActiveX: true,
|
|
2699
|
+
isStickyActiveY: true,
|
|
2696
2700
|
offset: { x: 600, y: 600 },
|
|
2697
2701
|
size: { height: 50, width: 50 },
|
|
2698
2702
|
stickyOffset: { x: -10, y: -10 },
|
|
@@ -2705,7 +2709,6 @@ describe('virtual-scroll-logic', () => {
|
|
|
2705
2709
|
expect(result.insetInlineStart).toBe('10px');
|
|
2706
2710
|
expect(result.transform).toBe('translate(-10px, -10px)');
|
|
2707
2711
|
});
|
|
2708
|
-
|
|
2709
2712
|
it('calculates style for non-hydrated item', () => {
|
|
2710
2713
|
const result = calculateItemStyle({
|
|
2711
2714
|
containerTag: 'div',
|
|
@@ -2756,6 +2759,8 @@ describe('virtual-scroll-logic', () => {
|
|
|
2756
2759
|
item: {
|
|
2757
2760
|
index: 10,
|
|
2758
2761
|
isStickyActive: true,
|
|
2762
|
+
isStickyActiveX: true,
|
|
2763
|
+
isStickyActiveY: true,
|
|
2759
2764
|
offset: { x: 600, y: 600 },
|
|
2760
2765
|
size: { height: 50, width: 50 },
|
|
2761
2766
|
stickyOffset: { x: -10, y: -20 },
|
|
@@ -2768,7 +2773,6 @@ describe('virtual-scroll-logic', () => {
|
|
|
2768
2773
|
expect(result.insetInlineStart).toBe('10px');
|
|
2769
2774
|
expect(result.transform).toBe('translate(-10px, -20px)');
|
|
2770
2775
|
});
|
|
2771
|
-
|
|
2772
2776
|
it('correctly inverts transform in rtl mode', () => {
|
|
2773
2777
|
const item: RenderedItem = {
|
|
2774
2778
|
index: 0,
|
|
@@ -2817,7 +2821,20 @@ describe('virtual-scroll-logic', () => {
|
|
|
2817
2821
|
paddingStartX: 0,
|
|
2818
2822
|
paddingStartY: 0,
|
|
2819
2823
|
});
|
|
2820
|
-
expect(result.transform).toBe('translate(-
|
|
2824
|
+
expect(result.transform).toBe('translate(-100px, 20px)');
|
|
2825
|
+
|
|
2826
|
+
result = calculateItemStyle({
|
|
2827
|
+
containerTag: 'div',
|
|
2828
|
+
direction: 'horizontal',
|
|
2829
|
+
isHydrated: true,
|
|
2830
|
+
isRtl: true,
|
|
2831
|
+
item: { ...item, isStickyActive: true },
|
|
2832
|
+
itemSize: 50,
|
|
2833
|
+
paddingStartX: 0,
|
|
2834
|
+
paddingStartY: 0,
|
|
2835
|
+
});
|
|
2836
|
+
|
|
2837
|
+
expect(result.transform).toBe('translate(-10px, 200px)');
|
|
2821
2838
|
});
|
|
2822
2839
|
|
|
2823
2840
|
it('maintains 1:1 movement even when scale is high', () => {
|
|
@@ -293,6 +293,45 @@ function calculateAxisTarget({
|
|
|
293
293
|
return { target, itemSize, effectiveAlign };
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Helper to calculate sticky state for a single axis.
|
|
298
|
+
*
|
|
299
|
+
* @param scrollPos - Virtual scroll position.
|
|
300
|
+
* @param originalPos - Original virtual item position.
|
|
301
|
+
* @param size - Virtual item size.
|
|
302
|
+
* @param index - Item index.
|
|
303
|
+
* @param stickyIndices - All sticky indices.
|
|
304
|
+
* @param getNextStickyPos - Resolver for the next sticky item's position.
|
|
305
|
+
* @returns Sticky state for this axis.
|
|
306
|
+
*/
|
|
307
|
+
function calculateAxisSticky(
|
|
308
|
+
scrollPos: number,
|
|
309
|
+
originalPos: number,
|
|
310
|
+
size: number,
|
|
311
|
+
index: number,
|
|
312
|
+
stickyIndices: number[],
|
|
313
|
+
getNextStickyPos: (idx: number) => number,
|
|
314
|
+
) {
|
|
315
|
+
if (scrollPos <= originalPos) {
|
|
316
|
+
return { isActive: false, offset: 0 };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const nextStickyIdx = findNextStickyIndex(stickyIndices, index);
|
|
320
|
+
if (nextStickyIdx === undefined) {
|
|
321
|
+
return { isActive: true, offset: 0 };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const nextStickyPos = getNextStickyPos(nextStickyIdx);
|
|
325
|
+
if (scrollPos >= nextStickyPos) {
|
|
326
|
+
return { isActive: false, offset: 0 };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
isActive: true,
|
|
331
|
+
offset: Math.max(0, Math.min(size, nextStickyPos - scrollPos)) - size,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
296
335
|
// --- Exported Functions ---
|
|
297
336
|
|
|
298
337
|
/**
|
|
@@ -639,7 +678,6 @@ export function calculateColumnRange({
|
|
|
639
678
|
* @param params.height - Virtual item height (VU).
|
|
640
679
|
* @param params.stickyIndices - All sticky indices.
|
|
641
680
|
* @param params.fixedSize - Fixed item size (VU).
|
|
642
|
-
* @param params.fixedWidth - Fixed column width (VU).
|
|
643
681
|
* @param params.gap - Item gap (VU).
|
|
644
682
|
* @param params.columnGap - Column gap (VU).
|
|
645
683
|
* @param params.getItemQueryY - Resolver for vertical offset (VU).
|
|
@@ -659,61 +697,56 @@ export function calculateStickyItem({
|
|
|
659
697
|
height,
|
|
660
698
|
stickyIndices,
|
|
661
699
|
fixedSize,
|
|
662
|
-
fixedWidth,
|
|
663
700
|
gap,
|
|
664
701
|
columnGap,
|
|
665
702
|
getItemQueryY,
|
|
666
703
|
getItemQueryX,
|
|
667
704
|
}: StickyParams) {
|
|
668
|
-
let
|
|
705
|
+
let isStickyActiveX = false;
|
|
706
|
+
let isStickyActiveY = false;
|
|
669
707
|
const stickyOffset = { x: 0, y: 0 };
|
|
670
708
|
|
|
671
709
|
if (!isSticky) {
|
|
672
|
-
return { isStickyActive, stickyOffset };
|
|
710
|
+
return { isStickyActiveX, isStickyActiveY, isStickyActive: false, stickyOffset };
|
|
673
711
|
}
|
|
674
712
|
|
|
675
713
|
// Y Axis (Sticky Rows)
|
|
676
714
|
if (direction === 'vertical' || direction === 'both') {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
}
|
|
688
|
-
} else {
|
|
689
|
-
isStickyActive = true;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
715
|
+
const res = calculateAxisSticky(
|
|
716
|
+
relativeScrollY,
|
|
717
|
+
originalY,
|
|
718
|
+
height,
|
|
719
|
+
index,
|
|
720
|
+
stickyIndices,
|
|
721
|
+
(nextIdx) => (fixedSize !== null ? nextIdx * (fixedSize + gap) : getItemQueryY(nextIdx)),
|
|
722
|
+
);
|
|
723
|
+
isStickyActiveY = res.isActive;
|
|
724
|
+
stickyOffset.y = res.offset;
|
|
692
725
|
}
|
|
693
726
|
|
|
694
727
|
// X Axis (Sticky Columns / Items)
|
|
695
|
-
if (direction === 'horizontal'
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
stickyOffset.x = Math.max(0, Math.min(width, nextStickyX - relativeScrollX)) - width;
|
|
709
|
-
}
|
|
710
|
-
} else {
|
|
711
|
-
isStickyActive = true;
|
|
712
|
-
}
|
|
728
|
+
if (direction === 'horizontal') {
|
|
729
|
+
const res = calculateAxisSticky(
|
|
730
|
+
relativeScrollX,
|
|
731
|
+
originalX,
|
|
732
|
+
width,
|
|
733
|
+
index,
|
|
734
|
+
stickyIndices,
|
|
735
|
+
(nextIdx) => (fixedSize !== null ? nextIdx * (fixedSize + columnGap) : getItemQueryX(nextIdx)),
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
if (res.isActive) {
|
|
739
|
+
isStickyActiveX = true;
|
|
740
|
+
stickyOffset.x = res.offset;
|
|
713
741
|
}
|
|
714
742
|
}
|
|
715
743
|
|
|
716
|
-
return {
|
|
744
|
+
return {
|
|
745
|
+
isStickyActiveX,
|
|
746
|
+
isStickyActiveY,
|
|
747
|
+
isStickyActive: isStickyActiveX || isStickyActiveY,
|
|
748
|
+
stickyOffset,
|
|
749
|
+
};
|
|
717
750
|
}
|
|
718
751
|
|
|
719
752
|
/**
|
|
@@ -824,18 +857,18 @@ export function calculateItemStyle<T = unknown>({
|
|
|
824
857
|
}
|
|
825
858
|
|
|
826
859
|
if (isHydrated) {
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
: (item.isStickyActive ? item.stickyOffset.x : item.offset.x);
|
|
860
|
+
const isStickingVertically = item.isStickyActiveY ?? (item.isStickyActive && (isVertical || isBoth));
|
|
861
|
+
const isStickingHorizontally = item.isStickyActiveX ?? (item.isStickyActive && isHorizontal);
|
|
830
862
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
}
|
|
838
|
-
style.
|
|
863
|
+
const tx = isRtl
|
|
864
|
+
? -(isStickingHorizontally ? item.stickyOffset.x : item.offset.x)
|
|
865
|
+
: (isStickingHorizontally ? item.stickyOffset.x : item.offset.x);
|
|
866
|
+
const ty = isStickingVertically ? item.stickyOffset.y : item.offset.y;
|
|
867
|
+
|
|
868
|
+
if (item.isStickyActive || item.isStickyActiveX || item.isStickyActiveY) {
|
|
869
|
+
style.insetBlockStart = isStickingVertically ? `${ paddingStartY }px` : 'auto';
|
|
870
|
+
style.insetInlineStart = isStickingHorizontally ? `${ paddingStartX }px` : 'auto';
|
|
871
|
+
style.transform = `translate(${ tx }px, ${ ty }px)`;
|
|
839
872
|
} else {
|
|
840
873
|
style.transform = `translate(${ tx }px, ${ item.offset.y }px)`;
|
|
841
874
|
}
|