@formkit/auto-animate
Version:
Add motion to your apps with a single line of code.
831 lines (829 loc) • 27.4 kB
JavaScript
/**
* A set of all the parents currently being observe. This is the only non weak
* registry.
*/
const parents = new Set();
/**
* Element coordinates that is constantly kept up to date.
*/
const coords = new WeakMap();
/**
* Siblings of elements that have been removed from the dom.
*/
const siblings = new WeakMap();
/**
* Animations that are currently running.
*/
const animations = new WeakMap();
/**
* A map of existing intersection observers used to track element movements.
*/
const intersections = new WeakMap();
/**
* A map of existing mutation observers used to track element movements.
*/
const mutationObservers = new WeakMap();
/**
* Intervals for automatically checking the position of elements occasionally.
*/
const intervals = new WeakMap();
/**
* The configuration options for each group of elements.
*/
const options = new WeakMap();
/**
* Debounce counters by id, used to debounce calls to update positions.
*/
const debounces = new WeakMap();
/**
* All parents that are currently enabled are tracked here.
*/
const enabled = new WeakSet();
/**
* The document used to calculate transitions.
*/
let root;
/**
* The root’s XY scroll positions.
*/
let scrollX = 0;
let scrollY = 0;
/**
* Used to sign an element as the target.
*/
const TGT = "__aa_tgt";
/**
* Used to sign an element as being part of a removal.
*/
const DEL = "__aa_del";
/**
* Used to sign an element as being "new". When an element is removed from the
* dom, but may cycle back in we can sign it with new to ensure the next time
* it is recognized we consider it new.
*/
const NEW = "__aa_new";
/**
* Callback for handling all mutations.
* @param mutations - A mutation list
*/
const handleMutations = (mutations) => {
const elements = getElements(mutations);
// If elements is "false" that means this mutation that should be ignored.
if (elements) {
elements.forEach((el) => animate(el));
}
};
/**
*
* @param entries - Elements that have been resized.
*/
const handleResizes = (entries) => {
entries.forEach((entry) => {
if (entry.target === root)
updateAllPos();
if (coords.has(entry.target))
updatePos(entry.target);
});
};
/**
* Determine if an element is fully outside of the current viewport.
* @param el - Element to test
*/
function isOffscreen(el) {
const rect = el.getBoundingClientRect();
const vw = (root === null || root === void 0 ? void 0 : root.clientWidth) || 0;
const vh = (root === null || root === void 0 ? void 0 : root.clientHeight) || 0;
return rect.bottom < 0 || rect.top > vh || rect.right < 0 || rect.left > vw;
}
/**
* Observe this elements position.
* @param el - The element to observe the position of.
*/
function observePosition(el) {
const oldObserver = intersections.get(el);
oldObserver === null || oldObserver === void 0 ? void 0 : oldObserver.disconnect();
let rect = coords.get(el);
let invocations = 0;
const buffer = 5;
if (!rect) {
rect = getCoords(el);
coords.set(el, rect);
}
const { offsetWidth, offsetHeight } = root;
const rootMargins = [
rect.top - buffer,
offsetWidth - (rect.left + buffer + rect.width),
offsetHeight - (rect.top + buffer + rect.height),
rect.left - buffer,
];
const rootMargin = rootMargins
.map((px) => `${ -1 * Math.floor(px)}px`)
.join(" ");
const observer = new IntersectionObserver(() => {
++invocations > 1 && updatePos(el);
}, {
root,
threshold: 1,
rootMargin,
});
observer.observe(el);
intersections.set(el, observer);
}
/**
* Update the exact position of a given element.
* @param el - An element to update the position of.
* @param debounce - Whether or not to debounce the update. After an animation is finished, it should update as soon as possible to prevent flickering on quick toggles.
*/
function updatePos(el, debounce = true) {
clearTimeout(debounces.get(el));
const optionsOrPlugin = getOptions(el);
const delay = debounce
? isPlugin(optionsOrPlugin)
? 500
: optionsOrPlugin.duration
: 0;
debounces.set(el, setTimeout(async () => {
const currentAnimation = animations.get(el);
try {
await (currentAnimation === null || currentAnimation === void 0 ? void 0 : currentAnimation.finished);
coords.set(el, getCoords(el));
observePosition(el);
}
catch {
// ignore errors as the `.finished` promise is rejected when animations were cancelled
}
}, delay));
}
/**
* Updates all positions that are currently being tracked.
*/
function updateAllPos() {
clearTimeout(debounces.get(root));
debounces.set(root, setTimeout(() => {
parents.forEach((parent) => forEach(parent, (el) => lowPriority(() => updatePos(el))));
}, 100));
}
/**
* Its possible for a quick scroll or other fast events to get past the
* intersection observer, so occasionally we need want "cold-poll" for the
* latests and greatest position. We try to do this in the most non-disruptive
* fashion possible. First we only do this ever couple seconds, staggard by a
* random offset.
* @param el - Element
*/
function poll(el) {
setTimeout(() => {
intervals.set(el, setInterval(() => lowPriority(updatePos.bind(null, el)), 2000));
}, Math.round(2000 * Math.random()));
}
/**
* Perform some operation that is non critical at some point.
* @param callback
*/
function lowPriority(callback) {
if (typeof requestIdleCallback === "function") {
requestIdleCallback(() => callback());
}
else {
requestAnimationFrame(() => callback());
}
}
/**
* A resize observer, responsible for recalculating elements on resize.
*/
let resize;
/**
* Ensure the browser is supported.
*/
const supportedBrowser = typeof window !== "undefined" && "ResizeObserver" in window;
/**
* If this is in a browser, initialize our Web APIs
*/
if (supportedBrowser) {
root = document.documentElement;
new MutationObserver(handleMutations);
resize = new ResizeObserver(handleResizes);
window.addEventListener("scroll", () => {
scrollY = window.scrollY;
scrollX = window.scrollX;
});
resize.observe(root);
}
/**
* Retrieves all the elements that may have been affected by the last mutation
* including ones that have been removed and are no longer in the DOM.
* @param mutations - A mutation list.
* @returns
*/
function getElements(mutations) {
const observedNodes = mutations.reduce((nodes, mutation) => {
return [
...nodes,
...Array.from(mutation.addedNodes),
...Array.from(mutation.removedNodes),
];
}, []);
// Short circuit if _only_ comment nodes are observed
const onlyCommentNodesObserved = observedNodes.every((node) => node.nodeName === "#comment");
if (onlyCommentNodesObserved)
return false;
return mutations.reduce((elements, mutation) => {
// Short circuit if we find a purposefully deleted node.
if (elements === false)
return false;
if (mutation.target instanceof Element) {
target(mutation.target);
if (!elements.has(mutation.target)) {
elements.add(mutation.target);
for (let i = 0; i < mutation.target.children.length; i++) {
const child = mutation.target.children.item(i);
if (!child)
continue;
if (DEL in child) {
return false;
}
target(mutation.target, child);
elements.add(child);
}
}
if (mutation.removedNodes.length) {
for (let i = 0; i < mutation.removedNodes.length; i++) {
const child = mutation.removedNodes[i];
if (DEL in child) {
return false;
}
if (child instanceof Element) {
elements.add(child);
target(mutation.target, child);
siblings.set(child, [
mutation.previousSibling,
mutation.nextSibling,
]);
}
}
}
}
return elements;
}, new Set());
}
/**
* Assign the target to an element.
* @param el - The root element
* @param child
*/
function target(el, child) {
if (!child && !(TGT in el))
Object.defineProperty(el, TGT, { value: el });
else if (child && !(TGT in child))
Object.defineProperty(child, TGT, { value: el });
}
/**
* Determines what kind of change took place on the given element and then
* performs the proper animation based on that.
* @param el - The specific element to animate.
*/
function animate(el) {
var _a, _b;
const isMounted = el.isConnected;
const preExisting = coords.has(el);
if (isMounted && siblings.has(el))
siblings.delete(el);
if (((_a = animations.get(el)) === null || _a === void 0 ? void 0 : _a.playState) !== "finished") {
(_b = animations.get(el)) === null || _b === void 0 ? void 0 : _b.cancel();
}
if (NEW in el) {
add(el);
}
else if (preExisting && isMounted) {
remain(el);
}
else if (preExisting && !isMounted) {
remove(el);
}
else {
add(el);
}
}
/**
* Removes all non-digits from a string and casts to a number.
* @param str - A string containing a pixel value.
* @returns
*/
function raw(str) {
return Number(str.replace(/[^0-9.\-]/g, ""));
}
/**
* Get the scroll offset of elements
* @param el - Element
* @returns
*/
function getScrollOffset(el) {
let p = el.parentElement;
while (p) {
if (p.scrollLeft || p.scrollTop) {
return { x: p.scrollLeft, y: p.scrollTop };
}
p = p.parentElement;
}
return { x: 0, y: 0 };
}
/**
* Get the coordinates of elements adjusted for scroll position.
* @param el - Element
* @returns
*/
function getCoords(el) {
const rect = el.getBoundingClientRect();
const { x, y } = getScrollOffset(el);
return {
top: rect.top + y,
left: rect.left + x,
width: rect.width,
height: rect.height,
};
}
/**
* Returns the width/height that the element should be transitioned between.
* This takes into account box-sizing.
* @param el - Element being animated
* @param oldCoords - Old set of Coordinates coordinates
* @param newCoords - New set of Coordinates coordinates
* @returns
*/
function getTransitionSizes(el, oldCoords, newCoords) {
let widthFrom = oldCoords.width;
let heightFrom = oldCoords.height;
let widthTo = newCoords.width;
let heightTo = newCoords.height;
const styles = getComputedStyle(el);
const sizing = styles.getPropertyValue("box-sizing");
if (sizing === "content-box") {
const paddingY = raw(styles.paddingTop) +
raw(styles.paddingBottom) +
raw(styles.borderTopWidth) +
raw(styles.borderBottomWidth);
const paddingX = raw(styles.paddingLeft) +
raw(styles.paddingRight) +
raw(styles.borderRightWidth) +
raw(styles.borderLeftWidth);
widthFrom -= paddingX;
widthTo -= paddingX;
heightFrom -= paddingY;
heightTo -= paddingY;
}
return [widthFrom, widthTo, heightFrom, heightTo].map(Math.round);
}
/**
* Retrieves animation options for the current element.
* @param el - Element to retrieve options for.
* @returns
*/
function getOptions(el) {
return TGT in el && options.has(el[TGT])
? options.get(el[TGT])
: { duration: 250, easing: "ease-in-out" };
}
/**
* Returns the target of a given animation (generally the parent).
* @param el - An element to check for a target
* @returns
*/
function getTarget(el) {
if (TGT in el)
return el[TGT];
return undefined;
}
/**
* Checks if animations are enabled or disabled for a given element.
* @param el - Any element
* @returns
*/
function isEnabled(el) {
const target = getTarget(el);
return target ? enabled.has(target) : false;
}
/**
* Iterate over the children of a given parent.
* @param parent - A parent element
* @param callback - A callback
*/
function forEach(parent, ...callbacks) {
callbacks.forEach((callback) => callback(parent, options.has(parent)));
for (let i = 0; i < parent.children.length; i++) {
const child = parent.children.item(i);
if (child) {
callbacks.forEach((callback) => callback(child, options.has(child)));
}
}
}
/**
* Always return tuple to provide consistent interface
*/
function getPluginTuple(pluginReturn) {
if (Array.isArray(pluginReturn))
return pluginReturn;
return [pluginReturn];
}
/**
* Determine if config is plugin
*/
function isPlugin(config) {
return typeof config === "function";
}
/**
* The element in question is remaining in the DOM.
* @param el - Element to flip
* @returns
*/
function remain(el) {
const oldCoords = coords.get(el);
const newCoords = getCoords(el);
if (!isEnabled(el))
return coords.set(el, newCoords);
if (isOffscreen(el)) {
// When element is offscreen, skip FLIP to avoid broken transforms
coords.set(el, newCoords);
observePosition(el);
return;
}
let animation;
if (!oldCoords)
return;
const pluginOrOptions = getOptions(el);
if (typeof pluginOrOptions !== "function") {
let deltaLeft = oldCoords.left - newCoords.left;
let deltaTop = oldCoords.top - newCoords.top;
const deltaRight = oldCoords.left + oldCoords.width - (newCoords.left + newCoords.width);
const deltaBottom = oldCoords.top + oldCoords.height - (newCoords.top + newCoords.height);
// element is probably anchored and doesn't need to be offset
if (deltaBottom == 0)
deltaTop = 0;
if (deltaRight == 0)
deltaLeft = 0;
const [widthFrom, widthTo, heightFrom, heightTo] = getTransitionSizes(el, oldCoords, newCoords);
const start = {
transform: `translate(${deltaLeft}px, ${deltaTop}px)`,
};
const end = {
transform: `translate(0, 0)`,
};
if (widthFrom !== widthTo) {
start.width = `${widthFrom}px`;
end.width = `${widthTo}px`;
}
if (heightFrom !== heightTo) {
start.height = `${heightFrom}px`;
end.height = `${heightTo}px`;
}
animation = el.animate([start, end], {
duration: pluginOrOptions.duration,
easing: pluginOrOptions.easing,
});
}
else {
const [keyframes] = getPluginTuple(pluginOrOptions(el, "remain", oldCoords, newCoords));
animation = new Animation(keyframes);
animation.play();
}
animations.set(el, animation);
coords.set(el, newCoords);
animation.addEventListener("finish", updatePos.bind(null, el, false), {
once: true,
});
}
/**
* Adds the element with a transition.
* @param el - Animates the element being added.
*/
function add(el) {
if (NEW in el)
delete el[NEW];
const newCoords = getCoords(el);
coords.set(el, newCoords);
const pluginOrOptions = getOptions(el);
if (!isEnabled(el))
return;
if (isOffscreen(el)) {
// Skip entry animation if element is not visible in viewport
observePosition(el);
return;
}
let animation;
if (typeof pluginOrOptions !== "function") {
animation = el.animate([
{ transform: "scale(.98)", opacity: 0 },
{ transform: "scale(0.98)", opacity: 0, offset: 0.5 },
{ transform: "scale(1)", opacity: 1 },
], {
duration: pluginOrOptions.duration * 1.5,
easing: "ease-in",
});
}
else {
const [keyframes] = getPluginTuple(pluginOrOptions(el, "add", newCoords));
animation = new Animation(keyframes);
animation.play();
}
animations.set(el, animation);
animation.addEventListener("finish", updatePos.bind(null, el, false), {
once: true,
});
}
/**
* Clean up after removing an element from the dom.
* @param el - Element being removed
* @param styles - Optional styles that should be removed from the element.
*/
function cleanUp(el, styles) {
var _a;
el.remove();
coords.delete(el);
siblings.delete(el);
animations.delete(el);
(_a = intersections.get(el)) === null || _a === void 0 ? void 0 : _a.disconnect();
setTimeout(() => {
if (DEL in el)
delete el[DEL];
Object.defineProperty(el, NEW, { value: true, configurable: true });
if (styles && el instanceof HTMLElement) {
for (const style in styles) {
el.style[style] = "";
}
}
}, 0);
}
/**
* Animates the removal of an element.
* @param el - Element to remove
*/
function remove(el) {
var _a;
if (!siblings.has(el) || !coords.has(el))
return;
const [prev, next] = siblings.get(el);
Object.defineProperty(el, DEL, { value: true, configurable: true });
const finalX = window.scrollX;
const finalY = window.scrollY;
if (next &&
next.parentNode &&
next.parentNode instanceof Element) {
next.parentNode.insertBefore(el, next);
}
else if (prev && prev.parentNode) {
prev.parentNode.appendChild(el);
}
else {
(_a = getTarget(el)) === null || _a === void 0 ? void 0 : _a.appendChild(el);
}
if (!isEnabled(el))
return cleanUp(el);
const [top, left, width, height] = deletePosition(el);
const optionsOrPlugin = getOptions(el);
const oldCoords = coords.get(el);
if (finalX !== scrollX || finalY !== scrollY) {
adjustScroll(el, finalX, finalY, optionsOrPlugin);
}
let animation;
let styleReset = {
position: "absolute",
top: `${top}px`,
left: `${left}px`,
width: `${width}px`,
height: `${height}px`,
margin: "0",
pointerEvents: "none",
transformOrigin: "center",
zIndex: "100",
};
if (!isPlugin(optionsOrPlugin)) {
Object.assign(el.style, styleReset);
animation = el.animate([
{
transform: "scale(1)",
opacity: 1,
},
{
transform: "scale(.98)",
opacity: 0,
},
], {
duration: optionsOrPlugin.duration,
easing: "ease-out",
});
}
else {
const [keyframes, options] = getPluginTuple(optionsOrPlugin(el, "remove", oldCoords));
if ((options === null || options === void 0 ? void 0 : options.styleReset) !== false) {
styleReset =
(options === null || options === void 0 ? void 0 : options.styleReset) ||
styleReset;
Object.assign(el.style, styleReset);
}
animation = new Animation(keyframes);
animation.play();
}
animations.set(el, animation);
animation.addEventListener("finish", () => cleanUp(el, styleReset), {
once: true,
});
}
/**
* If the element being removed is at the very bottom of the page, and the
* the page was scrolled into a space being "made available" by the element
* that was removed, the page scroll will have jumped up some amount. We need
* to offset the jump by the amount that the page was "automatically" scrolled
* up. We can do this by comparing the scroll position before and after the
* element was removed, and then offsetting by that amount.
*
* @param el - The element being deleted
* @param finalX - The final X scroll position
* @param finalY - The final Y scroll position
* @param optionsOrPlugin - The options or plugin
* @returns
*/
function adjustScroll(el, finalX, finalY, optionsOrPlugin) {
const scrollDeltaX = scrollX - finalX;
const scrollDeltaY = scrollY - finalY;
const scrollBefore = document.documentElement.style.scrollBehavior;
const scrollBehavior = getComputedStyle(root).scrollBehavior;
if (scrollBehavior === "smooth") {
document.documentElement.style.scrollBehavior = "auto";
}
window.scrollTo(window.scrollX + scrollDeltaX, window.scrollY + scrollDeltaY);
if (!el.parentElement)
return;
const parent = el.parentElement;
let lastHeight = parent.clientHeight;
let lastWidth = parent.clientWidth;
const startScroll = performance.now();
// Here we use a manual scroll animation to keep the element using the same
// easing and timing as the parent’s scroll animation.
function smoothScroll() {
requestAnimationFrame(() => {
if (!isPlugin(optionsOrPlugin)) {
const deltaY = lastHeight - parent.clientHeight;
const deltaX = lastWidth - parent.clientWidth;
if (startScroll + optionsOrPlugin.duration >
performance.now()) {
window.scrollTo({
left: window.scrollX - deltaX,
top: window.scrollY - deltaY,
});
lastHeight = parent.clientHeight;
lastWidth = parent.clientWidth;
smoothScroll();
}
else {
document.documentElement.style.scrollBehavior = scrollBefore;
}
}
});
}
smoothScroll();
}
/**
* Determines the position of the element being removed.
* @param el - The element being deleted
* @returns
*/
function deletePosition(el) {
var _a;
const oldCoords = coords.get(el);
const [width, , height] = getTransitionSizes(el, oldCoords, getCoords(el));
let offsetParent = el.parentElement;
while (offsetParent &&
(getComputedStyle(offsetParent).position === "static" ||
offsetParent instanceof HTMLBodyElement)) {
offsetParent = offsetParent.parentElement;
}
if (!offsetParent)
offsetParent = document.body;
const parentStyles = getComputedStyle(offsetParent);
const parentCoords = !animations.has(el) || ((_a = animations.get(el)) === null || _a === void 0 ? void 0 : _a.playState) === "finished"
? getCoords(offsetParent)
: coords.get(offsetParent);
const top = Math.round(oldCoords.top - parentCoords.top) -
raw(parentStyles.borderTopWidth);
const left = Math.round(oldCoords.left - parentCoords.left) -
raw(parentStyles.borderLeftWidth);
return [top, left, width, height];
}
/**
* A function that automatically adds animation effects to itself and its
* immediate children. Specifically it adds effects for adding, moving, and
* removing DOM elements.
* @param el - A parent element to add animations to.
* @param options - An optional object of options.
*/
function autoAnimate(el, config = {}) {
if (supportedBrowser && resize) {
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const isDisabledDueToReduceMotion = mediaQuery.matches &&
!isPlugin(config) &&
!config.disrespectUserMotionPreference;
if (!isDisabledDueToReduceMotion) {
enabled.add(el);
if (getComputedStyle(el).position === "static") {
Object.assign(el.style, { position: "relative" });
}
forEach(el, updatePos, poll, (element) => resize === null || resize === void 0 ? void 0 : resize.observe(element));
if (isPlugin(config)) {
options.set(el, config);
}
else {
options.set(el, {
duration: 250,
easing: "ease-in-out",
...config,
});
}
const mo = new MutationObserver(handleMutations);
mo.observe(el, { childList: true });
mutationObservers.set(el, mo);
parents.add(el);
}
}
const controller = Object.freeze({
parent: el,
enable: () => {
enabled.add(el);
},
disable: () => {
enabled.delete(el);
// Cancel any in-flight animations and pending timers for immediate effect
forEach(el, (node) => {
const a = animations.get(node);
try {
a === null || a === void 0 ? void 0 : a.cancel();
}
catch { }
animations.delete(node);
const d = debounces.get(node);
if (d)
clearTimeout(d);
debounces.delete(node);
const i = intervals.get(node);
if (i)
clearInterval(i);
intervals.delete(node);
});
},
isEnabled: () => enabled.has(el),
destroy: () => {
enabled.delete(el);
parents.delete(el);
options.delete(el);
const mo = mutationObservers.get(el);
mo === null || mo === void 0 ? void 0 : mo.disconnect();
mutationObservers.delete(el);
forEach(el, (node) => {
// unobserve resize
resize === null || resize === void 0 ? void 0 : resize.unobserve(node);
// cancel animations
const a = animations.get(node);
try {
a === null || a === void 0 ? void 0 : a.cancel();
}
catch { }
animations.delete(node);
// disconnect observers
const io = intersections.get(node);
io === null || io === void 0 ? void 0 : io.disconnect();
intersections.delete(node);
// clear intervals and debounces
const i = intervals.get(node);
if (i)
clearInterval(i);
intervals.delete(node);
const d = debounces.get(node);
if (d)
clearTimeout(d);
debounces.delete(node);
// clear state
coords.delete(node);
siblings.delete(node);
});
},
});
return controller;
}
/**
* The vue directive.
*/
const vAutoAnimate = {
mounted: (el, binding) => {
const ctl = autoAnimate(el, binding.value || {});
Object.defineProperty(el, "__aa_ctl", { value: ctl, configurable: true });
},
unmounted: (el) => {
var _a;
const ctl = el["__aa_ctl"];
(_a = ctl === null || ctl === void 0 ? void 0 : ctl.destroy) === null || _a === void 0 ? void 0 : _a.call(ctl);
try {
delete el["__aa_ctl"];
}
catch { }
},
getSSRProps: () => ({}),
};
export { autoAnimate, autoAnimate as default, getTransitionSizes, vAutoAnimate };