@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.
- package/package.json +10 -12
- package/src/Collapse.tsx +0 -166
- package/src/Stagger.tsx +0 -63
- package/src/Transition.tsx +0 -280
- package/src/TransitionGroup.tsx +0 -139
- package/src/__tests__/Collapse.test.tsx +0 -803
- package/src/__tests__/GroupRenderer.test.tsx +0 -434
- package/src/__tests__/StaggerRenderer.test.tsx +0 -523
- package/src/__tests__/Transition.ssr.test.tsx +0 -183
- package/src/__tests__/Transition.test.tsx +0 -403
- package/src/__tests__/TransitionItem.test.tsx +0 -514
- package/src/__tests__/kinetic-modes.ssr.test.tsx +0 -214
- package/src/__tests__/kinetic.browser.test.tsx +0 -327
- package/src/__tests__/kinetic.test.tsx +0 -565
- package/src/__tests__/presets.test.ts +0 -46
- package/src/__tests__/stagger-component-children-hydration.test.tsx +0 -191
- package/src/__tests__/top-level-transition-stagger-function-children.test.tsx +0 -141
- package/src/__tests__/useAnimationEnd.test.ts +0 -194
- package/src/__tests__/useReducedMotion.test.ts +0 -160
- package/src/__tests__/useTransitionState.test.ts +0 -132
- package/src/__tests__/utils.test.ts +0 -139
- package/src/index.ts +0 -15
- package/src/jsx-augment.d.ts +0 -12
- package/src/kinetic/CollapseRenderer.tsx +0 -216
- package/src/kinetic/GroupRenderer.tsx +0 -149
- package/src/kinetic/StaggerRenderer.tsx +0 -94
- package/src/kinetic/TransitionItem.tsx +0 -250
- package/src/kinetic/TransitionRenderer.tsx +0 -230
- package/src/kinetic/createKineticComponent.tsx +0 -224
- package/src/kinetic/types.ts +0 -149
- package/src/kinetic.ts +0 -25
- package/src/presets.ts +0 -66
- package/src/types.ts +0 -118
- package/src/useAnimationEnd.ts +0 -59
- package/src/useReducedMotion.ts +0 -28
- package/src/useTransitionState.ts +0 -62
- 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
|
-
})
|