framer-motion
Version:
A simple and powerful JavaScript animation library
728 lines (708 loc) • 26 kB
JavaScript
'use strict';
var motionDom = require('motion-dom');
var React = require('react');
var index = require('./index-CfVlkVx9.js');
var motionUtils = require('motion-utils');
var jsxRuntime = require('react/jsx-runtime');
/**
* When a component is the child of `AnimatePresence`, it can use `usePresence`
* to access information about whether it's still present in the React tree.
*
* ```jsx
* import { usePresence } from "framer-motion"
*
* export const Component = () => {
* const [isPresent, safeToRemove] = usePresence()
*
* useEffect(() => {
* !isPresent && setTimeout(safeToRemove, 1000)
* }, [isPresent])
*
* return <div />
* }
* ```
*
* If `isPresent` is `false`, it means that a component has been removed from the tree,
* but `AnimatePresence` won't really remove it until `safeToRemove` has been called.
*
* @public
*/
function usePresence(subscribe = true) {
const context = React.useContext(index.PresenceContext);
if (context === null)
return [true, null];
const { isPresent, onExitComplete, register } = context;
// It's safe to call the following hooks conditionally (after an early return) because the context will always
// either be null or non-null for the lifespan of the component.
const id = React.useId();
React.useEffect(() => {
if (subscribe) {
return register(id);
}
}, [subscribe]);
const safeToRemove = React.useCallback(() => subscribe && onExitComplete && onExitComplete(id), [id, onExitComplete, subscribe]);
return !isPresent && onExitComplete ? [false, safeToRemove] : [true];
}
/**
* Similar to `usePresence`, except `useIsPresent` simply returns whether or not the component is present.
* There is no `safeToRemove` function.
*
* ```jsx
* import { useIsPresent } from "framer-motion"
*
* export const Component = () => {
* const isPresent = useIsPresent()
*
* useEffect(() => {
* !isPresent && console.log("I've been removed!")
* }, [isPresent])
*
* return <div />
* }
* ```
*
* @public
*/
function useIsPresent() {
return isPresent(React.useContext(index.PresenceContext));
}
function isPresent(context) {
return context === null ? true : context.isPresent;
}
const createDomVisualElement = (Component, options) => {
/**
* Use explicit isSVG override if provided, otherwise auto-detect
*/
const isSVG = options.isSVG ?? index.isSVGComponent(Component);
return isSVG
? new motionDom.SVGVisualElement(options)
: new motionDom.HTMLVisualElement(options, {
allowProjection: Component !== React.Fragment,
});
};
class AnimationFeature extends motionDom.Feature {
/**
* We dynamically generate the AnimationState manager as it contains a reference
* to the underlying animation library. We only want to load that if we load this,
* so people can optionally code split it out using the `m` component.
*/
constructor(node) {
super(node);
node.animationState || (node.animationState = motionDom.createAnimationState(node));
}
updateAnimationControlsSubscription() {
const { animate } = this.node.getProps();
if (motionDom.isAnimationControls(animate)) {
this.unmountControls = animate.subscribe(this.node);
}
}
/**
* Subscribe any provided AnimationControls to the component's VisualElement
*/
mount() {
this.updateAnimationControlsSubscription();
}
update() {
if (this.node.getProps().animate !== this.node.prevProps?.animate) {
this.updateAnimationControlsSubscription();
}
}
unmount() {
this.node.animationState.reset();
this.unmountControls?.();
}
}
let id = 0;
class ExitAnimationFeature extends motionDom.Feature {
constructor() {
super(...arguments);
this.id = id++;
}
update() {
const { presenceContext, prevPresenceContext, animationState } = this.node;
if (!presenceContext || !animationState)
return;
const { isPresent, onExitComplete } = presenceContext;
const prevIsPresent = prevPresenceContext?.isPresent;
if (isPresent === prevIsPresent)
return;
if (isPresent && prevIsPresent === false) {
/**
* When re-entering after the exit completed (element is at
* rest), replay the enter animation from initial.
*/
if (this.exit === true) {
const { initial } = this.node.getProps();
if (initial && !Array.isArray(initial)) {
const { transition, transitionEnd, ...target } = motionDom.resolveVariant(this.node, initial) || {};
for (const key in target) {
this.node
.getValue(key)
?.jump(target[key]);
}
}
/**
* A re-entering element is no longer an initial child, so
* AnimatePresence's initial={false} mustn't block its enter.
*/
this.node.blockInitialAnimation = false;
animationState.reset();
animationState.animateChanges();
}
else {
animationState.setActive("exit", false);
}
this.exit = undefined;
return;
}
const exit = (this.exit = animationState.setActive("exit", !isPresent));
if (onExitComplete && !isPresent) {
exit.then(() => {
/**
* An exit can resolve after the element has re-entered, e.g.
* when its final tick lands in the stop() of the enter.
*/
if (this.exit !== exit)
return;
this.exit = true;
onExitComplete(this.id);
});
}
}
mount() {
const { register, onExitComplete } = this.node.presenceContext || {};
if (onExitComplete) {
onExitComplete(this.id);
}
if (register) {
this.unmount = register(this.id, this.node);
}
}
unmount() { }
}
const animations = {
animation: {
Feature: AnimationFeature,
},
exit: {
Feature: ExitAnimationFeature,
},
};
class DragGesture extends motionDom.Feature {
constructor(node) {
super(node);
this.removeGroupControls = motionUtils.noop;
this.removeListeners = motionUtils.noop;
this.controls = new motionDom.VisualElementDragControls(node);
}
mount() {
// If we've been provided a DragControls for manual control over the drag gesture,
// subscribe this component to it on mount.
const { dragControls } = this.node.getProps();
if (dragControls) {
this.removeGroupControls = dragControls.subscribe(this.controls);
}
this.removeListeners = this.controls.addListeners() || motionUtils.noop;
}
update() {
const { dragControls, drag } = this.node.getProps();
const { dragControls: prevDragControls, drag: prevDrag } = this.node.prevProps || {};
if (dragControls !== prevDragControls) {
this.removeGroupControls();
if (dragControls) {
this.removeGroupControls = dragControls.subscribe(this.controls);
}
}
/**
* If drag is disabled mid-gesture, for instance from onDrag, end the
* gesture as if released. This must happen before this render's
* layout change is processed, otherwise the element is still
* animation-blocked, so it jumps to its new layout and keeps that
* jump as a drag offset, which release wouldn't animate away as
* drag is now disabled.
*/
if (prevDrag && !drag)
this.controls.stop(undefined, undefined, prevDrag);
}
unmount() {
this.removeGroupControls();
this.removeListeners();
/**
* In React 19, during list reorder reconciliation, components may
* briefly unmount and remount while the drag is still active. If we're
* actively dragging, we should NOT end the pan session - it will
* continue tracking pointer events via its window-level listeners.
*
* The pan session will be properly cleaned up when:
* 1. The drag ends naturally (pointerup/pointercancel)
* 2. The component is truly removed from the DOM
*/
if (!this.controls.isDragging) {
this.controls.endPanSession();
}
}
}
const asyncHandler = (handler) => (event, info) => {
if (handler) {
motionDom.frame.update(() => handler(event, info), false, true);
}
};
class PanGesture extends motionDom.Feature {
constructor() {
super(...arguments);
this.removePointerDownListener = motionUtils.noop;
}
onPointerDown(pointerDownEvent) {
this.session = new motionDom.PanSession(pointerDownEvent, this.createPanHandlers(), {
transformPagePoint: this.node.getTransformPagePoint(),
// Fixes https://github.com/motiondivision/motion/issues/2270
contextWindow: this.node.current?.ownerDocument.defaultView,
});
}
createPanHandlers() {
const { onPanSessionStart, onPanStart, onPan, onPanEnd } = this.node.getProps();
return {
onSessionStart: asyncHandler(onPanSessionStart),
onStart: asyncHandler(onPanStart),
onMove: asyncHandler(onPan),
onEnd: (event, info) => {
delete this.session;
if (onPanEnd) {
motionDom.frame.postRender(() => onPanEnd(event, info));
}
},
};
}
mount() {
this.removePointerDownListener = motionDom.addPointerEvent(this.node.current, "pointerdown", (event) => this.onPointerDown(event));
}
update() {
this.session?.updateHandlers(this.createPanHandlers());
}
unmount() {
this.removePointerDownListener();
this.session?.end();
}
}
/**
* Track whether we've taken any snapshots yet. If not,
* we can safely skip notification of didUpdate.
*
* Difficult to capture in a test but to prevent flickering
* we must set this to true either on update or unmount.
* Running `next-env/layout-id` in Safari will show this behaviour if broken.
*/
let hasTakenAnySnapshot = false;
/**
* The projection node is created with the props from the component's first
* render, so keep these in sync as they can change (or be enabled) later.
*/
const layoutOptions = ({ layout, layoutDependency, layoutAnchor, }) => ({
layout,
layoutDependency,
layoutAnchor,
animationType: typeof layout === "string" ? layout : "both",
});
/**
* Move a mounted node to its new layoutId's stack, as an unmount and remount
* would. Returns a takeover to run once every node in the commit has had the
* chance to change its layoutId.
*/
const switchLayoutId = (projection, layoutId) => {
const { snapshot } = projection;
const stack = projection.getStack();
stack?.remove(projection);
/**
* If this node led the old stack (so remove() made it the prevLead of
* the new lead), the new lead animates from this node's snapshot, which
* it now holds. Unlike a node that unmounts, this one stays mounted, so
* unlink them: the new lead mustn't read this node's snapshot after it
* changes, or stop or share its animation.
*/
const lead = stack?.lead;
if (stack?.prevLead === projection && lead.resumeFrom === projection)
lead.resumeFrom = lead.currentAnimation = projection.resumingFrom =
undefined;
projection.options.layoutId = layoutId;
/**
* A node that followed another no longer does, so clear the styles
* that hid it.
*/
if (!layoutId) {
projection.needsShow = true;
return;
}
projection.root.registerSharedNode(layoutId, projection);
/**
* Like a mounting node, this one animates from the lead it replaces.
* But if that lead is still on screen with this layoutId (e.g. an open
* card moving to another thumbnail), this node is already on screen
* too, so it takes over instantly: it keeps its own snapshot, and the
* lead is hidden, as at the end of a crossfade. A lead that left the
* stack in this commit (e.g. layoutId={selected ? "x" : undefined})
* is animated from, as is one that's exiting.
*/
return () => {
const { resumeFrom } = projection;
if (resumeFrom?.isPresent !== false &&
projection.getStack().members.includes(resumeFrom)) {
resumeFrom.hide();
projection.resumeFrom = undefined;
projection.snapshot = snapshot;
}
};
};
class MeasureLayoutWithContext extends React.Component {
/**
* This only mounts projection nodes for components that
* need measuring, we might want to do it for all components
* in order to incorporate transforms
*/
componentDidMount() {
const { visualElement, layoutGroup, switchLayoutGroup, layoutId } = this.props;
const { projection } = visualElement;
if (projection) {
layoutGroup.group?.add(projection);
layoutId && switchLayoutGroup?.register?.(projection);
if (hasTakenAnySnapshot) {
projection.root.didUpdate();
}
/**
* Without `layout` or `drag`, this only renders while there's a
* layoutId, so a mounted node can gain one here.
*/
if (projection.options.layoutId !== layoutId)
switchLayoutId(projection, layoutId)?.();
projection.addEventListener("animationComplete", () => this.safeToRemove());
projection.setOptions({
...projection.options,
...layoutOptions(this.props),
onExitComplete: () => this.safeToRemove(),
});
}
motionDom.globalProjectionState.hasEverUpdated = true;
}
getSnapshotBeforeUpdate(prevProps) {
const { layoutDependency, layoutId, visualElement, drag, isPresent } = this.props;
const { projection } = visualElement;
if (!projection)
return null;
/**
* TODO: We use this data in relegate to determine whether to
* promote a previous element. There's no guarantee its presence data
* will have updated by this point - if a bug like this arises it will
* have to be that we markForRelegation and then find a new lead some other way,
* perhaps in didUpdate
*/
projection.isPresent = isPresent;
this.takeOver =
prevProps.layoutId !== layoutId
? (projection.willUpdate(), switchLayoutId(projection, layoutId))
: undefined;
projection.setOptions({
...projection.options,
...layoutOptions(this.props),
});
hasTakenAnySnapshot = true;
if (drag ||
prevProps.layoutDependency !== layoutDependency ||
layoutDependency === undefined ||
prevProps.isPresent !== isPresent) {
projection.willUpdate();
}
else {
this.safeToRemove();
}
if (prevProps.isPresent !== isPresent) {
if (isPresent) {
projection.promote();
}
else if (!projection.relegate()) {
/**
* If there's another stack member taking over from this one,
* it's in charge of the exit animation and therefore should
* be in charge of the safe to remove. Otherwise we call it here.
*/
motionDom.frame.postRender(() => {
projection.getStack()?.members.length || this.safeToRemove();
});
}
}
return null;
}
componentDidUpdate() {
const { projection } = this.props.visualElement;
if (projection) {
this.takeOver?.();
projection.root.didUpdate();
motionDom.microtask.postRender(() => {
if (!projection.currentAnimation && projection.isLead()) {
this.safeToRemove();
}
});
}
}
componentWillUnmount() {
const { visualElement, layoutGroup, switchLayoutGroup: promoteContext, } = this.props;
const { projection } = visualElement;
hasTakenAnySnapshot = true;
if (projection) {
projection.scheduleCheckAfterUnmount();
layoutGroup.group?.remove(projection);
promoteContext?.deregister?.(projection);
}
}
safeToRemove() {
this.props.safeToRemove?.();
}
render() {
return null;
}
}
function MeasureLayout(props) {
const [isPresent, safeToRemove] = usePresence();
const layoutGroup = React.useContext(index.LayoutGroupContext);
return (jsxRuntime.jsx(MeasureLayoutWithContext, { ...props, layoutGroup: layoutGroup, switchLayoutGroup: React.useContext(index.SwitchLayoutGroupContext), isPresent: isPresent, safeToRemove: safeToRemove }));
}
const drag = {
pan: {
Feature: PanGesture,
},
drag: {
Feature: DragGesture,
ProjectionNode: motionDom.HTMLProjectionNode,
MeasureLayout,
},
};
/**
* Toggle `while{gesture}` and fire `on{gesture}{lifecycle}`, e.g.
* `onHoverStart` or, with an empty lifecycle, `onTap`.
*/
function handleGestureEvent(node, event, gesture, lifecycle) {
const { props } = node;
const whileKey = ("while" + gesture);
if (node.animationState && props[whileKey]) {
node.animationState.setActive(whileKey, lifecycle === "Start");
}
const callback = props[("on" + gesture + lifecycle)];
if (callback) {
motionDom.frame.postRender(() => callback(event, motionDom.extractEventInfo(event)));
}
}
class HoverGesture extends motionDom.Feature {
mount() {
const { current } = this.node;
if (!current)
return;
this.unmount = motionDom.hover(current, (_element, startEvent) => {
handleGestureEvent(this.node, startEvent, "Hover", "Start");
return (endEvent) => handleGestureEvent(this.node, endEvent, "Hover", "End");
});
}
unmount() { }
}
class FocusGesture extends motionDom.Feature {
constructor() {
super(...arguments);
this.isActive = false;
}
onFocus() {
let isFocusVisible = false;
/**
* If this element doesn't match focus-visible then don't
* apply whileHover. But, if matches throws that focus-visible
* is not a valid selector then in that browser outline styles will be applied
* to the element by default and we want to match that behaviour with whileFocus.
*/
try {
isFocusVisible = this.node.current.matches(":focus-visible");
}
catch (e) {
isFocusVisible = true;
}
if (!isFocusVisible || !this.node.animationState)
return;
this.node.animationState.setActive("whileFocus", true);
this.isActive = true;
}
onBlur() {
if (!this.isActive || !this.node.animationState)
return;
this.node.animationState.setActive("whileFocus", false);
this.isActive = false;
}
mount() {
this.unmount = motionUtils.pipe(motionDom.addDomEvent(this.node.current, "focus", () => this.onFocus()), motionDom.addDomEvent(this.node.current, "blur", () => this.onBlur()));
}
unmount() { }
}
function handlePressEvent(node, event, lifecycle) {
if (node.current instanceof HTMLButtonElement && node.current.disabled) {
return;
}
handleGestureEvent(node, event, "Tap", lifecycle);
}
class PressGesture extends motionDom.Feature {
mount() {
const { current } = this.node;
if (!current)
return;
const { globalTapTarget, propagate } = this.node.props;
this.unmount = motionDom.press(current, (_element, startEvent) => {
handlePressEvent(this.node, startEvent, "Start");
return (endEvent, { success }) => handlePressEvent(this.node, endEvent, success ? "" : "Cancel");
}, {
useGlobalTarget: globalTapTarget,
stopPropagation: propagate?.tap === false,
});
}
unmount() { }
}
/**
* Map an IntersectionHandler callback to an element. We only ever make one handler for one
* element, so even though these handlers might all be triggered by different
* observers, we can keep them in the same map.
*/
const observerCallbacks = new WeakMap();
/**
* Multiple observers can be created for multiple element/document roots. Each with
* different settings. So here we store dictionaries of observers to each root,
* using serialised settings (threshold/margin) as lookup keys.
*/
const observers = new WeakMap();
const fireObserverCallback = (entry) => {
const callback = observerCallbacks.get(entry.target);
callback && callback(entry);
};
const fireAllObserverCallbacks = (entries) => {
entries.forEach(fireObserverCallback);
};
function initIntersectionObserver({ root, ...options }) {
const lookupRoot = root || document;
/**
* If we don't have an observer lookup map for this root, create one.
*/
if (!observers.has(lookupRoot)) {
observers.set(lookupRoot, {});
}
const rootObservers = observers.get(lookupRoot);
const key = JSON.stringify(options);
/**
* If we don't have an observer for this combination of root and settings,
* create one.
*/
if (!rootObservers[key]) {
rootObservers[key] = new IntersectionObserver(fireAllObserverCallbacks, { root, ...options });
}
return rootObservers[key];
}
function observeIntersection(element, options, callback) {
const rootInteresectionObserver = initIntersectionObserver(options);
observerCallbacks.set(element, callback);
rootInteresectionObserver.observe(element);
return () => {
observerCallbacks.delete(element);
rootInteresectionObserver.unobserve(element);
};
}
const thresholdNames = {
some: 0,
all: 1,
};
class InViewFeature extends motionDom.Feature {
constructor() {
super(...arguments);
this.hasEnteredView = false;
this.isInView = false;
}
startObserver() {
this.stopObserver?.();
const { viewport = {} } = this.node.getProps();
const { root, margin: rootMargin, amount = "some", once } = viewport;
const options = {
root: root ? root.current : undefined,
rootMargin,
threshold: typeof amount === "number" ? amount : thresholdNames[amount],
};
const onIntersectionUpdate = (entry) => {
const { isIntersecting } = entry;
/**
* If there's been no change in the viewport state, early return.
*/
if (this.isInView === isIntersecting)
return;
this.isInView = isIntersecting;
/**
* Handle hasEnteredView. If this is only meant to run once, and
* element isn't visible, early return. Otherwise set hasEnteredView to true.
*/
if (once && !isIntersecting && this.hasEnteredView) {
return;
}
else if (isIntersecting) {
this.hasEnteredView = true;
}
if (this.node.animationState) {
this.node.animationState.setActive("whileInView", isIntersecting);
}
/**
* Use the latest committed props rather than the ones in scope
* when this observer is created
*/
const { onViewportEnter, onViewportLeave } = this.node.getProps();
const callback = isIntersecting ? onViewportEnter : onViewportLeave;
callback && callback(entry);
};
this.stopObserver = observeIntersection(this.node.current, options, onIntersectionUpdate);
}
mount() {
this.startObserver();
}
update() {
if (typeof IntersectionObserver === "undefined")
return;
const { props, prevProps } = this.node;
const hasOptionsChanged = ["amount", "margin", "root"].some(hasViewportOptionChanged(props, prevProps));
if (hasOptionsChanged) {
this.startObserver();
}
}
unmount() {
this.stopObserver?.();
this.hasEnteredView = false;
this.isInView = false;
}
}
function hasViewportOptionChanged({ viewport = {} }, { viewport: prevViewport = {} } = {}) {
return (name) => viewport[name] !== prevViewport[name];
}
const gestureAnimations = {
inView: {
Feature: InViewFeature,
},
tap: {
Feature: PressGesture,
},
focus: {
Feature: FocusGesture,
},
hover: {
Feature: HoverGesture,
},
};
const layout = {
layout: {
ProjectionNode: motionDom.HTMLProjectionNode,
MeasureLayout,
},
};
const featureBundle = /* @__PURE__ */ Object.assign({}, animations, gestureAnimations, drag, layout);
exports.animations = animations;
exports.createDomVisualElement = createDomVisualElement;
exports.drag = drag;
exports.featureBundle = featureBundle;
exports.gestureAnimations = gestureAnimations;
exports.layout = layout;
exports.useIsPresent = useIsPresent;
exports.usePresence = usePresence;
//# sourceMappingURL=feature-bundle-BsODp5VY.js.map