import GlueStick from '@informatix8/glue-stick';
import merge from 'lodash.merge';
class GlueStack {
/**
@class GlueStack
@summary Sticks a stack of headers inside the viewport instead of scrolling out of view.
@param {Object} options - Supplied configuration
@param {HTMLElement} options.mainContainer - Container which contains the headers as direct children
@param {String[]|HTMLElement[]} options.hierarchySelectors - Array of selectors for getting headers hierarchy **Required**
@param {Number} options.zIndex - zIndex of the lowest header element. **Optional**
@param {GlueStick} options.seniorSticky - Collapsible sticky element which appears while scrolling up. **Optional**
@param {Number} [options.stopStickingMaxWidth=600] Maximum responsive pixel width where the sticky stops sticking
@param {Object} options.glueStickOpts - additional params to pass to glueStick instances **Optional**
@param {Object} [options.callbacks] - User supplied functions to execute at given stages of the component lifecycle
@param {Function} options.callbacks.preCreate
@param {Function} options.callbacks.postCreate
@param {Function} options.callbacks.preDestroy
@param {Function} options.callbacks.postDestroy
*/
constructor(options) {
if (options === undefined) {
options = {};
}
const defaults = {};
defaults.stopStickingMaxWidth = options.stopStickingMaxWidth || 600;
defaults.hierarchySelectors = [];
defaults.mainContainer = document.body;
defaults.glueStickOpts = {};
merge(this, defaults, options);
this.hierarchy = this.hierarchySelectors.map((selector, idx) => ({
level: idx,
selector: selector,
glueStick: null,
lastAboveViewPort: null,
topSum: 0
}));
this.callCustom('preCreate');
this.lastScrollY = window.scrollY;
this.scrollLength = 0;
if (this.seniorSticky) {
this.seniorSticky.top = -window.scrollY;
this.seniorSticky.positionCalculations = [
function () {
const opts = {
top: this.top
};
if (this.prevOpts && this.prevOpts.top === opts.top && this.prevOpts.disable === opts.disable) {
return;
}
merge(this.prevOpts, {}, opts);
this.glued.update(opts);
}
];
}
const requestAnimFrame = (function () {
return window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
function (callback) {
window.setTimeout(callback, 1000 / 60);
};
}());
const animloop = () => {
if (this.destroyed) {
return;
}
requestAnimFrame(animloop);
this.calculateTops();
};
const start = () => {
// animloop();
this.scrollFn = this.onScroll.bind(this);
window.addEventListener('scroll', this.scrollFn);
this.calculateTops();
};
if (document.readyState === 'complete') {
start();
} else {
this.startFn = start;
window.addEventListener('load', start); // DO NOT INITIATE BEFORE window.onload !!!
}
const destroyFn = this.destroy.bind(this);
window.addEventListener('unload', destroyFn);
this.callCustom('postCreate');
}
/**
* @method destroy
* @memberOf GlueStack
* @instance
* @summary Destroy stack behavior
* @public
*/
destroy() {
this.callCustom('preDestroy');
this.destroyed = true;
this.hierarchy.forEach(hierarchyMember => {
if (hierarchyMember.glueStick) {
hierarchyMember.glueStick.destroy();
hierarchyMember.glueStick = null;
}
});
if (this.scrollFn) {
window.removeEventListener('scroll', this.scrollFn);
}
if (this.startFn) {
window.removeEventListener('load', this.startFn);
}
this.callCustom('postDestroy');
}
/**
* @method onScroll
* @memberOf GlueStack
* @instance
* @summary Handles scrollDown
* @private
*/
onScroll() {
if (this.seniorSticky) {
const bbox = this.seniorSticky.subject.getBoundingClientRect();
const scrollLength = window.scrollY - this.lastScrollY;
if (scrollLength < 0) { // Scroll up
if (this.scrollLength > bbox.height) {
this.scrollLength = bbox.height;
}
} else {
if (this.scrollLength < 0) {
this.scrollLength = 0;
}
}
this.scrollLength += scrollLength;
this.seniorSticky.top = -this.scrollLength;
if (this.seniorSticky.top < -bbox.height) {
this.seniorSticky.top = -bbox.height;
}
if (this.seniorSticky.top > 0) {
this.seniorSticky.top = 0;
}
}
this.lastScrollY = window.scrollY;
this.calculateTops();
}
/**
* @method cleanHierarchy
* @memberOf GlueStack
* @instance
* @summary Cleans hierarchy before calculation
* @private
*/
cleanHierarchy() {
this.hierarchy.forEach(hierarchyMember => {
hierarchyMember.lastAboveViewPort = null;
hierarchyMember.topSum = 0;
});
}
/**
* @method computeHeight
* @memberOf GlueStack
* @static
* @summary Calculates the height of the node excluding collapsing margins
* @private
*/
static computeHeight(node) {
let computedStyle = getComputedStyle(node);
let height = node.offsetHeight + parseInt(computedStyle.marginTop) + parseInt(computedStyle.marginBottom);
if (node.previousElementSibling) { // Fix collapsible margins
const prevComputedStyle = getComputedStyle(node.previousElementSibling);
if (prevComputedStyle.marginBottom !== '0') {
const prevMargin = parseInt(prevComputedStyle.marginBottom);
const currentMargin = parseInt(computedStyle.marginTop);
if (prevMargin > currentMargin) {
height -= currentMargin;
} else {
height -= prevMargin;
}
}
}
return height;
}
/**
* @method calculateHierarchyOffsets
* @memberOf GlueStack
* @instance
* @summary Calculate the offset of the header in case the next header in the same level is colliding
* @private
*/
calculateHierarchyOffsets(scrollY) {
let seniorStickyOffset = this.seniorSticky ? this.seniorSticky.subject.getBoundingClientRect().bottom : 0;
this.hierarchy.forEach(hierarchyMember => {
const seniors = this.hierarchy.filter(hierarchyMember2 => hierarchyMember2.level <= hierarchyMember.level);
const hierarchySelectorsQuery = seniors.map(hierarchyMember2 => hierarchyMember2.selector).join(',');
let nextHierarchyElement;
if (hierarchyMember.lastAboveViewPort) {
let nextElementSibling = hierarchyMember.lastAboveViewPort.nextElementSibling;
while (nextElementSibling) {
if (nextElementSibling.matches(hierarchySelectorsQuery)) {
nextHierarchyElement = nextElementSibling;
break;
}
nextElementSibling = nextElementSibling.nextElementSibling;
}
}
if (nextHierarchyElement) {
const bottom = hierarchyMember.topSum + hierarchyMember.lastAboveViewPort.offsetHeight;
const nextTopFromViewport = nextHierarchyElement.getBoundingClientRect().top;
if (nextTopFromViewport <= bottom) {
hierarchyMember.topSum -= (bottom - nextTopFromViewport);
}
}
hierarchyMember.topSum += seniorStickyOffset;
});
}
/**
* @method calculateTops
* @memberOf GlueStack
* @instance
* @summary Calculate which elements should be sticky and calculates the top position of each level
* @private
*/
calculateTops() {
const scrollY = window.scrollY;
let seniorStickyOffset = this.seniorSticky ? this.seniorSticky.subject.getBoundingClientRect().bottom : 0;
this.cleanHierarchy();
let sumOfHeights = 0;
for (let chNo = 0; chNo < this.mainContainer.children.length; chNo++) {
const node = this.mainContainer.children[chNo];
// if (node.classList.contains('sticky')) {
if (node.classList.contains('sticky-spacer')) {
continue;
}
node.absoluteTop = sumOfHeights;
let level = 0;
for (; level < this.hierarchy.length; level++) {
if (node.matches(this.hierarchy[level].selector)) {
break;
}
}
if (level < this.hierarchy.length) {
let topSum = 0;
for (let i = 0; i < level; i++) {
if (this.hierarchy[i].lastAboveViewPort) {
topSum += this.hierarchy[i].lastAboveViewPort.offsetHeight;
}
}
if (node.absoluteTop - scrollY <= topSum + seniorStickyOffset) {
this.hierarchy[level].lastAboveViewPort = node;
this.hierarchy[level].topSum = topSum;
for (let i = level + 1; i < this.hierarchy.length; i++) {
this.hierarchy[i].lastAboveViewPort = null;
}
}
}
let height = GlueStack.computeHeight(node);
sumOfHeights += height;
}
this.calculateHierarchyOffsets(scrollY);
this.hierarchy.forEach(hierarchyMember => {
if (!hierarchyMember.lastAboveViewPort) {
if (hierarchyMember.glueStick) {
hierarchyMember.glueStick.destroy();
hierarchyMember.glueStick = null;
}
return;
}
if (hierarchyMember.glueStick) {
const glueStickTop = hierarchyMember.glueStick.top;
const changed = hierarchyMember.glueStick.subject !== hierarchyMember.lastAboveViewPort || glueStickTop !== hierarchyMember.topSum;
if (!changed) {
return;
}
hierarchyMember.glueStick.destroy();
}
let zIndex;
if (this.zIndex) {
zIndex = this.zIndex + this.hierarchy.length - 1 - hierarchyMember.level;
}
const glueStickOpts = {};
merge(glueStickOpts, this.glueStickOpts, {
subject: hierarchyMember.lastAboveViewPort,
stopStickingMaxWidth: this.stopStickingMaxWidth,
top: hierarchyMember.topSum,
zIndex: zIndex,
positionCalculations: [
function () {
const opts = {
top: this.top
};
if (this.prevOpts && this.prevOpts.top === opts.top && this.prevOpts.disable === opts.disable) {
return;
}
merge(this.prevOpts, {}, opts);
this.glued.update(opts);
}
]
});
hierarchyMember.glueStick = new GlueStick(glueStickOpts);
});
}
/**
* @method callCustom
* @memberOf GlueStick
* @instance
* @summary execute an implementation defined callback on a certain action
* @private
*/
callCustom(userFn) {
const sliced = Array.prototype.slice.call(arguments, 1);
if (this.callbacks !== undefined && this.callbacks[userFn] !== undefined && typeof this.callbacks[userFn] === 'function') {
this.callbacks[userFn].apply(this, sliced);
}
}
}
export default GlueStack;