summaryrefslogtreecommitdiffstats
path: root/js/tests/unit/util
diff options
context:
space:
mode:
Diffstat (limited to 'js/tests/unit/util')
-rw-r--r--js/tests/unit/util/backdrop.spec.js321
-rw-r--r--js/tests/unit/util/component-functions.spec.js108
-rw-r--r--js/tests/unit/util/config.spec.js166
-rw-r--r--js/tests/unit/util/focustrap.spec.js218
-rw-r--r--js/tests/unit/util/index.spec.js814
-rw-r--r--js/tests/unit/util/sanitizer.spec.js105
-rw-r--r--js/tests/unit/util/scrollbar.spec.js363
-rw-r--r--js/tests/unit/util/swipe.spec.js291
-rw-r--r--js/tests/unit/util/template-factory.spec.js306
9 files changed, 2692 insertions, 0 deletions
diff --git a/js/tests/unit/util/backdrop.spec.js b/js/tests/unit/util/backdrop.spec.js
new file mode 100644
index 0000000..73384fc
--- /dev/null
+++ b/js/tests/unit/util/backdrop.spec.js
@@ -0,0 +1,321 @@
+import Backdrop from '../../../src/util/backdrop'
+import { getTransitionDurationFromElement } from '../../../src/util/index'
+import { clearFixture, getFixture } from '../../helpers/fixture'
+
+const CLASS_BACKDROP = '.modal-backdrop'
+const CLASS_NAME_FADE = 'fade'
+const CLASS_NAME_SHOW = 'show'
+
+describe('Backdrop', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ const list = document.querySelectorAll(CLASS_BACKDROP)
+
+ for (const el of list) {
+ el.remove()
+ }
+ })
+
+ describe('show', () => {
+ it('should append the backdrop html once on show and include the "show" class if it is "shown"', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: false
+ })
+ const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
+
+ expect(getElements()).toHaveSize(0)
+
+ instance.show()
+ instance.show(() => {
+ expect(getElements()).toHaveSize(1)
+ for (const el of getElements()) {
+ expect(el).toHaveClass(CLASS_NAME_SHOW)
+ }
+
+ resolve()
+ })
+ })
+ })
+
+ it('should not append the backdrop html if it is not "shown"', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: false,
+ isAnimated: true
+ })
+ const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
+
+ expect(getElements()).toHaveSize(0)
+ instance.show(() => {
+ expect(getElements()).toHaveSize(0)
+ resolve()
+ })
+ })
+ })
+
+ it('should append the backdrop html once and include the "fade" class if it is "shown" and "animated"', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: true
+ })
+ const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
+
+ expect(getElements()).toHaveSize(0)
+
+ instance.show(() => {
+ expect(getElements()).toHaveSize(1)
+ for (const el of getElements()) {
+ expect(el).toHaveClass(CLASS_NAME_FADE)
+ }
+
+ resolve()
+ })
+ })
+ })
+ })
+
+ describe('hide', () => {
+ it('should remove the backdrop html', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: true
+ })
+
+ const getElements = () => document.body.querySelectorAll(CLASS_BACKDROP)
+
+ expect(getElements()).toHaveSize(0)
+ instance.show(() => {
+ expect(getElements()).toHaveSize(1)
+ instance.hide(() => {
+ expect(getElements()).toHaveSize(0)
+ resolve()
+ })
+ })
+ })
+ })
+
+ it('should remove the "show" class', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: true
+ })
+ const elem = instance._getElement()
+
+ instance.show()
+ instance.hide(() => {
+ expect(elem).not.toHaveClass(CLASS_NAME_SHOW)
+ resolve()
+ })
+ })
+ })
+
+ it('should not try to remove Node on remove method if it is not "shown"', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: false,
+ isAnimated: true
+ })
+ const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
+ const spy = spyOn(instance, 'dispose').and.callThrough()
+
+ expect(getElements()).toHaveSize(0)
+ expect(instance._isAppended).toBeFalse()
+ instance.show(() => {
+ instance.hide(() => {
+ expect(getElements()).toHaveSize(0)
+ expect(spy).not.toHaveBeenCalled()
+ expect(instance._isAppended).toBeFalse()
+ resolve()
+ })
+ })
+ })
+ })
+
+ it('should not error if the backdrop no longer has a parent', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div id="wrapper"></div>'
+
+ const wrapper = fixtureEl.querySelector('#wrapper')
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: true,
+ rootElement: wrapper
+ })
+
+ const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
+
+ instance.show(() => {
+ wrapper.remove()
+ instance.hide(() => {
+ expect(getElements()).toHaveSize(0)
+ resolve()
+ })
+ })
+ })
+ })
+ })
+
+ describe('click callback', () => {
+ it('should execute callback on click', () => {
+ return new Promise(resolve => {
+ const spy = jasmine.createSpy('spy')
+
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: false,
+ clickCallback: () => spy()
+ })
+ const endTest = () => {
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ }, 10)
+ }
+
+ instance.show(() => {
+ const clickEvent = new Event('mousedown', { bubbles: true, cancelable: true })
+ document.querySelector(CLASS_BACKDROP).dispatchEvent(clickEvent)
+ endTest()
+ })
+ })
+ })
+
+ describe('animation callbacks', () => {
+ it('should show and hide backdrop after counting transition duration if it is animated', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: true
+ })
+ const spy2 = jasmine.createSpy('spy2')
+
+ const execDone = () => {
+ setTimeout(() => {
+ expect(spy2).toHaveBeenCalledTimes(2)
+ resolve()
+ }, 10)
+ }
+
+ instance.show(spy2)
+ instance.hide(() => {
+ spy2()
+ execDone()
+ })
+ expect(spy2).not.toHaveBeenCalled()
+ })
+ })
+
+ it('should show and hide backdrop without a delay if it is not animated', () => {
+ return new Promise(resolve => {
+ const spy = jasmine.createSpy('spy', getTransitionDurationFromElement)
+ const instance = new Backdrop({
+ isVisible: true,
+ isAnimated: false
+ })
+ const spy2 = jasmine.createSpy('spy2')
+
+ instance.show(spy2)
+ instance.hide(spy2)
+
+ setTimeout(() => {
+ expect(spy2).toHaveBeenCalled()
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should not call delay callbacks if it is not "shown"', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: false,
+ isAnimated: true
+ })
+ const spy = jasmine.createSpy('spy', getTransitionDurationFromElement)
+
+ instance.show()
+ instance.hide(() => {
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ })
+ })
+ })
+ })
+
+ describe('Config', () => {
+ describe('rootElement initialization', () => {
+ it('should be appended on "document.body" by default', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true
+ })
+ const getElement = () => document.querySelector(CLASS_BACKDROP)
+ instance.show(() => {
+ expect(getElement().parentElement).toEqual(document.body)
+ resolve()
+ })
+ })
+ })
+
+ it('should find the rootElement if passed as a string', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true,
+ rootElement: 'body'
+ })
+ const getElement = () => document.querySelector(CLASS_BACKDROP)
+ instance.show(() => {
+ expect(getElement().parentElement).toEqual(document.body)
+ resolve()
+ })
+ })
+ })
+
+ it('should be appended on any element given by the proper config', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div id="wrapper"></div>'
+
+ const wrapper = fixtureEl.querySelector('#wrapper')
+ const instance = new Backdrop({
+ isVisible: true,
+ rootElement: wrapper
+ })
+ const getElement = () => document.querySelector(CLASS_BACKDROP)
+ instance.show(() => {
+ expect(getElement().parentElement).toEqual(wrapper)
+ resolve()
+ })
+ })
+ })
+ })
+
+ describe('ClassName', () => {
+ it('should allow configuring className', () => {
+ return new Promise(resolve => {
+ const instance = new Backdrop({
+ isVisible: true,
+ className: 'foo'
+ })
+ const getElement = () => document.querySelector('.foo')
+ instance.show(() => {
+ expect(getElement()).toEqual(instance._getElement())
+ instance.dispose()
+ resolve()
+ })
+ })
+ })
+ })
+ })
+ })
+})
diff --git a/js/tests/unit/util/component-functions.spec.js b/js/tests/unit/util/component-functions.spec.js
new file mode 100644
index 0000000..ec36672
--- /dev/null
+++ b/js/tests/unit/util/component-functions.spec.js
@@ -0,0 +1,108 @@
+/* Test helpers */
+
+import { clearFixture, createEvent, getFixture } from '../../helpers/fixture'
+import { enableDismissTrigger } from '../../../src/util/component-functions'
+import BaseComponent from '../../../src/base-component'
+
+class DummyClass2 extends BaseComponent {
+ static get NAME() {
+ return 'test'
+ }
+
+ hide() {
+ return true
+ }
+
+ testMethod() {
+ return true
+ }
+}
+
+describe('Plugin functions', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('data-bs-dismiss functionality', () => {
+ it('should get Plugin and execute the given method, when a click occurred on data-bs-dismiss="PluginName"', () => {
+ fixtureEl.innerHTML = [
+ '<div id="foo" class="test">',
+ ' <button type="button" data-bs-dismiss="test" data-bs-target="#foo"></button>',
+ '</div>'
+ ].join('')
+
+ const spyGet = spyOn(DummyClass2, 'getOrCreateInstance').and.callThrough()
+ const spyTest = spyOn(DummyClass2.prototype, 'testMethod')
+ const componentWrapper = fixtureEl.querySelector('#foo')
+ const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]')
+ const event = createEvent('click')
+
+ enableDismissTrigger(DummyClass2, 'testMethod')
+ btnClose.dispatchEvent(event)
+
+ expect(spyGet).toHaveBeenCalledWith(componentWrapper)
+ expect(spyTest).toHaveBeenCalled()
+ })
+
+ it('if data-bs-dismiss="PluginName" hasn\'t got "data-bs-target", "getOrCreateInstance" has to be initialized by closest "plugin.Name" class', () => {
+ fixtureEl.innerHTML = [
+ '<div id="foo" class="test">',
+ ' <button type="button" data-bs-dismiss="test"></button>',
+ '</div>'
+ ].join('')
+
+ const spyGet = spyOn(DummyClass2, 'getOrCreateInstance').and.callThrough()
+ const spyHide = spyOn(DummyClass2.prototype, 'hide')
+ const componentWrapper = fixtureEl.querySelector('#foo')
+ const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]')
+ const event = createEvent('click')
+
+ enableDismissTrigger(DummyClass2)
+ btnClose.dispatchEvent(event)
+
+ expect(spyGet).toHaveBeenCalledWith(componentWrapper)
+ expect(spyHide).toHaveBeenCalled()
+ })
+
+ it('if data-bs-dismiss="PluginName" is disabled, must not trigger function', () => {
+ fixtureEl.innerHTML = [
+ '<div id="foo" class="test">',
+ ' <button type="button" disabled data-bs-dismiss="test"></button>',
+ '</div>'
+ ].join('')
+
+ const spy = spyOn(DummyClass2, 'getOrCreateInstance').and.callThrough()
+ const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]')
+ const event = createEvent('click')
+
+ enableDismissTrigger(DummyClass2)
+ btnClose.dispatchEvent(event)
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should prevent default when the trigger is <a> or <area>', () => {
+ fixtureEl.innerHTML = [
+ '<div id="foo" class="test">',
+ ' <a type="button" data-bs-dismiss="test"></a>',
+ '</div>'
+ ].join('')
+
+ const btnClose = fixtureEl.querySelector('[data-bs-dismiss="test"]')
+ const event = createEvent('click')
+
+ enableDismissTrigger(DummyClass2)
+ const spy = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+
+ btnClose.dispatchEvent(event)
+
+ expect(spy).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/js/tests/unit/util/config.spec.js b/js/tests/unit/util/config.spec.js
new file mode 100644
index 0000000..e1693c0
--- /dev/null
+++ b/js/tests/unit/util/config.spec.js
@@ -0,0 +1,166 @@
+import Config from '../../../src/util/config'
+import { clearFixture, getFixture } from '../../helpers/fixture'
+
+class DummyConfigClass extends Config {
+ static get NAME() {
+ return 'dummy'
+ }
+}
+
+describe('Config', () => {
+ let fixtureEl
+ const name = 'dummy'
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('NAME', () => {
+ it('should return plugin NAME', () => {
+ expect(DummyConfigClass.NAME).toEqual(name)
+ })
+ })
+
+ describe('DefaultType', () => {
+ it('should return plugin default type', () => {
+ expect(DummyConfigClass.DefaultType).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin defaults', () => {
+ expect(DummyConfigClass.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('mergeConfigObj', () => {
+ it('should parse element\'s data attributes and merge it with default config. Element\'s data attributes must excel Defaults', () => {
+ fixtureEl.innerHTML = '<div id="test" data-bs-test-bool="false" data-bs-test-int="8" data-bs-test-string1="bar"></div>'
+
+ spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({
+ testBool: true,
+ testString: 'foo',
+ testString1: 'foo',
+ testInt: 7
+ })
+ const instance = new DummyConfigClass()
+ const configResult = instance._mergeConfigObj({}, fixtureEl.querySelector('#test'))
+
+ expect(configResult.testBool).toEqual(false)
+ expect(configResult.testString).toEqual('foo')
+ expect(configResult.testString1).toEqual('bar')
+ expect(configResult.testInt).toEqual(8)
+ })
+
+ it('should parse element\'s data attributes and merge it with default config, plug these given during method call. The programmatically given should excel all', () => {
+ fixtureEl.innerHTML = '<div id="test" data-bs-test-bool="false" data-bs-test-int="8" data-bs-test-string-1="bar"></div>'
+
+ spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({
+ testBool: true,
+ testString: 'foo',
+ testString1: 'foo',
+ testInt: 7
+ })
+ const instance = new DummyConfigClass()
+ const configResult = instance._mergeConfigObj({
+ testString1: 'test',
+ testInt: 3
+ }, fixtureEl.querySelector('#test'))
+
+ expect(configResult.testBool).toEqual(false)
+ expect(configResult.testString).toEqual('foo')
+ expect(configResult.testString1).toEqual('test')
+ expect(configResult.testInt).toEqual(3)
+ })
+
+ it('should parse element\'s data attribute `config` and any rest attributes. The programmatically given should excel all. Data attribute `config` should excel only Defaults', () => {
+ fixtureEl.innerHTML = '<div id="test" data-bs-config=\'{"testBool":false,"testInt":50,"testInt2":100}\' data-bs-test-int="8" data-bs-test-string-1="bar"></div>'
+
+ spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({
+ testBool: true,
+ testString: 'foo',
+ testString1: 'foo',
+ testInt: 7,
+ testInt2: 600
+ })
+ const instance = new DummyConfigClass()
+ const configResult = instance._mergeConfigObj({
+ testString1: 'test'
+ }, fixtureEl.querySelector('#test'))
+
+ expect(configResult.testBool).toEqual(false)
+ expect(configResult.testString).toEqual('foo')
+ expect(configResult.testString1).toEqual('test')
+ expect(configResult.testInt).toEqual(8)
+ expect(configResult.testInt2).toEqual(100)
+ })
+
+ it('should omit element\'s data attribute `config` if is not an object', () => {
+ fixtureEl.innerHTML = '<div id="test" data-bs-config="foo" data-bs-test-int="8"></div>'
+
+ spyOnProperty(DummyConfigClass, 'Default', 'get').and.returnValue({
+ testInt: 7,
+ testInt2: 79
+ })
+ const instance = new DummyConfigClass()
+ const configResult = instance._mergeConfigObj({}, fixtureEl.querySelector('#test'))
+
+ expect(configResult.testInt).toEqual(8)
+ expect(configResult.testInt2).toEqual(79)
+ })
+ })
+
+ describe('typeCheckConfig', () => {
+ it('should check type of the config object', () => {
+ spyOnProperty(DummyConfigClass, 'DefaultType', 'get').and.returnValue({
+ toggle: 'boolean',
+ parent: '(string|element)'
+ })
+ const config = {
+ toggle: true,
+ parent: 777
+ }
+
+ const obj = new DummyConfigClass()
+ expect(() => {
+ obj._typeCheckConfig(config)
+ }).toThrowError(TypeError, obj.constructor.NAME.toUpperCase() + ': Option "parent" provided type "number" but expected type "(string|element)".')
+ })
+
+ it('should return null stringified when null is passed', () => {
+ spyOnProperty(DummyConfigClass, 'DefaultType', 'get').and.returnValue({
+ toggle: 'boolean',
+ parent: '(null|element)'
+ })
+
+ const obj = new DummyConfigClass()
+ const config = {
+ toggle: true,
+ parent: null
+ }
+
+ obj._typeCheckConfig(config)
+ expect().nothing()
+ })
+
+ it('should return undefined stringified when undefined is passed', () => {
+ spyOnProperty(DummyConfigClass, 'DefaultType', 'get').and.returnValue({
+ toggle: 'boolean',
+ parent: '(undefined|element)'
+ })
+
+ const obj = new DummyConfigClass()
+ const config = {
+ toggle: true,
+ parent: undefined
+ }
+
+ obj._typeCheckConfig(config)
+ expect().nothing()
+ })
+ })
+})
diff --git a/js/tests/unit/util/focustrap.spec.js b/js/tests/unit/util/focustrap.spec.js
new file mode 100644
index 0000000..bedd124
--- /dev/null
+++ b/js/tests/unit/util/focustrap.spec.js
@@ -0,0 +1,218 @@
+import FocusTrap from '../../../src/util/focustrap'
+import EventHandler from '../../../src/dom/event-handler'
+import SelectorEngine from '../../../src/dom/selector-engine'
+import { clearFixture, createEvent, getFixture } from '../../helpers/fixture'
+
+describe('FocusTrap', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('activate', () => {
+ it('should autofocus itself by default', () => {
+ fixtureEl.innerHTML = '<div id="focustrap" tabindex="-1"></div>'
+
+ const trapElement = fixtureEl.querySelector('div')
+
+ const spy = spyOn(trapElement, 'focus')
+
+ const focustrap = new FocusTrap({ trapElement })
+ focustrap.activate()
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('if configured not to autofocus, should not autofocus itself', () => {
+ fixtureEl.innerHTML = '<div id="focustrap" tabindex="-1"></div>'
+
+ const trapElement = fixtureEl.querySelector('div')
+
+ const spy = spyOn(trapElement, 'focus')
+
+ const focustrap = new FocusTrap({ trapElement, autofocus: false })
+ focustrap.activate()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should force focus inside focus trap if it can', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a href="#" id="outside">outside</a>',
+ '<div id="focustrap" tabindex="-1">',
+ ' <a href="#" id="inside">inside</a>',
+ '</div>'
+ ].join('')
+
+ const trapElement = fixtureEl.querySelector('div')
+ const focustrap = new FocusTrap({ trapElement })
+ focustrap.activate()
+
+ const inside = document.getElementById('inside')
+
+ const focusInListener = () => {
+ expect(spy).toHaveBeenCalled()
+ document.removeEventListener('focusin', focusInListener)
+ resolve()
+ }
+
+ const spy = spyOn(inside, 'focus')
+ spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [inside])
+
+ document.addEventListener('focusin', focusInListener)
+
+ const focusInEvent = createEvent('focusin', { bubbles: true })
+ Object.defineProperty(focusInEvent, 'target', {
+ value: document.getElementById('outside')
+ })
+
+ document.dispatchEvent(focusInEvent)
+ })
+ })
+
+ it('should wrap focus around forward on tab', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a href="#" id="outside">outside</a>',
+ '<div id="focustrap" tabindex="-1">',
+ ' <a href="#" id="first">first</a>',
+ ' <a href="#" id="inside">inside</a>',
+ ' <a href="#" id="last">last</a>',
+ '</div>'
+ ].join('')
+
+ const trapElement = fixtureEl.querySelector('div')
+ const focustrap = new FocusTrap({ trapElement })
+ focustrap.activate()
+
+ const first = document.getElementById('first')
+ const inside = document.getElementById('inside')
+ const last = document.getElementById('last')
+ const outside = document.getElementById('outside')
+
+ spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [first, inside, last])
+ const spy = spyOn(first, 'focus').and.callThrough()
+
+ const focusInListener = () => {
+ expect(spy).toHaveBeenCalled()
+ first.removeEventListener('focusin', focusInListener)
+ resolve()
+ }
+
+ first.addEventListener('focusin', focusInListener)
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'Tab'
+
+ document.dispatchEvent(keydown)
+ outside.focus()
+ })
+ })
+
+ it('should wrap focus around backwards on shift-tab', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a href="#" id="outside">outside</a>',
+ '<div id="focustrap" tabindex="-1">',
+ ' <a href="#" id="first">first</a>',
+ ' <a href="#" id="inside">inside</a>',
+ ' <a href="#" id="last">last</a>',
+ '</div>'
+ ].join('')
+
+ const trapElement = fixtureEl.querySelector('div')
+ const focustrap = new FocusTrap({ trapElement })
+ focustrap.activate()
+
+ const first = document.getElementById('first')
+ const inside = document.getElementById('inside')
+ const last = document.getElementById('last')
+ const outside = document.getElementById('outside')
+
+ spyOn(SelectorEngine, 'focusableChildren').and.callFake(() => [first, inside, last])
+ const spy = spyOn(last, 'focus').and.callThrough()
+
+ const focusInListener = () => {
+ expect(spy).toHaveBeenCalled()
+ last.removeEventListener('focusin', focusInListener)
+ resolve()
+ }
+
+ last.addEventListener('focusin', focusInListener)
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'Tab'
+ keydown.shiftKey = true
+
+ document.dispatchEvent(keydown)
+ outside.focus()
+ })
+ })
+
+ it('should force focus on itself if there is no focusable content', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<a href="#" id="outside">outside</a>',
+ '<div id="focustrap" tabindex="-1"></div>'
+ ].join('')
+
+ const trapElement = fixtureEl.querySelector('div')
+ const focustrap = new FocusTrap({ trapElement })
+ focustrap.activate()
+
+ const focusInListener = () => {
+ expect(spy).toHaveBeenCalled()
+ document.removeEventListener('focusin', focusInListener)
+ resolve()
+ }
+
+ const spy = spyOn(focustrap._config.trapElement, 'focus')
+
+ document.addEventListener('focusin', focusInListener)
+
+ const focusInEvent = createEvent('focusin', { bubbles: true })
+ Object.defineProperty(focusInEvent, 'target', {
+ value: document.getElementById('outside')
+ })
+
+ document.dispatchEvent(focusInEvent)
+ })
+ })
+ })
+
+ describe('deactivate', () => {
+ it('should flag itself as no longer active', () => {
+ const focustrap = new FocusTrap({ trapElement: fixtureEl })
+ focustrap.activate()
+ expect(focustrap._isActive).toBeTrue()
+
+ focustrap.deactivate()
+ expect(focustrap._isActive).toBeFalse()
+ })
+
+ it('should remove all event listeners', () => {
+ const focustrap = new FocusTrap({ trapElement: fixtureEl })
+ focustrap.activate()
+
+ const spy = spyOn(EventHandler, 'off')
+ focustrap.deactivate()
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('doesn\'t try removing event listeners unless it needs to (in case it hasn\'t been activated)', () => {
+ const focustrap = new FocusTrap({ trapElement: fixtureEl })
+
+ const spy = spyOn(EventHandler, 'off')
+ focustrap.deactivate()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/js/tests/unit/util/index.spec.js b/js/tests/unit/util/index.spec.js
new file mode 100644
index 0000000..9f28ce0
--- /dev/null
+++ b/js/tests/unit/util/index.spec.js
@@ -0,0 +1,814 @@
+import * as Util from '../../../src/util/index'
+import { clearFixture, getFixture } from '../../helpers/fixture'
+import { noop } from '../../../src/util/index'
+
+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-bs-target', () => {
+ fixtureEl.innerHTML = [
+ '<div id="test" data-bs-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-bs-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-bs-target equal to #', () => {
+ fixtureEl.innerHTML = [
+ '<a id="test" data-bs-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 a selector from a href is a url without an anchor', () => {
+ fixtureEl.innerHTML = [
+ '<a id="test" data-bs-target="#" href="foo/bar.html"></a>',
+ '<div class="target"></div>'
+ ].join('')
+
+ const testEl = fixtureEl.querySelector('#test')
+
+ expect(Util.getSelectorFromElement(testEl)).toBeNull()
+ })
+
+ it('should return the anchor if a selector from a href is a url', () => {
+ fixtureEl.innerHTML = [
+ '<a id="test" data-bs-target="#" href="foo/bar.html#target"></a>',
+ '<div id="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-bs-target', () => {
+ fixtureEl.innerHTML = [
+ '<div id="test" data-bs-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-bs-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', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const el = fixtureEl.querySelector('div')
+ const spy = spyOn(el, 'dispatchEvent').and.callThrough()
+
+ el.addEventListener('transitionend', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ Util.triggerTransitionEnd(el)
+ })
+ })
+ })
+
+ describe('isElement', () => {
+ it('should detect if the parameter is an element or not and return Boolean', () => {
+ fixtureEl.innerHTML = [
+ '<div id="foo" class="test"></div>',
+ '<div id="bar" class="test"></div>'
+ ].join('')
+
+ const el = fixtureEl.querySelector('#foo')
+
+ expect(Util.isElement(el)).toBeTrue()
+ expect(Util.isElement({})).toBeFalse()
+ expect(Util.isElement(fixtureEl.querySelectorAll('.test'))).toBeFalse()
+ })
+
+ it('should detect jQuery element', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const el = fixtureEl.querySelector('div')
+ const fakejQuery = {
+ 0: el,
+ jquery: 'foo'
+ }
+
+ expect(Util.isElement(fakejQuery)).toBeTrue()
+ })
+ })
+
+ describe('getElement', () => {
+ it('should try to parse element', () => {
+ fixtureEl.innerHTML = [
+ '<div id="foo" class="test"></div>',
+ '<div id="bar" class="test"></div>'
+ ].join('')
+
+ const el = fixtureEl.querySelector('div')
+
+ expect(Util.getElement(el)).toEqual(el)
+ expect(Util.getElement('#foo')).toEqual(el)
+ expect(Util.getElement('#fail')).toBeNull()
+ expect(Util.getElement({})).toBeNull()
+ expect(Util.getElement([])).toBeNull()
+ expect(Util.getElement()).toBeNull()
+ expect(Util.getElement(null)).toBeNull()
+ expect(Util.getElement(fixtureEl.querySelectorAll('.test'))).toBeNull()
+
+ const fakejQueryObject = {
+ 0: el,
+ jquery: 'foo'
+ }
+
+ expect(Util.getElement(fakejQueryObject)).toEqual(el)
+ })
+ })
+
+ describe('isVisible', () => {
+ it('should return false if the element is not defined', () => {
+ expect(Util.isVisible(null)).toBeFalse()
+ expect(Util.isVisible(undefined)).toBeFalse()
+ })
+
+ it('should return false if the element provided is not a dom element', () => {
+ expect(Util.isVisible({})).toBeFalse()
+ })
+
+ 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)).toBeFalse()
+ })
+
+ 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)).toBeFalse()
+ })
+
+ it('should return false if an ancestor element is display none', () => {
+ fixtureEl.innerHTML = [
+ '<div style="display: none;">',
+ ' <div>',
+ ' <div>',
+ ' <div class="content"></div>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('.content')
+
+ expect(Util.isVisible(div)).toBeFalse()
+ })
+
+ it('should return false if an ancestor element is visibility hidden', () => {
+ fixtureEl.innerHTML = [
+ '<div style="visibility: hidden;">',
+ ' <div>',
+ ' <div>',
+ ' <div class="content"></div>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('.content')
+
+ expect(Util.isVisible(div)).toBeFalse()
+ })
+
+ it('should return true if an ancestor element is visibility hidden, but reverted', () => {
+ fixtureEl.innerHTML = [
+ '<div style="visibility: hidden;">',
+ ' <div style="visibility: visible;">',
+ ' <div>',
+ ' <div class="content"></div>',
+ ' </div>',
+ ' </div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('.content')
+
+ expect(Util.isVisible(div)).toBeTrue()
+ })
+
+ 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)).toBeTrue()
+ })
+
+ it('should return false if the element is hidden, but not via display or visibility', () => {
+ fixtureEl.innerHTML = [
+ '<details>',
+ ' <div id="element"></div>',
+ '</details>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('#element')
+
+ expect(Util.isVisible(div)).toBeFalse()
+ })
+
+ it('should return true if its a closed details element', () => {
+ fixtureEl.innerHTML = '<details id="element"></details>'
+
+ const div = fixtureEl.querySelector('#element')
+
+ expect(Util.isVisible(div)).toBeTrue()
+ })
+
+ it('should return true if the element is visible inside an open details element', () => {
+ fixtureEl.innerHTML = [
+ '<details open>',
+ ' <div id="element"></div>',
+ '</details>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('#element')
+
+ expect(Util.isVisible(div)).toBeTrue()
+ })
+
+ it('should return true if the element is a visible summary in a closed details element', () => {
+ fixtureEl.innerHTML = [
+ '<details>',
+ ' <summary id="element-1">',
+ ' <span id="element-2"></span>',
+ ' </summary>',
+ '</details>'
+ ].join('')
+
+ const element1 = fixtureEl.querySelector('#element-1')
+ const element2 = fixtureEl.querySelector('#element-2')
+
+ expect(Util.isVisible(element1)).toBeTrue()
+ expect(Util.isVisible(element2)).toBeTrue()
+ })
+ })
+
+ describe('isDisabled', () => {
+ it('should return true if the element is not defined', () => {
+ expect(Util.isDisabled(null)).toBeTrue()
+ expect(Util.isDisabled(undefined)).toBeTrue()
+ expect(Util.isDisabled()).toBeTrue()
+ })
+
+ it('should return true if the element provided is not a dom element', () => {
+ expect(Util.isDisabled({})).toBeTrue()
+ expect(Util.isDisabled('test')).toBeTrue()
+ })
+
+ it('should return true if the element has disabled attribute', () => {
+ fixtureEl.innerHTML = [
+ '<div>',
+ ' <div id="element" disabled="disabled"></div>',
+ ' <div id="element1" disabled="true"></div>',
+ ' <div id="element2" disabled></div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('#element')
+ const div1 = fixtureEl.querySelector('#element1')
+ const div2 = fixtureEl.querySelector('#element2')
+
+ expect(Util.isDisabled(div)).toBeTrue()
+ expect(Util.isDisabled(div1)).toBeTrue()
+ expect(Util.isDisabled(div2)).toBeTrue()
+ })
+
+ it('should return false if the element has disabled attribute with "false" value, or doesn\'t have attribute', () => {
+ fixtureEl.innerHTML = [
+ '<div>',
+ ' <div id="element" disabled="false"></div>',
+ ' <div id="element1" ></div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('#element')
+ const div1 = fixtureEl.querySelector('#element1')
+
+ expect(Util.isDisabled(div)).toBeFalse()
+ expect(Util.isDisabled(div1)).toBeFalse()
+ })
+
+ it('should return false if the element is not disabled ', () => {
+ fixtureEl.innerHTML = [
+ '<div>',
+ ' <button id="button"></button>',
+ ' <select id="select"></select>',
+ ' <select id="input"></select>',
+ '</div>'
+ ].join('')
+
+ const el = selector => fixtureEl.querySelector(selector)
+
+ expect(Util.isDisabled(el('#button'))).toBeFalse()
+ expect(Util.isDisabled(el('#select'))).toBeFalse()
+ expect(Util.isDisabled(el('#input'))).toBeFalse()
+ })
+
+ it('should return true if the element has disabled attribute', () => {
+ fixtureEl.innerHTML = [
+ '<div>',
+ ' <input id="input" disabled="disabled">',
+ ' <input id="input1" disabled="disabled">',
+ ' <button id="button" disabled="true"></button>',
+ ' <button id="button1" disabled="disabled"></button>',
+ ' <button id="button2" disabled></button>',
+ ' <select id="select" disabled></select>',
+ ' <select id="input" disabled></select>',
+ '</div>'
+ ].join('')
+
+ const el = selector => fixtureEl.querySelector(selector)
+
+ expect(Util.isDisabled(el('#input'))).toBeTrue()
+ expect(Util.isDisabled(el('#input1'))).toBeTrue()
+ expect(Util.isDisabled(el('#button'))).toBeTrue()
+ expect(Util.isDisabled(el('#button1'))).toBeTrue()
+ expect(Util.isDisabled(el('#button2'))).toBeTrue()
+ expect(Util.isDisabled(el('#input'))).toBeTrue()
+ })
+
+ it('should return true if the element has class "disabled"', () => {
+ fixtureEl.innerHTML = [
+ '<div>',
+ ' <div id="element" class="disabled"></div>',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('#element')
+
+ expect(Util.isDisabled(div)).toBeTrue()
+ })
+
+ it('should return true if the element has class "disabled" but disabled attribute is false', () => {
+ fixtureEl.innerHTML = [
+ '<div>',
+ ' <input id="input" class="disabled" disabled="false">',
+ '</div>'
+ ].join('')
+
+ const div = fixtureEl.querySelector('#input')
+
+ expect(Util.isDisabled(div)).toBeTrue()
+ })
+ })
+
+ 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)).toBeNull()
+ })
+
+ 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)).toBeNull()
+ })
+
+ 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 be a function', () => {
+ expect(Util.noop).toEqual(jasmine.any(Function))
+ })
+ })
+
+ describe('reflow', () => {
+ it('should return element offset height to force the reflow', () => {
+ fixtureEl.innerHTML = '<div></div>'
+
+ const div = fixtureEl.querySelector('div')
+ const spy = spyOnProperty(div, 'offsetHeight')
+ Util.reflow(div)
+ expect(spy).toHaveBeenCalled()
+ })
+ })
+
+ 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-bs-no-jquery', () => {
+ document.body.setAttribute('data-bs-no-jquery', '')
+
+ expect(window.jQuery).toEqual(fakejQuery)
+ expect(Util.getjQuery()).toBeNull()
+
+ document.body.removeAttribute('data-bs-no-jquery')
+ })
+
+ it('should not return jQuery if not present', () => {
+ window.jQuery = undefined
+ expect(Util.getjQuery()).toBeNull()
+ })
+ })
+
+ describe('onDOMContentLoaded', () => {
+ it('should execute callbacks when DOMContentLoaded is fired and should not add more than one listener', () => {
+ const spy = jasmine.createSpy()
+ const spy2 = jasmine.createSpy()
+
+ const spyAdd = spyOn(document, 'addEventListener').and.callThrough()
+ spyOnProperty(document, 'readyState').and.returnValue('loading')
+
+ Util.onDOMContentLoaded(spy)
+ Util.onDOMContentLoaded(spy2)
+
+ document.dispatchEvent(new Event('DOMContentLoaded', {
+ bubbles: true,
+ cancelable: true
+ }))
+
+ expect(spy).toHaveBeenCalled()
+ expect(spy2).toHaveBeenCalled()
+ expect(spyAdd).toHaveBeenCalledTimes(1)
+ })
+
+ it('should execute callback if readyState is not "loading"', () => {
+ const spy = jasmine.createSpy()
+ Util.onDOMContentLoaded(spy)
+ expect(spy).toHaveBeenCalled()
+ })
+ })
+
+ describe('defineJQueryPlugin', () => {
+ const fakejQuery = { fn: {} }
+
+ beforeEach(() => {
+ Object.defineProperty(window, 'jQuery', {
+ value: fakejQuery,
+ writable: true
+ })
+ })
+
+ afterEach(() => {
+ window.jQuery = undefined
+ })
+
+ it('should define a plugin on the jQuery instance', () => {
+ const pluginMock = Util.noop
+ pluginMock.NAME = 'test'
+ pluginMock.jQueryInterface = Util.noop
+
+ Util.defineJQueryPlugin(pluginMock)
+ expect(fakejQuery.fn.test).toEqual(pluginMock.jQueryInterface)
+ expect(fakejQuery.fn.test.Constructor).toEqual(pluginMock)
+ expect(fakejQuery.fn.test.noConflict).toEqual(jasmine.any(Function))
+ })
+ })
+
+ describe('execute', () => {
+ it('should execute if arg is function', () => {
+ const spy = jasmine.createSpy('spy')
+ Util.execute(spy)
+ expect(spy).toHaveBeenCalled()
+ })
+ })
+
+ describe('executeAfterTransition', () => {
+ it('should immediately execute a function when waitForTransition parameter is false', () => {
+ const el = document.createElement('div')
+ const callbackSpy = jasmine.createSpy('callback spy')
+ const eventListenerSpy = spyOn(el, 'addEventListener')
+
+ Util.executeAfterTransition(callbackSpy, el, false)
+
+ expect(callbackSpy).toHaveBeenCalled()
+ expect(eventListenerSpy).not.toHaveBeenCalled()
+ })
+
+ it('should execute a function when a transitionend event is dispatched', () => {
+ const el = document.createElement('div')
+ const callbackSpy = jasmine.createSpy('callback spy')
+
+ spyOn(window, 'getComputedStyle').and.returnValue({
+ transitionDuration: '0.05s',
+ transitionDelay: '0s'
+ })
+
+ Util.executeAfterTransition(callbackSpy, el)
+
+ el.dispatchEvent(new TransitionEvent('transitionend'))
+
+ expect(callbackSpy).toHaveBeenCalled()
+ })
+
+ it('should execute a function after a computed CSS transition duration and there was no transitionend event dispatched', () => {
+ return new Promise(resolve => {
+ const el = document.createElement('div')
+ const callbackSpy = jasmine.createSpy('callback spy')
+
+ spyOn(window, 'getComputedStyle').and.returnValue({
+ transitionDuration: '0.05s',
+ transitionDelay: '0s'
+ })
+
+ Util.executeAfterTransition(callbackSpy, el)
+
+ setTimeout(() => {
+ expect(callbackSpy).toHaveBeenCalled()
+ resolve()
+ }, 70)
+ })
+ })
+
+ it('should not execute a function a second time after a computed CSS transition duration and if a transitionend event has already been dispatched', () => {
+ return new Promise(resolve => {
+ const el = document.createElement('div')
+ const callbackSpy = jasmine.createSpy('callback spy')
+
+ spyOn(window, 'getComputedStyle').and.returnValue({
+ transitionDuration: '0.05s',
+ transitionDelay: '0s'
+ })
+
+ Util.executeAfterTransition(callbackSpy, el)
+
+ setTimeout(() => {
+ el.dispatchEvent(new TransitionEvent('transitionend'))
+ }, 50)
+
+ setTimeout(() => {
+ expect(callbackSpy).toHaveBeenCalledTimes(1)
+ resolve()
+ }, 70)
+ })
+ })
+
+ it('should not trigger a transitionend event if another transitionend event had already happened', () => {
+ return new Promise(resolve => {
+ const el = document.createElement('div')
+
+ spyOn(window, 'getComputedStyle').and.returnValue({
+ transitionDuration: '0.05s',
+ transitionDelay: '0s'
+ })
+
+ Util.executeAfterTransition(noop, el)
+
+ // simulate a event dispatched by the browser
+ el.dispatchEvent(new TransitionEvent('transitionend'))
+
+ const dispatchSpy = spyOn(el, 'dispatchEvent').and.callThrough()
+
+ setTimeout(() => {
+ // setTimeout should not have triggered another transitionend event.
+ expect(dispatchSpy).not.toHaveBeenCalled()
+ resolve()
+ }, 70)
+ })
+ })
+
+ it('should ignore transitionend events from nested elements', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '<div class="outer">',
+ ' <div class="nested"></div>',
+ '</div>'
+ ].join('')
+
+ const outer = fixtureEl.querySelector('.outer')
+ const nested = fixtureEl.querySelector('.nested')
+ const callbackSpy = jasmine.createSpy('callback spy')
+
+ spyOn(window, 'getComputedStyle').and.returnValue({
+ transitionDuration: '0.05s',
+ transitionDelay: '0s'
+ })
+
+ Util.executeAfterTransition(callbackSpy, outer)
+
+ nested.dispatchEvent(new TransitionEvent('transitionend', {
+ bubbles: true
+ }))
+
+ setTimeout(() => {
+ expect(callbackSpy).not.toHaveBeenCalled()
+ }, 20)
+
+ setTimeout(() => {
+ expect(callbackSpy).toHaveBeenCalled()
+ resolve()
+ }, 70)
+ })
+ })
+ })
+
+ describe('getNextActiveElement', () => {
+ it('should return first element if active not exists or not given and shouldGetNext is either true, or false with cycling being disabled', () => {
+ const array = ['a', 'b', 'c', 'd']
+
+ expect(Util.getNextActiveElement(array, '', true, true)).toEqual('a')
+ expect(Util.getNextActiveElement(array, 'g', true, true)).toEqual('a')
+ expect(Util.getNextActiveElement(array, '', true, false)).toEqual('a')
+ expect(Util.getNextActiveElement(array, 'g', true, false)).toEqual('a')
+ expect(Util.getNextActiveElement(array, '', false, false)).toEqual('a')
+ expect(Util.getNextActiveElement(array, 'g', false, false)).toEqual('a')
+ })
+
+ it('should return last element if active not exists or not given and shouldGetNext is false but cycling is enabled', () => {
+ const array = ['a', 'b', 'c', 'd']
+
+ expect(Util.getNextActiveElement(array, '', false, true)).toEqual('d')
+ expect(Util.getNextActiveElement(array, 'g', false, true)).toEqual('d')
+ })
+
+ it('should return next element or same if is last', () => {
+ const array = ['a', 'b', 'c', 'd']
+
+ expect(Util.getNextActiveElement(array, 'a', true, true)).toEqual('b')
+ expect(Util.getNextActiveElement(array, 'b', true, true)).toEqual('c')
+ expect(Util.getNextActiveElement(array, 'd', true, false)).toEqual('d')
+ })
+
+ it('should return next element or first, if is last and "isCycleAllowed = true"', () => {
+ const array = ['a', 'b', 'c', 'd']
+
+ expect(Util.getNextActiveElement(array, 'c', true, true)).toEqual('d')
+ expect(Util.getNextActiveElement(array, 'd', true, true)).toEqual('a')
+ })
+
+ it('should return previous element or same if is first', () => {
+ const array = ['a', 'b', 'c', 'd']
+
+ expect(Util.getNextActiveElement(array, 'b', false, true)).toEqual('a')
+ expect(Util.getNextActiveElement(array, 'd', false, true)).toEqual('c')
+ expect(Util.getNextActiveElement(array, 'a', false, false)).toEqual('a')
+ })
+
+ it('should return next element or first, if is last and "isCycleAllowed = true"', () => {
+ const array = ['a', 'b', 'c', 'd']
+
+ expect(Util.getNextActiveElement(array, 'd', false, true)).toEqual('c')
+ expect(Util.getNextActiveElement(array, 'a', false, true)).toEqual('d')
+ })
+ })
+})
diff --git a/js/tests/unit/util/sanitizer.spec.js b/js/tests/unit/util/sanitizer.spec.js
new file mode 100644
index 0000000..c656aed
--- /dev/null
+++ b/js/tests/unit/util/sanitizer.spec.js
@@ -0,0 +1,105 @@
+import { DefaultAllowlist, sanitizeHtml } from '../../../src/util/sanitizer'
+
+describe('Sanitizer', () => {
+ describe('sanitizeHtml', () => {
+ it('should return the same on empty string', () => {
+ const empty = ''
+
+ const result = sanitizeHtml(empty, DefaultAllowlist, 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, DefaultAllowlist, null)
+
+ expect(result).not.toContain('href="javascript:alert(7)')
+ })
+
+ it('should sanitize template and work with multiple regex', () => {
+ const template = [
+ '<div>',
+ ' <a href="javascript:alert(7)" aria-label="This is a link" data-foo="bar">Click me</a>',
+ ' <span>Some content</span>',
+ '</div>'
+ ].join('')
+
+ const myDefaultAllowList = DefaultAllowlist
+ // With the default allow list
+ let result = sanitizeHtml(template, myDefaultAllowList, null)
+
+ // `data-foo` won't be present
+ expect(result).not.toContain('data-foo="bar"')
+
+ // Add the following regex too
+ myDefaultAllowList['*'].push(/^data-foo/)
+
+ result = sanitizeHtml(template, myDefaultAllowList, null)
+
+ expect(result).not.toContain('href="javascript:alert(7)') // This is in the default list
+ expect(result).toContain('aria-label="This is a link"') // This is in the default list
+ expect(result).toContain('data-foo="bar"') // We explicitly allow this
+ })
+
+ 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, DefaultAllowlist, null)
+
+ expect(result).toContain('aria-pressed')
+ expect(result).toContain('class="test"')
+ })
+
+ it('should remove tags not in allowlist', () => {
+ const template = [
+ '<div>',
+ ' <script>alert(7)</script>',
+ '</div>'
+ ].join('')
+
+ const result = sanitizeHtml(template, DefaultAllowlist, null)
+
+ expect(result).not.toContain('<script>')
+ })
+
+ 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
+ }
+
+ const spy = spyOn(DOMParser.prototype, 'parseFromString')
+
+ const result = sanitizeHtml(template, DefaultAllowlist, mySanitize)
+
+ expect(result).toEqual(template)
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should allow multiple sanitation passes of the same template', () => {
+ const template = '<img src="test.jpg">'
+
+ const firstResult = sanitizeHtml(template, DefaultAllowlist, null)
+ const secondResult = sanitizeHtml(template, DefaultAllowlist, null)
+
+ expect(firstResult).toContain('src')
+ expect(secondResult).toContain('src')
+ })
+ })
+})
diff --git a/js/tests/unit/util/scrollbar.spec.js b/js/tests/unit/util/scrollbar.spec.js
new file mode 100644
index 0000000..6fcf571
--- /dev/null
+++ b/js/tests/unit/util/scrollbar.spec.js
@@ -0,0 +1,363 @@
+import { clearBodyAndDocument, clearFixture, getFixture } from '../../helpers/fixture'
+import Manipulator from '../../../src/dom/manipulator'
+import ScrollBarHelper from '../../../src/util/scrollbar'
+
+describe('ScrollBar', () => {
+ let fixtureEl
+ const doc = document.documentElement
+ const parseIntDecimal = arg => Number.parseInt(arg, 10)
+ const getPaddingX = el => parseIntDecimal(window.getComputedStyle(el).paddingRight)
+ const getMarginX = el => parseIntDecimal(window.getComputedStyle(el).marginRight)
+ const getOverFlow = el => el.style.overflow
+ const getPaddingAttr = el => Manipulator.getDataAttribute(el, 'padding-right')
+ const getMarginAttr = el => Manipulator.getDataAttribute(el, 'margin-right')
+ const getOverFlowAttr = el => Manipulator.getDataAttribute(el, 'overflow')
+ const windowCalculations = () => {
+ return {
+ htmlClient: document.documentElement.clientWidth,
+ htmlOffset: document.documentElement.offsetWidth,
+ docClient: document.body.clientWidth,
+ htmlBound: document.documentElement.getBoundingClientRect().width,
+ bodyBound: document.body.getBoundingClientRect().width,
+ window: window.innerWidth,
+ width: Math.abs(window.innerWidth - document.documentElement.clientWidth)
+ }
+ }
+
+ // iOS, Android devices and macOS browsers hide scrollbar by default and show it only while scrolling.
+ // So the tests for scrollbar would fail
+ const isScrollBarHidden = () => {
+ const calc = windowCalculations()
+ return calc.htmlClient === calc.htmlOffset && calc.htmlClient === calc.window
+ }
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ // custom fixture to avoid extreme style values
+ fixtureEl.removeAttribute('style')
+ })
+
+ afterAll(() => {
+ fixtureEl.remove()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ clearBodyAndDocument()
+ })
+
+ beforeEach(() => {
+ clearBodyAndDocument()
+ })
+
+ describe('isBodyOverflowing', () => {
+ it('should return true if body is overflowing', () => {
+ document.documentElement.style.overflowY = 'scroll'
+ document.body.style.overflowY = 'scroll'
+ fixtureEl.innerHTML = '<div style="height: 110vh; width: 100%"></div>'
+ const result = new ScrollBarHelper().isOverflowing()
+
+ if (isScrollBarHidden()) {
+ expect(result).toBeFalse()
+ } else {
+ expect(result).toBeTrue()
+ }
+ })
+
+ it('should return false if body is not overflowing', () => {
+ doc.style.overflowY = 'hidden'
+ document.body.style.overflowY = 'hidden'
+ fixtureEl.innerHTML = '<div style="height: 110vh; width: 100%"></div>'
+ const scrollBar = new ScrollBarHelper()
+ const result = scrollBar.isOverflowing()
+
+ expect(result).toBeFalse()
+ })
+ })
+
+ describe('getWidth', () => {
+ it('should return an integer greater than zero, if body is overflowing', () => {
+ doc.style.overflowY = 'scroll'
+ document.body.style.overflowY = 'scroll'
+ fixtureEl.innerHTML = '<div style="height: 110vh; width: 100%"></div>'
+ const result = new ScrollBarHelper().getWidth()
+
+ if (isScrollBarHidden()) {
+ expect(result).toEqual(0)
+ } else {
+ expect(result).toBeGreaterThan(1)
+ }
+ })
+
+ it('should return 0 if body is not overflowing', () => {
+ document.documentElement.style.overflowY = 'hidden'
+ document.body.style.overflowY = 'hidden'
+ fixtureEl.innerHTML = '<div style="height: 110vh; width: 100%"></div>'
+
+ const result = new ScrollBarHelper().getWidth()
+
+ expect(result).toEqual(0)
+ })
+ })
+
+ describe('hide - reset', () => {
+ it('should adjust the inline padding of fixed elements which are full-width', () => {
+ fixtureEl.innerHTML = [
+ '<div style="height: 110vh; width: 100%">',
+ ' <div class="fixed-top" id="fixed1" style="padding-right: 0px; width: 100vw"></div>',
+ ' <div class="fixed-top" id="fixed2" style="padding-right: 5px; width: 100vw"></div>',
+ '</div>'
+ ].join('')
+ doc.style.overflowY = 'scroll'
+
+ const fixedEl = fixtureEl.querySelector('#fixed1')
+ const fixedEl2 = fixtureEl.querySelector('#fixed2')
+ const originalPadding = getPaddingX(fixedEl)
+ const originalPadding2 = getPaddingX(fixedEl2)
+ const scrollBar = new ScrollBarHelper()
+ const expectedPadding = originalPadding + scrollBar.getWidth()
+ const expectedPadding2 = originalPadding2 + scrollBar.getWidth()
+
+ scrollBar.hide()
+
+ let currentPadding = getPaddingX(fixedEl)
+ let currentPadding2 = getPaddingX(fixedEl2)
+ expect(getPaddingAttr(fixedEl)).toEqual(`${originalPadding}px`)
+ expect(getPaddingAttr(fixedEl2)).toEqual(`${originalPadding2}px`)
+ expect(currentPadding).toEqual(expectedPadding)
+ expect(currentPadding2).toEqual(expectedPadding2)
+
+ scrollBar.reset()
+ currentPadding = getPaddingX(fixedEl)
+ currentPadding2 = getPaddingX(fixedEl2)
+ expect(getPaddingAttr(fixedEl)).toBeNull()
+ expect(getPaddingAttr(fixedEl2)).toBeNull()
+ expect(currentPadding).toEqual(originalPadding)
+ expect(currentPadding2).toEqual(originalPadding2)
+ })
+
+ it('should remove padding & margin if not existed before adjustment', () => {
+ fixtureEl.innerHTML = [
+ '<div style="height: 110vh; width: 100%">',
+ ' <div class="fixed" id="fixed" style="width: 100vw;"></div>',
+ ' <div class="sticky-top" id="sticky" style=" width: 100vw;"></div>',
+ '</div>'
+ ].join('')
+ doc.style.overflowY = 'scroll'
+
+ const fixedEl = fixtureEl.querySelector('#fixed')
+ const stickyEl = fixtureEl.querySelector('#sticky')
+ const scrollBar = new ScrollBarHelper()
+
+ scrollBar.hide()
+ scrollBar.reset()
+
+ expect(fixedEl.getAttribute('style').includes('padding-right')).toBeFalse()
+ expect(stickyEl.getAttribute('style').includes('margin-right')).toBeFalse()
+ })
+
+ it('should adjust the inline margin and padding of sticky elements', () => {
+ fixtureEl.innerHTML = [
+ '<div style="height: 110vh">',
+ ' <div class="sticky-top" style="margin-right: 10px; padding-right: 20px; width: 100vw; height: 10px"></div>',
+ '</div>'
+ ].join('')
+ doc.style.overflowY = 'scroll'
+
+ const stickyTopEl = fixtureEl.querySelector('.sticky-top')
+ const originalMargin = getMarginX(stickyTopEl)
+ const originalPadding = getPaddingX(stickyTopEl)
+ const scrollBar = new ScrollBarHelper()
+ const expectedMargin = originalMargin - scrollBar.getWidth()
+ const expectedPadding = originalPadding + scrollBar.getWidth()
+ scrollBar.hide()
+
+ expect(getMarginAttr(stickyTopEl)).toEqual(`${originalMargin}px`)
+ expect(getMarginX(stickyTopEl)).toEqual(expectedMargin)
+ expect(getPaddingAttr(stickyTopEl)).toEqual(`${originalPadding}px`)
+ expect(getPaddingX(stickyTopEl)).toEqual(expectedPadding)
+
+ scrollBar.reset()
+ expect(getMarginAttr(stickyTopEl)).toBeNull()
+ expect(getMarginX(stickyTopEl)).toEqual(originalMargin)
+ expect(getPaddingAttr(stickyTopEl)).toBeNull()
+ expect(getPaddingX(stickyTopEl)).toEqual(originalPadding)
+ })
+
+ it('should not adjust the inline margin and padding of sticky and fixed elements when element do not have full width', () => {
+ fixtureEl.innerHTML = '<div class="sticky-top" style="margin-right: 0px; padding-right: 0px; width: 50vw"></div>'
+
+ const stickyTopEl = fixtureEl.querySelector('.sticky-top')
+ const originalMargin = getMarginX(stickyTopEl)
+ const originalPadding = getPaddingX(stickyTopEl)
+
+ const scrollBar = new ScrollBarHelper()
+ scrollBar.hide()
+
+ const currentMargin = getMarginX(stickyTopEl)
+ const currentPadding = getPaddingX(stickyTopEl)
+
+ expect(currentMargin).toEqual(originalMargin)
+ expect(currentPadding).toEqual(originalPadding)
+
+ scrollBar.reset()
+ })
+
+ it('should not put data-attribute if element doesn\'t have the proper style property, should just remove style property if element didn\'t had one', () => {
+ fixtureEl.innerHTML = [
+ '<div style="height: 110vh; width: 100%">',
+ ' <div class="sticky-top" id="sticky" style="width: 100vw"></div>',
+ '</div>'
+ ].join('')
+
+ document.body.style.overflowY = 'scroll'
+ const scrollBar = new ScrollBarHelper()
+
+ const hasPaddingAttr = el => el.hasAttribute('data-bs-padding-right')
+ const hasMarginAttr = el => el.hasAttribute('data-bs-margin-right')
+ const stickyEl = fixtureEl.querySelector('#sticky')
+ const originalPadding = getPaddingX(stickyEl)
+ const originalMargin = getMarginX(stickyEl)
+ const scrollBarWidth = scrollBar.getWidth()
+
+ scrollBar.hide()
+
+ expect(getPaddingX(stickyEl)).toEqual(scrollBarWidth + originalPadding)
+ const expectedMargin = scrollBarWidth + originalMargin
+ expect(getMarginX(stickyEl)).toEqual(expectedMargin === 0 ? expectedMargin : -expectedMargin)
+ expect(hasMarginAttr(stickyEl)).toBeFalse() // We do not have to keep css margin
+ expect(hasPaddingAttr(stickyEl)).toBeFalse() // We do not have to keep css padding
+
+ scrollBar.reset()
+
+ expect(getPaddingX(stickyEl)).toEqual(originalPadding)
+ expect(getPaddingX(stickyEl)).toEqual(originalPadding)
+ })
+
+ describe('Body Handling', () => {
+ it('should ignore other inline styles when trying to restore body defaults ', () => {
+ document.body.style.color = 'red'
+
+ const scrollBar = new ScrollBarHelper()
+ const scrollBarWidth = scrollBar.getWidth()
+ scrollBar.hide()
+
+ expect(getPaddingX(document.body)).toEqual(scrollBarWidth)
+ expect(document.body.style.color).toEqual('red')
+
+ scrollBar.reset()
+ })
+
+ it('should hide scrollbar and reset it to its initial value', () => {
+ const styleSheetPadding = '7px'
+ fixtureEl.innerHTML = [
+ '<style>',
+ ' body {',
+ ` padding-right: ${styleSheetPadding}`,
+ ' }',
+ '</style>'
+ ].join('')
+
+ const el = document.body
+ const inlineStylePadding = '10px'
+ el.style.paddingRight = inlineStylePadding
+
+ const originalPadding = getPaddingX(el)
+ expect(originalPadding).toEqual(parseIntDecimal(inlineStylePadding)) // Respect only the inline style as it has prevails this of css
+ const originalOverFlow = 'auto'
+ el.style.overflow = originalOverFlow
+ const scrollBar = new ScrollBarHelper()
+ const scrollBarWidth = scrollBar.getWidth()
+
+ scrollBar.hide()
+
+ const currentPadding = getPaddingX(el)
+
+ expect(currentPadding).toEqual(scrollBarWidth + originalPadding)
+ expect(currentPadding).toEqual(scrollBarWidth + parseIntDecimal(inlineStylePadding))
+ expect(getPaddingAttr(el)).toEqual(inlineStylePadding)
+ expect(getOverFlow(el)).toEqual('hidden')
+ expect(getOverFlowAttr(el)).toEqual(originalOverFlow)
+
+ scrollBar.reset()
+
+ const currentPadding1 = getPaddingX(el)
+ expect(currentPadding1).toEqual(originalPadding)
+ expect(getPaddingAttr(el)).toBeNull()
+ expect(getOverFlow(el)).toEqual(originalOverFlow)
+ expect(getOverFlowAttr(el)).toBeNull()
+ })
+
+ it('should hide scrollbar and reset it to its initial value - respecting css rules', () => {
+ const styleSheetPadding = '7px'
+ fixtureEl.innerHTML = [
+ '<style>',
+ ' body {',
+ ` padding-right: ${styleSheetPadding}`,
+ ' }',
+ '</style>'
+ ].join('')
+ const el = document.body
+ const originalPadding = getPaddingX(el)
+ const originalOverFlow = 'scroll'
+ el.style.overflow = originalOverFlow
+ const scrollBar = new ScrollBarHelper()
+ const scrollBarWidth = scrollBar.getWidth()
+
+ scrollBar.hide()
+
+ const currentPadding = getPaddingX(el)
+
+ expect(currentPadding).toEqual(scrollBarWidth + originalPadding)
+ expect(currentPadding).toEqual(scrollBarWidth + parseIntDecimal(styleSheetPadding))
+ expect(getPaddingAttr(el)).toBeNull() // We do not have to keep css padding
+ expect(getOverFlow(el)).toEqual('hidden')
+ expect(getOverFlowAttr(el)).toEqual(originalOverFlow)
+
+ scrollBar.reset()
+
+ const currentPadding1 = getPaddingX(el)
+ expect(currentPadding1).toEqual(originalPadding)
+ expect(getPaddingAttr(el)).toBeNull()
+ expect(getOverFlow(el)).toEqual(originalOverFlow)
+ expect(getOverFlowAttr(el)).toBeNull()
+ })
+
+ it('should not adjust the inline body padding when it does not overflow', () => {
+ const originalPadding = getPaddingX(document.body)
+ const scrollBar = new ScrollBarHelper()
+
+ // Hide scrollbars to prevent the body overflowing
+ doc.style.overflowY = 'hidden'
+ doc.style.paddingRight = '0px'
+
+ scrollBar.hide()
+ const currentPadding = getPaddingX(document.body)
+
+ expect(currentPadding).toEqual(originalPadding)
+ scrollBar.reset()
+ })
+
+ it('should not adjust the inline body padding when it does not overflow, even on a scaled display', () => {
+ const originalPadding = getPaddingX(document.body)
+ const scrollBar = new ScrollBarHelper()
+ // Remove body margins as would be done by Bootstrap css
+ document.body.style.margin = '0'
+
+ // Hide scrollbars to prevent the body overflowing
+ doc.style.overflowY = 'hidden'
+
+ // Simulate a discrepancy between exact, i.e. floating point body width, and rounded body width
+ // as it can occur when zooming or scaling the display to something else than 100%
+ doc.style.paddingRight = '.48px'
+ scrollBar.hide()
+
+ const currentPadding = getPaddingX(document.body)
+
+ expect(currentPadding).toEqual(originalPadding)
+
+ scrollBar.reset()
+ })
+ })
+ })
+})
diff --git a/js/tests/unit/util/swipe.spec.js b/js/tests/unit/util/swipe.spec.js
new file mode 100644
index 0000000..f92bb5d
--- /dev/null
+++ b/js/tests/unit/util/swipe.spec.js
@@ -0,0 +1,291 @@
+import { clearFixture, getFixture } from '../../helpers/fixture'
+import EventHandler from '../../../src/dom/event-handler'
+import Swipe from '../../../src/util/swipe'
+import { noop } from '../../../src/util'
+
+describe('Swipe', () => {
+ const { Simulator, PointerEvent } = window
+ const originWinPointerEvent = PointerEvent
+ const supportPointerEvent = Boolean(PointerEvent)
+
+ let fixtureEl
+ let swipeEl
+ const clearPointerEvents = () => {
+ window.PointerEvent = null
+ }
+
+ const restorePointerEvents = () => {
+ window.PointerEvent = originWinPointerEvent
+ }
+
+ // The headless browser does not support touch events, so we need to fake it
+ // in order to test that touch events are added properly
+ const defineDocumentElementOntouchstart = () => {
+ document.documentElement.ontouchstart = noop
+ }
+
+ const deleteDocumentElementOntouchstart = () => {
+ delete document.documentElement.ontouchstart
+ }
+
+ const mockSwipeGesture = (element, options = {}, type = 'touch') => {
+ Simulator.setType(type)
+ const _options = { deltaX: 0, deltaY: 0, ...options }
+
+ Simulator.gestures.swipe(element, _options)
+ }
+
+ beforeEach(() => {
+ fixtureEl = getFixture()
+ const cssStyle = [
+ '<style>',
+ ' #fixture .pointer-event {',
+ ' touch-action: pan-y;',
+ ' }',
+ ' #fixture div {',
+ ' width: 300px;',
+ ' height: 300px;',
+ ' }',
+ '</style>'
+ ].join('')
+
+ fixtureEl.innerHTML = `<div id="swipeEl"></div>${cssStyle}`
+ swipeEl = fixtureEl.querySelector('div')
+ })
+
+ afterEach(() => {
+ clearFixture()
+ deleteDocumentElementOntouchstart()
+ })
+
+ describe('constructor', () => {
+ it('should add touch event listeners by default', () => {
+ defineDocumentElementOntouchstart()
+
+ spyOn(Swipe.prototype, '_initEvents').and.callThrough()
+ const swipe = new Swipe(swipeEl)
+ expect(swipe._initEvents).toHaveBeenCalled()
+ })
+
+ it('should not add touch event listeners if touch is not supported', () => {
+ spyOn(Swipe, 'isSupported').and.returnValue(false)
+
+ spyOn(Swipe.prototype, '_initEvents').and.callThrough()
+ const swipe = new Swipe(swipeEl)
+
+ expect(swipe._initEvents).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Config', () => {
+ it('Test leftCallback', () => {
+ return new Promise(resolve => {
+ const spyRight = jasmine.createSpy('spy')
+ clearPointerEvents()
+ defineDocumentElementOntouchstart()
+ // eslint-disable-next-line no-new
+ new Swipe(swipeEl, {
+ leftCallback() {
+ expect(spyRight).not.toHaveBeenCalled()
+ restorePointerEvents()
+ resolve()
+ },
+ rightCallback: spyRight
+ })
+
+ mockSwipeGesture(swipeEl, {
+ pos: [300, 10],
+ deltaX: -300
+ })
+ })
+ })
+
+ it('Test rightCallback', () => {
+ return new Promise(resolve => {
+ const spyLeft = jasmine.createSpy('spy')
+ clearPointerEvents()
+ defineDocumentElementOntouchstart()
+ // eslint-disable-next-line no-new
+ new Swipe(swipeEl, {
+ rightCallback() {
+ expect(spyLeft).not.toHaveBeenCalled()
+ restorePointerEvents()
+ resolve()
+ },
+ leftCallback: spyLeft
+ })
+
+ mockSwipeGesture(swipeEl, {
+ pos: [10, 10],
+ deltaX: 300
+ })
+ })
+ })
+
+ it('Test endCallback', () => {
+ return new Promise(resolve => {
+ clearPointerEvents()
+ defineDocumentElementOntouchstart()
+ let isFirstTime = true
+
+ const callback = () => {
+ if (isFirstTime) {
+ isFirstTime = false
+ return
+ }
+
+ expect().nothing()
+ restorePointerEvents()
+ resolve()
+ }
+
+ // eslint-disable-next-line no-new
+ new Swipe(swipeEl, {
+ endCallback: callback
+ })
+ mockSwipeGesture(swipeEl, {
+ pos: [10, 10],
+ deltaX: 300
+ })
+
+ mockSwipeGesture(swipeEl, {
+ pos: [300, 10],
+ deltaX: -300
+ })
+ })
+ })
+ })
+
+ describe('Functionality on PointerEvents', () => {
+ it('should not allow pinch with touch events', () => {
+ Simulator.setType('touch')
+ clearPointerEvents()
+ deleteDocumentElementOntouchstart()
+
+ const swipe = new Swipe(swipeEl)
+ const spy = spyOn(swipe, '_handleSwipe')
+
+ mockSwipeGesture(swipeEl, {
+ pos: [300, 10],
+ deltaX: -300,
+ deltaY: 0,
+ touches: 2
+ })
+
+ restorePointerEvents()
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('should allow swipeRight and call "rightCallback" with pointer events', () => {
+ return new Promise(resolve => {
+ if (!supportPointerEvent) {
+ expect().nothing()
+ resolve()
+ return
+ }
+
+ const style = '#fixture .pointer-event { touch-action: none !important; }'
+ fixtureEl.innerHTML += style
+
+ defineDocumentElementOntouchstart()
+ // eslint-disable-next-line no-new
+ new Swipe(swipeEl, {
+ rightCallback() {
+ deleteDocumentElementOntouchstart()
+ expect().nothing()
+ resolve()
+ }
+ })
+
+ mockSwipeGesture(swipeEl, { deltaX: 300 }, 'pointer')
+ })
+ })
+
+ it('should allow swipeLeft and call "leftCallback" with pointer events', () => {
+ return new Promise(resolve => {
+ if (!supportPointerEvent) {
+ expect().nothing()
+ resolve()
+ return
+ }
+
+ const style = '#fixture .pointer-event { touch-action: none !important; }'
+ fixtureEl.innerHTML += style
+
+ defineDocumentElementOntouchstart()
+ // eslint-disable-next-line no-new
+ new Swipe(swipeEl, {
+ leftCallback() {
+ expect().nothing()
+ deleteDocumentElementOntouchstart()
+ resolve()
+ }
+ })
+
+ mockSwipeGesture(swipeEl, {
+ pos: [300, 10],
+ deltaX: -300
+ }, 'pointer')
+ })
+ })
+ })
+
+ describe('Dispose', () => {
+ it('should call EventHandler.off', () => {
+ defineDocumentElementOntouchstart()
+ spyOn(EventHandler, 'off').and.callThrough()
+ const swipe = new Swipe(swipeEl)
+
+ swipe.dispose()
+ expect(EventHandler.off).toHaveBeenCalledWith(swipeEl, '.bs.swipe')
+ })
+
+ it('should destroy', () => {
+ const addEventSpy = spyOn(fixtureEl, 'addEventListener').and.callThrough()
+ const removeEventSpy = spyOn(EventHandler, 'off').and.callThrough()
+ defineDocumentElementOntouchstart()
+
+ const swipe = new Swipe(fixtureEl)
+
+ const expectedArgs =
+ swipe._supportPointerEvents ?
+ [
+ ['pointerdown', jasmine.any(Function), jasmine.any(Boolean)],
+ ['pointerup', jasmine.any(Function), jasmine.any(Boolean)]
+ ] :
+ [
+ ['touchstart', jasmine.any(Function), jasmine.any(Boolean)],
+ ['touchmove', jasmine.any(Function), jasmine.any(Boolean)],
+ ['touchend', jasmine.any(Function), jasmine.any(Boolean)]
+ ]
+
+ expect(addEventSpy.calls.allArgs()).toEqual(expectedArgs)
+
+ swipe.dispose()
+
+ expect(removeEventSpy).toHaveBeenCalledWith(fixtureEl, '.bs.swipe')
+ deleteDocumentElementOntouchstart()
+ })
+ })
+
+ describe('"isSupported" static', () => {
+ it('should return "true" if "touchstart" exists in document element)', () => {
+ Object.defineProperty(window.navigator, 'maxTouchPoints', () => 0)
+ defineDocumentElementOntouchstart()
+
+ expect(Swipe.isSupported()).toBeTrue()
+ })
+
+ it('should return "false" if "touchstart" not exists in document element and "navigator.maxTouchPoints" are zero (0)', () => {
+ Object.defineProperty(window.navigator, 'maxTouchPoints', () => 0)
+ deleteDocumentElementOntouchstart()
+
+ if ('ontouchstart' in document.documentElement) {
+ expect().nothing()
+ return
+ }
+
+ expect(Swipe.isSupported()).toBeFalse()
+ })
+ })
+})
diff --git a/js/tests/unit/util/template-factory.spec.js b/js/tests/unit/util/template-factory.spec.js
new file mode 100644
index 0000000..5e5724c
--- /dev/null
+++ b/js/tests/unit/util/template-factory.spec.js
@@ -0,0 +1,306 @@
+import { clearFixture, getFixture } from '../../helpers/fixture'
+import TemplateFactory from '../../../src/util/template-factory'
+
+describe('TemplateFactory', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('NAME', () => {
+ it('should return plugin NAME', () => {
+ expect(TemplateFactory.NAME).toEqual('TemplateFactory')
+ })
+ })
+
+ describe('Default', () => {
+ it('should return plugin default config', () => {
+ expect(TemplateFactory.Default).toEqual(jasmine.any(Object))
+ })
+ })
+
+ describe('toHtml', () => {
+ describe('Sanitization', () => {
+ it('should use "sanitizeHtml" to sanitize template', () => {
+ const factory = new TemplateFactory({
+ sanitize: true,
+ template: '<div><a href="javascript:alert(7)">Click me</a></div>'
+ })
+ const spy = spyOn(factory, '_maybeSanitize').and.callThrough()
+
+ expect(factory.toHtml().innerHTML).not.toContain('href="javascript:alert(7)')
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should not sanitize template', () => {
+ const factory = new TemplateFactory({
+ sanitize: false,
+ template: '<div><a href="javascript:alert(7)">Click me</a></div>'
+ })
+ const spy = spyOn(factory, '_maybeSanitize').and.callThrough()
+
+ expect(factory.toHtml().innerHTML).toContain('href="javascript:alert(7)')
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should use "sanitizeHtml" to sanitize content', () => {
+ const factory = new TemplateFactory({
+ sanitize: true,
+ html: true,
+ template: '<div id="foo"></div>',
+ content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' }
+ })
+ expect(factory.toHtml().innerHTML).not.toContain('href="javascript:alert(7)')
+ })
+
+ it('should not sanitize content', () => {
+ const factory = new TemplateFactory({
+ sanitize: false,
+ html: true,
+ template: '<div id="foo"></div>',
+ content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' }
+ })
+ expect(factory.toHtml().innerHTML).toContain('href="javascript:alert(7)')
+ })
+
+ it('should sanitize content only if "config.html" is enabled', () => {
+ const factory = new TemplateFactory({
+ sanitize: true,
+ html: false,
+ template: '<div id="foo"></div>',
+ content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' }
+ })
+ const spy = spyOn(factory, '_maybeSanitize').and.callThrough()
+
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('Extra Class', () => {
+ it('should add extra class', () => {
+ const factory = new TemplateFactory({
+ extraClass: 'testClass'
+ })
+ expect(factory.toHtml()).toHaveClass('testClass')
+ })
+
+ it('should add extra classes', () => {
+ const factory = new TemplateFactory({
+ extraClass: 'testClass testClass2'
+ })
+ expect(factory.toHtml()).toHaveClass('testClass')
+ expect(factory.toHtml()).toHaveClass('testClass2')
+ })
+
+ it('should resolve class if function is given', () => {
+ const factory = new TemplateFactory({
+ extraClass(arg) {
+ expect(arg).toEqual(factory)
+ return 'testClass'
+ }
+ })
+
+ expect(factory.toHtml()).toHaveClass('testClass')
+ })
+ })
+ })
+
+ describe('Content', () => {
+ it('add simple text content', () => {
+ const template = [
+ '<div>',
+ ' <div class="foo"></div>',
+ ' <div class="foo2"></div>',
+ '</div>'
+ ].join('')
+
+ const factory = new TemplateFactory({
+ template,
+ content: {
+ '.foo': 'bar',
+ '.foo2': 'bar2'
+ }
+ })
+
+ const html = factory.toHtml()
+ expect(html.querySelector('.foo').textContent).toEqual('bar')
+ expect(html.querySelector('.foo2').textContent).toEqual('bar2')
+ })
+
+ it('should not fill template if selector not exists', () => {
+ const factory = new TemplateFactory({
+ sanitize: true,
+ html: true,
+ template: '<div id="foo"></div>',
+ content: { '#bar': 'test' }
+ })
+
+ expect(factory.toHtml().outerHTML).toEqual('<div id="foo"></div>')
+ })
+
+ it('should remove template selector, if content is null', () => {
+ const factory = new TemplateFactory({
+ sanitize: true,
+ html: true,
+ template: '<div><div id="foo"></div></div>',
+ content: { '#foo': null }
+ })
+
+ expect(factory.toHtml().outerHTML).toEqual('<div></div>')
+ })
+
+ it('should resolve content if is function', () => {
+ const factory = new TemplateFactory({
+ sanitize: true,
+ html: true,
+ template: '<div><div id="foo"></div></div>',
+ content: { '#foo': () => null }
+ })
+
+ expect(factory.toHtml().outerHTML).toEqual('<div></div>')
+ })
+
+ it('if content is element and "config.html=false", should put content\'s textContent', () => {
+ fixtureEl.innerHTML = '<div>foo<span>bar</span></div>'
+ const contentElement = fixtureEl.querySelector('div')
+
+ const factory = new TemplateFactory({
+ html: false,
+ template: '<div><div id="foo"></div></div>',
+ content: { '#foo': contentElement }
+ })
+
+ const fooEl = factory.toHtml().querySelector('#foo')
+ expect(fooEl.innerHTML).not.toEqual(contentElement.innerHTML)
+ expect(fooEl.textContent).toEqual(contentElement.textContent)
+ expect(fooEl.textContent).toEqual('foobar')
+ })
+
+ it('if content is element and "config.html=true", should put content\'s outerHtml as child', () => {
+ fixtureEl.innerHTML = '<div>foo<span>bar</span></div>'
+ const contentElement = fixtureEl.querySelector('div')
+
+ const factory = new TemplateFactory({
+ html: true,
+ template: '<div><div id="foo"></div></div>',
+ content: { '#foo': contentElement }
+ })
+
+ const fooEl = factory.toHtml().querySelector('#foo')
+ expect(fooEl.innerHTML).toEqual(contentElement.outerHTML)
+ expect(fooEl.textContent).toEqual(contentElement.textContent)
+ })
+ })
+
+ describe('getContent', () => {
+ it('should get content as array', () => {
+ const factory = new TemplateFactory({
+ content: {
+ '.foo': 'bar',
+ '.foo2': 'bar2'
+ }
+ })
+ expect(factory.getContent()).toEqual(['bar', 'bar2'])
+ })
+
+ it('should filter empties', () => {
+ const factory = new TemplateFactory({
+ content: {
+ '.foo': 'bar',
+ '.foo2': '',
+ '.foo3': null,
+ '.foo4': () => 2,
+ '.foo5': () => null
+ }
+ })
+ expect(factory.getContent()).toEqual(['bar', 2])
+ })
+ })
+
+ describe('hasContent', () => {
+ it('should return true, if it has', () => {
+ const factory = new TemplateFactory({
+ content: {
+ '.foo': 'bar',
+ '.foo2': 'bar2',
+ '.foo3': ''
+ }
+ })
+ expect(factory.hasContent()).toBeTrue()
+ })
+
+ it('should return false, if filtered content is empty', () => {
+ const factory = new TemplateFactory({
+ content: {
+ '.foo2': '',
+ '.foo3': null,
+ '.foo4': () => null
+ }
+ })
+ expect(factory.hasContent()).toBeFalse()
+ })
+ })
+
+ describe('changeContent', () => {
+ it('should change Content', () => {
+ const template = [
+ '<div>',
+ ' <div class="foo"></div>',
+ ' <div class="foo2"></div>',
+ '</div>'
+ ].join('')
+
+ const factory = new TemplateFactory({
+ template,
+ content: {
+ '.foo': 'bar',
+ '.foo2': 'bar2'
+ }
+ })
+
+ const html = selector => factory.toHtml().querySelector(selector).textContent
+ expect(html('.foo')).toEqual('bar')
+ expect(html('.foo2')).toEqual('bar2')
+ factory.changeContent({
+ '.foo': 'test',
+ '.foo2': 'test2'
+ })
+
+ expect(html('.foo')).toEqual('test')
+ expect(html('.foo2')).toEqual('test2')
+ })
+
+ it('should change only the given, content', () => {
+ const template = [
+ '<div>',
+ ' <div class="foo"></div>',
+ ' <div class="foo2"></div>',
+ '</div>'
+ ].join('')
+
+ const factory = new TemplateFactory({
+ template,
+ content: {
+ '.foo': 'bar',
+ '.foo2': 'bar2'
+ }
+ })
+
+ const html = selector => factory.toHtml().querySelector(selector).textContent
+ expect(html('.foo')).toEqual('bar')
+ expect(html('.foo2')).toEqual('bar2')
+ factory.changeContent({
+ '.foo': 'test',
+ '.wrong': 'wrong'
+ })
+
+ expect(html('.foo')).toEqual('test')
+ expect(html('.foo2')).toEqual('bar2')
+ })
+ })
+})