bootstrap-vue
Version:
Bootstrap 4 Components for Vue.js 2
566 lines (499 loc) • 16.8 kB
JavaScript
const inBrowser = typeof window !== 'undefined';
const isServer = !inBrowser;
/*
* Pollyfill for Element.closest() for IE :(
* https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill
*/
if (inBrowser && window.Element && !Element.prototype.closest) {
Element.prototype.closest = function (s) {
const matches = (this.document || this.ownerDocument).querySelectorAll(s);
let el = this;
let i;
do {
i = matches.length;
// eslint-disable-next-line no-empty
while (--i >= 0 && matches.item(i) !== el) {
}
} while ((i < 0) && (el = el.parentElement));
return el;
};
}
/*
* Constants / Defaults
*/
const NAME = 'v-b-scrollspy';
const EVENT = 'scrollspy::activate';
const BVSS = '__BV_ScrollSpy__';
const Default = {
element: 'body',
offset: 10,
method: 'auto',
throttle: 200
};
const DefaultType = {
element: '(string|element)',
offset: 'number',
method: 'string',
throttle: 'number'
};
const ClassName = {
DROPDOWN_ITEM: 'dropdown-item',
DROPDOWN_MENU: 'dropdown-menu',
DROPDOWN_TOGGLE: 'dropdown-toggle',
NAV_LINK: 'nav-link',
LIST_ITEM: 'list-group-item',
ACTIVE: 'active'
};
const Selector = {
ACTIVE: '.active',
NAV_LIST_GROUP: '.nav, .list-group',
NAV: '.nav',
LIST_GROUP: '.list-group',
NAV_LINKS: '.nav-link',
LIST_ITEMS: '.list-group-item',
DROPDOWN: '.dropdown',
DROPDOWN_ITEMS: '.dropdown-item',
DROPDOWN_TOGGLE: '.dropdown-toggle'
};
const OffsetMethod = {
OFFSET: 'offset',
POSITION: 'position'
};
/*
* DOM Utility Methods
*/
function isElement(obj) {
return obj.nodeType;
}
// Wrapper for Element.closest to emulate jQuery's closest (sorta)
function closest(element, selector) {
const el = element.closest(selector);
return el === element ? null : el;
}
// Query Selector All wrapper
function $QSA(selector, element) {
if (!element) {
element = document;
}
if (!isElement(element)) {
return [];
}
return Array.prototype.slice.call(element.querySelectorAll(selector));
}
// Query Selector wrapper
function $QS(selector, element) {
if (!element) {
element = document;
}
if (!isElement(element)) {
return null;
}
return element.querySelector(selector) || null;
}
/*
* Utility Methods
*/
// Get Vue VM from element
function getVm(el) {
return el ? el.__vue__ : null;
}
// Better var type detection
function toType(obj) {
return ({}).toString.call(obj).match(/\s([a-zA-Z]+)/)[1].toLowerCase();
}
// Check config properties for expected types
function typeCheckConfig(componentName, config, configTypes) {
for (const property in configTypes) {
if (Object.prototype.hasOwnProperty.call(configTypes, property)) {
const expectedTypes = configTypes[property];
const value = config[property];
const valueType = value && isElement(value) ? 'element' : toType(value);
if (!new RegExp(expectedTypes).test(valueType)) {
console.error(
componentName + ': Option "' + property + '" provided type "' +
valueType + '" but expected type "' + expectedTypes + '"'
);
}
}
}
}
/*
* ScrollSpy Class
*/
function ScrollSpy(el, binding) {
// The element that contains the nav-links et al
this._$el = el;
// The selectors to find the nav-links
this._selector = [
Selector.NAV_LINKS,
Selector.LIST_ITEMS,
Selector.DROPDOWN_ITEMS
].join(',');
// Start off with default configurtion
this._config = Object.assign({}, Default);
// Target HREF IDs and their offsets
this._offsets = [];
this._targets = [];
// The currently active target (as an HREF id)
this._activeTarget = null;
// Curent scroll height (for detecting document height changes)
this._scrollHeight = 0;
// Reference to the $root VM so we can spew events
this._$root = null;
// Reference to our throttled resize timeout
this._resizeTimeout = null;
// Process bindings/config
this.updateConfig(binding);
}
/*
* ScrollSpy Public methods
*/
// Update config
ScrollSpy.prototype.updateConfig = function (binding) {
// If Argument, assume element ID
if (binding.arg) {
// Element ID specified as arg. We must pre-pend #
this._config.element = '#' + binding.arg;
}
// Process modifiers
Object.keys(binding.modifiers).forEach(val => {
if (/^\d+$/.test(val)) {
// Offest value
this._config.offset = parseInt(val, 10);
} else if (/^(auto|position|offset)$/.test(val)) {
// Offset method
this._config.method = val;
}
});
// Process value
if (typeof binding.value === 'string') {
// Value is a CSS ID or selector
this._config.element = binding.value;
} else if (typeof binding.value === 'number') {
// Value is offset
this._config.offset = Math.round(binding.value);
} else if (typeof binding.value === 'object') {
// Value is config object
Object.keys(binding.value).filter(k => Boolean(DefaultType[k])).forEach(k => {
this._config[k] = binding.value[k];
});
}
// Check the config and log error to console. Unknown options are ignored
typeCheckConfig(NAME, this._config, DefaultType);
// Get Vue instance from element
const vm = getVm(this._$el);
if (vm && vm.$root) {
this._$root = vm.$root;
}
return this;
};
// Turn on event listener
ScrollSpy.prototype.listen = function () {
const scroller = this._getScroller();
if (scroller) {
if (scroller.tagName !== 'BODY') {
scroller.addEventListener('scroll', this, false);
}
window.addEventListener('scroll', this, false);
window.addEventListener('orientationchange', this, false);
window.addEventListener('resize', this, false);
}
return this;
};
// Turn off event listener
ScrollSpy.prototype.unListen = function () {
const scroller = this._getScroller();
if (scroller) {
if (scroller.tagName !== 'BODY') {
scroller.removeEventListener('scroll', this, false);
}
window.removeEventListener('scroll', this, false);
window.removeEventListener('orientationchange', this, false);
window.removeEventListener('resize', this, false);
}
return this;
};
// Refresh the positions of the target IDs
ScrollSpy.prototype.refresh = function () {
const scroller = this._getScroller();
if (!scroller) {
return this;
}
const autoMethod = scroller.tagName === 'BODY' ? OffsetMethod.OFFSET : OffsetMethod.POSITION;
const offsetMethod = this._config.method === 'auto' ? autoMethod : this._config.method;
const offsetBase = offsetMethod === OffsetMethod.OFFSET ? 0 : this._getScrollTop();
this._offsets = [];
this._targets = [];
this._scrollHeight = this._getScrollHeight();
// Find all nav link/dropdown/list-item links in our element
$QSA(this._selector, this._$el).map(el => {
const href = el.getAttribute('href');
if (href && href.charAt(0) === '#' && href !== '#' && href.indexOf('#/') === -1) {
const target = $QS(href, scroller);
if (!target) {
return null;
}
const bcr = target.getBoundingClientRect();
if (bcr.width || bcr.height) {
return {
offset: (offsetMethod === OffsetMethod.OFFSET ? bcr.top : target.offsetTop) + offsetBase,
href
};
}
}
return null;
}).filter(
item => item
).sort(
(a, b) => a.offset - b.offset
).forEach(item => {
this._offsets.push(item.offset);
this._targets.push(item.href);
});
return this;
};
// Handle the active target selection
ScrollSpy.prototype.process = function () {
if (!this._getScroller) {
return this;
}
const scrollTop = this._getScrollTop() + this._config.offset;
const scrollHeight = this._getScrollHeight();
const maxScroll = this._config.offset + scrollHeight - this._getOffsetHeight();
if (this._scrollHeight !== scrollHeight) {
this.refresh();
}
if (scrollTop >= maxScroll) {
const target = this._targets[this._targets.length - 1];
if (this._activeTarget !== target) {
this._activate(target);
}
return this;
}
if (this._activeTarget && scrollTop < this._offsets[0] && this._offsets[0] > 0) {
this._activeTarget = null;
this._clear();
return this;
}
for (let i = this._offsets.length; i--;) {
const isActiveTarget =
this._activeTarget !== this._targets[i] &&
scrollTop >= this._offsets[i] &&
(this._offsets[i + 1] === undefined ||
scrollTop < this._offsets[i + 1]);
if (isActiveTarget) {
this._activate(this._targets[i]);
}
}
return this;
};
// Dispose of our stuff
ScrollSpy.prototype.dispose = function () {
// Ensure we are not listening to events
this.unListen();
// Garbage collection
clearTimeout(this._resizeTimeout);
this._resizeTimeout = null;
this._$el = null;
this._config = null;
this._selector = null;
this._offsets = null;
this._targets = null;
this._activeTarget = null;
this._scrollHeight = null;
this._$root = null;
};
/*
* ScrollSpy event handler
*/
ScrollSpy.prototype.handleEvent = function (e) {
const self = this;
function resizeThrottle() {
clearTimeout(this._resizeTimeout);
this._resizeTimeout = setTimeout(() => {
self.refresh().process();
}, self._config.throttle || Default.throttle);
}
if (e.type === 'scroll') {
this.process();
} else if (e.type === 'orientationchange') {
this.refresh().process();
} else if (e.type === 'resize') {
resizeThrottle();
}
};
/*
* ScrollSpy private methods
*/
// Get the srolling element
ScrollSpy.prototype._getScroller = function () {
if (isServer) {
return null;
}
const scroller = this._config.element;
if (!scroller) {
return null;
}
if (scroller && isElement(scroller)) {
return scroller;
} else if (typeof scroller === 'string') {
if (scroller === 'body') {
return document.body;
}
// Otherwise assume CSS selector
return $QS(scroller);
}
return null;
};
// Return the scroller top position
ScrollSpy.prototype._getScrollTop = function () {
const scroller = this._getScroller();
if (!scroller) {
return 0;
}
return scroller.tagName === 'BODY' ? window.pageYOffset : scroller.scrollTop;
};
// Return the scroller height
ScrollSpy.prototype._getScrollHeight = function () {
const scroller = this._getScroller();
if (!scroller) {
return 0;
}
return scroller.tagName === 'BODY' ?
Math.max(document.body.scrollHeight, document.documentElement.scrollHeight) :
scroller.scrollHeight;
};
// Return the scroller offset top position
ScrollSpy.prototype._getOffsetHeight = function () {
const scroller = this._getScroller();
if (!scroller) {
return 0;
}
return scroller.tagName === 'BODY' ? window.innerHeight : scroller.getBoundingClientRect().height;
};
// Activate the scrolled in target nav-link
ScrollSpy.prototype._activate = function (target) {
this._activeTarget = target;
this._clear();
let queries = this._selector.split(',');
queries = queries.map(selector => {
return selector + '[href="' + target + '"]';
});
const links = $QSA(queries.join(','), this._$el);
links.forEach(link => {
if (link.classList.contains(ClassName.DROPDOWN_ITEM)) {
// This is a dropdown item, so find the .dropdown-toggle and set it's state
const dropdown = closest(link, Selector.DROPDOWN);
if (dropdown) {
const toggle = $QS(Selector.DROPDOWN_TOGGLE, dropdown);
if (toggle) {
this._setActiveState(toggle, true);
}
}
// Also set this link's state
this._setActiveState(link, true);
} else {
// Set triggered link as active
this._setActiveState(link, true);
// Set triggered links parents as active
// With both <ul> and <nav> markup a parent is the previous sibling of any nav ancestor
// Handle parent .nav's and .list-group's
this._setParentsSiblingActiveState(link, Selector.NAV_LIST_GROUP, [ClassName.NAV_LINK, ClassName.LIST_ITEM], true);
}
});
// Signal event to root, passing ID of target
if (links && links.length > 0 && this._$root && this._$root.$emit) {
this._$root.$emit(EVENT, target);
}
};
// Clear the 'active' targets in our nav component
ScrollSpy.prototype._clear = function () {
$QSA(this._selector, this._$el).filter(el => {
if (el.classList.contains(ClassName.ACTIVE)) {
const href = el.getAttribute('href');
if (href.charAt(0) !== '#' || href.indexOf('#/') === 0) {
return false;
}
return true;
}
return false;
}).forEach(el => {
this._setActiveState(el, false);
});
};
// Set the active state. if el has a vue insatnce then try setting the active prop,
// Else fallback to adding the active class
ScrollSpy.prototype._setActiveState = function (el, state) {
if (el) {
if (el.classList.contains(ClassName.NAV_LINK) && !el.classList.contains(ClassName.DROPDOWN_TOGGLE)) {
// Special case where VM with 'active' prop is on parent element
el = el.parentElement;
}
const vm = getVm(el);
if (vm && Object.prototype.hasOwnProperty.call(vm.$props, 'active')) {
// This is a component that has an `active` prop
vm.$props.active = state;
} else {
// Fallback to setting class attribute since item doesn't have an 'active' prop
el.classList[state ? 'add' : 'remove'](ClassName.ACTIVE);
}
}
};
// Find all the matching parents given a CSS selector, then find previous sibling that matches the supplied classes
// And then set the active state on each immediate previous sibling of the parent(s)
ScrollSpy.prototype._setParentsSiblingActiveState = function (element, selector, classes, state) {
if (!classes) {
return;
}
if (!Array.isArray(classes)) {
classes = [classes];
}
let el = element;
while (el) {
el = closest(el, selector);
if (el && el.previousElementSibling) {
for (let i = 0; i < classes.length - 1; i++) {
if (el.previousElementSibling.classList.contains(classes[i])) {
this._setActiveState(el, state);
}
}
}
}
};
/*
* Export our directive
*/
export default {
bind(el, binding) {
if (isServer || el[BVSS]) {
return;
}
el[BVSS] = new ScrollSpy(el, binding);
},
inserted(el, binding) {
if (isServer || !el[BVSS]) {
return;
}
el[BVSS].updateConfig(binding).listen().refresh().process();
},
update(el, binding) {
if (isServer || !el[BVSS]) {
return;
}
el[BVSS].updateConfig(binding).refresh().process();
},
componentUpdated(el, binding) {
if (isServer || !el[BVSS]) {
return;
}
el[BVSS].updateConfig(binding).refresh().process();
},
unbind(el) {
if (isServer || !el[BVSS]) {
return;
}
// Remove scroll event listener on scrollElId
el[BVSS].unListen().dispose();
el[BVSS] = null;
}
};