From d0947a8c573a7e676605a67562ca90ee61639dca Mon Sep 17 00:00:00 2001 From: GeoSot Date: Wed, 3 Mar 2021 02:33:02 +0200 Subject: [PATCH] revamp scrollspy to use IntersectionObserver --- js/src/scrollspy.js | 231 ++++++++----------- js/tests/unit/scrollspy.spec.js | 389 +++++++++++++++----------------- 2 files changed, 278 insertions(+), 342 deletions(-) diff --git a/js/src/scrollspy.js b/js/src/scrollspy.js index 25fcd5ad2ac6..468d4bf9c517 100644 --- a/js/src/scrollspy.js +++ b/js/src/scrollspy.js @@ -5,12 +5,7 @@ * -------------------------------------------------------------------------- */ -import { - defineJQueryPlugin, - getElement, - getSelectorFromElement, - typeCheckConfig -} from './util/index' +import { defineJQueryPlugin, getElement, reflow, typeCheckConfig } from './util/index' import EventHandler from './dom/event-handler' import Manipulator from './dom/manipulator' import SelectorEngine from './dom/selector-engine' @@ -28,19 +23,18 @@ const EVENT_KEY = `.${DATA_KEY}` const DATA_API_KEY = '.data-api' const Default = { - offset: 10, - method: 'auto', - target: '' + target: null, + offset: null, // @deprecated, only for backwards Compatibility reasons + rootMargin: '0px 0px -40%' } const DefaultType = { - offset: 'number', - method: 'string', - target: '(string|element)' + target: 'element', + offset: '(number|null)', // @deprecated, only for backwards Compatibility reasons + rootMargin: 'string' } 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' @@ -51,13 +45,9 @@ 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 @@ -67,17 +57,15 @@ const METHOD_POSITION = 'position' 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._element is the observablesContainer + this._config = this._getConfig(config) - this.refresh() - this._process() + this._targetLinks = [] + this._activeTarget = null + this._observableSections = [] + this._observer = null + this.refresh() // initialize } // Getters @@ -93,49 +81,27 @@ class ScrollSpy extends BaseComponent { // 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 - ] - } - } + // `${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}, .${CLASS_NAME_DROPDOWN_ITEM}` + this._targetLinks = SelectorEngine + .find('[href]', this._config.target) + .filter(el => el.hash.length > 0)// ensure that all have id + + this._observableSections = this._targetLinks + .map(el => SelectorEngine.findOne(el.hash, this._element)) + .filter(el => el)// filter nulls + + reflow(this._element) + if (this._observer) { + this._observer.disconnect() + } else { + this._observer = this._getNewObserver() + } - return null - }) - .filter(item => item) - .sort((a, b) => a[0] - b[0]) - .forEach(item => { - this._offsets.push(item[0]) - this._targets.push(item[1]) - }) + this._observableSections.forEach(section => this._observer.observe(section)) } dispose() { + this._observer.disconnect() EventHandler.off(this._scrollElement, EVENT_KEY) super.dispose() } @@ -149,84 +115,33 @@ class ScrollSpy extends BaseComponent { ...(typeof config === 'object' && config ? config : {}) } - config.target = getElement(config.target) || document.documentElement + config.target = getElement(config.target) 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) - } - + _process(target) { + if (this._activeTarget === target) { return } - if (this._activeTarget && scrollTop < this._offsets[0] && this._offsets[0] > 0) { - this._activeTarget = null - this._clear() + this._clearActiveClass(this._config.target) + if (!target) { 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) + target.classList.add(CLASS_NAME_ACTIVE) - link.classList.add(CLASS_NAME_ACTIVE) - if (link.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) { - SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, link.closest(SELECTOR_DROPDOWN)) + if (target.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) { // Activate dropdown parents + SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, target.closest(SELECTOR_DROPDOWN)) .classList.add(CLASS_NAME_ACTIVE) } else { - SelectorEngine.parents(link, SELECTOR_NAV_LIST_GROUP) + SelectorEngine.parents(target, SELECTOR_NAV_LIST_GROUP) + SelectorEngine.parents(target, SELECTOR_NAV_LIST_GROUP) .forEach(listGroup => { // Set triggered links parents as active // With both ', '', '
', - '
', - '
', + '
test
', + '
test2
', '
' ].join('') @@ -84,7 +125,7 @@ describe('ScrollSpy', () => { target: '#navigation' }) - expect(scrollSpy._targets.length).toEqual(2) + expect(scrollSpy._targetLinks.length).toEqual(2) }) it('should only switch "active" class on current target', done => { @@ -113,49 +154,6 @@ describe('ScrollSpy', () => { '' ].join('') - const scrollSpyEl = fixtureEl.querySelector('#scrollspy-example') - const rootEl = fixtureEl.querySelector('#root') - const scrollSpy = new ScrollSpy(scrollSpyEl, { - target: 'ss-target' - }) - - spyOn(scrollSpy, '_process').and.callThrough() - - scrollSpyEl.addEventListener('scroll', () => { - expect(rootEl.classList.contains('active')).toEqual(true) - expect(scrollSpy._process).toHaveBeenCalled() - done() - }) - - scrollSpyEl.scrollTop = 350 - }) - - it('should only switch "active" class on current target specified w element', done => { - fixtureEl.innerHTML = [ - '
', - '
', - '
', - '
', - ' ', - '
', - '
', - '
', - '
', - '
', - '

Overview

', - '

', - '
', - '
', - '

Detail

', - '

', - '
', - '
', - '
' - ].join('') - const scrollSpyEl = fixtureEl.querySelector('#scrollspy-example') const rootEl = fixtureEl.querySelector('#root') const scrollSpy = new ScrollSpy(scrollSpyEl, { @@ -164,49 +162,52 @@ describe('ScrollSpy', () => { spyOn(scrollSpy, '_process').and.callThrough() - scrollSpyEl.addEventListener('scroll', () => { + onScrollStop(() => { expect(rootEl.classList.contains('active')).toEqual(true) + expect(scrollSpy._activeTarget).toEqual(fixtureEl.querySelector('[href="#detail"]')) expect(scrollSpy._process).toHaveBeenCalled() done() - }) + }, scrollSpyEl) - scrollSpyEl.scrollTop = 350 + scrollSpyEl.scrollTo({ top: 350, behavior: 'smooth' }) }) it('should correctly select middle navigation option when large offset is used', done => { fixtureEl.innerHTML = [ - '', - '', - '
', - '
', - '
', - '
', + '
', + ' ', + ' ', + '
', + '
', + '
', + '
', + '
', '
' ].join('') const contentEl = fixtureEl.querySelector('#content') - const scrollSpy = new ScrollSpy(contentEl, { + const scrollSpy = new ScrollSpy('#wrapper', { target: '#navigation', offset: Manipulator.position(contentEl).top }) spyOn(scrollSpy, '_process').and.callThrough() - contentEl.addEventListener('scroll', () => { + onScrollStop(() => { expect(fixtureEl.querySelector('#one-link').classList.contains('active')).toEqual(false) expect(fixtureEl.querySelector('#two-link').classList.contains('active')).toEqual(true) expect(fixtureEl.querySelector('#three-link').classList.contains('active')).toEqual(false) expect(scrollSpy._process).toHaveBeenCalled() done() - }) + }, contentEl) - contentEl.scrollTop = 550 + contentEl.scrollTo({ top: 550 }) }) it('should add the active class to the correct element', done => { @@ -249,7 +250,7 @@ describe('ScrollSpy', () => { }) }) - it('should add the active class to the correct element (nav markup)', done => { + it('should add to nav, the active class to the correct element (nav markup)', done => { fixtureEl.innerHTML = [ '