@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/kinetic",
3
- "version": "0.24.4",
3
+ "version": "0.24.6",
4
4
  "description": "CSS-transition-based animation components for Pyreon",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,8 +13,7 @@
13
13
  "!lib/**/*.map",
14
14
  "!lib/analysis",
15
15
  "README.md",
16
- "LICENSE",
17
- "src"
16
+ "LICENSE"
18
17
  ],
19
18
  "type": "module",
20
19
  "sideEffects": false,
@@ -22,7 +21,6 @@
22
21
  "types": "./lib/index.d.ts",
23
22
  "exports": {
24
23
  ".": {
25
- "bun": "./src/index.ts",
26
24
  "import": "./lib/index.js",
27
25
  "types": "./lib/index.d.ts"
28
26
  }
@@ -42,12 +40,12 @@
42
40
  "typecheck": "tsc --noEmit"
43
41
  },
44
42
  "devDependencies": {
45
- "@pyreon/core": "^0.24.4",
46
- "@pyreon/reactivity": "^0.24.4",
47
- "@pyreon/runtime-dom": "^0.24.4",
48
- "@pyreon/runtime-server": "^0.24.4",
43
+ "@pyreon/core": "^0.24.6",
44
+ "@pyreon/reactivity": "^0.24.6",
45
+ "@pyreon/runtime-dom": "^0.24.6",
46
+ "@pyreon/runtime-server": "^0.24.6",
49
47
  "@pyreon/test-utils": "^0.13.11",
50
- "@pyreon/typescript": "^0.24.4",
48
+ "@pyreon/typescript": "^0.24.6",
51
49
  "@vitest/browser-playwright": "^4.1.4",
52
50
  "@vitus-labs/tools-rolldown": "^2.4.0"
53
51
  },
@@ -55,8 +53,8 @@
55
53
  "node": ">= 22"
56
54
  },
57
55
  "dependencies": {
58
- "@pyreon/core": "^0.24.4",
59
- "@pyreon/reactivity": "^0.24.4",
60
- "@pyreon/runtime-dom": "^0.24.4"
56
+ "@pyreon/core": "^0.24.6",
57
+ "@pyreon/reactivity": "^0.24.6",
58
+ "@pyreon/runtime-dom": "^0.24.6"
61
59
  }
62
60
  }
