From ece16012270a9ef7781ce9269cb151c5e5961734 Mon Sep 17 00:00:00 2001 From: GeoSot Date: Wed, 13 Apr 2022 20:29:13 +0300 Subject: Revamp Scrollspy using Intersection observer (#33421) * Revamp scrollspy to use IntersectionObserver * Add smooth scroll support * Update scrollspy.js/md * move functionality to method * Update scrollspy.js * Add SmoothScroll to docs example * Refactor Using `Maps` and smaller methods * Update scrollspy.md/js * Update scrollspy.spec.js * Support backwards compatibility * minor optimizations * Merge activation functionality * Update scrollspy.md * Update scrollspy.js * Rewording some of the documentation changes * Update scrollspy.js * Update scrollspy.md * tweaking calculation functionality & drop text that suggests, to deactivate target when wrapper is not visible * tweak calculation * Fix lint * Support scrollspy in body & tests * change doc example to a more valid solution Co-authored-by: XhmikosR Co-authored-by: Patrick H. Lauke --- js/src/dom/manipulator.js | 16 - js/src/scrollspy.js | 264 ++++++++------- js/tests/unit/dom/manipulator.spec.js | 82 ----- js/tests/unit/scrollspy.spec.js | 617 +++++++++++++++++++++------------- 4 files changed, 527 insertions(+), 452 deletions(-) (limited to 'js') diff --git a/js/src/dom/manipulator.js b/js/src/dom/manipulator.js index e3ee293c7..5e6ad92ae 100644 --- a/js/src/dom/manipulator.js +++ b/js/src/dom/manipulator.js @@ -57,22 +57,6 @@ const Manipulator = { getDataAttribute(element, key) { return normalizeData(element.getAttribute(`data-bs-${normalizeDataKey(key)}`)) - }, - - offset(element) { - const rect = element.getBoundingClientRect() - - return { - top: rect.top + window.pageYOffset, - left: rect.left + window.pageXOffset - } - }, - - position(element) { - return { - top: element.offsetTop, - left: element.offsetLeft - } } } diff --git a/js/src/scrollspy.js b/js/src/scrollspy.js index 029970ed2..71c111a94 100644 --- a/js/src/scrollspy.js +++ b/js/src/scrollspy.js @@ -5,9 +5,8 @@ * -------------------------------------------------------------------------- */ -import { defineJQueryPlugin, getElement, getSelectorFromElement } from './util/index' +import { defineJQueryPlugin, getElement, isDisabled, isVisible } 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' @@ -21,34 +20,34 @@ 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 } 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' } /** @@ -58,16 +57,18 @@ const DefaultType = { class ScrollSpy extends BaseComponent { constructor(element, config) { super(element, config) - this._scrollElement = this._element.tagName === 'BODY' ? window : this._element - this._offsets = [] - this._targets = [] - this._activeTarget = null - this._scrollHeight = 0 - - EventHandler.on(this._scrollElement, EVENT_SCROLL, () => this._process()) - 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 @@ -85,145 +86,168 @@ class ScrollSpy extends BaseComponent { // Public refresh() { - this._offsets = [] - this._targets = [] - this._scrollHeight = this._getScrollHeight() - - 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 - 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) { - return null - } + this._initializeTargetsAndObservables() + this._maybeEnableSmoothScroll() - const targetBCR = target.getBoundingClientRect() - - return targetBCR.width || targetBCR.height ? - [Manipulator[offsetMethod](target).top + offsetBase, targetSelector] : - null - }) - .filter(Boolean) - .sort((a, b) => a[0] - b[0]) + if (this._observer) { + this._observer.disconnect() + } else { + this._observer = this._getNewObserver() + } - for (const target of targets) { - this._offsets.push(target[0]) - this._targets.push(target[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 - _configAfterMerge(config) { - config.target = getElement(config.target) || document.documentElement + // TODO: on v6 target should be given explicitly & remove the {target: 'ss-target'} case + config.target = getElement(config.target) || document.body 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 }) + 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: [0.1, 0.5, 1], + rootMargin: this._getRootMargin() + } + + 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) + } } + } + + // 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) - for (const i of this._offsets.keys()) { - 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(anchor.hash, this._element) - if (isActiveTarget) { - this._activate(this._targets[i]) + // ensure that the observableSection exists & is visible + if (isVisible(observableSection)) { + this._targetLinks.set(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
    and ', '
    ', - '
    ', - '
    ', + '
    test
    ', + '
    test2
    ', '
    ' ].join('') @@ -82,7 +169,7 @@ describe('ScrollSpy', () => { target: '#navigation' }) - expect(scrollSpy._targets).toHaveSize(2) + expect(scrollSpy._targetLinks).toHaveSize(2) }) it('should only switch "active" class on current target', () => { @@ -100,14 +187,8 @@ describe('ScrollSpy', () => { ' ', ' ', '
    ', - '
    ', - '

    Overview

    ', - '

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

    Detail

    ', - '

    ', - '
    ', + '
    Overview
    ', + '
    Detail
    ', '
    ', '' ].join('') @@ -120,13 +201,52 @@ describe('ScrollSpy', () => { spyOn(scrollSpy, '_process').and.callThrough() - scrollSpyEl.addEventListener('scroll', () => { + onScrollStop(() => { expect(rootEl).toHaveClass('active') expect(scrollSpy._process).toHaveBeenCalled() resolve() + }, scrollSpyEl) + + scrollTo(scrollSpyEl, 350) + }) + }) + + it('should not process data if `activeTarget` is same as given target', () => { + return new Promise((resolve, reject) => { + fixtureEl.innerHTML = [ + '', + '
    ', + '
    div 1
    ', + '
    div 2
    ', + '
    ' + ].join('') + + const contentEl = fixtureEl.querySelector('.content') + const scrollSpy = new ScrollSpy(contentEl, { + offset: 0, + target: '.navbar' }) - scrollSpyEl.scrollTop = 350 + const triggerSpy = spyOn(EventHandler, 'trigger').and.callThrough() + + scrollSpy._activeTarget = fixtureEl.querySelector('#a-1') + testElementIsActiveAfterScroll({ + elementSelector: '#a-1', + targetSelector: '#div-1', + contentEl, + scrollSpy, + cb: reject + }) + + setTimeout(() => { + expect(triggerSpy).not.toHaveBeenCalled() + resolve() + }, 100) }) }) @@ -145,14 +265,8 @@ describe('ScrollSpy', () => { ' ', ' ', '
    ', - '
    ', - '

    Overview

    ', - '

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

    Detail

    ', - '

    ', - '
    ', + '
    Overview
    ', + '
    Detail
    ', '
    ', '' ].join('') @@ -165,51 +279,14 @@ describe('ScrollSpy', () => { spyOn(scrollSpy, '_process').and.callThrough() - scrollSpyEl.addEventListener('scroll', () => { + onScrollStop(() => { expect(rootEl).toHaveClass('active') + expect(scrollSpy._activeTarget).toEqual(fixtureEl.querySelector('[href="#detail"]')) expect(scrollSpy._process).toHaveBeenCalled() resolve() - }) - - scrollSpyEl.scrollTop = 350 - }) - }) - - it('should correctly select middle navigation option when large offset is used', () => { - return new Promise(resolve => { - fixtureEl.innerHTML = [ - '', - '', - '
    ', - '
    ', - '
    ', - '
    ', - '
    ' - ].join('') - - const contentEl = fixtureEl.querySelector('#content') - const scrollSpy = new ScrollSpy(contentEl, { - target: '#navigation', - offset: Manipulator.position(contentEl).top - }) - - spyOn(scrollSpy, '_process').and.callThrough() - - contentEl.addEventListener('scroll', () => { - expect(fixtureEl.querySelector('#one-link')).not.toHaveClass('active') - expect(fixtureEl.querySelector('#two-link')).toHaveClass('active') - expect(fixtureEl.querySelector('#three-link')).not.toHaveClass('active') - expect(scrollSpy._process).toHaveBeenCalled() - resolve() - }) + }, scrollSpyEl) - contentEl.scrollTop = 550 + scrollTo(scrollSpyEl, 350) }) }) @@ -233,21 +310,18 @@ describe('ScrollSpy', () => { offset: 0, target: '.navbar' }) - const spy = spyOn(scrollSpy, '_process').and.callThrough() testElementIsActiveAfterScroll({ elementSelector: '#a-1', targetSelector: '#div-1', contentEl, scrollSpy, - spy, cb() { testElementIsActiveAfterScroll({ elementSelector: '#a-2', targetSelector: '#div-2', contentEl, scrollSpy, - spy, cb: resolve }) } @@ -255,7 +329,7 @@ describe('ScrollSpy', () => { }) }) - it('should add the active class to the correct element (nav markup)', () => { + it('should add to nav the active class to the correct element (nav markup)', () => { return new Promise(resolve => { fixtureEl.innerHTML = [ '
', '', '
', - '
', - '
', - '
', - '
', + '
', + '
text
', + '
text
', + '
text
', '
', '
' ].join('') @@ -362,29 +430,24 @@ describe('ScrollSpy', () => { const contentEl = fixtureEl.querySelector('#content') const scrollSpy = new ScrollSpy(contentEl, { target: '#navigation', - offset: Manipulator.position(contentEl).top + offset: contentEl.offsetTop }) const spy = spyOn(scrollSpy, '_process').and.callThrough() - let firstTime = true - - contentEl.addEventListener('scroll', () => { - const active = fixtureEl.querySelector('.active') - + onScrollStop(() => { + const active = () => fixtureEl.querySelector('.active') expect(spy).toHaveBeenCalled() - spy.calls.reset() - if (firstTime) { - expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1) - expect(active.getAttribute('id')).toEqual('two-link') - firstTime = false - contentEl.scrollTop = 0 - } else { - expect(active).toBeNull() + + expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1) + expect(active().getAttribute('id')).toEqual('two-link') + onScrollStop(() => { + expect(active()).toBeNull() resolve() - } - }) + }, contentEl) + scrollTo(contentEl, 0) + }, contentEl) - contentEl.scrollTop = 201 + scrollTo(contentEl, 200) }) }) @@ -399,43 +462,40 @@ describe('ScrollSpy', () => { ' ', ' ', '', - '
', - '
', - '
', - '
', - '
', + '
', + '
test
', + '
test
', + '
test
', + '
test
', '
' ].join('') - const negativeHeight = -10 + const negativeHeight = 0 const startOfSectionTwo = 101 const contentEl = fixtureEl.querySelector('#content') + // eslint-disable-next-line no-unused-vars const scrollSpy = new ScrollSpy(contentEl, { target: '#navigation', - offset: contentEl.offsetTop + rootMargin: '0px 0px -50%' }) - const spy = spyOn(scrollSpy, '_process').and.callThrough() - let firstTime = true + onScrollStop(() => { + const activeId = () => fixtureEl.querySelector('.active').getAttribute('id') - contentEl.addEventListener('scroll', () => { - const active = fixtureEl.querySelector('.active') + expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1) + expect(activeId()).toEqual('two-link') + scrollTo(contentEl, negativeHeight) - expect(spy).toHaveBeenCalled() - spy.calls.reset() - if (firstTime) { - expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1) - expect(active.getAttribute('id')).toEqual('two-link') - firstTime = false - contentEl.scrollTop = negativeHeight - } else { + onScrollStop(() => { expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1) - expect(active.getAttribute('id')).toEqual('one-link') + expect(activeId()).toEqual('one-link') resolve() - } - }) + }, contentEl) - contentEl.scrollTop = startOfSectionTwo + scrollTo(contentEl, 0) + }, contentEl) + + scrollTo(contentEl, startOfSectionTwo) }) }) @@ -465,46 +525,41 @@ describe('ScrollSpy', () => { offset: 0, target: '.navbar' }) - const spy = spyOn(scrollSpy, '_process').and.callThrough() + scrollTo(contentEl, 0) testElementIsActiveAfterScroll({ elementSelector: '#li-100-5', targetSelector: '#div-100-5', - scrollSpy, - spy, contentEl, + scrollSpy, cb() { - contentEl.scrollTop = 0 + scrollTo(contentEl, 0) testElementIsActiveAfterScroll({ - elementSelector: '#li-100-4', - targetSelector: '#div-100-4', - scrollSpy, - spy, + elementSelector: '#li-100-2', + targetSelector: '#div-100-2', contentEl, + scrollSpy, cb() { - contentEl.scrollTop = 0 + scrollTo(contentEl, 0) testElementIsActiveAfterScroll({ elementSelector: '#li-100-3', targetSelector: '#div-100-3', - scrollSpy, - spy, contentEl, + scrollSpy, cb() { - contentEl.scrollTop = 0 + scrollTo(contentEl, 0) testElementIsActiveAfterScroll({ elementSelector: '#li-100-2', targetSelector: '#div-100-2', - scrollSpy, - spy, contentEl, + scrollSpy, cb() { - contentEl.scrollTop = 0 + scrollTo(contentEl, 0) testElementIsActiveAfterScroll({ elementSelector: '#li-100-1', targetSelector: '#div-100-1', - scrollSpy, - spy, contentEl, + scrollSpy, cb: resolve }) } @@ -517,116 +572,73 @@ describe('ScrollSpy', () => { }) }) }) + }) - it('should allow passed in option offset method: offset', () => { - fixtureEl.innerHTML = [ - '', - '
', - '
div 1
', - '
div 2
', - '
div 3
', - '
' - ].join('') - - const contentEl = fixtureEl.querySelector('.content') - const targetEl = fixtureEl.querySelector('#div-jsm-2') - const scrollSpy = new ScrollSpy(contentEl, { - target: '.navbar', - offset: 0, - method: 'offset' - }) + describe('refresh', () => { + it('should disconnect existing observer', () => { + fixtureEl.innerHTML = getDummyFixture() - expect(scrollSpy._offsets[1]).toEqual(Manipulator.offset(targetEl).top) - expect(scrollSpy._offsets[1]).not.toEqual(Manipulator.position(targetEl).top) - }) + const el = fixtureEl.querySelector('.content') + const scrollSpy = new ScrollSpy(el) - it('should allow passed in option offset method: position', () => { - fixtureEl.innerHTML = [ - '', - '
', - '
div 1
', - '
div 2
', - '
div 3
', - '
' - ].join('') + spyOn(scrollSpy._observer, 'disconnect') - const contentEl = fixtureEl.querySelector('.content') - const targetEl = fixtureEl.querySelector('#div-jsm-2') - const scrollSpy = new ScrollSpy(contentEl, { - target: '.navbar', - offset: 0, - method: 'position' - }) + scrollSpy.refresh() - expect(scrollSpy._offsets[1]).not.toEqual(Manipulator.offset(targetEl).top) - expect(scrollSpy._offsets[1]).toEqual(Manipulator.position(targetEl).top) + expect(scrollSpy._observer.disconnect).toHaveBeenCalled() }) }) describe('dispose', () => { it('should dispose a scrollspy', () => { - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = getDummyFixture() - const divEl = fixtureEl.querySelector('div') - spyOn(divEl, 'addEventListener').and.callThrough() - spyOn(divEl, 'removeEventListener').and.callThrough() + const el = fixtureEl.querySelector('.content') + const scrollSpy = new ScrollSpy(el) - const scrollSpy = new ScrollSpy(divEl) - expect(divEl.addEventListener).toHaveBeenCalledWith('scroll', jasmine.any(Function), jasmine.any(Boolean)) + expect(ScrollSpy.getInstance(el)).not.toBeNull() scrollSpy.dispose() - expect(divEl.removeEventListener).toHaveBeenCalledWith('scroll', jasmine.any(Function), jasmine.any(Boolean)) + expect(ScrollSpy.getInstance(el)).toBeNull() }) }) describe('jQueryInterface', () => { it('should create a scrollspy', () => { - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = getDummyFixture() - const div = fixtureEl.querySelector('div') + const div = fixtureEl.querySelector('.content') jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface jQueryMock.elements = [div] - jQueryMock.fn.scrollspy.call(jQueryMock) + jQueryMock.fn.scrollspy.call(jQueryMock, { target: '#navBar' }) expect(ScrollSpy.getInstance(div)).not.toBeNull() }) it('should create a scrollspy with given config', () => { - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = getDummyFixture() - const div = fixtureEl.querySelector('div') + const div = fixtureEl.querySelector('.content') jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface jQueryMock.elements = [div] - jQueryMock.fn.scrollspy.call(jQueryMock, { offset: 15 }) + jQueryMock.fn.scrollspy.call(jQueryMock, { rootMargin: '100px' }) spyOn(ScrollSpy.prototype, 'constructor') - expect(ScrollSpy.prototype.constructor).not.toHaveBeenCalledWith(div, { offset: 15 }) + expect(ScrollSpy.prototype.constructor).not.toHaveBeenCalledWith(div, { rootMargin: '100px' }) const scrollspy = ScrollSpy.getInstance(div) expect(scrollspy).not.toBeNull() - expect(scrollspy._config.offset).toEqual(15) + expect(scrollspy._config.rootMargin).toEqual('100px') }) it('should not re create a scrollspy', () => { - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = getDummyFixture() - const div = fixtureEl.querySelector('div') + const div = fixtureEl.querySelector('.content') const scrollSpy = new ScrollSpy(div) jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface @@ -638,9 +650,9 @@ describe('ScrollSpy', () => { }) it('should call a scrollspy method', () => { - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = getDummyFixture() - const div = fixtureEl.querySelector('div') + const div = fixtureEl.querySelector('.content') const scrollSpy = new ScrollSpy(div) spyOn(scrollSpy, 'refresh') @@ -655,9 +667,9 @@ describe('ScrollSpy', () => { }) it('should throw error on undefined method', () => { - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = getDummyFixture() - const div = fixtureEl.querySelector('div') + const div = fixtureEl.querySelector('.content') const action = 'undefinedMethod' jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface @@ -667,29 +679,60 @@ describe('ScrollSpy', () => { jQueryMock.fn.scrollspy.call(jQueryMock, action) }).toThrowError(TypeError, `No method named "${action}"`) }) + + it('should throw error on protected method', () => { + fixtureEl.innerHTML = getDummyFixture() + + const div = fixtureEl.querySelector('.content') + const action = '_getConfig' + + jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface + jQueryMock.elements = [div] + + expect(() => { + jQueryMock.fn.scrollspy.call(jQueryMock, action) + }).toThrowError(TypeError, `No method named "${action}"`) + }) + + it('should throw error if method "constructor" is being called', () => { + fixtureEl.innerHTML = getDummyFixture() + + const div = fixtureEl.querySelector('.content') + const action = 'constructor' + + jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface + jQueryMock.elements = [div] + + expect(() => { + jQueryMock.fn.scrollspy.call(jQueryMock, action) + }).toThrowError(TypeError, `No method named "${action}"`) + }) }) describe('getInstance', () => { it('should return scrollspy instance', () => { - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = getDummyFixture() - const div = fixtureEl.querySelector('div') - const scrollSpy = new ScrollSpy(div) + const div = fixtureEl.querySelector('.content') + const scrollSpy = new ScrollSpy(div, { target: fixtureEl.querySelector('#navBar') }) expect(ScrollSpy.getInstance(div)).toEqual(scrollSpy) expect(ScrollSpy.getInstance(div)).toBeInstanceOf(ScrollSpy) }) it('should return null if there is no instance', () => { - expect(ScrollSpy.getInstance(fixtureEl)).toBeNull() + fixtureEl.innerHTML = getDummyFixture() + + const div = fixtureEl.querySelector('.content') + expect(ScrollSpy.getInstance(div)).toBeNull() }) }) describe('getOrCreateInstance', () => { it('should return scrollspy instance', () => { - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = getDummyFixture() - const div = fixtureEl.querySelector('div') + const div = fixtureEl.querySelector('.content') const scrollspy = new ScrollSpy(div) expect(ScrollSpy.getOrCreateInstance(div)).toEqual(scrollspy) @@ -698,18 +741,18 @@ describe('ScrollSpy', () => { }) it('should return new instance when there is no scrollspy instance', () => { - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = getDummyFixture() - const div = fixtureEl.querySelector('div') + const div = fixtureEl.querySelector('.content') expect(ScrollSpy.getInstance(div)).toBeNull() expect(ScrollSpy.getOrCreateInstance(div)).toBeInstanceOf(ScrollSpy) }) it('should return new instance when there is no scrollspy instance with given configuration', () => { - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = getDummyFixture() - const div = fixtureEl.querySelector('div') + const div = fixtureEl.querySelector('.content') expect(ScrollSpy.getInstance(div)).toBeNull() const scrollspy = ScrollSpy.getOrCreateInstance(div, { @@ -721,9 +764,9 @@ describe('ScrollSpy', () => { }) it('should return the instance when exists without given configuration', () => { - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = getDummyFixture() - const div = fixtureEl.querySelector('div') + const div = fixtureEl.querySelector('.content') const scrollspy = new ScrollSpy(div, { offset: 1 }) @@ -741,13 +784,119 @@ describe('ScrollSpy', () => { describe('event handler', () => { it('should create scrollspy on window load event', () => { - fixtureEl.innerHTML = '
' + fixtureEl.innerHTML = [ + '' + + '
' + ].join('') - const scrollSpyEl = fixtureEl.querySelector('div') + const scrollSpyEl = fixtureEl.querySelector('#wrapper') window.dispatchEvent(createEvent('load')) expect(ScrollSpy.getInstance(scrollSpyEl)).not.toBeNull() }) }) + + describe('SmoothScroll', () => { + it('should not enable smoothScroll', () => { + fixtureEl.innerHTML = getDummyFixture() + const offSpy = spyOn(EventHandler, 'off').and.callThrough() + const onSpy = spyOn(EventHandler, 'on').and.callThrough() + + const div = fixtureEl.querySelector('.content') + const target = fixtureEl.querySelector('#navBar') + // eslint-disable-next-line no-new + new ScrollSpy(div, { + offset: 1 + }) + + expect(offSpy).not.toHaveBeenCalledWith(target, 'click.bs.scrollspy') + expect(onSpy).not.toHaveBeenCalledWith(target, 'click.bs.scrollspy') + }) + + it('should enable smoothScroll', () => { + fixtureEl.innerHTML = getDummyFixture() + const offSpy = spyOn(EventHandler, 'off').and.callThrough() + const onSpy = spyOn(EventHandler, 'on').and.callThrough() + + const div = fixtureEl.querySelector('.content') + const target = fixtureEl.querySelector('#navBar') + // eslint-disable-next-line no-new + new ScrollSpy(div, { + offset: 1, + smoothScroll: true + }) + + expect(offSpy).toHaveBeenCalledWith(target, 'click.bs.scrollspy') + expect(onSpy).toHaveBeenCalledWith(target, 'click.bs.scrollspy', '[href]', jasmine.any(Function)) + }) + + it('should not smoothScroll to element if it not handles a scrollspy section', () => { + fixtureEl.innerHTML = [ + '', + '
', + '
div 1
', + '
' + ].join('') + + const div = fixtureEl.querySelector('.content') + // eslint-disable-next-line no-new + new ScrollSpy(div, { + offset: 1, + smoothScroll: true + }) + + const clickSpy = getElementScrollSpy(div) + + fixtureEl.querySelector('#anchor-2').click() + expect(clickSpy).not.toHaveBeenCalled() + }) + + it('should call `scrollTop` if element doesn\'t not support `scrollTo`', () => { + fixtureEl.innerHTML = getDummyFixture() + + const div = fixtureEl.querySelector('.content') + const link = fixtureEl.querySelector('[href="#div-jsm-1"]') + delete div.scrollTo + const clickSpy = getElementScrollSpy(div) + // eslint-disable-next-line no-new + new ScrollSpy(div, { + offset: 1, + smoothScroll: true + }) + + link.click() + expect(clickSpy).toHaveBeenCalled() + }) + + it('should smoothScroll to the proper observable element on anchor click', done => { + fixtureEl.innerHTML = getDummyFixture() + + const div = fixtureEl.querySelector('.content') + const link = fixtureEl.querySelector('[href="#div-jsm-1"]') + const observable = fixtureEl.querySelector('#div-jsm-1') + const clickSpy = getElementScrollSpy(div) + // eslint-disable-next-line no-new + new ScrollSpy(div, { + offset: 1, + smoothScroll: true + }) + + setTimeout(() => { + if (div.scrollTo) { + expect(clickSpy).toHaveBeenCalledWith({ top: observable.offsetTop - div.offsetTop }) + } else { + expect(clickSpy).toHaveBeenCalledWith(observable.offsetTop - div.offsetTop) + } + + done() + }, 100) + link.click() + }) + }) }) -- cgit v1.2.3