UNPKG

vue-sticky-directive

Version:

A powerful vue directive make element sticky.

299 lines (269 loc) 8.08 kB
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(); } } }, };