@pyreon/kinetic 0.11.0 → 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.
@@ -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 {}