@pdanpdan/virtual-scroll 0.3.0 → 0.4.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,2517 @@
1
+ import type { RenderedItem } from '../types';
2
+
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import {
6
+ calculateColumnRange,
7
+ calculateItemPosition,
8
+ calculateItemStyle,
9
+ calculateRange,
10
+ calculateScrollTarget,
11
+ calculateStickyItem,
12
+ calculateTotalSize,
13
+ } from './virtual-scroll-logic';
14
+
15
+ describe('virtual-scroll-logic', () => {
16
+ describe('calculateTotalSize', () => {
17
+ it('calculates vertical total size with fixed size', () => {
18
+ const result = calculateTotalSize({
19
+ columnCount: 0,
20
+ columnGap: 0,
21
+ direction: 'vertical',
22
+ fixedSize: 50,
23
+ fixedWidth: null,
24
+ gap: 10,
25
+ itemsLength: 100,
26
+ queryColumn: () => 0,
27
+ queryX: () => 0,
28
+ queryY: () => 0,
29
+ usableHeight: 500,
30
+ usableWidth: 500,
31
+ });
32
+ expect(result.height).toBe(5990);
33
+ expect(result.width).toBe(500);
34
+ });
35
+
36
+ it('calculates horizontal total size with fixed size', () => {
37
+ const result = calculateTotalSize({
38
+ columnCount: 0,
39
+ columnGap: 10,
40
+ direction: 'horizontal',
41
+ fixedSize: 50,
42
+ fixedWidth: null,
43
+ gap: 0,
44
+ itemsLength: 100,
45
+ queryColumn: () => 0,
46
+ queryX: () => 0,
47
+ queryY: () => 0,
48
+ usableHeight: 500,
49
+ usableWidth: 500,
50
+ });
51
+ expect(result.width).toBe(5990);
52
+ expect(result.height).toBe(500);
53
+ });
54
+
55
+ it('calculates grid (both) total size with fixed row size and dynamic column width', () => {
56
+ const result = calculateTotalSize({
57
+ columnCount: 5,
58
+ columnGap: 5,
59
+ direction: 'both',
60
+ fixedSize: 50,
61
+ fixedWidth: null,
62
+ gap: 10,
63
+ itemsLength: 100,
64
+ queryColumn: (idx) => idx * 105,
65
+ queryX: () => 0,
66
+ queryY: () => 0,
67
+ usableHeight: 500,
68
+ usableWidth: 500,
69
+ });
70
+ expect(result.height).toBe(5990);
71
+ expect(result.width).toBe(520);
72
+ });
73
+
74
+ it('calculates grid (both) total size with fixed sizes', () => {
75
+ const result = calculateTotalSize({
76
+ columnCount: 5,
77
+ columnGap: 5,
78
+ direction: 'both',
79
+ fixedSize: 50,
80
+ fixedWidth: 100,
81
+ gap: 10,
82
+ itemsLength: 100,
83
+ queryColumn: () => 0,
84
+ queryX: () => 0,
85
+ queryY: () => 0,
86
+ usableHeight: 500,
87
+ usableWidth: 500,
88
+ });
89
+ expect(result.height).toBe(5990);
90
+ expect(result.width).toBe(520);
91
+ });
92
+
93
+ it('calculates grid (both) total size with dynamic sizes', () => {
94
+ const result = calculateTotalSize({
95
+ columnCount: 5,
96
+ columnGap: 5,
97
+ direction: 'both',
98
+ fixedSize: null,
99
+ fixedWidth: null,
100
+ gap: 10,
101
+ itemsLength: 100,
102
+ queryColumn: (idx) => idx * 105,
103
+ queryX: () => 0,
104
+ queryY: (idx) => idx * 60,
105
+ usableHeight: 500,
106
+ usableWidth: 500,
107
+ });
108
+ expect(result.height).toBe(5990);
109
+ expect(result.width).toBe(520);
110
+ });
111
+
112
+ it('calculates horizontal total size with dynamic sizes', () => {
113
+ const result = calculateTotalSize({
114
+ columnCount: 0,
115
+ columnGap: 10,
116
+ direction: 'horizontal',
117
+ fixedSize: null,
118
+ fixedWidth: null,
119
+ gap: 0,
120
+ itemsLength: 100,
121
+ queryColumn: () => 0,
122
+ queryX: (idx) => idx * 60,
123
+ queryY: () => 0,
124
+ usableHeight: 500,
125
+ usableWidth: 500,
126
+ });
127
+ expect(result.width).toBe(5990);
128
+ });
129
+
130
+ it('calculates vertical total size with dynamic sizes', () => {
131
+ const result = calculateTotalSize({
132
+ columnCount: 0,
133
+ columnGap: 0,
134
+ direction: 'vertical',
135
+ fixedSize: null,
136
+ fixedWidth: null,
137
+ gap: 5,
138
+ itemsLength: 10,
139
+ queryColumn: () => 0,
140
+ queryX: () => 0,
141
+ queryY: (idx) => idx * 45,
142
+ usableHeight: 500,
143
+ usableWidth: 500,
144
+ });
145
+ expect(result.height).toBe(445);
146
+ });
147
+
148
+ it('calculates total sizes for single item (both, fixed rows, fixed cols)', () => {
149
+ const result = calculateTotalSize({
150
+ columnCount: 1,
151
+ columnGap: 10,
152
+ direction: 'both',
153
+ fixedSize: 50,
154
+ fixedWidth: 100,
155
+ gap: 10,
156
+ itemsLength: 1,
157
+ queryColumn: () => 0,
158
+ queryX: () => 0,
159
+ queryY: () => 0,
160
+ usableHeight: 500,
161
+ usableWidth: 500,
162
+ });
163
+ // columnCount=1 * (100 + 10) - 10 = 100.
164
+ // itemsLength=1 * (50 + 10) - 10 = 50.
165
+ expect(result.height).toBe(500);
166
+ expect(result.width).toBe(500);
167
+ });
168
+
169
+ it('calculates total width for single item (horizontal, fixed size)', () => {
170
+ const result = calculateTotalSize({
171
+ columnCount: 0,
172
+ columnGap: 10,
173
+ direction: 'horizontal',
174
+ fixedSize: 50,
175
+ fixedWidth: null,
176
+ gap: 0,
177
+ itemsLength: 1,
178
+ queryColumn: () => 0,
179
+ queryX: () => 0,
180
+ queryY: () => 0,
181
+ usableHeight: 500,
182
+ usableWidth: 500,
183
+ });
184
+ expect(result.width).toBe(50);
185
+ });
186
+
187
+ it('calculates total width for single item (horizontal, dynamic size)', () => {
188
+ const result = calculateTotalSize({
189
+ columnCount: 0,
190
+ columnGap: 10,
191
+ direction: 'horizontal',
192
+ fixedSize: null,
193
+ fixedWidth: null,
194
+ gap: 0,
195
+ itemsLength: 1,
196
+ queryColumn: () => 0,
197
+ queryX: (idx) => idx * 60,
198
+ queryY: () => 0,
199
+ usableHeight: 500,
200
+ usableWidth: 500,
201
+ });
202
+ // queryX(1) = 60. gap = 10. total = 60 - 10 = 50.
203
+ expect(result.width).toBe(50);
204
+ });
205
+
206
+ it('calculates total height for single item (vertical, dynamic size)', () => {
207
+ const result = calculateTotalSize({
208
+ columnCount: 0,
209
+ columnGap: 0,
210
+ direction: 'vertical',
211
+ fixedSize: null,
212
+ fixedWidth: null,
213
+ gap: 10,
214
+ itemsLength: 1,
215
+ queryColumn: () => 0,
216
+ queryX: () => 0,
217
+ queryY: (idx) => idx * 60,
218
+ usableHeight: 500,
219
+ usableWidth: 500,
220
+ });
221
+ // queryY(1) = 60. gap = 10. total = 60 - 10 = 50.
222
+ expect(result.height).toBe(50);
223
+ });
224
+
225
+ it('calculates total height for single small item (vertical, dynamic size, itemsLength 1)', () => {
226
+ const result = calculateTotalSize({
227
+ columnCount: 0,
228
+ columnGap: 0,
229
+ direction: 'vertical',
230
+ fixedSize: null,
231
+ fixedWidth: null,
232
+ gap: 10,
233
+ itemsLength: 1,
234
+ queryColumn: () => 0,
235
+ queryX: () => 0,
236
+ queryY: (idx) => (idx === 0 ? 0 : 5),
237
+ usableHeight: 500,
238
+ usableWidth: 500,
239
+ });
240
+ expect(result.height).toBe(0);
241
+ });
242
+
243
+ it('calculates total width for single small item (horizontal, dynamic size, itemsLength 1)', () => {
244
+ const result = calculateTotalSize({
245
+ columnCount: 0,
246
+ columnGap: 10,
247
+ direction: 'horizontal',
248
+ fixedSize: null,
249
+ fixedWidth: null,
250
+ gap: 0,
251
+ itemsLength: 1,
252
+ queryColumn: () => 0,
253
+ queryX: (idx) => (idx === 0 ? 0 : 5),
254
+ queryY: () => 0,
255
+ usableHeight: 500,
256
+ usableWidth: 500,
257
+ });
258
+ expect(result.width).toBe(0);
259
+ });
260
+
261
+ it('calculates total height for single item (both, dynamic size, queryY)', () => {
262
+ const result = calculateTotalSize({
263
+ columnCount: 1,
264
+ columnGap: 10,
265
+ direction: 'both',
266
+ fixedSize: null,
267
+ fixedWidth: null,
268
+ gap: 10,
269
+ itemsLength: 1,
270
+ queryColumn: (idx) => (idx === 0 ? 0 : 110),
271
+ queryX: () => 0,
272
+ queryY: (idx) => (idx === 0 ? 0 : 60),
273
+ usableHeight: 500,
274
+ usableWidth: 500,
275
+ });
276
+ expect(result.height).toBe(500);
277
+ expect(result.width).toBe(500);
278
+ });
279
+
280
+ it('calculates total height for single item (both, fixed rows, dynamic cols)', () => {
281
+ const result = calculateTotalSize({
282
+ columnCount: 1,
283
+ columnGap: 10,
284
+ direction: 'both',
285
+ fixedSize: 50,
286
+ fixedWidth: null,
287
+ gap: 10,
288
+ itemsLength: 1,
289
+ queryColumn: (idx) => (idx === 0 ? 0 : 110),
290
+ queryX: () => 0,
291
+ queryY: () => 0,
292
+ usableHeight: 500,
293
+ usableWidth: 500,
294
+ });
295
+ expect(result.height).toBe(500);
296
+ expect(result.width).toBe(500);
297
+ });
298
+
299
+ it('returns viewport size for empty items with fixed sizes (both)', () => {
300
+ const result = calculateTotalSize({
301
+ columnCount: 0,
302
+ columnGap: 10,
303
+ direction: 'both',
304
+ fixedSize: 50,
305
+ fixedWidth: 100,
306
+ gap: 10,
307
+ itemsLength: 0,
308
+ queryColumn: () => 0,
309
+ queryX: () => 0,
310
+ queryY: () => 0,
311
+ usableHeight: 500,
312
+ usableWidth: 500,
313
+ });
314
+ expect(result.height).toBe(500);
315
+ expect(result.width).toBe(500);
316
+ });
317
+
318
+ it('returns 0 for empty items with fixed sizes (horizontal)', () => {
319
+ const result = calculateTotalSize({
320
+ columnCount: 0,
321
+ columnGap: 10,
322
+ direction: 'horizontal',
323
+ fixedSize: 50,
324
+ fixedWidth: null,
325
+ gap: 0,
326
+ itemsLength: 0,
327
+ queryColumn: () => 0,
328
+ queryX: () => 0,
329
+ queryY: () => 0,
330
+ usableHeight: 500,
331
+ usableWidth: 500,
332
+ });
333
+ expect(result.width).toBe(0);
334
+ });
335
+
336
+ it('returns 0 for empty items with fixed sizes (vertical)', () => {
337
+ const result = calculateTotalSize({
338
+ columnCount: 0,
339
+ columnGap: 0,
340
+ direction: 'vertical',
341
+ fixedSize: 50,
342
+ fixedWidth: null,
343
+ gap: 10,
344
+ itemsLength: 0,
345
+ queryColumn: () => 0,
346
+ queryX: () => 0,
347
+ queryY: () => 0,
348
+ usableHeight: 500,
349
+ usableWidth: 500,
350
+ });
351
+ expect(result.height).toBe(0);
352
+ });
353
+
354
+ it('returns viewport size for empty items with dynamic sizes (both)', () => {
355
+ const result = calculateTotalSize({
356
+ columnCount: 0,
357
+ columnGap: 10,
358
+ direction: 'both',
359
+ fixedSize: null,
360
+ fixedWidth: null,
361
+ gap: 10,
362
+ itemsLength: 0,
363
+ queryColumn: () => 0,
364
+ queryX: () => 0,
365
+ queryY: () => 0,
366
+ usableHeight: 500,
367
+ usableWidth: 500,
368
+ });
369
+ expect(result.height).toBe(500);
370
+ expect(result.width).toBe(500);
371
+ });
372
+
373
+ it('returns 0 for empty items with dynamic sizes (horizontal)', () => {
374
+ const result = calculateTotalSize({
375
+ columnCount: 0,
376
+ columnGap: 10,
377
+ direction: 'horizontal',
378
+ fixedSize: null,
379
+ fixedWidth: null,
380
+ gap: 0,
381
+ itemsLength: 0,
382
+ queryColumn: () => 0,
383
+ queryX: () => 0,
384
+ queryY: () => 0,
385
+ usableHeight: 500,
386
+ usableWidth: 500,
387
+ });
388
+ expect(result.width).toBe(0);
389
+ });
390
+
391
+ it('returns 0 for empty items with dynamic sizes (vertical)', () => {
392
+ const result = calculateTotalSize({
393
+ columnCount: 0,
394
+ columnGap: 0,
395
+ direction: 'vertical',
396
+ fixedSize: null,
397
+ fixedWidth: null,
398
+ gap: 10,
399
+ itemsLength: 0,
400
+ queryColumn: () => 0,
401
+ queryX: () => 0,
402
+ queryY: () => 0,
403
+ usableHeight: 500,
404
+ usableWidth: 500,
405
+ });
406
+ expect(result.height).toBe(0);
407
+ });
408
+ });
409
+
410
+ describe('calculateRange', () => {
411
+ it('calculates vertical range with dynamic size', () => {
412
+ const result = calculateRange({
413
+ bufferAfter: 0,
414
+ bufferBefore: 0,
415
+ columnGap: 0,
416
+ direction: 'vertical',
417
+ findLowerBoundX: () => 0,
418
+ findLowerBoundY: (offset) => Math.floor(offset / 50),
419
+ fixedSize: null,
420
+ gap: 0,
421
+ itemsLength: 100,
422
+ queryX: () => 0,
423
+ queryY: (idx) => idx * 50,
424
+ relativeScrollX: 0,
425
+ relativeScrollY: 100,
426
+ usableHeight: 200,
427
+ usableWidth: 500,
428
+ });
429
+ expect(result.start).toBe(2);
430
+ expect(result.end).toBe(6);
431
+ });
432
+
433
+ it('calculates horizontal range with fixed size', () => {
434
+ const result = calculateRange({
435
+ bufferAfter: 0,
436
+ bufferBefore: 0,
437
+ columnGap: 10,
438
+ direction: 'horizontal',
439
+ findLowerBoundX: () => 0,
440
+ findLowerBoundY: () => 0,
441
+ fixedSize: 50,
442
+ gap: 0,
443
+ itemsLength: 100,
444
+ queryX: () => 0,
445
+ queryY: () => 0,
446
+ relativeScrollX: 120,
447
+ relativeScrollY: 0,
448
+ usableHeight: 500,
449
+ usableWidth: 100,
450
+ });
451
+ // item 0: 0-50, gap 50-60
452
+ // item 1: 60-110, gap 110-120
453
+ // item 2: 120-170, gap 170-180
454
+ // scroll 120 -> item 2.
455
+ // viewport 100 -> ends at 220. item 3: 180-230.
456
+ expect(result.start).toBe(2);
457
+ expect(result.end).toBe(4);
458
+ });
459
+
460
+ it('calculates vertical range with fixed size', () => {
461
+ const result = calculateRange({
462
+ bufferAfter: 5,
463
+ bufferBefore: 5,
464
+ columnGap: 0,
465
+ direction: 'vertical',
466
+ findLowerBoundX: () => 0,
467
+ findLowerBoundY: () => 0,
468
+ fixedSize: 50,
469
+ gap: 0,
470
+ itemsLength: 1000,
471
+ queryX: () => 0,
472
+ queryY: () => 0,
473
+ relativeScrollX: 0,
474
+ relativeScrollY: 1000,
475
+ usableHeight: 500,
476
+ usableWidth: 500,
477
+ });
478
+ expect(result.start).toBe(15);
479
+ expect(result.end).toBe(35);
480
+ });
481
+
482
+ it('calculates horizontal range with dynamic size', () => {
483
+ const result = calculateRange({
484
+ bufferAfter: 0,
485
+ bufferBefore: 0,
486
+ columnGap: 0,
487
+ direction: 'horizontal',
488
+ findLowerBoundX: (offset) => Math.floor(offset / 50),
489
+ findLowerBoundY: () => 0,
490
+ fixedSize: null,
491
+ gap: 0,
492
+ itemsLength: 1000,
493
+ queryX: (idx) => idx * 50,
494
+ queryY: () => 0,
495
+ relativeScrollX: 1000,
496
+ relativeScrollY: 0,
497
+ usableHeight: 500,
498
+ usableWidth: 500,
499
+ });
500
+ expect(result.start).toBe(20);
501
+ expect(result.end).toBe(30);
502
+ });
503
+
504
+ it('calculates horizontal range with dynamic size where end item is partially visible (edge case)', () => {
505
+ // Setup:
506
+ // Item 0: 0-100
507
+ // Item 1: 100-200
508
+ // Viewport: 0-150.
509
+ // Target end = 150.
510
+ // findLowerBoundX(150) -> returns 1 because queryX(1)=100 <= 150, queryX(2)=200 > 150.
511
+ // queryX(1) = 100 < 150.
512
+ // So end should increment to 2.
513
+ const result = calculateRange({
514
+ bufferAfter: 0,
515
+ bufferBefore: 0,
516
+ columnGap: 0,
517
+ direction: 'horizontal',
518
+ findLowerBoundX: (val) => val >= 200 ? 2 : (val >= 100 ? 1 : 0),
519
+ findLowerBoundY: () => 0,
520
+ fixedSize: null,
521
+ gap: 0,
522
+ itemsLength: 2,
523
+ queryX: (idx) => idx * 100,
524
+ queryY: () => 0,
525
+ relativeScrollX: 0,
526
+ relativeScrollY: 0,
527
+ usableHeight: 500,
528
+ usableWidth: 150,
529
+ });
530
+ expect(result.start).toBe(0);
531
+ expect(result.end).toBe(2);
532
+ });
533
+ });
534
+
535
+ describe('calculateScrollTarget', () => {
536
+ it('calculates target for horizontal end alignment', () => {
537
+ const result = calculateScrollTarget({
538
+ colIndex: 10,
539
+ columnCount: 100,
540
+ columnGap: 0,
541
+ direction: 'horizontal',
542
+ fixedSize: 50,
543
+ fixedWidth: null,
544
+ gap: 0,
545
+ getColumnQuery: () => 0,
546
+ getColumnSize: () => 0,
547
+ getItemQueryX: (idx) => idx * 50,
548
+ getItemQueryY: () => 0,
549
+ getItemSizeX: () => 50,
550
+ getItemSizeY: () => 0,
551
+ itemsLength: 100,
552
+ options: 'end',
553
+ relativeScrollX: 0,
554
+ relativeScrollY: 0,
555
+ rowIndex: null,
556
+ totalHeight: 0,
557
+ totalWidth: 5000,
558
+ usableHeight: 500,
559
+ usableWidth: 500,
560
+ });
561
+ // item 10 at 500. ends at 550. viewport 500 -> targetX = 550 - 500 = 50.
562
+ expect(result.targetX).toBe(50);
563
+ });
564
+
565
+ it('calculates target for grid column start alignment', () => {
566
+ const result = calculateScrollTarget({
567
+ colIndex: 10,
568
+ columnCount: 50,
569
+ columnGap: 10,
570
+ direction: 'both',
571
+ fixedSize: null,
572
+ fixedWidth: null,
573
+ gap: 0,
574
+ getColumnQuery: (idx) => idx * 110,
575
+ getColumnSize: () => 110,
576
+ getItemQueryX: () => 0,
577
+ getItemQueryY: () => 0,
578
+ getItemSizeX: () => 0,
579
+ getItemSizeY: () => 0,
580
+ itemsLength: 100,
581
+ options: { align: { x: 'start' } },
582
+ relativeScrollX: 0,
583
+ relativeScrollY: 0,
584
+ rowIndex: null,
585
+ totalHeight: 0,
586
+ totalWidth: 5500,
587
+ usableHeight: 500,
588
+ usableWidth: 500,
589
+ });
590
+ expect(result.targetX).toBe(1100);
591
+ });
592
+
593
+ it('calculates target for vertical start alignment with partial align in options object', () => {
594
+ const result = calculateScrollTarget({
595
+ colIndex: null,
596
+ columnCount: 0,
597
+ columnGap: 0,
598
+ direction: 'vertical',
599
+ fixedSize: 50,
600
+ fixedWidth: null,
601
+ gap: 0,
602
+ getColumnQuery: () => 0,
603
+ getColumnSize: () => 0,
604
+ getItemQueryX: () => 0,
605
+ getItemQueryY: (idx) => idx * 50,
606
+ getItemSizeX: () => 0,
607
+ getItemSizeY: () => 50,
608
+ itemsLength: 100,
609
+ options: { align: { y: 'start' } }, // x is missing
610
+ relativeScrollX: 50,
611
+ relativeScrollY: 0,
612
+ rowIndex: 10,
613
+ totalHeight: 5000,
614
+ totalWidth: 5000,
615
+ usableHeight: 500,
616
+ usableWidth: 500,
617
+ });
618
+ expect(result.targetY).toBe(500);
619
+ expect(result.targetX).toBe(50); // auto, already visible
620
+ });
621
+
622
+ it('calculates target for horizontal start alignment with partial options object', () => {
623
+ const result = calculateScrollTarget({
624
+ colIndex: 10,
625
+ columnCount: 100,
626
+ columnGap: 0,
627
+ direction: 'horizontal',
628
+ fixedSize: 50,
629
+ fixedWidth: null,
630
+ gap: 0,
631
+ getColumnQuery: () => 0,
632
+ getColumnSize: () => 0,
633
+ getItemQueryX: (idx) => idx * 50,
634
+ getItemQueryY: () => 0,
635
+ getItemSizeX: () => 50,
636
+ getItemSizeY: () => 0,
637
+ itemsLength: 100,
638
+ options: { align: { x: 'start' } }, // y is missing, should default to 'auto'
639
+ relativeScrollX: 0,
640
+ relativeScrollY: 50,
641
+ rowIndex: 10,
642
+ totalHeight: 5000,
643
+ totalWidth: 5000,
644
+ usableHeight: 500,
645
+ usableWidth: 500,
646
+ });
647
+ expect(result.targetX).toBe(500);
648
+ expect(result.targetY).toBe(50); // auto, already visible
649
+ });
650
+
651
+ it('calculates target for horizontal start alignment with options object', () => {
652
+ const result = calculateScrollTarget({
653
+ colIndex: 10,
654
+ columnCount: 100,
655
+ columnGap: 0,
656
+ direction: 'horizontal',
657
+ fixedSize: 50,
658
+ fixedWidth: null,
659
+ gap: 0,
660
+ getColumnQuery: () => 0,
661
+ getColumnSize: () => 0,
662
+ getItemQueryX: (idx) => idx * 50,
663
+ getItemQueryY: () => 0,
664
+ getItemSizeX: () => 50,
665
+ getItemSizeY: () => 0,
666
+ itemsLength: 100,
667
+ options: { align: { x: 'start' } },
668
+ relativeScrollX: 0,
669
+ relativeScrollY: 0,
670
+ rowIndex: null,
671
+ totalHeight: 0,
672
+ totalWidth: 5000,
673
+ usableHeight: 500,
674
+ usableWidth: 500,
675
+ });
676
+ expect(result.targetX).toBe(500);
677
+ });
678
+
679
+ it('calculates target for vertical start alignment with dynamic size', () => {
680
+ const result = calculateScrollTarget({
681
+ colIndex: null,
682
+ columnCount: 0,
683
+ columnGap: 0,
684
+ direction: 'vertical',
685
+ fixedSize: null,
686
+ fixedWidth: null,
687
+ gap: 10,
688
+ getColumnQuery: () => 0,
689
+ getColumnSize: () => 0,
690
+ getItemQueryX: () => 0,
691
+ getItemQueryY: (idx) => idx * 60,
692
+ getItemSizeX: () => 0,
693
+ getItemSizeY: () => 60,
694
+ itemsLength: 100,
695
+ options: 'start',
696
+ relativeScrollX: 0,
697
+ relativeScrollY: 0,
698
+ rowIndex: 10,
699
+ totalHeight: 6000,
700
+ totalWidth: 0,
701
+ usableHeight: 500,
702
+ usableWidth: 500,
703
+ });
704
+ expect(result.targetY).toBe(600);
705
+ expect(result.itemHeight).toBe(50);
706
+ });
707
+
708
+ it('calculates target for horizontal start alignment with dynamic size', () => {
709
+ const result = calculateScrollTarget({
710
+ colIndex: 10,
711
+ columnCount: 100,
712
+ columnGap: 10,
713
+ direction: 'horizontal',
714
+ fixedSize: null,
715
+ fixedWidth: null,
716
+ gap: 0,
717
+ getColumnQuery: () => 0,
718
+ getColumnSize: () => 0,
719
+ getItemQueryX: (idx) => idx * 60,
720
+ getItemQueryY: () => 0,
721
+ getItemSizeX: () => 60,
722
+ getItemSizeY: () => 0,
723
+ itemsLength: 100,
724
+ options: 'start',
725
+ relativeScrollX: 0,
726
+ relativeScrollY: 0,
727
+ rowIndex: null,
728
+ totalHeight: 0,
729
+ totalWidth: 6000,
730
+ usableHeight: 500,
731
+ usableWidth: 500,
732
+ });
733
+ expect(result.targetX).toBe(600);
734
+ expect(result.itemWidth).toBe(50);
735
+ });
736
+
737
+ it('calculates target for vertical center alignment', () => {
738
+ const result = calculateScrollTarget({
739
+ colIndex: null,
740
+ columnCount: 0,
741
+ columnGap: 0,
742
+ direction: 'vertical',
743
+ fixedSize: 50,
744
+ fixedWidth: null,
745
+ gap: 0,
746
+ getColumnQuery: () => 0,
747
+ getColumnSize: () => 0,
748
+ getItemQueryX: () => 0,
749
+ getItemQueryY: (idx) => idx * 50,
750
+ getItemSizeX: () => 0,
751
+ getItemSizeY: () => 50,
752
+ itemsLength: 100,
753
+ options: 'center',
754
+ relativeScrollX: 0,
755
+ relativeScrollY: 0,
756
+ rowIndex: 20,
757
+ totalHeight: 5000,
758
+ totalWidth: 0,
759
+ usableHeight: 500,
760
+ usableWidth: 500,
761
+ });
762
+ expect(result.targetY).toBe(775);
763
+ });
764
+
765
+ it('calculates target when rowIndex is past itemsLength', () => {
766
+ const result = calculateScrollTarget({
767
+ colIndex: null,
768
+ columnCount: 0,
769
+ columnGap: 0,
770
+ direction: 'vertical',
771
+ fixedSize: 50,
772
+ fixedWidth: null,
773
+ gap: 0,
774
+ getColumnQuery: () => 0,
775
+ getColumnSize: () => 0,
776
+ getItemQueryX: () => 0,
777
+ getItemQueryY: (idx) => idx * 50,
778
+ getItemSizeX: () => 0,
779
+ getItemSizeY: () => 50,
780
+ itemsLength: 100,
781
+ options: 'start',
782
+ relativeScrollX: 0,
783
+ relativeScrollY: 0,
784
+ rowIndex: 200,
785
+ totalHeight: 5000,
786
+ totalWidth: 0,
787
+ usableHeight: 500,
788
+ usableWidth: 500,
789
+ });
790
+ expect(result.targetY).toBe(4500);
791
+ });
792
+
793
+ it('calculates target for grid bidirectional alignment', () => {
794
+ const result = calculateScrollTarget({
795
+ colIndex: 10,
796
+ columnCount: 50,
797
+ columnGap: 0,
798
+ direction: 'both',
799
+ fixedSize: 50,
800
+ fixedWidth: null,
801
+ gap: 0,
802
+ getColumnQuery: (idx) => idx * 100,
803
+ getColumnSize: (_idx) => 100,
804
+ getItemQueryX: () => 0,
805
+ getItemQueryY: (idx) => idx * 50,
806
+ getItemSizeX: () => 0,
807
+ getItemSizeY: () => 50,
808
+ itemsLength: 100,
809
+ options: { x: 'center', y: 'end' },
810
+ relativeScrollX: 0,
811
+ relativeScrollY: 0,
812
+ rowIndex: 20,
813
+ totalHeight: 5000,
814
+ totalWidth: 5000,
815
+ usableHeight: 500,
816
+ usableWidth: 500,
817
+ });
818
+ // rowIndex 20 -> y=1000. end align -> 1000 - (500 - 50) = 550.
819
+ // colIndex 10 -> x=1000. center align -> 1000 - (500 - 100) / 2 = 1000 - 200 = 800.
820
+ expect(result.targetY).toBe(550);
821
+ expect(result.targetX).toBe(800);
822
+ });
823
+
824
+ it('calculates target accounting for active sticky item (vertical start alignment)', () => {
825
+ const result = calculateScrollTarget({
826
+ colIndex: null,
827
+ columnCount: 0,
828
+ columnGap: 0,
829
+ direction: 'vertical',
830
+ fixedSize: 50,
831
+ fixedWidth: null,
832
+ gap: 0,
833
+ getColumnQuery: () => 0,
834
+ getColumnSize: () => 0,
835
+ getItemQueryX: () => 0,
836
+ getItemQueryY: (idx) => idx * 50,
837
+ getItemSizeX: () => 0,
838
+ getItemSizeY: () => 50,
839
+ itemsLength: 200,
840
+ options: 'start',
841
+ relativeScrollX: 0,
842
+ relativeScrollY: 0,
843
+ rowIndex: 150,
844
+ totalHeight: 10000,
845
+ totalWidth: 0,
846
+ usableHeight: 500,
847
+ usableWidth: 500,
848
+ stickyIndices: [ 100 ], // Item 100 is sticky
849
+ });
850
+ // Item 150 is at 150 * 50 = 7500.
851
+ // Sticky item 100 is active. Height = 50.
852
+ // Target should be 7500 - 50 = 7450.
853
+ expect(result.targetY).toBe(7450);
854
+ });
855
+
856
+ it('calculates target accounting for active sticky item (horizontal start alignment)', () => {
857
+ const result = calculateScrollTarget({
858
+ colIndex: 150,
859
+ columnCount: 200,
860
+ columnGap: 0,
861
+ direction: 'horizontal',
862
+ fixedSize: 50,
863
+ fixedWidth: null,
864
+ gap: 0,
865
+ getColumnQuery: () => 0,
866
+ getColumnSize: () => 0,
867
+ getItemQueryX: (idx) => idx * 50,
868
+ getItemQueryY: () => 0,
869
+ getItemSizeX: () => 50,
870
+ getItemSizeY: () => 0,
871
+ itemsLength: 200,
872
+ options: 'start',
873
+ relativeScrollX: 0,
874
+ relativeScrollY: 0,
875
+ rowIndex: null,
876
+ totalHeight: 0,
877
+ totalWidth: 10000,
878
+ usableHeight: 500,
879
+ usableWidth: 500,
880
+ stickyIndices: [ 100 ], // Item 100 is sticky
881
+ });
882
+ // Item 150 is at 150 * 50 = 7500.
883
+ // Sticky item 100 is active. Width = 50.
884
+ // Target should be 7500 - 50 = 7450.
885
+ expect(result.targetX).toBe(7450);
886
+ });
887
+
888
+ it('calculates target for vertical start alignment (sticky indices present but none active)', () => {
889
+ const result = calculateScrollTarget({
890
+ colIndex: null,
891
+ columnCount: 0,
892
+ columnGap: 0,
893
+ direction: 'vertical',
894
+ fixedSize: 50,
895
+ fixedWidth: null,
896
+ gap: 0,
897
+ getColumnQuery: () => 0,
898
+ getColumnSize: () => 0,
899
+ getItemQueryX: () => 0,
900
+ getItemQueryY: (idx) => idx * 50,
901
+ getItemSizeX: () => 0,
902
+ getItemSizeY: () => 50,
903
+ itemsLength: 200,
904
+ options: 'start',
905
+ relativeScrollX: 0,
906
+ relativeScrollY: 0,
907
+ rowIndex: 50, // Target 50 (2500)
908
+ totalHeight: 10000,
909
+ totalWidth: 0,
910
+ usableHeight: 500,
911
+ usableWidth: 500,
912
+ stickyIndices: [ 100 ], // Sticky 100 is AFTER target 50
913
+ });
914
+ // Should align to 2500 without adjustment
915
+ expect(result.targetY).toBe(2500);
916
+ });
917
+
918
+ it('calculates target accounting for active sticky item (vertical start alignment, dynamic size)', () => {
919
+ const result = calculateScrollTarget({
920
+ colIndex: null,
921
+ columnCount: 0,
922
+ columnGap: 0,
923
+ direction: 'vertical',
924
+ fixedSize: null,
925
+ fixedWidth: null,
926
+ gap: 0,
927
+ getColumnQuery: () => 0,
928
+ getColumnSize: () => 0,
929
+ getItemQueryX: () => 0,
930
+ getItemQueryY: (idx) => idx * 50,
931
+ getItemSizeX: () => 0,
932
+ getItemSizeY: () => 50,
933
+ itemsLength: 200,
934
+ options: 'start',
935
+ relativeScrollX: 0,
936
+ relativeScrollY: 0,
937
+ rowIndex: 150, // Target 150 (7500)
938
+ totalHeight: 10000,
939
+ totalWidth: 0,
940
+ usableHeight: 500,
941
+ usableWidth: 500,
942
+ stickyIndices: [ 100 ], // Sticky 100 is active. Height 50.
943
+ });
944
+ // Target 7500 - 50 = 7450.
945
+ expect(result.targetY).toBe(7450);
946
+ });
947
+
948
+ it('calculates target accounting for active sticky item (vertical auto alignment, scrolling up)', () => {
949
+ const result = calculateScrollTarget({
950
+ colIndex: null,
951
+ columnCount: 0,
952
+ columnGap: 0,
953
+ direction: 'vertical',
954
+ fixedSize: 50,
955
+ fixedWidth: null,
956
+ gap: 0,
957
+ getColumnQuery: () => 0,
958
+ getColumnSize: () => 0,
959
+ getItemQueryX: () => 0,
960
+ getItemQueryY: (idx) => idx * 50,
961
+ getItemSizeX: () => 0,
962
+ getItemSizeY: () => 50,
963
+ itemsLength: 200,
964
+ options: 'auto',
965
+ relativeScrollX: 0,
966
+ relativeScrollY: 8000, // Currently at item 160 (8000)
967
+ rowIndex: 120, // Target item 120 (6000)
968
+ totalHeight: 10000,
969
+ totalWidth: 0,
970
+ usableHeight: 500,
971
+ usableWidth: 500,
972
+ stickyIndices: [ 100 ], // Item 100 is sticky
973
+ });
974
+ // Target 120 is at 6000.
975
+ // It is above viewport (8000). So it aligns to start.
976
+ // Sticky item 100 is active (index < 120). Height = 50.
977
+ // Target should be 6000 - 50 = 5950.
978
+ expect(result.targetY).toBe(5950);
979
+ });
980
+
981
+ it('calculates target accounting for active sticky item (grid start alignment, fixed width)', () => {
982
+ const result = calculateScrollTarget({
983
+ colIndex: 150,
984
+ columnCount: 200,
985
+ columnGap: 0,
986
+ direction: 'both',
987
+ fixedSize: 50,
988
+ fixedWidth: 100,
989
+ gap: 0,
990
+ getColumnQuery: (idx) => idx * 100,
991
+ getColumnSize: () => 100,
992
+ getItemQueryX: () => 0,
993
+ getItemQueryY: () => 0,
994
+ getItemSizeX: () => 0,
995
+ getItemSizeY: () => 50,
996
+ itemsLength: 200,
997
+ options: { x: 'start' },
998
+ relativeScrollX: 0,
999
+ relativeScrollY: 0,
1000
+ rowIndex: null,
1001
+ totalHeight: 10000,
1002
+ totalWidth: 20000,
1003
+ usableHeight: 500,
1004
+ usableWidth: 500,
1005
+ stickyIndices: [ 100 ], // Item 100 is sticky
1006
+ });
1007
+ // Target col 150 is at 150 * 100 = 15000.
1008
+ // Sticky item 100 is active. Width = 100.
1009
+ // Target should be 15000 - 100 = 14900.
1010
+ expect(result.targetX).toBe(14900);
1011
+ });
1012
+
1013
+ it('calculates target accounting for active sticky item (horizontal start alignment, dynamic size)', () => {
1014
+ const result = calculateScrollTarget({
1015
+ colIndex: 150,
1016
+ columnCount: 200,
1017
+ columnGap: 0,
1018
+ direction: 'horizontal',
1019
+ fixedSize: null,
1020
+ fixedWidth: null,
1021
+ gap: 0,
1022
+ getColumnQuery: () => 0,
1023
+ getColumnSize: () => 0,
1024
+ getItemQueryX: (idx) => idx * 50,
1025
+ getItemQueryY: () => 0,
1026
+ getItemSizeX: () => 50,
1027
+ getItemSizeY: () => 0,
1028
+ itemsLength: 200,
1029
+ options: 'start',
1030
+ relativeScrollX: 0,
1031
+ relativeScrollY: 0,
1032
+ rowIndex: null,
1033
+ totalHeight: 0,
1034
+ totalWidth: 10000,
1035
+ usableHeight: 500,
1036
+ usableWidth: 500,
1037
+ stickyIndices: [ 100 ],
1038
+ });
1039
+ // Target 150 at 7500. Sticky 100 at 5000, width 50.
1040
+ // Target = 7500 - 50 = 7450.
1041
+ expect(result.targetX).toBe(7450);
1042
+ });
1043
+
1044
+ it('calculates target accounting for active sticky item (grid start alignment, dynamic width)', () => {
1045
+ const result = calculateScrollTarget({
1046
+ colIndex: 150,
1047
+ columnCount: 200,
1048
+ columnGap: 0,
1049
+ direction: 'both',
1050
+ fixedSize: null,
1051
+ fixedWidth: null,
1052
+ gap: 0,
1053
+ getColumnQuery: (idx) => idx * 100,
1054
+ getColumnSize: () => 100,
1055
+ getItemQueryX: () => 0,
1056
+ getItemQueryY: () => 0,
1057
+ getItemSizeX: () => 0,
1058
+ getItemSizeY: () => 50,
1059
+ itemsLength: 200,
1060
+ options: { x: 'start' },
1061
+ relativeScrollX: 0,
1062
+ relativeScrollY: 0,
1063
+ rowIndex: null,
1064
+ totalHeight: 10000,
1065
+ totalWidth: 20000,
1066
+ usableHeight: 500,
1067
+ usableWidth: 500,
1068
+ stickyIndices: [ 100 ],
1069
+ });
1070
+ expect(result.targetX).toBe(14900);
1071
+ });
1072
+
1073
+ it('calculates target accounting for active sticky item (vertical auto alignment, dynamic size)', () => {
1074
+ const result = calculateScrollTarget({
1075
+ colIndex: null,
1076
+ columnCount: 0,
1077
+ columnGap: 0,
1078
+ direction: 'vertical',
1079
+ fixedSize: null,
1080
+ fixedWidth: null,
1081
+ gap: 0,
1082
+ getColumnQuery: () => 0,
1083
+ getColumnSize: () => 0,
1084
+ getItemQueryX: () => 0,
1085
+ getItemQueryY: (idx) => idx * 50,
1086
+ getItemSizeX: () => 0,
1087
+ getItemSizeY: () => 50,
1088
+ itemsLength: 200,
1089
+ options: 'auto',
1090
+ relativeScrollX: 0,
1091
+ relativeScrollY: 8000,
1092
+ rowIndex: 120,
1093
+ totalHeight: 10000,
1094
+ totalWidth: 0,
1095
+ usableHeight: 500,
1096
+ usableWidth: 500,
1097
+ stickyIndices: [ 100 ],
1098
+ });
1099
+ expect(result.targetY).toBe(5950);
1100
+ });
1101
+
1102
+ it('calculates target for vertical auto alignment (item taller than viewport)', () => {
1103
+ const result = calculateScrollTarget({
1104
+ colIndex: null,
1105
+ columnCount: 0,
1106
+ columnGap: 0,
1107
+ direction: 'vertical',
1108
+ fixedSize: 1000,
1109
+ fixedWidth: null,
1110
+ gap: 0,
1111
+ getColumnQuery: () => 0,
1112
+ getColumnSize: () => 0,
1113
+ getItemQueryX: () => 0,
1114
+ getItemQueryY: (idx) => idx * 1000,
1115
+ getItemSizeX: () => 0,
1116
+ getItemSizeY: () => 1000,
1117
+ itemsLength: 10,
1118
+ options: 'auto',
1119
+ relativeScrollX: 0,
1120
+ relativeScrollY: 0,
1121
+ rowIndex: 5, // Starts at 5000
1122
+ totalHeight: 10000,
1123
+ totalWidth: 0,
1124
+ usableHeight: 500,
1125
+ usableWidth: 500,
1126
+ });
1127
+ // Item 5 starts at 5000, ends 6000.
1128
+ // Viewport 0-500.
1129
+ // Item > Viewport.
1130
+ // 5000 > 0 + 0.5 && 6000 >= 500 - 0.5. (Visible check for large items)
1131
+ // Actually, if it's not visible, auto align.
1132
+ // targetStart = 5000. targetEnd = 5000 - (500 - 1000) = 5500.
1133
+ // Nearest to 0 is 5000.
1134
+ expect(result.targetY).toBe(5000);
1135
+ });
1136
+
1137
+ it('calculates target for vertical auto alignment (sticky indices present but none active)', () => {
1138
+ const result = calculateScrollTarget({
1139
+ colIndex: null,
1140
+ columnCount: 0,
1141
+ columnGap: 0,
1142
+ direction: 'vertical',
1143
+ fixedSize: 50,
1144
+ fixedWidth: null,
1145
+ gap: 0,
1146
+ getColumnQuery: () => 0,
1147
+ getColumnSize: () => 0,
1148
+ getItemQueryX: () => 0,
1149
+ getItemQueryY: (idx) => idx * 50,
1150
+ getItemSizeX: () => 0,
1151
+ getItemSizeY: () => 50,
1152
+ itemsLength: 200,
1153
+ options: 'auto',
1154
+ relativeScrollX: 0,
1155
+ relativeScrollY: 8000,
1156
+ rowIndex: 50, // Target 50 (2500).
1157
+ totalHeight: 10000,
1158
+ totalWidth: 0,
1159
+ usableHeight: 500,
1160
+ usableWidth: 500,
1161
+ stickyIndices: [ 100 ], // Sticky 100 is AFTER target 50.
1162
+ });
1163
+ // Should align to 2500 normally without sticky adjustment.
1164
+ expect(result.targetY).toBe(2500);
1165
+ });
1166
+
1167
+ it('calculates target for horizontal start alignment (sticky indices present but none active)', () => {
1168
+ const result = calculateScrollTarget({
1169
+ colIndex: 50,
1170
+ columnCount: 200,
1171
+ columnGap: 0,
1172
+ direction: 'horizontal',
1173
+ fixedSize: 50,
1174
+ fixedWidth: null,
1175
+ gap: 0,
1176
+ getColumnQuery: () => 0,
1177
+ getColumnSize: () => 0,
1178
+ getItemQueryX: (idx) => idx * 50,
1179
+ getItemQueryY: () => 0,
1180
+ getItemSizeX: () => 50,
1181
+ getItemSizeY: () => 0,
1182
+ itemsLength: 200,
1183
+ options: 'start',
1184
+ relativeScrollX: 0,
1185
+ relativeScrollY: 0,
1186
+ rowIndex: null,
1187
+ totalHeight: 0,
1188
+ totalWidth: 10000,
1189
+ usableHeight: 500,
1190
+ usableWidth: 500,
1191
+ stickyIndices: [ 100 ], // Sticky 100 is AFTER target 50.
1192
+ });
1193
+ // Target 50 at 2500. No sticky adjustment.
1194
+ expect(result.targetX).toBe(2500);
1195
+ });
1196
+
1197
+ it('calculates target for vertical auto alignment (large item already visible)', () => {
1198
+ const result = calculateScrollTarget({
1199
+ colIndex: null,
1200
+ columnCount: 0,
1201
+ columnGap: 0,
1202
+ direction: 'vertical',
1203
+ fixedSize: 1000,
1204
+ fixedWidth: null,
1205
+ gap: 0,
1206
+ getColumnQuery: () => 0,
1207
+ getColumnSize: () => 0,
1208
+ getItemQueryX: () => 0,
1209
+ getItemQueryY: (idx) => idx * 1000,
1210
+ getItemSizeX: () => 0,
1211
+ getItemSizeY: () => 1000,
1212
+ itemsLength: 10,
1213
+ options: 'auto',
1214
+ relativeScrollX: 0,
1215
+ relativeScrollY: 200, // Item 0 (0-1000) covers viewport (200-700).
1216
+ rowIndex: 0,
1217
+ totalHeight: 10000,
1218
+ totalWidth: 0,
1219
+ usableHeight: 500,
1220
+ usableWidth: 500,
1221
+ });
1222
+ // Should stay at 200.
1223
+ expect(result.targetY).toBe(200);
1224
+ });
1225
+
1226
+ it('detects visibility correctly when under a sticky item (auto alignment)', () => {
1227
+ const getItemQueryY = (index: number) => index * 100;
1228
+ const getItemSizeY = () => 100;
1229
+
1230
+ const params = {
1231
+ colIndex: null,
1232
+ columnCount: 100,
1233
+ columnGap: 0,
1234
+ direction: 'vertical' as const,
1235
+ fixedSize: 100,
1236
+ fixedWidth: null,
1237
+ gap: 0,
1238
+ getColumnQuery: (idx: number) => idx * 100,
1239
+ getColumnSize: () => 100,
1240
+ getItemQueryX: (idx: number) => idx * 100,
1241
+ getItemQueryY,
1242
+ getItemSizeX: () => 100,
1243
+ getItemSizeY,
1244
+ itemsLength: 1000,
1245
+ options: 'auto' as const,
1246
+ relativeScrollX: 0,
1247
+ relativeScrollY: 14950, // Row 150 is at 15000. It's at 50px from top.
1248
+ rowIndex: 150,
1249
+ stickyIndices: [ 100 ], // Sticky item at row 100 (10000), height 100.
1250
+ totalHeight: 120000,
1251
+ totalWidth: 10000,
1252
+ usableHeight: 800,
1253
+ usableWidth: 1000,
1254
+ };
1255
+
1256
+ const result = calculateScrollTarget(params);
1257
+
1258
+ // Row 150 starts at 15000. Viewport position = 15000 - 14950 = 50.
1259
+ // Sticky row 100 is at 0..100 in viewport.
1260
+ // Row 150 is hidden under sticky row 100.
1261
+ // Expected: detects it's covered and scrolls to 15000 - 100 = 14900.
1262
+ expect(result.targetY).toBe(14900);
1263
+ });
1264
+
1265
+ it('aligns correctly under a sticky item (start alignment)', () => {
1266
+ const getItemQueryY = (index: number) => {
1267
+ if (index <= 100) {
1268
+ return index * 120;
1269
+ }
1270
+ let sum = 12000;
1271
+ for (let i = 100; i < index; i++) {
1272
+ sum += (i % 2 === 0 ? 80 : 160);
1273
+ }
1274
+ return sum;
1275
+ };
1276
+
1277
+ const getItemSizeY = (index: number) => (index % 2 === 0 ? 80 : 160);
1278
+
1279
+ const params = {
1280
+ colIndex: 50,
1281
+ columnCount: 100,
1282
+ columnGap: 0,
1283
+ direction: 'both' as const,
1284
+ fixedSize: null,
1285
+ fixedWidth: null,
1286
+ gap: 0,
1287
+ getColumnQuery: (idx: number) => idx * 100,
1288
+ getColumnSize: () => 100,
1289
+ getItemQueryX: (idx: number) => idx * 100,
1290
+ getItemQueryY,
1291
+ getItemSizeX: () => 100,
1292
+ getItemSizeY,
1293
+ itemsLength: 1000,
1294
+ options: 'start' as const,
1295
+ relativeScrollX: 0,
1296
+ relativeScrollY: 0,
1297
+ rowIndex: 150,
1298
+ stickyIndices: [ 100, 200, 300 ],
1299
+ totalHeight: 120000,
1300
+ totalWidth: 10000,
1301
+ usableHeight: 800,
1302
+ usableWidth: 1000,
1303
+ };
1304
+
1305
+ const result = calculateScrollTarget(params);
1306
+
1307
+ // itemY(150) = 18000.
1308
+ // activeStickyIdx = 100. stickyHeight = 80.
1309
+ // targetY = 18000 - 80 = 17920.
1310
+ expect(result.targetY).toBe(17920);
1311
+ });
1312
+
1313
+ it('aligns to end when scrolling forward (vertical)', () => {
1314
+ const params = {
1315
+ rowIndex: 150,
1316
+ colIndex: null,
1317
+ options: 'auto' as const,
1318
+ itemsLength: 1000,
1319
+ columnCount: 0,
1320
+ direction: 'vertical' as const,
1321
+ usableWidth: 1000,
1322
+ usableHeight: 800,
1323
+ totalWidth: 1000,
1324
+ totalHeight: 100000,
1325
+ gap: 0,
1326
+ columnGap: 0,
1327
+ fixedSize: 100,
1328
+ fixedWidth: null,
1329
+ relativeScrollX: 0,
1330
+ relativeScrollY: 0,
1331
+ getItemSizeY: () => 100,
1332
+ getItemSizeX: () => 1000,
1333
+ getItemQueryY: (idx: number) => idx * 100,
1334
+ getItemQueryX: () => 0,
1335
+ getColumnSize: () => 0,
1336
+ getColumnQuery: () => 0,
1337
+ stickyIndices: [],
1338
+ };
1339
+
1340
+ const result = calculateScrollTarget(params);
1341
+ // itemY(150) = 15000. viewportHeight = 800. itemHeight = 100.
1342
+ // targetEnd = 15000 - (800 - 100) = 14300.
1343
+ // current relativeScrollY = 0.
1344
+ // minimal movement would pick 14300 anyway.
1345
+ expect(result.targetY).toBe(14300);
1346
+ expect(result.effectiveAlignY).toBe('end');
1347
+ });
1348
+
1349
+ it('aligns to start when scrolling backward (vertical)', () => {
1350
+ const params = {
1351
+ rowIndex: 10,
1352
+ colIndex: null,
1353
+ options: 'auto' as const,
1354
+ itemsLength: 1000,
1355
+ columnCount: 0,
1356
+ direction: 'vertical' as const,
1357
+ usableWidth: 1000,
1358
+ usableHeight: 800,
1359
+ totalWidth: 1000,
1360
+ totalHeight: 100000,
1361
+ gap: 0,
1362
+ columnGap: 0,
1363
+ fixedSize: 100,
1364
+ fixedWidth: null,
1365
+ relativeScrollX: 0,
1366
+ relativeScrollY: 15000, // We are at row 150
1367
+ getItemSizeY: () => 100,
1368
+ getItemSizeX: () => 1000,
1369
+ getItemQueryY: (idx: number) => idx * 100,
1370
+ getItemQueryX: () => 0,
1371
+ getColumnSize: () => 0,
1372
+ getColumnQuery: () => 0,
1373
+ stickyIndices: [],
1374
+ };
1375
+
1376
+ const result = calculateScrollTarget(params);
1377
+ // itemY(10) = 1000.
1378
+ // relativeScrollY = 15000.
1379
+ // minimal movement picks 1000.
1380
+ expect(result.targetY).toBe(1000);
1381
+ expect(result.effectiveAlignY).toBe('start');
1382
+ });
1383
+
1384
+ it('stays put if already visible (vertical)', () => {
1385
+ const params = {
1386
+ rowIndex: 150,
1387
+ colIndex: null,
1388
+ options: 'auto' as const,
1389
+ itemsLength: 1000,
1390
+ columnCount: 0,
1391
+ direction: 'vertical' as const,
1392
+ usableWidth: 1000,
1393
+ usableHeight: 800,
1394
+ totalWidth: 1000,
1395
+ totalHeight: 100000,
1396
+ gap: 0,
1397
+ columnGap: 0,
1398
+ fixedSize: 100,
1399
+ fixedWidth: null,
1400
+ relativeScrollX: 0,
1401
+ relativeScrollY: 14500, // item 150 is at 15000. Viewport is 14500 to 15300.
1402
+ getItemSizeY: () => 100,
1403
+ getItemSizeX: () => 1000,
1404
+ getItemQueryY: (idx: number) => idx * 100,
1405
+ getItemQueryX: () => 0,
1406
+ getColumnSize: () => 0,
1407
+ getColumnQuery: () => 0,
1408
+ stickyIndices: [],
1409
+ };
1410
+
1411
+ const result = calculateScrollTarget(params);
1412
+ expect(result.targetY).toBe(14500);
1413
+ expect(result.effectiveAlignY).toBe('auto');
1414
+ });
1415
+
1416
+ it('aligns to start if partially visible at top (backward scroll effect)', () => {
1417
+ const params = {
1418
+ rowIndex: 150,
1419
+ colIndex: null,
1420
+ options: 'auto' as const,
1421
+ itemsLength: 1000,
1422
+ columnCount: 0,
1423
+ direction: 'vertical' as const,
1424
+ usableWidth: 1000,
1425
+ usableHeight: 800,
1426
+ totalWidth: 1000,
1427
+ totalHeight: 100000,
1428
+ gap: 0,
1429
+ columnGap: 0,
1430
+ fixedSize: 100,
1431
+ fixedWidth: null,
1432
+ relativeScrollX: 0,
1433
+ relativeScrollY: 15050, // item 150 starts at 15000. Viewport is 15050 to 15850. Item is partially visible at top.
1434
+ getItemSizeY: () => 100,
1435
+ getItemSizeX: () => 1000,
1436
+ getItemQueryY: (idx: number) => idx * 100,
1437
+ getItemQueryX: () => 0,
1438
+ getColumnSize: () => 0,
1439
+ getColumnQuery: () => 0,
1440
+ stickyIndices: [],
1441
+ };
1442
+
1443
+ const result = calculateScrollTarget(params);
1444
+ // targetY should be 15000 (start alignment)
1445
+ expect(result.targetY).toBe(15000);
1446
+ expect(result.effectiveAlignY).toBe('start');
1447
+ });
1448
+
1449
+ it('aligns to end if partially visible at bottom (forward scroll effect)', () => {
1450
+ const params = {
1451
+ rowIndex: 150,
1452
+ colIndex: null,
1453
+ options: 'auto' as const,
1454
+ itemsLength: 1000,
1455
+ columnCount: 0,
1456
+ direction: 'vertical' as const,
1457
+ usableWidth: 1000,
1458
+ usableHeight: 800,
1459
+ totalWidth: 1000,
1460
+ totalHeight: 100000,
1461
+ gap: 0,
1462
+ columnGap: 0,
1463
+ fixedSize: 100,
1464
+ fixedWidth: null,
1465
+ relativeScrollX: 0,
1466
+ relativeScrollY: 14250, // item 150 is at 15000. Viewport ends at 15050. Item is partially visible at bottom.
1467
+ getItemSizeY: () => 100,
1468
+ getItemSizeX: () => 1000,
1469
+ getItemQueryY: (idx: number) => idx * 100,
1470
+ getItemQueryX: () => 0,
1471
+ getColumnSize: () => 0,
1472
+ getColumnQuery: () => 0,
1473
+ stickyIndices: [],
1474
+ };
1475
+
1476
+ const result = calculateScrollTarget(params);
1477
+ // targetY should be 15000 - (800 - 100) = 14300 (end alignment)
1478
+ expect(result.targetY).toBe(14300);
1479
+ expect(result.effectiveAlignY).toBe('end');
1480
+ });
1481
+
1482
+ it('aligns large item correctly when scrolling forward (minimal movement)', () => {
1483
+ const params = {
1484
+ rowIndex: 150,
1485
+ colIndex: null,
1486
+ options: 'auto' as const,
1487
+ itemsLength: 1000,
1488
+ columnCount: 0,
1489
+ direction: 'vertical' as const,
1490
+ usableWidth: 1000,
1491
+ usableHeight: 500,
1492
+ totalWidth: 1000,
1493
+ totalHeight: 1000000, // Large enough
1494
+ gap: 0,
1495
+ columnGap: 0,
1496
+ fixedSize: 1000, // Large item
1497
+ fixedWidth: null,
1498
+ relativeScrollX: 0,
1499
+ relativeScrollY: 0,
1500
+ getItemSizeY: () => 1000,
1501
+ getItemSizeX: () => 1000,
1502
+ getItemQueryY: (idx: number) => idx * 1000,
1503
+ getItemQueryX: () => 0,
1504
+ getColumnSize: () => 0,
1505
+ getColumnQuery: () => 0,
1506
+ stickyIndices: [],
1507
+ };
1508
+
1509
+ const result = calculateScrollTarget(params);
1510
+ // itemY(150) = 150000. relativeScrollY = 0.
1511
+ // targetStart = 150000.
1512
+ // targetEnd = 150000 - (500 - 1000) = 150500.
1513
+ // Minimal movement picks 150000.
1514
+ expect(result.targetY).toBe(150000);
1515
+ expect(result.effectiveAlignY).toBe('start');
1516
+ });
1517
+
1518
+ it('aligns large item correctly when scrolling backward (minimal movement)', () => {
1519
+ const params = {
1520
+ rowIndex: 10,
1521
+ colIndex: null,
1522
+ options: 'auto' as const,
1523
+ itemsLength: 1000,
1524
+ columnCount: 0,
1525
+ direction: 'vertical' as const,
1526
+ usableWidth: 1000,
1527
+ usableHeight: 500,
1528
+ totalWidth: 1000,
1529
+ totalHeight: 1000000,
1530
+ gap: 0,
1531
+ columnGap: 0,
1532
+ fixedSize: 1000, // Large item
1533
+ fixedWidth: null,
1534
+ relativeScrollX: 0,
1535
+ relativeScrollY: 100000,
1536
+ getItemSizeY: () => 1000,
1537
+ getItemSizeX: () => 1000,
1538
+ getItemQueryY: (idx: number) => idx * 1000,
1539
+ getItemQueryX: () => 0,
1540
+ getColumnSize: () => 0,
1541
+ getColumnQuery: () => 0,
1542
+ stickyIndices: [],
1543
+ };
1544
+
1545
+ const result = calculateScrollTarget(params);
1546
+ // itemY(10) = 10000. relativeScrollY = 100000.
1547
+ // targetStart = 10000.
1548
+ // targetEnd = 10000 - (500 - 1000) = 10500.
1549
+ // Minimal movement picks 10500.
1550
+ expect(result.targetY).toBe(10500);
1551
+ expect(result.effectiveAlignY).toBe('end');
1552
+ });
1553
+
1554
+ it('aligns large item correctly on X axis (minimal movement)', () => {
1555
+ const params = {
1556
+ rowIndex: null,
1557
+ colIndex: 150,
1558
+ options: 'auto' as const,
1559
+ itemsLength: 0,
1560
+ columnCount: 1000,
1561
+ direction: 'horizontal' as const,
1562
+ usableWidth: 500,
1563
+ usableHeight: 1000,
1564
+ totalWidth: 1000000,
1565
+ totalHeight: 1000,
1566
+ gap: 0,
1567
+ columnGap: 0,
1568
+ fixedSize: 1000, // In horizontal mode, fixedSize is the width
1569
+ fixedWidth: null,
1570
+ relativeScrollX: 0,
1571
+ relativeScrollY: 0,
1572
+ getItemSizeY: () => 1000,
1573
+ getItemSizeX: () => 1000,
1574
+ getItemQueryY: () => 0,
1575
+ getItemQueryX: (idx: number) => idx * 1000,
1576
+ getColumnSize: () => 1000,
1577
+ getColumnQuery: (idx: number) => idx * 1000,
1578
+ stickyIndices: [],
1579
+ };
1580
+
1581
+ const result = calculateScrollTarget(params);
1582
+ // itemX(150) = 150000. relativeScrollX = 0.
1583
+ // targetStart = 150000.
1584
+ // targetEnd = 150000 - (500 - 1000) = 150500.
1585
+ // Minimal movement picks 150000.
1586
+ expect(result.targetX).toBe(150000);
1587
+ expect(result.effectiveAlignX).toBe('start');
1588
+ });
1589
+
1590
+ it('aligns large item correctly on X axis scrolling backward (minimal movement)', () => {
1591
+ const params = {
1592
+ rowIndex: null,
1593
+ colIndex: 10,
1594
+ options: 'auto' as const,
1595
+ itemsLength: 0,
1596
+ columnCount: 1000,
1597
+ direction: 'horizontal' as const,
1598
+ usableWidth: 500,
1599
+ usableHeight: 1000,
1600
+ totalWidth: 1000000,
1601
+ totalHeight: 1000,
1602
+ gap: 0,
1603
+ columnGap: 0,
1604
+ fixedSize: 1000,
1605
+ fixedWidth: null,
1606
+ relativeScrollX: 100000,
1607
+ relativeScrollY: 0,
1608
+ getItemSizeY: () => 1000,
1609
+ getItemSizeX: () => 1000,
1610
+ getItemQueryY: () => 0,
1611
+ getItemQueryX: (idx: number) => idx * 1000,
1612
+ getColumnSize: () => 1000,
1613
+ getColumnQuery: (idx: number) => idx * 1000,
1614
+ stickyIndices: [],
1615
+ };
1616
+
1617
+ const result = calculateScrollTarget(params);
1618
+ // itemX(10) = 10000. relativeScrollX = 100000.
1619
+ // targetStart = 10000.
1620
+ // targetEnd = 10000 - (500 - 1000) = 10500.
1621
+ // Minimal movement picks 10500.
1622
+ expect(result.targetX).toBe(10500);
1623
+ expect(result.effectiveAlignX).toBe('end');
1624
+ });
1625
+
1626
+ it('calculates target when colIndex is past columnCount', () => {
1627
+ const result = calculateScrollTarget({
1628
+ colIndex: 200,
1629
+ columnCount: 100,
1630
+ columnGap: 10,
1631
+ direction: 'horizontal',
1632
+ fixedSize: 50,
1633
+ fixedWidth: null,
1634
+ gap: 0,
1635
+ getColumnQuery: () => 0,
1636
+ getColumnSize: () => 0,
1637
+ getItemQueryX: (idx) => idx * 60,
1638
+ getItemQueryY: () => 0,
1639
+ getItemSizeX: () => 50,
1640
+ getItemSizeY: () => 0,
1641
+ itemsLength: 100,
1642
+ options: 'start',
1643
+ relativeScrollX: 0,
1644
+ relativeScrollY: 0,
1645
+ rowIndex: null,
1646
+ totalHeight: 0,
1647
+ totalWidth: 6000,
1648
+ usableHeight: 500,
1649
+ usableWidth: 500,
1650
+ });
1651
+ expect(result.targetX).toBe(5500);
1652
+ });
1653
+
1654
+ it('aligns to start when scrolling backward on X axis (horizontal)', () => {
1655
+ const params = {
1656
+ rowIndex: null,
1657
+ colIndex: 10,
1658
+ options: 'auto' as const,
1659
+ itemsLength: 0,
1660
+ columnCount: 1000,
1661
+ direction: 'horizontal' as const,
1662
+ usableWidth: 1000,
1663
+ usableHeight: 800,
1664
+ totalWidth: 100000,
1665
+ totalHeight: 1000,
1666
+ gap: 0,
1667
+ columnGap: 0,
1668
+ fixedSize: 100,
1669
+ fixedWidth: null,
1670
+ relativeScrollX: 15000, // item 10 is at 1000
1671
+ relativeScrollY: 0,
1672
+ getItemSizeY: () => 1000,
1673
+ getItemSizeX: () => 100,
1674
+ getItemQueryY: () => 0,
1675
+ getItemQueryX: (idx: number) => idx * 100,
1676
+ getColumnSize: () => 0,
1677
+ getColumnQuery: () => 0,
1678
+ stickyIndices: [],
1679
+ };
1680
+
1681
+ const result = calculateScrollTarget(params);
1682
+ expect(result.targetX).toBe(1000);
1683
+ expect(result.effectiveAlignX).toBe('start');
1684
+ });
1685
+
1686
+ it('aligns to end when scrolling forward on X axis (horizontal)', () => {
1687
+ const params = {
1688
+ rowIndex: null,
1689
+ colIndex: 150,
1690
+ options: 'auto' as const,
1691
+ itemsLength: 0,
1692
+ columnCount: 1000,
1693
+ direction: 'horizontal' as const,
1694
+ usableWidth: 1000,
1695
+ usableHeight: 800,
1696
+ totalWidth: 100000,
1697
+ totalHeight: 1000,
1698
+ gap: 0,
1699
+ columnGap: 0,
1700
+ fixedSize: 100,
1701
+ fixedWidth: null,
1702
+ relativeScrollX: 0,
1703
+ relativeScrollY: 0,
1704
+ getItemSizeY: () => 1000,
1705
+ getItemSizeX: () => 100,
1706
+ getItemQueryY: () => 0,
1707
+ getItemQueryX: (idx: number) => idx * 100,
1708
+ getColumnSize: () => 0,
1709
+ getColumnQuery: () => 0,
1710
+ stickyIndices: [],
1711
+ };
1712
+
1713
+ const result = calculateScrollTarget(params);
1714
+ // itemX(150) = 15000. viewportWidth = 1000. itemWidth = 100.
1715
+ // targetEnd = 15000 - (1000 - 100) = 14100.
1716
+ expect(result.targetX).toBe(14100);
1717
+ expect(result.effectiveAlignX).toBe('end');
1718
+ });
1719
+
1720
+ it('stays put if colIndex already visible (horizontal)', () => {
1721
+ const params = {
1722
+ rowIndex: null,
1723
+ colIndex: 150,
1724
+ options: 'auto' as const,
1725
+ itemsLength: 0,
1726
+ columnCount: 1000,
1727
+ direction: 'horizontal' as const,
1728
+ usableWidth: 1000,
1729
+ usableHeight: 800,
1730
+ totalWidth: 100000,
1731
+ totalHeight: 1000,
1732
+ gap: 0,
1733
+ columnGap: 0,
1734
+ fixedSize: 100,
1735
+ fixedWidth: null,
1736
+ relativeScrollX: 14500, // item 150 is at 15000. Viewport is 14500 to 15500.
1737
+ relativeScrollY: 0,
1738
+ getItemSizeY: () => 1000,
1739
+ getItemSizeX: () => 100,
1740
+ getItemQueryY: () => 0,
1741
+ getItemQueryX: (idx: number) => idx * 100,
1742
+ getColumnSize: () => 0,
1743
+ getColumnQuery: () => 0,
1744
+ stickyIndices: [],
1745
+ };
1746
+
1747
+ const result = calculateScrollTarget(params);
1748
+ expect(result.targetX).toBe(14500);
1749
+ expect(result.effectiveAlignX).toBe('auto');
1750
+ });
1751
+ });
1752
+
1753
+ describe('calculateColumnRange', () => {
1754
+ it('calculates column range with dynamic width and 0 columns', () => {
1755
+ const result = calculateColumnRange({
1756
+ colBuffer: 0,
1757
+ columnCount: 0,
1758
+ columnGap: 10,
1759
+ fixedWidth: null,
1760
+ findLowerBound: () => 0,
1761
+ query: () => 0,
1762
+ relativeScrollX: 0,
1763
+ totalColsQuery: () => 0,
1764
+ usableWidth: 200,
1765
+ });
1766
+ expect(result.padStart).toBe(0);
1767
+ expect(result.padEnd).toBe(0);
1768
+ });
1769
+
1770
+ it('calculates column range with dynamic width', () => {
1771
+ const result = calculateColumnRange({
1772
+ colBuffer: 0,
1773
+ columnCount: 100,
1774
+ columnGap: 10,
1775
+ fixedWidth: null,
1776
+ findLowerBound: (offset) => Math.floor(offset / 110),
1777
+ query: (idx) => idx * 110,
1778
+ relativeScrollX: 220,
1779
+ totalColsQuery: () => 100 * 110,
1780
+ usableWidth: 200,
1781
+ });
1782
+ expect(result.start).toBe(2);
1783
+ expect(result.end).toBe(4);
1784
+ expect(result.padStart).toBe(220);
1785
+ expect(result.padEnd).toBe(100 * 110 - 10 - 440);
1786
+ });
1787
+
1788
+ it('calculates column range with fixed width where safeEnd is 0', () => {
1789
+ const result = calculateColumnRange({
1790
+ colBuffer: 0,
1791
+ columnCount: 10,
1792
+ columnGap: 10,
1793
+ fixedWidth: 100,
1794
+ findLowerBound: () => 0,
1795
+ query: () => 0,
1796
+ relativeScrollX: 0,
1797
+ totalColsQuery: () => 1090,
1798
+ usableWidth: 0,
1799
+ });
1800
+ // safeEnd will be 0 if viewportWidth is 0 and colBuffer is 0
1801
+ expect(result.end).toBe(0);
1802
+ expect(result.padEnd).toBe(1090);
1803
+ });
1804
+
1805
+ it('calculates column range with fixed width and 1 column', () => {
1806
+ const result = calculateColumnRange({
1807
+ colBuffer: 0,
1808
+ columnCount: 1,
1809
+ columnGap: 10,
1810
+ fixedWidth: 100,
1811
+ findLowerBound: () => 0,
1812
+ query: () => 0,
1813
+ relativeScrollX: 0,
1814
+ totalColsQuery: () => 100,
1815
+ usableWidth: 200,
1816
+ });
1817
+ expect(result.padStart).toBe(0);
1818
+ expect(result.padEnd).toBe(0);
1819
+ });
1820
+
1821
+ it('calculates column range with fixed width and 0 columns', () => {
1822
+ const result = calculateColumnRange({
1823
+ colBuffer: 0,
1824
+ columnCount: 0,
1825
+ columnGap: 10,
1826
+ fixedWidth: 100,
1827
+ findLowerBound: () => 0,
1828
+ query: () => 0,
1829
+ relativeScrollX: 0,
1830
+ totalColsQuery: () => 0,
1831
+ usableWidth: 200,
1832
+ });
1833
+ expect(result.padStart).toBe(0);
1834
+ expect(result.padEnd).toBe(0);
1835
+ });
1836
+
1837
+ it('calculates column range with fixed width', () => {
1838
+ const result = calculateColumnRange({
1839
+ colBuffer: 0,
1840
+ columnCount: 100,
1841
+ columnGap: 10,
1842
+ fixedWidth: 100,
1843
+ findLowerBound: () => 0,
1844
+ query: () => 0,
1845
+ relativeScrollX: 220,
1846
+ totalColsQuery: () => 100 * 110,
1847
+ usableWidth: 200,
1848
+ });
1849
+ // item 0: 0-100, gap 100-110
1850
+ // item 1: 110-210, gap 210-220
1851
+ // item 2: 220-320, gap 320-330
1852
+ // scroll 220 -> start 2.
1853
+ // viewport 200 -> ends 420. item 3: 330-430.
1854
+ expect(result.start).toBe(2);
1855
+ expect(result.end).toBe(4);
1856
+ expect(result.padStart).toBe(220);
1857
+ expect(result.padEnd).toBe(100 * 110 - 10 - 440);
1858
+ });
1859
+
1860
+ it('returns empty range when columnCount is 0', () => {
1861
+ const result = calculateColumnRange({
1862
+ colBuffer: 2,
1863
+ columnCount: 0,
1864
+ columnGap: 0,
1865
+ fixedWidth: null,
1866
+ findLowerBound: () => 0,
1867
+ query: () => 0,
1868
+ relativeScrollX: 0,
1869
+ totalColsQuery: () => 0,
1870
+ usableWidth: 500,
1871
+ });
1872
+ expect(result.end).toBe(0);
1873
+ });
1874
+
1875
+ it('calculates column range', () => {
1876
+ const result = calculateColumnRange({
1877
+ colBuffer: 2,
1878
+ columnCount: 100,
1879
+ columnGap: 0,
1880
+ fixedWidth: null,
1881
+ findLowerBound: (offset) => Math.floor(offset / 100),
1882
+ query: (idx) => idx * 100,
1883
+ relativeScrollX: 1000,
1884
+ totalColsQuery: () => 10000,
1885
+ usableWidth: 500,
1886
+ });
1887
+ expect(result.start).toBe(8);
1888
+ expect(result.end).toBe(17);
1889
+ });
1890
+
1891
+ it('calculates column range reaching the end of columns', () => {
1892
+ const result = calculateColumnRange({
1893
+ colBuffer: 0,
1894
+ columnCount: 10,
1895
+ columnGap: 10,
1896
+ fixedWidth: 100,
1897
+ findLowerBound: () => 0,
1898
+ query: () => 0,
1899
+ relativeScrollX: 1000,
1900
+ totalColsQuery: () => 1090,
1901
+ usableWidth: 500,
1902
+ });
1903
+
1904
+ // item 0: 0-100, gap 100-110, ... item 9: 990-1090.
1905
+ // relativeScrollX 1000 -> start 9.
1906
+ // viewportWidth 500 -> end 10.
1907
+ expect(result.start).toBe(9);
1908
+ expect(result.end).toBe(10);
1909
+ expect(result.padStart).toBe(9 * 110);
1910
+ expect(result.padEnd).toBe(0);
1911
+ });
1912
+
1913
+ it('calculates column range with dynamic width reaching the end of columns', () => {
1914
+ const result = calculateColumnRange({
1915
+ colBuffer: 0,
1916
+ columnCount: 10,
1917
+ columnGap: 10,
1918
+ fixedWidth: null,
1919
+ findLowerBound: (offset) => Math.floor(offset / 110),
1920
+ query: (idx) => idx * 110,
1921
+ relativeScrollX: 1000,
1922
+ totalColsQuery: () => 10 * 110,
1923
+ usableWidth: 500,
1924
+ });
1925
+ expect(result.start).toBe(9);
1926
+ expect(result.end).toBe(10);
1927
+ expect(result.padStart).toBe(9 * 110);
1928
+ expect(result.padEnd).toBe(0);
1929
+ });
1930
+ });
1931
+
1932
+ describe('calculateItemPosition', () => {
1933
+ it('calculates position for vertical item with fixed size', () => {
1934
+ const result = calculateItemPosition({
1935
+ columnGap: 0,
1936
+ direction: 'vertical',
1937
+ fixedSize: 50,
1938
+ gap: 10,
1939
+ getSizeX: () => 0,
1940
+ getSizeY: () => 50,
1941
+ index: 10,
1942
+ queryX: () => 0,
1943
+ queryY: () => 0,
1944
+ totalWidth: 500,
1945
+ usableHeight: 500,
1946
+ usableWidth: 500,
1947
+ });
1948
+ expect(result.y).toBe(600);
1949
+ expect(result.height).toBe(50);
1950
+ expect(result.width).toBe(500);
1951
+ });
1952
+
1953
+ it('calculates position for vertical item with dynamic size', () => {
1954
+ const result = calculateItemPosition({
1955
+ columnGap: 0,
1956
+ direction: 'vertical',
1957
+ fixedSize: null,
1958
+ gap: 10,
1959
+ getSizeX: () => 0,
1960
+ getSizeY: () => 60,
1961
+ index: 10,
1962
+ queryX: () => 0,
1963
+ queryY: (idx) => idx * 60,
1964
+ totalWidth: 500,
1965
+ usableHeight: 500,
1966
+ usableWidth: 500,
1967
+ });
1968
+ expect(result.y).toBe(600);
1969
+ expect(result.height).toBe(50);
1970
+ expect(result.width).toBe(500);
1971
+ });
1972
+
1973
+ it('calculates position for horizontal item with fixed size', () => {
1974
+ const result = calculateItemPosition({
1975
+ columnGap: 10,
1976
+ direction: 'horizontal',
1977
+ fixedSize: 50,
1978
+ gap: 0,
1979
+ getSizeX: () => 50,
1980
+ getSizeY: () => 0,
1981
+ index: 10,
1982
+ queryX: () => 0,
1983
+ queryY: () => 0,
1984
+ totalWidth: 5000,
1985
+ usableHeight: 500,
1986
+ usableWidth: 500,
1987
+ });
1988
+ expect(result.x).toBe(600);
1989
+ expect(result.width).toBe(50);
1990
+ expect(result.height).toBe(500);
1991
+ });
1992
+
1993
+ it('calculates position for horizontal item with dynamic size', () => {
1994
+ const result = calculateItemPosition({
1995
+ columnGap: 10,
1996
+ direction: 'horizontal',
1997
+ fixedSize: null,
1998
+ gap: 0,
1999
+ getSizeX: () => 60,
2000
+ getSizeY: () => 0,
2001
+ index: 10,
2002
+ queryX: (idx) => idx * 60,
2003
+ queryY: () => 0,
2004
+ totalWidth: 5000,
2005
+ usableHeight: 500,
2006
+ usableWidth: 500,
2007
+ });
2008
+ expect(result.x).toBe(600);
2009
+ expect(result.width).toBe(50);
2010
+ expect(result.height).toBe(500);
2011
+ });
2012
+
2013
+ it('calculates position for grid (both) item with dynamic size', () => {
2014
+ const result = calculateItemPosition({
2015
+ columnGap: 10,
2016
+ direction: 'both',
2017
+ fixedSize: null,
2018
+ gap: 10,
2019
+ getSizeX: () => 0,
2020
+ getSizeY: () => 60,
2021
+ index: 10,
2022
+ queryX: () => 0,
2023
+ queryY: (idx) => idx * 60,
2024
+ totalWidth: 5000,
2025
+ usableHeight: 500,
2026
+ usableWidth: 500,
2027
+ });
2028
+ expect(result.y).toBe(600);
2029
+ expect(result.height).toBe(50);
2030
+ expect(result.width).toBe(5000);
2031
+ });
2032
+ });
2033
+
2034
+ describe('calculateStickyItem', () => {
2035
+ it('calculates sticky offset when pushing (vertical, dynamic size)', () => {
2036
+ const result = calculateStickyItem({
2037
+ columnGap: 0,
2038
+ direction: 'vertical',
2039
+ fixedSize: null,
2040
+ fixedWidth: null,
2041
+ gap: 0,
2042
+ getItemQueryX: () => 0,
2043
+ getItemQueryY: (idx) => idx * 50,
2044
+ height: 50,
2045
+ index: 0,
2046
+ isSticky: true,
2047
+ originalX: 0,
2048
+ originalY: 0,
2049
+ relativeScrollX: 0,
2050
+ relativeScrollY: 480, // item 10 starts at 500
2051
+ stickyIndices: [ 0, 10 ],
2052
+ width: 500,
2053
+ });
2054
+ expect(result.isStickyActive).toBe(true);
2055
+ expect(result.stickyOffset.y).toBe(-30);
2056
+ });
2057
+
2058
+ it('calculates sticky offset when pushing (horizontal, fixed size)', () => {
2059
+ const result = calculateStickyItem({
2060
+ columnGap: 0,
2061
+ direction: 'horizontal',
2062
+ fixedSize: 50,
2063
+ fixedWidth: null,
2064
+ gap: 0,
2065
+ getItemQueryX: (idx) => idx * 50,
2066
+ getItemQueryY: () => 0,
2067
+ height: 500,
2068
+ index: 0,
2069
+ isSticky: true,
2070
+ originalX: 0,
2071
+ originalY: 0,
2072
+ relativeScrollX: 480,
2073
+ relativeScrollY: 0,
2074
+ stickyIndices: [ 0, 10 ],
2075
+ width: 50,
2076
+ });
2077
+ expect(result.isStickyActive).toBe(true);
2078
+ expect(result.stickyOffset.x).toBe(-30);
2079
+ });
2080
+
2081
+ it('is not sticky if scroll is before original position (horizontal)', () => {
2082
+ const result = calculateStickyItem({
2083
+ columnGap: 0,
2084
+ direction: 'horizontal',
2085
+ fixedSize: 50,
2086
+ fixedWidth: 100,
2087
+ gap: 0,
2088
+ getItemQueryX: (idx) => idx * 100,
2089
+ getItemQueryY: (idx) => idx * 50,
2090
+ height: 50,
2091
+ index: 1,
2092
+ isSticky: true,
2093
+ originalX: 100,
2094
+ originalY: 0,
2095
+ relativeScrollX: 50,
2096
+ relativeScrollY: 0,
2097
+ stickyIndices: [ 1 ],
2098
+ width: 100,
2099
+ });
2100
+ expect(result.isStickyActive).toBe(false);
2101
+ });
2102
+
2103
+ it('does not calculate horizontal sticky if vertical is already active in grid mode', () => {
2104
+ const result = calculateStickyItem({
2105
+ columnGap: 0,
2106
+ direction: 'both',
2107
+ fixedSize: 50,
2108
+ fixedWidth: 100,
2109
+ gap: 0,
2110
+ getItemQueryX: (idx) => idx * 100,
2111
+ getItemQueryY: (idx) => idx * 50,
2112
+ height: 50,
2113
+ index: 0,
2114
+ isSticky: true,
2115
+ originalX: 0,
2116
+ originalY: 0,
2117
+ relativeScrollX: 10,
2118
+ relativeScrollY: 10, // vertical is active
2119
+ stickyIndices: [ 0 ],
2120
+ width: 100,
2121
+ });
2122
+ expect(result.isStickyActive).toBe(true);
2123
+ expect(result.stickyOffset.y).toBe(0);
2124
+ expect(result.stickyOffset.x).toBe(0); // should not have checked horizontal
2125
+ });
2126
+
2127
+ it('calculates sticky active state for both directions (horizontal first)', () => {
2128
+ const result = calculateStickyItem({
2129
+ columnGap: 0,
2130
+ direction: 'both',
2131
+ fixedSize: 50,
2132
+ fixedWidth: 100,
2133
+ gap: 0,
2134
+ getItemQueryX: (idx) => idx * 100,
2135
+ getItemQueryY: (idx) => idx * 50,
2136
+ height: 50,
2137
+ index: 0,
2138
+ isSticky: true,
2139
+ originalX: 0,
2140
+ originalY: 0,
2141
+ relativeScrollX: 10,
2142
+ relativeScrollY: 0,
2143
+ stickyIndices: [ 0 ],
2144
+ width: 100,
2145
+ });
2146
+ expect(result.isStickyActive).toBe(true);
2147
+ expect(result.stickyOffset.x).toBe(0);
2148
+ expect(result.stickyOffset.y).toBe(0);
2149
+ });
2150
+
2151
+ it('calculates sticky active state when past next item (grid, fixed size)', () => {
2152
+ const result = calculateStickyItem({
2153
+ columnGap: 10,
2154
+ direction: 'both',
2155
+ fixedSize: 50,
2156
+ fixedWidth: null,
2157
+ gap: 10,
2158
+ getItemQueryX: (idx) => idx * 60,
2159
+ getItemQueryY: (idx) => idx * 60,
2160
+ height: 50,
2161
+ index: 0,
2162
+ isSticky: true,
2163
+ originalX: 0,
2164
+ originalY: 0,
2165
+ relativeScrollX: 60, // item 1 starts at 60
2166
+ relativeScrollY: 0,
2167
+ stickyIndices: [ 0, 1 ],
2168
+ width: 50,
2169
+ });
2170
+ expect(result.isStickyActive).toBe(false);
2171
+ });
2172
+
2173
+ it('calculates sticky active state when past next item (grid, fixed width)', () => {
2174
+ const result = calculateStickyItem({
2175
+ columnGap: 10,
2176
+ direction: 'both',
2177
+ fixedSize: 50,
2178
+ fixedWidth: 100,
2179
+ gap: 10,
2180
+ getItemQueryX: (idx) => idx * 110,
2181
+ getItemQueryY: (idx) => idx * 60,
2182
+ height: 50,
2183
+ index: 0,
2184
+ isSticky: true,
2185
+ originalX: 0,
2186
+ originalY: 0,
2187
+ relativeScrollX: 110, // item 1 starts at 110
2188
+ relativeScrollY: 0,
2189
+ stickyIndices: [ 0, 1 ],
2190
+ width: 100,
2191
+ });
2192
+ expect(result.isStickyActive).toBe(false);
2193
+ });
2194
+
2195
+ it('calculates sticky active state when past next item (horizontal, fixed width)', () => {
2196
+ const result = calculateStickyItem({
2197
+ columnGap: 0,
2198
+ direction: 'horizontal',
2199
+ fixedSize: null,
2200
+ fixedWidth: 50,
2201
+ gap: 0,
2202
+ getItemQueryX: (idx) => idx * 50,
2203
+ getItemQueryY: () => 0,
2204
+ height: 500,
2205
+ index: 0,
2206
+ isSticky: true,
2207
+ originalX: 0,
2208
+ originalY: 0,
2209
+ relativeScrollX: 600, // item 10 starts at 500
2210
+ relativeScrollY: 0,
2211
+ stickyIndices: [ 0, 10 ],
2212
+ width: 50,
2213
+ });
2214
+
2215
+ expect(result.isStickyActive).toBe(false);
2216
+ });
2217
+
2218
+ it('calculates sticky active state when past next item (horizontal, fixed size)', () => {
2219
+ const result = calculateStickyItem({
2220
+ columnGap: 0,
2221
+ direction: 'horizontal',
2222
+ fixedSize: 50,
2223
+ fixedWidth: null,
2224
+ gap: 0,
2225
+ getItemQueryX: (idx) => idx * 50,
2226
+ getItemQueryY: () => 0,
2227
+ height: 500,
2228
+ index: 0,
2229
+ isSticky: true,
2230
+ originalX: 0,
2231
+ originalY: 0,
2232
+ relativeScrollX: 600, // item 10 starts at 500
2233
+ relativeScrollY: 0,
2234
+ stickyIndices: [ 0, 10 ],
2235
+ width: 50,
2236
+ });
2237
+ expect(result.isStickyActive).toBe(false);
2238
+ });
2239
+
2240
+ it('calculates sticky active state when no next sticky item (horizontal)', () => {
2241
+ const result = calculateStickyItem({
2242
+ columnGap: 0,
2243
+ direction: 'horizontal',
2244
+ fixedSize: 50,
2245
+ fixedWidth: null,
2246
+ gap: 0,
2247
+ getItemQueryX: (idx) => idx * 50,
2248
+ getItemQueryY: () => 0,
2249
+ height: 500,
2250
+ index: 10,
2251
+ isSticky: true,
2252
+ originalX: 500,
2253
+ originalY: 0,
2254
+ relativeScrollX: 600,
2255
+ relativeScrollY: 0,
2256
+ stickyIndices: [ 0, 10 ],
2257
+ width: 50,
2258
+ });
2259
+ expect(result.isStickyActive).toBe(true);
2260
+ expect(result.stickyOffset.x).toBe(0);
2261
+ });
2262
+
2263
+ it('calculates sticky active state when no next sticky item (vertical)', () => {
2264
+ const result = calculateStickyItem({
2265
+ columnGap: 0,
2266
+ direction: 'vertical',
2267
+ fixedSize: 50,
2268
+ fixedWidth: null,
2269
+ gap: 0,
2270
+ getItemQueryX: () => 0,
2271
+ getItemQueryY: (idx) => idx * 50,
2272
+ height: 50,
2273
+ index: 10,
2274
+ isSticky: true,
2275
+ originalX: 0,
2276
+ originalY: 500,
2277
+ relativeScrollX: 0,
2278
+ relativeScrollY: 600,
2279
+ stickyIndices: [ 0, 10 ],
2280
+ width: 500,
2281
+ });
2282
+ expect(result.isStickyActive).toBe(true);
2283
+ expect(result.stickyOffset.y).toBe(0);
2284
+ });
2285
+
2286
+ it('ensures only one sticky item is active at a time in a sequence', () => {
2287
+ const stickyIndices = [ 0, 1, 2, 3, 4 ];
2288
+ const scrollY = 75; // items are 50px high. So item 1 should be active.
2289
+
2290
+ const results = stickyIndices.map((idx) => calculateStickyItem({
2291
+ columnGap: 0,
2292
+ direction: 'vertical',
2293
+ fixedSize: 50,
2294
+ fixedWidth: null,
2295
+ gap: 0,
2296
+ getItemQueryX: () => 0,
2297
+ getItemQueryY: (i) => i * 50,
2298
+ height: 50,
2299
+ index: idx,
2300
+ isSticky: true,
2301
+ originalX: 0,
2302
+ originalY: idx * 50,
2303
+ relativeScrollX: 0,
2304
+ relativeScrollY: scrollY,
2305
+ stickyIndices,
2306
+ width: 500,
2307
+ }));
2308
+
2309
+ const activeIndices = results.map((r, i) => r.isStickyActive ? i : -1).filter((i) => i !== -1);
2310
+ expect(activeIndices).toEqual([ 1 ]);
2311
+ });
2312
+
2313
+ it('does not make non-sticky items active sticky', () => {
2314
+ const result = calculateStickyItem({
2315
+ columnGap: 0,
2316
+ direction: 'vertical',
2317
+ fixedSize: 50,
2318
+ fixedWidth: null,
2319
+ gap: 0,
2320
+ getItemQueryX: () => 0,
2321
+ getItemQueryY: (idx) => idx * 50,
2322
+ height: 50,
2323
+ index: 5,
2324
+ isSticky: false,
2325
+ originalX: 0,
2326
+ originalY: 250,
2327
+ relativeScrollX: 0,
2328
+ relativeScrollY: 300,
2329
+ stickyIndices: [ 0, 10 ],
2330
+ width: 500,
2331
+ });
2332
+ expect(result.isStickyActive).toBe(false);
2333
+ });
2334
+ });
2335
+
2336
+ describe('calculateItemStyle', () => {
2337
+ it('calculates style for table container', () => {
2338
+ const result = calculateItemStyle({
2339
+ containerTag: 'table',
2340
+ direction: 'vertical',
2341
+ isHydrated: true,
2342
+ item: {
2343
+ index: 10,
2344
+ isStickyActive: false,
2345
+ offset: { x: 0, y: 600 },
2346
+ size: { height: 50, width: 500 },
2347
+ stickyOffset: { x: 0, y: 0 },
2348
+ } as unknown as RenderedItem<unknown>,
2349
+ itemSize: 50,
2350
+ paddingStartX: 0,
2351
+ paddingStartY: 0,
2352
+ });
2353
+ expect(result.minInlineSize).toBe('100%');
2354
+ });
2355
+
2356
+ it('calculates style for dynamic item size', () => {
2357
+ const result = calculateItemStyle({
2358
+ containerTag: 'div',
2359
+ direction: 'vertical',
2360
+ isHydrated: true,
2361
+ item: {
2362
+ index: 10,
2363
+ isStickyActive: false,
2364
+ offset: { x: 0, y: 600 },
2365
+ size: { height: 50, width: 500 },
2366
+ stickyOffset: { x: 0, y: 0 },
2367
+ } as unknown as RenderedItem<unknown>,
2368
+ itemSize: 0,
2369
+ paddingStartX: 0,
2370
+ paddingStartY: 0,
2371
+ });
2372
+ expect(result.blockSize).toBe('auto');
2373
+ expect(result.minBlockSize).toBe('1px');
2374
+ });
2375
+
2376
+ it('calculates style for dynamic item size (horizontal)', () => {
2377
+ const result = calculateItemStyle({
2378
+ containerTag: 'div',
2379
+ direction: 'horizontal',
2380
+ isHydrated: true,
2381
+ item: {
2382
+ index: 10,
2383
+ isStickyActive: false,
2384
+ offset: { x: 600, y: 0 },
2385
+ size: { height: 500, width: 50 },
2386
+ stickyOffset: { x: 0, y: 0 },
2387
+ } as unknown as RenderedItem<unknown>,
2388
+ itemSize: 0,
2389
+ paddingStartX: 0,
2390
+ paddingStartY: 0,
2391
+ });
2392
+ expect(result.inlineSize).toBe('auto');
2393
+ expect(result.minInlineSize).toBe('1px');
2394
+ });
2395
+
2396
+ it('calculates style for sticky item (vertical only)', () => {
2397
+ const result = calculateItemStyle({
2398
+ containerTag: 'div',
2399
+ direction: 'vertical',
2400
+ isHydrated: true,
2401
+ item: {
2402
+ index: 10,
2403
+ isStickyActive: true,
2404
+ offset: { x: 0, y: 600 },
2405
+ size: { height: 50, width: 500 },
2406
+ stickyOffset: { x: 0, y: -10 },
2407
+ } as unknown as RenderedItem<unknown>,
2408
+ itemSize: 50,
2409
+ paddingStartX: 10,
2410
+ paddingStartY: 10,
2411
+ });
2412
+ expect(result.insetBlockStart).toBe('10px');
2413
+ expect(result.insetInlineStart).toBeUndefined();
2414
+ });
2415
+
2416
+ it('calculates style for sticky item (grid both directions)', () => {
2417
+ const result = calculateItemStyle({
2418
+ containerTag: 'div',
2419
+ direction: 'both',
2420
+ isHydrated: true,
2421
+ item: {
2422
+ index: 10,
2423
+ isStickyActive: true,
2424
+ offset: { x: 600, y: 600 },
2425
+ size: { height: 50, width: 50 },
2426
+ stickyOffset: { x: -10, y: -10 },
2427
+ } as unknown as RenderedItem<unknown>,
2428
+ itemSize: 50,
2429
+ paddingStartX: 10,
2430
+ paddingStartY: 10,
2431
+ });
2432
+ expect(result.insetBlockStart).toBe('10px');
2433
+ expect(result.insetInlineStart).toBe('10px');
2434
+ });
2435
+
2436
+ it('calculates style for sticky item (grid)', () => {
2437
+ const result = calculateItemStyle({
2438
+ containerTag: 'div',
2439
+ direction: 'both',
2440
+ isHydrated: true,
2441
+ item: {
2442
+ index: 10,
2443
+ isStickyActive: true,
2444
+ offset: { x: 600, y: 600 },
2445
+ size: { height: 50, width: 50 },
2446
+ stickyOffset: { x: -10, y: -10 },
2447
+ } as unknown as RenderedItem<unknown>,
2448
+ itemSize: 50,
2449
+ paddingStartX: 10,
2450
+ paddingStartY: 10,
2451
+ });
2452
+ expect(result.insetBlockStart).toBe('10px');
2453
+ expect(result.insetInlineStart).toBe('10px');
2454
+ expect(result.transform).toBe('translate(-10px, -10px)');
2455
+ });
2456
+
2457
+ it('calculates style for non-hydrated item', () => {
2458
+ const result = calculateItemStyle({
2459
+ containerTag: 'div',
2460
+ direction: 'vertical',
2461
+ isHydrated: false,
2462
+ item: {
2463
+ index: 10,
2464
+ isStickyActive: false,
2465
+ offset: { x: 0, y: 600 },
2466
+ size: { height: 50, width: 500 },
2467
+ stickyOffset: { x: 0, y: 0 },
2468
+ } as unknown as RenderedItem<unknown>,
2469
+ itemSize: 50,
2470
+ paddingStartX: 0,
2471
+ paddingStartY: 0,
2472
+ });
2473
+ expect(result.transform).toBeUndefined();
2474
+ });
2475
+
2476
+ it('calculates style for sticky item (horizontal)', () => {
2477
+ const result = calculateItemStyle({
2478
+ containerTag: 'div',
2479
+ direction: 'horizontal',
2480
+ isHydrated: true,
2481
+ item: {
2482
+ index: 10,
2483
+ isStickyActive: true,
2484
+ offset: { x: 600, y: 0 },
2485
+ size: { height: 500, width: 50 },
2486
+ stickyOffset: { x: -10, y: 0 },
2487
+ } as unknown as RenderedItem<unknown>,
2488
+ itemSize: 50,
2489
+ paddingStartX: 10,
2490
+ paddingStartY: 10,
2491
+ });
2492
+ expect(result.insetInlineStart).toBe('10px');
2493
+ expect(result.transform).toBe('translate(-10px, 0px)');
2494
+ });
2495
+
2496
+ it('calculates style for sticky item with padding', () => {
2497
+ const result = calculateItemStyle({
2498
+ containerTag: 'div',
2499
+ direction: 'both',
2500
+ isHydrated: true,
2501
+ item: {
2502
+ index: 10,
2503
+ isStickyActive: true,
2504
+ offset: { x: 600, y: 600 },
2505
+ size: { height: 50, width: 50 },
2506
+ stickyOffset: { x: -10, y: -20 },
2507
+ } as unknown as RenderedItem<unknown>,
2508
+ itemSize: 50,
2509
+ paddingStartX: 10,
2510
+ paddingStartY: 20,
2511
+ });
2512
+ expect(result.insetBlockStart).toBe('20px');
2513
+ expect(result.insetInlineStart).toBe('10px');
2514
+ expect(result.transform).toBe('translate(-10px, -20px)');
2515
+ });
2516
+ });
2517
+ });