@pdanpdan/virtual-scroll 0.3.0 → 0.5.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,526 @@
1
+ import type { UseVirtualScrollbarProps } from './useVirtualScrollbar';
2
+
3
+ import { mount } from '@vue/test-utils';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { defineComponent, h, nextTick, ref } from 'vue';
6
+
7
+ import { useVirtualScrollbar } from './useVirtualScrollbar';
8
+
9
+ // Helper to test composable
10
+ function setup(propsValue: UseVirtualScrollbarProps) {
11
+ let result: ReturnType<typeof useVirtualScrollbar>;
12
+
13
+ const TestComponent = defineComponent({
14
+ setup() {
15
+ result = useVirtualScrollbar(propsValue);
16
+ return () => h('div', result.trackProps.value, [
17
+ h('div', result.thumbProps.value),
18
+ ]);
19
+ },
20
+ });
21
+ const wrapper = mount(TestComponent);
22
+ return { result: result!, wrapper };
23
+ }
24
+
25
+ // Mock PointerCapture APIs for JSDOM
26
+ if (typeof HTMLElement !== 'undefined') {
27
+ HTMLElement.prototype.setPointerCapture = vi.fn();
28
+ HTMLElement.prototype.releasePointerCapture = vi.fn();
29
+ }
30
+
31
+ describe('useVirtualScrollbar', () => {
32
+ describe('core calculations', () => {
33
+ it('calculates percentages correctly for vertical ltr', () => {
34
+ const { result } = setup({
35
+ axis: 'vertical',
36
+ totalSize: 1000,
37
+ position: 200,
38
+ viewportSize: 200,
39
+ scrollToOffset: vi.fn(),
40
+ });
41
+
42
+ expect(result.viewportPercent.value).toBe(0.2);
43
+ // position 200, scrollable range (1000 - 200) = 800. 200/800 = 0.25
44
+ expect(result.positionPercent.value).toBe(0.25);
45
+ expect(result.thumbSizePercent.value).toBe(20);
46
+ // positionPercent * (100 - thumbSizePercent) = 0.25 * 80 = 20
47
+ expect(result.thumbPositionPercent.value).toBe(20);
48
+
49
+ expect(result.thumbStyle.value).toMatchObject({
50
+ blockSize: '20%',
51
+ insetBlockStart: '20%',
52
+ });
53
+ });
54
+
55
+ it('handles small content where totalsize <= viewportsize', () => {
56
+ const { result } = setup({
57
+ axis: 'vertical',
58
+ totalSize: 500,
59
+ position: 0,
60
+ viewportSize: 600,
61
+ scrollToOffset: vi.fn(),
62
+ });
63
+
64
+ expect(result.viewportPercent.value).toBe(1);
65
+ expect(result.positionPercent.value).toBe(0);
66
+ expect(result.thumbSizePercent.value).toBe(100);
67
+ expect(result.thumbPositionPercent.value).toBe(0);
68
+ });
69
+
70
+ it('handles totalsize <= 0', () => {
71
+ const { result } = setup({
72
+ axis: 'vertical',
73
+ totalSize: 0,
74
+ position: 0,
75
+ viewportSize: 200,
76
+ scrollToOffset: vi.fn(),
77
+ });
78
+
79
+ expect(result.viewportPercent.value).toBe(0);
80
+ });
81
+
82
+ it('handles viewportsize <= 0 in thumbsizepercent', () => {
83
+ const { result } = setup({
84
+ axis: 'vertical',
85
+ totalSize: 1000,
86
+ position: 0,
87
+ viewportSize: 0,
88
+ scrollToOffset: vi.fn(),
89
+ });
90
+
91
+ // When viewportSize is 0, minPercent should be 0.1, so thumbSizePercent should be 10.
92
+ expect(result.thumbSizePercent.value).toBe(10);
93
+ });
94
+
95
+ it('detects rtl mode from props', () => {
96
+ const { result } = setup({
97
+ axis: 'horizontal',
98
+ totalSize: 1000,
99
+ position: 0,
100
+ viewportSize: 200,
101
+ scrollToOffset: vi.fn(),
102
+ isRtl: true,
103
+ });
104
+
105
+ expect(result.trackProps.value.style).toBeDefined();
106
+ });
107
+ });
108
+
109
+ describe('reactivity', () => {
110
+ it('updates when props change reactively', async () => {
111
+ const position = ref(0);
112
+ const { result } = setup({
113
+ axis: 'vertical',
114
+ totalSize: 1000,
115
+ position,
116
+ viewportSize: 200,
117
+ scrollToOffset: vi.fn(),
118
+ });
119
+
120
+ expect(result.positionPercent.value).toBe(0);
121
+
122
+ position.value = 400;
123
+ await nextTick();
124
+ expect(result.positionPercent.value).toBe(0.5);
125
+ });
126
+ });
127
+
128
+ describe('track interactions', () => {
129
+ it('calls scrolltooffset on track click (vertical)', async () => {
130
+ const scrollToOffset = vi.fn();
131
+ const { wrapper } = setup({
132
+ axis: 'vertical',
133
+ totalSize: 1000,
134
+ position: 0,
135
+ viewportSize: 200,
136
+ scrollToOffset,
137
+ });
138
+
139
+ const track = wrapper.find('.virtual-scrollbar-track');
140
+ vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
141
+ bottom: 200,
142
+ height: 100,
143
+ left: 0,
144
+ right: 10,
145
+ top: 100,
146
+ width: 10,
147
+ x: 0,
148
+ y: 100,
149
+ toJSON: () => {},
150
+ } as DOMRect);
151
+
152
+ // Click at y=150 (50px from track top)
153
+ // trackSize 100, thumbSize 20% = 20px
154
+ // targetPercent = (50 - 10) / (100 - 20) = 40 / 80 = 0.5
155
+ // scrollableRange = 800. 0.5 * 800 = 400
156
+ await track.trigger('mousedown', { clientY: 150 });
157
+ await nextTick();
158
+ expect(scrollToOffset).toHaveBeenCalledWith(400);
159
+ });
160
+
161
+ it('calls scrolltooffset on track click (horizontal ltr)', async () => {
162
+ const scrollToOffset = vi.fn();
163
+ const { wrapper } = setup({
164
+ axis: 'horizontal',
165
+ totalSize: 1000,
166
+ position: 0,
167
+ viewportSize: 200,
168
+ scrollToOffset,
169
+ isRtl: false,
170
+ });
171
+
172
+ const track = wrapper.find('.virtual-scrollbar-track');
173
+ vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
174
+ bottom: 10,
175
+ height: 10,
176
+ left: 100,
177
+ right: 200,
178
+ top: 0,
179
+ width: 100,
180
+ x: 100,
181
+ y: 0,
182
+ toJSON: () => {},
183
+ } as DOMRect);
184
+
185
+ // Click at x=150 (50px from track left)
186
+ await track.trigger('mousedown', { clientX: 150 });
187
+ expect(scrollToOffset).toHaveBeenCalledWith(400);
188
+ });
189
+
190
+ it('calls scrolltooffset on track click (horizontal rtl)', async () => {
191
+ const scrollToOffset = vi.fn();
192
+ const { wrapper } = setup({
193
+ axis: 'horizontal',
194
+ totalSize: 1000,
195
+ position: 0,
196
+ viewportSize: 200,
197
+ scrollToOffset,
198
+ isRtl: true,
199
+ });
200
+
201
+ const track = wrapper.find('.virtual-scrollbar-track');
202
+ vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
203
+ bottom: 10,
204
+ height: 10,
205
+ left: 100,
206
+ right: 200,
207
+ top: 0,
208
+ width: 100,
209
+ x: 100,
210
+ y: 0,
211
+ toJSON: () => {},
212
+ } as DOMRect);
213
+
214
+ // Click at x=125. Since RTL, distance from right is (200 - 125) = 75px.
215
+ // trackSize 100, thumbSize 20% = 20px
216
+ // targetPercent = (75 - 10) / (100 - 20) = 65 / 80 = 0.8125
217
+ // scrollableRange = 800. 0.8125 * 800 = 650
218
+ await track.trigger('mousedown', { clientX: 125 });
219
+ expect(scrollToOffset).toHaveBeenCalledWith(650);
220
+ });
221
+
222
+ it('scrolls to absolute end when clicking near the end of the track', async () => {
223
+ const scrollToOffset = vi.fn();
224
+ const props = {
225
+ axis: 'vertical' as const,
226
+ position: 0,
227
+ scrollToOffset,
228
+ totalSize: 1000,
229
+ viewportSize: 500,
230
+ };
231
+
232
+ const { trackProps } = useVirtualScrollbar(props);
233
+
234
+ const track = document.createElement('div');
235
+ vi.spyOn(track, 'getBoundingClientRect').mockReturnValue({
236
+ bottom: 500,
237
+ height: 500,
238
+ left: 0,
239
+ right: 10,
240
+ top: 0,
241
+ width: 10,
242
+ } as DOMRect);
243
+
244
+ // Click at 499px (very bottom)
245
+ trackProps.value.onMousedown({
246
+ clientY: 499,
247
+ currentTarget: track,
248
+ target: track,
249
+ } as unknown as MouseEvent);
250
+
251
+ // scrollableRange = 1000 - 500 = 500.
252
+ expect(scrollToOffset).toHaveBeenCalledWith(500);
253
+ });
254
+ });
255
+
256
+ describe('thumb interactions & dragging', () => {
257
+ it('handles dragging (vertical)', async () => {
258
+ const scrollToOffset = vi.fn();
259
+ const { wrapper } = setup({
260
+ axis: 'vertical',
261
+ totalSize: 1000,
262
+ position: 100,
263
+ viewportSize: 200,
264
+ scrollToOffset,
265
+ });
266
+
267
+ const thumb = wrapper.find('.virtual-scrollbar-thumb');
268
+
269
+ vi.spyOn(wrapper.element as HTMLElement, 'getBoundingClientRect').mockReturnValue({
270
+ bottom: 100,
271
+ height: 100,
272
+ left: 0,
273
+ right: 10,
274
+ top: 0,
275
+ width: 10,
276
+ x: 0,
277
+ y: 0,
278
+ toJSON: () => {},
279
+ } as DOMRect);
280
+
281
+ // Mock pointer capture
282
+ thumb.element.setPointerCapture = vi.fn();
283
+ thumb.element.releasePointerCapture = vi.fn();
284
+
285
+ // Start drag at y=20
286
+ thumb.element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientY: 20, pointerId: 1 }));
287
+ expect(thumb.element.setPointerCapture).toHaveBeenCalledWith(1);
288
+
289
+ // Move to y=60 (delta +40)
290
+ thumb.element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientY: 60 }));
291
+ expect(scrollToOffset).toHaveBeenCalledWith(500);
292
+
293
+ // End drag
294
+ thumb.element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 }));
295
+ expect(thumb.element.releasePointerCapture).toHaveBeenCalledWith(1);
296
+ });
297
+
298
+ it('handles dragging (horizontal ltr)', async () => {
299
+ const scrollToOffset = vi.fn();
300
+ const { wrapper } = setup({
301
+ axis: 'horizontal',
302
+ totalSize: 1000,
303
+ position: 100,
304
+ viewportSize: 200,
305
+ scrollToOffset,
306
+ isRtl: false,
307
+ });
308
+
309
+ const thumb = wrapper.find('.virtual-scrollbar-thumb');
310
+ const track = wrapper.find('.virtual-scrollbar-track');
311
+
312
+ vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
313
+ bottom: 10,
314
+ height: 10,
315
+ left: 0,
316
+ right: 100,
317
+ top: 0,
318
+ width: 100,
319
+ x: 0,
320
+ y: 0,
321
+ toJSON: () => {},
322
+ } as DOMRect);
323
+
324
+ thumb.element.setPointerCapture = vi.fn();
325
+
326
+ // Start drag at x=20
327
+ thumb.element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: 20, pointerId: 1 }));
328
+
329
+ // Move to x=60 (delta +40)
330
+ thumb.element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: 60 }));
331
+ expect(scrollToOffset).toHaveBeenCalledWith(500);
332
+ });
333
+
334
+ it('handles dragging (horizontal rtl)', async () => {
335
+ const scrollToOffset = vi.fn();
336
+ const { wrapper } = setup({
337
+ axis: 'horizontal',
338
+ totalSize: 1000,
339
+ position: 100,
340
+ viewportSize: 200,
341
+ scrollToOffset,
342
+ isRtl: true,
343
+ });
344
+
345
+ const thumb = wrapper.find('.virtual-scrollbar-thumb');
346
+
347
+ vi.spyOn(wrapper.element as HTMLElement, 'getBoundingClientRect').mockReturnValue({
348
+ bottom: 10,
349
+ height: 10,
350
+ left: 0,
351
+ right: 100,
352
+ top: 0,
353
+ width: 100,
354
+ x: 0,
355
+ y: 0,
356
+ toJSON: () => {},
357
+ } as DOMRect);
358
+
359
+ thumb.element.setPointerCapture = vi.fn();
360
+
361
+ // Start drag at x=80
362
+ thumb.element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: 80, pointerId: 1 }));
363
+
364
+ // Move to x=40
365
+ thumb.element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: 40 }));
366
+ await nextTick();
367
+ expect(scrollToOffset).toHaveBeenCalledWith(500);
368
+ });
369
+
370
+ it('clamps targetoffset to scrollablecontentrange when dragging thumb to the absolute end', async () => {
371
+ const scrollToOffset = vi.fn();
372
+ const props = {
373
+ axis: 'vertical' as const,
374
+ position: 0,
375
+ scrollToOffset,
376
+ totalSize: 1000,
377
+ viewportSize: 500,
378
+ };
379
+
380
+ const { thumbProps } = useVirtualScrollbar(props);
381
+
382
+ const thumb = document.createElement('div');
383
+ const track = document.createElement('div');
384
+ track.appendChild(thumb);
385
+
386
+ vi.spyOn(track, 'getBoundingClientRect').mockReturnValue({
387
+ bottom: 500,
388
+ height: 500,
389
+ top: 0,
390
+ } as DOMRect);
391
+
392
+ thumbProps.value.onPointerdown({
393
+ clientX: 0,
394
+ clientY: 0,
395
+ currentTarget: thumb,
396
+ pointerId: 1,
397
+ preventDefault: vi.fn(),
398
+ stopPropagation: vi.fn(),
399
+ } as unknown as PointerEvent);
400
+
401
+ // Drag down 300px (more than scrollableTrackRange 250px)
402
+ thumbProps.value.onPointermove({
403
+ clientX: 0,
404
+ clientY: 300,
405
+ currentTarget: thumb,
406
+ pointerId: 1,
407
+ } as unknown as PointerEvent);
408
+
409
+ expect(scrollToOffset).toHaveBeenCalledWith(500);
410
+ });
411
+
412
+ it('ignores thumb move when not dragging', async () => {
413
+ const scrollToOffset = vi.fn();
414
+ const { wrapper } = setup({
415
+ axis: 'vertical',
416
+ totalSize: 1000,
417
+ position: 0,
418
+ viewportSize: 200,
419
+ scrollToOffset,
420
+ });
421
+
422
+ const thumb = wrapper.find('.virtual-scrollbar-thumb');
423
+ thumb.element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientY: 50 }));
424
+ expect(scrollToOffset).not.toHaveBeenCalled();
425
+ });
426
+
427
+ it('ignores track click when thumb is clicked', async () => {
428
+ const scrollToOffset = vi.fn();
429
+ const { wrapper } = setup({
430
+ axis: 'vertical',
431
+ totalSize: 1000,
432
+ position: 0,
433
+ viewportSize: 200,
434
+ scrollToOffset,
435
+ });
436
+
437
+ const thumb = wrapper.find('.virtual-scrollbar-thumb');
438
+
439
+ // mousedown on thumb bubbles up to track
440
+ await thumb.trigger('mousedown');
441
+ expect(scrollToOffset).not.toHaveBeenCalled();
442
+ });
443
+ });
444
+
445
+ describe('edge cases & cleanup', () => {
446
+ it('handles scrollabletrackrange <= 0', async () => {
447
+ const scrollToOffset = vi.fn();
448
+ const { wrapper } = setup({
449
+ axis: 'vertical',
450
+ totalSize: 1000,
451
+ position: 0,
452
+ viewportSize: 200,
453
+ scrollToOffset,
454
+ });
455
+
456
+ const thumb = wrapper.find('.virtual-scrollbar-thumb');
457
+ const track = wrapper.find('.virtual-scrollbar-track');
458
+
459
+ thumb.element.setPointerCapture = vi.fn();
460
+
461
+ vi.spyOn(track.element, 'getBoundingClientRect').mockReturnValue({
462
+ height: 0,
463
+ width: 0,
464
+ toJSON: () => {},
465
+ } as DOMRect);
466
+
467
+ thumb.element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1 }));
468
+ thumb.element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientY: 50 }));
469
+ expect(scrollToOffset).not.toHaveBeenCalled();
470
+ });
471
+
472
+ it('handles missing track element during move', () => {
473
+ const { wrapper } = setup({
474
+ axis: 'vertical',
475
+ totalSize: 1000,
476
+ position: 0,
477
+ viewportSize: 200,
478
+ scrollToOffset: vi.fn(),
479
+ });
480
+
481
+ wrapper.unmount();
482
+ });
483
+
484
+ it('handles move with missing parent track', async () => {
485
+ const scrollToOffset = vi.fn();
486
+ const { wrapper } = setup({
487
+ axis: 'vertical',
488
+ totalSize: 1000,
489
+ position: 0,
490
+ viewportSize: 200,
491
+ scrollToOffset,
492
+ });
493
+
494
+ const thumb = wrapper.find('.virtual-scrollbar-thumb');
495
+ thumb.element.setPointerCapture = vi.fn();
496
+
497
+ thumb.element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1 }));
498
+
499
+ // Manually remove thumb from DOM so it has no parent
500
+ const el = thumb.element as HTMLElement;
501
+ const parent = el.parentElement;
502
+ if (parent) {
503
+ parent.removeChild(el);
504
+ }
505
+
506
+ thumb.element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientY: 50 }));
507
+ expect(scrollToOffset).not.toHaveBeenCalled();
508
+ });
509
+
510
+ it('handles release capture when not dragging', () => {
511
+ const { wrapper } = setup({
512
+ axis: 'vertical',
513
+ totalSize: 1000,
514
+ position: 0,
515
+ viewportSize: 200,
516
+ scrollToOffset: vi.fn(),
517
+ });
518
+
519
+ const thumb = wrapper.find('.virtual-scrollbar-thumb');
520
+ thumb.element.releasePointerCapture = vi.fn();
521
+
522
+ thumb.element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 }));
523
+ expect(thumb.element.releasePointerCapture).not.toHaveBeenCalled();
524
+ });
525
+ });
526
+ });