Home Manual Reference Source Repository

src/glider/index.js

/**
 * @file glider/index.js
 * @author Gregor Adams <greg@pixelass.com>
 */

import {PLUGIN_DEFAULTS} from '../config'
import {
  animate,
  eitherOr,
  arrayOrValue,
  modLoop,
  toggleClass,
  findAll as $,
  findFirst as $$
} from '../helpers'

/**
 * Paraglider plugin.
 * @type {class}
 */
class Glider {
  /**
   * Paraglider is an API driven slider.
   * It exposes a timeline and offers some options.
   *
   * This gives developers a lot of freedom when implementing a slider.
   * The main purpose for this plugin is to create slideshows with a parallax effect.
   * Due to the simplicity you can feed the timeline to other plugins and create amazing
   * effects like animating SVGs or drawing on a canvas.
   *
   * @param {PLUGIN_DEFAULTS} options Custom options for the Plugin call
   * @returns {this}
   */
  constructor(options = {}) {
    /**
     * Plugin options merged from defaults and custom configuration
     * @private
     * @type {object}
     */
    this.options = {
      ...PLUGIN_DEFAULTS,
      ...options
    }
    /**
     * State store for interaction flags
     * @private
     * @type {object}
     */
    this._state = {
      currentSlide: this.options.initialSlide,
      init: true
    }

    this.nextSlide = this.nextSlide.bind(this)
    this.prevSlide = this.prevSlide.bind(this)
    this.goTo = this.goTo.bind(this)
    this.handleDown = this.handleDown.bind(this)
    this.handleMove = this.handleMove.bind(this)
    this.handleUp = this.handleUp.bind(this)
    this.getClientX = this.getClientX.bind(this)
  }

  /**
   * Handles internal storage
   * @private
   * @param {object} newState The new state properties to merge into the old state
   */
  setState(newState) {
    this._state = {
      ...this.state,
      ...newState
    }
  }

  /**
   * Getter for the state
   * @private
   * @returns {object}
   */
  get state() {
    return this._state
  }

  /**
   * Init call for the plugin.
   *
   * This method assigns the element to the plugin scope, adds the required
   * event listeners and class names.
   * @param {Element} el An element containing the required markup with and
   * selectors
   */
  init(el) {
    const {classNames, onInit} = this.options
    /**
     * Outer element
     * @private
     * @type {Element}
     */
    this.el = el
    /**
     * This element is used to track mouse or touch interaction
     * @private
     * @type {Element}
     */
    this.slidesWrapper = $$(`.${classNames.slides}`, el)
    /**
     * A list of all slides.
     * @private
     * @type {array.<Element>}
     */
    this.slides = $(`.${classNames.slide}`, this.slidesWrapper)

    this.addListeners()
    this.addSides()
    this.addInitClassNames()
    if (typeof onInit === 'function') {
      const {
        next,
        previous,
        current,
        rest
      } = this.getReturnValues(false)
      /**
       * Callback for the end
       * @public
       * @type {onInit}
       */
      onInit({next, previous, current, rest}, this.slides, this.options)
    }
  }

  /**
   * Destroys the plugin by removing event listeners and class names
   */
  destroy() {
    const {onDestroy} = this.options
    this.removeListeners()
    this.removeClassNames()
    this.el = null
    this.slidesWrapper = null
    this.slides = null
    if (typeof onDestroy === 'function') {
      /**
       * Callback for the end
       * @public
       * @type {onDestroy}
       */
      onDestroy(this.options)
    }
  }

  /**
   * Adds event listeners needed for this plugin to work.
   * Movement and release should be tracked on window or document.
   * @private
   * @listens {touchmove}
   *   Listens to touchmove if `enableTouch === true`
   *   global listener to allow out of bounds movement.
   * @listens {touchend}
   *   Listens to touchend if `enableTouch === true`
   *   global listener to allow out of bounds movement.
   * @listens {touchstart}
   *   Listens to touchstart if `enableTouch === true`
   *   scoped listener to determine activity
   * @listens {mousemove}
   *   Listens to mousemove if `enableSwipe === true`
   *   global listener to allow out of bounds movement.
   * @listens {mouseup}
   *   Listens to mouseup if `enableSwipe === true`
   *   global listener to allow out of bounds movement.
   * @listens {mousedown}
   *   Listens to mousedown if `enableSwipe === true`
   *   scoped listener to determine activity
   */
  addListeners() {
    /* istanbul ignore next */
    if (this.options.enableTouch) {
      global.addEventListener('touchmove', this.handleMove, {passive: false})
      global.addEventListener('touchend', this.handleUp)
      this.slidesWrapper.addEventListener('touchstart', this.handleDown)
    }
    /* istanbul ignore next */
    if (this.options.enableSwipe) {
      global.addEventListener('mousemove', this.handleMove, {passive: false})
      global.addEventListener('mouseup', this.handleUp)
      this.slidesWrapper.addEventListener('mousedown', this.handleDown)
    }
  }

