import EventHandler from '../../src/dom/event-handler.js' import ScrollSpy from '../../src/scrollspy.js' import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture.js' describe('ScrollSpy', () => { let fixtureEl const getElementScrollSpy = element => element.scrollTo ? spyOn(element, 'scrollTo').and.callThrough() : spyOnProperty(element, 'scrollTop', 'set').and.callThrough() const scrollTo = (el, height) => { el.scrollTop = height } const onScrollStop = (callback, element, timeout = 30) => { let handle = null const onScroll = function () { if (handle) { window.clearTimeout(handle) } handle = setTimeout(() => { element.removeEventListener('scroll', onScroll) callback() }, timeout + 1) } element.addEventListener('scroll', onScroll) } const getDummyFixture = () => { return [ '', '
', '
div 1
', '
' ].join('') } const testElementIsActiveAfterScroll = ({ elementSelector, targetSelector, contentEl, scrollSpy, cb }) => { const element = fixtureEl.querySelector(elementSelector) const target = fixtureEl.querySelector(targetSelector) // add top padding to fix Chrome on Android failures const paddingTop = 0 const parentOffset = getComputedStyle(contentEl).getPropertyValue('position') === 'relative' ? 0 : contentEl.offsetTop const scrollHeight = (target.offsetTop - parentOffset) + paddingTop contentEl.addEventListener('activate.bs.scrollspy', event => { if (scrollSpy._activeTarget !== element) { return } expect(element).toHaveClass('active') expect(scrollSpy._activeTarget).toEqual(element) expect(event.relatedTarget).toEqual(element) cb() }) setTimeout(() => { // in case we scroll something before the test scrollTo(contentEl, scrollHeight) }, 100) } beforeAll(() => { fixtureEl = getFixture() }) afterEach(() => { clearFixture() }) describe('VERSION', () => { it('should return plugin version', () => { expect(ScrollSpy.VERSION).toEqual(jasmine.any(String)) }) }) describe('Default', () => { it('should return plugin default config', () => { expect(ScrollSpy.Default).toEqual(jasmine.any(Object)) }) }) describe('DATA_KEY', () => { it('should return plugin data key', () => { expect(ScrollSpy.DATA_KEY).toEqual('bs.scrollspy') }) }) describe('constructor', () => { it('should take care of element either passed as a CSS selector or DOM element', () => { fixtureEl.innerHTML = getDummyFixture() const sSpyEl = fixtureEl.querySelector('.content') const sSpyBySelector = new ScrollSpy('.content') const sSpyByElement = new ScrollSpy(sSpyEl) expect(sSpyBySelector._element).toEqual(sSpyEl) expect(sSpyByElement._element).toEqual(sSpyEl) }) it('should null, if element is not scrollable', () => { fixtureEl.innerHTML = [ '', '
', '
test
', '
' ].join('') const scrollSpy = new ScrollSpy(fixtureEl.querySelector('#content'), { target: '#navigation' }) expect(scrollSpy._observer.root).toBeNull() expect(scrollSpy._rootElement).toBeNull() }) it('should respect threshold option', () => { fixtureEl.innerHTML = [ '', '
', ' ', '
' ].join('') const scrollSpy = new ScrollSpy('#content', { target: '#navigation', threshold: [1] }) expect(scrollSpy._observer.thresholds).toEqual([1]) }) it('should respect threshold option markup', () => { fixtureEl.innerHTML = [ '', '
', ' ', '
' ].join('') const scrollSpy = new ScrollSpy('#content', { target: '#navigation' }) // See https://stackoverflow.com/a/45592926 const expectToBeCloseToArray = (actual, expected) => { expect(actual.length).toBe(expected.length) for (const x of actual) { const i = actual.indexOf(x) expect(x).withContext(`[${i}]`).toBeCloseTo(expected[i]) } } expectToBeCloseToArray(scrollSpy._observer.thresholds, [0, 0.2, 1]) }) it('should not take count to not visible sections', () => { fixtureEl.innerHTML = [ '', '
', '
test
', ' ', ' ', '
' ].join('') const scrollSpy = new ScrollSpy(fixtureEl.querySelector('#content'), { target: '#navigation' }) expect(scrollSpy._observableSections.size).toBe(1) expect(scrollSpy._targetLinks.size).toBe(1) }) it('should not process element without target', () => { fixtureEl.innerHTML = [ '', '
', '
test
', '
test2
', '
' ].join('') const scrollSpy = new ScrollSpy(fixtureEl.querySelector('#content'), { target: '#navigation' }) expect(scrollSpy._targetLinks).toHaveSize(2) }) it('should only switch "active" class on current target', () => { return new Promise(resolve => { fixtureEl.innerHTML = [ '
', '
', '
', '
', ' ', '
', '
', '
', '
', '
Overview
', '
Detail
', '
', '
' ].join('') const scrollSpyEl = fixtureEl.querySelector('#scrollspy-example') const rootEl = fixtureEl.querySelector('#root') const scrollSpy = new ScrollSpy(scrollSpyEl, { target: 'ss-target' }) const spy = spyOn(scrollSpy, '_process').and.callThrough() onScrollStop(() => { expect(rootEl).toHaveClass('active') expect(spy).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' }) 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) }) }) it('should only switch "active" class on current target specified w element', () => { return new Promise(resolve => { fixtureEl.innerHTML = [ '
', '
', '
', '
', ' ', '
', '
', '
', '
', '
Overview
', '
Detail
', '
', '
' ].join('') const scrollSpyEl = fixtureEl.querySelector('#scrollspy-example') const rootEl = fixtureEl.querySelector('#root') const scrollSpy = new ScrollSpy(scrollSpyEl, { target: fixtureEl.querySelector('#ss-target') }) const spy = spyOn(scrollSpy, '_process').and.callThrough() onScrollStop(() => { expect(rootEl).toHaveClass('active') expect(scrollSpy._activeTarget).toEqual(fixtureEl.querySelector('[href="#detail"]')) expect(spy).toHaveBeenCalled() resolve() }, scrollSpyEl) scrollTo(scrollSpyEl, 350) }) }) it('should add the active class to the correct element', () => { return new Promise(resolve => { fixtureEl.innerHTML = [ '', '
', '
div 1
', '
div 2
', '
' ].join('') const contentEl = fixtureEl.querySelector('.content') const scrollSpy = new ScrollSpy(contentEl, { offset: 0, target: '.navbar' }) testElementIsActiveAfterScroll({ elementSelector: '#a-1', targetSelector: '#div-1', contentEl, scrollSpy, cb() { testElementIsActiveAfterScroll({ elementSelector: '#a-2', targetSelector: '#div-2', contentEl, scrollSpy, cb: resolve }) } }) }) }) it('should add to nav the active class to the correct element (nav markup)', () => { return new Promise(resolve => { fixtureEl.innerHTML = [ '', '
', '
div 1
', '
div 2
', '
' ].join('') const contentEl = fixtureEl.querySelector('.content') const scrollSpy = new ScrollSpy(contentEl, { offset: 0, target: '.navbar' }) testElementIsActiveAfterScroll({ elementSelector: '#a-1', targetSelector: '#div-1', contentEl, scrollSpy, cb() { testElementIsActiveAfterScroll({ elementSelector: '#a-2', targetSelector: '#div-2', contentEl, scrollSpy, cb: resolve }) } }) }) }) it('should add to list-group, the active class to the correct element (list-group markup)', () => { return new Promise(resolve => { fixtureEl.innerHTML = [ '', '
', '
div 1
', '
div 2
', '
' ].join('') const contentEl = fixtureEl.querySelector('.content') const scrollSpy = new ScrollSpy(contentEl, { offset: 0, target: '.navbar' }) testElementIsActiveAfterScroll({ elementSelector: '#a-1', targetSelector: '#div-1', contentEl, scrollSpy, cb() { testElementIsActiveAfterScroll({ elementSelector: '#a-2', targetSelector: '#div-2', contentEl, scrollSpy, cb: resolve }) } }) }) }) it('should clear selection if above the first section', () => { return new Promise(resolve => { fixtureEl.innerHTML = [ '', '', '
', '
', '
text
', '
text
', '
text
', '
', '
' ].join('') const contentEl = fixtureEl.querySelector('#content') const scrollSpy = new ScrollSpy(contentEl, { target: '#navigation', offset: contentEl.offsetTop }) const spy = spyOn(scrollSpy, '_process').and.callThrough() onScrollStop(() => { const active = () => fixtureEl.querySelector('.active') expect(spy).toHaveBeenCalled() expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1) expect(active().getAttribute('id')).toEqual('two-link') onScrollStop(() => { expect(active()).toBeNull() resolve() }, contentEl) scrollTo(contentEl, 0) }, contentEl) scrollTo(contentEl, 200) }) }) it('should not clear selection if above the first section and first section is at the top', () => { return new Promise(resolve => { fixtureEl.innerHTML = [ '', '', '
', '
test
', '
test
', '
test
', '
test
', '
' ].join('') 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', rootMargin: '0px 0px -50%' }) onScrollStop(() => { const activeId = () => fixtureEl.querySelector('.active').getAttribute('id') expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1) expect(activeId()).toEqual('two-link') scrollTo(contentEl, negativeHeight) onScrollStop(() => { expect(fixtureEl.querySelectorAll('.active')).toHaveSize(1) expect(activeId()).toEqual('one-link') resolve() }, contentEl) scrollTo(contentEl, 0) }, contentEl) scrollTo(contentEl, startOfSectionTwo) }) }) it('should correctly select navigation element on backward scrolling when each target section height is 100%', () => { return new Promise(resolve => { fixtureEl.innerHTML = [ '', '
', '
div 1
', '
div 2
', '
div 3
', '
div 4
', '
div 5
', '
' ].join('') const contentEl = fixtureEl.querySelector('.content') const scrollSpy = new ScrollSpy(contentEl, { offset: 0, target: '.navbar' }) scrollTo(contentEl, 0) testElementIsActiveAfterScroll({ elementSelector: '#li-100-5', targetSelector: '#div-100-5', contentEl, scrollSpy, cb() { scrollTo(contentEl, 0) testElementIsActiveAfterScroll({ elementSelector: '#li-100-2', targetSelector: '#div-100-2', contentEl, scrollSpy, cb() { scrollTo(contentEl, 0) testElementIsActiveAfterScroll({ elementSelector: '#li-100-3', targetSelector: '#div-100-3', contentEl, scrollSpy, cb() { scrollTo(contentEl, 0) testElementIsActiveAfterScroll({ elementSelector: '#li-100-2', targetSelector: '#div-100-2', contentEl, scrollSpy, cb() { scrollTo(contentEl, 0) testElementIsActiveAfterScroll({ elementSelector: '#li-100-1', targetSelector: '#div-100-1', contentEl, scrollSpy, cb: resolve }) } }) } }) } }) } }) }) }) }) describe('refresh', () => { it('should disconnect existing observer', () => { fixtureEl.innerHTML = getDummyFixture() const el = fixtureEl.querySelector('.content') const scrollSpy = new ScrollSpy(el) const spy = spyOn(scrollSpy._observer, 'disconnect') scrollSpy.refresh() expect(spy).toHaveBeenCalled() }) }) describe('dispose', () => { it('should dispose a scrollspy', () => { fixtureEl.innerHTML = getDummyFixture() const el = fixtureEl.querySelector('.content') const scrollSpy = new ScrollSpy(el) expect(ScrollSpy.getInstance(el)).not.toBeNull() scrollSpy.dispose() expect(ScrollSpy.getInstance(el)).toBeNull() }) }) describe('jQueryInterface', () => { it('should create a scrollspy', () => { fixtureEl.innerHTML = getDummyFixture() const div = fixtureEl.querySelector('.content') jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface jQueryMock.elements = [div] jQueryMock.fn.scrollspy.call(jQueryMock, { target: '#navBar' }) expect(ScrollSpy.getInstance(div)).not.toBeNull() }) it('should create a scrollspy with given config', () => { fixtureEl.innerHTML = getDummyFixture() const div = fixtureEl.querySelector('.content') jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface jQueryMock.elements = [div] jQueryMock.fn.scrollspy.call(jQueryMock, { rootMargin: '100px' }) const spy = spyOn(ScrollSpy.prototype, 'constructor') expect(spy).not.toHaveBeenCalledWith(div, { rootMargin: '100px' }) const scrollspy = ScrollSpy.getInstance(div) expect(scrollspy).not.toBeNull() expect(scrollspy._config.rootMargin).toEqual('100px') }) it('should not re create a scrollspy', () => { fixtureEl.innerHTML = getDummyFixture() const div = fixtureEl.querySelector('.content') const scrollSpy = new ScrollSpy(div) jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface jQueryMock.elements = [div] jQueryMock.fn.scrollspy.call(jQueryMock) expect(ScrollSpy.getInstance(div)).toEqual(scrollSpy) }) it('should call a scrollspy method', () => { fixtureEl.innerHTML = getDummyFixture() const div = fixtureEl.querySelector('.content') const scrollSpy = new ScrollSpy(div) const spy = spyOn(scrollSpy, 'refresh') jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface jQueryMock.elements = [div] jQueryMock.fn.scrollspy.call(jQueryMock, 'refresh') expect(ScrollSpy.getInstance(div)).toEqual(scrollSpy) expect(spy).toHaveBeenCalled() }) it('should throw error on undefined method', () => { fixtureEl.innerHTML = getDummyFixture() const div = fixtureEl.querySelector('.content') const action = 'undefinedMethod' 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 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 = getDummyFixture() 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', () => { fixtureEl.innerHTML = getDummyFixture() const div = fixtureEl.querySelector('.content') expect(ScrollSpy.getInstance(div)).toBeNull() }) }) describe('getOrCreateInstance', () => { it('should return scrollspy instance', () => { fixtureEl.innerHTML = getDummyFixture() const div = fixtureEl.querySelector('.content') const scrollspy = new ScrollSpy(div) expect(ScrollSpy.getOrCreateInstance(div)).toEqual(scrollspy) expect(ScrollSpy.getInstance(div)).toEqual(ScrollSpy.getOrCreateInstance(div, {})) expect(ScrollSpy.getOrCreateInstance(div)).toBeInstanceOf(ScrollSpy) }) it('should return new instance when there is no scrollspy instance', () => { fixtureEl.innerHTML = getDummyFixture() 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 = getDummyFixture() const div = fixtureEl.querySelector('.content') expect(ScrollSpy.getInstance(div)).toBeNull() const scrollspy = ScrollSpy.getOrCreateInstance(div, { offset: 1 }) expect(scrollspy).toBeInstanceOf(ScrollSpy) expect(scrollspy._config.offset).toEqual(1) }) it('should return the instance when exists without given configuration', () => { fixtureEl.innerHTML = getDummyFixture() const div = fixtureEl.querySelector('.content') const scrollspy = new ScrollSpy(div, { offset: 1 }) expect(ScrollSpy.getInstance(div)).toEqual(scrollspy) const scrollspy2 = ScrollSpy.getOrCreateInstance(div, { offset: 2 }) expect(scrollspy).toBeInstanceOf(ScrollSpy) expect(scrollspy2).toEqual(scrollspy) expect(scrollspy2._config.offset).toEqual(1) }) }) describe('event handler', () => { it('should create scrollspy on window load event', () => { fixtureEl.innerHTML = [ '' + '
' ].join('') 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, behavior: 'smooth' }) } else { expect(clickSpy).toHaveBeenCalledWith(observable.offsetTop - div.offsetTop) } done() }, 100) link.click() }) it('should smoothscroll to observable with anchor link that contains a french word as id', done => { fixtureEl.innerHTML = [ '', '
', '
div 1
', '
' ].join('') const div = fixtureEl.querySelector('.content') const link = fixtureEl.querySelector('[href="#présentation"]') const observable = fixtureEl.querySelector('#présentation') 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, behavior: 'smooth' }) } else { expect(clickSpy).toHaveBeenCalledWith(observable.offsetTop - div.offsetTop) } done() }, 100) link.click() }) }) })