@pyreon/kinetic 0.11.1 → 0.11.2

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,522 @@
1
+ import type { VNode } from "@pyreon/core"
2
+ import { signal } from "@pyreon/reactivity"
3
+
4
+ let _reducedMotion = false
5
+
6
+ vi.mock("../useReducedMotion", () => ({
7
+ useReducedMotion: () => () => _reducedMotion,
8
+ }))
9
+
10
+ import TransitionItem from "../kinetic/TransitionItem"
11
+
12
+ // Mock rAF for deterministic double-rAF testing
13
+ let rafCallbacks: (() => void)[] = []
14
+ const originalRaf = globalThis.requestAnimationFrame
15
+ const originalCaf = globalThis.cancelAnimationFrame
16
+
17
+ beforeEach(() => {
18
+ vi.useFakeTimers()
19
+ rafCallbacks = []
20
+
21
+ vi.stubGlobal(
22
+ "requestAnimationFrame",
23
+ vi.fn((cb: () => void) => {
24
+ rafCallbacks.push(cb)
25
+ return rafCallbacks.length
26
+ }),
27
+ )
28
+
29
+ vi.stubGlobal("cancelAnimationFrame", vi.fn())
30
+ })
31
+
32
+ afterEach(() => {
33
+ vi.useRealTimers()
34
+ vi.stubGlobal("requestAnimationFrame", originalRaf)
35
+ vi.stubGlobal("cancelAnimationFrame", originalCaf)
36
+ })
37
+
38
+ const flushRaf = () => {
39
+ const cbs = [...rafCallbacks]
40
+ rafCallbacks = []
41
+ for (const cb of cbs) cb()
42
+ }
43
+
44
+ const fireTransitionEnd = (el: HTMLElement) => {
45
+ const event = new Event("transitionend", { bubbles: true })
46
+ Object.defineProperty(event, "target", { value: el })
47
+ el.dispatchEvent(event)
48
+ }
49
+
50
+ /**
51
+ * Recursively finds and invokes all refs in a VNode tree,
52
+ * wiring them to the given element.
53
+ */
54
+ const wireRef = (vnode: VNode | null, el: HTMLElement) => {
55
+ if (!vnode) return
56
+ const visitNode = (node: VNode) => {
57
+ const nodeProps = node.props as Record<string, unknown>
58
+ if (typeof nodeProps?.ref === "function") {
59
+ ;(nodeProps.ref as (element: HTMLElement | null) => void)(el)
60
+ } else if (nodeProps?.ref && typeof nodeProps.ref === "object") {
61
+ ;(nodeProps.ref as { current: HTMLElement | null }).current = el
62
+ }
63
+ if (node.children) {
64
+ const ch = Array.isArray(node.children) ? node.children : [node.children]
65
+ for (const c of ch) {
66
+ if (c && typeof c === "object" && "type" in (c as object)) visitNode(c as VNode)
67
+ }
68
+ }
69
+ if (nodeProps?.children) {
70
+ const pc = Array.isArray(nodeProps.children) ? nodeProps.children : [nodeProps.children]
71
+ for (const c of pc) {
72
+ if (c && typeof c === "object" && "type" in (c as object)) visitNode(c as VNode)
73
+ }
74
+ }
75
+ if (
76
+ nodeProps?.fallback &&
77
+ typeof nodeProps.fallback === "object" &&
78
+ "type" in (nodeProps.fallback as object)
79
+ ) {
80
+ visitNode(nodeProps.fallback as VNode)
81
+ }
82
+ }
83
+ visitNode(vnode)
84
+ }
85
+
86
+ /**
87
+ * Helper: call TransitionItem and wire a mock element to refs.
88
+ */
89
+ const setupTransitionItem = (props: Record<string, unknown>) => {
90
+ const el = document.createElement("div")
91
+ const child: VNode = {
92
+ type: "div",
93
+ props: { "data-testid": "child" },
94
+ children: ["Hello"],
95
+ key: null,
96
+ }
97
+
98
+ const vnode = TransitionItem({
99
+ ...props,
100
+ children: child,
101
+ } as any)
102
+
103
+ wireRef(vnode, el)
104
+
105
+ return { vnode, el }
106
+ }
107
+
108
+ describe("TransitionItem", () => {
109
+ it("returns a VNode when show returns true", () => {
110
+ const show = () => true
111
+ const child: VNode = { type: "div", props: {}, children: ["Hello"], key: null }
112
+ const vnode = TransitionItem({ show, children: child })
113
+ expect(vnode).not.toBeNull()
114
+ })
115
+
116
+ it("wraps child in a Show component", () => {
117
+ const show = () => true
118
+ const child: VNode = { type: "div", props: {}, children: ["Hello"], key: null }
119
+ const vnode = TransitionItem({ show, children: child })
120
+ expect(vnode).not.toBeNull()
121
+ expect(typeof vnode?.type).toBe("function")
122
+ })
123
+
124
+ it("clones child VNode with merged ref", () => {
125
+ const show = () => true
126
+ const child: VNode = { type: "div", props: {}, children: ["Hello"], key: null }
127
+ const vnode = TransitionItem({ show, children: child })
128
+
129
+ // The Show component's children (or fallback) should have a ref prop
130
+ const showProps = vnode?.props as Record<string, unknown>
131
+ const showChildren = showProps?.children as VNode | undefined
132
+ if (showChildren) {
133
+ const childProps = showChildren.props as Record<string, unknown>
134
+ expect(childProps?.ref).toBeDefined()
135
+ expect(typeof childProps?.ref).toBe("function")
136
+ }
137
+ })
138
+
139
+ it("fires onEnter callback when entering", () => {
140
+ const show = signal(false)
141
+ const onEnter = vi.fn()
142
+
143
+ setupTransitionItem({ show: () => show(), onEnter })
144
+
145
+ show.set(true)
146
+ expect(onEnter).toHaveBeenCalledTimes(1)
147
+ })
148
+
149
+ it("fires onLeave callback when leaving", () => {
150
+ const show = signal(true)
151
+ const onLeave = vi.fn()
152
+
153
+ setupTransitionItem({ show: () => show(), onLeave })
154
+
155
+ show.set(false)
156
+ expect(onLeave).toHaveBeenCalledTimes(1)
157
+ })
158
+
159
+ it("applies enter classes when entering", () => {
160
+ const show = signal(false)
161
+ const { el } = setupTransitionItem({
162
+ show: () => show(),
163
+ enter: "ti-enter",
164
+ enterFrom: "ti-enter-from",
165
+ enterTo: "ti-enter-to",
166
+ })
167
+
168
+ show.set(true)
169
+
170
+ expect(el.classList.contains("ti-enter")).toBe(true)
171
+ expect(el.classList.contains("ti-enter-from")).toBe(true)
172
+ expect(el.classList.contains("ti-enter-to")).toBe(false)
173
+ })
174
+
175
+ it("swaps enterFrom to enterTo after double rAF", () => {
176
+ const show = signal(false)
177
+ const { el } = setupTransitionItem({
178
+ show: () => show(),
179
+ enter: "ti-enter",
180
+ enterFrom: "ti-enter-from",
181
+ enterTo: "ti-enter-to",
182
+ })
183
+
184
+ show.set(true)
185
+
186
+ flushRaf()
187
+ flushRaf()
188
+
189
+ expect(el.classList.contains("ti-enter")).toBe(true)
190
+ expect(el.classList.contains("ti-enter-from")).toBe(false)
191
+ expect(el.classList.contains("ti-enter-to")).toBe(true)
192
+ })
193
+
194
+ it("applies leave classes when leaving", () => {
195
+ const show = signal(true)
196
+ const { el } = setupTransitionItem({
197
+ show: () => show(),
198
+ leave: "ti-leave",
199
+ leaveFrom: "ti-leave-from",
200
+ leaveTo: "ti-leave-to",
201
+ })
202
+
203
+ show.set(false)
204
+
205
+ expect(el.classList.contains("ti-leave")).toBe(true)
206
+ expect(el.classList.contains("ti-leave-from")).toBe(true)
207
+
208
+ flushRaf()
209
+ flushRaf()
210
+
211
+ expect(el.classList.contains("ti-leave-from")).toBe(false)
212
+ expect(el.classList.contains("ti-leave-to")).toBe(true)
213
+ })
214
+
215
+ it("applies enter style transitions", () => {
216
+ const show = signal(false)
217
+ const { el } = setupTransitionItem({
218
+ show: () => show(),
219
+ enterStyle: { opacity: 0 },
220
+ enterToStyle: { opacity: 1 },
221
+ enterTransition: "opacity 300ms ease",
222
+ })
223
+
224
+ show.set(true)
225
+
226
+ expect(el.style.opacity).toBe("0")
227
+ expect(el.style.transition).toBe("opacity 300ms ease")
228
+
229
+ flushRaf()
230
+ flushRaf()
231
+
232
+ expect(el.style.opacity).toBe("1")
233
+ })
234
+
235
+ it("applies leave style transitions", () => {
236
+ const show = signal(true)
237
+ const { el } = setupTransitionItem({
238
+ show: () => show(),
239
+ leaveStyle: { opacity: 1 },
240
+ leaveToStyle: { opacity: 0 },
241
+ leaveTransition: "opacity 200ms ease-in",
242
+ })
243
+
244
+ show.set(false)
245
+
246
+ expect(el.style.opacity).toBe("1")
247
+ expect(el.style.transition).toBe("opacity 200ms ease-in")
248
+
249
+ flushRaf()
250
+ flushRaf()
251
+
252
+ expect(el.style.opacity).toBe("0")
253
+ })
254
+
255
+ it("fires onAfterEnter after transitionend", () => {
256
+ const show = signal(false)
257
+ const onAfterEnter = vi.fn()
258
+
259
+ const { el } = setupTransitionItem({ show: () => show(), onAfterEnter })
260
+
261
+ show.set(true)
262
+ expect(onAfterEnter).not.toHaveBeenCalled()
263
+
264
+ flushRaf()
265
+ flushRaf()
266
+ fireTransitionEnd(el)
267
+
268
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
269
+ })
270
+
271
+ it("fires onAfterLeave after transitionend", () => {
272
+ const show = signal(true)
273
+ const onAfterLeave = vi.fn()
274
+
275
+ const { el } = setupTransitionItem({ show: () => show(), onAfterLeave })
276
+
277
+ show.set(false)
278
+ expect(onAfterLeave).not.toHaveBeenCalled()
279
+
280
+ flushRaf()
281
+ flushRaf()
282
+ fireTransitionEnd(el)
283
+
284
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
285
+ })
286
+
287
+ it("cleans up enter classes after transitionend", () => {
288
+ const show = signal(false)
289
+ const { el } = setupTransitionItem({
290
+ show: () => show(),
291
+ enter: "ti-enter",
292
+ enterFrom: "ti-enter-from",
293
+ enterTo: "ti-enter-to",
294
+ })
295
+
296
+ show.set(true)
297
+ flushRaf()
298
+ flushRaf()
299
+ fireTransitionEnd(el)
300
+
301
+ // enter class should be removed on entered stage
302
+ expect(el.classList.contains("ti-enter")).toBe(false)
303
+ })
304
+
305
+ it("cleans up transition style on entered stage", () => {
306
+ const show = signal(false)
307
+ const { el } = setupTransitionItem({
308
+ show: () => show(),
309
+ enter: "ti-enter",
310
+ enterTransition: "opacity 300ms ease",
311
+ enterStyle: { opacity: 0 },
312
+ enterToStyle: { opacity: 1 },
313
+ })
314
+
315
+ show.set(true)
316
+ expect(el.style.transition).toBe("opacity 300ms ease")
317
+ expect(el.classList.contains("ti-enter")).toBe(true)
318
+
319
+ flushRaf()
320
+ flushRaf()
321
+ fireTransitionEnd(el)
322
+
323
+ expect(el.style.transition).toBe("")
324
+ expect(el.classList.contains("ti-enter")).toBe(false)
325
+ })
326
+
327
+ it("appear=true fires onEnter on initial mount", () => {
328
+ const show = signal(true)
329
+ const onEnter = vi.fn()
330
+
331
+ setupTransitionItem({ show: () => show(), appear: true, onEnter })
332
+
333
+ expect(onEnter).toHaveBeenCalledTimes(1)
334
+ })
335
+
336
+ it("appear=false does not fire onEnter on initial mount when show is true", () => {
337
+ const show = signal(true)
338
+ const onEnter = vi.fn()
339
+
340
+ setupTransitionItem({ show: () => show(), appear: false, onEnter })
341
+
342
+ expect(onEnter).not.toHaveBeenCalled()
343
+ })
344
+
345
+ it("timeout fallback completes transition when transitionend never fires", () => {
346
+ const show = signal(false)
347
+ const onAfterEnter = vi.fn()
348
+
349
+ setupTransitionItem({ show: () => show(), timeout: 1000, onAfterEnter })
350
+
351
+ show.set(true)
352
+ expect(onAfterEnter).not.toHaveBeenCalled()
353
+
354
+ vi.advanceTimersByTime(1000)
355
+
356
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
357
+ })
358
+
359
+ it("unmount=false keeps element with display:none when hidden", () => {
360
+ const show = () => false
361
+ const child: VNode = { type: "div", props: {}, children: ["Hello"], key: null }
362
+ const vnode = TransitionItem({ show, unmount: false, children: child })
363
+
364
+ expect(vnode).not.toBeNull()
365
+ // With unmount=false, the fallback should contain a cloned VNode with display:none
366
+ const showProps = vnode?.props as Record<string, unknown>
367
+ if (showProps?.fallback && typeof showProps.fallback === "object") {
368
+ const fallbackNode = showProps.fallback as VNode
369
+ const fallbackProps = fallbackNode.props as Record<string, unknown>
370
+ const style = fallbackProps?.style as Record<string, unknown> | undefined
371
+ expect(style?.display).toBe("none")
372
+ }
373
+ })
374
+
375
+ it("unmount=false fallback has a merged ref", () => {
376
+ const show = () => false
377
+ const child: VNode = { type: "div", props: {}, children: ["Hello"], key: null }
378
+ const vnode = TransitionItem({ show, unmount: false, children: child })
379
+
380
+ const showProps = vnode?.props as Record<string, unknown>
381
+ if (showProps?.fallback && typeof showProps.fallback === "object") {
382
+ const fallbackNode = showProps.fallback as VNode
383
+ const fallbackProps = fallbackNode.props as Record<string, unknown>
384
+ expect(fallbackProps?.ref).toBeDefined()
385
+ expect(typeof fallbackProps?.ref).toBe("function")
386
+ }
387
+ })
388
+
389
+ it("unmount=false merges existing child style with display:none", () => {
390
+ const show = () => false
391
+ const child: VNode = {
392
+ type: "div",
393
+ props: { style: { color: "red", opacity: 1 } },
394
+ children: ["Hello"],
395
+ key: null,
396
+ }
397
+ const vnode = TransitionItem({ show, unmount: false, children: child })
398
+
399
+ const showProps = vnode?.props as Record<string, unknown>
400
+ if (showProps?.fallback && typeof showProps.fallback === "object") {
401
+ const fallbackNode = showProps.fallback as VNode
402
+ const fallbackProps = fallbackNode.props as Record<string, unknown>
403
+ const style = fallbackProps?.style as Record<string, unknown> | undefined
404
+ expect(style?.color).toBe("red")
405
+ expect(style?.opacity).toBe(1)
406
+ expect(style?.display).toBe("none")
407
+ }
408
+ })
409
+
410
+ it("appear=true applies enter classes on initial mount when show is true", () => {
411
+ const show = signal(true)
412
+ const { el } = setupTransitionItem({
413
+ show: () => show(),
414
+ appear: true,
415
+ enter: "ti-enter",
416
+ enterFrom: "ti-enter-from",
417
+ enterTo: "ti-enter-to",
418
+ })
419
+
420
+ // After appear, entering classes should be applied
421
+ expect(el.classList.contains("ti-enter")).toBe(true)
422
+ expect(el.classList.contains("ti-enter-from")).toBe(true)
423
+
424
+ flushRaf()
425
+ flushRaf()
426
+
427
+ expect(el.classList.contains("ti-enter-from")).toBe(false)
428
+ expect(el.classList.contains("ti-enter-to")).toBe(true)
429
+ })
430
+
431
+ it("appear=true completes full enter lifecycle", () => {
432
+ const show = signal(true)
433
+ const onEnter = vi.fn()
434
+ const onAfterEnter = vi.fn()
435
+
436
+ const { el } = setupTransitionItem({
437
+ show: () => show(),
438
+ appear: true,
439
+ onEnter,
440
+ onAfterEnter,
441
+ enter: "ti-enter",
442
+ })
443
+
444
+ expect(onEnter).toHaveBeenCalledTimes(1)
445
+
446
+ flushRaf()
447
+ flushRaf()
448
+ fireTransitionEnd(el)
449
+
450
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
451
+ // After entered stage, enter class should be cleaned up
452
+ expect(el.classList.contains("ti-enter")).toBe(false)
453
+ expect(el.style.transition).toBe("")
454
+ })
455
+ })
456
+
457
+ describe("TransitionItem — reduced motion", () => {
458
+ beforeEach(() => {
459
+ vi.useFakeTimers()
460
+ rafCallbacks = []
461
+ _reducedMotion = true
462
+
463
+ vi.stubGlobal(
464
+ "requestAnimationFrame",
465
+ vi.fn((cb: () => void) => {
466
+ rafCallbacks.push(cb)
467
+ return rafCallbacks.length
468
+ }),
469
+ )
470
+ vi.stubGlobal("cancelAnimationFrame", vi.fn())
471
+ })
472
+
473
+ afterEach(() => {
474
+ vi.useRealTimers()
475
+ vi.stubGlobal("requestAnimationFrame", originalRaf)
476
+ vi.stubGlobal("cancelAnimationFrame", originalCaf)
477
+ _reducedMotion = false
478
+ })
479
+
480
+ it("reduced motion: entering fires onEnter and onAfterEnter immediately", () => {
481
+ const show = signal(false)
482
+ const onEnter = vi.fn()
483
+ const onAfterEnter = vi.fn()
484
+
485
+ setupTransitionItem({ show: () => show(), onEnter, onAfterEnter })
486
+
487
+ show.set(true)
488
+
489
+ expect(onEnter).toHaveBeenCalledTimes(1)
490
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
491
+ })
492
+
493
+ it("reduced motion: leaving fires onLeave and onAfterLeave immediately", () => {
494
+ const show = signal(true)
495
+ const onLeave = vi.fn()
496
+ const onAfterLeave = vi.fn()
497
+
498
+ setupTransitionItem({ show: () => show(), onLeave, onAfterLeave })
499
+
500
+ show.set(false)
501
+
502
+ expect(onLeave).toHaveBeenCalledTimes(1)
503
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
504
+ })
505
+
506
+ it("reduced motion: does not apply CSS classes or rAF", () => {
507
+ const show = signal(false)
508
+ const { el } = setupTransitionItem({
509
+ show: () => show(),
510
+ enter: "ti-enter",
511
+ enterFrom: "ti-enter-from",
512
+ enterTo: "ti-enter-to",
513
+ })
514
+
515
+ show.set(true)
516
+
517
+ // No classes should be applied — reduced motion skips CSS transitions
518
+ expect(el.classList.contains("ti-enter")).toBe(false)
519
+ expect(el.classList.contains("ti-enter-from")).toBe(false)
520
+ expect(rafCallbacks.length).toBe(0)
521
+ })
522
+ })