  /**
   * Removes all event listeners. (Helpful when destroying the plugin instance)
   * @private
   */
  removeListeners() {
    /* istanbul ignore next */
    if (this.options.enableTouch) {
      global.removeEventListener('touchmove', this.handleMove)
      global.removeEventListener('touchend', this.handleUp)
      this.slidesWrapper.removeEventListener('touchstart', this.handleDown)
    }
    /* istanbul ignore next */
    if (this.options.enableSwipe) {
      global.removeEventListener('mousemove', this.handleMove)
      global.removeEventListener('mouseup', this.handleUp)
      this.slidesWrapper.removeEventListener('mousedown', this.handleDown)
    }
  }

  /**
   * Adds class names to slides
   * @private
   */
  addClassNames() {
    const {addClasses, addMultiClasses} = this.options
    const keys = Object.keys(addClasses)
    if (keys.length > 0) {
      const {currentSlide, previousSlide, nextSlide} = this.state
      const {visibleSlides, slideBy, classNames} = this.options
      const {current, next, previous} = classNames
      const {length} = this.slides
      const diff = visibleSlides - slideBy
      this.slides.forEach((slide, index) => {
        const isCurrent = (index === currentSlide) && (addClasses.current === true)
        const isNext = (index === modLoop(nextSlide, diff, length)) && (addClasses.next === true)
        const isPrevious = (index === previousSlide) && (addClasses.previous === true)
        toggleClass(slide, current, isCurrent)
        toggleClass(slide, next, isNext)
        toggleClass(slide, previous, isPrevious)
        if (addMultiClasses.current === true) {
          for (let i = 0; i < visibleSlides; i++) {
            const isCurrent = (index === modLoop(currentSlide, i, length))
            toggleClass(slide, `${current}__${i}`, isCurrent)
          }
        }
        if ((addMultiClasses.previous === true) || (addMultiClasses.next === true)) {
          for (let i = 0; i < slideBy; i++) {
            const isNext = (index === modLoop(nextSlide, (i + diff), length)) && (addMultiClasses.next === true)
            const isPrevious = (index === modLoop(previousSlide, i, length)) && (addMultiClasses.previous === true)
            toggleClass(slide, `${next}__${i}`, isNext)
            toggleClass(slide, `${previous}__${i}`, isPrevious)
          }
        }
      })
    }
  }

  /**
   * Initially set class names
   *
   * `init` will be removed after the first interaction. It allows a 'silent' start
   * when working with CSS animations or transitions.
   * @private
   */
  addInitClassNames() {
    const {classNames} = this.options
    this.el.classList.add(classNames.pluginLoaded)
    this.slides.forEach(slide => {
      slide.classList.add(classNames.init)
    })
    this.addClassNames()
  }

  /**
   * Batch removal of class names.
   * This is dirty but simply removes anything the plugin could have set.
   * @todo Find a better way to do this.
   * @private
   */
  removeClassNames() {
    const {classNames} = this.options
    const classList = (Object.keys(classNames).map(key => classNames[key]))

    this.el.classList.remove(...classList)
    this.slides.forEach(slide => {
      slide.classList.remove(...classList)
    })
  }

  /**
   * Add `previous` and `next` classes around the `current` slide.
   * This function respects pager clicks, which modify the next or previous element.
   * @private
   */
  addSides() {
    const {slideBy} = this.options
    const {currentSlide, requestedNext, requestedPrevious} = this.state
    const {length} = this.slides
    // Respect requested slides.
    // {goTo} could set these values.
    const originalNext = modLoop(currentSlide, slideBy, length)
    const originalPrevious = modLoop(currentSlide, (-1 * (slideBy)), length)
    const nextSlide = eitherOr(requestedNext, originalNext)
    const previousSlide = eitherOr(requestedPrevious, originalPrevious)
    this.setState({nextSlide, previousSlide})
  }

