@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,406 @@
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 Transition from "../Transition"
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
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex logic is inherent to this function
57
+ const visitNode = (node: VNode) => {
58
+ const nodeProps = node.props as Record<string, unknown>
59
+ if (typeof nodeProps?.ref === "function") {
60
+ ;(nodeProps.ref as (element: HTMLElement | null) => void)(el)
61
+ } else if (nodeProps?.ref && typeof nodeProps.ref === "object") {
62
+ ;(nodeProps.ref as { current: HTMLElement | null }).current = el
63
+ }
64
+ if (node.children) {
65
+ const ch = Array.isArray(node.children) ? node.children : [node.children]
66
+ for (const c of ch) {
67
+ if (c && typeof c === "object" && "type" in (c as object)) visitNode(c as VNode)
68
+ }
69
+ }
70
+ if (nodeProps?.children) {
71
+ const pc = Array.isArray(nodeProps.children) ? nodeProps.children : [nodeProps.children]
72
+ for (const c of pc) {
73
+ if (c && typeof c === "object" && "type" in (c as object)) visitNode(c as VNode)
74
+ }
75
+ }
76
+ if (
77
+ nodeProps?.fallback &&
78
+ typeof nodeProps.fallback === "object" &&
79
+ "type" in (nodeProps.fallback as object)
80
+ ) {
81
+ visitNode(nodeProps.fallback as VNode)
82
+ }
83
+ }
84
+ visitNode(vnode)
85
+ }
86
+
87
+ /**
88
+ * Helper: call Transition and wire up a mock element to the merged ref.
89
+ */
90
+ const setupTransition = (props: Record<string, unknown>) => {
91
+ const el = document.createElement("div")
92
+ const child: VNode = {
93
+ type: "div",
94
+ props: { "data-testid": "child" },
95
+ children: ["Hello"],
96
+ key: null,
97
+ }
98
+
99
+ const vnode = Transition({
100
+ ...props,
101
+ children: child,
102
+ } as any)
103
+
104
+ wireRef(vnode, el)
105
+
106
+ return { vnode, el }
107
+ }
108
+
109
+ describe("Transition", () => {
110
+ it("returns a VNode when show=true", () => {
111
+ const show = signal(true)
112
+ const child: VNode = { type: "div", props: {}, children: ["Hello"], key: null }
113
+ const vnode = Transition({ show, children: child })
114
+ expect(vnode).not.toBeNull()
115
+ })
116
+
117
+ it("returns a VNode with Show component", () => {
118
+ const show = signal(true)
119
+ const child: VNode = { type: "div", props: {}, children: ["Hello"], key: null }
120
+ const vnode = Transition({ show, children: child })
121
+ expect(vnode).not.toBeNull()
122
+ // The outermost VNode should be a Show component
123
+ expect(typeof vnode?.type).toBe("function")
124
+ })
125
+
126
+ it("fires onEnter callback when entering starts", () => {
127
+ const show = signal(false)
128
+ const onEnter = vi.fn()
129
+
130
+ setupTransition({ show, onEnter })
131
+
132
+ expect(onEnter).not.toHaveBeenCalled()
133
+
134
+ show.set(true)
135
+ expect(onEnter).toHaveBeenCalledTimes(1)
136
+ })
137
+
138
+ it("fires onLeave callback when leaving starts", () => {
139
+ const show = signal(true)
140
+ const onLeave = vi.fn()
141
+
142
+ setupTransition({ show, onLeave })
143
+
144
+ show.set(false)
145
+ expect(onLeave).toHaveBeenCalledTimes(1)
146
+ })
147
+
148
+ it("fires onAfterEnter after transitionend", () => {
149
+ const show = signal(false)
150
+ const onAfterEnter = vi.fn()
151
+
152
+ const { el } = setupTransition({ show, onAfterEnter })
153
+
154
+ show.set(true)
155
+ expect(onAfterEnter).not.toHaveBeenCalled()
156
+
157
+ flushRaf()
158
+ flushRaf()
159
+ fireTransitionEnd(el)
160
+
161
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
162
+ })
163
+
164
+ it("fires onAfterLeave after transitionend", () => {
165
+ const show = signal(true)
166
+ const onAfterLeave = vi.fn()
167
+
168
+ const { el } = setupTransition({ show, onAfterLeave })
169
+
170
+ show.set(false)
171
+ expect(onAfterLeave).not.toHaveBeenCalled()
172
+
173
+ flushRaf()
174
+ flushRaf()
175
+ fireTransitionEnd(el)
176
+
177
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
178
+ })
179
+
180
+ it("applies enter classes on entering", () => {
181
+ const show = signal(false)
182
+ const { el } = setupTransition({
183
+ show,
184
+ enter: "t-enter",
185
+ enterFrom: "t-enter-from",
186
+ enterTo: "t-enter-to",
187
+ })
188
+
189
+ show.set(true)
190
+
191
+ expect(el.classList.contains("t-enter")).toBe(true)
192
+ expect(el.classList.contains("t-enter-from")).toBe(true)
193
+ expect(el.classList.contains("t-enter-to")).toBe(false)
194
+ })
195
+
196
+ it("swaps enterFrom to enterTo after double rAF", () => {
197
+ const show = signal(false)
198
+ const { el } = setupTransition({
199
+ show,
200
+ enter: "t-enter",
201
+ enterFrom: "t-enter-from",
202
+ enterTo: "t-enter-to",
203
+ })
204
+
205
+ show.set(true)
206
+
207
+ flushRaf()
208
+ flushRaf()
209
+
210
+ expect(el.classList.contains("t-enter")).toBe(true)
211
+ expect(el.classList.contains("t-enter-from")).toBe(false)
212
+ expect(el.classList.contains("t-enter-to")).toBe(true)
213
+ })
214
+
215
+ it("cleans up enter classes after transitionend", () => {
216
+ const show = signal(false)
217
+ const { el } = setupTransition({
218
+ show,
219
+ enter: "t-enter",
220
+ enterFrom: "t-enter-from",
221
+ enterTo: "t-enter-to",
222
+ })
223
+
224
+ show.set(true)
225
+ flushRaf()
226
+ flushRaf()
227
+ fireTransitionEnd(el)
228
+
229
+ // enter class should be removed on entered stage
230
+ expect(el.classList.contains("t-enter")).toBe(false)
231
+ })
232
+
233
+ it("applies style-object transitions on entering", () => {
234
+ const show = signal(false)
235
+ const { el } = setupTransition({
236
+ show,
237
+ enterStyle: { opacity: 0 },
238
+ enterToStyle: { opacity: 1 },
239
+ enterTransition: "opacity 300ms ease",
240
+ })
241
+
242
+ show.set(true)
243
+
244
+ expect(el.style.opacity).toBe("0")
245
+ expect(el.style.transition).toBe("opacity 300ms ease")
246
+
247
+ flushRaf()
248
+ flushRaf()
249
+
250
+ expect(el.style.opacity).toBe("1")
251
+ })
252
+
253
+ it("applies leave classes on leaving", () => {
254
+ const show = signal(true)
255
+ const { el } = setupTransition({
256
+ show,
257
+ leave: "t-leave",
258
+ leaveFrom: "t-leave-from",
259
+ leaveTo: "t-leave-to",
260
+ })
261
+
262
+ show.set(false)
263
+
264
+ expect(el.classList.contains("t-leave")).toBe(true)
265
+ expect(el.classList.contains("t-leave-from")).toBe(true)
266
+
267
+ flushRaf()
268
+ flushRaf()
269
+
270
+ expect(el.classList.contains("t-leave-from")).toBe(false)
271
+ expect(el.classList.contains("t-leave-to")).toBe(true)
272
+ })
273
+
274
+ it("applies leave style transitions", () => {
275
+ const show = signal(true)
276
+ const { el } = setupTransition({
277
+ show,
278
+ leaveStyle: { opacity: 1 },
279
+ leaveToStyle: { opacity: 0 },
280
+ leaveTransition: "opacity 200ms ease-in",
281
+ })
282
+
283
+ show.set(false)
284
+
285
+ expect(el.style.opacity).toBe("1")
286
+ expect(el.style.transition).toBe("opacity 200ms ease-in")
287
+
288
+ flushRaf()
289
+ flushRaf()
290
+
291
+ expect(el.style.opacity).toBe("0")
292
+ })
293
+
294
+ it("appear=true fires onEnter on initial mount", () => {
295
+ const show = signal(true)
296
+ const onEnter = vi.fn()
297
+
298
+ setupTransition({ show, appear: true, onEnter })
299
+
300
+ expect(onEnter).toHaveBeenCalledTimes(1)
301
+ })
302
+
303
+ it("timeout fallback completes transition when transitionend never fires", () => {
304
+ const show = signal(false)
305
+ const onAfterEnter = vi.fn()
306
+
307
+ setupTransition({ show, timeout: 1000, onAfterEnter })
308
+
309
+ show.set(true)
310
+
311
+ expect(onAfterEnter).not.toHaveBeenCalled()
312
+
313
+ vi.advanceTimersByTime(1000)
314
+
315
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
316
+ })
317
+
318
+ it("cleans up transition style on entered stage", () => {
319
+ const show = signal(false)
320
+ const { el } = setupTransition({
321
+ show,
322
+ enter: "t-enter",
323
+ enterTransition: "opacity 300ms ease",
324
+ enterStyle: { opacity: 0 },
325
+ enterToStyle: { opacity: 1 },
326
+ })
327
+
328
+ show.set(true)
329
+ expect(el.style.transition).toBe("opacity 300ms ease")
330
+ expect(el.classList.contains("t-enter")).toBe(true)
331
+
332
+ flushRaf()
333
+ flushRaf()
334
+ fireTransitionEnd(el)
335
+
336
+ // After entering -> entered, transition reset and enter class removed
337
+ expect(el.style.transition).toBe("")
338
+ expect(el.classList.contains("t-enter")).toBe(false)
339
+ })
340
+ })
341
+
342
+ describe("Transition — reduced motion", () => {
343
+ beforeEach(() => {
344
+ vi.useFakeTimers()
345
+ rafCallbacks = []
346
+ _reducedMotion = true
347
+
348
+ vi.stubGlobal(
349
+ "requestAnimationFrame",
350
+ vi.fn((cb: () => void) => {
351
+ rafCallbacks.push(cb)
352
+ return rafCallbacks.length
353
+ }),
354
+ )
355
+ vi.stubGlobal("cancelAnimationFrame", vi.fn())
356
+ })
357
+
358
+ afterEach(() => {
359
+ vi.useRealTimers()
360
+ vi.stubGlobal("requestAnimationFrame", originalRaf)
361
+ vi.stubGlobal("cancelAnimationFrame", originalCaf)
362
+ _reducedMotion = false
363
+ })
364
+
365
+ it("reduced motion: entering fires onEnter and onAfterEnter immediately", () => {
366
+ const show = signal(false)
367
+ const onEnter = vi.fn()
368
+ const onAfterEnter = vi.fn()
369
+
370
+ setupTransition({ show, onEnter, onAfterEnter })
371
+
372
+ show.set(true)
373
+
374
+ expect(onEnter).toHaveBeenCalledTimes(1)
375
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
376
+ })
377
+
378
+ it("reduced motion: leaving fires onLeave and onAfterLeave immediately", () => {
379
+ const show = signal(true)
380
+ const onLeave = vi.fn()
381
+ const onAfterLeave = vi.fn()
382
+
383
+ setupTransition({ show, onLeave, onAfterLeave })
384
+
385
+ show.set(false)
386
+
387
+ expect(onLeave).toHaveBeenCalledTimes(1)
388
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
389
+ })
390
+
391
+ it("reduced motion: does not use rAF or apply CSS classes", () => {
392
+ const show = signal(false)
393
+ const { el } = setupTransition({
394
+ show,
395
+ enter: "t-enter",
396
+ enterFrom: "t-enter-from",
397
+ enterTo: "t-enter-to",
398
+ })
399
+
400
+ show.set(true)
401
+
402
+ expect(el.classList.contains("t-enter")).toBe(false)
403
+ expect(el.classList.contains("t-enter-from")).toBe(false)
404
+ expect(rafCallbacks.length).toBe(0)
405
+ })
406
+ })