animations.mjs

/**  
 * @module animations
 * @description
 * A collection of functions animating element transitions.
 * Substitutes for jQuery's "animation" functions slideUp(), slideDown(), slideToggle(), fadeIn(), fadeOut() functions.
 * Leans onto CSS transitions, reading the height transition duration and setting a timer based on that to clear the height property on animation end.
 * There is a unique reason for this, for instance animating height is ony possible through max-height, and if the max-height, which can produce inconsistent
 * animation duration depending on the element's actual height. Or, animating display none to display block, this can be done via opacity and pointer-events:none
 * this means the element will have to overlay the screen but be inaccessible. This module provides "javascript wrappers" that substitute the shortcomings of
 * the CSS transitions regarding these two cases.
 */

import { getTransitionDurations } from './dom.mjs'
import { isFunction } from './helpers.mjs'

/**
 * Clears the property transition timer of an element. Timer ID is stored in the element's dataset, under the propertyTransitionTimer key.
 * 
 * @param {HTMLElement} element
 * @param {string} [property='all'] The property to clear the timer for. Defaults to 'all', thus the key in the dataset will be allTransitionTimer.
 */
function clearTransitionTimer(element, property = 'all') {
  if (!element) return
  const dataPropName = `${property}TransitionTimer`
  if (!element.dataset[dataPropName]) return
  clearTimeout(parseInt(element.dataset[dataPropName]))
  delete element.dataset[dataPropName]
}


/**
 * Assigning a timer for a selected property and binding the timerID in the element's property 
 * for later retrieval for clearing
 * 
 * @param {HTMLElement} element 
 * @param {string} [property='all'] 
 * @param {number} timeout in milliseconds 
 * @param {function} callback 
 * @returns {number | null} timer ID if timer is successfully created
 */
function setTransitionTimer(element, property = 'all', timeout, callback) {
  if (!element) return
  const dataPropName = `${property}TransitionTimer`
  const timer = setTimeout(() => {
    clearTransitionTimer(element, property)
    if (isFunction(callback)) callback(element)
  }, timeout)
  element.dataset[dataPropName] = timer.toString()

  return timer
}

/**
 * Sets the slide duration of an element. The element must have a CSS transition set for the height property.
 * The CSS transition duration is used to determine how long the slide animation will take.
 * 
 * @param {HTMLElement} element
 */
function setTransitionDuration(element, property = 'all') {
  if (!element) return
  const dataPropName = `${property}TransitionDuration`
  if (element.dataset[dataPropName]) return parseInt(element.dataset[dataPropName])
  const transitionDurations = getTransitionDurations(element)
  if (!transitionDurations.hasOwnProperty(property)) return
  element.dataset[dataPropName] = transitionDurations[property].toString()
  return transitionDurations[property]
}

/**
 * Slides up an element. The element must have a CSS transition set for the height property. 
 * The transition duration is used to determine how long the slide up animation will take.
 * Substitutes for jQuery's slideUp() function.
 * 
 * @param {HTMLElement} element 
 * @param {Function} [callback]
 * @param {Function} [transitionStartCallback] callback function to be called when the transition starts
 * @example
 * slideUp(element)
 */
export function slideUp(element, callback, transitionStartCallback) {
  if (!element) return
  clearTransitionTimer(element, 'height')
  const styles = getComputedStyle(element)

  const duration = setTransitionDuration(element, 'height')
  
  element.style.overflow = 'hidden'
  if (styles.height !== 'auto') element.style.height = `${element.offsetHeight}px`

  setTimeout(() => {
    element.style.height = `0px`
    if (isFunction(transitionStartCallback)) transitionStartCallback(element)
  }, 10)

  setTransitionTimer(element, 'height', duration, (element) => {
    element.style.display = 'none'
    element.style.height = ''
    element.style.removeProperty('overflow')
    if (isFunction(callback)) callback(element)
  })
}

/**
 * Slides down an element. The element must have a CSS transition set for the height property.
 * The transition duration is used to determine how long the slide down animation will take.
 * Substitutes for jQuery's slideDown() function.
 * 
 * @param {HTMLElement} element
 * @param {Function} [callback] callback function to be called when the transition ends
 * @param {Function} [transitionStartCallback] callback function to be called when the transition starts
 * @example
 * slideDown(element)
 */
export function slideDown(element, callback, transitionStartCallback) {
  if (!element) return
  clearTransitionTimer(element, 'height')
  const styles = getComputedStyle(element)

  const duration = setTransitionDuration(element, 'height')

  let oldHeight = parseInt(styles.height)
  if (Number.isNaN(oldHeight)) oldHeight = 0

  if (element.hasAttribute('hidden')) element.removeAttribute('hidden')
  element.style.pointerEvents = 'none'
  if (!oldHeight) element.style.visibility = 'hidden'
  element.style.display = 'block'
  element.style.height = ''
  element.style.overflow = 'hidden'
  
  const height = element.offsetHeight
  element.style.height = oldHeight ? `${oldHeight}px` : '0px'

  setTimeout(() => {
    element.style.height = `${height}px`
    element.style.visibility = 'visible'
    element.style.removeProperty('pointer-events')
    if (isFunction(transitionStartCallback)) transitionStartCallback(element)
  }, 10)

  setTransitionTimer(element, 'height', duration, (element) => {
    element.style.height = ''
    element.style.removeProperty('overflow')
    if (isFunction(callback)) callback(element)
  })
}

