@pyreon/kinetic 0.11.0 → 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.
@@ -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