UNPKG

cambio

Version:

A simple shared animation component for React

483 lines (466 loc) 16.2 kB
'use client'; Object.defineProperty(exports, '__esModule', { value: true }); var React = require('react'); var dialog = require('@base-ui-components/react/dialog'); var react = require('motion/react'); function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } var React__default = /*#__PURE__*/_interopDefault(React); const CambioContext = React__default.default.createContext(null); function useCambioContext() { const context = React__default.default.useContext(CambioContext); if (!context) { throw new Error("Cambio components must be used within a Cambio Root"); } return context; } const MOTION_PRESETS = { snappy: { transition: { ease: [ 0.19, 1, 0.22, 1 ], duration: 0.24 }, drag: { stiffness: 600, damping: 40, restDelta: 0.01 } }, smooth: { transition: { ease: [ 0.42, 0, 0.58, 1 ], duration: 0.3 }, drag: { stiffness: 350, damping: 25, restDelta: 0.01 } }, bouncy: { transition: { type: "spring", stiffness: 1200, damping: 80, mass: 4 }, drag: { stiffness: 400, damping: 30, restDelta: 0.01 } }, reduced: { transition: { ease: "linear", duration: 0.01 }, drag: { stiffness: 1000, damping: 50, restDelta: 0.1 } } }; /** * Get motion configuration for a given preset * @param preset - The motion preset to use * @param forceReduced - Whether to force reduced motion regardless of preset * @returns MotionConfig with enter and exit transitions */ function getMotionConfig(preset, forceReduced = false) { if (forceReduced) { return MOTION_PRESETS.reduced; } return MOTION_PRESETS[preset]; } /** * Resolve the motion preset based on user preference and system settings * @param motion - Override preset or undefined to use default * @param reduceMotion - Whether reduced motion is preferred * @returns The resolved motion preset */ function resolveMotionPreset(motion, reduceMotion = false) { if (reduceMotion && !motion) { return "reduced"; } return motion != null ? motion : "smooth"; } /** * Parse motion config value into preset and variants * @param motion - The motion configuration value * @returns Object with preset and variants */ function parseMotionConfig(motion) { if (typeof motion === "string") { return { preset: motion }; } if (typeof motion === "object") { return { preset: "smooth", variants: motion }; } return { preset: "smooth" }; } /** * Get motion preset for a specific component, considering variants and overrides * @param componentType - The type of component (trigger, popup, backdrop) * @param componentMotion - Component-level motion override * @param globalMotion - Global motion configuration from Root * @param variants - Motion variants from Root * @param reduceMotion - Whether to force reduced motion * @returns The resolved motion preset for the component */ function getComponentMotionPreset(componentType, componentMotion, globalMotion, variants, reduceMotion = false) { if (reduceMotion) { return "reduced"; } if (componentMotion) { return componentMotion; } if (variants == null ? void 0 : variants[componentType]) { return variants[componentType]; } return globalMotion != null ? globalMotion : "smooth"; } /** * Helper function to check if user prefers reduced motion * @returns boolean indicating if reduced motion is preferred */ function getReducedMotion() { if (typeof window === "undefined") { return false; } const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); return mediaQuery.matches; } /** * React hook to detect user's preference for reduced motion with reactive updates * @returns boolean indicating if reduced motion is preferred */ function useReducedMotion() { const [reducedMotion, setReducedMotion] = React.useState(()=>getReducedMotion()); React.useEffect(()=>{ if (typeof window === "undefined") { return; } const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); const handleChange = ()=>setReducedMotion(mediaQuery.matches); if (mediaQuery.addEventListener) { mediaQuery.addEventListener("change", handleChange); } else { mediaQuery.addListener(handleChange); } return ()=>{ if (mediaQuery.removeEventListener) { mediaQuery.removeEventListener("change", handleChange); } else { mediaQuery.removeListener(handleChange); } }; }, []); return reducedMotion; } /** * Resolves the final reduced motion state based on user preference and override * @param reduceMotion - Override value (true/false) or undefined to use system preference * @returns boolean indicating if motion should be reduced */ function getReducedMotionState(reduceMotion) { if (typeof reduceMotion === "boolean") { return reduceMotion; } return getReducedMotion(); } /** * Resolve dismissible configuration with proper defaults * @param dismissible - The dismissible configuration value * @returns Resolved dismissible config with defaults */ function resolveDismissableConfig(dismissible) { if (!dismissible) { return null; } if (typeof dismissible === "boolean") { return dismissible ? { threshold: 100, velocity: 500 } : null; } return { threshold: 100, velocity: 500, ...dismissible }; } const Root$1 = react.motion.create(dialog.Dialog.Root); const Trigger$1 = react.motion.create(dialog.Dialog.Trigger); const Portal$1 = react.motion.create(dialog.Dialog.Portal); const Backdrop$1 = react.motion.create(dialog.Dialog.Backdrop); const Popup$1 = react.motion.create(dialog.Dialog.Popup); const Title$1 = react.motion.create(dialog.Dialog.Title); const Description$1 = react.motion.create(dialog.Dialog.Description); const Close$1 = react.motion.create(dialog.Dialog.Close); const MotionDialog = { Root: Root$1, Trigger: Trigger$1, Portal: Portal$1, Backdrop: Backdrop$1, Popup: Popup$1, Title: Title$1, Description: Description$1, Close: Close$1 }; const Backdrop = /*#__PURE__*/ React__default.default.forwardRef(function Backdrop({ motion: componentMotion, ...props }, ref) { const { open, motion: globalMotion, motionVariants, reduceMotion } = useCambioContext(); const resolvedMotion = getComponentMotionPreset("backdrop", componentMotion, globalMotion, motionVariants, reduceMotion); const componentMotionConfig = getMotionConfig(resolvedMotion, reduceMotion); const { transition = componentMotionConfig.transition, initial = { opacity: 0 }, animate = { opacity: 1 }, exit = { opacity: 0 } } = props; return /*#__PURE__*/ React__default.default.createElement(MotionDialog.Backdrop, { ...props, ref: ref, transition: transition, initial: initial, animate: animate, exit: exit, style: { ...props.style, display: "block" } }); }); Backdrop.displayName = "Cambio.Backdrop"; const Close = /*#__PURE__*/ React__default.default.memo(/*#__PURE__*/ React__default.default.forwardRef(function Close(props, ref) { return /*#__PURE__*/ React__default.default.createElement(MotionDialog.Close, { ref: ref, ...props }); })); Close.displayName = "Cambio.Close"; const Description = /*#__PURE__*/ React__default.default.memo(/*#__PURE__*/ React__default.default.forwardRef(function Description(props, ref) { return /*#__PURE__*/ React__default.default.createElement(MotionDialog.Description, { ref: ref, ...props }); })); Description.displayName = "Cambio.Description"; const DEFAULT_DRAG_SPRING_CONFIG = { stiffness: 400, damping: 30, restDelta: 0.01 }; const SCALE_DISTANCE_THRESHOLDS = [ 0, 50, 100 ]; const SCALE_OUTPUT_VALUES = [ 1, 0.98, 0.95 ]; const POPUP_OPACITY_DISTANCE_RANGE = [ 0, 80 ]; const POPUP_OPACITY_OUTPUT_RANGE = [ 1, 0.96 ]; const RESISTANCE_DISTANCE_DIVISOR = 20; const RESISTANCE_LOG_DIVISOR = 4; const Popup = /*#__PURE__*/ React__default.default.forwardRef(function Popup({ motion: componentMotion, ...props }, ref) { const { layoutId, open, onOpenChange, motion: globalMotion, motionVariants, reduceMotion, dismissible: rootDismissable } = useCambioContext(); const [isDragging, setIsDragging] = React.useState(false); const dragX = react.useMotionValue(0); const dragY = react.useMotionValue(0); const resolvedMotion = getComponentMotionPreset("popup", componentMotion, globalMotion, motionVariants, reduceMotion); const componentMotionConfig = getMotionConfig(resolvedMotion, reduceMotion); const dismissableConfig = resolveDismissableConfig(rootDismissable); const dragSpringConfig = componentMotionConfig.drag || DEFAULT_DRAG_SPRING_CONFIG; const springX = react.useSpring(dragX, dragSpringConfig); const springY = react.useSpring(dragY, dragSpringConfig); const distance = react.useTransform([ springX, springY ], ([x, y])=>Math.hypot(x, y)); const scale = react.useTransform(distance, SCALE_DISTANCE_THRESHOLDS, SCALE_OUTPUT_VALUES); const opacity = react.useTransform(distance, POPUP_OPACITY_DISTANCE_RANGE, POPUP_OPACITY_OUTPUT_RANGE); const resistance = react.useTransform(distance, (d)=>Math.max(0.1, 1 - Math.log(d / RESISTANCE_DISTANCE_DIVISOR + 1) / RESISTANCE_LOG_DIVISOR)); const dragConfig = React.useMemo(()=>{ if (!dismissableConfig) return {}; const { threshold = 60, velocity = 300 } = dismissableConfig; return { drag: true, dragElastic: 0, dragMomentum: false, dragSnapToOrigin: true, onDrag: (_e, info)=>{ if (!isDragging) { setIsDragging(true); } const resistanceValue = resistance.get(); dragX.set(info.offset.x * resistanceValue); dragY.set(info.offset.y * resistanceValue); }, onDragEnd: (_e, info)=>{ setIsDragging(false); const dist = Math.hypot(info.offset.x, info.offset.y); const speed = Math.hypot(info.velocity.x, info.velocity.y); const shouldDismiss = speed > velocity || dist > threshold; if (shouldDismiss && onOpenChange) { onOpenChange(false); } else { dragX.set(0); dragY.set(0); } } }; }, [ dismissableConfig, onOpenChange, dragX, dragY, isDragging, resistance ]); const { transition = componentMotionConfig.transition } = props; return /*#__PURE__*/ React__default.default.createElement(MotionDialog.Popup, { ...props, ...dragConfig, ref: ref, layoutId: layoutId, layoutCrossfade: false, layout: true, transition: transition, style: { position: "fixed", top: "50%", left: "50%", translate: "-50% -50%", touchAction: dismissableConfig ? "none" : "auto", cursor: isDragging ? "grabbing" : dismissableConfig ? "grab" : "default", userSelect: "none", WebkitUserSelect: "none", scale, opacity, x: springX, y: springY, ...props.style } }); }); Popup.displayName = "Cambio.Popup"; const Portal = /*#__PURE__*/ React__default.default.memo(/*#__PURE__*/ React__default.default.forwardRef(function Portal(props, _ref) { const { open } = useCambioContext(); return /*#__PURE__*/ React__default.default.createElement(react.AnimatePresence, null, open && /*#__PURE__*/ React__default.default.createElement(MotionDialog.Portal, { keepMounted: true, ...props })); })); Portal.displayName = "Cambio.Portal"; const Root = /*#__PURE__*/ React.forwardRef(function Root(props, _ref) { const generatedId = React.useId(); const { open, onOpenChange, defaultOpen = false, layoutId = `cambio-dialog-${generatedId}`, reduceMotion, motion, dismissible, ...rest } = props; const [openState, setOpenState] = React.useState(defaultOpen); const isOpen = open != null ? open : openState; const shouldReduceMotion = getReducedMotionState(reduceMotion); const { preset, variants } = parseMotionConfig(motion); const resolvedMotionPreset = resolveMotionPreset(preset, shouldReduceMotion); const motionConfig = getMotionConfig(resolvedMotionPreset, shouldReduceMotion); const handleChange = React.useCallback((next, _e, _reason)=>{ if (onOpenChange) { onOpenChange(next); } else { setOpenState(next); } }, [ onOpenChange ]); return /*#__PURE__*/ React__default.default.createElement(CambioContext.Provider, { value: { layoutId, open: isOpen, onOpenChange: (next)=>handleChange(next), reduceMotion: shouldReduceMotion, motion: resolvedMotionPreset, motionConfig, motionVariants: variants, dismissible } }, /*#__PURE__*/ React__default.default.createElement(MotionDialog.Root, { ...rest, open: isOpen, onOpenChange: handleChange })); }); Root.displayName = "Cambio.Root"; const Title = /*#__PURE__*/ React__default.default.memo(/*#__PURE__*/ React__default.default.forwardRef(function Title(props, ref) { return /*#__PURE__*/ React__default.default.createElement(MotionDialog.Title, { ref: ref, ...props }); })); Title.displayName = "Cambio.Title"; const TRIGGER_Z_INDEX = 1000; const Trigger = /*#__PURE__*/ React__default.default.memo(/*#__PURE__*/ React__default.default.forwardRef(function Trigger({ motion: componentMotion, ...props }, ref) { const { open, layoutId } = useCambioContext(); const [z, setZ] = React__default.default.useState(open ? TRIGGER_Z_INDEX : 0); React__default.default.useEffect(()=>{ if (open) { setZ(TRIGGER_Z_INDEX); } }, [ open ]); const handleLayoutAnimationComplete = React__default.default.useCallback(()=>{ if (!open) { setZ(0); } }, [ open ]); return /*#__PURE__*/ React__default.default.createElement(MotionDialog.Trigger, { ...props, ref: ref, layoutId: layoutId, layoutCrossfade: false, onLayoutAnimationComplete: handleLayoutAnimationComplete, style: { position: "relative", zIndex: z, ...props.style } }); })); Trigger.displayName = "Cambio.Trigger"; const Cambio = { Root: Root, Trigger: Trigger, Portal: Portal, Backdrop: Backdrop, Popup: Popup, Title: Title, Description: Description, Close: Close }; exports.Cambio = Cambio; exports.CambioContext = CambioContext; exports.MOTION_PRESETS = MOTION_PRESETS; exports.getMotionConfig = getMotionConfig; exports.getReducedMotionState = getReducedMotionState; exports.resolveMotionPreset = resolveMotionPreset; exports.useCambioContext = useCambioContext; exports.useReducedMotion = useReducedMotion;