diff --git a/js-css-animations/AnimationHandler.js b/js-css-animations/AnimationHandler.js new file mode 100644 index 0000000..9c72056 --- /dev/null +++ b/js-css-animations/AnimationHandler.js @@ -0,0 +1,40 @@ +export default class AnimationHandler { + constructor(element, action, animationId) { + this.element = element; + this.action = action; + this.animationId = animationId; + } + + static modules = {}; + + static async getModule(modulePath) { + if (!(modulePath in this.modules)) { + const toCamelCase = path => { + const fileName = path.match(/\/([^\.]+)(?:\.js)?$/)[1]; + return fileName.replace(/-([a-z])/g, (_, letter) => + letter.toUpperCase() + ); + }; + const module = await import(modulePath); + this.modules[toCamelCase(modulePath)] = module; + } + + return this.modules[modulePath]; + } + + async initDependencies() { + return Promise.resolve(); + } + + begin() { + throw new TypeError("Method 'begin' is not implemented."); + } + + middle() { + throw new TypeError("Method 'middle' is not implemented."); + } + + end() { + throw new Error("Method 'end' is not implemented."); + } +} diff --git a/js-css-animations/MotionAnimationHandler.js b/js-css-animations/MotionAnimationHandler.js new file mode 100644 index 0000000..15469bc --- /dev/null +++ b/js-css-animations/MotionAnimationHandler.js @@ -0,0 +1,47 @@ +import { MOTION_ANIMS_ID, PROPERTY_NAMES, CLASS_NAMES } from './globals.js'; +import AnimationHandler from './AnimationHandler.js'; + +export default class MotionAnimationHandler extends AnimationHandler { + constructor(element, action, animationId) { + super(element, action, animationId); + this.currentTransition = null; + } + + static #removeMotionCssClass(element) { + const className = [...element.classList].find(cl => + cl.match(/js\-anim\-\-(rotate|scale)/) + ); + + if (className) element.classList.remove(className); + if (className === CLASS_NAMES.move[MOTION_ANIMS_ID.rotate]) { + element.style.removeProperty(PROPERTY_NAMES.angle); + } + } + + async initDependencies() { + await AnimationHandler.getModule('./transitions.js'); + } + + begin() { + const { getCurrentTransition } = AnimationHandler.modules.transitions; + this.currentTransition = getCurrentTransition(this.element); + MotionAnimationHandler.#removeMotionCssClass(this.element); + } + + middle() { + if (this.currentTransition) { + const { appendTransition } = AnimationHandler.modules.transitions; + appendTransition( + this.element, + CLASS_NAMES[this.action][this.animationId], + this.currentTransition + ); + } + if (this.action === 'move') this.element.classList.add(CLASS_NAMES.moved); + } + + end() { + if (this.action === 'moveBack') + this.element.classList.remove(CLASS_NAMES.moved); + } +} diff --git a/js-css-animations/VisibilityAnimationHandler.js b/js-css-animations/VisibilityAnimationHandler.js new file mode 100644 index 0000000..e40cb9b --- /dev/null +++ b/js-css-animations/VisibilityAnimationHandler.js @@ -0,0 +1,71 @@ +import { CLASS_NAMES, PROPERTY_NAMES } from './globals.js'; +import AnimationHandler from './AnimationHandler.js'; + +export default class VisibilityAnimationHandler extends AnimationHandler { + constructor(element, action, animationId) { + super(element, action, animationId); + this.overflowHidden = null; + this.maintainSpace = null; + } + + setOverflowHidden(overflowHidden) { + this.overflowHidden = overflowHidden; + } + + setMaintainSpace(maintainSpace) { + this.maintainSpace = maintainSpace; + } + + static #applyOverflowHidden(element) { + element.classList.add(CLASS_NAMES.overflowHidden); + } + + static #removeOverflowHidden(element) { + element.classList.remove(CLASS_NAMES.overflowHidden); + } + + static #hasIterationProp(element) { + const iterationProperty = element.style.getPropertyValue( + PROPERTY_NAMES.iteration + ); + + return ( + iterationProperty != '1' && + iterationProperty.match(/^(infinite|\d+)$/) !== null + ); + } + + begin() { + if (this.overflowHidden && this.element.parentElement) + VisibilityAnimationHandler.#applyOverflowHidden( + this.element.parentElement + ); + } + + middle() { + setTimeout(() => { + if (this.action === 'show') { + this.element.classList.remove( + CLASS_NAMES.hidden, + CLASS_NAMES.collapsed + ); + } + }, 0); + } + + end() { + if (this.action === 'hide') { + this.maintainSpace + ? this.element.classList.add(CLASS_NAMES.hidden) + : this.element.classList.add(CLASS_NAMES.collapsed); + } + + if (this.overflowHidden && this.element.parentElement) + VisibilityAnimationHandler.#removeOverflowHidden( + this.element.parentElement + ); + + if (!VisibilityAnimationHandler.#hasIterationProp(this.element)) + this.element.classList.remove(CLASS_NAMES[this.action][this.animationId]); + } +} diff --git a/js-css-animations/VisibilityAnimationWithParentResizeHandler.js b/js-css-animations/VisibilityAnimationWithParentResizeHandler.js new file mode 100644 index 0000000..47d9d52 --- /dev/null +++ b/js-css-animations/VisibilityAnimationWithParentResizeHandler.js @@ -0,0 +1,68 @@ +import AnimationHandler from './AnimationHandler.js'; +import VisibilityAnimationHandler from './VisibilityAnimationHandler.js'; + +export default class VisibilityAnimationWithParentResizeHandler extends VisibilityAnimationHandler { + constructor(element, action, animationId) { + super(element, action, animationId); + this.heightTransition = null; + this.widthTransition = null; + this.dimension = null; + this.parentMeasures = null; + } + + setDimensionsTransition({ heightTransition, widthTransition }) { + this.heightTransition = heightTransition; + this.widthTransition = widthTransition; + } + + async initDependencies() { + await AnimationHandler.getModule('./resize-parent.js'); + await AnimationHandler.getModule('./measurements.js'); + } + + begin() { + super.begin(); + + if (this.widthTransition || this.heightTransition) { + const { initParentResize } = AnimationHandler.modules.resizeParent; + + const parentData = initParentResize({ + element: this.element, + action: this.action, + widthTransition: this.widthTransition, + heightTransition: this.heightTransition, + }); + + this.parentMeasures = parentData.parentMeasures; + this.dimension = parentData.dimension; + } + } + + middle() { + super.middle(); + + setTimeout(() => { + if (this.dimension) { + const { setParentMaxMeasures } = AnimationHandler.modules.measurements; + + setParentMaxMeasures({ + parentState: 'final', + element: this.element, + parentMeasures: this.parentMeasures, + action: this.action, + dimension: this.dimension, + }); + } + }, 0); + } + + end() { + super.end(); + + const { widthTransition, heightTransition } = this; + if (widthTransition || heightTransition) { + const { endParentResize } = AnimationHandler.modules.resizeParent; + endParentResize(this.element, { widthTransition, heightTransition }); + } + } +} diff --git a/js-css-animations/animate.js b/js-css-animations/animate.js index 4367dce..5d4f8b0 100644 --- a/js-css-animations/animate.js +++ b/js-css-animations/animate.js @@ -9,21 +9,6 @@ import { CUSTOM_CSS_PROPERTIES, } from './globals.js'; -import { - initParentResize, - endParentResize, - setOverflowHidden, - removeOverflowHidden, -} from './resize-parent.js'; - -import { - removeInlineTransition, - appendTransition, - getCurrentTransition, -} from './transitions.js'; - -import { setParentMaxMeasures } from './measurements.js'; - /** * Contains the default value for each custom option. * Those values can be overwritten by the user by calling jsCssAnimations.config() @@ -209,9 +194,12 @@ export const setCssProperty = (element, property, value) => { * @param {HTMLElement} element - The DOM element to update the CSS Properties * @param {Object.} opts - Object containing a custom property key and a CSS value to be updated */ -const updateCssProperties = (element, opts) => { +const updateCssProperties = async (element, opts) => { removeCustomCssProperties(element); - if (element !== document.documentElement) removeInlineTransition(element); + if (element !== document.documentElement) { + const { removeInlineTransition } = await import('./transitions.js'); + removeInlineTransition(element); + } CUSTOM_CSS_PROPERTIES.forEach(prop => { if (typeof opts[prop] === 'string' || typeof opts[prop] === 'number') { if (typeof opts[prop] === 'number') { @@ -295,20 +283,6 @@ const isVisibility = animType => animType === 'visibility'; */ const isMotion = animType => animType === 'motion'; -/** - * Removes the current motion animation CSS class from the element - * @param {HTMLElement} element - The DOM element being animated - */ -const removeMotionCssClass = element => { - const className = [...element.classList].find(cl => - cl.match(/js\-anim\-\-(rotate|scale)/) - ); - if (className) element.classList.remove(className); - if (className === CLASS_NAMES.move[MOTION_ANIMS_ID.rotate]) { - element.style.removeProperty(PROPERTY_NAMES.angle); - } -}; - /** * Sets an attribute to indicate that the element is currently being animated * and so can not perform any other animations @@ -334,61 +308,6 @@ const enable = element => { const isEnabled = element => !(element.getAttribute('js-anim--disabled') === 'true'); -/** - * Verifies if an element has defined an iteration CSS property - * @param {HTMLElement} element - * @returns True if the element has an iteration CSS property set, False otherwise - */ -const hasIterationProp = element => { - const iterationProperty = element.style.getPropertyValue( - PROPERTY_NAMES.iteration - ); - return ( - iterationProperty != '1' && - iterationProperty.match(/^(infinite|\d+)$/) !== null - ); -}; - -/** - * Sets the parent element dimensions, if needed. - * - * Removes the collapsed or hidden class from the element, when necessary - * @param {HTMLElement} element - The DOM element being animated - * @param {{ - * parentState: string, - * element: HTMLElement, - * parentMeasures: Object, - * action: string, - * dimension: string | undefined - * }} args - All the necessary arguments - */ -const handleVisibilityToggle = (element, args) => { - setTimeout(() => { - if (args.dimension) setParentMaxMeasures(args); - if (args.action === 'show') { - element.classList.remove(CLASS_NAMES.hidden, CLASS_NAMES.collapsed); - } - }, 0); -}; - -/** - * Adds the hidden or collapsed class, when necessary. - * Finalize parent element's resize operations, if needed. - * @param {HTMLElement} element - The DOM element being animated - * @param {Object} opts - All the necessary options - */ -const endVisibilityToggle = (element, opts) => { - if (opts.action === 'hide') { - opts.maintainSpace - ? element.classList.add(CLASS_NAMES.hidden) - : element.classList.add(CLASS_NAMES.collapsed); - } - if (opts.heightTransition || opts.widthTransition) - endParentResize(element, opts); - else if (opts.overflowHidden && element.parentElement) - removeOverflowHidden(element.parentElement); -}; - /** * Executes a given callback, checking, when necessary, if the callback was already * executed by another element being animated by the same trigger button @@ -413,6 +332,42 @@ const initCallback = (trigger, fn, type) => { } }; +const getAnimationHandler = async (animType, args) => { + const { element, action, id } = args; + let animationHandler = null; + + if (animType === 'motion') { + const { default: MotionAnimationHandler } = await import( + './MotionAnimationHandler.js' + ); + animationHandler = new MotionAnimationHandler(element, action, id); + } else if (animType === 'visibility') { + const { widthTransition, heightTransition } = args; + + if (widthTransition || heightTransition) { + const { default: VisibilityAnimationHandler } = await import( + './VisibilityAnimationWithParentResizeHandler.js' + ); + animationHandler = new VisibilityAnimationHandler(element, action, id); + animationHandler.setDimensionsTransition({ + heightTransition, + widthTransition, + }); + } else { + const { default: VisibilityAnimationHandler } = await import( + './VisibilityAnimationHandler.js' + ); + animationHandler = new VisibilityAnimationHandler(element, action, id); + } + + if (args.overflowHidden) animationHandler.setOverflowHidden(true); + if (args.maintainSpace) animationHandler.setMaintainSpace(true); + } + + await animationHandler.initDependencies(); + return animationHandler; +}; + /** * Handles all the animation process * @param {HTMLElement} element - The DOM element to animate @@ -422,7 +377,7 @@ const initCallback = (trigger, fn, type) => { * @see {@link module:globals.VISIBILITY_ANIMS_ID} * @see {@link module:globals.MOTION_ANIMS_ID} */ -const animate = (element, action, id, opts = {}) => { +const animate = async (element, action, id, opts = {}) => { disable(element); const { animType, @@ -444,89 +399,45 @@ const animate = (element, action, id, opts = {}) => { move: 'moveBack', moveBack: 'move', }); - let parentMeasures, dimension, currentTransition; if (trigger) TARGETS_STACK.add(element, trigger); - const handleAnimation = { - begining: { - visibility: () => { - if (widthTransition || heightTransition) { - ({ parentMeasures, dimension } = initParentResize({ - element, - action, - widthTransition, - heightTransition, - overflowHidden, - })); - } else if (overflowHidden && element.parentElement) - setOverflowHidden(element.parentElement); - }, - motion: () => { - currentTransition = getCurrentTransition(element); - removeMotionCssClass(element); - }, - }, - middle: { - visibility: () => { - handleVisibilityToggle(element, { - parentState: 'final', - element, - parentMeasures, - action, - dimension, - }); - }, - motion: () => { - if (currentTransition) { - appendTransition(element, CLASS_NAMES[action][id], currentTransition); - } - if (action === 'move') element.classList.add(CLASS_NAMES.moved); - }, - }, - end: { - visibility: () => { - endVisibilityToggle(element, { - action, - maintainSpace, - widthTransition, - heightTransition, - overflowHidden, - }); - if (!hasIterationProp(element)) - element.classList.remove(CLASS_NAMES[action][id]); - }, - motion: () => { - if (action === 'moveBack') element.classList.remove(CLASS_NAMES.moved); - }, - }, - conclude: () => { - if (trigger && opts.queryIndex === opts.totalTargets - 1) { - opts.staggerDelay - ? CALLBACK_TRACKER.remove(trigger) - : setTimeout(() => CALLBACK_TRACKER.remove(trigger), delay); - TARGETS_STACK.get(trigger).forEach(el => enable(el)); - TARGETS_STACK.remove(trigger); - } else if (!trigger) { - enable(element); - } - }, + const handleAnimation = await getAnimationHandler(animType, { + element, + action, + id, + widthTransition, + heightTransition, + overflowHidden, + maintainSpace, + }); + + const concludeAnimation = () => { + if (trigger && opts.queryIndex === opts.totalTargets - 1) { + opts.staggerDelay + ? CALLBACK_TRACKER.remove(trigger) + : setTimeout(() => CALLBACK_TRACKER.remove(trigger), delay); + TARGETS_STACK.get(trigger).forEach(el => enable(el)); + TARGETS_STACK.remove(trigger); + } else if (!trigger) { + enable(element); + } }; - handleAnimation.begining[animType](); + handleAnimation.begin(); if (typeof start === 'function') { initCallback(trigger, start, 'start'); } element.classList.add(CLASS_NAMES[action][id]); element.classList.remove(CLASS_NAMES[OPPOSITE_ACTION[action]][id]); - handleAnimation.middle[animType](); + handleAnimation.middle(); - setTimeout(() => { - handleAnimation.end[animType](); + setTimeout(async () => { + handleAnimation.end(); if (typeof complete === 'function') { initCallback(trigger, complete, 'complete'); } - handleAnimation.conclude(); + concludeAnimation(); }, duration + delay); }; @@ -557,7 +468,7 @@ const getAction = (element, animType) => { * @param {HTMLElement} el - The DOM element being animated * @param {Object} args - The animation's ID and type and all the options passed by the user */ -const preset = (el, args) => { +const preset = async (el, args) => { const { opts, animationId } = args; const { animType } = opts; if ( @@ -568,7 +479,7 @@ const preset = (el, args) => { ) opts.angle = undefined; - updateCssProperties(el, opts); + await updateCssProperties(el, opts); if (opts.staggerDelay) { const staggeredDelay = @@ -588,7 +499,7 @@ const preset = (el, args) => { * @see {@link module:globals.MOTION_ANIMS_ID} */ const eventHandler = (el, animationId, opts) => { - return (/** @type {Event} */ e) => { + return async (/** @type {Event} */ e) => { const { stopPropagation = CONFIG.stopPropagation, preventDefault = CONFIG.preventDefault, @@ -602,7 +513,7 @@ const eventHandler = (el, animationId, opts) => { `Can't find a valid action for this animation type` ); - preset(el, { + await preset(el, { animationId, opts, }); @@ -626,6 +537,17 @@ const init = (animationId, opts = {}) => { cursor, } = opts; + /** TODO: load dependencies on page load */ + // async function loadDependencies() { + // await import('./resize-parent.js'); + // await import('./measurements.js'); + // await import('./transitions.js'); + + // removeEventListener('load', loadDependencies); + // } + + // addEventListener('load', loadDependencies); + document.querySelectorAll(trigger).forEach(btn => { btn.classList.add(CLASS_NAMES.btnCursor); if (typeof cursor === 'string') { diff --git a/js-css-animations/animate.js.bak b/js-css-animations/animate.js.bak new file mode 100644 index 0000000..470d5b6 --- /dev/null +++ b/js-css-animations/animate.js.bak @@ -0,0 +1,699 @@ +/** + * Handles all the animation process + * @module animate + */ +import { + MOTION_ANIMS_ID, + PROPERTY_NAMES, + CLASS_NAMES, + CUSTOM_CSS_PROPERTIES, +} from './globals.js'; + +import { initParentResize, endParentResize } from './resize-parent.js'; + +import { + removeInlineTransition, + appendTransition, + getCurrentTransition, +} from './transitions.js'; + +import { setParentMaxMeasures } from './measurements.js'; + +/** + * Contains the default value for each custom option. + * Those values can be overwritten by the user by calling jsCssAnimations.config() + * and passing new default values for all the animations. + * @type {Object.} + */ +const configurations = { + default: Object.freeze({ + trigger: `.${CLASS_NAMES.trigger}`, + targetSelector: undefined, + staggerDelay: undefined, + start: undefined, + complete: undefined, + maintainSpace: false, + dimensionsTransition: true, + widthTransition: undefined, + heightTransition: undefined, + overflowHidden: true, + stopPropagation: true, + preventDefault: true, + on: 'click', + }), +}; + +/** + * ProxyHandler passed to the 'CONFIG' object to ensure that + * if an option is not customized by the user, the default value set + * in 'configurations.default' will be returned instead. + * @see {@link CONFIG} + * @see {@link configurations} + */ +const configHandler = { + /** + * @param {Object.} configurations - Contains the configuration options + * @param {string} option - Key name of the configuration option + */ + get(configurations, option) { + if (!(option in configurations)) return configurations.default[option]; + else return configurations[option]; + }, + /** + * @param {Object.} configurations - Contains the configuration options + * @param {string} option - Key name of the configuration option + * @param {any} value - Configuration option value + */ + set(configurations, option, value) { + configurations[option] = value; + return true; + }, +}; + +/** + * Object that handles configurations, either customized by the user + * or default values defined in 'configurations.default' object + * @type {Object.} + * @see {@link configurations} + */ +const CONFIG = new Proxy(configurations, configHandler); + +/** Matches duration or delay CSS properties values */ +const DURATION_REGEX = Object.freeze(new RegExp(/(\d?\.\d+|\d+)(ms|s)?/)); + +/** + * Keeps track of the callbacks being executed, preventing the callbacks to be executed + * multiple times if multiple elements are being animated by a single trigger. + * + * When an element triggers an animation, no matter how many elements are being animated, + * the start() and complete() callbacks should each be executed only once. + * @type {{ + * executing: Object.>, + * init: Function, + * remove: Function + * }} + */ +const CALLBACK_TRACKER = Object.freeze({ + executing: {}, + /** + * Initiates the tracker + * @param {string} trigger - A CSS selector representing the element which triggered the animation + */ + init: function (trigger) { + CALLBACK_TRACKER.executing[trigger] = {}; + }, + /** + * Removes 'trigger' from the tracker + * @param {string} trigger - A CSS selector representing the element which triggered the animation + */ + remove: function (trigger) { + delete this.executing[trigger]; + }, +}); + +/** + * Keeps track of all the targets being animated to ensure that the callback tracker + * will be removed only when all the targets have been animated. Also ensures that + * all targets will be re-enabled only when all targets have already been animated. + * @type {{add: Function, remove: Function, get: Function, stack: Object.}} + */ +const TARGETS_STACK = { + /** + * Adds an element to the stack + * @param {HTMLElement} elem - Element being animated + * @param {string} trigger - CSS selector for the element that triggered the animation + */ + add: function (elem, trigger) { + if (!(trigger in this.stack)) this.stack[trigger] = []; + this.stack[trigger].push(elem); + }, + /** + * Removes from the stack all the elements animated by the same trigger button + * @param {string} trigger - CSS selector for the element that triggered the animation + */ + remove: function (trigger) { + if (!(trigger in this.stack)) return; + delete this.stack[trigger]; + }, + /** + * Gets all elements included in the stack for a given trigger button + * @param {string} trigger - CSS selector for the element that triggered the animation + * @returns An array of elements that have been animated by the same trigger button + */ + get: function (trigger) { + if (!(trigger in this.stack)) return; + return this.stack[trigger]; + }, + stack: {}, +}; + +/** + * Keeps track of the EventListeners associated to a trigger selector + * @type {{[x: String]: EventListener[]}} + */ +const LISTENERS = {}; + +/** + * Removes the CSS properties customized by the user + * @param {HTMLElement} element - The DOM element with the custom CSS properties + */ +export const removeCustomCssProperties = element => { + CUSTOM_CSS_PROPERTIES.forEach(prop => { + element.style.removeProperty(PROPERTY_NAMES[prop]); + }); +}; + +/** + * Customize the default animations configurations by overwriting + * the 'CONFIG' values + * @param {Object} opts - All the options customized by the user + * @see {@link CONFIG} + */ +const updateDefaultConfig = opts => { + for (let option in CONFIG.default) { + if (opts[option] !== undefined) { + CONFIG[option] = opts[option]; + } + } +}; + +/** + * Reset the configurations to its default values + * by removing from 'CONFIG' all options customized by the user + * @see {@link CONFIG} + */ +const resetDefaultConfig = () => { + for (let option in CONFIG.default) { + delete CONFIG[option]; + } +}; + +/** + * Sets an inline CSS property + * @param {HTMLElement} element - The DOM element which will receive the property + * @param {string} property - Property key in the PROPERTY_NAMES object + * @param {string} value - Value of the CSS Property + * @see {@link module:globals.PROPERTY_NAMES} + */ +export const setCssProperty = (element, property, value) => { + element.style.setProperty(PROPERTY_NAMES[property], value); +}; + +/** + * Sets the CSS properties customized by the user in the animation function's options + * @param {HTMLElement} element - The DOM element to update the CSS Properties + * @param {Object.} opts - Object containing a custom property key and a CSS value to be updated + */ +const updateCssProperties = (element, opts) => { + removeCustomCssProperties(element); + if (element !== document.documentElement) removeInlineTransition(element); + CUSTOM_CSS_PROPERTIES.forEach(prop => { + if (typeof opts[prop] === 'string' || typeof opts[prop] === 'number') { + if (typeof opts[prop] === 'number') { + const unit = { + duration: 'ms', + delay: 'ms', + angle: 'deg', + blur: 'px', + iteration: '', + initialScale: '', + finalScale: '', + }; + + opts[prop] = `${opts[prop]}` + unit[prop]; + } + setCssProperty(element, prop, opts[prop]); + } + }); +}; + +/** + * Searches and returns the 'target-selector' attribute + * + * If the element which triggered the event doesn't have the attribute, + * will bubbles up untill the attribute is found. + * If no attribute is found, an empty string is returned and so + * no element will be selected to be animated + * @param {HTMLElement} eventTarget - The DOM element wich triggers the event + * @returns The CSS selector for the animation target(s) or an empty string + */ +const getTargetSelector = eventTarget => { + /** @type {HTMLElement|null} */ + let trigger = eventTarget; + while (trigger && !trigger.getAttribute('target-selector')) { + /** bubbles up untill the attribute is found */ + trigger = trigger.parentElement; + } + + if (!trigger) throw new ReferenceError('target-selector attribute not found'); + + return trigger.getAttribute('target-selector') ?? ''; +}; + +/** + * Removes the unit from the duration or delay and returns the value in milliseconds + * @param {string} value - duration or delay CSS property value + * @returns The duration or delay in milliseconds + */ +const getTimeInMs = value => { + if (value === undefined) return 0; + if (typeof value === 'number') return value; + let match = value.match(DURATION_REGEX) ?? [0, 0]; + return match.at(-1) === 's' ? Number(match[1]) * 1000 : Number(match[1]); +}; + +/** + * Returns an object with the duration and delay time in milliseconds + * @param {HTMLElement} element - The DOM element being animated + * @returns Both the duration and delay, in milliseconds + */ +const getTotalAnimTime = element => { + const total = {}; + ['duration', 'delay'].forEach(prop => { + total[prop] = getTimeInMs( + getComputedStyle(element).getPropertyValue(PROPERTY_NAMES[prop]) + ); + }); + return total; +}; + +/** + * Returns true if the animation type is 'visibility' + * @param {string} animType - Either 'motion' or 'visibility' + * @returns True if animation type is 'visibility'. False otherwise. + */ +const isVisibility = animType => animType === 'visibility'; +/** + * Returns true if the animation type is 'motion' + * @param {string} animType - Either 'motion' or 'visibility' + * @returns True if animation type is 'motion'. False otherwise. + */ +const isMotion = animType => animType === 'motion'; + +/** + * Removes the current motion animation CSS class from the element + * @param {HTMLElement} element - The DOM element being animated + */ +const removeMotionCssClass = element => { + const className = [...element.classList].find(cl => + cl.match(/js\-anim\-\-(rotate|scale)/) + ); + if (className) element.classList.remove(className); + if (className === CLASS_NAMES.move[MOTION_ANIMS_ID.rotate]) { + element.style.removeProperty(PROPERTY_NAMES.angle); + } +}; + +/** + * Sets an attribute to indicate that the element is currently being animated + * and so can not perform any other animations + * @param {HTMLElement} element - The DOM element being animated + */ +const disable = element => { + element.setAttribute('js-anim--disabled', 'true'); +}; + +/** + * Removes the attribute that indicates that an element is currently being animated + * @param {HTMLElement} element + */ +const enable = element => { + element.removeAttribute('js-anim--disabled'); +}; + +/** + * Verifies if an element is already being animated or not + * @param {HTMLElement} element - The DOM element to check + * @returns True if the element is not currently being animated + */ +const isEnabled = element => + !(element.getAttribute('js-anim--disabled') === 'true'); + +/** + * Adds a CSS class which will set the overflow property to 'clip' (or 'hidden') + * @param {HTMLElement} el - The DOM element which will receive the CSS class + */ +const setOverflowHidden = el => { + el.classList.add(CLASS_NAMES.overflowHidden); +}; + +/** + * Removes the CSS class which sets the overflow property to 'clip' (or 'hidden') + * @param {HTMLElement} el - The DOM element with the CSS class to remove + */ +const removeOverflowHidden = el => { + el.classList.remove(CLASS_NAMES.overflowHidden); +}; + +/** + * Verifies if an element has defined an iteration CSS property + * @param {HTMLElement} element + * @returns True if the element has an iteration CSS property set, False otherwise + */ +const hasIterationProp = element => { + const iterationProperty = element.style.getPropertyValue( + PROPERTY_NAMES.iteration + ); + return ( + iterationProperty != '1' && + iterationProperty.match(/^(infinite|\d+)$/) !== null + ); +}; + +/** + * Sets the parent element dimensions, if needed. + * + * Removes the collapsed or hidden class from the element, when necessary + * @param {HTMLElement} element - The DOM element being animated + * @param {{ + * parentState: string, + * element: HTMLElement, + * parentMeasures: Object, + * action: string, + * dimension: string | undefined + * }} args - All the necessary arguments + */ +const handleVisibilityToggle = (element, args) => { + setTimeout(() => { + if (args.dimension) setParentMaxMeasures(args); + if (args.action === 'show') { + element.classList.remove(CLASS_NAMES.hidden, CLASS_NAMES.collapsed); + } + }, 0); +}; + +/** + * Adds the hidden or collapsed class, when necessary. + * Finalize parent element's resize operations, if needed. + * @param {HTMLElement} element - The DOM element being animated + * @param {Object} opts - All the necessary options + */ +const endVisibilityToggle = (element, opts) => { + if (opts.action === 'hide') { + opts.maintainSpace + ? element.classList.add(CLASS_NAMES.hidden) + : element.classList.add(CLASS_NAMES.collapsed); + } + if (opts.heightTransition || opts.widthTransition) + endParentResize(element, opts); + + if (opts.overflowHidden && element.parentElement) + removeOverflowHidden(element.parentElement); +}; + +/** + * Executes a given callback, checking, when necessary, if the callback was already + * executed by another element being animated by the same trigger button + * @param {string} trigger - The CSS selector of the element that triggered the animation + * @param {Function} fn - The callback to execute + * @param {string} type - Either 'start' or 'complete' + */ +const initCallback = (trigger, fn, type) => { + if (!['start', 'complete'].includes(type)) + throw new ReferenceError( + `Invalid callback type: ${type}. Should be 'start' or 'complete'` + ); + if (trigger) { + if (!(trigger in CALLBACK_TRACKER.executing)) + CALLBACK_TRACKER.init(trigger); + if (!CALLBACK_TRACKER.executing[trigger][type]) { + CALLBACK_TRACKER.executing[trigger][type] = true; + fn(); + } + } else { + fn(); + } +}; + +/** + * Handles all the animation process + * @param {HTMLElement} element - The DOM element to animate + * @param {string} action - 'show', 'hide', or 'move' + * @param {number} id - ID of an animation in the *_ANIMS_ID objects + * @param {Object.} opts - All the options passed by the user + * @see {@link module:globals.VISIBILITY_ANIMS_ID} + * @see {@link module:globals.MOTION_ANIMS_ID} + */ +const animate = (element, action, id, opts = {}) => { + disable(element); + const { + animType, + trigger, + start = CONFIG.start, + complete = CONFIG.complete, + maintainSpace = CONFIG.maintainSpace, + dimensionsTransition = maintainSpace || isMotion(animType) + ? false + : CONFIG.dimensionsTransition, + widthTransition = CONFIG.widthTransition ?? dimensionsTransition, + heightTransition = CONFIG.heightTransition ?? dimensionsTransition, + overflowHidden = CONFIG.overflowHidden, + } = opts; + const { duration, delay } = getTotalAnimTime(element); + const OPPOSITE_ACTION = Object.freeze({ + hide: 'show', + show: 'hide', + move: 'moveBack', + moveBack: 'move', + }); + let parentMeasures, dimension, currentTransition; + + if (trigger) TARGETS_STACK.add(element, trigger); + + const handleAnimation = { + begining: { + visibility: () => { + if (widthTransition || heightTransition) { + ({ parentMeasures, dimension } = initParentResize({ + element, + action, + widthTransition, + heightTransition, + })); + } + + if (overflowHidden && element.parentElement) + setOverflowHidden(element.parentElement); + }, + motion: () => { + currentTransition = getCurrentTransition(element); + removeMotionCssClass(element); + }, + }, + middle: { + visibility: () => { + handleVisibilityToggle(element, { + parentState: 'final', + element, + parentMeasures, + action, + dimension, + }); + }, + motion: () => { + if (currentTransition) { + appendTransition(element, CLASS_NAMES[action][id], currentTransition); + } + if (action === 'move') element.classList.add(CLASS_NAMES.moved); + }, + }, + end: { + visibility: () => { + endVisibilityToggle(element, { + action, + maintainSpace, + widthTransition, + heightTransition, + overflowHidden, + }); + if (!hasIterationProp(element)) + element.classList.remove(CLASS_NAMES[action][id]); + }, + motion: () => { + if (action === 'moveBack') element.classList.remove(CLASS_NAMES.moved); + }, + }, + conclude: () => { + if (trigger && opts.queryIndex === opts.totalTargets - 1) { + opts.staggerDelay + ? CALLBACK_TRACKER.remove(trigger) + : setTimeout(() => CALLBACK_TRACKER.remove(trigger), delay); + TARGETS_STACK.get(trigger).forEach(el => enable(el)); + TARGETS_STACK.remove(trigger); + } else if (!trigger) { + enable(element); + } + }, + }; + + handleAnimation.begining[animType](); + if (typeof start === 'function') { + initCallback(trigger, start, 'start'); + } + element.classList.add(CLASS_NAMES[action][id]); + element.classList.remove(CLASS_NAMES[OPPOSITE_ACTION[action]][id]); + handleAnimation.middle[animType](); + + setTimeout(() => { + handleAnimation.end[animType](); + if (typeof complete === 'function') { + initCallback(trigger, complete, 'complete'); + } + handleAnimation.conclude(); + }, duration + delay); +}; + +/** + * Checks which animation CSS class is set to determine wich action to perform next + * @param {HTMLElement} element - The DOM element being animated + * @param {*} animType - Either 'motion' or 'visibility' + * @returns 'show' or 'hide' or 'move' or 'moveBack' + */ +const getAction = (element, animType) => { + const classList = [...element.classList]; + return isVisibility(animType) + ? classList.find( + c => c === CLASS_NAMES.collapsed || c === CLASS_NAMES.hidden + ) + ? 'show' + : 'hide' + : isMotion(animType) + ? classList.includes(CLASS_NAMES.moved) + ? 'moveBack' + : 'move' + : null; +}; + +/** + * Sets the CSS properties customized by the user, + * prior to the begining of the animation + * @param {HTMLElement} el - The DOM element being animated + * @param {Object} args - The animation's ID and type and all the options passed by the user + */ +const preset = (el, args) => { + const { opts, animationId } = args; + const { animType } = opts; + if ( + !isMotion(animType) || + ![MOTION_ANIMS_ID.rotate, MOTION_ANIMS_ID.rotationLoop].includes( + animationId + ) + ) + opts.angle = undefined; + + updateCssProperties(el, opts); + + if (opts.staggerDelay) { + const staggeredDelay = + getTimeInMs(opts.delay) + + getTimeInMs(opts.staggerDelay) * opts.queryIndex; + setCssProperty(el, 'delay', `${staggeredDelay}ms`); + } +}; + +/** + * Generates the handler function to be passed to the event listener + * @param {HTMLElement} el - The DOM element being animated + * @param {number} animationId - The ID of the animation in the *_ANIMS_ID + * @param {Object} opts - The options passed by the user + * @returns {EventListener} A function to be passed to the addEventListener() as a handler + * @see {@link module:globals.VISIBILITY_ANIMS_ID} + * @see {@link module:globals.MOTION_ANIMS_ID} + */ +const eventHandler = (el, animationId, opts) => { + return (/** @type {Event} */ e) => { + const { + stopPropagation = CONFIG.stopPropagation, + preventDefault = CONFIG.preventDefault, + } = opts; + if (stopPropagation) e.stopPropagation(); + if (preventDefault) e.preventDefault(); + + const action = getAction(el, opts.animType); + if (!action) + throw new ReferenceError( + `Can't find a valid action for this animation type` + ); + + preset(el, { + animationId, + opts, + }); + + if (isEnabled(el)) animate(el, action, animationId, opts); + }; +}; + +/** + * Initiate the event listener with the animation + * @param {number} animationId - The ID of the animation in *_ANIMS_ID object + * @param {Object} opts - All options passed by the user + * @see {@link module:globals.VISIBILITY_ANIMS_ID} + * @see {@link module:globals.MOTION_ANIMS_ID} + */ +const init = (animationId, opts = {}) => { + const { + on: eventType = CONFIG.on, + trigger = CONFIG.trigger, + targetSelector = CONFIG.targetSelector, + cursor, + } = opts; + + document.querySelectorAll(trigger).forEach(btn => { + btn.classList.add(CLASS_NAMES.btnCursor); + if (typeof cursor === 'string') { + setCssProperty(btn, 'cursor', cursor); + } + if (typeof targetSelector === 'string') { + btn.setAttribute('target-selector', targetSelector); + } + + if (!opts.trigger) opts.trigger = trigger; + LISTENERS[trigger] = []; + document + .querySelectorAll(getTargetSelector(btn)) + .forEach((el, i, queryList) => { + // @ts-ignore + const listener = eventHandler(el, animationId, { + ...opts, + totalTargets: queryList.length, + queryIndex: i, + }); + + LISTENERS[trigger].push(listener); + btn.addEventListener(eventType, listener); + }); + }); +}; + +/** + * Removes the event listener of all elements represented by the `triggerSelector` + * @param {String|null} triggerSelector - A valid CSS selector for the trigger Element. If ommited, '.${CLASS_NAMES.trigger}' will be used instead. + * @param {String} eventType - The event name. If ommited, 'click' is the default value. + */ +const end = (triggerSelector = null, eventType = 'click') => { + const triggerList = + typeof triggerSelector === 'string' + ? document.querySelectorAll(triggerSelector) + : document.querySelectorAll(`.${CLASS_NAMES.trigger}`); + + triggerList.forEach(trigger => { + LISTENERS[triggerSelector ?? `.${CLASS_NAMES.trigger}`].forEach( + listener => { + trigger.removeEventListener(eventType, listener); + } + ); + }); + delete LISTENERS[triggerSelector ?? `.${CLASS_NAMES.trigger}`]; +}; + +export { + init, + end, + animate, + preset, + isEnabled, + updateCssProperties, + updateDefaultConfig, + resetDefaultConfig, +}; diff --git a/js-css-animations/js-css-animations.js b/js-css-animations/js-css-animations.js index d4b83ae..7e2343d 100644 --- a/js-css-animations/js-css-animations.js +++ b/js-css-animations/js-css-animations.js @@ -71,9 +71,10 @@ const getTargets = selector => { */ const config = opts => { updateDefaultConfig(opts); - updateCssProperties(document.documentElement, opts); - if (opts.cursor) - setCssProperty(document.documentElement, 'cursor', opts.cursor); + updateCssProperties(document.documentElement, opts).then(() => { + if (opts.cursor) + setCssProperty(document.documentElement, 'cursor', opts.cursor); + }); }; /** diff --git a/js-css-animations/resize-parent.js b/js-css-animations/resize-parent.js index ced2214..bd4a7eb 100644 --- a/js-css-animations/resize-parent.js +++ b/js-css-animations/resize-parent.js @@ -3,11 +3,7 @@ * when child element is being animated * @module resize-parent */ -import { - CLASS_NAMES, - CUSTOM_CSS_PROPERTIES, - PROPERTY_NAMES, -} from './globals.js'; +import { CUSTOM_CSS_PROPERTIES, PROPERTY_NAMES } from './globals.js'; import { getParentMeasures, @@ -66,22 +62,6 @@ const getDimension = (wTransit, hTransit) => { return dimension; }; -/** - * Adds a CSS class which will set the overflow property to 'clip' (or 'hidden') - * @param {HTMLElement} el - The DOM element which will receive the CSS class - */ -const setOverflowHidden = el => { - el.classList.add(CLASS_NAMES.overflowHidden); -}; - -/** - * Removes the CSS class which sets the overflow property to 'clip' (or 'hidden') - * @param {HTMLElement} el - The DOM element with the CSS class to remove - */ -const removeOverflowHidden = el => { - el.classList.remove(CLASS_NAMES.overflowHidden); -}; - /** * Handles parent element width/height transitions during child element's animation * @param {{ @@ -89,7 +69,6 @@ const removeOverflowHidden = el => { * action: string, * widthTransition: boolean, * heightTransition: boolean, - * overflowHidden: boolean * }} args - Containing all the information needed to initiate parent's dimensions transitions * @returns An object with the dimension(s) to transition and the parent element's measurements before and after the child element's animation is performed */ @@ -101,7 +80,6 @@ const initParentResize = args => { const dimension = getDimension(widthTransition, heightTransition); setDimensionsTransitions(parentElement, widthTransition, heightTransition); setParentCssProperties(element); - if (args.overflowHidden) setOverflowHidden(parentElement); setParentMaxMeasures({ parentState: 'initial', element, @@ -124,12 +102,6 @@ const endParentResize = (element, opts) => { removeDimensionMax(parentElement, 'width'); removeCustomCssProperties(parentElement); removeDimensionsTransitions(parentElement, wTransit, hTransit); - removeOverflowHidden(parentElement); }; -export { - initParentResize, - endParentResize, - setOverflowHidden, - removeOverflowHidden, -}; +export { initParentResize, endParentResize }; diff --git a/main.js b/main.js index f34b957..c868174 100644 --- a/main.js +++ b/main.js @@ -4,124 +4,125 @@ jsCssAnimations.init.slideUp({ trigger: '.btn--slide-up', staggerDelay: 500, duration: '1s', + dimensionsTransition: false, start: () => { jsCssAnimations.toggle('#anchor img', 'rotateDownCCW', 'rotateUp'); }, }); -jsCssAnimations.init.slideRight({ - trigger: '.btn--slide-right', - start: () => { - jsCssAnimations.toggle('#anchor2 img', 'rotateRight', 'rotateUp'); - }, -}); +// jsCssAnimations.init.slideRight({ +// trigger: '.btn--slide-right', +// start: () => { +// jsCssAnimations.toggle('#anchor2 img', 'rotateRight', 'rotateUp'); +// }, +// }); -jsCssAnimations.init.slideDown({ - trigger: '.btn--slide-down', - delay: '1.5s', - start: () => { - jsCssAnimations.toggle('img', 'rotateDownCCW', 'rotateUp', { - delay: '1.5s', - }); - // @ts-ignore - document.querySelector('.delay-counter').innerText = '1.5 seconds Delay'; - jsCssAnimations.show.collapse('.delay-counter', { - keepSpace: true, - }); - }, - complete: () => { - jsCssAnimations.hide.fade('.delay-counter', { - keepSpace: true, - complete: () => { - // @ts-ignore - document.querySelector('.delay-counter').innerText = ''; - }, - }); - }, -}); +// jsCssAnimations.init.slideDown({ +// trigger: '.btn--slide-down', +// delay: '1.5s', +// start: () => { +// jsCssAnimations.toggle('img', 'rotateDownCCW', 'rotateUp', { +// delay: '1.5s', +// }); +// // @ts-ignore +// document.querySelector('.delay-counter').innerText = '1.5 seconds Delay'; +// jsCssAnimations.show.collapse('.delay-counter', { +// keepSpace: true, +// }); +// }, +// complete: () => { +// jsCssAnimations.hide.fade('.delay-counter', { +// keepSpace: true, +// complete: () => { +// // @ts-ignore +// document.querySelector('.delay-counter').innerText = ''; +// }, +// }); +// }, +// }); -jsCssAnimations.init.slideLeft({ - trigger: '.btn--slide-left', - staggerDelay: 500, - start: () => { - jsCssAnimations.toggle('img', 'slideLeft', 'slideLeft', { - staggerDelay: 400, - overflowHidden: false, - keepSpace: true, - }); - }, -}); +// jsCssAnimations.init.slideLeft({ +// trigger: '.btn--slide-left', +// staggerDelay: 500, +// start: () => { +// jsCssAnimations.toggle('img', 'slideLeft', 'slideLeft', { +// staggerDelay: 400, +// overflowHidden: false, +// keepSpace: true, +// }); +// }, +// }); -jsCssAnimations.init.collapse({ - trigger: '.collapse-expand--btn', - targetSelector: '.collapse-expand--p', -}); +// jsCssAnimations.init.collapse({ +// trigger: '.collapse-expand--btn', +// targetSelector: '.collapse-expand--p', +// }); -jsCssAnimations.init.collapse({ - trigger: '.collapse-expand--btn__mult', - targetSelector: '.collapse-expand--p__mult', - staggerDelay: 400, - keepSpace: true, - transfOrigin: 'center', -}); +// jsCssAnimations.init.collapse({ +// trigger: '.collapse-expand--btn__mult', +// targetSelector: '.collapse-expand--p__mult', +// staggerDelay: 400, +// keepSpace: true, +// transfOrigin: 'center', +// }); -jsCssAnimations.init.fade({ - trigger: '.fade--btn', - blur: 12, - keepSpace: true, -}); +// jsCssAnimations.init.fade({ +// trigger: '.fade--btn', +// blur: 12, +// keepSpace: true, +// }); -const validateInput = input => { - if (input.validity.patternMismatch) { - const msgArea = document.querySelector('.rotation--input-error'); - // @ts-ignore - msgArea.innerText = 'Type in a number (e.g.: 270, -22.5)'; - jsCssAnimations.show.fade(msgArea, { - complete: () => { - setTimeout(() => { - jsCssAnimations.hide.fade(msgArea, { delay: '2.5s' }); - }, 0); - }, - }); - input.value = ''; - return false; - } else { - return true; - } -}; +// const validateInput = input => { +// if (input.validity.patternMismatch) { +// const msgArea = document.querySelector('.rotation--input-error'); +// // @ts-ignore +// msgArea.innerText = 'Type in a number (e.g.: 270, -22.5)'; +// jsCssAnimations.show.fade(msgArea, { +// complete: () => { +// setTimeout(() => { +// jsCssAnimations.hide.fade(msgArea, { delay: '2.5s' }); +// }, 0); +// }, +// }); +// input.value = ''; +// return false; +// } else { +// return true; +// } +// }; -jsCssAnimations.init.rotate({ - trigger: '#rotation-angle', - targetSelector: '.rotation-area', - on: 'change', - // @ts-ignore - start: () => { - if (validateInput(document.querySelector('#rotation-angle'))) { - // @ts-ignore - const angle = Number(document.getElementById('rotation-angle')?.value); - jsCssAnimations.rotate('.rotation-area', { - angle: angle, - }); - } - }, - complete: () => { - jsCssAnimations.rotate('.rotation-area', { - angle: '0deg', - delay: '1s', - }); - }, -}); +// jsCssAnimations.init.rotate({ +// trigger: '#rotation-angle', +// targetSelector: '.rotation-area', +// on: 'change', +// // @ts-ignore +// start: () => { +// if (validateInput(document.querySelector('#rotation-angle'))) { +// // @ts-ignore +// const angle = Number(document.getElementById('rotation-angle')?.value); +// jsCssAnimations.rotate('.rotation-area', { +// angle: angle, +// }); +// } +// }, +// complete: () => { +// jsCssAnimations.rotate('.rotation-area', { +// angle: '0deg', +// delay: '1s', +// }); +// }, +// }); -jsCssAnimations.pulsate('#anchor2 img', { - finalScale: 1.2, -}); +// jsCssAnimations.pulsate('#anchor2 img', { +// finalScale: 1.2, +// }); -jsCssAnimations.init.scale({ - targetSelector: '.p2', - finalScale: 1.2, - duration: '1.5s', -}); +// jsCssAnimations.init.scale({ +// targetSelector: '.p2', +// finalScale: 1.2, +// duration: '1.5s', +// }); -document.querySelector('#anchor img')?.addEventListener('click', () => { - jsCssAnimations.end('.btn--slide-up'); -}); +// document.querySelector('#anchor img')?.addEventListener('click', () => { +// jsCssAnimations.end('.btn--slide-up'); +// });