@pyreon/kinetic 0.24.5 → 0.24.6

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.
Files changed (37) hide show
  1. package/package.json +10 -12
  2. package/src/Collapse.tsx +0 -166
  3. package/src/Stagger.tsx +0 -63
  4. package/src/Transition.tsx +0 -280
  5. package/src/TransitionGroup.tsx +0 -139
  6. package/src/__tests__/Collapse.test.tsx +0 -803
  7. package/src/__tests__/GroupRenderer.test.tsx +0 -434
  8. package/src/__tests__/StaggerRenderer.test.tsx +0 -523
  9. package/src/__tests__/Transition.ssr.test.tsx +0 -183
  10. package/src/__tests__/Transition.test.tsx +0 -403
  11. package/src/__tests__/TransitionItem.test.tsx +0 -514
  12. package/src/__tests__/kinetic-modes.ssr.test.tsx +0 -214
  13. package/src/__tests__/kinetic.browser.test.tsx +0 -327
  14. package/src/__tests__/kinetic.test.tsx +0 -565
  15. package/src/__tests__/presets.test.ts +0 -46
  16. package/src/__tests__/stagger-component-children-hydration.test.tsx +0 -191
  17. package/src/__tests__/top-level-transition-stagger-function-children.test.tsx +0 -141
  18. package/src/__tests__/useAnimationEnd.test.ts +0 -194
  19. package/src/__tests__/useReducedMotion.test.ts +0 -160
  20. package/src/__tests__/useTransitionState.test.ts +0 -132
  21. package/src/__tests__/utils.test.ts +0 -139
  22. package/src/index.ts +0 -15
  23. package/src/jsx-augment.d.ts +0 -12
  24. package/src/kinetic/CollapseRenderer.tsx +0 -216
  25. package/src/kinetic/GroupRenderer.tsx +0 -149
  26. package/src/kinetic/StaggerRenderer.tsx +0 -94
  27. package/src/kinetic/TransitionItem.tsx +0 -250
  28. package/src/kinetic/TransitionRenderer.tsx +0 -230
  29. package/src/kinetic/createKineticComponent.tsx +0 -224
  30. package/src/kinetic/types.ts +0 -149
  31. package/src/kinetic.ts +0 -25
  32. package/src/presets.ts +0 -66
  33. package/src/types.ts +0 -118
  34. package/src/useAnimationEnd.ts +0 -59
  35. package/src/useReducedMotion.ts +0 -28
  36. package/src/useTransitionState.ts +0 -62
  37. package/src/utils.ts +0 -113
