fix-wheel.js

/**
 * Mousewheel fix for certain browsers that just don't get it right
 * Includes:
 *
 * - fix for Safari 9 & 10:
 *   in Safari 9 & 10 the default event is debounced, therefore broken.
 *   The issue seems to occur due to the eleastic scroll and appeared with Safari 9
 *   which allows eleasic scroll in nested containers.
 *   to fix this issue the `html` and/or `body` element usually has to be set to `overflow: hidden;`.
 *   In many cases this comes with drawbacks or breaks other things.
 *   Preventing the default event and then applying the `deltaY` to the `scrollTop`
 *   of the rootNode fixes the issue without other hacks.
 *   - Attemt: prevent the mousewheel event and move the rootNode manually.
 *   - Concern: impact on performance?
 * @see  https://bugs.webkit.org/show_bug.cgi?id=149526
 * @author  Gregor Adams <greg@pixelass.com>
 * @license  MIT
 */

import browser from 'detect-browser'

/**
 * major browser version parsed from `detect-browser.js`'s result
 * @type {Number}
 */
const majorVersion = parseInt(browser.version.split('.')[0], 10)

/**
 * flag to find Safari 9 & 10 since it is known to have a bug
 * @see  https://bugs.webkit.org/show_bug.cgi?id=149526
 * @type {Boolean}
 */
const isSafari = (browser.name === 'safari')
const isSafari9 = (isSafari && majorVersion === 9)
const isSafari10 = (isSafari && majorVersion === 10)

/**
 * flag to determine if we need to modify the `mousewheel` event
 * currently only Safari 9 & 10 gets this wrong.
 * @type {Boolean}
 */
const mouseNeedsHelp = isSafari9 || isSafari10

/**
 * allows to create an instance that can be initialised and destroyed when needed
 * usually initialized on `document.ready` or when mounting a root component
 * and destroyed when a root component is unmounted
 */
class FixWheel {
  constructor (eventName) {
    this.eventName = eventName
    this.fixWheel = this.fixWheel.bind(this)
    this.checkOverflow = this.checkOverflow.bind(this)
  }

  /**
   * this method applys the fix for wheel events in browsers with faulty implementation
   * @param  {HTMLElement} rootNode a DOM node, usually `document.body` but can be any other root element.
   * @return {Boolean} returns `true` when applied, otherwise `false`.
   *                   Mainly used for debugging but can be used as a flag.
    */
  init (rootNode, force = false) {
    if (mouseNeedsHelp || force) {
      this.rootNode = rootNode
      this.rootNode.addEventListener(this.eventName, this.fixWheel, false)
      this.isFixed = true
    }
    return this.isFixed
  }

  /**
   * destroys the helper. removes the fix if it has been applied
   * @return {Boolean} should always return `false`.
   *                   Mainly used for debugging but can be used as a flag.
   */
  destroy () {
    if (this.isFixed) {
      this.rootNode.removeEventListener(this.eventName, this.fixWheel)
      this.isFixed = false
    }
    return this.isFixed
  }

  /**
   * prevent the default event
   * @param  {Object} e mousewheel event
   */
  preventDefault (e) {
    e.preventDefault()
  }

  /**
   * check the overflow. If it is `scroll` or `auto`, check the scrollOffset.
   * If the element is scrollable, apply scrollTop, otherwise check the `parentNode`
   * @param  {HTMLElement} el the starts a the `event.target` then any ancestor of that element
   * @return {HTMLElement}            Returns the original element or the next scrollable ancestor.
   *                                  Returns `null` if no element matched the criteria.
   */
  checkOverflow (el, deltaY) {
    const {clientHeight, scrollHeight, scrollTop} = el
    // get compouted CSS so we can check if the element is scrollable
    const css = window.getComputedStyle(el)
    const scrollable = css.overflowY === 'scroll' || css.overflowY === 'auto'
    if (scrollable) {
      // check if the content is higher than the element
      const overflows = clientHeight < scrollHeight
      if (overflows) {
        // check if the element is fully scrolled top or bottom
        const atEnd = clientHeight + scrollTop >= scrollHeight
        const atStart = scrollTop === 0
        if ((deltaY > 0 && !atEnd) || (deltaY < 0 && !atStart)) {
          return el
        } else if (deltaY === 0) {
          return null
        }
      }
    }
    // check for `parentNode` otherwise proceed and return `null`
    if (el.parentNode) {
      if (el.parentNode === this.rootNode) {
        return this.rootNode
      }
      return this.checkOverflow(el.parentNode, deltaY)
    }
    return null
  }

  /**
   * the actual fix for the issue.
   * @param  {Object} e mousewheel event
   * @param  {Number} e.deltaY wheel delta on the y-axis (if undefined simply does nothing)
   */
  fixWheel (e) {
    this.preventDefault(e)
    const node = this.checkOverflow(e.target, e.deltaY)
    if (node) {
      node.scrollTop += e.deltaY
    }
  }
}

export default FixWheel