UNPKG

@formkit/auto-animate

Version:

Add motion to your apps with a single line of code.

831 lines (829 loc) 27.4 kB
/** * 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 };