LasMD
10/16/2018 - 6:06 PM

Mini jQuery, sort of.

Mini jQuery, sort of.

/**
 * A collection of helper prototype for everyday DOM traversal, manipulation,
 * and event binding. Sort of a minimalist jQuery, mainly for demonstration 
 * purposes. MIT @ m3g4p0p
 */
window.$ = (function (undefined) {

  /**
   * Duration constants
   * @type {Object}
   */
  const DURATION = {
    DEFAULT: 500
  }

  /**
   * Style constants
   * @type {Object}
   */
  const STYLE = {
    SHOW: 'block',
    HIDE: 'none'
  }

  /**
   * Style property constants
   * @type {Object}
   */
  const PROPERTY = {
    DISPLAY: 'display'
  }

  /**
   * Type constants
   * @type {Object}
   */
  const TYPE = {
    FUNCTION: 'function',
    STRING: 'string'
  }

  /**
   * Map elements to events
   * @type {WeakMap}
   */
  const eventMap = new WeakMap()

  /**
   * Function to fade an element
   * @param  {HTMLElement} element
   * @param  {Number} from
   * @param  {Number} to
   * @param  {Number} duration
   * @param  {Function} callback
   * @return {void}
   */
  const fade = function fade (element, from, to, duration, callback) {
    const start = window.performance.now()

    element.style.display = STYLE.SHOW

    window.requestAnimationFrame(function step (timestamp) {
      const progress = timestamp - start
      element.style.opacity = from + (progress / duration) * (to - from)

      if (progress < duration) {
        window.requestAnimationFrame(step) 
      } else {
        if (element.style.opacity <= 0) {
          element.style.display = STYLE.HIDE
        }

        if (callback) {
          callback.call(element)
        }
      }
    })
  }

  /**
   * The methods in the collection'S prototype
   * @type {Object}
   */
  const prototype = {

    ///////////////
    // Traversal //
    ///////////////

    /**
     * Check if a node is already contained in the collection
     * @param  {HTMLElement} element
     * @return {Boolean}
     */
    has (element) {
      return Array.from(this).includes(element)
    },

    /**
     * Add an element or a list of elements to the collection
     * @param   {mixed} element
     * @returns {this}
     */
    add (element) {
      const elements = element.length !== undefined ? element : [element]

      Array.from(elements).forEach(element => {
        if (element && !this.has(element)) {
          Array.prototype.push.call(this, element)
        }
      })

      return this
    },

    /**
     * Find descendants of the current collection matching a selector
     * @param  {String} selector
     * @return {this}
     */
    find (selector) {
      return Array.from(this).reduce(
        (carry, element) => carry.add(element.querySelectorAll(selector)), 
        Object.create(prototype)
      )
    },

    /**
     * Filter the current collection by a selector or filter function
     * @param  {String|Function} selector
     * @return {this}
     */
    filter (selector) {
      return Object.create(prototype).add(
        Array.from(this).filter(
          typeof selector === TYPE.FUNCTION
            ? selector
            : element => element.matches(selector)
        )
      )
    },

    /**
     * Get a collection containing the adjecent next siblings
     * of the current collection, optionally filtered by a selector
     * @param  {String|undefined} selector
     * @return {this}
     */
    next (selector) {
      return Object.create(prototype).add(
        Array.from(this)
          .map(element => element.nextElementSibling)
          .filter(element => element && (!selector || element.matches(selector)))
      )
    },

    /**
     * Get a collection containing the adjecent previous siblings
     * of the current collection, optionally filtered by a selector
     * @param  {String|undefined} selector
     * @return {this}
     */
    prev (selector) {
      return Object.create(prototype).add(
        Array.from(this)
          .map(element => element.previousElementSibling)
          .filter(element => element && (!selector || element.matches(selector)))
      )
    },

    /**
     * Get a collection containing the immediate parents of
     * the current collection, optionally filtered by a selector
     * @param  {String|undefined} selector
     * @return {this}
     */
    parent (selector) {
      return Object.create(prototype).add(
        Array
          .from(this)
          .map(element => element.parentNode)
          .filter(element => !selector || element.matches(selector))
      )
    },

    /**
     * Get a collection containing the immediate parents of the
     * current collection, or, if a selector is specified, the next
     * ancestor that matches that selector
     * @param  {String|undefined} selector
     * @return {this}
     */
    parents (selector) {
      return Object.create(prototype).add(
        Array.from(this).map(function walk (element) {
          const parent = element.parentNode

          return parent && (!selector || parent.matches(selector))
            ? parent
            : walk(parent)
        })
      )
    },

    /**
     * Get a collection containing the immediate children of the
     * current collection, optionally filtered by a selector
     * @param  {String|undefined} selector
     * @return {this}
     */
    children (selector) {
      return Object.create(prototype).add(
        Array
          .from(this)
          .reduce((carry, element) => carry.concat(...element.children), [])
          .filter(element => !selector || element.matches(selector))
      )
    },

    //////////////////
    // Manipulation //
    //////////////////

    /**
     * Add a class to all elements in the current collection
     * @param   {String} className
     * @returns {this}
     */
    addClass (className) {
      Array.from(this).forEach(el => {
        el.classList.add(className)
      })

      return this
    },

    /**
     * Remove a class from all elements in the current collection
     * @param  {String} className
     * @return {this}
     */
    removeClass (className) {
      Array.from(this).forEach(el => {
        el.classList.remove(className)
      })

      return this
    },

    /**
     * Set the value property of all elements in the current
     * collection, or, if no value is specified, get the value
     * of the first element in the collection
     * @param  {mixed} newVal
     * @return {this}
     */
    val (newVal) {
      if (!newVal) {
        return this[0].value
      }
      
      Array.from(this).forEach(el => {
        el.value = newVal
      })

      return this
    },

    /**
     * Set the HTML of all elements in the current collection, 
     * or, if no markup is specified, get the HTML of the first 
     * element in the collection
     * @param  {String|undefined} newHtml
     * @return {this}
     */
    html (newHtml) {
      if (!newHtml) {
        return this[0].innerHtml
      }
      
      Array.from(this).forEach(el => {
        el.innerHtml = newVal
      })

      return this
    },

    /**
     * Set the text of all elements in the current collection, 
     * or, if no markup is specified, get the HTML of the first 
     * element in the collection
     * @param  {String|undefined} newText
     * @return {this}
     */
    text (newText) {
      if (!newText) {
        return this[0].textContent
      }
      
      Array.from(this).forEach(el => {
        el.textContent = newText
      })

      return this
    },

    ///////////////////////
    // CSS and animation //
    ///////////////////////

    /**
     * Hide all elements in the current collection
     * @return {this}
     */
    hide () {
      Array.from(this).forEach(element => {
        element.style.display = null

        if (window.getComputedStyle(element).getPropertyValue(PROPERTY.DISPLAY) !== STYLE.HIDE) {
          element.style.display = STYLE.HIDE
        }
      })

      return this
    },

    /**
     * Show all elements in the current collection
     * @return {this}
     */
    show () {
      Array.from(this).forEach(element => {
        element.style.display = null

        if (window.getComputedStyle(element).getPropertyValue(PROPERTY.DISPLAY) === STYLE.HIDE) {
          element.style.display = STYLE.SHOW
        }
      })

      return this
    },

    /**
     * Set the CSS of the elements in the current collection
     * by either specifying the CSS property and value, or
     * an object containing the style declarations
     * @param  {String|object} style
     * @param  {mixed} value
     * @return {this}
     */
    css (style, value) {
      const currentStyle = {}

      if (typeof style === TYPE.STRING) {

        if (!value) {
          return this[0] && window
            .getComputedStyle(this[0])
            .getPropertyValue(style)
        }

        currentStyle[style] = value
      } else {
        Object.assign(currentStyle, style)
      }

      Array.from(this).forEach(element => {
        Object.assign(element.style, currentStyle)
      })

      return this
    },

    /**
     * Fade the elements in the current collection in; optionally
     * takes the fade duration and a callback that gets executed
     * on each element after the animation finished
     * @param  {Number|undefined} duration
     * @param  {Function|undefined} callback
     * @return {this}
     */
    fadeIn (duration, callback) {
      Array.from(this).forEach(element => {
        fade(element, 0, 1, duration || DURATION.DEFAULT, callback)
      })

      return this
    },

    /**
     * Fade the elements in the current collection out; optionally
     * takes the fade duration and a callback that gets executed
     * on each element after the animation finished
     * @param  {Number|undefined} duration
     * @param  {Function|undefined} callback
     * @return {this}
     */
    fadeOut (duration, callback) {
      Array.from(this).forEach(element => {
        fade(element, 1, 0, duration || DURATION.DEFAULT, callback)
      })

      return this
    },

    ////////////
    // Events //
    ////////////

    /**
     * Bind event listeners to all elements in the current collection,
     * optionally delegated to a target element specified as 2nd argument
     * @param  {String} type
     * @param  {Function|String} target
     * @param  {Function|undefined} callback
     * @return {this}
     */
    on (type, target, callback) {
      const handler = callback
        ? function (event) {
          if (event.target.matches(target)) {
            callback.call(this, event)
          }
        }
        : target

      Array.from(this).forEach(element => {
        const events = eventMap.get(element) || eventMap.set(element, {}).get(element)

        events[type] = events[type] || []
        events[type].push(handler)
        element.addEventListener(type, handler)
      })

      return this
    },

    /**
     * Remove event listeners from the elements in the current
     * collection; if no handler is specified, all listeners of
     * the given type will be removed
     * @param  {String} type
     * @param  {Function|undefined} callback
     * @return {this}
     */
    off (type, callback) {
      Array.from(this).forEach(element => {
        const events = eventMap.get(element)
        const callbacks = events && events[type]

        if (callback) {
          element.removeEventListener(type, callback)

          if (callbacks) {
            events[type] = callbacks.filter(current => current !== callback)
          }
        } else if (callbacks) {
          delete events[type]

          callbacks.forEach(callback => {
            element.removeEventListener(type, callback)
          }) 
        }
      })

      return this
    },

    ///////////////////
    // Miscellaneous //
    ///////////////////

    /**
     * Execute a funtion on each element in the current collection
     * @param  {Function} fn
     * @return {this}
     */
    each (fn) {
      Array.from(this).forEach(element => {
        fn.call(element)
      })

      return this
    }
  }
  
  /**
   * Create a new collection
   * @param  {String} selector
   * @param  {HTMLElement|undefined} context
   * @return {Object}
   */
  return function createCollection (selector, context) {
    const initial = typeof selector === TYPE.STRING
      ? (context || document).querySelectorAll(selector)
      : selector

    const instance = Object.create(prototype)

    return initial 
      ? instance.add(initial)
      : instance
  }
})()