UNPKG

framer-motion

Version:

A simple and powerful JavaScript animation library

728 lines (708 loc) • 26 kB
'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