@pyreon/kinetic 0.11.0 → 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,562 @@
1
+ import type { VNode, VNodeChild } 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 { kinetic } from "../index"
11
+ import { fade, slideUp } from "../presets"
12
+
13
+ // Mock rAF for deterministic testing
14
+ let rafCallbacks: (() => void)[] = []
15
+ const originalRaf = globalThis.requestAnimationFrame
16
+ const originalCaf = globalThis.cancelAnimationFrame
17
+
18
+ beforeEach(() => {
19
+ vi.useFakeTimers()
20
+ rafCallbacks = []
21
+
22
+ vi.stubGlobal(
23
+ "requestAnimationFrame",
24
+ vi.fn((cb: () => void) => {
25
+ rafCallbacks.push(cb)
26
+ return rafCallbacks.length
27
+ }),
28
+ )
29
+
30
+ vi.stubGlobal("cancelAnimationFrame", vi.fn())
31
+ })
32
+
33
+ afterEach(() => {
34
+ vi.useRealTimers()
35
+ vi.stubGlobal("requestAnimationFrame", originalRaf)
36
+ vi.stubGlobal("cancelAnimationFrame", originalCaf)
37
+ })
38
+
39
+ const flushRaf = () => {
40
+ const cbs = [...rafCallbacks]
41
+ rafCallbacks = []
42
+ for (const cb of cbs) cb()
43
+ }
44
+
45
+ const fireTransitionEnd = (el: HTMLElement) => {
46
+ const event = new Event("transitionend", { bubbles: true })
47
+ Object.defineProperty(event, "target", { value: el })
48
+ el.dispatchEvent(event)
49
+ }
50
+
51
+ /**
52
+ * Wire up a mock element to the ref found in the VNode tree.
53
+ * The kinetic transition mode uses h(tag, { ref: mergedRef, ... })
54
+ * which means the ref is directly on the VNode props.
55
+ */
56
+ const wireRef = (vnode: VNode | null, el: HTMLElement) => {
57
+ if (!vnode) return
58
+
59
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex logic is inherent to this function
60
+ const visitNode = (node: VNode) => {
61
+ const props = node.props as Record<string, unknown>
62
+ if (typeof props?.ref === "function") {
63
+ ;(props.ref as (element: HTMLElement | null) => void)(el)
64
+ } else if (props?.ref && typeof props.ref === "object") {
65
+ ;(props.ref as { current: HTMLElement | null }).current = el
66
+ }
67
+
68
+ // Visit vnode.children (for intrinsic elements)
69
+ if (node.children) {
70
+ const children = Array.isArray(node.children) ? node.children : [node.children]
71
+ for (const child of children) {
72
+ if (child && typeof child === "object" && "type" in (child as object)) {
73
+ visitNode(child as VNode)
74
+ }
75
+ }
76
+ }
77
+
78
+ // Visit props.children (for component VNodes like Show)
79
+ if (props?.children) {
80
+ const pChildren = Array.isArray(props.children) ? props.children : [props.children]
81
+ for (const child of pChildren) {
82
+ if (child && typeof child === "object" && "type" in (child as object)) {
83
+ visitNode(child as VNode)
84
+ }
85
+ }
86
+ }
87
+
88
+ // Visit fallback on Show
89
+ if (
90
+ props?.fallback &&
91
+ typeof props.fallback === "object" &&
92
+ "type" in (props.fallback as object)
93
+ ) {
94
+ visitNode(props.fallback as VNode)
95
+ }
96
+ }
97
+
98
+ visitNode(vnode)
99
+ }
100
+
101
+ /**
102
+ * Resolve the outermost component-type VNode by calling the function.
103
+ * kinetic() returns VNodes where type is a component function (e.g. TransitionRenderer).
104
+ * We call it once to execute the component and set up watches/refs,
105
+ * but do NOT recurse into framework components like Show.
106
+ */
107
+ const resolveComponent = (vnode: VNodeChild): VNode | null => {
108
+ if (!vnode || typeof vnode !== "object" || !("type" in vnode)) return null
109
+ if (typeof vnode.type === "function") {
110
+ const props = vnode.props as Record<string, unknown>
111
+ const children = vnode.children
112
+ // Call the component function with props (children merged in)
113
+ const result = (vnode.type as (p: Record<string, unknown>) => VNodeChild)({
114
+ ...props,
115
+ ...(children != null ? { children } : {}),
116
+ })
117
+ if (!result || typeof result !== "object" || !("type" in result)) return null
118
+ return result
119
+ }
120
+ return vnode
121
+ }
122
+
123
+ // ─── Transition Mode (default) ────────────────────────────
124
+
125
+ describe("kinetic() — transition mode", () => {
126
+ it("returns a VNode when show=true", () => {
127
+ const FadeDiv = kinetic("div").preset(fade)
128
+ const show = signal(true)
129
+ const vnode = FadeDiv({ show, children: "Hello" })
130
+ expect(vnode).not.toBeNull()
131
+ })
132
+
133
+ it("fires onEnter callback when entering", () => {
134
+ const onEnter = vi.fn()
135
+ const show = signal(false)
136
+ const Slide = kinetic("div")
137
+ .enter({ opacity: 0, transform: "translateY(16px)" })
138
+ .enterTo({ opacity: 1, transform: "translateY(0)" })
139
+ .enterTransition("all 300ms ease")
140
+
141
+ const el = document.createElement("div")
142
+ const vnode = resolveComponent(Slide({ show, onEnter, children: "Hello" }))
143
+ wireRef(vnode, el)
144
+
145
+ show.set(true)
146
+ expect(onEnter).toHaveBeenCalledTimes(1)
147
+ })
148
+
149
+ it("applies enterStyle on entering", () => {
150
+ const show = signal(false)
151
+ const Slide = kinetic("div")
152
+ .enter({ opacity: 0, transform: "translateY(16px)" })
153
+ .enterTo({ opacity: 1, transform: "translateY(0)" })
154
+ .enterTransition("all 300ms ease")
155
+
156
+ const el = document.createElement("div")
157
+ const vnode = resolveComponent(Slide({ show, children: "Hello" }))
158
+ wireRef(vnode, el)
159
+
160
+ show.set(true)
161
+
162
+ expect(el.style.opacity).toBe("0")
163
+ expect(el.style.transition).toBe("all 300ms ease")
164
+
165
+ flushRaf()
166
+ flushRaf()
167
+
168
+ expect(el.style.opacity).toBe("1")
169
+ })
170
+
171
+ it("applies class-based transitions via .enterClass()", () => {
172
+ const show = signal(false)
173
+ const ClassFade = kinetic("div")
174
+ .enterClass({ active: "t-enter", from: "t-from", to: "t-to" })
175
+ .leaveClass({ active: "t-leave", from: "t-lfrom", to: "t-lto" })
176
+
177
+ const el = document.createElement("div")
178
+ const vnode = resolveComponent(ClassFade({ show, children: "Hello" }))
179
+ wireRef(vnode, el)
180
+
181
+ show.set(true)
182
+
183
+ expect(el.classList.contains("t-enter")).toBe(true)
184
+ expect(el.classList.contains("t-from")).toBe(true)
185
+
186
+ flushRaf()
187
+ flushRaf()
188
+
189
+ expect(el.classList.contains("t-from")).toBe(false)
190
+ expect(el.classList.contains("t-to")).toBe(true)
191
+ })
192
+
193
+ it("fires lifecycle callbacks at correct times", () => {
194
+ const onEnter = vi.fn()
195
+ const onAfterEnter = vi.fn()
196
+ const onLeave = vi.fn()
197
+ const onAfterLeave = vi.fn()
198
+ const show = signal(false)
199
+
200
+ const FadeDiv = kinetic("div").preset(fade)
201
+ const el = document.createElement("div")
202
+ const vnode = resolveComponent(
203
+ FadeDiv({ show, onEnter, onAfterEnter, onLeave, onAfterLeave, children: "Hello" }),
204
+ )
205
+ wireRef(vnode, el)
206
+
207
+ // Enter
208
+ show.set(true)
209
+ expect(onEnter).toHaveBeenCalledTimes(1)
210
+ expect(onAfterEnter).not.toHaveBeenCalled()
211
+
212
+ flushRaf()
213
+ flushRaf()
214
+ fireTransitionEnd(el)
215
+
216
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
217
+
218
+ // Leave
219
+ show.set(false)
220
+ expect(onLeave).toHaveBeenCalledTimes(1)
221
+
222
+ flushRaf()
223
+ flushRaf()
224
+ fireTransitionEnd(el)
225
+
226
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
227
+ })
228
+
229
+ it("timeout fallback completes transition", () => {
230
+ const onAfterEnter = vi.fn()
231
+ const show = signal(false)
232
+ const FadeDiv = kinetic("div").preset(fade).config({ timeout: 1000 })
233
+
234
+ const el = document.createElement("div")
235
+ const vnode = resolveComponent(FadeDiv({ show, onAfterEnter, children: "Hello" }))
236
+ wireRef(vnode, el)
237
+
238
+ show.set(true)
239
+
240
+ flushRaf()
241
+ flushRaf()
242
+
243
+ vi.advanceTimersByTime(1000)
244
+
245
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
246
+ })
247
+
248
+ it("appear=true animates on initial mount", () => {
249
+ const onEnter = vi.fn()
250
+ const show = signal(true)
251
+ const FadeDiv = kinetic("div").preset(fade).config({ appear: true }).on({ onEnter })
252
+
253
+ const el = document.createElement("div")
254
+ const vnode = resolveComponent(FadeDiv({ show, children: "Hello" }))
255
+ wireRef(vnode, el)
256
+
257
+ expect(onEnter).toHaveBeenCalledTimes(1)
258
+ })
259
+ })
260
+
261
+ // ─── Chain Immutability ────────────────────────────────────
262
+
263
+ describe("kinetic() — chaining", () => {
264
+ it("chain is immutable (each method returns new component)", () => {
265
+ const Base = kinetic("div")
266
+ const WithFade = Base.preset(fade)
267
+ const WithSlide = Base.preset(slideUp)
268
+
269
+ // WithFade and WithSlide are different components
270
+ expect(WithFade).not.toBe(WithSlide)
271
+ expect(WithFade).not.toBe(Base)
272
+ })
273
+
274
+ it(".preset() merges preset properties into config", () => {
275
+ const show = signal(false)
276
+ const FadeDiv = kinetic("div").preset(fade)
277
+
278
+ const el = document.createElement("div")
279
+ const vnode = resolveComponent(FadeDiv({ show, children: "Hello" }))
280
+ wireRef(vnode, el)
281
+
282
+ show.set(true)
283
+
284
+ expect(el.style.opacity).toBe("0")
285
+ expect(el.style.transition).toBe("opacity 300ms ease-out")
286
+ })
287
+
288
+ it(".on() callbacks from chain are used when runtime callbacks not provided", () => {
289
+ const onEnter = vi.fn()
290
+ const show = signal(false)
291
+ const FadeDiv = kinetic("div").preset(fade).on({ onEnter })
292
+
293
+ const el = document.createElement("div")
294
+ const vnode = resolveComponent(FadeDiv({ show, children: "Hello" }))
295
+ wireRef(vnode, el)
296
+
297
+ show.set(true)
298
+ expect(onEnter).toHaveBeenCalledTimes(1)
299
+ })
300
+
301
+ it("runtime props override chain config", () => {
302
+ const chainOnEnter = vi.fn()
303
+ const runtimeOnEnter = vi.fn()
304
+ const show = signal(false)
305
+ const FadeDiv = kinetic("div").preset(fade).on({ onEnter: chainOnEnter })
306
+
307
+ const el = document.createElement("div")
308
+ const vnode = resolveComponent(FadeDiv({ show, onEnter: runtimeOnEnter, children: "Hello" }))
309
+ wireRef(vnode, el)
310
+
311
+ show.set(true)
312
+
313
+ expect(runtimeOnEnter).toHaveBeenCalledTimes(1)
314
+ expect(chainOnEnter).not.toHaveBeenCalled()
315
+ })
316
+
317
+ it("displayName is set correctly", () => {
318
+ const FadeDiv = kinetic("div").preset(fade)
319
+ expect(FadeDiv.displayName).toBe("kinetic(div)")
320
+ })
321
+ })
322
+
323
+ // ─── Collapse Mode ─────────────────────────────────────────
324
+
325
+ describe("kinetic() — collapse mode", () => {
326
+ const mockScrollHeight = (value: number) => {
327
+ Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
328
+ configurable: true,
329
+ get() {
330
+ return value
331
+ },
332
+ })
333
+ }
334
+
335
+ beforeEach(() => {
336
+ mockScrollHeight(200)
337
+ })
338
+
339
+ it("fires onEnter on entering and onAfterEnter after transitionend", () => {
340
+ const onEnter = vi.fn()
341
+ const onAfterEnter = vi.fn()
342
+ const show = signal(false)
343
+ const Accordion = kinetic("div").collapse()
344
+
345
+ const wrapperEl = document.createElement("div")
346
+ const contentEl = document.createElement("div")
347
+ Object.defineProperty(wrapperEl, "offsetHeight", {
348
+ configurable: true,
349
+ get: () => 0,
350
+ })
351
+
352
+ const vnode = resolveComponent(
353
+ Accordion({
354
+ show,
355
+ onEnter,
356
+ onAfterEnter,
357
+ children: { type: "p", props: {}, children: ["Content"], key: null },
358
+ }),
359
+ )
360
+
361
+ // Wire up refs - CollapseRenderer uses h(config.tag, { ref: wrapperRef, ... })
362
+ // and inner <div ref={contentRef}>
363
+ wireRef(vnode, wrapperEl)
364
+
365
+ // Find contentRef in the VNode tree
366
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex logic is inherent to this function
367
+ const findContentRef = (node: VNode | null) => {
368
+ if (!node) return
369
+ if (node.children) {
370
+ const children = Array.isArray(node.children) ? node.children : [node.children]
371
+ for (const child of children) {
372
+ if (child && typeof child === "object" && "type" in (child as object)) {
373
+ const cNode = child as VNode
374
+ const props = cNode.props as Record<string, unknown>
375
+ if (props?.ref && typeof props.ref === "object" && cNode.type === "div") {
376
+ ;(props.ref as { current: HTMLElement | null }).current = contentEl
377
+ }
378
+ findContentRef(cNode)
379
+ }
380
+ }
381
+ }
382
+ // Check Show props
383
+ const props = node.props as Record<string, unknown>
384
+ if (props?.children) {
385
+ const pc = Array.isArray(props.children) ? props.children : [props.children]
386
+ for (const p of pc) {
387
+ if (p && typeof p === "object" && "type" in (p as object)) {
388
+ const pNode = p as VNode
389
+ const pProps = pNode.props as Record<string, unknown>
390
+ if (pProps?.ref && typeof pProps.ref === "object") {
391
+ ;(pProps.ref as { current: HTMLElement | null }).current = contentEl
392
+ }
393
+ }
394
+ }
395
+ }
396
+ }
397
+ findContentRef(vnode)
398
+
399
+ show.set(true)
400
+
401
+ expect(onEnter).toHaveBeenCalledTimes(1)
402
+
403
+ fireTransitionEnd(wrapperEl)
404
+
405
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
406
+ })
407
+ })
408
+
409
+ // ─── Transition mode — leave with styles ─────────────────────
410
+
411
+ describe("kinetic() — transition leave styles", () => {
412
+ it("applies leaveStyle and leaveTransition on leaving", () => {
413
+ const show = signal(true)
414
+ const Slide = kinetic("div")
415
+ .enter({ opacity: 0 })
416
+ .enterTo({ opacity: 1 })
417
+ .enterTransition("opacity 300ms ease")
418
+ .leave({ opacity: 1 })
419
+ .leaveTo({ opacity: 0 })
420
+ .leaveTransition("opacity 200ms ease-in")
421
+
422
+ const el = document.createElement("div")
423
+ const vnode = resolveComponent(Slide({ show, children: "Hello" }))
424
+ wireRef(vnode, el)
425
+
426
+ show.set(false)
427
+
428
+ expect(el.style.opacity).toBe("1")
429
+ expect(el.style.transition).toBe("opacity 200ms ease-in")
430
+
431
+ flushRaf()
432
+ flushRaf()
433
+
434
+ expect(el.style.opacity).toBe("0")
435
+ })
436
+ })
437
+
438
+ // ─── Config defaults ──────────────────────────────────────
439
+
440
+ describe("kinetic() — config defaults and overrides", () => {
441
+ it("appear from config is used when runtime appear not provided", () => {
442
+ const onEnter = vi.fn()
443
+ const show = signal(true)
444
+ const FadeDiv = kinetic("div").preset(fade).config({ appear: true })
445
+
446
+ const el = document.createElement("div")
447
+ const vnode = resolveComponent(FadeDiv({ show, onEnter, children: "Hello" }))
448
+ wireRef(vnode, el)
449
+
450
+ expect(onEnter).toHaveBeenCalledTimes(1)
451
+ })
452
+
453
+ it("runtime appear overrides config appear", () => {
454
+ const onEnter = vi.fn()
455
+ const show = signal(true)
456
+ const FadeDiv = kinetic("div").preset(fade).config({ appear: true })
457
+
458
+ const el = document.createElement("div")
459
+ const vnode = resolveComponent(FadeDiv({ show, appear: false, onEnter, children: "Hello" }))
460
+ wireRef(vnode, el)
461
+
462
+ expect(onEnter).not.toHaveBeenCalled()
463
+ })
464
+
465
+ it("timeout from config is used as fallback", () => {
466
+ const onAfterEnter = vi.fn()
467
+ const show = signal(false)
468
+ const FadeDiv = kinetic("div").preset(fade).config({ timeout: 200 })
469
+
470
+ const el = document.createElement("div")
471
+ const vnode = resolveComponent(FadeDiv({ show, onAfterEnter, children: "Hello" }))
472
+ wireRef(vnode, el)
473
+
474
+ show.set(true)
475
+
476
+ flushRaf()
477
+ flushRaf()
478
+
479
+ // No transitionend fired — timeout from config (200ms) should fire
480
+ vi.advanceTimersByTime(200)
481
+
482
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
483
+ })
484
+
485
+ it("runtime timeout overrides config timeout", () => {
486
+ const onAfterEnter = vi.fn()
487
+ const show = signal(false)
488
+ const FadeDiv = kinetic("div").preset(fade).config({ timeout: 200 })
489
+
490
+ const el = document.createElement("div")
491
+ const vnode = resolveComponent(FadeDiv({ show, timeout: 500, onAfterEnter, children: "Hello" }))
492
+ wireRef(vnode, el)
493
+
494
+ show.set(true)
495
+
496
+ flushRaf()
497
+ flushRaf()
498
+
499
+ // 200ms from config should NOT trigger since runtime is 500ms
500
+ vi.advanceTimersByTime(200)
501
+ expect(onAfterEnter).not.toHaveBeenCalled()
502
+
503
+ vi.advanceTimersByTime(300)
504
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
505
+ })
506
+ })
507
+
508
+ // ─── DisplayName ───────────────────────────────────────────
509
+
510
+ describe("kinetic() — displayName", () => {
511
+ it("uses tag string for displayName", () => {
512
+ const FadeDiv = kinetic("div").preset(fade)
513
+ expect(FadeDiv.displayName).toBe("kinetic(div)")
514
+ })
515
+ })
516
+
517
+ // ─── Reduced Motion ───────────────────────────────────────
518
+
519
+ describe("kinetic() — transition reduced motion", () => {
520
+ beforeEach(() => {
521
+ _reducedMotion = true
522
+ })
523
+
524
+ afterEach(() => {
525
+ _reducedMotion = false
526
+ })
527
+
528
+ it("reduced motion: entering fires onEnter and onAfterEnter immediately without rAF", () => {
529
+ const show = signal(false)
530
+ const onEnter = vi.fn()
531
+ const onAfterEnter = vi.fn()
532
+ const FadeDiv = kinetic("div").preset(fade)
533
+
534
+ const el = document.createElement("div")
535
+ const vnode = resolveComponent(FadeDiv({ show, onEnter, onAfterEnter, children: "Hello" }))
536
+ wireRef(vnode, el)
537
+
538
+ show.set(true)
539
+
540
+ expect(onEnter).toHaveBeenCalledTimes(1)
541
+ expect(onAfterEnter).toHaveBeenCalledTimes(1)
542
+ // No rAF should have been used
543
+ expect(rafCallbacks.length).toBe(0)
544
+ })
545
+
546
+ it("reduced motion: leaving fires onLeave and onAfterLeave immediately without rAF", () => {
547
+ const show = signal(true)
548
+ const onLeave = vi.fn()
549
+ const onAfterLeave = vi.fn()
550
+ const FadeDiv = kinetic("div").preset(fade)
551
+
552
+ const el = document.createElement("div")
553
+ const vnode = resolveComponent(FadeDiv({ show, onLeave, onAfterLeave, children: "Hello" }))
554
+ wireRef(vnode, el)
555
+
556
+ show.set(false)
557
+
558
+ expect(onLeave).toHaveBeenCalledTimes(1)
559
+ expect(onAfterLeave).toHaveBeenCalledTimes(1)
560
+ expect(rafCallbacks.length).toBe(0)
561
+ })
562
+ })
@@ -0,0 +1,46 @@
1
+ import { fade, presets, scaleIn, slideDown, slideLeft, slideRight, slideUp } from "../presets"
2
+
3
+ describe("presets", () => {
4
+ const allPresets = {
5
+ fade,
6
+ scaleIn,
7
+ slideUp,
8
+ slideDown,
9
+ slideLeft,
10
+ slideRight,
11
+ }
12
+
13
+ it.each(Object.entries(allPresets))("%s has all required style properties", (_, preset) => {
14
+ expect(preset.enterStyle).toBeDefined()
15
+ expect(preset.enterToStyle).toBeDefined()
16
+ expect(preset.enterTransition).toBeDefined()
17
+ expect(preset.leaveStyle).toBeDefined()
18
+ expect(preset.leaveToStyle).toBeDefined()
19
+ expect(preset.leaveTransition).toBeDefined()
20
+ })
21
+
22
+ it.each(Object.entries(allPresets))("%s has non-empty transition strings", (_, preset) => {
23
+ expect(typeof preset.enterTransition).toBe("string")
24
+ expect((preset.enterTransition as string).length).toBeGreaterThan(0)
25
+ expect(typeof preset.leaveTransition).toBe("string")
26
+ expect((preset.leaveTransition as string).length).toBeGreaterThan(0)
27
+ })
28
+
29
+ it("presets object contains all expected presets", () => {
30
+ expect(Object.keys(presets)).toEqual([
31
+ "fade",
32
+ "scaleIn",
33
+ "slideUp",
34
+ "slideDown",
35
+ "slideLeft",
36
+ "slideRight",
37
+ ])
38
+ })
39
+
40
+ it("presets are plain objects (no side effects)", () => {
41
+ for (const preset of Object.values(presets)) {
42
+ expect(typeof preset).toBe("object")
43
+ expect(preset).not.toBeInstanceOf(Array)
44
+ }
45
+ })
46
+ })