@pyreon/kinetic 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 +10 -12
- package/src/Collapse.tsx +0 -166
- package/src/Stagger.tsx +0 -63
- package/src/Transition.tsx +0 -280
- package/src/TransitionGroup.tsx +0 -139
- package/src/__tests__/Collapse.test.tsx +0 -803
- package/src/__tests__/GroupRenderer.test.tsx +0 -434
- package/src/__tests__/StaggerRenderer.test.tsx +0 -523
- package/src/__tests__/Transition.ssr.test.tsx +0 -183
- package/src/__tests__/Transition.test.tsx +0 -403
- package/src/__tests__/TransitionItem.test.tsx +0 -514
- package/src/__tests__/kinetic-modes.ssr.test.tsx +0 -214
- package/src/__tests__/kinetic.browser.test.tsx +0 -327
- package/src/__tests__/kinetic.test.tsx +0 -565
- package/src/__tests__/presets.test.ts +0 -46
- package/src/__tests__/stagger-component-children-hydration.test.tsx +0 -191
- package/src/__tests__/top-level-transition-stagger-function-children.test.tsx +0 -141
- package/src/__tests__/useAnimationEnd.test.ts +0 -194
- package/src/__tests__/useReducedMotion.test.ts +0 -160
- package/src/__tests__/useTransitionState.test.ts +0 -132
- package/src/__tests__/utils.test.ts +0 -139
- package/src/index.ts +0 -15
- package/src/jsx-augment.d.ts +0 -12
- package/src/kinetic/CollapseRenderer.tsx +0 -216
- package/src/kinetic/GroupRenderer.tsx +0 -149
- package/src/kinetic/StaggerRenderer.tsx +0 -94
- package/src/kinetic/TransitionItem.tsx +0 -250
- package/src/kinetic/TransitionRenderer.tsx +0 -230
- package/src/kinetic/createKineticComponent.tsx +0 -224
- package/src/kinetic/types.ts +0 -149
- package/src/kinetic.ts +0 -25
- package/src/presets.ts +0 -66
- package/src/types.ts +0 -118
- package/src/useAnimationEnd.ts +0 -59
- package/src/useReducedMotion.ts +0 -28
- package/src/useTransitionState.ts +0 -62
- package/src/utils.ts +0 -113
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import { signal } from '@pyreon/reactivity'
|
|
2
|
-
import useTransitionState from '../useTransitionState'
|
|
3
|
-
|
|
4
|
-
describe('useTransitionState', () => {
|
|
5
|
-
it('initial state is hidden when show=false', () => {
|
|
6
|
-
const show = signal(false)
|
|
7
|
-
const result = useTransitionState({ show })
|
|
8
|
-
expect(result.stage()).toBe('hidden')
|
|
9
|
-
expect(result.shouldMount()).toBe(false)
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
it('initial state is entered when show=true and appear=false', () => {
|
|
13
|
-
const show = signal(true)
|
|
14
|
-
const result = useTransitionState({ show })
|
|
15
|
-
expect(result.stage()).toBe('entered')
|
|
16
|
-
expect(result.shouldMount()).toBe(true)
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('transitions to entering when show changes false->true', () => {
|
|
20
|
-
const show = signal(false)
|
|
21
|
-
const result = useTransitionState({ show })
|
|
22
|
-
|
|
23
|
-
expect(result.stage()).toBe('hidden')
|
|
24
|
-
|
|
25
|
-
show.set(true)
|
|
26
|
-
expect(result.stage()).toBe('entering')
|
|
27
|
-
expect(result.shouldMount()).toBe(true)
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('complete() transitions entering->entered', () => {
|
|
31
|
-
const show = signal(false)
|
|
32
|
-
const result = useTransitionState({ show })
|
|
33
|
-
|
|
34
|
-
show.set(true)
|
|
35
|
-
expect(result.stage()).toBe('entering')
|
|
36
|
-
|
|
37
|
-
result.complete()
|
|
38
|
-
expect(result.stage()).toBe('entered')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('transitions to leaving when show changes true->false', () => {
|
|
42
|
-
const show = signal(true)
|
|
43
|
-
const result = useTransitionState({ show })
|
|
44
|
-
|
|
45
|
-
expect(result.stage()).toBe('entered')
|
|
46
|
-
|
|
47
|
-
show.set(false)
|
|
48
|
-
expect(result.stage()).toBe('leaving')
|
|
49
|
-
expect(result.shouldMount()).toBe(true)
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it('complete() transitions leaving->hidden', () => {
|
|
53
|
-
const show = signal(true)
|
|
54
|
-
const result = useTransitionState({ show })
|
|
55
|
-
|
|
56
|
-
show.set(false)
|
|
57
|
-
expect(result.stage()).toBe('leaving')
|
|
58
|
-
|
|
59
|
-
result.complete()
|
|
60
|
-
expect(result.stage()).toBe('hidden')
|
|
61
|
-
expect(result.shouldMount()).toBe(false)
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('appear=true enters after ref is connected', () => {
|
|
65
|
-
const show = signal(true)
|
|
66
|
-
const result = useTransitionState({ show, appear: true })
|
|
67
|
-
// Before ref is wired, element should be mounted but stage is 'entered'
|
|
68
|
-
expect(result.stage()).toBe('entered')
|
|
69
|
-
expect(result.shouldMount()).toBe(true)
|
|
70
|
-
|
|
71
|
-
// Simulate ref connection (as the renderer would do)
|
|
72
|
-
const el = document.createElement('div')
|
|
73
|
-
if (typeof result.ref === 'function') {
|
|
74
|
-
result.ref(el)
|
|
75
|
-
}
|
|
76
|
-
// Now the appear animation should trigger
|
|
77
|
-
expect(result.stage()).toBe('entering')
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('complete() is a no-op in entered state', () => {
|
|
81
|
-
const show = signal(true)
|
|
82
|
-
const result = useTransitionState({ show })
|
|
83
|
-
|
|
84
|
-
expect(result.stage()).toBe('entered')
|
|
85
|
-
|
|
86
|
-
result.complete()
|
|
87
|
-
expect(result.stage()).toBe('entered')
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('complete() is a no-op in hidden state', () => {
|
|
91
|
-
const show = signal(false)
|
|
92
|
-
const result = useTransitionState({ show })
|
|
93
|
-
|
|
94
|
-
expect(result.stage()).toBe('hidden')
|
|
95
|
-
|
|
96
|
-
result.complete()
|
|
97
|
-
expect(result.stage()).toBe('hidden')
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('handles rapid toggling true->false->true', () => {
|
|
101
|
-
const show = signal(true)
|
|
102
|
-
const result = useTransitionState({ show })
|
|
103
|
-
|
|
104
|
-
// Start leave
|
|
105
|
-
show.set(false)
|
|
106
|
-
expect(result.stage()).toBe('leaving')
|
|
107
|
-
|
|
108
|
-
// Interrupt with enter before leave completes
|
|
109
|
-
show.set(true)
|
|
110
|
-
expect(result.stage()).toBe('entering')
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
it('handles rapid toggling false->true->false (entering to leaving)', () => {
|
|
114
|
-
const show = signal(false)
|
|
115
|
-
const result = useTransitionState({ show })
|
|
116
|
-
|
|
117
|
-
// Start enter
|
|
118
|
-
show.set(true)
|
|
119
|
-
expect(result.stage()).toBe('entering')
|
|
120
|
-
|
|
121
|
-
// Interrupt with leave before enter completes
|
|
122
|
-
show.set(false)
|
|
123
|
-
expect(result.stage()).toBe('leaving')
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
it('provides a ref (callback or object)', () => {
|
|
127
|
-
const show = signal(false)
|
|
128
|
-
const result = useTransitionState({ show })
|
|
129
|
-
expect(result.ref).toBeDefined()
|
|
130
|
-
expect(typeof result.ref === 'function' || 'current' in result.ref).toBe(true)
|
|
131
|
-
})
|
|
132
|
-
})
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import type { CSSProperties } from '../types'
|
|
3
|
-
import { addClasses, mergeClassNames, mergeStyles, nextFrame, removeClasses } from '../utils'
|
|
4
|
-
|
|
5
|
-
describe('mergeClassNames', () => {
|
|
6
|
-
it('merges two class strings', () => {
|
|
7
|
-
expect(mergeClassNames('a', 'b')).toBe('a b')
|
|
8
|
-
})
|
|
9
|
-
|
|
10
|
-
it('returns existing when additional is undefined', () => {
|
|
11
|
-
expect(mergeClassNames('a', undefined)).toBe('a')
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
it('returns additional when existing is undefined', () => {
|
|
15
|
-
expect(mergeClassNames(undefined, 'b')).toBe('b')
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
it('returns undefined when both are undefined', () => {
|
|
19
|
-
expect(mergeClassNames(undefined, undefined)).toBeUndefined()
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it('returns undefined when both are empty strings', () => {
|
|
23
|
-
expect(mergeClassNames('', '')).toBeUndefined()
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
it('filters out empty strings', () => {
|
|
27
|
-
expect(mergeClassNames('a', '')).toBe('a')
|
|
28
|
-
})
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
describe('mergeStyles', () => {
|
|
32
|
-
it('merges two style objects with b taking precedence', () => {
|
|
33
|
-
const a = { color: 'red', fontSize: '12px' } as CSSProperties
|
|
34
|
-
const b = { color: 'blue' } as CSSProperties
|
|
35
|
-
expect(mergeStyles(a, b)).toEqual({ color: 'blue', fontSize: '12px' })
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('returns undefined when both are undefined', () => {
|
|
39
|
-
expect(mergeStyles(undefined, undefined)).toBeUndefined()
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('returns b when a is undefined', () => {
|
|
43
|
-
const b = { color: 'blue' } as CSSProperties
|
|
44
|
-
expect(mergeStyles(undefined, b)).toBe(b)
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('returns a when b is undefined', () => {
|
|
48
|
-
const a = { color: 'red' } as CSSProperties
|
|
49
|
-
expect(mergeStyles(a, undefined)).toBe(a)
|
|
50
|
-
})
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
describe('addClasses', () => {
|
|
54
|
-
it('adds space-separated classes to an element', () => {
|
|
55
|
-
const el = document.createElement('div')
|
|
56
|
-
addClasses(el, 'foo bar')
|
|
57
|
-
expect(el.classList.contains('foo')).toBe(true)
|
|
58
|
-
expect(el.classList.contains('bar')).toBe(true)
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('does nothing when classes is undefined', () => {
|
|
62
|
-
const el = document.createElement('div')
|
|
63
|
-
addClasses(el, undefined)
|
|
64
|
-
expect(el.classList.length).toBe(0)
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('does nothing when classes is empty string', () => {
|
|
68
|
-
const el = document.createElement('div')
|
|
69
|
-
addClasses(el, '')
|
|
70
|
-
expect(el.classList.length).toBe(0)
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('does nothing when classes is whitespace-only', () => {
|
|
74
|
-
const el = document.createElement('div')
|
|
75
|
-
addClasses(el, ' ')
|
|
76
|
-
expect(el.classList.length).toBe(0)
|
|
77
|
-
})
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
describe('removeClasses', () => {
|
|
81
|
-
it('removes space-separated classes from an element', () => {
|
|
82
|
-
const el = document.createElement('div')
|
|
83
|
-
el.classList.add('foo', 'bar', 'baz')
|
|
84
|
-
removeClasses(el, 'foo bar')
|
|
85
|
-
expect(el.classList.contains('foo')).toBe(false)
|
|
86
|
-
expect(el.classList.contains('bar')).toBe(false)
|
|
87
|
-
expect(el.classList.contains('baz')).toBe(true)
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('does nothing when classes is undefined', () => {
|
|
91
|
-
const el = document.createElement('div')
|
|
92
|
-
el.classList.add('foo')
|
|
93
|
-
removeClasses(el, undefined)
|
|
94
|
-
expect(el.classList.contains('foo')).toBe(true)
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('does nothing when classes is empty string', () => {
|
|
98
|
-
const el = document.createElement('div')
|
|
99
|
-
el.classList.add('foo')
|
|
100
|
-
removeClasses(el, '')
|
|
101
|
-
expect(el.classList.contains('foo')).toBe(true)
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('does nothing when classes is whitespace-only', () => {
|
|
105
|
-
const el = document.createElement('div')
|
|
106
|
-
el.classList.add('foo')
|
|
107
|
-
removeClasses(el, ' ')
|
|
108
|
-
expect(el.classList.contains('foo')).toBe(true)
|
|
109
|
-
})
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
describe('nextFrame', () => {
|
|
113
|
-
it('calls callback after double rAF', () => {
|
|
114
|
-
const callbacks: (() => void)[] = []
|
|
115
|
-
const originalRaf = globalThis.requestAnimationFrame
|
|
116
|
-
globalThis.requestAnimationFrame = ((cb: () => void) => {
|
|
117
|
-
callbacks.push(cb)
|
|
118
|
-
return callbacks.length
|
|
119
|
-
}) as typeof requestAnimationFrame
|
|
120
|
-
|
|
121
|
-
const fn = vi.fn()
|
|
122
|
-
nextFrame(fn)
|
|
123
|
-
|
|
124
|
-
// First rAF queued
|
|
125
|
-
expect(callbacks.length).toBe(1)
|
|
126
|
-
expect(fn).not.toHaveBeenCalled()
|
|
127
|
-
|
|
128
|
-
// Execute first rAF — queues second
|
|
129
|
-
callbacks[0]?.()
|
|
130
|
-
expect(callbacks.length).toBe(2)
|
|
131
|
-
expect(fn).not.toHaveBeenCalled()
|
|
132
|
-
|
|
133
|
-
// Execute second rAF — callback fires
|
|
134
|
-
callbacks[1]?.()
|
|
135
|
-
expect(fn).toHaveBeenCalledTimes(1)
|
|
136
|
-
|
|
137
|
-
globalThis.requestAnimationFrame = originalRaf
|
|
138
|
-
})
|
|
139
|
-
})
|
package/src/index.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export { default as kinetic } from './kinetic'
|
|
2
|
-
export type { KineticComponent } from './kinetic/types'
|
|
3
|
-
export type { Preset } from './presets'
|
|
4
|
-
export { fade, presets, scaleIn, slideDown, slideLeft, slideRight, slideUp } from './presets'
|
|
5
|
-
export type {
|
|
6
|
-
ClassTransitionProps,
|
|
7
|
-
StyleTransitionProps,
|
|
8
|
-
TransitionCallbacks,
|
|
9
|
-
TransitionStage,
|
|
10
|
-
TransitionStateResult,
|
|
11
|
-
} from './types'
|
|
12
|
-
export type { UseAnimationEnd } from './useAnimationEnd'
|
|
13
|
-
export { default as useAnimationEnd } from './useAnimationEnd'
|
|
14
|
-
export type { UseTransitionState } from './useTransitionState'
|
|
15
|
-
export { default as useTransitionState } from './useTransitionState'
|
package/src/jsx-augment.d.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
// Augment JSX namespace so that `key` is accepted on component elements,
|
|
2
|
-
// not just intrinsic HTML elements. Pyreon's jsx() runtime already handles
|
|
3
|
-
// key as the third argument — this just satisfies TypeScript.
|
|
4
|
-
declare global {
|
|
5
|
-
namespace JSX {
|
|
6
|
-
interface IntrinsicAttributes {
|
|
7
|
-
key?: string | number
|
|
8
|
-
}
|
|
9
|
-
}
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export {}
|
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
import type { VNode } from '@pyreon/core'
|
|
2
|
-
import { createRef, h, mergeProps, 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
|
-
// Initially-visible Collapses keep the original Show-gated inner content,
|
|
170
|
-
// preserving the runtime-unmount semantic that frees the inner subtree
|
|
171
|
-
// when the collapse is closed long-term. The SSR bug fires only when
|
|
172
|
-
// `show: () => false` at setup — the outer wrapper renders (with
|
|
173
|
-
// `height: 0; overflow: hidden`) but its children are stripped by the
|
|
174
|
-
// inner `<Show when={false}>` → empty wrapper in the prerendered HTML.
|
|
175
|
-
// Bad for SEO / social scrapers / accessibility / no-JS.
|
|
176
|
-
//
|
|
177
|
-
// Mirrors the fix shape applied to `<Transition>` (PR #717), the
|
|
178
|
-
// `TransitionRenderer` and `TransitionItem` (this PR). Ecosystem norm:
|
|
179
|
-
// content is structural, animation is visual.
|
|
180
|
-
//
|
|
181
|
-
// For initially-hidden Collapses, the inner content always renders —
|
|
182
|
-
// the outer wrapper's `height: 0px; overflow: hidden` already provides
|
|
183
|
-
// the visual hiding (genuinely layout-safe — no flex slot collapse;
|
|
184
|
-
// the outer wrapper participates in flex as a 0-height box, which is
|
|
185
|
-
// the standard CSS collapse behavior). When `show` flips true, the
|
|
186
|
-
// existing `watch(stage)` measures `content.scrollHeight` and animates
|
|
187
|
-
// height from 0 → that value — no change to the animation path.
|
|
188
|
-
//
|
|
189
|
-
// Trade-off: for initially-hidden Collapses, the inner subtree is
|
|
190
|
-
// ALWAYS mounted (never unmounted after a later close). Initially-
|
|
191
|
-
// visible Collapses keep the unmount behavior. Matches the trade-off
|
|
192
|
-
// documented across the other three kinetic renderers.
|
|
193
|
-
const wasInitiallyShown = show()
|
|
194
|
-
const innerContent = wasInitiallyShown ? (
|
|
195
|
-
<Show when={shouldRender}>
|
|
196
|
-
<div ref={contentRef}>{children}</div>
|
|
197
|
-
</Show>
|
|
198
|
-
) : (
|
|
199
|
-
<div ref={contentRef}>{children}</div>
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
// mergeProps (descriptor-preserving) instead of `{ ...htmlProps }` —
|
|
203
|
-
// every non-style HTML attr keeps its reactive getter; ref + the
|
|
204
|
-
// collapse-controlled style come last so they win (mergeProps is
|
|
205
|
-
// last-source-wins). The one-time `htmlProps.style` read above that
|
|
206
|
-
// seeds wrapperStyle is intentional: collapse OWNS the style prop
|
|
207
|
-
// (height/overflow are animation-driven), so a static merge of the
|
|
208
|
-
// user's initial style with the collapse overrides is correct here.
|
|
209
|
-
return h(
|
|
210
|
-
config.tag,
|
|
211
|
-
mergeProps(htmlProps, { ref: wrapperRef, style: wrapperStyle }),
|
|
212
|
-
innerContent,
|
|
213
|
-
)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export default CollapseRenderer
|
|
@@ -1,149 +0,0 @@
|
|
|
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
|
-
/**
|
|
15
|
-
* Children can be a static array OR a reactive accessor `() => VNode[]`.
|
|
16
|
-
* When passed as an accessor, GroupRenderer tracks changes and
|
|
17
|
-
* animates entering/leaving children automatically.
|
|
18
|
-
*/
|
|
19
|
-
children: VNode[] | (() => VNode[])
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
type KeyedChild = { key: string | number; element: VNode }
|
|
23
|
-
|
|
24
|
-
const isVNode = (child: unknown): child is VNode =>
|
|
25
|
-
child != null && typeof child === 'object' && 'type' in (child as object)
|
|
26
|
-
|
|
27
|
-
const getKeyedChildren = (children: VNode[]): KeyedChild[] => {
|
|
28
|
-
const result: KeyedChild[] = []
|
|
29
|
-
for (const child of children) {
|
|
30
|
-
if (isVNode(child)) {
|
|
31
|
-
const key = (child as VNode & { key?: string | number }).key
|
|
32
|
-
if (key != null) {
|
|
33
|
-
result.push({ key, element: child })
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return result
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Renders children with key-based enter/exit animation (no `show` prop).
|
|
42
|
-
* Children that appear (new key) animate in. Children that disappear
|
|
43
|
-
* (removed key) stay in DOM during leave animation, then unmount.
|
|
44
|
-
* config.tag wraps all children as a container element.
|
|
45
|
-
*
|
|
46
|
-
* In Pyreon, components run once. Pass children as a reactive accessor
|
|
47
|
-
* `() => VNode[]` for the group to detect changes and animate entries/exits.
|
|
48
|
-
*/
|
|
49
|
-
const GroupRenderer = ({
|
|
50
|
-
config,
|
|
51
|
-
htmlProps,
|
|
52
|
-
appear,
|
|
53
|
-
timeout,
|
|
54
|
-
callbacks,
|
|
55
|
-
children,
|
|
56
|
-
}: GroupRendererProps): VNode | null => {
|
|
57
|
-
const effectiveAppear = appear ?? config.appear ?? false
|
|
58
|
-
const effectiveTimeout = timeout ?? config.timeout ?? 5000
|
|
59
|
-
|
|
60
|
-
const prevMap = new Map<string | number, VNode>()
|
|
61
|
-
const leavingMap = new Map<string | number, VNode>()
|
|
62
|
-
const forceUpdate = signal(0)
|
|
63
|
-
|
|
64
|
-
// Normalize children to an accessor
|
|
65
|
-
const getChildren = typeof children === 'function' ? (children as () => VNode[]) : () => children
|
|
66
|
-
|
|
67
|
-
// Track initial keys for appear animation logic
|
|
68
|
-
const initialKeyed = getKeyedChildren(getChildren())
|
|
69
|
-
const initialKeys = new Set(initialKeyed.map((c) => c.key))
|
|
70
|
-
for (const { key, element } of initialKeyed) {
|
|
71
|
-
prevMap.set(key, element)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const handleAfterLeave = (key: string | number) => {
|
|
75
|
-
leavingMap.delete(key)
|
|
76
|
-
callbacks.onAfterLeave?.()
|
|
77
|
-
forceUpdate.update((c) => c + 1)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Reactive accessor — re-evaluates when children() or forceUpdate changes
|
|
81
|
-
return (() => {
|
|
82
|
-
forceUpdate()
|
|
83
|
-
|
|
84
|
-
const currentChildren = getChildren()
|
|
85
|
-
const currentKeyed = getKeyedChildren(currentChildren)
|
|
86
|
-
const currentMap = new Map<string | number, VNode>()
|
|
87
|
-
for (const { key, element } of currentKeyed) {
|
|
88
|
-
currentMap.set(key, element)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Detect leaving children
|
|
92
|
-
for (const [key, child] of prevMap) {
|
|
93
|
-
if (!currentMap.has(key) && !leavingMap.has(key)) {
|
|
94
|
-
leavingMap.set(key, child)
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Cancel leave if child reappears
|
|
99
|
-
for (const key of currentMap.keys()) {
|
|
100
|
-
leavingMap.delete(key)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Update prev for next diff
|
|
104
|
-
prevMap.clear()
|
|
105
|
-
for (const [key, element] of currentMap) {
|
|
106
|
-
prevMap.set(key, element)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Merge current + leaving
|
|
110
|
-
const allEntries: KeyedChild[] = [...currentKeyed]
|
|
111
|
-
for (const [key, element] of leavingMap) {
|
|
112
|
-
allEntries.push({ key, element })
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const groupedChildren = allEntries.map(({ key, element }) => {
|
|
116
|
-
const isInitial = initialKeys.has(key)
|
|
117
|
-
const isShowing = currentMap.has(key)
|
|
118
|
-
|
|
119
|
-
return (
|
|
120
|
-
<TransitionItem
|
|
121
|
-
show={() => isShowing}
|
|
122
|
-
appear={isInitial ? effectiveAppear : true}
|
|
123
|
-
timeout={effectiveTimeout}
|
|
124
|
-
enterStyle={config.enterStyle}
|
|
125
|
-
enterToStyle={config.enterToStyle}
|
|
126
|
-
enterTransition={config.enterTransition}
|
|
127
|
-
leaveStyle={config.leaveStyle}
|
|
128
|
-
leaveToStyle={config.leaveToStyle}
|
|
129
|
-
leaveTransition={config.leaveTransition}
|
|
130
|
-
enter={config.enter}
|
|
131
|
-
enterFrom={config.enterFrom}
|
|
132
|
-
enterTo={config.enterTo}
|
|
133
|
-
leave={config.leave}
|
|
134
|
-
leaveFrom={config.leaveFrom}
|
|
135
|
-
leaveTo={config.leaveTo}
|
|
136
|
-
onAfterLeave={() => handleAfterLeave(key)}
|
|
137
|
-
>
|
|
138
|
-
{element}
|
|
139
|
-
</TransitionItem>
|
|
140
|
-
)
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
// By reference — `{ ...htmlProps }` would value-copy and freeze any
|
|
144
|
-
// reactive HTML attr the kinetic split preserved as a getter.
|
|
145
|
-
return h(config.tag, htmlProps, ...groupedChildren)
|
|
146
|
-
}) as unknown as VNode
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export default GroupRenderer
|