/**
 * Toggles the slide state of an element. The element must have a CSS transition set for the height property.
 * The transition duration is used to determine how long the slide animation will take.
 * Substitutes for jQuery's slideToggle() function.
 * 
 * @param {HTMLElement} element
 * @param {Function} [callback] callback function to be called when the transition ends
 * @param {Function} [transitionStartCallback] callback function to be called when the transition starts
 * @example
 * slideToggle(element)
 */
export function slideToggle(element, callback, transitionStartCallback) {
  if (!element) return
  const styles = getComputedStyle(element)

  setTransitionDuration(element, 'height')
  if (!element.dataset.heightTransitionDuration) return

  if (styles.display === 'none' || parseInt(styles.height) === 0) {
    slideDown(element, callback, transitionStartCallback)
  } else {
    slideUp(element, callback, transitionStartCallback)
  }
}

/**
 * Fades in an element. The element must have a CSS transition set for the opacity property, and initial opacity to 0.
 * The transition duration is used to determine how long the fade in animation will take.
 * Substitutes for jQuery's fadeIn() function.
 * 
 * @param {HTMLElement} element
 * @param {Function} [callback] callback function to be called when the transition ends
 * @param {Function} [transitionStartCallback] callback function to be called when the transition starts
 * @example
 * fadeIn(element)
 */
export function fadeIn(element, callback, transitionStartCallback) {
  if (!element) return
  clearTransitionTimer(element, 'opacity')
  const styles = getComputedStyle(element)

  const duration = setTransitionDuration(element, 'opacity')

  let oldOpacity = parseInt(styles.opacity)
  if (Number.isNaN(oldOpacity)) oldOpacity = 0

  if (element.hasAttribute('hidden')) element.removeAttribute('hidden')
  element.style.pointerEvents = 'none'
  if (!oldOpacity) element.style.visibility = 'hidden'
  element.style.display = 'block'
  element.style.opacity = oldOpacity ? oldOpacity : 0

  setTimeout(() => {
    element.style.opacity = 1
    element.style.visibility = 'visible'
    element.style.removeProperty('pointer-events')
    if (isFunction(transitionStartCallback)) transitionStartCallback(element)
  }, 10)

  setTransitionTimer(element, 'opacity', duration, (element) => {
    if (isFunction(callback)) callback(element)
  })
}

/**
 * Fades out an element. The element must have a CSS transition set for the opacity property.
 * The transition duration is used to determine how long the fade out animation will take.
 * Substitutes for jQuery's fadeOut() function.
 * 
 * @param {HTMLElement} element
 * @param {Function} [callback] callback function to be called when the transition ends
 * @param {Function} [transitionStartCallback] callback function to be called when the transition starts
 * @example
 * fadeOut(element)
 */
export function fadeOut(element, callback, transitionStartCallback) {
  if (!element) return
  clearTransitionTimer(element, 'opacity')
  const styles = getComputedStyle(element)

  const duration = setTransitionDuration(element, 'opacity')

  element.style.opacity = styles.opacity

  setTimeout(() => {
    element.style.opacity = 0
    element.style.pointerEvents = 'none'
    if (isFunction(transitionStartCallback)) transitionStartCallback(element)
  }, 10)

  setTransitionTimer(element, 'opacity', duration, (element) => {
    element.style.display = 'none'
    element.style.opacity = ''
    element.style.pointerEvents = ''
    if (isFunction(callback)) callback(element)
  })
}

/**
 * Toggles the fade state of an element. The element must have a CSS transition set for the opacity property.
 * The transition duration is used to determine how long the fade animation will take.
 * Substitutes for jQuery's fadeToggle() function.
 * 
 * @param {HTMLElement} element
 * @param {Function} [callback] callback function to be called when the transition ends
 * @param {Function} [transitionStartCallback] callback function to be called when the transition starts
 * @example
 * fadeToggle(element)
 */
export function fadeToggle(element, callback, transitionStartCallback) {
  if (!element) return
  const styles = getComputedStyle(element)

  setTransitionDuration(element, 'opacity')
  if (!element.dataset.opacityTransitionDuration) return

  if (styles.display === 'none' || parseInt(styles.opacity) === 0) {
    fadeIn(element, callback, transitionStartCallback)
  } else {
    fadeOut(element, callback, transitionStartCallback)
  }
}