@pyreon/runtime-dom 0.11.3 → 0.11.4

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,550 @@
1
+ import type { ComponentFn } from "@pyreon/core"
2
+ import { h } from "@pyreon/core"
3
+ import { signal } from "@pyreon/reactivity"
4
+ import {
5
+ KeepAlive as _KeepAlive,
6
+ Transition as _Transition,
7
+ TransitionGroup as _TransitionGroup,
8
+ mount,
9
+ } from "../index"
10
+
11
+ const Transition = _Transition as unknown as ComponentFn<Record<string, unknown>>
12
+ const TransitionGroup = _TransitionGroup as unknown as ComponentFn<Record<string, unknown>>
13
+ const KeepAlive = _KeepAlive as unknown as ComponentFn<Record<string, unknown>>
14
+
15
+ function container(): HTMLElement {
16
+ const el = document.createElement("div")
17
+ document.body.appendChild(el)
18
+ return el
19
+ }
20
+
21
+ // ─── Transition ──────────────────────────────────────────────────────────────
22
+
23
+ describe("Transition", () => {
24
+ test("renders child when show is true", () => {
25
+ const el = container()
26
+ const show = signal(true)
27
+ mount(
28
+ h(Transition, { name: "fade", show: () => show() }, h("div", { class: "child" }, "hello")),
29
+ el,
30
+ )
31
+ expect(el.querySelector(".child")?.textContent).toBe("hello")
32
+ })
33
+
34
+ test("does not render child when show is false initially", () => {
35
+ const el = container()
36
+ const show = signal(false)
37
+ mount(
38
+ h(Transition, { name: "fade", show: () => show() }, h("div", { class: "child" }, "hello")),
39
+ el,
40
+ )
41
+ expect(el.querySelector(".child")).toBeNull()
42
+ })
43
+
44
+ test("uses default name 'pyreon' when no name provided", () => {
45
+ const el = container()
46
+ const show = signal(true)
47
+ mount(h(Transition, { show: () => show() }, h("div", { class: "child" }, "content")), el)
48
+ expect(el.querySelector(".child")?.textContent).toBe("content")
49
+ })
50
+
51
+ test("applies enter classes when show transitions from false to true", async () => {
52
+ const el = container()
53
+ const show = signal(false)
54
+ mount(
55
+ h(Transition, { name: "fade", show: () => show() }, h("div", { class: "target" }, "text")),
56
+ el,
57
+ )
58
+ expect(el.querySelector(".target")).toBeNull()
59
+
60
+ show.set(true)
61
+ // Wait for microtask (queueMicrotask in handleVisibilityChange)
62
+ await new Promise<void>((r) => setTimeout(r, 20))
63
+
64
+ const target = el.querySelector(".target") as HTMLElement
65
+ expect(target).not.toBeNull()
66
+ // After the enter animation starts, the element should have enter classes
67
+ // Classes will be in transition — at minimum the element should exist
68
+ expect(target.textContent).toBe("text")
69
+ })
70
+
71
+ test("applies custom enter/leave class overrides", async () => {
72
+ const el = container()
73
+ const show = signal(true)
74
+ mount(
75
+ h(
76
+ Transition,
77
+ {
78
+ show: () => show(),
79
+ enterFrom: "my-enter-from",
80
+ enterActive: "my-enter-active",
81
+ enterTo: "my-enter-to",
82
+ leaveFrom: "my-leave-from",
83
+ leaveActive: "my-leave-active",
84
+ leaveTo: "my-leave-to",
85
+ },
86
+ h("div", { class: "custom-target" }, "custom"),
87
+ ),
88
+ el,
89
+ )
90
+ expect(el.querySelector(".custom-target")).not.toBeNull()
91
+ })
92
+
93
+ test("calls lifecycle callbacks on enter", async () => {
94
+ const el = container()
95
+ const show = signal(false)
96
+ const onBeforeEnter = vi.fn()
97
+ const onAfterEnter = vi.fn()
98
+
99
+ mount(
100
+ h(
101
+ Transition,
102
+ {
103
+ name: "fade",
104
+ show: () => show(),
105
+ onBeforeEnter,
106
+ onAfterEnter,
107
+ },
108
+ h("div", { class: "lifecycle" }, "enter"),
109
+ ),
110
+ el,
111
+ )
112
+
113
+ show.set(true)
114
+ await new Promise<void>((r) => setTimeout(r, 20))
115
+ expect(onBeforeEnter).toHaveBeenCalled()
116
+
117
+ // Trigger the transitionend to complete the enter
118
+ const target = el.querySelector(".lifecycle") as HTMLElement
119
+ if (target) {
120
+ target.dispatchEvent(new Event("transitionend"))
121
+ await new Promise<void>((r) => setTimeout(r, 10))
122
+ expect(onAfterEnter).toHaveBeenCalled()
123
+ }
124
+ })
125
+
126
+ test("calls lifecycle callbacks on leave", async () => {
127
+ const el = container()
128
+ const show = signal(true)
129
+ const onBeforeLeave = vi.fn()
130
+ const onAfterLeave = vi.fn()
131
+
132
+ mount(
133
+ h(
134
+ Transition,
135
+ {
136
+ name: "fade",
137
+ show: () => show(),
138
+ onBeforeLeave,
139
+ onAfterLeave,
140
+ },
141
+ h("div", { class: "leave-target" }, "leave"),
142
+ ),
143
+ el,
144
+ )
145
+
146
+ // Initial render
147
+ await new Promise<void>((r) => setTimeout(r, 10))
148
+
149
+ show.set(false)
150
+ await new Promise<void>((r) => setTimeout(r, 20))
151
+ expect(onBeforeLeave).toHaveBeenCalled()
152
+
153
+ // Trigger transitionend to complete leave
154
+ const target = el.querySelector(".leave-target") as HTMLElement
155
+ if (target) {
156
+ target.dispatchEvent(new Event("transitionend"))
157
+ await new Promise<void>((r) => setTimeout(r, 20))
158
+ expect(onAfterLeave).toHaveBeenCalled()
159
+ }
160
+ })
161
+
162
+ test("appear option triggers enter animation on initial mount", async () => {
163
+ const el = container()
164
+ const show = signal(true)
165
+ const onBeforeEnter = vi.fn()
166
+
167
+ mount(
168
+ h(
169
+ Transition,
170
+ {
171
+ name: "fade",
172
+ show: () => show(),
173
+ appear: true,
174
+ onBeforeEnter,
175
+ },
176
+ h("div", { class: "appear-target" }, "appear"),
177
+ ),
178
+ el,
179
+ )
180
+
181
+ await new Promise<void>((r) => setTimeout(r, 20))
182
+ expect(onBeforeEnter).toHaveBeenCalled()
183
+ })
184
+
185
+ test("warns when child is a component (not a DOM element)", () => {
186
+ const el = container()
187
+ const show = signal(true)
188
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
189
+ const ChildComp = () => h("div", null, "comp-child")
190
+
191
+ mount(h(Transition, { name: "fade", show: () => show() }, h(ChildComp, null)), el)
192
+
193
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Transition child is a component"))
194
+ warnSpy.mockRestore()
195
+ })
196
+
197
+ test("handles null/undefined children gracefully", () => {
198
+ const el = container()
199
+ const show = signal(true)
200
+ // No children
201
+ expect(() => mount(h(Transition, { name: "fade", show: () => show() }), el)).not.toThrow()
202
+ })
203
+
204
+ test("cancels pending leave when re-entering", async () => {
205
+ const el = container()
206
+ const show = signal(true)
207
+
208
+ mount(
209
+ h(
210
+ Transition,
211
+ { name: "fade", show: () => show() },
212
+ h("div", { class: "cancel-test" }, "toggle"),
213
+ ),
214
+ el,
215
+ )
216
+
217
+ await new Promise<void>((r) => setTimeout(r, 10))
218
+
219
+ // Start leave
220
+ show.set(false)
221
+ await new Promise<void>((r) => setTimeout(r, 10))
222
+
223
+ // Re-enter before leave animation completes
224
+ show.set(true)
225
+ await new Promise<void>((r) => setTimeout(r, 30))
226
+
227
+ // Element should be visible again
228
+ const target = el.querySelector(".cancel-test")
229
+ expect(target).not.toBeNull()
230
+ })
231
+
232
+ test("handles animationend event (CSS animations)", async () => {
233
+ const el = container()
234
+ const show = signal(false)
235
+ const onAfterEnter = vi.fn()
236
+
237
+ mount(
238
+ h(
239
+ Transition,
240
+ { name: "anim", show: () => show(), onAfterEnter },
241
+ h("div", { class: "anim-target" }, "anim"),
242
+ ),
243
+ el,
244
+ )
245
+
246
+ show.set(true)
247
+ await new Promise<void>((r) => setTimeout(r, 30))
248
+
249
+ const target = el.querySelector(".anim-target") as HTMLElement
250
+ if (target) {
251
+ // Fire animationend instead of transitionend
252
+ target.dispatchEvent(new Event("animationend"))
253
+ await new Promise<void>((r) => setTimeout(r, 10))
254
+ expect(onAfterEnter).toHaveBeenCalled()
255
+ }
256
+ })
257
+ })
258
+
259
+ // ─── TransitionGroup ─────────────────────────────────────────────────────────
260
+
261
+ describe("TransitionGroup", () => {
262
+ test("renders items inside a wrapper element", async () => {
263
+ const el = container()
264
+ const items = signal([{ id: 1 }, { id: 2 }, { id: 3 }])
265
+
266
+ mount(
267
+ h(TransitionGroup, {
268
+ tag: "ul",
269
+ name: "list",
270
+ items: () => items(),
271
+ keyFn: (item: { id: number }) => item.id,
272
+ render: (item: { id: number }) => h("li", null, `item-${item.id}`),
273
+ }),
274
+ el,
275
+ )
276
+
277
+ await new Promise<void>((r) => setTimeout(r, 50))
278
+ const lis = el.querySelectorAll("li")
279
+ expect(lis.length).toBe(3)
280
+ expect(lis[0]?.textContent).toBe("item-1")
281
+ })
282
+
283
+ test("uses default tag 'div' and name 'pyreon'", async () => {
284
+ const el = container()
285
+ const items = signal([{ id: 1 }])
286
+
287
+ mount(
288
+ h(TransitionGroup, {
289
+ items: () => items(),
290
+ keyFn: (item: { id: number }) => item.id,
291
+ render: (item: { id: number }) => h("span", null, `s-${item.id}`),
292
+ }),
293
+ el,
294
+ )
295
+
296
+ await new Promise<void>((r) => setTimeout(r, 50))
297
+ expect(el.querySelector("div")).not.toBeNull()
298
+ expect(el.querySelector("span")?.textContent).toBe("s-1")
299
+ })
300
+
301
+ test("handles item additions", async () => {
302
+ const el = container()
303
+ const items = signal([{ id: 1 }])
304
+
305
+ mount(
306
+ h(TransitionGroup, {
307
+ tag: "div",
308
+ name: "list",
309
+ items: () => items(),
310
+ keyFn: (item: { id: number }) => item.id,
311
+ render: (item: { id: number }) => h("span", null, `item-${item.id}`),
312
+ }),
313
+ el,
314
+ )
315
+
316
+ await new Promise<void>((r) => setTimeout(r, 50))
317
+ expect(el.querySelectorAll("span").length).toBe(1)
318
+
319
+ items.set([{ id: 1 }, { id: 2 }])
320
+ await new Promise<void>((r) => setTimeout(r, 50))
321
+ expect(el.querySelectorAll("span").length).toBe(2)
322
+ })
323
+
324
+ test("handles item removals with leave animation", async () => {
325
+ const el = container()
326
+ const items = signal([{ id: 1 }, { id: 2 }])
327
+
328
+ mount(
329
+ h(TransitionGroup, {
330
+ tag: "div",
331
+ name: "list",
332
+ items: () => items(),
333
+ keyFn: (item: { id: number }) => item.id,
334
+ render: (item: { id: number }) => h("span", null, `item-${item.id}`),
335
+ }),
336
+ el,
337
+ )
338
+
339
+ await new Promise<void>((r) => setTimeout(r, 50))
340
+ expect(el.querySelectorAll("span").length).toBe(2)
341
+
342
+ items.set([{ id: 1 }])
343
+ await new Promise<void>((r) => setTimeout(r, 10))
344
+
345
+ // The removed item gets leave animation classes.
346
+ // After transitionend it would be removed. Simulate that.
347
+ const spans = el.querySelectorAll("span")
348
+ for (const span of spans) {
349
+ span.dispatchEvent(new Event("transitionend"))
350
+ }
351
+ await new Promise<void>((r) => setTimeout(r, 50))
352
+ })
353
+
354
+ test("calls lifecycle callbacks on enter/leave", async () => {
355
+ const el = container()
356
+ const items = signal([{ id: 1 }])
357
+ const onBeforeEnter = vi.fn()
358
+ const onAfterEnter = vi.fn()
359
+ const onBeforeLeave = vi.fn()
360
+
361
+ mount(
362
+ h(TransitionGroup, {
363
+ tag: "div",
364
+ name: "list",
365
+ items: () => items(),
366
+ keyFn: (item: { id: number }) => item.id,
367
+ render: (item: { id: number }) => h("span", null, `item-${item.id}`),
368
+ onBeforeEnter,
369
+ onAfterEnter,
370
+ onBeforeLeave,
371
+ }),
372
+ el,
373
+ )
374
+
375
+ // Wait for initial mount
376
+ await new Promise<void>((r) => setTimeout(r, 50))
377
+
378
+ // Add an item to trigger enter animation
379
+ items.set([{ id: 1 }, { id: 2 }])
380
+ await new Promise<void>((r) => setTimeout(r, 50))
381
+ expect(onBeforeEnter).toHaveBeenCalled()
382
+
383
+ // Trigger transitionend on the new item to fire onAfterEnter
384
+ const spans = el.querySelectorAll("span")
385
+ const newSpan = spans[spans.length - 1]
386
+ if (newSpan) {
387
+ newSpan.dispatchEvent(new Event("transitionend"))
388
+ await new Promise<void>((r) => setTimeout(r, 10))
389
+ expect(onAfterEnter).toHaveBeenCalled()
390
+ }
391
+
392
+ // Remove item to trigger leave
393
+ items.set([{ id: 1 }])
394
+ await new Promise<void>((r) => setTimeout(r, 10))
395
+ expect(onBeforeLeave).toHaveBeenCalled()
396
+ })
397
+
398
+ test("appear option animates items on initial mount", async () => {
399
+ const el = container()
400
+ const items = signal([{ id: 1 }])
401
+ const onBeforeEnter = vi.fn()
402
+
403
+ mount(
404
+ h(TransitionGroup, {
405
+ tag: "div",
406
+ name: "list",
407
+ appear: true,
408
+ items: () => items(),
409
+ keyFn: (item: { id: number }) => item.id,
410
+ render: (item: { id: number }) => h("span", null, `item-${item.id}`),
411
+ onBeforeEnter,
412
+ }),
413
+ el,
414
+ )
415
+
416
+ await new Promise<void>((r) => setTimeout(r, 50))
417
+ expect(onBeforeEnter).toHaveBeenCalled()
418
+ })
419
+
420
+ test("supports custom class overrides", async () => {
421
+ const el = container()
422
+ const items = signal([{ id: 1 }])
423
+
424
+ mount(
425
+ h(TransitionGroup, {
426
+ tag: "div",
427
+ items: () => items(),
428
+ keyFn: (item: { id: number }) => item.id,
429
+ render: (item: { id: number }) => h("span", null, `item-${item.id}`),
430
+ enterFrom: "custom-enter-from",
431
+ enterActive: "custom-enter-active",
432
+ enterTo: "custom-enter-to",
433
+ leaveFrom: "custom-leave-from",
434
+ leaveActive: "custom-leave-active",
435
+ leaveTo: "custom-leave-to",
436
+ moveClass: "custom-move",
437
+ }),
438
+ el,
439
+ )
440
+
441
+ await new Promise<void>((r) => setTimeout(r, 50))
442
+ expect(el.querySelector("span")).not.toBeNull()
443
+ })
444
+ })
445
+
446
+ // ─── KeepAlive ───────────────────────────────────────────────────────────────
447
+
448
+ describe("KeepAlive", () => {
449
+ test("renders children when active is true", async () => {
450
+ const el = container()
451
+ const active = signal(true)
452
+
453
+ mount(h(KeepAlive, { active: () => active() }, h("span", { class: "kept" }, "alive")), el)
454
+
455
+ await new Promise<void>((r) => setTimeout(r, 50))
456
+ const kept = el.querySelector(".kept")
457
+ expect(kept?.textContent).toBe("alive")
458
+ })
459
+
460
+ test("hides children but keeps them mounted when active is false", async () => {
461
+ const el = container()
462
+ const active = signal(true)
463
+
464
+ mount(h(KeepAlive, { active: () => active() }, h("span", { class: "kept" }, "alive")), el)
465
+
466
+ await new Promise<void>((r) => setTimeout(r, 50))
467
+
468
+ active.set(false)
469
+ await new Promise<void>((r) => setTimeout(r, 20))
470
+
471
+ // The container div should have display: none, but the child should still be in DOM
472
+ const wrapperDiv = el.querySelector("[style]") as HTMLElement
473
+ if (wrapperDiv) {
474
+ expect(wrapperDiv.style.display).toBe("none")
475
+ }
476
+ // Child should still exist in the DOM (kept alive)
477
+ expect(el.querySelector(".kept")).not.toBeNull()
478
+ })
479
+
480
+ test("restores display when re-activated", async () => {
481
+ const el = container()
482
+ const active = signal(true)
483
+
484
+ mount(h(KeepAlive, { active: () => active() }, h("span", { class: "kept" }, "alive")), el)
485
+
486
+ await new Promise<void>((r) => setTimeout(r, 50))
487
+ active.set(false)
488
+ await new Promise<void>((r) => setTimeout(r, 20))
489
+ active.set(true)
490
+ await new Promise<void>((r) => setTimeout(r, 20))
491
+
492
+ // The container's display should be restored (empty string = visible)
493
+ const wrapperDivs = el.querySelectorAll("div")
494
+ let foundVisible = false
495
+ for (const div of wrapperDivs) {
496
+ if (div.style.display === "" || div.style.display === "contents") {
497
+ foundVisible = true
498
+ }
499
+ }
500
+ expect(foundVisible).toBe(true)
501
+ })
502
+
503
+ test("defaults to active=true when no active prop provided", async () => {
504
+ const el = container()
505
+
506
+ mount(h(KeepAlive, {}, h("span", { class: "default" }, "default")), el)
507
+
508
+ await new Promise<void>((r) => setTimeout(r, 50))
509
+ expect(el.querySelector(".default")?.textContent).toBe("default")
510
+ })
511
+
512
+ test("mounts children only once (not re-created on toggle)", async () => {
513
+ const el = container()
514
+ const active = signal(true)
515
+ let mountCount = 0
516
+ const Counter = () => {
517
+ mountCount++
518
+ return h("span", null, "counter")
519
+ }
520
+
521
+ mount(h(KeepAlive, { active: () => active() }, h(Counter, null)), el)
522
+
523
+ await new Promise<void>((r) => setTimeout(r, 50))
524
+ expect(mountCount).toBe(1)
525
+
526
+ active.set(false)
527
+ await new Promise<void>((r) => setTimeout(r, 20))
528
+ active.set(true)
529
+ await new Promise<void>((r) => setTimeout(r, 20))
530
+
531
+ // Component should NOT be re-created
532
+ expect(mountCount).toBe(1)
533
+ })
534
+
535
+ test("uses display: contents wrapper for transparent layout", async () => {
536
+ const el = container()
537
+ mount(h(KeepAlive, {}, h("span", null, "child")), el)
538
+
539
+ await new Promise<void>((r) => setTimeout(r, 20))
540
+ // KeepAlive renders a div with style="display: contents"
541
+ const wrapper = el.querySelector("div")
542
+ expect(wrapper).not.toBeNull()
543
+ })
544
+
545
+ test("handles null children gracefully", async () => {
546
+ const el = container()
547
+ expect(() => mount(h(KeepAlive, {}), el)).not.toThrow()
548
+ await new Promise<void>((r) => setTimeout(r, 50))
549
+ })
550
+ })