jquery-ias-es6
Version: 
A jQuery plugin that turns your server-side pagination into an infinite scrolling one using AJAX
689 lines (573 loc) • 16.4 kB
JavaScript
/**
 * Infinite Ajax Scroll v2.3.1
 * A jQuery plugin for infinite scrolling
 * https://infiniteajaxscroll.com
 *
 * Commercial use requires one-time purchase of a commercial license
 * https://infiniteajaxscroll.com/docs/license.html
 *
 * Non-commercial use is licensed under the MIT License
 *
 * Copyright 2014-2018 Webcreate (Jeroen Fiege)
 */
import IASCallbacks from './callbacks';
export default (($) => {
  'use strict';
  var UNDETERMINED_SCROLLOFFSET = -1;
  var IAS = function($element, options) {
    this.itemsContainerSelector = options.container;
    this.itemSelector = options.item;
    this.nextSelector = options.next;
    this.paginationSelector = options.pagination;
    this.$scrollContainer = $element;
    this.$container = (window === $element.get(0) ? $(document) : $element);
    this.defaultDelay = options.delay;
    this.negativeMargin = options.negativeMargin;
    this.nextUrl = null;
    this.isBound = false;
    this.isPaused = false;
    this.isInitialized = false;
    this.jsXhr = false;
    this.listeners = {
      next:     new IASCallbacks($),
      load:     new IASCallbacks($),
      loaded:   new IASCallbacks($),
      render:   new IASCallbacks($),
      rendered: new IASCallbacks($),
      scroll:   new IASCallbacks($),
      noneLeft: new IASCallbacks($),
      ready:    new IASCallbacks($)
    };
    this.extensions = [];
    /**
     * Scroll event handler
     *
     * Note: calls to this functions should be throttled
     *
     * @private
     */
    this.scrollHandler = function() {
      // the throttle method can call the scrollHandler even thought we have called unbind()
      if (!this.isBound || this.isPaused) {
        return;
      }
      var currentScrollOffset = this.getCurrentScrollOffset(this.$scrollContainer),
          scrollThreshold = this.getScrollThreshold()
      ;
      // invalid scrollThreshold. The DOM might not have loaded yet...
      if (UNDETERMINED_SCROLLOFFSET == scrollThreshold) {
        return;
      }
      this.fire('scroll', [currentScrollOffset, scrollThreshold]);
      if (currentScrollOffset >= scrollThreshold) {
        this.next();
      }
    };
    /**
     * Returns the items container currently in the DOM
     *
     * @private
     * @returns {object}
     */
    this.getItemsContainer = function() {
      return $(this.itemsContainerSelector, this.$container);
    };
    /**
     * Returns the last item currently in the DOM
     *
     * @private
     * @returns {object}
     */
    this.getLastItem = function() {
      return $(this.itemSelector, this.getItemsContainer().get(0)).last();
    };
    /**
     * Returns the first item currently in the DOM
     *
     * @private
     * @returns {object}
     */
    this.getFirstItem = function() {
      return $(this.itemSelector, this.getItemsContainer().get(0)).first();
    };
    /**
     * Returns scroll threshold. This threshold marks the line from where
     * IAS should start loading the next page.
     *
     * @private
     * @param negativeMargin defaults to {this.negativeMargin}
     * @return {number}
     */
    this.getScrollThreshold = function(negativeMargin) {
      var $lastElement;
      negativeMargin = negativeMargin || this.negativeMargin;
      negativeMargin = (negativeMargin >= 0 ? negativeMargin * -1 : negativeMargin);
      $lastElement = this.getLastItem();
      // if the don't have a last element, the DOM might not have been loaded,
      // or the selector is invalid
      if (0 === $lastElement.length) {
        return UNDETERMINED_SCROLLOFFSET;
      }
      return ($lastElement.offset().top + $lastElement.height() + negativeMargin);
    };
    /**
     * Returns current scroll offset for the given scroll container
     *
     * @private
     * @param $container
     * @returns {number}
     */
    this.getCurrentScrollOffset = function($container) {
      var scrollTop = 0,
          containerHeight = $container.height();
      if (window === $container.get(0))  {
        scrollTop = $container.scrollTop();
      } else {
        scrollTop = $container.offset().top;
      }
      // compensate for iPhone
      if (navigator.platform.indexOf("iPhone") != -1 || navigator.platform.indexOf("iPod") != -1) {
        containerHeight += 80;
      }
      return (scrollTop + containerHeight);
    };
    /**
     * Returns the url for the next page
     *
     * @private
     */
    this.getNextUrl = function(container) {
      container = container || this.$container;
      // always take the last matching item
      return $(this.nextSelector, container).last().attr('href');
    };
    /**
     * Loads a page url
     *
     * @param url
     * @param callback
     * @param delay
     * @returns {object}        jsXhr object
     */
    this.load = function(url, callback, delay) {
      var self = this,
          $itemContainer,
          items = [],
          timeStart = +new Date(),
          timeDiff;
      delay = delay || this.defaultDelay;
      var loadEvent = {
        url: url,
        ajaxOptions: {
          dataType: 'html'
        }
      };
      self.fire('load', [loadEvent]);
      function xhrDoneCallback(data) {
        $itemContainer = $(this.itemsContainerSelector, data).eq(0);
        if (0 === $itemContainer.length) {
          $itemContainer = $(data).filter(this.itemsContainerSelector).eq(0);
        }
        if ($itemContainer) {
          $itemContainer.find(this.itemSelector).each(function() {
            items.push(this);
          });
        }
        self.fire('loaded', [data, items]);
        if (callback) {
          timeDiff = +new Date() - timeStart;
          if (timeDiff < delay) {
            setTimeout(function() {
              callback.call(self, data, items);
            }, delay - timeDiff);
          } else {
            callback.call(self, data, items);
          }
        }
      }
      this.jsXhr = $.ajax(loadEvent.url, loadEvent.ajaxOptions)
        .done($.proxy(xhrDoneCallback, self));
      return this.jsXhr;
    };
    /**
     * Renders items
     *
     * @param callback
     * @param items
     */
    this.render = function(items, callback) {
      var self = this,
          $lastItem = this.getLastItem(),
          count = 0;
      var promise = this.fire('render', [items]);
      promise.done(function() {
        $(items).hide(); // at first, hide it so we can fade it in later
        $lastItem.after(items);
        $(items).fadeIn(400, function() {
          // complete callback get fired for each item,
          // only act on the last item
          if (++count < items.length) {
            return;
          }
          self.fire('rendered', [items]);
          if (callback) {
            callback();
          }
        });
      });
      promise.fail(function() {
        if (callback) {
          callback();
        }
      });
    };
    /**
     * Hides the pagination
     */
    this.hidePagination = function() {
      if (this.paginationSelector) {
        $(this.paginationSelector, this.$container).hide();
      }
    };
    /**
     * Restores the pagination
     */
    this.restorePagination = function() {
      if (this.paginationSelector) {
        $(this.paginationSelector, this.$container).show();
      }
    };
    /**
     * Throttles a method
     *
     * Adopted from Ben Alman's jQuery throttle / debounce plugin
     *
     * @param callback
     * @param delay
     * @return {object}
     */
    this.throttle = function(callback, delay) {
      var lastExecutionTime = 0,
          wrapper,
          timerId
      ;
      wrapper = function() {
        var that = this,
            args = arguments,
            diff = +new Date() - lastExecutionTime;
        function execute() {
          lastExecutionTime = +new Date();
          callback.apply(that, args);
        }
        if (!timerId) {
          execute();
        } else {
          clearTimeout(timerId);
        }
        if (diff > delay) {
          execute();
        } else {
          timerId = setTimeout(execute, delay);
        }
      };
      if ($.guid) {
        wrapper.guid = callback.guid = callback.guid || $.guid++;
      }
      return wrapper;
    };
    /**
     * Fires an event with the ability to cancel further processing. This
     * can be achieved by returning false in a listener.
     *
     * @param event
     * @param args
     * @returns {*}
     */
    this.fire = function(event, args) {
      return this.listeners[event].fireWith(this, args);
    };
    /**
     * Pauses the scroll handler
     *
     * Note: internal use only, if you need to pause IAS use `unbind` method.
     *
     * @private
     */
    this.pause = function() {
      this.isPaused = true;
    };
    /**
     * Resumes the scroll handler
     *
     * Note: internal use only, if you need to resume IAS use `bind` method.
     *
     * @private
     */
    this.resume = function() {
      this.isPaused = false;
    };
    return this;
  };
  /**
   * Initialize IAS
   *
   * Note: Should be called when the document is ready
   *
   * @public
   */
  IAS.prototype.initialize = function() {
    if (this.isInitialized) {
      return false;
    }
    var supportsOnScroll = (!!('onscroll' in this.$scrollContainer.get(0))),
        currentScrollOffset = this.getCurrentScrollOffset(this.$scrollContainer),
        scrollThreshold = this.getScrollThreshold();
    // bail out when the browser doesn't support the scroll event
    if (!supportsOnScroll) {
      return false;
    }
    this.hidePagination();
    this.bind();
    this.nextUrl = this.getNextUrl();
    if (!this.nextUrl) {
      this.fire('noneLeft', [this.getLastItem()]);
    }
    // start loading next page if content is shorter than page fold
    if (this.nextUrl && currentScrollOffset >= scrollThreshold) {
      this.next();
      // flag as initialized when rendering is completed
      this.one('rendered', function() {
        this.isInitialized = true;
        this.fire('ready');
      });
    } else {
      this.isInitialized = true;
      this.fire('ready');
    }
    return this;
  };
  /**
   * Reinitializes IAS, for example after an ajax page update
   *
   * @public
   */
  IAS.prototype.reinitialize = function () {
    this.isInitialized = false;
    this.unbind();
    this.initialize();
  };
  /**
   * Binds IAS to DOM events
   *
   * @public
   */
  IAS.prototype.bind = function() {
    if (this.isBound) {
      return;
    }
    this.$scrollContainer.on('scroll', $.proxy(this.throttle(this.scrollHandler, 150), this));
    for (var i = 0, l = this.extensions.length; i < l; i++) {
      this.extensions[i].bind(this);
    }
    this.isBound = true;
    this.resume();
  };
  /**
   * Unbinds IAS to events
   *
   * @public
   */
  IAS.prototype.unbind = function() {
    if (!this.isBound) {
      return;
    }
    this.$scrollContainer.off('scroll', this.scrollHandler);
    // notify extensions about unbinding
    for (var i = 0, l = this.extensions.length; i < l; i++) {
      if (typeof this.extensions[i]['unbind'] != 'undefined') {
        this.extensions[i].unbind(this);
      }
    }
    this.isBound = false;
  };
  /**
   * Destroys IAS instance
   *
   * @public
   */
  IAS.prototype.destroy = function() {
    try {
      this.jsXhr.abort();
    } catch (e) {}
    this.unbind();
    this.$scrollContainer.data('ias', null);
  };
  /**
   * Registers an eventListener
   *
   * Note: chainable
   *
   * @public
   * @returns IAS
   */
  IAS.prototype.on = function(event, callback, priority) {
    if (typeof this.listeners[event] == 'undefined') {
      throw new Error('There is no event called "' + event + '"');
    }
    priority = priority || 0;
    this.listeners[event].add($.proxy(callback, this), priority);
    // ready is already fired, before on() could even be called, so
    // let's call the callback right away
    if (this.isInitialized) {
      if (event === 'ready') {
        $.proxy(callback, this)();
      }
      // same applies to noneLeft
      else if (event === 'noneLeft' && !this.nextUrl) {
        $.proxy(callback, this)();
      }
    }
    return this;
  };
  /**
   * Registers an eventListener which only gets
   * fired once.
   *
   * Note: chainable
   *
   * @public
   * @returns IAS
   */
  IAS.prototype.one = function(event, callback) {
    var self = this;
    var remover = function() {
      self.off(event, callback);
      self.off(event, remover);
    };
    this.on(event, callback);
    this.on(event, remover);
    return this;
  };
  /**
   * Removes an eventListener
   *
   * Note: chainable
   *
   * @public
   * @returns IAS
   */
  IAS.prototype.off = function(event, callback) {
    if (typeof this.listeners[event] == 'undefined') {
      throw new Error('There is no event called "' + event + '"');
    }
    this.listeners[event].remove(callback);
    return this;
  };
  /**
   * Load the next page
   *
   * @public
   */
  IAS.prototype.next = function() {
    var url = this.nextUrl,
        self = this;
    if (!url) {
      return false;
    }
    this.pause();
    var promise = this.fire('next', [url]);
    promise.done(function() {
      self.load(url, function(data, items) {
        self.render(items, function() {
          self.nextUrl = self.getNextUrl(data);
          if (!self.nextUrl) {
            self.fire('noneLeft', [self.getLastItem()]);
          }
          self.resume();
        });
      });
    });
    promise.fail(function() {
      self.resume();
    });
    return true;
  };
  /**
   * Adds an extension
   *
   * @public
   */
  IAS.prototype.extension = function(extension) {
    if (typeof extension['bind'] == 'undefined') {
      throw new Error('Extension doesn\'t have required method "bind"');
    }
    if (typeof extension['initialize'] != 'undefined') {
      extension.initialize(this);
    }
    this.extensions.push(extension);
    if (this.isBound) {
      this.reinitialize();
    }
    return this;
  };
  /**
   * Shortcut. Sets the window as scroll container.
   *
   * @public
   * @param option
   * @returns {*}
   */
  $.ias = function(option) {
    var $window = $(window);
    return $window.ias.apply($window, arguments);
  };
  /**
   * jQuery plugin initialization
   *
   * @public
   * @param option
   * @returns {*} the last IAS instance will be returned
   */
  $.fn.ias = function(option) {
    var args = Array.prototype.slice.call(arguments);
    var retval = this;
    this.each(function() {
      var $this = $(this),
          instance = $this.data('ias'),
          options = $.extend({}, $.fn.ias.defaults, $this.data(), typeof option == 'object' && option)
          ;
      // set a new instance as data
      if (!instance) {
        $this.data('ias', (instance = new IAS($this, options)));
        if (options.initialize) {
          $(document).ready($.proxy(instance.initialize, instance));
        }
      }
      // when the plugin is called with a method
      if (typeof option === 'string') {
        if (typeof instance[option] !== 'function') {
          throw new Error('There is no method called "' + option + '"');
        }
        args.shift(); // remove first argument ('option')
        instance[option].apply(instance, args);
      }
      retval = instance;
    });
    return retval;
  };
  /**
   * Plugin defaults
   *
   * @public
   * @type {object}
   */
  $.fn.ias.defaults = {
    item: '.item',
    container: '.listing',
    next: '.next',
    pagination: false,
    delay: 600,
    negativeMargin: 10,
    initialize: true
  };
})(jQuery);