@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
package/src/types.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Ref, VNode } from "@pyreon/core"
|
|
2
|
+
import type { Signal } from "@pyreon/reactivity"
|
|
3
|
+
|
|
4
|
+
export type CSSProperties = Record<string, string | number | undefined>
|
|
5
|
+
|
|
6
|
+
/** Internal lifecycle stages of a transition. */
|
|
7
|
+
export type TransitionStage = "hidden" | "entering" | "entered" | "leaving"
|
|
8
|
+
|
|
9
|
+
/** Class-based transition definition. */
|
|
10
|
+
export type ClassTransitionProps = {
|
|
11
|
+
/** Classes applied during the entire enter phase */
|
|
12
|
+
enter?: string | undefined
|
|
13
|
+
/** Classes applied on first frame of enter, removed on next frame */
|
|
14
|
+
enterFrom?: string | undefined
|
|
15
|
+
/** Classes applied on second frame of enter, kept until complete */
|
|
16
|
+
enterTo?: string | undefined
|
|
17
|
+
/** Classes applied during the entire leave phase */
|
|
18
|
+
leave?: string | undefined
|
|
19
|
+
/** Classes applied on first frame of leave */
|
|
20
|
+
leaveFrom?: string | undefined
|
|
21
|
+
/** Classes applied on second frame of leave, kept until complete */
|
|
22
|
+
leaveTo?: string | undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Style-object transition definition (zero-CSS option). */
|
|
26
|
+
export type StyleTransitionProps = {
|
|
27
|
+
/** Inline styles for the start of enter */
|
|
28
|
+
enterStyle?: CSSProperties | undefined
|
|
29
|
+
/** Inline styles for the end of enter */
|
|
30
|
+
enterToStyle?: CSSProperties | undefined
|
|
31
|
+
/** CSS transition shorthand applied during enter */
|
|
32
|
+
enterTransition?: string | undefined
|
|
33
|
+
/** Inline styles for the start of leave */
|
|
34
|
+
leaveStyle?: CSSProperties | undefined
|
|
35
|
+
/** Inline styles for the end of leave */
|
|
36
|
+
leaveToStyle?: CSSProperties | undefined
|
|
37
|
+
/** CSS transition shorthand applied during leave */
|
|
38
|
+
leaveTransition?: string | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Lifecycle callbacks. */
|
|
42
|
+
export type TransitionCallbacks = {
|
|
43
|
+
/** Called immediately when entering begins */
|
|
44
|
+
onEnter?: (() => void) | undefined
|
|
45
|
+
/** Called when enter animation completes */
|
|
46
|
+
onAfterEnter?: (() => void) | undefined
|
|
47
|
+
/** Called immediately when leaving begins */
|
|
48
|
+
onLeave?: (() => void) | undefined
|
|
49
|
+
/** Called when leave animation completes */
|
|
50
|
+
onAfterLeave?: (() => void) | undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type TransitionProps = ClassTransitionProps &
|
|
54
|
+
StyleTransitionProps &
|
|
55
|
+
TransitionCallbacks & {
|
|
56
|
+
/** Reactive accessor controlling visibility. true = enter, false = leave + unmount. */
|
|
57
|
+
show: () => boolean
|
|
58
|
+
/** If true, runs enter animation on initial mount. Default: false. */
|
|
59
|
+
appear?: boolean | undefined
|
|
60
|
+
/** If true (default), unmounts when hidden. If false, keeps with display:none. */
|
|
61
|
+
unmount?: boolean | undefined
|
|
62
|
+
/** Safety timeout in ms. Default: 5000. */
|
|
63
|
+
timeout?: number | undefined
|
|
64
|
+
/** Single child element. Must accept ref. */
|
|
65
|
+
children: VNode
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type TransitionGroupProps = ClassTransitionProps &
|
|
69
|
+
StyleTransitionProps &
|
|
70
|
+
TransitionCallbacks & {
|
|
71
|
+
/** If true, animates initial children on mount. Default: false. */
|
|
72
|
+
appear?: boolean | undefined
|
|
73
|
+
/** Safety timeout in ms. Default: 5000. */
|
|
74
|
+
timeout?: number | undefined
|
|
75
|
+
/** Children with unique keys. */
|
|
76
|
+
children: VNode[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type StaggerProps = ClassTransitionProps &
|
|
80
|
+
StyleTransitionProps &
|
|
81
|
+
TransitionCallbacks & {
|
|
82
|
+
/** Reactive accessor controlling visibility of all children. */
|
|
83
|
+
show: () => boolean
|
|
84
|
+
/** Delay between each child's animation start in ms. Default: 50. */
|
|
85
|
+
interval?: number | undefined
|
|
86
|
+
/** If true, reverses stagger order on leave. Default: false. */
|
|
87
|
+
reverseLeave?: boolean | undefined
|
|
88
|
+
/** If true, animates on initial mount. Default: false. */
|
|
89
|
+
appear?: boolean | undefined
|
|
90
|
+
/** Safety timeout in ms. Default: 5000. */
|
|
91
|
+
timeout?: number | undefined
|
|
92
|
+
/** Children to stagger. */
|
|
93
|
+
children: VNode[]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type CollapseProps = TransitionCallbacks & {
|
|
97
|
+
/** Reactive accessor controlling expanded/collapsed state. */
|
|
98
|
+
show: () => boolean
|
|
99
|
+
/** CSS transition for height. Default: "height 300ms ease". */
|
|
100
|
+
transition?: string | undefined
|
|
101
|
+
/** If true, animates on initial mount. Default: false. */
|
|
102
|
+
appear?: boolean | undefined
|
|
103
|
+
/** Safety timeout in ms. Default: 5000. */
|
|
104
|
+
timeout?: number | undefined
|
|
105
|
+
/** The content to collapse. */
|
|
106
|
+
children: VNode
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type TransitionStateResult = {
|
|
110
|
+
/** Current lifecycle stage (signal) */
|
|
111
|
+
stage: Signal<TransitionStage>
|
|
112
|
+
/** Ref callback to attach to the transitioning element */
|
|
113
|
+
ref: Ref<HTMLElement> | ((node: HTMLElement | null) => void)
|
|
114
|
+
/** Reactive accessor: whether the element should be rendered */
|
|
115
|
+
shouldMount: () => boolean
|
|
116
|
+
/** Call when the current animation finishes */
|
|
117
|
+
complete: () => void
|
|
118
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Ref } from "@pyreon/core"
|
|
2
|
+
import { watch } from "@pyreon/reactivity"
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TIMEOUT = 5000
|
|
5
|
+
|
|
6
|
+
export type UseAnimationEnd = (options: {
|
|
7
|
+
ref: Ref<HTMLElement>
|
|
8
|
+
onEnd: () => void
|
|
9
|
+
active: () => boolean
|
|
10
|
+
timeout?: number | undefined
|
|
11
|
+
}) => void
|
|
12
|
+
|
|
13
|
+
const useAnimationEnd: UseAnimationEnd = ({ ref, onEnd, active, timeout = DEFAULT_TIMEOUT }) => {
|
|
14
|
+
let called = false
|
|
15
|
+
|
|
16
|
+
watch(
|
|
17
|
+
active,
|
|
18
|
+
(isActive) => {
|
|
19
|
+
if (!isActive) {
|
|
20
|
+
called = false
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const el = ref.current
|
|
25
|
+
if (!el) return
|
|
26
|
+
|
|
27
|
+
called = false
|
|
28
|
+
|
|
29
|
+
const done = () => {
|
|
30
|
+
if (called) return
|
|
31
|
+
called = true
|
|
32
|
+
el.removeEventListener("transitionend", handleEnd)
|
|
33
|
+
el.removeEventListener("animationend", handleEnd)
|
|
34
|
+
clearTimeout(timer)
|
|
35
|
+
onEnd()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const handleEnd = (e: Event) => {
|
|
39
|
+
// Ignore bubbled events from children
|
|
40
|
+
if (e.target !== el) return
|
|
41
|
+
done()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
el.addEventListener("transitionend", handleEnd)
|
|
45
|
+
el.addEventListener("animationend", handleEnd)
|
|
46
|
+
|
|
47
|
+
const timer = setTimeout(done, timeout)
|
|
48
|
+
|
|
49
|
+
return () => {
|
|
50
|
+
el.removeEventListener("transitionend", handleEnd)
|
|
51
|
+
el.removeEventListener("animationend", handleEnd)
|
|
52
|
+
clearTimeout(timer)
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
{ immediate: true },
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default useAnimationEnd
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { onMount, onUnmount } from "@pyreon/core"
|
|
2
|
+
import { signal } from "@pyreon/reactivity"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Inline reduced-motion check for kinetic package.
|
|
6
|
+
* Avoids depending on @pyreon/hooks for a single media query.
|
|
7
|
+
*/
|
|
8
|
+
export function useReducedMotion(): () => boolean {
|
|
9
|
+
const matches = signal(false)
|
|
10
|
+
let mql: MediaQueryList | undefined
|
|
11
|
+
|
|
12
|
+
const onChange = (e: MediaQueryListEvent) => {
|
|
13
|
+
matches.set(e.matches)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
onMount(() => {
|
|
17
|
+
mql = window.matchMedia("(prefers-reduced-motion: reduce)")
|
|
18
|
+
matches.set(mql.matches)
|
|
19
|
+
mql.addEventListener("change", onChange)
|
|
20
|
+
return undefined
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
onUnmount(() => {
|
|
24
|
+
mql?.removeEventListener("change", onChange)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
return matches
|
|
28
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createRef } from "@pyreon/core"
|
|
2
|
+
import { runUntracked, signal, watch } from "@pyreon/reactivity"
|
|
3
|
+
import type { TransitionStage, TransitionStateResult } from "./types"
|
|
4
|
+
|
|
5
|
+
export type UseTransitionState = (options: {
|
|
6
|
+
show: () => boolean
|
|
7
|
+
appear?: boolean | undefined
|
|
8
|
+
}) => TransitionStateResult
|
|
9
|
+
|
|
10
|
+
const useTransitionState: UseTransitionState = ({ show, appear = false }) => {
|
|
11
|
+
const initialShow = show()
|
|
12
|
+
// When appear=true and show starts true, mount the element (stage='entered')
|
|
13
|
+
// but defer the enter animation until the ref is connected.
|
|
14
|
+
const needsAppear = appear && initialShow
|
|
15
|
+
const stage = signal<TransitionStage>(initialShow ? "entered" : "hidden")
|
|
16
|
+
const elementRef = createRef<HTMLElement>()
|
|
17
|
+
let isInitialMount = true
|
|
18
|
+
let appearTriggered = false
|
|
19
|
+
|
|
20
|
+
// Ref callback that triggers the appear animation once the element is wired
|
|
21
|
+
const refCallback = (node: HTMLElement | null) => {
|
|
22
|
+
elementRef.current = node
|
|
23
|
+
if (node && needsAppear && !appearTriggered) {
|
|
24
|
+
appearTriggered = true
|
|
25
|
+
stage.set("entering")
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
watch(
|
|
30
|
+
show,
|
|
31
|
+
(showVal) => {
|
|
32
|
+
if (isInitialMount) {
|
|
33
|
+
isInitialMount = false
|
|
34
|
+
// appear case is handled by refCallback above
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const currentStage = runUntracked(() => stage())
|
|
39
|
+
if (showVal && (currentStage === "hidden" || currentStage === "leaving")) {
|
|
40
|
+
stage.set("entering")
|
|
41
|
+
} else if (!showVal && (currentStage === "entered" || currentStage === "entering")) {
|
|
42
|
+
stage.set("leaving")
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{ immediate: true },
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const complete = () => {
|
|
49
|
+
const current = stage()
|
|
50
|
+
if (current === "entering") stage.set("entered")
|
|
51
|
+
if (current === "leaving") stage.set("hidden")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
stage,
|
|
56
|
+
ref: refCallback,
|
|
57
|
+
shouldMount: () => stage() !== "hidden",
|
|
58
|
+
complete,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default useTransitionState
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Ref, VNode } from "@pyreon/core"
|
|
2
|
+
import type { CSSProperties } from "./types"
|
|
3
|
+
|
|
4
|
+
const splitCache = new Map<string, string[]>()
|
|
5
|
+
const splitClasses = (classes: string): string[] => {
|
|
6
|
+
let cached = splitCache.get(classes)
|
|
7
|
+
if (!cached) {
|
|
8
|
+
cached = classes.split(/\s+/).filter(Boolean)
|
|
9
|
+
splitCache.set(classes, cached)
|
|
10
|
+
}
|
|
11
|
+
return cached
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Adds space-separated CSS classes to an element. */
|
|
15
|
+
export const addClasses = (el: HTMLElement, classes: string | undefined) => {
|
|
16
|
+
if (!classes) return
|
|
17
|
+
const list = splitClasses(classes)
|
|
18
|
+
if (list.length > 0) el.classList.add(...list)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Removes space-separated CSS classes from an element. */
|
|
22
|
+
export const removeClasses = (el: HTMLElement, classes: string | undefined) => {
|
|
23
|
+
if (!classes) return
|
|
24
|
+
const list = splitClasses(classes)
|
|
25
|
+
if (list.length > 0) el.classList.remove(...list)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Executes callback after two animation frames (double-rAF).
|
|
30
|
+
* Ensures the browser paints the current state before applying changes,
|
|
31
|
+
* which is required for CSS transitions to trigger.
|
|
32
|
+
*/
|
|
33
|
+
export const nextFrame = (callback: () => void): number =>
|
|
34
|
+
requestAnimationFrame(() => {
|
|
35
|
+
requestAnimationFrame(callback)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** Merges two className strings, filtering undefined/empty. */
|
|
39
|
+
export const mergeClassNames = (
|
|
40
|
+
existing: string | undefined,
|
|
41
|
+
additional: string | undefined,
|
|
42
|
+
): string | undefined => {
|
|
43
|
+
const parts = [existing, additional].filter(Boolean)
|
|
44
|
+
return parts.length > 0 ? parts.join(" ") : undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Merges two CSSProperties objects, with `b` taking precedence. */
|
|
48
|
+
export const mergeStyles = (
|
|
49
|
+
a: CSSProperties | undefined,
|
|
50
|
+
b: CSSProperties | undefined,
|
|
51
|
+
): CSSProperties | undefined => {
|
|
52
|
+
if (!a && !b) return undefined
|
|
53
|
+
if (!a) return b
|
|
54
|
+
if (!b) return a
|
|
55
|
+
return { ...a, ...b }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Ref & Motion Utilities ─────────────────────────────────
|
|
59
|
+
|
|
60
|
+
type RefCallback<T> = (node: T | null) => void
|
|
61
|
+
type RefLike<T> = RefCallback<T> | Ref<T>
|
|
62
|
+
|
|
63
|
+
/** Merges multiple refs (callback or object) into a single callback ref. */
|
|
64
|
+
export const mergeRefs = <T>(...refs: (RefLike<T> | undefined)[]): ((node: T | null) => void) => {
|
|
65
|
+
return (node: T | null) => {
|
|
66
|
+
for (const ref of refs) {
|
|
67
|
+
if (!ref) continue
|
|
68
|
+
if (typeof ref === "function") {
|
|
69
|
+
ref(node)
|
|
70
|
+
} else {
|
|
71
|
+
;(ref as { current: unknown }).current = node
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Clones a VNode with merged props. */
|
|
78
|
+
export const cloneVNode = (vnode: VNode, extraProps: Record<string, unknown>): VNode => ({
|
|
79
|
+
...vnode,
|
|
80
|
+
props: { ...vnode.props, ...extraProps },
|
|
81
|
+
})
|