@pyreon/virtual 0.0.1

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