0
0
mirror of https://github.com/twbs/bootstrap.git synced 2025-01-10 03:46:13 +01:00
Bootstrap/js/src/scrollspy.js

291 lines
8.1 KiB
JavaScript
Raw Normal View History

2015-05-11 21:05:35 +02:00
/**
* --------------------------------------------------------------------------
2021-10-09 08:33:12 +02:00
* Bootstrap (v5.1.3): scrollspy.js
2020-06-16 20:41:47 +02:00
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
2015-05-11 21:05:35 +02:00
* --------------------------------------------------------------------------
*/
import { defineJQueryPlugin, getElement, isDisabled, isVisible } from './util/index'
import EventHandler from './dom/event-handler'
import SelectorEngine from './dom/selector-engine'
2019-09-04 16:58:29 +02:00
import BaseComponent from './base-component'
2018-09-26 10:39:01 +02:00
/**
* Constants
*/
2015-05-11 21:05:35 +02:00
2019-02-26 12:20:34 +01:00
const NAME = 'scrollspy'
const DATA_KEY = 'bs.scrollspy'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
2018-09-26 10:39:01 +02:00
const EVENT_ACTIVATE = `activate${EVENT_KEY}`
const EVENT_CLICK = `click${EVENT_KEY}`
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
2018-09-26 10:39:01 +02:00
const CLASS_NAME_DROPDOWN_ITEM = 'dropdown-item'
const CLASS_NAME_ACTIVE = 'active'
2018-09-26 10:39:01 +02:00
const SELECTOR_DATA_SPY = '[data-bs-spy="scroll"]'
const SELECTOR_TARGET_LINKS = '[href]'
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_NAV_ITEMS} > ${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}`
const SELECTOR_DROPDOWN = '.dropdown'
const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle'
2018-09-26 10:39:01 +02:00
const Default = {
offset: null, // TODO: v6 @deprecated, keep it for backwards compatibility reasons
rootMargin: '0px 0px -25%',
smoothScroll: false,
target: null
}
const DefaultType = {
offset: '(number|null)', // TODO v6 @deprecated, keep it for backwards compatibility reasons
rootMargin: 'string',
smoothScroll: 'boolean',
target: 'element'
}
2018-09-26 10:39:01 +02:00
/**
* Class definition
2018-09-26 10:39:01 +02:00
*/
2015-05-11 21:05:35 +02:00
2019-09-04 16:58:29 +02:00
class ScrollSpy extends BaseComponent {
2018-09-26 10:39:01 +02:00
constructor(element, config) {
super(element, config)
2018-09-26 10:39:01 +02:00
// this._element is the observablesContainer and config.target the menu links wrapper
this._targetLinks = new Map()
this._observableSections = new Map()
this._rootElement = getComputedStyle(this._element).overflowY === 'visible' ? null : this._element
this._activeTarget = null
this._observer = null
this._previousScrollData = {
visibleEntryTop: 0,
parentScrollTop: 0
}
this.refresh() // initialize
2015-05-11 21:05:35 +02:00
}
2018-09-26 10:39:01 +02:00
// Getters
static get Default() {
return Default
}
2015-05-11 21:05:35 +02:00
static get DefaultType() {
return DefaultType
}
static get NAME() {
return NAME
2019-09-04 16:58:29 +02:00
}
2018-09-26 10:39:01 +02:00
// Public
refresh() {
this._initializeTargetsAndObservables()
this._maybeEnableSmoothScroll()
2019-02-26 12:20:34 +01:00
if (this._observer) {
this._observer.disconnect()
} else {
this._observer = this._getNewObserver()
}
2021-10-08 11:28:05 +02:00
for (const section of this._observableSections.values()) {
this._observer.observe(section)
}
2018-09-26 10:39:01 +02:00
}
2015-05-13 22:43:56 +02:00
2018-09-26 10:39:01 +02:00
dispose() {
this._observer.disconnect()
super.dispose()
2018-09-26 10:39:01 +02:00
}
2015-05-13 23:46:50 +02:00
2018-09-26 10:39:01 +02:00
// Private
_configAfterMerge(config) {
// TODO: on v6 target should be given explicitly & remove the {target: 'ss-target'} case
config.target = getElement(config.target) || document.body
2015-05-11 21:05:35 +02:00
2018-09-26 10:39:01 +02:00
return config
}
2015-05-11 21:05:35 +02:00
_maybeEnableSmoothScroll() {
if (!this._config.smoothScroll) {
return
}
2015-05-11 21:05:35 +02:00
// unregister any previous listeners
EventHandler.off(this._config.target, EVENT_CLICK)
EventHandler.on(this._config.target, EVENT_CLICK, SELECTOR_TARGET_LINKS, event => {
const observableSection = this._observableSections.get(event.target.hash)
if (observableSection) {
event.preventDefault()
const root = this._rootElement || window
const height = observableSection.offsetTop - this._element.offsetTop
if (root.scrollTo) {
root.scrollTo({ top: height })
return
}
2015-05-11 21:05:35 +02:00
// Chrome 60 doesn't support `scrollTo`
root.scrollTop = height
}
})
2018-09-26 10:39:01 +02:00
}
2015-05-11 21:05:35 +02:00
_getNewObserver() {
const options = {
root: this._rootElement,
threshold: [0.1, 0.5, 1],
rootMargin: this._getRootMargin()
}
return new IntersectionObserver(entries => this._observerCallback(entries), options)
}
2015-05-11 21:05:35 +02:00
// The logic of selection
_observerCallback(entries) {
const targetElement = entry => this._targetLinks.get(`#${entry.target.id}`)
const activate = entry => {
this._previousScrollData.visibleEntryTop = entry.target.offsetTop
this._process(targetElement(entry))
2018-09-26 10:39:01 +02:00
}
2015-05-11 21:05:35 +02:00
const parentScrollTop = (this._rootElement || document.documentElement).scrollTop
const userScrollsDown = parentScrollTop >= this._previousScrollData.parentScrollTop
this._previousScrollData.parentScrollTop = parentScrollTop
for (const entry of entries) {
if (!entry.isIntersecting) {
this._activeTarget = null
this._clearActiveClass(targetElement(entry))
2018-09-26 10:39:01 +02:00
continue
2015-05-11 21:05:35 +02:00
}
2019-02-26 12:20:34 +01:00
const entryIsLowerThanPrevious = entry.target.offsetTop >= this._previousScrollData.visibleEntryTop
// if we are scrolling down, pick the bigger offsetTop
if (userScrollsDown && entryIsLowerThanPrevious) {
activate(entry)
// if parent isn't scrolled, let's keep the first visible item, breaking the iteration
if (!parentScrollTop) {
return
}
2015-05-11 21:05:35 +02:00
continue
}
// if we are scrolling up, pick the smallest offsetTop
if (!userScrollsDown && !entryIsLowerThanPrevious) {
activate(entry)
}
2018-09-26 10:39:01 +02:00
}
}
// TODO: v6 Only for backwards compatibility reasons. Use rootMargin only
_getRootMargin() {
return this._config.offset ? `${this._config.offset}px 0px -30%` : this._config.rootMargin
}
_initializeTargetsAndObservables() {
this._targetLinks = new Map()
this._observableSections = new Map()
const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target)
2015-05-11 21:05:35 +02:00
for (const anchor of targetLinks) {
// ensure that the anchor has an id and is not disabled
if (!anchor.hash || isDisabled(anchor)) {
continue
}
const observableSection = SelectorEngine.findOne(anchor.hash, this._element)
2015-05-11 21:05:35 +02:00
// ensure that the observableSection exists & is visible
if (isVisible(observableSection)) {
this._targetLinks.set(anchor.hash, anchor)
this._observableSections.set(anchor.hash, observableSection)
2015-05-11 21:05:35 +02:00
}
}
2018-09-26 10:39:01 +02:00
}
2015-05-11 21:05:35 +02:00
_process(target) {
if (this._activeTarget === target) {
return
}
2018-09-26 10:39:01 +02:00
this._clearActiveClass(this._config.target)
this._activeTarget = target
target.classList.add(CLASS_NAME_ACTIVE)
this._activateParents(target)
2018-09-26 10:39:01 +02:00
EventHandler.trigger(this._element, EVENT_ACTIVATE, { relatedTarget: target })
}
2017-09-25 09:09:01 +02:00
_activateParents(target) {
// Activate dropdown parents
if (target.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) {
SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, target.closest(SELECTOR_DROPDOWN))
.classList.add(CLASS_NAME_ACTIVE)
return
}
for (const listGroup of SelectorEngine.parents(target, SELECTOR_NAV_LIST_GROUP)) {
// Set triggered links parents as active
// With both <ul> and <nav> markup a parent is the previous sibling of any nav ancestor
for (const item of SelectorEngine.prev(listGroup, SELECTOR_LINK_ITEMS)) {
item.classList.add(CLASS_NAME_ACTIVE)
}
2015-05-11 21:05:35 +02:00
}
2018-09-26 10:39:01 +02:00
}
2015-05-11 21:05:35 +02:00
_clearActiveClass(parent) {
parent.classList.remove(CLASS_NAME_ACTIVE)
const activeNodes = SelectorEngine.find(`${SELECTOR_TARGET_LINKS}.${CLASS_NAME_ACTIVE}`, parent)
for (const node of activeNodes) {
node.classList.remove(CLASS_NAME_ACTIVE)
}
2018-09-26 10:39:01 +02:00
}
2015-05-11 21:05:35 +02:00
2018-09-26 10:39:01 +02:00
// Static
2019-07-28 15:24:46 +02:00
static jQueryInterface(config) {
2018-09-26 10:39:01 +02:00
return this.each(function () {
const data = ScrollSpy.getOrCreateInstance(this, config)
2018-09-26 10:39:01 +02:00
2021-04-06 17:28:10 +02:00
if (typeof config !== 'string') {
return
2018-09-26 10:39:01 +02:00
}
if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
2021-04-06 17:28:10 +02:00
throw new TypeError(`No method named "${config}"`)
2018-09-26 10:39:01 +02:00
}
2021-04-06 17:28:10 +02:00
data[config]()
2018-09-26 10:39:01 +02:00
})
2015-05-11 21:05:35 +02:00
}
2018-09-26 10:39:01 +02:00
}
2015-05-11 21:05:35 +02:00
2018-09-26 10:39:01 +02:00
/**
* Data API implementation
2018-09-26 10:39:01 +02:00
*/
2015-05-11 21:05:35 +02:00
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
for (const spy of SelectorEngine.find(SELECTOR_DATA_SPY)) {
ScrollSpy.getOrCreateInstance(spy)
}
2018-09-26 10:39:01 +02:00
})
/**
* jQuery
*/
defineJQueryPlugin(ScrollSpy)
2015-05-11 21:05:35 +02:00
export default ScrollSpy