@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.
@@ -13,7 +13,7 @@ import {
13
13
  } from './virtual-scroll-logic';
14
14
 
15
15
  describe('virtual-scroll-logic', () => {
16
- describe('calculatetotalsize', () => {
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('calculaterange', () => {
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('calculatescrolltarget', () => {
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('aligns to end if partially visible at bottom (forward scroll effect)', () => {
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: 150,
1474
+ rowIndex: 10,
1475
1475
  viewportWidth: 500,
1476
1476
  viewportHeight: 500,
1477
1477
  colIndex: null,
1478
- options: 'auto' as const,
1479
- itemsLength: 1000,
1478
+ options: 'end' as const,
1479
+ itemsLength: 100,
1480
1480
  columnCount: 0,
1481
1481
  direction: 'vertical' as const,
1482
1482
  usableWidth: 1000,
1483
- usableHeight: 800,
1483
+ usableHeight: 1000,
1484
1484
  totalWidth: 1000,
1485
- totalHeight: 100000,
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: 14250,
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(14600);
1503
- expect(result.effectiveAlignY).toBe('end');
1503
+ expect(result.targetY).toBe(750);
1504
1504
  });
1505
1505
 
1506
- it('does not account for non-sticky footer (flowpaddingendy) in scroll target calculation', () => {
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: 10,
1512
+ rowIndex: 150,
1513
1513
  viewportWidth: 500,
1514
1514
  viewportHeight: 500,
1515
1515
  colIndex: null,
1516
- options: 'end' as const,
1517
- itemsLength: 100,
1516
+ options: 'auto' as const,
1517
+ itemsLength: 1000,
1518
1518
  columnCount: 0,
1519
1519
  direction: 'vertical' as const,
1520
1520
  usableWidth: 1000,
1521
- usableHeight: 1000,
1521
+ usableHeight: 800,
1522
1522
  totalWidth: 1000,
1523
- totalHeight: 10000,
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: 0,
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(750);
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('calculatecolumnrange', () => {
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('calculateitemposition', () => {
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('calculatestickyitem', () => {
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: 'both',
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('calculateitemstyle', () => {
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).toBeUndefined();
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(-10px, 20px)');
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 isStickyActive = false;
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
- if (relativeScrollY > originalY) {
678
- const nextStickyIdx = findNextStickyIndex(stickyIndices, index);
679
-
680
- if (nextStickyIdx !== undefined) {
681
- const nextStickyY = fixedSize !== null ? nextStickyIdx * (fixedSize + gap) : getItemQueryY(nextStickyIdx);
682
- if (relativeScrollY >= nextStickyY) {
683
- isStickyActive = false;
684
- } else {
685
- isStickyActive = true;
686
- stickyOffset.y = Math.max(0, Math.min(height, nextStickyY - relativeScrollY)) - height;
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' || (direction === 'both' && !isStickyActive)) {
696
- if (relativeScrollX > originalX) {
697
- const nextStickyIdx = findNextStickyIndex(stickyIndices, index);
698
-
699
- if (nextStickyIdx !== undefined) {
700
- const nextStickyX = direction === 'horizontal'
701
- ? (fixedSize !== null ? nextStickyIdx * (fixedSize + columnGap) : getItemQueryX(nextStickyIdx))
702
- : (fixedWidth !== null ? nextStickyIdx * (fixedWidth + columnGap) : getItemQueryX(nextStickyIdx));
703
-
704
- if (relativeScrollX >= nextStickyX) {
705
- isStickyActive = false;
706
- } else {
707
- isStickyActive = true;
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 { isStickyActive, stickyOffset };
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 tx = isRtl
828
- ? -(item.isStickyActive ? item.stickyOffset.x : item.offset.x)
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
- if (item.isStickyActive) {
832
- if (isVertical || isBoth) {
833
- style.insetBlockStart = `${ paddingStartY }px`;
834
- }
835
- if (isHorizontal || isBoth) {
836
- style.insetInlineStart = `${ paddingStartX }px`;
837
- }
838
- style.transform = `translate(${ tx }px, ${ item.stickyOffset.y }px)`;
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
  }