package/src/Collapse.tsx DELETED
@@ -1,166 +0,0 @@
1
- import type { VNode } from '@pyreon/core'
2
- import { createRef, Show } from '@pyreon/core'
3
- import { runUntracked, signal, watch } from '@pyreon/reactivity'
4
- import type { CollapseProps, TransitionStage } from './types'
5
- import useAnimationEnd from './useAnimationEnd'
6
- import { useReducedMotion } from './useReducedMotion'
7
-
8
- const Collapse = (props: CollapseProps): VNode | null => {
9
- const transition = props.transition ?? 'height 300ms ease'
10
- const appear = props.appear ?? false
11
- const timeout = props.timeout ?? 5000
12
-
13
- const reducedMotion = useReducedMotion()
14
- let wrapperRef: { current: HTMLDivElement | null } = createRef<HTMLDivElement>()
15
- const contentRef = createRef<HTMLDivElement>()
16
-
17
- const callbacks = {
18
- onEnter: props.onEnter,
19
- onAfterEnter: props.onAfterEnter,
20
- onLeave: props.onLeave,
21
- onAfterLeave: props.onAfterLeave,
22
- }
23
-
24
- const initialShow = props.show()
25
- // When appear=true and show starts true, mount but defer animation until ref is wired
26
- const needsAppear = appear && initialShow
27
- const stage = signal<TransitionStage>(initialShow ? 'entered' : 'hidden')
28
- let isInitialMount = true
29
- let appearTriggered = false
30
-
31
- // Intercept ref assignment to detect when element connects and trigger appear.
32
- // Uses queueMicrotask so all sibling refs are wired before the animation starts.
33
- if (needsAppear) {
34
- const orig = wrapperRef
35
- const proxy = { current: null as HTMLDivElement | null }
36
- Object.defineProperty(proxy, 'current', {
37
- get() {
38
- return orig.current
39
- },
40
- set(node: HTMLDivElement | null) {
41
- orig.current = node
42
- if (node && !appearTriggered) {
43
- appearTriggered = true
44
- queueMicrotask(() => stage.set('entering'))
45
- }
46
- },
47
- })
48
- wrapperRef = proxy
49
- }
50
-
51
- // State machine transitions
52
- watch(
53
- props.show,
54
- (showVal) => {
55
- if (isInitialMount) {
56
- isInitialMount = false
57
- // appear case is handled by wrapperRefCallback above
58
- return
59
- }
60
-
61
- const currentStage = runUntracked(() => stage())
62
- if (showVal && (currentStage === 'hidden' || currentStage === 'leaving')) {
63
- stage.set('entering')
64
- } else if (!showVal && (currentStage === 'entered' || currentStage === 'entering')) {
65
- stage.set('leaving')
66
- }
67
- },
68
- { immediate: true },
69
- )
70
-
71
- // Animate height
72
- watch(
73
- () => stage(),
74
- (currentStage) => {
75
- const wrapper = wrapperRef.current
76
- const content = contentRef.current
77
- if (!wrapper || !content) return
78
-
79
- if (reducedMotion()) {
80
- if (currentStage === 'entering') {
81
- callbacks.onEnter?.()
82
- wrapper.style.height = 'auto'
83
- wrapper.style.overflow = ''
84
- callbacks.onAfterEnter?.()
85
- stage.set('entered')
86
- } else if (currentStage === 'leaving') {
87
- callbacks.onLeave?.()
88
- wrapper.style.height = '0px'
89
- wrapper.style.overflow = 'hidden'
90
- callbacks.onAfterLeave?.()
91
- stage.set('hidden')
92
- }
93
- return
94
- }
95
-
96
- if (currentStage === 'entering') {
97
- callbacks.onEnter?.()
98
- const height = content.scrollHeight
99
- wrapper.style.transition = 'none'
100
- wrapper.style.height = '0px'
101
- wrapper.style.overflow = 'hidden'
102
- // Force reflow so the browser registers height: 0
103
- void wrapper.offsetHeight
104
- wrapper.style.transition = transition
105
- wrapper.style.height = `${height}px`
106
- }
107
-
108
- if (currentStage === 'leaving') {
109
- callbacks.onLeave?.()
110
- const height = content.scrollHeight
111
- wrapper.style.transition = 'none'
112
- wrapper.style.height = `${height}px`
113
- wrapper.style.overflow = 'hidden'
114
- // Force reflow
115
- void wrapper.offsetHeight
116
- wrapper.style.transition = transition
117
- wrapper.style.height = '0px'
118
- }
119
- },
120
- { immediate: true },
121
- )
122
-
123
- // Listen for animation end
124
- useAnimationEnd({
125
- ref: wrapperRef,
126
- active: () => (stage() === 'entering' || stage() === 'leaving') && !reducedMotion(),
127
- timeout,
128
- onEnd: () => {
129
- const wrapper = wrapperRef.current
130
- if (stage() === 'entering') {
131
- if (wrapper) {
132
- wrapper.style.height = 'auto'
133
- wrapper.style.overflow = ''
134
- wrapper.style.transition = ''
135
- }
136
- callbacks.onAfterEnter?.()
137
- stage.set('entered')
138
- } else if (stage() === 'leaving') {
139
- callbacks.onAfterLeave?.()
140
- stage.set('hidden')
141
- }
142
- },
143
- })
144
-
145
- const shouldRender = () => stage() !== 'hidden'
146
-
147
- return (
148
- <div
149
- ref={wrapperRef}
150
- style={{
151
- ...(stage() !== 'entered' ? { overflow: 'hidden' } : {}),
152
- ...(stage() === 'hidden'
153
- ? { height: '0px' }
154
- : stage() === 'entered'
155
- ? { height: 'auto' }
156
- : {}),
157
- }}
158
- >
159
- <Show when={shouldRender}>
160
- <div ref={contentRef}>{props.children}</div>
161
- </Show>
162
- </div>
163
- )
164
- }
165
-
166
- export default Collapse
package/src/Stagger.tsx DELETED
@@ -1,63 +0,0 @@
1
- import type { VNode } from '@pyreon/core'
2
- import { splitProps } from '@pyreon/core'
3
- import Transition from './Transition'
4
- import type { CSSProperties, StaggerProps } from './types'
5
- import { cloneVNode, resolveChildren } from './utils'
6
-
7
- const isVNode = (child: unknown): child is VNode =>
8
- child != null && typeof child === 'object' && 'type' in (child as object)
9
-
10
- const Stagger = (props: StaggerProps): VNode | null => {
11
- const [own, transitionProps] = splitProps(props, [
12
- 'show',
13
- 'interval',
14
- 'reverseLeave',
15
- 'appear',
16
- 'timeout',
17
- 'children',
18
- 'onAfterLeave',
19
- ])
20
- const interval = own.interval ?? 50
21
- const reverseLeave = own.reverseLeave ?? false
22
- const appear = own.appear ?? false
23
- const timeout = own.timeout ?? 5000
24
-
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)
31
- const count = childArray.length
32
-
33
- return (
34
- <>
35
- {childArray.map((child, index) => {
36
- const staggerIndex = !own.show() && reverseLeave ? count - 1 - index : index
37
- const delay = staggerIndex * interval
38
-
39
- return (
40
- <Transition
41
- key={(child as VNode & { key?: string | number }).key ?? index}
42
- show={own.show}
43
- appear={appear}
44
- timeout={timeout + delay}
45
- {...transitionProps}
46
- onAfterLeave={index === (reverseLeave ? 0 : count - 1) ? own.onAfterLeave : undefined}
47
- >
48
- {cloneVNode(child, {
49
- style: {
50
- ...((child.props as Record<string, unknown>)?.style as CSSProperties | undefined),
51
- '--stagger-index': staggerIndex,
52
- '--stagger-interval': `${interval}ms`,
53
- transitionDelay: `${delay}ms`,
54
- } as CSSProperties,
55
- })}
56
- </Transition>
57
- )
58
- })}
59
- </>
60
- )
61
- }
62
-
63
- export default Stagger
@@ -1,280 +0,0 @@
1
- import type { VNode } from '@pyreon/core'
2
- import { createRef, cx, Show } from '@pyreon/core'
3
- import { watch } from '@pyreon/reactivity'
4
- import type { ClassTransitionProps, StyleTransitionProps, TransitionProps } from './types'
5
- import useAnimationEnd from './useAnimationEnd'
6
- import { useReducedMotion } from './useReducedMotion'
7
- import useTransitionState from './useTransitionState'
8
- import {
9
- addClasses,
10
- cloneVNode,
11
- mergeRefs,
12
- mergeStyles,
13
- nextFrame,
14
- removeClasses,
15
- resolveChildren,
16
- } from './utils'
17
-
18
- const applyEnter = (
19
- el: HTMLElement,
20
- {
21
- enter,
22
- enterFrom,
23
- enterTo,
24
- enterStyle,
25
- enterToStyle,
26
- enterTransition,
27
- leave,
28
- leaveFrom,
29
- leaveTo,
30
- }: ClassTransitionProps & StyleTransitionProps,
31
- ) => {
32
- // Symmetric to applyLeave's `removeClasses(enter)` / `removeClasses(enterTo)`:
33
- // clear any residual leave-cycle classes — including the `leaveTo` /
34
- // `enterFrom` class the SSR / initial-hidden render path inlines for
35
- // ecosystem-correct structural content (see the `wasInitiallyShown`
36
- // branch below). Without this, the SSR-baked hidden-state class would
37
- // compete with `enterTo`'s CSS rules and the enter animation would
38
- // visually fight itself.
39
- removeClasses(el, leave)
40
- removeClasses(el, leaveFrom)
41
- removeClasses(el, leaveTo)
42
-
43
- addClasses(el, enter)
44
- addClasses(el, enterFrom)
45
- if (enterStyle) Object.assign(el.style, enterStyle)
46
- if (enterTransition) el.style.transition = enterTransition
47
-
48
- return nextFrame(() => {
49
- removeClasses(el, enterFrom)
50
- addClasses(el, enterTo)
51
- if (enterToStyle) Object.assign(el.style, enterToStyle)
52
- })
53
- }
54
-
55
- const applyLeave = (
56
- el: HTMLElement,
57
- {
58
- enter,
59
- enterTo,
60
- leave,
61
- leaveFrom,
62
- leaveTo,
63
- leaveStyle,
64
- leaveToStyle,
65
- leaveTransition,
66
- }: ClassTransitionProps & StyleTransitionProps,
67
- ) => {
68
- removeClasses(el, enter)
69
- removeClasses(el, enterTo)
70
-
71
- addClasses(el, leave)
72
- addClasses(el, leaveFrom)
73
- if (leaveStyle) Object.assign(el.style, leaveStyle)
74
- if (leaveTransition) el.style.transition = leaveTransition
75
-
76
- return nextFrame(() => {
77
- removeClasses(el, leaveFrom)
78
- addClasses(el, leaveTo)
79
- if (leaveToStyle) Object.assign(el.style, leaveToStyle)
80
- })
81
- }
82
-
83
- const applyReducedMotion = (
84
- stage: string,
85
- callbacks: {
86
- onEnter?: (() => void) | undefined
87
- onAfterEnter?: (() => void) | undefined
88
- onLeave?: (() => void) | undefined
89
- onAfterLeave?: (() => void) | undefined
90
- },
91
- complete: () => void,
92
- ) => {
93
- if (stage === 'entering') {
94
- callbacks.onEnter?.()
95
- callbacks.onAfterEnter?.()
96
- complete()
97
- } else if (stage === 'leaving') {
98
- callbacks.onLeave?.()
99
- callbacks.onAfterLeave?.()
100
- complete()
101
- }
102
- }
103
-
104
- const Transition = (props: TransitionProps): VNode | null => {
105
- const appear = props.appear ?? false
106
- const unmount = props.unmount ?? true
107
- const timeout = props.timeout ?? 5000
108
-
109
- const reducedMotion = useReducedMotion()
110
- const {
111
- stage,
112
- ref: stateRef,
113
- shouldMount,
114
- complete,
115
- } = useTransitionState({
116
- show: props.show,
117
- appear,
118
- })
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
126
- const elementRef = createRef<HTMLElement>()
127
- const childProps = (child?.props ?? {}) as Record<string, unknown>
128
- const mergedRef = mergeRefs(
129
- elementRef,
130
- stateRef,
131
- childProps.ref as ((el: HTMLElement | null) => void) | undefined,
132
- )
133
-
134
- const callbacks = {
135
- onEnter: props.onEnter,
136
- onAfterEnter: props.onAfterEnter,
137
- onLeave: props.onLeave,
138
- onAfterLeave: props.onAfterLeave,
139
- }
140
-
141
- const transitionConfig = {
142
- enter: props.enter,
143
- enterFrom: props.enterFrom,
144
- enterTo: props.enterTo,
145
- leave: props.leave,
146
- leaveFrom: props.leaveFrom,
147
- leaveTo: props.leaveTo,
148
- enterStyle: props.enterStyle,
149
- enterToStyle: props.enterToStyle,
150
- enterTransition: props.enterTransition,
151
- leaveStyle: props.leaveStyle,
152
- leaveToStyle: props.leaveToStyle,
153
- leaveTransition: props.leaveTransition,
154
- }
155
-
156
- useAnimationEnd({
157
- ref: elementRef,
158
- active: () => (stage() === 'entering' || stage() === 'leaving') && !reducedMotion(),
159
- timeout,
160
- onEnd: () => {
161
- if (stage() === 'entering') {
162
- callbacks.onAfterEnter?.()
163
- } else if (stage() === 'leaving') {
164
- callbacks.onAfterLeave?.()
165
- }
166
- complete()
167
- },
168
- })
169
-
170
- watch(
171
- () => stage(),
172
- (currentStage) => {
173
- const el = elementRef.current
174
- if (!el) return
175
-
176
- if (reducedMotion()) {
177
- applyReducedMotion(currentStage, callbacks, complete)
178
- return
179
- }
180
-
181
- if (currentStage === 'entering') {
182
- callbacks.onEnter?.()
183
- const frameId = applyEnter(el, transitionConfig)
184
- return () => cancelAnimationFrame(frameId)
185
- }
186
-
187
- if (currentStage === 'leaving') {
188
- callbacks.onLeave?.()
189
- const frameId = applyLeave(el, transitionConfig)
190
- return () => cancelAnimationFrame(frameId)
191
- }
192
-
193
- if (currentStage === 'entered') {
194
- removeClasses(el, props.enter)
195
- el.style.transition = ''
196
- }
197
- },
198
- { immediate: true },
199
- )
200
-
201
- // Initially-visible Transitions keep the original Show-gated mount,
202
- // which preserves the documented runtime-unmount semantic for the
203
- // visible → hidden transition (modal close, dropdown collapse, etc.).
204
- // The SSR bug (children dropped from prerendered HTML) only fires for
205
- // the initially-HIDDEN case below, because `<Show when={false}>`
206
- // renders `null` on the server.
207
- const wasInitiallyShown = props.show()
208
- if (wasInitiallyShown) {
209
- return (
210
- <Show
211
- when={shouldMount}
212
- fallback={
213
- unmount
214
- ? null
215
- : cloneVNode(child, {
216
- ref: mergedRef,
217
- style: mergeStyles(
218
- childProps.style as Record<string, string | number | undefined> | undefined,
219
- { display: 'none' },
220
- ),
221
- })
222
- }
223
- >
224
- {cloneVNode(child, { ref: mergedRef })}
225
- </Show>
226
- )
227
- }
228
-
229
- // Initially-hidden path — ecosystem-correct (Framer Motion / react-
230
- // transition-group / react-spring all render children in SSR regardless
231
- // of animation state; visual hiding is class/style only). Always emits
232
- // children so SSG / SEO / social scrapers / no-JS users see the
233
- // structural content. The hidden visual is supplied by `leaveTo`
234
- // (explicit hidden-end state) or `enterFrom` (pre-enter state — covers
235
- // the scroll-reveal pattern that only configures the enter side).
236
- //
237
- // Trade-off: for an initially-hidden Transition, `unmount: true` no
238
- // longer triggers a true DOM removal after a later leave animation
239
- // completes — the element stays in DOM with the leave-to class
240
- // applied. Initially-visible Transitions keep the unmount semantic
241
- // (the branch above). This matches Framer Motion / react-transition-
242
- // group conventions and is the price of SSR correctness; the rare
243
- // user who needs true unmount on a started-hidden element can drive
244
- // mount/unmount themselves outside `<Transition>`.
245
- //
246
- // The `watch(stage)` effect above drives the enter animation when
247
- // `show` flips true; `applyEnter` (above) clears these residual
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.)
259
- const hiddenClass = props.leaveTo ?? props.enterFrom
260
- const hiddenStyle = props.leaveToStyle ?? props.enterStyle
261
- const childClass = childProps.class
262
- const mergedClass = hiddenClass
263
- ? cx([childClass as Parameters<typeof cx>[0], hiddenClass])
264
- : undefined
265
- const mergedStyle = mergeStyles(
266
- childProps.style as Record<string, string | number | undefined> | undefined,
267
- hiddenStyle,
268
- )
269
-
270
- // Build extra-props carefully — undefined values must NOT be passed to
271
- // cloneVNode because `{...vnode.props, ...extraProps}` spreads them and
272
- // overrides any user-set `class`/`style` on the child vnode with undefined.
273
- const extra: Record<string, unknown> = { ref: mergedRef }
274
- if (mergedClass !== undefined) extra.class = mergedClass
275
- if (mergedStyle !== undefined) extra.style = mergedStyle
276
-
277
- return cloneVNode(child, extra)
278
- }
279
-
280
- export default Transition
@@ -1,139 +0,0 @@
1
- import type { VNode } from '@pyreon/core'
2
- import { splitProps } from '@pyreon/core'
3
- import { signal } from '@pyreon/reactivity'
4
- import Transition from './Transition'
5
- import type { ClassTransitionProps, StyleTransitionProps, TransitionCallbacks } from './types'
6
-
7
- export type TransitionGroupProps = ClassTransitionProps &
8
- StyleTransitionProps &
9
- TransitionCallbacks & {
10
- appear?: boolean | undefined
11
- timeout?: number | undefined
12
- /**
13
- * Children can be a static array OR a reactive accessor `() => VNode[]`.
14
- * When passed as an accessor, TransitionGroup tracks changes and
15
- * animates entering/leaving children automatically.
16
- */
17
- children: VNode[] | (() => VNode[])
18
- }
19
-
20
- type KeyedChild = { key: string | number; element: VNode }
21
-
22
- const isVNode = (child: unknown): child is VNode =>
23
- child != null && typeof child === 'object' && 'type' in (child as object)
24
-
25
- const getKeyedChildren = (children: VNode[]): KeyedChild[] => {
26
- const result: KeyedChild[] = []
27
- for (const child of children) {
28
- if (isVNode(child)) {
29
- const key = (child as VNode & { key?: string | number }).key
30
- if (key != null) {
31
- result.push({ key, element: child })
32
- }
33
- }
34
- }
35
- return result
36
- }
37
-
38
- /**
39
- * Renders children with key-based enter/exit animations.
40
- *
41
- * In Pyreon, components run once. For TransitionGroup to detect children
42
- * changes, pass children as a reactive accessor: `() => VNode[]`.
43
- * The component uses a reactive accessor internally to diff previous vs
44
- * current children and animate entries/exits.
45
- */
46
- const TransitionGroup = (props: TransitionGroupProps): VNode | null => {
47
- const [own, transitionProps] = splitProps(props, [
48
- 'children',
49
- 'appear',
50
- 'timeout',
51
- 'onAfterLeave',
52
- ])
53
- const appear = own.appear ?? false
54
- const prevMap = new Map<string | number, VNode>()
55
- const leavingMap = new Map<string | number, VNode>()
56
- const forceUpdate = signal(0)
57
-
58
- // Normalize children to an accessor for uniform handling
59
- const getChildren =
60
- typeof own.children === 'function'
61
- ? (own.children as () => VNode[])
62
- : () => own.children as VNode[]
63
-
64
- // Track initial keys for appear animation logic
65
- const initialKeyed = getKeyedChildren(getChildren())
66
- const initialKeys = new Set(initialKeyed.map((c) => c.key))
67
- for (const { key, element } of initialKeyed) {
68
- prevMap.set(key, element)
69
- }
70
-
71
- const handleAfterLeave = (key: string | number) => {
72
- leavingMap.delete(key)
73
- own.onAfterLeave?.()
74
- forceUpdate.update((c) => c + 1)
75
- }
76
-
77
- // Reactive accessor — re-evaluates when children() or forceUpdate changes.
78
- // The runtime mounts this via mountReactive + effect, creating a
79
- // reactive scope that tracks signal reads.
80
- return (() => {
81
- // Read forceUpdate to re-evaluate when leaving children finish
82
- forceUpdate()
83
-
84
- const currentChildren = getChildren()
85
- const currentKeyed = getKeyedChildren(currentChildren)
86
- const currentMap = new Map<string | number, VNode>()
87
- for (const { key, element } of currentKeyed) {
88
- currentMap.set(key, element)
89
- }
90
-
91
- // Detect leaving children (were in prev but not in current)
92
- for (const [key, child] of prevMap) {
93
- if (!currentMap.has(key) && !leavingMap.has(key)) {
94
- leavingMap.set(key, child)
95
- }
96
- }
97
-
98
- // If a leaving child reappears, cancel the leave
99
- for (const key of currentMap.keys()) {
100
- leavingMap.delete(key)
101
- }
102
-
103
- // Update prev for next diff
104
- prevMap.clear()
105
- for (const [key, element] of currentMap) {
106
- prevMap.set(key, element)
107
- }
108
-
109
- // Merge current + leaving, preserving insertion order
110
- const allEntries: KeyedChild[] = [...currentKeyed]
111
- for (const [key, element] of leavingMap) {
112
- allEntries.push({ key, element })
113
- }
114
-
115
- return (
116
- <>
117
- {allEntries.map(({ key, element }) => {
118
- const isInitial = initialKeys.has(key)
119
- const isShowing = currentMap.has(key)
120
-
121
- return (
122
- <Transition
123
- key={key}
124
- show={() => isShowing}
125
- appear={isInitial ? appear : true}
126
- timeout={own.timeout}
127
- {...transitionProps}
128
- onAfterLeave={() => handleAfterLeave(key)}
129
- >
130
- {element}
131
- </Transition>
132
- )
133
- })}
134
- </>
135
- )
136
- }) as unknown as VNode
137
- }
138
-
139
- export default TransitionGroup