diff options
Diffstat (limited to 'js/tests/unit')
23 files changed, 4459 insertions, 602 deletions
diff --git a/js/tests/unit/alert.spec.js b/js/tests/unit/alert.spec.js index 916c7fd07..72cd23d89 100644 --- a/js/tests/unit/alert.spec.js +++ b/js/tests/unit/alert.spec.js @@ -2,7 +2,7 @@ import Alert from '../../src/alert' import { getTransitionDurationFromElement } from '../../src/util/index' /** Test helpers */ -import { getFixture, clearFixture, jQueryMock } from '../helpers/fixture' +import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture' describe('Alert', () => { let fixtureEl @@ -15,6 +15,17 @@ describe('Alert', () => { clearFixture() }) + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '<div class="alert"></div>' + + const alertEl = fixtureEl.querySelector('.alert') + const alertBySelector = new Alert('.alert') + const alertByElement = new Alert(alertEl) + + expect(alertBySelector._element).toEqual(alertEl) + expect(alertByElement._element).toEqual(alertEl) + }) + it('should return version', () => { expect(typeof Alert.VERSION).toEqual('string') }) @@ -91,25 +102,20 @@ describe('Alert', () => { it('should not remove alert if close event is prevented', done => { fixtureEl.innerHTML = '<div class="alert"></div>' - const alertEl = document.querySelector('.alert') + const getAlert = () => document.querySelector('.alert') + const alertEl = getAlert() const alert = new Alert(alertEl) - const endTest = () => { + alertEl.addEventListener('close.bs.alert', event => { + event.preventDefault() setTimeout(() => { - expect(alert._removeElement).not.toHaveBeenCalled() + expect(getAlert()).not.toBeNull() done() }, 10) - } - - spyOn(alert, '_removeElement') - - alertEl.addEventListener('close.bs.alert', event => { - event.preventDefault() - endTest() }) alertEl.addEventListener('closed.bs.alert', () => { - endTest() + throw new Error('should not fire closed event') }) alert.close() @@ -123,7 +129,7 @@ describe('Alert', () => { const alertEl = document.querySelector('.alert') const alert = new Alert(alertEl) - expect(Alert.getInstance(alertEl)).toBeDefined() + expect(Alert.getInstance(alertEl)).not.toBeNull() alert.dispose() @@ -156,9 +162,9 @@ describe('Alert', () => { jQueryMock.fn.alert = Alert.jQueryInterface jQueryMock.elements = [alertEl] + expect(Alert.getInstance(alertEl)).toBeNull() jQueryMock.fn.alert.call(jQueryMock, 'close') - expect(Alert.getInstance(alertEl)).toBeDefined() expect(fixtureEl.querySelector('.alert')).toBeNull() }) @@ -172,7 +178,7 @@ describe('Alert', () => { jQueryMock.fn.alert.call(jQueryMock) - expect(Alert.getInstance(alertEl)).toBeDefined() + expect(Alert.getInstance(alertEl)).not.toBeNull() expect(fixtureEl.querySelector('.alert')).not.toBeNull() }) }) @@ -196,4 +202,26 @@ describe('Alert', () => { expect(Alert.getInstance(div)).toEqual(null) }) }) + + describe('getOrCreateInstance', () => { + it('should return alert instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const alert = new Alert(div) + + expect(Alert.getOrCreateInstance(div)).toEqual(alert) + expect(Alert.getInstance(div)).toEqual(Alert.getOrCreateInstance(div, {})) + expect(Alert.getOrCreateInstance(div)).toBeInstanceOf(Alert) + }) + + it('should return new instance when there is no alert instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Alert.getInstance(div)).toEqual(null) + expect(Alert.getOrCreateInstance(div)).toBeInstanceOf(Alert) + }) + }) }) diff --git a/js/tests/unit/base-component.spec.js b/js/tests/unit/base-component.spec.js new file mode 100644 index 000000000..b8ec83f12 --- /dev/null +++ b/js/tests/unit/base-component.spec.js @@ -0,0 +1,147 @@ +import BaseComponent from '../../src/base-component' +import { clearFixture, getFixture } from '../helpers/fixture' +import EventHandler from '../../src/dom/event-handler' +import { noop } from '../../src/util' + +class DummyClass extends BaseComponent { + constructor(element) { + super(element) + + EventHandler.on(this._element, `click${DummyClass.EVENT_KEY}`, noop) + } + + static get NAME() { + return 'dummy' + } +} + +describe('Base Component', () => { + let fixtureEl + const name = 'dummy' + let element + let instance + const createInstance = () => { + fixtureEl.innerHTML = '<div id="foo"></div>' + element = fixtureEl.querySelector('#foo') + instance = new DummyClass(element) + } + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('Static Methods', () => { + describe('VERSION', () => { + it('should return version', () => { + expect(typeof DummyClass.VERSION).toEqual('string') + }) + }) + + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(DummyClass.DATA_KEY).toEqual(`bs.${name}`) + }) + }) + + describe('NAME', () => { + it('should return plugin NAME', () => { + expect(DummyClass.NAME).toEqual(name) + }) + }) + + describe('EVENT_KEY', () => { + it('should return plugin event key', () => { + expect(DummyClass.EVENT_KEY).toEqual(`.bs.${name}`) + }) + }) + }) + describe('Public Methods', () => { + describe('constructor', () => { + it('should accept element, either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = [ + '<div id="foo"></div>', + '<div id="bar"></div>' + ].join('') + + const el = fixtureEl.querySelector('#foo') + const elInstance = new DummyClass(el) + const selectorInstance = new DummyClass('#bar') + + expect(elInstance._element).toEqual(el) + expect(selectorInstance._element).toEqual(fixtureEl.querySelector('#bar')) + }) + }) + describe('dispose', () => { + it('should dispose an component', () => { + createInstance() + expect(DummyClass.getInstance(element)).not.toBeNull() + + instance.dispose() + + expect(DummyClass.getInstance(element)).toBeNull() + expect(instance._element).toBeNull() + }) + + it('should de-register element event listeners', () => { + createInstance() + spyOn(EventHandler, 'off') + + instance.dispose() + + expect(EventHandler.off).toHaveBeenCalledWith(element, DummyClass.EVENT_KEY) + }) + }) + + describe('getInstance', () => { + it('should return an instance', () => { + createInstance() + + expect(DummyClass.getInstance(element)).toEqual(instance) + expect(DummyClass.getInstance(element)).toBeInstanceOf(DummyClass) + }) + + it('should accept element, either passed as a CSS selector, jQuery element, or DOM element', () => { + createInstance() + + expect(DummyClass.getInstance('#foo')).toEqual(instance) + expect(DummyClass.getInstance(element)).toEqual(instance) + + const fakejQueryObject = { + 0: element, + jquery: 'foo' + } + + expect(DummyClass.getInstance(fakejQueryObject)).toEqual(instance) + }) + + it('should return null when there is no instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(DummyClass.getInstance(div)).toEqual(null) + }) + }) + describe('getOrCreateInstance', () => { + it('should return an instance', () => { + createInstance() + + expect(DummyClass.getOrCreateInstance(element)).toEqual(instance) + expect(DummyClass.getInstance(element)).toEqual(DummyClass.getOrCreateInstance(element, {})) + expect(DummyClass.getOrCreateInstance(element)).toBeInstanceOf(DummyClass) + }) + + it('should return new instance when there is no alert instance', () => { + fixtureEl.innerHTML = '<div id="foo"></div>' + element = fixtureEl.querySelector('#foo') + + expect(DummyClass.getInstance(element)).toEqual(null) + expect(DummyClass.getOrCreateInstance(element)).toBeInstanceOf(DummyClass) + }) + }) + }) +}) diff --git a/js/tests/unit/button.spec.js b/js/tests/unit/button.spec.js index e442fd90d..be99177e8 100644 --- a/js/tests/unit/button.spec.js +++ b/js/tests/unit/button.spec.js @@ -18,6 +18,16 @@ describe('Button', () => { clearFixture() }) + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '<button data-bs-toggle="button">Placeholder</button>' + const buttonEl = fixtureEl.querySelector('[data-bs-toggle="button"]') + const buttonBySelector = new Button('[data-bs-toggle="button"]') + const buttonByElement = new Button(buttonEl) + + expect(buttonBySelector._element).toEqual(buttonEl) + expect(buttonByElement._element).toEqual(buttonEl) + }) + describe('VERSION', () => { it('should return plugin version', () => { expect(Button.VERSION).toEqual(jasmine.any(String)) @@ -81,7 +91,7 @@ describe('Button', () => { const btnEl = fixtureEl.querySelector('.btn') const button = new Button(btnEl) - expect(Button.getInstance(btnEl)).toBeDefined() + expect(Button.getInstance(btnEl)).not.toBeNull() button.dispose() @@ -116,7 +126,7 @@ describe('Button', () => { jQueryMock.fn.button.call(jQueryMock, 'toggle') - expect(Button.getInstance(btnEl)).toBeDefined() + expect(Button.getInstance(btnEl)).not.toBeNull() expect(btnEl.classList.contains('active')).toEqual(true) }) @@ -130,7 +140,7 @@ describe('Button', () => { jQueryMock.fn.button.call(jQueryMock) - expect(Button.getInstance(btnEl)).toBeDefined() + expect(Button.getInstance(btnEl)).not.toBeNull() expect(btnEl.classList.contains('active')).toEqual(false) }) }) @@ -154,4 +164,26 @@ describe('Button', () => { expect(Button.getInstance(div)).toEqual(null) }) }) + + describe('getOrCreateInstance', () => { + it('should return button instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const button = new Button(div) + + expect(Button.getOrCreateInstance(div)).toEqual(button) + expect(Button.getInstance(div)).toEqual(Button.getOrCreateInstance(div, {})) + expect(Button.getOrCreateInstance(div)).toBeInstanceOf(Button) + }) + + it('should return new instance when there is no button instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Button.getInstance(div)).toEqual(null) + expect(Button.getOrCreateInstance(div)).toBeInstanceOf(Button) + }) + }) }) diff --git a/js/tests/unit/carousel.spec.js b/js/tests/unit/carousel.spec.js index 533e1ba7e..a933f1eda 100644 --- a/js/tests/unit/carousel.spec.js +++ b/js/tests/unit/carousel.spec.js @@ -2,7 +2,8 @@ import Carousel from '../../src/carousel' import EventHandler from '../../src/dom/event-handler' /** Test helpers */ -import { getFixture, clearFixture, createEvent, jQueryMock } from '../helpers/fixture' +import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture' +import * as util from '../../src/util' describe('Carousel', () => { const { Simulator, PointerEvent } = window @@ -13,7 +14,7 @@ describe('Carousel', () => { const stylesCarousel = document.createElement('style') stylesCarousel.type = 'text/css' - stylesCarousel.appendChild(document.createTextNode(cssStyleCarousel)) + stylesCarousel.append(document.createTextNode(cssStyleCarousel)) const clearPointerEvents = () => { window.PointerEvent = null @@ -52,6 +53,17 @@ describe('Carousel', () => { }) describe('constructor', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>' + + const carouselEl = fixtureEl.querySelector('#myCarousel') + const carouselBySelector = new Carousel('#myCarousel') + const carouselByElement = new Carousel(carouselEl) + + expect(carouselBySelector._element).toEqual(carouselEl) + expect(carouselByElement._element).toEqual(carouselEl) + }) + it('should go to next item if right arrow key is pressed', done => { fixtureEl.innerHTML = [ '<div id="myCarousel" class="carousel slide">', @@ -164,8 +176,7 @@ describe('Carousel', () => { }) const spyKeydown = spyOn(carousel, '_keydown').and.callThrough() - const spyPrev = spyOn(carousel, 'prev') - const spyNext = spyOn(carousel, 'next') + const spySlide = spyOn(carousel, '_slide') const keydown = createEvent('keydown', { bubbles: true, cancelable: true }) keydown.key = 'ArrowRight' @@ -178,12 +189,10 @@ describe('Carousel', () => { input.dispatchEvent(keydown) expect(spyKeydown).toHaveBeenCalled() - expect(spyPrev).not.toHaveBeenCalled() - expect(spyNext).not.toHaveBeenCalled() + expect(spySlide).not.toHaveBeenCalled() spyKeydown.calls.reset() - spyPrev.calls.reset() - spyNext.calls.reset() + spySlide.calls.reset() Object.defineProperty(keydown, 'target', { value: textarea @@ -191,8 +200,27 @@ describe('Carousel', () => { textarea.dispatchEvent(keydown) expect(spyKeydown).toHaveBeenCalled() - expect(spyPrev).not.toHaveBeenCalled() - expect(spyNext).not.toHaveBeenCalled() + expect(spySlide).not.toHaveBeenCalled() + }) + + it('should not slide if arrow key is pressed and carousel is sliding', () => { + fixtureEl.innerHTML = '<div></div>' + + const carouselEl = fixtureEl.querySelector('div') + const carousel = new Carousel(carouselEl, {}) + + spyOn(carousel, '_triggerSlideEvent') + + carousel._isSliding = true; + + ['ArrowLeft', 'ArrowRight'].forEach(key => { + const keydown = createEvent('keydown') + keydown.key = key + + carouselEl.dispatchEvent(keydown) + }) + + expect(carousel._triggerSlideEvent).not.toHaveBeenCalled() }) it('should wrap around from end to start when wrap option is true', done => { @@ -309,7 +337,7 @@ describe('Carousel', () => { expect(carousel._addTouchEventListeners).toHaveBeenCalled() }) - it('should allow swiperight and call prev with pointer events', done => { + it('should allow swiperight and call _slide (prev) with pointer events', done => { if (!supportPointerEvent) { expect().nothing() done() @@ -317,7 +345,7 @@ describe('Carousel', () => { } document.documentElement.ontouchstart = () => {} - document.head.appendChild(stylesCarousel) + document.head.append(stylesCarousel) Simulator.setType('pointer') fixtureEl.innerHTML = [ @@ -337,12 +365,13 @@ describe('Carousel', () => { const item = fixtureEl.querySelector('#item') const carousel = new Carousel(carouselEl) - spyOn(carousel, 'prev').and.callThrough() + spyOn(carousel, '_slide').and.callThrough() - carouselEl.addEventListener('slid.bs.carousel', () => { + carouselEl.addEventListener('slid.bs.carousel', event => { expect(item.classList.contains('active')).toEqual(true) - expect(carousel.prev).toHaveBeenCalled() - document.head.removeChild(stylesCarousel) + expect(carousel._slide).toHaveBeenCalledWith('right') + expect(event.direction).toEqual('right') + stylesCarousel.remove() delete document.documentElement.ontouchstart done() }) @@ -361,7 +390,7 @@ describe('Carousel', () => { } document.documentElement.ontouchstart = () => {} - document.head.appendChild(stylesCarousel) + document.head.append(stylesCarousel) Simulator.setType('pointer') fixtureEl.innerHTML = [ @@ -381,12 +410,13 @@ describe('Carousel', () => { const item = fixtureEl.querySelector('#item') const carousel = new Carousel(carouselEl) - spyOn(carousel, 'next').and.callThrough() + spyOn(carousel, '_slide').and.callThrough() - carouselEl.addEventListener('slid.bs.carousel', () => { + carouselEl.addEventListener('slid.bs.carousel', event => { expect(item.classList.contains('active')).toEqual(false) - expect(carousel.next).toHaveBeenCalled() - document.head.removeChild(stylesCarousel) + expect(carousel._slide).toHaveBeenCalledWith('left') + expect(event.direction).toEqual('left') + stylesCarousel.remove() delete document.documentElement.ontouchstart done() }) @@ -398,7 +428,7 @@ describe('Carousel', () => { }) }) - it('should allow swiperight and call prev with touch events', done => { + it('should allow swiperight and call _slide (prev) with touch events', done => { Simulator.setType('touch') clearPointerEvents() document.documentElement.ontouchstart = () => {} @@ -420,11 +450,12 @@ describe('Carousel', () => { const item = fixtureEl.querySelector('#item') const carousel = new Carousel(carouselEl) - spyOn(carousel, 'prev').and.callThrough() + spyOn(carousel, '_slide').and.callThrough() - carouselEl.addEventListener('slid.bs.carousel', () => { + carouselEl.addEventListener('slid.bs.carousel', event => { expect(item.classList.contains('active')).toEqual(true) - expect(carousel.prev).toHaveBeenCalled() + expect(carousel._slide).toHaveBeenCalledWith('right') + expect(event.direction).toEqual('right') delete document.documentElement.ontouchstart restorePointerEvents() done() @@ -436,7 +467,7 @@ describe('Carousel', () => { }) }) - it('should allow swipeleft and call next with touch events', done => { + it('should allow swipeleft and call _slide (next) with touch events', done => { Simulator.setType('touch') clearPointerEvents() document.documentElement.ontouchstart = () => {} @@ -458,11 +489,12 @@ describe('Carousel', () => { const item = fixtureEl.querySelector('#item') const carousel = new Carousel(carouselEl) - spyOn(carousel, 'next').and.callThrough() + spyOn(carousel, '_slide').and.callThrough() - carouselEl.addEventListener('slid.bs.carousel', () => { + carouselEl.addEventListener('slid.bs.carousel', event => { expect(item.classList.contains('active')).toEqual(false) - expect(carousel.next).toHaveBeenCalled() + expect(carousel._slide).toHaveBeenCalledWith('left') + expect(event.direction).toEqual('left') delete document.documentElement.ontouchstart restorePointerEvents() done() @@ -475,6 +507,49 @@ describe('Carousel', () => { }) }) + it('should not slide when swiping and carousel is sliding', done => { + Simulator.setType('touch') + clearPointerEvents() + document.documentElement.ontouchstart = () => {} + + fixtureEl.innerHTML = [ + '<div class="carousel" data-bs-interval="false">', + ' <div class="carousel-inner">', + ' <div id="item" class="carousel-item active">', + ' <img alt="">', + ' </div>', + ' <div class="carousel-item">', + ' <img alt="">', + ' </div>', + ' </div>', + '</div>' + ].join('') + + const carouselEl = fixtureEl.querySelector('.carousel') + const carousel = new Carousel(carouselEl) + carousel._isSliding = true + + spyOn(carousel, '_triggerSlideEvent') + + Simulator.gestures.swipe(carouselEl, { + deltaX: 300, + deltaY: 0 + }) + + Simulator.gestures.swipe(carouselEl, { + pos: [300, 10], + deltaX: -300, + deltaY: 0 + }) + + setTimeout(() => { + expect(carousel._triggerSlideEvent).not.toHaveBeenCalled() + delete document.documentElement.ontouchstart + restorePointerEvents() + done() + }, 300) + }) + it('should not allow pinch with touch events', done => { Simulator.setType('touch') clearPointerEvents() @@ -540,12 +615,12 @@ describe('Carousel', () => { const carouselEl = fixtureEl.querySelector('div') const carousel = new Carousel(carouselEl, {}) - spyOn(carousel, '_slide') + spyOn(carousel, '_triggerSlideEvent') carousel._isSliding = true carousel.next() - expect(carousel._slide).not.toHaveBeenCalled() + expect(carousel._triggerSlideEvent).not.toHaveBeenCalled() }) it('should not fire slid when slide is prevented', done => { @@ -695,6 +770,34 @@ describe('Carousel', () => { carousel.next() }) + + it('should call next()/prev() instance methods when clicking the respective direction buttons', () => { + fixtureEl.innerHTML = [ + '<div id="carousel" class="carousel slide">', + ' <div class="carousel-inner">', + ' <div class="carousel-item active">item 1</div>', + ' <div class="carousel-item">item 2</div>', + ' <div class="carousel-item">item 3</div>', + ' </div>', + ' <button class="carousel-control-prev" type="button" data-bs-target="#carousel" data-bs-slide="prev"></button>', + ' <button class="carousel-control-next" type="button" data-bs-target="#carousel" data-bs-slide="next"></button>', + '</div>' + ].join('') + + const carouselEl = fixtureEl.querySelector('#carousel') + const prevBtnEl = fixtureEl.querySelector('.carousel-control-prev') + const nextBtnEl = fixtureEl.querySelector('.carousel-control-next') + + const carousel = new Carousel(carouselEl) + const nextSpy = spyOn(carousel, 'next') + const prevSpy = spyOn(carousel, 'prev') + + nextBtnEl.click() + prevBtnEl.click() + + expect(nextSpy).toHaveBeenCalled() + expect(prevSpy).toHaveBeenCalled() + }) }) describe('nextWhenVisible', () => { @@ -723,12 +826,12 @@ describe('Carousel', () => { const carouselEl = fixtureEl.querySelector('div') const carousel = new Carousel(carouselEl, {}) - spyOn(carousel, '_slide') + spyOn(carousel, '_triggerSlideEvent') carousel._isSliding = true carousel.prev() - expect(carousel._slide).not.toHaveBeenCalled() + expect(carousel._triggerSlideEvent).not.toHaveBeenCalled() }) }) @@ -1050,6 +1153,77 @@ describe('Carousel', () => { }) }) }) + describe('rtl function', () => { + it('"_directionToOrder" and "_orderToDirection" must return the right results', () => { + fixtureEl.innerHTML = '<div></div>' + + const carouselEl = fixtureEl.querySelector('div') + const carousel = new Carousel(carouselEl, {}) + + expect(carousel._directionToOrder('left')).toEqual('next') + expect(carousel._directionToOrder('prev')).toEqual('prev') + expect(carousel._directionToOrder('right')).toEqual('prev') + expect(carousel._directionToOrder('next')).toEqual('next') + + expect(carousel._orderToDirection('next')).toEqual('left') + expect(carousel._orderToDirection('prev')).toEqual('right') + }) + + it('"_directionToOrder" and "_orderToDirection" must return the right results when rtl=true', () => { + document.documentElement.dir = 'rtl' + fixtureEl.innerHTML = '<div></div>' + + const carouselEl = fixtureEl.querySelector('div') + const carousel = new Carousel(carouselEl, {}) + expect(util.isRTL()).toEqual(true, 'rtl has to be true') + + expect(carousel._directionToOrder('left')).toEqual('prev') + expect(carousel._directionToOrder('prev')).toEqual('prev') + expect(carousel._directionToOrder('right')).toEqual('next') + expect(carousel._directionToOrder('next')).toEqual('next') + + expect(carousel._orderToDirection('next')).toEqual('right') + expect(carousel._orderToDirection('prev')).toEqual('left') + document.documentElement.dir = 'ltl' + }) + + it('"_slide" has to call _directionToOrder and "_orderToDirection"', () => { + fixtureEl.innerHTML = '<div></div>' + + const carouselEl = fixtureEl.querySelector('div') + const carousel = new Carousel(carouselEl, {}) + const spy = spyOn(carousel, '_directionToOrder').and.callThrough() + const spy2 = spyOn(carousel, '_orderToDirection').and.callThrough() + + carousel._slide('left') + expect(spy).toHaveBeenCalledWith('left') + expect(spy2).toHaveBeenCalledWith('next') + + carousel._slide('right') + expect(spy).toHaveBeenCalledWith('right') + expect(spy2).toHaveBeenCalledWith('prev') + }) + + it('"_slide" has to call "_directionToOrder" and "_orderToDirection" when rtl=true', () => { + document.documentElement.dir = 'rtl' + fixtureEl.innerHTML = '<div></div>' + + const carouselEl = fixtureEl.querySelector('div') + const carousel = new Carousel(carouselEl, {}) + const spy = spyOn(carousel, '_directionToOrder').and.callThrough() + const spy2 = spyOn(carousel, '_orderToDirection').and.callThrough() + + carousel._slide('left') + expect(spy).toHaveBeenCalledWith('left') + expect(spy2).toHaveBeenCalledWith('prev') + + carousel._slide('right') + expect(spy).toHaveBeenCalledWith('right') + expect(spy2).toHaveBeenCalledWith('next') + + document.documentElement.dir = 'ltl' + }) + }) describe('dispose', () => { it('should destroy a carousel', () => { @@ -1119,6 +1293,60 @@ describe('Carousel', () => { }) }) + describe('getOrCreateInstance', () => { + it('should return carousel instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const carousel = new Carousel(div) + + expect(Carousel.getOrCreateInstance(div)).toEqual(carousel) + expect(Carousel.getInstance(div)).toEqual(Carousel.getOrCreateInstance(div, {})) + expect(Carousel.getOrCreateInstance(div)).toBeInstanceOf(Carousel) + }) + + it('should return new instance when there is no carousel instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Carousel.getInstance(div)).toEqual(null) + expect(Carousel.getOrCreateInstance(div)).toBeInstanceOf(Carousel) + }) + + it('should return new instance when there is no carousel instance with given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Carousel.getInstance(div)).toEqual(null) + const carousel = Carousel.getOrCreateInstance(div, { + interval: 1 + }) + expect(carousel).toBeInstanceOf(Carousel) + + expect(carousel._config.interval).toEqual(1) + }) + + it('should return the instance when exists without given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const carousel = new Carousel(div, { + interval: 1 + }) + expect(Carousel.getInstance(div)).toEqual(carousel) + + const carousel2 = Carousel.getOrCreateInstance(div, { + interval: 2 + }) + expect(carousel).toBeInstanceOf(Carousel) + expect(carousel2).toEqual(carousel) + + expect(carousel2._config.interval).toEqual(1) + }) + }) + describe('jQueryInterface', () => { it('should create a carousel', () => { fixtureEl.innerHTML = '<div></div>' @@ -1130,7 +1358,7 @@ describe('Carousel', () => { jQueryMock.fn.carousel.call(jQueryMock) - expect(Carousel.getInstance(div)).toBeDefined() + expect(Carousel.getInstance(div)).not.toBeNull() }) it('should not re create a carousel', () => { @@ -1188,7 +1416,7 @@ describe('Carousel', () => { window.dispatchEvent(loadEvent) - expect(Carousel.getInstance(carouselEl)).toBeDefined() + expect(Carousel.getInstance(carouselEl)).not.toBeNull() }) it('should create carousel and go to the next slide on click (with real button controls)', done => { diff --git a/js/tests/unit/collapse.spec.js b/js/tests/unit/collapse.spec.js index cd30ed8da..6220623fc 100644 --- a/js/tests/unit/collapse.spec.js +++ b/js/tests/unit/collapse.spec.js @@ -34,6 +34,17 @@ describe('Collapse', () => { }) describe('constructor', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '<div class="my-collapse"></div>' + + const collapseEl = fixtureEl.querySelector('div.my-collapse') + const collapseBySelector = new Collapse('div.my-collapse') + const collapseByElement = new Collapse(collapseEl) + + expect(collapseBySelector._element).toEqual(collapseEl) + expect(collapseByElement._element).toEqual(collapseEl) + }) + it('should allow jquery object in parent config', () => { fixtureEl.innerHTML = [ '<div class="my-collapse">', @@ -47,14 +58,14 @@ describe('Collapse', () => { const collapseEl = fixtureEl.querySelector('div.collapse') const myCollapseEl = fixtureEl.querySelector('.my-collapse') const fakejQueryObject = { - 0: myCollapseEl + 0: myCollapseEl, + jquery: 'foo' } const collapse = new Collapse(collapseEl, { parent: fakejQueryObject }) - expect(collapse._config.parent).toEqual(fakejQueryObject) - expect(collapse._getParent()).toEqual(myCollapseEl) + expect(collapse._config.parent).toEqual(myCollapseEl) }) it('should allow non jquery object in parent config', () => { @@ -92,8 +103,7 @@ describe('Collapse', () => { parent: 'div.my-collapse' }) - expect(collapse._config.parent).toEqual('div.my-collapse') - expect(collapse._getParent()).toEqual(myCollapseEl) + expect(collapse._config.parent).toEqual(myCollapseEl) }) }) @@ -213,7 +223,7 @@ describe('Collapse', () => { }) it('should show a collapsed element on width', done => { - fixtureEl.innerHTML = '<div class="collapse width" style="width: 0px;"></div>' + fixtureEl.innerHTML = '<div class="collapse collapse-horizontal" style="width: 0px;"></div>' const collapseEl = fixtureEl.querySelector('div') const collapse = new Collapse(collapseEl, { @@ -799,7 +809,7 @@ describe('Collapse', () => { jQueryMock.fn.collapse.call(jQueryMock) - expect(Collapse.getInstance(div)).toBeDefined() + expect(Collapse.getInstance(div)).not.toBeNull() }) it('should not re create a collapse', () => { @@ -850,4 +860,58 @@ describe('Collapse', () => { expect(Collapse.getInstance(div)).toEqual(null) }) }) + + describe('getOrCreateInstance', () => { + it('should return collapse instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const collapse = new Collapse(div) + + expect(Collapse.getOrCreateInstance(div)).toEqual(collapse) + expect(Collapse.getInstance(div)).toEqual(Collapse.getOrCreateInstance(div, {})) + expect(Collapse.getOrCreateInstance(div)).toBeInstanceOf(Collapse) + }) + + it('should return new instance when there is no collapse instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Collapse.getInstance(div)).toEqual(null) + expect(Collapse.getOrCreateInstance(div)).toBeInstanceOf(Collapse) + }) + + it('should return new instance when there is no collapse instance with given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Collapse.getInstance(div)).toEqual(null) + const collapse = Collapse.getOrCreateInstance(div, { + toggle: false + }) + expect(collapse).toBeInstanceOf(Collapse) + + expect(collapse._config.toggle).toEqual(false) + }) + + it('should return the instance when exists without given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const collapse = new Collapse(div, { + toggle: false + }) + expect(Collapse.getInstance(div)).toEqual(collapse) + + const collapse2 = Collapse.getOrCreateInstance(div, { + toggle: true + }) + expect(collapse).toBeInstanceOf(Collapse) + expect(collapse2).toEqual(collapse) + + expect(collapse2._config.toggle).toEqual(false) + }) + }) }) diff --git a/js/tests/unit/dom/data.spec.js b/js/tests/unit/dom/data.spec.js index c80f32db0..a00d3b734 100644 --- a/js/tests/unit/dom/data.spec.js +++ b/js/tests/unit/dom/data.spec.js @@ -4,128 +4,103 @@ import Data from '../../../src/dom/data' import { getFixture, clearFixture } from '../../helpers/fixture' describe('Data', () => { + const TEST_KEY = 'bs.test' + const UNKNOWN_KEY = 'bs.unknown' + const TEST_DATA = { + test: 'bsData' + } + let fixtureEl + let div beforeAll(() => { fixtureEl = getFixture() }) + beforeEach(() => { + fixtureEl.innerHTML = '<div></div>' + div = fixtureEl.querySelector('div') + }) + afterEach(() => { + Data.remove(div, TEST_KEY) clearFixture() }) - describe('setData', () => { - it('should set data in an element by adding a bsKey attribute', () => { - fixtureEl.innerHTML = '<div></div>' - - const div = fixtureEl.querySelector('div') - const data = { - test: 'bsData' - } - - Data.setData(div, 'test', data) - expect(div.bsKey).toBeDefined() - }) + it('should return null for unknown elements', () => { + const data = { ...TEST_DATA } - it('should change data if something is already stored', () => { - fixtureEl.innerHTML = '<div></div>' + Data.set(div, TEST_KEY, data) - const div = fixtureEl.querySelector('div') - const data = { - test: 'bsData' - } - - Data.setData(div, 'test', data) - - data.test = 'bsData2' - Data.setData(div, 'test', data) - - expect(div.bsKey).toBeDefined() - }) + expect(Data.get(null)).toBeNull() + expect(Data.get(undefined)).toBeNull() + expect(Data.get(document.createElement('div'), TEST_KEY)).toBeNull() }) - describe('getData', () => { - it('should return stored data', () => { - fixtureEl.innerHTML = '<div></div>' - - const div = fixtureEl.querySelector('div') - const data = { - test: 'bsData' - } + it('should return null for unknown keys', () => { + const data = { ...TEST_DATA } - Data.setData(div, 'test', data) - expect(Data.getData(div, 'test')).toEqual(data) - }) + Data.set(div, TEST_KEY, data) - it('should return null on undefined element', () => { - expect(Data.getData(null)).toEqual(null) - expect(Data.getData(undefined)).toEqual(null) - }) - - it('should return null when an element have nothing stored', () => { - fixtureEl.innerHTML = '<div></div>' - - const div = fixtureEl.querySelector('div') - - expect(Data.getData(div, 'test')).toEqual(null) - }) - - it('should return null when an element have nothing stored with the provided key', () => { - fixtureEl.innerHTML = '<div></div>' + expect(Data.get(div, null)).toBeNull() + expect(Data.get(div, undefined)).toBeNull() + expect(Data.get(div, UNKNOWN_KEY)).toBeNull() + }) - const div = fixtureEl.querySelector('div') - const data = { - test: 'bsData' - } + it('should store data for an element with a given key and return it', () => { + const data = { ...TEST_DATA } - Data.setData(div, 'test', data) + Data.set(div, TEST_KEY, data) - expect(Data.getData(div, 'test2')).toEqual(null) - }) + expect(Data.get(div, TEST_KEY)).toBe(data) }) - describe('removeData', () => { - it('should do nothing when an element have nothing stored', () => { - fixtureEl.innerHTML = '<div></div>' + it('should overwrite data if something is already stored', () => { + const data = { ...TEST_DATA } + const copy = { ...data } - const div = fixtureEl.querySelector('div') + Data.set(div, TEST_KEY, data) + Data.set(div, TEST_KEY, copy) - Data.removeData(div, 'test') - expect().nothing() - }) + expect(Data.get(div, TEST_KEY)).not.toBe(data) + expect(Data.get(div, TEST_KEY)).toBe(copy) + }) - it('should should do nothing if it\'s not a valid key provided', () => { - fixtureEl.innerHTML = '<div></div>' + it('should do nothing when an element have nothing stored', () => { + Data.remove(div, TEST_KEY) - const div = fixtureEl.querySelector('div') - const data = { - test: 'bsData' - } + expect().nothing() + }) - Data.setData(div, 'test', data) + it('should remove nothing for an unknown key', () => { + const data = { ...TEST_DATA } - expect(div.bsKey).toBeDefined() + Data.set(div, TEST_KEY, data) + Data.remove(div, UNKNOWN_KEY) - Data.removeData(div, 'test2') + expect(Data.get(div, TEST_KEY)).toBe(data) + }) - expect(div.bsKey).toBeDefined() - }) + it('should remove data for a given key', () => { + const data = { ...TEST_DATA } - it('should remove data if something is stored', () => { - fixtureEl.innerHTML = '<div></div>' + Data.set(div, TEST_KEY, data) + Data.remove(div, TEST_KEY) - const div = fixtureEl.querySelector('div') - const data = { - test: 'bsData' - } + expect(Data.get(div, TEST_KEY)).toBeNull() + }) - Data.setData(div, 'test', data) + it('should console.error a message if called with multiple keys', () => { + /* eslint-disable no-console */ + console.error = jasmine.createSpy('console.error') - expect(div.bsKey).toBeDefined() + const data = { ...TEST_DATA } + const copy = { ...data } - Data.removeData(div, 'test') + Data.set(div, TEST_KEY, data) + Data.set(div, UNKNOWN_KEY, copy) - expect(div.bsKey).toBeUndefined() - }) + expect(console.error).toHaveBeenCalled() + expect(Data.get(div, UNKNOWN_KEY)).toBe(null) }) }) diff --git a/js/tests/unit/dom/event-handler.spec.js b/js/tests/unit/dom/event-handler.spec.js index e596a49b5..45f2d6e55 100644 --- a/js/tests/unit/dom/event-handler.spec.js +++ b/js/tests/unit/dom/event-handler.spec.js @@ -77,10 +77,78 @@ describe('EventHandler', () => { div.click() }) + + it('should handle mouseenter/mouseleave like the native counterpart', done => { + fixtureEl.innerHTML = [ + '<div class="outer">', + '<div class="inner">', + '<div class="nested">', + '<div class="deep"></div>', + '</div>', + '</div>', + '<div class="sibling"></div>', + '</div>' + ] + + const outer = fixtureEl.querySelector('.outer') + const inner = fixtureEl.querySelector('.inner') + const nested = fixtureEl.querySelector('.nested') + const deep = fixtureEl.querySelector('.deep') + const sibling = fixtureEl.querySelector('.sibling') + + const enterSpy = jasmine.createSpy('mouseenter') + const leaveSpy = jasmine.createSpy('mouseleave') + const delegateEnterSpy = jasmine.createSpy('mouseenter') + const delegateLeaveSpy = jasmine.createSpy('mouseleave') + + EventHandler.on(inner, 'mouseenter', enterSpy) + EventHandler.on(inner, 'mouseleave', leaveSpy) + EventHandler.on(outer, 'mouseenter', '.inner', delegateEnterSpy) + EventHandler.on(outer, 'mouseleave', '.inner', delegateLeaveSpy) + + EventHandler.on(sibling, 'mouseenter', () => { + expect(enterSpy.calls.count()).toBe(2) + expect(leaveSpy.calls.count()).toBe(2) + expect(delegateEnterSpy.calls.count()).toBe(2) + expect(delegateLeaveSpy.calls.count()).toBe(2) + done() + }) + + const moveMouse = (from, to) => { + from.dispatchEvent(new MouseEvent('mouseout', { + bubbles: true, + relatedTarget: to + })) + + to.dispatchEvent(new MouseEvent('mouseover', { + bubbles: true, + relatedTarget: from + })) + } + + // from outer to deep and back to outer (nested) + moveMouse(outer, inner) + moveMouse(inner, nested) + moveMouse(nested, deep) + moveMouse(deep, nested) + moveMouse(nested, inner) + moveMouse(inner, outer) + + setTimeout(() => { + expect(enterSpy.calls.count()).toBe(1) + expect(leaveSpy.calls.count()).toBe(1) + expect(delegateEnterSpy.calls.count()).toBe(1) + expect(delegateLeaveSpy.calls.count()).toBe(1) + + // from outer to inner to sibling (adjacent) + moveMouse(outer, inner) + moveMouse(inner, sibling) + }, 20) + }) }) describe('one', () => { - it('should call listener just one', done => { + it('should call listener just once', done => { fixtureEl.innerHTML = '<div></div>' let called = 0 @@ -101,6 +169,28 @@ describe('EventHandler', () => { done() }, 20) }) + + it('should call delegated listener just once', done => { + fixtureEl.innerHTML = '<div></div>' + + let called = 0 + const div = fixtureEl.querySelector('div') + const obj = { + oneListener() { + called++ + } + } + + EventHandler.one(fixtureEl, 'bootstrap', 'div', obj.oneListener) + + EventHandler.trigger(div, 'bootstrap') + EventHandler.trigger(div, 'bootstrap') + + setTimeout(() => { + expect(called).toEqual(1) + done() + }, 20) + }) }) describe('off', () => { @@ -278,10 +368,10 @@ describe('EventHandler', () => { it('should remove the correct delegated event listener', () => { const element = document.createElement('div') const subelement = document.createElement('span') - element.appendChild(subelement) + element.append(subelement) const anchor = document.createElement('a') - element.appendChild(anchor) + element.append(anchor) let i = 0 const handler = () => { @@ -291,7 +381,7 @@ describe('EventHandler', () => { EventHandler.on(element, 'click', 'a', handler) EventHandler.on(element, 'click', 'span', handler) - fixtureEl.appendChild(element) + fixtureEl.append(element) EventHandler.trigger(anchor, 'click') EventHandler.trigger(subelement, 'click') diff --git a/js/tests/unit/dom/manipulator.spec.js b/js/tests/unit/dom/manipulator.spec.js index 3d91e6f74..13d0c3d17 100644 --- a/js/tests/unit/dom/manipulator.spec.js +++ b/js/tests/unit/dom/manipulator.spec.js @@ -119,6 +119,60 @@ describe('Manipulator', () => { expect(offset.top).toEqual(jasmine.any(Number)) expect(offset.left).toEqual(jasmine.any(Number)) }) + + it('should return offset relative to attached element\'s offset', () => { + const top = 500 + const left = 1000 + + fixtureEl.innerHTML = `<div style="position:absolute;top:${top}px;left:${left}px"></div>` + + const div = fixtureEl.querySelector('div') + const offset = Manipulator.offset(div) + const fixtureOffset = Manipulator.offset(fixtureEl) + + expect(offset).toEqual({ + top: fixtureOffset.top + top, + left: fixtureOffset.left + left + }) + }) + + it('should not change offset when viewport is scrolled', done => { + const top = 500 + const left = 1000 + const scrollY = 200 + const scrollX = 400 + + fixtureEl.innerHTML = `<div style="position:absolute;top:${top}px;left:${left}px"></div>` + + const div = fixtureEl.querySelector('div') + const offset = Manipulator.offset(div) + + // append an element that forces scrollbars on the window so we can scroll + const { defaultView: win, body } = fixtureEl.ownerDocument + const forceScrollBars = document.createElement('div') + forceScrollBars.style.cssText = 'position:absolute;top:5000px;left:5000px;width:1px;height:1px' + body.append(forceScrollBars) + + const scrollHandler = () => { + expect(window.pageYOffset).toBe(scrollY) + expect(window.pageXOffset).toBe(scrollX) + + const newOffset = Manipulator.offset(div) + + expect(newOffset).toEqual({ + top: offset.top, + left: offset.left + }) + + win.removeEventListener('scroll', scrollHandler) + forceScrollBars.remove() + win.scrollTo(0, 0) + done() + } + + win.addEventListener('scroll', scrollHandler) + win.scrollTo(scrollX, scrollY) + }) }) describe('position', () => { diff --git a/js/tests/unit/dom/selector-engine.spec.js b/js/tests/unit/dom/selector-engine.spec.js index d108a2efb..08c3ae818 100644 --- a/js/tests/unit/dom/selector-engine.spec.js +++ b/js/tests/unit/dom/selector-engine.spec.js @@ -156,5 +156,87 @@ describe('SelectorEngine', () => { expect(SelectorEngine.next(divTest, '.btn')).toEqual([btn]) }) }) + + describe('focusableChildren', () => { + it('should return only elements with specific tag names', () => { + fixtureEl.innerHTML = [ + '<div>lorem</div>', + '<span>lorem</span>', + '<a>lorem</a>', + '<button>lorem</button>', + '<input />', + '<textarea></textarea>', + '<select></select>', + '<details>lorem</details>' + ].join('') + + const expectedElements = [ + fixtureEl.querySelector('a'), + fixtureEl.querySelector('button'), + fixtureEl.querySelector('input'), + fixtureEl.querySelector('textarea'), + fixtureEl.querySelector('select'), + fixtureEl.querySelector('details') + ] + + expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements) + }) + + it('should return any element with non negative tab index', () => { + fixtureEl.innerHTML = [ + '<div tabindex>lorem</div>', + '<div tabindex="0">lorem</div>', + '<div tabindex="10">lorem</div>' + ].join('') + + const expectedElements = [ + fixtureEl.querySelector('[tabindex]'), + fixtureEl.querySelector('[tabindex="0"]'), + fixtureEl.querySelector('[tabindex="10"]') + ] + + expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements) + }) + + it('should return not return elements with negative tab index', () => { + fixtureEl.innerHTML = [ + '<button tabindex="-1">lorem</button>' + ].join('') + + const expectedElements = [] + + expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements) + }) + + it('should return contenteditable elements', () => { + fixtureEl.innerHTML = [ + '<div contenteditable="true">lorem</div>' + ].join('') + + const expectedElements = [fixtureEl.querySelector('[contenteditable="true"]')] + + expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements) + }) + + it('should not return disabled elements', () => { + fixtureEl.innerHTML = [ + '<button disabled="true">lorem</button>' + ].join('') + + const expectedElements = [] + + expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements) + }) + + it('should not return invisible elements', () => { + fixtureEl.innerHTML = [ + '<button style="display:none;">lorem</button>' + ].join('') + + const expectedElements = [] + + expect(SelectorEngine.focusableChildren(fixtureEl)).toEqual(expectedElements) + }) + }) }) diff --git a/js/tests/unit/dropdown.spec.js b/js/tests/unit/dropdown.spec.js index 658cb65b0..2b6d8cd78 100644 --- a/js/tests/unit/dropdown.spec.js +++ b/js/tests/unit/dropdown.spec.js @@ -1,8 +1,9 @@ import Dropdown from '../../src/dropdown' import EventHandler from '../../src/dom/event-handler' +import { noop } from '../../src/util' /** Test helpers */ -import { getFixture, clearFixture, createEvent, jQueryMock } from '../helpers/fixture' +import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture' describe('Dropdown', () => { let fixtureEl @@ -40,24 +41,22 @@ describe('Dropdown', () => { }) describe('constructor', () => { - it('should add a listener on trigger which do not have data-bs-toggle="dropdown"', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { fixtureEl.innerHTML = [ '<div class="dropdown">', - ' <button class="btn">Dropdown</button>', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', ' <div class="dropdown-menu">', - ' <a class="dropdown-item" href="#">Secondary link</a>', + ' <a class="dropdown-item" href="#">Link</a>', ' </div>', '</div>' ].join('') - const btnDropdown = fixtureEl.querySelector('.btn') - const dropdown = new Dropdown(btnDropdown) - - spyOn(dropdown, 'toggle') - - btnDropdown.click() + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownBySelector = new Dropdown('[data-bs-toggle="dropdown"]') + const dropdownByElement = new Dropdown(btnDropdown) - expect(dropdown.toggle).toHaveBeenCalled() + expect(dropdownBySelector._element).toEqual(btnDropdown) + expect(dropdownByElement._element).toEqual(btnDropdown) }) it('should create offset modifier correctly when offset option is a function', done => { @@ -197,18 +196,17 @@ describe('Dropdown', () => { const firstDropdownEl = fixtureEl.querySelector('.first') const secondDropdownEl = fixtureEl.querySelector('.second') const dropdown1 = new Dropdown(btnDropdown1) - const dropdown2 = new Dropdown(btnDropdown2) firstDropdownEl.addEventListener('shown.bs.dropdown', () => { expect(btnDropdown1.classList.contains('show')).toEqual(true) spyOn(dropdown1._popper, 'destroy') - dropdown2.toggle() + btnDropdown2.click() }) - secondDropdownEl.addEventListener('shown.bs.dropdown', () => { + secondDropdownEl.addEventListener('shown.bs.dropdown', () => setTimeout(() => { expect(dropdown1._popper.destroy).toHaveBeenCalled() done() - }) + })) dropdown1.toggle() }) @@ -234,7 +232,7 @@ describe('Dropdown', () => { btnDropdown.addEventListener('shown.bs.dropdown', () => { expect(btnDropdown.classList.contains('show')).toEqual(true) expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') - expect(EventHandler.on).toHaveBeenCalled() + expect(EventHandler.on).toHaveBeenCalledWith(jasmine.any(Object), 'mouseover', noop) dropdown.toggle() }) @@ -242,7 +240,7 @@ describe('Dropdown', () => { btnDropdown.addEventListener('hidden.bs.dropdown', () => { expect(btnDropdown.classList.contains('show')).toEqual(false) expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false') - expect(EventHandler.off).toHaveBeenCalled() + expect(EventHandler.off).toHaveBeenCalledWith(jasmine.any(Object), 'mouseover', noop) document.documentElement.ontouchstart = defaultValueOnTouchStart done() @@ -449,6 +447,7 @@ describe('Dropdown', () => { const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') const virtualElement = { + nodeType: 1, getBoundingClientRect() { return { width: 0, @@ -725,7 +724,7 @@ describe('Dropdown', () => { it('should hide a dropdown', done => { fixtureEl.innerHTML = [ '<div class="dropdown">', - ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="true">Dropdown</button>', ' <div class="dropdown-menu show">', ' <a class="dropdown-item" href="#">Secondary link</a>', ' </div>', @@ -738,6 +737,7 @@ describe('Dropdown', () => { btnDropdown.addEventListener('hidden.bs.dropdown', () => { expect(dropdownMenu.classList.contains('show')).toEqual(false) + expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false') done() }) @@ -876,6 +876,39 @@ describe('Dropdown', () => { done() }) }) + + it('should remove event listener on touch-enabled device that was added in show method', done => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#">Dropdwon item</a>', + ' </div>', + '</div>' + ].join('') + + const defaultValueOnTouchStart = document.documentElement.ontouchstart + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdown = new Dropdown(btnDropdown) + + document.documentElement.ontouchstart = () => {} + spyOn(EventHandler, 'off') + + btnDropdown.addEventListener('shown.bs.dropdown', () => { + dropdown.hide() + }) + + btnDropdown.addEventListener('hidden.bs.dropdown', () => { + expect(btnDropdown.classList.contains('show')).toEqual(false) + expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false') + expect(EventHandler.off).toHaveBeenCalled() + + document.documentElement.ontouchstart = defaultValueOnTouchStart + done() + }) + + dropdown.show() + }) }) describe('dispose', () => { @@ -890,21 +923,19 @@ describe('Dropdown', () => { ].join('') const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') - spyOn(btnDropdown, 'addEventListener').and.callThrough() - spyOn(btnDropdown, 'removeEventListener').and.callThrough() const dropdown = new Dropdown(btnDropdown) expect(dropdown._popper).toBeNull() - expect(dropdown._menu).toBeDefined() - expect(dropdown._element).toBeDefined() - expect(btnDropdown.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function), jasmine.any(Boolean)) + expect(dropdown._menu).not.toBeNull() + expect(dropdown._element).not.toBeNull() + spyOn(EventHandler, 'off') dropdown.dispose() expect(dropdown._menu).toBeNull() expect(dropdown._element).toBeNull() - expect(btnDropdown.removeEventListener).toHaveBeenCalledWith('click', jasmine.any(Function), jasmine.any(Boolean)) + expect(EventHandler.off).toHaveBeenCalledWith(btnDropdown, Dropdown.EVENT_KEY) }) it('should dispose dropdown with Popper', () => { @@ -922,9 +953,9 @@ describe('Dropdown', () => { dropdown.toggle() - expect(dropdown._popper).toBeDefined() - expect(dropdown._menu).toBeDefined() - expect(dropdown._element).toBeDefined() + expect(dropdown._popper).not.toBeNull() + expect(dropdown._menu).not.toBeNull() + expect(dropdown._element).not.toBeNull() dropdown.dispose() @@ -950,7 +981,7 @@ describe('Dropdown', () => { dropdown.toggle() - expect(dropdown._popper).toBeDefined() + expect(dropdown._popper).not.toBeNull() spyOn(dropdown._popper, 'update') spyOn(dropdown, '_detectNavbar') @@ -1002,13 +1033,13 @@ describe('Dropdown', () => { showEventTriggered = true }) - btnDropdown.addEventListener('shown.bs.dropdown', e => { + btnDropdown.addEventListener('shown.bs.dropdown', e => setTimeout(() => { expect(btnDropdown.classList.contains('show')).toEqual(true) expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') expect(showEventTriggered).toEqual(true) expect(e.relatedTarget).toEqual(btnDropdown) document.body.click() - }) + })) btnDropdown.addEventListener('hide.bs.dropdown', () => { hideEventTriggered = true @@ -1050,6 +1081,47 @@ describe('Dropdown', () => { dropdown.show() }) + it('should not collapse the dropdown when clicking a select option nested in the dropdown', done => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>', + ' <div class="dropdown-menu">', + ' <select>', + ' <option selected>Open this select menu</option>', + ' <option value="1">One</option>', + ' </select>', + ' </div>', + '</div>' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + const dropdown = new Dropdown(btnDropdown) + + const hideSpy = spyOn(dropdown, '_completeHide') + + btnDropdown.addEventListener('shown.bs.dropdown', () => { + const clickEvent = new MouseEvent('click', { + bubbles: true + }) + + dropdownMenu.querySelector('option').dispatchEvent(clickEvent) + }) + + dropdownMenu.addEventListener('click', event => { + expect(event.target.tagName).toMatch(/select|option/i) + + Dropdown.clearMenus(event) + + setTimeout(() => { + expect(hideSpy).not.toHaveBeenCalled() + done() + }, 10) + }) + + dropdown.show() + }) + it('should manage bs attribute `data-bs-popper`="none" when dropdown is in navbar', done => { fixtureEl.innerHTML = [ '<nav class="navbar navbar-expand-md navbar-light bg-light">', @@ -1094,7 +1166,7 @@ describe('Dropdown', () => { btnDropdown.addEventListener('shown.bs.dropdown', () => { // Popper adds this attribute when we use it - expect(dropdownMenu.getAttribute('x-placement')).toEqual(null) + expect(dropdownMenu.getAttribute('data-popper-placement')).toEqual(null) done() }) @@ -1467,7 +1539,7 @@ describe('Dropdown', () => { triggerDropdown.click() }) - it('should focus on the first element when using ArrowUp for the first time', done => { + it('should open the dropdown and focus on the last item when using ArrowUp for the first time', done => { fixtureEl.innerHTML = [ '<div class="dropdown">', ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', @@ -1479,22 +1551,47 @@ describe('Dropdown', () => { ].join('') const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') - const item1 = fixtureEl.querySelector('#item1') + const lastItem = fixtureEl.querySelector('#item2') triggerDropdown.addEventListener('shown.bs.dropdown', () => { - const keydown = createEvent('keydown') - keydown.key = 'ArrowUp' + setTimeout(() => { + expect(document.activeElement).toEqual(lastItem, 'item2 is focused') + done() + }) + }) - document.activeElement.dispatchEvent(keydown) - expect(document.activeElement).toEqual(item1, 'item1 is focused') + const keydown = createEvent('keydown') + keydown.key = 'ArrowUp' + triggerDropdown.dispatchEvent(keydown) + }) - done() + it('should open the dropdown and focus on the first item when using ArrowDown for the first time', done => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', + ' <div class="dropdown-menu">', + ' <a id="item1" class="dropdown-item" href="#">A link</a>', + ' <a id="item2" class="dropdown-item" href="#">Another link</a>', + ' </div>', + '</div>' + ].join('') + + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const firstItem = fixtureEl.querySelector('#item1') + + triggerDropdown.addEventListener('shown.bs.dropdown', () => { + setTimeout(() => { + expect(document.activeElement).toEqual(firstItem, 'item1 is focused') + done() + }) }) - triggerDropdown.click() + const keydown = createEvent('keydown') + keydown.key = 'ArrowDown' + triggerDropdown.dispatchEvent(keydown) }) - it('should not close the dropdown if the user clicks on a text field', done => { + it('should not close the dropdown if the user clicks on a text field within dropdown-menu', done => { fixtureEl.innerHTML = [ '<div class="dropdown">', ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', @@ -1520,7 +1617,7 @@ describe('Dropdown', () => { triggerDropdown.click() }) - it('should not close the dropdown if the user clicks on a textarea', done => { + it('should not close the dropdown if the user clicks on a textarea within dropdown-menu', done => { fixtureEl.innerHTML = [ '<div class="dropdown">', ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', @@ -1546,6 +1643,33 @@ describe('Dropdown', () => { triggerDropdown.click() }) + it('should close the dropdown if the user clicks on a text field that is not contained within dropdown-menu', done => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', + ' <div class="dropdown-menu">', + ' </div>', + '</div>', + '<input type="text">' + ] + + const triggerDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const input = fixtureEl.querySelector('input') + + triggerDropdown.addEventListener('hidden.bs.dropdown', () => { + expect().nothing() + done() + }) + + triggerDropdown.addEventListener('shown.bs.dropdown', () => { + input.dispatchEvent(createEvent('click', { + bubbles: true + })) + }) + + triggerDropdown.click() + }) + it('should ignore keyboard events for <input>s and <textarea>s within dropdown-menu, except for escape key', done => { fixtureEl.innerHTML = [ '<div class="dropdown">', @@ -1653,6 +1777,133 @@ describe('Dropdown', () => { done() }, 20) }) + + it('should propagate escape key events if dropdown is closed', done => { + fixtureEl.innerHTML = [ + '<div class="parent">', + ' <div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#">Some Item</a>', + ' </div>', + ' </div>', + '</div>' + ] + + const parent = fixtureEl.querySelector('.parent') + const toggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + + const parentKeyHandler = jasmine.createSpy('parentKeyHandler') + + parent.addEventListener('keydown', parentKeyHandler) + parent.addEventListener('keyup', () => { + expect(parentKeyHandler).toHaveBeenCalled() + done() + }) + + const keydownEscape = createEvent('keydown', { bubbles: true }) + keydownEscape.key = 'Escape' + const keyupEscape = createEvent('keyup', { bubbles: true }) + keyupEscape.key = 'Escape' + + toggle.focus() + toggle.dispatchEvent(keydownEscape) + toggle.dispatchEvent(keyupEscape) + }) + + it('should close dropdown (only) by clicking inside the dropdown menu when it has data-attribute `data-bs-auto-close="inside"`', done => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="inside">Dropdown toggle</button>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#">Dropdown item</a>', + ' </div>', + '</div>' + ] + + const dropdownToggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + + const expectDropdownToBeOpened = () => setTimeout(() => { + expect(dropdownToggle.classList.contains('show')).toEqual(true) + dropdownMenu.click() + }, 150) + + dropdownToggle.addEventListener('shown.bs.dropdown', () => { + document.documentElement.click() + expectDropdownToBeOpened() + }) + + dropdownToggle.addEventListener('hidden.bs.dropdown', () => setTimeout(() => { + expect(dropdownToggle.classList.contains('show')).toEqual(false) + done() + })) + + dropdownToggle.click() + }) + + it('should close dropdown (only) by clicking outside the dropdown menu when it has data-attribute `data-bs-auto-close="outside"`', done => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="outside">Dropdown toggle</button>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#">Dropdown item</a>', + ' </div>', + '</div>' + ] + + const dropdownToggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + + const expectDropdownToBeOpened = () => setTimeout(() => { + expect(dropdownToggle.classList.contains('show')).toEqual(true) + document.documentElement.click() + }, 150) + + dropdownToggle.addEventListener('shown.bs.dropdown', () => { + dropdownMenu.click() + expectDropdownToBeOpened() + }) + + dropdownToggle.addEventListener('hidden.bs.dropdown', () => { + expect(dropdownToggle.classList.contains('show')).toEqual(false) + done() + }) + + dropdownToggle.click() + }) + + it('should not close dropdown by clicking inside or outside the dropdown menu when it has data-attribute `data-bs-auto-close="false"`', done => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="false">Dropdown toggle</button>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#">Dropdown item</a>', + ' </div>', + '</div>' + ] + + const dropdownToggle = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + + const expectDropdownToBeOpened = (shouldTriggerClick = true) => setTimeout(() => { + expect(dropdownToggle.classList.contains('show')).toEqual(true) + if (shouldTriggerClick) { + document.documentElement.click() + } else { + done() + } + + expectDropdownToBeOpened(false) + }, 150) + + dropdownToggle.addEventListener('shown.bs.dropdown', () => { + dropdownMenu.click() + expectDropdownToBeOpened() + }) + + dropdownToggle.click() + }) }) describe('jQueryInterface', () => { @@ -1666,7 +1917,7 @@ describe('Dropdown', () => { jQueryMock.fn.dropdown.call(jQueryMock) - expect(Dropdown.getInstance(div)).toBeDefined() + expect(Dropdown.getInstance(div)).not.toBeNull() }) it('should not re create a dropdown', () => { @@ -1718,6 +1969,60 @@ describe('Dropdown', () => { }) }) + describe('getOrCreateInstance', () => { + it('should return dropdown instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const dropdown = new Dropdown(div) + + expect(Dropdown.getOrCreateInstance(div)).toEqual(dropdown) + expect(Dropdown.getInstance(div)).toEqual(Dropdown.getOrCreateInstance(div, {})) + expect(Dropdown.getOrCreateInstance(div)).toBeInstanceOf(Dropdown) + }) + + it('should return new instance when there is no dropdown instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Dropdown.getInstance(div)).toEqual(null) + expect(Dropdown.getOrCreateInstance(div)).toBeInstanceOf(Dropdown) + }) + + it('should return new instance when there is no dropdown instance with given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Dropdown.getInstance(div)).toEqual(null) + const dropdown = Dropdown.getOrCreateInstance(div, { + display: 'dynamic' + }) + expect(dropdown).toBeInstanceOf(Dropdown) + + expect(dropdown._config.display).toEqual('dynamic') + }) + + it('should return the instance when exists without given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const dropdown = new Dropdown(div, { + display: 'dynamic' + }) + expect(Dropdown.getInstance(div)).toEqual(dropdown) + + const dropdown2 = Dropdown.getOrCreateInstance(div, { + display: 'static' + }) + expect(dropdown).toBeInstanceOf(Dropdown) + expect(dropdown2).toEqual(dropdown) + + expect(dropdown2._config.display).toEqual('dynamic') + }) + }) + it('should open dropdown when pressing keydown or keyup', done => { fixtureEl.innerHTML = [ '<div class="dropdown">', @@ -1765,4 +2070,51 @@ describe('Dropdown', () => { triggerDropdown.dispatchEvent(keydown) }) + + it('should allow `data-bs-toggle="dropdown"` click events to bubble up', () => { + fixtureEl.innerHTML = [ + '<div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#">Secondary link</a>', + ' </div>', + '</div>' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const clickListener = jasmine.createSpy('clickListener') + const delegatedClickListener = jasmine.createSpy('delegatedClickListener') + + btnDropdown.addEventListener('click', clickListener) + document.addEventListener('click', delegatedClickListener) + + btnDropdown.click() + + expect(clickListener).toHaveBeenCalled() + expect(delegatedClickListener).toHaveBeenCalled() + }) + + it('should open the dropdown when clicking the child element inside `data-bs-toggle="dropdown"`', done => { + fixtureEl.innerHTML = [ + '<div class="container">', + ' <div class="dropdown">', + ' <button class="btn dropdown-toggle" data-bs-toggle="dropdown"><span id="childElement">Dropdown</span></button>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#subMenu">Sub menu</a>', + ' </div>', + ' </div>', + '</div>' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const childElement = fixtureEl.querySelector('#childElement') + + btnDropdown.addEventListener('shown.bs.dropdown', () => setTimeout(() => { + expect(btnDropdown.classList.contains('show')).toEqual(true) + expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true') + done() + })) + + childElement.click() + }) }) diff --git a/js/tests/unit/modal.spec.js b/js/tests/unit/modal.spec.js index 8a159eef6..a65ed4afa 100644 --- a/js/tests/unit/modal.spec.js +++ b/js/tests/unit/modal.spec.js @@ -1,47 +1,30 @@ import Modal from '../../src/modal' import EventHandler from '../../src/dom/event-handler' +import ScrollBarHelper from '../../src/util/scrollbar' /** Test helpers */ -import { getFixture, clearFixture, createEvent, jQueryMock } from '../helpers/fixture' +import { clearBodyAndDocument, clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture' describe('Modal', () => { let fixtureEl - let style beforeAll(() => { fixtureEl = getFixture() - - // Enable the scrollbar measurer - const css = '.modal-scrollbar-measure { position: absolute; top: -9999px; width: 50px; height: 50px; overflow: scroll; }' - - style = document.createElement('style') - style.type = 'text/css' - style.appendChild(document.createTextNode(css)) - - document.head.appendChild(style) - - // Simulate scrollbars - document.documentElement.style.paddingRight = '16px' }) afterEach(() => { clearFixture() - + clearBodyAndDocument() document.body.classList.remove('modal-open') - document.body.removeAttribute('style') - document.body.removeAttribute('data-bs-padding-right') document.querySelectorAll('.modal-backdrop') .forEach(backdrop => { - document.body.removeChild(backdrop) + backdrop.remove() }) - - document.body.style.paddingRight = '0px' }) - afterAll(() => { - document.head.removeChild(style) - document.documentElement.style.paddingRight = '0px' + beforeEach(() => { + clearBodyAndDocument() }) describe('VERSION', () => { @@ -62,161 +45,37 @@ describe('Modal', () => { }) }) - describe('toggle', () => { - it('should toggle a modal', done => { + describe('constructor', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>' const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - const originalPadding = '0px' - - document.body.style.paddingRight = originalPadding - - modalEl.addEventListener('shown.bs.modal', () => { - expect(document.body.getAttribute('data-bs-padding-right')).toEqual(originalPadding, 'original body padding should be stored in data-bs-padding-right') - modal.toggle() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - expect(document.body.getAttribute('data-bs-padding-right')).toBeNull() - expect().nothing() - done() - }) - - modal.toggle() - }) - - it('should adjust the inline padding of fixed elements when opening and restore when closing', done => { - fixtureEl.innerHTML = [ - '<div class="fixed-top" style="padding-right: 0px"></div>', - '<div class="modal"><div class="modal-dialog"></div></div>' - ].join('') - - const fixedEl = fixtureEl.querySelector('.fixed-top') - const originalPadding = Number.parseInt(window.getComputedStyle(fixedEl).paddingRight, 10) - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - modalEl.addEventListener('shown.bs.modal', () => { - const expectedPadding = originalPadding + modal._getScrollbarWidth() - const currentPadding = Number.parseInt(window.getComputedStyle(modalEl).paddingRight, 10) - - expect(fixedEl.getAttribute('data-bs-padding-right')).toEqual('0px', 'original fixed element padding should be stored in data-bs-padding-right') - expect(currentPadding).toEqual(expectedPadding, 'fixed element padding should be adjusted while opening') - modal.toggle() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - const currentPadding = Number.parseInt(window.getComputedStyle(modalEl).paddingRight, 10) - - expect(fixedEl.getAttribute('data-bs-padding-right')).toEqual(null, 'data-bs-padding-right should be cleared after closing') - expect(currentPadding).toEqual(originalPadding, 'fixed element padding should be reset after closing') - done() - }) + const modalBySelector = new Modal('.modal') + const modalByElement = new Modal(modalEl) - modal.toggle() + expect(modalBySelector._element).toEqual(modalEl) + expect(modalByElement._element).toEqual(modalEl) }) + }) - it('should adjust the inline margin of sticky elements when opening and restore when closing', done => { + describe('toggle', () => { + it('should call ScrollBarHelper to handle scrollBar on body', done => { fixtureEl.innerHTML = [ - '<div class="sticky-top" style="margin-right: 0px;"></div>', '<div class="modal"><div class="modal-dialog"></div></div>' ].join('') - const stickyTopEl = fixtureEl.querySelector('.sticky-top') - const originalMargin = Number.parseInt(window.getComputedStyle(stickyTopEl).marginRight, 10) - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - modalEl.addEventListener('shown.bs.modal', () => { - const expectedMargin = originalMargin - modal._getScrollbarWidth() - const currentMargin = Number.parseInt(window.getComputedStyle(stickyTopEl).marginRight, 10) - - expect(stickyTopEl.getAttribute('data-bs-margin-right')).toEqual('0px', 'original sticky element margin should be stored in data-bs-margin-right') - expect(currentMargin).toEqual(expectedMargin, 'sticky element margin should be adjusted while opening') - modal.toggle() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - const currentMargin = Number.parseInt(window.getComputedStyle(stickyTopEl).marginRight, 10) - - expect(stickyTopEl.getAttribute('data-bs-margin-right')).toEqual(null, 'data-bs-margin-right should be cleared after closing') - expect(currentMargin).toEqual(originalMargin, 'sticky element margin should be reset after closing') - done() - }) - - modal.toggle() - }) - - it('should ignore values set via CSS when trying to restore body padding after closing', done => { - fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>' - const styleTest = document.createElement('style') - - styleTest.type = 'text/css' - styleTest.appendChild(document.createTextNode('body { padding-right: 7px; }')) - document.head.appendChild(styleTest) - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - modalEl.addEventListener('shown.bs.modal', () => { - modal.toggle() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - expect(window.getComputedStyle(document.body).paddingLeft).toEqual('0px', 'body does not have inline padding set') - document.head.removeChild(styleTest) - done() - }) - - modal.toggle() - }) - - it('should ignore other inline styles when trying to restore body padding after closing', done => { - fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>' - const styleTest = document.createElement('style') - - styleTest.type = 'text/css' - styleTest.appendChild(document.createTextNode('body { padding-right: 7px; }')) - - document.head.appendChild(styleTest) - document.body.style.color = 'red' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - - modalEl.addEventListener('shown.bs.modal', () => { - modal.toggle() - }) - - modalEl.addEventListener('hidden.bs.modal', () => { - const bodyPaddingRight = document.body.style.paddingRight - - expect(bodyPaddingRight === '0px' || bodyPaddingRight === '').toEqual(true, 'body does not have inline padding set') - expect(document.body.style.color).toEqual('red', 'body still has other inline styles set') - document.head.removeChild(styleTest) - document.body.removeAttribute('style') - done() - }) - - modal.toggle() - }) - - it('should properly restore non-pixel inline body padding after closing', done => { - fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>' - - document.body.style.paddingRight = '5%' - + spyOn(ScrollBarHelper.prototype, 'hide').and.callThrough() + spyOn(ScrollBarHelper.prototype, 'reset').and.callThrough() const modalEl = fixtureEl.querySelector('.modal') const modal = new Modal(modalEl) modalEl.addEventListener('shown.bs.modal', () => { + expect(ScrollBarHelper.prototype.hide).toHaveBeenCalled() modal.toggle() }) modalEl.addEventListener('hidden.bs.modal', () => { - expect(document.body.style.paddingRight).toEqual('5%') - document.body.removeAttribute('style') + expect(ScrollBarHelper.prototype.reset).toHaveBeenCalled() done() }) @@ -238,9 +97,9 @@ describe('Modal', () => { modalEl.addEventListener('shown.bs.modal', () => { expect(modalEl.getAttribute('aria-modal')).toEqual('true') expect(modalEl.getAttribute('role')).toEqual('dialog') - expect(modalEl.getAttribute('aria-hidden')).toEqual(null) + expect(modalEl.getAttribute('aria-hidden')).toBeNull() expect(modalEl.style.display).toEqual('block') - expect(document.querySelector('.modal-backdrop')).toBeDefined() + expect(document.querySelector('.modal-backdrop')).not.toBeNull() done() }) @@ -262,7 +121,7 @@ describe('Modal', () => { modalEl.addEventListener('shown.bs.modal', () => { expect(modalEl.getAttribute('aria-modal')).toEqual('true') expect(modalEl.getAttribute('role')).toEqual('dialog') - expect(modalEl.getAttribute('aria-hidden')).toEqual(null) + expect(modalEl.getAttribute('aria-hidden')).toBeNull() expect(modalEl.style.display).toEqual('block') expect(document.querySelector('.modal-backdrop')).toBeNull() done() @@ -283,8 +142,8 @@ describe('Modal', () => { modalEl.addEventListener('shown.bs.modal', () => { const dynamicModal = document.getElementById(id) - expect(dynamicModal).toBeDefined() - dynamicModal.parentNode.removeChild(dynamicModal) + expect(dynamicModal).not.toBeNull() + dynamicModal.remove() done() }) @@ -343,6 +202,33 @@ describe('Modal', () => { modal.show() }) + it('should be shown after the first call to show() has been prevented while fading is enabled ', done => { + fixtureEl.innerHTML = '<div class="modal fade"><div class="modal-dialog"></div></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + + let prevented = false + modalEl.addEventListener('show.bs.modal', e => { + if (!prevented) { + e.preventDefault() + prevented = true + + setTimeout(() => { + modal.show() + }) + } + }) + + modalEl.addEventListener('shown.bs.modal', () => { + expect(prevented).toBeTrue() + expect(modal._isAnimated()).toBeTrue() + done() + }) + + modal.show() + }) + it('should set is transitioning if fade class is present', done => { fixtureEl.innerHTML = '<div class="modal fade"><div class="modal-dialog"></div></div>' @@ -350,7 +236,9 @@ describe('Modal', () => { const modal = new Modal(modalEl) modalEl.addEventListener('show.bs.modal', () => { - expect(modal._isTransitioning).toEqual(true) + setTimeout(() => { + expect(modal._isTransitioning).toEqual(true) + }) }) modalEl.addEventListener('shown.bs.modal', () => { @@ -361,7 +249,7 @@ describe('Modal', () => { modal.show() }) - it('should close modal when a click occurred on data-bs-dismiss="modal"', done => { + it('should close modal when a click occurred on data-bs-dismiss="modal" inside modal', done => { fixtureEl.innerHTML = [ '<div class="modal fade">', ' <div class="modal-dialog">', @@ -390,6 +278,33 @@ describe('Modal', () => { modal.show() }) + it('should close modal when a click occurred on a data-bs-dismiss="modal" with "bs-target" outside of modal element', done => { + fixtureEl.innerHTML = [ + '<button type="button" data-bs-dismiss="modal" data-bs-target="#modal1"></button>', + '<div id="modal1" class="modal fade">', + ' <div class="modal-dialog">', + ' </div>', + '</div>' + ].join('') + + const modalEl = fixtureEl.querySelector('.modal') + const btnClose = fixtureEl.querySelector('[data-bs-dismiss="modal"]') + const modal = new Modal(modalEl) + + spyOn(modal, 'hide').and.callThrough() + + modalEl.addEventListener('shown.bs.modal', () => { + btnClose.click() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + expect(modal.hide).toHaveBeenCalled() + done() + }) + + modal.show() + }) + it('should set .modal\'s scroll top to 0', done => { fixtureEl.innerHTML = [ '<div class="modal fade">', @@ -430,7 +345,7 @@ describe('Modal', () => { modal.show() }) - it('should not enforce focus if focus equal to false', done => { + it('should not trap focus if focus equal to false', done => { fixtureEl.innerHTML = '<div class="modal fade"><div class="modal-dialog"></div></div>' const modalEl = fixtureEl.querySelector('.modal') @@ -438,10 +353,10 @@ describe('Modal', () => { focus: false }) - spyOn(modal, '_enforceFocus') + spyOn(modal._focustrap, 'activate').and.callThrough() modalEl.addEventListener('shown.bs.modal', () => { - expect(modal._enforceFocus).not.toHaveBeenCalled() + expect(modal._focustrap.activate).not.toHaveBeenCalled() done() }) @@ -548,7 +463,7 @@ describe('Modal', () => { }) it('should not close modal when clicking outside of modal-content if backdrop = static', done => { - fixtureEl.innerHTML = '<div class="modal" data-bs-backdrop="static" ><div class="modal-dialog"></div></div>' + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>' const modalEl = fixtureEl.querySelector('.modal') const modal = new Modal(modalEl, { @@ -575,7 +490,7 @@ describe('Modal', () => { }) it('should close modal when escape key is pressed with keyboard = true and backdrop is static', done => { - fixtureEl.innerHTML = '<div class="modal" data-bs-backdrop="static"><div class="modal-dialog"></div></div>' + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>' const modalEl = fixtureEl.querySelector('.modal') const modal = new Modal(modalEl, { @@ -632,7 +547,7 @@ describe('Modal', () => { }) it('should not overflow when clicking outside of modal-content if backdrop = static', done => { - fixtureEl.innerHTML = '<div class="modal" data-bs-backdrop="static"><div class="modal-dialog" style="transition-duration: 20ms;"></div></div>' + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" style="transition-duration: 20ms;"></div></div>' const modalEl = fixtureEl.querySelector('.modal') const modal = new Modal(modalEl, { @@ -650,90 +565,40 @@ describe('Modal', () => { modal.show() }) - it('should not adjust the inline body padding when it does not overflow', done => { - fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>' + it('should not queue multiple callbacks when clicking outside of modal-content and backdrop = static', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" style="transition-duration: 50ms;"></div></div>' const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - const originalPadding = window.getComputedStyle(document.body).paddingRight - - // Hide scrollbars to prevent the body overflowing - document.body.style.overflow = 'hidden' - document.documentElement.style.paddingRight = '0px' - - modalEl.addEventListener('shown.bs.modal', () => { - const currentPadding = window.getComputedStyle(document.body).paddingRight - - expect(currentPadding).toEqual(originalPadding, 'body padding should not be adjusted') - - // Restore scrollbars - document.body.style.overflow = 'auto' - document.documentElement.style.paddingRight = '16px' - done() + const modal = new Modal(modalEl, { + backdrop: 'static' }) - modal.show() - }) - - it('should not adjust the inline body padding when it does not overflow, even on a scaled display', done => { - fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>' - - const modalEl = fixtureEl.querySelector('.modal') - const modal = new Modal(modalEl) - const originalPadding = window.getComputedStyle(document.body).paddingRight - - // Remove body margins as would be done by Bootstrap css - document.body.style.margin = '0' - - // Hide scrollbars to prevent the body overflowing - document.body.style.overflow = 'hidden' - - // Simulate a discrepancy between exact, i.e. floating point body width, and rounded body width - // as it can occur when zooming or scaling the display to something else than 100% - document.documentElement.style.paddingRight = '.48px' - modalEl.addEventListener('shown.bs.modal', () => { - const currentPadding = window.getComputedStyle(document.body).paddingRight + const spy = spyOn(modal, '_queueCallback').and.callThrough() - expect(currentPadding).toEqual(originalPadding, 'body padding should not be adjusted') + modalEl.click() + modalEl.click() - // Restore overridden css - document.body.style.removeProperty('margin') - document.body.style.removeProperty('overflow') - document.documentElement.style.paddingRight = '16px' - done() + setTimeout(() => { + expect(spy).toHaveBeenCalledTimes(1) + done() + }, 20) }) modal.show() }) - it('should enforce focus', done => { + it('should trap focus', done => { fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>' const modalEl = fixtureEl.querySelector('.modal') const modal = new Modal(modalEl) - spyOn(modal, '_enforceFocus').and.callThrough() - - const focusInListener = () => { - expect(modal._element.focus).toHaveBeenCalled() - document.removeEventListener('focusin', focusInListener) - done() - } + spyOn(modal._focustrap, 'activate').and.callThrough() modalEl.addEventListener('shown.bs.modal', () => { - expect(modal._enforceFocus).toHaveBeenCalled() - - spyOn(modal._element, 'focus') - - document.addEventListener('focusin', focusInListener) - - const focusInEvent = createEvent('focusin', { bubbles: true }) - Object.defineProperty(focusInEvent, 'target', { - value: fixtureEl - }) - - document.dispatchEvent(focusInEvent) + expect(modal._focustrap.activate).toHaveBeenCalled() + done() }) modal.show() @@ -756,8 +621,8 @@ describe('Modal', () => { }) modalEl.addEventListener('hidden.bs.modal', () => { - expect(modalEl.getAttribute('aria-modal')).toEqual(null) - expect(modalEl.getAttribute('role')).toEqual(null) + expect(modalEl.getAttribute('aria-modal')).toBeNull() + expect(modalEl.getAttribute('role')).toBeNull() expect(modalEl.getAttribute('aria-hidden')).toEqual('true') expect(modalEl.style.display).toEqual('none') expect(document.querySelector('.modal-backdrop')).toBeNull() @@ -778,8 +643,8 @@ describe('Modal', () => { }) modalEl.addEventListener('hidden.bs.modal', () => { - expect(modalEl.getAttribute('aria-modal')).toEqual(null) - expect(modalEl.getAttribute('role')).toEqual(null) + expect(modalEl.getAttribute('aria-modal')).toBeNull() + expect(modalEl.getAttribute('role')).toBeNull() expect(modalEl.getAttribute('aria-hidden')).toEqual('true') expect(modalEl.style.display).toEqual('none') expect(document.querySelector('.modal-backdrop')).toBeNull() @@ -840,6 +705,25 @@ describe('Modal', () => { modal.show() }) + + it('should release focus trap', done => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>' + + const modalEl = fixtureEl.querySelector('.modal') + const modal = new Modal(modalEl) + spyOn(modal._focustrap, 'deactivate').and.callThrough() + + modalEl.addEventListener('shown.bs.modal', () => { + modal.hide() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + expect(modal._focustrap.deactivate).toHaveBeenCalled() + done() + }) + + modal.show() + }) }) describe('dispose', () => { @@ -848,6 +732,8 @@ describe('Modal', () => { const modalEl = fixtureEl.querySelector('.modal') const modal = new Modal(modalEl) + const focustrap = modal._focustrap + spyOn(focustrap, 'deactivate').and.callThrough() expect(Modal.getInstance(modalEl)).toEqual(modal) @@ -855,8 +741,9 @@ describe('Modal', () => { modal.dispose() - expect(Modal.getInstance(modalEl)).toEqual(null) - expect(EventHandler.off).toHaveBeenCalledTimes(4) + expect(Modal.getInstance(modalEl)).toBeNull() + expect(EventHandler.off).toHaveBeenCalledTimes(3) + expect(focustrap.deactivate).toHaveBeenCalled() }) }) @@ -888,18 +775,18 @@ describe('Modal', () => { modalEl.addEventListener('shown.bs.modal', () => { expect(modalEl.getAttribute('aria-modal')).toEqual('true') expect(modalEl.getAttribute('role')).toEqual('dialog') - expect(modalEl.getAttribute('aria-hidden')).toEqual(null) + expect(modalEl.getAttribute('aria-hidden')).toBeNull() expect(modalEl.style.display).toEqual('block') - expect(document.querySelector('.modal-backdrop')).toBeDefined() + expect(document.querySelector('.modal-backdrop')).not.toBeNull() setTimeout(() => trigger.click(), 10) }) modalEl.addEventListener('hidden.bs.modal', () => { - expect(modalEl.getAttribute('aria-modal')).toEqual(null) - expect(modalEl.getAttribute('role')).toEqual(null) + expect(modalEl.getAttribute('aria-modal')).toBeNull() + expect(modalEl.getAttribute('role')).toBeNull() expect(modalEl.getAttribute('aria-hidden')).toEqual('true') expect(modalEl.style.display).toEqual('none') - expect(document.querySelector('.modal-backdrop')).toEqual(null) + expect(document.querySelector('.modal-backdrop')).toBeNull() done() }) @@ -940,9 +827,9 @@ describe('Modal', () => { modalEl.addEventListener('shown.bs.modal', () => { expect(modalEl.getAttribute('aria-modal')).toEqual('true') expect(modalEl.getAttribute('role')).toEqual('dialog') - expect(modalEl.getAttribute('aria-hidden')).toEqual(null) + expect(modalEl.getAttribute('aria-hidden')).toBeNull() expect(modalEl.style.display).toEqual('block') - expect(document.querySelector('.modal-backdrop')).toBeDefined() + expect(document.querySelector('.modal-backdrop')).not.toBeNull() expect(Event.prototype.preventDefault).toHaveBeenCalled() done() }) @@ -981,6 +868,60 @@ describe('Modal', () => { trigger.click() }) + it('should not prevent default when a click occurred on data-bs-dismiss="modal" where tagName is DIFFERENT than <a> or <area>', done => { + fixtureEl.innerHTML = [ + '<div class="modal">', + ' <div class="modal-dialog">', + ' <button type="button" data-bs-dismiss="modal"></button>', + ' </div>', + '</div>' + ].join('') + + const modalEl = fixtureEl.querySelector('.modal') + const btnClose = fixtureEl.querySelector('button[data-bs-dismiss="modal"]') + const modal = new Modal(modalEl) + + spyOn(Event.prototype, 'preventDefault').and.callThrough() + + modalEl.addEventListener('shown.bs.modal', () => { + btnClose.click() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + expect(Event.prototype.preventDefault).not.toHaveBeenCalled() + done() + }) + + modal.show() + }) + + it('should prevent default when a click occurred on data-bs-dismiss="modal" where tagName is <a> or <area>', done => { + fixtureEl.innerHTML = [ + '<div class="modal">', + ' <div class="modal-dialog">', + ' <a type="button" data-bs-dismiss="modal"></a>', + ' </div>', + '</div>' + ].join('') + + const modalEl = fixtureEl.querySelector('.modal') + const btnClose = fixtureEl.querySelector('a[data-bs-dismiss="modal"]') + const modal = new Modal(modalEl) + + spyOn(Event.prototype, 'preventDefault').and.callThrough() + + modalEl.addEventListener('shown.bs.modal', () => { + btnClose.click() + }) + + modalEl.addEventListener('hidden.bs.modal', () => { + expect(Event.prototype.preventDefault).toHaveBeenCalled() + done() + }) + + modal.show() + }) + it('should not focus the trigger if the modal is not visible', done => { fixtureEl.innerHTML = [ '<a data-bs-toggle="modal" href="#" data-bs-target="#exampleModal" style="display: none;"></a>', @@ -1037,6 +978,29 @@ describe('Modal', () => { trigger.click() }) + + it('should call hide first, if another modal is open', done => { + fixtureEl.innerHTML = [ + '<button data-bs-toggle="modal" data-bs-target="#modal2"></button>', + '<div id="modal1" class="modal fade"><div class="modal-dialog"></div></div>', + '<div id="modal2" class="modal"><div class="modal-dialog"></div></div>' + ].join('') + + const trigger2 = fixtureEl.querySelector('button') + const modalEl1 = document.querySelector('#modal1') + const modalEl2 = document.querySelector('#modal2') + const modal1 = new Modal(modalEl1) + + modalEl1.addEventListener('shown.bs.modal', () => { + trigger2.click() + }) + modalEl1.addEventListener('hidden.bs.modal', () => { + expect(Modal.getInstance(modalEl2)).not.toBeNull() + expect(modalEl2.classList.contains('show')).toBeTrue() + done() + }) + modal1.show() + }) }) describe('jQueryInterface', () => { @@ -1050,7 +1014,24 @@ describe('Modal', () => { jQueryMock.fn.modal.call(jQueryMock) - expect(Modal.getInstance(div)).toBeDefined() + expect(Modal.getInstance(div)).not.toBeNull() + }) + + it('should create a modal with given config', () => { + fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog"></div></div>' + + const div = fixtureEl.querySelector('div') + + jQueryMock.fn.modal = Modal.jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.modal.call(jQueryMock, { keyboard: false }) + spyOn(Modal.prototype, 'constructor') + expect(Modal.prototype.constructor).not.toHaveBeenCalledWith(div, { keyboard: false }) + + const modal = Modal.getInstance(div) + expect(modal).not.toBeNull() + expect(modal._config.keyboard).toBe(false) }) it('should not re create a modal', () => { @@ -1129,7 +1110,61 @@ describe('Modal', () => { const div = fixtureEl.querySelector('div') + expect(Modal.getInstance(div)).toBeNull() + }) + }) + + describe('getOrCreateInstance', () => { + it('should return modal instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const modal = new Modal(div) + + expect(Modal.getOrCreateInstance(div)).toEqual(modal) + expect(Modal.getInstance(div)).toEqual(Modal.getOrCreateInstance(div, {})) + expect(Modal.getOrCreateInstance(div)).toBeInstanceOf(Modal) + }) + + it('should return new instance when there is no modal instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + expect(Modal.getInstance(div)).toEqual(null) + expect(Modal.getOrCreateInstance(div)).toBeInstanceOf(Modal) + }) + + it('should return new instance when there is no modal instance with given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Modal.getInstance(div)).toEqual(null) + const modal = Modal.getOrCreateInstance(div, { + backdrop: true + }) + expect(modal).toBeInstanceOf(Modal) + + expect(modal._config.backdrop).toEqual(true) + }) + + it('should return the instance when exists without given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const modal = new Modal(div, { + backdrop: true + }) + expect(Modal.getInstance(div)).toEqual(modal) + + const modal2 = Modal.getOrCreateInstance(div, { + backdrop: false + }) + expect(modal).toBeInstanceOf(Modal) + expect(modal2).toEqual(modal) + + expect(modal2._config.backdrop).toEqual(true) }) }) }) diff --git a/js/tests/unit/offcanvas.spec.js b/js/tests/unit/offcanvas.spec.js new file mode 100644 index 000000000..ecbb710a5 --- /dev/null +++ b/js/tests/unit/offcanvas.spec.js @@ -0,0 +1,747 @@ +import Offcanvas from '../../src/offcanvas' +import EventHandler from '../../src/dom/event-handler' + +/** Test helpers */ +import { clearBodyAndDocument, clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture' +import { isVisible } from '../../src/util' +import ScrollBarHelper from '../../src/util/scrollbar' + +describe('Offcanvas', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + document.body.classList.remove('offcanvas-open') + clearBodyAndDocument() + }) + + beforeEach(() => { + clearBodyAndDocument() + }) + + describe('VERSION', () => { + it('should return plugin version', () => { + expect(Offcanvas.VERSION).toEqual(jasmine.any(String)) + }) + }) + + describe('Default', () => { + it('should return plugin default config', () => { + expect(Offcanvas.Default).toEqual(jasmine.any(Object)) + }) + }) + + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(Offcanvas.DATA_KEY).toEqual('bs.offcanvas') + }) + }) + + describe('constructor', () => { + it('should call hide when a element with data-bs-dismiss="offcanvas" is clicked', () => { + fixtureEl.innerHTML = [ + '<div class="offcanvas">', + ' <a href="#" data-bs-dismiss="offcanvas">Close</a>', + '</div>' + ].join('') + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const closeEl = fixtureEl.querySelector('a') + const offCanvas = new Offcanvas(offCanvasEl) + + spyOn(offCanvas, 'hide') + + closeEl.click() + + expect(offCanvas._config.keyboard).toBe(true) + expect(offCanvas.hide).toHaveBeenCalled() + }) + + it('should hide if esc is pressed', () => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl) + const keyDownEsc = createEvent('keydown') + keyDownEsc.key = 'Escape' + + spyOn(offCanvas, 'hide') + + offCanvasEl.dispatchEvent(keyDownEsc) + + expect(offCanvas.hide).toHaveBeenCalled() + }) + + it('should not hide if esc is not pressed', () => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl) + const keydownTab = createEvent('keydown') + keydownTab.key = 'Tab' + + spyOn(offCanvas, 'hide') + + document.dispatchEvent(keydownTab) + + expect(offCanvas.hide).not.toHaveBeenCalled() + }) + + it('should not hide if esc is pressed but with keyboard = false', () => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl, { keyboard: false }) + const keyDownEsc = createEvent('keydown') + keyDownEsc.key = 'Escape' + + spyOn(offCanvas, 'hide') + + document.dispatchEvent(keyDownEsc) + + expect(offCanvas._config.keyboard).toBe(false) + expect(offCanvas.hide).not.toHaveBeenCalled() + }) + }) + + describe('config', () => { + it('should have default values', () => { + fixtureEl.innerHTML = [ + '<div class="offcanvas">', + '</div>' + ].join('') + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl) + + expect(offCanvas._config.backdrop).toEqual(true) + expect(offCanvas._backdrop._config.isVisible).toEqual(true) + expect(offCanvas._config.keyboard).toEqual(true) + expect(offCanvas._config.scroll).toEqual(false) + }) + + it('should read data attributes and override default config', () => { + fixtureEl.innerHTML = [ + '<div class="offcanvas" data-bs-scroll="true" data-bs-backdrop="false" data-bs-keyboard="false">', + '</div>' + ].join('') + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl) + + expect(offCanvas._config.backdrop).toEqual(false) + expect(offCanvas._backdrop._config.isVisible).toEqual(false) + expect(offCanvas._config.keyboard).toEqual(false) + expect(offCanvas._config.scroll).toEqual(true) + }) + + it('given a config object must override data attributes', () => { + fixtureEl.innerHTML = [ + '<div class="offcanvas" data-bs-scroll="true" data-bs-backdrop="false" data-bs-keyboard="false">', + '</div>' + ].join('') + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl, { + backdrop: true, + keyboard: true, + scroll: false + }) + expect(offCanvas._config.backdrop).toEqual(true) + expect(offCanvas._config.keyboard).toEqual(true) + expect(offCanvas._config.scroll).toEqual(false) + }) + }) + describe('options', () => { + it('if scroll is enabled, should allow body to scroll while offcanvas is open', done => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + spyOn(ScrollBarHelper.prototype, 'hide').and.callThrough() + spyOn(ScrollBarHelper.prototype, 'reset').and.callThrough() + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl, { scroll: true }) + + offCanvasEl.addEventListener('shown.bs.offcanvas', () => { + expect(ScrollBarHelper.prototype.hide).not.toHaveBeenCalled() + offCanvas.hide() + }) + offCanvasEl.addEventListener('hidden.bs.offcanvas', () => { + expect(ScrollBarHelper.prototype.reset).not.toHaveBeenCalled() + done() + }) + offCanvas.show() + }) + + it('if scroll is disabled, should call ScrollBarHelper to handle scrollBar on body', done => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + spyOn(ScrollBarHelper.prototype, 'hide').and.callThrough() + spyOn(ScrollBarHelper.prototype, 'reset').and.callThrough() + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl, { scroll: false }) + + offCanvasEl.addEventListener('shown.bs.offcanvas', () => { + expect(ScrollBarHelper.prototype.hide).toHaveBeenCalled() + offCanvas.hide() + }) + offCanvasEl.addEventListener('hidden.bs.offcanvas', () => { + expect(ScrollBarHelper.prototype.reset).toHaveBeenCalled() + done() + }) + offCanvas.show() + }) + + it('should hide a shown element if user click on backdrop', done => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new Offcanvas(offCanvasEl, { backdrop: true }) + + const clickEvent = document.createEvent('MouseEvents') + clickEvent.initEvent('mousedown', true, true) + spyOn(offCanvas._backdrop._config, 'clickCallback').and.callThrough() + + offCanvasEl.addEventListener('shown.bs.offcanvas', () => { + expect(typeof offCanvas._backdrop._config.clickCallback).toBe('function') + + offCanvas._backdrop._getElement().dispatchEvent(clickEvent) + }) + + offCanvasEl.addEventListener('hidden.bs.offcanvas', () => { + expect(offCanvas._backdrop._config.clickCallback).toHaveBeenCalled() + done() + }) + + offCanvas.show() + }) + + it('should not trap focus if scroll is allowed', done => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl, { + scroll: true + }) + + spyOn(offCanvas._focustrap, 'activate').and.callThrough() + + offCanvasEl.addEventListener('shown.bs.offcanvas', () => { + expect(offCanvas._focustrap.activate).not.toHaveBeenCalled() + done() + }) + + offCanvas.show() + }) + }) + + describe('toggle', () => { + it('should call show method if show class is not present', () => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl) + + spyOn(offCanvas, 'show') + + offCanvas.toggle() + + expect(offCanvas.show).toHaveBeenCalled() + }) + + it('should call hide method if show class is present', () => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl) + offCanvas.show() + expect(offCanvasEl.classList.contains('show')).toBe(true) + + spyOn(offCanvas, 'hide') + + offCanvas.toggle() + + expect(offCanvas.hide).toHaveBeenCalled() + }) + }) + + describe('show', () => { + it('should do nothing if already shown', () => { + fixtureEl.innerHTML = '<div class="offcanvas show"></div>' + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new Offcanvas(offCanvasEl) + offCanvas.show() + + expect(offCanvasEl.classList.contains('show')).toBe(true) + + spyOn(offCanvas._backdrop, 'show').and.callThrough() + spyOn(EventHandler, 'trigger').and.callThrough() + offCanvas.show() + + expect(EventHandler.trigger).not.toHaveBeenCalled() + expect(offCanvas._backdrop.show).not.toHaveBeenCalled() + }) + + it('should show a hidden element', done => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new Offcanvas(offCanvasEl) + spyOn(offCanvas._backdrop, 'show').and.callThrough() + + offCanvasEl.addEventListener('shown.bs.offcanvas', () => { + expect(offCanvasEl.classList.contains('show')).toEqual(true) + expect(offCanvas._backdrop.show).toHaveBeenCalled() + done() + }) + + offCanvas.show() + }) + + it('should not fire shown when show is prevented', done => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new Offcanvas(offCanvasEl) + spyOn(offCanvas._backdrop, 'show').and.callThrough() + + const expectEnd = () => { + setTimeout(() => { + expect(offCanvas._backdrop.show).not.toHaveBeenCalled() + done() + }, 10) + } + + offCanvasEl.addEventListener('show.bs.offcanvas', e => { + e.preventDefault() + expectEnd() + }) + + offCanvasEl.addEventListener('shown.bs.offcanvas', () => { + throw new Error('should not fire shown event') + }) + + offCanvas.show() + }) + + it('on window load, should make visible an offcanvas element, if its markup contains class "show"', done => { + fixtureEl.innerHTML = '<div class="offcanvas show"></div>' + + const offCanvasEl = fixtureEl.querySelector('div') + spyOn(Offcanvas.prototype, 'show').and.callThrough() + + offCanvasEl.addEventListener('shown.bs.offcanvas', () => { + done() + }) + + window.dispatchEvent(createEvent('load')) + + const instance = Offcanvas.getInstance(offCanvasEl) + expect(instance).not.toBeNull() + expect(Offcanvas.prototype.show).toHaveBeenCalled() + }) + + it('should trap focus', done => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + const offCanvasEl = fixtureEl.querySelector('.offcanvas') + const offCanvas = new Offcanvas(offCanvasEl) + + spyOn(offCanvas._focustrap, 'activate').and.callThrough() + + offCanvasEl.addEventListener('shown.bs.offcanvas', () => { + expect(offCanvas._focustrap.activate).toHaveBeenCalled() + done() + }) + + offCanvas.show() + }) + }) + + describe('hide', () => { + it('should do nothing if already shown', () => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + spyOn(EventHandler, 'trigger').and.callThrough() + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new Offcanvas(offCanvasEl) + spyOn(offCanvas._backdrop, 'hide').and.callThrough() + + offCanvas.hide() + expect(offCanvas._backdrop.hide).not.toHaveBeenCalled() + expect(EventHandler.trigger).not.toHaveBeenCalled() + }) + + it('should hide a shown element', done => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new Offcanvas(offCanvasEl) + spyOn(offCanvas._backdrop, 'hide').and.callThrough() + offCanvas.show() + + offCanvasEl.addEventListener('hidden.bs.offcanvas', () => { + expect(offCanvasEl.classList.contains('show')).toEqual(false) + expect(offCanvas._backdrop.hide).toHaveBeenCalled() + done() + }) + + offCanvas.hide() + }) + + it('should not fire hidden when hide is prevented', done => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new Offcanvas(offCanvasEl) + spyOn(offCanvas._backdrop, 'hide').and.callThrough() + + offCanvas.show() + + const expectEnd = () => { + setTimeout(() => { + expect(offCanvas._backdrop.hide).not.toHaveBeenCalled() + done() + }, 10) + } + + offCanvasEl.addEventListener('hide.bs.offcanvas', e => { + e.preventDefault() + expectEnd() + }) + + offCanvasEl.addEventListener('hidden.bs.offcanvas', () => { + throw new Error('should not fire hidden event') + }) + + offCanvas.hide() + }) + + it('should release focus trap', done => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new Offcanvas(offCanvasEl) + spyOn(offCanvas._focustrap, 'deactivate').and.callThrough() + offCanvas.show() + + offCanvasEl.addEventListener('hidden.bs.offcanvas', () => { + expect(offCanvas._focustrap.deactivate).toHaveBeenCalled() + done() + }) + + offCanvas.hide() + }) + }) + + describe('dispose', () => { + it('should dispose an offcanvas', () => { + fixtureEl.innerHTML = '<div class="offcanvas"></div>' + + const offCanvasEl = fixtureEl.querySelector('div') + const offCanvas = new Offcanvas(offCanvasEl) + const backdrop = offCanvas._backdrop + spyOn(backdrop, 'dispose').and.callThrough() + const focustrap = offCanvas._focustrap + spyOn(focustrap, 'deactivate').and.callThrough() + + expect(Offcanvas.getInstance(offCanvasEl)).toEqual(offCanvas) + + spyOn(EventHandler, 'off') + + offCanvas.dispose() + + expect(backdrop.dispose).toHaveBeenCalled() + expect(offCanvas._backdrop).toBeNull() + expect(focustrap.deactivate).toHaveBeenCalled() + expect(offCanvas._focustrap).toBeNull() + expect(Offcanvas.getInstance(offCanvasEl)).toEqual(null) + }) + }) + + describe('data-api', () => { + it('should not prevent event for input', done => { + fixtureEl.innerHTML = [ + '<input type="checkbox" data-bs-toggle="offcanvas" data-bs-target="#offcanvasdiv1" />', + '<div id="offcanvasdiv1" class="offcanvas"></div>' + ].join('') + + const target = fixtureEl.querySelector('input') + const offCanvasEl = fixtureEl.querySelector('#offcanvasdiv1') + + offCanvasEl.addEventListener('shown.bs.offcanvas', () => { + expect(offCanvasEl.classList.contains('show')).toEqual(true) + expect(target.checked).toEqual(true) + done() + }) + + target.click() + }) + + it('should not call toggle on disabled elements', () => { + fixtureEl.innerHTML = [ + '<a href="#" data-bs-toggle="offcanvas" data-bs-target="#offcanvasdiv1" class="disabled"></a>', + '<div id="offcanvasdiv1" class="offcanvas"></div>' + ].join('') + + const target = fixtureEl.querySelector('a') + + spyOn(Offcanvas.prototype, 'toggle') + + target.click() + + expect(Offcanvas.prototype.toggle).not.toHaveBeenCalled() + }) + + it('should call hide first, if another offcanvas is open', done => { + fixtureEl.innerHTML = [ + '<button id="btn2" data-bs-toggle="offcanvas" data-bs-target="#offcanvas2" ></button>', + '<div id="offcanvas1" class="offcanvas"></div>', + '<div id="offcanvas2" class="offcanvas"></div>' + ].join('') + + const trigger2 = fixtureEl.querySelector('#btn2') + const offcanvasEl1 = document.querySelector('#offcanvas1') + const offcanvasEl2 = document.querySelector('#offcanvas2') + const offcanvas1 = new Offcanvas(offcanvasEl1) + + offcanvasEl1.addEventListener('shown.bs.offcanvas', () => { + trigger2.click() + }) + offcanvasEl1.addEventListener('hidden.bs.offcanvas', () => { + expect(Offcanvas.getInstance(offcanvasEl2)).not.toBeNull() + done() + }) + offcanvas1.show() + }) + + it('should focus on trigger element after closing offcanvas', done => { + fixtureEl.innerHTML = [ + '<button id="btn" data-bs-toggle="offcanvas" data-bs-target="#offcanvas" ></button>', + '<div id="offcanvas" class="offcanvas"></div>' + ].join('') + + const trigger = fixtureEl.querySelector('#btn') + const offcanvasEl = fixtureEl.querySelector('#offcanvas') + const offcanvas = new Offcanvas(offcanvasEl) + spyOn(trigger, 'focus') + + offcanvasEl.addEventListener('shown.bs.offcanvas', () => { + offcanvas.hide() + }) + offcanvasEl.addEventListener('hidden.bs.offcanvas', () => { + setTimeout(() => { + expect(trigger.focus).toHaveBeenCalled() + done() + }, 5) + }) + + trigger.click() + }) + + it('should not focus on trigger element after closing offcanvas, if it is not visible', done => { + fixtureEl.innerHTML = [ + '<button id="btn" data-bs-toggle="offcanvas" data-bs-target="#offcanvas" ></button>', + '<div id="offcanvas" class="offcanvas"></div>' + ].join('') + + const trigger = fixtureEl.querySelector('#btn') + const offcanvasEl = fixtureEl.querySelector('#offcanvas') + const offcanvas = new Offcanvas(offcanvasEl) + spyOn(trigger, 'focus') + + offcanvasEl.addEventListener('shown.bs.offcanvas', () => { + trigger.style.display = 'none' + offcanvas.hide() + }) + offcanvasEl.addEventListener('hidden.bs.offcanvas', () => { + setTimeout(() => { + expect(isVisible(trigger)).toBe(false) + expect(trigger.focus).not.toHaveBeenCalled() + done() + }, 5) + }) + + trigger.click() + }) + }) + + describe('jQueryInterface', () => { + it('should create an offcanvas', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.offcanvas.call(jQueryMock) + + expect(Offcanvas.getInstance(div)).not.toBeNull() + }) + + it('should not re create an offcanvas', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const offCanvas = new Offcanvas(div) + + jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.offcanvas.call(jQueryMock) + + expect(Offcanvas.getInstance(div)).toEqual(offCanvas) + }) + + it('should throw error on undefined method', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const action = 'undefinedMethod' + + jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface + jQueryMock.elements = [div] + + expect(() => { + jQueryMock.fn.offcanvas.call(jQueryMock, action) + }).toThrowError(TypeError, `No method named "${action}"`) + }) + + it('should throw error on protected method', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const action = '_getConfig' + + jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface + jQueryMock.elements = [div] + + expect(() => { + jQueryMock.fn.offcanvas.call(jQueryMock, action) + }).toThrowError(TypeError, `No method named "${action}"`) + }) + + it('should throw error if method "constructor" is being called', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const action = 'constructor' + + jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface + jQueryMock.elements = [div] + + expect(() => { + jQueryMock.fn.offcanvas.call(jQueryMock, action) + }).toThrowError(TypeError, `No method named "${action}"`) + }) + + it('should call offcanvas method', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + spyOn(Offcanvas.prototype, 'show') + + jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.offcanvas.call(jQueryMock, 'show') + expect(Offcanvas.prototype.show).toHaveBeenCalled() + }) + + it('should create a offcanvas with given config', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + jQueryMock.fn.offcanvas = Offcanvas.jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.offcanvas.call(jQueryMock, { scroll: true }) + + const offcanvas = Offcanvas.getInstance(div) + expect(offcanvas).not.toBeNull() + expect(offcanvas._config.scroll).toBe(true) + }) + }) + + describe('getInstance', () => { + it('should return offcanvas instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const offCanvas = new Offcanvas(div) + + expect(Offcanvas.getInstance(div)).toEqual(offCanvas) + expect(Offcanvas.getInstance(div)).toBeInstanceOf(Offcanvas) + }) + + it('should return null when there is no offcanvas instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Offcanvas.getInstance(div)).toBeNull() + }) + }) + + describe('getOrCreateInstance', () => { + it('should return offcanvas instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const offcanvas = new Offcanvas(div) + + expect(Offcanvas.getOrCreateInstance(div)).toEqual(offcanvas) + expect(Offcanvas.getInstance(div)).toEqual(Offcanvas.getOrCreateInstance(div, {})) + expect(Offcanvas.getOrCreateInstance(div)).toBeInstanceOf(Offcanvas) + }) + + it('should return new instance when there is no Offcanvas instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Offcanvas.getInstance(div)).toEqual(null) + expect(Offcanvas.getOrCreateInstance(div)).toBeInstanceOf(Offcanvas) + }) + + it('should return new instance when there is no offcanvas instance with given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Offcanvas.getInstance(div)).toEqual(null) + const offcanvas = Offcanvas.getOrCreateInstance(div, { + scroll: true + }) + expect(offcanvas).toBeInstanceOf(Offcanvas) + + expect(offcanvas._config.scroll).toEqual(true) + }) + + it('should return the instance when exists without given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const offcanvas = new Offcanvas(div, { + scroll: true + }) + expect(Offcanvas.getInstance(div)).toEqual(offcanvas) + + const offcanvas2 = Offcanvas.getOrCreateInstance(div, { + scroll: false + }) + expect(offcanvas).toBeInstanceOf(Offcanvas) + expect(offcanvas2).toEqual(offcanvas) + + expect(offcanvas2._config.scroll).toEqual(true) + }) + }) +}) diff --git a/js/tests/unit/popover.spec.js b/js/tests/unit/popover.spec.js index e5c235e2a..c54fc49ee 100644 --- a/js/tests/unit/popover.spec.js +++ b/js/tests/unit/popover.spec.js @@ -1,7 +1,7 @@ import Popover from '../../src/popover' /** Test helpers */ -import { getFixture, clearFixture, jQueryMock } from '../helpers/fixture' +import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture' describe('Popover', () => { let fixtureEl @@ -16,7 +16,7 @@ describe('Popover', () => { const popoverList = document.querySelectorAll('.popover') popoverList.forEach(popoverEl => { - document.body.removeChild(popoverEl) + popoverEl.remove() }) }) @@ -70,7 +70,7 @@ describe('Popover', () => { const popover = new Popover(popoverEl) popoverEl.addEventListener('shown.bs.popover', () => { - expect(document.querySelector('.popover')).toBeDefined() + expect(document.querySelector('.popover')).not.toBeNull() done() }) @@ -89,7 +89,7 @@ describe('Popover', () => { popoverEl.addEventListener('shown.bs.popover', () => { const popoverDisplayed = document.querySelector('.popover') - expect(popoverDisplayed).toBeDefined() + expect(popoverDisplayed).not.toBeNull() expect(popoverDisplayed.querySelector('.popover-header').textContent).toEqual('Bootstrap') expect(popoverDisplayed.querySelector('.popover-body').textContent).toEqual('loves writing tests (╯°□°)╯︵ ┻━┻') done() @@ -109,7 +109,7 @@ describe('Popover', () => { popoverEl.addEventListener('shown.bs.popover', () => { const popoverDisplayed = document.querySelector('.popover') - expect(popoverDisplayed).toBeDefined() + expect(popoverDisplayed).not.toBeNull() expect(popoverDisplayed.querySelector('.popover-body').textContent).toEqual('Popover content') done() }) @@ -117,6 +117,77 @@ describe('Popover', () => { popover.show() }) + it('should show a popover with just content without having header', done => { + fixtureEl.innerHTML = '<a href="#">Nice link</a>' + + const popoverEl = fixtureEl.querySelector('a') + const popover = new Popover(popoverEl, { + content: 'Some beautiful content :)' + }) + + popoverEl.addEventListener('shown.bs.popover', () => { + const popoverDisplayed = document.querySelector('.popover') + + expect(popoverDisplayed).not.toBeNull() + expect(popoverDisplayed.querySelector('.popover-header')).toBeNull() + expect(popoverDisplayed.querySelector('.popover-body').textContent).toEqual('Some beautiful content :)') + done() + }) + + popover.show() + }) + + it('should show a popover with just title without having body', done => { + fixtureEl.innerHTML = '<a href="#">Nice link</a>' + + const popoverEl = fixtureEl.querySelector('a') + const popover = new Popover(popoverEl, { + title: 'Title, which does not require content' + }) + + popoverEl.addEventListener('shown.bs.popover', () => { + const popoverDisplayed = document.querySelector('.popover') + + expect(popoverDisplayed).not.toBeNull() + expect(popoverDisplayed.querySelector('.popover-body')).toBeNull() + expect(popoverDisplayed.querySelector('.popover-header').textContent).toEqual('Title, which does not require content') + done() + }) + + popover.show() + }) + + it('should call setContent once', done => { + fixtureEl.innerHTML = '<a href="#">BS twitter</a>' + + const popoverEl = fixtureEl.querySelector('a') + const popover = new Popover(popoverEl, { + content: 'Popover content' + }) + + const spy = spyOn(popover, 'setContent').and.callThrough() + let times = 1 + + popoverEl.addEventListener('hidden.bs.popover', () => { + popover.show() + }) + + popoverEl.addEventListener('shown.bs.popover', () => { + const popoverDisplayed = document.querySelector('.popover') + + expect(popoverDisplayed).not.toBeNull() + expect(popoverDisplayed.querySelector('.popover-body').textContent).toEqual('Popover content') + expect(spy).toHaveBeenCalledTimes(1) + if (times > 1) { + done() + } + + times++ + popover.hide() + }) + popover.show() + }) + it('should show a popover with provided custom class', done => { fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="https://twitter.com/getbootstrap" data-bs-custom-class="custom-class">BS twitter</a>' @@ -125,7 +196,7 @@ describe('Popover', () => { popoverEl.addEventListener('shown.bs.popover', () => { const tip = document.querySelector('.popover') - expect(tip).toBeDefined() + expect(tip).not.toBeNull() expect(tip.classList.contains('custom-class')).toBeTrue() done() }) @@ -165,7 +236,7 @@ describe('Popover', () => { jQueryMock.fn.popover.call(jQueryMock) - expect(Popover.getInstance(popoverEl)).toBeDefined() + expect(Popover.getInstance(popoverEl)).not.toBeNull() }) it('should create a popover with a config object', () => { @@ -180,7 +251,7 @@ describe('Popover', () => { content: 'Popover content' }) - expect(Popover.getInstance(popoverEl)).toBeDefined() + expect(Popover.getInstance(popoverEl)).not.toBeNull() }) it('should not re create a popover', () => { @@ -226,21 +297,6 @@ describe('Popover', () => { expect(popover.show).toHaveBeenCalled() }) - - it('should do nothing if dipose is called when a popover do not exist', () => { - fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="https://twitter.com/getbootstrap">BS twitter</a>' - - const popoverEl = fixtureEl.querySelector('a') - - jQueryMock.fn.popover = Popover.jQueryInterface - jQueryMock.elements = [popoverEl] - - spyOn(Popover.prototype, 'dispose') - - jQueryMock.fn.popover.call(jQueryMock, 'dispose') - - expect(Popover.prototype.dispose).not.toHaveBeenCalled() - }) }) describe('getInstance', () => { @@ -262,4 +318,58 @@ describe('Popover', () => { expect(Popover.getInstance(popoverEl)).toEqual(null) }) }) + + describe('getOrCreateInstance', () => { + it('should return popover instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const popover = new Popover(div) + + expect(Popover.getOrCreateInstance(div)).toEqual(popover) + expect(Popover.getInstance(div)).toEqual(Popover.getOrCreateInstance(div, {})) + expect(Popover.getOrCreateInstance(div)).toBeInstanceOf(Popover) + }) + + it('should return new instance when there is no popover instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Popover.getInstance(div)).toEqual(null) + expect(Popover.getOrCreateInstance(div)).toBeInstanceOf(Popover) + }) + + it('should return new instance when there is no popover instance with given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Popover.getInstance(div)).toEqual(null) + const popover = Popover.getOrCreateInstance(div, { + placement: 'top' + }) + expect(popover).toBeInstanceOf(Popover) + + expect(popover._config.placement).toEqual('top') + }) + + it('should return the instance when exists without given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const popover = new Popover(div, { + placement: 'top' + }) + expect(Popover.getInstance(div)).toEqual(popover) + + const popover2 = Popover.getOrCreateInstance(div, { + placement: 'bottom' + }) + expect(popover).toBeInstanceOf(Popover) + expect(popover2).toEqual(popover) + + expect(popover2._config.placement).toEqual('top') + }) + }) }) diff --git a/js/tests/unit/scrollspy.spec.js b/js/tests/unit/scrollspy.spec.js index a00da485f..ad44d5b3c 100644 --- a/js/tests/unit/scrollspy.spec.js +++ b/js/tests/unit/scrollspy.spec.js @@ -54,19 +54,15 @@ describe('ScrollSpy', () => { }) describe('constructor', () => { - it('should generate an id when there is not one', () => { - fixtureEl.innerHTML = [ - '<nav></nav>', - '<div class="content"></div>' - ].join('') + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '<nav id="navigation"></nav><div class="content"></div>' - const navEl = fixtureEl.querySelector('nav') - const scrollSpy = new ScrollSpy(fixtureEl.querySelector('.content'), { - target: navEl - }) + const sSpyEl = fixtureEl.querySelector('#navigation') + const sSpyBySelector = new ScrollSpy('#navigation') + const sSpyByElement = new ScrollSpy(sSpyEl) - expect(scrollSpy).toBeDefined() - expect(navEl.getAttribute('id')).not.toEqual(null) + expect(sSpyBySelector._element).toEqual(sSpyEl) + expect(sSpyByElement._element).toEqual(sSpyEl) }) it('should not process element without target', () => { @@ -591,7 +587,24 @@ describe('ScrollSpy', () => { jQueryMock.fn.scrollspy.call(jQueryMock) - expect(ScrollSpy.getInstance(div)).toBeDefined() + expect(ScrollSpy.getInstance(div)).not.toBeNull() + }) + + it('should create a scrollspy with given config', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.scrollspy.call(jQueryMock, { offset: 15 }) + spyOn(ScrollSpy.prototype, 'constructor') + expect(ScrollSpy.prototype.constructor).not.toHaveBeenCalledWith(div, { offset: 15 }) + + const scrollspy = ScrollSpy.getInstance(div) + expect(scrollspy).not.toBeNull() + expect(scrollspy._config.offset).toBe(15) }) it('should not re create a scrollspy', () => { @@ -656,6 +669,60 @@ describe('ScrollSpy', () => { }) }) + describe('getOrCreateInstance', () => { + it('should return scrollspy instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + 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 = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(ScrollSpy.getInstance(div)).toEqual(null) + expect(ScrollSpy.getOrCreateInstance(div)).toBeInstanceOf(ScrollSpy) + }) + + it('should return new instance when there is no scrollspy instance with given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(ScrollSpy.getInstance(div)).toEqual(null) + 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 = '<div></div>' + + const div = fixtureEl.querySelector('div') + 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 = '<div data-bs-spy="scroll"></div>' diff --git a/js/tests/unit/tab.spec.js b/js/tests/unit/tab.spec.js index 463fb79af..4bd9c7a73 100644 --- a/js/tests/unit/tab.spec.js +++ b/js/tests/unit/tab.spec.js @@ -20,6 +20,22 @@ describe('Tab', () => { }) }) + describe('constructor', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = [ + '<ul class="nav"><li><a href="#home" role="tab">Home</a></li></ul>', + '<ul><li id="home"></li></ul>' + ].join('') + + const tabEl = fixtureEl.querySelector('[href="#home"]') + const tabBySelector = new Tab('[href="#home"]') + const tabByElement = new Tab(tabEl) + + expect(tabBySelector._element).toEqual(tabEl) + expect(tabByElement._element).toEqual(tabEl) + }) + }) + describe('show', () => { it('should activate element by tab id (using buttons, the preferred semantic way)', done => { fixtureEl.innerHTML = [ @@ -160,7 +176,7 @@ describe('Tab', () => { fixtureEl.innerHTML = [ '<ul class="nav nav-tabs" role="tablist">', ' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>', - ' <li class="nav-item" role="presentation"><button type="button" href="#profile" class="nav-link" role="tab">Profile</button></li>', + ' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#profile" class="nav-link" role="tab">Profile</button></li>', '</ul>', '<div class="tab-content">', ' <div class="tab-pane active" id="home" role="tabpanel"></div>', @@ -182,32 +198,6 @@ describe('Tab', () => { }, 30) }) - it('should not fire shown when tab is disabled', done => { - fixtureEl.innerHTML = [ - '<ul class="nav nav-tabs" role="tablist">', - ' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>', - ' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#profile" class="nav-link disabled" role="tab">Profile</button></li>', - '</ul>', - '<div class="tab-content">', - ' <div class="tab-pane active" id="home" role="tabpanel"></div>', - ' <div class="tab-pane" id="profile" role="tabpanel"></div>', - '</div>' - ].join('') - - const triggerDisabled = fixtureEl.querySelector('button.disabled') - const tab = new Tab(triggerDisabled) - - triggerDisabled.addEventListener('shown.bs.tab', () => { - throw new Error('should not trigger shown event') - }) - - tab.show() - setTimeout(() => { - expect().nothing() - done() - }, 30) - }) - it('show and shown events should reference correct relatedTarget', done => { fixtureEl.innerHTML = [ '<ul class="nav nav-tabs" role="tablist">', @@ -343,8 +333,8 @@ describe('Tab', () => { const tabId = linkEl.getAttribute('href') const tabIdEl = fixtureEl.querySelector(tabId) - liEl.parentNode.removeChild(liEl) - tabIdEl.parentNode.removeChild(tabIdEl) + liEl.remove() + tabIdEl.remove() secondNavTab.show() }) @@ -378,7 +368,7 @@ describe('Tab', () => { jQueryMock.fn.tab.call(jQueryMock) - expect(Tab.getInstance(div)).toBeDefined() + expect(Tab.getInstance(div)).not.toBeNull() }) it('should not re create a tab', () => { @@ -443,6 +433,28 @@ describe('Tab', () => { }) }) + describe('getOrCreateInstance', () => { + it('should return tab instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const tab = new Tab(div) + + expect(Tab.getOrCreateInstance(div)).toEqual(tab) + expect(Tab.getInstance(div)).toEqual(Tab.getOrCreateInstance(div, {})) + expect(Tab.getOrCreateInstance(div)).toBeInstanceOf(Tab) + }) + + it('should return new instance when there is no tab instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Tab.getInstance(div)).toEqual(null) + expect(Tab.getOrCreateInstance(div)).toBeInstanceOf(Tab) + }) + }) + describe('data-api', () => { it('should create dynamically a tab', done => { fixtureEl.innerHTML = [ @@ -490,6 +502,63 @@ describe('Tab', () => { expect(fixtureEl.querySelector('li:last-child .dropdown-menu a:first-child').classList.contains('active')).toEqual(false) }) + it('selecting a dropdown tab does not activate another', () => { + const nav1 = [ + '<ul class="nav nav-tabs" id="nav1">', + ' <li class="nav-item active"><a class="nav-link" href="#home" data-bs-toggle="tab">Home</a></li>', + ' <li class="nav-item dropdown">', + ' <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">Dropdown</a>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#dropdown1" id="dropdown1-tab" data-bs-toggle="tab">@fat</a>', + ' </div>', + ' </li>', + '</ul>' + ].join('') + const nav2 = [ + '<ul class="nav nav-tabs" id="nav2">', + ' <li class="nav-item active"><a class="nav-link" href="#home" data-bs-toggle="tab">Home</a></li>', + ' <li class="nav-item dropdown">', + ' <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">Dropdown</a>', + ' <div class="dropdown-menu">', + ' <a class="dropdown-item" href="#dropdown1" id="dropdown1-tab" data-bs-toggle="tab">@fat</a>', + ' </div>', + ' </li>', + '</ul>' + ].join('') + + fixtureEl.innerHTML = nav1 + nav2 + + const firstDropItem = fixtureEl.querySelector('#nav1 .dropdown-item') + + firstDropItem.click() + expect(firstDropItem.classList.contains('active')).toEqual(true) + expect(fixtureEl.querySelector('#nav1 .dropdown-toggle').classList.contains('active')).toEqual(true) + expect(fixtureEl.querySelector('#nav2 .dropdown-toggle').classList.contains('active')).toEqual(false) + expect(fixtureEl.querySelector('#nav2 .dropdown-item').classList.contains('active')).toEqual(false) + }) + + it('should support li > .dropdown-item', () => { + fixtureEl.innerHTML = [ + '<ul class="nav nav-tabs">', + ' <li class="nav-item"><a class="nav-link active" href="#home" data-bs-toggle="tab">Home</a></li>', + ' <li class="nav-item"><a class="nav-link" href="#profile" data-bs-toggle="tab">Profile</a></li>', + ' <li class="nav-item dropdown">', + ' <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">Dropdown</a>', + ' <ul class="dropdown-menu">', + ' <li><a class="dropdown-item" href="#dropdown1" id="dropdown1-tab" data-bs-toggle="tab">@fat</a></li>', + ' <li><a class="dropdown-item" href="#dropdown2" id="dropdown2-tab" data-bs-toggle="tab">@mdo</a></li>', + ' </ul>', + ' </li>', + '</ul>' + ].join('') + + const firstDropItem = fixtureEl.querySelector('.dropdown-item') + + firstDropItem.click() + expect(firstDropItem.classList.contains('active')).toEqual(true) + expect(fixtureEl.querySelector('.nav-link').classList.contains('active')).toEqual(false) + }) + it('should handle nested tabs', done => { fixtureEl.innerHTML = [ '<nav class="nav nav-tabs" role="tablist">', @@ -618,5 +687,74 @@ describe('Tab', () => { secondNavEl.click() }) + + it('should prevent default when the trigger is <a> or <area>', done => { + fixtureEl.innerHTML = [ + '<ul class="nav" role="tablist">', + ' <li><a type="button" href="#test" class="active" role="tab" data-bs-toggle="tab">Home</a></li>', + ' <li><a type="button" href="#test2" role="tab" data-bs-toggle="tab">Home</a></li>', + '</ul>' + ].join('') + + const tabEl = fixtureEl.querySelector('[href="#test2"]') + spyOn(Event.prototype, 'preventDefault').and.callThrough() + + tabEl.addEventListener('shown.bs.tab', () => { + expect(tabEl.classList.contains('active')).toEqual(true) + expect(Event.prototype.preventDefault).toHaveBeenCalled() + done() + }) + + tabEl.click() + }) + + it('should not fire shown when tab has disabled attribute', done => { + fixtureEl.innerHTML = [ + '<ul class="nav nav-tabs" role="tablist">', + ' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>', + ' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#profile" class="nav-link" disabled role="tab">Profile</button></li>', + '</ul>', + '<div class="tab-content">', + ' <div class="tab-pane active" id="home" role="tabpanel"></div>', + ' <div class="tab-pane" id="profile" role="tabpanel"></div>', + '</div>' + ].join('') + + const triggerDisabled = fixtureEl.querySelector('button[disabled]') + triggerDisabled.addEventListener('shown.bs.tab', () => { + throw new Error('should not trigger shown event') + }) + + triggerDisabled.click() + setTimeout(() => { + expect().nothing() + done() + }, 30) + }) + + it('should not fire shown when tab has disabled class', done => { + fixtureEl.innerHTML = [ + '<ul class="nav nav-tabs" role="tablist">', + ' <li class="nav-item" role="presentation"><a href="#home" class="nav-link active" role="tab" aria-selected="true">Home</a></li>', + ' <li class="nav-item" role="presentation"><a href="#profile" class="nav-link disabled" role="tab">Profile</a></li>', + '</ul>', + '<div class="tab-content">', + ' <div class="tab-pane active" id="home" role="tabpanel"></div>', + ' <div class="tab-pane" id="profile" role="tabpanel"></div>', + '</div>' + ].join('') + + const triggerDisabled = fixtureEl.querySelector('a.disabled') + + triggerDisabled.addEventListener('shown.bs.tab', () => { + throw new Error('should not trigger shown event') + }) + + triggerDisabled.click() + setTimeout(() => { + expect().nothing() + done() + }, 30) + }) }) }) diff --git a/js/tests/unit/toast.spec.js b/js/tests/unit/toast.spec.js index f8ef6e54b..c491650b1 100644 --- a/js/tests/unit/toast.spec.js +++ b/js/tests/unit/toast.spec.js @@ -1,7 +1,7 @@ import Toast from '../../src/toast' /** Test helpers */ -import { getFixture, clearFixture, jQueryMock } from '../helpers/fixture' +import { getFixture, clearFixture, createEvent, jQueryMock } from '../helpers/fixture' describe('Toast', () => { let fixtureEl @@ -27,6 +27,17 @@ describe('Toast', () => { }) describe('constructor', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '<div class="toast"></div>' + + const toastEl = fixtureEl.querySelector('.toast') + const toastBySelector = new Toast('.toast') + const toastByElement = new Toast(toastEl) + + expect(toastBySelector._element).toEqual(toastEl) + expect(toastByElement._element).toEqual(toastEl) + }) + it('should allow to config in js', done => { fixtureEl.innerHTML = [ '<div class="toast">', @@ -199,6 +210,182 @@ describe('Toast', () => { toast.show() }) + + it('should clear timeout if toast is interacted with mouse', done => { + fixtureEl.innerHTML = [ + '<div class="toast">', + ' <div class="toast-body">', + ' a simple toast', + ' </div>', + '</div>' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + const spy = spyOn(toast, '_clearTimeout').and.callThrough() + + setTimeout(() => { + spy.calls.reset() + + toastEl.addEventListener('mouseover', () => { + expect(toast._clearTimeout).toHaveBeenCalledTimes(1) + expect(toast._timeout).toBeNull() + done() + }) + + const mouseOverEvent = createEvent('mouseover') + toastEl.dispatchEvent(mouseOverEvent) + }, toast._config.delay / 2) + + toast.show() + }) + + it('should clear timeout if toast is interacted with keyboard', done => { + fixtureEl.innerHTML = [ + '<button id="outside-focusable">outside focusable</button>', + '<div class="toast">', + ' <div class="toast-body">', + ' a simple toast', + ' <button>with a button</button>', + ' </div>', + '</div>' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + const spy = spyOn(toast, '_clearTimeout').and.callThrough() + + setTimeout(() => { + spy.calls.reset() + + toastEl.addEventListener('focusin', () => { + expect(toast._clearTimeout).toHaveBeenCalledTimes(1) + expect(toast._timeout).toBeNull() + done() + }) + + const insideFocusable = toastEl.querySelector('button') + insideFocusable.focus() + }, toast._config.delay / 2) + + toast.show() + }) + + it('should still auto hide after being interacted with mouse and keyboard', done => { + fixtureEl.innerHTML = [ + '<button id="outside-focusable">outside focusable</button>', + '<div class="toast">', + ' <div class="toast-body">', + ' a simple toast', + ' <button>with a button</button>', + ' </div>', + '</div>' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + + setTimeout(() => { + toastEl.addEventListener('mouseover', () => { + const insideFocusable = toastEl.querySelector('button') + insideFocusable.focus() + }) + + toastEl.addEventListener('focusin', () => { + const mouseOutEvent = createEvent('mouseout') + toastEl.dispatchEvent(mouseOutEvent) + }) + + toastEl.addEventListener('mouseout', () => { + const outsideFocusable = document.getElementById('outside-focusable') + outsideFocusable.focus() + }) + + toastEl.addEventListener('focusout', () => { + expect(toast._timeout).not.toBeNull() + done() + }) + + const mouseOverEvent = createEvent('mouseover') + toastEl.dispatchEvent(mouseOverEvent) + }, toast._config.delay / 2) + + toast.show() + }) + + it('should not auto hide if focus leaves but mouse pointer remains inside', done => { + fixtureEl.innerHTML = [ + '<button id="outside-focusable">outside focusable</button>', + '<div class="toast">', + ' <div class="toast-body">', + ' a simple toast', + ' <button>with a button</button>', + ' </div>', + '</div>' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + + setTimeout(() => { + toastEl.addEventListener('mouseover', () => { + const insideFocusable = toastEl.querySelector('button') + insideFocusable.focus() + }) + + toastEl.addEventListener('focusin', () => { + const outsideFocusable = document.getElementById('outside-focusable') + outsideFocusable.focus() + }) + + toastEl.addEventListener('focusout', () => { + expect(toast._timeout).toBeNull() + done() + }) + + const mouseOverEvent = createEvent('mouseover') + toastEl.dispatchEvent(mouseOverEvent) + }, toast._config.delay / 2) + + toast.show() + }) + + it('should not auto hide if mouse pointer leaves but focus remains inside', done => { + fixtureEl.innerHTML = [ + '<button id="outside-focusable">outside focusable</button>', + '<div class="toast">', + ' <div class="toast-body">', + ' a simple toast', + ' <button>with a button</button>', + ' </div>', + '</div>' + ].join('') + + const toastEl = fixtureEl.querySelector('.toast') + const toast = new Toast(toastEl) + + setTimeout(() => { + toastEl.addEventListener('mouseover', () => { + const insideFocusable = toastEl.querySelector('button') + insideFocusable.focus() + }) + + toastEl.addEventListener('focusin', () => { + const mouseOutEvent = createEvent('mouseout') + toastEl.dispatchEvent(mouseOutEvent) + }) + + toastEl.addEventListener('mouseout', () => { + expect(toast._timeout).toBeNull() + done() + }) + + const mouseOverEvent = createEvent('mouseover') + toastEl.dispatchEvent(mouseOverEvent) + }, toast._config.delay / 2) + + toast.show() + }) }) describe('hide', () => { @@ -280,18 +467,14 @@ describe('Toast', () => { fixtureEl.innerHTML = '<div></div>' const toastEl = fixtureEl.querySelector('div') - spyOn(toastEl, 'addEventListener').and.callThrough() - spyOn(toastEl, 'removeEventListener').and.callThrough() const toast = new Toast(toastEl) - expect(Toast.getInstance(toastEl)).toBeDefined() - expect(toastEl.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function), jasmine.any(Boolean)) + expect(Toast.getInstance(toastEl)).not.toBeNull() toast.dispose() expect(Toast.getInstance(toastEl)).toBeNull() - expect(toastEl.removeEventListener).toHaveBeenCalledWith('click', jasmine.any(Function), jasmine.any(Boolean)) }) it('should allow to destroy toast and hide it before that', done => { @@ -307,7 +490,7 @@ describe('Toast', () => { const toast = new Toast(toastEl) const expected = () => { expect(toastEl.classList.contains('show')).toEqual(true) - expect(Toast.getInstance(toastEl)).toBeDefined() + expect(Toast.getInstance(toastEl)).not.toBeNull() toast.dispose() @@ -336,7 +519,7 @@ describe('Toast', () => { jQueryMock.fn.toast.call(jQueryMock) - expect(Toast.getInstance(div)).toBeDefined() + expect(Toast.getInstance(div)).not.toBeNull() }) it('should not re create a toast', () => { @@ -404,4 +587,58 @@ describe('Toast', () => { expect(Toast.getInstance(div)).toEqual(null) }) }) + + describe('getOrCreateInstance', () => { + it('should return toast instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const toast = new Toast(div) + + expect(Toast.getOrCreateInstance(div)).toEqual(toast) + expect(Toast.getInstance(div)).toEqual(Toast.getOrCreateInstance(div, {})) + expect(Toast.getOrCreateInstance(div)).toBeInstanceOf(Toast) + }) + + it('should return new instance when there is no toast instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Toast.getInstance(div)).toEqual(null) + expect(Toast.getOrCreateInstance(div)).toBeInstanceOf(Toast) + }) + + it('should return new instance when there is no toast instance with given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Toast.getInstance(div)).toEqual(null) + const toast = Toast.getOrCreateInstance(div, { + delay: 1 + }) + expect(toast).toBeInstanceOf(Toast) + + expect(toast._config.delay).toEqual(1) + }) + + it('should return the instance when exists without given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const toast = new Toast(div, { + delay: 1 + }) + expect(Toast.getInstance(div)).toEqual(toast) + + const toast2 = Toast.getOrCreateInstance(div, { + delay: 2 + }) + expect(toast).toBeInstanceOf(Toast) + expect(toast2).toEqual(toast) + + expect(toast2._config.delay).toEqual(1) + }) + }) }) diff --git a/js/tests/unit/tooltip.spec.js b/js/tests/unit/tooltip.spec.js index 84f5abcda..22a7edd01 100644 --- a/js/tests/unit/tooltip.spec.js +++ b/js/tests/unit/tooltip.spec.js @@ -3,7 +3,7 @@ import EventHandler from '../../src/dom/event-handler' import { noop } from '../../src/util/index' /** Test helpers */ -import { getFixture, clearFixture, jQueryMock, createEvent } from '../helpers/fixture' +import { clearFixture, createEvent, getFixture, jQueryMock } from '../helpers/fixture' describe('Tooltip', () => { let fixtureEl @@ -16,7 +16,7 @@ describe('Tooltip', () => { clearFixture() document.querySelectorAll('.tooltip').forEach(tooltipEl => { - document.body.removeChild(tooltipEl) + tooltipEl.remove() }) }) @@ -63,13 +63,24 @@ describe('Tooltip', () => { }) describe('constructor', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = '<a href="#" id="tooltipEl" rel="tooltip" title="Nice and short title">' + + const tooltipEl = fixtureEl.querySelector('#tooltipEl') + const tooltipBySelector = new Tooltip('#tooltipEl') + const tooltipByElement = new Tooltip(tooltipEl) + + expect(tooltipBySelector._element).toEqual(tooltipEl) + expect(tooltipByElement._element).toEqual(tooltipEl) + }) + it('should not take care of disallowed data attributes', () => { fixtureEl.innerHTML = '<a href="#" rel="tooltip" data-bs-sanitize="false" title="Another tooltip">' const tooltipEl = fixtureEl.querySelector('a') const tooltip = new Tooltip(tooltipEl) - expect(tooltip.config.sanitize).toEqual(true) + expect(tooltip._config.sanitize).toEqual(true) }) it('should convert title and content to string if numbers', () => { @@ -81,8 +92,8 @@ describe('Tooltip', () => { content: 7 }) - expect(tooltip.config.title).toEqual('1') - expect(tooltip.config.content).toEqual('7') + expect(tooltip._config.title).toEqual('1') + expect(tooltip._config.content).toEqual('7') }) it('should enable selector delegation', done => { @@ -183,7 +194,7 @@ describe('Tooltip', () => { tooltip.enable() tooltipEl.addEventListener('shown.bs.tooltip', () => { - expect(document.querySelector('.tooltip')).toBeDefined() + expect(document.querySelector('.tooltip')).not.toBeNull() done() }) @@ -256,7 +267,7 @@ describe('Tooltip', () => { const tooltip = new Tooltip(tooltipEl) tooltipEl.addEventListener('shown.bs.tooltip', () => { - expect(document.querySelector('.tooltip')).toBeDefined() + expect(document.querySelector('.tooltip')).not.toBeNull() done() }) @@ -375,7 +386,7 @@ describe('Tooltip', () => { const tooltip = new Tooltip(tooltipEl) tooltipEl.addEventListener('shown.bs.tooltip', () => { - expect(document.querySelector('.tooltip')).toBeDefined() + expect(document.querySelector('.tooltip')).not.toBeNull() tooltip.dispose() @@ -397,7 +408,7 @@ describe('Tooltip', () => { tooltipEl.addEventListener('shown.bs.tooltip', () => { const tooltipShown = document.querySelector('.tooltip') - expect(tooltipShown).toBeDefined() + expect(tooltipShown).not.toBeNull() expect(tooltipEl.getAttribute('aria-describedby')).toEqual(tooltipShown.getAttribute('id')) expect(tooltipShown.getAttribute('id')).toContain('tooltip') done() @@ -435,11 +446,11 @@ describe('Tooltip', () => { const tooltip = new Tooltip(tooltipEl) document.documentElement.ontouchstart = noop - spyOn(EventHandler, 'on') + spyOn(EventHandler, 'on').and.callThrough() tooltipEl.addEventListener('shown.bs.tooltip', () => { expect(document.querySelector('.tooltip')).not.toBeNull() - expect(EventHandler.on).toHaveBeenCalled() + expect(EventHandler.on).toHaveBeenCalledWith(jasmine.any(Object), 'mouseover', noop) document.documentElement.ontouchstart = undefined done() }) @@ -479,7 +490,7 @@ describe('Tooltip', () => { tooltipEl.removeEventListener('shown.bs.tooltip', firstCallback) let tooltipShown = document.querySelector('.tooltip') - tooltipShown.parentNode.removeChild(tooltipShown) + tooltipShown.remove() tooltipEl.addEventListener('shown.bs.tooltip', () => { tooltipShown = document.querySelector('.tooltip') @@ -505,7 +516,7 @@ describe('Tooltip', () => { }) tooltipEl.addEventListener('shown.bs.tooltip', () => { - expect(fixtureEl.querySelector('.tooltip')).toBeDefined() + expect(fixtureEl.querySelector('.tooltip')).not.toBeNull() done() }) @@ -524,7 +535,7 @@ describe('Tooltip', () => { }) tooltipEl.addEventListener('shown.bs.tooltip', () => { - expect(fixtureEl.querySelector('.tooltip')).toBeDefined() + expect(fixtureEl.querySelector('.tooltip')).not.toBeNull() done() }) @@ -540,7 +551,7 @@ describe('Tooltip', () => { }) tooltipEl.addEventListener('shown.bs.tooltip', () => { - expect(fixtureEl.querySelector('.tooltip')).toBeDefined() + expect(fixtureEl.querySelector('.tooltip')).not.toBeNull() done() }) @@ -557,7 +568,7 @@ describe('Tooltip', () => { }) tooltipEl.addEventListener('shown.bs.tooltip', () => { - expect(document.querySelector('.tooltip')).toBeDefined() + expect(document.querySelector('.tooltip')).not.toBeNull() expect(spy).toHaveBeenCalled() done() }) @@ -576,7 +587,7 @@ describe('Tooltip', () => { tooltipEl.addEventListener('shown.bs.tooltip', () => { const tip = document.querySelector('.tooltip') - expect(tip).toBeDefined() + expect(tip).not.toBeNull() expect(tip.classList.contains('fade')).toEqual(false) done() }) @@ -697,6 +708,100 @@ describe('Tooltip', () => { tooltipEl.dispatchEvent(createEvent('mouseover')) }) + it('should not hide tooltip if leave event occurs and interaction remains inside trigger', done => { + fixtureEl.innerHTML = [ + '<a href="#" rel="tooltip" title="Another tooltip">', + '<b>Trigger</b>', + 'the tooltip', + '</a>' + ] + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + const triggerChild = tooltipEl.querySelector('b') + + spyOn(tooltip, 'hide').and.callThrough() + + tooltipEl.addEventListener('mouseover', () => { + const moveMouseToChildEvent = createEvent('mouseout') + Object.defineProperty(moveMouseToChildEvent, 'relatedTarget', { + value: triggerChild + }) + + tooltipEl.dispatchEvent(moveMouseToChildEvent) + }) + + tooltipEl.addEventListener('mouseout', () => { + expect(tooltip.hide).not.toHaveBeenCalled() + done() + }) + + tooltipEl.dispatchEvent(createEvent('mouseover')) + }) + + it('should properly maintain tooltip state if leave event occurs and enter event occurs during hide transition', done => { + // Style this tooltip to give it plenty of room for popper to do what it wants + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip" data-bs-placement="top" style="position:fixed;left:50%;top:50%;">Trigger</a>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + spyOn(window, 'getComputedStyle').and.returnValue({ + transitionDuration: '0.15s', + transitionDelay: '0s' + }) + + setTimeout(() => { + expect(tooltip._popper).not.toBeNull() + expect(tooltip.getTipElement().getAttribute('data-popper-placement')).toBe('top') + tooltipEl.dispatchEvent(createEvent('mouseout')) + + setTimeout(() => { + expect(tooltip.getTipElement().classList.contains('show')).toEqual(false) + tooltipEl.dispatchEvent(createEvent('mouseover')) + }, 100) + + setTimeout(() => { + expect(tooltip._popper).not.toBeNull() + expect(tooltip.getTipElement().getAttribute('data-popper-placement')).toBe('top') + done() + }, 200) + }, 0) + + tooltipEl.dispatchEvent(createEvent('mouseover')) + }) + + it('should only trigger inserted event if a new tooltip element was created', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip">' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + spyOn(window, 'getComputedStyle').and.returnValue({ + transitionDuration: '0.15s', + transitionDelay: '0s' + }) + + const insertedFunc = jasmine.createSpy() + tooltipEl.addEventListener('inserted.bs.tooltip', insertedFunc) + + setTimeout(() => { + expect(insertedFunc).toHaveBeenCalledTimes(1) + tooltip.hide() + + setTimeout(() => { + tooltip.show() + }, 100) + + setTimeout(() => { + expect(insertedFunc).toHaveBeenCalledTimes(1) + done() + }, 200) + }, 0) + + tooltip.show() + }) + it('should show a tooltip with custom class provided in data attributes', done => { fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip" data-bs-custom-class="custom-class">' @@ -705,7 +810,7 @@ describe('Tooltip', () => { tooltipEl.addEventListener('shown.bs.tooltip', () => { const tip = document.querySelector('.tooltip') - expect(tip).toBeDefined() + expect(tip).not.toBeNull() expect(tip.classList.contains('custom-class')).toBeTrue() done() }) @@ -723,7 +828,7 @@ describe('Tooltip', () => { tooltipEl.addEventListener('shown.bs.tooltip', () => { const tip = document.querySelector('.tooltip') - expect(tip).toBeDefined() + expect(tip).not.toBeNull() expect(tip.classList.contains('custom-class')).toBeTrue() expect(tip.classList.contains('custom-class-2')).toBeTrue() done() @@ -743,7 +848,7 @@ describe('Tooltip', () => { tooltipEl.addEventListener('shown.bs.tooltip', () => { const tip = document.querySelector('.tooltip') - expect(tip).toBeDefined() + expect(tip).not.toBeNull() expect(spy).toHaveBeenCalled() expect(tip.classList.contains('custom-class')).toBeTrue() done() @@ -784,7 +889,7 @@ describe('Tooltip', () => { tooltipEl.addEventListener('hidden.bs.tooltip', () => { expect(document.querySelector('.tooltip')).toBeNull() - expect(EventHandler.off).toHaveBeenCalled() + expect(EventHandler.off).toHaveBeenCalledWith(jasmine.any(Object), 'mouseover', noop) document.documentElement.ontouchstart = undefined done() }) @@ -940,10 +1045,10 @@ describe('Tooltip', () => { const tooltipEl = fixtureEl.querySelector('a') const tooltip = new Tooltip(tooltipEl) - tooltip.setContent() - const tip = tooltip.getTipElement() + tooltip.setContent(tip) + expect(tip.classList.contains('show')).toEqual(false) expect(tip.classList.contains('fade')).toEqual(false) expect(tip.querySelector('.tooltip-inner').textContent).toEqual('Another tooltip') @@ -1024,7 +1129,7 @@ describe('Tooltip', () => { html: true }) - tooltip.getTipElement().appendChild(childContent) + tooltip.getTipElement().append(childContent) tooltip.setElementContent(tooltip.getTipElement(), childContent) expect().nothing() @@ -1158,7 +1263,7 @@ describe('Tooltip', () => { tooltipEl.addEventListener('shown.bs.tooltip', () => { const tooltipShown = document.querySelector('.tooltip') - expect(tooltipShown).toBeDefined() + expect(tooltipShown).not.toBeNull() expect(tooltipEl.getAttribute('aria-label')).toEqual('Another tooltip') done() }) @@ -1175,7 +1280,7 @@ describe('Tooltip', () => { tooltipEl.addEventListener('shown.bs.tooltip', () => { const tooltipShown = document.querySelector('.tooltip') - expect(tooltipShown).toBeDefined() + expect(tooltipShown).not.toBeNull() expect(tooltipEl.getAttribute('aria-label')).toEqual('Different label') done() }) @@ -1192,7 +1297,7 @@ describe('Tooltip', () => { tooltipEl.addEventListener('shown.bs.tooltip', () => { const tooltipShown = document.querySelector('.tooltip') - expect(tooltipShown).toBeDefined() + expect(tooltipShown).not.toBeNull() expect(tooltipEl.getAttribute('aria-label')).toBeNull() done() }) @@ -1201,6 +1306,60 @@ describe('Tooltip', () => { }) }) + describe('getOrCreateInstance', () => { + it('should return tooltip instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const tooltip = new Tooltip(div) + + expect(Tooltip.getOrCreateInstance(div)).toEqual(tooltip) + expect(Tooltip.getInstance(div)).toEqual(Tooltip.getOrCreateInstance(div, {})) + expect(Tooltip.getOrCreateInstance(div)).toBeInstanceOf(Tooltip) + }) + + it('should return new instance when there is no tooltip instance', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Tooltip.getInstance(div)).toEqual(null) + expect(Tooltip.getOrCreateInstance(div)).toBeInstanceOf(Tooltip) + }) + + it('should return new instance when there is no tooltip instance with given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + expect(Tooltip.getInstance(div)).toEqual(null) + const tooltip = Tooltip.getOrCreateInstance(div, { + title: () => 'test' + }) + expect(tooltip).toBeInstanceOf(Tooltip) + + expect(tooltip.getTitle()).toEqual('test') + }) + + it('should return the instance when exists without given configuration', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const tooltip = new Tooltip(div, { + title: () => 'nothing' + }) + expect(Tooltip.getInstance(div)).toEqual(tooltip) + + const tooltip2 = Tooltip.getOrCreateInstance(div, { + title: () => 'test' + }) + expect(tooltip).toBeInstanceOf(Tooltip) + expect(tooltip2).toEqual(tooltip) + + expect(tooltip2.getTitle()).toEqual('nothing') + }) + }) + describe('jQueryInterface', () => { it('should create a tooltip', () => { fixtureEl.innerHTML = '<div></div>' @@ -1212,7 +1371,7 @@ describe('Tooltip', () => { jQueryMock.fn.tooltip.call(jQueryMock) - expect(Tooltip.getInstance(div)).toBeDefined() + expect(Tooltip.getInstance(div)).not.toBeNull() }) it('should not re create a tooltip', () => { @@ -1246,21 +1405,6 @@ describe('Tooltip', () => { expect(tooltip.show).toHaveBeenCalled() }) - it('should do nothing when we call dispose or hide if there is no tooltip created', () => { - fixtureEl.innerHTML = '<div></div>' - - const div = fixtureEl.querySelector('div') - - spyOn(Tooltip.prototype, 'dispose') - - jQueryMock.fn.tooltip = Tooltip.jQueryInterface - jQueryMock.elements = [div] - - jQueryMock.fn.tooltip.call(jQueryMock, 'dispose') - - expect(Tooltip.prototype.dispose).not.toHaveBeenCalled() - }) - it('should throw error on undefined method', () => { fixtureEl.innerHTML = '<div></div>' diff --git a/js/tests/unit/util/backdrop.spec.js b/js/tests/unit/util/backdrop.spec.js new file mode 100644 index 000000000..b885b60b5 --- /dev/null +++ b/js/tests/unit/util/backdrop.spec.js @@ -0,0 +1,292 @@ +import Backdrop from '../../../src/util/backdrop' +import { getTransitionDurationFromElement } from '../../../src/util/index' +import { clearFixture, getFixture } from '../../helpers/fixture' + +const CLASS_BACKDROP = '.modal-backdrop' +const CLASS_NAME_FADE = 'fade' +const CLASS_NAME_SHOW = 'show' + +describe('Backdrop', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + const list = document.querySelectorAll(CLASS_BACKDROP) + + list.forEach(el => { + el.remove() + }) + }) + + describe('show', () => { + it('if it is "shown", should append the backdrop html once, on show, and contain "show" class', done => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: false + }) + const getElements = () => document.querySelectorAll(CLASS_BACKDROP) + + expect(getElements().length).toEqual(0) + + instance.show() + instance.show(() => { + expect(getElements().length).toEqual(1) + getElements().forEach(el => { + expect(el.classList.contains(CLASS_NAME_SHOW)).toEqual(true) + }) + done() + }) + }) + + it('if it is not "shown", should not append the backdrop html', done => { + const instance = new Backdrop({ + isVisible: false, + isAnimated: true + }) + const getElements = () => document.querySelectorAll(CLASS_BACKDROP) + + expect(getElements().length).toEqual(0) + instance.show(() => { + expect(getElements().length).toEqual(0) + done() + }) + }) + + it('if it is "shown" and "animated", should append the backdrop html once, and contain "fade" class', done => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: true + }) + const getElements = () => document.querySelectorAll(CLASS_BACKDROP) + + expect(getElements().length).toEqual(0) + + instance.show(() => { + expect(getElements().length).toEqual(1) + getElements().forEach(el => { + expect(el.classList.contains(CLASS_NAME_FADE)).toEqual(true) + }) + done() + }) + }) + }) + + describe('hide', () => { + it('should remove the backdrop html', done => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: true + }) + + const getElements = () => document.body.querySelectorAll(CLASS_BACKDROP) + + expect(getElements().length).toEqual(0) + instance.show(() => { + expect(getElements().length).toEqual(1) + instance.hide(() => { + expect(getElements().length).toEqual(0) + done() + }) + }) + }) + + it('should remove "show" class', done => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: true + }) + const elem = instance._getElement() + + instance.show() + instance.hide(() => { + expect(elem.classList.contains(CLASS_NAME_SHOW)).toEqual(false) + done() + }) + }) + + it('if it is not "shown", should not try to remove Node on remove method', done => { + const instance = new Backdrop({ + isVisible: false, + isAnimated: true + }) + const getElements = () => document.querySelectorAll(CLASS_BACKDROP) + const spy = spyOn(instance, 'dispose').and.callThrough() + + expect(getElements().length).toEqual(0) + expect(instance._isAppended).toEqual(false) + instance.show(() => { + instance.hide(() => { + expect(getElements().length).toEqual(0) + expect(spy).not.toHaveBeenCalled() + expect(instance._isAppended).toEqual(false) + done() + }) + }) + }) + + it('should not error if the backdrop no longer has a parent', done => { + fixtureEl.innerHTML = '<div id="wrapper"></div>' + + const wrapper = fixtureEl.querySelector('#wrapper') + const instance = new Backdrop({ + isVisible: true, + isAnimated: true, + rootElement: wrapper + }) + + const getElements = () => document.querySelectorAll(CLASS_BACKDROP) + + instance.show(() => { + wrapper.remove() + instance.hide(() => { + expect(getElements().length).toEqual(0) + done() + }) + }) + }) + }) + + describe('click callback', () => { + it('it should execute callback on click', done => { + const spy = jasmine.createSpy('spy') + + const instance = new Backdrop({ + isVisible: true, + isAnimated: false, + clickCallback: () => spy() + }) + const endTest = () => { + setTimeout(() => { + expect(spy).toHaveBeenCalled() + done() + }, 10) + } + + instance.show(() => { + const clickEvent = document.createEvent('MouseEvents') + clickEvent.initEvent('mousedown', true, true) + document.querySelector(CLASS_BACKDROP).dispatchEvent(clickEvent) + endTest() + }) + }) + }) + + describe('animation callbacks', () => { + it('if it is animated, should show and hide backdrop after counting transition duration', done => { + const instance = new Backdrop({ + isVisible: true, + isAnimated: true + }) + const spy2 = jasmine.createSpy('spy2') + + const execDone = () => { + setTimeout(() => { + expect(spy2).toHaveBeenCalledTimes(2) + done() + }, 10) + } + + instance.show(spy2) + instance.hide(() => { + spy2() + execDone() + }) + expect(spy2).not.toHaveBeenCalled() + }) + + it('if it is not animated, should show and hide backdrop without delay', done => { + const spy = jasmine.createSpy('spy', getTransitionDurationFromElement) + const instance = new Backdrop({ + isVisible: true, + isAnimated: false + }) + const spy2 = jasmine.createSpy('spy2') + + instance.show(spy2) + instance.hide(spy2) + + setTimeout(() => { + expect(spy2).toHaveBeenCalled() + expect(spy).not.toHaveBeenCalled() + done() + }, 10) + }) + + it('if it is not "shown", should not call delay callbacks', done => { + const instance = new Backdrop({ + isVisible: false, + isAnimated: true + }) + const spy = jasmine.createSpy('spy', getTransitionDurationFromElement) + + instance.show() + instance.hide(() => { + expect(spy).not.toHaveBeenCalled() + done() + }) + }) + }) + describe('Config', () => { + describe('rootElement initialization', () => { + it('Should be appended on "document.body" by default', done => { + const instance = new Backdrop({ + isVisible: true + }) + const getElement = () => document.querySelector(CLASS_BACKDROP) + instance.show(() => { + expect(getElement().parentElement).toEqual(document.body) + done() + }) + }) + + it('Should find the rootElement if passed as a string', done => { + const instance = new Backdrop({ + isVisible: true, + rootElement: 'body' + }) + const getElement = () => document.querySelector(CLASS_BACKDROP) + instance.show(() => { + expect(getElement().parentElement).toEqual(document.body) + done() + }) + }) + + it('Should appended on any element given by the proper config', done => { + fixtureEl.innerHTML = [ + '<div id="wrapper">', + '</div>' + ].join('') + + const wrapper = fixtureEl.querySelector('#wrapper') + const instance = new Backdrop({ + isVisible: true, + rootElement: wrapper + }) + const getElement = () => document.querySelector(CLASS_BACKDROP) + instance.show(() => { + expect(getElement().parentElement).toEqual(wrapper) + done() + }) + }) + }) + + describe('ClassName', () => { + it('Should be able to have different classNames than default', done => { + const instance = new Backdrop({ + isVisible: true, + className: 'foo' + }) + const getElement = () => document.querySelector('.foo') + instance.show(() => { + expect(getElement()).toEqual(instance._getElement()) + instance.dispose() + done() + }) + }) + }) + }) +}) diff --git a/js/tests/unit/util/component-functions.spec.js b/js/tests/unit/util/component-functions.spec.js new file mode 100644 index 000000000..edaedd32e --- /dev/null +++ b/js/tests/unit/util/component-functions.spec.js @@ -0,0 +1,108 @@ +/* Test helpers */ + +import { clearFixture, createEvent, getFixture } from '../../helpers/fixture' +import { enableDismissTrigger } from '../../../src/util/component-functions' +import BaseComponent from '../../../src/base-component' + +class DummyClass2 extends BaseComponent { + static get NAME() { + return 'test' + } + + hide() { + return true + } + + testMethod() { + return true + } +} + +describe('Plugin functions', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('data-bs-dismiss functionality', () => { + it('should get Plugin and execute the given method, when a click occurred on data-bs-dismiss="PluginName"', () => { + fixtureEl.innerHTML = [ + '<div id="foo" class="test">', + ' <button type="button" data-bs-dismiss="test" data-bs-target="#foo"></button>', + '</div>' + ].join('') + + spyOn(DummyClass2, 'getOrCreateInstance').and.callThrough() + spyOn(DummyClass2.prototype, 'testMethod') + const componentWrapper = fixtureEl.querySelector('#foo') + const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]') + const event = createEvent('click') + + enableDismissTrigger(DummyClass2, 'testMethod') + btnClose.dispatchEvent(event) + + expect(DummyClass2.getOrCreateInstance).toHaveBeenCalledWith(componentWrapper) + expect(DummyClass2.prototype.testMethod).toHaveBeenCalled() + }) + + it('if data-bs-dismiss="PluginName" hasn\'t got "data-bs-target", "getOrCreateInstance" has to be initialized by closest "plugin.Name" class', () => { + fixtureEl.innerHTML = [ + '<div id="foo" class="test">', + ' <button type="button" data-bs-dismiss="test"></button>', + '</div>' + ].join('') + + spyOn(DummyClass2, 'getOrCreateInstance').and.callThrough() + spyOn(DummyClass2.prototype, 'hide') + const componentWrapper = fixtureEl.querySelector('#foo') + const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]') + const event = createEvent('click') + + enableDismissTrigger(DummyClass2) + btnClose.dispatchEvent(event) + + expect(DummyClass2.getOrCreateInstance).toHaveBeenCalledWith(componentWrapper) + expect(DummyClass2.prototype.hide).toHaveBeenCalled() + }) + + it('if data-bs-dismiss="PluginName" is disabled, must not trigger function', () => { + fixtureEl.innerHTML = [ + '<div id="foo" class="test">', + ' <button type="button" disabled data-bs-dismiss="test"></button>', + '</div>' + ].join('') + + spyOn(DummyClass2, 'getOrCreateInstance').and.callThrough() + const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]') + const event = createEvent('click') + + enableDismissTrigger(DummyClass2) + btnClose.dispatchEvent(event) + + expect(DummyClass2.getOrCreateInstance).not.toHaveBeenCalled() + }) + + it('should prevent default when the trigger is <a> or <area>', () => { + fixtureEl.innerHTML = [ + '<div id="foo" class="test">', + ' <a type="button" data-bs-dismiss="test"></a>', + '</div>' + ].join('') + + const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]') + const event = createEvent('click') + + enableDismissTrigger(DummyClass2) + spyOn(Event.prototype, 'preventDefault').and.callThrough() + + btnClose.dispatchEvent(event) + + expect(Event.prototype.preventDefault).toHaveBeenCalled() + }) + }) +}) diff --git a/js/tests/unit/util/focustrap.spec.js b/js/tests/unit/util/focustrap.spec.js new file mode 100644 index 000000000..2457239c4 --- /dev/null +++ b/js/tests/unit/util/focustrap.spec.js @@ -0,0 +1,210 @@ +import FocusTrap from '../../../src/util/focustrap' +import EventHandler from '../../../src/dom/event-handler' +import SelectorEngine from '../../../src/dom/selector-engine' +import { clearFixture, getFixture, createEvent } from '../../helpers/fixture' + +describe('FocusTrap', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('activate', () => { + it('should autofocus itself by default', () => { + fixtureEl.innerHTML = '<div id="focustrap" tabindex="-1"></div>' + + const trapElement = fixtureEl.querySelector('div') + + spyOn(trapElement, 'focus') + + const focustrap = new FocusTrap({ trapElement }) + focustrap.activate() + + expect(trapElement.focus).toHaveBeenCalled() + }) + + it('if configured not to autofocus, should not autofocus itself', () => { + fixtureEl.innerHTML = '<div id="focustrap" tabindex="-1"></div>' + + const trapElement = fixtureEl.querySelector('div') + + spyOn(trapElement, 'focus') + + const focustrap = new FocusTrap({ trapElement, autofocus: false }) + focustrap.activate() + + expect(trapElement.focus).not.toHaveBeenCalled() + }) + + it('should force focus inside focus trap if it can', done => { + fixtureEl.innerHTML = [ + '<a href="#" id="outside">outside</a>', + '<div id="focustrap" tabindex="-1">', + ' <a href="#" id="inside">inside</a>', + '</div>' + ].join('') + + const trapElement = fixtureEl.querySelector('div') + const focustrap = new FocusTrap({ trapElement }) + focustrap.activate() + + const inside = document.getElementById('inside') + + const focusInListener = () => { + expect(inside.focus).toHaveBeenCalled() + document.removeEventListener('focusin', focusInListener) + done() + } + + spyOn(inside, 'focus') + spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [inside]) + + document.addEventListener('focusin', focusInListener) + + const focusInEvent = createEvent('focusin', { bubbles: true }) + Object.defineProperty(focusInEvent, 'target', { + value: document.getElementById('outside') + }) + + document.dispatchEvent(focusInEvent) + }) + + it('should wrap focus around foward on tab', done => { + fixtureEl.innerHTML = [ + '<a href="#" id="outside">outside</a>', + '<div id="focustrap" tabindex="-1">', + ' <a href="#" id="first">first</a>', + ' <a href="#" id="inside">inside</a>', + ' <a href="#" id="last">last</a>', + '</div>' + ].join('') + + const trapElement = fixtureEl.querySelector('div') + const focustrap = new FocusTrap({ trapElement }) + focustrap.activate() + + const first = document.getElementById('first') + const inside = document.getElementById('inside') + const last = document.getElementById('last') + const outside = document.getElementById('outside') + + spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [first, inside, last]) + spyOn(first, 'focus').and.callThrough() + + const focusInListener = () => { + expect(first.focus).toHaveBeenCalled() + first.removeEventListener('focusin', focusInListener) + done() + } + + first.addEventListener('focusin', focusInListener) + + const keydown = createEvent('keydown') + keydown.key = 'Tab' + + document.dispatchEvent(keydown) + outside.focus() + }) + + it('should wrap focus around backwards on shift-tab', done => { + fixtureEl.innerHTML = [ + '<a href="#" id="outside">outside</a>', + '<div id="focustrap" tabindex="-1">', + ' <a href="#" id="first">first</a>', + ' <a href="#" id="inside">inside</a>', + ' <a href="#" id="last">last</a>', + '</div>' + ].join('') + + const trapElement = fixtureEl.querySelector('div') + const focustrap = new FocusTrap({ trapElement }) + focustrap.activate() + + const first = document.getElementById('first') + const inside = document.getElementById('inside') + const last = document.getElementById('last') + const outside = document.getElementById('outside') + + spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [first, inside, last]) + spyOn(last, 'focus').and.callThrough() + + const focusInListener = () => { + expect(last.focus).toHaveBeenCalled() + last.removeEventListener('focusin', focusInListener) + done() + } + + last.addEventListener('focusin', focusInListener) + + const keydown = createEvent('keydown') + keydown.key = 'Tab' + keydown.shiftKey = true + + document.dispatchEvent(keydown) + outside.focus() + }) + + it('should force focus on itself if there is no focusable content', done => { + fixtureEl.innerHTML = [ + '<a href="#" id="outside">outside</a>', + '<div id="focustrap" tabindex="-1"></div>' + ].join('') + + const trapElement = fixtureEl.querySelector('div') + const focustrap = new FocusTrap({ trapElement }) + focustrap.activate() + + const focusInListener = () => { + expect(focustrap._config.trapElement.focus).toHaveBeenCalled() + document.removeEventListener('focusin', focusInListener) + done() + } + + spyOn(focustrap._config.trapElement, 'focus') + + document.addEventListener('focusin', focusInListener) + + const focusInEvent = createEvent('focusin', { bubbles: true }) + Object.defineProperty(focusInEvent, 'target', { + value: document.getElementById('outside') + }) + + document.dispatchEvent(focusInEvent) + }) + }) + + describe('deactivate', () => { + it('should flag itself as no longer active', () => { + const focustrap = new FocusTrap({ trapElement: fixtureEl }) + focustrap.activate() + expect(focustrap._isActive).toBe(true) + + focustrap.deactivate() + expect(focustrap._isActive).toBe(false) + }) + + it('should remove all event listeners', () => { + const focustrap = new FocusTrap({ trapElement: fixtureEl }) + focustrap.activate() + + spyOn(EventHandler, 'off') + focustrap.deactivate() + + expect(EventHandler.off).toHaveBeenCalled() + }) + + it('doesn\'t try removing event listeners unless it needs to (in case it hasn\'t been activated)', () => { + const focustrap = new FocusTrap({ trapElement: fixtureEl }) + + spyOn(EventHandler, 'off') + focustrap.deactivate() + + expect(EventHandler.off).not.toHaveBeenCalled() + }) + }) +}) diff --git a/js/tests/unit/util/index.spec.js b/js/tests/unit/util/index.spec.js index 935e021dd..38e94dc6b 100644 --- a/js/tests/unit/util/index.spec.js +++ b/js/tests/unit/util/index.spec.js @@ -1,7 +1,7 @@ import * as Util from '../../../src/util/index' /** Test helpers */ -import { getFixture, clearFixture } from '../../helpers/fixture' +import { clearFixture, getFixture } from '../../helpers/fixture' describe('Util', () => { let fixtureEl @@ -157,12 +157,13 @@ describe('Util', () => { describe('triggerTransitionEnd', () => { it('should trigger transitionend event', done => { - fixtureEl.innerHTML = '<div style="transition: all 300ms ease-out;"></div>' + fixtureEl.innerHTML = '<div></div>' const el = fixtureEl.querySelector('div') + const spy = spyOn(el, 'dispatchEvent').and.callThrough() el.addEventListener('transitionend', () => { - expect().nothing() + expect(spy).toHaveBeenCalled() done() }) @@ -171,51 +172,58 @@ describe('Util', () => { }) describe('isElement', () => { - it('should detect if the parameter is an element or not', () => { - fixtureEl.innerHTML = '<div></div>' + it('should detect if the parameter is an element or not and return Boolean', () => { + fixtureEl.innerHTML = + [ + '<div id="foo" class="test"></div>', + '<div id="bar" class="test"></div>' + ].join('') - const el = document.querySelector('div') + const el = fixtureEl.querySelector('#foo') - expect(Util.isElement(el)).toEqual(el.nodeType) - expect(Util.isElement({})).toEqual(undefined) + expect(Util.isElement(el)).toEqual(true) + expect(Util.isElement({})).toEqual(false) + expect(Util.isElement(fixtureEl.querySelectorAll('.test'))).toEqual(false) }) it('should detect jQuery element', () => { fixtureEl.innerHTML = '<div></div>' - const el = document.querySelector('div') + const el = fixtureEl.querySelector('div') const fakejQuery = { - 0: el + 0: el, + jquery: 'foo' } - expect(Util.isElement(fakejQuery)).toEqual(el.nodeType) + expect(Util.isElement(fakejQuery)).toEqual(true) }) }) - describe('emulateTransitionEnd', () => { - it('should emulate transition end', () => { - fixtureEl.innerHTML = '<div></div>' - - const el = document.querySelector('div') - const spy = spyOn(window, 'setTimeout') - - Util.emulateTransitionEnd(el, 10) - expect(spy).toHaveBeenCalled() - }) - - it('should not emulate transition end if already triggered', done => { - fixtureEl.innerHTML = '<div></div>' + describe('getElement', () => { + it('should try to parse element', () => { + fixtureEl.innerHTML = + [ + '<div id="foo" class="test"></div>', + '<div id="bar" class="test"></div>' + ].join('') const el = fixtureEl.querySelector('div') - const spy = spyOn(el, 'removeEventListener') - Util.emulateTransitionEnd(el, 10) - Util.triggerTransitionEnd(el) + expect(Util.getElement(el)).toEqual(el) + expect(Util.getElement('#foo')).toEqual(el) + expect(Util.getElement('#fail')).toBeNull() + expect(Util.getElement({})).toBeNull() + expect(Util.getElement([])).toBeNull() + expect(Util.getElement()).toBeNull() + expect(Util.getElement(null)).toBeNull() + expect(Util.getElement(fixtureEl.querySelectorAll('.test'))).toBeNull() + + const fakejQueryObject = { + 0: el, + jquery: 'foo' + } - setTimeout(() => { - expect(spy).toHaveBeenCalled() - done() - }, 20) + expect(Util.getElement(fakejQueryObject)).toEqual(el) }) }) @@ -292,10 +300,14 @@ describe('Util', () => { expect(Util.isVisible(div)).toEqual(false) }) - it('should return false if the parent element is not visible', () => { + it('should return false if an ancestor element is display none', () => { fixtureEl.innerHTML = [ '<div style="display: none;">', - ' <div class="content"></div>', + ' <div>', + ' <div>', + ' <div class="content"></div>', + ' </div>', + ' </div>', '</div>' ].join('') @@ -304,6 +316,38 @@ describe('Util', () => { expect(Util.isVisible(div)).toEqual(false) }) + it('should return false if an ancestor element is visibility hidden', () => { + fixtureEl.innerHTML = [ + '<div style="visibility: hidden;">', + ' <div>', + ' <div>', + ' <div class="content"></div>', + ' </div>', + ' </div>', + '</div>' + ].join('') + + const div = fixtureEl.querySelector('.content') + + expect(Util.isVisible(div)).toEqual(false) + }) + + it('should return true if an ancestor element is visibility hidden, but reverted', () => { + fixtureEl.innerHTML = [ + '<div style="visibility: hidden;">', + ' <div style="visibility: visible;">', + ' <div>', + ' <div class="content"></div>', + ' </div>', + ' </div>', + '</div>' + ].join('') + + const div = fixtureEl.querySelector('.content') + + expect(Util.isVisible(div)).toEqual(true) + }) + it('should return true if the element is visible', () => { fixtureEl.innerHTML = [ '<div>', @@ -315,6 +359,126 @@ describe('Util', () => { expect(Util.isVisible(div)).toEqual(true) }) + + it('should return false if the element is hidden, but not via display or visibility', () => { + fixtureEl.innerHTML = [ + '<details>', + ' <div id="element"></div>', + '</details>' + ].join('') + + const div = fixtureEl.querySelector('#element') + + expect(Util.isVisible(div)).toEqual(false) + }) + }) + + describe('isDisabled', () => { + it('should return true if the element is not defined', () => { + expect(Util.isDisabled(null)).toEqual(true) + expect(Util.isDisabled(undefined)).toEqual(true) + expect(Util.isDisabled()).toEqual(true) + }) + + it('should return true if the element provided is not a dom element', () => { + expect(Util.isDisabled({})).toEqual(true) + expect(Util.isDisabled('test')).toEqual(true) + }) + + it('should return true if the element has disabled attribute', () => { + fixtureEl.innerHTML = [ + '<div>', + ' <div id="element" disabled="disabled"></div>', + ' <div id="element1" disabled="true"></div>', + ' <div id="element2" disabled></div>', + '</div>' + ].join('') + + const div = fixtureEl.querySelector('#element') + const div1 = fixtureEl.querySelector('#element1') + const div2 = fixtureEl.querySelector('#element2') + + expect(Util.isDisabled(div)).toEqual(true) + expect(Util.isDisabled(div1)).toEqual(true) + expect(Util.isDisabled(div2)).toEqual(true) + }) + + it('should return false if the element has disabled attribute with "false" value, or doesn\'t have attribute', () => { + fixtureEl.innerHTML = [ + '<div>', + ' <div id="element" disabled="false"></div>', + ' <div id="element1" ></div>', + '</div>' + ].join('') + + const div = fixtureEl.querySelector('#element') + const div1 = fixtureEl.querySelector('#element1') + + expect(Util.isDisabled(div)).toEqual(false) + expect(Util.isDisabled(div1)).toEqual(false) + }) + + it('should return false if the element is not disabled ', () => { + fixtureEl.innerHTML = [ + '<div>', + ' <button id="button"></button>', + ' <select id="select"></select>', + ' <select id="input"></select>', + '</div>' + ].join('') + + const el = selector => fixtureEl.querySelector(selector) + + expect(Util.isDisabled(el('#button'))).toEqual(false) + expect(Util.isDisabled(el('#select'))).toEqual(false) + expect(Util.isDisabled(el('#input'))).toEqual(false) + }) + it('should return true if the element has disabled attribute', () => { + fixtureEl.innerHTML = [ + '<div>', + ' <input id="input" disabled="disabled"/>', + ' <input id="input1" disabled="disabled"/>', + ' <button id="button" disabled="true"></button>', + ' <button id="button1" disabled="disabled"></button>', + ' <button id="button2" disabled></button>', + ' <select id="select" disabled></select>', + ' <select id="input" disabled></select>', + '</div>' + ].join('') + + const el = selector => fixtureEl.querySelector(selector) + + expect(Util.isDisabled(el('#input'))).toEqual(true) + expect(Util.isDisabled(el('#input1'))).toEqual(true) + expect(Util.isDisabled(el('#button'))).toEqual(true) + expect(Util.isDisabled(el('#button1'))).toEqual(true) + expect(Util.isDisabled(el('#button2'))).toEqual(true) + expect(Util.isDisabled(el('#input'))).toEqual(true) + }) + + it('should return true if the element has class "disabled"', () => { + fixtureEl.innerHTML = [ + '<div>', + ' <div id="element" class="disabled"></div>', + '</div>' + ].join('') + + const div = fixtureEl.querySelector('#element') + + expect(Util.isDisabled(div)).toEqual(true) + }) + + it('should return true if the element has class "disabled" but disabled attribute is false', () => { + fixtureEl.innerHTML = [ + '<div>', + ' <input id="input" class="disabled" disabled="false"/>', + '</div>' + ].join('') + + const div = fixtureEl.querySelector('#input') + + expect(Util.isDisabled(div)).toEqual(true) + }) }) describe('findShadowRoot', () => { @@ -369,8 +533,8 @@ describe('Util', () => { }) describe('noop', () => { - it('should return a function', () => { - expect(typeof Util.noop()).toEqual('function') + it('should be a function', () => { + expect(typeof Util.noop).toEqual('function') }) }) @@ -379,8 +543,9 @@ describe('Util', () => { fixtureEl.innerHTML = '<div></div>' const div = fixtureEl.querySelector('div') - - expect(Util.reflow(div)).toEqual(0) + const spy = spyOnProperty(div, 'offsetHeight') + Util.reflow(div) + expect(spy).toHaveBeenCalled() }) }) @@ -418,15 +583,24 @@ describe('Util', () => { }) describe('onDOMContentLoaded', () => { - it('should execute callback when DOMContentLoaded is fired', () => { + it('should execute callbacks when DOMContentLoaded is fired and should not add more than one listener', () => { const spy = jasmine.createSpy() + const spy2 = jasmine.createSpy() + + spyOn(document, 'addEventListener').and.callThrough() spyOnProperty(document, 'readyState').and.returnValue('loading') + Util.onDOMContentLoaded(spy) - window.document.dispatchEvent(new Event('DOMContentLoaded', { + Util.onDOMContentLoaded(spy2) + + document.dispatchEvent(new Event('DOMContentLoaded', { bubbles: true, cancelable: true })) + expect(spy).toHaveBeenCalled() + expect(spy2).toHaveBeenCalled() + expect(document.addEventListener).toHaveBeenCalledTimes(1) }) it('should execute callback if readyState is not "loading"', () => { @@ -452,12 +626,192 @@ describe('Util', () => { it('should define a plugin on the jQuery instance', () => { const pluginMock = function () {} + pluginMock.NAME = 'test' pluginMock.jQueryInterface = function () {} - Util.defineJQueryPlugin('test', pluginMock) + Util.defineJQueryPlugin(pluginMock) expect(fakejQuery.fn.test).toBe(pluginMock.jQueryInterface) expect(fakejQuery.fn.test.Constructor).toBe(pluginMock) expect(typeof fakejQuery.fn.test.noConflict).toEqual('function') }) }) + + describe('execute', () => { + it('should execute if arg is function', () => { + const spy = jasmine.createSpy('spy') + Util.execute(spy) + expect(spy).toHaveBeenCalled() + }) + }) + + describe('executeAfterTransition', () => { + it('should immediately execute a function when waitForTransition parameter is false', () => { + const el = document.createElement('div') + const callbackSpy = jasmine.createSpy('callback spy') + const eventListenerSpy = spyOn(el, 'addEventListener') + + Util.executeAfterTransition(callbackSpy, el, false) + + expect(callbackSpy).toHaveBeenCalled() + expect(eventListenerSpy).not.toHaveBeenCalled() + }) + + it('should execute a function when a transitionend event is dispatched', () => { + const el = document.createElement('div') + const callbackSpy = jasmine.createSpy('callback spy') + + spyOn(window, 'getComputedStyle').and.returnValue({ + transitionDuration: '0.05s', + transitionDelay: '0s' + }) + + Util.executeAfterTransition(callbackSpy, el) + + el.dispatchEvent(new TransitionEvent('transitionend')) + + expect(callbackSpy).toHaveBeenCalled() + }) + + it('should execute a function after a computed CSS transition duration and there was no transitionend event dispatched', done => { + const el = document.createElement('div') + const callbackSpy = jasmine.createSpy('callback spy') + + spyOn(window, 'getComputedStyle').and.returnValue({ + transitionDuration: '0.05s', + transitionDelay: '0s' + }) + + Util.executeAfterTransition(callbackSpy, el) + + setTimeout(() => { + expect(callbackSpy).toHaveBeenCalled() + done() + }, 70) + }) + + it('should not execute a function a second time after a computed CSS transition duration and if a transitionend event has already been dispatched', done => { + const el = document.createElement('div') + const callbackSpy = jasmine.createSpy('callback spy') + + spyOn(window, 'getComputedStyle').and.returnValue({ + transitionDuration: '0.05s', + transitionDelay: '0s' + }) + + Util.executeAfterTransition(callbackSpy, el) + + setTimeout(() => { + el.dispatchEvent(new TransitionEvent('transitionend')) + }, 50) + + setTimeout(() => { + expect(callbackSpy).toHaveBeenCalledTimes(1) + done() + }, 70) + }) + + it('should not trigger a transitionend event if another transitionend event had already happened', done => { + const el = document.createElement('div') + + spyOn(window, 'getComputedStyle').and.returnValue({ + transitionDuration: '0.05s', + transitionDelay: '0s' + }) + + Util.executeAfterTransition(() => {}, el) + + // simulate a event dispatched by the browser + el.dispatchEvent(new TransitionEvent('transitionend')) + + const dispatchSpy = spyOn(el, 'dispatchEvent').and.callThrough() + + setTimeout(() => { + // setTimeout should not have triggered another transitionend event. + expect(dispatchSpy).not.toHaveBeenCalled() + done() + }, 70) + }) + + it('should ignore transitionend events from nested elements', done => { + fixtureEl.innerHTML = [ + '<div class="outer">', + ' <div class="nested"></div>', + '</div>' + ].join('') + + const outer = fixtureEl.querySelector('.outer') + const nested = fixtureEl.querySelector('.nested') + const callbackSpy = jasmine.createSpy('callback spy') + + spyOn(window, 'getComputedStyle').and.returnValue({ + transitionDuration: '0.05s', + transitionDelay: '0s' + }) + + Util.executeAfterTransition(callbackSpy, outer) + + nested.dispatchEvent(new TransitionEvent('transitionend', { + bubbles: true + })) + + setTimeout(() => { + expect(callbackSpy).not.toHaveBeenCalled() + }, 20) + + setTimeout(() => { + expect(callbackSpy).toHaveBeenCalled() + done() + }, 70) + }) + }) + + describe('getNextActiveElement', () => { + it('should return first element if active not exists or not given and shouldGetNext is either true, or false with cycling being disabled', () => { + const array = ['a', 'b', 'c', 'd'] + + expect(Util.getNextActiveElement(array, '', true, true)).toEqual('a') + expect(Util.getNextActiveElement(array, 'g', true, true)).toEqual('a') + expect(Util.getNextActiveElement(array, '', true, false)).toEqual('a') + expect(Util.getNextActiveElement(array, 'g', true, false)).toEqual('a') + expect(Util.getNextActiveElement(array, '', false, false)).toEqual('a') + expect(Util.getNextActiveElement(array, 'g', false, false)).toEqual('a') + }) + + it('should return last element if active not exists or not given and shouldGetNext is false but cycling is enabled', () => { + const array = ['a', 'b', 'c', 'd'] + + expect(Util.getNextActiveElement(array, '', false, true)).toEqual('d') + expect(Util.getNextActiveElement(array, 'g', false, true)).toEqual('d') + }) + + it('should return next element or same if is last', () => { + const array = ['a', 'b', 'c', 'd'] + + expect(Util.getNextActiveElement(array, 'a', true, true)).toEqual('b') + expect(Util.getNextActiveElement(array, 'b', true, true)).toEqual('c') + expect(Util.getNextActiveElement(array, 'd', true, false)).toEqual('d') + }) + + it('should return next element or first, if is last and "isCycleAllowed = true"', () => { + const array = ['a', 'b', 'c', 'd'] + + expect(Util.getNextActiveElement(array, 'c', true, true)).toEqual('d') + expect(Util.getNextActiveElement(array, 'd', true, true)).toEqual('a') + }) + + it('should return previous element or same if is first', () => { + const array = ['a', 'b', 'c', 'd'] + + expect(Util.getNextActiveElement(array, 'b', false, true)).toEqual('a') + expect(Util.getNextActiveElement(array, 'd', false, true)).toEqual('c') + expect(Util.getNextActiveElement(array, 'a', false, false)).toEqual('a') + }) + + it('should return next element or first, if is last and "isCycleAllowed = true"', () => { + const array = ['a', 'b', 'c', 'd'] + + expect(Util.getNextActiveElement(array, 'd', false, true)).toEqual('c') + expect(Util.getNextActiveElement(array, 'a', false, true)).toEqual('d') + }) + }) }) diff --git a/js/tests/unit/util/sanitizer.spec.js b/js/tests/unit/util/sanitizer.spec.js index 869b8c561..7379d221f 100644 --- a/js/tests/unit/util/sanitizer.spec.js +++ b/js/tests/unit/util/sanitizer.spec.js @@ -66,5 +66,15 @@ describe('Sanitizer', () => { expect(result).toEqual(template) expect(DOMParser.prototype.parseFromString).not.toHaveBeenCalled() }) + + it('should allow multiple sanitation passes of the same template', () => { + const template = '<img src="test.jpg">' + + const firstResult = sanitizeHtml(template, DefaultAllowlist, null) + const secondResult = sanitizeHtml(template, DefaultAllowlist, null) + + expect(firstResult).toContain('src') + expect(secondResult).toContain('src') + }) }) }) diff --git a/js/tests/unit/util/scrollbar.spec.js b/js/tests/unit/util/scrollbar.spec.js new file mode 100644 index 000000000..280adb8e5 --- /dev/null +++ b/js/tests/unit/util/scrollbar.spec.js @@ -0,0 +1,353 @@ +import { clearBodyAndDocument, clearFixture, getFixture } from '../../helpers/fixture' +import Manipulator from '../../../src/dom/manipulator' +import ScrollBarHelper from '../../../src/util/scrollbar' + +describe('ScrollBar', () => { + let fixtureEl + const doc = document.documentElement + const parseInt = arg => Number.parseInt(arg, 10) + const getPaddingX = el => parseInt(window.getComputedStyle(el).paddingRight) + const getMarginX = el => parseInt(window.getComputedStyle(el).marginRight) + const getOverFlow = el => el.style.overflow + const getPaddingAttr = el => Manipulator.getDataAttribute(el, 'padding-right') + const getMarginAttr = el => Manipulator.getDataAttribute(el, 'margin-right') + const getOverFlowAttr = el => Manipulator.getDataAttribute(el, 'overflow') + const windowCalculations = () => { + return { + htmlClient: document.documentElement.clientWidth, + htmlOffset: document.documentElement.offsetWidth, + docClient: document.body.clientWidth, + htmlBound: document.documentElement.getBoundingClientRect().width, + bodyBound: document.body.getBoundingClientRect().width, + window: window.innerWidth, + width: Math.abs(window.innerWidth - document.documentElement.clientWidth) + } + } + + const isScrollBarHidden = () => { // IOS devices, Android devices and Browsers on Mac, hide scrollbar by default and appear it, only while scrolling. So the tests for scrollbar would fail + const calc = windowCalculations() + return calc.htmlClient === calc.htmlOffset && calc.htmlClient === calc.window + } + + beforeAll(() => { + fixtureEl = getFixture() + // custom fixture to avoid extreme style values + fixtureEl.removeAttribute('style') + }) + + afterAll(() => { + fixtureEl.remove() + }) + + afterEach(() => { + clearFixture() + clearBodyAndDocument() + }) + + beforeEach(() => { + clearBodyAndDocument() + }) + + describe('isBodyOverflowing', () => { + it('should return true if body is overflowing', () => { + document.documentElement.style.overflowY = 'scroll' + document.body.style.overflowY = 'scroll' + fixtureEl.innerHTML = [ + '<div style="height: 110vh; width: 100%"></div>' + ].join('') + const result = new ScrollBarHelper().isOverflowing() + + if (isScrollBarHidden()) { + expect(result).toEqual(false) + } else { + expect(result).toEqual(true) + } + }) + + it('should return false if body is not overflowing', () => { + doc.style.overflowY = 'hidden' + document.body.style.overflowY = 'hidden' + fixtureEl.innerHTML = [ + '<div style="height: 110vh; width: 100%"></div>' + ].join('') + const scrollBar = new ScrollBarHelper() + const result = scrollBar.isOverflowing() + + expect(result).toEqual(false) + }) + }) + + describe('getWidth', () => { + it('should return an integer greater than zero, if body is overflowing', () => { + doc.style.overflowY = 'scroll' + document.body.style.overflowY = 'scroll' + fixtureEl.innerHTML = [ + '<div style="height: 110vh; width: 100%"></div>' + ].join('') + const result = new ScrollBarHelper().getWidth() + + if (isScrollBarHidden()) { + expect(result).toBe(0) + } else { + expect(result).toBeGreaterThan(1) + } + }) + + it('should return 0 if body is not overflowing', () => { + document.documentElement.style.overflowY = 'hidden' + document.body.style.overflowY = 'hidden' + fixtureEl.innerHTML = [ + '<div style="height: 110vh; width: 100%"></div>' + ].join('') + + const result = new ScrollBarHelper().getWidth() + + expect(result).toEqual(0) + }) + }) + + describe('hide - reset', () => { + it('should adjust the inline padding of fixed elements which are full-width', done => { + fixtureEl.innerHTML = [ + '<div style="height: 110vh; width: 100%">' + + '<div class="fixed-top" id="fixed1" style="padding-right: 0px; width: 100vw"></div>', + '<div class="fixed-top" id="fixed2" style="padding-right: 5px; width: 100vw"></div>', + '</div>' + ].join('') + doc.style.overflowY = 'scroll' + + const fixedEl = fixtureEl.querySelector('#fixed1') + const fixedEl2 = fixtureEl.querySelector('#fixed2') + const originalPadding = getPaddingX(fixedEl) + const originalPadding2 = getPaddingX(fixedEl2) + const scrollBar = new ScrollBarHelper() + const expectedPadding = originalPadding + scrollBar.getWidth() + const expectedPadding2 = originalPadding2 + scrollBar.getWidth() + + scrollBar.hide() + + let currentPadding = getPaddingX(fixedEl) + let currentPadding2 = getPaddingX(fixedEl2) + expect(getPaddingAttr(fixedEl)).toEqual(`${originalPadding}px`, 'original fixed element padding should be stored in data-bs-padding-right') + expect(getPaddingAttr(fixedEl2)).toEqual(`${originalPadding2}px`, 'original fixed element padding should be stored in data-bs-padding-right') + expect(currentPadding).toEqual(expectedPadding, 'fixed element padding should be adjusted while opening') + expect(currentPadding2).toEqual(expectedPadding2, 'fixed element padding should be adjusted while opening') + + scrollBar.reset() + currentPadding = getPaddingX(fixedEl) + currentPadding2 = getPaddingX(fixedEl2) + expect(getPaddingAttr(fixedEl)).toEqual(null, 'data-bs-padding-right should be cleared after closing') + expect(getPaddingAttr(fixedEl2)).toEqual(null, 'data-bs-padding-right should be cleared after closing') + expect(currentPadding).toEqual(originalPadding, 'fixed element padding should be reset after closing') + expect(currentPadding2).toEqual(originalPadding2, 'fixed element padding should be reset after closing') + done() + }) + + it('should adjust the inline margin and padding of sticky elements', done => { + fixtureEl.innerHTML = [ + '<div style="height: 110vh">' + + '<div class="sticky-top" style="margin-right: 10px; padding-right: 20px; width: 100vw; height: 10px"></div>', + '</div>' + ].join('') + doc.style.overflowY = 'scroll' + + const stickyTopEl = fixtureEl.querySelector('.sticky-top') + const originalMargin = getMarginX(stickyTopEl) + const originalPadding = getPaddingX(stickyTopEl) + const scrollBar = new ScrollBarHelper() + const expectedMargin = originalMargin - scrollBar.getWidth() + const expectedPadding = originalPadding + scrollBar.getWidth() + scrollBar.hide() + + expect(getMarginAttr(stickyTopEl)).toEqual(`${originalMargin}px`, 'original sticky element margin should be stored in data-bs-margin-right') + expect(getMarginX(stickyTopEl)).toEqual(expectedMargin, 'sticky element margin should be adjusted while opening') + expect(getPaddingAttr(stickyTopEl)).toEqual(`${originalPadding}px`, 'original sticky element margin should be stored in data-bs-margin-right') + expect(getPaddingX(stickyTopEl)).toEqual(expectedPadding, 'sticky element margin should be adjusted while opening') + + scrollBar.reset() + expect(getMarginAttr(stickyTopEl)).toEqual(null, 'data-bs-margin-right should be cleared after closing') + expect(getMarginX(stickyTopEl)).toEqual(originalMargin, 'sticky element margin should be reset after closing') + expect(getPaddingAttr(stickyTopEl)).toEqual(null, 'data-bs-margin-right should be cleared after closing') + expect(getPaddingX(stickyTopEl)).toEqual(originalPadding, 'sticky element margin should be reset after closing') + done() + }) + + it('should not adjust the inline margin and padding of sticky and fixed elements when element do not have full width', () => { + fixtureEl.innerHTML = [ + '<div class="sticky-top" style="margin-right: 0px; padding-right: 0px; width: 50vw"></div>' + ].join('') + + const stickyTopEl = fixtureEl.querySelector('.sticky-top') + const originalMargin = getMarginX(stickyTopEl) + const originalPadding = getPaddingX(stickyTopEl) + + const scrollBar = new ScrollBarHelper() + scrollBar.hide() + + const currentMargin = getMarginX(stickyTopEl) + const currentPadding = getPaddingX(stickyTopEl) + + expect(currentMargin).toEqual(originalMargin, 'sticky element\'s margin should not be adjusted while opening') + expect(currentPadding).toEqual(originalPadding, 'sticky element\'s padding should not be adjusted while opening') + + scrollBar.reset() + }) + + it('should not put data-attribute if element doesn\'t have the proper style property, should just remove style property if element didn\'t had one', () => { + fixtureEl.innerHTML = [ + '<div style="height: 110vh; width: 100%">' + + '<div class="sticky-top" id="sticky" style="width: 100vw"></div>', + '</div>' + ].join('') + + document.body.style.overflowY = 'scroll' + const scrollBar = new ScrollBarHelper() + + const hasPaddingAttr = el => el.hasAttribute('data-bs-padding-right') + const hasMarginAttr = el => el.hasAttribute('data-bs-margin-right') + const stickyEl = fixtureEl.querySelector('#sticky') + const originalPadding = getPaddingX(stickyEl) + const originalMargin = getMarginX(stickyEl) + const scrollBarWidth = scrollBar.getWidth() + + scrollBar.hide() + + expect(getPaddingX(stickyEl)).toEqual(scrollBarWidth + originalPadding) + const expectedMargin = scrollBarWidth + originalMargin + expect(getMarginX(stickyEl)).toEqual(expectedMargin === 0 ? expectedMargin : -expectedMargin) + expect(hasMarginAttr(stickyEl)).toBeFalse() // We do not have to keep css margin + expect(hasPaddingAttr(stickyEl)).toBeFalse() // We do not have to keep css padding + + scrollBar.reset() + + expect(getPaddingX(stickyEl)).toEqual(originalPadding) + expect(getPaddingX(stickyEl)).toEqual(originalPadding) + }) + + describe('Body Handling', () => { + it('should ignore other inline styles when trying to restore body defaults ', () => { + document.body.style.color = 'red' + + const scrollBar = new ScrollBarHelper() + const scrollBarWidth = scrollBar.getWidth() + scrollBar.hide() + + expect(getPaddingX(document.body)).toEqual(scrollBarWidth, 'body does not have inline padding set') + expect(document.body.style.color).toEqual('red', 'body still has other inline styles set') + + scrollBar.reset() + }) + + it('should hide scrollbar and reset it to its initial value', () => { + const styleSheetPadding = '7px' + fixtureEl.innerHTML = [ + '<style>', + ' body {', + ` padding-right: ${styleSheetPadding} }`, + ' }', + '</style>' + ].join('') + + const el = document.body + const inlineStylePadding = '10px' + el.style.paddingRight = inlineStylePadding + + const originalPadding = getPaddingX(el) + expect(originalPadding).toEqual(parseInt(inlineStylePadding)) // Respect only the inline style as it has prevails this of css + const originalOverFlow = 'auto' + el.style.overflow = originalOverFlow + const scrollBar = new ScrollBarHelper() + const scrollBarWidth = scrollBar.getWidth() + + scrollBar.hide() + + const currentPadding = getPaddingX(el) + + expect(currentPadding).toEqual(scrollBarWidth + originalPadding) + expect(currentPadding).toEqual(scrollBarWidth + parseInt(inlineStylePadding)) + expect(getPaddingAttr(el)).toEqual(inlineStylePadding) + expect(getOverFlow(el)).toEqual('hidden') + expect(getOverFlowAttr(el)).toEqual(originalOverFlow) + + scrollBar.reset() + + const currentPadding1 = getPaddingX(el) + expect(currentPadding1).toEqual(originalPadding) + expect(getPaddingAttr(el)).toEqual(null) + expect(getOverFlow(el)).toEqual(originalOverFlow) + expect(getOverFlowAttr(el)).toEqual(null) + }) + + it('should hide scrollbar and reset it to its initial value - respecting css rules', () => { + const styleSheetPadding = '7px' + fixtureEl.innerHTML = [ + '<style>', + ' body {', + ` padding-right: ${styleSheetPadding} }`, + ' }', + '</style>' + ].join('') + const el = document.body + const originalPadding = getPaddingX(el) + const originalOverFlow = 'scroll' + el.style.overflow = originalOverFlow + const scrollBar = new ScrollBarHelper() + const scrollBarWidth = scrollBar.getWidth() + + scrollBar.hide() + + const currentPadding = getPaddingX(el) + + expect(currentPadding).toEqual(scrollBarWidth + originalPadding) + expect(currentPadding).toEqual(scrollBarWidth + parseInt(styleSheetPadding)) + expect(getPaddingAttr(el)).toBeNull() // We do not have to keep css padding + expect(getOverFlow(el)).toEqual('hidden') + expect(getOverFlowAttr(el)).toEqual(originalOverFlow) + + scrollBar.reset() + + const currentPadding1 = getPaddingX(el) + expect(currentPadding1).toEqual(originalPadding) + expect(getPaddingAttr(el)).toEqual(null) + expect(getOverFlow(el)).toEqual(originalOverFlow) + expect(getOverFlowAttr(el)).toEqual(null) + }) + + it('should not adjust the inline body padding when it does not overflow', () => { + const originalPadding = getPaddingX(document.body) + const scrollBar = new ScrollBarHelper() + + // Hide scrollbars to prevent the body overflowing + doc.style.overflowY = 'hidden' + doc.style.paddingRight = '0px' + + scrollBar.hide() + const currentPadding = getPaddingX(document.body) + + expect(currentPadding).toEqual(originalPadding, 'body padding should not be adjusted') + scrollBar.reset() + }) + + it('should not adjust the inline body padding when it does not overflow, even on a scaled display', () => { + const originalPadding = getPaddingX(document.body) + const scrollBar = new ScrollBarHelper() + // Remove body margins as would be done by Bootstrap css + document.body.style.margin = '0' + + // Hide scrollbars to prevent the body overflowing + doc.style.overflowY = 'hidden' + + // Simulate a discrepancy between exact, i.e. floating point body width, and rounded body width + // as it can occur when zooming or scaling the display to something else than 100% + doc.style.paddingRight = '.48px' + scrollBar.hide() + + const currentPadding = getPaddingX(document.body) + + expect(currentPadding).toEqual(originalPadding, 'body padding should not be adjusted') + + scrollBar.reset() + }) + }) + }) +}) |
