feat(tooltip): animation changed to framer-motion

This commit is contained in:
Junior Garcia 2023-03-19 20:09:48 -03:00
parent 3844e9b501
commit 133f1b494a
14 changed files with 335 additions and 236 deletions

View File

@ -1,7 +1,7 @@
import {forwardRef} from "@nextui-org/system";
import {useMemo, ReactNode} from "react";
import {ChevronIcon} from "@nextui-org/shared-icons";
import {Collapse} from "@nextui-org/framer-transitions";
import {CollapseTransition} from "@nextui-org/framer-transitions";
import {UseAccordionItemProps, useAccordionItem} from "./use-accordion-item";
@ -46,9 +46,9 @@ const Accordion = forwardRef<AccordionItemProps, "div">((props, ref) => {
}
return (
<Collapse in={isOpen} {...motionProps}>
<CollapseTransition in={isOpen} {...motionProps}>
<div {...getContentProps()}>{children}</div>
</Collapse>
</CollapseTransition>
);
}, [isOpen, disableAnimation, children, motionProps]);

View File

@ -3,7 +3,7 @@ import type {
AccordionItemSlots,
SlotsToClasses,
} from "@nextui-org/theme";
import type {CollapseProps} from "@nextui-org/framer-transitions";
import type {CollapseTransitionProps} from "@nextui-org/framer-transitions";
import {BaseItem, ItemProps} from "@nextui-org/aria-utils";
import {FocusableProps} from "@react-types/shared";
@ -53,7 +53,7 @@ export interface Props<T extends object = {}>
/**
* The properties passed to the underlying `Collapse` component.
*/
motionProps?: CollapseProps;
motionProps?: CollapseTransitionProps;
/**
* Classname or List of classes to change the styles of the element.
* if `className` is passed, it will be added to the base slot.

View File

@ -43,11 +43,13 @@
"@nextui-org/system": "workspace:*",
"@nextui-org/theme": "workspace:*",
"@nextui-org/aria-utils": "workspace:*",
"@nextui-org/framer-transitions": "workspace:*",
"@react-aria/focus": "^3.11.0",
"@react-aria/overlays": "^3.13.0",
"@react-aria/tooltip": "^3.4.0",
"@react-stately/tooltip": "^3.3.0",
"@react-aria/utils": "^3.15.0"
"@react-aria/utils": "^3.15.0",
"framer-motion": "^10.6.0"
},
"devDependencies": {
"@react-types/overlays": "^3.7.0",

View File

@ -0,0 +1,33 @@
import {Variants} from "framer-motion";
export const scale: Variants = {
exit: {
scale: 0.6,
opacity: 0,
transition: {
opacity: {
duration: 0.15,
easings: "easeInOut",
},
scale: {
duration: 0.2,
easings: "easeInOut",
},
},
},
enter: {
scale: 1,
opacity: 1,
transition: {
opacity: {
easings: "easeOut",
duration: 0.2,
},
scale: {
type: "spring",
stiffness: 800,
damping: 35,
},
},
},
};

View File

@ -1,10 +1,12 @@
import {forwardRef} from "@nextui-org/system";
import {warn} from "@nextui-org/shared-utils";
import {cloneElement, Children} from "react";
import {cloneElement, Children, useMemo} from "react";
import {OverlayContainer} from "@react-aria/overlays";
import {CSSTransition} from "@nextui-org/react-utils";
import {AnimatePresence, motion} from "framer-motion";
import {UseTooltipProps, useTooltip} from "./use-tooltip";
import {scale} from "./tooltip-transition";
import {getOrigins} from "./utils";
export interface TooltipProps extends Omit<UseTooltipProps, "ref"> {}
@ -13,8 +15,10 @@ const Tooltip = forwardRef<TooltipProps, "div">((props, ref) => {
Component,
children,
content,
mountOverlay,
transitionProps,
isOpen,
placement,
disableAnimation,
motionProps,
getTriggerProps,
getTooltipProps,
} = useTooltip({
@ -38,16 +42,44 @@ const Tooltip = forwardRef<TooltipProps, "div">((props, ref) => {
warn("Tooltip must have only one child node. Please, check your code.");
}
const contentComponent = useMemo(() => {
if (disableAnimation) {
return <Component {...getTooltipProps()}>{content}</Component>;
}
const {className, ...otherTooltipProps} = getTooltipProps();
return (
<AnimatePresence initial={false}>
{isOpen && (
<div {...otherTooltipProps}>
<motion.div
animate="enter"
exit="exit"
initial="exit"
style={{
...getOrigins(placement),
}}
variants={scale}
{...motionProps}
>
<Component className={className}>{content}</Component>
</motion.div>
</div>
)}
</AnimatePresence>
);
}, [isOpen, content, disableAnimation, children, motionProps, getTooltipProps]);
return (
<>
{trigger}
{mountOverlay && (
<OverlayContainer>
<CSSTransition {...transitionProps}>
<OverlayContainer>
{/* <CSSTransition {...transitionProps}>
<Component {...getTooltipProps()}>{content}</Component>
</CSSTransition>
</OverlayContainer>
)}
</CSSTransition> */}
{contentComponent}
</OverlayContainer>
</>
);
});

