',
+ '
',
+ '
',
+ ' Working Tab 1 (#tab1) Content Here',
+ '
',
+ '
',
+ ' Working Tab 2 (#2) with numeric ID',
+ '
',
+ '
'
+ ].join('')
+ const profileTriggerEl = fixtureEl.querySelector('#trigger2')
+ const tab = new Tab(profileTriggerEl)
+
+ profileTriggerEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelector(`#${CSS.escape('2')}`)).toHaveClass('active')
+ done()
+ })
+
+ tab.show()
+ })
+
+ it('should not fire shown when show is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '
'
+
+ const navEl = fixtureEl.querySelector('.nav > div')
+ const tab = new Tab(navEl)
+ const expectDone = () => {
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 30)
+ }
+
+ navEl.addEventListener('show.bs.tab', ev => {
+ ev.preventDefault()
+ expectDone()
+ })
+
+ navEl.addEventListener('shown.bs.tab', () => {
+ reject(new Error('should not trigger shown event'))
+ })
+
+ tab.show()
+ })
+ })
+
+ it('should not fire shown when tab is already active', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ '
',
+ '
'
+ ].join('')
+
+ const triggerActive = fixtureEl.querySelector('button.active')
+ const tab = new Tab(triggerActive)
+
+ triggerActive.addEventListener('shown.bs.tab', () => {
+ reject(new Error('should not trigger shown event'))
+ })
+
+ tab.show()
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 30)
+ })
+ })
+
+ it('show and shown events should reference correct relatedTarget', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ '
',
+ '
'
+ ].join('')
+
+ const secondTabTrigger = fixtureEl.querySelector('#triggerProfile')
+ const secondTab = new Tab(secondTabTrigger)
+
+ secondTabTrigger.addEventListener('show.bs.tab', ev => {
+ expect(ev.relatedTarget.getAttribute('data-bs-target')).toEqual('#home')
+ })
+
+ secondTabTrigger.addEventListener('shown.bs.tab', ev => {
+ expect(ev.relatedTarget.getAttribute('data-bs-target')).toEqual('#home')
+ expect(secondTabTrigger.getAttribute('aria-selected')).toEqual('true')
+ expect(fixtureEl.querySelector('button:not(.active)').getAttribute('aria-selected')).toEqual('false')
+ resolve()
+ })
+
+ secondTab.show()
+ })
+ })
+
+ it('should fire hide and hidden events', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const triggerList = fixtureEl.querySelectorAll('button')
+ 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.getAttribute('data-bs-target')).toEqual('#profile')
+ })
+
+ triggerList[0].addEventListener('hidden.bs.tab', ev => {
+ expect(hideCalled).toBeTrue()
+ expect(ev.relatedTarget.getAttribute('data-bs-target')).toEqual('#profile')
+ resolve()
+ })
+
+ firstTab.show()
+ })
+ })
+
+ it('should not fire hidden when hide is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const triggerList = fixtureEl.querySelectorAll('button')
+ const firstTab = new Tab(triggerList[0])
+ const secondTab = new Tab(triggerList[1])
+ const expectDone = () => {
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 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', () => {
+ reject(new Error('should not trigger hidden'))
+ })
+
+ firstTab.show()
+ })
+ })
+
+ it('should handle removed tabs', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' - ',
+ ' ',
+ ' ',
+ ' ',
+ '
',
+ ' - ',
+ ' ',
+ ' ',
+ ' ',
+ '
',
+ ' - ',
+ ' ',
+ ' ',
+ ' ',
+ '
',
+ '
',
+ '
',
+ '
test 1
',
+ '
test 2
',
+ '
test 3
',
+ '
'
+ ].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')).toHaveSize(2)
+ resolve()
+ })
+
+ btnCloseEl.addEventListener('click', () => {
+ const linkEl = btnCloseEl.parentNode
+ const liEl = linkEl.parentNode
+ const tabId = linkEl.getAttribute('href')
+ const tabIdEl = fixtureEl.querySelector(tabId)
+
+ liEl.remove()
+ tabIdEl.remove()
+ secondNavTab.show()
+ })
+
+ btnCloseEl.click()
+ })
+ })
+
+ it('should not focus on opened tab', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ '
',
+ '
'
+ ].join('')
+
+ const firstTab = fixtureEl.querySelector('#home')
+ firstTab.focus()
+
+ const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')
+ const tab = new Tab(profileTriggerEl)
+
+ profileTriggerEl.addEventListener('shown.bs.tab', () => {
+ expect(document.activeElement).toBe(firstTab)
+ expect(document.activeElement).not.toBe(profileTriggerEl)
+ resolve()
+ })
+
+ tab.show()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should dispose a tab', () => {
+ fixtureEl.innerHTML = '
'
+
+ const el = fixtureEl.querySelector('.nav > div')
+ const tab = new Tab(fixtureEl.querySelector('.nav > div'))
+
+ expect(Tab.getInstance(el)).not.toBeNull()
+
+ tab.dispose()
+
+ expect(Tab.getInstance(el)).toBeNull()
+ })
+ })
+
+ describe('_activate', () => {
+ it('should not be called if element argument is null', () => {
+ fixtureEl.innerHTML = [
+ '
'
+ ].join('')
+
+ const tabEl = fixtureEl.querySelector('.nav-link')
+ const tab = new Tab(tabEl)
+ const spy = jasmine.createSpy('spy')
+
+ const spyQueue = spyOn(tab, '_queueCallback')
+ tab._activate(null, spy)
+ expect(spyQueue).not.toHaveBeenCalled()
+ expect(spy).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('_setInitialAttributes', () => {
+ it('should put aria attributes', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ '
',
+ '
'
+ ].join('')
+
+ const tabEl = fixtureEl.querySelector('.nav-link')
+ const parent = fixtureEl.querySelector('.nav')
+ const children = fixtureEl.querySelectorAll('.nav-link')
+ const tabPanel = fixtureEl.querySelector('#panel')
+ const tabPanel2 = fixtureEl.querySelector('#panel2')
+
+ expect(parent.getAttribute('role')).toEqual(null)
+ expect(tabEl.getAttribute('role')).toEqual(null)
+ expect(tabPanel.getAttribute('role')).toEqual(null)
+ const tab = new Tab(tabEl)
+ tab._setInitialAttributes(parent, children)
+
+ expect(parent.getAttribute('role')).toEqual('tablist')
+ expect(tabEl.getAttribute('role')).toEqual('tab')
+
+ expect(tabPanel.getAttribute('role')).toEqual('tabpanel')
+ expect(tabPanel2.getAttribute('role')).toEqual('tabpanel')
+ expect(tabPanel.hasAttribute('tabindex')).toBeFalse()
+ expect(tabPanel.hasAttribute('tabindex2')).toBeFalse()
+
+ expect(tabPanel.getAttribute('aria-labelledby')).toEqual('foo')
+ expect(tabPanel2.hasAttribute('aria-labelledby')).toBeFalse()
+ })
+ })
+
+ describe('_keydown', () => {
+ it('if event is not one of left/right/up/down arrow, ignore it', () => {
+ fixtureEl.innerHTML = [
+ '
'
+ ].join('')
+
+ const tabEl = fixtureEl.querySelector('.nav-link')
+ const tab = new Tab(tabEl)
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'Enter'
+ const spyStop = spyOn(Event.prototype, 'stopPropagation').and.callThrough()
+ const spyPrevent = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+ const spyKeydown = spyOn(tab, '_keydown')
+ const spyGet = spyOn(tab, '_getChildren')
+
+ tabEl.dispatchEvent(keydown)
+ expect(spyKeydown).toHaveBeenCalled()
+ expect(spyGet).not.toHaveBeenCalled()
+
+ expect(spyStop).not.toHaveBeenCalled()
+ expect(spyPrevent).not.toHaveBeenCalled()
+ })
+
+ it('if keydown event is right/down arrow, handle it', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const tabEl1 = fixtureEl.querySelector('#tab1')
+ const tabEl2 = fixtureEl.querySelector('#tab2')
+ const tabEl3 = fixtureEl.querySelector('#tab3')
+ const tab1 = new Tab(tabEl1)
+ const tab2 = new Tab(tabEl2)
+ const tab3 = new Tab(tabEl3)
+ const spyShow1 = spyOn(tab1, 'show').and.callThrough()
+ const spyShow2 = spyOn(tab2, 'show').and.callThrough()
+ const spyShow3 = spyOn(tab3, 'show').and.callThrough()
+ const spyFocus1 = spyOn(tabEl1, 'focus').and.callThrough()
+ const spyFocus2 = spyOn(tabEl2, 'focus').and.callThrough()
+ const spyFocus3 = spyOn(tabEl3, 'focus').and.callThrough()
+
+ const spyStop = spyOn(Event.prototype, 'stopPropagation').and.callThrough()
+ const spyPrevent = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+
+ let keydown = createEvent('keydown')
+ keydown.key = 'ArrowRight'
+
+ tabEl1.dispatchEvent(keydown)
+ expect(spyShow2).toHaveBeenCalled()
+ expect(spyFocus2).toHaveBeenCalled()
+
+ keydown = createEvent('keydown')
+ keydown.key = 'ArrowDown'
+
+ tabEl2.dispatchEvent(keydown)
+ expect(spyShow3).toHaveBeenCalled()
+ expect(spyFocus3).toHaveBeenCalled()
+
+ tabEl3.dispatchEvent(keydown)
+ expect(spyShow1).toHaveBeenCalled()
+ expect(spyFocus1).toHaveBeenCalled()
+
+ expect(spyStop).toHaveBeenCalledTimes(3)
+ expect(spyPrevent).toHaveBeenCalledTimes(3)
+ })
+
+ it('if keydown event is left arrow, handle it', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const tabEl1 = fixtureEl.querySelector('#tab1')
+ const tabEl2 = fixtureEl.querySelector('#tab2')
+ const tab1 = new Tab(tabEl1)
+ const tab2 = new Tab(tabEl2)
+ const spyShow1 = spyOn(tab1, 'show').and.callThrough()
+ const spyShow2 = spyOn(tab2, 'show').and.callThrough()
+ const spyFocus1 = spyOn(tabEl1, 'focus').and.callThrough()
+ const spyFocus2 = spyOn(tabEl2, 'focus').and.callThrough()
+
+ const spyStop = spyOn(Event.prototype, 'stopPropagation').and.callThrough()
+ const spyPrevent = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+
+ let keydown = createEvent('keydown')
+ keydown.key = 'ArrowLeft'
+
+ tabEl2.dispatchEvent(keydown)
+ expect(spyShow1).toHaveBeenCalled()
+ expect(spyFocus1).toHaveBeenCalled()
+
+ keydown = createEvent('keydown')
+ keydown.key = 'ArrowUp'
+
+ tabEl1.dispatchEvent(keydown)
+ expect(spyShow2).toHaveBeenCalled()
+ expect(spyFocus2).toHaveBeenCalled()
+
+ expect(spyStop).toHaveBeenCalledTimes(2)
+ expect(spyPrevent).toHaveBeenCalledTimes(2)
+ })
+
+ it('if keydown event is Home, handle it', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const tabEl1 = fixtureEl.querySelector('#tab1')
+ const tabEl3 = fixtureEl.querySelector('#tab3')
+
+ const tab3 = new Tab(tabEl3)
+ tab3.show()
+
+ const spyShown = jasmine.createSpy()
+ tabEl1.addEventListener('shown.bs.tab', spyShown)
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'Home'
+
+ tabEl3.dispatchEvent(keydown)
+
+ expect(spyShown).toHaveBeenCalled()
+ })
+
+ it('if keydown event is End, handle it', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const tabEl1 = fixtureEl.querySelector('#tab1')
+ const tabEl3 = fixtureEl.querySelector('#tab3')
+
+ const tab1 = new Tab(tabEl1)
+ tab1.show()
+
+ const spyShown = jasmine.createSpy()
+ tabEl3.addEventListener('shown.bs.tab', spyShown)
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'End'
+
+ tabEl1.dispatchEvent(keydown)
+
+ expect(spyShown).toHaveBeenCalled()
+ })
+
+ it('if keydown event is right arrow and next element is disabled', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const tabEl1 = fixtureEl.querySelector('#tab1')
+ const tabEl2 = fixtureEl.querySelector('#tab2')
+ const tabEl3 = fixtureEl.querySelector('#tab3')
+ const tabEl4 = fixtureEl.querySelector('#tab4')
+ const tab1 = new Tab(tabEl1)
+ const tab2 = new Tab(tabEl2)
+ const tab3 = new Tab(tabEl3)
+ const tab4 = new Tab(tabEl4)
+ const spy1 = spyOn(tab1, 'show').and.callThrough()
+ const spy2 = spyOn(tab2, 'show').and.callThrough()
+ const spy3 = spyOn(tab3, 'show').and.callThrough()
+ const spy4 = spyOn(tab4, 'show').and.callThrough()
+ const spyFocus1 = spyOn(tabEl1, 'focus').and.callThrough()
+ const spyFocus2 = spyOn(tabEl2, 'focus').and.callThrough()
+ const spyFocus3 = spyOn(tabEl3, 'focus').and.callThrough()
+ const spyFocus4 = spyOn(tabEl4, 'focus').and.callThrough()
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowRight'
+
+ tabEl1.dispatchEvent(keydown)
+ expect(spy1).not.toHaveBeenCalled()
+ expect(spy2).not.toHaveBeenCalled()
+ expect(spy3).not.toHaveBeenCalled()
+ expect(spy4).toHaveBeenCalledTimes(1)
+ expect(spyFocus1).not.toHaveBeenCalled()
+ expect(spyFocus2).not.toHaveBeenCalled()
+ expect(spyFocus3).not.toHaveBeenCalled()
+ expect(spyFocus4).toHaveBeenCalledTimes(1)
+ })
+
+ it('if keydown event is left arrow and next element is disabled', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const tabEl1 = fixtureEl.querySelector('#tab1')
+ const tabEl2 = fixtureEl.querySelector('#tab2')
+ const tabEl3 = fixtureEl.querySelector('#tab3')
+ const tabEl4 = fixtureEl.querySelector('#tab4')
+ const tab1 = new Tab(tabEl1)
+ const tab2 = new Tab(tabEl2)
+ const tab3 = new Tab(tabEl3)
+ const tab4 = new Tab(tabEl4)
+ const spy1 = spyOn(tab1, 'show').and.callThrough()
+ const spy2 = spyOn(tab2, 'show').and.callThrough()
+ const spy3 = spyOn(tab3, 'show').and.callThrough()
+ const spy4 = spyOn(tab4, 'show').and.callThrough()
+ const spyFocus1 = spyOn(tabEl1, 'focus').and.callThrough()
+ const spyFocus2 = spyOn(tabEl2, 'focus').and.callThrough()
+ const spyFocus3 = spyOn(tabEl3, 'focus').and.callThrough()
+ const spyFocus4 = spyOn(tabEl4, 'focus').and.callThrough()
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'ArrowLeft'
+
+ tabEl4.dispatchEvent(keydown)
+ expect(spy4).not.toHaveBeenCalled()
+ expect(spy3).not.toHaveBeenCalled()
+ expect(spy2).not.toHaveBeenCalled()
+ expect(spy1).toHaveBeenCalledTimes(1)
+ expect(spyFocus4).not.toHaveBeenCalled()
+ expect(spyFocus3).not.toHaveBeenCalled()
+ expect(spyFocus2).not.toHaveBeenCalled()
+ expect(spyFocus1).toHaveBeenCalledTimes(1)
+ })
+
+ it('if keydown event is Home and first element is disabled', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const tabEl1 = fixtureEl.querySelector('#tab1')
+ const tabEl2 = fixtureEl.querySelector('#tab2')
+ const tabEl3 = fixtureEl.querySelector('#tab3')
+ const tab3 = new Tab(tabEl3)
+
+ tab3.show()
+
+ const spyShown1 = jasmine.createSpy()
+ const spyShown2 = jasmine.createSpy()
+ tabEl1.addEventListener('shown.bs.tab', spyShown1)
+ tabEl2.addEventListener('shown.bs.tab', spyShown2)
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'Home'
+
+ tabEl3.dispatchEvent(keydown)
+
+ expect(spyShown1).not.toHaveBeenCalled()
+ expect(spyShown2).toHaveBeenCalled()
+ })
+
+ it('if keydown event is End and last element is disabled', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ ' ',
+ '
'
+ ].join('')
+
+ const tabEl1 = fixtureEl.querySelector('#tab1')
+ const tabEl2 = fixtureEl.querySelector('#tab2')
+ const tabEl3 = fixtureEl.querySelector('#tab3')
+ const tab1 = new Tab(tabEl1)
+
+ tab1.show()
+
+ const spyShown2 = jasmine.createSpy()
+ const spyShown3 = jasmine.createSpy()
+ tabEl2.addEventListener('shown.bs.tab', spyShown2)
+ tabEl3.addEventListener('shown.bs.tab', spyShown3)
+
+ const keydown = createEvent('keydown')
+ keydown.key = 'End'
+
+ tabEl1.dispatchEvent(keydown)
+
+ expect(spyShown3).not.toHaveBeenCalled()
+ expect(spyShown2).toHaveBeenCalled()
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a tab', () => {
+ fixtureEl.innerHTML = '
'
+
+ const div = fixtureEl.querySelector('.nav > div')
+
+ jQueryMock.fn.tab = Tab.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tab.call(jQueryMock)
+
+ expect(Tab.getInstance(div)).not.toBeNull()
+ })
+
+ it('should not re create a tab', () => {
+ fixtureEl.innerHTML = '
'
+
+ const div = fixtureEl.querySelector('.nav > 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 = '
'
+
+ const div = fixtureEl.querySelector('.nav > div')
+ const tab = new Tab(div)
+
+ const spy = spyOn(tab, 'show')
+
+ jQueryMock.fn.tab = Tab.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tab.call(jQueryMock, 'show')
+
+ expect(Tab.getInstance(div)).toEqual(tab)
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '
'
+
+ const div = fixtureEl.querySelector('.nav > div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.tab = Tab.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.tab.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return null if there is no instance', () => {
+ expect(Tab.getInstance(fixtureEl)).toBeNull()
+ })
+
+ it('should return this instance', () => {
+ fixtureEl.innerHTML = '
'
+
+ const divEl = fixtureEl.querySelector('.nav > div')
+ const tab = new Tab(divEl)
+
+ expect(Tab.getInstance(divEl)).toEqual(tab)
+ expect(Tab.getInstance(divEl)).toBeInstanceOf(Tab)
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return tab instance', () => {
+ fixtureEl.innerHTML = '
'
+
+ const div = fixtureEl.querySelector('div')
+ const tab = new Tab(div)
+
+ expect(Tab.getOrCreateInstance(div)).toEqual(tab)
+ expect(Tab.getInstance(div)).toEqual(Tab.getOrCreateInstance(div, {}))
+ expect(Tab.getOrCreateInstance(div)).toBeInstanceOf(Tab)
+ })
+
+ it('should return new instance when there is no tab instance', () => {
+ fixtureEl.innerHTML = '
'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Tab.getInstance(div)).toBeNull()
+ expect(Tab.getOrCreateInstance(div)).toBeInstanceOf(Tab)
+ })
+ })
+
+ describe('data-api', () => {
+ it('should create dynamically a tab', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ '
',
+ '
'
+ ].join('')
+
+ const secondTabTrigger = fixtureEl.querySelector('#triggerProfile')
+
+ secondTabTrigger.addEventListener('shown.bs.tab', () => {
+ expect(secondTabTrigger).toHaveClass('active')
+ expect(fixtureEl.querySelector('#profile')).toHaveClass('active')
+ resolve()
+ })
+
+ secondTabTrigger.click()
+ })
+ })
+
+ it('selected tab should deactivate previous selected link in dropdown', () => {
+ fixtureEl.innerHTML = [
+ '
'
+ ].join('')
+
+ const firstLiLinkEl = fixtureEl.querySelector('li:first-child a')
+
+ firstLiLinkEl.click()
+ expect(firstLiLinkEl).toHaveClass('active')
+ expect(fixtureEl.querySelector('li:last-child a')).not.toHaveClass('active')
+ expect(fixtureEl.querySelector('li:last-child .dropdown-menu a:first-child')).not.toHaveClass('active')
+ })
+
+ it('selecting a dropdown tab does not activate another', () => {
+ const nav1 = [
+ '
'
+ ].join('')
+ const nav2 = [
+ '
'
+ ].join('')
+
+ fixtureEl.innerHTML = nav1 + nav2
+
+ const firstDropItem = fixtureEl.querySelector('#nav1 .dropdown-item')
+
+ firstDropItem.click()
+ expect(firstDropItem).toHaveClass('active')
+ expect(fixtureEl.querySelector('#nav1 .dropdown-toggle')).toHaveClass('active')
+ expect(fixtureEl.querySelector('#nav2 .dropdown-toggle')).not.toHaveClass('active')
+ expect(fixtureEl.querySelector('#nav2 .dropdown-item')).not.toHaveClass('active')
+ })
+
+ it('should support li > .dropdown-item', () => {
+ fixtureEl.innerHTML = [
+ '
'
+ ].join('')
+
+ const dropItems = fixtureEl.querySelectorAll('.dropdown-item')
+
+ dropItems[1].click()
+ expect(dropItems[0]).not.toHaveClass('active')
+ expect(dropItems[1]).toHaveClass('active')
+ expect(fixtureEl.querySelector('.nav-link')).not.toHaveClass('active')
+ })
+
+ it('should handle nested tabs', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '
',
+ '
',
+ '
',
+ '
',
+ '
',
+ '
Nested Tab1 Content
',
+ '
Nested Tab2 Content
',
+ '
',
+ '
',
+ '
Tab2 Content
',
+ '
Tab3 Content
',
+ '
'
+ ].join('')
+
+ const tab1El = fixtureEl.querySelector('#tab1')
+ const tabNested2El = fixtureEl.querySelector('#tabNested2')
+ const xTab1El = fixtureEl.querySelector('#x-tab1')
+
+ tabNested2El.addEventListener('shown.bs.tab', () => {
+ expect(xTab1El).toHaveClass('active')
+ resolve()
+ })
+
+ tab1El.addEventListener('shown.bs.tab', () => {
+ expect(xTab1El).toHaveClass('active')
+ tabNested2El.click()
+ })
+
+ tab1El.click()
+ })
+ })
+
+ it('should not remove fade class if no active pane is present', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ' ',
+ '
',
+ '
'
+ ].join('')
+
+ const triggerTabProfileEl = fixtureEl.querySelector('#tab-profile')
+ const triggerTabHomeEl = fixtureEl.querySelector('#tab-home')
+ const tabProfileEl = fixtureEl.querySelector('#profile')
+ const tabHomeEl = fixtureEl.querySelector('#home')
+
+ triggerTabHomeEl.addEventListener('shown.bs.tab', () => {
+ setTimeout(() => {
+ expect(tabProfileEl).toHaveClass('fade')
+ expect(tabProfileEl).not.toHaveClass('show')
+
+ expect(tabHomeEl).toHaveClass('fade')
+ expect(tabHomeEl).toHaveClass('show')
+
+ resolve()
+ }, 10)
+ })
+
+ triggerTabProfileEl.addEventListener('shown.bs.tab', () => {
+ setTimeout(() => {
+ expect(tabProfileEl).toHaveClass('fade')
+ expect(tabProfileEl).toHaveClass('show')
+ triggerTabHomeEl.click()
+ }, 10)
+ })
+
+ triggerTabProfileEl.click()
+ })
+ })
+
+ it('should add `show` class to tab panes if there is no `.fade` class', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' - ',
+ ' ',
+ '
',
+ ' - ',
+ ' ',
+ '
',
+ '
',
+ '
',
+ '
test 1
',
+ '
test 2
',
+ '
'
+ ].join('')
+
+ const secondNavEl = fixtureEl.querySelector('#secondNav')
+
+ secondNavEl.addEventListener('shown.bs.tab', () => {
+ expect(fixtureEl.querySelectorAll('.tab-content .show')).toHaveSize(1)
+ resolve()
+ })
+
+ secondNavEl.click()
+ })
+ })
+
+ it('should add show class to tab panes if there is a `.fade` class', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' - ',
+ ' ',
+ '
',
+ ' - ',
+ ' ',
+ '
',
+ '
',
+ '
',
+ '
test 1
',
+ '
test 2
',
+ '
'
+ ].join('')
+
+ const secondNavEl = fixtureEl.querySelector('#secondNav')
+
+ secondNavEl.addEventListener('shown.bs.tab', () => {
+ setTimeout(() => {
+ expect(fixtureEl.querySelectorAll('.show')).toHaveSize(1)
+ resolve()
+ }, 10)
+ })
+
+ secondNavEl.click()
+ })
+ })
+
+ it('should prevent default when the trigger is
or ', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ ''
+ ].join('')
+
+ const tabEl = fixtureEl.querySelector('[href="#test2"]')
+ const spy = spyOn(Event.prototype, 'preventDefault').and.callThrough()
+
+ tabEl.addEventListener('shown.bs.tab', () => {
+ expect(tabEl).toHaveClass('active')
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ tabEl.click()
+ })
+ })
+
+ it('should not fire shown when tab has disabled attribute', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ ' ',
+ '
',
+ ''
+ ].join('')
+
+ const triggerDisabled = fixtureEl.querySelector('button[disabled]')
+ triggerDisabled.addEventListener('shown.bs.tab', () => {
+ reject(new Error('should not trigger shown event'))
+ })
+
+ triggerDisabled.click()
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 30)
+ })
+ })
+
+ it('should not fire shown when tab has disabled class', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '',
+ ''
+ ].join('')
+
+ const triggerDisabled = fixtureEl.querySelector('a.disabled')
+
+ triggerDisabled.addEventListener('shown.bs.tab', () => {
+ reject(new Error('should not trigger shown event'))
+ })
+
+ triggerDisabled.click()
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 30)
+ })
+ })
+ })
+})
diff --git a/bootstrap/js/tests/unit/toast.spec.js b/bootstrap/js/tests/unit/toast.spec.js
new file mode 100644
index 0000000..200fe3e
--- /dev/null
+++ b/bootstrap/js/tests/unit/toast.spec.js
@@ -0,0 +1,672 @@
+import Toast from '../../src/toast.js'
+import {
+ clearFixture, createEvent, getFixture, jQueryMock
+} from '../helpers/fixture.js'
+
+describe('Toast', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('VERSION', () => {
+ it('should return plugin version', () => {
+ expect(Toast.VERSION).toEqual(jasmine.any(String))
+ })
+ })
+
+ describe('DATA_KEY', () => {
+ it('should return plugin data key', () => {
+ expect(Toast.DATA_KEY).toEqual('bs.toast')
+ })
+ })
+
+ describe('constructor', () => {
+ it('should take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = ''
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toastBySelector = new Toast('.toast')
+ const toastByElement = new Toast(toastEl)
+
+ expect(toastBySelector._element).toEqual(toastEl)
+ expect(toastByElement._element).toEqual(toastEl)
+ })
+
+ it('should allow to config in js', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ '
',
+ ' a simple toast',
+ '
',
+ '
'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl, {
+ delay: 1
+ })
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ expect(toastEl).toHaveClass('show')
+ resolve()
+ })
+
+ toast.show()
+ })
+ })
+
+ it('should close toast when close element with data-bs-dismiss attribute is set', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ '
'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl)
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ expect(toastEl).toHaveClass('show')
+
+ const button = toastEl.querySelector('.btn-close')
+
+ button.click()
+ })
+
+ toastEl.addEventListener('hidden.bs.toast', () => {
+ expect(toastEl).not.toHaveClass('show')
+ resolve()
+ })
+
+ toast.show()
+ })
+ })
+ })
+
+ describe('Default', () => {
+ it('should expose default setting to allow to override them', () => {
+ const defaultDelay = 1000
+
+ Toast.Default.delay = defaultDelay
+
+ fixtureEl.innerHTML = [
+ '',
+ ' ',
+ '
'
+ ].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', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ '
',
+ ' a simple toast',
+ '
',
+ '
'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ toastEl.addEventListener('hidden.bs.toast', () => {
+ expect(toastEl).not.toHaveClass('show')
+ resolve()
+ })
+
+ toast.show()
+ })
+ })
+
+ it('should not add fade class', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ '
',
+ ' a simple toast',
+ '
',
+ '
'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ expect(toastEl).not.toHaveClass('fade')
+ resolve()
+ })
+
+ toast.show()
+ })
+ })
+
+ it('should not trigger shown if show is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '',
+ '
',
+ ' a simple toast',
+ '
',
+ '
'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ const assertDone = () => {
+ setTimeout(() => {
+ expect(toastEl).not.toHaveClass('show')
+ resolve()
+ }, 20)
+ }
+
+ toastEl.addEventListener('show.bs.toast', event => {
+ event.preventDefault()
+ assertDone()
+ })
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ reject(new Error('shown event should not be triggered if show is prevented'))
+ })
+
+ toast.show()
+ })
+ })
+
+ it('should clear timeout if toast is shown again before it is hidden', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ '
',
+ ' a simple toast',
+ '
',
+ '
'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ setTimeout(() => {
+ toast._config.autohide = false
+ toastEl.addEventListener('shown.bs.toast', () => {
+ expect(spy).toHaveBeenCalled()
+ expect(toast._timeout).toBeNull()
+ resolve()
+ })
+ toast.show()
+ }, toast._config.delay / 2)
+
+ const spy = spyOn(toast, '_clearTimeout').and.callThrough()
+
+ toast.show()
+ })
+ })
+
+ it('should clear timeout if toast is interacted with mouse', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ '
',
+ ' a simple toast',
+ '
',
+ '
'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+ const spy = spyOn(toast, '_clearTimeout').and.callThrough()
+
+ setTimeout(() => {
+ spy.calls.reset()
+
+ toastEl.addEventListener('mouseover', () => {
+ expect(toast._clearTimeout).toHaveBeenCalledTimes(1)
+ expect(toast._timeout).toBeNull()
+ resolve()
+ })
+
+ const mouseOverEvent = createEvent('mouseover')
+ toastEl.dispatchEvent(mouseOverEvent)
+ }, toast._config.delay / 2)
+
+ toast.show()
+ })
+ })
+
+ it('should clear timeout if toast is interacted with keyboard', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ '',
+ '
',
+ ' a simple toast',
+ ' ',
+ '
',
+ '
'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+ const spy = spyOn(toast, '_clearTimeout').and.callThrough()
+
+ setTimeout(() => {
+ spy.calls.reset()
+
+ toastEl.addEventListener('focusin', () => {
+ expect(toast._clearTimeout).toHaveBeenCalledTimes(1)
+ expect(toast._timeout).toBeNull()
+ resolve()
+ })
+
+ const insideFocusable = toastEl.querySelector('button')
+ insideFocusable.focus()
+ }, toast._config.delay / 2)
+
+ toast.show()
+ })
+ })
+
+ it('should still auto hide after being interacted with mouse and keyboard', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ '',
+ '
',
+ ' a simple toast',
+ ' ',
+ '
',
+ '
'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ setTimeout(() => {
+ toastEl.addEventListener('mouseover', () => {
+ const insideFocusable = toastEl.querySelector('button')
+ insideFocusable.focus()
+ })
+
+ toastEl.addEventListener('focusin', () => {
+ const mouseOutEvent = createEvent('mouseout')
+ toastEl.dispatchEvent(mouseOutEvent)
+ })
+
+ toastEl.addEventListener('mouseout', () => {
+ const outsideFocusable = document.getElementById('outside-focusable')
+ outsideFocusable.focus()
+ })
+
+ toastEl.addEventListener('focusout', () => {
+ expect(toast._timeout).not.toBeNull()
+ resolve()
+ })
+
+ const mouseOverEvent = createEvent('mouseover')
+ toastEl.dispatchEvent(mouseOverEvent)
+ }, toast._config.delay / 2)
+
+ toast.show()
+ })
+ })
+
+ it('should not auto hide if focus leaves but mouse pointer remains inside', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ '',
+ '
',
+ ' a simple toast',
+ ' ',
+ '
',
+ '
'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ setTimeout(() => {
+ toastEl.addEventListener('mouseover', () => {
+ const insideFocusable = toastEl.querySelector('button')
+ insideFocusable.focus()
+ })
+
+ toastEl.addEventListener('focusin', () => {
+ const outsideFocusable = document.getElementById('outside-focusable')
+ outsideFocusable.focus()
+ })
+
+ toastEl.addEventListener('focusout', () => {
+ expect(toast._timeout).toBeNull()
+ resolve()
+ })
+
+ const mouseOverEvent = createEvent('mouseover')
+ toastEl.dispatchEvent(mouseOverEvent)
+ }, toast._config.delay / 2)
+
+ toast.show()
+ })
+ })
+
+ it('should not auto hide if mouse pointer leaves but focus remains inside', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ '',
+ '
',
+ ' a simple toast',
+ ' ',
+ '
',
+ '
'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ setTimeout(() => {
+ toastEl.addEventListener('mouseover', () => {
+ const insideFocusable = toastEl.querySelector('button')
+ insideFocusable.focus()
+ })
+
+ toastEl.addEventListener('focusin', () => {
+ const mouseOutEvent = createEvent('mouseout')
+ toastEl.dispatchEvent(mouseOutEvent)
+ })
+
+ toastEl.addEventListener('mouseout', () => {
+ expect(toast._timeout).toBeNull()
+ resolve()
+ })
+
+ const mouseOverEvent = createEvent('mouseover')
+ toastEl.dispatchEvent(mouseOverEvent)
+ }, toast._config.delay / 2)
+
+ toast.show()
+ })
+ })
+ })
+
+ describe('hide', () => {
+ it('should allow to hide toast manually', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ '
',
+ ' a simple toast',
+ '
',
+ '
'
+ ].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).not.toHaveClass('show')
+ resolve()
+ })
+
+ toast.show()
+ })
+ })
+
+ it('should do nothing when we call hide on a non shown toast', () => {
+ fixtureEl.innerHTML = ''
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl)
+
+ const spy = spyOn(toastEl.classList, 'contains')
+
+ toast.hide()
+
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should not trigger hidden if hide is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = [
+ '',
+ '
',
+ ' a simple toast',
+ '
',
+ '
'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('.toast')
+ const toast = new Toast(toastEl)
+
+ const assertDone = () => {
+ setTimeout(() => {
+ expect(toastEl).toHaveClass('show')
+ resolve()
+ }, 20)
+ }
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ toast.hide()
+ })
+
+ toastEl.addEventListener('hide.bs.toast', event => {
+ event.preventDefault()
+ assertDone()
+ })
+
+ toastEl.addEventListener('hidden.bs.toast', () => {
+ reject(new Error('hidden event should not be triggered if hide is prevented'))
+ })
+
+ toast.show()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should allow to destroy toast', () => {
+ fixtureEl.innerHTML = ''
+
+ const toastEl = fixtureEl.querySelector('div')
+
+ const toast = new Toast(toastEl)
+
+ expect(Toast.getInstance(toastEl)).not.toBeNull()
+
+ toast.dispose()
+
+ expect(Toast.getInstance(toastEl)).toBeNull()
+ })
+
+ it('should allow to destroy toast and hide it before that', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ '
',
+ ' a simple toast',
+ '
',
+ '
'
+ ].join('')
+
+ const toastEl = fixtureEl.querySelector('div')
+ const toast = new Toast(toastEl)
+ const expected = () => {
+ expect(toastEl).toHaveClass('show')
+ expect(Toast.getInstance(toastEl)).not.toBeNull()
+
+ toast.dispose()
+
+ expect(Toast.getInstance(toastEl)).toBeNull()
+ expect(toastEl).not.toHaveClass('show')
+
+ resolve()
+ }
+
+ toastEl.addEventListener('shown.bs.toast', () => {
+ setTimeout(expected, 1)
+ })
+
+ toast.show()
+ })
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a toast', () => {
+ fixtureEl.innerHTML = ''
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.toast = Toast.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.toast.call(jQueryMock)
+
+ expect(Toast.getInstance(div)).not.toBeNull()
+ })
+
+ it('should not re create a toast', () => {
+ fixtureEl.innerHTML = ''
+
+ 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 = ''
+
+ const div = fixtureEl.querySelector('div')
+ const toast = new Toast(div)
+
+ const spy = spyOn(toast, 'show')
+
+ jQueryMock.fn.toast = Toast.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.toast.call(jQueryMock, 'show')
+
+ expect(Toast.getInstance(div)).toEqual(toast)
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = ''
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.toast = Toast.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.toast.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return a toast instance', () => {
+ fixtureEl.innerHTML = ''
+
+ const div = fixtureEl.querySelector('div')
+ const toast = new Toast(div)
+
+ expect(Toast.getInstance(div)).toEqual(toast)
+ expect(Toast.getInstance(div)).toBeInstanceOf(Toast)
+ })
+
+ it('should return null when there is no toast instance', () => {
+ fixtureEl.innerHTML = ''
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Toast.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return toast instance', () => {
+ fixtureEl.innerHTML = ''
+
+ const div = fixtureEl.querySelector('div')
+ const toast = new Toast(div)
+
+ expect(Toast.getOrCreateInstance(div)).toEqual(toast)
+ expect(Toast.getInstance(div)).toEqual(Toast.getOrCreateInstance(div, {}))
+ expect(Toast.getOrCreateInstance(div)).toBeInstanceOf(Toast)
+ })
+
+ it('should return new instance when there is no toast instance', () => {
+ fixtureEl.innerHTML = ''
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Toast.getInstance(div)).toBeNull()
+ expect(Toast.getOrCreateInstance(div)).toBeInstanceOf(Toast)
+ })
+
+ it('should return new instance when there is no toast instance with given configuration', () => {
+ fixtureEl.innerHTML = ''
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Toast.getInstance(div)).toBeNull()
+ const toast = Toast.getOrCreateInstance(div, {
+ delay: 1
+ })
+ expect(toast).toBeInstanceOf(Toast)
+
+ expect(toast._config.delay).toEqual(1)
+ })
+
+ it('should return the instance when exists without given configuration', () => {
+ fixtureEl.innerHTML = ''
+
+ const div = fixtureEl.querySelector('div')
+ const toast = new Toast(div, {
+ delay: 1
+ })
+ expect(Toast.getInstance(div)).toEqual(toast)
+
+ const toast2 = Toast.getOrCreateInstance(div, {
+ delay: 2
+ })
+ expect(toast).toBeInstanceOf(Toast)
+ expect(toast2).toEqual(toast)
+
+ expect(toast2._config.delay).toEqual(1)
+ })
+ })
+})
diff --git a/bootstrap/js/tests/unit/tooltip.spec.js b/bootstrap/js/tests/unit/tooltip.spec.js
new file mode 100644
index 0000000..ceb8de4
--- /dev/null
+++ b/bootstrap/js/tests/unit/tooltip.spec.js
@@ -0,0 +1,1553 @@
+import EventHandler from '../../src/dom/event-handler.js'
+import Tooltip from '../../src/tooltip.js'
+import { noop } from '../../src/util/index.js'
+import {
+ clearFixture, createEvent, getFixture, jQueryMock
+} from '../helpers/fixture.js'
+
+describe('Tooltip', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+
+ for (const tooltipEl of document.querySelectorAll('.tooltip')) {
+ tooltipEl.remove()
+ }
+ })
+
+ 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_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 take care of element either passed as a CSS selector or DOM element', () => {
+ fixtureEl.innerHTML = ''
+
+ const tooltipEl = fixtureEl.querySelector('#tooltipEl')
+ const tooltipBySelector = new Tooltip('#tooltipEl')
+ const tooltipByElement = new Tooltip(tooltipEl)
+
+ expect(tooltipBySelector._element).toEqual(tooltipEl)
+ expect(tooltipByElement._element).toEqual(tooltipEl)
+ })
+
+ it('should not take care of disallowed data attributes', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._config.sanitize).toBeTrue()
+ })
+
+ it('should convert title and content to string if numbers', () => {
+ fixtureEl.innerHTML = '
'
+
+ 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', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const containerEl = fixtureEl.querySelector('div')
+ const tooltipContainer = new Tooltip(containerEl, {
+ selector: 'a[rel="tooltip"]',
+ trigger: 'click'
+ })
+
+ containerEl.innerHTML = '
'
+
+ const tooltipInContainerEl = containerEl.querySelector('a')
+
+ tooltipInContainerEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ tooltipContainer.dispose()
+ resolve()
+ })
+
+ tooltipInContainerEl.click()
+ })
+ })
+
+ it('should create offset modifier when offset is passed as a function', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const getOffset = jasmine.createSpy('getOffset').and.returnValue([10, 20])
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ offset: getOffset,
+ popperConfig: {
+ onFirstUpdate(state) {
+ expect(getOffset).toHaveBeenCalledWith({
+ popper: state.rects.popper,
+ reference: state.rects.reference,
+ placement: state.placement
+ }, tooltipEl)
+ resolve()
+ }
+ }
+ })
+
+ const offset = tooltip._getOffset()
+
+ expect(offset).toEqual(jasmine.any(Function))
+
+ tooltip.show()
+ })
+ })
+
+ it('should create offset modifier when offset option is passed in data attribute', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._getOffset()).toEqual([10, 20])
+ })
+
+ it('should allow to pass config to Popper with `popperConfig`', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ popperConfig: {
+ placement: 'left'
+ }
+ })
+
+ const popperConfig = tooltip._getPopperConfig('top')
+
+ expect(popperConfig.placement).toEqual('left')
+ })
+
+ it('should allow to pass config to Popper with `popperConfig` as a function', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const getPopperConfig = jasmine.createSpy('getPopperConfig').and.returnValue({ placement: 'left' })
+ const tooltip = new Tooltip(tooltipEl, {
+ popperConfig: getPopperConfig
+ })
+
+ const popperConfig = tooltip._getPopperConfig('top')
+
+ expect(getPopperConfig).toHaveBeenCalled()
+ expect(popperConfig.placement).toEqual('left')
+ })
+
+ it('should use original title, if not "data-bs-title" is given', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._getTitle()).toEqual('Another tooltip')
+ })
+ })
+
+ describe('enable', () => {
+ it('should enable a tooltip', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.enable()
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+ })
+
+ describe('disable', () => {
+ it('should disable tooltip', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.disable()
+
+ tooltipEl.addEventListener('show.bs.tooltip', () => {
+ reject(new Error('should not show a disabled tooltip'))
+ })
+
+ tooltip.show()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ })
+ })
+ })
+
+ describe('toggleEnabled', () => {
+ it('should toggle enabled', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._isEnabled).toBeTrue()
+
+ tooltip.toggleEnabled()
+
+ expect(tooltip._isEnabled).toBeFalse()
+ })
+ })
+
+ describe('toggle', () => {
+ it('should do nothing if disabled', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.disable()
+
+ tooltipEl.addEventListener('show.bs.tooltip', () => {
+ reject(new Error('should not show a disabled tooltip'))
+ })
+
+ tooltip.toggle()
+
+ setTimeout(() => {
+ expect().nothing()
+ resolve()
+ }, 10)
+ })
+ })
+
+ it('should show a tooltip', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ resolve()
+ })
+
+ tooltip.toggle()
+ })
+ })
+
+ it('should call toggle and show the tooltip when trigger is "click"', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ trigger: 'click'
+ })
+
+ const spy = spyOn(tooltip, 'toggle').and.callThrough()
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ tooltipEl.click()
+ })
+ })
+
+ it('should hide a tooltip', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ 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()
+ resolve()
+ })
+
+ tooltip.toggle()
+ })
+ })
+
+ it('should call toggle and hide the tooltip when trigger is "click"', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ trigger: 'click'
+ })
+
+ const spy = spyOn(tooltip, 'toggle').and.callThrough()
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ tooltipEl.click()
+ })
+
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ tooltipEl.click()
+ })
+ })
+ })
+
+ describe('dispose', () => {
+ it('should destroy a tooltip', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const addEventSpy = spyOn(tooltipEl, 'addEventListener').and.callThrough()
+ const removeEventSpy = spyOn(tooltipEl, 'removeEventListener').and.callThrough()
+
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(Tooltip.getInstance(tooltipEl)).toEqual(tooltip)
+
+ const expectedArgs = [
+ ['mouseover', jasmine.any(Function), jasmine.any(Boolean)],
+ ['mouseout', jasmine.any(Function), jasmine.any(Boolean)],
+ ['focusin', jasmine.any(Function), jasmine.any(Boolean)],
+ ['focusout', jasmine.any(Function), jasmine.any(Boolean)]
+ ]
+
+ expect(addEventSpy.calls.allArgs()).toEqual(expectedArgs)
+
+ tooltip.dispose()
+
+ expect(Tooltip.getInstance(tooltipEl)).toBeNull()
+ expect(removeEventSpy.calls.allArgs()).toEqual(expectedArgs)
+ })
+
+ it('should destroy a tooltip after it is shown and hidden', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ tooltip.hide()
+ })
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ tooltip.dispose()
+ expect(tooltip.tip).toBeNull()
+ expect(Tooltip.getInstance(tooltipEl)).toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should destroy a tooltip and remove it from the dom', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+
+ tooltip.dispose()
+
+ expect(document.querySelector('.tooltip')).toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should destroy a tooltip and reset it\'s initial title', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ '
'
+ ].join('')
+
+ const tooltipWithTitleEl = fixtureEl.querySelector('#tooltipWithTitle')
+ const tooltip = new Tooltip('#tooltipWithTitle')
+ expect(tooltipWithTitleEl.getAttribute('title')).toBeNull()
+ tooltip.dispose()
+ expect(tooltipWithTitleEl.getAttribute('title')).toBe('tooltipTitle')
+
+ const tooltipWithoutTitleEl = fixtureEl.querySelector('#tooltipWithoutTitle')
+ const tooltip2 = new Tooltip('#tooltipWithTitle')
+ expect(tooltipWithoutTitleEl.getAttribute('title')).toBeNull()
+ tooltip2.dispose()
+ expect(tooltipWithoutTitleEl.getAttribute('title')).toBeNull()
+ })
+ })
+
+ describe('show', () => {
+ it('should show a tooltip', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown).not.toBeNull()
+ expect(tooltipEl.getAttribute('aria-describedby')).toEqual(tooltipShown.getAttribute('id'))
+ expect(tooltipShown.getAttribute('id')).toContain('tooltip')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip when hovering a child element', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ''
+ ].join('')
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ const spy = spyOn(tooltip, 'show')
+
+ tooltipEl.querySelector('rect').dispatchEvent(createEvent('mouseover', { bubbles: true }))
+
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ }, 0)
+ })
+ })
+
+ it('should show a tooltip on mobile', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+ document.documentElement.ontouchstart = noop
+
+ const spy = spyOn(EventHandler, 'on').and.callThrough()
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ expect(spy).toHaveBeenCalledWith(jasmine.any(Object), 'mouseover', noop)
+ document.documentElement.ontouchstart = undefined
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip relative to placement option', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ placement: 'bottom'
+ })
+
+ tooltipEl.addEventListener('inserted.bs.tooltip', () => {
+ expect(tooltip._getTipElement()).toHaveClass('bs-tooltip-auto')
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(tooltip._getTipElement()).toHaveClass('bs-tooltip-auto')
+ expect(tooltip._getTipElement().getAttribute('data-popper-placement')).toEqual('bottom')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should not error when trying to show a tooltip that has been removed from the dom', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ const firstCallback = () => {
+ tooltipEl.removeEventListener('shown.bs.tooltip', firstCallback)
+ let tooltipShown = document.querySelector('.tooltip')
+
+ tooltipShown.remove()
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown).not.toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ }
+
+ tooltipEl.addEventListener('shown.bs.tooltip', firstCallback)
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip with a dom element container', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ container: fixtureEl
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(fixtureEl.querySelector('.tooltip')).not.toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip with a jquery element container', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ container: {
+ 0: fixtureEl,
+ jquery: 'jQuery'
+ }
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(fixtureEl.querySelector('.tooltip')).not.toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip with a selector in container', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ container: '#fixture'
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(fixtureEl.querySelector('.tooltip')).not.toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip with placement as a function', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ 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')).not.toBeNull()
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip without the animation', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ animation: false
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tip = document.querySelector('.tooltip')
+
+ expect(tip).not.toBeNull()
+ expect(tip).not.toHaveClass('fade')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should throw an error the element is not visible', () => {
+ fixtureEl.innerHTML = '
'
+
+ 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', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ const expectedDone = () => {
+ setTimeout(() => {
+ expect(document.querySelector('.tooltip')).toBeNull()
+ resolve()
+ }, 10)
+ }
+
+ tooltipEl.addEventListener('show.bs.tooltip', ev => {
+ ev.preventDefault()
+ expectedDone()
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ reject(new Error('Tooltip should not be shown'))
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show tooltip if leave event hasn\'t occurred before delay expires', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ delay: 150
+ })
+
+ const spy = spyOn(tooltip, 'show')
+
+ setTimeout(() => {
+ expect(spy).not.toHaveBeenCalled()
+ }, 100)
+
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ }, 200)
+
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ })
+ })
+
+ it('should not show tooltip if leave event occurs before delay expires', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ delay: 150
+ })
+
+ const spy = spyOn(tooltip, 'show')
+
+ setTimeout(() => {
+ expect(spy).not.toHaveBeenCalled()
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ }, 100)
+
+ setTimeout(() => {
+ expect(spy).toHaveBeenCalled()
+ expect(document.querySelectorAll('.tooltip')).toHaveSize(0)
+ resolve()
+ }, 200)
+
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ })
+ })
+
+ it('should not hide tooltip if leave event occurs and enter event occurs within the hide delay', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._config.delay).toEqual({ show: 0, hide: 150 })
+
+ setTimeout(() => {
+ expect(tooltip._getTipElement()).toHaveClass('show')
+ tooltipEl.dispatchEvent(createEvent('mouseout'))
+
+ setTimeout(() => {
+ expect(tooltip._getTipElement()).toHaveClass('show')
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ }, 100)
+
+ setTimeout(() => {
+ expect(tooltip._getTipElement()).toHaveClass('show')
+ expect(document.querySelectorAll('.tooltip')).toHaveSize(1)
+ resolve()
+ }, 200)
+ }, 10)
+
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ })
+ })
+
+ it('should not hide tooltip if leave event occurs and interaction remains inside trigger', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = [
+ '',
+ 'Trigger',
+ 'the tooltip',
+ ''
+ ].join('')
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+ const triggerChild = tooltipEl.querySelector('b')
+
+ const spy = spyOn(tooltip, 'hide').and.callThrough()
+
+ tooltipEl.addEventListener('mouseover', () => {
+ const moveMouseToChildEvent = createEvent('mouseout')
+ Object.defineProperty(moveMouseToChildEvent, 'relatedTarget', {
+ value: triggerChild
+ })
+
+ tooltipEl.dispatchEvent(moveMouseToChildEvent)
+ })
+
+ tooltipEl.addEventListener('mouseout', () => {
+ expect(spy).not.toHaveBeenCalled()
+ resolve()
+ })
+
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ })
+ })
+
+ it('should properly maintain tooltip state if leave event occurs and enter event occurs during hide transition', () => {
+ return new Promise(resolve => {
+ // Style this tooltip to give it plenty of room for popper to do what it wants
+ fixtureEl.innerHTML = '
Trigger'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ spyOn(window, 'getComputedStyle').and.returnValue({
+ transitionDuration: '0.15s',
+ transitionDelay: '0s'
+ })
+
+ setTimeout(() => {
+ expect(tooltip._popper).not.toBeNull()
+ expect(tooltip._getTipElement().getAttribute('data-popper-placement')).toEqual('top')
+ tooltipEl.dispatchEvent(createEvent('mouseout'))
+
+ setTimeout(() => {
+ expect(tooltip._getTipElement()).not.toHaveClass('show')
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ }, 100)
+
+ setTimeout(() => {
+ expect(tooltip._popper).not.toBeNull()
+ expect(tooltip._getTipElement().getAttribute('data-popper-placement')).toEqual('top')
+ resolve()
+ }, 200)
+ }, 10)
+
+ tooltipEl.dispatchEvent(createEvent('mouseover'))
+ })
+ })
+
+ it('should only trigger inserted event if a new tooltip element was created', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ spyOn(window, 'getComputedStyle').and.returnValue({
+ transitionDuration: '0.15s',
+ transitionDelay: '0s'
+ })
+
+ const insertedFunc = jasmine.createSpy()
+ tooltipEl.addEventListener('inserted.bs.tooltip', insertedFunc)
+
+ setTimeout(() => {
+ expect(insertedFunc).toHaveBeenCalledTimes(1)
+ tooltip.hide()
+
+ setTimeout(() => {
+ tooltip.show()
+ }, 100)
+
+ setTimeout(() => {
+ expect(insertedFunc).toHaveBeenCalledTimes(2)
+ resolve()
+ }, 200)
+ }, 0)
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip with custom class provided in data attributes', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tip = document.querySelector('.tooltip')
+ expect(tip).not.toBeNull()
+ expect(tip).toHaveClass('custom-class')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip with custom class provided as a string in config', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ customClass: 'custom-class custom-class-2'
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tip = document.querySelector('.tooltip')
+ expect(tip).not.toBeNull()
+ expect(tip).toHaveClass('custom-class')
+ expect(tip).toHaveClass('custom-class-2')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should show a tooltip with custom class provided as a function in config', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const spy = jasmine.createSpy('customClass').and.returnValue('custom-class')
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ customClass: spy
+ })
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tip = document.querySelector('.tooltip')
+ expect(tip).not.toBeNull()
+ expect(spy).toHaveBeenCalled()
+ expect(tip).toHaveClass('custom-class')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should remove `title` attribute if exists', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ expect(tooltipEl.getAttribute('title')).toBeNull()
+ resolve()
+ })
+ tooltip.show()
+ })
+ })
+ })
+
+ describe('hide', () => {
+ it('should hide a tooltip', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ 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()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should hide a tooltip on mobile', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+ const spy = spyOn(EventHandler, 'off')
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ document.documentElement.ontouchstart = noop
+ tooltip.hide()
+ })
+
+ tooltipEl.addEventListener('hidden.bs.tooltip', () => {
+ expect(document.querySelector('.tooltip')).toBeNull()
+ expect(spy).toHaveBeenCalledWith(jasmine.any(Object), 'mouseover', noop)
+ document.documentElement.ontouchstart = undefined
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should hide a tooltip without animation', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ 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()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should not hide a tooltip if hide event is prevented', () => {
+ return new Promise((resolve, reject) => {
+ fixtureEl.innerHTML = '
'
+
+ const assertDone = () => {
+ setTimeout(() => {
+ expect(document.querySelector('.tooltip')).not.toBeNull()
+ resolve()
+ }, 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', () => {
+ reject(new Error('should not trigger hidden event'))
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should not throw error running hide if popper hasn\'t been shown', () => {
+ fixtureEl.innerHTML = '
'
+
+ const div = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(div)
+
+ try {
+ tooltip.hide()
+ expect().nothing()
+ } catch {
+ throw new Error('should not throw error')
+ }
+ })
+ })
+
+ describe('update', () => {
+ it('should call popper update', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const spy = spyOn(tooltip._popper, 'update')
+
+ tooltip.update()
+
+ expect(spy).toHaveBeenCalled()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should do nothing if the tooltip is not shown', () => {
+ fixtureEl.innerHTML = '
'
+
+ 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 = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._isWithContent()).toBeTrue()
+ })
+
+ it('should return false if there is no content', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._isWithContent()).toBeFalse()
+ })
+ })
+
+ describe('_getTipElement', () => {
+ it('should create the tip element and return it', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ const spy = spyOn(document, 'createElement').and.callThrough()
+
+ expect(tooltip._getTipElement()).toBeDefined()
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should return the created tip element', () => {
+ fixtureEl.innerHTML = '
'
+
+ 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 = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, { animation: false })
+
+ const tip = tooltip._getTipElement()
+
+ tooltip.setContent(tip)
+
+ expect(tip).not.toHaveClass('show')
+ expect(tip).not.toHaveClass('fade')
+ expect(tip.querySelector('.tooltip-inner').textContent).toEqual('Another tooltip')
+ })
+
+ it('should re-show tip if it was already shown', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+ tooltip.show()
+ const tip = () => tooltip._getTipElement()
+
+ expect(tip()).toHaveClass('show')
+ tooltip.setContent({ '.tooltip-inner': 'foo' })
+
+ expect(tip()).toHaveClass('show')
+ expect(tip().querySelector('.tooltip-inner').textContent).toEqual('foo')
+ })
+
+ it('should keep tip hidden, if it was already hidden before', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+ const tip = () => tooltip._getTipElement()
+
+ expect(tip()).not.toHaveClass('show')
+ tooltip.setContent({ '.tooltip-inner': 'foo' })
+
+ expect(tip()).not.toHaveClass('show')
+ tooltip.show()
+ expect(tip().querySelector('.tooltip-inner').textContent).toEqual('foo')
+ })
+
+ it('"setContent" should keep the initial template', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.setContent({ '.tooltip-inner': 'foo' })
+ const tip = tooltip._getTipElement()
+
+ expect(tip).toHaveClass('tooltip')
+ expect(tip).toHaveClass('bs-tooltip-auto')
+ expect(tip.querySelector('.tooltip-arrow')).not.toBeNull()
+ expect(tip.querySelector('.tooltip-inner')).not.toBeNull()
+ })
+ })
+
+ describe('setContent', () => {
+ it('should do nothing if the element is null', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.setContent({ '.tooltip': null })
+ expect().nothing()
+ })
+
+ it('should do nothing if the content is a child of the element', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ''
+ ].join('')
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const childContent = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(tooltipEl, {
+ html: true
+ })
+
+ tooltip._getTipElement().append(childContent)
+ tooltip.setContent({ '.tooltip': childContent })
+
+ expect().nothing()
+ })
+
+ it('should add the content as a child of the element for jQuery elements', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' ',
+ ''
+ ].join('')
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const childContent = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(tooltipEl, {
+ html: true
+ })
+
+ tooltip.setContent({ '.tooltip': { 0: childContent, jquery: 'jQuery' } })
+ tooltip.show()
+
+ expect(childContent.parentNode).toEqual(tooltip._getTipElement())
+ })
+
+ it('should add the child text content in the element', () => {
+ fixtureEl.innerHTML = [
+ '
',
+ ' Tooltip
',
+ ''
+ ].join('')
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const childContent = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.setContent({ '.tooltip': childContent })
+
+ expect(childContent.textContent).toEqual(tooltip._getTipElement().textContent)
+ })
+
+ it('should add html without sanitize it', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ sanitize: false,
+ html: true
+ })
+
+ tooltip.setContent({ '.tooltip': '
Tooltip
' })
+
+ expect(tooltip._getTipElement().querySelector('div').id).toEqual('childContent')
+ })
+
+ it('should add html sanitized', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ html: true
+ })
+
+ const content = [
+ '
',
+ ' ',
+ '
'
+ ].join('')
+
+ tooltip.setContent({ '.tooltip': content })
+ expect(tooltip._getTipElement().querySelector('div').id).toEqual('childContent')
+ expect(tooltip._getTipElement().querySelector('button')).toBeNull()
+ })
+
+ it('should add text content', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltip.setContent({ '.tooltip': 'test' })
+
+ expect(tooltip._getTipElement().textContent).toEqual('test')
+ })
+ })
+
+ describe('_getTitle', () => {
+ it('should return the title', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ expect(tooltip._getTitle()).toEqual('Another tooltip')
+ })
+
+ it('should call title function', () => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl, {
+ title: () => 'test'
+ })
+
+ expect(tooltip._getTitle()).toEqual('test')
+ })
+ })
+
+ describe('getInstance', () => {
+ it('should return tooltip instance', () => {
+ fixtureEl.innerHTML = '
'
+
+ const div = fixtureEl.querySelector('div')
+ const alert = new Tooltip(div)
+
+ expect(Tooltip.getInstance(div)).toEqual(alert)
+ expect(Tooltip.getInstance(div)).toBeInstanceOf(Tooltip)
+ })
+
+ it('should return null when there is no tooltip instance', () => {
+ fixtureEl.innerHTML = '
'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Tooltip.getInstance(div)).toBeNull()
+ })
+ })
+
+ describe('aria-label', () => {
+ it('should add the aria-label attribute for referencing original title', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown).not.toBeNull()
+ expect(tooltipEl.getAttribute('aria-label')).toEqual('Another tooltip')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should add the aria-label attribute when element text content is a whitespace string', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown).not.toBeNull()
+ expect(tooltipEl.getAttribute('aria-label')).toEqual('A tooltip')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should not add the aria-label attribute if the attribute already exists', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown).not.toBeNull()
+ expect(tooltipEl.getAttribute('aria-label')).toEqual('Different label')
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+
+ it('should not add the aria-label attribute if the element has text content', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
text content'
+
+ const tooltipEl = fixtureEl.querySelector('a')
+ const tooltip = new Tooltip(tooltipEl)
+
+ tooltipEl.addEventListener('shown.bs.tooltip', () => {
+ const tooltipShown = document.querySelector('.tooltip')
+
+ expect(tooltipShown).not.toBeNull()
+ expect(tooltipEl.getAttribute('aria-label')).toBeNull()
+ resolve()
+ })
+
+ tooltip.show()
+ })
+ })
+ })
+
+ describe('getOrCreateInstance', () => {
+ it('should return tooltip instance', () => {
+ fixtureEl.innerHTML = '
'
+
+ const div = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(div)
+
+ expect(Tooltip.getOrCreateInstance(div)).toEqual(tooltip)
+ expect(Tooltip.getInstance(div)).toEqual(Tooltip.getOrCreateInstance(div, {}))
+ expect(Tooltip.getOrCreateInstance(div)).toBeInstanceOf(Tooltip)
+ })
+
+ it('should return new instance when there is no tooltip instance', () => {
+ fixtureEl.innerHTML = '
'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Tooltip.getInstance(div)).toBeNull()
+ expect(Tooltip.getOrCreateInstance(div)).toBeInstanceOf(Tooltip)
+ })
+
+ it('should return new instance when there is no tooltip instance with given configuration', () => {
+ fixtureEl.innerHTML = '
'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Tooltip.getInstance(div)).toBeNull()
+ const tooltip = Tooltip.getOrCreateInstance(div, {
+ title: () => 'test'
+ })
+ expect(tooltip).toBeInstanceOf(Tooltip)
+
+ expect(tooltip._getTitle()).toEqual('test')
+ })
+
+ it('should return the instance when exists without given configuration', () => {
+ fixtureEl.innerHTML = '
'
+
+ const div = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(div, {
+ title: () => 'nothing'
+ })
+ expect(Tooltip.getInstance(div)).toEqual(tooltip)
+
+ const tooltip2 = Tooltip.getOrCreateInstance(div, {
+ title: () => 'test'
+ })
+ expect(tooltip).toBeInstanceOf(Tooltip)
+ expect(tooltip2).toEqual(tooltip)
+
+ expect(tooltip2._getTitle()).toEqual('nothing')
+ })
+ })
+
+ describe('jQueryInterface', () => {
+ it('should create a tooltip', () => {
+ fixtureEl.innerHTML = '
'
+
+ const div = fixtureEl.querySelector('div')
+
+ jQueryMock.fn.tooltip = Tooltip.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tooltip.call(jQueryMock)
+
+ expect(Tooltip.getInstance(div)).not.toBeNull()
+ })
+
+ it('should not re create a tooltip', () => {
+ fixtureEl.innerHTML = '
'
+
+ 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 = '
'
+
+ const div = fixtureEl.querySelector('div')
+ const tooltip = new Tooltip(div)
+
+ const spy = spyOn(tooltip, 'show')
+
+ jQueryMock.fn.tooltip = Tooltip.jQueryInterface
+ jQueryMock.elements = [div]
+
+ jQueryMock.fn.tooltip.call(jQueryMock, 'show')
+
+ expect(Tooltip.getInstance(div)).toEqual(tooltip)
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('should throw error on undefined method', () => {
+ fixtureEl.innerHTML = '
'
+
+ const div = fixtureEl.querySelector('div')
+ const action = 'undefinedMethod'
+
+ jQueryMock.fn.tooltip = Tooltip.jQueryInterface
+ jQueryMock.elements = [div]
+
+ expect(() => {
+ jQueryMock.fn.tooltip.call(jQueryMock, action)
+ }).toThrowError(TypeError, `No method named "${action}"`)
+ })
+ })
+})
diff --git a/bootstrap/js/tests/unit/util/backdrop.spec.js b/bootstrap/js/tests/unit/util/backdrop.spec.js
new file mode 100644
index 0000000..0faaac6
--- /dev/null
+++ b/bootstrap/js/tests/unit/util/backdrop.spec.js
@@ -0,0 +1,321 @@
+import Backdrop from '../../../src/util/backdrop.js'
+import { getTransitionDurationFromElement } from '../../../src/util/index.js'
+import { clearFixture, getFixture } from '../../helpers/fixture.js'
+
+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 = '
'
+
+ 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 = '
'
+
+ 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/bootstrap/js/tests/unit/util/component-functions.spec.js b/bootstrap/js/tests/unit/util/component-functions.spec.js
new file mode 100644
index 0000000..ce83785
--- /dev/null
+++ b/bootstrap/js/tests/unit/util/component-functions.spec.js
@@ -0,0 +1,106 @@
+import BaseComponent from '../../../src/base-component.js'
+import { enableDismissTrigger } from '../../../src/util/component-functions.js'
+import { clearFixture, createEvent, getFixture } from '../../helpers/fixture.js'
+
+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 = [
+ '
',
+ ' ',
+ '
'
+ ].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 = [
+ '
',
+ ' ',
+ '
'
+ ].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 = [
+ '
',
+ ' ',
+ '
'
+ ].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
or ', () => {
+ fixtureEl.innerHTML = [
+ ''
+ ].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/bootstrap/js/tests/unit/util/config.spec.js b/bootstrap/js/tests/unit/util/config.spec.js
new file mode 100644
index 0000000..93987a7
--- /dev/null
+++ b/bootstrap/js/tests/unit/util/config.spec.js
@@ -0,0 +1,166 @@
+import Config from '../../../src/util/config.js'
+import { clearFixture, getFixture } from '../../helpers/fixture.js'
+
+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 = ''
+
+ 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 = ''
+
+ 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 = ''
+
+ 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 = ''
+
+ 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/bootstrap/js/tests/unit/util/focustrap.spec.js b/bootstrap/js/tests/unit/util/focustrap.spec.js
new file mode 100644
index 0000000..0a20017
--- /dev/null
+++ b/bootstrap/js/tests/unit/util/focustrap.spec.js
@@ -0,0 +1,218 @@
+import EventHandler from '../../../src/dom/event-handler.js'
+import SelectorEngine from '../../../src/dom/selector-engine.js'
+import FocusTrap from '../../../src/util/focustrap.js'
+import { clearFixture, createEvent, getFixture } from '../../helpers/fixture.js'
+
+describe('FocusTrap', () => {
+ let fixtureEl
+
+ beforeAll(() => {
+ fixtureEl = getFixture()
+ })
+
+ afterEach(() => {
+ clearFixture()
+ })
+
+ describe('activate', () => {
+ it('should autofocus itself by default', () => {
+ fixtureEl.innerHTML = ''
+
+ 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 = ''
+
+ 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 = [
+ 'outside',
+ '
'
+ ].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 = [
+ '
outside',
+ '
'
+ ].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 = [
+ '
outside',
+ '
'
+ ].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 = [
+ '
outside',
+ '
'
+ ].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/bootstrap/js/tests/unit/util/index.spec.js b/bootstrap/js/tests/unit/util/index.spec.js
new file mode 100644
index 0000000..4065a91
--- /dev/null
+++ b/bootstrap/js/tests/unit/util/index.spec.js
@@ -0,0 +1,720 @@
+import * as Util from '../../../src/util/index.js'
+import { noop } from '../../../src/util/index.js'
+import { clearFixture, getFixture } from '../../helpers/fixture.js'
+
+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('getTransitionDurationFromElement', () => {
+ it('should get transition from element', () => {
+ fixtureEl.innerHTML = '
'
+
+ 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 = '
'
+
+ expect(Util.getTransitionDurationFromElement(fixtureEl.querySelector('div'))).toEqual(0)
+ })
+ })
+
+ describe('triggerTransitionEnd', () => {
+ it('should trigger transitionend event', () => {
+ return new Promise(resolve => {
+ fixtureEl.innerHTML = '
'
+
+ 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 = [
+ '
',
+ '
'
+ ].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 = '
'
+
+ 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 = [
+ '
',
+ '
'
+ ].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 = '
'
+
+ 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 = '
'
+
+ const div = fixtureEl.querySelector('div')
+
+ expect(Util.isVisible(div)).toBeFalse()
+ })
+
+ it('should return false if an ancestor element is display none', () => {
+ fixtureEl.innerHTML = [
+ '
'
+ ].join('')
+
+ const div = fixtureEl.querySelector('.content')
+
+ expect(Util.isVisible(div)).toBeFalse()
+ })
+
+ it('should return false if an ancestor element is visibility hidden', () => {
+ fixtureEl.innerHTML = [
+ '
'
+ ].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 = [
+ '
'
+ ].join('')
+
+ const div = fixtureEl.querySelector('.content')
+
+ expect(Util.isVisible(div)).toBeTrue()
+ })
+
+ it('should return true if the element is visible', () => {
+ fixtureEl.innerHTML = [
+ '
'
+ ].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 = [
+ '
',
+ ' ',
+ ' '
+ ].join('')
+
+ const div = fixtureEl.querySelector('#element')
+
+ expect(Util.isVisible(div)).toBeFalse()
+ })
+
+ it('should return true if its a closed details element', () => {
+ fixtureEl.innerHTML = '
'
+
+ 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 = [
+ '
',
+ ' ',
+ ' '
+ ].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 = [
+ '
',
+ ' ',
+ ' ',
+ '
',
+ ' '
+ ].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 = [
+ '
',
+ '
',
+ '
',
+ '
',
+ '
'
+ ].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 = [
+ '
'
+ ].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 = [
+ '
',
+ ' ',
+ ' ',
+ ' ',
+ '
'
+ ].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 = [
+ '
',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ '
'
+ ].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 = [
+ '
'
+ ].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 = [
+ '
',
+ ' ',
+ '
'
+ ].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 = '
'
+
+ 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 = '
'
+
+ const div = fixtureEl.querySelector('div')
+ const shadowRoot = div.attachShadow({
+ mode: 'open'
+ })
+
+ expect(Util.findShadowRoot(shadowRoot)).toEqual(shadowRoot)
+
+ shadowRoot.innerHTML = '
'
+
+ 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 = '
'
+
+ 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()
+ })
+
+ it('should execute if arg is function & return the result', () => {
+ const functionFoo = (num1, num2 = 10) => num1 + num2
+ const resultFoo = Util.execute(functionFoo, [4, 5])
+ expect(resultFoo).toBe(9)
+
+ const resultFoo1 = Util.execute(functionFoo, [4])
+ expect(resultFoo1).toBe(14)
+
+ const functionBar = () => 'foo'
+ const resultBar = Util.execute(functionBar)
+ expect(resultBar).toBe('foo')
+ })
+
+ it('should not execute if arg is not function & return default argument', () => {
+ const foo = 'bar'
+ expect(Util.execute(foo)).toBe('bar')
+ expect(Util.execute(foo, [], 4)).toBe(4)
+ })
+ })
+
+ 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 = [
+ '
'
+ ].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/bootstrap/js/tests/unit/util/sanitizer.spec.js b/bootstrap/js/tests/unit/util/sanitizer.spec.js
new file mode 100644
index 0000000..2b21ef2
--- /dev/null
+++ b/bootstrap/js/tests/unit/util/sanitizer.spec.js
@@ -0,0 +1,163 @@
+import { DefaultAllowlist, sanitizeHtml } from '../../../src/util/sanitizer.js'
+
+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 retain tags with valid URLs', () => {
+ const validUrls = [
+ '',
+ 'http://abc',
+ 'HTTP://abc',
+ 'https://abc',
+ 'HTTPS://abc',
+ 'ftp://abc',
+ 'FTP://abc',
+ 'mailto:me@example.com',
+ 'MAILTO:me@example.com',
+ 'tel:123-123-1234',
+ 'TEL:123-123-1234',
+ 'sip:me@example.com',
+ 'SIP:me@example.com',
+ '#anchor',
+ '/page1.md',
+ 'http://JavaScript/my.js',
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', // Truncated.
+ 'data:video/webm;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/',
+ 'data:audio/opus;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/',
+ 'unknown-scheme:abc'
+ ]
+
+ for (const url of validUrls) {
+ const template = [
+ '
',
+ `
Click me`,
+ '
Some content',
+ '
'
+ ].join('')
+
+ const result = sanitizeHtml(template, DefaultAllowlist, null)
+
+ expect(result).toContain(`href="${url}"`)
+ }
+ })
+
+ it('should sanitize template by removing tags with XSS', () => {
+ const invalidUrls = [
+ // eslint-disable-next-line no-script-url
+ 'javascript:alert(7)',
+ // eslint-disable-next-line no-script-url
+ 'javascript:evil()',
+ // eslint-disable-next-line no-script-url
+ 'JavaScript:abc',
+ ' javascript:abc',
+ ' \n Java\n Script:abc',
+ 'javascript:',
+ 'javascript:',
+ 'j avascript:',
+ 'javascript:',
+ 'javascript:',
+ 'jav ascript:alert();',
+ 'jav\u0000ascript:alert();'
+ ]
+
+ for (const url of invalidUrls) {
+ const template = [
+ '
',
+ `
Click me`,
+ '
Some content',
+ '
'
+ ].join('')
+
+ const result = sanitizeHtml(template, DefaultAllowlist, null)
+
+ expect(result).not.toContain(`href="${url}"`)
+ }
+ })
+
+ it('should sanitize template and work with multiple regex', () => {
+ const template = [
+ '
',
+ '
Click me',
+ '
Some content',
+ '
'
+ ].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 = [
+ '
',
+ ' Some content',
+ '
'
+ ].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 = [
+ '
',
+ ' ',
+ '
'
+ ].join('')
+
+ const result = sanitizeHtml(template, DefaultAllowlist, null)
+
+ expect(result).not.toContain('
+