@pyreon/kinetic 0.24.4 → 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,214 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SSR regression coverage for the `kinetic(tag).<mode>` API — the three
|
|
3
|
-
* renderer files PR #717 didn't reach:
|
|
4
|
-
*
|
|
5
|
-
* - `TransitionRenderer` → `kinetic('div').preset(...)` (default
|
|
6
|
-
* `.transition` mode — the README's main example)
|
|
7
|
-
* - `TransitionItem` → `kinetic('ul').stagger()` per item (the
|
|
8
|
-
* cascade-children mode); transitively `kinetic('ul').group()`
|
|
9
|
-
* - `CollapseRenderer` → `kinetic('div').collapse()` (height-animation
|
|
10
|
-
* mode)
|
|
11
|
-
*
|
|
12
|
-
* Background. The top-level `<Transition>` was fixed in PR #717. But the
|
|
13
|
-
* `kinetic(tag).<mode>` API — which the README promotes as the primary
|
|
14
|
-
* surface — has its own per-mode renderers, and all three carried the
|
|
15
|
-
* SAME `<Show when={shouldMount} fallback={null}>` shape, dropping
|
|
16
|
-
* children from prerendered HTML when `show()` is false at SSR. That
|
|
17
|
-
* meant every documented `kinetic(tag).<mode>` consumer hit the bug
|
|
18
|
-
* even after #717 landed — including the cascading-Stagger pattern this
|
|
19
|
-
* report's author flagged on a real resume page.
|
|
20
|
-
*
|
|
21
|
-
* The fix mirrors #717: branch each renderer at setup on `props.show()`.
|
|
22
|
-
* Initially-visible → existing `<Show>`-gated mount (preserves runtime-
|
|
23
|
-
* unmount semantic). Initially-hidden → always render the inner content
|
|
24
|
-
* with the hidden-state class/style inlined; the existing `watch(stage)`
|
|
25
|
-
* effect drives the enter animation when `show` flips true.
|
|
26
|
-
*
|
|
27
|
-
* Hidden-state picker (mirrors #717): `leaveTo` / `leaveToStyle` win
|
|
28
|
-
* (explicit hidden-end state); fall back to `enterFrom` / `enterStyle`
|
|
29
|
-
* (pre-enter state). The `enterStyle` fallback covers the preset path —
|
|
30
|
-
* `@pyreon/kinetic-presets` factories populate `enterStyle` as the
|
|
31
|
-
* hidden state but may not set `leaveToStyle`. Without the fallback,
|
|
32
|
-
* preset users would SSR-render VISIBLE → flash-on-hydration.
|
|
33
|
-
*
|
|
34
|
-
* API note. `kinetic(tag)` takes animation config via CHAIN methods
|
|
35
|
-
* (`.enter()`, `.enterClass({from, to, active})`, `.leaveClass(...)`,
|
|
36
|
-
* `.preset()`), NOT as runtime props. Runtime props are limited to
|
|
37
|
-
* `show` / `appear` / `unmount` / `timeout` plus HTML attributes —
|
|
38
|
-
* anything else gets forwarded to the rendered element. The tests
|
|
39
|
-
* below use the chain API to faithfully exercise real user code.
|
|
40
|
-
*
|
|
41
|
-
* Coverage layered with PR #717: the test file there
|
|
42
|
-
* (`Transition.ssr.test.tsx`) covers the direct `<Transition>` import
|
|
43
|
-
* path; this file covers the `kinetic(tag).<mode>` factory paths.
|
|
44
|
-
*/
|
|
45
|
-
|
|
46
|
-
import { h } from '@pyreon/core'
|
|
47
|
-
import { renderToString } from '@pyreon/runtime-server'
|
|
48
|
-
import { describe, expect, it } from 'vitest'
|
|
49
|
-
import kinetic from '../kinetic'
|
|
50
|
-
|
|
51
|
-
describe('kinetic(tag).transition — SSR / initially-hidden (TransitionRenderer)', () => {
|
|
52
|
-
it('emits children when show=false initially (kinetic-mode shape, was: empty wrapper)', async () => {
|
|
53
|
-
// Cascading-bug shape — every `kinetic('div').preset(...)` user with a
|
|
54
|
-
// scroll-reveal `show` accessor hit this. Pre-fix: outer wrapper renders
|
|
55
|
-
// but children are dropped by the inner Show fallback.
|
|
56
|
-
const FadeSection = kinetic('section').enterClass({
|
|
57
|
-
active: 'transition-all duration-300',
|
|
58
|
-
from: 'opacity-0',
|
|
59
|
-
to: 'opacity-100',
|
|
60
|
-
})
|
|
61
|
-
const html = await renderToString(
|
|
62
|
-
h(FadeSection, { show: () => false },
|
|
63
|
-
h('h2', null, 'Work Experience'),
|
|
64
|
-
h('p', null, 'real content for SEO + social scrapers'),
|
|
65
|
-
),
|
|
66
|
-
)
|
|
67
|
-
expect(html).toContain('<h2')
|
|
68
|
-
expect(html).toContain('Work Experience')
|
|
69
|
-
expect(html).toContain('real content for SEO + social scrapers')
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('inlines `leaveTo` class over `enterFrom` (explicit hidden-end state wins)', async () => {
|
|
73
|
-
const Panel = kinetic('aside')
|
|
74
|
-
.enterClass({ from: 'translate-y-4', to: 'translate-y-0' })
|
|
75
|
-
.leaveClass({ to: 'is-hidden opacity-0' })
|
|
76
|
-
const html = await renderToString(
|
|
77
|
-
h(Panel, { show: () => false },
|
|
78
|
-
h('div', null, 'panel content'),
|
|
79
|
-
),
|
|
80
|
-
)
|
|
81
|
-
expect(html).toContain('is-hidden opacity-0')
|
|
82
|
-
expect(html).toContain('panel content')
|
|
83
|
-
// leaveTo wins — the competing enterFrom should NOT be applied.
|
|
84
|
-
expect(html).not.toContain('translate-y-4')
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
it('inlines `enterStyle` as hidden style when leaveToStyle undefined (preset path)', async () => {
|
|
88
|
-
// The preset shape — `@pyreon/kinetic-presets` factories populate
|
|
89
|
-
// `enterStyle` (= `.enter()` chain) as the hidden state. Without the
|
|
90
|
-
// enterStyle fallback, SSR would render VISIBLE → flash-on-hydration.
|
|
91
|
-
// This locks in the critical preset-compatibility behaviour.
|
|
92
|
-
const FadeUpDiv = kinetic('div')
|
|
93
|
-
.enter({ opacity: 0, transform: 'translateY(16px)' })
|
|
94
|
-
.enterTo({ opacity: 1, transform: 'translateY(0)' })
|
|
95
|
-
.enterTransition('all 300ms ease-out')
|
|
96
|
-
const html = await renderToString(
|
|
97
|
-
h(FadeUpDiv, { show: () => false },
|
|
98
|
-
h('h1', null, 'preset-shaped hidden state'),
|
|
99
|
-
),
|
|
100
|
-
)
|
|
101
|
-
expect(html).toContain('preset-shaped hidden state')
|
|
102
|
-
expect(html).toContain('opacity: 0')
|
|
103
|
-
expect(html).toContain('translateY(16px)')
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
it('initially-visible (show=true) renders normally — unchanged behaviour', async () => {
|
|
107
|
-
const FadeDiv = kinetic('div').leaveClass({ to: 'is-hidden' })
|
|
108
|
-
const html = await renderToString(
|
|
109
|
-
h(FadeDiv, { show: () => true },
|
|
110
|
-
h('main', null, 'visible from the start'),
|
|
111
|
-
),
|
|
112
|
-
)
|
|
113
|
-
expect(html).toContain('visible from the start')
|
|
114
|
-
// leaveTo must NOT leak onto the initially-visible render.
|
|
115
|
-
expect(html).not.toContain('is-hidden')
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
it('falls back to `enterFrom` class for scroll-reveal patterns (only enter side configured)', async () => {
|
|
119
|
-
const RevealSection = kinetic('section').enterClass({
|
|
120
|
-
active: 'transition-all duration-700',
|
|
121
|
-
from: 'opacity-0 translate-y-8',
|
|
122
|
-
to: 'opacity-100 translate-y-0',
|
|
123
|
-
})
|
|
124
|
-
const html = await renderToString(
|
|
125
|
-
h(RevealSection, { show: () => false, id: 'resume-section' },
|
|
126
|
-
h('p', null, 'work history goes here'),
|
|
127
|
-
),
|
|
128
|
-
)
|
|
129
|
-
expect(html).toContain('id="resume-section"')
|
|
130
|
-
expect(html).toContain('work history goes here')
|
|
131
|
-
expect(html).toContain('opacity-0 translate-y-8')
|
|
132
|
-
})
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
describe('kinetic(tag).stagger() — SSR / initially-hidden (TransitionItem per item)', () => {
|
|
136
|
-
it('emits all child items when show=false initially (cascading stagger SSR shape)', async () => {
|
|
137
|
-
// The reported real-app pattern: cascading intro / list reveal.
|
|
138
|
-
// Pre-fix: every per-item TransitionItem rendered null on the server,
|
|
139
|
-
// dropping the full list from prerendered HTML.
|
|
140
|
-
const StaggerList = kinetic('ul')
|
|
141
|
-
.enterClass({
|
|
142
|
-
active: 'transition-all',
|
|
143
|
-
from: 'opacity-0 translate-y-4',
|
|
144
|
-
to: 'opacity-100 translate-y-0',
|
|
145
|
-
})
|
|
146
|
-
.stagger({ interval: 80 })
|
|
147
|
-
const html = await renderToString(
|
|
148
|
-
h(StaggerList, { show: () => false },
|
|
149
|
-
[
|
|
150
|
-
h('li', { key: 'h' }, 'Heading'),
|
|
151
|
-
h('li', { key: 't' }, 'tagline content'),
|
|
152
|
-
h('li', { key: 's' }, 'social icons row'),
|
|
153
|
-
],
|
|
154
|
-
),
|
|
155
|
-
)
|
|
156
|
-
expect(html).toContain('Heading')
|
|
157
|
-
expect(html).toContain('tagline content')
|
|
158
|
-
expect(html).toContain('social icons row')
|
|
159
|
-
// Every per-item TransitionItem should apply the hidden class
|
|
160
|
-
// (enterFrom in this scroll-reveal shape).
|
|
161
|
-
const occurrences = (html.match(/opacity-0 translate-y-4/g) ?? []).length
|
|
162
|
-
expect(occurrences).toBeGreaterThanOrEqual(3)
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
it('initially-visible stagger (show=true) renders all items unchanged', async () => {
|
|
166
|
-
const StaggerList = kinetic('ul')
|
|
167
|
-
.enterClass({ from: 'opacity-0', to: 'opacity-100' })
|
|
168
|
-
.leaveClass({ to: 'is-hidden' })
|
|
169
|
-
.stagger({ interval: 50 })
|
|
170
|
-
const html = await renderToString(
|
|
171
|
-
h(StaggerList, { show: () => true },
|
|
172
|
-
[h('li', { key: 'a' }, 'item-a'), h('li', { key: 'b' }, 'item-b')],
|
|
173
|
-
),
|
|
174
|
-
)
|
|
175
|
-
expect(html).toContain('item-a')
|
|
176
|
-
expect(html).toContain('item-b')
|
|
177
|
-
// leaveTo must NOT leak onto visible items.
|
|
178
|
-
expect(html).not.toContain('is-hidden')
|
|
179
|
-
})
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
describe('kinetic(tag).collapse() — SSR / initially-hidden (CollapseRenderer)', () => {
|
|
183
|
-
it('emits inner content when show=false initially (was: empty 0-height wrapper)', async () => {
|
|
184
|
-
// Pre-fix: outer wrapper renders with `height: 0; overflow: hidden`
|
|
185
|
-
// but its children are stripped by the inner Show — empty wrapper in
|
|
186
|
-
// prerendered HTML. The fix keeps the outer wrapper's visual hiding
|
|
187
|
-
// (height: 0 IS the layout-safe collapse mechanism — flex slots see
|
|
188
|
-
// a 0-height box, no slot-collapse) while always rendering inner content.
|
|
189
|
-
const Accordion = kinetic('div').collapse()
|
|
190
|
-
const html = await renderToString(
|
|
191
|
-
h(Accordion, { show: () => false },
|
|
192
|
-
h('div', { class: 'panel-body' }, 'accordion panel content for SEO'),
|
|
193
|
-
),
|
|
194
|
-
)
|
|
195
|
-
expect(html).toContain('accordion panel content for SEO')
|
|
196
|
-
expect(html).toContain('panel-body')
|
|
197
|
-
// The outer wrapper retains the collapse-controlled hidden style —
|
|
198
|
-
// visual hiding via height:0 + overflow:hidden, not by dropping children.
|
|
199
|
-
expect(html).toContain('height: 0px')
|
|
200
|
-
expect(html).toContain('overflow: hidden')
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
it('initially-visible collapse (show=true) renders content normally', async () => {
|
|
204
|
-
const Accordion = kinetic('section').collapse()
|
|
205
|
-
const html = await renderToString(
|
|
206
|
-
h(Accordion, { show: () => true },
|
|
207
|
-
h('p', null, 'expanded content'),
|
|
208
|
-
),
|
|
209
|
-
)
|
|
210
|
-
expect(html).toContain('expanded content')
|
|
211
|
-
// height: 'auto' is the entered-state hint
|
|
212
|
-
expect(html).toContain('height: auto')
|
|
213
|
-
})
|
|
214
|
-
})
|
|
@@ -1,327 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @pyreon/core */
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
3
|
-
import { _rp, h } from '@pyreon/core'
|
|
4
|
-
import { signal } from '@pyreon/reactivity'
|
|
5
|
-
import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
|
|
6
|
-
import kinetic from '../kinetic'
|
|
7
|
-
import { nextFrame, mergeClassNames } from '../utils'
|
|
8
|
-
import Transition from '../Transition'
|
|
9
|
-
|
|
10
|
-
describe('@pyreon/kinetic browser smoke', () => {
|
|
11
|
-
// Regression: createKineticComponent + the 4 renderers used to value-copy
|
|
12
|
-
// user props (`for…in` / `const { children, ...rest }` / `{ ...htmlProps }`),
|
|
13
|
-
// firing every getter at component-setup time. The compiler emits a
|
|
14
|
-
// reactive HTML attr as `_rp(() => sig())`; mount.ts's makeReactiveProps
|
|
15
|
-
// turns it into a getter on `props`. The value-copy collapsed that getter
|
|
16
|
-
// to a static snapshot, freezing the attribute forever. The fix routes
|
|
17
|
-
// every hop through descriptor-preserving splitProps / mergeProps / by-ref
|
|
18
|
-
// so runtime-dom's applyProps detects the getter descriptor and wraps the
|
|
19
|
-
// read in a renderEffect. Bisect-verified: reverting createKineticComponent's
|
|
20
|
-
// splitProps split back to `for…in` fails this with `expected 'a' to be 'b'`.
|
|
21
|
-
it('forwards a reactive HTML attr through the kinetic pipeline (descriptor-preserving)', async () => {
|
|
22
|
-
const FadeDiv = kinetic('div')
|
|
23
|
-
const show = signal(true)
|
|
24
|
-
const v = signal('a')
|
|
25
|
-
const { container, unmount } = mountInBrowser(
|
|
26
|
-
h(
|
|
27
|
-
FadeDiv,
|
|
28
|
-
{ show, 'data-testid': 'fd', 'data-variant': _rp(() => v()) },
|
|
29
|
-
h('span', { 'data-id': 'kc' }, 'hi'),
|
|
30
|
-
),
|
|
31
|
-
)
|
|
32
|
-
const el = () => container.querySelector('[data-testid="fd"]')
|
|
33
|
-
expect(el()?.getAttribute('data-variant')).toBe('a')
|
|
34
|
-
v.set('b')
|
|
35
|
-
await flush()
|
|
36
|
-
expect(el()?.getAttribute('data-variant')).toBe('b')
|
|
37
|
-
unmount()
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('Transition mounts a visible child into real DOM', async () => {
|
|
41
|
-
const show = signal(true)
|
|
42
|
-
const { container, unmount } = mountInBrowser(
|
|
43
|
-
<Transition show={show}>
|
|
44
|
-
<div data-id="t">hello</div>
|
|
45
|
-
</Transition>,
|
|
46
|
-
)
|
|
47
|
-
const el = container.querySelector('[data-id="t"]')
|
|
48
|
-
expect(el?.textContent).toBe('hello')
|
|
49
|
-
unmount()
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it('Transition fires onLeave when show signal goes true → false', async () => {
|
|
53
|
-
let onLeaveCalls = 0
|
|
54
|
-
const show = signal(true)
|
|
55
|
-
const { unmount } = mountInBrowser(
|
|
56
|
-
<Transition show={show} onLeave={() => { onLeaveCalls++ }}>
|
|
57
|
-
<div data-id="t">hi</div>
|
|
58
|
-
</Transition>,
|
|
59
|
-
)
|
|
60
|
-
expect(onLeaveCalls).toBe(0)
|
|
61
|
-
show.set(false)
|
|
62
|
-
await flush()
|
|
63
|
-
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))))
|
|
64
|
-
// onLeave fires once the leave transition starts — asserts the
|
|
65
|
-
// signal → stage machine → lifecycle callback path ran end-to-end.
|
|
66
|
-
expect(onLeaveCalls).toBe(1)
|
|
67
|
-
unmount()
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('nextFrame schedules a callback via requestAnimationFrame', async () => {
|
|
71
|
-
let fired = false
|
|
72
|
-
nextFrame(() => {
|
|
73
|
-
fired = true
|
|
74
|
-
})
|
|
75
|
-
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))))
|
|
76
|
-
expect(fired).toBe(true)
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('mergeClassNames filters empty + joins', () => {
|
|
80
|
-
expect(mergeClassNames('a', 'b')).toBe('a b')
|
|
81
|
-
expect(mergeClassNames('a', undefined)).toBe('a')
|
|
82
|
-
expect(mergeClassNames(undefined, undefined)).toBe(undefined)
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
// Regression: a kinetic-wrapped component must FORWARD a compiler-shaped
|
|
86
|
-
// reactive HTML attr (`<KineticDiv class={sig()}>` → `_rp(() => sig())`,
|
|
87
|
-
// which `makeReactiveProps` turns into a getter on `props`) so the DOM
|
|
88
|
-
// patches when the signal changes. The factory's prop split + the
|
|
89
|
-
// renderers' element spread used to value-copy props, firing the getter
|
|
90
|
-
// once at setup and freezing the attribute. We build the vnode with
|
|
91
|
-
// `h()` + `_rp()` directly because this browser config has no Pyreon
|
|
92
|
-
// compiler plugin — that faithfully reproduces the exact post-
|
|
93
|
-
// makeReactiveProps shape the mount pipeline sees in a real app.
|
|
94
|
-
//
|
|
95
|
-
// Bisect-verified: revert createKineticComponent's splitProps back to
|
|
96
|
-
// `htmlProps[key] = props[key]` → this fails with the className stuck
|
|
97
|
-
// at 'one' (`expected 'one' to be 'two'`). Restored → passes.
|
|
98
|
-
it('forwards a compiler-shaped reactive HTML attr — DOM patches on signal change (transition mode)', async () => {
|
|
99
|
-
const KineticDiv = kinetic('div')
|
|
100
|
-
const cls = signal('one')
|
|
101
|
-
const { container, unmount } = mountInBrowser(
|
|
102
|
-
h(
|
|
103
|
-
KineticDiv,
|
|
104
|
-
{ show: () => true, class: _rp(() => cls()) },
|
|
105
|
-
h('span', { 'data-id': 'k' }, 'x'),
|
|
106
|
-
),
|
|
107
|
-
)
|
|
108
|
-
const el = () => container.querySelector('div')
|
|
109
|
-
expect(el()?.querySelector('[data-id="k"]')?.textContent).toBe('x')
|
|
110
|
-
expect(el()?.className).toBe('one')
|
|
111
|
-
|
|
112
|
-
cls.set('two')
|
|
113
|
-
await flush()
|
|
114
|
-
expect(el()?.className).toBe('two')
|
|
115
|
-
|
|
116
|
-
cls.set('three')
|
|
117
|
-
await flush()
|
|
118
|
-
expect(el()?.className).toBe('three')
|
|
119
|
-
unmount()
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
it('forwards a compiler-shaped reactive HTML attr — collapse mode (mergeProps path)', async () => {
|
|
123
|
-
const KineticDiv = kinetic('div').collapse()
|
|
124
|
-
const cls = signal('a')
|
|
125
|
-
const { container, unmount } = mountInBrowser(
|
|
126
|
-
h(
|
|
127
|
-
KineticDiv,
|
|
128
|
-
{ show: () => true, class: _rp(() => cls()) },
|
|
129
|
-
h('span', { 'data-id': 'c' }, 'y'),
|
|
130
|
-
),
|
|
131
|
-
)
|
|
132
|
-
const el = () => container.querySelector('div')
|
|
133
|
-
expect(el()?.className).toBe('a')
|
|
134
|
-
cls.set('b')
|
|
135
|
-
await flush()
|
|
136
|
-
expect(el()?.className).toBe('b')
|
|
137
|
-
unmount()
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
it('runs in a real browser — Vitest defines `process.env.NODE_ENV !== "production"`', () => {
|
|
141
|
-
// Sanity check the test env: dev gates use bundler-agnostic
|
|
142
|
-
// `process.env.NODE_ENV !== 'production'`. Vitest's Vite pipeline
|
|
143
|
-
// replaces this at build time so the literal lands as
|
|
144
|
-
// `"development" !== "production"` → `true` in dev runs.
|
|
145
|
-
expect(process.env.NODE_ENV).not.toBe('production')
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
// ── Initially-hidden Transition: client-side parity with the SSR fix ─────
|
|
149
|
-
//
|
|
150
|
-
// The SSR test file (`Transition.ssr.test.tsx`) proves children land in
|
|
151
|
-
// prerendered HTML; these specs prove the SAME render path works under
|
|
152
|
-
// a real DOM — the element mounts with the hidden-state class applied,
|
|
153
|
-
// and an `applyEnter` triggered by a `show` flip cleanly transitions it
|
|
154
|
-
// out of the hidden state (the companion `applyEnter` fix that removes
|
|
155
|
-
// residual `leave`/`leaveFrom`/`leaveTo` classes ensures the SSR-baked
|
|
156
|
-
// hidden class doesn't fight `enterTo`).
|
|
157
|
-
|
|
158
|
-
it('Transition with initial show=false mounts the element with the hidden class (no null)', async () => {
|
|
159
|
-
const show = signal(false)
|
|
160
|
-
const { container, unmount } = mountInBrowser(
|
|
161
|
-
<Transition
|
|
162
|
-
show={show}
|
|
163
|
-
enterFrom="hide-state"
|
|
164
|
-
enterTo="show-state"
|
|
165
|
-
enter="transition-opacity"
|
|
166
|
-
>
|
|
167
|
-
<div data-id="reveal-target">scroll-reveal content</div>
|
|
168
|
-
</Transition>,
|
|
169
|
-
)
|
|
170
|
-
// Pre-fix: container.querySelector returns null (children were dropped).
|
|
171
|
-
const el = container.querySelector('[data-id="reveal-target"]') as HTMLElement | null
|
|
172
|
-
expect(el).not.toBeNull()
|
|
173
|
-
expect(el!.textContent).toBe('scroll-reveal content')
|
|
174
|
-
// enterFrom is the fallback hidden-state class (scroll-reveal pattern
|
|
175
|
-
// configures only the enter side).
|
|
176
|
-
expect(el!.classList.contains('hide-state')).toBe(true)
|
|
177
|
-
unmount()
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
it('flipping show=true on an initially-hidden Transition cleans the hidden class and runs enter', async () => {
|
|
181
|
-
const show = signal(false)
|
|
182
|
-
const { container, unmount } = mountInBrowser(
|
|
183
|
-
<Transition
|
|
184
|
-
show={show}
|
|
185
|
-
enterFrom="hide-state"
|
|
186
|
-
enterTo="show-state"
|
|
187
|
-
enter="enter-active"
|
|
188
|
-
>
|
|
189
|
-
<div data-id="reveal-target">content</div>
|
|
190
|
-
</Transition>,
|
|
191
|
-
)
|
|
192
|
-
const el = () => container.querySelector('[data-id="reveal-target"]') as HTMLElement | null
|
|
193
|
-
// Starts hidden.
|
|
194
|
-
expect(el()!.classList.contains('hide-state')).toBe(true)
|
|
195
|
-
// Flip show → true; applyEnter runs in the watch effect on the SAME
|
|
196
|
-
// element (the SSR fix guarantees the element is already in DOM).
|
|
197
|
-
show.set(true)
|
|
198
|
-
await flush()
|
|
199
|
-
// The companion applyEnter fix removes residual `leave`/`leaveFrom`/
|
|
200
|
-
// `leaveTo` AND adds `enter` + `enterFrom`. enterFrom was already
|
|
201
|
-
// applied (it WAS the hidden-state class); the next frame removes it
|
|
202
|
-
// and adds enterTo. Two rAFs for full transition.
|
|
203
|
-
await new Promise<void>((resolve) =>
|
|
204
|
-
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
205
|
-
)
|
|
206
|
-
await flush()
|
|
207
|
-
// After the double-rAF, enterTo is applied + enterFrom removed.
|
|
208
|
-
expect(el()!.classList.contains('show-state')).toBe(true)
|
|
209
|
-
expect(el()!.classList.contains('hide-state')).toBe(false)
|
|
210
|
-
// `enter` (the active marker) is applied throughout the transition.
|
|
211
|
-
expect(el()!.classList.contains('enter-active')).toBe(true)
|
|
212
|
-
unmount()
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
// ── Initially-hidden kinetic(tag).<mode> — client-side parity with SSR ──
|
|
216
|
-
//
|
|
217
|
-
// Companion to PR #717's `<Transition>` direct-import specs (the two
|
|
218
|
-
// above). These exercise the `kinetic(tag).<mode>` factory paths — the
|
|
219
|
-
// README's primary documented surface — whose per-mode renderers carried
|
|
220
|
-
// the same SSR-children-dropped bug until this PR fixed them. SSR specs
|
|
221
|
-
// in `kinetic-modes.ssr.test.tsx` prove children land in prerendered
|
|
222
|
-
// HTML; these specs prove the SAME render path works under a real DOM —
|
|
223
|
-
// the element mounts with the hidden-state class/style applied, and an
|
|
224
|
-
// `applyEnter` triggered by a `show` flip cleanly transitions it out.
|
|
225
|
-
|
|
226
|
-
it('kinetic("div").transition with initial show=false mounts element with hidden class', async () => {
|
|
227
|
-
const Reveal = kinetic('section').enterClass({
|
|
228
|
-
active: 'enter-active',
|
|
229
|
-
from: 'hide-state',
|
|
230
|
-
to: 'show-state',
|
|
231
|
-
})
|
|
232
|
-
const show = signal(false)
|
|
233
|
-
const { container, unmount } = mountInBrowser(
|
|
234
|
-
h(Reveal, { show, 'data-id': 'reveal-target' }, h('p', null, 'scroll-reveal content')),
|
|
235
|
-
)
|
|
236
|
-
// Pre-fix: container.querySelector returns null (children dropped).
|
|
237
|
-
const el = container.querySelector('[data-id="reveal-target"]') as HTMLElement | null
|
|
238
|
-
expect(el).not.toBeNull()
|
|
239
|
-
expect(el!.textContent).toContain('scroll-reveal content')
|
|
240
|
-
// enterFrom is the fallback hidden-state class (scroll-reveal pattern
|
|
241
|
-
// configures only the enter side).
|
|
242
|
-
expect(el!.classList.contains('hide-state')).toBe(true)
|
|
243
|
-
unmount()
|
|
244
|
-
})
|
|
245
|
-
|
|
246
|
-
it('kinetic("div").transition show=true flip cleans hidden class + runs enter animation', async () => {
|
|
247
|
-
const Reveal = kinetic('section').enterClass({
|
|
248
|
-
active: 'enter-active',
|
|
249
|
-
from: 'hide-state',
|
|
250
|
-
to: 'show-state',
|
|
251
|
-
})
|
|
252
|
-
const show = signal(false)
|
|
253
|
-
const { container, unmount } = mountInBrowser(
|
|
254
|
-
h(Reveal, { show, 'data-id': 'reveal-target' }, h('p', null, 'content')),
|
|
255
|
-
)
|
|
256
|
-
const el = () => container.querySelector('[data-id="reveal-target"]') as HTMLElement | null
|
|
257
|
-
expect(el()!.classList.contains('hide-state')).toBe(true)
|
|
258
|
-
|
|
259
|
-
show.set(true)
|
|
260
|
-
await flush()
|
|
261
|
-
// Double-rAF for the applyEnter nextFrame → enterTo applied.
|
|
262
|
-
await new Promise<void>((resolve) =>
|
|
263
|
-
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
264
|
-
)
|
|
265
|
-
await flush()
|
|
266
|
-
|
|
267
|
-
expect(el()!.classList.contains('show-state')).toBe(true)
|
|
268
|
-
// enterFrom (hide-state) was removed; the symmetric applyEnter cleanup
|
|
269
|
-
// ALSO removes leave-side classes (none here) — locks in the
|
|
270
|
-
// companion fix that prevents residual hidden classes from fighting
|
|
271
|
-
// enterTo's CSS rules.
|
|
272
|
-
expect(el()!.classList.contains('hide-state')).toBe(false)
|
|
273
|
-
expect(el()!.classList.contains('enter-active')).toBe(true)
|
|
274
|
-
unmount()
|
|
275
|
-
})
|
|
276
|
-
|
|
277
|
-
it('kinetic("ul").stagger() with initial show=false mounts all items with hidden class', async () => {
|
|
278
|
-
// The reported real-app cascading-Stagger pattern at SSR. Each per-item
|
|
279
|
-
// TransitionItem must render structurally; the hidden class lands on
|
|
280
|
-
// each item via the enterFrom fallback.
|
|
281
|
-
const Staggered = kinetic('ul')
|
|
282
|
-
.enterClass({ active: 'enter-active', from: 'item-hidden', to: 'item-shown' })
|
|
283
|
-
.stagger({ interval: 50 })
|
|
284
|
-
const show = signal(false)
|
|
285
|
-
const { container, unmount } = mountInBrowser(
|
|
286
|
-
h(Staggered, { show, 'data-id': 'stagger-list' }, [
|
|
287
|
-
h('li', { key: 'a' }, 'first item'),
|
|
288
|
-
h('li', { key: 'b' }, 'second item'),
|
|
289
|
-
h('li', { key: 'c' }, 'third item'),
|
|
290
|
-
]),
|
|
291
|
-
)
|
|
292
|
-
const list = container.querySelector('[data-id="stagger-list"]') as HTMLElement | null
|
|
293
|
-
expect(list).not.toBeNull()
|
|
294
|
-
const items = list!.querySelectorAll('li')
|
|
295
|
-
expect(items.length).toBe(3)
|
|
296
|
-
// Every per-item TransitionItem applies the hidden class.
|
|
297
|
-
for (const item of items) {
|
|
298
|
-
expect(item.classList.contains('item-hidden')).toBe(true)
|
|
299
|
-
}
|
|
300
|
-
expect(list!.textContent).toContain('first item')
|
|
301
|
-
expect(list!.textContent).toContain('second item')
|
|
302
|
-
expect(list!.textContent).toContain('third item')
|
|
303
|
-
unmount()
|
|
304
|
-
})
|
|
305
|
-
|
|
306
|
-
it('kinetic("div").collapse() with initial show=false mounts inner content (visually hidden via height:0)', async () => {
|
|
307
|
-
// CollapseRenderer's fix: outer wrapper retains height:0 + overflow:hidden
|
|
308
|
-
// (layout-safe visual hiding); inner content is always rendered so SSG
|
|
309
|
-
// ships the structural HTML for SEO. Real-DOM parity check.
|
|
310
|
-
const Accordion = kinetic('div').collapse()
|
|
311
|
-
const show = signal(false)
|
|
312
|
-
const { container, unmount } = mountInBrowser(
|
|
313
|
-
h(Accordion, { show, 'data-id': 'accordion' },
|
|
314
|
-
h('div', { 'data-id': 'inner' }, 'accordion content'),
|
|
315
|
-
),
|
|
316
|
-
)
|
|
317
|
-
const wrapper = container.querySelector('[data-id="accordion"]') as HTMLElement | null
|
|
318
|
-
const inner = container.querySelector('[data-id="inner"]') as HTMLElement | null
|
|
319
|
-
expect(wrapper).not.toBeNull()
|
|
320
|
-
expect(inner).not.toBeNull() // ← was null pre-fix (Show dropped it)
|
|
321
|
-
expect(inner!.textContent).toBe('accordion content')
|
|
322
|
-
// Outer wrapper visually hides via height:0 (computed style — real CSS).
|
|
323
|
-
expect(wrapper!.style.height).toBe('0px')
|
|
324
|
-
expect(wrapper!.style.overflow).toBe('hidden')
|
|
325
|
-
unmount()
|
|
326
|
-
})
|
|
327
|
-
})
|