@@ -1,523 +0,0 @@
1
- import type { VNode } from '@pyreon/core'
2
- import { h } from '@pyreon/core'
3
- import StaggerRenderer from '../kinetic/StaggerRenderer'
4
- import type { KineticConfig } from '../kinetic/types'
5
-
6
- // Mock rAF for deterministic testing
7
- let rafCallbacks: (() => void)[] = []
8
- const originalRaf = globalThis.requestAnimationFrame
9
- const originalCaf = globalThis.cancelAnimationFrame
10
-
11
- beforeEach(() => {
12
- vi.useFakeTimers()
13
- rafCallbacks = []
14
-
15
- vi.stubGlobal(
16
- 'requestAnimationFrame',
17
- vi.fn((cb: () => void) => {
18
- rafCallbacks.push(cb)
19
- return rafCallbacks.length
20
- }),
21
- )
22
-
23
- vi.stubGlobal('cancelAnimationFrame', vi.fn())
24
- })
25
-
26
- afterEach(() => {
27
- vi.useRealTimers()
28
- vi.stubGlobal('requestAnimationFrame', originalRaf)
29
- vi.stubGlobal('cancelAnimationFrame', originalCaf)
30
- })
31
-
32
- const makeConfig = (overrides: Partial<KineticConfig> = {}): KineticConfig => ({
33
- tag: 'div',
34
- mode: 'stagger',
35
- enter: 's-enter',
36
- enterFrom: 's-enter-from',
37
- enterTo: 's-enter-to',
38
- leave: 's-leave',
39
- leaveFrom: 's-leave-from',
40
- leaveTo: 's-leave-to',
41
- ...overrides,
42
- })
43
-
44
- // Real h() instead of a hand-built `{ type, props, children, key }`
45
- // literal — same VNode shape as production.
46
- const makeChild = (key: string | number, text: string): VNode => {
47
- const vnode = h('span', { 'data-testid': `child-${key}` }, text) as VNode
48
- return { ...vnode, key }
49
- }
50
-
51
- /**
52
- * Extract the cloned child VNode from a TransitionItem VNode.
53
- *
54
- * With Pyreon's automatic JSX runtime, component children are placed in
55
- * props.children (not in vnode.children). We check both locations for safety.
56
- */
57
- const extractStaggerChild = (tiVNode: VNode): VNode | null => {
58
- // For automatic JSX runtime, children are in props.children
59
- const props = tiVNode.props as Record<string, unknown>
60
- if (props?.children) {
61
- const pc = Array.isArray(props.children) ? props.children : [props.children]
62
- for (const c of pc) {
63
- if (c && typeof c === 'object' && 'type' in (c as object)) return c as VNode
64
- }
65
- }
66
- // Fallback: check vnode.children (classic runtime)
67
- if (tiVNode.children) {
68
- const ch = Array.isArray(tiVNode.children) ? tiVNode.children : [tiVNode.children]
69
- for (const c of ch) {
70
- if (c && typeof c === 'object' && 'type' in (c as object)) return c as VNode
71
- }
72
- }
73
- return null
74
- }
75
-
76
- describe('StaggerRenderer', () => {
77
- it('returns a VNode wrapping children in config.tag', () => {
78
- const config = makeConfig()
79
- const children = [makeChild('a', 'Alpha'), makeChild('b', 'Beta')]
80
-
81
- const vnode = StaggerRenderer({
82
- config,
83
- htmlProps: {},
84
- show: () => true,
85
- callbacks: {},
86
- children,
87
- })
88
-
89
- expect(vnode).not.toBeNull()
90
- expect(vnode?.type).toBe('div')
91
- })
92
-
93
- it('uses custom tag from config', () => {
94
- const config = makeConfig({ tag: 'ul' })
95
- const children = [makeChild('a', 'Alpha')]
96
-
97
- const vnode = StaggerRenderer({
98
- config,
99
- htmlProps: {},
100
- show: () => true,
101
- callbacks: {},
102
- children,
103
- })
104
-
105
- expect(vnode?.type).toBe('ul')
106
- })
107
-
108
- it('passes htmlProps to the wrapper element', () => {
109
- const config = makeConfig()
110
- const children = [makeChild('a', 'Alpha')]
111
-
112
- const vnode = StaggerRenderer({
113
- config,
114
- htmlProps: { 'data-testid': 'stagger-wrapper', class: 'my-stagger' },
115
- show: () => true,
116
- callbacks: {},
117
- children,
118
- })
119
-
120
- const props = vnode?.props as Record<string, unknown>
121
- expect(props?.['data-testid']).toBe('stagger-wrapper')
122
- expect(props?.class).toBe('my-stagger')
123
- })
124
-
125
- it('wraps each child in a TransitionItem', () => {
126
- const config = makeConfig()
127
- const children = [makeChild('a', 'Alpha'), makeChild('b', 'Beta'), makeChild('c', 'Charlie')]
128
-
129
- const vnode = StaggerRenderer({
130
- config,
131
- htmlProps: {},
132
- show: () => true,
133
- callbacks: {},
134
- children,
135
- })
136
-
137
- const wrapperChildren = vnode?.children
138
- const childArray = Array.isArray(wrapperChildren) ? wrapperChildren : [wrapperChildren]
139
- expect(childArray.length).toBe(3)
140
-
141
- for (const child of childArray) {
142
- const childVNode = child as VNode
143
- expect(typeof childVNode.type).toBe('function')
144
- }
145
- })
146
-
147
- it('injects --stagger-index CSS custom property on each child', () => {
148
- const config = makeConfig()
149
- const children = [makeChild('a', 'Alpha'), makeChild('b', 'Beta'), makeChild('c', 'Charlie')]
150
-
151
- const vnode = StaggerRenderer({
152
- config,
153
- htmlProps: {},
154
- show: () => true,
155
- callbacks: {},
156
- children,
157
- })
158
-
159
- const wrapperChildren = vnode?.children as VNode[]
160
-
161
- for (let i = 0; i < wrapperChildren.length; i++) {
162
- const clonedChild = extractStaggerChild(wrapperChildren[i] as VNode)
163
- const childProps = clonedChild?.props as Record<string, unknown>
164
- const style = childProps?.style as Record<string, unknown>
165
-
166
- expect(style?.['--stagger-index']).toBe(i)
167
- }
168
- })
169
-
170
- it('injects --stagger-interval CSS custom property on each child', () => {
171
- const config = makeConfig({ interval: 100 })
172
- const children = [makeChild('a', 'Alpha'), makeChild('b', 'Beta')]
173
-
174
- const vnode = StaggerRenderer({
175
- config,
176
- htmlProps: {},
177
- show: () => true,
178
- interval: 100,
179
- callbacks: {},
180
- children,
181
- })
182
-
183
- const wrapperChildren = vnode?.children as VNode[]
184
-
185
- for (const child of wrapperChildren) {
186
- const clonedChild = extractStaggerChild(child as VNode)
187
- const childProps = clonedChild?.props as Record<string, unknown>
188
- const style = childProps?.style as Record<string, unknown>
189
-
190
- expect(style?.['--stagger-interval']).toBe('100ms')
191
- }
192
- })
193
-
194
- it('applies transitionDelay based on interval * index', () => {
195
- const config = makeConfig()
196
- const children = [makeChild('a', 'Alpha'), makeChild('b', 'Beta'), makeChild('c', 'Charlie')]
197
-
198
- const vnode = StaggerRenderer({
199
- config,
200
- htmlProps: {},
201
- show: () => true,
202
- interval: 75,
203
- callbacks: {},
204
- children,
205
- })
206
-
207
- const wrapperChildren = vnode?.children as VNode[]
208
-
209
- for (let i = 0; i < wrapperChildren.length; i++) {
210
- const clonedChild = extractStaggerChild(wrapperChildren[i] as VNode)
211
- const childProps = clonedChild?.props as Record<string, unknown>
212
- const style = childProps?.style as Record<string, unknown>
213
-
214
- expect(style?.transitionDelay).toBe(`${i * 75}ms`)
215
- }
216
- })
217
-
218
- it('uses default interval of 50ms when not specified', () => {
219
- const config = makeConfig()
220
- const children = [makeChild('a', 'Alpha'), makeChild('b', 'Beta')]
221
-
222
- const vnode = StaggerRenderer({
223
- config,
224
- htmlProps: {},
225
- show: () => true,
226
- callbacks: {},
227
- children,
228
- })
229
-
230
- const wrapperChildren = vnode?.children as VNode[]
231
-
232
- // Check second child has 50ms delay (index=1 * 50ms)
233
- const clonedChild = extractStaggerChild(wrapperChildren[1] as VNode)
234
- const childProps = clonedChild?.props as Record<string, unknown>
235
- const style = childProps?.style as Record<string, unknown>
236
-
237
- expect(style?.transitionDelay).toBe('50ms')
238
- expect(style?.['--stagger-interval']).toBe('50ms')
239
- })
240
-
241
- it('reverseLeave reverses stagger index order on leave', () => {
242
- const config = makeConfig()
243
- const children = [makeChild('a', 'Alpha'), makeChild('b', 'Beta'), makeChild('c', 'Charlie')]
244
-
245
- // show=false with reverseLeave=true
246
- const vnode = StaggerRenderer({
247
- config,
248
- htmlProps: {},
249
- show: () => false,
250
- interval: 100,
251
- reverseLeave: true,
252
- callbacks: {},
253
- children,
254
- })
255
-
256
- const wrapperChildren = vnode?.children as VNode[]
257
- const count = wrapperChildren.length
258
-
259
- // With reverseLeave, staggerIndex = count - 1 - index
260
- // For 3 children: child 0 gets index 2, child 1 gets index 1, child 2 gets index 0
261
- for (let i = 0; i < count; i++) {
262
- const expectedStaggerIndex = count - 1 - i
263
- const clonedChild = extractStaggerChild(wrapperChildren[i] as VNode)
264
- const childProps = clonedChild?.props as Record<string, unknown>
265
- const style = childProps?.style as Record<string, unknown>
266
-
267
- expect(style?.['--stagger-index']).toBe(expectedStaggerIndex)
268
- expect(style?.transitionDelay).toBe(`${expectedStaggerIndex * 100}ms`)
269
- }
270
- })
271
-
272
- it('does not reverse stagger index when show is true even if reverseLeave is true', () => {
273
- const config = makeConfig()
274
- const children = [makeChild('a', 'Alpha'), makeChild('b', 'Beta')]
275
-
276
- const vnode = StaggerRenderer({
277
- config,
278
- htmlProps: {},
279
- show: () => true,
280
- interval: 100,
281
- reverseLeave: true,
282
- callbacks: {},
283
- children,
284
- })
285
-
286
- const wrapperChildren = vnode?.children as VNode[]
287
-
288
- // Normal order when showing
289
- for (let i = 0; i < wrapperChildren.length; i++) {
290
- const clonedChild = extractStaggerChild(wrapperChildren[i] as VNode)
291
- const childProps = clonedChild?.props as Record<string, unknown>
292
- const style = childProps?.style as Record<string, unknown>
293
-
294
- expect(style?.['--stagger-index']).toBe(i)
295
- }
296
- })
297
-
298
- it('passes transition class config to TransitionItem children', () => {
299
- const config = makeConfig({
300
- enter: 'custom-enter',
301
- enterFrom: 'custom-from',
302
- enterTo: 'custom-to',
303
- leave: 'custom-leave',
304
- leaveFrom: 'custom-lfrom',
305
- leaveTo: 'custom-lto',
306
- })
307
- const children = [makeChild('a', 'Alpha')]
308
-
309
- const vnode = StaggerRenderer({
310
- config,
311
- htmlProps: {},
312
- show: () => true,
313
- callbacks: {},
314
- children,
315
- })
316
-
317
- const wrapperChildren = vnode?.children as VNode[]
318
- const tiProps = wrapperChildren[0]?.props as Record<string, unknown>
319
-
320
- expect(tiProps.enter).toBe('custom-enter')
321
- expect(tiProps.enterFrom).toBe('custom-from')
322
- expect(tiProps.enterTo).toBe('custom-to')
323
- expect(tiProps.leave).toBe('custom-leave')
324
- expect(tiProps.leaveFrom).toBe('custom-lfrom')
325
- expect(tiProps.leaveTo).toBe('custom-lto')
326
- })
327
-
328
- it('passes style transition config to TransitionItem children', () => {
329
- const config = makeConfig({
330
- enterStyle: { opacity: 0 },
331
- enterToStyle: { opacity: 1 },
332
- enterTransition: 'opacity 300ms ease',
333
- leaveStyle: { opacity: 1 },
334
- leaveToStyle: { opacity: 0 },
335
- leaveTransition: 'opacity 200ms ease-in',
336
- })
337
- const children = [makeChild('a', 'Alpha')]
338
-
339
- const vnode = StaggerRenderer({
340
- config,
341
- htmlProps: {},
342
- show: () => true,
343
- callbacks: {},
344
- children,
345
- })
346
-
347
- const wrapperChildren = vnode?.children as VNode[]
348
- const tiProps = wrapperChildren[0]?.props as Record<string, unknown>
349
-
350
- expect(tiProps.enterStyle).toEqual({ opacity: 0 })
351
- expect(tiProps.enterToStyle).toEqual({ opacity: 1 })
352
- expect(tiProps.enterTransition).toBe('opacity 300ms ease')
353
- expect(tiProps.leaveStyle).toEqual({ opacity: 1 })
354
- expect(tiProps.leaveToStyle).toEqual({ opacity: 0 })
355
- expect(tiProps.leaveTransition).toBe('opacity 200ms ease-in')
356
- })
357
-
358
- it('adjusts timeout per child based on stagger delay', () => {
359
- const config = makeConfig()
360
- const children = [makeChild('a', 'Alpha'), makeChild('b', 'Beta'), makeChild('c', 'Charlie')]
361
-
362
- const vnode = StaggerRenderer({
363
- config,
364
- htmlProps: {},
365
- show: () => true,
366
- timeout: 1000,
367
- interval: 100,
368
- callbacks: {},
369
- children,
370
- })
371
-
372
- const wrapperChildren = vnode?.children as VNode[]
373
-
374
- // timeout = effectiveTimeout + delay, where delay = staggerIndex * interval
375
- for (let i = 0; i < wrapperChildren.length; i++) {
376
- const tiProps = wrapperChildren[i]?.props as Record<string, unknown>
377
- const expectedTimeout = 1000 + i * 100
378
- expect(tiProps.timeout).toBe(expectedTimeout)
379
- }
380
- })
381
-
382
- it('only fires onAfterLeave on the last child (normal order)', () => {
383
- const onAfterLeave = vi.fn()
384
- const config = makeConfig()
385
- const children = [makeChild('a', 'Alpha'), makeChild('b', 'Beta'), makeChild('c', 'Charlie')]
386
-
387
- const vnode = StaggerRenderer({
388
- config,
389
- htmlProps: {},
390
- show: () => true,
391
- callbacks: { onAfterLeave },
392
- children,
393
- })
394
-
395
- const wrapperChildren = vnode?.children as VNode[]
396
-
397
- // onAfterLeave should only be on the last child (index=2)
398
- for (let i = 0; i < wrapperChildren.length; i++) {
399
- const tiProps = wrapperChildren[i]?.props as Record<string, unknown>
400
- if (i === 2) {
401
- expect(tiProps.onAfterLeave).toBeDefined()
402
- } else {
403
- expect(tiProps.onAfterLeave).toBeUndefined()
404
- }
405
- }
406
- })
407
-
408
- it('fires onAfterLeave on the first child when reverseLeave is true', () => {
409
- const onAfterLeave = vi.fn()
410
- const config = makeConfig()
411
- const children = [makeChild('a', 'Alpha'), makeChild('b', 'Beta'), makeChild('c', 'Charlie')]
412
-
413
- const vnode = StaggerRenderer({
414
- config,
415
- htmlProps: {},
416
- show: () => false,
417
- reverseLeave: true,
418
- callbacks: { onAfterLeave },
419
- children,
420
- })
421
-
422
- const wrapperChildren = vnode?.children as VNode[]
423
-
424
- // With reverseLeave, onAfterLeave should be on the first child (index=0)
425
- for (let i = 0; i < wrapperChildren.length; i++) {
426
- const tiProps = wrapperChildren[i]?.props as Record<string, unknown>
427
- if (i === 0) {
428
- expect(tiProps.onAfterLeave).toBeDefined()
429
- } else {
430
- expect(tiProps.onAfterLeave).toBeUndefined()
431
- }
432
- }
433
- })
434
-
435
- it('uses config.interval when interval prop is not provided', () => {
436
- const config = makeConfig({ interval: 200 })
437
- const children = [makeChild('a', 'Alpha'), makeChild('b', 'Beta')]
438
-
439
- const vnode = StaggerRenderer({
440
- config,
441
- htmlProps: {},
442
- show: () => true,
443
- callbacks: {},
444
- children,
445
- })
446
-
447
- const wrapperChildren = vnode?.children as VNode[]
448
- const clonedChild = extractStaggerChild(wrapperChildren[1] as VNode)
449
- const childProps = clonedChild?.props as Record<string, unknown>
450
- const style = childProps?.style as Record<string, unknown>
451
-
452
- expect(style?.transitionDelay).toBe('200ms')
453
- expect(style?.['--stagger-interval']).toBe('200ms')
454
- })
455
-
456
- it('interval prop overrides config.interval', () => {
457
- const config = makeConfig({ interval: 200 })
458
- const children = [makeChild('a', 'Alpha'), makeChild('b', 'Beta')]
459
-
460
- const vnode = StaggerRenderer({
461
- config,
462
- htmlProps: {},
463
- show: () => true,
464
- interval: 300,
465
- callbacks: {},
466
- children,
467
- })
468
-
469
- const wrapperChildren = vnode?.children as VNode[]
470
- const clonedChild = extractStaggerChild(wrapperChildren[1] as VNode)
471
- const childProps = clonedChild?.props as Record<string, unknown>
472
- const style = childProps?.style as Record<string, unknown>
473
-
474
- expect(style?.transitionDelay).toBe('300ms')
475
- expect(style?.['--stagger-interval']).toBe('300ms')
476
- })
477
-
478
- it('preserves existing style on child when injecting stagger styles', () => {
479
- const config = makeConfig()
480
- const realVnode = h('span', { style: { color: 'red', fontWeight: 'bold' } }, 'Styled') as VNode
481
- const childWithStyle: VNode = { ...realVnode, key: 'styled' }
482
-
483
- const vnode = StaggerRenderer({
484
- config,
485
- htmlProps: {},
486
- show: () => true,
487
- interval: 50,
488
- callbacks: {},
489
- children: [childWithStyle],
490
- })
491
-
492
- const wrapperChildren = vnode?.children as VNode[]
493
- const clonedChild = extractStaggerChild(wrapperChildren[0] as VNode)
494
- const childProps = clonedChild?.props as Record<string, unknown>
495
- const style = childProps?.style as Record<string, unknown>
496
-
497
- // Original styles preserved
498
- expect(style?.color).toBe('red')
499
- expect(style?.fontWeight).toBe('bold')
500
- // Stagger styles injected
501
- expect(style?.['--stagger-index']).toBe(0)
502
- expect(style?.['--stagger-interval']).toBe('50ms')
503
- expect(style?.transitionDelay).toBe('0ms')
504
- })
505
-
506
- it('filters out non-VNode children', () => {
507
- const config = makeConfig()
508
- const validChild = makeChild('a', 'Alpha')
509
- // Simulate non-VNode values in children array
510
- const children = [validChild, null as unknown as VNode, undefined as unknown as VNode]
511
-
512
- const vnode = StaggerRenderer({
513
- config,
514
- htmlProps: {},
515
- show: () => true,
516
- callbacks: {},
517
- children,
518
- })
519
-
520
- const wrapperChildren = vnode?.children as VNode[]
521
- expect(wrapperChildren.length).toBe(1)
522
- })
523
- })
@@ -1,183 +0,0 @@
1
- /**
2
- * SSR regression coverage for `<Transition>` (the missing test layer that
3
- * let the children-dropped-on-SSR bug ship).
4
- *
5
- * Background. `<Transition show={() => false} ...>` used to render `<Show
6
- * when={false} fallback={null}>` on the server, which emitted EMPTY HTML —
7
- * any SSG site using kinetic for scroll-triggered reveal (the documented
8
- * `useIntersection` + sticky-signal pattern, where `show` is false at SSR
9
- * because IntersectionObserver can't fire until client hydration) shipped
10
- * with the wrapped content STRUCTURALLY ABSENT from the prerendered HTML.
11
- * Bad for SEO, social scrapers, accessibility tools, and no-JS users.
12
- *
13
- * Ecosystem norm (the framing this fix aligns Pyreon with): Framer Motion,
14
- * react-transition-group, react-spring, AutoAnimate all render children in
15
- * SSR regardless of animation state and only apply animation styles on the
16
- * client. "Content is structural, animation is visual."
17
- *
18
- * What the fix changes. `Transition` now branches at setup on
19
- * `props.show()`:
20
- * - initially-visible → existing `<Show>`-gated mount (unchanged;
21
- * preserves the runtime-unmount semantic for the visible→hidden case)
22
- * - initially-hidden → always render children with hidden-state classes
23
- * inlined (`leaveTo` if defined, else `enterFrom` — covers the
24
- * scroll-reveal pattern that only configures the enter side). The
25
- * existing `watch(stage)` effect drives the enter animation when
26
- * `show` flips true.
27
- *
28
- * Why these tests are load-bearing. Zero existing tests exercised
29
- * `show: () => false` initial state (the bug class). The fact that this
30
- * shipped is exactly the "no test catches it because no test runs the real
31
- * path" failure mode the `test-environment-parity.md` rule was written to
32
- * prevent — both real `h()` AND a SSR-driving environment (`renderToString`)
33
- * are needed to catch it.
34
- *
35
- * Bisect-verified: reverting `Transition.tsx`'s `wasInitiallyShown` branch
36
- * fails every spec below with `expected '' to contain '...'` (the empty-
37
- * children bug). Restored → all green.
38
- */
39
-
40
- import { h } from '@pyreon/core'
41
- import { renderToString } from '@pyreon/runtime-server'
42
- import { describe, expect, it } from 'vitest'
43
- import Transition from '../Transition'
44
-
45
- describe('Transition — SSR / initially-hidden children render', () => {
46
- it('emits children when show=false initially (was: <Show fallback={null}> → empty)', async () => {
47
- // The canonical bug shape — scroll-reveal pattern at SSR time.
48
- const html = await renderToString(
49
- h(Transition, {
50
- show: () => false,
51
- enterFrom: 'opacity-0',
52
- enterTo: 'opacity-100',
53
- enter: 'transition-opacity duration-300',
54
- children: h('section', null, 'real content for SEO + social scrapers'),
55
- }),
56
- )
57
- // Structural content must land in the prerendered HTML. Pre-fix this
58
- // assertion failed with `expected '' to contain '...'`.
59
- expect(html).toContain('<section')
60
- expect(html).toContain('real content for SEO + social scrapers')
61
- })
62
-
63
- it('inlines `leaveTo` as the hidden-state class (explicit hidden-end state takes precedence)', async () => {
64
- const html = await renderToString(
65
- h(Transition, {
66
- show: () => false,
67
- enterFrom: 'opacity-0 translate-y-4', // present but NOT selected
68
- enterTo: 'opacity-100 translate-y-0',
69
- leave: 'transition-opacity',
70
- leaveFrom: 'opacity-100',
71
- leaveTo: 'is-hidden opacity-0', // ← explicit hidden-end state, selected
72
- children: h('div', null, 'panel content'),
73
- }),
74
- )
75
- expect(html).toContain('is-hidden opacity-0')
76
- expect(html).toContain('panel content')
77
- // The competing `enterFrom` should NOT be applied (leaveTo wins).
78
- expect(html).not.toContain('translate-y-4')
79
- })
80
-
81
- it('falls back to `enterFrom` as the hidden class for scroll-reveal patterns (only enter side configured)', async () => {
82
- // The exact pattern the reported bug surfaced on — only the enter
83
- // animation is configured (because IO triggers `show` true; there's
84
- // no leave side for the reveal pattern).
85
- const html = await renderToString(
86
- h(Transition, {
87
- show: () => false,
88
- enter: 'transition-all duration-700',
89
- enterFrom: 'opacity-0 translate-y-8',
90
- enterTo: 'opacity-100 translate-y-0',
91
- children: h('section', { id: 'resume-section' }, 'work history goes here'),
92
- }),
93
- )
94
- expect(html).toContain('id="resume-section"')
95
- expect(html).toContain('work history goes here')
96
- // enterFrom IS the resting hidden state for this pattern.
97
- expect(html).toContain('opacity-0 translate-y-8')
98
- })
99
-
100
- it('inlines `leaveToStyle` as the hidden inline style when defined', async () => {
101
- const html = await renderToString(
102
- h(Transition, {
103
- show: () => false,
104
- leaveTo: 'animated-section',
105
- leaveToStyle: { opacity: 0, transform: 'translateY(20px)' },
106
- children: h('article', null, 'article body'),
107
- }),
108
- )
109
- expect(html).toContain('article body')
110
- expect(html).toContain('opacity: 0')
111
- expect(html).toContain('translateY(20px)')
112
- })
113
-
114
- it('falls back to `enterStyle` as hidden style when leaveToStyle undefined (preset path)', async () => {
115
- // The preset shape — `@pyreon/kinetic-presets` factories (fadeUp,
116
- // blurInUp, slideLeft, …) populate `enterStyle` as the hidden state
117
- // but may not set `leaveToStyle`. PR #717 shipped the
118
- // `wasInitiallyShown` branch with `hiddenStyle = props.leaveToStyle`
119
- // alone — so preset users SSR-rendered VISIBLE → flash-on-hydration.
120
- // This regression test locks in the `?? props.enterStyle` fallback
121
- // that aligns the style picker with the existing
122
- // `hiddenClass = leaveTo ?? enterFrom` class picker.
123
- //
124
- // The companion `kinetic(tag).<mode>` paths (TransitionRenderer /
125
- // TransitionItem / CollapseRenderer) got the same fallback in #719;
126
- // this commit closes the matching gap on the direct `<Transition>`
127
- // import path.
128
- const html = await renderToString(
129
- h(Transition, {
130
- show: () => false,
131
- enter: 'transition-all duration-300',
132
- enterStyle: { opacity: 0, transform: 'translateY(16px)' },
133
- enterToStyle: { opacity: 1, transform: 'translateY(0)' },
134
- children: h('section', null, 'preset-shaped hidden state'),
135
- }),
136
- )
137
- expect(html).toContain('preset-shaped hidden state')
138
- expect(html).toContain('opacity: 0')
139
- expect(html).toContain('translateY(16px)')
140
- })
141
-
142
- it('merges the hidden class with any user-set class on the child', async () => {
143
- const html = await renderToString(
144
- h(Transition, {
145
- show: () => false,
146
- leaveTo: 'is-hidden',
147
- children: h('div', { class: 'card card--featured' }, 'merged-class content'),
148
- }),
149
- )
150
- expect(html).toContain('merged-class content')
151
- expect(html).toContain('card')
152
- expect(html).toContain('card--featured')
153
- expect(html).toContain('is-hidden')
154
- })
155
-
156
- it('emits children unchanged when neither leaveTo nor enterFrom is defined (graceful no-op)', async () => {
157
- // An unusual config — no enter/leave classes at all. Children should
158
- // still render structurally (the SEO/SSG contract); no hidden class
159
- // is appended because there's nothing to append.
160
- const html = await renderToString(
161
- h(Transition, {
162
- show: () => false,
163
- children: h('div', null, 'bare content'),
164
- }),
165
- )
166
- expect(html).toContain('bare content')
167
- })
168
-
169
- it('initially-visible Transition (show=true) renders children normally — unchanged behavior', async () => {
170
- // The other branch of the fix — initially-visible Transitions keep
171
- // the original `<Show>`-gated path. This spec locks in the no-
172
- // regression contract for the existing common case.
173
- const html = await renderToString(
174
- h(Transition, {
175
- show: () => true,
176
- leaveTo: 'is-hidden', // must NOT leak onto initially-visible
177
- children: h('main', null, 'visible from the start'),
178
- }),
179
- )
180
- expect(html).toContain('visible from the start')
181
- expect(html).not.toContain('is-hidden')
182
- })
183
- })