View File

@ -0,0 +1 @@
export type TooltipPlacement = "start" | "end" | "right" | "left" | "top" | "bottom";

View File

@ -1,8 +1,9 @@
import type {TooltipVariantProps} from "@nextui-org/theme";
import type {AriaTooltipProps} from "@react-types/tooltip";
import type {OverlayTriggerProps} from "@react-types/overlays";
import type {CSSTransitionProps} from "@nextui-org/react-utils";
import type {ReactNode, Ref} from "react";
import type {HTMLMotionProps} from "framer-motion";
import type {TooltipPlacement} from "./types";
import {useTooltipTriggerState} from "@react-stately/tooltip";
import {mergeProps} from "@react-aria/utils";
@ -11,7 +12,7 @@ import {useOverlayPosition, useOverlay, AriaOverlayProps} from "@react-aria/over
import {HTMLNextUIProps, mapPropsVariants, PropGetter} from "@nextui-org/system";
import {tooltip} from "@nextui-org/theme";
import {ReactRef, mergeRefs} from "@nextui-org/shared-utils";
import {useMemo, useRef, useState, useCallback} from "react";
import {useMemo, useRef, useCallback} from "react";
export interface UseTooltipProps
extends HTMLNextUIProps<"div", TooltipVariantProps>,
@ -22,6 +23,9 @@ export interface UseTooltipProps
* Ref to the DOM node.
*/
ref?: ReactRef<HTMLElement | null>;
/**
* The children to render. Usually a trigger element.
*/
children?: ReactNode;
/**
* The content of the tooltip.
@ -36,6 +40,11 @@ export interface UseTooltipProps
* @default 0
*/
delay?: number;
/**
* The delay time for the tooltip to hide.
* @default 0
*/
closeDelay?: number;
/**
* The additional offset applied along the main axis between the element and its
* anchor element.
@ -56,13 +65,17 @@ export interface UseTooltipProps
* The placement of the element with respect to its anchor element.
* @default 'top'
*/
placement?: "start" | "end" | "right" | "left" | "top" | "bottom";
placement?: TooltipPlacement;
/**
* The placement padding that should be applied between the element and its
* surrounding container.
* @default 12
*/
containerPadding?: number;
/**
* The properties passed to the underlying `Collapse` component.
*/
motionProps?: HTMLMotionProps<"div">;
/** Handler that is called when the overlay should close. */
onClose?: () => void;
}
@ -84,6 +97,7 @@ export function useTooltip(originalProps: UseTooltipProps) {
containerPadding = 12,
placement: placementProp = "top",
delay = 0,
closeDelay = 0,
offset = 7,
isDismissable = true,
shouldCloseOnBlur = false,
@ -91,31 +105,30 @@ export function useTooltip(originalProps: UseTooltipProps) {
shouldCloseOnInteractOutside,
className,
onClose,
motionProps,
...otherProps
} = props;
const Component = as || "div";
const state = useTooltipTriggerState({delay, isDisabled, isOpen, defaultOpen, onOpenChange});
const [exited, setExited] = useState(!state.isOpen);
const state = useTooltipTriggerState({
delay,
isDisabled,
isOpen,
defaultOpen,
onOpenChange,
});
const triggerRef = useRef<HTMLElement>(null);
const overlayRef = useRef<HTMLElement>(null);
const domRef = mergeRefs(overlayRef, ref);
const immediate = closeDelay === 0;
const handleClose = useCallback(() => {
onClose?.();
state.close();
}, [state, onClose]);
const onEntered = useCallback(() => {
setExited(false);
}, []);
const onExited = useCallback(() => {
setExited(true);
}, []);
state.close(immediate);
}, [state, immediate, onClose]);
const {triggerProps, tooltipProps: triggerTooltipProps} = useTooltipTrigger(
{
@ -129,6 +142,10 @@ export function useTooltip(originalProps: UseTooltipProps) {
const {tooltipProps} = useReactAriaTooltip(mergeProps(props, triggerTooltipProps), state);
tooltipProps.onPointerLeave = () => {
state.close(immediate);
};
const {
overlayProps: positionProps,
// arrowProps,
@ -143,8 +160,6 @@ export function useTooltip(originalProps: UseTooltipProps) {
containerPadding,
});
const mountOverlay = (state.isOpen || !exited) && !isDisabled;
const {overlayProps} = useOverlay(
{
isOpen: state.isOpen,
@ -157,18 +172,6 @@ export function useTooltip(originalProps: UseTooltipProps) {
overlayRef,
);
const transitionProps = useMemo<CSSTransitionProps>(
() => ({
enterTime: originalProps?.disableAnimation ? 0 : 250,
leaveTime: originalProps?.disableAnimation ? 0 : 60,
clearTime: originalProps?.disableAnimation ? 0 : 60,
isVisible: state.isOpen,
onEntered: onEntered,
onExited: onExited,
}),
[originalProps?.disableAnimation, state.isOpen],
);
const styles = useMemo(
() =>
tooltip({
@ -201,8 +204,11 @@ export function useTooltip(originalProps: UseTooltipProps) {
Component,
content,
children,
mountOverlay,
transitionProps,
placement: placementProp,
isOpen: state.isOpen,
disableAnimation: originalProps?.disableAnimation,
isDisabled,
motionProps,
getTriggerProps,
getTooltipProps,
};

View File

@ -0,0 +1,26 @@
import type {TooltipPlacement} from "./types";
export const getOrigins = (placement: TooltipPlacement) => {
const origins = {
top: {
originY: 1,
},
bottom: {
originY: 0,
},
start: {
originX: 1,
},
end: {
originX: 0,
},
left: {
originX: 1,
},
right: {
originX: 0,
},
};
return origins?.[placement] || {};
};

View File

@ -81,6 +81,7 @@ export default {
const defaultProps = {
...tooltip.defaultVariants,
placement: "top",
delay: 0,
offset: 7,
defaultOpen: false,

View File

@ -9,21 +9,13 @@ import {colorVariants} from "../utils";
* @example
* <div>
* <button>your trigger</button>
* <div role="tooltip" className={styles} data-transition='enter/leave'>
* <div role="tooltip" className={styles}>
* // tooltip content
* </div>
* </div>
*/
const tooltip = tv({
base: [
"inline-flex",
"flex-col",
"items-center",
"justify-center",
"box-border",
"animate-appearance-in",
"data-[transition=leave]:animate-appearance-out",
],
base: ["inline-flex", "flex-col", "items-center", "justify-center", "box-border"],
variants: {
variant: {
solid: "",
@ -65,7 +57,7 @@ const tooltip = tv({
defaultVariants: {
variant: "solid",
color: "neutral",
size: "sm",
size: "md",
radius: "lg",
},
compoundVariants: [

View File

@ -0,0 +1,178 @@
/**
* Part of this code is taken from @chakra-ui/system
*/
import {warn, isNumeric} from "@nextui-org/shared-utils";
import {AnimatePresence, HTMLMotionProps, motion, Variants as _Variants} from "framer-motion";
import {forwardRef, useEffect, useState} from "react";
import {TRANSITION_EASINGS, Variants, WithTransitionConfig, withDelay} from "./transition-utils";
export interface CollapseTransitionOptions {
/**
* If `true`, the opacity of the content will be animated
* @default true
*/
animateOpacity?: boolean;
/**
* The height you want the content in its collapsed state.
* @default 0
*/
startingHeight?: number;
/**
* The height you want the content in its expanded state.
* @default "auto"
*/
endingHeight?: number | string;
/**
* The y-axis offset you want the content in its collapsed state.
* @default 10
*/
startingY?: number;
/**
* The y-axis offset you want the content in its expanded state.
* @default 0
*/
endingY?: number;
}
const defaultTransitions = {
exit: {
height: {
duration: 0.2,
ease: TRANSITION_EASINGS.ease,
},
opacity: {
duration: 0.3,
ease: TRANSITION_EASINGS.ease,
},
},
enter: {
height: {
type: "spring",
stiffness: 300,
damping: 24,
},
opacity: {
duration: 0.8,
ease: TRANSITION_EASINGS.ease,
},
y: {
duration: 0.5,
ease: TRANSITION_EASINGS.ease,
},
},
};
const variants: Variants<CollapseTransitionOptions> = {
enter: ({animateOpacity, endingHeight, endingY, transition, transitionEnd, delay}) => ({
...(animateOpacity && {opacity: 1}),
y: endingY,
height: endingHeight,
transitionEnd: transitionEnd?.enter,
transition: transition?.enter ?? withDelay.enter(defaultTransitions.enter, delay),
}),
exit: ({animateOpacity, startingHeight, transition, startingY, transitionEnd, delay}) => ({
...(animateOpacity && {opacity: isNumeric(startingHeight) ? 1 : 0}),
y: startingY,
height: startingHeight,
transitionEnd: transitionEnd?.exit,
transition: transition?.exit ?? withDelay.exit(defaultTransitions.exit, delay),
}),
};
export type ICollapseTransition = CollapseTransitionProps;
export interface CollapseTransitionProps
extends WithTransitionConfig<HTMLMotionProps<"div">>,
CollapseTransitionOptions {}
export const CollapseTransition = forwardRef<HTMLDivElement, CollapseTransitionProps>(
(props, ref) => {
const {
in: isOpen,
unmountOnExit,
animateOpacity = true,
startingHeight = 0,
endingHeight = "auto",
startingY = 10,
endingY = 0,
style,
className,
transition,
transitionEnd,
...rest
} = props;
const [mounted, setMounted] = useState(false);
useEffect(() => {
const timeout = setTimeout(() => {
setMounted(true);
});
return () => clearTimeout(timeout);
}, []);
/**
* Warn 🚨: `startingHeight` and `unmountOnExit` are mutually exclusive
*
* If you specify a starting height, the collapsed needs to be mounted
* for the height to take effect.
*/
if (Boolean(startingHeight > 0 && unmountOnExit)) {
warn(
`startingHeight and unmountOnExit are mutually exclusive. You can't use them together`,
"FramerTransitions - Collapse",
);
}
const hasStartingHeight = parseFloat(startingHeight.toString()) > 0;
const custom = {
startingHeight,
endingHeight,
startingY,
endingY,
animateOpacity,
transition: !mounted ? {enter: {duration: 0}} : transition,
transitionEnd: {
enter: transitionEnd?.enter,
exit: unmountOnExit
? transitionEnd?.exit
: {
...transitionEnd?.exit,
display: hasStartingHeight ? "block" : "none",
},
},
};
const show = unmountOnExit ? isOpen : true;
const animate = isOpen || unmountOnExit ? "enter" : "exit";
return (
<AnimatePresence custom={custom} initial={false}>
{show && (
<motion.div
ref={ref}
{...rest}
animate={animate}
className={className}
custom={custom}
exit="exit"
initial={unmountOnExit ? "exit" : false}
style={{
overflow: "hidden",
display: "block",
...style,
}}
variants={variants as _Variants}
/>
)}
</AnimatePresence>
);
},
);
CollapseTransition.displayName = "NextUI.CollapseTransition";

View File

@ -1,176 +0,0 @@
/**
* Part of this code is taken from @chakra-ui/system
*/
import {warn, isNumeric} from "@nextui-org/shared-utils";
import {AnimatePresence, HTMLMotionProps, motion, Variants as _Variants} from "framer-motion";
import {forwardRef, useEffect, useState} from "react";
import {TRANSITION_EASINGS, Variants, WithTransitionConfig, withDelay} from "./transition-utils";
export interface CollapseOptions {
/**
* If `true`, the opacity of the content will be animated
* @default true
*/
animateOpacity?: boolean;
/**
* The height you want the content in its collapsed state.
* @default 0
*/
startingHeight?: number;
/**
* The height you want the content in its expanded state.
* @default "auto"
*/
endingHeight?: number | string;
/**
* The y-axis offset you want the content in its collapsed state.
* @default 10
*/
startingY?: number;
/**
* The y-axis offset you want the content in its expanded state.
* @default 0
*/
endingY?: number;
}
const defaultTransitions = {
exit: {
height: {
duration: 0.2,
ease: TRANSITION_EASINGS.ease,
},
opacity: {
duration: 0.3,
ease: TRANSITION_EASINGS.ease,
},
},
enter: {
height: {
type: "spring",
stiffness: 300,
damping: 24,
},
opacity: {
duration: 0.8,
ease: TRANSITION_EASINGS.ease,
},
y: {
duration: 0.5,
ease: TRANSITION_EASINGS.ease,
},
},
};
const variants: Variants<CollapseOptions> = {
enter: ({animateOpacity, endingHeight, endingY, transition, transitionEnd, delay}) => ({
...(animateOpacity && {opacity: 1}),
y: endingY,
height: endingHeight,
transitionEnd: transitionEnd?.enter,
transition: transition?.enter ?? withDelay.enter(defaultTransitions.enter, delay),
}),
exit: ({animateOpacity, startingHeight, transition, startingY, transitionEnd, delay}) => ({
...(animateOpacity && {opacity: isNumeric(startingHeight) ? 1 : 0}),
y: startingY,
height: startingHeight,
transitionEnd: transitionEnd?.exit,
transition: transition?.exit ?? withDelay.exit(defaultTransitions.exit, delay),
}),
};
export type ICollapse = CollapseProps;
export interface CollapseProps
extends WithTransitionConfig<HTMLMotionProps<"div">>,
CollapseOptions {}
export const Collapse = forwardRef<HTMLDivElement, CollapseProps>((props, ref) => {
const {
in: isOpen,
unmountOnExit,
animateOpacity = true,
startingHeight = 0,
endingHeight = "auto",
startingY = 10,
endingY = 0,
style,
className,
transition,
transitionEnd,
...rest
} = props;
const [mounted, setMounted] = useState(false);
useEffect(() => {
const timeout = setTimeout(() => {
setMounted(true);
});
return () => clearTimeout(timeout);
}, []);
/**
* Warn 🚨: `startingHeight` and `unmountOnExit` are mutually exclusive
*
* If you specify a starting height, the collapsed needs to be mounted
* for the height to take effect.
*/
if (Boolean(startingHeight > 0 && unmountOnExit)) {
warn(
`startingHeight and unmountOnExit are mutually exclusive. You can't use them together`,
"FramerTransitions - Collapse",
);
}
const hasStartingHeight = parseFloat(startingHeight.toString()) > 0;
const custom = {
startingHeight,
endingHeight,
startingY,
endingY,
animateOpacity,
transition: !mounted ? {enter: {duration: 0}} : transition,
transitionEnd: {
enter: transitionEnd?.enter,
exit: unmountOnExit
? transitionEnd?.exit
: {
...transitionEnd?.exit,
display: hasStartingHeight ? "block" : "none",
},
},
};
const show = unmountOnExit ? isOpen : true;
const animate = isOpen || unmountOnExit ? "enter" : "exit";
return (
<AnimatePresence custom={custom} initial={false}>
{show && (
<motion.div
ref={ref}
{...rest}
animate={animate}
className={className}
custom={custom}
exit="exit"
initial={unmountOnExit ? "exit" : false}
style={{
overflow: "hidden",
display: "block",
...style,
}}
variants={variants as _Variants}
/>
)}
</AnimatePresence>
);
});
Collapse.displayName = "NextUI.Collapse";

View File

@ -1,2 +1,2 @@
export * from "./collapse";
export * from "./collapse-transition";
export * from "./transition-utils";

4
pnpm-lock.yaml generated
View File

@ -729,6 +729,7 @@ importers:
'@nextui-org/aria-utils': workspace:*
'@nextui-org/button': workspace:*
'@nextui-org/dom-utils': workspace:*
'@nextui-org/framer-transitions': workspace:*
'@nextui-org/react-utils': workspace:*
'@nextui-org/shared-utils': workspace:*
'@nextui-org/system': workspace:*
@ -741,10 +742,12 @@ importers:
'@react-types/overlays': ^3.7.0
'@react-types/tooltip': ^3.3.0
clean-package: 2.2.0
framer-motion: ^10.6.0
react: ^18.2.0
dependencies:
'@nextui-org/aria-utils': link:../../utilities/aria-utils
'@nextui-org/dom-utils': link:../../utilities/dom-utils
'@nextui-org/framer-transitions': link:../../utilities/framer-transitions
'@nextui-org/react-utils': link:../../utilities/react-utils
'@nextui-org/shared-utils': link:../../utilities/shared-utils
'@nextui-org/system': link:../../core/system
@ -754,6 +757,7 @@ importers:
'@react-aria/tooltip': 3.4.0_react@18.2.0
'@react-aria/utils': 3.15.0_react@18.2.0
'@react-stately/tooltip': 3.3.0_react@18.2.0
framer-motion: 10.6.0_react@18.2.0
devDependencies:
'@nextui-org/button': link:../button
'@react-types/overlays': 3.7.0_react@18.2.0