  /**
   * Moves to the next slide via trigger.
   * @param {?Event} [e=null] optionally pass the event to prevent it
   */
  nextSlide(e = null) {
    /* istanbul ignore next */
    if (e && 'preventDefault' in e) {
      e.preventDefault()
    }
    /* istanbul ignore next */
    if (this.state.init) {
      this.setState({init: false})
      this.slides.forEach(slide => {
        slide.classList.remove(this.options.classNames.init)
      })
    }
    this.addSides()
    this.addClassNames()
    this.spring(0, 1, this.options.speed)
    return this.state.nextSlide
  }

  /**
   * Moves to the previous slide via trigger.
   * @param {?Event} [e=null] optionally pass the event to prevent it
   */
  prevSlide(e = null) {
    /* istanbul ignore next */
    if (e && 'preventDefault' in e) {
      e.preventDefault()
    }
    /* istanbul ignore next */
    if (this.state.init) {
      this.setState({init: false})
      this.slides.forEach(slide => {
        slide.classList.remove(this.options.classNames.init)
      })
    }
    this.addSides()
    this.addClassNames()
    this.spring(0, -1, this.options.speed)
    return this.state.previousSlide
  }

  /**
   * Moves to the nth slide via trigger. Respects left/right movement
   * @param {number} n index of requested slide
   */
  goTo(n) {
    if (n > this.state.currentSlide) {
      this.setState({requestedNext: n})
      return this.nextSlide()
    /* istanbul ignore next */
    } else /* istanbul ignore next */ if (n < this.state.currentSlide) {
      this.setState({requestedPrevious: n})
      return this.prevSlide()
    }
  }

  /**
   * Handles the snap animation
   * @private
   * @param {number} progress Current value
   * @param {number} end Final value
   * @param {number} duration Time to pass the until animation is done.
   */
  spring(progress, end, duration) {
    // Cancel previous animations
    global.cancelAnimationFrame(this.animation)
    /**
     * Animation cache to allow canceling
     */
    const {currentSlide, nextSlide, previousSlide} = this.state
    const {loop, visibleSlides, slideBy} = this.options
    const {length} = this.slides
    let theEnd = end
    if (!loop) {
      theEnd = end < 0 && previousSlide > currentSlide ? 0 : theEnd
      theEnd = end > 0 && modLoop(nextSlide, visibleSlides - slideBy, length) < currentSlide ? 0 : theEnd
    }
    /**
     * Animation flag. Calls the animation and stores the function to allow `cancelAnimationFrame`
     * @private
     * @type {loop}
     */
    this.animation = animate(duration, progress, theEnd,
      p => {
        this.setState({
          x: p * this.el.offsetWidth
        })
        if (p === theEnd) {
          this.handleEnd(theEnd)
        } else {
          this.handleProgress()
        }
      }
    )
  }

  /* istanbul ignore next */
  /**
   * Find clientX from the event.
   * This helper will return the correct value for touch or mouse.
   * @private
   * @param {Event} e Mouse or touch event
   * @returns {number} THe clientX of the event
   */
  getClientX(e) {
    const {touches = []} = e
    const {clientX} = touches[0] || e
    return clientX
  }

  /**
   * Prepares return values
   * @private
   * @param {boolean} direction
   * @returns {object}
   */
  getReturnValues(direction = true) {
    const {length} = this.slides
    const {visibleSlides, slideBy, loop} = this.options
    const {currentSlide, nextSlide, previousSlide} = this.state
    const progress = this.state.x / this.el.offsetWidth
    const right = progress * -1
    const currentItems = []
    for (let i = 0; i < visibleSlides; i++) {
      currentItems.push(modLoop(currentSlide, i, length))
    }
    // We only need the lower value
    const nextItems = [null]
    const useNext = (currentSlide < modLoop(nextSlide, (visibleSlides - slideBy), length)) || loop
    /* istanbul ignore next */
    const returnNext = (progress > right && direction)
    /* istanbul ignore next */
    if ((useNext && returnNext) || !direction) {
      nextItems.pop()
      for (let i = 0; i < visibleSlides; i++) {
        nextItems.push(modLoop(nextSlide, i, length))
      }
    }
    const previousItems = [null]
    const usePrevious = (currentSlide > previousSlide) || loop
    /* istanbul ignore next */
    const returnPrevious = (progress < right && direction)
    /* istanbul ignore next */
    if ((usePrevious && returnPrevious) || !direction) {
      previousItems.pop()
      for (let i = 0; i < slideBy; i++) {
        previousItems.push(modLoop(previousSlide, i, length))
      }
    }

    const rest = this.slides.map((el, index) => index)
      .filter(originalIndex =>
        [...previousItems, ...currentItems, ...nextItems].filter(x => x !== 0).indexOf(originalIndex) === -1)

    return {
      rest,
      previous: arrayOrValue(previousItems),
      next: arrayOrValue(nextItems),
      current: arrayOrValue(currentItems),
      progress: Math.abs(progress)
    }
  }

