headroom.js
Version:
Give your page some headroom. Hide your header until you need it
438 lines (374 loc) • 10.7 kB
JavaScript
/*!
* headroom.js v0.12.0 - Give your page some headroom. Hide your header until you need it
* Copyright (c) 2020 Nick Williams - http://wicky.nillia.ms/headroom.js
* License: MIT
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.Headroom = factory());
}(this, function () { 'use strict';
function isBrowser() {
return typeof window !== "undefined";
}
/**
* Used to detect browser support for adding an event listener with options
* Credit: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
*/
function passiveEventsSupported() {
var supported = false;
try {
var options = {
// eslint-disable-next-line getter-return
get passive() {
supported = true;
}
};
window.addEventListener("test", options, options);
window.removeEventListener("test", options, options);
} catch (err) {
supported = false;
}
return supported;
}
function isSupported() {
return !!(
isBrowser() &&
function() {}.bind &&
"classList" in document.documentElement &&
Object.assign &&
Object.keys &&
requestAnimationFrame
);
}
function isDocument(obj) {
return obj.nodeType === 9; // Node.DOCUMENT_NODE === 9
}
function isWindow(obj) {
// `obj === window` or `obj instanceof Window` is not sufficient,
// as the obj may be the window of an iframe.
return obj && obj.document && isDocument(obj.document);
}
function windowScroller(win) {
var doc = win.document;
var body = doc.body;
var html = doc.documentElement;
return {
/**
* @see http://james.padolsey.com/javascript/get-document-height-cross-browser/
* @return {Number} the scroll height of the document in pixels
*/
scrollHeight: function() {
return Math.max(
body.scrollHeight,
html.scrollHeight,
body.offsetHeight,
html.offsetHeight,
body.clientHeight,
html.clientHeight
);
},
/**
* @see http://andylangton.co.uk/blog/development/get-viewport-size-width-and-height-javascript
* @return {Number} the height of the viewport in pixels
*/
height: function() {
return win.innerHeight || html.clientHeight || body.clientHeight;
},
/**
* Gets the Y scroll position
* @return {Number} pixels the page has scrolled along the Y-axis
*/
scrollY: function() {
if (win.pageYOffset !== undefined) {
return win.pageYOffset;
}
return (html || body.parentNode || body).scrollTop;
}
};
}
function elementScroller(element) {
return {
/**
* @return {Number} the scroll height of the element in pixels
*/
scrollHeight: function() {
return Math.max(
element.scrollHeight,
element.offsetHeight,
element.clientHeight
);
},
/**
* @return {Number} the height of the element in pixels
*/
height: function() {
return Math.max(element.offsetHeight, element.clientHeight);
},
/**
* Gets the Y scroll position
* @return {Number} pixels the element has scrolled along the Y-axis
*/
scrollY: function() {
return element.scrollTop;
}
};
}
function createScroller(element) {
return isWindow(element) ? windowScroller(element) : elementScroller(element);
}
/**
* @param element EventTarget
*/
function trackScroll(element, options, callback) {
var isPassiveSupported = passiveEventsSupported();
var rafId;
var scrolled = false;
var scroller = createScroller(element);
var lastScrollY = scroller.scrollY();
var details = {};
function update() {
var scrollY = Math.round(scroller.scrollY());
var height = scroller.height();
var scrollHeight = scroller.scrollHeight();
// reuse object for less memory churn
details.scrollY = scrollY;
details.lastScrollY = lastScrollY;
details.direction = scrollY > lastScrollY ? "down" : "up";
details.distance = Math.abs(scrollY - lastScrollY);
details.isOutOfBounds = scrollY < 0 || scrollY + height > scrollHeight;
details.top = scrollY <= options.offset[details.direction];
details.bottom = scrollY + height >= scrollHeight;
details.toleranceExceeded =
details.distance > options.tolerance[details.direction];
callback(details);
lastScrollY = scrollY;
scrolled = false;
}
function handleScroll() {
if (!scrolled) {
scrolled = true;
rafId = requestAnimationFrame(update);
}
}
var eventOptions = isPassiveSupported
? { passive: true, capture: false }
: false;
element.addEventListener("scroll", handleScroll, eventOptions);
update();
return {
destroy: function() {
cancelAnimationFrame(rafId);
element.removeEventListener("scroll", handleScroll, eventOptions);
}
};
}
function normalizeUpDown(t) {
return t === Object(t) ? t : { down: t, up: t };
}
/**
* UI enhancement for fixed headers.
* Hides header when scrolling down
* Shows header when scrolling up
* @constructor
* @param {DOMElement} elem the header element
* @param {Object} options options for the widget
*/
function Headroom(elem, options) {
options = options || {};
Object.assign(this, Headroom.options, options);
this.classes = Object.assign({}, Headroom.options.classes, options.classes);
this.elem = elem;
this.tolerance = normalizeUpDown(this.tolerance);
this.offset = normalizeUpDown(this.offset);
this.initialised = false;
this.frozen = false;
}
Headroom.prototype = {
constructor: Headroom,
/**
* Start listening to scrolling
* @public
*/
init: function() {
if (Headroom.cutsTheMustard && !this.initialised) {
this.addClass("initial");
this.initialised = true;
// defer event registration to handle browser
// potentially restoring previous scroll position
setTimeout(
function(self) {
self.scrollTracker = trackScroll(
self.scroller,
{ offset: self.offset, tolerance: self.tolerance },
self.update.bind(self)
);
},
100,
this
);
}
return this;
},
/**
* Destroy the widget, clearing up after itself
* @public
*/
destroy: function() {
this.initialised = false;
Object.keys(this.classes).forEach(this.removeClass, this);
this.scrollTracker.destroy();
},
/**
* Unpin the element
* @public
*/
unpin: function() {
if (this.hasClass("pinned") || !this.hasClass("unpinned")) {
this.addClass("unpinned");
this.removeClass("pinned");
if (this.onUnpin) {
this.onUnpin.call(this);
}
}
},
/**
* Pin the element
* @public
*/
pin: function() {
if (this.hasClass("unpinned")) {
this.addClass("pinned");
this.removeClass("unpinned");
if (this.onPin) {
this.onPin.call(this);
}
}
},
/**
* Freezes the current state of the widget
* @public
*/
freeze: function() {
this.frozen = true;
this.addClass("frozen");
},
/**
* Re-enables the default behaviour of the widget
* @public
*/
unfreeze: function() {
this.frozen = false;
this.removeClass("frozen");
},
top: function() {
if (!this.hasClass("top")) {
this.addClass("top");
this.removeClass("notTop");
if (this.onTop) {
this.onTop.call(this);
}
}
},
notTop: function() {
if (!this.hasClass("notTop")) {
this.addClass("notTop");
this.removeClass("top");
if (this.onNotTop) {
this.onNotTop.call(this);
}
}
},
bottom: function() {
if (!this.hasClass("bottom")) {
this.addClass("bottom");
this.removeClass("notBottom");
if (this.onBottom) {
this.onBottom.call(this);
}
}
},
notBottom: function() {
if (!this.hasClass("notBottom")) {
this.addClass("notBottom");
this.removeClass("bottom");
if (this.onNotBottom) {
this.onNotBottom.call(this);
}
}
},
shouldUnpin: function(details) {
var scrollingDown = details.direction === "down";
return scrollingDown && !details.top && details.toleranceExceeded;
},
shouldPin: function(details) {
var scrollingUp = details.direction === "up";
return (scrollingUp && details.toleranceExceeded) || details.top;
},
addClass: function(className) {
this.elem.classList.add.apply(
this.elem.classList,
this.classes[className].split(" ")
);
},
removeClass: function(className) {
this.elem.classList.remove.apply(
this.elem.classList,
this.classes[className].split(" ")
);
},
hasClass: function(className) {
return this.classes[className].split(" ").every(function(cls) {
return this.classList.contains(cls);
}, this.elem);
},
update: function(details) {
if (details.isOutOfBounds) {
// Ignore bouncy scrolling in OSX
return;
}
if (this.frozen === true) {
return;
}
if (details.top) {
this.top();
} else {
this.notTop();
}
if (details.bottom) {
this.bottom();
} else {
this.notBottom();
}
if (this.shouldUnpin(details)) {
this.unpin();
} else if (this.shouldPin(details)) {
this.pin();
}
}
};
/**
* Default options
* @type {Object}
*/
Headroom.options = {
tolerance: {
up: 0,
down: 0
},
offset: 0,
scroller: isBrowser() ? window : null,
classes: {
frozen: "headroom--frozen",
pinned: "headroom--pinned",
unpinned: "headroom--unpinned",
top: "headroom--top",
notTop: "headroom--not-top",
bottom: "headroom--bottom",
notBottom: "headroom--not-bottom",
initial: "headroom"
}
};
Headroom.cutsTheMustard = isSupported();
return Headroom;
}));