Spaces:
Running
Running
/** | |
* -------------------------------------------------------------------------- | |
* Bootstrap (v5.1.3): scrollspy.js | |
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) | |
* -------------------------------------------------------------------------- | |
*/ | |
import { | |
defineJQueryPlugin, | |
getElement, | |
getSelectorFromElement, | |
typeCheckConfig | |
} from './util/index' | |
import EventHandler from './dom/event-handler' | |
import Manipulator from './dom/manipulator' | |
import SelectorEngine from './dom/selector-engine' | |
import BaseComponent from './base-component' | |
/** | |
* ------------------------------------------------------------------------ | |
* Constants | |
* ------------------------------------------------------------------------ | |
*/ | |
const NAME = 'scrollspy' | |
const DATA_KEY = 'bs.scrollspy' | |
const EVENT_KEY = `.${DATA_KEY}` | |
const DATA_API_KEY = '.data-api' | |
const Default = { | |
offset: 10, | |
method: 'auto', | |
target: '' | |
} | |
const DefaultType = { | |
offset: 'number', | |
method: 'string', | |
target: '(string|element)' | |
} | |
const EVENT_ACTIVATE = `activate${EVENT_KEY}` | |
const EVENT_SCROLL = `scroll${EVENT_KEY}` | |
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}` | |
const CLASS_NAME_DROPDOWN_ITEM = 'dropdown-item' | |
const CLASS_NAME_ACTIVE = 'active' | |
const SELECTOR_DATA_SPY = '[data-bs-spy="scroll"]' | |
const SELECTOR_NAV_LIST_GROUP = '.nav, .list-group' | |
const SELECTOR_NAV_LINKS = '.nav-link' | |
const SELECTOR_NAV_ITEMS = '.nav-item' | |
const SELECTOR_LIST_ITEMS = '.list-group-item' | |
const SELECTOR_LINK_ITEMS = `${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}, .${CLASS_NAME_DROPDOWN_ITEM}` | |
const SELECTOR_DROPDOWN = '.dropdown' | |
const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle' | |
const METHOD_OFFSET = 'offset' | |
const METHOD_POSITION = 'position' | |
/** | |
* ------------------------------------------------------------------------ | |
* Class Definition | |
* ------------------------------------------------------------------------ | |
*/ | |
class ScrollSpy extends BaseComponent { | |
constructor(element, config) { | |
super(element) | |
this._scrollElement = this._element.tagName === 'BODY' ? window : this._element | |
this._config = this._getConfig(config) | |
this._offsets = [] | |
this._targets = [] | |
this._activeTarget = null | |
this._scrollHeight = 0 | |
EventHandler.on(this._scrollElement, EVENT_SCROLL, () => this._process()) | |
this.refresh() | |
this._process() | |
} | |
// Getters | |
static get Default() { | |
return Default | |
} | |
static get NAME() { | |
return NAME | |
} | |
// Public | |
refresh() { | |
const autoMethod = this._scrollElement === this._scrollElement.window ? | |
METHOD_OFFSET : | |
METHOD_POSITION | |
const offsetMethod = this._config.method === 'auto' ? | |
autoMethod : | |
this._config.method | |
const offsetBase = offsetMethod === METHOD_POSITION ? | |
this._getScrollTop() : | |
0 | |
this._offsets = [] | |
this._targets = [] | |
this._scrollHeight = this._getScrollHeight() | |
const targets = SelectorEngine.find(SELECTOR_LINK_ITEMS, this._config.target) | |
targets.map(element => { | |
const targetSelector = getSelectorFromElement(element) | |
const target = targetSelector ? SelectorEngine.findOne(targetSelector) : null | |
if (target) { | |
const targetBCR = target.getBoundingClientRect() | |
if (targetBCR.width || targetBCR.height) { | |
return [ | |
Manipulator[offsetMethod](target).top + offsetBase, | |
targetSelector | |
] | |
} | |
} | |
return null | |
}) | |
.filter(item => item) | |
.sort((a, b) => a[0] - b[0]) | |
.forEach(item => { | |
this._offsets.push(item[0]) | |
this._targets.push(item[1]) | |
}) | |
} | |
dispose() { | |
EventHandler.off(this._scrollElement, EVENT_KEY) | |
super.dispose() | |
} | |
// Private | |
_getConfig(config) { | |
config = { | |
...Default, | |
...Manipulator.getDataAttributes(this._element), | |
...(typeof config === 'object' && config ? config : {}) | |
} | |
config.target = getElement(config.target) || document.documentElement | |
typeCheckConfig(NAME, config, DefaultType) | |
return config | |
} | |
_getScrollTop() { | |
return this._scrollElement === window ? | |
this._scrollElement.pageYOffset : | |
this._scrollElement.scrollTop | |
} | |
_getScrollHeight() { | |
return this._scrollElement.scrollHeight || Math.max( | |
document.body.scrollHeight, | |
document.documentElement.scrollHeight | |
) | |
} | |
_getOffsetHeight() { | |
return this._scrollElement === window ? | |
window.innerHeight : | |
this._scrollElement.getBoundingClientRect().height | |
} | |
_process() { | |
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 | |
} | |
if (this._activeTarget && scrollTop < this._offsets[0] && this._offsets[0] > 0) { | |
this._activeTarget = null | |
this._clear() | |
return | |
} | |
for (let i = this._offsets.length; i--;) { | |
const isActiveTarget = this._activeTarget !== this._targets[i] && | |
scrollTop >= this._offsets[i] && | |
(typeof this._offsets[i + 1] === 'undefined' || scrollTop < this._offsets[i + 1]) | |
if (isActiveTarget) { | |
this._activate(this._targets[i]) | |
} | |
} | |
} | |
_activate(target) { | |
this._activeTarget = target | |
this._clear() | |
const queries = SELECTOR_LINK_ITEMS.split(',') | |
.map(selector => `${selector}[data-bs-target="${target}"],${selector}[href="${target}"]`) | |
const link = SelectorEngine.findOne(queries.join(','), this._config.target) | |
link.classList.add(CLASS_NAME_ACTIVE) | |
if (link.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) { | |
SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, link.closest(SELECTOR_DROPDOWN)) | |
.classList.add(CLASS_NAME_ACTIVE) | |
} else { | |
SelectorEngine.parents(link, SELECTOR_NAV_LIST_GROUP) | |
.forEach(listGroup => { | |
// Set triggered links parents as active | |
// With both <ul> and <nav> markup a parent is the previous sibling of any nav ancestor | |
SelectorEngine.prev(listGroup, `${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}`) | |
.forEach(item => item.classList.add(CLASS_NAME_ACTIVE)) | |
// Handle special case when .nav-link is inside .nav-item | |
SelectorEngine.prev(listGroup, SELECTOR_NAV_ITEMS) | |
.forEach(navItem => { | |
SelectorEngine.children(navItem, SELECTOR_NAV_LINKS) | |
.forEach(item => item.classList.add(CLASS_NAME_ACTIVE)) | |
}) | |
}) | |
} | |
EventHandler.trigger(this._scrollElement, EVENT_ACTIVATE, { | |
relatedTarget: target | |
}) | |
} | |
_clear() { | |
SelectorEngine.find(SELECTOR_LINK_ITEMS, this._config.target) | |
.filter(node => node.classList.contains(CLASS_NAME_ACTIVE)) | |
.forEach(node => node.classList.remove(CLASS_NAME_ACTIVE)) | |
} | |
// Static | |
static jQueryInterface(config) { | |
return this.each(function() { | |
const data = ScrollSpy.getOrCreateInstance(this, config) | |
if (typeof config !== 'string') { | |
return | |
} | |
if (typeof data[config] === 'undefined') { | |
throw new TypeError(`No method named "${config}"`) | |
} | |
data[config]() | |
}) | |
} | |
} | |
/** | |
* ------------------------------------------------------------------------ | |
* Data Api implementation | |
* ------------------------------------------------------------------------ | |
*/ | |
EventHandler.on(window, EVENT_LOAD_DATA_API, () => { | |
SelectorEngine.find(SELECTOR_DATA_SPY) | |
.forEach(spy => new ScrollSpy(spy)) | |
}) | |
/** | |
* ------------------------------------------------------------------------ | |
* jQuery | |
* ------------------------------------------------------------------------ | |
* add .ScrollSpy to jQuery only if jQuery is present | |
*/ | |
defineJQueryPlugin(ScrollSpy) | |
export default ScrollSpy |