@pyreon/kinetic 0.11.1 → 0.11.2
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 +6 -5
- package/src/Collapse.tsx +172 -0
- package/src/Stagger.tsx +52 -0
- package/src/Transition.tsx +214 -0
- package/src/TransitionGroup.tsx +108 -0
- package/src/__tests__/Collapse.test.tsx +782 -0
- package/src/__tests__/GroupRenderer.test.tsx +388 -0
- package/src/__tests__/StaggerRenderer.test.tsx +526 -0
- package/src/__tests__/Transition.test.tsx +406 -0
- package/src/__tests__/TransitionItem.test.tsx +522 -0
- package/src/__tests__/kinetic.test.tsx +562 -0
- package/src/__tests__/presets.test.ts +46 -0
- package/src/__tests__/useAnimationEnd.test.ts +194 -0
- package/src/__tests__/useReducedMotion.test.ts +157 -0
- package/src/__tests__/useTransitionState.test.ts +132 -0
- package/src/__tests__/utils.test.ts +139 -0
- package/src/index.ts +23 -0
- package/src/jsx-augment.d.ts +12 -0
- package/src/kinetic/CollapseRenderer.tsx +178 -0
- package/src/kinetic/GroupRenderer.tsx +124 -0
- package/src/kinetic/StaggerRenderer.tsx +88 -0
- package/src/kinetic/TransitionItem.tsx +196 -0
- package/src/kinetic/TransitionRenderer.tsx +168 -0
- package/src/kinetic/createKineticComponent.tsx +211 -0
- package/src/kinetic/types.ts +149 -0
- package/src/kinetic.ts +25 -0
- package/src/presets.ts +66 -0
- package/src/types.ts +118 -0
- package/src/useAnimationEnd.ts +59 -0
- package/src/useReducedMotion.ts +28 -0
- package/src/useTransitionState.ts +62 -0
- package/src/utils.ts +81 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/kinetic",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.2",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/pyreon/pyreon",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"!lib/**/*.map",
|
|
25
25
|
"!lib/analysis",
|
|
26
26
|
"README.md",
|
|
27
|
-
"LICENSE"
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"src"
|
|
28
29
|
],
|
|
29
30
|
"engines": {
|
|
30
31
|
"node": ">= 22"
|
|
@@ -43,11 +44,11 @@
|
|
|
43
44
|
"typecheck": "tsc --noEmit"
|
|
44
45
|
},
|
|
45
46
|
"peerDependencies": {
|
|
46
|
-
"@pyreon/core": "^0.11.
|
|
47
|
-
"@pyreon/reactivity": "^0.11.
|
|
47
|
+
"@pyreon/core": "^0.11.2",
|
|
48
|
+
"@pyreon/reactivity": "^0.11.2"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"@vitus-labs/tools-rolldown": "^1.15.3",
|
|
51
|
-
"@pyreon/typescript": "^0.11.
|
|
52
|
+
"@pyreon/typescript": "^0.11.2"
|
|
52
53
|
}
|
|
53
54
|
}
|
package/src/Collapse.tsx
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
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 = ({
|
|
9
|
+
show,
|
|
10
|
+
transition = "height 300ms ease",
|
|
11
|
+
appear = false,
|
|
12
|
+
timeout = 5000,
|
|
13
|
+
onEnter,
|
|
14
|
+
onAfterEnter,
|
|
15
|
+
onLeave,
|
|
16
|
+
onAfterLeave,
|
|
17
|
+
children,
|
|
18
|
+
}: CollapseProps): VNode | null => {
|
|
19
|
+
const reducedMotion = useReducedMotion()
|
|
20
|
+
let wrapperRef: { current: HTMLDivElement | null } = createRef<HTMLDivElement>()
|
|
21
|
+
const contentRef = createRef<HTMLDivElement>()
|
|
22
|
+
|
|
23
|
+
const callbacks = {
|
|
24
|
+
onEnter,
|
|
25
|
+
onAfterEnter,
|
|
26
|
+
onLeave,
|
|
27
|
+
onAfterLeave,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const initialShow = show()
|
|
31
|
+
// When appear=true and show starts true, mount but defer animation until ref is wired
|
|
32
|
+
const needsAppear = appear && initialShow
|
|
33
|
+
const stage = signal<TransitionStage>(initialShow ? "entered" : "hidden")
|
|
34
|
+
let isInitialMount = true
|
|
35
|
+
let appearTriggered = false
|
|
36
|
+
|
|
37
|
+
// Intercept ref assignment to detect when element connects and trigger appear.
|
|
38
|
+
// Uses queueMicrotask so all sibling refs are wired before the animation starts.
|
|
39
|
+
if (needsAppear) {
|
|
40
|
+
const orig = wrapperRef
|
|
41
|
+
const proxy = { current: null as HTMLDivElement | null }
|
|
42
|
+
Object.defineProperty(proxy, "current", {
|
|
43
|
+
get() {
|
|
44
|
+
return orig.current
|
|
45
|
+
},
|
|
46
|
+
set(node: HTMLDivElement | null) {
|
|
47
|
+
orig.current = node
|
|
48
|
+
if (node && !appearTriggered) {
|
|
49
|
+
appearTriggered = true
|
|
50
|
+
queueMicrotask(() => stage.set("entering"))
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
wrapperRef = proxy
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// State machine transitions
|
|
58
|
+
watch(
|
|
59
|
+
show,
|
|
60
|
+
(showVal) => {
|
|
61
|
+
if (isInitialMount) {
|
|
62
|
+
isInitialMount = false
|
|
63
|
+
// appear case is handled by wrapperRefCallback above
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const currentStage = runUntracked(() => stage())
|
|
68
|
+
if (showVal && (currentStage === "hidden" || currentStage === "leaving")) {
|
|
69
|
+
stage.set("entering")
|
|
70
|
+
} else if (!showVal && (currentStage === "entered" || currentStage === "entering")) {
|
|
71
|
+
stage.set("leaving")
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{ immediate: true },
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
// Animate height
|
|
78
|
+
watch(
|
|
79
|
+
() => stage(),
|
|
80
|
+
(currentStage) => {
|
|
81
|
+
const wrapper = wrapperRef.current
|
|
82
|
+
const content = contentRef.current
|
|
83
|
+
if (!wrapper || !content) return
|
|
84
|
+
|
|
85
|
+
if (reducedMotion()) {
|
|
86
|
+
if (currentStage === "entering") {
|
|
87
|
+
callbacks.onEnter?.()
|
|
88
|
+
wrapper.style.height = "auto"
|
|
89
|
+
wrapper.style.overflow = ""
|
|
90
|
+
callbacks.onAfterEnter?.()
|
|
91
|
+
stage.set("entered")
|
|
92
|
+
} else if (currentStage === "leaving") {
|
|
93
|
+
callbacks.onLeave?.()
|
|
94
|
+
wrapper.style.height = "0px"
|
|
95
|
+
wrapper.style.overflow = "hidden"
|
|
96
|
+
callbacks.onAfterLeave?.()
|
|
97
|
+
stage.set("hidden")
|
|
98
|
+
}
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (currentStage === "entering") {
|
|
103
|
+
callbacks.onEnter?.()
|
|
104
|
+
const height = content.scrollHeight
|
|
105
|
+
wrapper.style.transition = "none"
|
|
106
|
+
wrapper.style.height = "0px"
|
|
107
|
+
wrapper.style.overflow = "hidden"
|
|
108
|
+
// Force reflow so the browser registers height: 0
|
|
109
|
+
void wrapper.offsetHeight
|
|
110
|
+
wrapper.style.transition = transition
|
|
111
|
+
wrapper.style.height = `${height}px`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (currentStage === "leaving") {
|
|
115
|
+
callbacks.onLeave?.()
|
|
116
|
+
const height = content.scrollHeight
|
|
117
|
+
wrapper.style.transition = "none"
|
|
118
|
+
wrapper.style.height = `${height}px`
|
|
119
|
+
wrapper.style.overflow = "hidden"
|
|
120
|
+
// Force reflow
|
|
121
|
+
void wrapper.offsetHeight
|
|
122
|
+
wrapper.style.transition = transition
|
|
123
|
+
wrapper.style.height = "0px"
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
{ immediate: true },
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
// Listen for animation end
|
|
130
|
+
useAnimationEnd({
|
|
131
|
+
ref: wrapperRef,
|
|
132
|
+
active: () => (stage() === "entering" || stage() === "leaving") && !reducedMotion(),
|
|
133
|
+
timeout,
|
|
134
|
+
onEnd: () => {
|
|
135
|
+
const wrapper = wrapperRef.current
|
|
136
|
+
if (stage() === "entering") {
|
|
137
|
+
if (wrapper) {
|
|
138
|
+
wrapper.style.height = "auto"
|
|
139
|
+
wrapper.style.overflow = ""
|
|
140
|
+
wrapper.style.transition = ""
|
|
141
|
+
}
|
|
142
|
+
callbacks.onAfterEnter?.()
|
|
143
|
+
stage.set("entered")
|
|
144
|
+
} else if (stage() === "leaving") {
|
|
145
|
+
callbacks.onAfterLeave?.()
|
|
146
|
+
stage.set("hidden")
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const shouldRender = () => stage() !== "hidden"
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div
|
|
155
|
+
ref={wrapperRef}
|
|
156
|
+
style={{
|
|
157
|
+
...(stage() !== "entered" ? { overflow: "hidden" } : {}),
|
|
158
|
+
...(stage() === "hidden"
|
|
159
|
+
? { height: "0px" }
|
|
160
|
+
: stage() === "entered"
|
|
161
|
+
? { height: "auto" }
|
|
162
|
+
: {}),
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<Show when={shouldRender}>
|
|
166
|
+
<div ref={contentRef}>{children}</div>
|
|
167
|
+
</Show>
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default Collapse
|
package/src/Stagger.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { VNode } from "@pyreon/core"
|
|
2
|
+
import Transition from "./Transition"
|
|
3
|
+
import type { CSSProperties, StaggerProps } from "./types"
|
|
4
|
+
import { cloneVNode } from "./utils"
|
|
5
|
+
|
|
6
|
+
const isVNode = (child: unknown): child is VNode =>
|
|
7
|
+
child != null && typeof child === "object" && "type" in (child as object)
|
|
8
|
+
|
|
9
|
+
const Stagger = ({
|
|
10
|
+
show,
|
|
11
|
+
interval = 50,
|
|
12
|
+
reverseLeave = false,
|
|
13
|
+
appear = false,
|
|
14
|
+
timeout = 5000,
|
|
15
|
+
children,
|
|
16
|
+
onAfterLeave,
|
|
17
|
+
...transitionProps
|
|
18
|
+
}: StaggerProps): VNode | null => {
|
|
19
|
+
const childArray = (Array.isArray(children) ? children : [children]).filter(isVNode)
|
|
20
|
+
const count = childArray.length
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
{childArray.map((child, index) => {
|
|
25
|
+
const staggerIndex = !show() && reverseLeave ? count - 1 - index : index
|
|
26
|
+
const delay = staggerIndex * interval
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Transition
|
|
30
|
+
key={(child as VNode & { key?: string | number }).key ?? index}
|
|
31
|
+
show={show}
|
|
32
|
+
appear={appear}
|
|
33
|
+
timeout={timeout + delay}
|
|
34
|
+
{...transitionProps}
|
|
35
|
+
onAfterLeave={index === (reverseLeave ? 0 : count - 1) ? onAfterLeave : undefined}
|
|
36
|
+
>
|
|
37
|
+
{cloneVNode(child, {
|
|
38
|
+
style: {
|
|
39
|
+
...((child.props as Record<string, unknown>)?.style as CSSProperties | undefined),
|
|
40
|
+
"--stagger-index": staggerIndex,
|
|
41
|
+
"--stagger-interval": `${interval}ms`,
|
|
42
|
+
transitionDelay: `${delay}ms`,
|
|
43
|
+
} as CSSProperties,
|
|
44
|
+
})}
|
|
45
|
+
</Transition>
|
|
46
|
+
)
|
|
47
|
+
})}
|
|
48
|
+
</>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default Stagger
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { VNode } from "@pyreon/core"
|
|
2
|
+
import { createRef, 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 { addClasses, cloneVNode, mergeRefs, mergeStyles, nextFrame, removeClasses } from "./utils"
|
|
9
|
+
|
|
10
|
+
const applyEnter = (
|
|
11
|
+
el: HTMLElement,
|
|
12
|
+
{
|
|
13
|
+
enter,
|
|
14
|
+
enterFrom,
|
|
15
|
+
enterTo,
|
|
16
|
+
enterStyle,
|
|
17
|
+
enterToStyle,
|
|
18
|
+
enterTransition,
|
|
19
|
+
}: ClassTransitionProps & StyleTransitionProps,
|
|
20
|
+
) => {
|
|
21
|
+
addClasses(el, enter)
|
|
22
|
+
addClasses(el, enterFrom)
|
|
23
|
+
if (enterStyle) Object.assign(el.style, enterStyle)
|
|
24
|
+
if (enterTransition) el.style.transition = enterTransition
|
|
25
|
+
|
|
26
|
+
return nextFrame(() => {
|
|
27
|
+
removeClasses(el, enterFrom)
|
|
28
|
+
addClasses(el, enterTo)
|
|
29
|
+
if (enterToStyle) Object.assign(el.style, enterToStyle)
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const applyLeave = (
|
|
34
|
+
el: HTMLElement,
|
|
35
|
+
{
|
|
36
|
+
enter,
|
|
37
|
+
enterTo,
|
|
38
|
+
leave,
|
|
39
|
+
leaveFrom,
|
|
40
|
+
leaveTo,
|
|
41
|
+
leaveStyle,
|
|
42
|
+
leaveToStyle,
|
|
43
|
+
leaveTransition,
|
|
44
|
+
}: ClassTransitionProps & StyleTransitionProps,
|
|
45
|
+
) => {
|
|
46
|
+
removeClasses(el, enter)
|
|
47
|
+
removeClasses(el, enterTo)
|
|
48
|
+
|
|
49
|
+
addClasses(el, leave)
|
|
50
|
+
addClasses(el, leaveFrom)
|
|
51
|
+
if (leaveStyle) Object.assign(el.style, leaveStyle)
|
|
52
|
+
if (leaveTransition) el.style.transition = leaveTransition
|
|
53
|
+
|
|
54
|
+
return nextFrame(() => {
|
|
55
|
+
removeClasses(el, leaveFrom)
|
|
56
|
+
addClasses(el, leaveTo)
|
|
57
|
+
if (leaveToStyle) Object.assign(el.style, leaveToStyle)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const applyReducedMotion = (
|
|
62
|
+
stage: string,
|
|
63
|
+
callbacks: {
|
|
64
|
+
onEnter?: (() => void) | undefined
|
|
65
|
+
onAfterEnter?: (() => void) | undefined
|
|
66
|
+
onLeave?: (() => void) | undefined
|
|
67
|
+
onAfterLeave?: (() => void) | undefined
|
|
68
|
+
},
|
|
69
|
+
complete: () => void,
|
|
70
|
+
) => {
|
|
71
|
+
if (stage === "entering") {
|
|
72
|
+
callbacks.onEnter?.()
|
|
73
|
+
callbacks.onAfterEnter?.()
|
|
74
|
+
complete()
|
|
75
|
+
} else if (stage === "leaving") {
|
|
76
|
+
callbacks.onLeave?.()
|
|
77
|
+
callbacks.onAfterLeave?.()
|
|
78
|
+
complete()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const Transition = ({
|
|
83
|
+
show,
|
|
84
|
+
appear = false,
|
|
85
|
+
unmount = true,
|
|
86
|
+
timeout = 5000,
|
|
87
|
+
enter,
|
|
88
|
+
enterFrom,
|
|
89
|
+
enterTo,
|
|
90
|
+
leave,
|
|
91
|
+
leaveFrom,
|
|
92
|
+
leaveTo,
|
|
93
|
+
enterStyle,
|
|
94
|
+
enterToStyle,
|
|
95
|
+
enterTransition,
|
|
96
|
+
leaveStyle,
|
|
97
|
+
leaveToStyle,
|
|
98
|
+
leaveTransition,
|
|
99
|
+
onEnter,
|
|
100
|
+
onAfterEnter,
|
|
101
|
+
onLeave,
|
|
102
|
+
onAfterLeave,
|
|
103
|
+
children,
|
|
104
|
+
}: TransitionProps): VNode | null => {
|
|
105
|
+
const reducedMotion = useReducedMotion()
|
|
106
|
+
const {
|
|
107
|
+
stage,
|
|
108
|
+
ref: stateRef,
|
|
109
|
+
shouldMount,
|
|
110
|
+
complete,
|
|
111
|
+
} = useTransitionState({
|
|
112
|
+
show,
|
|
113
|
+
appear,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const elementRef = createRef<HTMLElement>()
|
|
117
|
+
const mergedRef = mergeRefs(
|
|
118
|
+
elementRef,
|
|
119
|
+
stateRef,
|
|
120
|
+
(children.props as Record<string, unknown>)?.ref as
|
|
121
|
+
| ((el: HTMLElement | null) => void)
|
|
122
|
+
| undefined,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const callbacks = {
|
|
126
|
+
onEnter,
|
|
127
|
+
onAfterEnter,
|
|
128
|
+
onLeave,
|
|
129
|
+
onAfterLeave,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const transitionConfig = {
|
|
133
|
+
enter,
|
|
134
|
+
enterFrom,
|
|
135
|
+
enterTo,
|
|
136
|
+
leave,
|
|
137
|
+
leaveFrom,
|
|
138
|
+
leaveTo,
|
|
139
|
+
enterStyle,
|
|
140
|
+
enterToStyle,
|
|
141
|
+
enterTransition,
|
|
142
|
+
leaveStyle,
|
|
143
|
+
leaveToStyle,
|
|
144
|
+
leaveTransition,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
useAnimationEnd({
|
|
148
|
+
ref: elementRef,
|
|
149
|
+
active: () => (stage() === "entering" || stage() === "leaving") && !reducedMotion(),
|
|
150
|
+
timeout,
|
|
151
|
+
onEnd: () => {
|
|
152
|
+
if (stage() === "entering") {
|
|
153
|
+
callbacks.onAfterEnter?.()
|
|
154
|
+
} else if (stage() === "leaving") {
|
|
155
|
+
callbacks.onAfterLeave?.()
|
|
156
|
+
}
|
|
157
|
+
complete()
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
watch(
|
|
162
|
+
() => stage(),
|
|
163
|
+
(currentStage) => {
|
|
164
|
+
const el = elementRef.current
|
|
165
|
+
if (!el) return
|
|
166
|
+
|
|
167
|
+
if (reducedMotion()) {
|
|
168
|
+
applyReducedMotion(currentStage, callbacks, complete)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (currentStage === "entering") {
|
|
173
|
+
callbacks.onEnter?.()
|
|
174
|
+
const frameId = applyEnter(el, transitionConfig)
|
|
175
|
+
return () => cancelAnimationFrame(frameId)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (currentStage === "leaving") {
|
|
179
|
+
callbacks.onLeave?.()
|
|
180
|
+
const frameId = applyLeave(el, transitionConfig)
|
|
181
|
+
return () => cancelAnimationFrame(frameId)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (currentStage === "entered") {
|
|
185
|
+
removeClasses(el, enter)
|
|
186
|
+
el.style.transition = ""
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
{ immediate: true },
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<Show
|
|
194
|
+
when={shouldMount}
|
|
195
|
+
fallback={
|
|
196
|
+
unmount
|
|
197
|
+
? null
|
|
198
|
+
: cloneVNode(children, {
|
|
199
|
+
ref: mergedRef,
|
|
200
|
+
style: mergeStyles(
|
|
201
|
+
(children.props as Record<string, unknown>)?.style as
|
|
202
|
+
| Record<string, string | number | undefined>
|
|
203
|
+
| undefined,
|
|
204
|
+
{ display: "none" },
|
|
205
|
+
),
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
>
|
|
209
|
+
{cloneVNode(children, { ref: mergedRef })}
|
|
210
|
+
</Show>
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export default Transition
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { VNode } from "@pyreon/core"
|
|
2
|
+
import { signal } from "@pyreon/reactivity"
|
|
3
|
+
import Transition from "./Transition"
|
|
4
|
+
import type { ClassTransitionProps, StyleTransitionProps, TransitionCallbacks } from "./types"
|
|
5
|
+
|
|
6
|
+
export type TransitionGroupProps = ClassTransitionProps &
|
|
7
|
+
StyleTransitionProps &
|
|
8
|
+
TransitionCallbacks & {
|
|
9
|
+
appear?: boolean | undefined
|
|
10
|
+
timeout?: number | undefined
|
|
11
|
+
children: VNode[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type KeyedChild = { key: string | number; element: VNode }
|
|
15
|
+
|
|
16
|
+
const isVNode = (child: unknown): child is VNode =>
|
|
17
|
+
child != null && typeof child === "object" && "type" in (child as object)
|
|
18
|
+
|
|
19
|
+
const getKeyedChildren = (children: VNode[]): KeyedChild[] => {
|
|
20
|
+
const result: KeyedChild[] = []
|
|
21
|
+
for (const child of children) {
|
|
22
|
+
if (isVNode(child)) {
|
|
23
|
+
const key = (child as VNode & { key?: string | number }).key
|
|
24
|
+
if (key != null) {
|
|
25
|
+
result.push({ key, element: child })
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return result
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TransitionGroup = ({
|
|
33
|
+
children,
|
|
34
|
+
appear = false,
|
|
35
|
+
timeout,
|
|
36
|
+
onAfterLeave,
|
|
37
|
+
...transitionProps
|
|
38
|
+
}: TransitionGroupProps): VNode | null => {
|
|
39
|
+
const prevMap = new Map<string | number, VNode>()
|
|
40
|
+
const leavingMap = new Map<string | number, VNode>()
|
|
41
|
+
const forceUpdateSignal = signal(0)
|
|
42
|
+
|
|
43
|
+
// Build current keyed children map
|
|
44
|
+
const currentKeyed = getKeyedChildren(children)
|
|
45
|
+
const currentMap = new Map<string | number, VNode>()
|
|
46
|
+
for (const { key, element } of currentKeyed) {
|
|
47
|
+
currentMap.set(key, element)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Track initial keys to know which children were present on first render
|
|
51
|
+
const initialKeys: Set<string | number> = new Set(currentMap.keys())
|
|
52
|
+
|
|
53
|
+
// Detect leaving children (were in prev but not in current)
|
|
54
|
+
for (const [key, child] of prevMap) {
|
|
55
|
+
if (!currentMap.has(key)) {
|
|
56
|
+
leavingMap.set(key, child)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// If a leaving child reappears, stop leaving
|
|
61
|
+
for (const key of currentMap.keys()) {
|
|
62
|
+
leavingMap.delete(key)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Update prev
|
|
66
|
+
prevMap.clear()
|
|
67
|
+
for (const [key, element] of currentMap) {
|
|
68
|
+
prevMap.set(key, element)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const handleAfterLeave = (key: string | number) => {
|
|
72
|
+
leavingMap.delete(key)
|
|
73
|
+
onAfterLeave?.()
|
|
74
|
+
forceUpdateSignal.update((c) => c + 1)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Merge current + leaving, preserving insertion order
|
|
78
|
+
const allEntries: KeyedChild[] = [...currentKeyed]
|
|
79
|
+
|
|
80
|
+
for (const [key, element] of leavingMap) {
|
|
81
|
+
allEntries.push({ key, element })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
{allEntries.map(({ key, element }) => {
|
|
87
|
+
// New children (not in initial render) must appear with animation
|
|
88
|
+
const isInitial = initialKeys.has(key)
|
|
89
|
+
const isShowing = currentMap.has(key)
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Transition
|
|
93
|
+
key={key}
|
|
94
|
+
show={() => isShowing}
|
|
95
|
+
appear={isInitial ? appear : true}
|
|
96
|
+
timeout={timeout}
|
|
97
|
+
{...transitionProps}
|
|
98
|
+
onAfterLeave={() => handleAfterLeave(key)}
|
|
99
|
+
>
|
|
100
|
+
{element}
|
|
101
|
+
</Transition>
|
|
102
|
+
)
|
|
103
|
+
})}
|
|
104
|
+
</>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export default TransitionGroup
|