@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.
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,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
- })