@pyreon/kinetic 0.21.0 → 0.23.0
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/README.md +107 -119
- package/lib/index.js +73 -13
- package/package.json +11 -11
- package/src/Stagger.tsx +7 -2
- package/src/Transition.tsx +30 -6
- package/src/__tests__/Collapse.test.tsx +25 -4
- package/src/__tests__/Transition.ssr.test.tsx +28 -0
- package/src/__tests__/kinetic-modes.ssr.test.tsx +214 -0
- package/src/__tests__/kinetic.browser.test.tsx +113 -0
- package/src/__tests__/stagger-component-children-hydration.test.tsx +191 -0
- package/src/__tests__/top-level-transition-stagger-function-children.test.tsx +141 -0
- package/src/kinetic/CollapseRenderer.tsx +34 -3
- package/src/kinetic/StaggerRenderer.tsx +4 -2
- package/src/kinetic/TransitionItem.tsx +92 -22
- package/src/kinetic/TransitionRenderer.tsx +95 -33
- package/src/utils.ts +28 -0
package/README.md
CHANGED
|
@@ -1,149 +1,131 @@
|
|
|
1
1
|
# @pyreon/kinetic
|
|
2
2
|
|
|
3
|
-
CSS-
|
|
3
|
+
CSS-transition animation library — enter/exit, stagger, collapse, list reconciliation, ~3KB.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Most animation libraries run their own JavaScript animation loop on the main thread. Kinetic takes a different approach: it delegates all interpolation to the browser's CSS transition engine (compositor thread for `transform`/`opacity`), and only handles orchestration — mount/unmount lifecycle, stagger timing, height measurement, and list diffing.
|
|
8
|
-
|
|
9
|
-
The result: GPU-composited 60/120 FPS animations with a 3.2KB footprint.
|
|
10
|
-
|
|
11
|
-
### How It Compares
|
|
12
|
-
|
|
13
|
-
| Library | Gzipped | Engine | Enter/Exit | Stagger | List Recon. | Collapse | Reduced Motion |
|
|
14
|
-
| ---------------------- | ---------- | ------------------- | ---------- | ------- | ----------- | -------- | -------------- |
|
|
15
|
-
| **@pyreon/kinetic** | **3.2 KB** | CSS transitions | Yes | Yes | Yes | Yes | Yes |
|
|
16
|
-
| Motion (framer-motion) | ~34 KB | JS (rAF + WAAPI) | Yes | Yes | Yes | Quirky | Yes |
|
|
17
|
-
| @react-spring/web | ~16-24 KB | JS (spring physics) | Yes | Partial | Yes | Manual | Yes |
|
|
18
|
-
| react-transition-group | ~5 KB | CSS classes | Yes | No | Yes | No | No |
|
|
19
|
-
| AutoAnimate | ~2.5 KB | JS (FLIP) | Yes | No | Yes | No | Yes |
|
|
20
|
-
|
|
21
|
-
**Key advantages:**
|
|
22
|
-
|
|
23
|
-
- **10x smaller than Motion** for CSS-transition use cases
|
|
24
|
-
- **CSS-first**: `transform`/`opacity` run on GPU compositor thread, not main thread
|
|
25
|
-
- **Only library** combining CSS transitions + stagger + collapse + list reconciliation
|
|
26
|
-
- **122 presets** available via `@pyreon/kinetic-presets`
|
|
5
|
+
`@pyreon/kinetic` delegates interpolation to the browser's CSS transition engine (GPU compositor thread for `transform` / `opacity`) and only handles orchestration: mount/unmount lifecycle, stagger timing, height measurement, and key-based list diffing. The result is 60/120fps animations with a 3.2KB footprint and four composable modes (transition, collapse, stagger, group) accessed through a chainable, immutable API. Pair with `@pyreon/kinetic-presets` for 120+ ready-made animations, or define your own via inline `.enter()` / `.enterTo()` styles or class-based transitions (Tailwind, CSS modules). Reduced-motion is detected automatically; SSR renders children in their hidden-state class so scroll-reveal patterns reach SEO crawlers.
|
|
27
6
|
|
|
28
7
|
## Install
|
|
29
8
|
|
|
30
9
|
```bash
|
|
31
|
-
bun add @pyreon/kinetic
|
|
10
|
+
bun add @pyreon/kinetic @pyreon/core @pyreon/reactivity @pyreon/runtime-dom
|
|
32
11
|
```
|
|
33
12
|
|
|
34
|
-
## Quick
|
|
13
|
+
## Quick start
|
|
35
14
|
|
|
36
|
-
```
|
|
15
|
+
```tsx
|
|
37
16
|
import { kinetic, fade, slideUp } from '@pyreon/kinetic'
|
|
38
17
|
import { signal } from '@pyreon/reactivity'
|
|
39
18
|
|
|
40
|
-
// Create animated components at module level
|
|
41
19
|
const FadeDiv = kinetic('div').preset(fade)
|
|
42
20
|
const SlideSection = kinetic('section').preset(slideUp)
|
|
43
21
|
|
|
44
|
-
// Use with signals for reactive show/hide
|
|
45
22
|
const show = signal(true)
|
|
46
|
-
|
|
47
|
-
FadeDiv({ show: show(), children: 'Hello, world!' })
|
|
23
|
+
<FadeDiv show={show()}>Hello, world!</FadeDiv>
|
|
48
24
|
```
|
|
49
25
|
|
|
50
|
-
##
|
|
26
|
+
## How it compares
|
|
51
27
|
|
|
52
|
-
|
|
28
|
+
| Library | Gzipped | Engine | Enter/Exit | Stagger | List Recon. | Collapse | Reduced Motion |
|
|
29
|
+
| ---------------------- | ---------- | ------------------- | ---------- | ------- | ----------- | -------- | -------------- |
|
|
30
|
+
| **@pyreon/kinetic** | **3.2 KB** | CSS transitions | Yes | Yes | Yes | Yes | Yes |
|
|
31
|
+
| Motion (framer-motion) | ~34 KB | JS (rAF + WAAPI) | Yes | Yes | Yes | Quirky | Yes |
|
|
32
|
+
| @react-spring/web | ~16-24 KB | JS (spring physics) | Yes | Partial | Yes | Manual | Yes |
|
|
33
|
+
| react-transition-group | ~5 KB | CSS classes | Yes | No | Yes | No | No |
|
|
34
|
+
| AutoAnimate | ~2.5 KB | JS (FLIP) | Yes | No | Yes | No | Yes |
|
|
35
|
+
|
|
36
|
+
Key advantages: 10x smaller than Motion for CSS-transition use cases; only library combining CSS transitions + stagger + collapse + key-based list reconciliation; 120+ presets via `@pyreon/kinetic-presets`.
|
|
53
37
|
|
|
54
|
-
|
|
38
|
+
## `kinetic(tag)` — animated component factory
|
|
55
39
|
|
|
56
40
|
```ts
|
|
57
|
-
kinetic('div')
|
|
58
|
-
kinetic('section')
|
|
59
|
-
kinetic(MyComponent)
|
|
41
|
+
kinetic('div') // HTML element string
|
|
42
|
+
kinetic('section')
|
|
43
|
+
kinetic(MyComponent) // Any Pyreon component
|
|
60
44
|
```
|
|
61
45
|
|
|
62
|
-
Returns a renderable Pyreon component with chain methods
|
|
46
|
+
Returns a renderable Pyreon component with chain methods. Default mode: **transition**.
|
|
63
47
|
|
|
64
|
-
|
|
48
|
+
## Chain methods
|
|
65
49
|
|
|
66
|
-
|
|
50
|
+
Every method returns a new component (immutable). The tag generic flows through, preserving HTML attribute types.
|
|
67
51
|
|
|
68
52
|
```ts
|
|
69
|
-
//
|
|
70
|
-
.enter(styles) // CSSProperties
|
|
71
|
-
.enterTo(styles) // CSSProperties
|
|
72
|
-
.enterTransition(value) // CSS transition string
|
|
73
|
-
.leave(styles) // CSSProperties
|
|
74
|
-
.leaveTo(styles) // CSSProperties
|
|
75
|
-
.leaveTransition(value)
|
|
76
|
-
|
|
77
|
-
// Class-based
|
|
53
|
+
// Inline style-based config
|
|
54
|
+
.enter(styles) // CSSProperties at enter start
|
|
55
|
+
.enterTo(styles) // CSSProperties after first frame
|
|
56
|
+
.enterTransition(value) // CSS transition string
|
|
57
|
+
.leave(styles) // CSSProperties at leave start
|
|
58
|
+
.leaveTo(styles) // CSSProperties after first frame
|
|
59
|
+
.leaveTransition(value)
|
|
60
|
+
|
|
61
|
+
// Class-based config (Tailwind / CSS modules friendly)
|
|
78
62
|
.enterClass({ active?, from?, to? })
|
|
79
63
|
.leaveClass({ active?, from?, to? })
|
|
80
64
|
|
|
81
|
-
//
|
|
65
|
+
// Preset (spreads style + class props)
|
|
82
66
|
.preset(preset)
|
|
83
67
|
|
|
84
|
-
//
|
|
85
|
-
.config(
|
|
86
|
-
.on(
|
|
68
|
+
// Behaviour
|
|
69
|
+
.config({ appear, unmount, timeout, ... })
|
|
70
|
+
.on({ onEnter, onAfterEnter, onLeave, onAfterLeave })
|
|
87
71
|
|
|
88
72
|
// Mode switches
|
|
89
|
-
.collapse(opts?) // Height
|
|
90
|
-
.stagger(opts?) // Staggered
|
|
73
|
+
.collapse(opts?) // Height-animation mode
|
|
74
|
+
.stagger(opts?) // Staggered-children mode
|
|
91
75
|
.group() // Key-based list reconciliation mode
|
|
92
76
|
```
|
|
93
77
|
|
|
94
|
-
|
|
78
|
+
## Four modes
|
|
95
79
|
|
|
96
|
-
|
|
80
|
+
### Transition (default)
|
|
97
81
|
|
|
98
|
-
Single
|
|
82
|
+
Single-element enter/leave with CSS transitions.
|
|
99
83
|
|
|
100
|
-
```
|
|
84
|
+
```tsx
|
|
101
85
|
const FadeDiv = kinetic('div').preset(fade)
|
|
102
|
-
|
|
103
|
-
FadeDiv({ show: isOpen, children: 'Content' })
|
|
86
|
+
<FadeDiv show={isOpen}>Content</FadeDiv>
|
|
104
87
|
```
|
|
105
88
|
|
|
106
|
-
|
|
89
|
+
### Collapse
|
|
107
90
|
|
|
108
91
|
Height animation with `overflow: hidden`. Measures `scrollHeight` automatically.
|
|
109
92
|
|
|
110
|
-
```
|
|
93
|
+
```tsx
|
|
111
94
|
const Accordion = kinetic('div').collapse()
|
|
112
95
|
const FancyAccordion = kinetic('section').collapse({
|
|
113
96
|
transition: 'height 400ms cubic-bezier(0.4, 0, 0.2, 1)',
|
|
114
97
|
})
|
|
115
98
|
|
|
116
|
-
Accordion
|
|
99
|
+
<Accordion show={isExpanded}>Expandable content</Accordion>
|
|
117
100
|
```
|
|
118
101
|
|
|
119
|
-
|
|
102
|
+
### Stagger
|
|
120
103
|
|
|
121
104
|
Staggered entrance/exit for child elements.
|
|
122
105
|
|
|
123
|
-
```
|
|
106
|
+
```tsx
|
|
124
107
|
const StaggerList = kinetic('ul').preset(slideUp).stagger({ interval: 75 })
|
|
125
108
|
|
|
126
|
-
StaggerList
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
h('li', { key: '3' }, 'Item 3'),
|
|
132
|
-
],
|
|
133
|
-
})
|
|
109
|
+
<StaggerList show={isVisible}>
|
|
110
|
+
<li key="1">Item 1</li>
|
|
111
|
+
<li key="2">Item 2</li>
|
|
112
|
+
<li key="3">Item 3</li>
|
|
113
|
+
</StaggerList>
|
|
134
114
|
```
|
|
135
115
|
|
|
136
|
-
|
|
116
|
+
### Group
|
|
137
117
|
|
|
138
|
-
Key-based enter/exit — adding a child triggers enter
|
|
118
|
+
Key-based enter/exit — adding a keyed child triggers enter; removing triggers leave + unmount. No `show` prop.
|
|
139
119
|
|
|
140
|
-
```
|
|
120
|
+
```tsx
|
|
141
121
|
const AnimatedList = kinetic('ul').preset(fade).group()
|
|
142
122
|
|
|
143
|
-
AnimatedList
|
|
123
|
+
<AnimatedList>
|
|
124
|
+
{items.map((item) => <li key={item.id}>{item.text}</li>)}
|
|
125
|
+
</AnimatedList>
|
|
144
126
|
```
|
|
145
127
|
|
|
146
|
-
|
|
128
|
+
## Inline configuration
|
|
147
129
|
|
|
148
130
|
Build animations without presets:
|
|
149
131
|
|
|
@@ -157,7 +139,7 @@ const SlidePanel = kinetic('aside')
|
|
|
157
139
|
.leaveTransition('all 200ms ease-in')
|
|
158
140
|
```
|
|
159
141
|
|
|
160
|
-
|
|
142
|
+
## Class-based transitions
|
|
161
143
|
|
|
162
144
|
Works with Tailwind CSS, CSS modules, or any class-based approach:
|
|
163
145
|
|
|
@@ -167,79 +149,85 @@ const TailwindFade = kinetic('div')
|
|
|
167
149
|
.leaveClass({ active: 'transition-opacity duration-200', from: 'opacity-100', to: 'opacity-0' })
|
|
168
150
|
```
|
|
169
151
|
|
|
170
|
-
|
|
152
|
+
## Lifecycle callbacks
|
|
171
153
|
|
|
172
|
-
```
|
|
173
|
-
FadeDiv
|
|
174
|
-
show
|
|
175
|
-
onEnter
|
|
176
|
-
onAfterEnter
|
|
177
|
-
onLeave
|
|
178
|
-
onAfterLeave
|
|
179
|
-
|
|
180
|
-
|
|
154
|
+
```tsx
|
|
155
|
+
<FadeDiv
|
|
156
|
+
show={isOpen}
|
|
157
|
+
onEnter={() => console.log('entering')}
|
|
158
|
+
onAfterEnter={() => console.log('entered')}
|
|
159
|
+
onLeave={() => console.log('leaving')}
|
|
160
|
+
onAfterLeave={() => console.log('left')}
|
|
161
|
+
>
|
|
162
|
+
Content
|
|
163
|
+
</FadeDiv>
|
|
181
164
|
```
|
|
182
165
|
|
|
183
|
-
|
|
166
|
+
## Composition with rocketstyle
|
|
184
167
|
|
|
185
|
-
Kinetic
|
|
168
|
+
Kinetic and rocketstyle compose naturally:
|
|
186
169
|
|
|
187
|
-
|
|
170
|
+
```ts
|
|
171
|
+
import rocketstyle from '@pyreon/rocketstyle'
|
|
188
172
|
|
|
189
|
-
|
|
173
|
+
const Button = rocketstyle()({ component: 'button', name: 'Button' })
|
|
174
|
+
.theme({ primaryColor: 'blue' })
|
|
190
175
|
|
|
191
|
-
|
|
192
|
-
|
|
176
|
+
const AnimatedButton = kinetic(Button).preset(fade)
|
|
177
|
+
// Has BOTH rocketstyle dimension props AND kinetic show/lifecycle props
|
|
178
|
+
<AnimatedButton show={isVisible} state="primary" size="large">Click me</AnimatedButton>
|
|
193
179
|
```
|
|
194
180
|
|
|
195
|
-
|
|
181
|
+
## Built-in presets
|
|
196
182
|
|
|
197
|
-
|
|
183
|
+
Six presets are included in the core package: `fade`, `scaleIn`, `slideUp`, `slideDown`, `slideLeft`, `slideRight`. For 120+ presets, factories, and composition utilities, add `@pyreon/kinetic-presets`.
|
|
198
184
|
|
|
199
|
-
|
|
185
|
+
## Low-level hooks
|
|
186
|
+
|
|
187
|
+
If you need transition state outside `kinetic()`:
|
|
200
188
|
|
|
201
189
|
```ts
|
|
202
|
-
import
|
|
190
|
+
import { useTransitionState, useAnimationEnd } from '@pyreon/kinetic'
|
|
203
191
|
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
192
|
+
const state = useTransitionState({ show: () => isOpen() })
|
|
193
|
+
// state.stage() → 'enter' | 'enter-active' | 'enter-to' | 'leave' | 'leave-active' | 'leave-to' | 'idle'
|
|
194
|
+
```
|
|
207
195
|
|
|
208
|
-
|
|
196
|
+
## Accessibility
|
|
209
197
|
|
|
210
|
-
|
|
211
|
-
AnimatedButton({ show: isVisible, primary: true, size: 'large', children: 'Click me' })
|
|
212
|
-
```
|
|
198
|
+
Kinetic automatically detects `prefers-reduced-motion: reduce`. When enabled, animations are skipped instantly — callbacks still fire, but no visual animation occurs. No configuration needed.
|
|
213
199
|
|
|
214
200
|
## SSR / SSG
|
|
215
201
|
|
|
216
202
|
`<Transition show={() => false}>` **always renders children in SSR**, with the hidden-state class inlined (`leaveTo` if defined, else `enterFrom`). This matches Framer Motion / react-transition-group / react-spring conventions: content is structural, animation is visual.
|
|
217
203
|
|
|
218
|
-
|
|
204
|
+
Load-bearing for scroll-reveal on SSG sites — `useIntersection` can't fire on the server, so `show` is false at SSR. Without structural rendering, the wrapped content would be absent from prerendered HTML (bad for SEO, social scrapers, no-JS users).
|
|
219
205
|
|
|
220
206
|
```tsx
|
|
221
207
|
const RevealSection = kinetic('section')
|
|
222
|
-
.
|
|
223
|
-
.enterFrom('opacity-0 translate-y-8') // ← this IS the SSR hidden state
|
|
224
|
-
.enterTo('opacity-100 translate-y-0')
|
|
208
|
+
.enterClass({ active: 'transition-all duration-700', from: 'opacity-0 translate-y-8', to: 'opacity-100 translate-y-0' })
|
|
225
209
|
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
// On client: when scrolled into view, show flips true, enter animation runs.
|
|
210
|
+
// SSR: <section class="opacity-0 translate-y-8">…full content…</section>
|
|
211
|
+
// Client: when scrolled into view, show flips true → enter animation runs
|
|
229
212
|
<RevealSection show={isInView}>
|
|
230
213
|
<h2>Work Experience</h2>
|
|
231
214
|
<p>…content reaches SEO crawlers and social scrapers…</p>
|
|
232
215
|
</RevealSection>
|
|
233
216
|
```
|
|
234
217
|
|
|
235
|
-
**Trade-off**: for an initially-hidden Transition, `unmount: true` (the default) no longer triggers a true DOM removal after a later leave animation completes — the element stays in DOM with the leave-to class applied. **Initially-visible** Transitions
|
|
218
|
+
**Trade-off**: for an initially-hidden Transition, `unmount: true` (the default) no longer triggers a true DOM removal after a later leave animation completes — the element stays in DOM with the leave-to class applied. **Initially-visible** Transitions keep the runtime-unmount semantic unchanged. If you need true unmount on a started-hidden element, drive mount/unmount yourself outside `<Transition>`.
|
|
219
|
+
|
|
220
|
+
## Gotchas
|
|
221
|
+
|
|
222
|
+
- **Animations run on the GPU compositor thread** only when you animate `transform` / `opacity` / `filter`. Animating `width` / `height` / `top` / `left` falls back to the main thread and may jank.
|
|
223
|
+
- **Stagger `interval` is per-CHILD**, not total duration. Five children at 75ms = 375ms total stagger window.
|
|
224
|
+
- **Group mode requires keyed children.** Without `key=`, every render replaces every child and you get no animation. The compiler-suggested `<For each={items} by={i => i.id}>` is the idiomatic pattern.
|
|
225
|
+
- **SSR initial-hidden Transitions break true-unmount semantics.** See SSR / SSG section above — opt out by driving mount/unmount yourself.
|
|
226
|
+
- **Reduced-motion skips visuals but still fires callbacks.** Don't rely on the animation completing for state machine progression — use callbacks.
|
|
236
227
|
|
|
237
|
-
##
|
|
228
|
+
## Documentation
|
|
238
229
|
|
|
239
|
-
|
|
240
|
-
| ------------------ | -------- |
|
|
241
|
-
| @pyreon/core | >= 0.0.1 |
|
|
242
|
-
| @pyreon/reactivity | >= 0.0.1 |
|
|
230
|
+
Full docs: [docs.pyreon.dev/docs/kinetic](https://docs.pyreon.dev/docs/kinetic) (or `docs/docs/kinetic.md` in this repo).
|
|
243
231
|
|
|
244
232
|
## License
|
|
245
233
|
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Show, createRef, h, mergeProps, onMount, onUnmount, splitProps } from "@pyreon/core";
|
|
1
|
+
import { Show, createRef, cx, h, mergeProps, onMount, onUnmount, splitProps } from "@pyreon/core";
|
|
2
2
|
import { runUntracked, signal, watch } from "@pyreon/reactivity";
|
|
3
3
|
import { jsx } from "@pyreon/core/jsx-runtime";
|
|
4
4
|
|
|
@@ -172,16 +172,20 @@ const CollapseRenderer = ({ config, htmlProps, show, appear, timeout, transition
|
|
|
172
172
|
...stage() !== "entered" ? { overflow: "hidden" } : {},
|
|
173
173
|
...stage() === "hidden" ? { height: "0px" } : stage() === "entered" ? { height: "auto" } : {}
|
|
174
174
|
};
|
|
175
|
-
|
|
176
|
-
ref: wrapperRef,
|
|
177
|
-
style: wrapperStyle
|
|
178
|
-
}), /* @__PURE__ */ jsx(Show, {
|
|
175
|
+
const innerContent = show() ? /* @__PURE__ */ jsx(Show, {
|
|
179
176
|
when: shouldRender,
|
|
180
177
|
children: /* @__PURE__ */ jsx("div", {
|
|
181
178
|
ref: contentRef,
|
|
182
179
|
children
|
|
183
180
|
})
|
|
184
|
-
})
|
|
181
|
+
}) : /* @__PURE__ */ jsx("div", {
|
|
182
|
+
ref: contentRef,
|
|
183
|
+
children
|
|
184
|
+
});
|
|
185
|
+
return h(config.tag, mergeProps(htmlProps, {
|
|
186
|
+
ref: wrapperRef,
|
|
187
|
+
style: wrapperStyle
|
|
188
|
+
}), innerContent);
|
|
185
189
|
};
|
|
186
190
|
|
|
187
191
|
//#endregion
|
|
@@ -286,10 +290,39 @@ const cloneVNode = (vnode, extraProps) => ({
|
|
|
286
290
|
...extraProps
|
|
287
291
|
}
|
|
288
292
|
});
|
|
293
|
+
/**
|
|
294
|
+
* Resolves a `children` value the Pyreon compiler may have wrapped in a
|
|
295
|
+
* deferred accessor.
|
|
296
|
+
*
|
|
297
|
+
* **Why:** the compiler's prop-inlining pass rewrites `<Comp>{children}</Comp>`
|
|
298
|
+
* to `Comp({ ..., children: () => <inlined-expression> })` whenever
|
|
299
|
+
* `children` is a local `const` derived from a getter-shaped binding
|
|
300
|
+
* (`const children = childHolder.children` after `splitProps`). DOM-side
|
|
301
|
+
* consumers route through `mountChild` which already treats function
|
|
302
|
+
* children as reactive accessors, so the wrap is invisible there. Kinetic's
|
|
303
|
+
* Stagger/Group/Transition/Collapse renderers iterate `children` directly
|
|
304
|
+
* at the VNode level (to build per-child `TransitionItem`s), so a wrapped
|
|
305
|
+
* function landed in `Array.isArray(children) ? children : [children]` as
|
|
306
|
+
* `[function]` → `.filter(isVNode)` → `[]` → the rendered `<div>` had zero
|
|
307
|
+
* children → SSR content vanished post-hydration. Reproducer:
|
|
308
|
+
* `examples/bokisch.com`'s Intro section with `kinetic('div').stagger()`
|
|
309
|
+
* + `appear` + `show={() => true}` + component children → SSG HTML had
|
|
310
|
+
* `<h1>Hello</h1>`, post-hydrate the entire subtree was replaced by
|
|
311
|
+
* `<!--pyreon-->` markers.
|
|
312
|
+
*
|
|
313
|
+
* Kinetic deliberately snapshots children at render time (animation state
|
|
314
|
+
* is per-item, built once) — it does NOT observe children changes after
|
|
315
|
+
* the initial render. Eagerly unwrapping the function matches that
|
|
316
|
+
* contract; no reactivity is lost.
|
|
317
|
+
*/
|
|
318
|
+
const resolveChildren = (children) => typeof children === "function" ? children() : children;
|
|
289
319
|
|
|
290
320
|
//#endregion
|
|
291
321
|
//#region src/kinetic/TransitionItem.tsx
|
|
292
322
|
const applyEnter$1 = (el, config) => {
|
|
323
|
+
removeClasses(el, config.leave);
|
|
324
|
+
removeClasses(el, config.leaveFrom);
|
|
325
|
+
removeClasses(el, config.leaveTo);
|
|
293
326
|
addClasses(el, config.enter);
|
|
294
327
|
addClasses(el, config.enterFrom);
|
|
295
328
|
if (config.enterStyle) Object.assign(el.style, config.enterStyle);
|
|
@@ -331,6 +364,7 @@ const applyReducedMotion$1 = (stage, callbacks, complete) => {
|
|
|
331
364
|
* Uses cloneVNode to inject ref onto the child — the child must accept ref.
|
|
332
365
|
*/
|
|
333
366
|
const TransitionItem = (props) => {
|
|
367
|
+
const child = resolveChildren(props.children);
|
|
334
368
|
const appear = props.appear ?? false;
|
|
335
369
|
const unmount = props.unmount ?? true;
|
|
336
370
|
const timeout = props.timeout ?? 5e3;
|
|
@@ -340,7 +374,7 @@ const TransitionItem = (props) => {
|
|
|
340
374
|
appear
|
|
341
375
|
});
|
|
342
376
|
const elementRef = createRef();
|
|
343
|
-
const mergedRef = mergeRefs(elementRef, stateRef, props
|
|
377
|
+
const mergedRef = mergeRefs(elementRef, stateRef, (child?.props)?.ref);
|
|
344
378
|
const callbacks = {
|
|
345
379
|
onEnter: props.onEnter,
|
|
346
380
|
onAfterEnter: props.onAfterEnter,
|
|
@@ -393,14 +427,24 @@ const TransitionItem = (props) => {
|
|
|
393
427
|
el.style.transition = "";
|
|
394
428
|
}
|
|
395
429
|
}, { immediate: true });
|
|
396
|
-
return /* @__PURE__ */ jsx(Show, {
|
|
430
|
+
if (props.show()) return /* @__PURE__ */ jsx(Show, {
|
|
397
431
|
when: shouldMount,
|
|
398
|
-
fallback: unmount ? null : cloneVNode(
|
|
432
|
+
fallback: unmount ? null : cloneVNode(child, {
|
|
399
433
|
ref: mergedRef,
|
|
400
|
-
style: mergeStyles(props
|
|
434
|
+
style: mergeStyles((child?.props)?.style, { display: "none" })
|
|
401
435
|
}),
|
|
402
|
-
children: cloneVNode(
|
|
436
|
+
children: cloneVNode(child, { ref: mergedRef })
|
|
403
437
|
});
|
|
438
|
+
const hiddenClass = props.leaveTo ?? props.enterFrom;
|
|
439
|
+
const hiddenStyle = props.leaveToStyle ?? props.enterStyle;
|
|
440
|
+
const childProps = child?.props ?? {};
|
|
441
|
+
const childClass = childProps.class;
|
|
442
|
+
const mergedClass = hiddenClass ? cx([childClass, hiddenClass]) : void 0;
|
|
443
|
+
const mergedStyle = mergeStyles(childProps.style, hiddenStyle);
|
|
444
|
+
const extra = { ref: mergedRef };
|
|
445
|
+
if (mergedClass !== void 0) extra.class = mergedClass;
|
|
446
|
+
if (mergedStyle !== void 0) extra.style = mergedStyle;
|
|
447
|
+
return cloneVNode(child, extra);
|
|
404
448
|
};
|
|
405
449
|
|
|
406
450
|
//#endregion
|
|
@@ -495,7 +539,8 @@ const StaggerRenderer = ({ config, htmlProps, show, appear, timeout, interval, r
|
|
|
495
539
|
const effectiveTimeout = timeout ?? config.timeout ?? 5e3;
|
|
496
540
|
const effectiveInterval = interval ?? config.interval ?? 50;
|
|
497
541
|
const effectiveReverseLeave = reverseLeave ?? config.reverseLeave ?? false;
|
|
498
|
-
const
|
|
542
|
+
const resolved = resolveChildren(children);
|
|
543
|
+
const childArray = (Array.isArray(resolved) ? resolved : [resolved]).filter(isVNode);
|
|
499
544
|
const count = childArray.length;
|
|
500
545
|
const staggeredChildren = childArray.map((child, index) => {
|
|
501
546
|
const staggerIndex = !show() && effectiveReverseLeave ? count - 1 - index : index;
|
|
@@ -531,6 +576,9 @@ const StaggerRenderer = ({ config, htmlProps, show, appear, timeout, interval, r
|
|
|
531
576
|
//#endregion
|
|
532
577
|
//#region src/kinetic/TransitionRenderer.tsx
|
|
533
578
|
const applyEnter = (el, config) => {
|
|
579
|
+
removeClasses(el, config.leave);
|
|
580
|
+
removeClasses(el, config.leaveFrom);
|
|
581
|
+
removeClasses(el, config.leaveTo);
|
|
534
582
|
addClasses(el, config.enter);
|
|
535
583
|
addClasses(el, config.enterFrom);
|
|
536
584
|
if (config.enterStyle) Object.assign(el.style, config.enterStyle);
|
|
@@ -610,7 +658,7 @@ const TransitionRenderer = (props) => {
|
|
|
610
658
|
el.style.transition = "";
|
|
611
659
|
}
|
|
612
660
|
}, { immediate: true });
|
|
613
|
-
return /* @__PURE__ */ jsx(Show, {
|
|
661
|
+
if (props.show()) return /* @__PURE__ */ jsx(Show, {
|
|
614
662
|
when: shouldMount,
|
|
615
663
|
fallback: effectiveUnmount ? null : h(props.config.tag, mergeProps(props.htmlProps, {
|
|
616
664
|
ref: mergedRef,
|
|
@@ -621,6 +669,18 @@ const TransitionRenderer = (props) => {
|
|
|
621
669
|
}), props.children),
|
|
622
670
|
children: h(props.config.tag, mergeProps(props.htmlProps, { ref: mergedRef }), props.children)
|
|
623
671
|
});
|
|
672
|
+
const hiddenClass = props.config.leaveTo ?? props.config.enterFrom;
|
|
673
|
+
const hiddenStyle = props.config.leaveToStyle ?? props.config.enterStyle;
|
|
674
|
+
const childClass = props.htmlProps.class;
|
|
675
|
+
const mergedClass = hiddenClass ? cx([childClass, hiddenClass]) : void 0;
|
|
676
|
+
const mergedStyle = hiddenStyle ? {
|
|
677
|
+
...props.htmlProps.style ?? {},
|
|
678
|
+
...hiddenStyle
|
|
679
|
+
} : void 0;
|
|
680
|
+
const extra = { ref: mergedRef };
|
|
681
|
+
if (mergedClass !== void 0) extra.class = mergedClass;
|
|
682
|
+
if (mergedStyle !== void 0) extra.style = mergedStyle;
|
|
683
|
+
return h(props.config.tag, mergeProps(props.htmlProps, extra), props.children);
|
|
624
684
|
};
|
|
625
685
|
|
|
626
686
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/kinetic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "CSS-transition-based animation components for Pyreon",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,21 +42,21 @@
|
|
|
42
42
|
"typecheck": "tsc --noEmit"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@pyreon/core": "^0.
|
|
46
|
-
"@pyreon/reactivity": "^0.
|
|
47
|
-
"@pyreon/runtime-dom": "^0.
|
|
48
|
-
"@pyreon/runtime-server": "^0.
|
|
49
|
-
"@pyreon/test-utils": "^0.13.
|
|
50
|
-
"@pyreon/typescript": "^0.
|
|
45
|
+
"@pyreon/core": "^0.23.0",
|
|
46
|
+
"@pyreon/reactivity": "^0.23.0",
|
|
47
|
+
"@pyreon/runtime-dom": "^0.23.0",
|
|
48
|
+
"@pyreon/runtime-server": "^0.23.0",
|
|
49
|
+
"@pyreon/test-utils": "^0.13.10",
|
|
50
|
+
"@pyreon/typescript": "^0.23.0",
|
|
51
51
|
"@vitest/browser-playwright": "^4.1.4",
|
|
52
|
-
"@vitus-labs/tools-rolldown": "^2.
|
|
52
|
+
"@vitus-labs/tools-rolldown": "^2.4.0"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
|
55
55
|
"node": ">= 22"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@pyreon/core": "^0.
|
|
59
|
-
"@pyreon/reactivity": "^0.
|
|
60
|
-
"@pyreon/runtime-dom": "^0.
|
|
58
|
+
"@pyreon/core": "^0.23.0",
|
|
59
|
+
"@pyreon/reactivity": "^0.23.0",
|
|
60
|
+
"@pyreon/runtime-dom": "^0.23.0"
|
|
61
61
|
}
|
|
62
62
|
}
|
package/src/Stagger.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import type { VNode } from '@pyreon/core'
|
|
|
2
2
|
import { splitProps } from '@pyreon/core'
|
|
3
3
|
import Transition from './Transition'
|
|
4
4
|
import type { CSSProperties, StaggerProps } from './types'
|
|
5
|
-
import { cloneVNode } from './utils'
|
|
5
|
+
import { cloneVNode, resolveChildren } from './utils'
|
|
6
6
|
|
|
7
7
|
const isVNode = (child: unknown): child is VNode =>
|
|
8
8
|
child != null && typeof child === 'object' && 'type' in (child as object)
|
|
@@ -22,7 +22,12 @@ const Stagger = (props: StaggerProps): VNode | null => {
|
|
|
22
22
|
const appear = own.appear ?? false
|
|
23
23
|
const timeout = own.timeout ?? 5000
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// Unwrap the compiler's `() => x` accessor wrap — see `resolveChildren`
|
|
26
|
+
// jsdoc. PR #731 fixed this on `StaggerRenderer` (the internal kinetic-
|
|
27
|
+
// mode renderer); this is the parallel fix for the top-level `<Stagger>`
|
|
28
|
+
// component, which has the same iteration shape and the same bug.
|
|
29
|
+
const resolved = resolveChildren(own.children)
|
|
30
|
+
const childArray = (Array.isArray(resolved) ? resolved : [resolved]).filter(isVNode)
|
|
26
31
|
const count = childArray.length
|
|
27
32
|
|
|
28
33
|
return (
|
package/src/Transition.tsx
CHANGED
|
@@ -5,7 +5,15 @@ import type { ClassTransitionProps, StyleTransitionProps, TransitionProps } from
|
|
|
5
5
|
import useAnimationEnd from './useAnimationEnd'
|
|
6
6
|
import { useReducedMotion } from './useReducedMotion'
|
|
7
7
|
import useTransitionState from './useTransitionState'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
addClasses,
|
|
10
|
+
cloneVNode,
|
|
11
|
+
mergeRefs,
|
|
12
|
+
mergeStyles,
|
|
13
|
+
nextFrame,
|
|
14
|
+
removeClasses,
|
|
15
|
+
resolveChildren,
|
|
16
|
+
} from './utils'
|
|
9
17
|
|
|
10
18
|
const applyEnter = (
|
|
11
19
|
el: HTMLElement,
|
|
@@ -109,8 +117,14 @@ const Transition = (props: TransitionProps): VNode | null => {
|
|
|
109
117
|
appear,
|
|
110
118
|
})
|
|
111
119
|
|
|
120
|
+
// Unwrap the compiler's `() => x` accessor wrap — see `resolveChildren`
|
|
121
|
+
// jsdoc. Parallel to `TransitionItem`'s fix (PR #731). Without this,
|
|
122
|
+
// `props.children.props` reads `function.props` (undefined), the merged
|
|
123
|
+
// ref is missing the child's own ref, and the downstream `cloneVNode`
|
|
124
|
+
// calls produce `{type: undefined}` → `<undefined>` DOM tags.
|
|
125
|
+
const child = resolveChildren(props.children) as VNode
|
|
112
126
|
const elementRef = createRef<HTMLElement>()
|
|
113
|
-
const childProps = (props
|
|
127
|
+
const childProps = (child?.props ?? {}) as Record<string, unknown>
|
|
114
128
|
const mergedRef = mergeRefs(
|
|
115
129
|
elementRef,
|
|
116
130
|
stateRef,
|
|
@@ -198,7 +212,7 @@ const Transition = (props: TransitionProps): VNode | null => {
|
|
|
198
212
|
fallback={
|
|
199
213
|
unmount
|
|
200
214
|
? null
|
|
201
|
-
: cloneVNode(
|
|
215
|
+
: cloneVNode(child, {
|
|
202
216
|
ref: mergedRef,
|
|
203
217
|
style: mergeStyles(
|
|
204
218
|
childProps.style as Record<string, string | number | undefined> | undefined,
|
|
@@ -207,7 +221,7 @@ const Transition = (props: TransitionProps): VNode | null => {
|
|
|
207
221
|
})
|
|
208
222
|
}
|
|
209
223
|
>
|
|
210
|
-
{cloneVNode(
|
|
224
|
+
{cloneVNode(child, { ref: mergedRef })}
|
|
211
225
|
</Show>
|
|
212
226
|
)
|
|
213
227
|
}
|
|
@@ -232,8 +246,18 @@ const Transition = (props: TransitionProps): VNode | null => {
|
|
|
232
246
|
// The `watch(stage)` effect above drives the enter animation when
|
|
233
247
|
// `show` flips true; `applyEnter` (above) clears these residual
|
|
234
248
|
// hidden-state classes so they don't fight `enterTo`.
|
|
249
|
+
// Picker mirrors what #719 introduced for the kinetic(tag).<mode>
|
|
250
|
+
// renderers (TransitionRenderer / TransitionItem / CollapseRenderer):
|
|
251
|
+
// prefer leave-end state, fall back to pre-enter state. The
|
|
252
|
+
// `enterStyle` fallback covers the preset path — `@pyreon/kinetic-presets`
|
|
253
|
+
// factories (fadeUp, blurInUp, slideLeft, …) populate `enterStyle` as
|
|
254
|
+
// the hidden state but may not set `leaveToStyle`. Without this
|
|
255
|
+
// fallback, preset users SSR-render VISIBLE → flash-on-hydration.
|
|
256
|
+
// (PR #717 shipped this branch with `leaveToStyle` alone; the class
|
|
257
|
+
// picker already had the `enterFrom` fallback. This commit aligns the
|
|
258
|
+
// style picker so both halves match.)
|
|
235
259
|
const hiddenClass = props.leaveTo ?? props.enterFrom
|
|
236
|
-
const hiddenStyle = props.leaveToStyle
|
|
260
|
+
const hiddenStyle = props.leaveToStyle ?? props.enterStyle
|
|
237
261
|
const childClass = childProps.class
|
|
238
262
|
const mergedClass = hiddenClass
|
|
239
263
|
? cx([childClass as Parameters<typeof cx>[0], hiddenClass])
|
|
@@ -250,7 +274,7 @@ const Transition = (props: TransitionProps): VNode | null => {
|
|
|
250
274
|
if (mergedClass !== undefined) extra.class = mergedClass
|
|
251
275
|
if (mergedStyle !== undefined) extra.style = mergedStyle
|
|
252
276
|
|
|
253
|
-
return cloneVNode(
|
|
277
|
+
return cloneVNode(child, extra)
|
|
254
278
|
}
|
|
255
279
|
|
|
256
280
|
export default Transition
|