sticky-state
Version:
StickyState is a high performant module making native position:sticky statefull and polyfill the missing sticky browser feature
598 lines (499 loc) • 17.1 kB
JavaScript
import assign from 'object-assign';
import classString from 'classstring';
import EventDispatcher from 'eventdispatcher';
import ScrollFeatures from 'scrollfeatures';
const defaults = {
disabled: false,
className: 'sticky',
stateClassName: 'is-sticky',
fixedClass: 'sticky-fixed',
wrapperClass: 'sticky-wrap',
wrapFixedSticky: true,
absoluteClass: 'is-absolute',
scrollClass: {
down: null,
up: null,
none: null,
persist: false,
active: false
}
};
const initialState = {
sticky: false,
absolute: false,
fixedOffset: '',
offsetHeight: 0,
bounds: {
top: null,
left: null,
right: null,
bottom: null,
height: null,
width: null
},
restrict: {
top: null,
left: null,
right: null,
bottom: null,
height: null,
width: null
},
wrapperStyle: null,
elementStyle: null,
initialStyle: null,
style: {
top: null,
bottom: null,
left: null,
right: null,
'margin-top': 0,
'margin-bottom': 0,
'margin-left': 0,
'margin-right': 0
},
disabled: false
};
const getAbsolutBoundingRect = (el, fixedHeight) => {
var rect = el.getBoundingClientRect();
var top = rect.top + ScrollFeatures.windowY;
var height = fixedHeight || rect.height;
return {
top: top,
bottom: top + height,
height: height,
width: rect.width,
left: rect.left,
right: rect.right
};
};
const addBounds = (rect1, rect2) => {
var rect = assign({}, rect1);
rect.top -= rect2.top;
rect.left -= rect2.left;
rect.right = rect.left + rect1.width;
rect.bottom = rect.top + rect1.height;
return rect;
};
const getPositionStyle = el => {
var result = {};
var style = window.getComputedStyle(el, null);
for (var key in initialState.style) {
var value = parseInt(style.getPropertyValue(key));
value = isNaN(value) ? null : value;
result[key] = value;
}
return result;
};
const getPreviousElementSibling = el => {
var prev = el.previousElementSibling;
if (prev && prev.tagName.toLocaleLowerCase() === 'script') {
prev = getPreviousElementSibling(prev);
}
return prev;
};
class StickyState extends EventDispatcher {
el = null;
firstRender = true;
scroll = null;
wrapper = null;
options = null;
constructor(element, options) {
var elements;
if (element instanceof window.NodeList) {
elements = [].slice.call(element, 1);
element = element[0];
}
super({target: element});
this.el = element;
if (options && options.scrollClass) {
options.scrollClass = assign({}, defaults.scrollClass, options.scrollClass, { active: true });
}
this.options = assign({}, defaults, options);
this.setState(assign({}, initialState, { disabled: this.options.disabled }), true);
this.scrollTarget = ScrollFeatures.getScrollParent(this.el);
this.hasOwnScrollTarget = this.scrollTarget !== window;
if (this.hasOwnScrollTarget) {
this.updateFixedOffset = this.updateFixedOffset.bind(this);
}
this.render = this.render.bind(this);
this.addSrollHandler();
this.addResizeHandler();
this.render();
if (elements && elements.length) {
var collection = StickyState.apply(elements, options);
collection.push(this);
return collection;
}
}
static apply(elements, options) {
if (elements && elements.length) {
var arr = new StickyStateCollection();
for (var i = 0; i < elements.length; i++) {
arr.push(new StickyState(elements[i], options));
}
return arr;
}
return new StickyState(elements, options);
}
static get native() {
return Can.sticky;
}
setState(newState, silent) {
this.lastState = this.state || newState;
this.state = assign({}, this.state, newState);
if (silent !== true) {
this.render();
this.trigger(this.state.sticky ? 'sticky:on' : 'sticky:off');
}
}
disable(value) {
this.setState({disabled:value}, value);
}
getBoundingClientRect() {
return this.el.getBoundingClientRect();
}
getBounds(noCache) {
var clientRect = this.getBoundingClientRect();
var offsetHeight = ScrollFeatures.documentHeight;
noCache = noCache === true;
if (noCache !== true && this.state.bounds.height !== null) {
if (this.state.offsetHeight === offsetHeight && clientRect.height === this.state.bounds.height) {
return {
offsetHeight: offsetHeight,
style: this.state.style,
bounds: this.state.bounds,
restrict: this.state.restrict
};
}
}
// var style = noCache ? this.state.style : getPositionStyle(this.el);
var initialStyle = this.state.initialStyle;
if (!initialStyle) {
initialStyle = getPositionStyle(this.el);
}
var style = initialStyle;
var child = this.wrapper || this.el;
var rect;
var restrict;
var offsetY = 0;
var offsetX = 0;
if (!Can.sticky) {
rect = getAbsolutBoundingRect(child, clientRect.height);
if (this.hasOwnScrollTarget) {
var parentRect = getAbsolutBoundingRect(this.scrollTarget);
offsetY = this.scroll.y;
rect = addBounds(rect, parentRect);
restrict = parentRect;
restrict.top = 0;
restrict.height = this.scroll.scrollHeight || restrict.height;
restrict.bottom = restrict.height;
}
} else {
var elem = getPreviousElementSibling(child);
offsetY = 0;
if (elem) {
offsetY = parseInt(window.getComputedStyle(elem)['margin-bottom']);
offsetY = offsetY || 0;
rect = getAbsolutBoundingRect(elem);
if (this.hasOwnScrollTarget) {
rect = addBounds(rect, getAbsolutBoundingRect(this.scrollTarget));
offsetY += this.scroll.y;
}
rect.top = rect.bottom + offsetY;
} else {
elem = child.parentNode;
offsetY = parseInt(window.getComputedStyle(elem)['padding-top']);
offsetY = offsetY || 0;
rect = getAbsolutBoundingRect(elem);
if (this.hasOwnScrollTarget) {
rect = addBounds(rect, getAbsolutBoundingRect(this.scrollTarget));
offsetY += this.scroll.y;
}
rect.top = rect.top + offsetY;
}
if (this.hasOwnScrollTarget) {
restrict = getAbsolutBoundingRect(this.scrollTarget);
restrict.top = 0;
restrict.height = this.scroll.scrollHeight || restrict.height;
restrict.bottom = restrict.height;
}
rect.height = child.clientHeight;
rect.width = child.clientWidth;
rect.bottom = rect.top + rect.height;
}
restrict = restrict || getAbsolutBoundingRect(child.parentNode);
return {
offsetHeight: offsetHeight,
style: style,
bounds: rect,
initialStyle: initialStyle,
restrict: restrict
};
}
updateBounds(silent, noCache) {
silent = silent === true;
noCache = noCache === true;
this.setState(this.getBounds(noCache), silent);
}
updateFixedOffset() {
this.lastState.fixedOffset = this.state.fixedOffset;
if (this.state.sticky) {
this.state.fixedOffset = (this.scrollTarget.getBoundingClientRect().top) + 'px;';
} else {
this.state.fixedOffset = '';
}
if (this.lastState.fixedOffset !== this.state.fixedOffset) {
this.render();
}
}
addSrollHandler() {
if (!this.scroll) {
var hasScrollTarget = ScrollFeatures.hasInstance(this.scrollTarget);
this.scroll = ScrollFeatures.getInstance(this.scrollTarget);
this.onScroll = this.onScroll.bind(this);
this.scroll.on('scroll:start', this.onScroll);
this.scroll.on('scroll:progress', this.onScroll);
this.scroll.on('scroll:stop', this.onScroll);
if (this.options.scrollClass.active) {
this.onScrollDirection = this.onScrollDirection.bind(this);
this.scroll.on('scroll:up', this.onScrollDirection);
this.scroll.on('scroll:down', this.onScrollDirection);
if (!this.options.scrollClass.persist) {
this.scroll.on('scroll:stop', this.onScrollDirection);
}
}
if (hasScrollTarget && this.scroll.scrollY > 0) {
this.scroll.trigger('scroll:progress');
}
}
}
removeSrollHandler() {
if (this.scroll) {
this.scroll.off('scroll:start', this.onScroll);
this.scroll.off('scroll:progress', this.onScroll);
this.scroll.off('scroll:stop', this.onScroll);
if (this.options.scrollClass.active) {
this.scroll.off('scroll:up', this.onScrollDirection);
this.scroll.off('scroll:down', this.onScrollDirection);
this.scroll.off('scroll:stop', this.onScrollDirection);
}
if(!this.scroll.hasListeners()){
this.scroll.destroy();
}
this.onScroll = null;
this.onScrollDirection = null;
this.scroll = null;
}
}
addResizeHandler() {
if (!this.onResize) {
this.onResize = this.update.bind(this);
window.addEventListener('sticky:update', this.onResize, false);
window.addEventListener('resize', this.onResize, false);
window.addEventListener('orientationchange', this.onResize, false);
}
}
removeResizeHandler() {
if (this.onResize) {
window.removeEventListener('sticky:update', this.onResize);
window.removeEventListener('resize', this.onResize);
window.removeEventListener('orientationchange', this.onResize);
this.onResize = null;
}
}
destroy() {
super.destroy();
this.removeSrollHandler();
this.removeResizeHandler();
this.render = null;
this.el = null;
this.state = null;
this.wrapper = null;
}
getScrollClasses(obj) {
if (this.options.scrollClass.active) {
obj = obj || {};
var direction = (this.scroll.y <= 0 || this.scroll.y + this.scroll.clientHeight >= this.scroll.scrollHeight) ? 0 : this.scroll.directionY;
obj[this.options.scrollClass.up] = direction < 0;
obj[this.options.scrollClass.down] = direction > 0;
}
return obj;
}
onScrollDirection(e) {
if (this.state.sticky || e.type === ScrollFeatures.events.SCROLL_STOP) {
this.el.className = classString(this.el.className, this.getScrollClasses());
}
}
onScroll(e) {
this.updateStickyState(false);
if (this.hasOwnScrollTarget && !Can.sticky) {
this.updateFixedOffset();
if (this.state.sticky && !this.hasWindowScrollListener) {
this.hasWindowScrollListener = true;
ScrollFeatures.getInstance(window).on('scroll:progress', this.updateFixedOffset);
} else if (!this.state.sticky && this.hasWindowScrollListener) {
this.hasWindowScrollListener = false;
ScrollFeatures.getInstance(window).off('scroll:progress', this.updateFixedOffset);
}
}
}
update() {
this.scroll.updateScrollPosition();
this.updateBounds(true, true);
this.updateStickyState(false);
}
getStickyState() {
if (this.state.disabled) {
return { sticky: false, absolute: false };
}
var scrollY = this.scroll.y;
var scrollX = this.scroll.x;
var top = this.state.style.top;
var bottom = this.state.style.bottom;
// var left = this.state.style.left;
// var right = this.state.style.right;
var sticky = this.state.sticky;
var absolute = this.state.absolute;
if (top !== null) {
var offsetBottom = this.state.restrict.bottom - this.state.bounds.height - top;
top = this.state.bounds.top - top;
if (this.state.sticky === false && ((scrollY >= top && scrollY <= offsetBottom) || (top <= 0 && scrollY < top))) {
sticky = true;
absolute = false;
} else if (this.state.sticky && (top > 0 && scrollY < top || scrollY > offsetBottom)) {
sticky = false;
absolute = scrollY > offsetBottom;
}
} else if (bottom !== null) {
scrollY += window.innerHeight;
var offsetTop = this.state.restrict.top + this.state.bounds.height - bottom;
bottom = this.state.bounds.bottom + bottom;
if (this.state.sticky === false && scrollY <= bottom && scrollY >= offsetTop) {
sticky = true;
absolute = false;
} else if (this.state.sticky && (scrollY > bottom || scrollY < offsetTop)) {
sticky = false;
absolute = scrollY <= offsetTop;
}
}
return { sticky, absolute };
}
updateStickyState(silent) {
var values = this.getStickyState();
if (values.sticky !== this.state.sticky || values.absolute !== this.state.absolute) {
silent = silent === true;
values = assign(values, this.getBounds());
this.setState(values, silent);
}
}
render() {
if (this.state.disabled) {
return;
}
var classNameObj = {};
if (this.firstRender) {
this.firstRender = false;
if (!Can.sticky) {
this.wrapper = document.createElement('div');
this.wrapper.className = this.options.wrapperClass;
var parent = this.el.parentNode;
if (parent) {
parent.insertBefore(this.wrapper, this.el);
}
if(this.options.wrapFixedSticky){
this.wrapper.appendChild(this.el);
}
classNameObj[this.options.fixedClass] = true;
}
this.updateBounds(true, true);
this.updateStickyState(true);
}
if (!Can.sticky) {
var elementStyle = '';
var height = (this.state.disabled || this.state.bounds.height === null || (!this.state.sticky && !this.state.absolute)) ? 'auto;' : this.state.bounds.height + 'px;';
var wrapperStyle = 'height:' + height;
wrapperStyle += (height === 'auto;') ? '' : (this.state.style['margin-top'] ? 'margin-top:' + this.state.style['margin-top'] + 'px;' : '0;') + (this.state.style['margin-bottom'] ? 'margin-bottom' + this.state.style['margin-bottom'] + 'px;' : '0;');
if (this.state.absolute !== this.lastState.absolute) {
wrapperStyle += this.state.absolute ? 'position:relative;' : '';
classNameObj[this.options.absoluteClass] = this.state.absolute;
elementStyle += this.state.absolute ? ((this.state.style.top !== null) ? ('margin-top:' + (this.state.restrict.height - (this.state.bounds.height + this.state.style.top) + (this.state.restrict.top - this.state.bounds.top)) + 'px;') : 'margin-top:0;' + ((this.state.style.bottom !== null) ? ('margin-bottom:' + (this.state.restrict.height - (this.state.bounds.height + this.state.style.bottom) + (this.state.restrict.bottom - this.state.bounds.bottom)) + 'px;') : 'margin-bottom:0;')) : 'margin-bottom:0;margin-top:0;';
}
if ((this.state.style.top !== null || this.state.style.bottom !== null) && (this.hasOwnScrollTarget && !this.state.absolute && this.lastState.fixedOffset !== this.state.fixedOffset)) {
elementStyle += 'margin-top:' + (this.state.fixedOffset ? this.state.fixedOffset : '0;');
}
if (this.state.wrapperStyle !== wrapperStyle) {
this.state.wrapperStyle = wrapperStyle;
this.wrapper.style.cssText += wrapperStyle;
}
if (this.state.elementStyle !== elementStyle) {
this.state.elementStyle = elementStyle;
this.el.style.cssText += elementStyle;
}
}
classNameObj[this.options.stateClassName] = this.state.sticky;
classNameObj = this.getScrollClasses(classNameObj);
var className = classString(this.el.className, classNameObj);
if (this.el.className !== className) {
this.el.className = className;
}
return this.el;
}
}
var _canSticky = null;
class Can {
static get sticky() {
if (_canSticky !== null) {
return _canSticky;
}
if (typeof window !== 'undefined') {
if (window.Modernizr && window.Modernizr.hasOwnProperty('csspositionsticky')) {
return _canSticky = window.Modernizr.csspositionsticky;
}
var documentFragment = document.documentElement;
var testEl = document.createElement('div');
documentFragment.appendChild(testEl);
var prefixedSticky = ['sticky', '-webkit-sticky'];
_canSticky = false;
for (var i = 0; i < prefixedSticky.length; i++) {
testEl.style.position = prefixedSticky[i];
_canSticky = !!window.getComputedStyle(testEl).position.match('sticky');
if (_canSticky) {
break;
}
}
documentFragment.removeChild(testEl);
}
return _canSticky;
};
}
class StickyStateCollection extends EventDispatcher {
constructor() {
super();
this.items = [];
}
push(item) {
this.items.push(item);
}
update() {
var i = -1;
while (++i < this.items.length) {
this.items[i].update();
}
}
addListener(event, listener) {
var i = -1;
while (++i < this.items.length) {
this.items[i].addListener(event, listener);
}
return this;
}
removeListener(event, listener) {
var i = -1;
while (++i < this.items.length) {
this.items[i].removeListener(event, listener);
}
return this;
}
}
export default StickyState;