Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/twbs/bootstrap.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/js/tests
diff options
context:
space:
mode:
authorJohann-S <johann.servoire@gmail.com>2019-10-02 12:43:54 +0300
committerJohann-S <johann.servoire@gmail.com>2019-10-03 10:55:57 +0300
commit3d12b541c488ea09efced2fb987fcbf384c656bb (patch)
tree1863095dd8162e25a1909cf741e32faa091c32d4 /js/tests
parent393ddae09b0578c8d381540bdbb4e68cdec1b45b (diff)
return to the original file structure to avoid breaking modularity
Diffstat (limited to 'js/tests')
-rw-r--r--js/tests/README.md6
-rw-r--r--js/tests/karma.conf.js83
-rw-r--r--js/tests/units/.eslintrc.json14
-rw-r--r--js/tests/units/alert.spec.js173
-rw-r--r--js/tests/units/button.spec.js292
-rw-r--r--js/tests/units/carousel.spec.js1201
-rw-r--r--js/tests/units/collapse.spec.js826
-rw-r--r--js/tests/units/dom/data.spec.js131
-rw-r--r--js/tests/units/dom/event-handler.spec.js327
-rw-r--r--js/tests/units/dom/manipulator.spec.js158
-rw-r--r--js/tests/units/dom/selector-engine.spec.js115
-rw-r--r--js/tests/units/dropdown.spec.js1564
-rw-r--r--js/tests/units/modal.spec.js987
-rw-r--r--js/tests/units/popover.spec.js251
-rw-r--r--js/tests/units/scrollspy.spec.js653
-rw-r--r--js/tests/units/tab.spec.js593
-rw-r--r--js/tests/units/toast.spec.js374
-rw-r--r--js/tests/units/tooltip.spec.js1020
-rw-r--r--js/tests/units/util/index.spec.js382
-rw-r--r--js/tests/units/util/sanitizer.spec.js70
20 files changed, 9169 insertions, 51 deletions
diff --git a/js/tests/README.md b/js/tests/README.md
index 76a582e855..dd2cc0f1b9 100644
--- a/js/tests/README.md
+++ b/js/tests/README.md
@@ -1,6 +1,6 @@
## How does Bootstrap's test suite work?
-Bootstrap uses [Jasmine](https://jasmine.github.io/). Each plugin has a file dedicated to its tests in `src/<plugin-name>/<plugin-name>.spec.js`.
+Bootstrap uses [Jasmine](https://jasmine.github.io/). Each plugin has a file dedicated to its tests in `tests/units/<plugin-name>.spec.js`.
* `visual/` contains "visual" tests which are run interactively in real browsers and require manual verification by humans.
@@ -9,7 +9,7 @@ To run the unit test suite via [Karma](https://karma-runner.github.io/) and debu
## How do I add a new unit test?
-1. Locate and open the file dedicated to the plugin which you need to add tests to (`src/<plugin-name>/<plugin-name>.spec.js`).
+1. Locate and open the file dedicated to the plugin which you need to add tests to (`tests/units/<plugin-name>.spec.js`).
2. Review the [Jasmine API Documentation](https://jasmine.github.io/pages/docs_home.html) and use the existing tests as references for how to structure your new tests.
3. Write the necessary unit test(s) for the new or revised functionality.
4. Run `npm run js-test` to see the results of your newly-added test(s).
@@ -26,7 +26,7 @@ To run the unit test suite via [Karma](https://karma-runner.github.io/) and debu
## Code coverage
-Currently we're aiming for at least 90% test coverage for our code. To ensure your changes meet or exceed this limit, run `npm run js-compile && npm run js-test` and open the file in `js/coverage/lcov-report/index.html` to see the code coverage for each plugin. See more details when you select a plugin and ensure your change is fully covered by unit tests.
+Currently we're aiming for at least 90% test coverage for our code. To ensure your changes meet or exceed this limit, run `npm run js-test-karma` and open the file in `js/coverage/lcov-report/index.html` to see the code coverage for each plugin. See more details when you select a plugin and ensure your change is fully covered by unit tests.
### Example tests
diff --git a/js/tests/karma.conf.js b/js/tests/karma.conf.js
index 06c594bceb..8e30094104 100644
--- a/js/tests/karma.conf.js
+++ b/js/tests/karma.conf.js
@@ -46,39 +46,6 @@ const customLaunchers = {
}
}
-const rollupPreprocessor = {
- plugins: [
- istanbul({
- exclude: ['js/src/**/*.spec.js']
- }),
- babel({
- // Only transpile our source code
- exclude: 'node_modules/**',
- // Include only required helpers
- externalHelpersWhitelist: [
- 'defineProperties',
- 'createClass',
- 'inheritsLoose',
- 'defineProperty',
- 'objectSpread2'
- ],
- plugins: [
- '@babel/plugin-proposal-object-rest-spread'
- ]
- }),
- resolve()
- ],
- output: {
- format: 'iife',
- name: 'bootstrapTest',
- sourcemap: 'inline'
- }
-}
-
-let files = [
- 'node_modules/hammer-simulator/index.js'
-]
-
const conf = {
basePath: '../..',
port: 9876,
@@ -88,6 +55,41 @@ const conf = {
concurrency: Infinity,
client: {
clearContext: false
+ },
+ files: [
+ 'node_modules/hammer-simulator/index.js',
+ { pattern: 'js/tests/units/**/*.spec.js', watched: !browserStack }
+ ],
+ preprocessors: {
+ 'js/tests/units/**/*.spec.js': ['rollup']
+ },
+ rollupPreprocessor: {
+ plugins: [
+ istanbul({
+ exclude: ['js/tests/units/**/*.spec.js', 'js/tests/helpers/**/*.js']
+ }),
+ babel({
+ // Only transpile our source code
+ exclude: 'node_modules/**',
+ // Include only required helpers
+ externalHelpersWhitelist: [
+ 'defineProperties',
+ 'createClass',
+ 'inheritsLoose',
+ 'defineProperty',
+ 'objectSpread2'
+ ],
+ plugins: [
+ '@babel/plugin-proposal-object-rest-spread'
+ ]
+ }),
+ resolve()
+ ],
+ output: {
+ format: 'iife',
+ name: 'bootstrapTest',
+ sourcemap: 'inline'
+ }
}
}
@@ -104,13 +106,6 @@ if (browserStack) {
conf.customLaunchers = browsers
conf.browsers = browsersKeys
reporters.push('BrowserStack', 'kjhtml')
- files = files.concat([
- { pattern: 'js/src/**/*.spec.js', watched: false }
- ])
- conf.preprocessors = {
- 'js/src/**/*.spec.js': ['rollup']
- }
- conf.rollupPreprocessor = rollupPreprocessor
} else {
frameworks.push('detectBrowsers')
plugins.push(
@@ -119,14 +114,7 @@ if (browserStack) {
'karma-detect-browsers',
'karma-coverage-istanbul-reporter'
)
- files = files.concat([
- { pattern: 'js/src/**/*.spec.js', watched: true }
- ])
reporters.push('coverage-istanbul')
- conf.preprocessors = {
- 'js/src/**/*.spec.js': ['rollup']
- }
- conf.rollupPreprocessor = rollupPreprocessor
conf.customLaunchers = customLaunchers
conf.detectBrowsers = detectBrowsers
conf.coverageIstanbulReporter = {
@@ -165,7 +153,6 @@ if (browserStack) {
conf.frameworks = frameworks
conf.plugins = plugins
conf.reporters = reporters
-conf.files = files
module.exports = karmaConfig => {
// possible values: karmaConfig.LOG_DISABLE || karmaConfig.LOG_ERROR || karmaConfig.LOG_WARN || karmaConfig.LOG_INFO || karmaConfig.LOG_DEBUG
diff --git a/js/tests/units/.eslintrc.json b/js/tests/units/.eslintrc.json
new file mode 100644
index 0000000000..a8c1a6ae34
--- /dev/null
+++ b/js/tests/units/.eslintrc.json
@@ -0,0 +1,14 @@
+{
+ "root": true,
+ "extends": [
+ "../../../.eslintrc.json"
+ ],
+ "overrides": [
+ {
+ "files": ["**/*.spec.js"],
+ "env": {
+ "jasmine": true
+ }
+ }
+ ]
+}
diff --git a/js/tests/units/alert.spec.js b/js/tests/units/alert.spec.js
new file mode 100644
index 0000000000..32f11b6188
--- /dev/null
+++ b/js/tests/units/alert.spec.js
@@ -0,0 +1,173 @@
+import Alert from '../../src/alert'
+import { makeArray, getTransitionDurationFromElement } from '../../src/util/index'
+
+/** Test helpers */
+import { getFixture, clearFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Alert', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ it('should return version', () => {
+ expect(typeof Alert.VERSION).toEqual('string')
+ })
+
+ describe('data-api', () => {
+ it('should close an alert without instantiate it manually', () => {
+ fixtureEl.innerHTML = [
+ '<div class="alert">',
+ ' <button type="button" data-dismiss="alert">x</button>',
+ '</div>'
+ ].join('')
+
+ const button = document.querySelector('button')
+
+ button.click()
+ expect(makeArray(document.querySelectorAll('.alert')).length).toEqual(0)
+ })
+
+ it('should close an alert without instantiate it manually with the parent selector', () => {
+ fixtureEl.innerHTML = [
+ '<div class="alert">',
+ ' <button type="button" data-target=".alert" data-dismiss="alert">x</button>',
+ '</div>'
+ ].join('')
+
+ const button = document.querySelector('button')
+
+ button.click()
+ expect(makeArray(document.querySelectorAll('.alert')).length).toEqual(0)
+ })
+ })
+
+ describe('close', () => {
+ it('should close an alert', done => {
+ const spy = jasmine.createSpy('spy', getTransitionDurationFromElement)
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = document.querySelector('.alert')
+ const alert = new Alert(alertEl)
+
+ alertEl.addEventListener('closed.bs.alert', () => {
+ expect(makeArray(document.querySelectorAll('.alert')).length).toEqual(0)
+ expect(spy).not.toHaveBeenCalled()
+ done()
+ })
+
+ alert.close()
+ })
+
+ it('should close alert with fade class', done => {
+ fixtureEl.innerHTML = '<div class="alert fade"></div>'
+
+ const alertEl = document.querySelector('.alert')
+ const alert = new Alert(alertEl)
+
+ alertEl.addEventListener('transitionend', () => {
+ expect().nothing()
+ })
+
+ alertEl.addEventListener('closed.bs.alert', () => {
+ expect(makeArray(document.querySelectorAll('.alert')).length).toEqual(0)
+ done()
+ })
+
+ alert.close()
+ })
+
+ it('should not remove alert if close event is prevented', done => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = document.querySelector('.alert')
+ const alert = new Alert(alertEl)
+
+ const endTest = () => {
+ setTimeout(() => {
+ expect(alert._removeElement).not.toHaveBeenCalled()
+ done()
+ }, 10)
+ }
+
+ spyOn(alert, '_removeElement')
+
+ alertEl.addEventListener('close.bs.alert', event => {
+ event.preventDefault()
+ endTest()
+ })
+
+ alertEl.addEventListener('closed.bs.alert', () => {
+ endTest()
+ })
+
+ alert.close()
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose an alert', () => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = document.querySelector('.alert')
+ const alert = new Alert(alertEl)
+
+ expect(Alert.getInstance(alertEl)).toBeDefined()
+
+ alert.dispose()
+
+ expect(Alert.getInstance(alertEl)).toBeNull()
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should handle config passed and toggle existing alert', () => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = fixtureEl.querySelector('.alert')
+ const alert = new Alert(alertEl)
+
+ spyOn(alert, 'close')
+
+ jQueryMock.fn.alert = Alert.jQueryInterface
+ jQueryMock.elements = [alertEl]
+
+ jQueryMock.fn.alert.call(jQueryMock, 'close')
+
+ expect(alert.close).toHaveBeenCalled()
+ })
+
+ it('should create new alert instance and call close', () => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = fixtureEl.querySelector('.alert')
+
+ jQueryMock.fn.alert = Alert.jQueryInterface
+ jQueryMock.elements = [alertEl]
+
+ jQueryMock.fn.alert.call(jQueryMock, 'close')
+
+ expect(Alert.getInstance(alertEl)).toBeDefined()
+ expect(fixtureEl.querySelector('.alert')).toBeNull()
+ })
+
+ it('should just create an alert instance without calling close', () => {
+ fixtureEl.innerHTML = '<div class="alert"></div>'
+
+ const alertEl = fixtureEl.querySelector('.alert')
+
+ jQueryMock.fn.alert = Alert.jQueryInterface
+ jQueryMock.elements = [alertEl]
+
+ jQueryMock.fn.alert.call(jQueryMock)
+
+ expect(Alert.getInstance(alertEl)).toBeDefined()
+ expect(fixtureEl.querySelector('.alert')).not.toBeNull()
+ })
+ })
+})
diff --git a/js/tests/units/button.spec.js b/js/tests/units/button.spec.js
new file mode 100644
index 0000000000..a3c95be1ba
--- /dev/null
+++ b/js/tests/units/button.spec.js
@@ -0,0 +1,292 @@
+import Button from '../../src/button'
+import EventHandler from '../../src/dom/event-handler'
+
+/** Test helpers */
+import {
+ getFixture,
+ clearFixture,
+ createEvent,
+ jQueryMock
+} from '../helpers/fixture'
+
+describe('Button', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Button.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('data-api', () => {
+ it('should toggle active class on click', () => {
+ fixtureEl.innerHTML = [
+ '<button class="btn" data-toggle="button">btn</button>',
+ '<button class="btn testParent" data-toggle="button"><div class="test"></div></button>'
+ ].join('')
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+ const btnTestParent = fixtureEl.querySelector('.testParent')
+
+ expect(btn.classList.contains('active')).toEqual(false)
+
+ btn.click()
+
+ expect(btn.classList.contains('active')).toEqual(true)
+
+ btn.click()
+
+ expect(btn.classList.contains('active')).toEqual(false)
+
+ divTest.click()
+
+ expect(btnTestParent.classList.contains('active')).toEqual(true)
+ })
+
+ it('should trigger input change event when toggled button has input field', done => {
+ fixtureEl.innerHTML = [
+ '<div class="btn-group" data-toggle="buttons">',
+ ' <label class="btn btn-primary">',
+ ' <input type="radio" id="radio" autocomplete="off"> Radio',
+ ' </label>',
+ '</div>'
+ ].join('')
+
+ const input = fixtureEl.querySelector('input')
+ const label = fixtureEl.querySelector('label')
+
+ input.addEventListener('change', () => {
+ expect().nothing()
+ done()
+ })
+
+ label.click()
+ })
+
+ it('should not trigger input change event when input already checked and button is active', () => {
+ fixtureEl.innerHTML = [
+ '<button type="button" class="btn btn-primary active" data-toggle="buttons">',
+ ' <input type="radio" id="radio" autocomplete="off" checked> Radio',
+ '</button>'
+ ].join('')
+
+ const button = fixtureEl.querySelector('button')
+
+ spyOn(EventHandler, 'trigger')
+
+ button.click()
+
+ expect(EventHandler.trigger).not.toHaveBeenCalled()
+ })
+
+ it('should remove active when an other radio button is clicked', () => {
+ fixtureEl.innerHTML = [
+ '<div class="btn-group btn-group-toggle" data-toggle="buttons">',
+ ' <label class="btn btn-secondary active">',
+ ' <input type="radio" name="options" id="option1" autocomplete="off" checked> Active',
+ ' </label>',
+ ' <label class="btn btn-secondary">',
+ ' <input type="radio" name="options" id="option2" autocomplete="off"> Radio',
+ ' </label>',
+ ' <label class="btn btn-secondary">',
+ ' <input type="radio" name="options" id="option3" autocomplete="off"> Radio',
+ ' </label>',
+ '</div>'
+ ].join('')
+
+ const option1 = fixtureEl.querySelector('#option1')
+ const option2 = fixtureEl.querySelector('#option2')
+
+ expect(option1.checked).toEqual(true)
+ expect(option1.parentElement.classList.contains('active')).toEqual(true)
+
+ const clickEvent = createEvent('click')
+
+ option2.dispatchEvent(clickEvent)
+
+ expect(option1.checked).toEqual(false)
+ expect(option1.parentElement.classList.contains('active')).toEqual(false)
+ expect(option2.checked).toEqual(true)
+ expect(option2.parentElement.classList.contains('active')).toEqual(true)
+ })
+
+ it('should do nothing if the child is not an input', () => {
+ fixtureEl.innerHTML = [
+ '<div class="btn-group btn-group-toggle" data-toggle="buttons">',
+ ' <label class="btn btn-secondary active">',
+ ' <span id="option1">el 1</span>',
+ ' </label>',
+ ' <label class="btn btn-secondary">',
+ ' <span id="option2">el 2</span>',
+ ' </label>',
+ ' <label class="btn btn-secondary">',
+ ' <span>el 3</span>',
+ ' </label>',
+ '</div>'
+ ].join('')
+
+ const option2 = fixtureEl.querySelector('#option2')
+ const clickEvent = createEvent('click')
+
+ option2.dispatchEvent(clickEvent)
+
+ expect().nothing()
+ })
+
+ it('should add focus class on focus event', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-toggle="button"><input type="text" /></button>'
+
+ const btn = fixtureEl.querySelector('.btn')
+ const input = fixtureEl.querySelector('input')
+
+ const focusEvent = createEvent('focus')
+ input.dispatchEvent(focusEvent)
+
+ expect(btn.classList.contains('focus')).toEqual(true)
+ })
+
+ it('should not add focus class', () => {
+ fixtureEl.innerHTML = '<button data-toggle="button"><input type="text" /></button>'
+
+ const btn = fixtureEl.querySelector('button')
+ const input = fixtureEl.querySelector('input')
+
+ const focusEvent = createEvent('focus')
+ input.dispatchEvent(focusEvent)
+
+ expect(btn.classList.contains('focus')).toEqual(false)
+ })
+
+ it('should remove focus class on blur event', () => {
+ fixtureEl.innerHTML = '<button class="btn focus" data-toggle="button"><input type="text" /></button>'
+
+ const btn = fixtureEl.querySelector('.btn')
+ const input = fixtureEl.querySelector('input')
+
+ const focusEvent = createEvent('blur')
+ input.dispatchEvent(focusEvent)
+
+ expect(btn.classList.contains('focus')).toEqual(false)
+ })
+
+ it('should not remove focus class on blur event', () => {
+ fixtureEl.innerHTML = '<button class="focus" data-toggle="button"><input type="text" /></button>'
+
+ const btn = fixtureEl.querySelector('button')
+ const input = fixtureEl.querySelector('input')
+
+ const focusEvent = createEvent('blur')
+ input.dispatchEvent(focusEvent)
+
+ expect(btn.classList.contains('focus')).toEqual(true)
+ })
+ })
+
+ describe('toggle', () => {
+ it('should toggle aria-pressed', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-toggle="button" aria-pressed="false"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+ const button = new Button(btnEl)
+
+ expect(btnEl.getAttribute('aria-pressed')).toEqual('false')
+ expect(btnEl.classList.contains('active')).toEqual(false)
+
+ button.toggle()
+
+ expect(btnEl.getAttribute('aria-pressed')).toEqual('true')
+ expect(btnEl.classList.contains('active')).toEqual(true)
+ })
+
+ it('should handle disabled attribute on non-button elements', () => {
+ fixtureEl.innerHTML = [
+ '<div class="btn-group disabled" data-toggle="buttons" aria-disabled="true" disabled>',
+ ' <label class="btn btn-danger disabled" aria-disabled="true" disabled>',
+ ' <input type="checkbox" aria-disabled="true" autocomplete="off" disabled class="disabled"/>',
+ ' </label>',
+ '</div>'
+ ].join('')
+
+ const btnGroupEl = fixtureEl.querySelector('.btn-group')
+ const btnDanger = fixtureEl.querySelector('.btn-danger')
+ const input = fixtureEl.querySelector('input')
+
+ const button = new Button(btnGroupEl)
+
+ button.toggle()
+
+ expect(btnDanger.hasAttribute('disabled')).toEqual(true)
+ expect(input.checked).toEqual(false)
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose a button', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-toggle="button"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+ const button = new Button(btnEl)
+
+ expect(Button.getInstance(btnEl)).toBeDefined()
+
+ button.dispose()
+
+ expect(Button.getInstance(btnEl)).toBeNull()
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should handle config passed and toggle existing button', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-toggle="button"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+ const button = new Button(btnEl)
+
+ spyOn(button, 'toggle')
+
+ jQueryMock.fn.button = Button.jQueryInterface
+ jQueryMock.elements = [btnEl]
+
+ jQueryMock.fn.button.call(jQueryMock, 'toggle')
+
+ expect(button.toggle).toHaveBeenCalled()
+ })
+
+ it('should create new button instance and call toggle', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-toggle="button"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+
+ jQueryMock.fn.button = Button.jQueryInterface
+ jQueryMock.elements = [btnEl]
+
+ jQueryMock.fn.button.call(jQueryMock, 'toggle')
+
+ expect(Button.getInstance(btnEl)).toBeDefined()
+ expect(btnEl.classList.contains('active')).toEqual(true)
+ })
+
+ it('should just create a button instance without calling toggle', () => {
+ fixtureEl.innerHTML = '<button class="btn" data-toggle="button"></button>'
+
+ const btnEl = fixtureEl.querySelector('.btn')
+
+ jQueryMock.fn.button = Button.jQueryInterface
+ jQueryMock.elements = [btnEl]
+
+ jQueryMock.fn.button.call(jQueryMock)
+
+ expect(Button.getInstance(btnEl)).toBeDefined()
+ expect(btnEl.classList.contains('active')).toEqual(false)
+ })
+ })
+})
diff --git a/js/tests/units/carousel.spec.js b/js/tests/units/carousel.spec.js
new file mode 100644
index 0000000000..a163f9ae41
--- /dev/null
+++ b/js/tests/units/carousel.spec.js
@@ -0,0 +1,1201 @@
+import Carousel from '../../src/carousel'
+import EventHandler from '../../src/dom/event-handler'
+
+/** Test helpers */
+import { getFixture, clearFixture, createEvent, jQueryMock } from '../helpers/fixture'
+
+describe('Carousel', () => {
+ const { Simulator, PointerEvent, MSPointerEvent } = window
+ const originWinPointerEvent = PointerEvent || MSPointerEvent
+ const supportPointerEvent = Boolean(PointerEvent || MSPointerEvent)
+
+ window.MSPointerEvent = null
+ const cssStyleCarousel = '.carousel.pointer-event { -ms-touch-action: none; touch-action: none; }'
+
+ const stylesCarousel = document.createElement('style')
+ stylesCarousel.type = 'text/css'
+ stylesCarousel.appendChild(document.createTextNode(cssStyleCarousel))
+
+ const clearPointerEvents = () => {
+ window.PointerEvent = null
+ }
+
+ const restorePointerEvents = () => {
+ window.PointerEvent = originWinPointerEvent
+ }
+
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Carousel.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Carousel.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('constructor', () => {
+ it('should go to next item if right arrow key is pressed', done => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {
+ keyboard: true
+ })
+
+ spyOn(carousel, '_keydown').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item2'))
+ expect(carousel._keydown).toHaveBeenCalled()
+ done()
+ })
+
+ const keyDown = createEvent('keydown')
+ keyDown.which = 39
+
+ carouselEl.dispatchEvent(keyDown)
+ })
+
+ it('should go to previous item if left arrow key is pressed', done => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div id="item1" class="carousel-item">item 1</div>',
+ ' <div class="carousel-item active">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {
+ keyboard: true
+ })
+
+ spyOn(carousel, '_keydown').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item1'))
+ expect(carousel._keydown).toHaveBeenCalled()
+ done()
+ })
+
+ const keyDown = createEvent('keydown')
+ keyDown.which = 37
+
+ carouselEl.dispatchEvent(keyDown)
+ })
+
+ it('should not prevent keydown if key is not ARROW_LEFT or ARROW_RIGHT', done => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" 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>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {
+ keyboard: true
+ })
+
+ spyOn(carousel, '_keydown').and.callThrough()
+
+ carouselEl.addEventListener('keydown', event => {
+ expect(carousel._keydown).toHaveBeenCalled()
+ expect(event.defaultPrevented).toEqual(false)
+ done()
+ })
+
+ const keyDown = createEvent('keydown')
+ keyDown.which = 40
+
+ carouselEl.dispatchEvent(keyDown)
+ })
+
+ it('should ignore keyboard events within <input>s and <textarea>s', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">',
+ ' <input type="text" />',
+ ' <textarea></textarea>',
+ ' </div>',
+ ' <div class="carousel-item"></div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const input = fixtureEl.querySelector('input')
+ const textarea = fixtureEl.querySelector('textarea')
+ const carousel = new Carousel(carouselEl, {
+ keyboard: true
+ })
+
+ const spyKeyDown = spyOn(carousel, '_keydown').and.callThrough()
+ const spyPrev = spyOn(carousel, 'prev')
+ const spyNext = spyOn(carousel, 'next')
+
+ const keyDown = createEvent('keydown', { bubbles: true, cancelable: true })
+ keyDown.which = 39
+ Object.defineProperty(keyDown, 'target', {
+ value: input,
+ writable: true,
+ configurable: true
+ })
+
+ input.dispatchEvent(keyDown)
+
+ expect(spyKeyDown).toHaveBeenCalled()
+ expect(spyPrev).not.toHaveBeenCalled()
+ expect(spyNext).not.toHaveBeenCalled()
+
+ spyKeyDown.calls.reset()
+ spyPrev.calls.reset()
+ spyNext.calls.reset()
+
+ Object.defineProperty(keyDown, 'target', {
+ value: textarea
+ })
+ textarea.dispatchEvent(keyDown)
+
+ expect(spyKeyDown).toHaveBeenCalled()
+ expect(spyPrev).not.toHaveBeenCalled()
+ expect(spyNext).not.toHaveBeenCalled()
+ })
+
+ it('should wrap around from end to start when wrap option is true', done => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div id="one" class="carousel-item active"></div>',
+ ' <div id="two" class="carousel-item"></div>',
+ ' <div id="three" class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, { wrap: true })
+ const getActiveId = () => {
+ return carouselEl.querySelector('.carousel-item.active').getAttribute('id')
+ }
+
+ carouselEl.addEventListener('slid.bs.carousel', e => {
+ const activeId = getActiveId()
+
+ if (activeId === 'two') {
+ carousel.next()
+ return
+ }
+
+ if (activeId === 'three') {
+ carousel.next()
+ return
+ }
+
+ if (activeId === 'one') {
+ // carousel wrapped around and slid from 3rd to 1st slide
+ expect(activeId).toEqual('one')
+ expect(e.from + 1).toEqual(3)
+ done()
+ }
+ })
+
+ carousel.next()
+ })
+
+ it('should stay at the start when the prev method is called and wrap is false', done => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div id="one" class="carousel-item active"></div>',
+ ' <div id="two" class="carousel-item"></div>',
+ ' <div id="three" class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const firstElement = fixtureEl.querySelector('#one')
+ const carousel = new Carousel(carouselEl, { wrap: false })
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ throw new Error('carousel slid when it should not have slid')
+ })
+
+ carousel.prev()
+
+ setTimeout(() => {
+ expect(firstElement.classList.contains('active')).toEqual(true)
+ done()
+ }, 10)
+ })
+
+ it('should not add touch event listeners if touch = false', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+
+ spyOn(Carousel.prototype, '_addTouchEventListeners')
+
+ const carousel = new Carousel(carouselEl, {
+ touch: false
+ })
+
+ expect(carousel._addTouchEventListeners).not.toHaveBeenCalled()
+ })
+
+ it('should not add touch event listeners if touch supported = false', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+
+ const carousel = new Carousel(carouselEl)
+
+ EventHandler.off(carouselEl, '.bs-carousel')
+ carousel._touchSupported = false
+
+ spyOn(carousel, '_addTouchEventListeners')
+
+ carousel._addEventListeners()
+
+ expect(carousel._addTouchEventListeners).not.toHaveBeenCalled()
+ })
+
+ it('should add touch event listeners by default', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+
+ spyOn(Carousel.prototype, '_addTouchEventListeners')
+
+ document.documentElement.ontouchstart = () => {}
+ const carousel = new Carousel(carouselEl)
+
+ expect(carousel._addTouchEventListeners).toHaveBeenCalled()
+ })
+
+ it('should allow swiperight and call prev with pointer events', done => {
+ if (!supportPointerEvent) {
+ expect().nothing()
+ done()
+ return
+ }
+
+ document.documentElement.ontouchstart = () => {}
+ document.head.appendChild(stylesCarousel)
+ Simulator.setType('pointer')
+
+ fixtureEl.innerHTML = [
+ '<div class="carousel" data-interval="false">',
+ ' <div class="carousel-inner">',
+ ' <div id="item" class="carousel-item">',
+ ' <img alt="">',
+ ' </div>',
+ ' <div class="carousel-item active">',
+ ' <img alt="">',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const item = fixtureEl.querySelector('#item')
+ const carousel = new Carousel(carouselEl)
+
+ spyOn(carousel, 'prev').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(item.classList.contains('active')).toEqual(true)
+ expect(carousel.prev).toHaveBeenCalled()
+ document.head.removeChild(stylesCarousel)
+ delete document.documentElement.ontouchstart
+ done()
+ })
+
+ Simulator.gestures.swipe(carouselEl, {
+ deltaX: 300,
+ deltaY: 0
+ })
+ })
+
+ it('should allow swipeleft and call next with pointer events', done => {
+ if (!supportPointerEvent) {
+ expect().nothing()
+ done()
+ return
+ }
+
+ document.documentElement.ontouchstart = () => {}
+ document.head.appendChild(stylesCarousel)
+ Simulator.setType('pointer')
+
+ fixtureEl.innerHTML = [
+ '<div class="carousel" data-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 item = fixtureEl.querySelector('#item')
+ const carousel = new Carousel(carouselEl)
+
+ spyOn(carousel, 'next').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(item.classList.contains('active')).toEqual(false)
+ expect(carousel.next).toHaveBeenCalled()
+ document.head.removeChild(stylesCarousel)
+ delete document.documentElement.ontouchstart
+ done()
+ })
+
+ Simulator.gestures.swipe(carouselEl, {
+ pos: [300, 10],
+ deltaX: -300,
+ deltaY: 0
+ })
+ })
+
+ it('should allow swiperight and call prev with touch events', done => {
+ Simulator.setType('touch')
+ clearPointerEvents()
+ document.documentElement.ontouchstart = () => {}
+
+ fixtureEl.innerHTML = [
+ '<div class="carousel" data-interval="false">',
+ ' <div class="carousel-inner">',
+ ' <div id="item" class="carousel-item">',
+ ' <img alt="">',
+ ' </div>',
+ ' <div class="carousel-item active">',
+ ' <img alt="">',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const item = fixtureEl.querySelector('#item')
+ const carousel = new Carousel(carouselEl)
+
+ spyOn(carousel, 'prev').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(item.classList.contains('active')).toEqual(true)
+ expect(carousel.prev).toHaveBeenCalled()
+ delete document.documentElement.ontouchstart
+ restorePointerEvents()
+ done()
+ })
+
+ Simulator.gestures.swipe(carouselEl, {
+ deltaX: 300,
+ deltaY: 0
+ })
+ })
+
+ it('should allow swipeleft and call next with touch events', done => {
+ Simulator.setType('touch')
+ clearPointerEvents()
+ document.documentElement.ontouchstart = () => {}
+
+ fixtureEl.innerHTML = [
+ '<div class="carousel" data-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 item = fixtureEl.querySelector('#item')
+ const carousel = new Carousel(carouselEl)
+
+ spyOn(carousel, 'next').and.callThrough()
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(item.classList.contains('active')).toEqual(false)
+ expect(carousel.next).toHaveBeenCalled()
+ delete document.documentElement.ontouchstart
+ restorePointerEvents()
+ done()
+ })
+
+ Simulator.gestures.swipe(carouselEl, {
+ pos: [300, 10],
+ deltaX: -300,
+ deltaY: 0
+ })
+ })
+
+ it('should not allow pinch with touch events', done => {
+ Simulator.setType('touch')
+ clearPointerEvents()
+ document.documentElement.ontouchstart = () => {}
+
+ fixtureEl.innerHTML = '<div class="carousel" data-interval="false"></div>'
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const carousel = new Carousel(carouselEl)
+
+ Simulator.gestures.swipe(carouselEl, {
+ pos: [300, 10],
+ deltaX: -300,
+ deltaY: 0,
+ touches: 2
+ }, () => {
+ restorePointerEvents()
+ delete document.documentElement.ontouchstart
+ expect(carousel.touchDeltaX).toEqual(0)
+ done()
+ })
+ })
+
+ it('should call pause method on mouse over with pause equal to hover', done => {
+ fixtureEl.innerHTML = '<div class="carousel"></div>'
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const carousel = new Carousel(carouselEl)
+
+ spyOn(carousel, 'pause')
+
+ const mouseOverEvent = createEvent('mouseover')
+ carouselEl.dispatchEvent(mouseOverEvent)
+
+ setTimeout(() => {
+ expect(carousel.pause).toHaveBeenCalled()
+ done()
+ }, 10)
+ })
+
+ it('should call cycle on mouse out with pause equal to hover', done => {
+ fixtureEl.innerHTML = '<div class="carousel"></div>'
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const carousel = new Carousel(carouselEl)
+
+ spyOn(carousel, 'cycle')
+
+ const mouseOutEvent = createEvent('mouseout')
+ carouselEl.dispatchEvent(mouseOutEvent)
+
+ setTimeout(() => {
+ expect(carousel.cycle).toHaveBeenCalled()
+ done()
+ }, 10)
+ })
+ })
+
+ describe('next', () => {
+ it('should not slide if the carousel is sliding', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+
+ spyOn(carousel, '_slide')
+
+ carousel._isSliding = true
+ carousel.next()
+
+ expect(carousel._slide).not.toHaveBeenCalled()
+ })
+
+ it('should not fire slid when slide is prevented', done => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+ let slidEvent = false
+
+ const doneTest = () => {
+ setTimeout(() => {
+ expect(slidEvent).toEqual(false)
+ done()
+ }, 20)
+ }
+
+ carouselEl.addEventListener('slide.bs.carousel', e => {
+ e.preventDefault()
+ doneTest()
+ })
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ slidEvent = true
+ })
+
+ carousel.next()
+ })
+
+ it('should fire slide event with: direction, relatedTarget, from and to', done => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" 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>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ const onSlide = e => {
+ expect(e.direction).toEqual('left')
+ expect(e.relatedTarget.classList.contains('carousel-item')).toEqual(true)
+ expect(e.from).toEqual(0)
+ expect(e.to).toEqual(1)
+
+ carouselEl.removeEventListener('slide.bs.carousel', onSlide)
+ carouselEl.addEventListener('slide.bs.carousel', onSlide2)
+
+ carousel.prev()
+ }
+
+ const onSlide2 = e => {
+ expect(e.direction).toEqual('right')
+ done()
+ }
+
+ carouselEl.addEventListener('slide.bs.carousel', onSlide)
+ carousel.next()
+ })
+
+ it('should fire slid event with: direction, relatedTarget, from and to', done => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" 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>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ const onSlid = e => {
+ expect(e.direction).toEqual('left')
+ expect(e.relatedTarget.classList.contains('carousel-item')).toEqual(true)
+ expect(e.from).toEqual(0)
+ expect(e.to).toEqual(1)
+
+ carouselEl.removeEventListener('slid.bs.carousel', onSlid)
+ carouselEl.addEventListener('slid.bs.carousel', onSlid2)
+
+ carousel.prev()
+ }
+
+ const onSlid2 = e => {
+ expect(e.direction).toEqual('right')
+ done()
+ }
+
+ carouselEl.addEventListener('slid.bs.carousel', onSlid)
+ carousel.next()
+ })
+
+ it('should get interval from data attribute in individual item', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {
+ interval: 1814
+ })
+
+ expect(carousel._config.interval).toEqual(1814)
+
+ carousel.next()
+
+ expect(carousel._config.interval).toEqual(7)
+ })
+
+ it('should update indicators if present', done => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <ol class="carousel-indicators">',
+ ' <li data-target="#myCarousel" data-slide-to="0" class="active"></li>',
+ ' <li id="secondIndicator" data-target="#myCarousel" data-slide-to="1"></li>',
+ ' <li data-target="#myCarousel" data-slide-to="2"></li>',
+ ' </ol>',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const secondIndicator = fixtureEl.querySelector('#secondIndicator')
+ const carousel = new Carousel(carouselEl)
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(secondIndicator.classList.contains('active')).toEqual(true)
+ done()
+ })
+
+ carousel.next()
+ })
+ })
+
+ describe('nextWhenVisible', () => {
+ it('should not call next when the page is not visible', () => {
+ fixtureEl.innerHTML = [
+ '<div style="display: none;">',
+ ' <div class="carousel" data-interval="false"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('.carousel')
+ const carousel = new Carousel(carouselEl)
+
+ spyOn(carousel, 'next')
+
+ carousel.nextWhenVisible()
+
+ expect(carousel.next).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('prev', () => {
+ it('should not slide if the carousel is sliding', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const carousel = new Carousel(carouselEl, {})
+
+ spyOn(carousel, '_slide')
+
+ carousel._isSliding = true
+ carousel.prev()
+
+ expect(carousel._slide).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('pause', () => {
+ it('should call cycle if the carousel have carousel-item-next and carousel-item-prev class', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item carousel-item-next">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <div class="carousel-control-prev"></div>',
+ ' <div class="carousel-control-next"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+
+ spyOn(carousel, 'cycle')
+ spyOn(window, 'clearInterval')
+
+ carousel.pause()
+
+ expect(carousel.cycle).toHaveBeenCalledWith(true)
+ expect(window.clearInterval).toHaveBeenCalled()
+ expect(carousel._isPaused).toEqual(true)
+ })
+
+ it('should not call cycle if nothing is in transition', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" 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>',
+ ' <div class="carousel-control-prev"></div>',
+ ' <div class="carousel-control-next"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+
+ spyOn(carousel, 'cycle')
+ spyOn(window, 'clearInterval')
+
+ carousel.pause()
+
+ expect(carousel.cycle).not.toHaveBeenCalled()
+ expect(window.clearInterval).toHaveBeenCalled()
+ expect(carousel._isPaused).toEqual(true)
+ })
+
+ it('should not set is paused at true if an event is passed', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" 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>',
+ ' <div class="carousel-control-prev"></div>',
+ ' <div class="carousel-control-next"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+ const event = createEvent('mouseenter')
+
+ spyOn(window, 'clearInterval')
+
+ carousel.pause(event)
+
+ expect(window.clearInterval).toHaveBeenCalled()
+ expect(carousel._isPaused).toEqual(false)
+ })
+ })
+
+ describe('cycle', () => {
+ it('should set an interval', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" 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>',
+ ' <div class="carousel-control-prev"></div>',
+ ' <div class="carousel-control-next"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+
+ spyOn(window, 'setInterval').and.callThrough()
+
+ carousel.cycle()
+
+ expect(window.setInterval).toHaveBeenCalled()
+ })
+
+ it('should not set interval if the carousel is paused', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" 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>',
+ ' <div class="carousel-control-prev"></div>',
+ ' <div class="carousel-control-next"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+
+ spyOn(window, 'setInterval').and.callThrough()
+
+ carousel._isPaused = true
+ carousel.cycle(true)
+
+ expect(window.setInterval).not.toHaveBeenCalled()
+ })
+
+ it('should clear interval if there is one', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" 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>',
+ ' <div class="carousel-control-prev"></div>',
+ ' <div class="carousel-control-next"></div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+
+ carousel._interval = setInterval(() => {}, 10)
+
+ spyOn(window, 'setInterval').and.callThrough()
+ spyOn(window, 'clearInterval').and.callThrough()
+
+ carousel.cycle()
+
+ expect(window.setInterval).toHaveBeenCalled()
+ expect(window.clearInterval).toHaveBeenCalled()
+ })
+ })
+
+ describe('to', () => {
+ it('should go directement to the provided index', done => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div id="item1" class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item">item 2</div>',
+ ' <div id="item3" class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item1'))
+
+ carousel.to(2)
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item3'))
+ done()
+ })
+ })
+
+ it('should return to a previous slide if the provided index is lower than the current', done => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div id="item3" class="carousel-item active">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item3'))
+
+ carousel.to(1)
+
+ carouselEl.addEventListener('slid.bs.carousel', () => {
+ expect(fixtureEl.querySelector('.active')).toEqual(fixtureEl.querySelector('#item2'))
+ done()
+ })
+ })
+
+ it('should do nothing if a wrong index is provided', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ const spy = spyOn(carousel, '_slide')
+
+ carousel.to(25)
+
+ expect(spy).not.toHaveBeenCalled()
+
+ spy.calls.reset()
+
+ carousel.to(-5)
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should call pause and cycle is the provided is the same compare to the current one', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ spyOn(carousel, '_slide')
+ spyOn(carousel, 'pause')
+ spyOn(carousel, 'cycle')
+
+ carousel.to(0)
+
+ expect(carousel._slide).not.toHaveBeenCalled()
+ expect(carousel.pause).toHaveBeenCalled()
+ expect(carousel.cycle).toHaveBeenCalled()
+ })
+
+ it('should wait before performing to if a slide is sliding', done => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl, {})
+
+ spyOn(EventHandler, 'one').and.callThrough()
+ spyOn(carousel, '_slide')
+
+ carousel._isSliding = true
+ carousel.to(1)
+
+ expect(carousel._slide).not.toHaveBeenCalled()
+ expect(EventHandler.one).toHaveBeenCalled()
+
+ spyOn(carousel, 'to')
+
+ EventHandler.trigger(carouselEl, 'slid.bs.carousel')
+
+ setTimeout(() => {
+ expect(carousel.to).toHaveBeenCalledWith(1)
+ done()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should destroy a carousel', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div class="carousel-item" data-interval="7">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const carouselEl = fixtureEl.querySelector('#myCarousel')
+ const carousel = new Carousel(carouselEl)
+
+ spyOn(EventHandler, 'off').and.callThrough()
+
+ carousel.dispose()
+
+ expect(EventHandler.off).toHaveBeenCalled()
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a carousel', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.carousel = Carousel.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.carousel.call(jQueryMock)
+
+ expect(Carousel.getInstance(div)).toBeDefined()
+ })
+
+ it('should not re create a carousel', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const carousel = new Carousel(div)
+
+ jQueryMock.fn.carousel = Carousel.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.carousel.call(jQueryMock)
+
+ expect(Carousel.getInstance(div)).toEqual(carousel)
+ })
+
+ it('should call to if the config is a number', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const carousel = new Carousel(div)
+ const slideTo = 2
+
+ spyOn(carousel, 'to')
+
+ jQueryMock.fn.carousel = Carousel.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.carousel.call(jQueryMock, slideTo)
+
+ expect(carousel.to).toHaveBeenCalledWith(slideTo)
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.carousel = Carousel.jQueryInterface
+ jQueryMock.elements = [div]
+
+ try {
+ jQueryMock.fn.carousel.call(jQueryMock, action)
+ } catch (error) {
+ expect(error.message).toEqual(`No method named "${action}"`)
+ }
+ })
+ })
+
+ describe('data-api', () => {
+ it('should init carousels with data-ride="carousel" on load', () => {
+ fixtureEl.innerHTML = '<div data-ride="carousel"></div>'
+
+ const carouselEl = fixtureEl.querySelector('div')
+ const loadEvent = createEvent('load')
+
+ window.dispatchEvent(loadEvent)
+
+ expect(Carousel.getInstance(carouselEl)).toBeDefined()
+ })
+
+ it('should create carousel and go to the next slide on click', done => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <div class="carousel-control-prev" data-target="#myCarousel" role="button" data-slide="prev"></div>',
+ ' <div id="next" class="carousel-control-next" data-target="#myCarousel" role="button" data-slide="next"></div>',
+ '</div>'
+ ].join('')
+
+ const next = fixtureEl.querySelector('#next')
+ const item2 = fixtureEl.querySelector('#item2')
+
+ next.click()
+
+ setTimeout(() => {
+ expect(item2.classList.contains('active')).toEqual(true)
+ done()
+ }, 10)
+ })
+
+ it('should create carousel and go to the next slide on click with data-slide-to', done => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="carousel slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <div id="next" data-target="#myCarousel" data-slide-to="1"></div>',
+ '</div>'
+ ].join('')
+
+ const next = fixtureEl.querySelector('#next')
+ const item2 = fixtureEl.querySelector('#item2')
+
+ next.click()
+
+ setTimeout(() => {
+ expect(item2.classList.contains('active')).toEqual(true)
+ done()
+ }, 10)
+ })
+
+ it('should do nothing if no selector on click on arrows', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" 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>',
+ ' <div class="carousel-control-prev" data-target="#myCarousel" role="button" data-slide="prev"></div>',
+ ' <div id="next" class="carousel-control-next" role="button" data-slide="next"></div>',
+ '</div>'
+ ].join('')
+
+ const next = fixtureEl.querySelector('#next')
+
+ next.click()
+
+ expect().nothing()
+ })
+
+ it('should do nothing if no carousel class on click on arrows', () => {
+ fixtureEl.innerHTML = [
+ '<div id="myCarousel" class="slide">',
+ ' <div class="carousel-inner">',
+ ' <div class="carousel-item active">item 1</div>',
+ ' <div id="item2" class="carousel-item">item 2</div>',
+ ' <div class="carousel-item">item 3</div>',
+ ' </div>',
+ ' <div class="carousel-control-prev" data-target="#myCarousel" role="button" data-slide="prev"></div>',
+ ' <div id="next" class="carousel-control-next" data-target="#myCarousel" role="button" data-slide="next"></div>',
+ '</div>'
+ ].join('')
+
+ const next = fixtureEl.querySelector('#next')
+
+ next.click()
+
+ expect().nothing()
+ })
+ })
+})
diff --git a/js/tests/units/collapse.spec.js b/js/tests/units/collapse.spec.js
new file mode 100644
index 0000000000..3122ae6f4b
--- /dev/null
+++ b/js/tests/units/collapse.spec.js
@@ -0,0 +1,826 @@
+import Collapse from '../../src/collapse'
+import EventHandler from '../../src/dom/event-handler'
+import { makeArray } from '../../src/util/index'
+
+/** Test helpers */
+import { getFixture, clearFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Collapse', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Collapse.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Collapse.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('constructor', () => {
+ it('should allow jquery object in parent config', () => {
+ fixtureEl.innerHTML = [
+ '<div class="my-collapse">',
+ ' <div class="item">',
+ ' <a data-toggle="collapse" href="#">Toggle item</a>',
+ ' <div class="collapse">Lorem ipsum</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const collapseEl = fixtureEl.querySelector('div.collapse')
+ const myCollapseEl = fixtureEl.querySelector('.my-collapse')
+ const fakejQueryObject = {
+ 0: myCollapseEl
+ }
+ const collapse = new Collapse(collapseEl, {
+ parent: fakejQueryObject
+ })
+
+ expect(collapse._config.parent).toEqual(fakejQueryObject)
+ expect(collapse._getParent()).toEqual(myCollapseEl)
+ })
+
+ it('should allow non jquery object in parent config', () => {
+ fixtureEl.innerHTML = [
+ '<div class="my-collapse">',
+ ' <div class="item">',
+ ' <a data-toggle="collapse" href="#">Toggle item</a>',
+ ' <div class="collapse">Lorem ipsum</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const collapseEl = fixtureEl.querySelector('div.collapse')
+ const myCollapseEl = fixtureEl.querySelector('.my-collapse')
+ const collapse = new Collapse(collapseEl, {
+ parent: myCollapseEl
+ })
+
+ expect(collapse._config.parent).toEqual(myCollapseEl)
+ })
+
+ it('should allow string selector in parent config', () => {
+ fixtureEl.innerHTML = [
+ '<div class="my-collapse">',
+ ' <div class="item">',
+ ' <a data-toggle="collapse" href="#">Toggle item</a>',
+ ' <div class="collapse">Lorem ipsum</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const collapseEl = fixtureEl.querySelector('div.collapse')
+ const myCollapseEl = fixtureEl.querySelector('.my-collapse')
+ const collapse = new Collapse(collapseEl, {
+ parent: 'div.my-collapse'
+ })
+
+ expect(collapse._config.parent).toEqual('div.my-collapse')
+ expect(collapse._getParent()).toEqual(myCollapseEl)
+ })
+ })
+
+ describe('toggle', () => {
+ it('should call show method if show class is not present', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl)
+
+ spyOn(collapse, 'show')
+
+ collapse.toggle()
+
+ expect(collapse.show).toHaveBeenCalled()
+ })
+
+ it('should call hide method if show class is present', () => {
+ fixtureEl.innerHTML = '<div class="show"></div>'
+
+ const collapseEl = fixtureEl.querySelector('.show')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ spyOn(collapse, 'hide')
+
+ collapse.toggle()
+
+ expect(collapse.hide).toHaveBeenCalled()
+ })
+
+ it('should find collapse children if they have collapse class too not only data-parent', done => {
+ fixtureEl.innerHTML = [
+ '<div class="my-collapse">',
+ ' <div class="item">',
+ ' <a data-toggle="collapse" href="#">Toggle item 1</a>',
+ ' <div id="collapse1" class="collapse show">Lorem ipsum 1</div>',
+ ' </div>',
+ ' <div class="item">',
+ ' <a id="triggerCollapse2" data-toggle="collapse" href="#">Toggle item 2</a>',
+ ' <div id="collapse2" class="collapse">Lorem ipsum 2</div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const parent = fixtureEl.querySelector('.my-collapse')
+ const collapseEl1 = fixtureEl.querySelector('#collapse1')
+ const collapseEl2 = fixtureEl.querySelector('#collapse2')
+
+ const collapseList = makeArray(fixtureEl.querySelectorAll('.collapse'))
+ .map(el => new Collapse(el, {
+ parent,
+ toggle: false
+ }))
+
+ collapseEl2.addEventListener('shown.bs.collapse', () => {
+ expect(collapseEl2.classList.contains('show')).toEqual(true)
+ expect(collapseEl1.classList.contains('show')).toEqual(false)
+ done()
+ })
+
+ collapseList[1].toggle()
+ })
+ })
+
+ describe('show', () => {
+ it('should do nothing if is transitioning', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ spyOn(EventHandler, 'trigger')
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapse._isTransitioning = true
+ collapse.show()
+
+ expect(EventHandler.trigger).not.toHaveBeenCalled()
+ })
+
+ it('should do nothing if already shown', () => {
+ fixtureEl.innerHTML = '<div class="show"></div>'
+
+ spyOn(EventHandler, 'trigger')
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapse.show()
+
+ expect(EventHandler.trigger).not.toHaveBeenCalled()
+ })
+
+ it('should show a collapsed element', done => {
+ fixtureEl.innerHTML = '<div class="collapse" style="height: 0px;"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapseEl.addEventListener('show.bs.collapse', () => {
+ expect(collapseEl.style.height).toEqual('0px')
+ })
+ collapseEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseEl.classList.contains('show')).toEqual(true)
+ expect(collapseEl.style.height).toEqual('')
+ done()
+ })
+
+ collapse.show()
+ })
+
+ it('should show a collapsed element on width', done => {
+ fixtureEl.innerHTML = '<div class="collapse width" style="width: 0px;"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapseEl.addEventListener('show.bs.collapse', () => {
+ expect(collapseEl.style.width).toEqual('0px')
+ })
+ collapseEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseEl.classList.contains('show')).toEqual(true)
+ expect(collapseEl.style.width).toEqual('')
+ done()
+ })
+
+ collapse.show()
+ })
+
+ it('should collapse only the first collapse', done => {
+ fixtureEl.innerHTML = [
+ '<div class="card" id="accordion1">',
+ ' <div id="collapse1" class="collapse"/>',
+ '</div>',
+ '<div class="card" id="accordion2">',
+ ' <div id="collapse2" class="collapse show"/>',
+ '</div>'
+ ].join('')
+
+ const el1 = fixtureEl.querySelector('#collapse1')
+ const el2 = fixtureEl.querySelector('#collapse2')
+ const collapse = new Collapse(el1, {
+ toggle: false
+ })
+
+ el1.addEventListener('shown.bs.collapse', () => {
+ expect(el1.classList.contains('show')).toEqual(true)
+ expect(el2.classList.contains('show')).toEqual(true)
+ done()
+ })
+
+ collapse.show()
+ })
+
+ it('should not fire shown when show is prevented', done => {
+ fixtureEl.innerHTML = '<div class="collapse"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ const expectEnd = () => {
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ }, 10)
+ }
+
+ collapseEl.addEventListener('show.bs.collapse', e => {
+ e.preventDefault()
+ expectEnd()
+ })
+
+ collapseEl.addEventListener('shown.bs.collapse', () => {
+ throw new Error('should not fire shown event')
+ })
+
+ collapse.show()
+ })
+ })
+
+ describe('hide', () => {
+ it('should do nothing if is transitioning', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ spyOn(EventHandler, 'trigger')
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapse._isTransitioning = true
+ collapse.hide()
+
+ expect(EventHandler.trigger).not.toHaveBeenCalled()
+ })
+
+ it('should do nothing if already shown', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ spyOn(EventHandler, 'trigger')
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapse.hide()
+
+ expect(EventHandler.trigger).not.toHaveBeenCalled()
+ })
+
+ it('should hide a collapsed element', done => {
+ fixtureEl.innerHTML = '<div class="collapse show"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ collapseEl.addEventListener('hidden.bs.collapse', () => {
+ expect(collapseEl.classList.contains('show')).toEqual(false)
+ expect(collapseEl.style.height).toEqual('')
+ done()
+ })
+
+ collapse.hide()
+ })
+
+ it('should not fire hidden when hide is prevented', done => {
+ fixtureEl.innerHTML = '<div class="collapse show"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ const expectEnd = () => {
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ }, 10)
+ }
+
+ collapseEl.addEventListener('hide.bs.collapse', e => {
+ e.preventDefault()
+ expectEnd()
+ })
+
+ collapseEl.addEventListener('hidden.bs.collapse', () => {
+ throw new Error('should not fire hidden event')
+ })
+
+ collapse.hide()
+ })
+ })
+
+ describe('dispose', () => {
+ it('should destroy a collapse', () => {
+ fixtureEl.innerHTML = '<div class="collapse show"></div>'
+
+ const collapseEl = fixtureEl.querySelector('div')
+ const collapse = new Collapse(collapseEl, {
+ toggle: false
+ })
+
+ expect(Collapse.getInstance(collapseEl)).toEqual(collapse)
+
+ collapse.dispose()
+
+ expect(Collapse.getInstance(collapseEl)).toEqual(null)
+ })
+ })
+
+ describe('data-api', () => {
+ it('should show multiple collapsed elements', done => {
+ fixtureEl.innerHTML = [
+ '<a role="button" data-toggle="collapse" class="collapsed" href=".multi"></a>',
+ '<div id="collapse1" class="collapse multi"/>',
+ '<div id="collapse2" class="collapse multi"/>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('a')
+ const collapse1 = fixtureEl.querySelector('#collapse1')
+ const collapse2 = fixtureEl.querySelector('#collapse2')
+
+ collapse2.addEventListener('shown.bs.collapse', () => {
+ expect(trigger.getAttribute('aria-expanded')).toEqual('true')
+ expect(trigger.classList.contains('collapsed')).toEqual(false)
+ expect(collapse1.classList.contains('show')).toEqual(true)
+ expect(collapse1.classList.contains('show')).toEqual(true)
+ done()
+ })
+
+ trigger.click()
+ })
+
+ it('should hide multiple collapsed elements', done => {
+ fixtureEl.innerHTML = [
+ '<a role="button" data-toggle="collapse" href=".multi"></a>',
+ '<div id="collapse1" class="collapse multi show"/>',
+ '<div id="collapse2" class="collapse multi show"/>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('a')
+ const collapse1 = fixtureEl.querySelector('#collapse1')
+ const collapse2 = fixtureEl.querySelector('#collapse2')
+
+ collapse2.addEventListener('hidden.bs.collapse', () => {
+ expect(trigger.getAttribute('aria-expanded')).toEqual('false')
+ expect(trigger.classList.contains('collapsed')).toEqual(true)
+ expect(collapse1.classList.contains('show')).toEqual(false)
+ expect(collapse1.classList.contains('show')).toEqual(false)
+ done()
+ })
+
+ trigger.click()
+ })
+
+ it('should remove "collapsed" class from target when collapse is shown', done => {
+ fixtureEl.innerHTML = [
+ '<a id="link1" role="button" data-toggle="collapse" class="collapsed" href="#" data-target="#test1" />',
+ '<a id="link2" role="button" data-toggle="collapse" class="collapsed" href="#" data-target="#test1" />',
+ '<div id="test1"></div>'
+ ].join('')
+
+ const link1 = fixtureEl.querySelector('#link1')
+ const link2 = fixtureEl.querySelector('#link2')
+ const collapseTest1 = fixtureEl.querySelector('#test1')
+
+ collapseTest1.addEventListener('shown.bs.collapse', () => {
+ expect(link1.getAttribute('aria-expanded')).toEqual('true')
+ expect(link2.getAttribute('aria-expanded')).toEqual('true')
+ expect(link1.classList.contains('collapsed')).toEqual(false)
+ expect(link2.classList.contains('collapsed')).toEqual(false)
+ done()
+ })
+
+ link1.click()
+ })
+
+ it('should add "collapsed" class to target when collapse is hidden', done => {
+ fixtureEl.innerHTML = [
+ '<a id="link1" role="button" data-toggle="collapse" href="#" data-target="#test1" />',
+ '<a id="link2" role="button" data-toggle="collapse" href="#" data-target="#test1" />',
+ '<div id="test1" class="show"></div>'
+ ].join('')
+
+ const link1 = fixtureEl.querySelector('#link1')
+ const link2 = fixtureEl.querySelector('#link2')
+ const collapseTest1 = fixtureEl.querySelector('#test1')
+
+ collapseTest1.addEventListener('hidden.bs.collapse', () => {
+ expect(link1.getAttribute('aria-expanded')).toEqual('false')
+ expect(link2.getAttribute('aria-expanded')).toEqual('false')
+ expect(link1.classList.contains('collapsed')).toEqual(true)
+ expect(link2.classList.contains('collapsed')).toEqual(true)
+ done()
+ })
+
+ link1.click()
+ })
+
+ it('should allow accordion to use children other than card', done => {
+ fixtureEl.innerHTML = [
+ '<div id="accordion">',
+ ' <div class="item">',
+ ' <a id="linkTrigger" data-toggle="collapse" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne"></a>',
+ ' <div id="collapseOne" class="collapse" role="tabpanel" aria-labelledby="headingThree" data-parent="#accordion"></div>',
+ ' </div>',
+ ' <div class="item">',
+ ' <a id="linkTriggerTwo" data-toggle="collapse" href="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo"></a>',
+ ' <div id="collapseTwo" class="collapse show" role="tabpanel" aria-labelledby="headingTwo" data-parent="#accordion"></div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('#linkTrigger')
+ const triggerTwo = fixtureEl.querySelector('#linkTriggerTwo')
+ const collapseOne = fixtureEl.querySelector('#collapseOne')
+ const collapseTwo = fixtureEl.querySelector('#collapseTwo')
+
+ collapseOne.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOne.classList.contains('show')).toEqual(true)
+ expect(collapseTwo.classList.contains('show')).toEqual(false)
+
+ collapseTwo.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOne.classList.contains('show')).toEqual(false)
+ expect(collapseTwo.classList.contains('show')).toEqual(true)
+ done()
+ })
+
+ triggerTwo.click()
+ })
+
+ trigger.click()
+ })
+
+ it('should not prevent event for input', done => {
+ fixtureEl.innerHTML = [
+ '<input type="checkbox" data-toggle="collapse" data-target="#collapsediv1" />',
+ '<div id="collapsediv1"></div>'
+ ].join('')
+
+ const target = fixtureEl.querySelector('input')
+ const collapseEl = fixtureEl.querySelector('#collapsediv1')
+
+ collapseEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseEl.classList.contains('show')).toEqual(true)
+ expect(target.checked).toEqual(true)
+ done()
+ })
+
+ target.click()
+ })
+
+ it('should allow accordion to contain nested elements', done => {
+ fixtureEl.innerHTML = [
+ '<div id="accordion">',
+ ' <div class="row">',
+ ' <div class="col-lg-6">',
+ ' <div class="item">',
+ ' <a id="linkTrigger" data-toggle="collapse" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne"></a>',
+ ' <div id="collapseOne" class="collapse" role="tabpanel" aria-labelledby="headingThree" data-parent="#accordion"></div>',
+ ' </div>',
+ ' </div>',
+ ' <div class="col-lg-6">',
+ ' <div class="item">',
+ ' <a id="linkTriggerTwo" data-toggle="collapse" href="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo"></a>',
+ ' <div id="collapseTwo" class="collapse show" role="tabpanel" aria-labelledby="headingTwo" data-parent="#accordion"></div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerEl = fixtureEl.querySelector('#linkTrigger')
+ const triggerTwoEl = fixtureEl.querySelector('#linkTriggerTwo')
+ const collapseOneEl = fixtureEl.querySelector('#collapseOne')
+ const collapseTwoEl = fixtureEl.querySelector('#collapseTwo')
+
+ collapseOneEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOneEl.classList.contains('show')).toEqual(true)
+ expect(triggerEl.classList.contains('collapsed')).toEqual(false)
+ expect(triggerEl.getAttribute('aria-expanded')).toEqual('true')
+
+ expect(collapseTwoEl.classList.contains('show')).toEqual(false)
+ expect(triggerTwoEl.classList.contains('collapsed')).toEqual(true)
+ expect(triggerTwoEl.getAttribute('aria-expanded')).toEqual('false')
+
+ collapseTwoEl.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOneEl.classList.contains('show')).toEqual(false)
+ expect(triggerEl.classList.contains('collapsed')).toEqual(true)
+ expect(triggerEl.getAttribute('aria-expanded')).toEqual('false')
+
+ expect(collapseTwoEl.classList.contains('show')).toEqual(true)
+ expect(triggerTwoEl.classList.contains('collapsed')).toEqual(false)
+ expect(triggerTwoEl.getAttribute('aria-expanded')).toEqual('true')
+ done()
+ })
+
+ triggerTwoEl.click()
+ })
+
+ triggerEl.click()
+ })
+
+ it('should allow accordion to target multiple elements', done => {
+ fixtureEl.innerHTML = [
+ '<div id="accordion">',
+ ' <a id="linkTriggerOne" data-toggle="collapse" data-target=".collapseOne" href="#" aria-expanded="false" aria-controls="collapseOne"></a>',
+ ' <a id="linkTriggerTwo" data-toggle="collapse" data-target=".collapseTwo" href="#" aria-expanded="false" aria-controls="collapseTwo"></a>',
+ ' <div id="collapseOneOne" class="collapse collapseOne" role="tabpanel" data-parent="#accordion"></div>',
+ ' <div id="collapseOneTwo" class="collapse collapseOne" role="tabpanel" data-parent="#accordion"></div>',
+ ' <div id="collapseTwoOne" class="collapse collapseTwo" role="tabpanel" data-parent="#accordion"></div>',
+ ' <div id="collapseTwoTwo" class="collapse collapseTwo" role="tabpanel" data-parent="#accordion"></div>',
+ '</div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('#linkTriggerOne')
+ const triggerTwo = fixtureEl.querySelector('#linkTriggerTwo')
+ const collapseOneOne = fixtureEl.querySelector('#collapseOneOne')
+ const collapseOneTwo = fixtureEl.querySelector('#collapseOneTwo')
+ const collapseTwoOne = fixtureEl.querySelector('#collapseTwoOne')
+ const collapseTwoTwo = fixtureEl.querySelector('#collapseTwoTwo')
+ const collapsedElements = {
+ one: false,
+ two: false
+ }
+
+ function firstTest() {
+ expect(collapseOneOne.classList.contains('show')).toEqual(true)
+ expect(collapseOneTwo.classList.contains('show')).toEqual(true)
+
+ expect(collapseTwoOne.classList.contains('show')).toEqual(false)
+ expect(collapseTwoTwo.classList.contains('show')).toEqual(false)
+
+ triggerTwo.click()
+ }
+
+ function secondTest() {
+ expect(collapseOneOne.classList.contains('show')).toEqual(false)
+ expect(collapseOneTwo.classList.contains('show')).toEqual(false)
+
+ expect(collapseTwoOne.classList.contains('show')).toEqual(true)
+ expect(collapseTwoTwo.classList.contains('show')).toEqual(true)
+ done()
+ }
+
+ collapseOneOne.addEventListener('shown.bs.collapse', () => {
+ if (collapsedElements.one) {
+ firstTest()
+ } else {
+ collapsedElements.one = true
+ }
+ })
+
+ collapseOneTwo.addEventListener('shown.bs.collapse', () => {
+ if (collapsedElements.one) {
+ firstTest()
+ } else {
+ collapsedElements.one = true
+ }
+ })
+
+ collapseTwoOne.addEventListener('shown.bs.collapse', () => {
+ if (collapsedElements.two) {
+ secondTest()
+ } else {
+ collapsedElements.two = true
+ }
+ })
+
+ collapseTwoTwo.addEventListener('shown.bs.collapse', () => {
+ if (collapsedElements.two) {
+ secondTest()
+ } else {
+ collapsedElements.two = true
+ }
+ })
+
+ trigger.click()
+ })
+
+ it('should collapse accordion children but not nested accordion children', done => {
+ fixtureEl.innerHTML = [
+ '<div id="accordion">',
+ ' <div class="item">',
+ ' <a id="linkTrigger" data-toggle="collapse" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne"></a>',
+ ' <div id="collapseOne" data-parent="#accordion" class="collapse" role="tabpanel" aria-labelledby="headingThree">',
+ ' <div id="nestedAccordion">',
+ ' <div class="item">',
+ ' <a id="nestedLinkTrigger" data-toggle="collapse" href="#nestedCollapseOne" aria-expanded="false" aria-controls="nestedCollapseOne"></a>',
+ ' <div id="nestedCollapseOne" data-parent="#nestedAccordion" class="collapse" role="tabpanel" aria-labelledby="headingThree"></div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' <div class="item">',
+ ' <a id="linkTriggerTwo" data-toggle="collapse" href="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo"></a>',
+ ' <div id="collapseTwo" data-parent="#accordion" class="collapse show" role="tabpanel" aria-labelledby="headingTwo"></div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const trigger = fixtureEl.querySelector('#linkTrigger')
+ const triggerTwo = fixtureEl.querySelector('#linkTriggerTwo')
+ const nestedTrigger = fixtureEl.querySelector('#nestedLinkTrigger')
+ const collapseOne = fixtureEl.querySelector('#collapseOne')
+ const collapseTwo = fixtureEl.querySelector('#collapseTwo')
+ const nestedCollapseOne = fixtureEl.querySelector('#nestedCollapseOne')
+
+ function handlerCollapseOne() {
+ expect(collapseOne.classList.contains('show')).toEqual(true)
+ expect(collapseTwo.classList.contains('show')).toEqual(false)
+ expect(nestedCollapseOne.classList.contains('show')).toEqual(false)
+
+ nestedCollapseOne.addEventListener('shown.bs.collapse', handlerNestedCollapseOne)
+ nestedTrigger.click()
+ collapseOne.removeEventListener('shown.bs.collapse', handlerCollapseOne)
+ }
+
+ function handlerNestedCollapseOne() {
+ expect(collapseOne.classList.contains('show')).toEqual(true)
+ expect(collapseTwo.classList.contains('show')).toEqual(false)
+ expect(nestedCollapseOne.classList.contains('show')).toEqual(true)
+
+ collapseTwo.addEventListener('shown.bs.collapse', () => {
+ expect(collapseOne.classList.contains('show')).toEqual(false)
+ expect(collapseTwo.classList.contains('show')).toEqual(true)
+ expect(nestedCollapseOne.classList.contains('show')).toEqual(true)
+ done()
+ })
+
+ triggerTwo.click()
+ nestedCollapseOne.removeEventListener('shown.bs.collapse', handlerNestedCollapseOne)
+ }
+
+ collapseOne.addEventListener('shown.bs.collapse', handlerCollapseOne)
+ trigger.click()
+ })
+
+ it('should add "collapsed" class and set aria-expanded to triggers only when all the targeted collapse are hidden', done => {
+ fixtureEl.innerHTML = [
+ '<a id="trigger1" role="button" data-toggle="collapse" href="#test1"/>',
+ '<a id="trigger2" role="button" data-toggle="collapse" href="#test2"/>',
+ '<a id="trigger3" role="button" data-toggle="collapse" href=".multi"/>',
+ '<div id="test1" class="multi"/>',
+ '<div id="test2" class="multi"/>'
+ ].join('')
+
+ const trigger1 = fixtureEl.querySelector('#trigger1')
+ const trigger2 = fixtureEl.querySelector('#trigger2')
+ const trigger3 = fixtureEl.querySelector('#trigger3')
+ const target1 = fixtureEl.querySelector('#test1')
+ const target2 = fixtureEl.querySelector('#test2')
+
+ const target2Shown = () => {
+ expect(trigger1.classList.contains('collapsed')).toEqual(false)
+ expect(trigger1.getAttribute('aria-expanded')).toEqual('true')
+
+ expect(trigger2.classList.contains('collapsed')).toEqual(false)
+ expect(trigger2.getAttribute('aria-expanded')).toEqual('true')
+
+ expect(trigger3.classList.contains('collapsed')).toEqual(false)
+ expect(trigger3.getAttribute('aria-expanded')).toEqual('true')
+
+ target2.addEventListener('hidden.bs.collapse', () => {
+ expect(trigger1.classList.contains('collapsed')).toEqual(false)
+ expect(trigger1.getAttribute('aria-expanded')).toEqual('true')
+
+ expect(trigger2.classList.contains('collapsed')).toEqual(true)
+ expect(trigger2.getAttribute('aria-expanded')).toEqual('false')
+
+ expect(trigger3.classList.contains('collapsed')).toEqual(false)
+ expect(trigger3.getAttribute('aria-expanded')).toEqual('true')
+
+ target1.addEventListener('hidden.bs.collapse', () => {
+ expect(trigger1.classList.contains('collapsed')).toEqual(true)
+ expect(trigger1.getAttribute('aria-expanded')).toEqual('false')
+
+ expect(trigger2.classList.contains('collapsed')).toEqual(true)
+ expect(trigger2.getAttribute('aria-expanded')).toEqual('false')
+
+ expect(trigger3.classList.contains('collapsed')).toEqual(true)
+ expect(trigger3.getAttribute('aria-expanded')).toEqual('false')
+ done()
+ })
+
+ trigger1.click()
+ })
+
+ trigger2.click()
+ }
+
+ target2.addEventListener('shown.bs.collapse', target2Shown)
+ trigger3.click()
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a collapse', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.collapse = Collapse.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.collapse.call(jQueryMock)
+
+ expect(Collapse.getInstance(div)).toBeDefined()
+ })
+
+ it('should not re create a collapse', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const collapse = new Collapse(div)
+
+ jQueryMock.fn.collapse = Collapse.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.collapse.call(jQueryMock)
+
+ expect(Collapse.getInstance(div)).toEqual(collapse)
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.collapse = Collapse.jQueryInterface
+ jQueryMock.elements = [div]
+
+ try {
+ jQueryMock.fn.collapse.call(jQueryMock, action)
+ } catch (error) {
+ expect(error.message).toEqual(`No method named "${action}"`)
+ }
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return collapse instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const collapse = new Collapse(div)
+
+ expect(Collapse.getInstance(div)).toEqual(collapse)
+ })
+
+ it('should return null when there is no collapse instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Collapse.getInstance(div)).toEqual(null)
+ })
+ })
+})
diff --git a/js/tests/units/dom/data.spec.js b/js/tests/units/dom/data.spec.js
new file mode 100644
index 0000000000..ab3240b9bb
--- /dev/null
+++ b/js/tests/units/dom/data.spec.js
@@ -0,0 +1,131 @@
+import Data from '../../../src/dom/data'
+
+/** Test helpers */
+import { getFixture, clearFixture } from '../../helpers/fixture'
+
+describe('Data', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('setData', () => {
+ it('should set data in an element by adding a key attribute', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const data = {
+ test: 'bsData'
+ }
+
+ Data.setData(div, 'test', data)
+ expect(div.key).toBeDefined()
+ })
+
+ it('should change data if something is already stored', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const data = {
+ test: 'bsData'
+ }
+
+ Data.setData(div, 'test', data)
+
+ data.test = 'bsData2'
+ Data.setData(div, 'test', data)
+
+ expect(div.key).toBeDefined()
+ })
+ })
+
+ describe('getData', () => {
+ it('should return stored data', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const data = {
+ test: 'bsData'
+ }
+
+ Data.setData(div, 'test', data)
+ expect(Data.getData(div, 'test')).toEqual(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>'
+
+ const div = fixtureEl.querySelector('div')
+ const data = {
+ test: 'bsData'
+ }
+
+ Data.setData(div, 'test', data)
+
+ expect(Data.getData(div, 'test2')).toEqual(null)
+ })
+ })
+
+ describe('removeData', () => {
+ it('should do nothing when an element have nothing stored', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Data.removeData(div, 'test')
+ expect().nothing()
+ })
+
+ it('should should do nothing if it\'s not a valid key provided', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const data = {
+ test: 'bsData'
+ }
+
+ Data.setData(div, 'test', data)
+
+ expect(div.key).toBeDefined()
+
+ Data.removeData(div, 'test2')
+
+ expect(div.key).toBeDefined()
+ })
+
+ it('should remove data if something is stored', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const data = {
+ test: 'bsData'
+ }
+
+ Data.setData(div, 'test', data)
+
+ expect(div.key).toBeDefined()
+
+ Data.removeData(div, 'test')
+
+ expect(div.key).toBeUndefined()
+ })
+ })
+})
diff --git a/js/tests/units/dom/event-handler.spec.js b/js/tests/units/dom/event-handler.spec.js
new file mode 100644
index 0000000000..5551ddaa30
--- /dev/null
+++ b/js/tests/units/dom/event-handler.spec.js
@@ -0,0 +1,327 @@
+import EventHandler from '../../../src/dom/event-handler'
+
+/** Test helpers */
+import { getFixture, clearFixture } from '../../helpers/fixture'
+
+describe('EventHandler', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('on', () => {
+ it('should not add event listener if the event is not a string', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.on(div, null, () => {})
+ EventHandler.on(null, 'click', () => {})
+
+ expect().nothing()
+ })
+
+ it('should add event listener', done => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.on(div, 'click', () => {
+ expect().nothing()
+ done()
+ })
+
+ div.click()
+ })
+
+ it('should add namespaced event listener', done => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.on(div, 'bs.namespace', () => {
+ expect().nothing()
+ done()
+ })
+
+ EventHandler.trigger(div, 'bs.namespace')
+ })
+
+ it('should add native namespaced event listener', done => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.on(div, 'click.namespace', () => {
+ expect().nothing()
+ done()
+ })
+
+ EventHandler.trigger(div, 'click')
+ })
+
+ it('should handle event delegation', done => {
+ EventHandler.on(document, 'click', '.test', () => {
+ expect().nothing()
+ done()
+ })
+
+ fixtureEl.innerHTML = '<div class="test"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ div.click()
+ })
+ })
+
+ describe('one', () => {
+ it('should call listener just one', done => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ let called = 0
+ const div = fixtureEl.querySelector('div')
+ const obj = {
+ oneListener() {
+ called++
+ }
+ }
+
+ EventHandler.one(div, 'bootstrap', obj.oneListener)
+
+ EventHandler.trigger(div, 'bootstrap')
+ EventHandler.trigger(div, 'bootstrap')
+
+ setTimeout(() => {
+ expect(called).toEqual(1)
+ done()
+ }, 20)
+ })
+ })
+
+ describe('off', () => {
+ it('should not remove a listener', () => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ EventHandler.off(div, null, () => {})
+ EventHandler.off(null, 'click', () => {})
+ expect().nothing()
+ })
+
+ it('should remove a listener', done => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called = 0
+ const handler = () => {
+ called++
+ }
+
+ EventHandler.on(div, 'foobar', handler)
+ EventHandler.trigger(div, 'foobar')
+
+ EventHandler.off(div, 'foobar', handler)
+ EventHandler.trigger(div, 'foobar')
+
+ setTimeout(() => {
+ expect(called).toEqual(1)
+ done()
+ }, 20)
+ })
+
+ it('should remove all the events', done => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called = 0
+
+ EventHandler.on(div, 'foobar', () => {
+ called++
+ })
+ EventHandler.on(div, 'foobar', () => {
+ called++
+ })
+ EventHandler.trigger(div, 'foobar')
+
+ EventHandler.off(div, 'foobar')
+ EventHandler.trigger(div, 'foobar')
+
+ setTimeout(() => {
+ expect(called).toEqual(2)
+ done()
+ }, 20)
+ })
+
+ it('should remove all the namespaced listeners if namespace is passed', done => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called = 0
+
+ EventHandler.on(div, 'foobar.namespace', () => {
+ called++
+ })
+ EventHandler.on(div, 'foofoo.namespace', () => {
+ called++
+ })
+ EventHandler.trigger(div, 'foobar.namespace')
+ EventHandler.trigger(div, 'foofoo.namespace')
+
+ EventHandler.off(div, '.namespace')
+ EventHandler.trigger(div, 'foobar.namespace')
+ EventHandler.trigger(div, 'foofoo.namespace')
+
+ setTimeout(() => {
+ expect(called).toEqual(2)
+ done()
+ }, 20)
+ })
+
+ it('should remove the namespaced listeners', done => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let calledCallback1 = 0
+ let calledCallback2 = 0
+
+ EventHandler.on(div, 'foobar.namespace', () => {
+ calledCallback1++
+ })
+ EventHandler.on(div, 'foofoo.namespace', () => {
+ calledCallback2++
+ })
+
+ EventHandler.trigger(div, 'foobar.namespace')
+ EventHandler.off(div, 'foobar.namespace')
+ EventHandler.trigger(div, 'foobar.namespace')
+
+ EventHandler.trigger(div, 'foofoo.namespace')
+
+ setTimeout(() => {
+ expect(calledCallback1).toEqual(1)
+ expect(calledCallback2).toEqual(1)
+ done()
+ }, 20)
+ })
+
+ it('should remove the all the namespaced listeners for native events', done => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called = 0
+
+ EventHandler.on(div, 'click.namespace', () => {
+ called++
+ })
+ EventHandler.on(div, 'click.namespace2', () => {
+ called++
+ })
+
+ EventHandler.trigger(div, 'click')
+ EventHandler.off(div, 'click')
+ EventHandler.trigger(div, 'click')
+
+ setTimeout(() => {
+ expect(called).toEqual(2)
+ done()
+ }, 20)
+ })
+
+ it('should remove the specified namespaced listeners for native events', done => {
+ fixtureEl.innerHTML = '<div></div>'
+ const div = fixtureEl.querySelector('div')
+
+ let called1 = 0
+ let called2 = 0
+
+ EventHandler.on(div, 'click.namespace', () => {
+ called1++
+ })
+ EventHandler.on(div, 'click.namespace2', () => {
+ called2++
+ })
+ EventHandler.trigger(div, 'click')
+
+ EventHandler.off(div, 'click.namespace')
+ EventHandler.trigger(div, 'click')
+
+ setTimeout(() => {
+ expect(called1).toEqual(1)
+ expect(called2).toEqual(2)
+ done()
+ }, 20)
+ })
+
+ it('should remove a listener registered by .one', done => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const handler = () => {
+ throw new Error('called')
+ }
+
+ EventHandler.one(div, 'foobar', handler)
+ EventHandler.off(div, 'foobar', handler)
+
+ EventHandler.trigger(div, 'foobar')
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ }, 20)
+ })
+
+ it('should remove the correct delegated event listener', () => {
+ const element = document.createElement('div')
+ const subelement = document.createElement('span')
+ element.appendChild(subelement)
+
+ const anchor = document.createElement('a')
+ element.appendChild(anchor)
+
+ let i = 0
+ const handler = () => {
+ i++
+ }
+
+ EventHandler.on(element, 'click', 'a', handler)
+ EventHandler.on(element, 'click', 'span', handler)
+
+ fixtureEl.appendChild(element)
+
+ EventHandler.trigger(anchor, 'click')
+ EventHandler.trigger(subelement, 'click')
+
+ // first listeners called
+ expect(i === 2).toEqual(true)
+
+ EventHandler.off(element, 'click', 'span', handler)
+ EventHandler.trigger(subelement, 'click')
+
+ // removed listener not called
+ expect(i === 2).toEqual(true)
+
+ EventHandler.trigger(anchor, 'click')
+
+ // not removed listener called
+ expect(i === 3).toEqual(true)
+
+ EventHandler.on(element, 'click', 'span', handler)
+ EventHandler.trigger(anchor, 'click')
+ EventHandler.trigger(subelement, 'click')
+
+ // listener re-registered
+ expect(i === 5).toEqual(true)
+
+ EventHandler.off(element, 'click', 'span')
+ EventHandler.trigger(subelement, 'click')
+
+ // listener removed again
+ expect(i === 5).toEqual(true)
+ })
+ })
+})
diff --git a/js/tests/units/dom/manipulator.spec.js b/js/tests/units/dom/manipulator.spec.js
new file mode 100644
index 0000000000..986f692980
--- /dev/null
+++ b/js/tests/units/dom/manipulator.spec.js
@@ -0,0 +1,158 @@
+import Manipulator from '../../../src/dom/manipulator'
+
+/** Test helpers */
+import { getFixture, clearFixture } from '../../helpers/fixture'
+
+describe('Manipulator', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('setDataAttribute', () => {
+ it('should set data attribute', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Manipulator.setDataAttribute(div, 'key', 'value')
+ expect(div.getAttribute('data-key')).toEqual('value')
+ })
+
+ it('should set data attribute in lower case', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Manipulator.setDataAttribute(div, 'tEsT', 'value')
+ expect(div.getAttribute('data-test')).toEqual('value')
+ })
+ })
+
+ describe('removeDataAttribute', () => {
+ it('should remove data attribute', () => {
+ fixtureEl.innerHTML = '<div data-key="value"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Manipulator.removeDataAttribute(div, 'key')
+ expect(div.getAttribute('data-key')).toBeNull()
+ })
+
+ it('should remove data attribute in lower case', () => {
+ fixtureEl.innerHTML = '<div data-testkey="value" ></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Manipulator.removeDataAttribute(div, 'tEStKeY')
+ expect(div.getAttribute('data-testkey')).toBeNull()
+ })
+ })
+
+ describe('getDataAttributes', () => {
+ it('should return empty object for null', () => {
+ expect(Manipulator.getDataAttributes(null), {})
+ expect().nothing()
+ })
+
+ it('should get all data attributes', () => {
+ fixtureEl.innerHTML = '<div data-test="js" data-test2="js2" ></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttributes(div)).toEqual({
+ test: 'js',
+ test2: 'js2'
+ })
+ })
+ })
+
+ describe('getDataAttribute', () => {
+ it('should get data attribute', () => {
+ fixtureEl.innerHTML = '<div data-test="null" ></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttribute(div, 'test')).toBeNull()
+ })
+
+ it('should get data attribute in lower case', () => {
+ fixtureEl.innerHTML = '<div data-test="value" ></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttribute(div, 'tEsT')).toEqual('value')
+ })
+
+ it('should normalize data', () => {
+ fixtureEl.innerHTML = '<div data-test="false" ></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Manipulator.getDataAttribute(div, 'test')).toEqual(false)
+
+ div.setAttribute('data-test', 'true')
+ expect(Manipulator.getDataAttribute(div, 'test')).toEqual(true)
+
+ div.setAttribute('data-test', '1')
+ expect(Manipulator.getDataAttribute(div, 'test')).toEqual(1)
+ })
+ })
+
+ describe('offset', () => {
+ it('should return object with two properties top and left, both numbers', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const offset = Manipulator.offset(div)
+
+ expect(offset).toBeDefined()
+ expect(offset.top).toEqual(jasmine.any(Number))
+ expect(offset.left).toEqual(jasmine.any(Number))
+ })
+ })
+
+ describe('position', () => {
+ it('should return object with two properties top and left, both numbers', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const position = Manipulator.position(div)
+
+ expect(position).toBeDefined()
+ expect(position.top).toEqual(jasmine.any(Number))
+ expect(position.left).toEqual(jasmine.any(Number))
+ })
+ })
+
+ describe('toggleClass', () => {
+ it('should not error out if element is null or undefined', () => {
+ Manipulator.toggleClass(null, 'test')
+ Manipulator.toggleClass(undefined, 'test')
+ expect().nothing()
+ })
+
+ it('should add class if it is missing', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Manipulator.toggleClass(div, 'test')
+ expect(div.classList.contains('test')).toEqual(true)
+ })
+
+ it('should remove class if it is set', () => {
+ fixtureEl.innerHTML = '<div class="test"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ Manipulator.toggleClass(div, 'test')
+ expect(div.classList.contains('test')).toEqual(false)
+ })
+ })
+})
diff --git a/js/tests/units/dom/selector-engine.spec.js b/js/tests/units/dom/selector-engine.spec.js
new file mode 100644
index 0000000000..e13438e6fd
--- /dev/null
+++ b/js/tests/units/dom/selector-engine.spec.js
@@ -0,0 +1,115 @@
+import SelectorEngine from '../../../src/dom/selector-engine'
+import { makeArray } from '../../../src/util/index'
+
+/** Test helpers */
+import { getFixture, clearFixture } from '../../helpers/fixture'
+
+describe('SelectorEngine', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('matches', () => {
+ it('should return matched elements', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ expect(SelectorEngine.matches(fixtureEl, 'div')).toEqual(true)
+ })
+ })
+
+ describe('find', () => {
+ it('should find elements', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(makeArray(SelectorEngine.find('div', fixtureEl))).toEqual([div])
+ })
+
+ it('should find elements globaly', () => {
+ fixtureEl.innerHTML = '<div id="test"></div>'
+
+ const div = fixtureEl.querySelector('#test')
+
+ expect(makeArray(SelectorEngine.find('#test'))).toEqual([div])
+ })
+
+ it('should handle :scope selectors', () => {
+ fixtureEl.innerHTML = `<ul>
+ <li></li>
+ <li>
+ <a href="#" class="active">link</a>
+ </li>
+ <li></li>
+ </ul>`
+
+ const listEl = fixtureEl.querySelector('ul')
+ const aActive = fixtureEl.querySelector('.active')
+
+ expect(makeArray(SelectorEngine.find(':scope > li > .active', listEl))).toEqual([aActive])
+ })
+ })
+
+ describe('findOne', () => {
+ it('should return one element', () => {
+ fixtureEl.innerHTML = '<div id="test"></div>'
+
+ const div = fixtureEl.querySelector('#test')
+
+ expect(SelectorEngine.findOne('#test')).toEqual(div)
+ })
+ })
+
+ describe('children', () => {
+ it('should find children', () => {
+ fixtureEl.innerHTML = `<ul>
+ <li></li>
+ <li></li>
+ <li></li>
+ </ul>`
+
+ const list = fixtureEl.querySelector('ul')
+ const liList = makeArray(fixtureEl.querySelectorAll('li'))
+ const result = makeArray(SelectorEngine.children(list, 'li'))
+
+ expect(result).toEqual(liList)
+ })
+ })
+
+ describe('parents', () => {
+ it('should return parents', () => {
+ expect(SelectorEngine.parents(fixtureEl, 'body').length).toEqual(1)
+ })
+ })
+
+ describe('prev', () => {
+ it('should return previous element', () => {
+ fixtureEl.innerHTML = '<div class="test"></div><button class="btn"></button>'
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+
+ expect(SelectorEngine.prev(btn, '.test')).toEqual([divTest])
+ })
+
+ it('should return previous element with an extra element between', () => {
+ fixtureEl.innerHTML = [
+ '<div class="test"></div>',
+ '<span></span>',
+ '<button class="btn"></button>'
+ ].join('')
+
+ const btn = fixtureEl.querySelector('.btn')
+ const divTest = fixtureEl.querySelector('.test')
+
+ expect(SelectorEngine.prev(btn, '.test')).toEqual([divTest])
+ })
+ })
+})
+
diff --git a/js/tests/units/dropdown.spec.js b/js/tests/units/dropdown.spec.js
new file mode 100644
index 0000000000..0046cf6809
--- /dev/null
+++ b/js/tests/units/dropdown.spec.js
@@ -0,0 +1,1564 @@
+import Popper from 'popper.js'
+
+import Dropdown from '../../src/dropdown'
+import EventHandler from '../../src/dom/event-handler'
+
+/** Test helpers */
+import { getFixture, clearFixture, createEvent, jQueryMock } from '../helpers/fixture'
+
+describe('Dropdown', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Dropdown.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Dropdown.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('DefaultType', () => {
+ it('should return plugin default type config', () => {
+ expect(Dropdown.DefaultType).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('constructor', () => {
+ it('should create offset modifier correctly when offset option is a function', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const getOffset = offsets => offsets
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown, {
+ offset: getOffset
+ })
+
+ const offset = dropdown._getOffset()
+
+ expect(offset.offset).toBeUndefined()
+ expect(typeof offset.fn).toEqual('function')
+ })
+
+ it('should create offset modifier correctly when offset option is not a function', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const myOffset = 7
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown, {
+ offset: myOffset
+ })
+
+ const offset = dropdown._getOffset()
+
+ expect(offset.offset).toEqual(myOffset)
+ expect(offset.fn).toBeUndefined()
+ })
+
+ it('should add a listener on trigger which do not have data-toggle="dropdown"', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('.btn')
+ const dropdown = new Dropdown(btnDropdown)
+
+ spyOn(dropdown, 'toggle')
+
+ btnDropdown.click()
+
+ expect(dropdown.toggle).toHaveBeenCalled()
+ })
+
+ it('should allow to pass config to popper.js with `popperConfig`', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown, {
+ popperConfig: {
+ placement: 'left'
+ }
+ })
+
+ const popperConfig = dropdown._getPopperConfig()
+
+ expect(popperConfig.placement).toEqual('left')
+ })
+ })
+
+ describe('toggle', () => {
+ it('should toggle a dropdown', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownEl.classList.contains('show')).toEqual(true)
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ done()
+ })
+
+ dropdown.toggle()
+ })
+
+ it('should destroy old popper references on toggle', done => {
+ fixtureEl.innerHTML = [
+ '<div class="first dropdown">',
+ ' <button href="#" class="firstBtn btn" data-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>',
+ '<div class="second dropdown">',
+ ' <button href="#" class="secondBtn btn" data-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown1 = fixtureEl.querySelector('.firstBtn')
+ const btnDropdown2 = fixtureEl.querySelector('.secondBtn')
+ 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(firstDropdownEl.classList.contains('show')).toEqual(true)
+ spyOn(dropdown1._popper, 'destroy')
+ dropdown2.toggle()
+ })
+
+ secondDropdownEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdown1._popper.destroy).toHaveBeenCalled()
+ done()
+ })
+
+ dropdown1.toggle()
+ })
+
+ it('should toggle a dropdown and add/remove event listener on mobile', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const defaultValueOnTouchStart = document.documentElement.ontouchstart
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown)
+
+ document.documentElement.ontouchstart = () => {}
+ spyOn(EventHandler, 'on')
+ spyOn(EventHandler, 'off')
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownEl.classList.contains('show')).toEqual(true)
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ expect(EventHandler.on).toHaveBeenCalled()
+
+ dropdown.toggle()
+ })
+
+ dropdownEl.addEventListener('hidden.bs.dropdown', () => {
+ expect(dropdownEl.classList.contains('show')).toEqual(false)
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false')
+ expect(EventHandler.off).toHaveBeenCalled()
+
+ document.documentElement.ontouchstart = defaultValueOnTouchStart
+ done()
+ })
+
+ dropdown.toggle()
+ })
+
+ it('should toggle a dropdown at the right', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu dropdown-menu-right">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownEl.classList.contains('show')).toEqual(true)
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ done()
+ })
+
+ dropdown.toggle()
+ })
+
+ it('should toggle a dropup', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropup">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropupEl = fixtureEl.querySelector('.dropup')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropupEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropupEl.classList.contains('show')).toEqual(true)
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ done()
+ })
+
+ dropdown.toggle()
+ })
+
+ it('should toggle a dropup at the right', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropup">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu dropdown-menu-right">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropupEl = fixtureEl.querySelector('.dropup')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropupEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropupEl.classList.contains('show')).toEqual(true)
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ done()
+ })
+
+ dropdown.toggle()
+ })
+
+ it('should toggle a dropright', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropright">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const droprightEl = fixtureEl.querySelector('.dropright')
+ const dropdown = new Dropdown(btnDropdown)
+
+ droprightEl.addEventListener('shown.bs.dropdown', () => {
+ expect(droprightEl.classList.contains('show')).toEqual(true)
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ done()
+ })
+
+ dropdown.toggle()
+ })
+
+ it('should toggle a dropleft', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropleft">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropleftEl = fixtureEl.querySelector('.dropleft')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropleftEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropleftEl.classList.contains('show')).toEqual(true)
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ done()
+ })
+
+ dropdown.toggle()
+ })
+
+ it('should toggle a dropdown with parent reference', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown, {
+ reference: 'parent'
+ })
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownEl.classList.contains('show')).toEqual(true)
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ done()
+ })
+
+ dropdown.toggle()
+ })
+
+ it('should toggle a dropdown with a dom node reference', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown, {
+ reference: fixtureEl
+ })
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownEl.classList.contains('show')).toEqual(true)
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ done()
+ })
+
+ dropdown.toggle()
+ })
+
+ it('should toggle a dropdown with a jquery object reference', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown, {
+ reference: { 0: fixtureEl, jquery: 'jQuery' }
+ })
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownEl.classList.contains('show')).toEqual(true)
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ done()
+ })
+
+ dropdown.toggle()
+ })
+
+ it('should not toggle a dropdown if the element is disabled', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button disabled href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ throw new Error('should not throw shown.bs.dropdown event')
+ })
+
+ dropdown.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ })
+ })
+
+ it('should not toggle a dropdown if the element contains .disabled', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle disabled" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ throw new Error('should not throw shown.bs.dropdown event')
+ })
+
+ dropdown.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ })
+ })
+
+ it('should not toggle a dropdown if the menu is shown', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ throw new Error('should not throw shown.bs.dropdown event')
+ })
+
+ dropdown.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ })
+ })
+
+ it('should not toggle a dropdown if show event is prevented', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('show.bs.dropdown', e => {
+ e.preventDefault()
+ })
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ throw new Error('should not throw shown.bs.dropdown event')
+ })
+
+ dropdown.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ })
+ })
+ })
+
+ describe('show', () => {
+ it('should show a dropdown', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownEl.classList.contains('show')).toEqual(true)
+ done()
+ })
+
+ dropdown.show()
+ })
+
+ it('should not show a dropdown if the element is disabled', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button disabled href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ throw new Error('should not throw shown.bs.dropdown event')
+ })
+
+ dropdown.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ }, 10)
+ })
+
+ it('should not show a dropdown if the element contains .disabled', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle disabled" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ throw new Error('should not throw shown.bs.dropdown event')
+ })
+
+ dropdown.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ }, 10)
+ })
+
+ it('should not show a dropdown if the menu is shown', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ throw new Error('should not throw shown.bs.dropdown event')
+ })
+
+ dropdown.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ }, 10)
+ })
+
+ it('should not show a dropdown if show event is prevented', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('show.bs.dropdown', e => {
+ e.preventDefault()
+ })
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ throw new Error('should not throw shown.bs.dropdown event')
+ })
+
+ dropdown.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ }, 10)
+ })
+ })
+
+ describe('hide', () => {
+ it('should hide a dropdown', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('hidden.bs.dropdown', () => {
+ expect(dropdownMenu.classList.contains('show')).toEqual(false)
+ done()
+ })
+
+ dropdown.hide()
+ })
+
+ it('should hide a dropdown and destroy popper', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ spyOn(dropdown._popper, 'destroy')
+ dropdown.hide()
+ })
+
+ dropdownEl.addEventListener('hidden.bs.dropdown', () => {
+ expect(dropdown._popper.destroy).toHaveBeenCalled()
+ done()
+ })
+
+ dropdown.show()
+ })
+
+ it('should not hide a dropdown if the element is disabled', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button disabled href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('hidden.bs.dropdown', () => {
+ throw new Error('should not throw hidden.bs.dropdown event')
+ })
+
+ dropdown.hide()
+
+ setTimeout(() => {
+ expect(dropdownMenu.classList.contains('show')).toEqual(true)
+ done()
+ }, 10)
+ })
+
+ it('should not hide a dropdown if the element contains .disabled', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle disabled" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('hidden.bs.dropdown', () => {
+ throw new Error('should not throw hidden.bs.dropdown event')
+ })
+
+ dropdown.hide()
+
+ setTimeout(() => {
+ expect(dropdownMenu.classList.contains('show')).toEqual(true)
+ done()
+ }, 10)
+ })
+
+ it('should not hide a dropdown if the menu is not shown', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('hidden.bs.dropdown', () => {
+ throw new Error('should not throw hidden.bs.dropdown event')
+ })
+
+ dropdown.hide()
+
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ }, 10)
+ })
+
+ it('should not hide a dropdown if hide event is prevented', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu show">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdownEl.addEventListener('hide.bs.dropdown', e => {
+ e.preventDefault()
+ })
+
+ dropdownEl.addEventListener('hidden.bs.dropdown', () => {
+ throw new Error('should not throw hidden.bs.dropdown event')
+ })
+
+ dropdown.hide()
+
+ setTimeout(() => {
+ expect(dropdownMenu.classList.contains('show')).toEqual(true)
+ done()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose dropdown', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ expect(dropdown._popper).toBeNull()
+ expect(dropdown._menu).toBeDefined()
+ expect(dropdown._element).toBeDefined()
+
+ dropdown.dispose()
+
+ expect(dropdown._menu).toBeNull()
+ expect(dropdown._element).toBeNull()
+ })
+
+ it('should dispose dropdown with popper.js', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdown.toggle()
+
+ expect(dropdown._popper).toBeDefined()
+ expect(dropdown._menu).toBeDefined()
+ expect(dropdown._element).toBeDefined()
+
+ spyOn(Popper.prototype, 'destroy')
+
+ dropdown.dispose()
+
+ expect(dropdown._popper).toBeNull()
+ expect(dropdown._menu).toBeNull()
+ expect(dropdown._element).toBeNull()
+ expect(Popper.prototype.destroy).toHaveBeenCalled()
+ })
+ })
+
+ describe('update', () => {
+ it('should call popper.js and detect navbar on update', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ dropdown.toggle()
+
+ expect(dropdown._popper).toBeDefined()
+
+ spyOn(dropdown._popper, 'scheduleUpdate')
+ spyOn(dropdown, '_detectNavbar')
+
+ dropdown.update()
+
+ expect(dropdown._popper.scheduleUpdate).toHaveBeenCalled()
+ expect(dropdown._detectNavbar).toHaveBeenCalled()
+ })
+
+ it('should just detect navbar on update', () => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdown = new Dropdown(btnDropdown)
+
+ spyOn(dropdown, '_detectNavbar')
+
+ dropdown.update()
+
+ expect(dropdown._popper).toBeNull()
+ expect(dropdown._detectNavbar).toHaveBeenCalled()
+ })
+ })
+
+ describe('data-api', () => {
+ it('should not add class position-static to dropdown if boundary not set', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownEl.classList.contains('position-static')).toEqual(false)
+ done()
+ })
+
+ btnDropdown.click()
+ })
+
+ it('should add class position-static to dropdown if boundary not scrollParent', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown" data-boundary="viewport">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownEl.classList.contains('position-static')).toEqual(true)
+ done()
+ })
+
+ btnDropdown.click()
+ })
+
+ it('should show and hide a dropdown', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ let showEventTriggered = false
+ let hideEventTriggered = false
+
+ dropdownEl.addEventListener('show.bs.dropdown', () => {
+ showEventTriggered = true
+ })
+
+ dropdownEl.addEventListener('shown.bs.dropdown', e => {
+ expect(dropdownEl.classList.contains('show')).toEqual(true)
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('true')
+ expect(showEventTriggered).toEqual(true)
+ expect(e.relatedTarget).toEqual(btnDropdown)
+ document.body.click()
+ })
+
+ dropdownEl.addEventListener('hide.bs.dropdown', () => {
+ hideEventTriggered = true
+ })
+
+ dropdownEl.addEventListener('hidden.bs.dropdown', e => {
+ expect(dropdownEl.classList.contains('show')).toEqual(false)
+ expect(btnDropdown.getAttribute('aria-expanded')).toEqual('false')
+ expect(hideEventTriggered).toEqual(true)
+ expect(e.relatedTarget).toEqual(btnDropdown)
+ done()
+ })
+
+ btnDropdown.click()
+ })
+
+ it('should not use popper.js in navbar', done => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar navbar-expand-md navbar-light bg-light">',
+ ' <div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown" aria-expanded="false">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ ' </div>',
+ '</nav>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownMenu.getAttribute('style')).toEqual(null, 'no inline style applied by popper.js')
+ done()
+ })
+
+ btnDropdown.click()
+ })
+
+ it('should not use popper.js if display set to static', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown" data-display="static">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+ const dropdownMenu = fixtureEl.querySelector('.dropdown-menu')
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ // popper.js add this attribute when we use it
+ expect(dropdownMenu.getAttribute('x-placement')).toEqual(null)
+ done()
+ })
+
+ btnDropdown.click()
+ })
+
+ it('should remove "show" class if tabbing outside of menu', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const btnDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdownEl = fixtureEl.querySelector('.dropdown')
+
+ dropdownEl.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownEl.classList.contains('show')).toEqual(true)
+
+ const keyUp = createEvent('keyup')
+
+ keyUp.which = 9 // Tab
+ document.dispatchEvent(keyUp)
+ })
+
+ dropdownEl.addEventListener('hidden.bs.dropdown', () => {
+ expect(dropdownEl.classList.contains('show')).toEqual(false)
+ done()
+ })
+
+ btnDropdown.click()
+ })
+
+ it('should remove "show" class if body is clicked, with multiple dropdowns', done => {
+ fixtureEl.innerHTML = [
+ '<div class="nav">',
+ ' <div class="dropdown" id="testmenu">',
+ ' <a class="dropdown-toggle" data-toggle="dropdown" href="#testmenu">Test menu <span class="caret"/></a>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#sub1">Submenu 1</a>',
+ ' </div>',
+ ' </div>',
+ '</div>',
+ '<div class="btn-group">',
+ ' <button class="btn">Actions</button>',
+ ' <button class="btn dropdown-toggle" data-toggle="dropdown"></button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Action 1</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdownList = fixtureEl.querySelectorAll('[data-toggle="dropdown"]')
+
+ expect(triggerDropdownList.length).toEqual(2)
+
+ const first = triggerDropdownList[0]
+ const last = triggerDropdownList[1]
+ const dropdownTestMenu = first.parentNode
+ const btnGroup = last.parentNode
+
+ dropdownTestMenu.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownTestMenu.classList.contains('show')).toEqual(true)
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(1)
+ document.body.click()
+ })
+
+ dropdownTestMenu.addEventListener('hidden.bs.dropdown', () => {
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(0)
+ last.click()
+ })
+
+ btnGroup.addEventListener('shown.bs.dropdown', () => {
+ expect(btnGroup.classList.contains('show')).toEqual(true)
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(1)
+ document.body.click()
+ })
+
+ btnGroup.addEventListener('hidden.bs.dropdown', () => {
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(0)
+ done()
+ })
+
+ first.click()
+ })
+
+ it('should remove "show" class if body if tabbing outside of menu, with multiple dropdowns', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <a class="dropdown-toggle" data-toggle="dropdown" href="#testmenu">Test menu</a>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#sub1">Submenu 1</a>',
+ ' </div>',
+ '</div>',
+ '<div class="btn-group">',
+ ' <button class="btn">Actions</button>',
+ ' <button class="btn dropdown-toggle" data-toggle="dropdown"></button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Action 1</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdownList = fixtureEl.querySelectorAll('[data-toggle="dropdown"]')
+
+ expect(triggerDropdownList.length).toEqual(2)
+
+ const first = triggerDropdownList[0]
+ const last = triggerDropdownList[1]
+ const dropdownTestMenu = first.parentNode
+ const btnGroup = last.parentNode
+
+ dropdownTestMenu.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdownTestMenu.classList.contains('show')).toEqual(true, '"show" class added on click')
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(1, 'only one dropdown is shown')
+
+ const keyUp = createEvent('keyup')
+ keyUp.which = 9 // Tab
+
+ document.dispatchEvent(keyUp)
+ })
+
+ dropdownTestMenu.addEventListener('hidden.bs.dropdown', () => {
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(0, '"show" class removed')
+ last.click()
+ })
+
+ btnGroup.addEventListener('shown.bs.dropdown', () => {
+ expect(btnGroup.classList.contains('show')).toEqual(true, '"show" class added on click')
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(1, 'only one dropdown is shown')
+
+ const keyUp = createEvent('keyup')
+ keyUp.which = 9 // Tab
+
+ document.dispatchEvent(keyUp)
+ })
+
+ btnGroup.addEventListener('hidden.bs.dropdown', () => {
+ expect(fixtureEl.querySelectorAll('.dropdown-menu.show').length).toEqual(0, '"show" class removed')
+ done()
+ })
+
+ first.click()
+ })
+
+ it('should fire hide and hidden event without a clickEvent if event type is not click', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#sub1">Submenu 1</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdown = fixtureEl.querySelector('.dropdown')
+
+ dropdown.addEventListener('hide.bs.dropdown', e => {
+ expect(e.clickEvent).toBeUndefined()
+ })
+
+ dropdown.addEventListener('hidden.bs.dropdown', e => {
+ expect(e.clickEvent).toBeUndefined()
+ done()
+ })
+
+ dropdown.addEventListener('shown.bs.dropdown', () => {
+ const keyDown = createEvent('keydown')
+
+ keyDown.which = 27
+ triggerDropdown.dispatchEvent(keyDown)
+ })
+
+ triggerDropdown.click()
+ })
+
+ it('should ignore keyboard events within <input>s and <textarea>s', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#sub1">Submenu 1</a>',
+ ' <input type="text" />',
+ ' <textarea></textarea>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdown = fixtureEl.querySelector('.dropdown')
+ const input = fixtureEl.querySelector('input')
+ const textarea = fixtureEl.querySelector('textarea')
+
+ dropdown.addEventListener('shown.bs.dropdown', () => {
+ input.focus()
+ const keyDown = createEvent('keydown')
+
+ keyDown.which = 38
+ input.dispatchEvent(keyDown)
+
+ expect(document.activeElement).toEqual(input, 'input still focused')
+
+ textarea.focus()
+ textarea.dispatchEvent(keyDown)
+
+ expect(document.activeElement).toEqual(textarea, 'textarea still focused')
+ done()
+ })
+
+ triggerDropdown.click()
+ })
+
+ it('should skip disabled element when using keyboard navigation', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item disabled" href="#sub1">Submenu 1</a>',
+ ' <button class="dropdown-item" type="button" disabled>Disabled button</button>',
+ ' <a id="item1" class="dropdown-item" href="#">Another link</a>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdown = fixtureEl.querySelector('.dropdown')
+
+ dropdown.addEventListener('shown.bs.dropdown', () => {
+ const keyDown = createEvent('keydown')
+ keyDown.which = 40
+
+ triggerDropdown.dispatchEvent(keyDown)
+ triggerDropdown.dispatchEvent(keyDown)
+
+ expect(document.activeElement.classList.contains('disabled')).toEqual(false, '.disabled not focused')
+ expect(document.activeElement.hasAttribute('disabled')).toEqual(false, ':disabled not focused')
+ done()
+ })
+
+ triggerDropdown.click()
+ })
+
+ it('should focus next/previous element when using keyboard navigation', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-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-toggle="dropdown"]')
+ const dropdown = fixtureEl.querySelector('.dropdown')
+ const item1 = fixtureEl.querySelector('#item1')
+ const item2 = fixtureEl.querySelector('#item2')
+
+ dropdown.addEventListener('shown.bs.dropdown', () => {
+ const keyDown40 = createEvent('keydown')
+ keyDown40.which = 40
+
+ triggerDropdown.dispatchEvent(keyDown40)
+ expect(document.activeElement).toEqual(item1, 'item1 is focused')
+
+ document.activeElement.dispatchEvent(keyDown40)
+ expect(document.activeElement).toEqual(item2, 'item2 is focused')
+
+ const keyDown38 = createEvent('keydown')
+ keyDown38.which = 38
+
+ document.activeElement.dispatchEvent(keyDown38)
+ expect(document.activeElement).toEqual(item1, 'item1 is focused')
+
+ done()
+ })
+
+ triggerDropdown.click()
+ })
+
+ it('should not close the dropdown if the user clicks on a text field', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <input type="text" />',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdown = fixtureEl.querySelector('.dropdown')
+ const input = fixtureEl.querySelector('input')
+
+ input.addEventListener('click', () => {
+ expect(dropdown.classList.contains('show')).toEqual(true, 'dropdown menu is shown')
+ done()
+ })
+
+ dropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdown.classList.contains('show')).toEqual(true, 'dropdown menu is shown')
+ input.dispatchEvent(createEvent('click'))
+ })
+
+ triggerDropdown.click()
+ })
+
+ it('should not close the dropdown if the user clicks on a textarea', done => {
+ fixtureEl.innerHTML = [
+ '<div class="dropdown">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <textarea></textarea>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdown = fixtureEl.querySelector('.dropdown')
+ const textarea = fixtureEl.querySelector('textarea')
+
+ textarea.addEventListener('click', () => {
+ expect(dropdown.classList.contains('show')).toEqual(true, 'dropdown menu is shown')
+ done()
+ })
+
+ dropdown.addEventListener('shown.bs.dropdown', () => {
+ expect(dropdown.classList.contains('show')).toEqual(true, 'dropdown menu is shown')
+ textarea.dispatchEvent(createEvent('click'))
+ })
+
+ 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">',
+ ' <button href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#sub1">Submenu 1</a>',
+ ' <input type="text" />',
+ ' <textarea></textarea>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const triggerDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdown = fixtureEl.querySelector('.dropdown')
+ const input = fixtureEl.querySelector('input')
+ const textarea = fixtureEl.querySelector('textarea')
+
+ // Space key
+ const keyDownSpace = createEvent('keydown')
+ keyDownSpace.which = 32
+
+ // Key up
+ const keyDownUp = createEvent('keydown')
+ keyDownSpace.which = 38
+
+ // Key down
+ const keyDown = createEvent('keydown')
+ keyDownSpace.which = 40
+
+ // Key escape
+ const keyDownEscape = createEvent('keydown')
+ keyDownEscape.which = 27
+
+ dropdown.addEventListener('shown.bs.dropdown', () => {
+ // Space key
+ input.focus()
+ input.dispatchEvent(keyDownSpace)
+
+ expect(document.activeElement).toEqual(input, 'input still focused')
+
+ textarea.focus()
+ textarea.dispatchEvent(keyDownSpace)
+
+ expect(document.activeElement).toEqual(textarea, 'textarea still focused')
+
+ // Key up
+ input.focus()
+ input.dispatchEvent(keyDownUp)
+
+ expect(document.activeElement).toEqual(input, 'input still focused')
+
+ textarea.focus()
+ textarea.dispatchEvent(keyDownUp)
+
+ expect(document.activeElement).toEqual(textarea, 'textarea still focused')
+
+ // Key down
+ input.focus()
+ input.dispatchEvent(keyDown)
+
+ expect(document.activeElement).toEqual(input, 'input still focused')
+
+ textarea.focus()
+ textarea.dispatchEvent(keyDown)
+
+ expect(document.activeElement).toEqual(textarea, 'textarea still focused')
+
+ // Key escape
+ input.focus()
+ input.dispatchEvent(keyDownEscape)
+
+ expect(dropdown.classList.contains('show')).toEqual(false, 'dropdown menu is not shown')
+ done()
+ })
+
+ triggerDropdown.click()
+ })
+
+ it('should not open dropdown if escape key was pressed on the toggle', done => {
+ fixtureEl.innerHTML = [
+ '<div class="tabs">',
+ ' <div class="dropdown">',
+ ' <button disabled href="#" class="btn dropdown-toggle" data-toggle="dropdown">Dropdown</button>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item" href="#">Secondary link</a>',
+ ' <a class="dropdown-item" href="#">Something else here</a>',
+ ' <div class="divider"/>',
+ ' <a class="dropdown-item" href="#">Another link</a>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ]
+
+ const triggerDropdown = fixtureEl.querySelector('[data-toggle="dropdown"]')
+ const dropdown = new Dropdown(triggerDropdown)
+ const button = fixtureEl.querySelector('button[data-toggle="dropdown"]')
+
+ spyOn(dropdown, 'toggle')
+
+ // Key escape
+ button.focus()
+ // Key escape
+ const keyDownEscape = createEvent('keydown')
+ keyDownEscape.which = 27
+ button.dispatchEvent(keyDownEscape)
+
+ setTimeout(() => {
+ expect(dropdown.toggle).not.toHaveBeenCalled()
+ expect(triggerDropdown.parentNode.classList.contains('show')).toEqual(false)
+ done()
+ }, 20)
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a dropdown', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.dropdown = Dropdown.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.dropdown.call(jQueryMock)
+
+ expect(Dropdown.getInstance(div)).toBeDefined()
+ })
+
+ it('should not re create a dropdown', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const dropdown = new Dropdown(div)
+
+ jQueryMock.fn.dropdown = Dropdown.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.dropdown.call(jQueryMock)
+
+ expect(Dropdown.getInstance(div)).toEqual(dropdown)
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.dropdown = Dropdown.jQueryInterface
+ jQueryMock.elements = [div]
+
+ try {
+ jQueryMock.fn.dropdown.call(jQueryMock, action)
+ } catch (error) {
+ expect(error.message).toEqual(`No method named "${action}"`)
+ }
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return dropdown instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const dropdown = new Dropdown(div)
+
+ expect(Dropdown.getInstance(div)).toEqual(dropdown)
+ })
+
+ it('should return null when there is no dropdown instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Dropdown.getInstance(div)).toEqual(null)
+ })
+ })
+})
diff --git a/js/tests/units/modal.spec.js b/js/tests/units/modal.spec.js
new file mode 100644
index 0000000000..604934785a
--- /dev/null
+++ b/js/tests/units/modal.spec.js
@@ -0,0 +1,987 @@
+import Modal from '../../src/modal'
+import EventHandler from '../../src/dom/event-handler'
+import { makeArray } from '../../src/util/index'
+
+/** Test helpers */
+import { getFixture, clearFixture, createEvent, 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()
+
+ document.body.classList.remove('modal-open')
+ document.body.removeAttribute('style')
+ document.body.removeAttribute('data-padding-right')
+ const backdropList = makeArray(document.querySelectorAll('.modal-backdrop'))
+
+ backdropList.forEach(backdrop => {
+ document.body.removeChild(backdrop)
+ })
+
+ document.body.style.paddingRight = '0px'
+ })
+
+ afterAll(() => {
+ document.head.removeChild(style)
+ document.documentElement.style.paddingRight = '0px'
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Modal.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Modal.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('toggle', () => {
+ it('should toggle a modal', done => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></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-padding-right')).toEqual(originalPadding, 'original body padding should be stored in data-padding-right')
+ modal.toggle()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ expect(document.body.getAttribute('data-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>'
+ ].join('')
+
+ const fixedEl = fixtureEl.querySelector('.fixed-top')
+ const originalPadding = 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 = parseInt(window.getComputedStyle(modalEl).paddingRight, 10)
+
+ expect(fixedEl.getAttribute('data-padding-right')).toEqual('0px', 'original fixed element padding should be stored in data-padding-right')
+ expect(currentPadding).toEqual(expectedPadding, 'fixed element padding should be adjusted while opening')
+ modal.toggle()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ const currentPadding = parseInt(window.getComputedStyle(modalEl).paddingRight, 10)
+
+ expect(fixedEl.getAttribute('data-padding-right')).toEqual(null, 'data-padding-right should be cleared after closing')
+ expect(currentPadding).toEqual(originalPadding, 'fixed element padding should be reset after closing')
+ done()
+ })
+
+ modal.toggle()
+ })
+
+ it('should adjust the inline margin of sticky elements when opening and restore when closing', done => {
+ fixtureEl.innerHTML = [
+ '<div class="sticky-top" style="margin-right: 0px;"></div>',
+ '<div class="modal"><div class="modal-dialog" /></div>'
+ ].join('')
+
+ const stickyTopEl = fixtureEl.querySelector('.sticky-top')
+ const originalMargin = 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 = parseInt(window.getComputedStyle(stickyTopEl).marginRight, 10)
+
+ expect(stickyTopEl.getAttribute('data-margin-right')).toEqual('0px', 'original sticky element margin should be stored in data-margin-right')
+ expect(currentMargin).toEqual(expectedMargin, 'sticky element margin should be adjusted while opening')
+ modal.toggle()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ const currentMargin = parseInt(window.getComputedStyle(stickyTopEl).marginRight, 10)
+
+ expect(stickyTopEl.getAttribute('data-margin-right')).toEqual(null, 'data-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>'
+ 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>'
+ 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>'
+
+ document.body.style.paddingRight = '5%'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ modal.toggle()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ expect(document.body.style.paddingRight).toEqual('5%')
+ document.body.removeAttribute('style')
+ done()
+ })
+
+ modal.toggle()
+ })
+ })
+
+ describe('show', () => {
+ it('should show a modal', done => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('show.bs.modal', e => {
+ expect(e).toBeDefined()
+ })
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modalEl.getAttribute('aria-modal')).toEqual('true')
+ expect(modalEl.getAttribute('aria-hidden')).toEqual(null)
+ expect(modalEl.style.display).toEqual('block')
+ expect(document.querySelector('.modal-backdrop')).toBeDefined()
+ done()
+ })
+
+ modal.show()
+ })
+
+ it('should show a modal without backdrop', done => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl, {
+ backdrop: false
+ })
+
+ modalEl.addEventListener('show.bs.modal', e => {
+ expect(e).toBeDefined()
+ })
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modalEl.getAttribute('aria-modal')).toEqual('true')
+ expect(modalEl.getAttribute('aria-hidden')).toEqual(null)
+ expect(modalEl.style.display).toEqual('block')
+ expect(document.querySelector('.modal-backdrop')).toBeNull()
+ done()
+ })
+
+ modal.show()
+ })
+
+ it('should show a modal and append the element', done => {
+ const modalEl = document.createElement('div')
+ const id = 'dynamicModal'
+
+ modalEl.setAttribute('id', id)
+ modalEl.classList.add('modal')
+ modalEl.innerHTML = '<div class="modal-dialog"></div>'
+
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const dynamicModal = document.getElementById(id)
+ expect(dynamicModal).toBeDefined()
+ dynamicModal.parentNode.removeChild(dynamicModal)
+ done()
+ })
+
+ modal.show()
+ })
+
+ it('should do nothing if a modal is shown', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ spyOn(EventHandler, 'trigger')
+ modal._isShown = true
+
+ modal.show()
+
+ expect(EventHandler.trigger).not.toHaveBeenCalled()
+ })
+
+ it('should do nothing if a modal is transitioning', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ spyOn(EventHandler, 'trigger')
+ modal._isTransitioning = true
+
+ modal.show()
+
+ expect(EventHandler.trigger).not.toHaveBeenCalled()
+ })
+
+ it('should not fire shown event when show is prevented', done => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('show.bs.modal', e => {
+ e.preventDefault()
+
+ const expectedDone = () => {
+ expect().nothing()
+ done()
+ }
+
+ setTimeout(expectedDone, 10)
+ })
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ throw new Error('shown event triggered')
+ })
+
+ modal.show()
+ })
+
+ it('should set is transitioning if fade class is present', done => {
+ fixtureEl.innerHTML = '<div class="modal fade"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('show.bs.modal', () => {
+ expect(modal._isTransitioning).toEqual(true)
+ })
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modal._isTransitioning).toEqual(false)
+ done()
+ })
+
+ modal.show()
+ })
+
+ it('should close modal when a click occurred on data-dismiss="modal"', done => {
+ fixtureEl.innerHTML = [
+ '<div class="modal fade">',
+ ' <div class="modal-dialog">',
+ ' <div class="modal-header">',
+ ' <button type="button" data-dismiss="modal"></button>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const btnClose = fixtureEl.querySelector('[data-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 body scroll top to 0 if .modal-dialog-scrollable', done => {
+ fixtureEl.innerHTML = [
+ '<div class="modal fade">',
+ ' <div class="modal-dialog modal-dialog-scrollable">',
+ ' <div class="modal-body"></div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modalBody = modalEl.querySelector('.modal-body')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modalBody.scrollTop).toEqual(0)
+ done()
+ })
+
+ modal.show()
+ })
+
+ it('should set .modal\'s scroll top to 0 if .modal-dialog-scrollable and modal body do not exists', done => {
+ fixtureEl.innerHTML = [
+ '<div class="modal fade">',
+ ' <div class="modal-dialog modal-dialog-scrollable">',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modalEl.scrollTop).toEqual(0)
+ done()
+ })
+
+ modal.show()
+ })
+
+ it('should not enforce focus if focus equal to false', done => {
+ fixtureEl.innerHTML = '<div class="modal fade"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl, {
+ focus: false
+ })
+
+ spyOn(modal, '_enforceFocus')
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modal._enforceFocus).not.toHaveBeenCalled()
+ done()
+ })
+
+ modal.show()
+ })
+
+ it('should add listener when escape touch is pressed', done => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ spyOn(modal, 'hide').and.callThrough()
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const keydownEscape = createEvent('keydown')
+ keydownEscape.which = 27
+
+ modalEl.dispatchEvent(keydownEscape)
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ expect(modal.hide).toHaveBeenCalled()
+ done()
+ })
+
+ modal.show()
+ })
+
+ it('should do nothing when the pressed key is not escape', done => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ spyOn(modal, 'hide')
+
+ const expectDone = () => {
+ expect(modal.hide).not.toHaveBeenCalled()
+
+ done()
+ }
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const keydownTab = createEvent('keydown')
+ keydownTab.which = 9
+
+ modalEl.dispatchEvent(keydownTab)
+ setTimeout(expectDone, 30)
+ })
+
+ modal.show()
+ })
+
+ it('should adjust dialog on resize', done => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ spyOn(modal, '_adjustDialog').and.callThrough()
+
+ const expectDone = () => {
+ expect(modal._adjustDialog).toHaveBeenCalled()
+
+ done()
+ }
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const resizeEvent = createEvent('resize')
+
+ window.dispatchEvent(resizeEvent)
+ setTimeout(expectDone, 10)
+ })
+
+ modal.show()
+ })
+
+ it('should not close modal when clicking outside of modal-content if backdrop = false', done => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl, {
+ backdrop: false
+ })
+
+ const shownCallback = () => {
+ setTimeout(() => {
+ expect(modal._isShown).toEqual(true)
+ done()
+ }, 10)
+ }
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ modalEl.click()
+ shownCallback()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ throw new Error('Should not hide a 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>'
+
+ 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()
+ })
+
+ modal.show()
+ })
+
+ it('should enforce focus', done => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const isIE11 = Boolean(window.MSInputMethodContext) && Boolean(document.documentMode)
+ 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()
+ }
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modal._enforceFocus).toHaveBeenCalled()
+
+ if (isIE11) {
+ done()
+ return
+ }
+
+ spyOn(modal._element, 'focus')
+
+ document.addEventListener('focusin', focusInListener)
+
+ const focusInEvent = createEvent('focusin', { bubbles: true })
+ Object.defineProperty(focusInEvent, 'target', {
+ value: fixtureEl
+ })
+
+ document.dispatchEvent(focusInEvent)
+ })
+
+ modal.show()
+ })
+ })
+
+ describe('hide', () => {
+ it('should hide a modal', done => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ modal.hide()
+ })
+
+ modalEl.addEventListener('hide.bs.modal', e => {
+ expect(e).toBeDefined()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ expect(modalEl.getAttribute('aria-modal')).toEqual(null)
+ expect(modalEl.getAttribute('aria-hidden')).toEqual('true')
+ expect(modalEl.style.display).toEqual('none')
+ expect(document.querySelector('.modal-backdrop')).toBeNull()
+ done()
+ })
+
+ modal.show()
+ })
+
+ it('should close modal when clicking outside of modal-content', done => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ modalEl.click()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ expect(modalEl.getAttribute('aria-modal')).toEqual(null)
+ expect(modalEl.getAttribute('aria-hidden')).toEqual('true')
+ expect(modalEl.style.display).toEqual('none')
+ expect(document.querySelector('.modal-backdrop')).toBeNull()
+ done()
+ })
+
+ modal.show()
+ })
+
+ it('should do nothing is the modal is not shown', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modal.hide()
+
+ expect().nothing()
+ })
+
+ it('should do nothing is the modal is transitioning', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modal._isTransitioning = true
+ modal.hide()
+
+ expect().nothing()
+ })
+
+ it('should not hide a modal if hide is prevented', done => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ modal.hide()
+ })
+
+ const hideCallback = () => {
+ setTimeout(() => {
+ expect(modal._isShown).toEqual(true)
+ done()
+ }, 10)
+ }
+
+ modalEl.addEventListener('hide.bs.modal', e => {
+ e.preventDefault()
+ hideCallback()
+ })
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ throw new Error('should not trigger hidden')
+ })
+
+ modal.show()
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose a modal', () => {
+ fixtureEl.innerHTML = '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ expect(Modal.getInstance(modalEl)).toEqual(modal)
+
+ spyOn(EventHandler, 'off')
+
+ modal.dispose()
+
+ expect(Modal.getInstance(modalEl)).toEqual(null)
+ expect(EventHandler.off).toHaveBeenCalledTimes(4)
+ })
+ })
+
+ describe('handleUpdate', () => {
+ it('should call adjust dialog', () => {
+ fixtureEl.innerHTML = '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>'
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+
+ spyOn(modal, '_adjustDialog')
+
+ modal.handleUpdate()
+
+ expect(modal._adjustDialog).toHaveBeenCalled()
+ })
+ })
+
+ describe('data-api', () => {
+ it('should open modal', done => {
+ fixtureEl.innerHTML = [
+ '<button type="button" data-toggle="modal" data-target="#exampleModal"></button>',
+ '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const trigger = fixtureEl.querySelector('[data-toggle="modal"]')
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modalEl.getAttribute('aria-modal')).toEqual('true')
+ expect(modalEl.getAttribute('aria-hidden')).toEqual(null)
+ expect(modalEl.style.display).toEqual('block')
+ expect(document.querySelector('.modal-backdrop')).toBeDefined()
+ done()
+ })
+
+ trigger.click()
+ })
+
+ it('should not recreate a new modal', done => {
+ fixtureEl.innerHTML = [
+ '<button type="button" data-toggle="modal" data-target="#exampleModal"></button>',
+ '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const modal = new Modal(modalEl)
+ const trigger = fixtureEl.querySelector('[data-toggle="modal"]')
+
+ spyOn(modal, 'show').and.callThrough()
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modal.show).toHaveBeenCalled()
+ done()
+ })
+
+ trigger.click()
+ })
+
+ it('should prevent default when the trigger is <a> or <area>', done => {
+ fixtureEl.innerHTML = [
+ '<a data-toggle="modal" href="#" data-target="#exampleModal"></a>',
+ '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const trigger = fixtureEl.querySelector('[data-toggle="modal"]')
+
+ spyOn(Event.prototype, 'preventDefault').and.callThrough()
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ expect(modalEl.getAttribute('aria-modal')).toEqual('true')
+ expect(modalEl.getAttribute('aria-hidden')).toEqual(null)
+ expect(modalEl.style.display).toEqual('block')
+ expect(document.querySelector('.modal-backdrop')).toBeDefined()
+ expect(Event.prototype.preventDefault).toHaveBeenCalled()
+ done()
+ })
+
+ trigger.click()
+ })
+
+ it('should focus the trigger on hide', done => {
+ fixtureEl.innerHTML = [
+ '<a data-toggle="modal" href="#" data-target="#exampleModal"></a>',
+ '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const trigger = fixtureEl.querySelector('[data-toggle="modal"]')
+
+ spyOn(trigger, 'focus')
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const modal = Modal.getInstance(modalEl)
+
+ modal.hide()
+ })
+
+ const hideListener = () => {
+ setTimeout(() => {
+ expect(trigger.focus).toHaveBeenCalled()
+ done()
+ }, 20)
+ }
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ hideListener()
+ })
+
+ trigger.click()
+ })
+
+ it('should not focus the trigger if the modal is not visible', done => {
+ fixtureEl.innerHTML = [
+ '<a data-toggle="modal" href="#" data-target="#exampleModal" style="display: none;"></a>',
+ '<div id="exampleModal" class="modal" style="display: none;"><div class="modal-dialog" /></div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const trigger = fixtureEl.querySelector('[data-toggle="modal"]')
+
+ spyOn(trigger, 'focus')
+
+ modalEl.addEventListener('shown.bs.modal', () => {
+ const modal = Modal.getInstance(modalEl)
+
+ modal.hide()
+ })
+
+ const hideListener = () => {
+ setTimeout(() => {
+ expect(trigger.focus).not.toHaveBeenCalled()
+ done()
+ }, 20)
+ }
+
+ modalEl.addEventListener('hidden.bs.modal', () => {
+ hideListener()
+ })
+
+ trigger.click()
+ })
+
+ it('should not focus the trigger if the modal is not shown', done => {
+ fixtureEl.innerHTML = [
+ '<a data-toggle="modal" href="#" data-target="#exampleModal"></a>',
+ '<div id="exampleModal" class="modal"><div class="modal-dialog" /></div>'
+ ].join('')
+
+ const modalEl = fixtureEl.querySelector('.modal')
+ const trigger = fixtureEl.querySelector('[data-toggle="modal"]')
+
+ spyOn(trigger, 'focus')
+
+ const showListener = () => {
+ setTimeout(() => {
+ expect(trigger.focus).not.toHaveBeenCalled()
+ done()
+ }, 10)
+ }
+
+ modalEl.addEventListener('show.bs.modal', e => {
+ e.preventDefault()
+ showListener()
+ })
+
+ trigger.click()
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a modal', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.modal = Modal.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.modal.call(jQueryMock)
+
+ expect(Modal.getInstance(div)).toBeDefined()
+ })
+
+ it('should not re create a modal', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const modal = new Modal(div)
+
+ jQueryMock.fn.modal = Modal.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.modal.call(jQueryMock)
+
+ expect(Modal.getInstance(div)).toEqual(modal)
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.modal = Modal.jQueryInterface
+ jQueryMock.elements = [div]
+
+ try {
+ jQueryMock.fn.modal.call(jQueryMock, action)
+ } catch (error) {
+ expect(error.message).toEqual(`No method named "${action}"`)
+ }
+ })
+
+ it('should should call show method', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const modal = new Modal(div)
+
+ jQueryMock.fn.modal = Modal.jQueryInterface
+ jQueryMock.elements = [div]
+
+ spyOn(modal, 'show')
+
+ jQueryMock.fn.modal.call(jQueryMock, 'show')
+
+ expect(modal.show).toHaveBeenCalled()
+ })
+
+ it('should should not call show method', () => {
+ fixtureEl.innerHTML = '<div class="modal" data-show="false"><div class="modal-dialog" /></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.modal = Modal.jQueryInterface
+ jQueryMock.elements = [div]
+
+ spyOn(Modal.prototype, 'show')
+
+ jQueryMock.fn.modal.call(jQueryMock)
+
+ expect(Modal.prototype.show).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return modal instance', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const modal = new Modal(div)
+
+ expect(Modal.getInstance(div)).toEqual(modal)
+ })
+
+ it('should return null when there is no modal instance', () => {
+ fixtureEl.innerHTML = '<div class="modal"><div class="modal-dialog" /></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Modal.getInstance(div)).toEqual(null)
+ })
+ })
+})
diff --git a/js/tests/units/popover.spec.js b/js/tests/units/popover.spec.js
new file mode 100644
index 0000000000..1c6cd389c9
--- /dev/null
+++ b/js/tests/units/popover.spec.js
@@ -0,0 +1,251 @@
+import Popover from '../../src/popover'
+import { makeArray } from '../../src/util/index'
+
+/** Test helpers */
+import { getFixture, clearFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Popover', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+
+ const popoverList = makeArray(document.querySelectorAll('.popover'))
+
+ popoverList.forEach(popoverEl => {
+ document.body.removeChild(popoverEl)
+ })
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Popover.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Popover.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('NAME', () => {
+ it('should return plugin name', () => {
+ expect(Popover.NAME).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Popover.DATA_KEY).toEqual('bs.popover')
+ })
+ })
+
+ describe('Event', () => {
+ it('should return plugin events', () => {
+ expect(Popover.Event).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('EVENT_KEY', () => {
+ it('should return plugin event key', () => {
+ expect(Popover.EVENT_KEY).toEqual('.bs.popover')
+ })
+ })
+
+ describe('DefaultType', () => {
+ it('should return plugin default type', () => {
+ expect(Popover.DefaultType).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('show', () => {
+ it('should show a popover', done => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl)
+
+ popoverEl.addEventListener('shown.bs.popover', () => {
+ expect(document.querySelector('.popover')).toBeDefined()
+ done()
+ })
+
+ popover.show()
+ })
+
+ it('should set title and content from functions', done => {
+ fixtureEl.innerHTML = '<a href="#">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl, {
+ title: () => 'Bootstrap',
+ content: () => 'loves writing tests (╯°□°)╯︵ ┻━┻'
+ })
+
+ popoverEl.addEventListener('shown.bs.popover', () => {
+ const popoverDisplayed = document.querySelector('.popover')
+
+ expect(popoverDisplayed).toBeDefined()
+ expect(popoverDisplayed.querySelector('.popover-header').textContent).toEqual('Bootstrap')
+ expect(popoverDisplayed.querySelector('.popover-body').textContent).toEqual('loves writing tests (╯°□°)╯︵ ┻━┻')
+ done()
+ })
+
+ popover.show()
+ })
+
+ it('should show a popover with just content', done => {
+ fixtureEl.innerHTML = '<a href="#">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl, {
+ content: 'Popover content'
+ })
+
+ popoverEl.addEventListener('shown.bs.popover', () => {
+ const popoverDisplayed = document.querySelector('.popover')
+
+ expect(popoverDisplayed).toBeDefined()
+ expect(popoverDisplayed.querySelector('.popover-body').textContent).toEqual('Popover content')
+ done()
+ })
+
+ popover.show()
+ })
+ })
+
+ describe('hide', () => {
+ it('should hide a popover', done => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl)
+
+ popoverEl.addEventListener('shown.bs.popover', () => {
+ popover.hide()
+ })
+
+ popoverEl.addEventListener('hidden.bs.popover', () => {
+ expect(document.querySelector('.popover')).toBeNull()
+ done()
+ })
+
+ popover.show()
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a popover', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+
+ jQueryMock.fn.popover = Popover.jQueryInterface
+ jQueryMock.elements = [popoverEl]
+
+ jQueryMock.fn.popover.call(jQueryMock)
+
+ expect(Popover.getInstance(popoverEl)).toBeDefined()
+ })
+
+ it('should create a popover with a config object', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+
+ jQueryMock.fn.popover = Popover.jQueryInterface
+ jQueryMock.elements = [popoverEl]
+
+ jQueryMock.fn.popover.call(jQueryMock, {
+ content: 'Popover content'
+ })
+
+ expect(Popover.getInstance(popoverEl)).toBeDefined()
+ })
+
+ it('should not re create a popover', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl)
+
+ jQueryMock.fn.popover = Popover.jQueryInterface
+ jQueryMock.elements = [popoverEl]
+
+ jQueryMock.fn.popover.call(jQueryMock)
+
+ expect(Popover.getInstance(popoverEl)).toEqual(popover)
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.popover = Popover.jQueryInterface
+ jQueryMock.elements = [popoverEl]
+
+ try {
+ jQueryMock.fn.popover.call(jQueryMock, action)
+ } catch (error) {
+ expect(error.message).toEqual(`No method named "${action}"`)
+ }
+ })
+
+ it('should should call show method', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl)
+
+ jQueryMock.fn.popover = Popover.jQueryInterface
+ jQueryMock.elements = [popoverEl]
+
+ spyOn(popover, 'show')
+
+ jQueryMock.fn.popover.call(jQueryMock, 'show')
+
+ 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-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', () => {
+ it('should return popover instance', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+ const popover = new Popover(popoverEl)
+
+ expect(Popover.getInstance(popoverEl)).toEqual(popover)
+ })
+
+ it('should return null when there is no popover instance', () => {
+ fixtureEl.innerHTML = '<a href="#" title="Popover" data-content="https://twitter.com/getbootstrap">BS twitter</a>'
+
+ const popoverEl = fixtureEl.querySelector('a')
+
+ expect(Popover.getInstance(popoverEl)).toEqual(null)
+ })
+ })
+})
diff --git a/js/tests/units/scrollspy.spec.js b/js/tests/units/scrollspy.spec.js
new file mode 100644
index 0000000000..9ac02ce99f
--- /dev/null
+++ b/js/tests/units/scrollspy.spec.js
@@ -0,0 +1,653 @@
+import ScrollSpy from '../../src/scrollspy'
+import Manipulator from '../../src/dom/manipulator'
+import EventHandler from '../../src/dom/event-handler'
+
+/** Test helpers */
+import { getFixture, clearFixture, createEvent, jQueryMock } from '../helpers/fixture'
+
+describe('ScrollSpy', () => {
+ let fixtureEl
+
+ const testElementIsActiveAfterScroll = ({ elementSelector, targetSelector, contentEl, scrollSpy, spy, cb }) => {
+ const element = fixtureEl.querySelector(elementSelector)
+ const target = fixtureEl.querySelector(targetSelector)
+
+ // add top padding to fix Chrome on Android failures
+ const paddingTop = 5
+ const scrollHeight = Math.ceil(contentEl.scrollTop + Manipulator.position(target).top) + paddingTop
+
+ function listener() {
+ expect(element.classList.contains('active')).toEqual(true)
+ contentEl.removeEventListener('scroll', listener)
+ expect(scrollSpy._process).toHaveBeenCalled()
+ spy.calls.reset()
+ cb()
+ }
+
+ contentEl.addEventListener('scroll', listener)
+ contentEl.scrollTop = scrollHeight
+ }
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(ScrollSpy.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(ScrollSpy.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('constructor', () => {
+ it('should generate an id when there is not one', () => {
+ fixtureEl.innerHTML = [
+ '<nav></nav>',
+ '<div class="content"></div>'
+ ].join('')
+
+ const navEl = fixtureEl.querySelector('nav')
+ const scrollSpy = new ScrollSpy(fixtureEl.querySelector('.content'), {
+ target: navEl
+ })
+
+ expect(scrollSpy).toBeDefined()
+ expect(navEl.getAttribute('id')).not.toEqual(null)
+ })
+
+ it('should not process element without target', () => {
+ fixtureEl.innerHTML = [
+ '<nav id="navigation" class="navbar">',
+ ' <ul class="navbar-nav">',
+ ' <li class="nav-item active"><a class="nav-link" id="one-link" href="#">One</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="two-link" href="#two">Two</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="three-link" href="#three">Three</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div id="content" style="height: 200px; overflow-y: auto;">',
+ ' <div id="two" style="height: 300px;"></div>',
+ ' <div id="three" style="height: 10px;"></div>',
+ '</div>'
+ ].join('')
+
+ const scrollSpy = new ScrollSpy(fixtureEl.querySelector('#content'), {
+ target: '#navigation'
+ })
+
+ expect(scrollSpy._targets.length).toEqual(2)
+ })
+
+ it('should only switch "active" class on current target', done => {
+ fixtureEl.innerHTML = [
+ '<div id="root" class="active" style="display: block">',
+ ' <div class="topbar">',
+ ' <div class="topbar-inner">',
+ ' <div class="container" id="ss-target">',
+ ' <ul class="nav">',
+ ' <li class="nav-item"><a href="#masthead">Overview</a></li>',
+ ' <li class="nav-item"><a href="#detail">Detail</a></li>',
+ ' </ul>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' <div id="scrollspy-example" style="height: 100px; overflow: auto;">',
+ ' <div style="height: 200px;">',
+ ' <h4 id="masthead">Overview</h4>',
+ ' <p style="height: 200px;"></p>',
+ ' </div>',
+ ' <div style="height: 200px;">',
+ ' <h4 id="detail">Detail</h4>',
+ ' <p style="height: 200px;"></p>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const scrollSpyEl = fixtureEl.querySelector('#scrollspy-example')
+ const rootEl = fixtureEl.querySelector('#root')
+ const scrollSpy = new ScrollSpy(scrollSpyEl, {
+ target: 'ss-target'
+ })
+
+ spyOn(scrollSpy, '_process').and.callThrough()
+
+ scrollSpyEl.addEventListener('scroll', () => {
+ expect(rootEl.classList.contains('active')).toEqual(true)
+ expect(scrollSpy._process).toHaveBeenCalled()
+ done()
+ })
+
+ scrollSpyEl.scrollTop = 350
+ })
+
+ it('should only switch "active" class on current target specified w element', done => {
+ fixtureEl.innerHTML = [
+ '<div id="root" class="active" style="display: block">',
+ ' <div class="topbar">',
+ ' <div class="topbar-inner">',
+ ' <div class="container" id="ss-target">',
+ ' <ul class="nav">',
+ ' <li class="nav-item"><a href="#masthead">Overview</a></li>',
+ ' <li class="nav-item"><a href="#detail">Detail</a></li>',
+ ' </ul>',
+ ' </div>',
+ ' </div>',
+ ' </div>',
+ ' <div id="scrollspy-example" style="height: 100px; overflow: auto;">',
+ ' <div style="height: 200px;">',
+ ' <h4 id="masthead">Overview</h4>',
+ ' <p style="height: 200px;"></p>',
+ ' </div>',
+ ' <div style="height: 200px;">',
+ ' <h4 id="detail">Detail</h4>',
+ ' <p style="height: 200px;"></p>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const scrollSpyEl = fixtureEl.querySelector('#scrollspy-example')
+ const rootEl = fixtureEl.querySelector('#root')
+ const scrollSpy = new ScrollSpy(scrollSpyEl, {
+ target: fixtureEl.querySelector('#ss-target')
+ })
+
+ spyOn(scrollSpy, '_process').and.callThrough()
+
+ scrollSpyEl.addEventListener('scroll', () => {
+ expect(rootEl.classList.contains('active')).toEqual(true)
+ expect(scrollSpy._process).toHaveBeenCalled()
+ done()
+ })
+
+ scrollSpyEl.scrollTop = 350
+ })
+
+ it('should correctly select middle navigation option when large offset is used', done => {
+ fixtureEl.innerHTML = [
+ '<div id="header" style="height: 500px;"></div>',
+ '<nav id="navigation" class="navbar">',
+ ' <ul class="navbar-nav">',
+ ' <li class="nav-item active"><a class="nav-link" id="one-link" href="#one">One</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="two-link" href="#two">Two</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="three-link" href="#three">Three</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div id="content" style="height: 200px; overflow-y: auto;">',
+ ' <div id="one" style="height: 500px;"></div>',
+ ' <div id="two" style="height: 300px;"></div>',
+ ' <div id="three" style="height: 10px;"></div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('#content')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ target: '#navigation',
+ offset: Manipulator.position(contentEl).top
+ })
+
+ spyOn(scrollSpy, '_process').and.callThrough()
+
+ contentEl.addEventListener('scroll', () => {
+ expect(fixtureEl.querySelector('#one-link').classList.contains('active')).toEqual(false)
+ expect(fixtureEl.querySelector('#two-link').classList.contains('active')).toEqual(true)
+ expect(fixtureEl.querySelector('#three-link').classList.contains('active')).toEqual(false)
+ expect(scrollSpy._process).toHaveBeenCalled()
+ done()
+ })
+
+ contentEl.scrollTop = 550
+ })
+
+ it('should add the active class to the correct element', done => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar">',
+ ' <ul class="nav">',
+ ' <li class="nav-item"><a class="nav-link" id="a-1" href="#div-1">div 1</a></li>',
+ ' <li class="nav-item"><a class="nav-link" id="a-2" href="#div-2">div 2</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div class="content" style="overflow: auto; height: 50px">',
+ ' <div id="div-1" style="height: 100px; padding: 0; margin: 0">div 1</div>',
+ ' <div id="div-2" style="height: 200px; padding: 0; margin: 0">div 2</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ offset: 0,
+ target: '.navbar'
+ })
+ const spy = spyOn(scrollSpy, '_process').and.callThrough()
+
+ testElementIsActiveAfterScroll({
+ elementSelector: '#a-1',
+ targetSelector: '#div-1',
+ contentEl,
+ scrollSpy,
+ spy,
+ cb: () => {
+ testElementIsActiveAfterScroll({
+ elementSelector: '#a-2',
+ targetSelector: '#div-2',
+ contentEl,
+ scrollSpy,
+ spy,
+ cb: () => done()
+ })
+ }
+ })
+ })
+
+ it('should add the active class to the correct element (nav markup)', done => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar">',
+ ' <nav class="nav">',
+ ' <a class="nav-link" id="a-1" href="#div-1">div 1</a>',
+ ' <a class="nav-link" id="a-2" href="#div-2">div 2</a>',
+ ' </nav>',
+ '</nav>',
+ '<div class="content" style="overflow: auto; height: 50px">',
+ ' <div id="div-1" style="height: 100px; padding: 0; margin: 0">div 1</div>',
+ ' <div id="div-2" style="height: 200px; padding: 0; margin: 0">div 2</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ offset: 0,
+ target: '.navbar'
+ })
+ const spy = spyOn(scrollSpy, '_process').and.callThrough()
+
+ testElementIsActiveAfterScroll({
+ elementSelector: '#a-1',
+ targetSelector: '#div-1',
+ contentEl,
+ scrollSpy,
+ spy,
+ cb: () => {
+ testElementIsActiveAfterScroll({
+ elementSelector: '#a-2',
+ targetSelector: '#div-2',
+ contentEl,
+ scrollSpy,
+ spy,
+ cb: () => done()
+ })
+ }
+ })
+ })
+
+ it('should add the active class to the correct element (list-group markup)', done => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar">',
+ ' <div class="list-group">',
+ ' <a class="list-group-item" id="a-1" href="#div-1">div 1</a>',
+ ' <a class="list-group-item" id="a-2" href="#div-2">div 2</a>',
+ ' </div>',
+ '</nav>',
+ '<div class="content" style="overflow: auto; height: 50px">',
+ ' <div id="div-1" style="height: 100px; padding: 0; margin: 0">div 1</div>',
+ ' <div id="div-2" style="height: 200px; padding: 0; margin: 0">div 2</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ offset: 0,
+ target: '.navbar'
+ })
+ const spy = spyOn(scrollSpy, '_process').and.callThrough()
+
+ testElementIsActiveAfterScroll({
+ elementSelector: '#a-1',
+ targetSelector: '#div-1',
+ contentEl,
+ scrollSpy,
+ spy,
+ cb: () => {
+ testElementIsActiveAfterScroll({
+ elementSelector: '#a-2',
+ targetSelector: '#div-2',
+ contentEl,
+ scrollSpy,
+ spy,
+ cb: () => done()
+ })
+ }
+ })
+ })
+
+ it('should clear selection if above the first section', done => {
+ fixtureEl.innerHTML = [
+ '<div id="header" style="height: 500px;"></div>',
+ '<nav id="navigation" class="navbar">',
+ ' <ul class="navbar-nav">',
+ ' <li class="nav-item"><a id="one-link" class="nav-link active" href="#one">One</a></li>',
+ ' <li class="nav-item"><a id="two-link" class="nav-link" href="#two">Two</a></li>',
+ ' <li class="nav-item"><a id="three-link" class="nav-link" href="#three">Three</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div id="content" style="height: 200px; overflow-y: auto;">',
+ ' <div id="spacer" style="height: 100px;"></div>',
+ ' <div id="one" style="height: 100px;"></div>',
+ ' <div id="two" style="height: 100px;"></div>',
+ ' <div id="three" style="height: 100px;"></div>',
+ ' <div id="spacer" style="height: 100px;"></div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('#content')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ target: '#navigation',
+ offset: Manipulator.position(contentEl).top
+ })
+ const spy = spyOn(scrollSpy, '_process').and.callThrough()
+
+ let firstTime = true
+
+ contentEl.addEventListener('scroll', () => {
+ const active = fixtureEl.querySelector('.active')
+
+ expect(spy).toHaveBeenCalled()
+ spy.calls.reset()
+ if (firstTime) {
+ expect(fixtureEl.querySelectorAll('.active').length).toEqual(1)
+ expect(active.getAttribute('id')).toEqual('two-link')
+ firstTime = false
+ contentEl.scrollTop = 0
+ } else {
+ expect(active).toBeNull()
+ done()
+ }
+ })
+
+ contentEl.scrollTop = 201
+ })
+
+ it('should not clear selection if above the first section and first section is at the top', done => {
+ fixtureEl.innerHTML = [
+ '<div id="header" style="height: 500px;"></div>',
+ '<nav id="navigation" class="navbar">',
+ ' <ul class="navbar-nav">',
+ ' <li class="nav-item"><a id="one-link" class="nav-link active" href="#one">One</a></li>',
+ ' <li class="nav-item"><a id="two-link" class="nav-link" href="#two">Two</a></li>',
+ ' <li class="nav-item"><a id="three-link" class="nav-link" href="#three">Three</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div id="content" style="height: 200px; overflow-y: auto;">',
+ ' <div id="one" style="height: 100px;"></div>',
+ ' <div id="two" style="height: 100px;"></div>',
+ ' <div id="three" style="height: 100px;"></div>',
+ ' <div id="spacer" style="height: 100px;"></div>',
+ '</div>'
+ ].join('')
+
+ const negativeHeight = -10
+ const startOfSectionTwo = 101
+ const contentEl = fixtureEl.querySelector('#content')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ target: '#navigation',
+ offset: contentEl.offsetTop
+ })
+ const spy = spyOn(scrollSpy, '_process').and.callThrough()
+
+ let firstTime = true
+
+ contentEl.addEventListener('scroll', () => {
+ const active = fixtureEl.querySelector('.active')
+
+ expect(spy).toHaveBeenCalled()
+ spy.calls.reset()
+ if (firstTime) {
+ expect(fixtureEl.querySelectorAll('.active').length).toEqual(1)
+ expect(active.getAttribute('id')).toEqual('two-link')
+ firstTime = false
+ contentEl.scrollTop = negativeHeight
+ } else {
+ expect(fixtureEl.querySelectorAll('.active').length).toEqual(1)
+ expect(active.getAttribute('id')).toEqual('one-link')
+ done()
+ }
+ })
+
+ contentEl.scrollTop = startOfSectionTwo
+ })
+
+ it('should correctly select navigation element on backward scrolling when each target section height is 100%', done => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar">',
+ ' <ul class="nav">',
+ ' <li class="nav-item"><a id="li-100-1" class="nav-link" href="#div-100-1">div 1</a></li>',
+ ' <li class="nav-item"><a id="li-100-2" class="nav-link" href="#div-100-2">div 2</a></li>',
+ ' <li class="nav-item"><a id="li-100-3" class="nav-link" href="#div-100-3">div 3</a></li>',
+ ' <li class="nav-item"><a id="li-100-4" class="nav-link" href="#div-100-4">div 4</a></li>',
+ ' <li class="nav-item"><a id="li-100-5" class="nav-link" href="#div-100-5">div 5</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div class="content" style="position: relative; overflow: auto; height: 100px">',
+ ' <div id="div-100-1" style="position: relative; height: 100%; padding: 0; margin: 0">div 1</div>',
+ ' <div id="div-100-2" style="position: relative; height: 100%; padding: 0; margin: 0">div 2</div>',
+ ' <div id="div-100-3" style="position: relative; height: 100%; padding: 0; margin: 0">div 3</div>',
+ ' <div id="div-100-4" style="position: relative; height: 100%; padding: 0; margin: 0">div 4</div>',
+ ' <div id="div-100-5" style="position: relative; height: 100%; padding: 0; margin: 0">div 5</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ offset: 0,
+ target: '.navbar'
+ })
+ const spy = spyOn(scrollSpy, '_process').and.callThrough()
+
+ testElementIsActiveAfterScroll({
+ elementSelector: '#li-100-5',
+ targetSelector: '#div-100-5',
+ scrollSpy,
+ spy,
+ contentEl,
+ cb() {
+ contentEl.scrollTop = 0
+ testElementIsActiveAfterScroll({
+ elementSelector: '#li-100-4',
+ targetSelector: '#div-100-4',
+ scrollSpy,
+ spy,
+ contentEl,
+ cb() {
+ contentEl.scrollTop = 0
+ testElementIsActiveAfterScroll({
+ elementSelector: '#li-100-3',
+ targetSelector: '#div-100-3',
+ scrollSpy,
+ spy,
+ contentEl,
+ cb() {
+ contentEl.scrollTop = 0
+ testElementIsActiveAfterScroll({
+ elementSelector: '#li-100-2',
+ targetSelector: '#div-100-2',
+ scrollSpy,
+ spy,
+ contentEl,
+ cb() {
+ contentEl.scrollTop = 0
+ testElementIsActiveAfterScroll({
+ elementSelector: '#li-100-1',
+ targetSelector: '#div-100-1',
+ scrollSpy,
+ spy,
+ contentEl,
+ cb: done
+ })
+ }
+ })
+ }
+ })
+ }
+ })
+ }
+ })
+ })
+
+ it('should allow passed in option offset method: offset', () => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar">',
+ ' <ul class="nav">',
+ ' <li class="nav-item"><a id="li-jsm-1" class="nav-link" href="#div-jsm-1">div 1</a></li>',
+ ' <li class="nav-item"><a id="li-jsm-2" class="nav-link" href="#div-jsm-2">div 2</a></li>',
+ ' <li class="nav-item"><a id="li-jsm-3" class="nav-link" href="#div-jsm-3">div 3</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div class="content" style="position: relative; overflow: auto; height: 100px">',
+ ' <div id="div-jsm-1" style="position: relative; height: 200px; padding: 0; margin: 0">div 1</div>',
+ ' <div id="div-jsm-2" style="position: relative; height: 150px; padding: 0; margin: 0">div 2</div>',
+ ' <div id="div-jsm-3" style="position: relative; height: 250px; padding: 0; margin: 0">div 3</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const targetEl = fixtureEl.querySelector('#div-jsm-2')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ target: '.navbar',
+ offset: 0,
+ method: 'offset'
+ })
+
+ expect(scrollSpy._offsets[1]).toEqual(Manipulator.offset(targetEl).top)
+ expect(scrollSpy._offsets[1]).not.toEqual(Manipulator.position(targetEl).top)
+ })
+
+ it('should allow passed in option offset method: position', () => {
+ fixtureEl.innerHTML = [
+ '<nav class="navbar">',
+ ' <ul class="nav">',
+ ' <li class="nav-item"><a id="li-jsm-1" class="nav-link" href="#div-jsm-1">div 1</a></li>',
+ ' <li class="nav-item"><a id="li-jsm-2" class="nav-link" href="#div-jsm-2">div 2</a></li>',
+ ' <li class="nav-item"><a id="li-jsm-3" class="nav-link" href="#div-jsm-3">div 3</a></li>',
+ ' </ul>',
+ '</nav>',
+ '<div class="content" style="position: relative; overflow: auto; height: 100px">',
+ ' <div id="div-jsm-1" style="position: relative; height: 200px; padding: 0; margin: 0">div 1</div>',
+ ' <div id="div-jsm-2" style="position: relative; height: 150px; padding: 0; margin: 0">div 2</div>',
+ ' <div id="div-jsm-3" style="position: relative; height: 250px; padding: 0; margin: 0">div 3</div>',
+ '</div>'
+ ].join('')
+
+ const contentEl = fixtureEl.querySelector('.content')
+ const targetEl = fixtureEl.querySelector('#div-jsm-2')
+ const scrollSpy = new ScrollSpy(contentEl, {
+ target: '.navbar',
+ offset: 0,
+ method: 'position'
+ })
+
+ expect(scrollSpy._offsets[1]).not.toEqual(Manipulator.offset(targetEl).top)
+ expect(scrollSpy._offsets[1]).toEqual(Manipulator.position(targetEl).top)
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose a scrollspy', () => {
+ spyOn(EventHandler, 'off')
+ fixtureEl.innerHTML = '<div style="display: none;"></div>'
+
+ const divEl = fixtureEl.querySelector('div')
+ const scrollSpy = new ScrollSpy(divEl)
+
+ scrollSpy.dispose()
+ expect(EventHandler.off).toHaveBeenCalledWith(divEl, '.bs.scrollspy')
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a scrollspy', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.scrollspy.call(jQueryMock)
+
+ expect(ScrollSpy.getInstance(div)).toBeDefined()
+ })
+
+ it('should not re create a scrollspy', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const scrollSpy = new ScrollSpy(div)
+
+ jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.scrollspy.call(jQueryMock)
+
+ expect(ScrollSpy.getInstance(div)).toEqual(scrollSpy)
+ })
+
+ it('should call a scrollspy method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const scrollSpy = new ScrollSpy(div)
+
+ spyOn(scrollSpy, 'refresh')
+
+ jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.scrollspy.call(jQueryMock, 'refresh')
+
+ expect(ScrollSpy.getInstance(div)).toEqual(scrollSpy)
+ expect(scrollSpy.refresh).toHaveBeenCalled()
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.scrollspy = ScrollSpy.jQueryInterface
+ jQueryMock.elements = [div]
+
+ try {
+ jQueryMock.fn.scrollspy.call(jQueryMock, action)
+ } catch (error) {
+ expect(error.message).toEqual(`No method named "${action}"`)
+ }
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return null if there is no instance', () => {
+ expect(ScrollSpy.getInstance(fixtureEl)).toEqual(null)
+ })
+ })
+
+ describe('event handler', () => {
+ it('should create scrollspy on window load event', () => {
+ fixtureEl.innerHTML = '<div data-spy="scroll"></div>'
+
+ const scrollSpyEl = fixtureEl.querySelector('div')
+
+ window.dispatchEvent(createEvent('load'))
+
+ expect(ScrollSpy.getInstance(scrollSpyEl)).not.toBeNull()
+ })
+ })
+})
diff --git a/js/tests/units/tab.spec.js b/js/tests/units/tab.spec.js
new file mode 100644
index 0000000000..3e45f4d03e
--- /dev/null
+++ b/js/tests/units/tab.spec.js
@@ -0,0 +1,593 @@
+import Tab from '../../src/tab'
+
+/** Test helpers */
+import { getFixture, clearFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Tab', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Tab.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('show', () => {
+ it('should activate element by tab id', done => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav">',
+ ' <li><a href="#home" role="tab">Home</a></li>',
+ ' <li><a id="triggerProfile" role="tab" href="#profile">Profile</a></li>',
+ '</ul>',
+ '<ul><li id="home"/><li id="profile"/></ul>'
+ ].join('')
+
+ const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')
+ const tab = new Tab(profileTriggerEl)
+
+ profileTriggerEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelector('#profile').classList.contains('active')).toEqual(true)
+ expect(profileTriggerEl.getAttribute('aria-selected')).toEqual('true')
+ done()
+ })
+
+ tab.show()
+ })
+
+ it('should activate element by tab id in ordered list', done => {
+ fixtureEl.innerHTML = [
+ '<ol class="nav nav-pills">',
+ ' <li><a href="#home">Home</a></li>',
+ ' <li><a id="triggerProfile" href="#profile">Profile</a></li>',
+ '</ol>',
+ '<ol><li id="home"/><li id="profile"/></ol>'
+ ].join('')
+
+ const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')
+ const tab = new Tab(profileTriggerEl)
+
+ profileTriggerEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelector('#profile').classList.contains('active')).toEqual(true)
+ done()
+ })
+
+ tab.show()
+ })
+
+ it('should activate element by tab id in nav list', done => {
+ fixtureEl.innerHTML = [
+ '<nav class="nav">',
+ ' <a href="#home">Home</a>',
+ ' <a id="triggerProfile" href="#profile">Profile</a>',
+ '</nav>',
+ '<nav><div id="home"></div><div id="profile"></div></nav>'
+ ].join('')
+
+ const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')
+ const tab = new Tab(profileTriggerEl)
+
+ profileTriggerEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelector('#profile').classList.contains('active')).toEqual(true)
+ done()
+ })
+
+ tab.show()
+ })
+
+ it('should activate element by tab id in list group', done => {
+ fixtureEl.innerHTML = [
+ '<div class="list-group">',
+ ' <a href="#home">Home</a>',
+ ' <a id="triggerProfile" href="#profile">Profile</a>',
+ '</div>',
+ '<nav><div id="home"></div><div id="profile"></div></nav>'
+ ].join('')
+
+ const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')
+ const tab = new Tab(profileTriggerEl)
+
+ profileTriggerEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelector('#profile').classList.contains('active')).toEqual(true)
+ done()
+ })
+
+ tab.show()
+ })
+
+ it('should not fire shown when show is prevented', done => {
+ fixtureEl.innerHTML = '<div class="nav"></div>'
+
+ const navEl = fixtureEl.querySelector('div')
+ const tab = new Tab(navEl)
+ const expectDone = () => {
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ }, 30)
+ }
+
+ navEl.addEventListener('show.bs.tab', ev => {
+ ev.preventDefault()
+ expectDone()
+ })
+
+ navEl.addEventListener('shown.bs.tab', () => {
+ throw new Error('should not trigger shown event')
+ })
+
+ tab.show()
+ })
+
+ it('should not fire shown when tab is already active', done => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item"><a href="#home" class="nav-link active" role="tab">Home</a></li>',
+ ' <li class="nav-item"><a href="#profile" class="nav-link" 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 triggerActive = fixtureEl.querySelector('a.active')
+ const tab = new Tab(triggerActive)
+
+ triggerActive.addEventListener('shown.bs.tab', () => {
+ throw new Error('should not trigger shown event')
+ })
+
+ tab.show()
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ }, 30)
+ })
+
+ it('should not fire shown when tab is disabled', done => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item"><a href="#home" class="nav-link active" role="tab">Home</a></li>',
+ ' <li class="nav-item"><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')
+ 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">',
+ ' <li class="nav-item"><a href="#home" class="nav-link active" role="tab">Home</a></li>',
+ ' <li class="nav-item"><a id="triggerProfile" href="#profile" class="nav-link" 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 secondTabTrigger = fixtureEl.querySelector('#triggerProfile')
+ const secondTab = new Tab(secondTabTrigger)
+
+ secondTabTrigger.addEventListener('show.bs.tab', ev => {
+ expect(ev.relatedTarget.hash).toEqual('#home')
+ })
+
+ secondTabTrigger.addEventListener('shown.bs.tab', ev => {
+ expect(ev.relatedTarget.hash).toEqual('#home')
+ expect(secondTabTrigger.getAttribute('aria-selected')).toEqual('true')
+ expect(fixtureEl.querySelector('a:not(.active)').getAttribute('aria-selected')).toEqual('false')
+ done()
+ })
+
+ secondTab.show()
+ })
+
+ it('should fire hide and hidden events', done => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav">',
+ ' <li><a href="#home">Home</a></li>',
+ ' <li><a href="#profile">Profile</a></li>',
+ '</ul>'
+ ].join('')
+
+ const triggerList = fixtureEl.querySelectorAll('a')
+ const firstTab = new Tab(triggerList[0])
+ const secondTab = new Tab(triggerList[1])
+
+ let hideCalled = false
+ triggerList[0].addEventListener('shown.bs.tab', () => {
+ secondTab.show()
+ })
+
+ triggerList[0].addEventListener('hide.bs.tab', ev => {
+ hideCalled = true
+ expect(ev.relatedTarget.hash).toEqual('#profile')
+ })
+
+ triggerList[0].addEventListener('hidden.bs.tab', ev => {
+ expect(hideCalled).toEqual(true)
+ expect(ev.relatedTarget.hash).toEqual('#profile')
+ done()
+ })
+
+ firstTab.show()
+ })
+
+ it('should not fire hidden when hide is prevented', done => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav">',
+ ' <li><a href="#home">Home</a></li>',
+ ' <li><a href="#profile">Profile</a></li>',
+ '</ul>'
+ ].join('')
+
+ const triggerList = fixtureEl.querySelectorAll('a')
+ const firstTab = new Tab(triggerList[0])
+ const secondTab = new Tab(triggerList[1])
+ const expectDone = () => {
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ }, 30)
+ }
+
+ triggerList[0].addEventListener('shown.bs.tab', () => {
+ secondTab.show()
+ })
+
+ triggerList[0].addEventListener('hide.bs.tab', ev => {
+ ev.preventDefault()
+ expectDone()
+ })
+
+ triggerList[0].addEventListener('hidden.bs.tab', () => {
+ throw new Error('should not trigger hidden')
+ })
+
+ firstTab.show()
+ })
+
+ it('should handle removed tabs', done => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item">',
+ ' <a class="nav-link nav-tab" href="#profile" role="tab" data-toggle="tab">',
+ ' <button class="close"><span aria-hidden="true">&times;</span></button>',
+ ' </a>',
+ ' </li>',
+ ' <li class="nav-item">',
+ ' <a id="secondNav" class="nav-link nav-tab" href="#buzz" role="tab" data-toggle="tab">',
+ ' <button class="close"><span aria-hidden="true">&times;</span></button>',
+ ' </a>',
+ ' </li>',
+ ' <li class="nav-item">',
+ ' <a class="nav-link nav-tab" href="#references" role="tab" data-toggle="tab">',
+ ' <button id="btnClose" class="close"><span aria-hidden="true">&times;</span></button>',
+ ' </a>',
+ ' </li>',
+ '</ul>',
+ '<div class="tab-content">',
+ ' <div role="tabpanel" class="tab-pane fade show active" id="profile">test 1</div>',
+ ' <div role="tabpanel" class="tab-pane fade" id="buzz">test 2</div>',
+ ' <div role="tabpanel" class="tab-pane fade" id="references">test 3</div>',
+ '</div>'
+ ].join('')
+
+ const secondNavEl = fixtureEl.querySelector('#secondNav')
+ const btnCloseEl = fixtureEl.querySelector('#btnClose')
+ const secondNavTab = new Tab(secondNavEl)
+
+ secondNavEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelectorAll('.nav-tab').length).toEqual(2)
+ done()
+ })
+
+ btnCloseEl.addEventListener('click', () => {
+ const linkEl = btnCloseEl.parentNode
+ const liEl = linkEl.parentNode
+ const tabId = linkEl.getAttribute('href')
+ const tabIdEl = fixtureEl.querySelector(tabId)
+
+ liEl.parentNode.removeChild(liEl)
+ tabIdEl.parentNode.removeChild(tabIdEl)
+ secondNavTab.show()
+ })
+
+ btnCloseEl.click()
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose a tab', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const el = fixtureEl.querySelector('div')
+ const tab = new Tab(fixtureEl.querySelector('div'))
+
+ expect(Tab.getInstance(el)).not.toBeNull()
+
+ tab.dispose()
+
+ expect(Tab.getInstance(el)).toBeNull()
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a tab', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.tab = Tab.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tab.call(jQueryMock)
+
+ expect(Tab.getInstance(div)).toBeDefined()
+ })
+
+ it('should not re create a tab', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const tab = new Tab(div)
+
+ jQueryMock.fn.tab = Tab.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tab.call(jQueryMock)
+
+ expect(Tab.getInstance(div)).toEqual(tab)
+ })
+
+ it('should call a tab method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const tab = new Tab(div)
+
+ spyOn(tab, 'show')
+
+ jQueryMock.fn.tab = Tab.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tab.call(jQueryMock, 'show')
+
+ expect(Tab.getInstance(div)).toEqual(tab)
+ expect(tab.show).toHaveBeenCalled()
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.tab = Tab.jQueryInterface
+ jQueryMock.elements = [div]
+
+ try {
+ jQueryMock.fn.tab.call(jQueryMock, action)
+ } catch (error) {
+ expect(error.message).toEqual(`No method named "${action}"`)
+ }
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return null if there is no instance', () => {
+ expect(Tab.getInstance(fixtureEl)).toEqual(null)
+ })
+
+ it('should return this instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const divEl = fixtureEl.querySelector('div')
+ const tab = new Tab(divEl)
+
+ expect(Tab.getInstance(divEl)).toEqual(tab)
+ })
+ })
+
+ describe('data-api', () => {
+ it('should create dynamically a tab', done => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item"><a href="#home" class="nav-link active" role="tab">Home</a></li>',
+ ' <li class="nav-item"><a id="triggerProfile" data-toggle="tab" href="#profile" class="nav-link" 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 secondTabTrigger = fixtureEl.querySelector('#triggerProfile')
+
+ secondTabTrigger.addEventListener('shown.bs.tab', () => {
+ expect(secondTabTrigger.classList.contains('active')).toEqual(true)
+ expect(fixtureEl.querySelector('#profile').classList.contains('active')).toEqual(true)
+ done()
+ })
+
+ secondTabTrigger.click()
+ })
+
+ it('selected tab should deactivate previous selected link in dropdown', () => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs">',
+ ' <li class="nav-item"><a class="nav-link" href="#home" data-toggle="tab">Home</a></li>',
+ ' <li class="nav-item"><a class="nav-link" href="#profile" data-toggle="tab">Profile</a></li>',
+ ' <li class="nav-item dropdown">',
+ ' <a class="nav-link dropdown-toggle active" data-toggle="dropdown" href="#">Dropdown</>',
+ ' <div class="dropdown-menu">',
+ ' <a class="dropdown-item active" href="#dropdown1" id="dropdown1-tab" data-toggle="tab">@fat</a>',
+ ' <a class="dropdown-item" href="#dropdown2" id="dropdown2-tab" data-toggle="tab">@mdo</a>',
+ ' </div>',
+ ' </li>',
+ '</ul>'
+ ].join('')
+
+ const firstLiLinkEl = fixtureEl.querySelector('li:first-child a')
+
+ firstLiLinkEl.click()
+ expect(firstLiLinkEl.classList.contains('active')).toEqual(true)
+ expect(fixtureEl.querySelector('li:last-child a').classList.contains('active')).toEqual(false)
+ expect(fixtureEl.querySelector('li:last-child .dropdown-menu a:first-child').classList.contains('active')).toEqual(false)
+ })
+
+ it('should handle nested tabs', done => {
+ fixtureEl.innerHTML = [
+ '<nav class="nav nav-tabs" role="tablist">',
+ ' <a id="tab1" href="#x-tab1" class="nav-item nav-link" data-toggle="tab" role="tab" aria-controls="x-tab1">Tab 1</a>',
+ ' <a href="#x-tab2" class="nav-item nav-link active" data-toggle="tab" role="tab" aria-controls="x-tab2" aria-selected="true">Tab 2</a>',
+ ' <a href="#x-tab3" class="nav-item nav-link" data-toggle="tab" role="tab" aria-controls="x-tab3">Tab 3</a>',
+ '</nav>',
+ '<div class="tab-content">',
+ ' <div class="tab-pane" id="x-tab1" role="tabpanel">',
+ ' <nav class="nav nav-tabs" role="tablist">',
+ ' <a href="#nested-tab1" class="nav-item nav-link active" data-toggle="tab" role="tab" aria-controls="x-tab1" aria-selected="true">Nested Tab 1</a>',
+ ' <a id="tabNested2" href="#nested-tab2" class="nav-item nav-link" data-toggle="tab" role="tab" aria-controls="x-profile">Nested Tab2</a>',
+ ' </nav>',
+ ' <div class="tab-content">',
+ ' <div class="tab-pane active" id="nested-tab1" role="tabpanel">Nested Tab1 Content</div>',
+ ' <div class="tab-pane" id="nested-tab2" role="tabpanel">Nested Tab2 Content</div>',
+ ' </div>',
+ ' </div>',
+ ' <div class="tab-pane active" id="x-tab2" role="tabpanel">Tab2 Content</div>',
+ ' <div class="tab-pane" id="x-tab3" role="tabpanel">Tab3 Content</div>',
+ '</div>'
+ ].join('')
+
+ const tab1El = fixtureEl.querySelector('#tab1')
+ const tabNested2El = fixtureEl.querySelector('#tabNested2')
+ const xTab1El = fixtureEl.querySelector('#x-tab1')
+
+ tabNested2El.addEventListener('shown.bs.tab', () => {
+ expect(xTab1El.classList.contains('active')).toEqual(true)
+ done()
+ })
+
+ tab1El.addEventListener('shown.bs.tab', () => {
+ expect(xTab1El.classList.contains('active')).toEqual(true)
+ tabNested2El.click()
+ })
+
+ tab1El.click()
+ })
+
+ it('should not remove fade class if no active pane is present', done => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item"><a id="tab-home" href="#home" class="nav-link" data-toggle="tab" role="tab">Home</a></li>',
+ ' <li class="nav-item"><a id="tab-profile" href="#profile" class="nav-link" data-toggle="tab" role="tab">Profile</a></li>',
+ '</ul>',
+ '<div class="tab-content">',
+ ' <div class="tab-pane fade" id="home" role="tabpanel"></div>',
+ ' <div class="tab-pane fade" id="profile" role="tabpanel"></div>',
+ '</div>'
+ ].join('')
+
+ const triggerTabProfileEl = fixtureEl.querySelector('#tab-profile')
+ const triggerTabHomeEl = fixtureEl.querySelector('#tab-home')
+ const tabProfileEl = fixtureEl.querySelector('#profile')
+ const tabHomeEl = fixtureEl.querySelector('#home')
+
+ triggerTabProfileEl.addEventListener('shown.bs.tab', () => {
+ expect(tabProfileEl.classList.contains('fade')).toEqual(true)
+ expect(tabProfileEl.classList.contains('show')).toEqual(true)
+
+ triggerTabHomeEl.addEventListener('shown.bs.tab', () => {
+ expect(tabProfileEl.classList.contains('fade')).toEqual(true)
+ expect(tabProfileEl.classList.contains('show')).toEqual(false)
+
+ expect(tabHomeEl.classList.contains('fade')).toEqual(true)
+ expect(tabHomeEl.classList.contains('show')).toEqual(true)
+
+ done()
+ })
+
+ triggerTabHomeEl.click()
+ })
+
+ triggerTabProfileEl.click()
+ })
+
+ it('should not add show class to tab panes if there is no `.fade` class', done => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item">',
+ ' <a class="nav-link nav-tab" href="#home" role="tab" data-toggle="tab">Home</a>',
+ ' </li>',
+ ' <li class="nav-item">',
+ ' <a id="secondNav" class="nav-link nav-tab" href="#profile" role="tab" data-toggle="tab">Profile</a>',
+ ' </li>',
+ '</ul>',
+ '<div class="tab-content">',
+ ' <div role="tabpanel" class="tab-pane" id="home">test 1</div>',
+ ' <div role="tabpanel" class="tab-pane" id="profile">test 2</div>',
+ '</div>'
+ ].join('')
+
+ const secondNavEl = fixtureEl.querySelector('#secondNav')
+
+ secondNavEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelectorAll('.show').length).toEqual(0)
+ done()
+ })
+
+ secondNavEl.click()
+ })
+
+ it('should add show class to tab panes if there is a `.fade` class', done => {
+ fixtureEl.innerHTML = [
+ '<ul class="nav nav-tabs" role="tablist">',
+ ' <li class="nav-item">',
+ ' <a class="nav-link nav-tab" href="#home" role="tab" data-toggle="tab">Home</a>',
+ ' </li>',
+ ' <li class="nav-item">',
+ ' <a id="secondNav" class="nav-link nav-tab" href="#profile" role="tab" data-toggle="tab">Profile</a>',
+ ' </li>',
+ '</ul>',
+ '<div class="tab-content">',
+ ' <div role="tabpanel" class="tab-pane fade" id="home">test 1</div>',
+ ' <div role="tabpanel" class="tab-pane fade" id="profile">test 2</div>',
+ '</div>'
+ ].join('')
+
+ const secondNavEl = fixtureEl.querySelector('#secondNav')
+
+ secondNavEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelectorAll('.show').length).toEqual(1)
+ done()
+ })
+
+ secondNavEl.click()
+ })
+ })
+})
diff --git a/js/tests/units/toast.spec.js b/js/tests/units/toast.spec.js
new file mode 100644
index 0000000000..ee623c8ccc
--- /dev/null
+++ b/js/tests/units/toast.spec.js
@@ -0,0 +1,374 @@
+import Toast from '../../src/toast'
+
+/** Test helpers */
+import { getFixture, clearFixture, jQueryMock } from '../helpers/fixture'
+
+describe('Toast', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Toast.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('constructor', () => {
+ it('should allow to config in js', done => {
+ fixtureEl.innerHTML = [
+ '<div class="toast">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl, {
+ delay: 1
+ })
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ expect(toastEl.classList.contains('show')).toEqual(true)
+ done()
+ })
+
+ toast.show()
+ })
+
+ it('should close toast when close element with data-dismiss attribute is set', done => {
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-delay="1" data-autohide="false" data-animation="false">',
+ ' <button type="button" class="ml-2 mb-1 close" data-dismiss="toast">',
+ ' close',
+ ' </button>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl)
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ expect(toastEl.classList.contains('show')).toEqual(true)
+
+ const button = toastEl.querySelector('.close')
+
+ button.click()
+ })
+
+ toastEl.addEventListener('hidden.bs.toast', () => {
+ expect(toastEl.classList.contains('show')).toEqual(false)
+ done()
+ })
+
+ toast.show()
+ })
+ })
+
+ describe('Default', () => {
+ it('should expose default setting to allow to override them', () => {
+ const defaultDelay = 1000
+
+ Toast.Default.delay = defaultDelay
+
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-autohide="false" data-animation="false">',
+ ' <button type="button" class="ml-2 mb-1 close" data-dismiss="toast">',
+ ' close',
+ ' </button>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl)
+
+ expect(toast._config.delay).toEqual(defaultDelay)
+ })
+ })
+
+ describe('DefaultType', () => {
+ it('should expose default setting types for read', () => {
+ expect(Toast.DefaultType).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('show', () => {
+ it('should auto hide', done => {
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-delay="1">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ toastEl.addEventListener('hidden.bs.toast', () => {
+ expect(toastEl.classList.contains('show')).toEqual(false)
+ done()
+ })
+
+ toast.show()
+ })
+
+ it('should not add fade class', done => {
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-delay="1" data-animation="false">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ expect(toastEl.classList.contains('fade')).toEqual(false)
+ done()
+ })
+
+ toast.show()
+ })
+
+ it('should not trigger shown if show is prevented', done => {
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-delay="1" data-animation="false">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ const assertDone = () => {
+ setTimeout(() => {
+ expect(toastEl.classList.contains('show')).toEqual(false)
+ done()
+ }, 20)
+ }
+
+ toastEl.addEventListener('show.bs.toast', event => {
+ event.preventDefault()
+ assertDone()
+ })
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ throw new Error('shown event should not be triggered if show is prevented')
+ })
+
+ toast.show()
+ })
+ })
+
+ describe('hide', () => {
+ it('should allow to hide toast manually', done => {
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-delay="1" data-autohide="false">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ ' </div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ toast.hide()
+ })
+
+ toastEl.addEventListener('hidden.bs.toast', () => {
+ expect(toastEl.classList.contains('show')).toEqual(false)
+ done()
+ })
+
+ toast.show()
+ })
+
+ it('should do nothing when we call hide on a non shown toast', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl)
+
+ spyOn(toastEl.classList, 'contains')
+
+ toast.hide()
+
+ expect(toastEl.classList.contains).toHaveBeenCalled()
+ })
+
+ it('should not trigger hidden if hide is prevented', done => {
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-delay="1" data-animation="false">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ const assertDone = () => {
+ setTimeout(() => {
+ expect(toastEl.classList.contains('show')).toEqual(true)
+ done()
+ }, 20)
+ }
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ toast.hide()
+ })
+
+ toastEl.addEventListener('hide.bs.toast', event => {
+ event.preventDefault()
+ assertDone()
+ })
+
+ toastEl.addEventListener('hidden.bs.toast', () => {
+ throw new Error('hidden event should not be triggered if hide is prevented')
+ })
+
+ toast.show()
+ })
+ })
+
+ describe('dispose', () => {
+ it('should allow to destroy toast', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl)
+
+ expect(Toast.getInstance(toastEl)).toBeDefined()
+
+ toast.dispose()
+
+ expect(Toast.getInstance(toastEl)).toBeNull()
+ })
+
+ it('should allow to destroy toast and hide it before that', done => {
+ fixtureEl.innerHTML = [
+ '<div class="toast" data-delay="0" data-autohide="false">',
+ ' <div class="toast-body">',
+ ' a simple toast',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl)
+ const expected = () => {
+ expect(toastEl.classList.contains('show')).toEqual(true)
+ expect(Toast.getInstance(toastEl)).toBeDefined()
+
+ toast.dispose()
+
+ expect(Toast.getInstance(toastEl)).toBeNull()
+ expect(toastEl.classList.contains('show')).toEqual(false)
+
+ done()
+ }
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ setTimeout(expected, 1)
+ })
+
+ toast.show()
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a toast', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.toast = Toast.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.toast.call(jQueryMock)
+
+ expect(Toast.getInstance(div)).toBeDefined()
+ })
+
+ it('should not re create a toast', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const toast = new Toast(div)
+
+ jQueryMock.fn.toast = Toast.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.toast.call(jQueryMock)
+
+ expect(Toast.getInstance(div)).toEqual(toast)
+ })
+
+ it('should call a toast method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const toast = new Toast(div)
+
+ spyOn(toast, 'show')
+
+ jQueryMock.fn.toast = Toast.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.toast.call(jQueryMock, 'show')
+
+ expect(Toast.getInstance(div)).toEqual(toast)
+ expect(toast.show).toHaveBeenCalled()
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.toast = Toast.jQueryInterface
+ jQueryMock.elements = [div]
+
+ try {
+ jQueryMock.fn.toast.call(jQueryMock, action)
+ } catch (error) {
+ expect(error.message).toEqual(`No method named "${action}"`)
+ }
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return collapse instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const toast = new Toast(div)
+
+ expect(Toast.getInstance(div)).toEqual(toast)
+ })
+
+ it('should return null when there is no collapse instance', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Toast.getInstance(div)).toEqual(null)
+ })
+ })
+})
diff --git a/js/tests/units/tooltip.spec.js b/js/tests/units/tooltip.spec.js
new file mode 100644
index 0000000000..338638a2d7
--- /dev/null
+++ b/js/tests/units/tooltip.spec.js
@@ -0,0 +1,1020 @@
+import Tooltip from '../../src/tooltip'
+import EventHandler from '../../src/dom/event-handler'
+import { makeArray, noop } from '../../src/util/index'
+
+/** Test helpers */
+import { getFixture, clearFixture, jQueryMock, createEvent } from '../helpers/fixture'
+
+describe('Tooltip', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+
+ const tooltipList = makeArray(document.querySelectorAll('.tooltip'))
+
+ tooltipList.forEach(tooltipEl => {
+ document.body.removeChild(tooltipEl)
+ })
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Tooltip.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(Tooltip.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('NAME', () => {
+ it('should return plugin name', () => {
+ expect(Tooltip.NAME).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Tooltip.DATA_KEY).toEqual('bs.tooltip')
+ })
+ })
+
+ describe('Event', () => {
+ it('should return plugin events', () => {
+ expect(Tooltip.Event).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('EVENT_KEY', () => {
+ it('should return plugin event key', () => {
+ expect(Tooltip.EVENT_KEY).toEqual('.bs.tooltip')
+ })
+ })
+
+ describe('DefaultType', () => {
+ it('should return plugin default type', () => {
+ expect(Tooltip.DefaultType).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('constructor', () => {
+ it('should not take care of disallowed data attributes', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" data-sanitize="false" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip.config.sanitize).toEqual(true)
+ })
+
+ it('should convert title and content to string if numbers', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ title: 1,
+ content: 7
+ })
+
+ expect(tooltip.config.title).toEqual('1')
+ expect(tooltip.config.content).toEqual('7')
+ })
+
+ it('should enable selector delegation', done => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const containerEl = fixtureEl.querySelector('div')
+ const tooltipContainer = new Tooltip(containerEl, {
+ selector: 'a[rel="tooltip"]',
+ trigger: 'click'
+ })
+
+ containerEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipInContainerEl = containerEl.querySelector('a')
+
+ tooltipInContainerEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ tooltipContainer.dispose()
+ done()
+ })
+
+ tooltipInContainerEl.click()
+ })
+
+ it('should allow to pass config to popper.js with `popperConfig`', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ popperConfig: {
+ placement: 'left'
+ }
+ })
+
+ const popperConfig = tooltip._getPopperConfig('top')
+
+ expect(popperConfig.placement).toEqual('left')
+ })
+ })
+
+ describe('enable', () => {
+ it('should enable a tooltip', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.enable()
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeDefined()
+ done()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ describe('disable', () => {
+ it('should disable tooltip', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.disable()
+
+ tooltipEl.addEventListener('show.bs.tooltip', () => {
+ throw new Error('should not show a disabled tooltip')
+ })
+
+ tooltip.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ }, 10)
+ })
+ })
+
+ describe('toggleEnabled', () => {
+ it('should toggle enabled', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._isEnabled).toEqual(true)
+
+ tooltip.toggleEnabled()
+
+ expect(tooltip._isEnabled).toEqual(false)
+ })
+ })
+
+ describe('toggle', () => {
+ it('should do nothing if disabled', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.disable()
+
+ tooltipEl.addEventListener('show.bs.tooltip', () => {
+ throw new Error('should not show a disabled tooltip')
+ })
+
+ tooltip.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ done()
+ }, 10)
+ })
+
+ it('should show a tooltip', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeDefined()
+ done()
+ })
+
+ tooltip.toggle()
+ })
+
+ it('should call toggle and show the tooltip when trigger is "click"', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ trigger: 'click'
+ })
+
+ spyOn(tooltip, 'toggle').and.callThrough()
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(tooltip.toggle).toHaveBeenCalled()
+ done()
+ })
+
+ tooltipEl.click()
+ })
+
+ it('should hide a tooltip', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ tooltip.toggle()
+ })
+
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeNull()
+ done()
+ })
+
+ tooltip.toggle()
+ })
+
+ it('should call toggle and hide the tooltip when trigger is "click"', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ trigger: 'click'
+ })
+
+ spyOn(tooltip, 'toggle').and.callThrough()
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ tooltipEl.click()
+ })
+
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ expect(tooltip.toggle).toHaveBeenCalled()
+ done()
+ })
+
+ tooltipEl.click()
+ })
+ })
+
+ describe('dispose', () => {
+ it('should destroy a tooltip', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(Tooltip.getInstance(tooltipEl)).toEqual(tooltip)
+
+ tooltip.dispose()
+
+ expect(Tooltip.getInstance(tooltipEl)).toEqual(null)
+ })
+
+ it('should destroy a tooltip and remove it from the dom', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeDefined()
+
+ tooltip.dispose()
+
+ expect(document.querySelector('.tooltip')).toBeNull()
+ done()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ describe('show', () => {
+ it('should show a tooltip', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown).toBeDefined()
+ expect(tooltipEl.getAttribute('aria-describedby')).toEqual(tooltipShown.getAttribute('id'))
+ expect(tooltipShown.getAttribute('id').indexOf('tooltip') !== -1).toEqual(true)
+ done()
+ })
+
+ tooltip.show()
+ })
+
+ it('should show a tooltip on mobile', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+ document.documentElement.ontouchstart = noop
+
+ spyOn(EventHandler, 'on')
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ expect(EventHandler.on).toHaveBeenCalled()
+ document.documentElement.ontouchstart = undefined
+ done()
+ })
+
+ tooltip.show()
+ })
+
+ it('should show a tooltip relative to placement option', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ placement: 'bottom'
+ })
+
+ tooltipEl.addEventListener('inserted.bs.tooltip', () => {
+ expect(tooltip.getTipElement().classList.contains('bs-tooltip-bottom')).toEqual(true)
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown.classList.contains('bs-tooltip-bottom')).toEqual(true)
+ done()
+ })
+
+ tooltip.show()
+ })
+
+ it('should not error when trying to show a tooltip that has been removed from the dom', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ const firstCallback = () => {
+ tooltipEl.removeEventListener('shown.bs.tooltip', firstCallback)
+ let tooltipShown = document.querySelector('.tooltip')
+
+ tooltipShown.parentNode.removeChild(tooltipShown)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown).not.toBeNull()
+ done()
+ })
+
+ tooltip.show()
+ }
+
+ tooltipEl.addEventListener('shown.bs.tooltip', firstCallback)
+
+ tooltip.show()
+ })
+
+ it('should show a tooltip with a dom element container', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ container: fixtureEl
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(fixtureEl.querySelector('.tooltip')).toBeDefined()
+ done()
+ })
+
+ tooltip.show()
+ })
+
+ it('should show a tooltip with a jquery element container', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ container: {
+ 0: fixtureEl,
+ jquery: 'jQuery'
+ }
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(fixtureEl.querySelector('.tooltip')).toBeDefined()
+ done()
+ })
+
+ tooltip.show()
+ })
+
+ it('should show a tooltip with a selector in container', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ container: '#fixture'
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(fixtureEl.querySelector('.tooltip')).toBeDefined()
+ done()
+ })
+
+ tooltip.show()
+ })
+
+ it('should show a tooltip with placement as a function', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const spy = jasmine.createSpy('placement').and.returnValue('top')
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ placement: spy
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeDefined()
+ expect(spy).toHaveBeenCalled()
+ done()
+ })
+
+ tooltip.show()
+ })
+
+ it('should show a tooltip with offset as a function', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const spy = jasmine.createSpy('offset').and.returnValue({})
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ offset: spy
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeDefined()
+ expect(spy).toHaveBeenCalled()
+ done()
+ })
+
+ tooltip.show()
+ })
+
+ it('should show a tooltip without the animation', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ animation: false
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tip = document.querySelector('.tooltip')
+
+ expect(tip).toBeDefined()
+ expect(tip.classList.contains('fade')).toEqual(false)
+ done()
+ })
+
+ tooltip.show()
+ })
+
+ it('should throw an error the element is not visible', () => {
+ fixtureEl.innerHTML = '<a href="#" style="display: none" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ try {
+ tooltip.show()
+ } catch (error) {
+ expect(error.message).toEqual('Please use show on visible elements')
+ }
+ })
+
+ it('should not show a tooltip if show.bs.tooltip is prevented', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ const expectedDone = () => {
+ setTimeout(() => {
+ expect(document.querySelector('.tooltip')).toBeNull()
+ done()
+ }, 10)
+ }
+
+ tooltipEl.addEventListener('show.bs.tooltip', ev => {
+ ev.preventDefault()
+ expectedDone()
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ throw new Error('Tooltip should not be shown')
+ })
+
+ tooltip.show()
+ })
+
+ it('should show tooltip if leave event hasn\'t occurred before delay expires', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ delay: 150
+ })
+
+ spyOn(tooltip, 'show')
+
+ setTimeout(() => {
+ expect(tooltip.show).not.toHaveBeenCalled()
+ }, 100)
+
+ setTimeout(() => {
+ expect(tooltip.show).toHaveBeenCalled()
+ done()
+ }, 200)
+
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ })
+
+ it('should not show tooltip if leave event occurs before delay expires', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ delay: 150
+ })
+
+ spyOn(tooltip, 'show')
+
+ setTimeout(() => {
+ expect(tooltip.show).not.toHaveBeenCalled()
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ }, 100)
+
+ setTimeout(() => {
+ expect(tooltip.show).toHaveBeenCalled()
+ expect(document.querySelectorAll('.tooltip').length).toEqual(0)
+ done()
+ }, 200)
+
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ })
+
+ it('should not hide tooltip if leave event occurs and enter event occurs within the hide delay', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ delay: {
+ show: 0,
+ hide: 150
+ }
+ })
+
+ setTimeout(() => {
+ expect(tooltip.getTipElement().classList.contains('show')).toEqual(true)
+ tooltipEl.dispatchEvent(createEvent('mouseout'))
+
+ setTimeout(() => {
+ expect(tooltip.getTipElement().classList.contains('show')).toEqual(true)
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ }, 100)
+
+ setTimeout(() => {
+ expect(tooltip.getTipElement().classList.contains('show')).toEqual(true)
+ done()
+ }, 200)
+ }, 0)
+
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ })
+ })
+
+ describe('hide', () => {
+ it('should hide a tooltip', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => tooltip.hide())
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeNull()
+ expect(tooltipEl.getAttribute('aria-describedby')).toBeNull()
+ done()
+ })
+
+ tooltip.show()
+ })
+
+ it('should hide a tooltip on mobile', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ document.documentElement.ontouchstart = noop
+ spyOn(EventHandler, 'off')
+ tooltip.hide()
+ })
+
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeNull()
+ expect(EventHandler.off).toHaveBeenCalled()
+ document.documentElement.ontouchstart = undefined
+ done()
+ })
+
+ tooltip.show()
+ })
+
+ it('should hide a tooltip without animation', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ animation: false
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => tooltip.hide())
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeNull()
+ expect(tooltipEl.getAttribute('aria-describedby')).toBeNull()
+ done()
+ })
+
+ tooltip.show()
+ })
+
+ it('should not hide a tooltip if hide event is prevented', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const assertDone = () => {
+ setTimeout(() => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ done()
+ }, 20)
+ }
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ animation: false
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => tooltip.hide())
+ tooltipEl.addEventListener('hide.bs.tooltip', event => {
+ event.preventDefault()
+ assertDone()
+ })
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ throw new Error('should not trigger hidden event')
+ })
+
+ tooltip.show()
+ })
+ })
+
+ describe('update', () => {
+ it('should call popper schedule update', done => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ spyOn(tooltip._popper, 'scheduleUpdate')
+
+ tooltip.update()
+
+ expect(tooltip._popper.scheduleUpdate).toHaveBeenCalled()
+ done()
+ })
+
+ tooltip.show()
+ })
+
+ it('should do nothing if the tooltip is not shown', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.update()
+ expect().nothing()
+ })
+ })
+
+ describe('isWithContent', () => {
+ it('should return true if there is content', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip.isWithContent()).toEqual(true)
+ })
+
+ it('should return false if there is no content', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title=""/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip.isWithContent()).toEqual(false)
+ })
+ })
+
+ describe('getTipElement', () => {
+ it('should create the tip element and return it', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ spyOn(document, 'createElement').and.callThrough()
+
+ expect(tooltip.getTipElement()).toBeDefined()
+ expect(document.createElement).toHaveBeenCalled()
+ })
+
+ it('should return the created tip element', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ const spy = spyOn(document, 'createElement').and.callThrough()
+
+ expect(tooltip.getTipElement()).toBeDefined()
+ expect(spy).toHaveBeenCalled()
+
+ spy.calls.reset()
+
+ expect(tooltip.getTipElement()).toBeDefined()
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('setContent', () => {
+ it('should set tip content', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.setContent()
+
+ const tip = tooltip.getTipElement()
+
+ expect(tip.classList.contains('show')).toEqual(false)
+ expect(tip.classList.contains('fade')).toEqual(false)
+ expect(tip.querySelector('.tooltip-inner').textContent).toEqual('Another tooltip')
+ })
+ })
+
+ describe('setElementContent', () => {
+ it('should do nothing if the element is null', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.setElementContent(null, null)
+ expect().nothing()
+ })
+
+ it('should add the content as a child of the element', () => {
+ fixtureEl.innerHTML = [
+ '<a href="#" rel="tooltip" title="Another tooltip"/>',
+ '<div id="childContent"></div>'
+ ].join('')
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const childContent = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(tooltipEl, {
+ html: true
+ })
+
+ tooltip.setElementContent(tooltip.getTipElement(), childContent)
+
+ expect(childContent.parentNode).toEqual(tooltip.getTipElement())
+ })
+
+ it('should do nothing if the content is a child of the element', () => {
+ fixtureEl.innerHTML = [
+ '<a href="#" rel="tooltip" title="Another tooltip"/>',
+ '<div id="childContent"></div>'
+ ].join('')
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const childContent = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(tooltipEl, {
+ html: true
+ })
+
+ tooltip.getTipElement().appendChild(childContent)
+ tooltip.setElementContent(tooltip.getTipElement(), childContent)
+
+ expect().nothing()
+ })
+
+ it('should add the content as a child of the element for jQuery elements', () => {
+ fixtureEl.innerHTML = [
+ '<a href="#" rel="tooltip" title="Another tooltip"/>',
+ '<div id="childContent"></div>'
+ ].join('')
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const childContent = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(tooltipEl, {
+ html: true
+ })
+
+ tooltip.setElementContent(tooltip.getTipElement(), { 0: childContent, jquery: 'jQuery' })
+
+ expect(childContent.parentNode).toEqual(tooltip.getTipElement())
+ })
+
+ it('should add the child text content in the element', () => {
+ fixtureEl.innerHTML = [
+ '<a href="#" rel="tooltip" title="Another tooltip"/>',
+ '<div id="childContent">Tooltip</div>'
+ ].join('')
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const childContent = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.setElementContent(tooltip.getTipElement(), childContent)
+
+ expect(childContent.textContent).toEqual(tooltip.getTipElement().textContent)
+ })
+
+ it('should add html without sanitize it', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ sanitize: false,
+ html: true
+ })
+
+ tooltip.setElementContent(tooltip.getTipElement(), '<div id="childContent">Tooltip</div>')
+
+ expect(tooltip.getTipElement().querySelector('div').id).toEqual('childContent')
+ })
+
+ it('should add html sanitized', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ html: true
+ })
+
+ tooltip.setElementContent(tooltip.getTipElement(), [
+ '<div id="childContent">',
+ ' <button type="button">test btn</button>',
+ '</div>'
+ ].join(''))
+
+ expect(tooltip.getTipElement().querySelector('div').id).toEqual('childContent')
+ expect(tooltip.getTipElement().querySelector('button')).toEqual(null)
+ })
+
+ it('should add text content', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.setElementContent(tooltip.getTipElement(), 'test')
+
+ expect(tooltip.getTipElement().innerText).toEqual('test')
+ })
+ })
+
+ describe('getTitle', () => {
+ it('should return the title', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip.getTitle()).toEqual('Another tooltip')
+ })
+
+ it('should call title function', () => {
+ fixtureEl.innerHTML = '<a href="#" rel="tooltip" />'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ title: () => 'test'
+ })
+
+ expect(tooltip.getTitle()).toEqual('test')
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a tooltip', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.tooltip = Tooltip.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tooltip.call(jQueryMock)
+
+ expect(Tooltip.getInstance(div)).toBeDefined()
+ })
+
+ it('should not re create a tooltip', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(div)
+
+ jQueryMock.fn.tooltip = Tooltip.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tooltip.call(jQueryMock)
+
+ expect(Tooltip.getInstance(div)).toEqual(tooltip)
+ })
+
+ it('should call a tooltip method', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(div)
+
+ spyOn(tooltip, 'show')
+
+ jQueryMock.fn.tooltip = Tooltip.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tooltip.call(jQueryMock, 'show')
+
+ expect(Tooltip.getInstance(div)).toEqual(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>'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.tooltip = Tooltip.jQueryInterface
+ jQueryMock.elements = [div]
+
+ try {
+ jQueryMock.fn.tooltip.call(jQueryMock, action)
+ } catch (error) {
+ expect(error.message).toEqual(`No method named "${action}"`)
+ }
+ })
+ })
+})
diff --git a/js/tests/units/util/index.spec.js b/js/tests/units/util/index.spec.js
new file mode 100644
index 0000000000..42c273f060
--- /dev/null
+++ b/js/tests/units/util/index.spec.js
@@ -0,0 +1,382 @@
+import * as Util from '../../../src/util/index'
+
+/** Test helpers */
+import { getFixture, clearFixture } from '../../helpers/fixture'
+
+describe('Util', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('getUID', () => {
+ it('should generate uid', () => {
+ const uid = Util.getUID('bs')
+ const uid2 = Util.getUID('bs')
+
+ expect(uid).not.toEqual(uid2)
+ })
+ })
+
+ describe('getSelectorFromElement', () => {
+ it('should get selector from data-target', () => {
+ fixtureEl.innerHTML = [
+ '<div id="test" data-target=".target"></div>',
+ '<div class="target"></div>'
+ ].join('')
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getSelectorFromElement(testEl)).toEqual('.target')
+ })
+
+ it('should get selector from href if no data-target set', () => {
+ fixtureEl.innerHTML = [
+ '<a id="test" href=".target"></a>',
+ '<div class="target"></div>'
+ ].join('')
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getSelectorFromElement(testEl)).toEqual('.target')
+ })
+
+ it('should get selector from href if data-target equal to #', () => {
+ fixtureEl.innerHTML = [
+ '<a id="test" data-target="#" href=".target"></a>',
+ '<div class="target"></div>'
+ ].join('')
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getSelectorFromElement(testEl)).toEqual('.target')
+ })
+
+ it('should return null if selector not found', () => {
+ fixtureEl.innerHTML = '<a id="test" href=".target"></a>'
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getSelectorFromElement(testEl)).toBeNull()
+ })
+
+ it('should return null if no selector', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const testEl = fixtureEl.querySelector('div')
+
+ expect(Util.getSelectorFromElement(testEl)).toBeNull()
+ })
+ })
+
+ describe('getElementFromSelector', () => {
+ it('should get element from data-target', () => {
+ fixtureEl.innerHTML = [
+ '<div id="test" data-target=".target"></div>',
+ '<div class="target"></div>'
+ ].join('')
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getElementFromSelector(testEl)).toEqual(fixtureEl.querySelector('.target'))
+ })
+
+ it('should get element from href if no data-target set', () => {
+ fixtureEl.innerHTML = [
+ '<a id="test" href=".target"></a>',
+ '<div class="target"></div>'
+ ].join('')
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getElementFromSelector(testEl)).toEqual(fixtureEl.querySelector('.target'))
+ })
+
+ it('should return null if element not found', () => {
+ fixtureEl.innerHTML = '<a id="test" href=".target"></a>'
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getElementFromSelector(testEl)).toBeNull()
+ })
+
+ it('should return null if no selector', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const testEl = fixtureEl.querySelector('div')
+
+ expect(Util.getElementFromSelector(testEl)).toBeNull()
+ })
+ })
+
+ describe('getTransitionDurationFromElement', () => {
+ it('should get transition from element', () => {
+ fixtureEl.innerHTML = '<div style="transition: all 300ms ease-out;"></div>'
+
+ expect(Util.getTransitionDurationFromElement(fixtureEl.querySelector('div'))).toEqual(300)
+ })
+
+ it('should return 0 if the element is undefined or null', () => {
+ expect(Util.getTransitionDurationFromElement(null)).toEqual(0)
+ expect(Util.getTransitionDurationFromElement(undefined)).toEqual(0)
+ })
+
+ it('should return 0 if the element do not possess transition', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ expect(Util.getTransitionDurationFromElement(fixtureEl.querySelector('div'))).toEqual(0)
+ })
+ })
+
+ describe('triggerTransitionEnd', () => {
+ it('should trigger transitionend event', done => {
+ fixtureEl.innerHTML = '<div style="transition: all 300ms ease-out;"></div>'
+
+ const el = fixtureEl.querySelector('div')
+
+ el.addEventListener('transitionend', () => {
+ expect().nothing()
+ done()
+ })
+
+ Util.triggerTransitionEnd(el)
+ })
+ })
+
+ describe('isElement', () => {
+ it('should detect if the parameter is an element or not', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const el = document.querySelector('div')
+
+ expect(Util.isElement(el)).toEqual(el.nodeType)
+ expect(Util.isElement({})).toEqual(undefined)
+ })
+
+ it('should detect jQuery element', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const el = document.querySelector('div')
+ const fakejQuery = {
+ 0: el
+ }
+
+ expect(Util.isElement(fakejQuery)).toEqual(el.nodeType)
+ })
+ })
+
+ 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>'
+
+ const el = fixtureEl.querySelector('div')
+ const spy = spyOn(el, 'removeEventListener')
+
+ Util.emulateTransitionEnd(el, 10)
+ Util.triggerTransitionEnd(el)
+
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled()
+ done()
+ }, 20)
+ })
+ })
+
+ describe('typeCheckConfig', () => {
+ it('should check type of the config object', () => {
+ const namePlugin = 'collapse'
+ const defaultType = {
+ toggle: 'boolean',
+ parent: '(string|element)'
+ }
+ const config = {
+ toggle: true,
+ parent: 777
+ }
+
+ expect(() => {
+ Util.typeCheckConfig(namePlugin, config, defaultType)
+ }).toThrow(new Error('COLLAPSE: Option "parent" provided type "number" but expected type "(string|element)".'))
+ })
+ })
+
+ describe('makeArray', () => {
+ it('should convert node list to array', () => {
+ const nodeList = document.querySelectorAll('div')
+
+ expect(Array.isArray(nodeList)).toEqual(false)
+ expect(Array.isArray(Util.makeArray(nodeList))).toEqual(true)
+ })
+
+ it('should return an empty array if the nodeList is undefined', () => {
+ expect(Util.makeArray(null)).toEqual([])
+ expect(Util.makeArray(undefined)).toEqual([])
+ })
+ })
+
+ describe('isVisible', () => {
+ it('should return false if the element is not defined', () => {
+ expect(Util.isVisible(null)).toEqual(false)
+ expect(Util.isVisible(undefined)).toEqual(false)
+ })
+
+ it('should return false if the element provided is not a dom element', () => {
+ expect(Util.isVisible({})).toEqual(false)
+ })
+
+ it('should return false if the element is not visible with display none', () => {
+ fixtureEl.innerHTML = '<div style="display: none;"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Util.isVisible(div)).toEqual(false)
+ })
+
+ it('should return false if the element is not visible with visibility hidden', () => {
+ fixtureEl.innerHTML = '<div style="visibility: hidden;"></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Util.isVisible(div)).toEqual(false)
+ })
+
+ it('should return false if the parent element is not visible', () => {
+ fixtureEl.innerHTML = [
+ '<div style="display: none;">',
+ ' <div class="content"></div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('.content')
+
+ expect(Util.isVisible(div)).toEqual(false)
+ })
+
+ it('should return true if the element is visible', () => {
+ fixtureEl.innerHTML = [
+ '<div>',
+ ' <div id="element"></div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('#element')
+
+ expect(Util.isVisible(div)).toEqual(true)
+ })
+ })
+
+ describe('findShadowRoot', () => {
+ it('should return null if shadow dom is not available', () => {
+ // Only for newer browsers
+ if (!document.documentElement.attachShadow) {
+ expect().nothing()
+ return
+ }
+
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ spyOn(document.documentElement, 'attachShadow').and.returnValue(null)
+
+ expect(Util.findShadowRoot(div)).toEqual(null)
+ })
+
+ it('should return null when we do not find a shadow root', () => {
+ // Only for newer browsers
+ if (!document.documentElement.attachShadow) {
+ expect().nothing()
+ return
+ }
+
+ spyOn(document, 'getRootNode').and.returnValue(undefined)
+
+ expect(Util.findShadowRoot(document)).toEqual(null)
+ })
+
+ it('should return the shadow root when found', () => {
+ // Only for newer browsers
+ if (!document.documentElement.attachShadow) {
+ expect().nothing()
+ return
+ }
+
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const shadowRoot = div.attachShadow({
+ mode: 'open'
+ })
+
+ expect(Util.findShadowRoot(shadowRoot)).toEqual(shadowRoot)
+
+ shadowRoot.innerHTML = '<button>Shadow Button</button>'
+
+ expect(Util.findShadowRoot(shadowRoot.firstChild)).toEqual(shadowRoot)
+ })
+ })
+
+ describe('noop', () => {
+ it('should return a function', () => {
+ expect(typeof Util.noop()).toEqual('function')
+ })
+ })
+
+ describe('reflow', () => {
+ it('should return element offset height to force the reflow', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Util.reflow(div)).toEqual(0)
+ })
+ })
+
+ describe('getjQuery', () => {
+ const fakejQuery = { trigger() {} }
+
+ beforeEach(() => {
+ Object.defineProperty(window, 'jQuery', {
+ value: fakejQuery,
+ writable: true
+ })
+ })
+
+ afterEach(() => {
+ window.jQuery = undefined
+ })
+
+ it('should return jQuery object when present', () => {
+ expect(Util.getjQuery()).toEqual(fakejQuery)
+ })
+
+ it('should not return jQuery object when present if data-no-jquery', () => {
+ document.body.setAttribute('data-no-jquery', '')
+
+ expect(window.jQuery).toEqual(fakejQuery)
+ expect(Util.getjQuery()).toEqual(null)
+
+ document.body.removeAttribute('data-no-jquery')
+ })
+
+ it('should not return jQuery if not present', () => {
+ window.jQuery = undefined
+ expect(Util.getjQuery()).toEqual(null)
+ })
+ })
+})
diff --git a/js/tests/units/util/sanitizer.spec.js b/js/tests/units/util/sanitizer.spec.js
new file mode 100644
index 0000000000..c4259e7fd6
--- /dev/null
+++ b/js/tests/units/util/sanitizer.spec.js
@@ -0,0 +1,70 @@
+import { DefaultWhitelist, sanitizeHtml } from '../../../src/util/sanitizer'
+
+describe('Sanitizer', () => {
+ describe('sanitizeHtml', () => {
+ it('should return the same on empty string', () => {
+ const empty = ''
+
+ const result = sanitizeHtml(empty, DefaultWhitelist, null)
+
+ expect(result).toEqual(empty)
+ })
+
+ it('should sanitize template by removing tags with XSS', () => {
+ const template = [
+ '<div>',
+ ' <a href="javascript:alert(7)">Click me</a>',
+ ' <span>Some content</span>',
+ '</div>'
+ ].join('')
+
+ const result = sanitizeHtml(template, DefaultWhitelist, null)
+
+ expect(result.indexOf('script') === -1).toEqual(true)
+ })
+
+ it('should allow aria attributes and safe attributes', () => {
+ const template = [
+ '<div aria-pressed="true">',
+ ' <span class="test">Some content</span>',
+ '</div>'
+ ].join('')
+
+ const result = sanitizeHtml(template, DefaultWhitelist, null)
+
+ expect(result.indexOf('aria-pressed') !== -1).toEqual(true)
+ expect(result.indexOf('class="test"') !== -1).toEqual(true)
+ })
+
+ it('should remove not whitelist tags', () => {
+ const template = [
+ '<div>',
+ ' <script>alert(7)</script>',
+ '</div>'
+ ].join('')
+
+ const result = sanitizeHtml(template, DefaultWhitelist, null)
+
+ expect(result.indexOf('<script>') === -1).toEqual(true)
+ })
+
+ it('should not use native api to sanitize if a custom function passed', () => {
+ const template = [
+ '<div>',
+ ' <span>Some content</span>',
+ '</div>'
+ ].join('')
+
+ function mySanitize(htmlUnsafe) {
+ return htmlUnsafe
+ }
+
+ spyOn(DOMParser.prototype, 'parseFromString')
+
+ const result = sanitizeHtml(template, DefaultWhitelist, mySanitize)
+
+ expect(result).toEqual(template)
+ expect(DOMParser.prototype.parseFromString).not.toHaveBeenCalled()
+ })
+ })
+})