-
-
Notifications
You must be signed in to change notification settings - Fork 79.2k
Description
Prerequisites
- I have searched for duplicate or closed feature requests
- I have read the contributing guidelines
Proposal
The Scrollspy component was broken when switching to the observer api. I decided to look into it. Basically, I understood why the tests were passed, which tests did not work as they should, what exactly broke, and so on. I started fixing the component while getting rid of the old functionality, rewriting, adding and correcting the tests. I've managed to make some progress with the component. But I have a question, are you interested in this and what are your plans for it? Can I continue working on it and send a PR, what do you say?
Motivation and context
The component has been in a broken state for a long time. I'd like to fix it.
Here's what's ready at the moment. I just have to fix a couple of bugs and add scroll-offset-top for the header, getting rid of the offset parameter. It can even be made dynamic by passing an overlapping absolute positioning element in the configuration and adding styles. There's a lot to think about.
first.webm
second.webm
third.webm
For now, I'll just leave the code of the new component:
/**
* --------------------------------------------------------------------------
* 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 {
getElement, isDisabled, isVisible
} from './util/index.js'
/**
* Constants
*/
const NAME = 'scrollspy'
const DATA_KEY = 'bs.scrollspy'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_ACTIVATE = `activate${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_NAV_ITEMS} > ${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}`
const SELECTOR_DROPDOWN = '.dropdown'
const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle'
export const SPY_ENGINE_CONFIG = {
rootMargin: '-2% 0px -98% 0px',
threshold: [0]
}
export const SPY_SENTRY_CONFIG = {
rootMargin: '0px 0px 0px 0px',
threshold: [0]
}
const Default = {
rootMargin: SPY_ENGINE_CONFIG.rootMargin,
threshold: SPY_ENGINE_CONFIG.threshold,
smoothScroll: false,
target: null
}
const DefaultType = {
rootMargin: 'string',
threshold: 'array',
smoothScroll: 'boolean',
target: 'element'
}
/**
* Class definition
*/
class ScrollSpy extends BaseComponent {
constructor(element, config) {
super(element, config)
// 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._sentryObserver = null
this._sentryObserverElement = null
this.refresh() // initialize
}
// Getters
static get Default() {
return Default
}
static get DefaultType() {
return DefaultType
}
static get NAME() {
return NAME
}
// Public
refresh() {
this._initializeTargets()
this._captureTargets()
this._customizeScrollBehavior()
this._activateAnchor()
this._observer?.disconnect()
this._observer = this._getNewObserver()
this._sentryObserver?.disconnect()
this._sentryObserver = this._getNewSentryObserver()
for (const section of this._observableSections.values()) {
this._observer.observe(section)
}
this._sentryObserver.observe(this._sentryObserverElement)
}
dispose() {
this._observer.disconnect()
super.dispose()
}
// Private
_configAfterMerge(config) {
config.target = getElement(config.target)
if (!config.target) {
throw new TypeError('Bootstrap ScrollSpy: You must specify a valid "target" element')
}
return config
}
_initializeTargets() {
this._targetLinks = new Map()
this._observableSections = new Map()
this._sentryObserverElement = SelectorEngine.findOne('.sentry-observer', this._element)
if (!this._sentryObserverElement) {
const sentryObserverElement = document.createElement('div')
sentryObserverElement.classList.add('sentry-observer', 'visibility-hidden')
sentryObserverElement.style.height = '1px'
sentryObserverElement.style.width = '1px'
this._element.append(sentryObserverElement)
}
}
_captureTargets() {
const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target)
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)
// ensure that the observableSection exists & is visible
if (isVisible(observableSection)) {
this._targetLinks.set(decodeURI(anchor.hash), anchor)
this._observableSections.set(anchor.hash, observableSection)
}
}
this._sentryObserverElement = SelectorEngine.findOne('.sentry-observer', this._element)
}
_customizeScrollBehavior() {
if (this._rootElement && this._config.smoothScroll) {
this._rootElement.classList.add('scroll-smooth')
}
if (this._rootElement && !this._config.smoothScroll) {
this._rootElement.classList.remove('scroll-smooth')
}
}
_activateAnchor() {
const [, firstObservableSection] = [...this._observableSections].shift()
const [, firstAnchorElement] = [...this._targetLinks].shift()
const rect = firstObservableSection.getBoundingClientRect()
if (rect.top > 0) {
this._setActiveClass(firstAnchorElement)
}
}
_getNewSentryObserver() {
const options = {
root: this._rootElement,
threshold: SPY_SENTRY_CONFIG.threshold,
rootMargin: SPY_SENTRY_CONFIG.rootMargin
}
return new IntersectionObserver(entries => this._sentryObserverCallback(entries), options)
}
_sentryObserverCallback(entries) {
const visibleEntry = entries.find(entry => entry.isIntersecting)
if (!visibleEntry) {
return
}
const targets = [...this._targetLinks]
const [, lastAnchorElement] = targets.pop()
if (!lastAnchorElement.classList.contains(CLASS_NAME_ACTIVE)) {
for (const [, element] of targets) {
this._clearActiveClass(element)
}
this._setActiveClass(lastAnchorElement)
}
}
_getNewObserver() {
const options = {
root: this._rootElement,
threshold: this._config.threshold,
rootMargin: this._config.rootMargin
}
return new IntersectionObserver(entries => this._observerCallback(entries), options)
}
_observerCallback(entries) {
const visibleEntry = entries.find(entry => entry.isIntersecting)
if (!visibleEntry) {
return
}
const element = this._targetLinks.get(`#${visibleEntry.target.id}`)
this._process(element)
}
_process(target) {
if (this._activeTarget === target) {
return
}
this._clearActiveClass(this._config.target)
this._setActiveClass(target)
this._activateDropdownParentElement(target)
this._activateListGroupParentElement(target)
EventHandler.trigger(this._element, EVENT_ACTIVATE, { relatedTarget: target })
}
_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)
}
}
_setActiveClass(target) {
this._activeTarget = target
target.classList.add(CLASS_NAME_ACTIVE)
}
_activateDropdownParentElement(target) {
// Set the parent active dropdown class if dropdown is the target of the clicked link or the current viewport
if (target.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) {
SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, target.closest(SELECTOR_DROPDOWN))
.classList.add(CLASS_NAME_ACTIVE)
}
}
_activateListGroupParentElement(target) {
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)
}
}
}
}
/**
* Data API implementation
*/
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
for (const spy of SelectorEngine.find(SELECTOR_DATA_SPY)) {
ScrollSpy.getOrCreateInstance(spy)
}
})
export default ScrollSpy