cambio
Version:
A simple shared animation component for React
483 lines (466 loc) • 16.2 kB
JavaScript
'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;