diff options
| author | Bobby <[email protected]> | 2024-08-16 20:47:33 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2024-08-16 20:47:33 -0400 |
| commit | 6b28433d9cfde435be8ec2bd6cf91e6324d08865 (patch) | |
| tree | 8343c27b8b95ff5639233e81cf157f92e5688466 /js/src/scrollspy.js | |
| parent | d53094ec16ba385faae2973ddee648698b32ab24 (diff) | |
| parent | 048f56f51460df75e92a2f7b472e1c56baeb68f7 (diff) | |
| download | bootstrap-6b28433d9cfde435be8ec2bd6cf91e6324d08865.tar.xz bootstrap-6b28433d9cfde435be8ec2bd6cf91e6324d08865.zip | |
Diffstat (limited to 'js/src/scrollspy.js')
| -rw-r--r-- | js/src/scrollspy.js | 304 |
1 files changed, 157 insertions, 147 deletions
diff --git a/js/src/scrollspy.js b/js/src/scrollspy.js index 27bc0cd87..368092de4 100644 --- a/js/src/scrollspy.js +++ b/js/src/scrollspy.js @@ -1,20 +1,16 @@ /** * -------------------------------------------------------------------------- - * Bootstrap (v5.1.3): scrollspy.js + * Bootstrap scrollspy.js * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) * -------------------------------------------------------------------------- */ +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import SelectorEngine from './dom/selector-engine.js' 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' + defineJQueryPlugin, getElement, isDisabled, isVisible +} from './util/index.js' /** * Constants @@ -26,34 +22,36 @@ const EVENT_KEY = `.${DATA_KEY}` const DATA_API_KEY = '.data-api' const EVENT_ACTIVATE = `activate${EVENT_KEY}` -const EVENT_SCROLL = `scroll${EVENT_KEY}` +const EVENT_CLICK = `click${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_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_LIST_ITEMS}, .${CLASS_NAME_DROPDOWN_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' -const METHOD_OFFSET = 'offset' -const METHOD_POSITION = 'position' - const Default = { - offset: 10, - method: 'auto', - target: '' + offset: null, // TODO: v6 @deprecated, keep it for backwards compatibility reasons + rootMargin: '0px 0px -25%', + smoothScroll: false, + target: null, + threshold: [0.1, 0.5, 1] } const DefaultType = { - offset: 'number', - method: 'string', - target: '(string|element)' + offset: '(number|null)', // TODO v6 @deprecated, keep it for backwards compatibility reasons + rootMargin: 'string', + smoothScroll: 'boolean', + target: 'element', + threshold: 'array' } /** @@ -62,18 +60,19 @@ const DefaultType = { 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()) + super(element, config) - this.refresh() - this._process() + // 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 } // Getters @@ -81,169 +80,180 @@ class ScrollSpy extends BaseComponent { return Default } + static get DefaultType() { + return DefaultType + } + 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) - .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 - ] - } - } + this._initializeTargetsAndObservables() + this._maybeEnableSmoothScroll() - return null - }) - .filter(item => item) - .sort((a, b) => a[0] - b[0]) + if (this._observer) { + this._observer.disconnect() + } else { + this._observer = this._getNewObserver() + } - for (const item of targets) { - this._offsets.push(item[0]) - this._targets.push(item[1]) + for (const section of this._observableSections.values()) { + this._observer.observe(section) } } dispose() { - EventHandler.off(this._scrollElement, EVENT_KEY) + this._observer.disconnect() super.dispose() } // Private - _getConfig(config) { - config = { - ...Default, - ...Manipulator.getDataAttributes(this._element), - ...(typeof config === 'object' && config ? config : {}) - } + _configAfterMerge(config) { + // TODO: on v6 target should be given explicitly & remove the {target: 'ss-target'} case + config.target = getElement(config.target) || document.body - config.target = getElement(config.target) || document.documentElement + // TODO: v6 Only for backwards compatibility reasons. Use rootMargin only + config.rootMargin = config.offset ? `${config.offset}px 0px -30%` : config.rootMargin - typeCheckConfig(NAME, config, DefaultType) + if (typeof config.threshold === 'string') { + config.threshold = config.threshold.split(',').map(value => Number.parseFloat(value)) + } return config } - _getScrollTop() { - return this._scrollElement === window ? - this._scrollElement.pageYOffset : - this._scrollElement.scrollTop - } + _maybeEnableSmoothScroll() { + if (!this._config.smoothScroll) { + return + } - _getScrollHeight() { - return this._scrollElement.scrollHeight || Math.max( - document.body.scrollHeight, - document.documentElement.scrollHeight - ) - } + // 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, behavior: 'smooth' }) + return + } - _getOffsetHeight() { - return this._scrollElement === window ? - window.innerHeight : - this._scrollElement.getBoundingClientRect().height + // Chrome 60 doesn't support `scrollTo` + root.scrollTop = height + } + }) } - _process() { - const scrollTop = this._getScrollTop() + this._config.offset - const scrollHeight = this._getScrollHeight() - const maxScroll = this._config.offset + scrollHeight - this._getOffsetHeight() + _getNewObserver() { + const options = { + root: this._rootElement, + threshold: this._config.threshold, + rootMargin: this._config.rootMargin + } + + return new IntersectionObserver(entries => this._observerCallback(entries), options) + } - if (this._scrollHeight !== scrollHeight) { - this.refresh() + // 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)) } - if (scrollTop >= maxScroll) { - const target = this._targets[this._targets.length - 1] + 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)) - if (this._activeTarget !== target) { - this._activate(target) + continue } - return - } + 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 + } - if (this._activeTarget && scrollTop < this._offsets[0] && this._offsets[0] > 0) { - this._activeTarget = null - this._clear() - return + continue + } + + // if we are scrolling up, pick the smallest offsetTop + if (!userScrollsDown && !entryIsLowerThanPrevious) { + activate(entry) + } } + } + + _initializeTargetsAndObservables() { + this._targetLinks = new Map() + this._observableSections = new Map() + + const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target) - 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]) + 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(decodeURI(anchor.hash), this._element) - if (isActiveTarget) { - this._activate(this._targets[i]) + // ensure that the observableSection exists & is visible + if (isVisible(observableSection)) { + this._targetLinks.set(decodeURI(anchor.hash), anchor) + this._observableSections.set(anchor.hash, observableSection) } } } - _activate(target) { - this._activeTarget = target - - this._clear() + _process(target) { + if (this._activeTarget === target) { + return + } - const queries = SELECTOR_LINK_ITEMS.split(',') - .map(selector => `${selector}[data-bs-target="${target}"],${selector}[href="${target}"]`) + this._clearActiveClass(this._config.target) + this._activeTarget = target + target.classList.add(CLASS_NAME_ACTIVE) + this._activateParents(target) - const link = SelectorEngine.findOne(queries.join(','), this._config.target) + EventHandler.trigger(this._element, EVENT_ACTIVATE, { relatedTarget: target }) + } - link.classList.add(CLASS_NAME_ACTIVE) - if (link.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) { - SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, link.closest(SELECTOR_DROPDOWN)) + _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) - } else { - for (const listGroup of SelectorEngine.parents(link, 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_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}`)) { - item.classList.add(CLASS_NAME_ACTIVE) - } + return + } - // Handle special case when .nav-link is inside .nav-item - for (const navItem of SelectorEngine.prev(listGroup, SELECTOR_NAV_ITEMS)) { - for (const item of SelectorEngine.children(navItem, SELECTOR_NAV_LINKS)) { - item.classList.add(CLASS_NAME_ACTIVE) - } - } + 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) } } - - EventHandler.trigger(this._scrollElement, EVENT_ACTIVATE, { - relatedTarget: target - }) } - _clear() { - const activeNodes = SelectorEngine.find(SELECTOR_LINK_ITEMS, this._config.target) - .filter(node => node.classList.contains(CLASS_NAME_ACTIVE)) + _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) } @@ -258,7 +268,7 @@ class ScrollSpy extends BaseComponent { return } - if (typeof data[config] === 'undefined') { + if (data[config] === undefined || config.startsWith('_') || config === 'constructor') { throw new TypeError(`No method named "${config}"`) } @@ -273,7 +283,7 @@ class ScrollSpy extends BaseComponent { EventHandler.on(window, EVENT_LOAD_DATA_API, () => { for (const spy of SelectorEngine.find(SELECTOR_DATA_SPY)) { - new ScrollSpy(spy) // eslint-disable-line no-new + ScrollSpy.getOrCreateInstance(spy) } }) |
