@pyreon/kinetic 0.11.1 → 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.
- package/package.json +6 -5
- package/src/Collapse.tsx +172 -0
- package/src/Stagger.tsx +52 -0
- package/src/Transition.tsx +214 -0
- package/src/TransitionGroup.tsx +108 -0
- package/src/__tests__/Collapse.test.tsx +782 -0
- package/src/__tests__/GroupRenderer.test.tsx +388 -0
- package/src/__tests__/StaggerRenderer.test.tsx +526 -0
- package/src/__tests__/Transition.test.tsx +406 -0
- package/src/__tests__/TransitionItem.test.tsx +522 -0
- package/src/__tests__/kinetic.test.tsx +562 -0
- package/src/__tests__/presets.test.ts +46 -0
- package/src/__tests__/useAnimationEnd.test.ts +194 -0
- package/src/__tests__/useReducedMotion.test.ts +157 -0
- package/src/__tests__/useTransitionState.test.ts +132 -0
- package/src/__tests__/utils.test.ts +139 -0
- package/src/index.ts +23 -0
- package/src/jsx-augment.d.ts +12 -0
- package/src/kinetic/CollapseRenderer.tsx +178 -0
- package/src/kinetic/GroupRenderer.tsx +124 -0
- package/src/kinetic/StaggerRenderer.tsx +88 -0
- package/src/kinetic/TransitionItem.tsx +196 -0
- package/src/kinetic/TransitionRenderer.tsx +168 -0
- package/src/kinetic/createKineticComponent.tsx +211 -0
- package/src/kinetic/types.ts +149 -0
- package/src/kinetic.ts +25 -0
- package/src/presets.ts +66 -0
- package/src/types.ts +118 -0
- package/src/useAnimationEnd.ts +59 -0
- package/src/useReducedMotion.ts +28 -0
- package/src/useTransitionState.ts +62 -0
- package/src/utils.ts +81 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { signal } from "@pyreon/reactivity"
|
|
2
|
+
import useAnimationEnd from "../useAnimationEnd"
|
|
3
|
+
|
|
4
|
+
const createMockRef = () => {
|
|
5
|
+
const el = document.createElement("div")
|
|
6
|
+
return { current: el }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("useAnimationEnd", () => {
|
|
10
|
+
beforeEach(() => vi.useFakeTimers())
|
|
11
|
+
afterEach(() => vi.useRealTimers())
|
|
12
|
+
|
|
13
|
+
it("calls onEnd when transitionend fires on the element", () => {
|
|
14
|
+
const onEnd = vi.fn()
|
|
15
|
+
const ref = createMockRef()
|
|
16
|
+
const active = signal(true)
|
|
17
|
+
|
|
18
|
+
useAnimationEnd({ ref, onEnd, active })
|
|
19
|
+
|
|
20
|
+
const event = new Event("transitionend", { bubbles: true })
|
|
21
|
+
Object.defineProperty(event, "target", { value: ref.current })
|
|
22
|
+
ref.current.dispatchEvent(event)
|
|
23
|
+
|
|
24
|
+
expect(onEnd).toHaveBeenCalledTimes(1)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("calls onEnd when animationend fires on the element", () => {
|
|
28
|
+
const onEnd = vi.fn()
|
|
29
|
+
const ref = createMockRef()
|
|
30
|
+
const active = signal(true)
|
|
31
|
+
|
|
32
|
+
useAnimationEnd({ ref, onEnd, active })
|
|
33
|
+
|
|
34
|
+
const event = new Event("animationend", { bubbles: true })
|
|
35
|
+
Object.defineProperty(event, "target", { value: ref.current })
|
|
36
|
+
ref.current.dispatchEvent(event)
|
|
37
|
+
|
|
38
|
+
expect(onEnd).toHaveBeenCalledTimes(1)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("ignores bubbled events from children", () => {
|
|
42
|
+
const onEnd = vi.fn()
|
|
43
|
+
const ref = createMockRef()
|
|
44
|
+
const child = document.createElement("span")
|
|
45
|
+
ref.current.appendChild(child)
|
|
46
|
+
const active = signal(true)
|
|
47
|
+
|
|
48
|
+
useAnimationEnd({ ref, onEnd, active })
|
|
49
|
+
|
|
50
|
+
const event = new Event("transitionend", { bubbles: true })
|
|
51
|
+
child.dispatchEvent(event)
|
|
52
|
+
|
|
53
|
+
expect(onEnd).not.toHaveBeenCalled()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("fires timeout fallback when no event fires", () => {
|
|
57
|
+
const onEnd = vi.fn()
|
|
58
|
+
const ref = createMockRef()
|
|
59
|
+
const active = signal(true)
|
|
60
|
+
|
|
61
|
+
useAnimationEnd({ ref, onEnd, active, timeout: 1000 })
|
|
62
|
+
|
|
63
|
+
expect(onEnd).not.toHaveBeenCalled()
|
|
64
|
+
|
|
65
|
+
vi.advanceTimersByTime(1000)
|
|
66
|
+
|
|
67
|
+
expect(onEnd).toHaveBeenCalledTimes(1)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("uses default timeout of 5000ms", () => {
|
|
71
|
+
const onEnd = vi.fn()
|
|
72
|
+
const ref = createMockRef()
|
|
73
|
+
const active = signal(true)
|
|
74
|
+
|
|
75
|
+
useAnimationEnd({ ref, onEnd, active })
|
|
76
|
+
|
|
77
|
+
vi.advanceTimersByTime(4999)
|
|
78
|
+
expect(onEnd).not.toHaveBeenCalled()
|
|
79
|
+
|
|
80
|
+
vi.advanceTimersByTime(1)
|
|
81
|
+
expect(onEnd).toHaveBeenCalledTimes(1)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("only fires onEnd once even if multiple events fire", () => {
|
|
85
|
+
const onEnd = vi.fn()
|
|
86
|
+
const ref = createMockRef()
|
|
87
|
+
const active = signal(true)
|
|
88
|
+
|
|
89
|
+
useAnimationEnd({ ref, onEnd, active })
|
|
90
|
+
|
|
91
|
+
const event1 = new Event("transitionend", { bubbles: true })
|
|
92
|
+
Object.defineProperty(event1, "target", { value: ref.current })
|
|
93
|
+
ref.current.dispatchEvent(event1)
|
|
94
|
+
|
|
95
|
+
const event2 = new Event("animationend", { bubbles: true })
|
|
96
|
+
Object.defineProperty(event2, "target", { value: ref.current })
|
|
97
|
+
ref.current.dispatchEvent(event2)
|
|
98
|
+
|
|
99
|
+
expect(onEnd).toHaveBeenCalledTimes(1)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("does not fire when active is false", () => {
|
|
103
|
+
const onEnd = vi.fn()
|
|
104
|
+
const ref = createMockRef()
|
|
105
|
+
const active = signal(false)
|
|
106
|
+
|
|
107
|
+
useAnimationEnd({ ref, onEnd, active, timeout: 100 })
|
|
108
|
+
|
|
109
|
+
vi.advanceTimersByTime(200)
|
|
110
|
+
|
|
111
|
+
expect(onEnd).not.toHaveBeenCalled()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("does not fire when active=true but ref.current is null", () => {
|
|
115
|
+
const onEnd = vi.fn()
|
|
116
|
+
const ref = { current: null } as { current: HTMLElement | null }
|
|
117
|
+
const active = signal(true)
|
|
118
|
+
|
|
119
|
+
useAnimationEnd({ ref, onEnd, active, timeout: 100 })
|
|
120
|
+
|
|
121
|
+
// No timer should be set when ref is null
|
|
122
|
+
vi.advanceTimersByTime(200)
|
|
123
|
+
|
|
124
|
+
expect(onEnd).not.toHaveBeenCalled()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("does not call onEnd twice when transitionend fires and then timeout fires", () => {
|
|
128
|
+
const onEnd = vi.fn()
|
|
129
|
+
const ref = createMockRef()
|
|
130
|
+
const active = signal(true)
|
|
131
|
+
|
|
132
|
+
useAnimationEnd({ ref, onEnd, active, timeout: 1000 })
|
|
133
|
+
|
|
134
|
+
// First: transitionend fires — calls done()
|
|
135
|
+
const event = new Event("transitionend", { bubbles: true })
|
|
136
|
+
Object.defineProperty(event, "target", { value: ref.current })
|
|
137
|
+
ref.current.dispatchEvent(event)
|
|
138
|
+
|
|
139
|
+
expect(onEnd).toHaveBeenCalledTimes(1)
|
|
140
|
+
|
|
141
|
+
// Second: timeout fires — should be no-op because called is true
|
|
142
|
+
vi.advanceTimersByTime(1000)
|
|
143
|
+
|
|
144
|
+
expect(onEnd).toHaveBeenCalledTimes(1)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it("does not call onEnd twice when timeout fires and then transitionend fires", () => {
|
|
148
|
+
const onEnd = vi.fn()
|
|
149
|
+
const ref = createMockRef()
|
|
150
|
+
const active = signal(true)
|
|
151
|
+
|
|
152
|
+
useAnimationEnd({ ref, onEnd, active, timeout: 500 })
|
|
153
|
+
|
|
154
|
+
// First: timeout fires — calls done()
|
|
155
|
+
vi.advanceTimersByTime(500)
|
|
156
|
+
|
|
157
|
+
expect(onEnd).toHaveBeenCalledTimes(1)
|
|
158
|
+
|
|
159
|
+
// Second: transitionend fires — should be no-op via called guard
|
|
160
|
+
const event = new Event("transitionend", { bubbles: true })
|
|
161
|
+
Object.defineProperty(event, "target", { value: ref.current })
|
|
162
|
+
ref.current.dispatchEvent(event)
|
|
163
|
+
|
|
164
|
+
expect(onEnd).toHaveBeenCalledTimes(1)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("resets called when active transitions from true to false", () => {
|
|
168
|
+
const onEnd = vi.fn()
|
|
169
|
+
const ref = createMockRef()
|
|
170
|
+
const active = signal(true)
|
|
171
|
+
|
|
172
|
+
useAnimationEnd({ ref, onEnd, active, timeout: 1000 })
|
|
173
|
+
|
|
174
|
+
// Fire to set called = true
|
|
175
|
+
const event = new Event("transitionend", { bubbles: true })
|
|
176
|
+
Object.defineProperty(event, "target", { value: ref.current })
|
|
177
|
+
ref.current.dispatchEvent(event)
|
|
178
|
+
|
|
179
|
+
expect(onEnd).toHaveBeenCalledTimes(1)
|
|
180
|
+
|
|
181
|
+
// Deactivate — resets called
|
|
182
|
+
active.set(false)
|
|
183
|
+
|
|
184
|
+
// Re-activate
|
|
185
|
+
active.set(true)
|
|
186
|
+
|
|
187
|
+
// Should be able to fire again
|
|
188
|
+
const event2 = new Event("transitionend", { bubbles: true })
|
|
189
|
+
Object.defineProperty(event2, "target", { value: ref.current })
|
|
190
|
+
ref.current.dispatchEvent(event2)
|
|
191
|
+
|
|
192
|
+
expect(onEnd).toHaveBeenCalledTimes(2)
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Track lifecycle callbacks
|
|
2
|
+
let mountCallbacks: Array<() => undefined | (() => void)> = []
|
|
3
|
+
let unmountCallbacks: Array<() => void> = []
|
|
4
|
+
|
|
5
|
+
vi.mock("@pyreon/core", () => ({
|
|
6
|
+
onMount: vi.fn((cb: () => undefined | (() => void)) => {
|
|
7
|
+
mountCallbacks.push(cb)
|
|
8
|
+
}),
|
|
9
|
+
onUnmount: vi.fn((cb: () => void) => {
|
|
10
|
+
unmountCallbacks.push(cb)
|
|
11
|
+
}),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
vi.mock("@pyreon/reactivity", () => {
|
|
15
|
+
const signal = <T>(initial: T) => {
|
|
16
|
+
let value = initial
|
|
17
|
+
const s = (() => value) as (() => T) & {
|
|
18
|
+
set: (v: T) => void
|
|
19
|
+
update: (fn: (c: T) => T) => void
|
|
20
|
+
peek: () => T
|
|
21
|
+
subscribe: () => () => void
|
|
22
|
+
direct: () => () => void
|
|
23
|
+
label: string | undefined
|
|
24
|
+
debug: () => { name: string | undefined; value: T; subscriberCount: number }
|
|
25
|
+
}
|
|
26
|
+
s.set = (v: T) => {
|
|
27
|
+
value = v
|
|
28
|
+
}
|
|
29
|
+
s.update = (fn: (c: T) => T) => {
|
|
30
|
+
value = fn(value)
|
|
31
|
+
}
|
|
32
|
+
s.peek = () => value
|
|
33
|
+
s.subscribe = () => () => undefined
|
|
34
|
+
s.direct = () => () => undefined
|
|
35
|
+
s.label = undefined
|
|
36
|
+
s.debug = () => ({ name: undefined, value, subscriberCount: 0 })
|
|
37
|
+
return s
|
|
38
|
+
}
|
|
39
|
+
return { signal }
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
import { useReducedMotion } from "../useReducedMotion"
|
|
43
|
+
|
|
44
|
+
describe("useReducedMotion", () => {
|
|
45
|
+
let changeHandlers: Array<(e: any) => void>
|
|
46
|
+
let removedHandlers: Array<(e: any) => void>
|
|
47
|
+
|
|
48
|
+
const createMockMQL = (matches: boolean) => ({
|
|
49
|
+
matches,
|
|
50
|
+
media: "(prefers-reduced-motion: reduce)",
|
|
51
|
+
addEventListener: vi.fn((event: string, handler: (e: any) => void) => {
|
|
52
|
+
if (event === "change") changeHandlers.push(handler)
|
|
53
|
+
}),
|
|
54
|
+
removeEventListener: vi.fn((event: string, handler: (e: any) => void) => {
|
|
55
|
+
if (event === "change") removedHandlers.push(handler)
|
|
56
|
+
}),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
mountCallbacks = []
|
|
61
|
+
unmountCallbacks = []
|
|
62
|
+
changeHandlers = []
|
|
63
|
+
removedHandlers = []
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
vi.restoreAllMocks()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("returns false initially", () => {
|
|
71
|
+
vi.stubGlobal(
|
|
72
|
+
"matchMedia",
|
|
73
|
+
vi.fn(() => createMockMQL(false)),
|
|
74
|
+
)
|
|
75
|
+
const result = useReducedMotion()
|
|
76
|
+
expect(result()).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("reads matchMedia state on mount (true)", () => {
|
|
80
|
+
vi.stubGlobal(
|
|
81
|
+
"matchMedia",
|
|
82
|
+
vi.fn(() => createMockMQL(true)),
|
|
83
|
+
)
|
|
84
|
+
const result = useReducedMotion()
|
|
85
|
+
|
|
86
|
+
// Fire mount callback
|
|
87
|
+
for (const cb of mountCallbacks) cb()
|
|
88
|
+
|
|
89
|
+
expect(result()).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("reads matchMedia state on mount (false)", () => {
|
|
93
|
+
vi.stubGlobal(
|
|
94
|
+
"matchMedia",
|
|
95
|
+
vi.fn(() => createMockMQL(false)),
|
|
96
|
+
)
|
|
97
|
+
const result = useReducedMotion()
|
|
98
|
+
|
|
99
|
+
for (const cb of mountCallbacks) cb()
|
|
100
|
+
|
|
101
|
+
expect(result()).toBe(false)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("reacts to change events", () => {
|
|
105
|
+
vi.stubGlobal(
|
|
106
|
+
"matchMedia",
|
|
107
|
+
vi.fn(() => createMockMQL(false)),
|
|
108
|
+
)
|
|
109
|
+
const result = useReducedMotion()
|
|
110
|
+
|
|
111
|
+
for (const cb of mountCallbacks) cb()
|
|
112
|
+
expect(result()).toBe(false)
|
|
113
|
+
|
|
114
|
+
// Simulate preference change
|
|
115
|
+
for (const handler of changeHandlers) {
|
|
116
|
+
handler({ matches: true })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
expect(result()).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("queries the correct media string", () => {
|
|
123
|
+
const mockMatchMedia = vi.fn(() => createMockMQL(false))
|
|
124
|
+
vi.stubGlobal("matchMedia", mockMatchMedia)
|
|
125
|
+
|
|
126
|
+
useReducedMotion()
|
|
127
|
+
for (const cb of mountCallbacks) cb()
|
|
128
|
+
|
|
129
|
+
expect(mockMatchMedia).toHaveBeenCalledWith("(prefers-reduced-motion: reduce)")
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("registers a change listener on mount", () => {
|
|
133
|
+
vi.stubGlobal(
|
|
134
|
+
"matchMedia",
|
|
135
|
+
vi.fn(() => createMockMQL(false)),
|
|
136
|
+
)
|
|
137
|
+
useReducedMotion()
|
|
138
|
+
|
|
139
|
+
for (const cb of mountCallbacks) cb()
|
|
140
|
+
|
|
141
|
+
expect(changeHandlers).toHaveLength(1)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("removes the change listener on unmount", () => {
|
|
145
|
+
vi.stubGlobal(
|
|
146
|
+
"matchMedia",
|
|
147
|
+
vi.fn(() => createMockMQL(false)),
|
|
148
|
+
)
|
|
149
|
+
useReducedMotion()
|
|
150
|
+
|
|
151
|
+
for (const cb of mountCallbacks) cb()
|
|
152
|
+
expect(changeHandlers).toHaveLength(1)
|
|
153
|
+
|
|
154
|
+
for (const cb of unmountCallbacks) cb()
|
|
155
|
+
expect(removedHandlers).toHaveLength(1)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { default as kinetic } from "./kinetic"
|
|
2
|
+
export type { KineticComponent } from "./kinetic/types"
|
|
3
|
+
export type { Preset } from "./presets"
|
|
4
|
+
export {
|
|
5
|
+
fade,
|
|
6
|
+
presets,
|
|
7
|
+
scaleIn,
|
|
8
|
+
slideDown,
|
|
9
|
+
slideLeft,
|
|
10
|
+
slideRight,
|
|
11
|
+
slideUp,
|
|
12
|
+
} from "./presets"
|
|
13
|
+
export type {
|
|
14
|
+
ClassTransitionProps,
|
|
15
|
+
StyleTransitionProps,
|
|
16
|
+
TransitionCallbacks,
|
|
17
|
+
TransitionStage,
|
|
18
|
+
TransitionStateResult,
|
|
19
|
+
} from "./types"
|
|
20
|
+
export type { UseAnimationEnd } from "./useAnimationEnd"
|
|
21
|
+
export { default as useAnimationEnd } from "./useAnimationEnd"
|
|
22
|
+
export type { UseTransitionState } from "./useTransitionState"
|
|
23
|
+
export { default as useTransitionState } from "./useTransitionState"
|
|
@@ -0,0 +1,12 @@
|
|
|
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 {}
|