@pyreon/runtime-dom 0.24.5 → 0.24.6
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 +5 -9
- package/src/delegate.ts +0 -98
- package/src/devtools.ts +0 -339
- package/src/env.d.ts +0 -6
- package/src/hydrate.ts +0 -450
- package/src/hydration-debug.ts +0 -129
- package/src/index.ts +0 -83
- package/src/keep-alive-entry.ts +0 -3
- package/src/keep-alive.ts +0 -83
- package/src/manifest.ts +0 -236
- package/src/mount.ts +0 -597
- package/src/nodes.ts +0 -896
- package/src/props.ts +0 -474
- package/src/template.ts +0 -523
- package/src/tests/callback-ref-unmount.browser.test.ts +0 -62
- package/src/tests/callback-ref-unmount.test.ts +0 -52
- package/src/tests/compiler-integration.test.tsx +0 -508
- package/src/tests/coverage-gaps.test.ts +0 -3183
- package/src/tests/coverage.test.ts +0 -1140
- package/src/tests/ctx-stack-growth-repro.test.tsx +0 -158
- package/src/tests/dev-gate-pattern.test.ts +0 -46
- package/src/tests/dev-gate-treeshake.test.ts +0 -256
- package/src/tests/error-boundary-stack-leak-repro.test.tsx +0 -133
- package/src/tests/fanout-repro.test.tsx +0 -219
- package/src/tests/hydration-integration.test.tsx +0 -540
- package/src/tests/keyed-array-in-for-batched-toggle.browser.test.ts +0 -140
- package/src/tests/lifecycle-integration.test.tsx +0 -342
- package/src/tests/lis-prepend.browser.test.ts +0 -99
- package/src/tests/manifest-snapshot.test.ts +0 -85
- package/src/tests/mount.test.ts +0 -3529
- package/src/tests/native-markers.test.ts +0 -19
- package/src/tests/props.test.ts +0 -581
- package/src/tests/reactive-props.test.ts +0 -270
- package/src/tests/real-world-integration.test.tsx +0 -714
- package/src/tests/rs-collapse-dyn-h.browser.test.ts +0 -303
- package/src/tests/rs-collapse-dyn.browser.test.ts +0 -316
- package/src/tests/rs-collapse-h.browser.test.ts +0 -152
- package/src/tests/rs-collapse-h.test.ts +0 -237
- package/src/tests/rs-collapse.browser.test.ts +0 -128
- package/src/tests/runtime-dom.browser.test.ts +0 -409
- package/src/tests/setup.ts +0 -3
- package/src/tests/show-context.test.ts +0 -270
- package/src/tests/show-of-for-batched-toggle.browser.test.ts +0 -122
- package/src/tests/ssr-xss-round-trip.browser.test.ts +0 -93
- package/src/tests/style-key-removal.browser.test.ts +0 -54
- package/src/tests/style-key-removal.test.ts +0 -88
- package/src/tests/template.test.ts +0 -383
- package/src/tests/transition-timeout-leak.test.ts +0 -126
- package/src/tests/transition.test.ts +0 -568
- package/src/tests/verified-correct-probes.test.ts +0 -56
- package/src/transition-entry.ts +0 -7
- package/src/transition-group.ts +0 -350
- package/src/transition.ts +0 -245
package/src/transition-group.ts
DELETED
|
@@ -1,350 +0,0 @@
|
|
|
1
|
-
import type { Props, VNode, VNodeChild } from '@pyreon/core'
|
|
2
|
-
import { createRef, h, nativeCompat, 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
|
-
* Cancel function for an in-progress enter / leave / move transition —
|
|
46
|
-
* removes listeners, clears the safety timer, strips active-state
|
|
47
|
-
* classes, but does NOT fire the onAfterX callback. Called when a
|
|
48
|
-
* transition is superseded or when the whole TransitionGroup unmounts
|
|
49
|
-
* mid-transition (so onAfterEnter/Leave doesn't fire on a detached
|
|
50
|
-
* element and the 5s timer doesn't leak past unmount).
|
|
51
|
-
*/
|
|
52
|
-
cancelTransition: (() => void) | null
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* TransitionGroup — animates a keyed reactive list with CSS enter/leave and
|
|
57
|
-
* FLIP move animations.
|
|
58
|
-
*
|
|
59
|
-
* Class lifecycle:
|
|
60
|
-
* Enter: {name}-enter-from → {name}-enter-active + {name}-enter-to → cleanup
|
|
61
|
-
* Leave: {name}-leave-from → {name}-leave-active + {name}-leave-to → item removed
|
|
62
|
-
* Move: {name}-move (applied when an item shifts position)
|
|
63
|
-
*
|
|
64
|
-
* @example
|
|
65
|
-
* const items = signal([{ id: 1 }, { id: 2 }])
|
|
66
|
-
*
|
|
67
|
-
* h(TransitionGroup, {
|
|
68
|
-
* tag: "ul",
|
|
69
|
-
* name: "list",
|
|
70
|
-
* items,
|
|
71
|
-
* keyFn: (item) => item.id,
|
|
72
|
-
* render: (item) => h("li", { class: "item" }, item.id),
|
|
73
|
-
* })
|
|
74
|
-
*
|
|
75
|
-
* // CSS:
|
|
76
|
-
* // .list-enter-from, .list-leave-to { opacity: 0; transform: translateY(-10px); }
|
|
77
|
-
* // .list-enter-active, .list-leave-active { transition: all 300ms ease; }
|
|
78
|
-
* // .list-move { transition: transform 300ms ease; }
|
|
79
|
-
*/
|
|
80
|
-
export function TransitionGroup<T = unknown>(props: TransitionGroupProps<T>): VNodeChild {
|
|
81
|
-
const tag = props.tag ?? 'div'
|
|
82
|
-
const n = props.name ?? 'pyreon'
|
|
83
|
-
const cls = {
|
|
84
|
-
ef: props.enterFrom ?? `${n}-enter-from`,
|
|
85
|
-
ea: props.enterActive ?? `${n}-enter-active`,
|
|
86
|
-
et: props.enterTo ?? `${n}-enter-to`,
|
|
87
|
-
lf: props.leaveFrom ?? `${n}-leave-from`,
|
|
88
|
-
la: props.leaveActive ?? `${n}-leave-active`,
|
|
89
|
-
lt: props.leaveTo ?? `${n}-leave-to`,
|
|
90
|
-
mv: props.moveClass ?? `${n}-move`,
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const containerRef = createRef<HTMLElement>()
|
|
94
|
-
const entries = new Map<string | number, ItemEntry>()
|
|
95
|
-
// Gates the effect until the container element is in the DOM
|
|
96
|
-
const ready = signal(false)
|
|
97
|
-
let firstRun = true
|
|
98
|
-
|
|
99
|
-
const applyEnter = (entry: ItemEntry, el: HTMLElement) => {
|
|
100
|
-
props.onBeforeEnter?.(el)
|
|
101
|
-
el.classList.remove(cls.lf, cls.la, cls.lt)
|
|
102
|
-
el.classList.add(cls.ef, cls.ea)
|
|
103
|
-
requestAnimationFrame(() => {
|
|
104
|
-
el.classList.remove(cls.ef)
|
|
105
|
-
el.classList.add(cls.et)
|
|
106
|
-
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
107
|
-
const done = () => {
|
|
108
|
-
el.removeEventListener('transitionend', done)
|
|
109
|
-
el.removeEventListener('animationend', done)
|
|
110
|
-
if (safetyTimer !== null) {
|
|
111
|
-
clearTimeout(safetyTimer)
|
|
112
|
-
safetyTimer = null
|
|
113
|
-
}
|
|
114
|
-
entry.cancelTransition = null
|
|
115
|
-
el.classList.remove(cls.ea, cls.et)
|
|
116
|
-
props.onAfterEnter?.(el)
|
|
117
|
-
}
|
|
118
|
-
entry.cancelTransition = () => {
|
|
119
|
-
el.removeEventListener('transitionend', done)
|
|
120
|
-
el.removeEventListener('animationend', done)
|
|
121
|
-
if (safetyTimer !== null) {
|
|
122
|
-
clearTimeout(safetyTimer)
|
|
123
|
-
safetyTimer = null
|
|
124
|
-
}
|
|
125
|
-
el.classList.remove(cls.ef, cls.ea, cls.et)
|
|
126
|
-
}
|
|
127
|
-
el.addEventListener('transitionend', done, { once: true })
|
|
128
|
-
el.addEventListener('animationend', done, { once: true })
|
|
129
|
-
// Safety timeout: if CSS animation never fires (off-screen, zero
|
|
130
|
-
// duration, `display: none`), force cleanup so the entry's
|
|
131
|
-
// onAfterEnter runs and the listener + closure don't leak.
|
|
132
|
-
safetyTimer = setTimeout(done, 5000)
|
|
133
|
-
})
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const applyLeave = (entry: ItemEntry, el: HTMLElement, onDone: () => void) => {
|
|
137
|
-
props.onBeforeLeave?.(el)
|
|
138
|
-
el.classList.remove(cls.ef, cls.ea, cls.et)
|
|
139
|
-
el.classList.add(cls.lf, cls.la)
|
|
140
|
-
requestAnimationFrame(() => {
|
|
141
|
-
el.classList.remove(cls.lf)
|
|
142
|
-
el.classList.add(cls.lt)
|
|
143
|
-
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
144
|
-
const done = () => {
|
|
145
|
-
el.removeEventListener('transitionend', done)
|
|
146
|
-
el.removeEventListener('animationend', done)
|
|
147
|
-
if (safetyTimer !== null) {
|
|
148
|
-
clearTimeout(safetyTimer)
|
|
149
|
-
safetyTimer = null
|
|
150
|
-
}
|
|
151
|
-
entry.cancelTransition = null
|
|
152
|
-
el.classList.remove(cls.la, cls.lt)
|
|
153
|
-
props.onAfterLeave?.(el)
|
|
154
|
-
onDone()
|
|
155
|
-
}
|
|
156
|
-
entry.cancelTransition = () => {
|
|
157
|
-
el.removeEventListener('transitionend', done)
|
|
158
|
-
el.removeEventListener('animationend', done)
|
|
159
|
-
if (safetyTimer !== null) {
|
|
160
|
-
clearTimeout(safetyTimer)
|
|
161
|
-
safetyTimer = null
|
|
162
|
-
}
|
|
163
|
-
el.classList.remove(cls.lf, cls.la, cls.lt)
|
|
164
|
-
}
|
|
165
|
-
el.addEventListener('transitionend', done, { once: true })
|
|
166
|
-
el.addEventListener('animationend', done, { once: true })
|
|
167
|
-
// Safety timeout: CRITICAL for transition-group. Without it, a list
|
|
168
|
-
// item whose leave transition never fires (off-screen, zero
|
|
169
|
-
// duration, `display: none`) stays in the `entries` Map forever
|
|
170
|
-
// because `onDone` never runs to `entries.delete(key)` — a real
|
|
171
|
-
// memory leak that grows with every list mutation.
|
|
172
|
-
safetyTimer = setTimeout(done, 5000)
|
|
173
|
-
})
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Start leave animation for removed items. */
|
|
177
|
-
const processLeaves = (newKeys: Set<string | number>) => {
|
|
178
|
-
for (const [key, entry] of entries) {
|
|
179
|
-
if (newKeys.has(key) || entry.leaving) continue
|
|
180
|
-
entry.leaving = true
|
|
181
|
-
const el = entry.ref.current
|
|
182
|
-
if (el) {
|
|
183
|
-
applyLeave(entry, el, () => {
|
|
184
|
-
entry.cleanup()
|
|
185
|
-
entries.delete(key)
|
|
186
|
-
})
|
|
187
|
-
} else {
|
|
188
|
-
entry.cleanup()
|
|
189
|
-
entries.delete(key)
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** Mount new items and return the list of newly created entries. */
|
|
195
|
-
const mountNewItems = (items: T[], container: HTMLElement): ItemEntry[] => {
|
|
196
|
-
const newEntries: ItemEntry[] = []
|
|
197
|
-
for (let i = 0; i < items.length; i++) {
|
|
198
|
-
const item = items[i] as T
|
|
199
|
-
const key = props.keyFn(item, i)
|
|
200
|
-
if (entries.has(key)) continue
|
|
201
|
-
const itemRef = createRef<HTMLElement>()
|
|
202
|
-
// Both render AND mountChild must run untracked — child component
|
|
203
|
-
// setup (signal reads inside the render callback's resulting tree,
|
|
204
|
-
// useTheme / useQuery's options() construction etc.) must NOT
|
|
205
|
-
// subscribe this effect. Otherwise an unrelated signal flip re-runs
|
|
206
|
-
// the TransitionGroup effect, runCleanup() disposes the children's
|
|
207
|
-
// inner effects, and the next mount path skips re-rendering kept
|
|
208
|
-
// entries → the inner reactivity is lost. Same shape as the
|
|
209
|
-
// mountFor / mountKeyedList fix in nodes.ts.
|
|
210
|
-
const cleanup = runUntracked(() => {
|
|
211
|
-
const rawVNode = props.render(item, i)
|
|
212
|
-
const vnode: VNode =
|
|
213
|
-
typeof rawVNode.type === 'string'
|
|
214
|
-
? { ...rawVNode, props: { ...rawVNode.props, ref: itemRef } as Props }
|
|
215
|
-
: rawVNode
|
|
216
|
-
return mountChild(vnode, container, null)
|
|
217
|
-
})
|
|
218
|
-
const entry: ItemEntry = { key, ref: itemRef, cleanup, leaving: false, cancelTransition: null }
|
|
219
|
-
entries.set(key, entry)
|
|
220
|
-
newEntries.push(entry)
|
|
221
|
-
}
|
|
222
|
-
return newEntries
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const startMoveAnimation = (entry: ItemEntry, el: HTMLElement) => {
|
|
226
|
-
requestAnimationFrame(() => {
|
|
227
|
-
el.classList.add(cls.mv)
|
|
228
|
-
el.style.transform = ''
|
|
229
|
-
el.style.transition = ''
|
|
230
|
-
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
231
|
-
const done = () => {
|
|
232
|
-
el.removeEventListener('transitionend', done)
|
|
233
|
-
el.removeEventListener('animationend', done)
|
|
234
|
-
if (safetyTimer !== null) {
|
|
235
|
-
clearTimeout(safetyTimer)
|
|
236
|
-
safetyTimer = null
|
|
237
|
-
}
|
|
238
|
-
entry.cancelTransition = null
|
|
239
|
-
el.classList.remove(cls.mv)
|
|
240
|
-
}
|
|
241
|
-
entry.cancelTransition = () => {
|
|
242
|
-
el.removeEventListener('transitionend', done)
|
|
243
|
-
el.removeEventListener('animationend', done)
|
|
244
|
-
if (safetyTimer !== null) {
|
|
245
|
-
clearTimeout(safetyTimer)
|
|
246
|
-
safetyTimer = null
|
|
247
|
-
}
|
|
248
|
-
el.classList.remove(cls.mv)
|
|
249
|
-
}
|
|
250
|
-
el.addEventListener('transitionend', done, { once: true })
|
|
251
|
-
el.addEventListener('animationend', done, { once: true })
|
|
252
|
-
safetyTimer = setTimeout(done, 5000)
|
|
253
|
-
})
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const flipEntry = (entry: ItemEntry, oldPos: DOMRect) => {
|
|
257
|
-
if (!entry.ref.current) return
|
|
258
|
-
const newPos = entry.ref.current.getBoundingClientRect()
|
|
259
|
-
const dx = oldPos.left - newPos.left
|
|
260
|
-
const dy = oldPos.top - newPos.top
|
|
261
|
-
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return
|
|
262
|
-
const el = entry.ref.current
|
|
263
|
-
el.style.transform = `translate(${dx}px, ${dy}px)`
|
|
264
|
-
el.style.transition = 'none'
|
|
265
|
-
startMoveAnimation(entry, el)
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** Apply FLIP move animations for items that shifted position. */
|
|
269
|
-
const applyFlipMoves = (oldPositions: Map<string | number, DOMRect>) => {
|
|
270
|
-
requestAnimationFrame(() => {
|
|
271
|
-
for (const [key, entry] of entries) {
|
|
272
|
-
if (entry.leaving) continue
|
|
273
|
-
const oldPos = oldPositions.get(key)
|
|
274
|
-
if (!oldPos) continue
|
|
275
|
-
flipEntry(entry, oldPos)
|
|
276
|
-
}
|
|
277
|
-
})
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const recordOldPositions = (): Map<string | number, DOMRect> => {
|
|
281
|
-
const oldPositions = new Map<string | number, DOMRect>()
|
|
282
|
-
for (const [key, entry] of entries) {
|
|
283
|
-
if (!entry.leaving && entry.ref.current) {
|
|
284
|
-
oldPositions.set(key, entry.ref.current.getBoundingClientRect())
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
return oldPositions
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const reorderEntries = (items: T[], container: HTMLElement) => {
|
|
291
|
-
for (let i = 0; i < items.length; i++) {
|
|
292
|
-
const key = props.keyFn(items[i] as T, i)
|
|
293
|
-
const entry = entries.get(key)
|
|
294
|
-
if (!entry || entry.leaving || !entry.ref.current) continue
|
|
295
|
-
container.appendChild(entry.ref.current)
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const animateNewEntries = (newEntries: ItemEntry[]) => {
|
|
300
|
-
for (const entry of newEntries) {
|
|
301
|
-
queueMicrotask(() => {
|
|
302
|
-
if (entry.ref.current) applyEnter(entry, entry.ref.current)
|
|
303
|
-
})
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
const e = effect(() => {
|
|
308
|
-
if (!ready()) return
|
|
309
|
-
const container = containerRef.current
|
|
310
|
-
if (!container) return
|
|
311
|
-
|
|
312
|
-
const items = props.items()
|
|
313
|
-
const newKeys = new Set(items.map((item, i) => props.keyFn(item, i)))
|
|
314
|
-
const isFirst = firstRun
|
|
315
|
-
firstRun = false
|
|
316
|
-
|
|
317
|
-
const oldPositions = recordOldPositions()
|
|
318
|
-
processLeaves(newKeys)
|
|
319
|
-
const newEntries = mountNewItems(items, container)
|
|
320
|
-
reorderEntries(items, container)
|
|
321
|
-
|
|
322
|
-
if (!isFirst || props.appear) animateNewEntries(newEntries)
|
|
323
|
-
if (!isFirst && oldPositions.size > 0) applyFlipMoves(oldPositions)
|
|
324
|
-
})
|
|
325
|
-
|
|
326
|
-
// Fire the effect once the container is in the DOM
|
|
327
|
-
onMount(() => {
|
|
328
|
-
ready.set(true)
|
|
329
|
-
})
|
|
330
|
-
|
|
331
|
-
onUnmount(() => {
|
|
332
|
-
e.dispose()
|
|
333
|
-
for (const entry of entries.values()) {
|
|
334
|
-
// Cancel any in-progress enter/leave/move transition so the 5s
|
|
335
|
-
// safety timer doesn't keep running past container unmount and
|
|
336
|
-
// onAfterEnter / onAfterLeave don't fire on a detached element.
|
|
337
|
-
entry.cancelTransition?.()
|
|
338
|
-
entry.cancelTransition = null
|
|
339
|
-
entry.cleanup()
|
|
340
|
-
}
|
|
341
|
-
entries.clear()
|
|
342
|
-
})
|
|
343
|
-
|
|
344
|
-
return h(tag, { ref: containerRef })
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Mark as native so compat-mode jsx() runtimes skip wrapCompatComponent —
|
|
348
|
-
// TransitionGroup uses signal/effect/onMount/onUnmount + mountChild that
|
|
349
|
-
// need Pyreon's setup frame.
|
|
350
|
-
nativeCompat(TransitionGroup)
|
package/src/transition.ts
DELETED
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
import type { Props, VNode, VNodeChild } from '@pyreon/core'
|
|
2
|
-
import { createRef, Fragment, h, nativeCompat, onUnmount } from '@pyreon/core'
|
|
3
|
-
import { effect, runUntracked, signal } from '@pyreon/reactivity'
|
|
4
|
-
|
|
5
|
-
// Dev-mode gate: `import.meta.env.DEV` is the Vite/Rolldown standard,
|
|
6
|
-
// literal-replaced at build time. The previous `typeof process !== 'undefined'`
|
|
7
|
-
// pattern was dead code in real Vite browser bundles because Vite does not
|
|
8
|
-
// polyfill `process` for the client — every wrapped warning silently never
|
|
9
|
-
// fired in dev. Enforced by the `pyreon/no-process-dev-gate` lint rule.
|
|
10
|
-
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
11
|
-
|
|
12
|
-
export interface TransitionProps {
|
|
13
|
-
/**
|
|
14
|
-
* CSS class name prefix.
|
|
15
|
-
* "fade" → fade-enter-from, fade-enter-active, fade-enter-to, fade-leave-from, …
|
|
16
|
-
* Default: "pyreon"
|
|
17
|
-
*/
|
|
18
|
-
name?: string
|
|
19
|
-
/** Reactive boolean controlling whether the child is shown. */
|
|
20
|
-
show: () => boolean
|
|
21
|
-
/**
|
|
22
|
-
* If true, runs the enter transition on the initial mount (instead of
|
|
23
|
-
* appearing immediately). Default: false.
|
|
24
|
-
*/
|
|
25
|
-
appear?: boolean
|
|
26
|
-
// Individual class name overrides (override the prefix-based defaults)
|
|
27
|
-
enterFrom?: string
|
|
28
|
-
enterActive?: string
|
|
29
|
-
enterTo?: string
|
|
30
|
-
leaveFrom?: string
|
|
31
|
-
leaveActive?: string
|
|
32
|
-
leaveTo?: string
|
|
33
|
-
// Lifecycle callbacks
|
|
34
|
-
onBeforeEnter?: (el: HTMLElement) => void
|
|
35
|
-
onAfterEnter?: (el: HTMLElement) => void
|
|
36
|
-
onBeforeLeave?: (el: HTMLElement) => void
|
|
37
|
-
onAfterLeave?: (el: HTMLElement) => void
|
|
38
|
-
/**
|
|
39
|
-
* The single child element to animate.
|
|
40
|
-
* Must be a direct DOM element VNode (not a component) for class injection to work.
|
|
41
|
-
*/
|
|
42
|
-
children?: VNodeChild
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Transition — adds CSS enter/leave animation classes to a single child element,
|
|
47
|
-
* controlled by the reactive `show` prop.
|
|
48
|
-
*
|
|
49
|
-
* Class lifecycle:
|
|
50
|
-
* Enter: {name}-enter-from → (next frame) → {name}-enter-active + {name}-enter-to → cleanup
|
|
51
|
-
* Leave: {name}-leave-from → (next frame) → {name}-leave-active + {name}-leave-to → unmount
|
|
52
|
-
*
|
|
53
|
-
* The child element stays in the DOM during the leave animation and is removed only
|
|
54
|
-
* after the CSS transition / animation completes.
|
|
55
|
-
*
|
|
56
|
-
* @example
|
|
57
|
-
* const visible = signal(false)
|
|
58
|
-
*
|
|
59
|
-
* h(Transition, { name: "fade", show: () => visible() },
|
|
60
|
-
* h("div", { class: "modal" }, "content")
|
|
61
|
-
* )
|
|
62
|
-
*
|
|
63
|
-
* // CSS:
|
|
64
|
-
* // .fade-enter-from, .fade-leave-to { opacity: 0; }
|
|
65
|
-
* // .fade-enter-active, .fade-leave-active { transition: opacity 300ms ease; }
|
|
66
|
-
*/
|
|
67
|
-
export function Transition(props: TransitionProps): VNodeChild {
|
|
68
|
-
const n = props.name ?? 'pyreon'
|
|
69
|
-
const cls = {
|
|
70
|
-
ef: props.enterFrom ?? `${n}-enter-from`,
|
|
71
|
-
ea: props.enterActive ?? `${n}-enter-active`,
|
|
72
|
-
et: props.enterTo ?? `${n}-enter-to`,
|
|
73
|
-
lf: props.leaveFrom ?? `${n}-leave-from`,
|
|
74
|
-
la: props.leaveActive ?? `${n}-leave-active`,
|
|
75
|
-
lt: props.leaveTo ?? `${n}-leave-to`,
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Ref injected into the child element so we can apply/remove classes
|
|
79
|
-
const ref = createRef<HTMLElement>()
|
|
80
|
-
const isMounted = signal(runUntracked<boolean>(props.show))
|
|
81
|
-
|
|
82
|
-
// Cancel in-progress enter / leave when the component unmounts or when a
|
|
83
|
-
// new transition supersedes the current one. Both are set inside their
|
|
84
|
-
// respective applyX(). Calling the cancel removes event listeners, clears
|
|
85
|
-
// the safety timer, and strips active-state classes — WITHOUT firing the
|
|
86
|
-
// onAfterX callback (which would run on a detached element after unmount).
|
|
87
|
-
let pendingEnterCancel: (() => void) | null = null
|
|
88
|
-
let pendingLeaveCancel: (() => void) | null = null
|
|
89
|
-
let initialized = false
|
|
90
|
-
|
|
91
|
-
const applyEnter = (el: HTMLElement) => {
|
|
92
|
-
pendingLeaveCancel?.()
|
|
93
|
-
pendingLeaveCancel = null
|
|
94
|
-
pendingEnterCancel?.()
|
|
95
|
-
pendingEnterCancel = null
|
|
96
|
-
props.onBeforeEnter?.(el)
|
|
97
|
-
el.classList.remove(cls.lf, cls.la, cls.lt)
|
|
98
|
-
el.classList.add(cls.ef, cls.ea)
|
|
99
|
-
requestAnimationFrame(() => {
|
|
100
|
-
el.classList.remove(cls.ef)
|
|
101
|
-
el.classList.add(cls.et)
|
|
102
|
-
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
103
|
-
const done = () => {
|
|
104
|
-
// Remove both listeners — only one fires, so clean up the other
|
|
105
|
-
el.removeEventListener('transitionend', done)
|
|
106
|
-
el.removeEventListener('animationend', done)
|
|
107
|
-
// Clear the safety timeout — without this, when transitionend fires
|
|
108
|
-
// normally the 5s timer would still fire later and re-invoke done(),
|
|
109
|
-
// leaking timer refs and re-firing onAfterEnter.
|
|
110
|
-
if (safetyTimer !== null) {
|
|
111
|
-
clearTimeout(safetyTimer)
|
|
112
|
-
safetyTimer = null
|
|
113
|
-
}
|
|
114
|
-
pendingEnterCancel = null
|
|
115
|
-
el.classList.remove(cls.ea, cls.et)
|
|
116
|
-
props.onAfterEnter?.(el)
|
|
117
|
-
}
|
|
118
|
-
// Cancel path (called from onUnmount or a superseding transition): tears
|
|
119
|
-
// down without firing onAfterEnter on a detached element.
|
|
120
|
-
pendingEnterCancel = () => {
|
|
121
|
-
el.removeEventListener('transitionend', done)
|
|
122
|
-
el.removeEventListener('animationend', done)
|
|
123
|
-
if (safetyTimer !== null) {
|
|
124
|
-
clearTimeout(safetyTimer)
|
|
125
|
-
safetyTimer = null
|
|
126
|
-
}
|
|
127
|
-
el.classList.remove(cls.ef, cls.ea, cls.et)
|
|
128
|
-
}
|
|
129
|
-
el.addEventListener('transitionend', done, { once: true })
|
|
130
|
-
el.addEventListener('animationend', done, { once: true })
|
|
131
|
-
// Safety timeout: if CSS animation never fires (bad CSS, off-screen), force cleanup
|
|
132
|
-
safetyTimer = setTimeout(done, 5000)
|
|
133
|
-
})
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const applyLeave = (el: HTMLElement) => {
|
|
137
|
-
pendingEnterCancel?.()
|
|
138
|
-
pendingEnterCancel = null
|
|
139
|
-
props.onBeforeLeave?.(el)
|
|
140
|
-
el.classList.remove(cls.ef, cls.ea, cls.et)
|
|
141
|
-
el.classList.add(cls.lf, cls.la)
|
|
142
|
-
requestAnimationFrame(() => {
|
|
143
|
-
el.classList.remove(cls.lf)
|
|
144
|
-
el.classList.add(cls.lt)
|
|
145
|
-
let safetyTimer: ReturnType<typeof setTimeout> | null = null
|
|
146
|
-
const done = () => {
|
|
147
|
-
// Remove both listeners — only one fires, so clean up the other
|
|
148
|
-
el.removeEventListener('transitionend', done)
|
|
149
|
-
el.removeEventListener('animationend', done)
|
|
150
|
-
// Clear the safety timeout (see applyEnter for rationale).
|
|
151
|
-
if (safetyTimer !== null) {
|
|
152
|
-
clearTimeout(safetyTimer)
|
|
153
|
-
safetyTimer = null
|
|
154
|
-
}
|
|
155
|
-
el.classList.remove(cls.la, cls.lt)
|
|
156
|
-
pendingLeaveCancel = null
|
|
157
|
-
isMounted.set(false)
|
|
158
|
-
props.onAfterLeave?.(el)
|
|
159
|
-
}
|
|
160
|
-
pendingLeaveCancel = () => {
|
|
161
|
-
el.removeEventListener('transitionend', done)
|
|
162
|
-
el.removeEventListener('animationend', done)
|
|
163
|
-
if (safetyTimer !== null) {
|
|
164
|
-
clearTimeout(safetyTimer)
|
|
165
|
-
safetyTimer = null
|
|
166
|
-
}
|
|
167
|
-
el.classList.remove(cls.lf, cls.la, cls.lt)
|
|
168
|
-
}
|
|
169
|
-
el.addEventListener('transitionend', done, { once: true })
|
|
170
|
-
el.addEventListener('animationend', done, { once: true })
|
|
171
|
-
// Safety timeout: if CSS animation never fires, force cleanup
|
|
172
|
-
safetyTimer = setTimeout(done, 5000)
|
|
173
|
-
})
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const handleVisibilityChange = (visible: boolean) => {
|
|
177
|
-
if (visible) {
|
|
178
|
-
if (!isMounted.peek()) isMounted.set(true)
|
|
179
|
-
queueMicrotask(() => applyEnter(ref.current as HTMLElement))
|
|
180
|
-
return
|
|
181
|
-
}
|
|
182
|
-
if (!isMounted.peek()) return
|
|
183
|
-
const el = ref.current
|
|
184
|
-
if (!el) {
|
|
185
|
-
isMounted.set(false)
|
|
186
|
-
return
|
|
187
|
-
}
|
|
188
|
-
applyLeave(el)
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// queueMicrotask defers the appear-animation to after the DOM has
|
|
192
|
-
// committed the initial mount — that scheduling IS the reactive
|
|
193
|
-
// subscription's job here (it tracks `props.show()` and `props.appear`
|
|
194
|
-
// and schedules the visual transition). Not setup work.
|
|
195
|
-
// pyreon-lint-disable-next-line pyreon/no-imperative-effect-on-create
|
|
196
|
-
effect(() => {
|
|
197
|
-
const visible = props.show()
|
|
198
|
-
if (!initialized) {
|
|
199
|
-
initialized = true
|
|
200
|
-
if (visible && props.appear) {
|
|
201
|
-
queueMicrotask(() => applyEnter(ref.current as HTMLElement))
|
|
202
|
-
}
|
|
203
|
-
return
|
|
204
|
-
}
|
|
205
|
-
handleVisibilityChange(visible)
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
onUnmount(() => {
|
|
209
|
-
// Cancel both pending transitions so neither fires its onAfterX
|
|
210
|
-
// callback on a now-detached element after the 5s safety window.
|
|
211
|
-
pendingEnterCancel?.()
|
|
212
|
-
pendingEnterCancel = null
|
|
213
|
-
pendingLeaveCancel?.()
|
|
214
|
-
pendingLeaveCancel = null
|
|
215
|
-
})
|
|
216
|
-
|
|
217
|
-
// Return a reactive getter. Each call clones the child VNode with our injected ref
|
|
218
|
-
// so we can read / write classes on the underlying DOM element.
|
|
219
|
-
const rawChild = props.children
|
|
220
|
-
// Return an empty Fragment (not null) when unmounted so mountChild uses
|
|
221
|
-
// mountReactive instead of the null/primitive text-node fast-path, which
|
|
222
|
-
// cannot later be swapped for a VNode when the element enters.
|
|
223
|
-
const emptyFragment = h(Fragment, null)
|
|
224
|
-
return (() => {
|
|
225
|
-
if (!isMounted()) return emptyFragment
|
|
226
|
-
if (!rawChild || typeof rawChild !== 'object' || Array.isArray(rawChild)) {
|
|
227
|
-
return rawChild ?? null
|
|
228
|
-
}
|
|
229
|
-
const vnode = rawChild as VNode
|
|
230
|
-
// Only inject ref into DOM element children — component children need ref forwarding
|
|
231
|
-
if (typeof vnode.type !== 'string') {
|
|
232
|
-
if (__DEV__) {
|
|
233
|
-
console.warn(
|
|
234
|
-
'[Pyreon] Transition child is a component. Wrap it in a DOM element for enter/leave animations to work.',
|
|
235
|
-
)
|
|
236
|
-
}
|
|
237
|
-
return vnode
|
|
238
|
-
}
|
|
239
|
-
return { ...vnode, props: { ...vnode.props, ref } as Props }
|
|
240
|
-
}) as unknown as VNode
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Mark as native so compat-mode jsx() runtimes skip wrapCompatComponent —
|
|
244
|
-
// Transition uses signal/effect/onUnmount that need Pyreon's setup frame.
|
|
245
|
-
nativeCompat(Transition)
|