@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
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { VNode } from "@pyreon/core"
|
|
2
|
+
import { createRef, h, Show } from "@pyreon/core"
|
|
3
|
+
import { runUntracked, signal, watch } from "@pyreon/reactivity"
|
|
4
|
+
import type { CSSProperties, TransitionCallbacks, TransitionStage } from "../types"
|
|
5
|
+
import useAnimationEnd from "../useAnimationEnd"
|
|
6
|
+
import { useReducedMotion } from "../useReducedMotion"
|
|
7
|
+
import type { KineticConfig } from "./types"
|
|
8
|
+
|
|
9
|
+
type CollapseRendererProps = {
|
|
10
|
+
config: KineticConfig
|
|
11
|
+
htmlProps: Record<string, unknown>
|
|
12
|
+
show: () => boolean
|
|
13
|
+
appear?: boolean | undefined
|
|
14
|
+
timeout?: number | undefined
|
|
15
|
+
transition?: string | undefined
|
|
16
|
+
callbacks: Partial<TransitionCallbacks>
|
|
17
|
+
children: VNode | VNode[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Renders a height-animated collapse. The config.tag becomes the outer
|
|
22
|
+
* wrapper (overflow:hidden + animated height). An inner div measures
|
|
23
|
+
* scrollHeight for the target value.
|
|
24
|
+
*/
|
|
25
|
+
const CollapseRenderer = ({
|
|
26
|
+
config,
|
|
27
|
+
htmlProps,
|
|
28
|
+
show,
|
|
29
|
+
appear,
|
|
30
|
+
timeout,
|
|
31
|
+
transition,
|
|
32
|
+
callbacks,
|
|
33
|
+
children,
|
|
34
|
+
}: CollapseRendererProps): VNode | null => {
|
|
35
|
+
const reducedMotion = useReducedMotion()
|
|
36
|
+
let wrapperRef: { current: HTMLElement | null } = createRef<HTMLElement>()
|
|
37
|
+
const contentRef = createRef<HTMLDivElement>()
|
|
38
|
+
|
|
39
|
+
const effectiveAppear = appear ?? config.appear ?? false
|
|
40
|
+
const effectiveTimeout = timeout ?? config.timeout ?? 5000
|
|
41
|
+
const effectiveTransition = transition ?? config.transition ?? "height 300ms ease"
|
|
42
|
+
|
|
43
|
+
const initialShow = show()
|
|
44
|
+
const needsAppear = effectiveAppear && initialShow
|
|
45
|
+
const stage = signal<TransitionStage>(initialShow ? "entered" : "hidden")
|
|
46
|
+
let isInitialMount = true
|
|
47
|
+
let appearTriggered = false
|
|
48
|
+
|
|
49
|
+
// Intercept ref assignment to trigger appear after all refs are wired
|
|
50
|
+
if (needsAppear) {
|
|
51
|
+
const orig = wrapperRef
|
|
52
|
+
const proxy = { current: null as HTMLElement | null }
|
|
53
|
+
Object.defineProperty(proxy, "current", {
|
|
54
|
+
get() {
|
|
55
|
+
return orig.current
|
|
56
|
+
},
|
|
57
|
+
set(node: HTMLElement | null) {
|
|
58
|
+
orig.current = node
|
|
59
|
+
if (node && !appearTriggered) {
|
|
60
|
+
appearTriggered = true
|
|
61
|
+
queueMicrotask(() => stage.set("entering"))
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
wrapperRef = proxy
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// State machine transitions
|
|
69
|
+
watch(
|
|
70
|
+
show,
|
|
71
|
+
(showVal) => {
|
|
72
|
+
if (isInitialMount) {
|
|
73
|
+
isInitialMount = false
|
|
74
|
+
// appear case is handled by ref proxy above
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const currentStage = runUntracked(() => stage())
|
|
79
|
+
if (showVal && (currentStage === "hidden" || currentStage === "leaving")) {
|
|
80
|
+
stage.set("entering")
|
|
81
|
+
} else if (!showVal && (currentStage === "entered" || currentStage === "entering")) {
|
|
82
|
+
stage.set("leaving")
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
{ immediate: true },
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// Animate height
|
|
89
|
+
watch(
|
|
90
|
+
() => stage(),
|
|
91
|
+
(currentStage) => {
|
|
92
|
+
const wrapper = wrapperRef.current
|
|
93
|
+
const content = contentRef.current
|
|
94
|
+
if (!wrapper || !content) return
|
|
95
|
+
|
|
96
|
+
if (reducedMotion()) {
|
|
97
|
+
if (currentStage === "entering") {
|
|
98
|
+
callbacks.onEnter?.()
|
|
99
|
+
wrapper.style.height = "auto"
|
|
100
|
+
wrapper.style.overflow = ""
|
|
101
|
+
callbacks.onAfterEnter?.()
|
|
102
|
+
stage.set("entered")
|
|
103
|
+
} else if (currentStage === "leaving") {
|
|
104
|
+
callbacks.onLeave?.()
|
|
105
|
+
wrapper.style.height = "0px"
|
|
106
|
+
wrapper.style.overflow = "hidden"
|
|
107
|
+
callbacks.onAfterLeave?.()
|
|
108
|
+
stage.set("hidden")
|
|
109
|
+
}
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (currentStage === "entering") {
|
|
114
|
+
callbacks.onEnter?.()
|
|
115
|
+
const height = content.scrollHeight
|
|
116
|
+
wrapper.style.transition = "none"
|
|
117
|
+
wrapper.style.height = "0px"
|
|
118
|
+
wrapper.style.overflow = "hidden"
|
|
119
|
+
// Force reflow
|
|
120
|
+
void wrapper.offsetHeight
|
|
121
|
+
wrapper.style.transition = effectiveTransition
|
|
122
|
+
wrapper.style.height = `${height}px`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (currentStage === "leaving") {
|
|
126
|
+
callbacks.onLeave?.()
|
|
127
|
+
const height = content.scrollHeight
|
|
128
|
+
wrapper.style.transition = "none"
|
|
129
|
+
wrapper.style.height = `${height}px`
|
|
130
|
+
wrapper.style.overflow = "hidden"
|
|
131
|
+
// Force reflow
|
|
132
|
+
void wrapper.offsetHeight
|
|
133
|
+
wrapper.style.transition = effectiveTransition
|
|
134
|
+
wrapper.style.height = "0px"
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
{ immediate: true },
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
useAnimationEnd({
|
|
141
|
+
ref: wrapperRef,
|
|
142
|
+
active: () => (stage() === "entering" || stage() === "leaving") && !reducedMotion(),
|
|
143
|
+
timeout: effectiveTimeout,
|
|
144
|
+
onEnd: () => {
|
|
145
|
+
const wrapper = wrapperRef.current
|
|
146
|
+
if (stage() === "entering") {
|
|
147
|
+
if (wrapper) {
|
|
148
|
+
wrapper.style.height = "auto"
|
|
149
|
+
wrapper.style.overflow = ""
|
|
150
|
+
wrapper.style.transition = ""
|
|
151
|
+
}
|
|
152
|
+
callbacks.onAfterEnter?.()
|
|
153
|
+
stage.set("entered")
|
|
154
|
+
} else if (stage() === "leaving") {
|
|
155
|
+
callbacks.onAfterLeave?.()
|
|
156
|
+
stage.set("hidden")
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const shouldRender = () => stage() !== "hidden"
|
|
162
|
+
|
|
163
|
+
const wrapperStyle: CSSProperties = {
|
|
164
|
+
...((htmlProps.style as CSSProperties) ?? {}),
|
|
165
|
+
...(stage() !== "entered" ? { overflow: "hidden" } : {}),
|
|
166
|
+
...(stage() === "hidden" ? { height: "0px" } : stage() === "entered" ? { height: "auto" } : {}),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return h(
|
|
170
|
+
config.tag,
|
|
171
|
+
{ ref: wrapperRef, ...htmlProps, style: wrapperStyle },
|
|
172
|
+
<Show when={shouldRender}>
|
|
173
|
+
<div ref={contentRef}>{children}</div>
|
|
174
|
+
</Show>,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export default CollapseRenderer
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { VNode } from "@pyreon/core"
|
|
2
|
+
import { h } from "@pyreon/core"
|
|
3
|
+
import { signal } from "@pyreon/reactivity"
|
|
4
|
+
import type { TransitionCallbacks } from "../types"
|
|
5
|
+
import TransitionItem from "./TransitionItem"
|
|
6
|
+
import type { KineticConfig } from "./types"
|
|
7
|
+
|
|
8
|
+
type GroupRendererProps = {
|
|
9
|
+
config: KineticConfig
|
|
10
|
+
htmlProps: Record<string, unknown>
|
|
11
|
+
appear?: boolean | undefined
|
|
12
|
+
timeout?: number | undefined
|
|
13
|
+
callbacks: Partial<TransitionCallbacks>
|
|
14
|
+
children: VNode[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type KeyedChild = { key: string | number; element: VNode }
|
|
18
|
+
|
|
19
|
+
const isVNode = (child: unknown): child is VNode =>
|
|
20
|
+
child != null && typeof child === "object" && "type" in (child as object)
|
|
21
|
+
|
|
22
|
+
const getKeyedChildren = (children: VNode[]): KeyedChild[] => {
|
|
23
|
+
const result: KeyedChild[] = []
|
|
24
|
+
for (const child of children) {
|
|
25
|
+
if (isVNode(child)) {
|
|
26
|
+
const key = (child as VNode & { key?: string | number }).key
|
|
27
|
+
if (key != null) {
|
|
28
|
+
result.push({ key, element: child })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return result
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Renders children with key-based enter/exit animation (no `show` prop).
|
|
37
|
+
* Children that appear (new key) animate in. Children that disappear
|
|
38
|
+
* (removed key) stay in DOM during leave animation, then unmount.
|
|
39
|
+
* config.tag wraps all children as a container element.
|
|
40
|
+
*/
|
|
41
|
+
const GroupRenderer = ({
|
|
42
|
+
config,
|
|
43
|
+
htmlProps,
|
|
44
|
+
appear,
|
|
45
|
+
timeout,
|
|
46
|
+
callbacks,
|
|
47
|
+
children,
|
|
48
|
+
}: GroupRendererProps): VNode | null => {
|
|
49
|
+
const effectiveAppear = appear ?? config.appear ?? false
|
|
50
|
+
const effectiveTimeout = timeout ?? config.timeout ?? 5000
|
|
51
|
+
|
|
52
|
+
const prevMap = new Map<string | number, VNode>()
|
|
53
|
+
const leavingMap = new Map<string | number, VNode>()
|
|
54
|
+
const forceUpdateSignal = signal(0)
|
|
55
|
+
|
|
56
|
+
const currentKeyed = getKeyedChildren(children)
|
|
57
|
+
const currentMap = new Map<string | number, VNode>()
|
|
58
|
+
for (const { key, element } of currentKeyed) {
|
|
59
|
+
currentMap.set(key, element)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const initialKeys: Set<string | number> = new Set(currentMap.keys())
|
|
63
|
+
|
|
64
|
+
// Detect leaving children
|
|
65
|
+
for (const [key, child] of prevMap) {
|
|
66
|
+
if (!currentMap.has(key)) {
|
|
67
|
+
leavingMap.set(key, child)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// If a leaving child reappears, stop leaving
|
|
72
|
+
for (const key of currentMap.keys()) {
|
|
73
|
+
leavingMap.delete(key)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
prevMap.clear()
|
|
77
|
+
for (const [key, element] of currentMap) {
|
|
78
|
+
prevMap.set(key, element)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const handleAfterLeave = (key: string | number) => {
|
|
82
|
+
leavingMap.delete(key)
|
|
83
|
+
callbacks.onAfterLeave?.()
|
|
84
|
+
forceUpdateSignal.update((c) => c + 1)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Merge current + leaving
|
|
88
|
+
const allEntries: KeyedChild[] = [...currentKeyed]
|
|
89
|
+
for (const [key, element] of leavingMap) {
|
|
90
|
+
allEntries.push({ key, element })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const groupedChildren = allEntries.map(({ key, element }) => {
|
|
94
|
+
const isInitial = initialKeys.has(key)
|
|
95
|
+
const isShowing = currentMap.has(key)
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<TransitionItem
|
|
99
|
+
show={() => isShowing}
|
|
100
|
+
appear={isInitial ? effectiveAppear : true}
|
|
101
|
+
timeout={effectiveTimeout}
|
|
102
|
+
enterStyle={config.enterStyle}
|
|
103
|
+
enterToStyle={config.enterToStyle}
|
|
104
|
+
enterTransition={config.enterTransition}
|
|
105
|
+
leaveStyle={config.leaveStyle}
|
|
106
|
+
leaveToStyle={config.leaveToStyle}
|
|
107
|
+
leaveTransition={config.leaveTransition}
|
|
108
|
+
enter={config.enter}
|
|
109
|
+
enterFrom={config.enterFrom}
|
|
110
|
+
enterTo={config.enterTo}
|
|
111
|
+
leave={config.leave}
|
|
112
|
+
leaveFrom={config.leaveFrom}
|
|
113
|
+
leaveTo={config.leaveTo}
|
|
114
|
+
onAfterLeave={() => handleAfterLeave(key)}
|
|
115
|
+
>
|
|
116
|
+
{element}
|
|
117
|
+
</TransitionItem>
|
|
118
|
+
)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
return h(config.tag, { ...htmlProps }, ...groupedChildren)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default GroupRenderer
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { VNode } from "@pyreon/core"
|
|
2
|
+
import { h } from "@pyreon/core"
|
|
3
|
+
import type { CSSProperties, TransitionCallbacks } from "../types"
|
|
4
|
+
import { cloneVNode } from "../utils"
|
|
5
|
+
import TransitionItem from "./TransitionItem"
|
|
6
|
+
import type { KineticConfig } from "./types"
|
|
7
|
+
|
|
8
|
+
type StaggerRendererProps = {
|
|
9
|
+
config: KineticConfig
|
|
10
|
+
htmlProps: Record<string, unknown>
|
|
11
|
+
show: () => boolean
|
|
12
|
+
appear?: boolean | undefined
|
|
13
|
+
timeout?: number | undefined
|
|
14
|
+
interval?: number | undefined
|
|
15
|
+
reverseLeave?: boolean | undefined
|
|
16
|
+
callbacks: Partial<TransitionCallbacks>
|
|
17
|
+
children: VNode[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const isVNode = (child: unknown): child is VNode =>
|
|
21
|
+
child != null && typeof child === "object" && "type" in (child as object)
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Renders children with staggered enter/exit animation.
|
|
25
|
+
* config.tag wraps the staggered children as a container element.
|
|
26
|
+
* Each child is individually animated via TransitionItem.
|
|
27
|
+
*/
|
|
28
|
+
const StaggerRenderer = ({
|
|
29
|
+
config,
|
|
30
|
+
htmlProps,
|
|
31
|
+
show,
|
|
32
|
+
appear,
|
|
33
|
+
timeout,
|
|
34
|
+
interval,
|
|
35
|
+
reverseLeave,
|
|
36
|
+
callbacks,
|
|
37
|
+
children,
|
|
38
|
+
}: StaggerRendererProps): VNode | null => {
|
|
39
|
+
const effectiveAppear = appear ?? config.appear ?? false
|
|
40
|
+
const effectiveTimeout = timeout ?? config.timeout ?? 5000
|
|
41
|
+
const effectiveInterval = interval ?? config.interval ?? 50
|
|
42
|
+
const effectiveReverseLeave = reverseLeave ?? config.reverseLeave ?? false
|
|
43
|
+
|
|
44
|
+
const childArray = (Array.isArray(children) ? children : [children]).filter(isVNode)
|
|
45
|
+
const count = childArray.length
|
|
46
|
+
|
|
47
|
+
const staggeredChildren = childArray.map((child, index) => {
|
|
48
|
+
const staggerIndex = !show() && effectiveReverseLeave ? count - 1 - index : index
|
|
49
|
+
const delay = staggerIndex * effectiveInterval
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<TransitionItem
|
|
53
|
+
key={(child as VNode & { key?: string | number }).key ?? index}
|
|
54
|
+
show={show}
|
|
55
|
+
appear={effectiveAppear}
|
|
56
|
+
timeout={effectiveTimeout + delay}
|
|
57
|
+
enterStyle={config.enterStyle}
|
|
58
|
+
enterToStyle={config.enterToStyle}
|
|
59
|
+
enterTransition={config.enterTransition}
|
|
60
|
+
leaveStyle={config.leaveStyle}
|
|
61
|
+
leaveToStyle={config.leaveToStyle}
|
|
62
|
+
leaveTransition={config.leaveTransition}
|
|
63
|
+
enter={config.enter}
|
|
64
|
+
enterFrom={config.enterFrom}
|
|
65
|
+
enterTo={config.enterTo}
|
|
66
|
+
leave={config.leave}
|
|
67
|
+
leaveFrom={config.leaveFrom}
|
|
68
|
+
leaveTo={config.leaveTo}
|
|
69
|
+
onAfterLeave={
|
|
70
|
+
index === (effectiveReverseLeave ? 0 : count - 1) ? callbacks.onAfterLeave : undefined
|
|
71
|
+
}
|
|
72
|
+
>
|
|
73
|
+
{cloneVNode(child, {
|
|
74
|
+
style: {
|
|
75
|
+
...((child.props as Record<string, unknown>)?.style as CSSProperties | undefined),
|
|
76
|
+
"--stagger-index": staggerIndex,
|
|
77
|
+
"--stagger-interval": `${effectiveInterval}ms`,
|
|
78
|
+
transitionDelay: `${delay}ms`,
|
|
79
|
+
} as CSSProperties,
|
|
80
|
+
})}
|
|
81
|
+
</TransitionItem>
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
return h(config.tag, { ...htmlProps }, ...staggeredChildren)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default StaggerRenderer
|
|
@@ -0,0 +1,196 @@
|
|
|
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, TransitionCallbacks } 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
|
+
type TransitionItemProps = ClassTransitionProps &
|
|
11
|
+
StyleTransitionProps &
|
|
12
|
+
TransitionCallbacks & {
|
|
13
|
+
show: () => boolean
|
|
14
|
+
appear?: boolean | undefined
|
|
15
|
+
unmount?: boolean | undefined
|
|
16
|
+
timeout?: number | undefined
|
|
17
|
+
delay?: number | undefined
|
|
18
|
+
children: VNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const applyEnter = (el: HTMLElement, config: ClassTransitionProps & StyleTransitionProps) => {
|
|
22
|
+
addClasses(el, config.enter)
|
|
23
|
+
addClasses(el, config.enterFrom)
|
|
24
|
+
if (config.enterStyle) Object.assign(el.style, config.enterStyle)
|
|
25
|
+
if (config.enterTransition) el.style.transition = config.enterTransition
|
|
26
|
+
|
|
27
|
+
return nextFrame(() => {
|
|
28
|
+
removeClasses(el, config.enterFrom)
|
|
29
|
+
addClasses(el, config.enterTo)
|
|
30
|
+
if (config.enterToStyle) Object.assign(el.style, config.enterToStyle)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const applyLeave = (el: HTMLElement, config: ClassTransitionProps & StyleTransitionProps) => {
|
|
35
|
+
removeClasses(el, config.enter)
|
|
36
|
+
removeClasses(el, config.enterTo)
|
|
37
|
+
|
|
38
|
+
addClasses(el, config.leave)
|
|
39
|
+
addClasses(el, config.leaveFrom)
|
|
40
|
+
if (config.leaveStyle) Object.assign(el.style, config.leaveStyle)
|
|
41
|
+
if (config.leaveTransition) el.style.transition = config.leaveTransition
|
|
42
|
+
|
|
43
|
+
return nextFrame(() => {
|
|
44
|
+
removeClasses(el, config.leaveFrom)
|
|
45
|
+
addClasses(el, config.leaveTo)
|
|
46
|
+
if (config.leaveToStyle) Object.assign(el.style, config.leaveToStyle)
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const applyReducedMotion = (
|
|
51
|
+
stage: string,
|
|
52
|
+
callbacks: Partial<TransitionCallbacks>,
|
|
53
|
+
complete: () => void,
|
|
54
|
+
) => {
|
|
55
|
+
if (stage === "entering") {
|
|
56
|
+
callbacks.onEnter?.()
|
|
57
|
+
callbacks.onAfterEnter?.()
|
|
58
|
+
complete()
|
|
59
|
+
} else if (stage === "leaving") {
|
|
60
|
+
callbacks.onLeave?.()
|
|
61
|
+
callbacks.onAfterLeave?.()
|
|
62
|
+
complete()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Internal per-child transition component. Used by StaggerRenderer and
|
|
68
|
+
* GroupRenderer to give each child its own animation state.
|
|
69
|
+
*
|
|
70
|
+
* Uses cloneVNode to inject ref onto the child — the child must accept ref.
|
|
71
|
+
*/
|
|
72
|
+
const TransitionItem = ({
|
|
73
|
+
show,
|
|
74
|
+
appear = false,
|
|
75
|
+
unmount = true,
|
|
76
|
+
timeout = 5000,
|
|
77
|
+
enter,
|
|
78
|
+
enterFrom,
|
|
79
|
+
enterTo,
|
|
80
|
+
leave,
|
|
81
|
+
leaveFrom,
|
|
82
|
+
leaveTo,
|
|
83
|
+
enterStyle,
|
|
84
|
+
enterToStyle,
|
|
85
|
+
enterTransition,
|
|
86
|
+
leaveStyle,
|
|
87
|
+
leaveToStyle,
|
|
88
|
+
leaveTransition,
|
|
89
|
+
onEnter,
|
|
90
|
+
onAfterEnter,
|
|
91
|
+
onLeave,
|
|
92
|
+
onAfterLeave,
|
|
93
|
+
children,
|
|
94
|
+
}: TransitionItemProps): VNode | null => {
|
|
95
|
+
const reducedMotion = useReducedMotion()
|
|
96
|
+
const { stage, ref: stateRef, shouldMount, complete } = useTransitionState({ show, appear })
|
|
97
|
+
|
|
98
|
+
const elementRef = createRef<HTMLElement>()
|
|
99
|
+
const mergedRef = mergeRefs(
|
|
100
|
+
elementRef,
|
|
101
|
+
stateRef,
|
|
102
|
+
(children.props as Record<string, unknown>)?.ref as
|
|
103
|
+
| ((el: HTMLElement | null) => void)
|
|
104
|
+
| undefined,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const callbacks = {
|
|
108
|
+
onEnter,
|
|
109
|
+
onAfterEnter,
|
|
110
|
+
onLeave,
|
|
111
|
+
onAfterLeave,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const transitionConfig = {
|
|
115
|
+
enter,
|
|
116
|
+
enterFrom,
|
|
117
|
+
enterTo,
|
|
118
|
+
leave,
|
|
119
|
+
leaveFrom,
|
|
120
|
+
leaveTo,
|
|
121
|
+
enterStyle,
|
|
122
|
+
enterToStyle,
|
|
123
|
+
enterTransition,
|
|
124
|
+
leaveStyle,
|
|
125
|
+
leaveToStyle,
|
|
126
|
+
leaveTransition,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
useAnimationEnd({
|
|
130
|
+
ref: elementRef,
|
|
131
|
+
active: () => (stage() === "entering" || stage() === "leaving") && !reducedMotion(),
|
|
132
|
+
timeout,
|
|
133
|
+
onEnd: () => {
|
|
134
|
+
if (stage() === "entering") {
|
|
135
|
+
callbacks.onAfterEnter?.()
|
|
136
|
+
} else if (stage() === "leaving") {
|
|
137
|
+
callbacks.onAfterLeave?.()
|
|
138
|
+
}
|
|
139
|
+
complete()
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
watch(
|
|
144
|
+
() => stage(),
|
|
145
|
+
(currentStage) => {
|
|
146
|
+
const el = elementRef.current
|
|
147
|
+
if (!el) return
|
|
148
|
+
|
|
149
|
+
if (reducedMotion()) {
|
|
150
|
+
applyReducedMotion(currentStage, callbacks, complete)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (currentStage === "entering") {
|
|
155
|
+
callbacks.onEnter?.()
|
|
156
|
+
const frameId = applyEnter(el, transitionConfig)
|
|
157
|
+
return () => cancelAnimationFrame(frameId)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (currentStage === "leaving") {
|
|
161
|
+
callbacks.onLeave?.()
|
|
162
|
+
const frameId = applyLeave(el, transitionConfig)
|
|
163
|
+
return () => cancelAnimationFrame(frameId)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (currentStage === "entered") {
|
|
167
|
+
removeClasses(el, enter)
|
|
168
|
+
el.style.transition = ""
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
{ immediate: true },
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<Show
|
|
176
|
+
when={shouldMount}
|
|
177
|
+
fallback={
|
|
178
|
+
unmount
|
|
179
|
+
? null
|
|
180
|
+
: cloneVNode(children, {
|
|
181
|
+
ref: mergedRef,
|
|
182
|
+
style: mergeStyles(
|
|
183
|
+
(children.props as Record<string, unknown>)?.style as
|
|
184
|
+
| Record<string, string | number | undefined>
|
|
185
|
+
| undefined,
|
|
186
|
+
{ display: "none" },
|
|
187
|
+
),
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
>
|
|
191
|
+
{cloneVNode(children, { ref: mergedRef })}
|
|
192
|
+
</Show>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export default TransitionItem
|