import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Collapse from '../../src/bootstrap/collapse'
import EventHandler from '../../src/bootstrap/dom/event-handler'
import { clearFixture, getFixture } from '../helpers/fixture'
describe('Collapse', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Collapse.VERSION).toBe('string')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(typeof Collapse.Default).toBe('object')
})
})
describe('DATA_KEY', () => {
it('should return plugin data key', () => {
expect(Collapse.DATA_KEY).toBe('bs.collapse')
})
})
describe('constructor', () => {
it('should accept element as CSS selector or DOM element', () => {
fixtureEl.innerHTML = '
'
const collapseEl = fixtureEl.querySelector('div.my-collapse')!
const collapseBySelector = new Collapse('div.my-collapse')
const collapseByElement = new Collapse(collapseEl)
expect(collapseBySelector._element).toBe(collapseEl)
expect(collapseByElement._element).toBe(collapseEl)
})
it('should allow DOM element in parent config', () => {
fixtureEl.innerHTML = ['', '
', '
Toggle', '
Lorem ipsum
', '
', '
'].join('')
const collapseEl = fixtureEl.querySelector('div.collapse')!
const myCollapseEl = fixtureEl.querySelector('.my-collapse')!
const collapse = new Collapse(collapseEl, { parent: myCollapseEl })
expect(collapse._config.parent).toBe(myCollapseEl)
})
it('should allow string selector in parent config', () => {
fixtureEl.innerHTML = ['', '
', '
Toggle', '
Lorem ipsum
', '
', '
'].join('')
const collapseEl = fixtureEl.querySelector('div.collapse')!
const myCollapseEl = fixtureEl.querySelector('.my-collapse')!
const collapse = new Collapse(collapseEl, { parent: 'div.my-collapse' })
expect(collapse._config.parent).toBe(myCollapseEl)
})
})
describe('toggle', () => {
it('should call show if not shown', () => {
fixtureEl.innerHTML = ''
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl)
const spy = vi.spyOn(collapse, 'show')
collapse.toggle()
expect(spy).toHaveBeenCalled()
})
it('should call hide if shown', () => {
fixtureEl.innerHTML = ''
const collapseEl = fixtureEl.querySelector('.show')!
const collapse = new Collapse(collapseEl, { toggle: false })
const spy = vi.spyOn(collapse, 'hide')
collapse.toggle()
expect(spy).toHaveBeenCalled()
})
it('should collapse children with parent', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = [
'',
].join('')
const parent = fixtureEl.querySelector('.my-collapse')!
const collapseEl1 = fixtureEl.querySelector('#collapse1')!
const collapseEl2 = fixtureEl.querySelector('#collapse2')!
const collapseList = Array.from(fixtureEl.querySelectorAll('.collapse')).map((el) => new Collapse(el, { parent, toggle: false }))
collapseEl2.addEventListener('shown.bs.collapse', () => {
expect(collapseEl2.classList.contains('show')).toBe(true)
expect(collapseEl1.classList.contains('show')).toBe(false)
resolve()
})
collapseList[1].toggle()
})
})
})
describe('show', () => {
it('should do nothing if transitioning', () => {
fixtureEl.innerHTML = ''
const spy = vi.spyOn(EventHandler, 'trigger')
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapse._isTransitioning = true
collapse.show()
expect(spy).not.toHaveBeenCalled()
})
it('should do nothing if already shown', () => {
fixtureEl.innerHTML = ''
const spy = vi.spyOn(EventHandler, 'trigger')
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapse.show()
expect(spy).not.toHaveBeenCalled()
})
it('should show a collapsed element', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ''
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapseEl.addEventListener('show.bs.collapse', () => {
expect(collapseEl.style.height).toBe('0px')
})
collapseEl.addEventListener('shown.bs.collapse', () => {
expect(collapseEl.classList.contains('show')).toBe(true)
expect(collapseEl.style.height).toBe('')
resolve()
})
collapse.show()
})
})
it('should show a collapsed element on width', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ''
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapseEl.addEventListener('show.bs.collapse', () => {
expect(collapseEl.style.width).toBe('0px')
})
collapseEl.addEventListener('shown.bs.collapse', () => {
expect(collapseEl.classList.contains('show')).toBe(true)
expect(collapseEl.style.width).toBe('')
resolve()
})
collapse.show()
})
})
it('should collapse only the first collapse', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ['', ''].join('')
const el1 = fixtureEl.querySelector('#collapse1')!
const el2 = fixtureEl.querySelector('#collapse2')!
const collapse = new Collapse(el1, { toggle: false })
el1.addEventListener('shown.bs.collapse', () => {
expect(el1.classList.contains('show')).toBe(true)
expect(el2.classList.contains('show')).toBe(true)
resolve()
})
collapse.show()
})
})
it('should handle toggling children siblings in accordion', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = [
'',
' ',
'
',
'
',
'
',
'
',
'
',
'
content
',
'
',
'
',
'
',
'
content
',
'
',
'
',
'
',
'
',
'
',
].join('')
const el = (s: string) => fixtureEl.querySelector(s) as HTMLElement
const parentBtn = el('[data-bs-target="#parentContent"]')
const childBtn1 = el('[data-bs-target="#childContent1"]')
const childBtn2 = el('[data-bs-target="#childContent2"]')
const parentCollapseEl = el('#parentContent')
const childCollapseEl1 = el('#childContent1')
const childCollapseEl2 = el('#childContent2')
parentCollapseEl.addEventListener('shown.bs.collapse', () => {
expect(parentCollapseEl.classList.contains('show')).toBe(true)
childBtn1.click()
})
childCollapseEl1.addEventListener('shown.bs.collapse', () => {
expect(childCollapseEl1.classList.contains('show')).toBe(true)
childBtn2.click()
})
childCollapseEl2.addEventListener('shown.bs.collapse', () => {
expect(childCollapseEl2.classList.contains('show')).toBe(true)
expect(childCollapseEl1.classList.contains('show')).toBe(false)
resolve()
})
parentBtn.click()
})
})
it('should not show if active children are transitioning', () => {
fixtureEl.innerHTML = [''].join('')
const el1 = fixtureEl.querySelector('#collapse1')!
const el2 = fixtureEl.querySelector('#collapse2')!
const collapse1 = new Collapse(el1, { toggle: false })
const collapse2 = new Collapse(el2, { toggle: false })
collapse1._isTransitioning = true
const spy = vi.spyOn(EventHandler, 'trigger')
collapse2.show()
expect(spy).not.toHaveBeenCalled()
})
it('should not fire shown when show is prevented', () => {
return new Promise((resolve, reject) => {
fixtureEl.innerHTML = ''
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapseEl.addEventListener('show.bs.collapse', (event) => {
event.preventDefault()
setTimeout(() => {
resolve()
}, 10)
})
collapseEl.addEventListener('shown.bs.collapse', () => {
reject(new Error('should not fire shown'))
})
collapse.show()
})
})
})
describe('hide', () => {
it('should do nothing if transitioning', () => {
fixtureEl.innerHTML = ''
const spy = vi.spyOn(EventHandler, 'trigger')
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapse._isTransitioning = true
collapse.hide()
expect(spy).not.toHaveBeenCalled()
})
it('should do nothing if not shown', () => {
fixtureEl.innerHTML = ''
const spy = vi.spyOn(EventHandler, 'trigger')
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapse.hide()
expect(spy).not.toHaveBeenCalled()
})
it('should hide a collapse element', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ''
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapseEl.addEventListener('hidden.bs.collapse', () => {
expect(collapseEl.classList.contains('show')).toBe(false)
expect(collapseEl.style.height).toBe('')
resolve()
})
collapse.hide()
})
})
it('should not fire hidden when hide is prevented', () => {
return new Promise((resolve, reject) => {
fixtureEl.innerHTML = ''
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapseEl.addEventListener('hide.bs.collapse', (event) => {
event.preventDefault()
setTimeout(resolve, 10)
})
collapseEl.addEventListener('hidden.bs.collapse', () => {
reject(new Error('should not fire hidden'))
})
collapse.hide()
})
})
})
describe('dispose', () => {
it('should destroy a collapse', () => {
fixtureEl.innerHTML = ''
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
expect(Collapse.getInstance(collapseEl)).toBe(collapse)
collapse.dispose()
expect(Collapse.getInstance(collapseEl)).toBeNull()
})
})
describe('data-api', () => {
it('should prevent url change on nested elements', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ['', ' ', '', ''].join('')
const triggerEl = fixtureEl.querySelector('a')!
const nestedTriggerEl = fixtureEl.querySelector('#nested')!
const spy = vi.spyOn(Event.prototype, 'preventDefault')
triggerEl.addEventListener('click', (event) => {
expect((event.target as Element).isEqualNode(nestedTriggerEl)).toBe(true)
expect(spy).toHaveBeenCalled()
resolve()
})
nestedTriggerEl.click()
})
})
it('should show multiple collapsed elements', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ['', '', ''].join('')
const trigger = fixtureEl.querySelector('a')!
const collapse1 = fixtureEl.querySelector('#collapse1')!
const collapse2 = fixtureEl.querySelector('#collapse2')!
collapse2.addEventListener('shown.bs.collapse', () => {
expect(trigger.getAttribute('aria-expanded')).toBe('true')
expect(trigger.classList.contains('collapsed')).toBe(false)
expect(collapse1.classList.contains('show')).toBe(true)
expect(collapse2.classList.contains('show')).toBe(true)
resolve()
})
trigger.click()
})
})
it('should hide multiple collapsed elements', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ['', '', ''].join('')
const trigger = fixtureEl.querySelector('a')!
const collapse1 = fixtureEl.querySelector('#collapse1')!
const collapse2 = fixtureEl.querySelector('#collapse2')!
collapse2.addEventListener('hidden.bs.collapse', () => {
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(trigger.classList.contains('collapsed')).toBe(true)
expect(collapse1.classList.contains('show')).toBe(false)
expect(collapse2.classList.contains('show')).toBe(false)
resolve()
})
trigger.click()
})
})
it('should show collapse via data-tblr-target', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ['', ''].join('')
const trigger = fixtureEl.querySelector('a') as HTMLElement
const collapseEl = fixtureEl.querySelector('#test1')!
collapseEl.addEventListener('shown.bs.collapse', () => {
expect(collapseEl.classList.contains('show')).toBe(true)
expect(trigger.classList.contains('collapsed')).toBe(false)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
resolve()
})
trigger.click()
})
})
it('should hide collapse via data-tblr-target', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ['', ''].join('')
const trigger = fixtureEl.querySelector('a') as HTMLElement
const collapseEl = fixtureEl.querySelector('#test1')!
collapseEl.addEventListener('hidden.bs.collapse', () => {
expect(collapseEl.classList.contains('show')).toBe(false)
expect(trigger.classList.contains('collapsed')).toBe(true)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
resolve()
})
trigger.click()
})
})
it('should remove collapsed class from trigger on show', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ['', '', ''].join('')
const link1 = fixtureEl.querySelector('#link1')!
const link2 = fixtureEl.querySelector('#link2')!
const collapseEl = fixtureEl.querySelector('#test1')!
collapseEl.addEventListener('shown.bs.collapse', () => {
expect(link1.getAttribute('aria-expanded')).toBe('true')
expect(link2.getAttribute('aria-expanded')).toBe('true')
expect(link1.classList.contains('collapsed')).toBe(false)
expect(link2.classList.contains('collapsed')).toBe(false)
resolve()
})
;(link1 as HTMLElement).click()
})
})
it('should add collapsed class to trigger on hide', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ['', '', ''].join('')
const link1 = fixtureEl.querySelector('#link1')!
const link2 = fixtureEl.querySelector('#link2')!
const collapseEl = fixtureEl.querySelector('#test1')!
collapseEl.addEventListener('hidden.bs.collapse', () => {
expect(link1.getAttribute('aria-expanded')).toBe('false')
expect(link2.getAttribute('aria-expanded')).toBe('false')
expect(link1.classList.contains('collapsed')).toBe(true)
expect(link2.classList.contains('collapsed')).toBe(true)
resolve()
})
;(link1 as HTMLElement).click()
})
})
it('should allow accordion with non-card children', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = [
'',
].join('')
const trigger = fixtureEl.querySelector('#linkTrigger') as HTMLElement
const triggerTwo = fixtureEl.querySelector('#linkTriggerTwo') as HTMLElement
const collapseOne = fixtureEl.querySelector('#collapseOne')!
const collapseTwo = fixtureEl.querySelector('#collapseTwo')!
collapseOne.addEventListener('shown.bs.collapse', () => {
expect(collapseOne.classList.contains('show')).toBe(true)
expect(collapseTwo.classList.contains('show')).toBe(false)
collapseTwo.addEventListener('shown.bs.collapse', () => {
expect(collapseOne.classList.contains('show')).toBe(false)
expect(collapseTwo.classList.contains('show')).toBe(true)
resolve()
})
triggerTwo.click()
})
trigger.click()
})
})
it('should not prevent event for input', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ['', ''].join('')
const target = fixtureEl.querySelector('input') as HTMLInputElement
const collapseEl = fixtureEl.querySelector('#collapsediv1')!
collapseEl.addEventListener('shown.bs.collapse', () => {
expect(collapseEl.classList.contains('show')).toBe(true)
expect(target.checked).toBe(true)
resolve()
})
target.click()
})
})
it('should allow accordion with nested elements', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = [
'',
].join('')
const triggerEl = fixtureEl.querySelector('#linkTrigger') as HTMLElement
const triggerTwoEl = fixtureEl.querySelector('#linkTriggerTwo') as HTMLElement
const collapseOneEl = fixtureEl.querySelector('#collapseOne')!
const collapseTwoEl = fixtureEl.querySelector('#collapseTwo')!
collapseOneEl.addEventListener('shown.bs.collapse', () => {
expect(collapseOneEl.classList.contains('show')).toBe(true)
expect(triggerEl.classList.contains('collapsed')).toBe(false)
expect(triggerEl.getAttribute('aria-expanded')).toBe('true')
expect(collapseTwoEl.classList.contains('show')).toBe(false)
expect(triggerTwoEl.classList.contains('collapsed')).toBe(true)
expect(triggerTwoEl.getAttribute('aria-expanded')).toBe('false')
collapseTwoEl.addEventListener('shown.bs.collapse', () => {
expect(collapseOneEl.classList.contains('show')).toBe(false)
expect(triggerEl.classList.contains('collapsed')).toBe(true)
expect(triggerEl.getAttribute('aria-expanded')).toBe('false')
expect(collapseTwoEl.classList.contains('show')).toBe(true)
expect(triggerTwoEl.classList.contains('collapsed')).toBe(false)
expect(triggerTwoEl.getAttribute('aria-expanded')).toBe('true')
resolve()
})
triggerTwoEl.click()
})
triggerEl.click()
})
})
it('should collapse accordion children but not nested accordion', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = [
'',
].join('')
const trigger = fixtureEl.querySelector('#linkTrigger') as HTMLElement
const triggerTwo = fixtureEl.querySelector('#linkTriggerTwo') as HTMLElement
const nestedTrigger = fixtureEl.querySelector('#nestedLinkTrigger') as HTMLElement
const collapseOne = fixtureEl.querySelector('#collapseOne')!
const collapseTwo = fixtureEl.querySelector('#collapseTwo')!
const nestedCollapseOne = fixtureEl.querySelector('#nestedCollapseOne')!
function handlerCollapseOne() {
expect(collapseOne.classList.contains('show')).toBe(true)
expect(collapseTwo.classList.contains('show')).toBe(false)
expect(nestedCollapseOne.classList.contains('show')).toBe(false)
nestedCollapseOne.addEventListener('shown.bs.collapse', handlerNestedCollapseOne)
nestedTrigger.click()
collapseOne.removeEventListener('shown.bs.collapse', handlerCollapseOne)
}
function handlerNestedCollapseOne() {
expect(collapseOne.classList.contains('show')).toBe(true)
expect(collapseTwo.classList.contains('show')).toBe(false)
expect(nestedCollapseOne.classList.contains('show')).toBe(true)
collapseTwo.addEventListener('shown.bs.collapse', () => {
expect(collapseOne.classList.contains('show')).toBe(false)
expect(collapseTwo.classList.contains('show')).toBe(true)
expect(nestedCollapseOne.classList.contains('show')).toBe(true)
resolve()
})
triggerTwo.click()
nestedCollapseOne.removeEventListener('shown.bs.collapse', handlerNestedCollapseOne)
}
collapseOne.addEventListener('shown.bs.collapse', handlerCollapseOne)
trigger.click()
})
})
})
describe('getInstance', () => {
it('should return collapse instance', () => {
fixtureEl.innerHTML = ''
const div = fixtureEl.querySelector('div')!
const collapse = new Collapse(div)
expect(Collapse.getInstance(div)).toBe(collapse)
expect(Collapse.getInstance(div)).toBeInstanceOf(Collapse)
})
it('should return null when there is no collapse instance', () => {
fixtureEl.innerHTML = ''
const div = fixtureEl.querySelector('div')!
expect(Collapse.getInstance(div)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should return collapse instance', () => {
fixtureEl.innerHTML = ''
const div = fixtureEl.querySelector('div')!
const collapse = new Collapse(div)
expect(Collapse.getOrCreateInstance(div)).toBe(collapse)
expect(Collapse.getOrCreateInstance(div)).toBeInstanceOf(Collapse)
})
it('should return new instance when there is no collapse instance', () => {
fixtureEl.innerHTML = ''
const div = fixtureEl.querySelector('div')!
expect(Collapse.getInstance(div)).toBeNull()
expect(Collapse.getOrCreateInstance(div)).toBeInstanceOf(Collapse)
})
it('should return new instance with given configuration', () => {
fixtureEl.innerHTML = ''
const div = fixtureEl.querySelector('div')!
const collapse = Collapse.getOrCreateInstance(div, { toggle: false })
expect(collapse).toBeInstanceOf(Collapse)
expect(collapse._config.toggle).toBe(false)
})
it('should return existing instance ignoring new configuration', () => {
fixtureEl.innerHTML = ''
const div = fixtureEl.querySelector('div')!
const collapse = new Collapse(div, { toggle: false })
const collapse2 = Collapse.getOrCreateInstance(div, { toggle: true })
expect(collapse2).toBe(collapse)
expect(collapse2._config.toggle).toBe(false)
})
})
describe('data-tblr-toggle', () => {
it('should show collapse via data-tblr-toggle="collapse"', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ['', 'Content
'].join('')
const target = fixtureEl.querySelector('#test1')!
const btn = fixtureEl.querySelector('[data-tblr-toggle="collapse"]') as HTMLElement
target.addEventListener('shown.bs.collapse', () => {
expect(target.classList.contains('show')).toBe(true)
resolve()
})
btn.click()
})
})
it('should hide collapse via data-tblr-toggle="collapse"', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ['', 'Content
'].join('')
const target = fixtureEl.querySelector('#test1')!
const btn = fixtureEl.querySelector('[data-tblr-toggle="collapse"]') as HTMLElement
target.addEventListener('hidden.bs.collapse', () => {
expect(target.classList.contains('show')).toBe(false)
resolve()
})
btn.click()
})
})
it('should show collapse via data-tblr-toggle with data-tblr-target', () => {
return new Promise((resolve) => {
fixtureEl.innerHTML = ['', 'Content
'].join('')
const target = fixtureEl.querySelector('#test1')!
const btn = fixtureEl.querySelector('[data-tblr-toggle="collapse"]') as HTMLElement
target.addEventListener('shown.bs.collapse', () => {
expect(target.classList.contains('show')).toBe(true)
resolve()
})
btn.click()
})
})
})
})