@pyreon/kinetic 0.11.1 → 0.11.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/kinetic",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
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.1",
47
- "@pyreon/reactivity": "^0.11.1"
47
+ "@pyreon/core": "^0.11.3",
48
+ "@pyreon/reactivity": "^0.11.3"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@vitus-labs/tools-rolldown": "^1.15.3",
51
- "@pyreon/typescript": "^0.11.1"
52
+ "@pyreon/typescript": "^0.11.3"
52
53
  }
53
54
  }
@@ -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
@@ -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