@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,526 @@
1
+ import type { VNode } from "@pyreon/core"
2
+ import StaggerRenderer from "../kinetic/StaggerRenderer"
3
+ import type { KineticConfig } from "../kinetic/types"
4
+
5
+ // Mock rAF for deterministic testing
6
+ let rafCallbacks: (() => void)[] = []
7
+ const originalRaf = globalThis.requestAnimationFrame
8
+ const originalCaf = globalThis.cancelAnimationFrame
9
+
10
+ beforeEach(() => {
11
+ vi.useFakeTimers()
12
+ rafCallbacks = []
13
+
14
+ vi.stubGlobal(
15
+ "requestAnimationFrame",
16
+ vi.fn((cb: () => void) => {
17
+ rafCallbacks.push(cb)
18
+ return rafCallbacks.length
19
+ }),
20
+ )
21
+
22
+ vi.stubGlobal("cancelAnimationFrame", vi.fn())
23
+ })
24
+
25
+ afterEach(() => {
26
+ vi.useRealTimers()
27
+ vi.stubGlobal("requestAnimationFrame", originalRaf)
28
+ vi.stubGlobal("cancelAnimationFrame", originalCaf)
29
+ })
30
+
31
+ const makeConfig = (overrides: Partial<KineticConfig> = {}): KineticConfig => ({
32
+ tag: "div",
33
+ mode: "stagger",
34
+ enter: "s-enter",
35
+ enterFrom: "s-enter-from",
36
+ enterTo: "s-enter-to",
37
+ leave: "s-leave",
38
+ leaveFrom: "s-leave-from",
39
+ leaveTo: "s-leave-to",
40
+ ...overrides,
41
+ })
42
+
43
+ const makeChild = (key: string | number, text: string): VNode => ({
44
+ type: "span",
45
+ props: { "data-testid": `child-${key}` },
46
+ children: [text],
47
+ key,
48
+ })
49
+
50
+ /**
51
+ * Extract the cloned child VNode from a TransitionItem VNode.
52
+ *
53
+ * With Pyreon's automatic JSX runtime, component children are placed in
54
+ * props.children (not in vnode.children). We check both locations for safety.
55
+ */
56
+ const extractStaggerChild = (tiVNode: VNode): VNode | null => {
57
+ // For automatic JSX runtime, children are in props.children
58
+ const props = tiVNode.props as Record<string, unknown>
59
+ if (props?.children) {
60
+ const pc = Array.isArray(props.children) ? props.children : [props.children]
61
+ for (const c of pc) {
62
+ if (c && typeof c === "object" && "type" in (c as object)) return c as VNode
63
+ }
64
+ }
65
+ // Fallback: check vnode.children (classic runtime)
66
+ if (tiVNode.children) {
67
+ const ch = Array.isArray(tiVNode.children) ? tiVNode.children : [tiVNode.children]
68
+ for (const c of ch) {
69
+ if (c && typeof c === "object" && "type" in (c as object)) return c as VNode
70
+ }
71
+ }
72
+ return null
73
+ }
74
+
75
+ describe("StaggerRenderer", () => {
76
+ it("returns a VNode wrapping children in config.tag", () => {
77
+ const config = makeConfig()
78
+ const children = [makeChild("a", "Alpha"), makeChild("b", "Beta")]
79
+
80
+ const vnode = StaggerRenderer({
81
+ config,
82
+ htmlProps: {},
83
+ show: () => true,
84
+ callbacks: {},
85
+ children,
86
+ })
87
+
88
+ expect(vnode).not.toBeNull()
89
+ expect(vnode?.type).toBe("div")
90
+ })
91
+
92
+ it("uses custom tag from config", () => {
93
+ const config = makeConfig({ tag: "ul" })
94
+ const children = [makeChild("a", "Alpha")]
95
+
96
+ const vnode = StaggerRenderer({
97
+ config,
98
+ htmlProps: {},
99
+ show: () => true,
100
+ callbacks: {},
101
+ children,
102
+ })
103
+
104
+ expect(vnode?.type).toBe("ul")
105
+ })
106
+
107
+ it("passes htmlProps to the wrapper element", () => {
108
+ const config = makeConfig()
109
+ const children = [makeChild("a", "Alpha")]
110
+
111
+ const vnode = StaggerRenderer({
112
+ config,
113
+ htmlProps: { "data-testid": "stagger-wrapper", class: "my-stagger" },
114
+ show: () => true,
115
+ callbacks: {},
116
+ children,
117
+ })
118
+
119
+ const props = vnode?.props as Record<string, unknown>
120
+ expect(props?.["data-testid"]).toBe("stagger-wrapper")
121
+ expect(props?.class).toBe("my-stagger")
122
+ })
123
+
124
+ it("wraps each child in a TransitionItem", () => {
125
+ const config = makeConfig()
126
+ const children = [makeChild("a", "Alpha"), makeChild("b", "Beta"), makeChild("c", "Charlie")]
127
+
128
+ const vnode = StaggerRenderer({
129
+ config,
130
+ htmlProps: {},
131
+ show: () => true,
132
+ callbacks: {},
133
+ children,
134
+ })
135
+
136
+ const wrapperChildren = vnode?.children
137
+ const childArray = Array.isArray(wrapperChildren) ? wrapperChildren : [wrapperChildren]
138
+ expect(childArray.length).toBe(3)
139
+
140
+ for (const child of childArray) {
141
+ const childVNode = child as VNode
142
+ expect(typeof childVNode.type).toBe("function")
143
+ }
144
+ })
145
+
146
+ it("injects --stagger-index CSS custom property on each child", () => {
147
+ const config = makeConfig()
148
+ const children = [makeChild("a", "Alpha"), makeChild("b", "Beta"), makeChild("c", "Charlie")]
149
+
150
+ const vnode = StaggerRenderer({
151
+ config,
152
+ htmlProps: {},
153
+ show: () => true,
154
+ callbacks: {},
155
+ children,
156
+ })
157
+
158
+ const wrapperChildren = vnode?.children as VNode[]
159
+
160
+ for (let i = 0; i < wrapperChildren.length; i++) {
161
+ const clonedChild = extractStaggerChild(wrapperChildren[i] as VNode)
162
+ const childProps = clonedChild?.props as Record<string, unknown>
163
+ const style = childProps?.style as Record<string, unknown>
164
+
165
+ expect(style?.["--stagger-index"]).toBe(i)
166
+ }
167
+ })
168
+
169
+ it("injects --stagger-interval CSS custom property on each child", () => {
170
+ const config = makeConfig({ interval: 100 })
171
+ const children = [makeChild("a", "Alpha"), makeChild("b", "Beta")]
172
+
173
+ const vnode = StaggerRenderer({
174
+ config,
175
+ htmlProps: {},
176
+ show: () => true,
177
+ interval: 100,
178
+ callbacks: {},
179
+ children,
180
+ })
181
+
182
+ const wrapperChildren = vnode?.children as VNode[]
183
+
184
+ for (const child of wrapperChildren) {
185
+ const clonedChild = extractStaggerChild(child as VNode)
186
+ const childProps = clonedChild?.props as Record<string, unknown>
187
+ const style = childProps?.style as Record<string, unknown>
188
+
189
+ expect(style?.["--stagger-interval"]).toBe("100ms")
190
+ }
191
+ })
192
+
193
+ it("applies transitionDelay based on interval * index", () => {
194
+ const config = makeConfig()
195
+ const children = [makeChild("a", "Alpha"), makeChild("b", "Beta"), makeChild("c", "Charlie")]
196
+
197
+ const vnode = StaggerRenderer({
198
+ config,
199
+ htmlProps: {},
200
+ show: () => true,
201
+ interval: 75,
202
+ callbacks: {},
203
+ children,
204
+ })
205
+
206
+ const wrapperChildren = vnode?.children as VNode[]
207
+
208
+ for (let i = 0; i < wrapperChildren.length; i++) {
209
+ const clonedChild = extractStaggerChild(wrapperChildren[i] as VNode)
210
+ const childProps = clonedChild?.props as Record<string, unknown>
211
+ const style = childProps?.style as Record<string, unknown>
212
+
213
+ expect(style?.transitionDelay).toBe(`${i * 75}ms`)
214
+ }
215
+ })
216
+
217
+ it("uses default interval of 50ms when not specified", () => {
218
+ const config = makeConfig()
219
+ const children = [makeChild("a", "Alpha"), makeChild("b", "Beta")]
220
+
221
+ const vnode = StaggerRenderer({
222
+ config,
223
+ htmlProps: {},
224
+ show: () => true,
225
+ callbacks: {},
226
+ children,
227
+ })
228
+
229
+ const wrapperChildren = vnode?.children as VNode[]
230
+
231
+ // Check second child has 50ms delay (index=1 * 50ms)
232
+ const clonedChild = extractStaggerChild(wrapperChildren[1] as VNode)
233
+ const childProps = clonedChild?.props as Record<string, unknown>
234
+ const style = childProps?.style as Record<string, unknown>
235
+
236
+ expect(style?.transitionDelay).toBe("50ms")
237
+ expect(style?.["--stagger-interval"]).toBe("50ms")
238
+ })
239
+
240
+ it("reverseLeave reverses stagger index order on leave", () => {
241
+ const config = makeConfig()
242
+ const children = [makeChild("a", "Alpha"), makeChild("b", "Beta"), makeChild("c", "Charlie")]
243
+
244
+ // show=false with reverseLeave=true
245
+ const vnode = StaggerRenderer({
246
+ config,
247
+ htmlProps: {},
248
+ show: () => false,
249
+ interval: 100,
250
+ reverseLeave: true,
251
+ callbacks: {},
252
+ children,
253
+ })
254
+
255
+ const wrapperChildren = vnode?.children as VNode[]
256
+ const count = wrapperChildren.length
257
+
258
+ // With reverseLeave, staggerIndex = count - 1 - index
259
+ // For 3 children: child 0 gets index 2, child 1 gets index 1, child 2 gets index 0
260
+ for (let i = 0; i < count; i++) {
261
+ const expectedStaggerIndex = count - 1 - i
262
+ const clonedChild = extractStaggerChild(wrapperChildren[i] as VNode)
263
+ const childProps = clonedChild?.props as Record<string, unknown>
264
+ const style = childProps?.style as Record<string, unknown>
265
+
266
+ expect(style?.["--stagger-index"]).toBe(expectedStaggerIndex)
267
+ expect(style?.transitionDelay).toBe(`${expectedStaggerIndex * 100}ms`)
268
+ }
269
+ })
270
+
271
+ it("does not reverse stagger index when show is true even if reverseLeave is true", () => {
272
+ const config = makeConfig()
273
+ const children = [makeChild("a", "Alpha"), makeChild("b", "Beta")]
274
+
275
+ const vnode = StaggerRenderer({
276
+ config,
277
+ htmlProps: {},
278
+ show: () => true,
279
+ interval: 100,
280
+ reverseLeave: true,
281
+ callbacks: {},
282
+ children,
283
+ })
284
+
285
+ const wrapperChildren = vnode?.children as VNode[]
286
+
287
+ // Normal order when showing
288
+ for (let i = 0; i < wrapperChildren.length; i++) {
289
+ const clonedChild = extractStaggerChild(wrapperChildren[i] as VNode)
290
+ const childProps = clonedChild?.props as Record<string, unknown>
291
+ const style = childProps?.style as Record<string, unknown>
292
+
293
+ expect(style?.["--stagger-index"]).toBe(i)
294
+ }
295
+ })
296
+
297
+ it("passes transition class config to TransitionItem children", () => {
298
+ const config = makeConfig({
299
+ enter: "custom-enter",
300
+ enterFrom: "custom-from",
301
+ enterTo: "custom-to",
302
+ leave: "custom-leave",
303
+ leaveFrom: "custom-lfrom",
304
+ leaveTo: "custom-lto",
305
+ })
306
+ const children = [makeChild("a", "Alpha")]
307
+
308
+ const vnode = StaggerRenderer({
309
+ config,
310
+ htmlProps: {},
311
+ show: () => true,
312
+ callbacks: {},
313
+ children,
314
+ })
315
+
316
+ const wrapperChildren = vnode?.children as VNode[]
317
+ const tiProps = wrapperChildren[0]?.props as Record<string, unknown>
318
+
319
+ expect(tiProps.enter).toBe("custom-enter")
320
+ expect(tiProps.enterFrom).toBe("custom-from")
321
+ expect(tiProps.enterTo).toBe("custom-to")
322
+ expect(tiProps.leave).toBe("custom-leave")
323
+ expect(tiProps.leaveFrom).toBe("custom-lfrom")
324
+ expect(tiProps.leaveTo).toBe("custom-lto")
325
+ })
326
+
327
+ it("passes style transition config to TransitionItem children", () => {
328
+ const config = makeConfig({
329
+ enterStyle: { opacity: 0 },
330
+ enterToStyle: { opacity: 1 },
331
+ enterTransition: "opacity 300ms ease",
332
+ leaveStyle: { opacity: 1 },
333
+ leaveToStyle: { opacity: 0 },
334
+ leaveTransition: "opacity 200ms ease-in",
335
+ })
336
+ const children = [makeChild("a", "Alpha")]
337
+
338
+ const vnode = StaggerRenderer({
339
+ config,
340
+ htmlProps: {},
341
+ show: () => true,
342
+ callbacks: {},
343
+ children,
344
+ })
345
+
346
+ const wrapperChildren = vnode?.children as VNode[]
347
+ const tiProps = wrapperChildren[0]?.props as Record<string, unknown>
348
+
349
+ expect(tiProps.enterStyle).toEqual({ opacity: 0 })
350
+ expect(tiProps.enterToStyle).toEqual({ opacity: 1 })
351
+ expect(tiProps.enterTransition).toBe("opacity 300ms ease")
352
+ expect(tiProps.leaveStyle).toEqual({ opacity: 1 })
353
+ expect(tiProps.leaveToStyle).toEqual({ opacity: 0 })
354
+ expect(tiProps.leaveTransition).toBe("opacity 200ms ease-in")
355
+ })
356
+
357
+ it("adjusts timeout per child based on stagger delay", () => {
358
+ const config = makeConfig()
359
+ const children = [makeChild("a", "Alpha"), makeChild("b", "Beta"), makeChild("c", "Charlie")]
360
+
361
+ const vnode = StaggerRenderer({
362
+ config,
363
+ htmlProps: {},
364
+ show: () => true,
365
+ timeout: 1000,
366
+ interval: 100,
367
+ callbacks: {},
368
+ children,
369
+ })
370
+
371
+ const wrapperChildren = vnode?.children as VNode[]
372
+
373
+ // timeout = effectiveTimeout + delay, where delay = staggerIndex * interval
374
+ for (let i = 0; i < wrapperChildren.length; i++) {
375
+ const tiProps = wrapperChildren[i]?.props as Record<string, unknown>
376
+ const expectedTimeout = 1000 + i * 100
377
+ expect(tiProps.timeout).toBe(expectedTimeout)
378
+ }
379
+ })
380
+
381
+ it("only fires onAfterLeave on the last child (normal order)", () => {
382
+ const onAfterLeave = vi.fn()
383
+ const config = makeConfig()
384
+ const children = [makeChild("a", "Alpha"), makeChild("b", "Beta"), makeChild("c", "Charlie")]
385
+
386
+ const vnode = StaggerRenderer({
387
+ config,
388
+ htmlProps: {},
389
+ show: () => true,
390
+ callbacks: { onAfterLeave },
391
+ children,
392
+ })
393
+
394
+ const wrapperChildren = vnode?.children as VNode[]
395
+
396
+ // onAfterLeave should only be on the last child (index=2)
397
+ for (let i = 0; i < wrapperChildren.length; i++) {
398
+ const tiProps = wrapperChildren[i]?.props as Record<string, unknown>
399
+ if (i === 2) {
400
+ expect(tiProps.onAfterLeave).toBeDefined()
401
+ } else {
402
+ expect(tiProps.onAfterLeave).toBeUndefined()
403
+ }
404
+ }
405
+ })
406
+
407
+ it("fires onAfterLeave on the first child when reverseLeave is true", () => {
408
+ const onAfterLeave = vi.fn()
409
+ const config = makeConfig()
410
+ const children = [makeChild("a", "Alpha"), makeChild("b", "Beta"), makeChild("c", "Charlie")]
411
+
412
+ const vnode = StaggerRenderer({
413
+ config,
414
+ htmlProps: {},
415
+ show: () => false,
416
+ reverseLeave: true,
417
+ callbacks: { onAfterLeave },
418
+ children,
419
+ })
420
+
421
+ const wrapperChildren = vnode?.children as VNode[]
422
+
423
+ // With reverseLeave, onAfterLeave should be on the first child (index=0)
424
+ for (let i = 0; i < wrapperChildren.length; i++) {
425
+ const tiProps = wrapperChildren[i]?.props as Record<string, unknown>
426
+ if (i === 0) {
427
+ expect(tiProps.onAfterLeave).toBeDefined()
428
+ } else {
429
+ expect(tiProps.onAfterLeave).toBeUndefined()
430
+ }
431
+ }
432
+ })
433
+
434
+ it("uses config.interval when interval prop is not provided", () => {
435
+ const config = makeConfig({ interval: 200 })
436
+ const children = [makeChild("a", "Alpha"), makeChild("b", "Beta")]
437
+
438
+ const vnode = StaggerRenderer({
439
+ config,
440
+ htmlProps: {},
441
+ show: () => true,
442
+ callbacks: {},
443
+ children,
444
+ })
445
+
446
+ const wrapperChildren = vnode?.children as VNode[]
447
+ const clonedChild = extractStaggerChild(wrapperChildren[1] as VNode)
448
+ const childProps = clonedChild?.props as Record<string, unknown>
449
+ const style = childProps?.style as Record<string, unknown>
450
+
451
+ expect(style?.transitionDelay).toBe("200ms")
452
+ expect(style?.["--stagger-interval"]).toBe("200ms")
453
+ })
454
+
455
+ it("interval prop overrides config.interval", () => {
456
+ const config = makeConfig({ interval: 200 })
457
+ const children = [makeChild("a", "Alpha"), makeChild("b", "Beta")]
458
+
459
+ const vnode = StaggerRenderer({
460
+ config,
461
+ htmlProps: {},
462
+ show: () => true,
463
+ interval: 300,
464
+ callbacks: {},
465
+ children,
466
+ })
467
+
468
+ const wrapperChildren = vnode?.children as VNode[]
469
+ const clonedChild = extractStaggerChild(wrapperChildren[1] as VNode)
470
+ const childProps = clonedChild?.props as Record<string, unknown>
471
+ const style = childProps?.style as Record<string, unknown>
472
+
473
+ expect(style?.transitionDelay).toBe("300ms")
474
+ expect(style?.["--stagger-interval"]).toBe("300ms")
475
+ })
476
+
477
+ it("preserves existing style on child when injecting stagger styles", () => {
478
+ const config = makeConfig()
479
+ const childWithStyle: VNode = {
480
+ type: "span",
481
+ props: { style: { color: "red", fontWeight: "bold" } },
482
+ children: ["Styled"],
483
+ key: "styled",
484
+ }
485
+
486
+ const vnode = StaggerRenderer({
487
+ config,
488
+ htmlProps: {},
489
+ show: () => true,
490
+ interval: 50,
491
+ callbacks: {},
492
+ children: [childWithStyle],
493
+ })
494
+
495
+ const wrapperChildren = vnode?.children as VNode[]
496
+ const clonedChild = extractStaggerChild(wrapperChildren[0] as VNode)
497
+ const childProps = clonedChild?.props as Record<string, unknown>
498
+ const style = childProps?.style as Record<string, unknown>
499
+
500
+ // Original styles preserved
501
+ expect(style?.color).toBe("red")
502
+ expect(style?.fontWeight).toBe("bold")
503
+ // Stagger styles injected
504
+ expect(style?.["--stagger-index"]).toBe(0)
505
+ expect(style?.["--stagger-interval"]).toBe("50ms")
506
+ expect(style?.transitionDelay).toBe("0ms")
507
+ })
508
+
509
+ it("filters out non-VNode children", () => {
510
+ const config = makeConfig()
511
+ const validChild = makeChild("a", "Alpha")
512
+ // Simulate non-VNode values in children array
513
+ const children = [validChild, null as unknown as VNode, undefined as unknown as VNode]
514
+
515
+ const vnode = StaggerRenderer({
516
+ config,
517
+ htmlProps: {},
518
+ show: () => true,
519
+ callbacks: {},
520
+ children,
521
+ })
522
+
523
+ const wrapperChildren = vnode?.children as VNode[]
524
+ expect(wrapperChildren.length).toBe(1)
525
+ })
526
+ })