framer-motion
Version:
A simple and powerful JavaScript animation library
2,116 lines • 76.3 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var motionDom = require('motion-dom');
var component = require('remix/component');
var motionUtils = require('motion-utils');
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();
}
}
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, animationState } = this.node;
if (!presenceContext || !animationState)
return;
const { isPresent, onExitComplete } = presenceContext;
/**
* Compare presence with our own exit, not the previous presence
* context. An element can mount already exiting, for instance when
* Suspense reveals it in the render that removed it, and then both
* contexts have isPresent: false (#4049).
*/
if (isPresent === !this.exit)
return;
if (isPresent) {
/**
* 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", true));
if (onExitComplete) {
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 } = this.node.presenceContext || {};
if (register) {
this.unmount = register(this.id, this.node);
}
// Start the exit of an element that mounts already exiting
this.update();
}
unmount() { }
}
const animations = {
animation: {
Feature: AnimationFeature,
},
exit: {
Feature: ExitAnimationFeature,
},
};
/**
* Toggle `while{gesture}` and fire `on{gesture}{lifecycle}`, e.g.
* `onHoverStart` or, with an empty lifecycle, `onTap`. A repeat fires the
* callback but leaves `while{gesture}` active.
*/
function handleGestureEvent(node, event, gesture, lifecycle, isRepeat) {
const { props } = node;
const whileKey = ("while" + gesture);
if (!isRepeat && 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, isRepeat) {
if (node.current instanceof HTMLButtonElement && node.current.disabled) {
return;
}
handleGestureEvent(node, event, "Tap", lifecycle, isRepeat);
}
class PressGesture extends motionDom.Feature {
constructor() {
super(...arguments);
/**
* press() reads its options on each press, so this object is kept
* and updated in place when props change.
*/
this.options = {};
}
mount() {
const { current } = this.node;
if (!current)
return;
this.update();
this.unmount = motionDom.press(current, (_element, startEvent) => {
handlePressEvent(this.node, startEvent, "Start");
return {
onComplete: (endEvent, { success }) => handlePressEvent(this.node, endEvent, success ? "" : "Cancel"),
onRepeat: () => handlePressEvent(this.node, startEvent, "", true),
};
}, this.options);
}
update() {
const { options, node } = this;
const { globalTapTarget, propagate, tap } = node.props;
for (const key in options)
delete options[key];
Object.assign(options, tap, {
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 featureProps = {
animation: ["variants", ...motionDom.variantPriorityOrder],
exit: ["exit"],
drag: ["drag", "dragControls"],
focus: ["whileFocus"],
hover: ["whileHover", "onHoverStart", "onHoverEnd"],
tap: ["whileTap", "onTap", "onTapStart", "onTapCancel"],
pan: ["onPan", "onPanStart", "onPanSessionStart", "onPanEnd"],
inView: ["whileInView", "onViewportEnter", "onViewportLeave"],
layout: ["layout", "layoutId"],
};
let isInitialized = false;
/**
* Initialize feature definitions with isEnabled checks.
* This must be called before any motion components are rendered.
*/
function initFeatureDefinitions() {
if (isInitialized)
return;
const initialFeatureDefinitions = {};
for (const key in featureProps) {
initialFeatureDefinitions[key] = {
isEnabled: (props) => featureProps[key].some((name) => !!props[name]),
};
}
motionDom.setFeatureDefinitions(initialFeatureDefinitions);
isInitialized = true;
}
/**
* Get the current feature definitions, initializing if needed.
*/
function getInitializedFeatureDefinitions() {
initFeatureDefinitions();
return motionDom.getFeatureDefinitions();
}
function loadFeatures(features) {
const featureDefinitions = getInitializedFeatureDefinitions();
for (const key in features) {
featureDefinitions[key] = {
...featureDefinitions[key],
...features[key],
};
}
motionDom.setFeatureDefinitions(featureDefinitions);
}
/**
* A list of all valid MotionProps.
*
* @privateRemarks
* This doesn't throw if a `MotionProp` name is missing - it should.
*/
const validMotionProps = new Set([
"animate",
"exit",
"variants",
"initial",
"style",
"values",
"transition",
"transformTemplate",
"custom",
"inherit",
"onBeforeLayoutMeasure",
"onAnimationStart",
"onAnimationComplete",
"onUpdate",
"onDragStart",
"onDrag",
"onDragEnd",
"onMeasureDragConstraints",
"onDirectionLock",
"onDragTransitionEnd",
"_dragX",
"_dragY",
"onHoverStart",
"onHoverEnd",
"onViewportEnter",
"onViewportLeave",
"globalTapTarget",
"tap",
"propagate",
"ignoreStrict",
"viewport",
"pinch",
]);
/**
* Check whether a prop name is a valid `MotionProp` key.
*
* @param key - Name of the property to check
* @returns `true` is key is a valid `MotionProp`.
*
* @public
*/
function isValidMotionProp(key) {
return (key.startsWith("while") ||
(key.startsWith("drag") && key !== "draggable") ||
key.startsWith("layout") ||
key.startsWith("onTap") ||
key.startsWith("onPan") ||
key.startsWith("onLayout") ||
validMotionProps.has(key));
}
/**
* We keep these listed separately as we use the lowercase tag names as part
* of the runtime bundle to detect SVG components
*/
const lowercaseSVGElements = [
"animate",
"circle",
"defs",
"desc",
"ellipse",
"g",
"image",
"line",
"filter",
"marker",
"mask",
"metadata",
"path",
"pattern",
"polygon",
"polyline",
"rect",
"stop",
"switch",
"symbol",
"svg",
"text",
"tspan",
"use",
"view",
];
const threshold = 50;
// Pixels per millisecond (25px per frame at 60fps)
const maxSpeed = 1.5;
const overflowStyles = new Set(["auto", "scroll"]);
/**
* Per axis and scrollable element, for the current drag: the edge it's
* auto-scrolling towards, "start" (top/left) or "end" (bottom/right), and
* its scroll limit when that began (Bug 1 fix). A drag in a Reorder.Board
* scrolls on both axes, often of one element (like the page), and can
* scroll several, so they're kept per axis and all cleared when it ends.
*/
const scrollStates = {
x: new Map(),
y: new Map(),
};
function resetAutoScrollState() {
scrollStates.x.clear();
scrollStates.y.clear();
}
function isScrollableElement(element, axis) {
const style = getComputedStyle(element);
const overflow = axis === "x" ? style.overflowX : style.overflowY;
const isDocumentScroll = element === document.body ||
element === document.documentElement;
/**
* An element that scrolls on one axis computes overflow auto on both
* (e.g. a column of a board that scrolls the other way), so it must also
* have content to scroll on this one.
*/
return (isDocumentScroll ||
(overflowStyles.has(overflow) &&
(axis === "x"
? element.scrollWidth > element.clientWidth
: element.scrollHeight > element.clientHeight)));
}
function findScrollableAncestor(element, axis) {
let current = element?.parentElement;
while (current) {
if (isScrollableElement(current, axis)) {
return current;
}
current = current.parentElement;
}
return null;
}
/**
* Speed ramps up towards the edge and is capped at maxSpeed once the
* pointer reaches or passes it. This runs from onDrag, within a frame, so
* scale by the frame's elapsed time to scroll at the same speed whatever
* the display's refresh rate.
*/
function scrollSpeed(distanceFromEdge) {
const intensity = Math.min(1, 1 - distanceFromEdge / threshold);
return maxSpeed * intensity * intensity * motionDom.frameData.delta;
}
function getScrollAmount(pointerPosition, scrollElement, axis) {
const rect = scrollElement.getBoundingClientRect();
const start = axis === "x" ? Math.max(0, rect.left) : Math.max(0, rect.top);
const end = axis === "x" ? Math.min(window.innerWidth, rect.right) : Math.min(window.innerHeight, rect.bottom);
const distanceFromStart = pointerPosition - start;
const distanceFromEnd = end - pointerPosition;
if (distanceFromStart < threshold) {
return { amount: -scrollSpeed(distanceFromStart), edge: "start" };
}
else if (distanceFromEnd < threshold) {
return { amount: scrollSpeed(distanceFromEnd), edge: "end" };
}
return { amount: 0, edge: null };
}
function autoScrollIfNeeded(groupElement, pointerPosition, axis, velocity) {
if (!groupElement)
return;
const scrollableAncestor = findScrollableAncestor(groupElement, axis);
if (!scrollableAncestor)
return;
// Convert pointer position from page coordinates to viewport coordinates.
// The gesture system uses pageX/pageY but getBoundingClientRect() returns
// viewport-relative coordinates, so we need to account for page scroll.
const viewportPointerPosition = pointerPosition - (axis === "x" ? window.scrollX : window.scrollY);
const { amount: scrollAmount, edge } = getScrollAmount(viewportPointerPosition, scrollableAncestor, axis);
const states = scrollStates[axis];
// If not in any threshold zone, clear this axis' state
if (edge === null) {
states.delete(scrollableAncestor);
return;
}
let state = states.get(scrollableAncestor);
const isDocumentScroll = scrollableAncestor === document.body ||
scrollableAncestor === document.documentElement;
// If not currently scrolling this edge, check velocity to see if we should start
if (state?.edge !== edge) {
// Only start scrolling if velocity is towards the edge
const shouldStart = (edge === "start" && velocity < 0) ||
(edge === "end" && velocity > 0);
if (!shouldStart)
return;
// Activate this edge, and record the initial scroll limit
// (prevents infinite scroll)
state = {
edge,
limit: axis === "x"
? scrollableAncestor.scrollWidth - (isDocumentScroll ? window.innerWidth : scrollableAncestor.clientWidth)
: scrollableAncestor.scrollHeight - (isDocumentScroll ? window.innerHeight : scrollableAncestor.clientHeight),
};
states.set(scrollableAncestor, state);
}
// Cap scrolling at initial limit (prevents infinite scroll)
if (scrollAmount > 0) {
const currentScroll = axis === "x"
? (isDocumentScroll ? window.scrollX : scrollableAncestor.scrollLeft)
: (isDocumentScroll ? window.scrollY : scrollableAncestor.scrollTop);
if (currentScroll >= state.limit)
return;
}
// Apply scroll
if (axis === "x") {
if (isDocumentScroll) {
window.scrollBy({ left: scrollAmount });
}
else {
scrollableAncestor.scrollLeft += scrollAmount;
}
}
else {
if (isDocumentScroll) {
window.scrollBy({ top: scrollAmount });
}
else {
scrollableAncestor.scrollTop += scrollAmount;
}
}
}
function checkReorder(order, value, offset, velocity, axis, direction = "ltr") {
const index = order.findIndex((item) => item.value === value);
if (index === -1)
return order;
if (axis === "xy") {
const { layout } = order[index];
const point = {
x: center(layout.x) + offset.x,
y: center(layout.y) + offset.y,
};
const lines = getLines(order);
const sourceLine = lines.find((line) => line.items.includes(order[index]));
const targetLine = lines.reduce((closest, line) => distanceToLine(point.y, line) < distanceToLine(point.y, closest)
? line
: closest);
if (targetLine !== sourceLine) {
return moveToLine(order, index, point.x, targetLine, direction);
}
const currentDistance = distanceToBox(point, layout);
let target = -1;
let targetDistance = currentDistance;
order.forEach((item, targetIndex) => {
if (targetIndex === index)
return;
const distance = distanceToBox(point, item.layout);
if (distance < targetDistance) {
target = targetIndex;
targetDistance = distance;
}
});
return target === -1
? order
: motionUtils.moveItem(order, index, index + Math.sign(target - index));
}
if (!velocity[axis])
return order;
const nextOffset = velocity[axis] > 0 ? 1 : -1;
const nextItem = order[index + nextOffset];
if (!nextItem)
return order;
const item = order[index];
const itemLayout = item.layout[axis];
const nextLayout = nextItem.layout[axis];
const nextItemCenter = center(nextLayout);
if ((nextOffset === 1 && itemLayout.max + offset[axis] > nextItemCenter) ||
(nextOffset === -1 && itemLayout.min + offset[axis] < nextItemCenter)) {
return motionUtils.moveItem(order, index, index + nextOffset);
}
return order;
}
/**
* The index a key takes `order[index]` to, or -1 if there's nowhere to go,
* `key` being its index in Up, Left, Down, Right, Home, End. Left and Right
* go along the order (swapped in RTL). On the "xy" axis, Up and Down go to
* the row above or below, to the item there nearest across. Home and End
* go to either end.
*/
function keyTarget(order, index, key, axis, isRTL) {
const step = key > 1 !== !!(key % 2 && isRTL) ? 1 : -1;
let target = key > 3 ? (key - 4) * (order.length - 1) : index + step;
if (axis === "xy" && key < 4 && !(key % 2) && index > -1) {
/**
* Rows top to bottom as drawn, which isn't their order with
* flex-wrap: wrap-reverse.
*/
const lines = getLines(order).sort((a, b) => a.min - b.min);
const line = lines[lines.findIndex(({ items }) => items.includes(order[index])) +
step];
const distance = (item) => Math.abs(center(item.layout.x) - center(order[index].layout.x));
target = line
? order.indexOf(line.items.reduce((nearest, item) => distance(item) < distance(nearest) ? item : nearest))
: -1;
}
return index > -1 && target !== index && order[target] ? target : -1;
}
const center = ({ min, max }) => motionDom.mixNumber(min, max, 0.5);
function getLines(order) {
const lines = [];
order.forEach((item) => {
const { min, max } = item.layout.y;
const line = lines[lines.length - 1];
if (!line || min >= line.max || max <= line.min) {
lines.push({ items: [item], min, max });
}
else {
line.items.push(item);
line.min = Math.min(line.min, min);
line.max = Math.max(line.max, max);
}
});
return lines;
}
function distanceToLine(y, line) {
return y < line.min ? line.min - y : y > line.max ? y - line.max : 0;
}
function moveToLine(order, index, x, line, direction) {
const remaining = order.filter((_, itemIndex) => itemIndex !== index);
const before = line.items.find((item) => {
const itemCenter = center(item.layout.x);
return direction === "ltr" ? x < itemCenter : x > itemCenter;
});
const targetIndex = before
? remaining.indexOf(before)
: remaining.indexOf(line.items[line.items.length - 1]) + 1;
const nextOrder = [...remaining];
nextOrder.splice(targetIndex, 0, order[index]);
return nextOrder.every((item, itemIndex) => item === order[itemIndex])
? order
: nextOrder;
}
function distanceToBox(point, box) {
const x = Math.max(box.x.min - point.x, 0, point.x - box.x.max);
const y = Math.max(box.y.min - point.y, 0, point.y - box.y.max);
return x * x + y * y;
}
const isSeparated = (a, b) => a.max <= b.min || b.max <= a.min;
function detectAxis(layouts) {
let x = false;
let y = false;
for (let i = 0; i < layouts.length; i++) {
for (let j = i + 1; j < layouts.length; j++) {
x || (x = isSeparated(layouts[i].x, layouts[j].x));
y || (y = isSeparated(layouts[i].y, layouts[j].y));
if (x && y)
return "xy";
}
}
return x ? "x" : "y";
}
const keys = ["ArrowUp", "ArrowLeft", "ArrowDown", "ArrowRight", "Home", "End"];
function createReorder(motion) {
/**
* A list of items that can be reordered by drag or keyboard, with the
* same props as React's `Reorder.Group`.
*/
function Group(handle) {
const itemLayouts = new Map();
const items = new Map();
let active;
let detectedAxis = "y";
let isReordering = false;
let element;
let renderedValues;
const getOrder = () => handle.props.values.flatMap((value) => {
const layout = itemLayouts.get(value);
return layout ? [{ value, layout }] : [];
});
const isRTL = () => !!element && getComputedStyle(element).direction === "rtl";
/**
* Write the new order of the measured items back into the slots they
* hold in `values`, leaving unmeasured values in place.
*/
const commit = (newOrder, moved) => {
const { values, onReorder } = handle.props;
let i = 0;
const newValues = values.map((value) => itemLayouts.has(value) ? newOrder[i++].value : value);
newValues.some((value, j) => value !== values[j]) &&
onReorder(newValues, moved);
};
const getTabStop = () => {
const { values } = handle.props;
return values.includes(active) ? active : values[0];
};
const isRendered = (value) => items.get(value)?.element?.isConnected;
/**
* If the tab stop isn't rendered (e.g. it's virtualized), the first
* rendered item takes its place. Items check again when removed.
*/
const checkTabStop = () => {
isRendered(getTabStop()) ||
context.setActive(handle.props.values.find(isRendered));
};
const context = {
axis: "y",
hasReordered: false,
getElement: () => element,
items,
isTabStop: (value) => value === getTabStop() || !handle.props.values.includes(value),
checkTabStop,
/**
* Only values in `values` can be the tab stop.
*/
setActive: (value) => {
const tabStop = getTabStop();
if (!handle.props.values.includes(value))
return;
active = value;
if (value !== tabStop) {
items.get(tabStop)?.update();
items.get(value)?.update();
}
},
/**
* Space grabs and drops the item, arrows along `axis` move the
* grabbed item one place, or a row in a grid (see keyTarget), or
* move focus that way if it isn't grabbed. Home and End go to
* either end. Escape restores the order in `from`, the values
* when it was grabbed.
*/
onKeyDown: (event, item, from, grab) => {
const { key, currentTarget } = event;
if (event.defaultPrevented ||
event.target !== currentTarget ||
event.altKey ||
event.ctrlKey ||
event.metaKey) {
return;
}
const { axis } = context;
const order = getOrder();
const keyIndex = keys.indexOf(key);
const isX = keyIndex % 2;
if (key === " ") {
event.repeat ||
grab(currentTarget, from ? undefined : handle.props.values);
}
else if (key === "Escape" && from) {
const kept = from.flatMap((value) => order.filter((data) => data.value === value));
commit(order.map((data) => from.includes(data.value) ? kept.shift() : data), item);
grab(currentTarget);
}
else if (keyIndex < 0 ||
(keyIndex < 4 &&
axis !== "xy" &&
axis !== (isX ? "x" : "y"))) {
return;
}
else {
/**
* Focus only moves between rendered items.
*/
const list = from
? order
: order.filter(({ value }) => isRendered(value));
const index = list.findIndex(({ value }) => value === item);
const target = keyTarget(list, index, keyIndex, axis, !!isX && isRTL());
if (target > -1) {
from
? commit(motionUtils.moveItem(order, index, target), item)
: items.get(list[target].value)?.element?.focus();
}
}
event.preventDefault();
},
registerItem: (value, layout) => {
itemLayouts.set(value, layout);
if (!handle.props.axis) {
const nextAxis = detectAxis(handle.props.values.flatMap((itemValue) => {
const itemLayout = itemLayouts.get(itemValue);
return itemLayout ? [itemLayout] : [];
}));
if (nextAxis !== detectedAxis) {
detectedAxis = nextAxis;
handle.update();
}
}
},
updateOrder: (item, offset, velocity) => {
if (isReordering)
return;
const order = getOrder();
const newOrder = checkReorder(order, item, offset, velocity, context.axis, isRTL() ? "rtl" : "ltr");
if (order !== newOrder) {
isReordering = context.hasReordered = true;
commit(newOrder, item);
}
},
};
return () => {
const { as = "ul", axis, values, onReorder, style, mix, children, ...props } = handle.props;
const valuesSet = new Set(values);
for (const map of [itemLayouts, items]) {
map.forEach((_, value) => valuesSet.has(value) || map.delete(value));
}
/**
* Only allow the next reorder once the new order has rendered.
*/
if (values !== renderedValues) {
renderedValues = values;
handle.queueTask(() => (isReordering = false));
}
handle.queueTask(checkTabStop);
context.axis = axis || detectedAxis;
handle.context.set(context);
return component.createElement(motion[as], {
...props,
/**
* Scroll anchoring would move the page while items
* reorder, and with it the dragged item.
*/
style: { overflowAnchor: "none", ...style },
mix: [
component.ref((node) => (element = node)),
/**
* The release that ends a reordering drag isn't a
* click. Clicks with no pointer press behind them
* (detail 0, e.g. keyboard) are left alone.
*/
component.on("pointerdown", () => {
context.hasReordered = false;
}, true),
component.on("click", (event) => {
if (context.hasReordered && event.detail) {
context.hasReordered = false;
event.stopPropagation();
event.preventDefault();
}
}, true),
mix,
],
}, children);
};
}
/**
* An item in a `Reorder.Group`, with the same props as React's
* `Reorder.Item`.
*/
function Item(handle) {
/**
* The group's values when this item was grabbed with the keyboard, for
* Escape to restore. Set only while grabbed.
*/
let grabbedFrom;
const defaultX = motionDom.motionValue(0);
const defaultY = motionDom.motionValue(0);
let zIndex;
const self = { update: () => handle.update() };
const getGroup = () => handle.context.get(Group);
/**
* A keyboard grab is a drag without a pointer, so it shares whileDrag.
*/
const grab = (element, from) => {
grabbedFrom = from;
motionDom.visualElementStore
.get(element)
?.animationState?.setActive("whileDrag", !!from);
};
return () => {
const { as = "li", value, style = {}, layout = true, onDrag, onDragEnd, mix, children, ...props } = handle.props;
const group = getGroup();
const { axis } = group;
group.items.set(value, self);
const x = motionDom.isMotionValue(style.x) ? style.x : defaultX;
const y = motionDom.isMotionValue(style.y) ? style.y : defaultY;
if (zIndex?.x !== x || zIndex.y !== y) {
zIndex = {
x,
y,
value: motionDom.transformValue(() => x.get() || y.get() ? 1 : "unset"),
};
}
return component.createElement(motion[as], {
drag: axis === "xy" ? true : axis,
tabIndex: group.isTabStop(value) ? 0 : -1,
...props,
dragSnapToOrigin: true,
style: { ...style, x, y, zIndex: zIndex.value },
layout,
onDrag: (event, info) => {
const { velocity, point } = info;
group.updateOrder(handle.props.value, { x: x.get(), y: y.get() }, velocity);
const scrollAxis = group.axis === "xy"
? Math.abs(velocity.x) > Math.abs(velocity.y)
? "x"
: "y"
: group.axis;
autoScrollIfNeeded(group.getElement() || null, point[scrollAxis], scrollAxis, velocity[scrollAxis]);
onDrag?.(event, info);
},
onDragEnd: (event, info) => {
// Runs a frame after release, so after the release's click
group.hasReordered = false;
resetAutoScrollState();
onDragEnd?.(event, info);
},
onLayoutMeasure: (measured) => group.registerItem(handle.props.value, measured),
mix: [
mix,
component.ref((node, signal) => {
self.element = node;
signal.addEventListener("abort", () => queueMicrotask(group.checkTabStop));
}),
/**
* The item focused last, or holding focus, is the
* group's tab stop.
*/
component.on("focusin", () => group.setActive(handle.props.value)),
component.on("keydown", (event) => group.onKeyDown(event, handle.props.value, grabbedFrom, grab)),
/**
* Remix blurs the item when it moves its node, and
* focuses it again in the same flush. So only drop
* it if focus is elsewhere once that's done.
*/
component.on("blur", ({ currentTarget }) => queueMicrotask(() => grabbedFrom &&
currentTarget.ownerDocument
.activeElement !== currentTarget &&
grab(currentTarget))),
],
}, children);
};
}
return { Group, Item };
}
let featuresLoaded = false;
function loadRemixFeatures() {
if (featuresLoaded)
return;
featuresLoaded = true;
loadFeatures({
...animations,
...gestureAnimations,
pan: { Feature: PanGesture },
drag: { Feature: DragGesture },
});
}
/**
* Set a default `transition` and `reducedMotion` for every motion element
* below it.
*
* @public
*/
function MotionConfig(handle) {
return () => {
const { children, ...config } = handle.props;
handle.context.set({
...handle.context.get(MotionConfig),
...config,
});
return children;
};
}
const flatten = (node) => Array.isArray(node) ? node.flat(Infinity) : [node];
/**
* A motion component is a component, so Remix can't see the key of the
* element it renders. Pass the key to the element, so Remix keeps the
* element when the component is reordered.
*/
const withPresenceKey = (child) => child?.key != null && motionComponents.has(child.type)
? { ...child, props: { ...child.props, presenceKey: child.key } }
: child;
/**
* Keeps an exiting child of `AnimatePresence` until the exits of the
* motion elements inside it finish, as React's PresenceChild.
*/
function PresenceChild(handle) {
let exitId = 0;
let popped = false;
let exits = [];
return () => {
const { isPresent, children } = handle.props;
if (isPresent)
exits = [];
const context = {
...handle.props,
exits,
popped,
};
handle.context.set(context);
/**
* Every exit registered since the child started exiting, checked
* by the latest render only.
*/
const id = ++exitId;
if (isPresent) {
popped = false;
}
else {
handle.queueTask(() => {
popped || (popped = !!context.popped);
Promise.all([...exits]).then(() => {
if (id === exitId)
handle.props.onExitComplete();
});
});
}
return children;
};
}
const getKey = (child, index) => child?.key ?? "__i" + index;
/**
* Keep removed children of `AnimatePresence` until their motion elements
* finish their `exit` animations. Also choose how entering and exiting
* children share the screen, skip their first `initial` animation, and
* know when exits finish.
*
* @public
*/
function AnimatePresence(handle) {
let items = [];
const exited = new Set();
const group = {
props: handle.props,
isFirstRender: true,
};
const remove = (key) => {
exited.add(key);
handle.update();
};
return () => {
handle.context.set(group);
if (group.isFirstRender) {
handle.queueTask(() => (group.isFirstRender = false));
}
const { mode, custom } = handle.props;
const present = flatten(handle.props.children).filter((child) => child != null && child !== false && child !== true);
const presentKeys = new Map(present.map((child, i) => [getKey(child, i), child]));
/**
* Merge the present children with the exiting ones, which keep
* their place.
*/
const next = present.map((child, i) => ({
key: getKey(child, i),
child,
isPresent: true,
}));
items.forEach((item, i) => {
if (presentKeys.has(item.key) || exited.has(item.key))
return;
next.splice(Math.min(i, next.length), 0, {
...item,
isPresent: false,
});
});
const hadExited = exited.size > 0;
exited.clear();
items = next;
/**
* Once every exit finishes. Not when an exiting child comes back.
*/
const isExiting = next.some((item) => !item.isPresent);
if (hadExited && !isExiting) {
handle.queueTask(() => handle.props.onExitComplete?.());
}
/**
* In "wait" mode, render the new children once the old ones
* have exited.
*/
const shown = mode === "wait" && isExiting
? next.filter((item) => !item.isPresent)
: next;
return shown.map(({ key, child, isPresent }) => component.createElement(PresenceChild, {
key,
isPresent,
custom,
popLayout: mode === "popLayout",
anchorX: handle.props.anchorX,
anchorY: handle.props.anchorY,
onExitComplete: () => remove(key),
}, withPresenceKey(child)));
};
}
/**
* Inside `AnimatePresence`: keep the exiting child until `exit` resolves,
* like React's `safeToRemove`. Call it in each render while `isPresent()`
* is `false`.
*
* @public
*/
function holdPresence(handle, exit) {
const context = handle.context.get(PresenceChild);
context && !context.isPresent && context.exits.push(exit);
}
/**
* Inside `AnimatePresence`: whether the component is present, or exiting.
*
* @public
*/
function isPresent(handle) {
const context = handle.context.get(PresenceChild);
return context?.isPresent ?? true;
}
/**
* Inside `AnimatePresence`: the `custom` value of `AnimatePresence`, while
* the component exits.
*
* @public
*/
function getPresenceData(handle) {
return handle.context.get(PresenceChild)?.custom;
}
/**
* Give the `layoutId`s inside an `id`, so separate groups don't animate
* between each other.
*
* @public
*/
function LayoutGroup(handle) {
return () => {
const parent = handle.context.get(LayoutGroup)?.id;
const { id } = handle.props;
handle.context.set({
id: parent && id ? parent + "-" + id : id || parent,
});
return handle.props.children;
};
}
/**
* Passes variant labels from a motion component to the motion components
* inside it, so their server render starts in the inherited variant.
*/
function VariantProvider(handle) {
return () => {
handle.context.set(handle.props.value);
return handle.props.children;
};
}
const isLabels = (value) => typeof value === "string" || Array.isArray(value);
function getVariantContext(props, parent) {
if (!motionDom.isControllingVariants(props)) {
return props.inherit === false ? undefined : parent;
}
return {
initial: props.initial === false || isLabels(props.initial)
? props.initial
: undefined,
animate: isLabels(props.animate) ? props.animate : undefined,
};
}
const isSVGElement = (tag) => !tag.includes("-") &&
(lowercaseSVGElements.includes(tag) || /[A-Z]/u.test(tag));
/**
* The values a motion element renders before it animates, as React's
* `makeLatestValues`.
*/
function makeLatestValues(props, context, isSVG, isInitialBlocked) {
const values = {};
const motionValues = (isSVG ? motionDom.scrapeSVGMotionValuesFromProps : motionDom.scrapeHTMLMotionValuesFromProps)(props, {}, undefined);
for (const key in motionValues) {
values[key] = motionDom.resolveMotionValue(motionValues[key]);
}
let { initial, animate } = props;
if (context &&
motionDom.isVariantNode(props) &&
!motionDom.isControllingVariants(props) &&
props.inherit !== false) {
if (initial === undefined)
initial = context.initial;
if (animate === undefined)
animate = context.animate;
}
const isBlocked = isInitialBlocked || initial === false;
const toSet = isBlocked ? animate : initial;
if (!toSet || typeof toSet === "boolean")
return values;
for (const definition of Array.isArray(toSet) ? toSet : [toSet]) {
const resolved = motionDom.resolveVariantFromProps(props, definition);
if (!resolved)
continue;
const { transitionEnd, transition, ...target } = resolved;
for (const key in target) {
let value = target[key];
if (Array.isArray(value)) {
value = value[isBlocked ? value.length - 1 : 0];
}
if (value !== null)
values[key] = value;
}
Object.assign(values, transitionEnd);
}
return values;
}
/**
* Split `style` into what Remix renders and what Motion owns: motion
* values and transforms like `x`, which the VisualElement renders.
*/
function splitStyle(style, props) {
const css = {};
for (const key in style) {
const value = style[key];
if (!motionDom.isMotionValue(value) && !motionDom.isForcedMotionValue(key, props)) {
css[key] = value;
}
}
return css;
}
const createRenderState = () => ({
style: {},
vars: {},
transform: {},
transformOrigin: {},
attrs: {},
});
/**
* The style, and attributes for SVG, that render `latestValues`.
*/
function renderValues(latestValues, props, tag, isSVG) {
const state = createRenderState();
if (isSVG) {
motionDom.buildSVGAttrs(state, latestValues, motionDom.isSVGTag(tag), props.transformTemplate, props.style);
}
else {
motionDom.buildHTMLStyles(state, latestValues, props.transformTemplate);
}
return { ...state.attrs, style: { ...state.vars, ...state.style } };
}
/**
* Run each callback even if an earlier one throws, and report the error.
*/
const run = (callback) => {
try {
callback();
}
catch (error) {
queueMicrotask(() => {
throw error;
});
}
};
/**
* Elements inserted in one commit are mounted together on the next
* microtask, so every parent exists before its children look it up.
* Remix inserts children before their parents.
*/
let insertQueue;
function queueInsert(create, mount, animate) {
if (!insertQueue) {
insertQueue = [];
queueMicrotask(flushInserts);
}
insertQueue.push([create, mount, animate]);
}
/**
* Mount the elements inserted in this commit. Also runs before a layout
* snapshot, in case they update again before the microtask.
*/
function flushInserts() {
const queue = insertQueue;
if (!queue)
return;
insertQueue = undefined;
// Parents first, so children join their variant tree...
for (let i = queue.length - 1; i >= 0; i--)
run(queue[i][0]);
for (let i = queue.length - 1; i >= 0; i--)
run(queue[i][1]);
// ...and children animate first, as with React's effects.
for (const [, , animate] of queue)
run(animate);
}
/**
* Updates queued in one commit: every element gets its new props before
* any of them animates, as React's insertion effects run before its
* effects. So a variant change from a parent animates its children with
* their new transitions.
*/
let updateQueue;
function queueUpdate(update, animate) {
if (!updateQueue) {
updateQueue = [];
queueMicrotask(() => {
const queue = updateQueue;
updateQueue = undefined;
for (const [update] of queue)
run(update);
// Parents render first, so animate in reverse: children first.
for (let i = queue.length - 1; i >= 0; i--)
run(queue[i][1]);
});
}
updateQueue.push([update, animate]);
}
/**
* Elements created and not yet mounted, so children inserted in the same
* commit can find their parent.
*/
const pendingElements = /*@__PURE__*/ new WeakMap();
function findParent(element) {
for (let node = element.parentElement; node; node = node.parentElement) {
const parent = pendingElements.get(node) || motionDom.visualElementStore.get(node);
if (parent)
return parent;
}
return undefined;
}
/**
* Remix only tells the root of a removed subtree that it's leaving, so
* the root starts the exits of the motion elements inside it, as
* AnimatePresence does in React, and waits for all of them.
*/
const presences = /*@__PURE__*/ new WeakMap();
/**
* Take an exiting element out of the layout, where it is now, so the
* elements around it move at once. Returns a function that puts it back.
*/
function measurePop(node) {
if (!(node instanceof HTMLElement))
return;
const parent = node.offsetParent;
const { offsetTop: top, offsetLeft: left } = node;
const { width, height } = getComputedStyle(node);
const w = parseFloat(width);
const h = parseFloat(height);
return {
top,
left,
right: (parent?.offsetWidth || 0) - w - left,
bottom: (parent?.offsetHeight || 0) - h - top,
width,
height,
};
}
function popLayout(node, box = measurePop(node), anchorX = "left", anchorY = "top") {
if (!(node instanceof HTMLElement) || !box)
return;
const { style } = node;
const popped = {
position: "absolute",
width: box.width,
height: box.height,
[anchorX]: box[anchorX] + "px",
[anchorY]: box[anchorY] + "px",
};
const previous = {};
for (const key in popped)
previous[key] = style[key];
Object.assign(style, popped);
return () => Object.assign(style, previous);
}
const getLayoutNode = (node) => motionDom.acquireProjectionNode(node)[0];
/**
* layoutId elements exiting within AnimatePresence, and how to finish
* their exit. A layout snapshot resets node options, so they're marked
* again after each one.
*/
const exitingLayouts = /*@__PURE__*/ new Map();
function markLayoutExit(node) {
const projection = getLayoutNode(node);
projection.isPresent = false;
/**
* Nodes created in this snapshot promote in document order, so an
* exiting node can resume from the one that takes over from it.
*/
projection.resumeFrom = undefined;
projection.setOptions({ onExitComplete: exitingLayouts.get(node) });
return projection;
}
/**
* Layout animations: every host with `layout` or `layoutId` that is about
* to update shares one animateLayout() for the whole document. It must
* snapshot synchronously in `beforeUpdate`, as Remix changes the DOM
* straight after, and finishes when the first host commits.
*/
let layoutPass;
function snapshotLayout() {
if (layoutPass)
return;
flushInserts();
let commit;
const committed = new Promise((resolve) => {
commit = () => {
// A late fallback mustn't end a newer pass.
if (layoutPass?.commit === commit)
layoutPass = undefined;
resolve();
};
});
layoutPass = {
builder: motionDom.animateLayout(document.documentElement, () => committed),
commit,
};
motionDom.flushLayoutSnapshots();
exitingLayouts.forEach((_, node) => markLayoutExit(node));
/**
* If the update is abandoned, don't block the next one.
*/
requestAnimationFrame(commit);
}
/**
* Animate to the latest props. Then forget which children entered with
* this render, so the next children's `delayChildren` stagger starts
* from 0, as in React.
*/
function animateChanges(visualElement) {
if (!visualElement)
return;
visualElement.animationState?.animateChanges();
visualElement.enteringChildren = undefined;
}
let presenceId = 0;
const motionMixin = /*@__PURE__*/ component.createMixin((handle, tag) => {
const isSVG = isSVGElement(tag);
let props = {};
let element;
let visualElement;
let releaseProjection;
let initialValues;
let presence = null;
let isExiting = false;
let isInitialBlocked;
const id = String(presenceId++);
const getConfig = () => handle.context.get(MotionConfig);
const getVariantParent = () => handle.context.get(VariantProvider);
const getGroup = () => handle.context.get(AnimatePresence);
const getPresenceChild = () => handle.context.get(PresenceChild);
/**
* Inside AnimatePresence: the exit this element started.
*/
let exitDone;
let exitResolvers = [];
let pop;
let popBox;
let restoreLayout;
const getProps = () => {
const transition = props.transition ?? getConfig()?.transition;
return transition ? { ...props, transition } : props;
};
const getLayoutId = () => {
const group = handle.context.get(LayoutGroup)?.id;
const { layoutId } = props;
return group && layoutId ? group + "-" + layoutId : layoutId;
};
const setPresence = (isPresent, onExitComplete) => {
const custom = getPresenceChild()?.custom;
presence = {
id,
isPresent,
register: () => () => { },
onExitComplete,
custom: custom === undefined ? props.custom : custom,
};
visualElement.update(getProps(), presence);
visualElement.updateFeatures();
};
/**
* Drag measures layout, so it needs a projection node before its
* feature mounts, including when drag is turned on later. A
* layoutId element joins its stack when it mounts, as in React.
*/
const acquireProjection = (node) => {
if (!props.drag && !getLayoutId())
return;
releaseProjection || (releaseProjection = motionDom.acquireProjectionNode(node)[1]);
props.drag &&
getLayoutNode(node).setOptions({ alwaysMeasureLayout: true });
};
/**
* Exit within AnimatePresence: animate `exit`, and hand a layoutId
* over to the element that replaces it, as React's exit does.
*/
const startExit = (layoutId) => {
const current = visualElement;
const exits = [];
if (props.exit) {
isExiting = true;
exits.push(new Promise((resolve) => {
exitResolvers.push(resolve);
handle.queueTask(() => {
if (visualElement === current && isExiting) {
setPresence(false, resolve);
}
else
resolve();
});
}));
}
const node = element;
if (layoutId) {
exits.push(new Promise((resolve) => {
exitingLayouts.set(node, () => {
exitingLayouts.delete(node);
resolve();
});
/**
* Measure the layout now, before the DOM changes,
* and hand over as React does before its commit.
*/
snapshotLayout();
const projection = markLayoutExit(node);
/**
* If an element replaces this one, it crossfades
* from it and calls onExitComplete.
*/
projection.relegate() ||
motionDom.frame.postRender(() => {
const stack = projection.getStack();
if (!stack || stack.lead === projection) {
exitingLayouts.get(node)?.();
}
});
}));
}
// In case this element didn't update in the commit.
handle.queueTask(beforeLayout);
if (layoutId)
handle.queueTask(() => layoutPass?.commit());
return Promise.all(exits).then(() => { });
};
handle.addEventListener("insert", ({ node }) => {
element = node;
loadRemixFeatures();
presences.set(node, {
exit: () => {
if (!props.exit || !visualElement || isExiting)
return;
isExiting = true;
return new Promise((resolve) => setPresence(false, resolve));
},
enter: () => {
if (!isExiting || !visualElement)
return;
isExiting = false;
setPresence(true);
},
});
queueInsert(() => {
/**
* Removed before it mounted.
*/
if (element !== node)
return;
const nodeProps = getProps();
const VisualElement = isSVG
? motionDom.SVGVisualElement
: motionDom.HTMLVisualElement;
visualElement = new VisualElement({
parent: findParent(node),
props: nodeProps,
presenceContext: presence,
blockInitialAnimation: isInitialBlocked,
reducedMotionConfig: getConfig()?.reducedMotion,
visualState: {
latestValues: makeLatestValues(nodeProps, getVariantParent(), isSVG, isInitialBlocked),
renderState: createRenderState(),
},
});
pendingElements.set(node, visualElement);
}, () => {
pendingElements.delete(node);
if (element !== node || !visualElement)
return;
visualElement.mount(node);
/**
* The element rendered its first values. Render the
* latest, in case they changed before it mounted.
*/
visualElement.scheduleRenderMicrotask();
acquireProjection(node);
visualElement.updateFeatures();
}, () => {
if (element === node)
animateChanges(visualElement);
});
});
handle.addEventListener("beforeRemove", (event) => {
const node = element;
if (!node || isExiting || getPresenceChild())
return;
const leaving = [node, ...node.querySelectorAll("*")]
.map((el) => presences.get(el))
.filter(Boolean);
const exited = leaving
.map((presence) => presence.exit())
.filter(Boolean);
if (!exited.length)
return;
let cancel;
const done = new Promise((resolve) => {
cancel = resolve;
Promise.all(exited).then(() => resolve());
});
event.persistNode((signal) => {
const reclaim = () => {
leaving.forEach((presence) => presence.enter());
cancel();
};
if (signal.aborted)
reclaim();
signal.addEventListener("abort", reclaim);
return done;
});
});
handle.addEventListener("remove", () => {
if (element) {
presences.delete(element);
// Removed by its own component mid-exit: its exits are done.
exitingLayouts.get(element)?.();
}
exitResolvers.forEach((resolve) => resolve());
exitResolvers = [];
releaseProjection?.();
visualElement?.unmount();
releaseProjection = visualElement = element = undefined;
presence = null;
});
/**
* After the DOM changes, before the layout animation measures it:
* take an exiting element out of the layout.
*/
const beforeLayout = () => {
pop?.();
pop = undefined;
};
handle.addEventListener("beforeUpdate", ({ node }) => {
/**
* Where this element is before the DOM changes, in case it
* exits in this update and leaves the layout.
*/
if (getPresenceChild()?.popLayout && !exitDone) {
popBox = measurePop(node);
}
const hasLayout = props.layout || props.layoutId;
if (hasLayout)
snapshotLayout();
else if (!getPresenceChild()?.popLayout)
return;
/**
* Set the options once this render's props have arrived, after
* the snapshot has reset them, and before the layout animation
* starts.
*/
queueMicrotask(() => {
beforeLayout();
// Removed in this update.
if (!hasLayout || element !== node)
return;
/**
* The layout animation's node, which isn't always the
* VisualElement's: animateLayout() can adopt the element
* before it mounts.
*/
getLayoutNode(node).setOptions({
transition: getProps().transition,
animationType: typeof props.layout === "string"
? props.layout
: "both",
layoutAnchor: props.layoutAnchor,
crossfade: props.layoutCrossfade,
});
});
});
handle.addEventListener("commit", () => layoutPass?.commit());
return (nextProps, hostProps) => {
props = {
...nextProps,
style: hostProps.style ?? nextProps.style,
};
/**
* Motion renders motion values passed as attributes, like
* <motion.circle cx={cx} />, so Remix never sees them.
*/
const motionAttrs = {};
for (const key in hostProps) {
const value = hostProps[key];
if (key !== "style" && motionDom.isMotionValue(value)) {
props[key] = value;
motionAttrs[key] = undefined;
}
}
const { layout } = props;
const layoutId = getLayoutId();
const presenceChild = getPresenceChild();
if (presenceChild && visualElement && element) {
if (!presenceChild.isPresent) {
if (!exitDone) {
exitDone = startExit(layoutId);
/**
* The outermost motion element of the child leaves
* the layout. Parents render before children.
*/
if (presenceChild.popLayout && !presenceChild.popped) {
presenceChild.popped = true;
const node = element;
const { anchorX, anchorY } = presenceChild;
const box = popBox;
pop = () => (restoreLayout = popLayout(node, box, anchorX, anchorY));
}
}
presenceChild.exits.push(exitDone);
}
else if (exitDone) {
exitDone = pop = undefined;
exitResolvers = [];
restoreLayout?.();
restoreLayout = undefined;
if (isExiting) {
isExiting = false;
setPresence(true);
}
if (exitingLayouts.delete(element)) {
const projection = getLayoutNode(element);
projection.isPresent = true;
projection.setOptions({ onExitComplete: undefined });
projection.promote();
}
}
}
/**
* A layoutId element inserted during a layout pass animates
* from the element it replaces, with its own transition.
*/
if (layoutId && layoutPass && !element) {
layoutPass.builder.shared(layoutId, getProps().transition || {});
}
if (visualElement) {
const current = visualElement;
handle.queueTask(() => queueUpdate(() => {
if (visualElement !== current)
return;
current.update(getProps(), presence);
acquireProjection(element);
current.updateFeatures();
}, () => visualElement === current && animateChanges(current)));
}
/**
* Render the values Motion starts from, on the server too. Keep
* rendering the same values on later renders, so Remix's
* per-property diff never overwrites what Motion animated.
*/
if (!initialValues) {
const group = getGroup();
isInitialBlocked =
group?.isFirstRender && group.props.initial === false;
initialValues = renderValues(makeLatestValues(getProps(), getVariantParent(), isSVG, isInitialBlocked), props, tag, isSVG);
}
const { style: initialStyle, ...attrs } = initialValues;
const patch = { ...motionAttrs, ...attrs };
const style = { ...splitStyle(props.style, props), ...initialStyle };
/**
* As React: no ghost image, text selection or scrolling in the
* drag direction, and tap gestures are keyboard accessible.
*/
if (!isSVG) {
const { drag } = props;
if (drag && props.dragListener !== false) {
patch.draggable = false;
style.userSelect =
style.WebkitUserSelect =
style.WebkitTouchCallout =
"none";
style.touchAction =
drag === true ? "none" : `pan-${drag === "x" ? "y" : "x"}`;
}
if (hostProps.tabIndex === undefined &&
(props.onTap || props.onTapStart || props.whileTap)) {
patch.tabIndex = 0;
}
}
if (Object.keys(style).length)
patch.style = style;
if (layout)
patch["data-layout"] = layout === true ? "" : layout;
if (layoutId)
patch["data-layout-id"] = layoutId;
return component.createElement(handle.element, { ...hostProps, ...patch });
};
});
const motionComponents = /*@__PURE__*/ new WeakSet();
function createMotionComponent(tag) {
const component$1 = (handle) => {
let text;
return () => {
let { children, mix, presenceKey, ...rest } = handle.props;
const motionProps = {};
const hostProps = {};
for (const key in rest) {
if (key === "style" || !isValidMotionProp(key)) {
hostProps[key] = rest[key];
}
if (isValidMotionProp(key))
motionProps[key] = rest[key];
}
/**
* A motion value child renders its first value. Motion then
* updates the text, so keep rendering the same text.
*/
if (motionDom.isMotionValue(children)) {
motionProps.children = children;
if (text?.value !== children) {
text = { value: children, initial: String(children.get()) };
}
children = text.initial;
}
const parent = handle.context.get(VariantProvider);
return component.createElement(tag, {
...hostProps,
...(presenceKey != null && { key: presenceKey }),
mix: [motionMixin(motionProps), mix],
}, component.createElement(VariantProvider, { value: getVariantContext(motionProps, parent) }, children));
};
};
motionComponents.add(component$1);
return component$1;
}
const components = /*@__PURE__*/ new Map();
/**
* Animate elements with Motion in Remix 3, with the same props as React's
* `<motion.div>`:
*
* ```tsx
* <motion.ul initial="hidden" animate="visible" variants={list}>
* {items.map((item) => (
* <motion.li key={item.id} variants={item} exit={{ opacity: 0 }} />
* ))}
* </motion.ul>
* ```
*
* `initial` is rendered on the server. `exit` keeps an element, and
* everything inside it, in the DOM until its exit animation finishes.
*
* `motion(props)` is the same as a mixin for any element, as used by the
* components.
*
* Experimental: Remix 3's component API is new, so this API can change
* in a minor release.
*
* @public
*/
const motion = /*@__PURE__*/ new Proxy(motionMixin, {
get(target, key, receiver) {
if (typeof key !== "string" || key in target) {
return Reflect.get(target, key, receiver);
}
let component = components.get(key);
if (!component) {
component = createMotionComponent(key);
components.set(key, component);
}
return component;
},
});
const scopeMixin = /*@__PURE__*/ component.createMixin((handle) => {
let setup = () => { };
let scopeOptions;
let scope;
handle.addEventListener("insert", ({ node }) => {
scope = motionDom.scopeMotion(node, (s) => setup(s), scopeOptions);
});
handle.addEventListener("remove", () => {
scope?.cancel();
scope = undefined;
});
return (nextSetup, nextOptions) => {
setup = nextSetup;
scopeOptions = nextOptions;
return handle.element;
};
}, { ssr: false });
/**
* The `mix` form of vanilla `scopeMotion()`: run `setup` in a scope rooted
* at this element, so selectors resolve inside it. Everything it starts is
* cancelled, and inline styles restored, when the element is removed.
*
* ```tsx
* <ul mix={scopeMotion(() => {
* animate("li", { opacity: [0, 1] }, { delay: stagger(0.05) })
* })} />
* ```
*
* @public
*/
const scopeMotion = scopeMixin;
/**
* Drag, or grab with Space and move with the arrow keys, to reorder a
* list, with the same props as React's `Reorder`:
*
* ```tsx
* <Reorder.Group values={items} onReorder={(next) => {
* items = next
* handle.update()
* }}>
* {items.map((item) => (
* <Reorder.Item key={item} value={item}>{item}</Reorder.Item>
* ))}
* </Reorder.Group>
* ```
*
* @public
*/
const Reorder = /*@__PURE__*/ createReorder(motion);
exports.AnimatePresence = AnimatePresence;
exports.LayoutGroup = LayoutGroup;
exports.MotionConfig = MotionConfig;
exports.Reorder = Reorder;
exports.getPresenceData = getPresenceData;
exports.holdPresence = holdPresence;
exports.isPresent = isPresent;
exports.motion = motion;
exports.scopeMotion = scopeMotion;
//# sourceMappingURL=remix.js.map