aboutsummaryrefslogtreecommitdiff
path: root/js/tests/unit
diff options
context:
space:
mode:
Diffstat (limited to 'js/tests/unit')
-rw-r--r--js/tests/unit/alert.spec.js58
-rw-r--r--js/tests/unit/base-component.spec.js147
-rw-r--r--js/tests/unit/button.spec.js38
-rw-r--r--js/tests/unit/carousel.spec.js298
-rw-r--r--js/tests/unit/collapse.spec.js78
-rw-r--r--js/tests/unit/dom/data.spec.js151
-rw-r--r--js/tests/unit/dom/event-handler.spec.js98
-rw-r--r--js/tests/unit/dom/manipulator.spec.js54
-rw-r--r--js/tests/unit/dom/selector-engine.spec.js82
-rw-r--r--js/tests/unit/dropdown.spec.js436
-rw-r--r--js/tests/unit/modal.spec.js539
-rw-r--r--js/tests/unit/offcanvas.spec.js747
-rw-r--r--js/tests/unit/popover.spec.js156
-rw-r--r--js/tests/unit/scrollspy.spec.js91
-rw-r--r--js/tests/unit/tab.spec.js198
-rw-r--r--js/tests/unit/toast.spec.js253
-rw-r--r--js/tests/unit/tooltip.spec.js230
-rw-r--r--js/tests/unit/util/backdrop.spec.js292
-rw-r--r--js/tests/unit/util/component-functions.spec.js108
-rw-r--r--js/tests/unit/util/focustrap.spec.js210
-rw-r--r--js/tests/unit/util/index.spec.js434
-rw-r--r--js/tests/unit/util/sanitizer.spec.js10
-rw-r--r--js/tests/unit/util/scrollbar.spec.js353
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()
+ })
+ })
+ })
+})