  /* istanbul ignore next */
  /**
   * First interaction with the mouse or per touch will be used to set flags and
   * define initial values.
   * @private
   * @param {Event} e Mouse or touch event
   */
  handleDown(e) {
    const clientX = this.getClientX(e)
    /* istanbul ignore next */
    if (this.state.init) {
      this.slides.forEach(slide => {
        slide.classList.remove(this.options.classNames.init)
      })
    }
    // Flag down
    // set start coordinate,
    // set current progress
    this.setState({
      down: true,
      xStart: clientX,
      x: 0,
      init: false
    })
  }

  /* istanbul ignore next */
  /**
   * Last interaction with the mouse or per touch will be used to set flags
   * and define initial values.
   * Only fires if down is active. Prevents unintended behavior when the first
   * touch or mousedown was outside the element.
   * @private
   */
  handleUp() {
    // Only proceed if the plugin signals a previous down event.
    const {down, blocked} = this.state
    if (down && blocked) {
      const {snapBackAt} = this.options
      const progress = this.state.x / this.el.offsetWidth
      let end = 0
      if (progress <= (-1 * snapBackAt)) {
        end = -1
      } else if (progress >= snapBackAt) {
        end = 1
      }
      this.spring(progress, end, this.options.spring)
    }
    this.setState({down: false, blocked: false})
  }

  /* istanbul ignore next */
  /**
   * Handler for mouse or touch movement.
   * Waits for a threshold and then records the movement on the `x` axis
   * @private
   * @param {Event} e Mouse or touch move event
   */
  handleMove(e) {
    if (this.state.down) {
      const {xStart, x, blocked} = this.state
      const {threshold} = this.options
      if ((Math.abs(x) > threshold) || blocked) {
        this.setState({blocked: true})
        e.preventDefault()
        this.handleProgress()
      }
      const clientX = this.getClientX(e)
      this.setState({
        x: xStart - clientX
      })
    }
  }

  /**
   * Handles the progress. Calculates the progress from the
   * internal state and element dimension.
   * A callback is fired if set
   * @private
   */
  handleProgress() {
    const {onSlide, slideBy} = this.options

    if (typeof onSlide === 'function') {
      const {
        progress,
        next,
        previous,
        current,
        rest
      } = this.getReturnValues()
      /**
       * Callback for progression
       * @type {onSlide}
       */
      onSlide(
        progress * slideBy,
        {next, previous, current, rest},
        this.slides,
        this.options
      )
    }
  }

  /**
   * Handle the end of the slide animation.
   * If there is a callback called `onEnd` call it.
   * @private
   * @param {number} end Final value
   */
  handleEnd(end) {
    const {onEnd} = this.options
    if (end === -1) {
      this.setState({
        currentSlide: this.state.previousSlide
      })
    /* istanbul ignore next */
    } else /* istanbul ignore next */ if (end === 1) {
      this.setState({
        currentSlide: this.state.nextSlide
      })
    }
    this.setState({
      requestedNext: null,
      requestedPrevious: null
    })
    this.addSides()
    this.addClassNames()
    global.cancelAnimationFrame(this.animation)

    if (typeof onEnd === 'function') {
      const {
        next,
        previous,
        current,
        rest
      } = this.getReturnValues(false)
      /**
       * Callback for the end
       * @public
       * @type {onEnd}
       */
      onEnd({next, previous, current, rest}, this.slides, this.options)
    }
  }
}

export default Glider