pjax
Version: 
Easily enable fast AJAX navigation on any website (using pushState + XHR)
319 lines (264 loc) • 8.59 kB
JavaScript
var executeScripts = require("./lib/execute-scripts");
var forEachEls = require("./lib/foreach-els");
var parseOptions = require("./lib/parse-options");
var switches = require("./lib/switches");
var newUid = require("./lib/uniqueid");
var on = require("./lib/events/on");
var trigger = require("./lib/events/trigger");
var clone = require("./lib/util/clone");
var contains = require("./lib/util/contains");
var extend = require("./lib/util/extend");
var noop = require("./lib/util/noop");
var Pjax = function(options) {
  this.state = {
    numPendingSwitches: 0,
    href: null,
    options: null
  };
  this.options = parseOptions(options);
  this.log("Pjax options", this.options);
  if (this.options.scrollRestoration && "scrollRestoration" in history) {
    history.scrollRestoration = "manual";
  }
  this.maxUid = this.lastUid = newUid();
  this.parseDOM(document);
  on(
    window,
    "popstate",
    function(st) {
      if (st.state) {
        var opt = clone(this.options);
        opt.url = st.state.url;
        opt.title = st.state.title;
        // Since state already exists, prevent it from being pushed again
        opt.history = false;
        opt.scrollPos = st.state.scrollPos;
        if (st.state.uid < this.lastUid) {
          opt.backward = true;
        } else {
          opt.forward = true;
        }
        this.lastUid = st.state.uid;
        // @todo implement history cache here, based on uid
        this.loadUrl(st.state.url, opt);
      }
    }.bind(this)
  );
};
Pjax.switches = switches;
Pjax.prototype = {
  log: require("./lib/proto/log"),
  getElements: function(el) {
    return el.querySelectorAll(this.options.elements);
  },
  parseDOM: function(el) {
    var parseElement = require("./lib/proto/parse-element");
    forEachEls(this.getElements(el), parseElement, this);
  },
  refresh: function(el) {
    this.parseDOM(el || document);
  },
  reload: function() {
    window.location.reload();
  },
  attachLink: require("./lib/proto/attach-link"),
  attachForm: require("./lib/proto/attach-form"),
  forEachSelectors: function(cb, context, DOMcontext) {
    return require("./lib/foreach-selectors").bind(this)(
      this.options.selectors,
      cb,
      context,
      DOMcontext
    );
  },
  switchSelectors: function(selectors, fromEl, toEl, options) {
    return require("./lib/switches-selectors").bind(this)(
      this.options.switches,
      this.options.switchesOptions,
      selectors,
      fromEl,
      toEl,
      options
    );
  },
  latestChance: function(href) {
    window.location = href;
  },
  onSwitch: function() {
    trigger(window, "resize scroll");
    this.state.numPendingSwitches--;
    // debounce calls, so we only run this once after all switches are finished.
    if (this.state.numPendingSwitches === 0) {
      this.afterAllSwitches();
    }
  },
  loadContent: function(html, options) {
    if (typeof html !== "string") {
      trigger(document, "pjax:complete pjax:error", options);
      return;
    }
    var tmpEl = document.implementation.createHTMLDocument("pjax");
    // parse HTML attributes to copy them
    // since we are forced to use documentElement.innerHTML (outerHTML can't be used for <html>)
    var htmlRegex = /<html[^>]+>/gi;
    var htmlAttribsRegex = /\s?[a-z:]+(?:=['"][^'">]+['"])*/gi;
    var matches = html.match(htmlRegex);
    if (matches && matches.length) {
      matches = matches[0].match(htmlAttribsRegex);
      if (matches.length) {
        matches.shift();
        matches.forEach(function(htmlAttrib) {
          var attr = htmlAttrib.trim().split("=");
          if (attr.length === 1) {
            tmpEl.documentElement.setAttribute(attr[0], true);
          } else {
            tmpEl.documentElement.setAttribute(attr[0], attr[1].slice(1, -1));
          }
        });
      }
    }
    tmpEl.documentElement.innerHTML = html;
    this.log(
      "load content",
      tmpEl.documentElement.attributes,
      tmpEl.documentElement.innerHTML.length
    );
    // Clear out any focused controls before inserting new page contents.
    if (
      document.activeElement &&
      contains(document, this.options.selectors, document.activeElement)
    ) {
      try {
        document.activeElement.blur();
      } catch (e) {} // eslint-disable-line no-empty
    }
    this.switchSelectors(this.options.selectors, tmpEl, document, options);
  },
  abortRequest: require("./lib/abort-request"),
  doRequest: require("./lib/send-request"),
  handleResponse: require("./lib/proto/handle-response"),
  loadUrl: function(href, options) {
    options =
      typeof options === "object"
        ? extend({}, this.options, options)
        : clone(this.options);
    this.log("load href", href, options);
    // Abort any previous request
    this.abortRequest(this.request);
    trigger(document, "pjax:send", options);
    // Do the request
    this.request = this.doRequest(
      href,
      options,
      this.handleResponse.bind(this)
    );
  },
  afterAllSwitches: function() {
    // FF bug: Won’t autofocus fields that are inserted via JS.
    // This behavior is incorrect. So if theres no current focus, autofocus
    // the last field.
    //
    // http://www.w3.org/html/wg/drafts/html/master/forms.html
    var autofocusEl = Array.prototype.slice
      .call(document.querySelectorAll("[autofocus]"))
      .pop();
    if (autofocusEl && document.activeElement !== autofocusEl) {
      autofocusEl.focus();
    }
    // execute scripts when DOM have been completely updated
    this.options.selectors.forEach(function(selector) {
      forEachEls(document.querySelectorAll(selector), function(el) {
        executeScripts(el);
      });
    });
    var state = this.state;
    if (state.options.history) {
      if (!window.history.state) {
        this.lastUid = this.maxUid = newUid();
        window.history.replaceState(
          {
            url: window.location.href,
            title: document.title,
            uid: this.maxUid,
            scrollPos: [0, 0]
          },
          document.title
        );
      }
      // Update browser history
      this.lastUid = this.maxUid = newUid();
      window.history.pushState(
        {
          url: state.href,
          title: state.options.title,
          uid: this.maxUid,
          scrollPos: [0, 0]
        },
        state.options.title,
        state.href
      );
    }
    this.forEachSelectors(function(el) {
      this.parseDOM(el);
    }, this);
    // Fire Events
    trigger(document, "pjax:complete pjax:success", state.options);
    if (typeof state.options.analytics === "function") {
      state.options.analytics();
    }
    if (state.options.history) {
      // First parse url and check for hash to override scroll
      var a = document.createElement("a");
      a.href = this.state.href;
      if (a.hash) {
        var name = a.hash.slice(1);
        name = decodeURIComponent(name);
        var curtop = 0;
        var target =
          document.getElementById(name) || document.getElementsByName(name)[0];
        if (target) {
          // http://stackoverflow.com/questions/8111094/cross-browser-javascript-function-to-find-actual-position-of-an-element-in-page
          if (target.offsetParent) {
            do {
              curtop += target.offsetTop;
              target = target.offsetParent;
            } while (target);
          }
        }
        window.scrollTo(0, curtop);
      } else if (state.options.scrollTo !== false) {
        // Scroll page to top on new page load
        if (state.options.scrollTo.length > 1) {
          window.scrollTo(state.options.scrollTo[0], state.options.scrollTo[1]);
        } else {
          window.scrollTo(0, state.options.scrollTo);
        }
      }
    } else if (state.options.scrollRestoration && state.options.scrollPos) {
      window.scrollTo(state.options.scrollPos[0], state.options.scrollPos[1]);
    }
    this.state = {
      numPendingSwitches: 0,
      href: null,
      options: null
    };
  }
};
Pjax.isSupported = require("./lib/is-supported");
// arguably could do `if( require("./lib/is-supported")()) {` but that might be a little to simple
if (Pjax.isSupported()) {
  module.exports = Pjax;
}
// if there isn’t required browser functions, returning stupid api
else {
  var stupidPjax = noop;
  for (var key in Pjax.prototype) {
    if (
      Pjax.prototype.hasOwnProperty(key) &&
      typeof Pjax.prototype[key] === "function"
    ) {
      stupidPjax[key] = noop;
    }
  }
  module.exports = stupidPjax;
}