vue-sticky-directive
Version:
A powerful vue directive make element sticky.
299 lines (269 loc) • 8.08 kB
JavaScript
const namespace = '@@vue-sticky-directive';
const events = [
'resize',
'scroll',
'touchstart',
'touchmove',
'touchend',
'pageshow',
'load',
];
const batchStyle = (el, style = {}, className = {}) => {
for (let k in style) {
el.style[k] = style[k];
}
for (let k in className) {
if (className[k] && !el.classList.contains(k)) {
el.classList.add(k);
} else if (!className[k] && el.classList.contains(k)) {
el.classList.remove(k);
}
}
};
class Sticky {
constructor(el, vm) {
this.el = el;
this.vm = vm;
this.unsubscribers = [];
this.isPending = false;
this.state = {
isTopSticky: null,
isBottomSticky: null,
height: null,
width: null,
xOffset: null,
};
this.lastState = {
top: null,
bottom: null,
sticked: false,
};
const offset = this.getAttribute('sticky-offset') || {};
const side = this.getAttribute('sticky-side') || 'top';
const zIndex = this.getAttribute('sticky-z-index') || '10';
const onStick = this.getAttribute('on-stick') || null;
this.options = {
topOffset: Number(offset.top) || 0,
bottomOffset: Number(offset.bottom) || 0,
shouldTopSticky: side === 'top' || side === 'both',
shouldBottomSticky: side === 'bottom' || side === 'both',
zIndex: zIndex,
onStick: onStick,
};
}
doBind() {
if (this.unsubscribers.length > 0) {
return;
}
const { el, vm } = this;
vm.$nextTick(() => {
this.placeholderEl = document.createElement('div');
this.containerEl = this.getContainerEl();
el.parentNode.insertBefore(this.placeholderEl, el);
events.forEach(event => {
const fn = this.update.bind(this);
this.unsubscribers.push(() => window.removeEventListener(event, fn));
this.unsubscribers.push(() =>
this.containerEl.removeEventListener(event, fn),
);
window.addEventListener(event, fn, { passive: true });
this.containerEl.addEventListener(event, fn, { passive: true });
});
});
}
doUnbind() {
this.unsubscribers.forEach(fn => fn());
this.unsubscribers = [];
this.resetElement();
}
update() {
if (!this.isPending) {
requestAnimationFrame(() => {
this.isPending = false;
this.recomputeState();
this.updateElements();
});
this.isPending = true;
}
}
isTopSticky() {
if (!this.options.shouldTopSticky) return false;
const fromTop = this.state.placeholderElRect.top;
const fromBottom = this.state.containerElRect.bottom;
const topBreakpoint = this.options.topOffset;
const bottomBreakpoint = this.options.bottomOffset;
return fromTop <= topBreakpoint && fromBottom >= bottomBreakpoint;
}
isBottomSticky() {
if (!this.options.shouldBottomSticky) return false;
const fromBottom =
window.innerHeight - this.state.placeholderElRect.top - this.state.height;
const fromTop = window.innerHeight - this.state.containerElRect.top;
const topBreakpoint = this.options.topOffset;
const bottomBreakpoint = this.options.bottomOffset;
return fromBottom <= bottomBreakpoint && fromTop >= topBreakpoint;
}
recomputeState() {
this.state = Object.assign({}, this.state, {
height: this.getHeight(),
width: this.getWidth(),
xOffset: this.getXOffset(),
placeholderElRect: this.getPlaceholderElRect(),
containerElRect: this.getContainerElRect(),
});
this.state.isTopSticky = this.isTopSticky();
this.state.isBottomSticky = this.isBottomSticky();
}
fireEvents() {
if (
typeof this.options.onStick === 'function' &&
(this.lastState.top !== this.state.isTopSticky ||
this.lastState.bottom !== this.state.isBottomSticky ||
this.lastState.sticked !==
(this.state.isTopSticky || this.state.isBottomSticky))
) {
this.lastState = {
top: this.state.isTopSticky,
bottom: this.state.isBottomSticky,
sticked: this.state.isBottomSticky || this.state.isTopSticky,
};
this.options.onStick(this.lastState);
}
}
updateElements() {
const placeholderStyle = { paddingTop: 0 };
const elStyle = {
position: 'static',
top: 'auto',
bottom: 'auto',
left: 'auto',
width: 'auto',
zIndex: this.options.zIndex,
};
const placeholderClassName = { 'vue-sticky-placeholder': true };
const elClassName = {
'vue-sticky-el': true,
'top-sticky': false,
'bottom-sticky': false,
};
if (this.state.isTopSticky) {
elStyle.position = 'fixed';
elStyle.top = this.options.topOffset + 'px';
elStyle.left = this.state.xOffset + 'px';
elStyle.width = this.state.width + 'px';
const bottomLimit =
this.state.containerElRect.bottom -
this.state.height -
this.options.bottomOffset -
this.options.topOffset;
if (bottomLimit < 0) {
elStyle.top = bottomLimit + this.options.topOffset + 'px';
}
placeholderStyle.paddingTop = this.state.height + 'px';
elClassName['top-sticky'] = true;
} else if (this.state.isBottomSticky) {
elStyle.position = 'fixed';
elStyle.bottom = this.options.bottomOffset + 'px';
elStyle.left = this.state.xOffset + 'px';
elStyle.width = this.state.width + 'px';
const topLimit =
window.innerHeight -
this.state.containerElRect.top -
this.state.height -
this.options.bottomOffset -
this.options.topOffset;
if (topLimit < 0) {
elStyle.bottom = topLimit + this.options.bottomOffset + 'px';
}
placeholderStyle.paddingTop = this.state.height + 'px';
elClassName['bottom-sticky'] = true;
} else {
placeholderStyle.paddingTop = 0;
}
batchStyle(this.el, elStyle, elClassName);
batchStyle(this.placeholderEl, placeholderStyle, placeholderClassName);
this.fireEvents();
}
resetElement() {
['position', 'top', 'bottom', 'left', 'width', 'zIndex'].forEach(attr => {
this.el.style.removeProperty(attr);
});
this.el.classList.remove('bottom-sticky', 'top-sticky');
const { parentNode } = this.placeholderEl;
if (parentNode) {
parentNode.removeChild(this.placeholderEl);
}
}
getContainerEl() {
let node = this.el.parentNode;
while (
node &&
node.tagName !== 'HTML' &&
node.tagName !== 'BODY' &&
node.nodeType === 1
) {
if (node.hasAttribute('sticky-container')) {
return node;
}
node = node.parentNode;
}
return this.el.parentNode;
}
getXOffset() {
return this.placeholderEl.getBoundingClientRect().left;
}
getWidth() {
return this.placeholderEl.getBoundingClientRect().width;
}
getHeight() {
return this.el.getBoundingClientRect().height;
}
getPlaceholderElRect() {
return this.placeholderEl.getBoundingClientRect();
}
getContainerElRect() {
return this.containerEl.getBoundingClientRect();
}
getAttribute(name) {
const expr = this.el.getAttribute(name);
let result = undefined;
if (expr) {
if (this.vm[expr]) {
result = this.vm[expr];
} else {
try {
result = eval(`(${expr})`);
} catch (error) {
result = expr;
}
}
}
return result;
}
}
export default {
inserted(el, bind, vnode) {
if (typeof bind.value === 'undefined' || bind.value) {
el[namespace] = new Sticky(el, vnode.context);
el[namespace].doBind();
}
},
unbind(el, bind, vnode) {
if (el[namespace]) {
el[namespace].doUnbind();
el[namespace] = undefined;
}
},
componentUpdated(el, bind, vnode) {
if (typeof bind.value === 'undefined' || bind.value) {
if (!el[namespace]) {
el[namespace] = new Sticky(el, vnode.context);
}
el[namespace].doBind();
} else {
if (el[namespace]) {
el[namespace].doUnbind();
}
}
},
};