diff --git a/packages/bui-core/src/Fade/Fade.miniapp.tsx b/packages/bui-core/src/Fade/Fade.miniapp.tsx new file mode 100644 index 00000000..9a352669 --- /dev/null +++ b/packages/bui-core/src/Fade/Fade.miniapp.tsx @@ -0,0 +1,173 @@ +/** + * Fade Animation Component + * @description A component that implements fade in/out animation effects for elements + * @component Fade + */ +import React, { useEffect, useState, useRef } from 'react'; +import { + createTransitions, + duration, + easing, + getTransitionProps, + useForkRef, +} from '@bifrostui/utils'; +import type { FadeProps } from './Fade.types'; +import './index.less'; + +const defaultEasing = { + enter: easing.easeOut, + exit: easing.sharp, +}; + +const defaultTimeout = { + enter: duration.enteringScreen, + exit: duration.leavingScreen, +}; + +const Fade = React.forwardRef((props, ref) => { + const { + // Base props + children, + in: inProp, + style, + // Animation controls + appear = true, + enter = true, + exit = true, + delay = 0, + easing: easingProp = defaultEasing, + timeout = defaultTimeout, + // Lifecycle hooks + mountOnEnter, + unmountOnExit, + // Animation callbacks + onEnter, + onEntering, + onEntered, + onExit, + onExiting, + onExited, + // Other props + ...others + } = props; + + const isFirstMount = useRef(true); + const [isMounted, setIsMounted] = useState( + () => !mountOnEnter || !unmountOnExit, + ); + const elementRef = useRef(null); + // @ts-expect-error will upstream fix + const handleRef = useForkRef(ref, children?.ref, elementRef); + // Whether to animate on first mount + const shouldAnimateOnFirstMount = inProp && appear; + // Whether to animate on subsequent updates + const shouldAnimate = (inProp && enter) || (!inProp && exit); + /** + * Animation configuration + */ + const getAnimationDuration = () => { + if (isFirstMount.current) { + return shouldAnimateOnFirstMount ? timeout : 0; + } + return shouldAnimate ? timeout : 0; + }; + + // Whether to skip the initial animation + const shouldSkipFirstAnimation = + isFirstMount.current && !shouldAnimateOnFirstMount; + + /** + * Generate animation configuration + */ + const transitions = createTransitions(); + const animationName = inProp ? 'bui-fade-in' : 'bui-fade-out'; + const animationDuration = getAnimationDuration(); + const animation = transitions.create( + animationName, + getTransitionProps( + { + timeout: animationDuration, + style, + easing: easingProp, + delay, + }, + { mode: inProp ? 'enter' : 'exit' }, + ), + ); + + /** + * Lifecycle management + */ + useEffect(() => { + // Control component mount state + if (inProp && !isMounted) { + setIsMounted(true); + } + }, [inProp, isMounted]); + + useEffect(() => { + // Update first render state + if (isMounted && isFirstMount.current) { + isFirstMount.current = false; + } + }, [isMounted]); + + /** + * Animation event handlers + */ + useEffect(() => { + // Trigger animation start callback + const shouldTriggerCallback = isMounted && animationDuration !== 0; + if (!shouldTriggerCallback) return; + + if (inProp) { + onEnter?.(elementRef.current); + } else { + onExit?.(elementRef.current); + } + }, [inProp, isMounted, animationDuration, onEnter, onExit]); + + const handleAnimationStart = () => { + if (animationDuration === 0) return; + + if (inProp) { + onEntering?.(elementRef.current); + } else { + onExiting?.(elementRef.current); + } + }; + + const handleAnimationEnd = () => { + if (shouldSkipFirstAnimation) return; + + if (inProp) { + onEntered?.(elementRef.current); + } else { + onExited?.(elementRef.current); + if (unmountOnExit) { + setIsMounted(false); + } + } + }; + /** + * Render + */ + if (!children || !isMounted) return null; + + return React.cloneElement(children, { + ...others, + ref: handleRef, + onAnimationEnd: handleAnimationEnd, + onAnimationStart: handleAnimationStart, + style: { + animation, + animationFillMode: 'forwards', + ...style, + ...children.props?.style, + }, + }); +}); + +Fade.displayName = 'BuiFade'; + +export default Fade; diff --git a/packages/bui-core/src/Fade/Fade.tsx b/packages/bui-core/src/Fade/Fade.tsx index d2f39a8d..a8f3f184 100644 --- a/packages/bui-core/src/Fade/Fade.tsx +++ b/packages/bui-core/src/Fade/Fade.tsx @@ -49,15 +49,19 @@ const Fade = React.forwardRef( ref={nodeRef} > {(state, childProps) => { - const transition = transitions.create( - 'opacity', - getTransitionProps( - { timeout, style, easing: easingProp, delay }, - { - mode: state, - }, - ), - ); + const transition = + state === 'entering' || state === 'exiting' + ? transitions.create( + 'opacity', + getTransitionProps( + { timeout, style, easing: easingProp, delay }, + { + mode: state, + }, + ), + ) + : 'none'; + return React.cloneElement(children, { style: { visibility: state === 'exited' ? 'hidden' : 'visible', diff --git a/packages/bui-core/src/Fade/index.less b/packages/bui-core/src/Fade/index.less new file mode 100644 index 00000000..82ba711e --- /dev/null +++ b/packages/bui-core/src/Fade/index.less @@ -0,0 +1,20 @@ +/* 简单的进入动画 */ +@keyframes bui-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes bui-fade-out { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} diff --git a/packages/bui-core/src/Modal/Modal.miniapp.tsx b/packages/bui-core/src/Modal/Modal.miniapp.tsx index d2d707a1..f94774b1 100644 --- a/packages/bui-core/src/Modal/Modal.miniapp.tsx +++ b/packages/bui-core/src/Modal/Modal.miniapp.tsx @@ -21,6 +21,7 @@ const Modal = React.forwardRef( disableScrollLock = false, hideBackdrop = false, onClose, + onClick, keepMounted, ...others } = props; @@ -49,6 +50,9 @@ const Modal = React.forwardRef( className={clsx(prefixCls, className)} ref={handleRef} catchMove={!disableScrollLock} + onClick={(event) => { + onClick?.(event); + }} {...others} > {!hideBackdrop ? ( diff --git a/packages/bui-core/src/Slide/Slide.miniapp.tsx b/packages/bui-core/src/Slide/Slide.miniapp.tsx new file mode 100644 index 00000000..87fcc0c1 --- /dev/null +++ b/packages/bui-core/src/Slide/Slide.miniapp.tsx @@ -0,0 +1,204 @@ +/** + * Slide Animation Component + * @description A component that implements slide in/out animation effects for elements + * @component Slide + */ +import React, { useEffect, useState, useRef } from 'react'; +import { + createTransitions, + duration, + easing, + getTransitionProps, + useForkRef, +} from '@bifrostui/utils'; +import type { SlideProps } from './Slide.types'; +import './index.less'; + +const defaultEasing = { + enter: easing.easeOut, + exit: easing.sharp, +}; + +const defaultTimeout = { + enter: duration.enteringScreen, + exit: duration.leavingScreen, +}; +/** + * Get animation name based on direction and state + * @param direction The slide direction + * @param inProp Whether the element is entering or exiting + * @returns The animation class name + */ +const getAnimationName = ( + direction: 'left' | 'right' | 'up' | 'down', + inProp: boolean, +): string => { + const animations = { + left: inProp ? 'bui-slide-left-in' : 'bui-slide-left-out', + right: inProp ? 'bui-slide-right-in' : 'bui-slide-right-out', + up: inProp ? 'bui-slide-up-in' : 'bui-slide-up-out', + down: inProp ? 'bui-slide-down-in' : 'bui-slide-down-out', + }; + + return animations[direction] || animations.down; +}; + +/** + * Slide 组件 + * @component + */ +const Slide = React.forwardRef( + ( + { + // Base props + children, + in: inProp, + style, + direction = 'down', + // Animation controls + appear = true, + enter = true, + exit = true, + delay = 0, + easing: easingProp = defaultEasing, + timeout = defaultTimeout, + // Lifecycle hooks + mountOnEnter, + unmountOnExit, + // Animation callbacks + onEnter, + onEntering, + onEntered, + onExit, + onExiting, + onExited, + // Other props + ...others + }, + ref, + ) => { + /** + * Refs & State + */ + const isFirstMount = useRef(true); + const [isMounted, setIsMounted] = useState( + () => !mountOnEnter || !unmountOnExit, + ); + const elementRef = useRef(null); + // @ts-expect-error will upstream fix + const handleRef = useForkRef(ref, children?.ref, elementRef); + // Whether to animate on first mount + const shouldAnimateOnFirstMount = inProp && appear; + // Whether to animate on subsequent updates + const shouldAnimate = (inProp && enter) || (!inProp && exit); + /** + * Animation configuration + */ + const getAnimationDuration = () => { + if (isFirstMount.current) { + return shouldAnimateOnFirstMount ? timeout : 0; + } + return shouldAnimate ? timeout : 0; + }; + + // Whether to skip the initial animation + const shouldSkipFirstAnimation = + isFirstMount.current && !shouldAnimateOnFirstMount; + + /** + * Generate animation configuration + */ + const transitions = createTransitions(); + const animationName = getAnimationName(direction, inProp); + const animationDuration = getAnimationDuration(); + const animation = transitions.create( + animationName, + getTransitionProps( + { + timeout: animationDuration, + style, + easing: easingProp, + delay, + }, + { mode: inProp ? 'enter' : 'exit' }, + ), + ); + + /** + * Lifecycle management + */ + useEffect(() => { + // Control component mount state + if (inProp && !isMounted) { + setIsMounted(true); + } + }, [inProp, isMounted]); + + useEffect(() => { + // Update first render state + if (isMounted && isFirstMount.current) { + isFirstMount.current = false; + } + }, [isMounted]); + + /** + * Animation event handlers + */ + useEffect(() => { + // Trigger animation start callback + const shouldTriggerCallback = isMounted && animationDuration !== 0; + if (!shouldTriggerCallback) return; + + if (inProp) { + onEnter?.(elementRef.current); + } else { + onExit?.(elementRef.current); + } + }, [inProp, isMounted, animationDuration, onEnter, onExit]); + + const handleAnimationStart = () => { + if (animationDuration === 0) return; + + if (inProp) { + onEntering?.(elementRef.current); + } else { + onExiting?.(elementRef.current); + } + }; + + const handleAnimationEnd = () => { + if (shouldSkipFirstAnimation) return; + + if (inProp) { + onEntered?.(elementRef.current); + } else { + onExited?.(elementRef.current); + if (unmountOnExit) { + setIsMounted(false); + } + } + }; + + /** + * Render + */ + if (!children || !isMounted) return null; + + return React.cloneElement(children, { + ...others, + ref: handleRef, + onAnimationEnd: handleAnimationEnd, + onAnimationStart: handleAnimationStart, + style: { + animation, + animationFillMode: 'forwards', + ...style, + ...children.props?.style, + }, + }); + }, +); + +Slide.displayName = 'BuiSlide'; + +export default Slide; diff --git a/packages/bui-core/src/Slide/Slide.tsx b/packages/bui-core/src/Slide/Slide.tsx index f7689aa0..d3bf6c11 100644 --- a/packages/bui-core/src/Slide/Slide.tsx +++ b/packages/bui-core/src/Slide/Slide.tsx @@ -63,15 +63,18 @@ const Slide = React.forwardRef( ref={nodeRef} > {(state, childProps) => { - const transition = transitions.create( - 'transform', - getTransitionProps( - { timeout, style, easing: easingProp, delay }, - { - mode: state, - }, - ), - ); + const transition = + state === 'entering' || state === 'exiting' + ? transitions.create( + 'transform', + getTransitionProps( + { timeout, style, easing: easingProp, delay }, + { + mode: state, + }, + ), + ) + : 'none'; const transform = state === 'entering' || state === 'entered' ? 'none' diff --git a/packages/bui-core/src/Slide/index.less b/packages/bui-core/src/Slide/index.less new file mode 100644 index 00000000..e7ac3fcf --- /dev/null +++ b/packages/bui-core/src/Slide/index.less @@ -0,0 +1,87 @@ +@keyframes bui-slide-left-in { + from { + transform: translateX(100%); + } + + to { + visibility: visible; + transform: none; + } +} + +@keyframes bui-slide-left-out { + from { + transform: none; + } + + to { + visibility: hidden; + transform: translateX(100%); + } +} + +@keyframes bui-slide-right-in { + from { + transform: translateX(-100%); + } + + to { + visibility: visible; + transform: none; + } +} + +@keyframes bui-slide-right-out { + from { + transform: none; + } + + to { + visibility: hidden; + transform: translateX(-100%); + } +} + +@keyframes bui-slide-up-in { + from { + transform: translateY(100%); + } + + to { + visibility: visible; + transform: none; + } +} + +@keyframes bui-slide-up-out { + from { + transform: none; + } + + to { + visibility: hidden; + transform: translateY(100%); + } +} + +@keyframes bui-slide-down-in { + from { + transform: translateY(-100%); + } + + to { + visibility: visible; + transform: none; + } +} + +@keyframes bui-slide-down-out { + from { + transform: none; + } + + to { + visibility: hidden; + transform: translateY(-100%); + } +} diff --git a/packages/bui-core/src/Transition/TransitionCore.tsx b/packages/bui-core/src/Transition/TransitionCore.tsx index e8946bdc..ddb31604 100644 --- a/packages/bui-core/src/Transition/TransitionCore.tsx +++ b/packages/bui-core/src/Transition/TransitionCore.tsx @@ -87,8 +87,8 @@ const TransitionCore = forwardRef( nextCallback?.current?.(); }); }; - const performEnter = async () => { - if (!enter) { + const performEnter = async (mounting) => { + if (!enter && !mounting) { safeSetState(ENTERED, async () => { await onEntered?.(innerNodeRef?.current); }); @@ -123,18 +123,18 @@ const TransitionCore = forwardRef( }); }; - const updateStatus = (nextStatus) => { + const updateStatus = (nextStatus, mounting) => { if (nextStatus !== null) { cancelNextCallback(); if (nextStatus === ENTERING) { - performEnter(); + performEnter(mounting); } else if (nextStatus === EXITING) { performExit(); } } }; useEffect(() => { - nextTick(() => updateStatus(appearStatus.current)); + nextTick(() => updateStatus(appearStatus.current, true)); return () => { cancelNextCallback(); }; @@ -154,13 +154,13 @@ const TransitionCore = forwardRef( } else if (status === ENTERING || status === ENTERED) { nextStatus = EXITING; } - if (isMounted) updateStatus(nextStatus); + if (isMounted) updateStatus(nextStatus, false); else safeSetState(inProp ? 'EXITED' : 'ENTERED', () => { // With unmountOnExit or mountOnEnter, the enter animation should happen at the transition between `exited` and `entering`. // To make the animation happen, we have to separate each rendering and avoid being processed as batched. forceReflow(innerNodeRef?.current); - updateStatus(nextStatus); + updateStatus(nextStatus, false); }); }, [inProp]);