@pyreon/runtime-dom 0.1.0

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,3 @@
1
+ import { GlobalRegistrator } from "@happy-dom/global-registrator"
2
+
3
+ GlobalRegistrator.register()
@@ -0,0 +1,264 @@
1
+ import type { Props, VNode, VNodeChild } from "@pyreon/core"
2
+ import { createRef, h, onMount, onUnmount } from "@pyreon/core"
3
+ import { effect, runUntracked, signal } from "@pyreon/reactivity"
4
+ import { mountChild } from "./mount"
5
+
6
+ export interface TransitionGroupProps<T = unknown> {
7
+ /** Wrapper element tag. Default: "div" */
8
+ tag?: string
9
+ /** CSS class prefix. Default: "pyreon" */
10
+ name?: string
11
+ /** Animate items on initial mount. Default: false */
12
+ appear?: boolean
13
+ // CSS class overrides
14
+ enterFrom?: string
15
+ enterActive?: string
16
+ enterTo?: string
17
+ leaveFrom?: string
18
+ leaveActive?: string
19
+ leaveTo?: string
20
+ /** Class applied during FLIP move animation. Default: "{name}-move" */
21
+ moveClass?: string
22
+ /** Reactive list source */
23
+ items: () => T[]
24
+ /** Stable key extractor */
25
+ keyFn: (item: T, index: number) => string | number
26
+ /**
27
+ * Render a single DOM-element VNode for each item.
28
+ * Must return a VNode whose `type` is a string (e.g. "div", "li") so
29
+ * the component can inject a ref and read the underlying DOM node.
30
+ */
31
+ render: (item: T, index: number) => VNode
32
+ // Lifecycle callbacks
33
+ onBeforeEnter?: (el: HTMLElement) => void
34
+ onAfterEnter?: (el: HTMLElement) => void
35
+ onBeforeLeave?: (el: HTMLElement) => void
36
+ onAfterLeave?: (el: HTMLElement) => void
37
+ }
38
+
39
+ type ItemEntry = {
40
+ key: string | number
41
+ ref: ReturnType<typeof createRef<HTMLElement>>
42
+ cleanup: () => void
43
+ leaving: boolean
44
+ }
45
+
46
+ /**
47
+ * TransitionGroup — animates a keyed reactive list with CSS enter/leave and
48
+ * FLIP move animations.
49
+ *
50
+ * Class lifecycle:
51
+ * Enter: {name}-enter-from → {name}-enter-active + {name}-enter-to → cleanup
52
+ * Leave: {name}-leave-from → {name}-leave-active + {name}-leave-to → item removed
53
+ * Move: {name}-move (applied when an item shifts position)
54
+ *
55
+ * @example
56
+ * const items = signal([{ id: 1 }, { id: 2 }])
57
+ *
58
+ * h(TransitionGroup, {
59
+ * tag: "ul",
60
+ * name: "list",
61
+ * items,
62
+ * keyFn: (item) => item.id,
63
+ * render: (item) => h("li", { class: "item" }, item.id),
64
+ * })
65
+ *
66
+ * // CSS:
67
+ * // .list-enter-from, .list-leave-to { opacity: 0; transform: translateY(-10px); }
68
+ * // .list-enter-active, .list-leave-active { transition: all 300ms ease; }
69
+ * // .list-move { transition: transform 300ms ease; }
70
+ */
71
+ export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VNodeChild {
72
+ const tag = props.tag ?? "div"
73
+ const n = props.name ?? "pyreon"
74
+ const cls = {
75
+ ef: props.enterFrom ?? `${n}-enter-from`,
76
+ ea: props.enterActive ?? `${n}-enter-active`,
77
+ et: props.enterTo ?? `${n}-enter-to`,
78
+ lf: props.leaveFrom ?? `${n}-leave-from`,
79
+ la: props.leaveActive ?? `${n}-leave-active`,
80
+ lt: props.leaveTo ?? `${n}-leave-to`,
81
+ mv: props.moveClass ?? `${n}-move`,
82
+ }
83
+
84
+ const containerRef = createRef<HTMLElement>()
85
+ const entries = new Map<string | number, ItemEntry>()
86
+ // Gates the effect until the container element is in the DOM
87
+ const ready = signal(false)
88
+ let firstRun = true
89
+
90
+ const applyEnter = (el: HTMLElement) => {
91
+ props.onBeforeEnter?.(el)
92
+ el.classList.remove(cls.lf, cls.la, cls.lt)
93
+ el.classList.add(cls.ef, cls.ea)
94
+ requestAnimationFrame(() => {
95
+ el.classList.remove(cls.ef)
96
+ el.classList.add(cls.et)
97
+ const done = () => {
98
+ el.removeEventListener("transitionend", done)
99
+ el.removeEventListener("animationend", done)
100
+ el.classList.remove(cls.ea, cls.et)
101
+ props.onAfterEnter?.(el)
102
+ }
103
+ el.addEventListener("transitionend", done, { once: true })
104
+ el.addEventListener("animationend", done, { once: true })
105
+ })
106
+ }
107
+
108
+ const applyLeave = (el: HTMLElement, onDone: () => void) => {
109
+ props.onBeforeLeave?.(el)
110
+ el.classList.remove(cls.ef, cls.ea, cls.et)
111
+ el.classList.add(cls.lf, cls.la)
112
+ requestAnimationFrame(() => {
113
+ el.classList.remove(cls.lf)
114
+ el.classList.add(cls.lt)
115
+ const done = () => {
116
+ el.removeEventListener("transitionend", done)
117
+ el.removeEventListener("animationend", done)
118
+ el.classList.remove(cls.la, cls.lt)
119
+ props.onAfterLeave?.(el)
120
+ onDone()
121
+ }
122
+ el.addEventListener("transitionend", done, { once: true })
123
+ el.addEventListener("animationend", done, { once: true })
124
+ })
125
+ }
126
+
127
+ /** Start leave animation for removed items. */
128
+ const processLeaves = (newKeys: Set<string | number>) => {
129
+ for (const [key, entry] of entries) {
130
+ if (newKeys.has(key) || entry.leaving) continue
131
+ entry.leaving = true
132
+ const el = entry.ref.current
133
+ if (el) {
134
+ applyLeave(el, () => {
135
+ entry.cleanup()
136
+ entries.delete(key)
137
+ })
138
+ } else {
139
+ entry.cleanup()
140
+ entries.delete(key)
141
+ }
142
+ }
143
+ }
144
+
145
+ /** Mount new items and return the list of newly created entries. */
146
+ const mountNewItems = (items: T[], container: HTMLElement): ItemEntry[] => {
147
+ const newEntries: ItemEntry[] = []
148
+ for (let i = 0; i < items.length; i++) {
149
+ const item = items[i] as T
150
+ const key = props.keyFn(item, i)
151
+ if (entries.has(key)) continue
152
+ const itemRef = createRef<HTMLElement>()
153
+ const rawVNode = runUntracked(() => props.render(item, i))
154
+ const vnode: VNode =
155
+ typeof rawVNode.type === "string"
156
+ ? { ...rawVNode, props: { ...rawVNode.props, ref: itemRef } as Props }
157
+ : rawVNode
158
+ const cleanup = mountChild(vnode, container, null)
159
+ const entry: ItemEntry = { key, ref: itemRef, cleanup, leaving: false }
160
+ entries.set(key, entry)
161
+ newEntries.push(entry)
162
+ }
163
+ return newEntries
164
+ }
165
+
166
+ const startMoveAnimation = (el: HTMLElement) => {
167
+ requestAnimationFrame(() => {
168
+ el.classList.add(cls.mv)
169
+ el.style.transform = ""
170
+ el.style.transition = ""
171
+ const done = () => {
172
+ el.removeEventListener("transitionend", done)
173
+ el.removeEventListener("animationend", done)
174
+ el.classList.remove(cls.mv)
175
+ }
176
+ el.addEventListener("transitionend", done, { once: true })
177
+ el.addEventListener("animationend", done, { once: true })
178
+ })
179
+ }
180
+
181
+ const flipEntry = (entry: ItemEntry, oldPos: DOMRect) => {
182
+ if (!entry.ref.current) return
183
+ const newPos = entry.ref.current.getBoundingClientRect()
184
+ const dx = oldPos.left - newPos.left
185
+ const dy = oldPos.top - newPos.top
186
+ if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return
187
+ const el = entry.ref.current
188
+ el.style.transform = `translate(${dx}px, ${dy}px)`
189
+ el.style.transition = "none"
190
+ startMoveAnimation(el)
191
+ }
192
+
193
+ /** Apply FLIP move animations for items that shifted position. */
194
+ const applyFlipMoves = (oldPositions: Map<string | number, DOMRect>) => {
195
+ requestAnimationFrame(() => {
196
+ for (const [key, entry] of entries) {
197
+ if (entry.leaving) continue
198
+ const oldPos = oldPositions.get(key)
199
+ if (!oldPos) continue
200
+ flipEntry(entry, oldPos)
201
+ }
202
+ })
203
+ }
204
+
205
+ const recordOldPositions = (): Map<string | number, DOMRect> => {
206
+ const oldPositions = new Map<string | number, DOMRect>()
207
+ for (const [key, entry] of entries) {
208
+ if (!entry.leaving && entry.ref.current) {
209
+ oldPositions.set(key, entry.ref.current.getBoundingClientRect())
210
+ }
211
+ }
212
+ return oldPositions
213
+ }
214
+
215
+ const reorderEntries = (items: T[], container: HTMLElement) => {
216
+ for (let i = 0; i < items.length; i++) {
217
+ const key = props.keyFn(items[i] as T, i)
218
+ const entry = entries.get(key)
219
+ if (!entry || entry.leaving || !entry.ref.current) continue
220
+ container.appendChild(entry.ref.current)
221
+ }
222
+ }
223
+
224
+ const animateNewEntries = (newEntries: ItemEntry[]) => {
225
+ for (const entry of newEntries) {
226
+ queueMicrotask(() => {
227
+ if (entry.ref.current) applyEnter(entry.ref.current)
228
+ })
229
+ }
230
+ }
231
+
232
+ const e = effect(() => {
233
+ if (!ready()) return
234
+ const container = containerRef.current
235
+ if (!container) return
236
+
237
+ const items = props.items()
238
+ const newKeys = new Set(items.map((item, i) => props.keyFn(item, i)))
239
+ const isFirst = firstRun
240
+ firstRun = false
241
+
242
+ const oldPositions = recordOldPositions()
243
+ processLeaves(newKeys)
244
+ const newEntries = mountNewItems(items, container)
245
+ reorderEntries(items, container)
246
+
247
+ if (!isFirst || props.appear) animateNewEntries(newEntries)
248
+ if (!isFirst && oldPositions.size > 0) applyFlipMoves(oldPositions)
249
+ })
250
+
251
+ // Fire the effect once the container is in the DOM
252
+ onMount(() => {
253
+ ready.set(true)
254
+ return undefined
255
+ })
256
+
257
+ onUnmount(() => {
258
+ e.dispose()
259
+ for (const entry of entries.values()) entry.cleanup()
260
+ entries.clear()
261
+ })
262
+
263
+ return h(tag, { ref: containerRef })
264
+ }
@@ -0,0 +1,184 @@
1
+ import type { Props, VNode, VNodeChild } from "@pyreon/core"
2
+ import { createRef, Fragment, h, onUnmount } from "@pyreon/core"
3
+ import { effect, runUntracked, signal } from "@pyreon/reactivity"
4
+
5
+ const __DEV__ = typeof process !== "undefined" && process.env.NODE_ENV !== "production"
6
+
7
+ export interface TransitionProps {
8
+ /**
9
+ * CSS class name prefix.
10
+ * "fade" → fade-enter-from, fade-enter-active, fade-enter-to, fade-leave-from, …
11
+ * Default: "pyreon"
12
+ */
13
+ name?: string
14
+ /** Reactive boolean controlling whether the child is shown. */
15
+ show: () => boolean
16
+ /**
17
+ * If true, runs the enter transition on the initial mount (instead of
18
+ * appearing immediately). Default: false.
19
+ */
20
+ appear?: boolean
21
+ // Individual class name overrides (override the prefix-based defaults)
22
+ enterFrom?: string
23
+ enterActive?: string
24
+ enterTo?: string
25
+ leaveFrom?: string
26
+ leaveActive?: string
27
+ leaveTo?: string
28
+ // Lifecycle callbacks
29
+ onBeforeEnter?: (el: HTMLElement) => void
30
+ onAfterEnter?: (el: HTMLElement) => void
31
+ onBeforeLeave?: (el: HTMLElement) => void
32
+ onAfterLeave?: (el: HTMLElement) => void
33
+ /**
34
+ * The single child element to animate.
35
+ * Must be a direct DOM element VNode (not a component) for class injection to work.
36
+ */
37
+ children?: VNodeChild
38
+ }
39
+
40
+ /**
41
+ * Transition — adds CSS enter/leave animation classes to a single child element,
42
+ * controlled by the reactive `show` prop.
43
+ *
44
+ * Class lifecycle:
45
+ * Enter: {name}-enter-from → (next frame) → {name}-enter-active + {name}-enter-to → cleanup
46
+ * Leave: {name}-leave-from → (next frame) → {name}-leave-active + {name}-leave-to → unmount
47
+ *
48
+ * The child element stays in the DOM during the leave animation and is removed only
49
+ * after the CSS transition / animation completes.
50
+ *
51
+ * @example
52
+ * const visible = signal(false)
53
+ *
54
+ * h(Transition, { name: "fade", show: () => visible() },
55
+ * h("div", { class: "modal" }, "content")
56
+ * )
57
+ *
58
+ * // CSS:
59
+ * // .fade-enter-from, .fade-leave-to { opacity: 0; }
60
+ * // .fade-enter-active, .fade-leave-active { transition: opacity 300ms ease; }
61
+ */
62
+ export function Transition(props: TransitionProps): VNodeChild {
63
+ const n = props.name ?? "pyreon"
64
+ const cls = {
65
+ ef: props.enterFrom ?? `${n}-enter-from`,
66
+ ea: props.enterActive ?? `${n}-enter-active`,
67
+ et: props.enterTo ?? `${n}-enter-to`,
68
+ lf: props.leaveFrom ?? `${n}-leave-from`,
69
+ la: props.leaveActive ?? `${n}-leave-active`,
70
+ lt: props.leaveTo ?? `${n}-leave-to`,
71
+ }
72
+
73
+ // Ref injected into the child element so we can apply/remove classes
74
+ const ref = createRef<HTMLElement>()
75
+ const isMounted = signal(runUntracked<boolean>(props.show))
76
+
77
+ // Cancel an in-progress leave when re-entering before the animation ends
78
+ let pendingLeaveCancel: (() => void) | null = null
79
+ let initialized = false
80
+
81
+ const applyEnter = (el: HTMLElement) => {
82
+ pendingLeaveCancel?.()
83
+ pendingLeaveCancel = null
84
+ props.onBeforeEnter?.(el)
85
+ el.classList.remove(cls.lf, cls.la, cls.lt)
86
+ el.classList.add(cls.ef, cls.ea)
87
+ requestAnimationFrame(() => {
88
+ el.classList.remove(cls.ef)
89
+ el.classList.add(cls.et)
90
+ const done = () => {
91
+ // Remove both listeners — only one fires, so clean up the other
92
+ el.removeEventListener("transitionend", done)
93
+ el.removeEventListener("animationend", done)
94
+ el.classList.remove(cls.ea, cls.et)
95
+ props.onAfterEnter?.(el)
96
+ }
97
+ el.addEventListener("transitionend", done, { once: true })
98
+ el.addEventListener("animationend", done, { once: true })
99
+ })
100
+ }
101
+
102
+ const applyLeave = (el: HTMLElement) => {
103
+ props.onBeforeLeave?.(el)
104
+ el.classList.remove(cls.ef, cls.ea, cls.et)
105
+ el.classList.add(cls.lf, cls.la)
106
+ requestAnimationFrame(() => {
107
+ el.classList.remove(cls.lf)
108
+ el.classList.add(cls.lt)
109
+ const done = () => {
110
+ // Remove both listeners — only one fires, so clean up the other
111
+ el.removeEventListener("transitionend", done)
112
+ el.removeEventListener("animationend", done)
113
+ el.classList.remove(cls.la, cls.lt)
114
+ pendingLeaveCancel = null
115
+ isMounted.set(false)
116
+ props.onAfterLeave?.(el)
117
+ }
118
+ pendingLeaveCancel = () => {
119
+ el.removeEventListener("transitionend", done)
120
+ el.removeEventListener("animationend", done)
121
+ el.classList.remove(cls.lf, cls.la, cls.lt)
122
+ }
123
+ el.addEventListener("transitionend", done, { once: true })
124
+ el.addEventListener("animationend", done, { once: true })
125
+ })
126
+ }
127
+
128
+ const handleVisibilityChange = (visible: boolean) => {
129
+ if (visible) {
130
+ if (!isMounted.peek()) isMounted.set(true)
131
+ queueMicrotask(() => applyEnter(ref.current as HTMLElement))
132
+ return
133
+ }
134
+ if (!isMounted.peek()) return
135
+ const el = ref.current
136
+ if (!el) {
137
+ isMounted.set(false)
138
+ return
139
+ }
140
+ applyLeave(el)
141
+ }
142
+
143
+ effect(() => {
144
+ const visible = props.show()
145
+ if (!initialized) {
146
+ initialized = true
147
+ if (visible && props.appear) {
148
+ queueMicrotask(() => applyEnter(ref.current as HTMLElement))
149
+ }
150
+ return
151
+ }
152
+ handleVisibilityChange(visible)
153
+ })
154
+
155
+ onUnmount(() => {
156
+ pendingLeaveCancel?.()
157
+ pendingLeaveCancel = null
158
+ })
159
+
160
+ // Return a reactive getter. Each call clones the child VNode with our injected ref
161
+ // so we can read / write classes on the underlying DOM element.
162
+ const rawChild = props.children
163
+ // Return an empty Fragment (not null) when unmounted so mountChild uses
164
+ // mountReactive instead of the null/primitive text-node fast-path, which
165
+ // cannot later be swapped for a VNode when the element enters.
166
+ const emptyFragment = h(Fragment, null)
167
+ return (() => {
168
+ if (!isMounted()) return emptyFragment
169
+ if (!rawChild || typeof rawChild !== "object" || Array.isArray(rawChild)) {
170
+ return rawChild ?? null
171
+ }
172
+ const vnode = rawChild as VNode
173
+ // Only inject ref into DOM element children — component children need ref forwarding
174
+ if (typeof vnode.type !== "string") {
175
+ if (__DEV__) {
176
+ console.warn(
177
+ "[Pyreon] Transition child is a component. Wrap it in a DOM element for enter/leave animations to work.",
178
+ )
179
+ }
180
+ return vnode
181
+ }
182
+ return { ...vnode, props: { ...vnode.props, ref } as Props }
183
+ }) as unknown as VNode
184
+ }