@pyreon/virtual 0.24.5 → 0.25.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.
@@ -1,612 +0,0 @@
1
- import { signal } from '@pyreon/reactivity'
2
- import { mount } from '@pyreon/runtime-dom'
3
- import { useVirtualizer, useWindowVirtualizer } from '../index'
4
-
5
- // ─── Helpers ──────────────────────────────────────────────────────────────────
6
-
7
- function mountWith<T>(fn: () => T): { result: T; unmount: () => void } {
8
- let result: T | undefined
9
- const el = document.createElement('div')
10
- document.body.appendChild(el)
11
- const Wrapper = () => {
12
- result = fn()
13
- return null
14
- }
15
- const unmount = mount(<Wrapper />, el)
16
- return {
17
- result: result!,
18
- unmount: () => {
19
- unmount()
20
- el.remove()
21
- },
22
- }
23
- }
24
-
25
- /** Create a mock scroll container with a known size. */
26
- function createScrollContainer(height = 200): HTMLDivElement {
27
- const container = document.createElement('div')
28
- // happy-dom doesn't have real layout, but we can set properties
29
- Object.defineProperty(container, 'offsetHeight', { value: height })
30
- Object.defineProperty(container, 'offsetWidth', { value: 300 })
31
- Object.defineProperty(container, 'scrollHeight', { value: 10000 })
32
- Object.defineProperty(container, 'clientHeight', { value: height })
33
- document.body.appendChild(container)
34
- return container
35
- }
36
-
37
- // ─── useVirtualizer ──────────────────────────────────────────────────────────
38
-
39
- describe('useVirtualizer', () => {
40
- it('creates a virtualizer with virtual items', () => {
41
- const container = createScrollContainer()
42
- const { result: virt, unmount } = mountWith(() =>
43
- useVirtualizer(() => ({
44
- count: 1000,
45
- getScrollElement: () => container,
46
- estimateSize: () => 35,
47
- })),
48
- )
49
-
50
- // Should have some virtual items based on container size + overscan
51
- expect(virt.virtualItems()).toBeDefined()
52
- expect(Array.isArray(virt.virtualItems())).toBe(true)
53
- unmount()
54
- container.remove()
55
- })
56
-
57
- it('returns correct total size', () => {
58
- const container = createScrollContainer()
59
- const { result: virt, unmount } = mountWith(() =>
60
- useVirtualizer(() => ({
61
- count: 100,
62
- getScrollElement: () => container,
63
- estimateSize: () => 50,
64
- })),
65
- )
66
-
67
- // 100 items * 50px = 5000px
68
- expect(virt.totalSize()).toBe(5000)
69
- unmount()
70
- container.remove()
71
- })
72
-
73
- it('reactive count — updates when count signal changes', () => {
74
- const container = createScrollContainer()
75
- const count = signal(100)
76
- const { result: virt, unmount } = mountWith(() =>
77
- useVirtualizer(() => ({
78
- count: count(),
79
- getScrollElement: () => container,
80
- estimateSize: () => 50,
81
- })),
82
- )
83
-
84
- expect(virt.totalSize()).toBe(5000)
85
-
86
- count.set(200)
87
- expect(virt.totalSize()).toBe(10000)
88
- unmount()
89
- container.remove()
90
- })
91
-
92
- it('reactive estimateSize — updates total size', () => {
93
- const container = createScrollContainer()
94
- const itemSize = signal(50)
95
- const { result: virt, unmount } = mountWith(() =>
96
- useVirtualizer(() => ({
97
- count: 100,
98
- getScrollElement: () => container,
99
- estimateSize: () => itemSize(),
100
- })),
101
- )
102
-
103
- expect(virt.totalSize()).toBe(5000)
104
-
105
- itemSize.set(100)
106
- // Must call measure() to invalidate the measurement cache when estimateSize changes
107
- virt.instance.measure()
108
- expect(virt.totalSize()).toBe(10000)
109
- unmount()
110
- container.remove()
111
- })
112
-
113
- it('exposes the virtualizer instance', () => {
114
- const container = createScrollContainer()
115
- const { result: virt, unmount } = mountWith(() =>
116
- useVirtualizer(() => ({
117
- count: 50,
118
- getScrollElement: () => container,
119
- estimateSize: () => 40,
120
- })),
121
- )
122
-
123
- expect(virt.instance).toBeDefined()
124
- expect(typeof virt.instance.scrollToIndex).toBe('function')
125
- expect(typeof virt.instance.scrollToOffset).toBe('function')
126
- expect(typeof virt.instance.measureElement).toBe('function')
127
- unmount()
128
- container.remove()
129
- })
130
-
131
- it('virtual items have correct structure', () => {
132
- const container = createScrollContainer()
133
- const { result: virt, unmount } = mountWith(() =>
134
- useVirtualizer(() => ({
135
- count: 1000,
136
- getScrollElement: () => container,
137
- estimateSize: () => 35,
138
- })),
139
- )
140
-
141
- const items = virt.virtualItems()
142
- if (items.length > 0) {
143
- const item = items[0]!
144
- expect(typeof item.index).toBe('number')
145
- expect(typeof item.start).toBe('number')
146
- expect(typeof item.end).toBe('number')
147
- expect(typeof item.size).toBe('number')
148
- expect(typeof item.key).toBeDefined()
149
- }
150
- unmount()
151
- container.remove()
152
- })
153
-
154
- it('overscan controls extra items rendered', () => {
155
- const container = createScrollContainer(200)
156
- const { result: small, unmount: unmount1 } = mountWith(() =>
157
- useVirtualizer(() => ({
158
- count: 1000,
159
- getScrollElement: () => container,
160
- estimateSize: () => 35,
161
- overscan: 0,
162
- })),
163
- )
164
-
165
- const { result: large, unmount: unmount2 } = mountWith(() =>
166
- useVirtualizer(() => ({
167
- count: 1000,
168
- getScrollElement: () => container,
169
- estimateSize: () => 35,
170
- overscan: 10,
171
- })),
172
- )
173
-
174
- // More overscan = more virtual items
175
- expect(large.virtualItems().length).toBeGreaterThanOrEqual(small.virtualItems().length)
176
- unmount1()
177
- unmount2()
178
- container.remove()
179
- })
180
-
181
- it('gap option affects total size', () => {
182
- const container = createScrollContainer()
183
- const { result: noGap, unmount: unmount1 } = mountWith(() =>
184
- useVirtualizer(() => ({
185
- count: 10,
186
- getScrollElement: () => container,
187
- estimateSize: () => 50,
188
- gap: 0,
189
- })),
190
- )
191
-
192
- const { result: withGap, unmount: unmount2 } = mountWith(() =>
193
- useVirtualizer(() => ({
194
- count: 10,
195
- getScrollElement: () => container,
196
- estimateSize: () => 50,
197
- gap: 10,
198
- })),
199
- )
200
-
201
- // 10 items * 50 = 500 vs 10 items * 50 + 9 gaps * 10 = 590
202
- expect(noGap.totalSize()).toBe(500)
203
- expect(withGap.totalSize()).toBe(590)
204
- unmount1()
205
- unmount2()
206
- container.remove()
207
- })
208
-
209
- it('horizontal mode works', () => {
210
- const container = createScrollContainer()
211
- const { result: virt, unmount } = mountWith(() =>
212
- useVirtualizer(() => ({
213
- count: 100,
214
- getScrollElement: () => container,
215
- estimateSize: () => 100,
216
- horizontal: true,
217
- })),
218
- )
219
-
220
- expect(virt.totalSize()).toBe(10000)
221
- expect(virt.virtualItems().length).toBeGreaterThan(0)
222
- unmount()
223
- container.remove()
224
- })
225
-
226
- it('padding affects total size', () => {
227
- const container = createScrollContainer()
228
- const { result: virt, unmount } = mountWith(() =>
229
- useVirtualizer(() => ({
230
- count: 10,
231
- getScrollElement: () => container,
232
- estimateSize: () => 50,
233
- paddingStart: 20,
234
- paddingEnd: 30,
235
- })),
236
- )
237
-
238
- // 10 * 50 + 20 + 30 = 550
239
- expect(virt.totalSize()).toBe(550)
240
- unmount()
241
- container.remove()
242
- })
243
-
244
- it('isScrolling starts as false', () => {
245
- const container = createScrollContainer()
246
- const { result: virt, unmount } = mountWith(() =>
247
- useVirtualizer(() => ({
248
- count: 100,
249
- getScrollElement: () => container,
250
- estimateSize: () => 50,
251
- })),
252
- )
253
-
254
- expect(virt.isScrolling()).toBe(false)
255
- unmount()
256
- container.remove()
257
- })
258
-
259
- it('enabled: false produces empty virtual items', () => {
260
- const container = createScrollContainer()
261
- const { result: virt, unmount } = mountWith(() =>
262
- useVirtualizer(() => ({
263
- count: 100,
264
- getScrollElement: () => container,
265
- estimateSize: () => 50,
266
- enabled: false,
267
- })),
268
- )
269
-
270
- expect(virt.virtualItems()).toHaveLength(0)
271
- expect(virt.totalSize()).toBe(0)
272
- unmount()
273
- container.remove()
274
- })
275
-
276
- it('onChange callback updates signals when triggered', () => {
277
- const container = createScrollContainer()
278
- const onChangeSpy = vi.fn()
279
- const { result: virt, unmount } = mountWith(() =>
280
- useVirtualizer(() => ({
281
- count: 100,
282
- getScrollElement: () => container,
283
- estimateSize: () => 50,
284
- onChange: onChangeSpy,
285
- })),
286
- )
287
-
288
- // Manually invoke the instance's onChange to simulate a scroll/resize event
289
- // This exercises the constructor's onChange callback (lines 76-81)
290
- const onChange = virt.instance.options.onChange
291
- if (onChange) {
292
- onChange(virt.instance, false)
293
- }
294
-
295
- expect(virt.virtualItems()).toBeDefined()
296
- expect(typeof virt.totalSize()).toBe('number')
297
- expect(typeof virt.isScrolling()).toBe('boolean')
298
- // The user's onChange should have been forwarded
299
- expect(onChangeSpy).toHaveBeenCalled()
300
- unmount()
301
- container.remove()
302
- })
303
-
304
- it('onChange callback works without user-provided onChange', () => {
305
- const container = createScrollContainer()
306
- const { result: virt, unmount } = mountWith(() =>
307
- useVirtualizer(() => ({
308
- count: 100,
309
- getScrollElement: () => container,
310
- estimateSize: () => 50,
311
- // No onChange — exercises the resolvedOptions.onChange?.() optional chain
312
- })),
313
- )
314
-
315
- // Trigger onChange without a user handler
316
- const onChange = virt.instance.options.onChange
317
- if (onChange) {
318
- onChange(virt.instance, false)
319
- }
320
-
321
- expect(virt.virtualItems()).toBeDefined()
322
- unmount()
323
- container.remove()
324
- })
325
- })
326
-
327
- // ─── useWindowVirtualizer ─────────────────────────────────────────────────────
328
-
329
- describe('useWindowVirtualizer', () => {
330
- beforeEach(() => {
331
- // Ensure window has layout-like properties for happy-dom
332
- Object.defineProperty(window, 'innerHeight', {
333
- value: 768,
334
- writable: true,
335
- configurable: true,
336
- })
337
- Object.defineProperty(window, 'innerWidth', {
338
- value: 1024,
339
- writable: true,
340
- configurable: true,
341
- })
342
- Object.defineProperty(window, 'scrollY', {
343
- value: 0,
344
- writable: true,
345
- configurable: true,
346
- })
347
- })
348
-
349
- it('creates an instance and returns virtualItems, totalSize, isScrolling signals', () => {
350
- const { result: virt, unmount } = mountWith(() =>
351
- useWindowVirtualizer(() => ({
352
- count: 1000,
353
- estimateSize: () => 35,
354
- })),
355
- )
356
-
357
- expect(virt.instance).toBeDefined()
358
- expect(virt.virtualItems()).toBeDefined()
359
- expect(Array.isArray(virt.virtualItems())).toBe(true)
360
- expect(typeof virt.totalSize()).toBe('number')
361
- expect(virt.isScrolling()).toBe(false)
362
- unmount()
363
- })
364
-
365
- it('returns correct total size', () => {
366
- const { result: virt, unmount } = mountWith(() =>
367
- useWindowVirtualizer(() => ({
368
- count: 100,
369
- estimateSize: () => 50,
370
- })),
371
- )
372
-
373
- expect(virt.totalSize()).toBe(5000)
374
- unmount()
375
- })
376
-
377
- it('reactive count — updates when count signal changes', () => {
378
- const count = signal(100)
379
- const { result: virt, unmount } = mountWith(() =>
380
- useWindowVirtualizer(() => ({
381
- count: count(),
382
- estimateSize: () => 50,
383
- })),
384
- )
385
-
386
- expect(virt.totalSize()).toBe(5000)
387
-
388
- count.set(200)
389
- expect(virt.totalSize()).toBe(10000)
390
- unmount()
391
- })
392
-
393
- it('reactive estimateSize — updates total size after measure()', () => {
394
- const itemSize = signal(50)
395
- const { result: virt, unmount } = mountWith(() =>
396
- useWindowVirtualizer(() => ({
397
- count: 100,
398
- estimateSize: () => itemSize(),
399
- })),
400
- )
401
-
402
- expect(virt.totalSize()).toBe(5000)
403
-
404
- itemSize.set(100)
405
- virt.instance.measure()
406
- expect(virt.totalSize()).toBe(10000)
407
- unmount()
408
- })
409
-
410
- it('exposes instance methods (scrollToIndex, scrollToOffset, measureElement)', () => {
411
- const { result: virt, unmount } = mountWith(() =>
412
- useWindowVirtualizer(() => ({
413
- count: 50,
414
- estimateSize: () => 40,
415
- })),
416
- )
417
-
418
- expect(typeof virt.instance.scrollToIndex).toBe('function')
419
- expect(typeof virt.instance.scrollToOffset).toBe('function')
420
- expect(typeof virt.instance.measureElement).toBe('function')
421
- unmount()
422
- })
423
-
424
- it('virtual items have correct structure', () => {
425
- const { result: virt, unmount } = mountWith(() =>
426
- useWindowVirtualizer(() => ({
427
- count: 1000,
428
- estimateSize: () => 35,
429
- })),
430
- )
431
-
432
- const items = virt.virtualItems()
433
- if (items.length > 0) {
434
- const item = items[0]!
435
- expect(typeof item.index).toBe('number')
436
- expect(typeof item.start).toBe('number')
437
- expect(typeof item.end).toBe('number')
438
- expect(typeof item.size).toBe('number')
439
- expect(typeof item.key).toBeDefined()
440
- }
441
- unmount()
442
- })
443
-
444
- it('gap option affects total size', () => {
445
- const { result: noGap, unmount: unmount1 } = mountWith(() =>
446
- useWindowVirtualizer(() => ({
447
- count: 10,
448
- estimateSize: () => 50,
449
- gap: 0,
450
- })),
451
- )
452
-
453
- const { result: withGap, unmount: unmount2 } = mountWith(() =>
454
- useWindowVirtualizer(() => ({
455
- count: 10,
456
- estimateSize: () => 50,
457
- gap: 10,
458
- })),
459
- )
460
-
461
- // 10 items * 50 = 500 vs 10 items * 50 + 9 gaps * 10 = 590
462
- expect(noGap.totalSize()).toBe(500)
463
- expect(withGap.totalSize()).toBe(590)
464
- unmount1()
465
- unmount2()
466
- })
467
-
468
- it('padding affects total size', () => {
469
- const { result: virt, unmount } = mountWith(() =>
470
- useWindowVirtualizer(() => ({
471
- count: 10,
472
- estimateSize: () => 50,
473
- paddingStart: 20,
474
- paddingEnd: 30,
475
- })),
476
- )
477
-
478
- // 10 * 50 + 20 + 30 = 550
479
- expect(virt.totalSize()).toBe(550)
480
- unmount()
481
- })
482
-
483
- it('horizontal mode works', () => {
484
- const { result: virt, unmount } = mountWith(() =>
485
- useWindowVirtualizer(() => ({
486
- count: 100,
487
- estimateSize: () => 100,
488
- horizontal: true,
489
- })),
490
- )
491
-
492
- expect(virt.totalSize()).toBe(10000)
493
- expect(virt.virtualItems().length).toBeGreaterThan(0)
494
- unmount()
495
- })
496
-
497
- it('enabled: false produces empty virtual items', () => {
498
- const { result: virt, unmount } = mountWith(() =>
499
- useWindowVirtualizer(() => ({
500
- count: 100,
501
- estimateSize: () => 50,
502
- enabled: false,
503
- })),
504
- )
505
-
506
- expect(virt.virtualItems()).toHaveLength(0)
507
- expect(virt.totalSize()).toBe(0)
508
- unmount()
509
- })
510
-
511
- it('calls user-provided onChange callback', () => {
512
- const onChangeSpy = vi.fn()
513
- const count = signal(10)
514
- const { result: virt, unmount } = mountWith(() =>
515
- useWindowVirtualizer(() => ({
516
- count: count(),
517
- estimateSize: () => 50,
518
- onChange: onChangeSpy,
519
- })),
520
- )
521
-
522
- // Trigger a reactive update which calls setOptions + _willUpdate
523
- count.set(20)
524
- // The onChange may be called during the update cycle
525
- // At minimum, the virtualizer should still work correctly
526
- expect(virt.totalSize()).toBe(1000)
527
- unmount()
528
- })
529
-
530
- it('overscan controls extra items rendered', () => {
531
- const { result: small, unmount: unmount1 } = mountWith(() =>
532
- useWindowVirtualizer(() => ({
533
- count: 1000,
534
- estimateSize: () => 35,
535
- overscan: 0,
536
- })),
537
- )
538
-
539
- const { result: large, unmount: unmount2 } = mountWith(() =>
540
- useWindowVirtualizer(() => ({
541
- count: 1000,
542
- estimateSize: () => 35,
543
- overscan: 10,
544
- })),
545
- )
546
-
547
- expect(large.virtualItems().length).toBeGreaterThanOrEqual(small.virtualItems().length)
548
- unmount1()
549
- unmount2()
550
- })
551
-
552
- it('onChange callback updates signals when triggered directly', () => {
553
- const onChangeSpy = vi.fn()
554
- const { result: virt, unmount } = mountWith(() =>
555
- useWindowVirtualizer(() => ({
556
- count: 100,
557
- estimateSize: () => 50,
558
- onChange: onChangeSpy,
559
- })),
560
- )
561
-
562
- // Trigger the constructor's onChange callback (lines 63-68)
563
- const onChange = virt.instance.options.onChange
564
- if (onChange) {
565
- onChange(virt.instance, false)
566
- }
567
-
568
- expect(virt.virtualItems()).toBeDefined()
569
- expect(typeof virt.totalSize()).toBe('number')
570
- expect(typeof virt.isScrolling()).toBe('boolean')
571
- expect(onChangeSpy).toHaveBeenCalled()
572
- unmount()
573
- })
574
-
575
- it('onChange works without user-provided onChange', () => {
576
- const { result: virt, unmount } = mountWith(() =>
577
- useWindowVirtualizer(() => ({
578
- count: 100,
579
- estimateSize: () => 50,
580
- })),
581
- )
582
-
583
- const onChange = virt.instance.options.onChange
584
- if (onChange) {
585
- onChange(virt.instance, false)
586
- }
587
-
588
- expect(virt.virtualItems()).toBeDefined()
589
- unmount()
590
- })
591
-
592
- it('handles missing document/window gracefully', () => {
593
- const origDoc = globalThis.document
594
- const origWin = globalThis.window
595
- try {
596
- // @ts-expect-error — temporarily remove globals to exercise SSR fallback branches
597
- delete globalThis.document
598
- // @ts-expect-error
599
- delete globalThis.window
600
-
601
- const { totalSize } = useWindowVirtualizer(() => ({
602
- count: 100,
603
- estimateSize: () => 50,
604
- }))
605
-
606
- expect(totalSize()).toBeGreaterThanOrEqual(0)
607
- } finally {
608
- globalThis.document = origDoc
609
- globalThis.window = origWin
610
- }
611
- })
612
- })