Remove Bootstrap dependency and vendor JS/SCSS sources into Tabler (#2627)

This commit is contained in:
Paweł Kuna
2026-03-18 21:55:42 +01:00
committed by GitHub
parent 65829e9d5e
commit 9d5c83f3ad
165 changed files with 25538 additions and 1026 deletions
+192
View File
@@ -0,0 +1,192 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
import Alert from '../../src/bootstrap/alert'
import { clearFixture, getFixture } from '../helpers/fixture'
describe('Alert', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
it('should accept element as CSS selector or DOM element', () => {
fixtureEl.innerHTML = '<div class="alert"></div>'
const alertEl = fixtureEl.querySelector('.alert')!
const alertBySelector = new Alert('.alert')
const alertByElement = new Alert(alertEl)
expect(alertBySelector._element).toBe(alertEl)
expect(alertByElement._element).toBe(alertEl)
})
it('should return version', () => {
expect(typeof Alert.VERSION).toBe('string')
})
describe('DATA_KEY', () => {
it('should return plugin data key', () => {
expect(Alert.DATA_KEY).toBe('bs.alert')
})
})
describe('data-api', () => {
it('should close an alert without instantiating manually', () => {
fixtureEl.innerHTML = [
'<div class="alert">',
' <button type="button" data-bs-dismiss="alert">x</button>',
'</div>'
].join('')
const button = document.querySelector('button')!
button.click()
expect(document.querySelectorAll('.alert')).toHaveLength(0)
})
it('should close an alert with parent selector', () => {
fixtureEl.innerHTML = [
'<div class="alert">',
' <button type="button" data-bs-target=".alert" data-bs-dismiss="alert">x</button>',
'</div>'
].join('')
const button = document.querySelector('button')!
button.click()
expect(document.querySelectorAll('.alert')).toHaveLength(0)
})
it('should close an alert via data-tblr-dismiss', () => {
fixtureEl.innerHTML = [
'<div class="alert">',
' <button type="button" data-tblr-dismiss="alert">x</button>',
'</div>'
].join('')
const button = document.querySelector('button')!
button.click()
expect(document.querySelectorAll('.alert')).toHaveLength(0)
})
it('should close an alert via data-tblr-dismiss with data-tblr-target', () => {
fixtureEl.innerHTML = [
'<div class="alert">',
' <button type="button" data-tblr-target=".alert" data-tblr-dismiss="alert">x</button>',
'</div>'
].join('')
const button = document.querySelector('button')!
button.click()
expect(document.querySelectorAll('.alert')).toHaveLength(0)
})
})
describe('close', () => {
it('should close an alert', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<div class="alert"></div>'
const alertEl = document.querySelector('.alert')!
const alert = new Alert(alertEl)
alertEl.addEventListener('closed.bs.alert', () => {
expect(document.querySelectorAll('.alert')).toHaveLength(0)
resolve()
})
alert.close()
})
})
it('should close alert with fade class', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<div class="alert fade"></div>'
const alertEl = document.querySelector('.alert')!
const alert = new Alert(alertEl)
alertEl.addEventListener('closed.bs.alert', () => {
expect(document.querySelectorAll('.alert')).toHaveLength(0)
resolve()
})
alert.close()
})
})
it('should not remove alert if close event is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<div class="alert"></div>'
const alertEl = document.querySelector('.alert')!
const alert = new Alert(alertEl)
alertEl.addEventListener('close.bs.alert', event => {
event.preventDefault()
setTimeout(() => {
expect(document.querySelector('.alert')).not.toBeNull()
resolve()
}, 10)
})
alert.close()
})
})
})
describe('dispose', () => {
it('should dispose an alert', () => {
fixtureEl.innerHTML = '<div class="alert"></div>'
const alertEl = document.querySelector('.alert')!
const alert = new Alert(alertEl)
expect(Alert.getInstance(alertEl)).not.toBeNull()
alert.dispose()
expect(Alert.getInstance(alertEl)).toBeNull()
})
})
describe('getInstance', () => {
it('should return alert instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const alert = new Alert(div)
expect(Alert.getInstance(div)).toBe(alert)
expect(Alert.getInstance(div)).toBeInstanceOf(Alert)
})
it('should return null when there is no alert instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Alert.getInstance(div)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should return alert instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const alert = new Alert(div)
expect(Alert.getOrCreateInstance(div)).toBe(alert)
expect(Alert.getOrCreateInstance(div)).toBeInstanceOf(Alert)
})
it('should return new instance when there is no alert instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Alert.getInstance(div)).toBeNull()
expect(Alert.getOrCreateInstance(div)).toBeInstanceOf(Alert)
})
})
})
+166
View File
@@ -0,0 +1,166 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import BaseComponent from '../../src/bootstrap/base-component'
import EventHandler from '../../src/bootstrap/dom/event-handler'
import { noop } from '../../src/bootstrap/util/index'
import { clearFixture, getFixture } from '../helpers/fixture'
class DummyClass extends BaseComponent {
constructor(element: string | HTMLElement) {
super(element)
EventHandler.on(this._element, `click${DummyClass.EVENT_KEY}`, noop as EventListener)
}
static get NAME(): string {
return 'dummy'
}
}
describe('BaseComponent', () => {
let fixtureEl: HTMLElement
let element: HTMLElement
let instance: DummyClass
const createInstance = () => {
fixtureEl.innerHTML = '<div id="foo"></div>'
element = fixtureEl.querySelector('#foo')!
instance = new DummyClass(element)
}
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
describe('Static Methods', () => {
it('VERSION should return a string', () => {
expect(typeof DummyClass.VERSION).toBe('string')
})
it('DATA_KEY should return plugin data key', () => {
expect(DummyClass.DATA_KEY).toBe('bs.dummy')
})
it('NAME should throw if not overridden', () => {
expect(() => BaseComponent.NAME).toThrow(Error)
})
it('NAME should return plugin name', () => {
expect(DummyClass.NAME).toBe('dummy')
})
it('EVENT_KEY should return plugin event key', () => {
expect(DummyClass.EVENT_KEY).toBe('.bs.dummy')
})
it('eventName should return namespaced event', () => {
expect(DummyClass.eventName('show')).toBe('show.bs.dummy')
})
})
describe('constructor', () => {
it('should accept element passed as DOM element', () => {
fixtureEl.innerHTML = '<div id="foo"></div>'
const el = fixtureEl.querySelector('#foo')!
const inst = new DummyClass(el)
expect(inst._element).toBe(el)
})
it('should accept element passed as CSS selector', () => {
fixtureEl.innerHTML = '<div id="bar"></div>'
const inst = new DummyClass('#bar')
expect(inst._element).toBe(fixtureEl.querySelector('#bar'))
})
it('should not initialize if element is not found', () => {
fixtureEl.innerHTML = ''
const inst = new DummyClass('#nonexistent')
expect(inst._element).toBeUndefined()
})
})
describe('dispose', () => {
it('should dispose a component', () => {
createInstance()
expect(DummyClass.getInstance(element)).not.toBeNull()
instance.dispose()
expect(DummyClass.getInstance(element)).toBeNull()
expect(instance._element).toBeNull()
})
it('should de-register element event listeners', () => {
createInstance()
const spy = vi.spyOn(EventHandler, 'off')
instance.dispose()
expect(spy).toHaveBeenCalledWith(element, DummyClass.EVENT_KEY)
vi.restoreAllMocks()
})
})
describe('getInstance', () => {
it('should return an instance', () => {
createInstance()
expect(DummyClass.getInstance(element)).toBe(instance)
expect(DummyClass.getInstance(element)).toBeInstanceOf(DummyClass)
})
it('should accept CSS selector', () => {
createInstance()
expect(DummyClass.getInstance('#foo')).toBe(instance)
})
it('should return null when there is no instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(DummyClass.getInstance(div)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should return existing instance', () => {
createInstance()
expect(DummyClass.getOrCreateInstance(element)).toBe(instance)
expect(DummyClass.getOrCreateInstance(element)).toBeInstanceOf(DummyClass)
})
it('should create new instance if none exists', () => {
fixtureEl.innerHTML = '<div id="foo"></div>'
element = fixtureEl.querySelector('#foo')!
expect(DummyClass.getInstance(element)).toBeNull()
expect(DummyClass.getOrCreateInstance(element)).toBeInstanceOf(DummyClass)
})
it('should pass null config when config is not an object', () => {
fixtureEl.innerHTML = '<div id="foo"></div>'
element = fixtureEl.querySelector('#foo')!
const inst = DummyClass.getOrCreateInstance(element, 'string-config' as unknown as Record<string, unknown>)
expect(inst).toBeInstanceOf(DummyClass)
})
})
describe('_queueCallback', () => {
it('should execute callback immediately when isAnimated is false', () => {
createInstance()
const callback = vi.fn()
instance._queueCallback(callback, element, false)
expect(callback).toHaveBeenCalledOnce()
})
it('should execute callback after transition when isAnimated is true', () => {
createInstance()
const callback = vi.fn()
instance._queueCallback(callback, element, true)
expect(callback).not.toHaveBeenCalled()
element.dispatchEvent(new Event('transitionend'))
expect(callback).toHaveBeenCalledOnce()
})
})
})
+170
View File
@@ -0,0 +1,170 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Button from '../../src/bootstrap/button'
import { clearFixture, getFixture } from '../helpers/fixture'
describe('Button', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
it('should accept element as CSS selector or DOM element', () => {
fixtureEl.innerHTML = '<button data-bs-toggle="button">Placeholder</button>'
const buttonEl = fixtureEl.querySelector('[data-bs-toggle="button"]')!
const buttonBySelector = new Button('[data-bs-toggle="button"]')
const buttonByElement = new Button(buttonEl)
expect(buttonBySelector._element).toBe(buttonEl)
expect(buttonByElement._element).toBe(buttonEl)
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Button.VERSION).toBe('string')
})
})
describe('DATA_KEY', () => {
it('should return plugin data key', () => {
expect(Button.DATA_KEY).toBe('bs.button')
})
})
describe('data-api', () => {
it('should toggle active class on click', () => {
fixtureEl.innerHTML = [
'<button class="btn" data-bs-toggle="button">btn</button>',
'<button class="btn testParent" data-bs-toggle="button"><div class="test"></div></button>'
].join('')
const btn = fixtureEl.querySelector('.btn') as HTMLElement
const divTest = fixtureEl.querySelector('.test') as HTMLElement
const btnTestParent = fixtureEl.querySelector('.testParent') as HTMLElement
expect(btn.classList.contains('active')).toBe(false)
btn.click()
expect(btn.classList.contains('active')).toBe(true)
btn.click()
expect(btn.classList.contains('active')).toBe(false)
divTest.click()
expect(btnTestParent.classList.contains('active')).toBe(true)
})
})
describe('toggle', () => {
it('should toggle aria-pressed', () => {
fixtureEl.innerHTML = '<button class="btn" data-bs-toggle="button" aria-pressed="false"></button>'
const btnEl = fixtureEl.querySelector('.btn')!
const button = new Button(btnEl)
expect(btnEl.getAttribute('aria-pressed')).toBe('false')
expect(btnEl.classList.contains('active')).toBe(false)
button.toggle()
expect(btnEl.getAttribute('aria-pressed')).toBe('true')
expect(btnEl.classList.contains('active')).toBe(true)
})
})
describe('dispose', () => {
it('should dispose a button', () => {
fixtureEl.innerHTML = '<button class="btn" data-bs-toggle="button"></button>'
const btnEl = fixtureEl.querySelector('.btn')!
const button = new Button(btnEl)
expect(Button.getInstance(btnEl)).not.toBeNull()
button.dispose()
expect(Button.getInstance(btnEl)).toBeNull()
})
})
describe('data-tblr-toggle', () => {
it('should toggle active class via data-tblr-toggle', () => {
fixtureEl.innerHTML = '<button class="btn" data-tblr-toggle="button">btn</button>'
const btn = fixtureEl.querySelector('.btn') as HTMLElement
expect(btn.classList.contains('active')).toBe(false)
btn.click()
expect(btn.classList.contains('active')).toBe(true)
btn.click()
expect(btn.classList.contains('active')).toBe(false)
})
it('should toggle active on child click with data-tblr-toggle', () => {
fixtureEl.innerHTML = '<button class="btn" data-tblr-toggle="button"><span class="inner">text</span></button>'
const inner = fixtureEl.querySelector('.inner') as HTMLElement
const btn = fixtureEl.querySelector('.btn') as HTMLElement
inner.click()
expect(btn.classList.contains('active')).toBe(true)
})
})
describe('data-api edge cases', () => {
it('should do nothing when closest returns null', () => {
fixtureEl.innerHTML = '<div id="wrapper"><span data-bs-toggle="button"></span></div>'
const span = fixtureEl.querySelector('span')!
vi.spyOn(span, 'closest').mockReturnValue(null)
span.click()
expect(Button.getInstance(span)).toBeNull()
})
})
describe('getInstance', () => {
it('should return button instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const button = new Button(div)
expect(Button.getInstance(div)).toBe(button)
expect(Button.getInstance(div)).toBeInstanceOf(Button)
})
it('should return null when there is no button instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Button.getInstance(div)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should return button instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const button = new Button(div)
expect(Button.getOrCreateInstance(div)).toBe(button)
expect(Button.getOrCreateInstance(div)).toBeInstanceOf(Button)
})
it('should return new instance when there is no button instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Button.getInstance(div)).toBeNull()
expect(Button.getOrCreateInstance(div)).toBeInstanceOf(Button)
})
})
})
+639
View File
@@ -0,0 +1,639 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Carousel from '../../src/bootstrap/carousel'
import EventHandler from '../../src/bootstrap/dom/event-handler'
import { clearFixture, createEvent, getFixture } from '../helpers/fixture'
describe('Carousel', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Carousel.VERSION).toBe('string')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(typeof Carousel.Default).toBe('object')
})
})
describe('DATA_KEY', () => {
it('should return plugin data key', () => {
expect(Carousel.DATA_KEY).toBe('bs.carousel')
})
})
describe('constructor', () => {
it('should accept element as CSS selector or DOM element', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carouselBySelector = new Carousel('#myCarousel')
const carouselByElement = new Carousel(carouselEl)
expect(carouselBySelector._element).toBe(carouselEl)
expect(carouselByElement._element).toBe(carouselEl)
})
it('should start cycling if ride=carousel', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-bs-ride="carousel"></div>'
const carousel = new Carousel('#myCarousel')
expect(carousel._interval).not.toBeNull()
carousel.dispose()
})
it('should not start cycling if ride!=carousel', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-bs-ride="true"></div>'
const carousel = new Carousel('#myCarousel')
expect(carousel._interval).toBeNull()
})
it('should go to next item on right arrow key', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div id="item2" class="carousel-item">item 2</div>',
' <div class="carousel-item">item 3</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { keyboard: true })
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('.active')).toBe(fixtureEl.querySelector('#item2'))
carousel.dispose()
resolve()
})
const keydown = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
carouselEl.dispatchEvent(keydown)
})
})
it('should go to previous item on left arrow key', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div id="item1" class="carousel-item">item 1</div>',
' <div class="carousel-item active">item 2</div>',
' <div class="carousel-item">item 3</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { keyboard: true })
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('.active')).toBe(fixtureEl.querySelector('#item1'))
carousel.dispose()
resolve()
})
const keydown = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
carouselEl.dispatchEvent(keydown)
})
})
it('should not prevent keydown for non-arrow keys', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
new Carousel(carouselEl, { keyboard: true })
const spy = vi.spyOn(Event.prototype, 'preventDefault')
const keydown = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
carouselEl.dispatchEvent(keydown)
expect(spy).not.toHaveBeenCalled()
})
it('should ignore keyboard in input/textarea', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active"><input type="text"></div>',
' <div class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { keyboard: true })
const slideSpy = vi.spyOn(carousel, '_slide')
const input = fixtureEl.querySelector('input')!
const keydown = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
Object.defineProperty(keydown, 'target', { value: input, configurable: true })
carouselEl.dispatchEvent(keydown)
expect(slideSpy).not.toHaveBeenCalled()
})
it('should not slide if already sliding', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
const triggerSpy = vi.spyOn(EventHandler, 'trigger')
carousel._isSliding = true
const keydown = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
carouselEl.dispatchEvent(keydown)
expect(triggerSpy).not.toHaveBeenCalled()
})
})
describe('next', () => {
it('should slide to next item', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div id="item2" class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item2')!.classList.contains('active')).toBe(true)
carousel.dispose()
resolve()
})
carousel.next()
})
})
})
describe('prev', () => {
it('should stay at start when wrap is false', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div id="one" class="carousel-item active"></div>',
' <div id="two" class="carousel-item"></div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { wrap: false })
carouselEl.addEventListener('slid.bs.carousel', () => {
reject(new Error('should not slide'))
})
carousel.prev()
setTimeout(() => {
expect(fixtureEl.querySelector('#one')!.classList.contains('active')).toBe(true)
resolve()
}, 50)
})
})
})
describe('pause', () => {
it('should clear interval', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carousel.cycle()
expect(carousel._interval).not.toBeNull()
carousel.pause()
expect(carousel._interval).toBeNull()
})
})
describe('cycle', () => {
it('should create an interval', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carousel.cycle()
expect(carousel._interval).not.toBeNull()
carousel.dispose()
})
})
describe('to', () => {
it('should go to specific index', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div id="item2" class="carousel-item">item 2</div>',
' <div id="item3" class="carousel-item">item 3</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item3')!.classList.contains('active')).toBe(true)
carousel.dispose()
resolve()
})
carousel.to(2)
})
})
it('should ignore invalid index', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
const spy = vi.spyOn(EventHandler, 'trigger')
carousel.to(-1)
carousel.to(10)
expect(spy).not.toHaveBeenCalled()
})
})
describe('dispose', () => {
it('should dispose carousel', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
expect(Carousel.getInstance(carouselEl)).not.toBeNull()
carousel.dispose()
expect(Carousel.getInstance(carouselEl)).toBeNull()
})
})
describe('getInstance', () => {
it('should return null if no instance', () => {
expect(Carousel.getInstance(fixtureEl)).toBeNull()
})
it('should return carousel instance', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
expect(Carousel.getInstance(carouselEl)).toBe(carousel)
})
})
describe('getOrCreateInstance', () => {
it('should return carousel instance', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
expect(Carousel.getOrCreateInstance(carouselEl)).toBe(carousel)
})
it('should return new instance', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
expect(Carousel.getInstance(carouselEl)).toBeNull()
expect(Carousel.getOrCreateInstance(carouselEl)).toBeInstanceOf(Carousel)
})
})
describe('_updateInterval', () => {
it('should use data-bs-interval from active element', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active" data-bs-interval="2000">item 1</div>',
' <div class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carousel._updateInterval()
expect(carousel._config.interval).toBe(2000)
})
it('should use data-tblr-interval from active element', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active" data-tblr-interval="3000">item 1</div>',
' <div class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carousel._updateInterval()
expect(carousel._config.interval).toBe(3000)
})
it('should fall back to defaultInterval if no data-interval', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carousel._updateInterval()
expect(carousel._config.interval).toBe(carousel._config.defaultInterval)
})
})
describe('_setActiveIndicatorElement', () => {
it('should update indicator with data-bs-slide-to', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-indicators">',
' <button class="active" data-bs-slide-to="0" aria-current="true"></button>',
' <button data-bs-slide-to="1"></button>',
' </div>',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carousel._setActiveIndicatorElement(1)
const indicators = fixtureEl.querySelectorAll('[data-bs-slide-to]')
expect(indicators[0].classList.contains('active')).toBe(false)
expect(indicators[1].classList.contains('active')).toBe(true)
expect(indicators[1].getAttribute('aria-current')).toBe('true')
})
it('should update indicator with data-tblr-slide-to', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-indicators">',
' <button class="active" data-tblr-slide-to="0" aria-current="true"></button>',
' <button data-tblr-slide-to="1"></button>',
' </div>',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carousel._setActiveIndicatorElement(1)
const indicators = fixtureEl.querySelectorAll('[data-tblr-slide-to]')
expect(indicators[0].classList.contains('active')).toBe(false)
expect(indicators[1].classList.contains('active')).toBe(true)
})
it('should skip if no indicators element', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
expect(() => carousel._setActiveIndicatorElement(0)).not.toThrow()
})
})
describe('_maybeEnableCycle', () => {
it('should not cycle if ride is false', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carousel = new Carousel('#myCarousel', { ride: false })
carousel._maybeEnableCycle()
expect(carousel._interval).toBeNull()
})
it('should defer cycle if sliding', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carousel = new Carousel('#myCarousel', { ride: true })
carousel._isSliding = true
const spy = vi.spyOn(EventHandler, 'one')
carousel._maybeEnableCycle()
expect(spy).toHaveBeenCalled()
})
})
describe('wrap', () => {
it('should wrap from end to start', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div id="one" class="carousel-item active"></div>',
' <div id="two" class="carousel-item"></div>',
' <div id="three" class="carousel-item"></div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { wrap: true })
let slidCount = 0
carouselEl.addEventListener('slid.bs.carousel', () => {
slidCount++
const activeId = carouselEl.querySelector('.carousel-item.active')!.id
if (slidCount < 3) {
carousel.next()
return
}
// wrapped back to first
expect(activeId).toBe('one')
carousel.dispose()
resolve()
})
carousel.next()
})
})
})
describe('data-api', () => {
it('should auto-init via data-bs-ride="carousel"', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-bs-ride="carousel"></div>'
window.dispatchEvent(createEvent('load'))
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const instance = Carousel.getInstance(carouselEl)
expect(instance).not.toBeNull()
instance!.dispose()
})
})
describe('data-tblr', () => {
it('should auto-init via data-tblr-ride="carousel"', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-tblr-ride="carousel"></div>'
window.dispatchEvent(createEvent('load'))
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const instance = Carousel.getInstance(carouselEl)
expect(instance).not.toBeNull()
instance!.dispose()
})
it('should start cycling via data-tblr-ride', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-tblr-ride="carousel"></div>'
const carousel = new Carousel('#myCarousel')
expect(carousel._interval).not.toBeNull()
carousel.dispose()
})
it('should navigate via data-tblr-slide="next"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div id="item2" class="carousel-item">item 2</div>',
' </div>',
' <button data-tblr-slide="next" data-bs-target="#myCarousel">Next</button>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const nextBtn = fixtureEl.querySelector('[data-tblr-slide="next"]') as HTMLElement
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item2')!.classList.contains('active')).toBe(true)
resolve()
})
nextBtn.click()
})
})
it('should navigate via data-tblr-slide="prev"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div id="item1" class="carousel-item">item 1</div>',
' <div class="carousel-item active">item 2</div>',
' </div>',
' <button data-tblr-slide="prev" data-bs-target="#myCarousel">Prev</button>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const prevBtn = fixtureEl.querySelector('[data-tblr-slide="prev"]') as HTMLElement
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item1')!.classList.contains('active')).toBe(true)
resolve()
})
prevBtn.click()
})
})
it('should handle data-tblr-slide-to in indicators', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-indicators">',
' <button class="active" data-tblr-slide-to="0" data-bs-target="#myCarousel" aria-current="true"></button>',
' <button data-tblr-slide-to="1" data-bs-target="#myCarousel"></button>',
' </div>',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div id="item2" class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const trigger = fixtureEl.querySelectorAll('[data-tblr-slide-to]')[1] as HTMLElement
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item2')!.classList.contains('active')).toBe(true)
resolve()
})
trigger.click()
})
})
})
})
+857
View File
@@ -0,0 +1,857 @@
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 = '<div class="my-collapse"></div>'
const collapseEl = fixtureEl.querySelector('div.my-collapse')!
const collapseBySelector = new Collapse('div.my-collapse')
const collapseByElement = new Collapse(collapseEl)
expect(collapseBySelector._element).toBe(collapseEl)
expect(collapseByElement._element).toBe(collapseEl)
})
it('should allow DOM element in parent config', () => {
fixtureEl.innerHTML = [
'<div class="my-collapse">',
' <div class="item">',
' <a data-bs-toggle="collapse" href="#">Toggle</a>',
' <div class="collapse">Lorem ipsum</div>',
' </div>',
'</div>'
].join('')
const collapseEl = fixtureEl.querySelector('div.collapse')!
const myCollapseEl = fixtureEl.querySelector('.my-collapse')!
const collapse = new Collapse(collapseEl, { parent: myCollapseEl })
expect(collapse._config.parent).toBe(myCollapseEl)
})
it('should allow string selector in parent config', () => {
fixtureEl.innerHTML = [
'<div class="my-collapse">',
' <div class="item">',
' <a data-bs-toggle="collapse" href="#">Toggle</a>',
' <div class="collapse">Lorem ipsum</div>',
' </div>',
'</div>'
].join('')
const collapseEl = fixtureEl.querySelector('div.collapse')!
const myCollapseEl = fixtureEl.querySelector('.my-collapse')!
const collapse = new Collapse(collapseEl, { parent: 'div.my-collapse' })
expect(collapse._config.parent).toBe(myCollapseEl)
})
})
describe('toggle', () => {
it('should call show if not shown', () => {
fixtureEl.innerHTML = '<div></div>'
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 = '<div class="show"></div>'
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<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="my-collapse">',
' <div class="item">',
' <a data-bs-toggle="collapse" href="#">Toggle 1</a>',
' <div id="collapse1" class="collapse show">Lorem ipsum 1</div>',
' </div>',
' <div class="item">',
' <a data-bs-toggle="collapse" href="#">Toggle 2</a>',
' <div id="collapse2" class="collapse">Lorem ipsum 2</div>',
' </div>',
'</div>'
].join('')
const parent = fixtureEl.querySelector('.my-collapse')!
const collapseEl1 = fixtureEl.querySelector('#collapse1')!
const collapseEl2 = fixtureEl.querySelector('#collapse2')!
const collapseList = 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 = '<div></div>'
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 = '<div class="show"></div>'
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<void>(resolve => {
fixtureEl.innerHTML = '<div class="collapse" style="height: 0px;"></div>'
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapseEl.addEventListener('show.bs.collapse', () => {
expect(collapseEl.style.height).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<void>(resolve => {
fixtureEl.innerHTML = '<div class="collapse collapse-horizontal" style="width: 0px;"></div>'
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapseEl.addEventListener('show.bs.collapse', () => {
expect(collapseEl.style.width).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<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="card" id="accordion1">',
' <div id="collapse1" class="collapse"></div>',
'</div>',
'<div class="card" id="accordion2">',
' <div id="collapse2" class="collapse show"></div>',
'</div>'
].join('')
const el1 = fixtureEl.querySelector('#collapse1')!
const el2 = fixtureEl.querySelector('#collapse2')!
const collapse = new Collapse(el1, { toggle: false })
el1.addEventListener('shown.bs.collapse', () => {
expect(el1.classList.contains('show')).toBe(true)
expect(el2.classList.contains('show')).toBe(true)
resolve()
})
collapse.show()
})
})
it('should handle toggling children siblings in accordion', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="parentGroup" class="accordion">',
' <div class="accordion-header">',
' <button data-bs-target="#parentContent" data-bs-toggle="collapse">Parent</button>',
' </div>',
' <div id="parentContent" class="accordion-collapse collapse" data-bs-parent="#parentGroup">',
' <div class="accordion-body">',
' <div id="childGroup" class="accordion">',
' <div class="accordion-item">',
' <button data-bs-target="#childContent1" data-bs-toggle="collapse">Child 1</button>',
' <div id="childContent1" class="accordion-collapse collapse" data-bs-parent="#childGroup">content</div>',
' </div>',
' <div class="accordion-item">',
' <button data-bs-target="#childContent2" data-bs-toggle="collapse">Child 2</button>',
' <div id="childContent2" class="accordion-collapse collapse" data-bs-parent="#childGroup">content</div>',
' </div>',
' </div>',
' </div>',
' </div>',
'</div>'
].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 = [
'<div id="accordion">',
' <div id="collapse1" class="collapse show" data-bs-parent="#accordion"></div>',
' <div id="collapse2" class="collapse" data-bs-parent="#accordion"></div>',
'</div>'
].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<void>((resolve, reject) => {
fixtureEl.innerHTML = '<div class="collapse"></div>'
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 = '<div></div>'
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 = '<div></div>'
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<void>(resolve => {
fixtureEl.innerHTML = '<div class="collapse show"></div>'
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapseEl.addEventListener('hidden.bs.collapse', () => {
expect(collapseEl.classList.contains('show')).toBe(false)
expect(collapseEl.style.height).toBe('')
resolve()
})
collapse.hide()
})
})
it('should not fire hidden when hide is prevented', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = '<div class="collapse show"></div>'
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 = '<div class="collapse show"></div>'
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<void>(resolve => {
fixtureEl.innerHTML = [
'<a role="button" data-bs-toggle="collapse" class="collapsed" href="#collapse">',
' <span id="nested"></span>',
'</a>',
'<div id="collapse" class="collapse"></div>'
].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<void>(resolve => {
fixtureEl.innerHTML = [
'<a role="button" data-bs-toggle="collapse" class="collapsed" href=".multi"></a>',
'<div id="collapse1" class="collapse multi"></div>',
'<div id="collapse2" class="collapse multi"></div>'
].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<void>(resolve => {
fixtureEl.innerHTML = [
'<a role="button" data-bs-toggle="collapse" href=".multi"></a>',
'<div id="collapse1" class="collapse multi show"></div>',
'<div id="collapse2" class="collapse multi show"></div>'
].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<void>(resolve => {
fixtureEl.innerHTML = [
'<a role="button" data-bs-toggle="collapse" class="collapsed" data-tblr-target="#test1"></a>',
'<div id="test1" class="collapse"></div>'
].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<void>(resolve => {
fixtureEl.innerHTML = [
'<a role="button" data-bs-toggle="collapse" data-tblr-target="#test1"></a>',
'<div id="test1" class="collapse show"></div>'
].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<void>(resolve => {
fixtureEl.innerHTML = [
'<a id="link1" role="button" data-bs-toggle="collapse" class="collapsed" href="#" data-bs-target="#test1"></a>',
'<a id="link2" role="button" data-bs-toggle="collapse" class="collapsed" href="#" data-bs-target="#test1"></a>',
'<div id="test1"></div>'
].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<void>(resolve => {
fixtureEl.innerHTML = [
'<a id="link1" role="button" data-bs-toggle="collapse" href="#" data-bs-target="#test1"></a>',
'<a id="link2" role="button" data-bs-toggle="collapse" href="#" data-bs-target="#test1"></a>',
'<div id="test1" class="show"></div>'
].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<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="accordion">',
' <div class="item">',
' <a id="linkTrigger" data-bs-toggle="collapse" href="#collapseOne"></a>',
' <div id="collapseOne" class="collapse" data-bs-parent="#accordion"></div>',
' </div>',
' <div class="item">',
' <a id="linkTriggerTwo" data-bs-toggle="collapse" href="#collapseTwo"></a>',
' <div id="collapseTwo" class="collapse show" data-bs-parent="#accordion"></div>',
' </div>',
'</div>'
].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<void>(resolve => {
fixtureEl.innerHTML = [
'<input type="checkbox" data-bs-toggle="collapse" data-bs-target="#collapsediv1">',
'<div id="collapsediv1"></div>'
].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<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="accordion">',
' <div class="row">',
' <div class="col-lg-6">',
' <div class="item">',
' <a id="linkTrigger" data-bs-toggle="collapse" href="#collapseOne"></a>',
' <div id="collapseOne" class="collapse" data-bs-parent="#accordion"></div>',
' </div>',
' </div>',
' <div class="col-lg-6">',
' <div class="item">',
' <a id="linkTriggerTwo" data-bs-toggle="collapse" href="#collapseTwo"></a>',
' <div id="collapseTwo" class="collapse show" data-bs-parent="#accordion"></div>',
' </div>',
' </div>',
' </div>',
'</div>'
].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<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="accordion">',
' <div class="item">',
' <a id="linkTrigger" data-bs-toggle="collapse" href="#collapseOne"></a>',
' <div id="collapseOne" data-bs-parent="#accordion" class="collapse">',
' <div id="nestedAccordion">',
' <div class="item">',
' <a id="nestedLinkTrigger" data-bs-toggle="collapse" href="#nestedCollapseOne"></a>',
' <div id="nestedCollapseOne" data-bs-parent="#nestedAccordion" class="collapse"></div>',
' </div>',
' </div>',
' </div>',
' </div>',
' <div class="item">',
' <a id="linkTriggerTwo" data-bs-toggle="collapse" href="#collapseTwo"></a>',
' <div id="collapseTwo" data-bs-parent="#accordion" class="collapse show"></div>',
' </div>',
'</div>'
].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 = '<div></div>'
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 = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Collapse.getInstance(div)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should return collapse instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const collapse = new Collapse(div)
expect(Collapse.getOrCreateInstance(div)).toBe(collapse)
expect(Collapse.getOrCreateInstance(div)).toBeInstanceOf(Collapse)
})
it('should return new instance when there is no collapse instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Collapse.getInstance(div)).toBeNull()
expect(Collapse.getOrCreateInstance(div)).toBeInstanceOf(Collapse)
})
it('should return new instance with given configuration', () => {
fixtureEl.innerHTML = '<div></div>'
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 = '<div></div>'
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<void>(resolve => {
fixtureEl.innerHTML = [
'<button data-tblr-toggle="collapse" data-bs-target="#test1">Toggle</button>',
'<div id="test1" class="collapse">Content</div>'
].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<void>(resolve => {
fixtureEl.innerHTML = [
'<button data-tblr-toggle="collapse" data-bs-target="#test1">Toggle</button>',
'<div id="test1" class="collapse show">Content</div>'
].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<void>(resolve => {
fixtureEl.innerHTML = [
'<button data-tblr-toggle="collapse" data-tblr-target="#test1">Toggle</button>',
'<div id="test1" class="collapse">Content</div>'
].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()
})
})
})
})
+182
View File
@@ -0,0 +1,182 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import Data from '../../../src/bootstrap/dom/data'
import { clearFixture, getFixture } from '../../helpers/fixture'
describe('Data', () => {
const TEST_KEY = 'bs.test'
const UNKNOWN_KEY = 'bs.unknown'
const TEST_DATA = { test: 'bsData' }
let fixtureEl: HTMLElement
let div: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
fixtureEl.innerHTML = '<div></div>'
div = fixtureEl.querySelector('div')!
})
afterEach(() => {
Data.remove(div, TEST_KEY)
clearFixture()
})
it('should return null for unknown elements', () => {
Data.set(div, TEST_KEY, { ...TEST_DATA })
expect(Data.get(document.createElement('div'), TEST_KEY)).toBeNull()
})
it('should return null for unknown keys', () => {
Data.set(div, TEST_KEY, { ...TEST_DATA })
expect(Data.get(div, UNKNOWN_KEY)).toBeNull()
})
it('should store data for an element with a given key and return it', () => {
const data = { ...TEST_DATA }
Data.set(div, TEST_KEY, data)
expect(Data.get(div, TEST_KEY)).toEqual(data)
})
it('should overwrite data if something is already stored', () => {
const data = { ...TEST_DATA }
const copy = { ...data }
Data.set(div, TEST_KEY, data)
Data.set(div, TEST_KEY, copy)
expect(Data.get(div, TEST_KEY)).not.toBe(data)
expect(Data.get(div, TEST_KEY)).toBe(copy)
})
it('should do nothing when an element has nothing stored', () => {
Data.remove(div, TEST_KEY)
})
it('should remove nothing for an unknown key', () => {
const data = { ...TEST_DATA }
Data.set(div, TEST_KEY, data)
Data.remove(div, UNKNOWN_KEY)
expect(Data.get(div, TEST_KEY)).toEqual(data)
})
it('should remove data for a given key', () => {
const data = { ...TEST_DATA }
Data.set(div, TEST_KEY, data)
Data.remove(div, TEST_KEY)
expect(Data.get(div, TEST_KEY)).toBeNull()
})
it('should console.error if called with multiple keys', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
Data.set(div, TEST_KEY, { ...TEST_DATA })
Data.set(div, UNKNOWN_KEY, { ...TEST_DATA })
expect(spy).toHaveBeenCalled()
expect(Data.get(div, UNKNOWN_KEY)).toBeNull()
spy.mockRestore()
})
it('should include the bound key name in error message', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
Data.set(div, TEST_KEY, { ...TEST_DATA })
Data.set(div, UNKNOWN_KEY, { ...TEST_DATA })
expect(spy).toHaveBeenCalledWith(
expect.stringContaining(TEST_KEY)
)
spy.mockRestore()
})
it('should not modify the first instance when a second key is rejected', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const original = { ...TEST_DATA }
Data.set(div, TEST_KEY, original)
Data.set(div, UNKNOWN_KEY, { other: true })
expect(Data.get(div, TEST_KEY)).toBe(original)
spy.mockRestore()
})
it('should handle set-remove-set cycle on the same element', () => {
const first = { v: 1 }
const second = { v: 2 }
Data.set(div, TEST_KEY, first)
Data.remove(div, TEST_KEY)
Data.set(div, TEST_KEY, second)
expect(Data.get(div, TEST_KEY)).toBe(second)
})
it('should handle multiple elements independently', () => {
const div2 = document.createElement('div')
const data1 = { el: 1 }
const data2 = { el: 2 }
Data.set(div, TEST_KEY, data1)
Data.set(div2, TEST_KEY, data2)
expect(Data.get(div, TEST_KEY)).toBe(data1)
expect(Data.get(div2, TEST_KEY)).toBe(data2)
Data.remove(div, TEST_KEY)
expect(Data.get(div, TEST_KEY)).toBeNull()
expect(Data.get(div2, TEST_KEY)).toBe(data2)
Data.remove(div2, TEST_KEY)
})
it('should return null for an element that was never stored', () => {
const fresh = document.createElement('span')
expect(Data.get(fresh, TEST_KEY)).toBeNull()
})
it('should clean up element entry when last key is removed', () => {
Data.set(div, TEST_KEY, { ...TEST_DATA })
Data.remove(div, TEST_KEY)
Data.set(div, UNKNOWN_KEY, { other: true })
expect(Data.get(div, UNKNOWN_KEY)).toEqual({ other: true })
Data.remove(div, UNKNOWN_KEY)
})
it('should allow overwriting the same key multiple times', () => {
const a = { v: 'a' }
const b = { v: 'b' }
const c = { v: 'c' }
Data.set(div, TEST_KEY, a)
Data.set(div, TEST_KEY, b)
Data.set(div, TEST_KEY, c)
expect(Data.get(div, TEST_KEY)).toBe(c)
})
it('should return the exact reference that was stored', () => {
const ref = { unique: Symbol('test') }
Data.set(div, TEST_KEY, ref)
expect(Data.get(div, TEST_KEY)).toBe(ref)
})
})
@@ -0,0 +1,460 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import EventHandler from '../../../src/bootstrap/dom/event-handler'
import { clearFixture, getFixture } from '../../helpers/fixture'
describe('EventHandler', () => {
let fixtureEl: HTMLElement
let div: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
fixtureEl.innerHTML = '<div><span><button>Click</button></span></div>'
div = fixtureEl.querySelector('div')!
})
afterEach(() => {
EventHandler.off(div, 'click')
clearFixture()
})
describe('on', () => {
it('should bind an event listener to an element', () => {
const handler = vi.fn()
EventHandler.on(div, 'click', handler)
div.click()
expect(handler).toHaveBeenCalledOnce()
})
it('should receive the Event object', () => {
const handler = vi.fn()
EventHandler.on(div, 'click', handler)
div.click()
expect(handler).toHaveBeenCalledWith(expect.any(Event))
})
it('should bind multiple different events on the same element', () => {
const clickHandler = vi.fn()
const focusHandler = vi.fn()
EventHandler.on(div, 'click', clickHandler)
EventHandler.on(div, 'focusin', focusHandler)
div.click()
expect(clickHandler).toHaveBeenCalledOnce()
expect(focusHandler).not.toHaveBeenCalled()
EventHandler.off(div, 'focusin')
})
it('should not add duplicate handlers for the same callback', () => {
const handler = vi.fn()
EventHandler.on(div, 'click', handler)
EventHandler.on(div, 'click', handler)
div.click()
expect(handler).toHaveBeenCalledOnce()
})
it('should do nothing if element is null', () => {
expect(() => {
EventHandler.on(null, 'click', vi.fn())
}).not.toThrow()
})
it('should handle a custom (non-native) event', () => {
const handler = vi.fn()
EventHandler.on(div, 'my.custom.event', handler)
EventHandler.trigger(div, 'my.custom.event')
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'my.custom.event')
})
})
describe('one', () => {
it('should call the handler only once', () => {
const handler = vi.fn()
EventHandler.one(div, 'click', handler)
div.click()
div.click()
expect(handler).toHaveBeenCalledOnce()
})
it('should unbind the handler after first call', () => {
const handler = vi.fn()
EventHandler.one(div, 'click', handler)
div.click()
expect(handler).toHaveBeenCalledOnce()
div.click()
expect(handler).toHaveBeenCalledOnce()
})
})
describe('off', () => {
it('should remove a specific handler', () => {
const handler = vi.fn()
EventHandler.on(div, 'click', handler)
EventHandler.off(div, 'click', handler)
div.click()
expect(handler).not.toHaveBeenCalled()
})
it('should remove all handlers for an event type', () => {
const handler1 = vi.fn()
const handler2 = vi.fn()
EventHandler.on(div, 'click', handler1)
EventHandler.on(div, 'click', handler2)
EventHandler.off(div, 'click')
div.click()
expect(handler1).not.toHaveBeenCalled()
expect(handler2).not.toHaveBeenCalled()
})
it('should do nothing if element is null', () => {
expect(() => {
EventHandler.off(null, 'click')
}).not.toThrow()
})
it('should do nothing if no handlers are registered', () => {
const fresh = document.createElement('div')
expect(() => {
EventHandler.off(fresh, 'click', vi.fn())
}).not.toThrow()
})
it('should only remove the specified handler, keeping others', () => {
const handler1 = vi.fn()
const handler2 = vi.fn()
EventHandler.on(div, 'click', handler1)
EventHandler.on(div, 'click', handler2)
EventHandler.off(div, 'click', handler1)
div.click()
expect(handler1).not.toHaveBeenCalled()
expect(handler2).toHaveBeenCalledOnce()
EventHandler.off(div, 'click', handler2)
})
})
describe('trigger', () => {
it('should trigger a native event', () => {
const handler = vi.fn()
EventHandler.on(div, 'click', handler)
EventHandler.trigger(div, 'click')
expect(handler).toHaveBeenCalledOnce()
})
it('should return the dispatched Event object', () => {
const evt = EventHandler.trigger(div, 'click')
expect(evt).toBeInstanceOf(Event)
expect(evt!.type).toBe('click')
})
it('should create a bubbling, cancelable event', () => {
const evt = EventHandler.trigger(div, 'click')
expect(evt!.bubbles).toBe(true)
expect(evt!.cancelable).toBe(true)
})
it('should return null if element is null', () => {
const evt = EventHandler.trigger(null, 'click')
expect(evt).toBeNull()
})
it('should hydrate the event with extra args', () => {
let receivedEvent: Event | null = null
EventHandler.on(div, 'show.bs.modal', (event: unknown) => {
receivedEvent = event as Event
})
EventHandler.trigger(div, 'show.bs.modal', { relatedTarget: div })
expect((receivedEvent as unknown as Record<string, unknown>).relatedTarget).toBe(div)
EventHandler.off(div, 'show.bs.modal')
})
it('should trigger a custom (non-native) event', () => {
const handler = vi.fn()
EventHandler.on(div, 'show.bs.modal', handler)
EventHandler.trigger(div, 'show.bs.modal')
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'show.bs.modal')
})
it('should allow preventing the default action', () => {
EventHandler.on(div, 'show.bs.test', (event: unknown) => {
(event as Event).preventDefault()
})
const evt = EventHandler.trigger(div, 'show.bs.test')
expect(evt!.defaultPrevented).toBe(true)
EventHandler.off(div, 'show.bs.test')
})
})
describe('namespaces', () => {
it('should support namespaced events', () => {
const handler = vi.fn()
EventHandler.on(div, 'click.bs.test', handler)
div.click()
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'click.bs.test')
})
it('should remove only namespaced handlers with off', () => {
const handler1 = vi.fn()
const handler2 = vi.fn()
EventHandler.on(div, 'click.ns1', handler1)
EventHandler.on(div, 'click.ns2', handler2)
EventHandler.off(div, 'click.ns1', handler1)
div.click()
expect(handler1).not.toHaveBeenCalled()
expect(handler2).toHaveBeenCalledOnce()
EventHandler.off(div, 'click.ns2')
})
it('should remove all handlers for a namespace with dot prefix', () => {
const clickHandler = vi.fn()
const focusHandler = vi.fn()
EventHandler.on(div, 'click.bs.test', clickHandler)
EventHandler.on(div, 'focusin.bs.test', focusHandler)
EventHandler.off(div, '.bs.test')
div.click()
div.dispatchEvent(new Event('focusin'))
expect(clickHandler).not.toHaveBeenCalled()
expect(focusHandler).not.toHaveBeenCalled()
})
})
describe('delegation', () => {
it('should handle delegated events', () => {
const handler = vi.fn()
const btn = fixtureEl.querySelector('button')!
EventHandler.on(div, 'click', 'button', handler)
btn.click()
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'click')
})
it('should not fire for non-matching delegated elements', () => {
const handler = vi.fn()
const span = fixtureEl.querySelector('span')!
EventHandler.on(div, 'click', 'button', handler)
span.click()
expect(handler).not.toHaveBeenCalled()
EventHandler.off(div, 'click')
})
it('should set delegateTarget on the event', () => {
let delegateTarget: EventTarget | null = null
const btn = fixtureEl.querySelector('button')!
EventHandler.on(div, 'click', 'button', (event: unknown) => {
delegateTarget = (event as Record<string, unknown>).delegateTarget as EventTarget
})
btn.click()
expect(delegateTarget).toBe(btn)
EventHandler.off(div, 'click')
})
it('should remove delegated handler with off', () => {
const handler = vi.fn()
const btn = fixtureEl.querySelector('button')!
EventHandler.on(div, 'click', 'button', handler)
EventHandler.off(div, 'click', 'button', handler)
btn.click()
expect(handler).not.toHaveBeenCalled()
})
it('should support one-off delegated events', () => {
const handler = vi.fn()
const btn = fixtureEl.querySelector('button')!
EventHandler.one(div, 'click', 'button', handler)
btn.click()
btn.click()
expect(handler).toHaveBeenCalledOnce()
})
})
describe('mouseenter / mouseleave (custom events)', () => {
it('should fire mouseenter handler when relatedTarget is outside', () => {
const handler = vi.fn()
EventHandler.on(div, 'mouseenter', handler)
const mouseoverEvent = new MouseEvent('mouseover', {
bubbles: true,
relatedTarget: document.body
})
div.dispatchEvent(mouseoverEvent)
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'mouseenter')
})
it('should not fire mouseenter handler when relatedTarget is inside delegateTarget', () => {
const handler = vi.fn()
const btn = fixtureEl.querySelector('button')!
EventHandler.on(div, 'mouseenter', handler)
const mouseoverEvent = new MouseEvent('mouseover', {
bubbles: true,
relatedTarget: btn
})
div.dispatchEvent(mouseoverEvent)
expect(handler).not.toHaveBeenCalled()
EventHandler.off(div, 'mouseenter')
})
it('should fire mouseenter handler when relatedTarget is null', () => {
const handler = vi.fn()
EventHandler.on(div, 'mouseenter', handler)
const mouseoverEvent = new MouseEvent('mouseover', {
bubbles: true,
relatedTarget: null
})
div.dispatchEvent(mouseoverEvent)
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'mouseenter')
})
})
describe('removeHandler guard', () => {
it('should not throw when removing a handler that was never added', () => {
const handler = vi.fn()
const otherHandler = vi.fn()
EventHandler.on(div, 'click', handler)
expect(() => {
EventHandler.off(div, 'click', otherHandler)
}).not.toThrow()
div.click()
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'click')
})
})
describe('trigger extras', () => {
it('should attach custom properties to the triggered event', () => {
let receivedEvent: Event | null = null
EventHandler.on(div, 'show.bs.test', (event: unknown) => {
receivedEvent = event as Event
})
EventHandler.trigger(div, 'show.bs.test', { customProp: 42, anotherProp: 'hello' })
expect(receivedEvent).not.toBeNull()
const evt = receivedEvent as unknown as Record<string, unknown>
expect(evt.customProp).toBe(42)
expect(evt.anotherProp).toBe('hello')
EventHandler.off(div, 'show.bs.test')
})
it('should work when trigger has no extra args', () => {
const handler = vi.fn()
EventHandler.on(div, 'show.bs.test', handler)
const evt = EventHandler.trigger(div, 'show.bs.test')
expect(evt).toBeInstanceOf(Event)
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'show.bs.test')
})
it('should use Object.defineProperty for read-only properties', () => {
let receivedEvent: Event | null = null
EventHandler.on(div, 'click', (event: unknown) => {
receivedEvent = event as Event
})
EventHandler.trigger(div, 'click', { type: 'overridden' })
expect(receivedEvent).not.toBeNull()
expect((receivedEvent as unknown as Record<string, unknown>).type).toBe('overridden')
EventHandler.off(div, 'click')
})
})
})
+201
View File
@@ -0,0 +1,201 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'
import Manipulator from '../../../src/bootstrap/dom/manipulator'
import { clearFixture, getFixture } from '../../helpers/fixture'
describe('Manipulator', () => {
let fixtureEl: HTMLElement
let div: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
fixtureEl.innerHTML = '<div></div>'
div = fixtureEl.querySelector('div')!
})
afterEach(() => {
clearFixture()
})
describe('setDataAttribute', () => {
it('should set a data-tblr-* attribute', () => {
Manipulator.setDataAttribute(div, 'key', 'value')
expect(div.getAttribute('data-tblr-key')).toBe('value')
})
it('should convert camelCase keys to kebab-case', () => {
Manipulator.setDataAttribute(div, 'testKey', '123')
expect(div.getAttribute('data-tblr-test-key')).toBe('123')
})
it('should overwrite existing value', () => {
Manipulator.setDataAttribute(div, 'key', 'old')
Manipulator.setDataAttribute(div, 'key', 'new')
expect(div.getAttribute('data-tblr-key')).toBe('new')
})
})
describe('removeDataAttribute', () => {
it('should remove data-tblr-* attribute', () => {
div.setAttribute('data-tblr-key', 'value')
Manipulator.removeDataAttribute(div, 'key')
expect(div.getAttribute('data-tblr-key')).toBeNull()
})
it('should remove data-bs-* attribute', () => {
div.setAttribute('data-bs-key', 'value')
Manipulator.removeDataAttribute(div, 'key')
expect(div.getAttribute('data-bs-key')).toBeNull()
})
it('should remove both prefixes at once', () => {
div.setAttribute('data-tblr-key', 'a')
div.setAttribute('data-bs-key', 'b')
Manipulator.removeDataAttribute(div, 'key')
expect(div.getAttribute('data-tblr-key')).toBeNull()
expect(div.getAttribute('data-bs-key')).toBeNull()
})
it('should handle camelCase keys', () => {
div.setAttribute('data-tblr-some-thing', 'x')
Manipulator.removeDataAttribute(div, 'someThing')
expect(div.getAttribute('data-tblr-some-thing')).toBeNull()
})
})
describe('getDataAttribute', () => {
it('should prioritize data-tblr-* over data-bs-*', () => {
div.setAttribute('data-tblr-key', 'tblr-value')
div.setAttribute('data-bs-key', 'bs-value')
expect(Manipulator.getDataAttribute(div, 'key')).toBe('tblr-value')
})
it('should fall back to data-bs-* if data-tblr-* is absent', () => {
div.setAttribute('data-bs-key', 'bs-value')
expect(Manipulator.getDataAttribute(div, 'key')).toBe('bs-value')
})
it('should return null if neither prefix exists', () => {
expect(Manipulator.getDataAttribute(div, 'missing')).toBeNull()
})
it('should normalize "true" to boolean true', () => {
div.setAttribute('data-tblr-flag', 'true')
expect(Manipulator.getDataAttribute(div, 'flag')).toBe(true)
})
it('should normalize "false" to boolean false', () => {
div.setAttribute('data-tblr-flag', 'false')
expect(Manipulator.getDataAttribute(div, 'flag')).toBe(false)
})
it('should normalize numeric strings to numbers', () => {
div.setAttribute('data-tblr-count', '42')
expect(Manipulator.getDataAttribute(div, 'count')).toBe(42)
})
it('should normalize "null" to null', () => {
div.setAttribute('data-tblr-val', 'null')
expect(Manipulator.getDataAttribute(div, 'val')).toBeNull()
})
it('should normalize empty string to null', () => {
div.setAttribute('data-tblr-val', '')
expect(Manipulator.getDataAttribute(div, 'val')).toBeNull()
})
it('should parse JSON-encoded values', () => {
div.setAttribute('data-tblr-obj', '{"a":1}')
expect(Manipulator.getDataAttribute(div, 'obj')).toEqual({ a: 1 })
})
it('should return raw string for non-parseable values', () => {
div.setAttribute('data-tblr-val', 'hello world')
expect(Manipulator.getDataAttribute(div, 'val')).toBe('hello world')
})
it('should handle camelCase key lookup', () => {
div.setAttribute('data-tblr-my-key', 'yes')
expect(Manipulator.getDataAttribute(div, 'myKey')).toBe('yes')
})
})
describe('getDataAttributes', () => {
it('should return empty object for null element', () => {
expect(Manipulator.getDataAttributes(null)).toEqual({})
})
it('should return empty object when no data attributes exist', () => {
expect(Manipulator.getDataAttributes(div)).toEqual({})
})
it('should collect data-tblr-* attributes', () => {
div.setAttribute('data-tblr-name', 'test')
div.setAttribute('data-tblr-count', '5')
const attrs = Manipulator.getDataAttributes(div)
expect(attrs.name).toBe('test')
expect(attrs.count).toBe(5)
})
it('should collect data-bs-* attributes', () => {
div.setAttribute('data-bs-toggle', 'modal')
const attrs = Manipulator.getDataAttributes(div)
expect(attrs.toggle).toBe('modal')
})
it('should prioritize tblr over bs for the same key', () => {
div.setAttribute('data-tblr-key', 'tblr')
div.setAttribute('data-bs-key', 'bs')
const attrs = Manipulator.getDataAttributes(div)
expect(attrs.key).toBe('tblr')
})
it('should exclude *Config attributes', () => {
div.setAttribute('data-tblr-config', '{}')
div.setAttribute('data-bs-config', '{}')
div.setAttribute('data-tblr-name', 'hello')
const attrs = Manipulator.getDataAttributes(div)
expect(attrs.name).toBe('hello')
expect('config' in attrs).toBe(false)
})
it('should normalize all values', () => {
div.setAttribute('data-tblr-flag', 'true')
div.setAttribute('data-tblr-num', '10')
div.setAttribute('data-tblr-nil', 'null')
const attrs = Manipulator.getDataAttributes(div)
expect(attrs.flag).toBe(true)
expect(attrs.num).toBe(10)
expect(attrs.nil).toBeNull()
})
})
})
@@ -0,0 +1,300 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import SelectorEngine from '../../../src/bootstrap/dom/selector-engine'
import { clearFixture, getFixture } from '../../helpers/fixture'
vi.mock('../../../src/bootstrap/util/index', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/bootstrap/util/index')>()
return {
...actual,
isVisible: () => true
}
})
describe('SelectorEngine', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
describe('find', () => {
beforeEach(() => {
fixtureEl.innerHTML = '<div><span class="a"></span><span class="b"></span></div>'
})
it('should return an array of matched elements', () => {
const result = SelectorEngine.find('span', fixtureEl)
expect(result).toHaveLength(2)
expect(result[0].classList.contains('a')).toBe(true)
expect(result[1].classList.contains('b')).toBe(true)
})
it('should return an empty array when nothing matches', () => {
expect(SelectorEngine.find('.missing', fixtureEl)).toEqual([])
})
it('should default to document.documentElement', () => {
const result = SelectorEngine.find(`#${fixtureEl.id} span`)
expect(result).toHaveLength(2)
})
})
describe('findOne', () => {
beforeEach(() => {
fixtureEl.innerHTML = '<div><span class="first"></span><span class="second"></span></div>'
})
it('should return the first matched element', () => {
const result = SelectorEngine.findOne('span', fixtureEl)
expect(result).not.toBeNull()
expect(result!.classList.contains('first')).toBe(true)
})
it('should return null when nothing matches', () => {
expect(SelectorEngine.findOne('.missing', fixtureEl)).toBeNull()
})
})
describe('children', () => {
beforeEach(() => {
fixtureEl.innerHTML = '<div><span class="match"></span><p></p><span class="match"></span></div>'
})
it('should return only direct children matching the selector', () => {
const parent = fixtureEl.querySelector('div')!
const result = SelectorEngine.children(parent, 'span')
expect(result).toHaveLength(2)
result.forEach(el => expect(el.tagName).toBe('SPAN'))
})
it('should return an empty array when no children match', () => {
const parent = fixtureEl.querySelector('div')!
expect(SelectorEngine.children(parent, 'button')).toEqual([])
})
})
describe('parents', () => {
beforeEach(() => {
fixtureEl.innerHTML = '<div class="outer"><div class="inner"><span id="target"></span></div></div>'
})
it('should return all ancestor elements matching the selector', () => {
const target = fixtureEl.querySelector('#target')!
const result = SelectorEngine.parents(target as HTMLElement, 'div')
expect(result.length).toBeGreaterThanOrEqual(2)
expect(result[0].classList.contains('inner')).toBe(true)
expect(result[1].classList.contains('outer')).toBe(true)
})
it('should return an empty array when no ancestors match', () => {
const target = fixtureEl.querySelector('#target')!
expect(SelectorEngine.parents(target as HTMLElement, 'table')).toEqual([])
})
})
describe('prev', () => {
beforeEach(() => {
fixtureEl.innerHTML = '<div><span class="a"></span><p class="b"></p><span class="c"></span></div>'
})
it('should return the previous sibling matching the selector', () => {
const el = fixtureEl.querySelector('.c')! as HTMLElement
const result = SelectorEngine.prev(el, 'span')
expect(result).toHaveLength(1)
expect(result[0].classList.contains('a')).toBe(true)
})
it('should return an empty array if no previous sibling matches', () => {
const el = fixtureEl.querySelector('.a')! as HTMLElement
expect(SelectorEngine.prev(el, 'button')).toEqual([])
})
it('should skip non-matching siblings', () => {
const el = fixtureEl.querySelector('.c')! as HTMLElement
const result = SelectorEngine.prev(el, 'p')
expect(result).toHaveLength(1)
expect(result[0].classList.contains('b')).toBe(true)
})
})
describe('next', () => {
beforeEach(() => {
fixtureEl.innerHTML = '<div><span class="a"></span><p class="b"></p><span class="c"></span></div>'
})
it('should return the next sibling matching the selector', () => {
const el = fixtureEl.querySelector('.a')! as HTMLElement
const result = SelectorEngine.next(el, 'span')
expect(result).toHaveLength(1)
expect(result[0].classList.contains('c')).toBe(true)
})
it('should return an empty array if no next sibling matches', () => {
const el = fixtureEl.querySelector('.c')! as HTMLElement
expect(SelectorEngine.next(el, 'button')).toEqual([])
})
it('should skip non-matching siblings', () => {
const el = fixtureEl.querySelector('.a')! as HTMLElement
const result = SelectorEngine.next(el, 'p')
expect(result).toHaveLength(1)
expect(result[0].classList.contains('b')).toBe(true)
})
})
describe('focusableChildren', () => {
it('should return focusable children', () => {
fixtureEl.innerHTML = '<div><button>OK</button><input type="text"><span>text</span></div>'
const parent = fixtureEl.querySelector('div')!
const result = SelectorEngine.focusableChildren(parent)
expect(result.length).toBeGreaterThanOrEqual(1)
const tags = result.map(el => el.tagName)
expect(tags).toContain('BUTTON')
expect(tags).toContain('INPUT')
expect(tags).not.toContain('SPAN')
})
it('should exclude disabled elements', () => {
fixtureEl.innerHTML = '<div><button disabled>No</button><button>Yes</button></div>'
const parent = fixtureEl.querySelector('div')!
const result = SelectorEngine.focusableChildren(parent)
const texts = result.map(el => el.textContent)
expect(texts).toContain('Yes')
expect(texts).not.toContain('No')
})
it('should exclude elements with negative tabindex', () => {
fixtureEl.innerHTML = '<div><button tabindex="-1">Hidden</button><button>Visible</button></div>'
const parent = fixtureEl.querySelector('div')!
const result = SelectorEngine.focusableChildren(parent)
const texts = result.map(el => el.textContent)
expect(texts).toContain('Visible')
expect(texts).not.toContain('Hidden')
})
})
describe('getSelector / getSelectorFromElement / getElementFromSelector', () => {
it('should resolve data-tblr-target', () => {
fixtureEl.innerHTML = '<div id="target"></div><a data-tblr-target="#target"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBe(fixtureEl.querySelector('#target'))
})
it('should resolve data-bs-target as fallback', () => {
fixtureEl.innerHTML = '<div id="target"></div><a data-bs-target="#target"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBe(fixtureEl.querySelector('#target'))
})
it('should prioritize data-tblr-target over data-bs-target', () => {
fixtureEl.innerHTML = '<div id="tblr"></div><div id="bs"></div><a data-tblr-target="#tblr" data-bs-target="#bs"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)!.id).toBe('tblr')
})
it('should resolve href as fallback', () => {
fixtureEl.innerHTML = '<div id="target"></div><a href="#target"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBe(fixtureEl.querySelector('#target'))
})
it('should return null when no selector can be resolved', () => {
fixtureEl.innerHTML = '<a></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBeNull()
})
it('should return null for href without hash or dot', () => {
fixtureEl.innerHTML = '<a href="https://example.com"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBeNull()
})
it('should return null for href="#"', () => {
fixtureEl.innerHTML = '<a href="#"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBeNull()
})
it('should extract hash from full URL href', () => {
fixtureEl.innerHTML = '<div id="section"></div><a href="http://example.com/page#section"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBe(fixtureEl.querySelector('#section'))
})
it('should resolve class-based href selectors', () => {
fixtureEl.innerHTML = '<div class="target"></div><a href=".target"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBe(fixtureEl.querySelector('.target'))
})
it('getSelectorFromElement should return selector string when element exists', () => {
fixtureEl.innerHTML = '<div id="target"></div><a data-tblr-target="#target"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getSelectorFromElement(trigger)).toBe('#target')
})
it('getSelectorFromElement should return null when target element does not exist', () => {
fixtureEl.innerHTML = '<a data-tblr-target="#nonexistent"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getSelectorFromElement(trigger)).toBeNull()
})
it('getSelectorFromElement should return null when no selector', () => {
fixtureEl.innerHTML = '<a></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getSelectorFromElement(trigger)).toBeNull()
})
})
describe('getMultipleElementsFromSelector', () => {
it('should return all matching elements', () => {
fixtureEl.innerHTML = '<div class="item"></div><div class="item"></div><a data-tblr-target=".item"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getMultipleElementsFromSelector(trigger)).toHaveLength(2)
})
it('should return an empty array when no selector', () => {
fixtureEl.innerHTML = '<a></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getMultipleElementsFromSelector(trigger)).toEqual([])
})
})
})
File diff suppressed because it is too large Load Diff
+646
View File
@@ -0,0 +1,646 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Modal from '../../src/bootstrap/modal'
import { clearFixture, getFixture } from '../helpers/fixture'
describe('Modal', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
document.body.classList.remove('modal-open')
for (const el of document.querySelectorAll('.modal-backdrop')) {
el.remove()
}
})
const createModalHTML = () => [
'<div class="modal" tabindex="-1">',
' <div class="modal-dialog">',
' <div class="modal-content">',
' <div class="modal-header">',
' <h5 class="modal-title">Modal</h5>',
' <button type="button" class="btn-close" data-bs-dismiss="modal"></button>',
' </div>',
' <div class="modal-body"><p>Content</p></div>',
' </div>',
' </div>',
'</div>'
].join('')
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Modal.VERSION).toBe('string')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(Modal.Default).toBeDefined()
expect(Modal.Default.backdrop).toBe(true)
expect(Modal.Default.focus).toBe(true)
expect(Modal.Default.keyboard).toBe(true)
})
})
describe('DefaultType', () => {
it('should return plugin default type config', () => {
expect(Modal.DefaultType).toBeDefined()
expect(Modal.DefaultType.backdrop).toBe('(boolean|string)')
})
})
describe('NAME', () => {
it('should return plugin name', () => {
expect(Modal.NAME).toBe('modal')
})
})
describe('constructor', () => {
it('should create modal instance', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
expect(modal).toBeInstanceOf(Modal)
expect(Modal.getInstance(modalEl)).toBe(modal)
})
it('should find dialog element', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
expect(modal._dialog).not.toBeNull()
})
})
describe('toggle', () => {
it('should show when hidden', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
expect(modal._isShown).toBe(true)
resolve()
})
modal.toggle()
})
})
it('should hide when shown', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.addEventListener('hidden.bs.modal', () => {
expect(modal._isShown).toBe(false)
resolve()
})
modal.toggle()
})
modal.show()
})
})
})
describe('show', () => {
it('should show modal', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
expect(modal._isShown).toBe(true)
expect(modalEl.classList.contains('show')).toBe(true)
expect(modalEl.getAttribute('aria-modal')).toBe('true')
expect(modalEl.getAttribute('role')).toBe('dialog')
expect(document.body.classList.contains('modal-open')).toBe(true)
resolve()
})
modal.show()
})
})
it('should not show if already shown', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
modal.show()
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 30)
})
modal.show()
})
})
it('should not show if show event is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('show.bs.modal', event => {
event.preventDefault()
setTimeout(() => {
expect(modal._isShown).toBe(false)
resolve()
}, 30)
})
modal.show()
})
})
it('should pass relatedTarget in show event', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML() + '<button id="trigger">Open</button>'
const modalEl = fixtureEl.querySelector('.modal')!
const trigger = fixtureEl.querySelector('#trigger') as HTMLElement
const modal = new Modal(modalEl)
modalEl.addEventListener('show.bs.modal', (event: any) => {
expect(event.relatedTarget).toBe(trigger)
resolve()
})
modal.show(trigger)
})
})
})
describe('hide', () => {
it('should hide modal', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
modal.hide()
})
modalEl.addEventListener('hidden.bs.modal', () => {
expect(modal._isShown).toBe(false)
expect(modalEl.style.display).toBe('none')
expect(modalEl.getAttribute('aria-hidden')).toBe('true')
resolve()
})
modal.show()
})
})
it('should not hide if not shown', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modal.hide()
expect(modal._isShown).toBe(false)
})
it('should not hide if hide event is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.addEventListener('hide.bs.modal', event => {
event.preventDefault()
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 30)
})
modal.hide()
})
modal.show()
})
})
})
describe('dispose', () => {
it('should dispose modal', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modal.dispose()
expect(Modal.getInstance(modalEl)).toBeNull()
})
})
describe('handleUpdate', () => {
it('should call _adjustDialog', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
const spy = vi.spyOn(modal, '_adjustDialog')
modal.handleUpdate()
expect(spy).toHaveBeenCalled()
})
})
describe('_isAnimated', () => {
it('should return true when fade class is present', () => {
fixtureEl.innerHTML = '<div class="modal fade"><div class="modal-dialog"></div></div>'
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
expect(modal._isAnimated()).toBe(true)
})
it('should return false without fade class', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
expect(modal._isAnimated()).toBe(false)
})
})
describe('keyboard', () => {
it('should close on Escape when keyboard is true', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { keyboard: true })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.addEventListener('hidden.bs.modal', () => {
expect(modal._isShown).toBe(false)
resolve()
})
modalEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
modal.show()
})
})
it('should not close on Escape when keyboard is false', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { keyboard: false })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 30)
})
modal.show()
})
})
it('should ignore non-Escape keys', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 30)
})
modal.show()
})
})
})
describe('getInstance', () => {
it('should return null if no instance', () => {
expect(Modal.getInstance(fixtureEl)).toBeNull()
})
it('should return modal instance', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
expect(Modal.getInstance(modalEl)).toBe(modal)
})
})
describe('getOrCreateInstance', () => {
it('should return existing instance', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
expect(Modal.getOrCreateInstance(modalEl)).toBe(modal)
})
it('should create new instance', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
expect(Modal.getInstance(modalEl)).toBeNull()
expect(Modal.getOrCreateInstance(modalEl)).toBeInstanceOf(Modal)
})
})
describe('_triggerBackdropTransition', () => {
it('should add and remove modal-static class', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { keyboard: false })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.addEventListener('hidePrevented.bs.modal', () => {
setTimeout(() => {
resolve()
}, 30)
})
modalEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
modal.show()
})
})
it('should not transition if hidePrevented is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { keyboard: false })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.addEventListener('hidePrevented.bs.modal', event => {
event.preventDefault()
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 30)
})
modalEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
modal.show()
})
})
it('should early return if overflowY is hidden', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { keyboard: false })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.style.overflowY = 'hidden'
modalEl.addEventListener('hidePrevented.bs.modal', () => {
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 30)
})
modalEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
modal.show()
})
})
it('should early return if already has modal-static class', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { keyboard: false })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.classList.add('modal-static')
modalEl.addEventListener('hidePrevented.bs.modal', () => {
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 30)
})
modalEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
modal.show()
})
})
})
describe('backdrop click', () => {
it('should hide when clicking outside dialog with backdrop true', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { backdrop: true })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.addEventListener('hidden.bs.modal', () => {
expect(modal._isShown).toBe(false)
resolve()
})
modalEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
modalEl.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
modal.show()
})
})
it('should not hide when click starts inside dialog', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const dialog = modalEl.querySelector('.modal-dialog')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
const mousedownEvent = new MouseEvent('mousedown', { bubbles: true })
Object.defineProperty(mousedownEvent, 'target', { value: dialog })
modalEl.dispatchEvent(mousedownEvent)
modalEl.dispatchEvent(new MouseEvent('click', { bubbles: true }))
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 50)
})
modal.show()
})
})
it('should trigger backdrop transition with static backdrop', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { backdrop: 'static' })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.addEventListener('hidePrevented.bs.modal', () => {
expect(modal._isShown).toBe(true)
resolve()
})
modalEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
modalEl.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
modal.show()
})
})
})
describe('_showElement', () => {
it('should append to body if not already in DOM', () => {
const modalEl = document.createElement('div')
modalEl.classList.add('modal')
modalEl.setAttribute('tabindex', '-1')
modalEl.innerHTML = '<div class="modal-dialog"><div class="modal-content"></div></div>'
const modal = new Modal(modalEl)
return new Promise<void>(resolve => {
modalEl.addEventListener('shown.bs.modal', () => {
expect(document.body.contains(modalEl)).toBe(true)
modal.dispose()
modalEl.remove()
resolve()
})
modal.show()
})
})
it('should scroll modal body to top', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
expect(modalEl.scrollTop).toBe(0)
resolve()
})
modal.show()
})
})
})
describe('data-tblr-toggle', () => {
it('should open modal via data-tblr-toggle="modal"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button data-tblr-toggle="modal" data-bs-target="#testModal">Open</button>',
'<div class="modal" id="testModal" tabindex="-1">',
' <div class="modal-dialog"><div class="modal-content"></div></div>',
'</div>'
].join('')
const modalEl = fixtureEl.querySelector('#testModal')!
const btn = fixtureEl.querySelector('[data-tblr-toggle="modal"]') as HTMLElement
modalEl.addEventListener('shown.bs.modal', () => {
const modal = Modal.getInstance(modalEl) as Modal
expect(modal._isShown).toBe(true)
resolve()
})
btn.click()
})
})
it('should open modal via data-tblr-toggle with data-tblr-target', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button data-tblr-toggle="modal" data-tblr-target="#testModal">Open</button>',
'<div class="modal" id="testModal" tabindex="-1">',
' <div class="modal-dialog"><div class="modal-content"></div></div>',
'</div>'
].join('')
const modalEl = fixtureEl.querySelector('#testModal')!
const btn = fixtureEl.querySelector('[data-tblr-toggle="modal"]') as HTMLElement
modalEl.addEventListener('shown.bs.modal', () => {
const modal = Modal.getInstance(modalEl) as Modal
expect(modal._isShown).toBe(true)
resolve()
})
btn.click()
})
})
})
describe('data-tblr-dismiss', () => {
it('should close modal via data-tblr-dismiss="modal"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="modal" tabindex="-1">',
' <div class="modal-dialog">',
' <div class="modal-content">',
' <button type="button" class="btn-close" data-tblr-dismiss="modal"></button>',
' </div>',
' </div>',
'</div>'
].join('')
const modalEl = fixtureEl.querySelector('.modal')!
const dismissBtn = fixtureEl.querySelector('[data-tblr-dismiss="modal"]') as HTMLElement
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
dismissBtn.click()
})
modalEl.addEventListener('hidden.bs.modal', () => {
expect(modal._isShown).toBe(false)
resolve()
})
modal.show()
})
})
})
})
+458
View File
@@ -0,0 +1,458 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Offcanvas from '../../src/bootstrap/offcanvas'
import { clearFixture, getFixture } from '../helpers/fixture'
describe('Offcanvas', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
for (const el of document.querySelectorAll('.offcanvas-backdrop')) {
el.remove()
}
})
const createOffcanvasHTML = () => [
'<div class="offcanvas offcanvas-start" tabindex="-1">',
' <div class="offcanvas-header">',
' <h5 class="offcanvas-title">Offcanvas</h5>',
' <button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>',
' </div>',
' <div class="offcanvas-body">Content</div>',
'</div>'
].join('')
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Offcanvas.VERSION).toBe('string')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(Offcanvas.Default).toBeDefined()
expect(Offcanvas.Default.backdrop).toBe(true)
expect(Offcanvas.Default.keyboard).toBe(true)
expect(Offcanvas.Default.scroll).toBe(false)
})
})
describe('DefaultType', () => {
it('should return plugin default type config', () => {
expect(Offcanvas.DefaultType).toBeDefined()
expect(Offcanvas.DefaultType.backdrop).toBe('(boolean|string)')
})
})
describe('NAME', () => {
it('should return plugin name', () => {
expect(Offcanvas.NAME).toBe('offcanvas')
})
})
describe('constructor', () => {
it('should create offcanvas instance', () => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
expect(instance).toBeInstanceOf(Offcanvas)
expect(Offcanvas.getInstance(el)).toBe(instance)
})
})
describe('toggle', () => {
it('should show when hidden', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
el.addEventListener('shown.bs.offcanvas', () => {
expect(instance._isShown).toBe(true)
resolve()
})
instance.toggle()
})
})
it('should hide when shown', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
el.addEventListener('shown.bs.offcanvas', () => {
el.addEventListener('hidden.bs.offcanvas', () => {
expect(instance._isShown).toBe(false)
resolve()
})
instance.toggle()
})
instance.show()
})
})
})
describe('show', () => {
it('should show offcanvas', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
el.addEventListener('shown.bs.offcanvas', () => {
expect(instance._isShown).toBe(true)
expect(el.classList.contains('show')).toBe(true)
expect(el.getAttribute('aria-modal')).toBe('true')
expect(el.getAttribute('role')).toBe('dialog')
resolve()
})
instance.show()
})
})
it('should not show if already shown', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
let showCount = 0
el.addEventListener('show.bs.offcanvas', () => {
showCount++
})
el.addEventListener('shown.bs.offcanvas', () => {
instance.show()
setTimeout(() => {
expect(showCount).toBe(1)
resolve()
}, 30)
})
instance.show()
})
})
it('should not show if show event is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
el.addEventListener('show.bs.offcanvas', event => {
event.preventDefault()
setTimeout(() => {
expect(instance._isShown).toBe(false)
resolve()
}, 30)
})
instance.show()
})
})
it('should pass relatedTarget in show event', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML() + '<button id="trigger">Open</button>'
const el = fixtureEl.querySelector('.offcanvas')!
const trigger = fixtureEl.querySelector('#trigger') as HTMLElement
const instance = new Offcanvas(el)
el.addEventListener('show.bs.offcanvas', (event: any) => {
expect(event.relatedTarget).toBe(trigger)
resolve()
})
instance.show(trigger)
})
})
})
describe('hide', () => {
it('should hide offcanvas', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
el.addEventListener('shown.bs.offcanvas', () => {
instance.hide()
})
el.addEventListener('hidden.bs.offcanvas', () => {
expect(instance._isShown).toBe(false)
expect(el.classList.contains('show')).toBe(false)
resolve()
})
instance.show()
})
})
it('should not hide if not shown', () => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
let hideCount = 0
el.addEventListener('hide.bs.offcanvas', () => {
hideCount++
})
instance.hide()
expect(hideCount).toBe(0)
})
it('should not hide if hide event is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
el.addEventListener('shown.bs.offcanvas', () => {
el.addEventListener('hide.bs.offcanvas', event => {
event.preventDefault()
setTimeout(() => {
expect(instance._isShown).toBe(true)
resolve()
}, 30)
})
instance.hide()
})
instance.show()
})
})
})
describe('dispose', () => {
it('should dispose offcanvas', () => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
instance.dispose()
expect(Offcanvas.getInstance(el)).toBeNull()
})
})
describe('keyboard', () => {
it('should close on Escape when keyboard is true', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el, { keyboard: true })
el.addEventListener('shown.bs.offcanvas', () => {
el.addEventListener('hidden.bs.offcanvas', () => {
expect(instance._isShown).toBe(false)
resolve()
})
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
instance.show()
})
})
it('should fire hidePrevented when keyboard is false and Escape pressed', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
new Offcanvas(el, { keyboard: false })
el.addEventListener('shown.bs.offcanvas', () => {
el.addEventListener('hidePrevented.bs.offcanvas', () => {
resolve()
})
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
Offcanvas.getOrCreateInstance(el)?.show()
})
})
it('should ignore non-Escape keys', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
el.addEventListener('shown.bs.offcanvas', () => {
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
setTimeout(() => {
expect(instance._isShown).toBe(true)
resolve()
}, 30)
})
instance.show()
})
})
})
describe('getInstance', () => {
it('should return null if no instance', () => {
expect(Offcanvas.getInstance(fixtureEl)).toBeNull()
})
it('should return offcanvas instance', () => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
expect(Offcanvas.getInstance(el)).toBe(instance)
})
})
describe('getOrCreateInstance', () => {
it('should return existing instance', () => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
expect(Offcanvas.getOrCreateInstance(el)).toBe(instance)
})
it('should create new instance', () => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
expect(Offcanvas.getInstance(el)).toBeNull()
expect(Offcanvas.getOrCreateInstance(el)).toBeInstanceOf(Offcanvas)
})
})
describe('scroll option', () => {
it('should not hide scrollbar when scroll is true', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el, { scroll: true })
el.addEventListener('shown.bs.offcanvas', () => {
expect(instance._isShown).toBe(true)
resolve()
})
instance.show()
})
})
})
describe('backdrop option', () => {
it('should work with backdrop set to false', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el, { backdrop: false })
el.addEventListener('shown.bs.offcanvas', () => {
expect(instance._isShown).toBe(true)
resolve()
})
instance.show()
})
})
it('should create offcanvas with static backdrop option', () => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el, { backdrop: 'static' })
expect(instance).toBeInstanceOf(Offcanvas)
})
})
describe('data-tblr-toggle', () => {
it('should open offcanvas via data-tblr-toggle="offcanvas"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button data-tblr-toggle="offcanvas" data-bs-target="#testOffcanvas">Open</button>',
'<div class="offcanvas offcanvas-start" id="testOffcanvas" tabindex="-1">',
' <div class="offcanvas-body">Content</div>',
'</div>'
].join('')
const offcanvasEl = fixtureEl.querySelector('#testOffcanvas')!
const btn = fixtureEl.querySelector('[data-tblr-toggle="offcanvas"]') as HTMLElement
offcanvasEl.addEventListener('shown.bs.offcanvas', () => {
const instance = Offcanvas.getInstance(offcanvasEl) as Offcanvas
expect(instance._isShown).toBe(true)
resolve()
})
btn.click()
})
})
it('should open offcanvas via data-tblr-toggle with data-tblr-target', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button data-tblr-toggle="offcanvas" data-tblr-target="#testOffcanvas">Open</button>',
'<div class="offcanvas offcanvas-start" id="testOffcanvas" tabindex="-1">',
' <div class="offcanvas-body">Content</div>',
'</div>'
].join('')
const offcanvasEl = fixtureEl.querySelector('#testOffcanvas')!
const btn = fixtureEl.querySelector('[data-tblr-toggle="offcanvas"]') as HTMLElement
offcanvasEl.addEventListener('shown.bs.offcanvas', () => {
const instance = Offcanvas.getInstance(offcanvasEl) as Offcanvas
expect(instance._isShown).toBe(true)
resolve()
})
btn.click()
})
})
})
describe('data-tblr-dismiss', () => {
it('should close offcanvas via data-tblr-dismiss="offcanvas"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="offcanvas offcanvas-start" tabindex="-1">',
' <div class="offcanvas-header">',
' <button type="button" class="btn-close" data-tblr-dismiss="offcanvas"></button>',
' </div>',
' <div class="offcanvas-body">Content</div>',
'</div>'
].join('')
const el = fixtureEl.querySelector('.offcanvas')!
const dismissBtn = fixtureEl.querySelector('[data-tblr-dismiss="offcanvas"]') as HTMLElement
const instance = new Offcanvas(el)
el.addEventListener('shown.bs.offcanvas', () => {
dismissBtn.click()
})
el.addEventListener('hidden.bs.offcanvas', () => {
expect(instance._isShown).toBe(false)
resolve()
})
instance.show()
})
})
})
})
+253
View File
@@ -0,0 +1,253 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Popover from '../../src/bootstrap/popover'
import Tooltip from '../../src/bootstrap/tooltip'
import { clearFixture, getFixture } from '../helpers/fixture'
vi.mock('@popperjs/core', () => ({
createPopper: vi.fn(() => ({
destroy: vi.fn(),
update: vi.fn(),
setOptions: vi.fn()
}))
}))
describe('Popover', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
for (const el of document.querySelectorAll('.popover')) {
el.remove()
}
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Popover.VERSION).toBe('string')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(Popover.Default).toBeDefined()
expect(Popover.Default.trigger).toBe('click')
expect(Popover.Default.placement).toBe('right')
expect(Popover.Default.content).toBe('')
})
})
describe('DefaultType', () => {
it('should return plugin default type config', () => {
expect(Popover.DefaultType).toBeDefined()
expect(Popover.DefaultType.content).toBe('(null|string|element|function)')
})
})
describe('NAME', () => {
it('should return plugin name', () => {
expect(Popover.NAME).toBe('popover')
})
})
describe('extends Tooltip', () => {
it('should be an instance of Tooltip', () => {
fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el)
expect(popover).toBeInstanceOf(Tooltip)
})
})
describe('constructor', () => {
it('should create popover instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Popover title" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el)
expect(popover).toBeInstanceOf(Popover)
expect(Popover.getInstance(el)).toBe(popover)
})
})
describe('show', () => {
it('should show popover', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Popover title" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { animation: false })
el.addEventListener('shown.bs.popover', () => {
expect(popover.tip).not.toBeNull()
expect(popover.tip!.classList.contains('show')).toBe(true)
resolve()
})
popover.show()
})
})
it('should show popover with only content', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { content: 'Only content', animation: false })
el.addEventListener('shown.bs.popover', () => {
expect(popover._isShown()).toBe(true)
resolve()
})
popover.show()
})
})
it('should show popover with only title', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Only title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { animation: false })
el.addEventListener('shown.bs.popover', () => {
expect(popover._isShown()).toBe(true)
resolve()
})
popover.show()
})
})
})
describe('hide', () => {
it('should hide popover', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { animation: false })
el.addEventListener('shown.bs.popover', () => {
popover.hide()
})
el.addEventListener('hidden.bs.popover', () => {
expect(popover._isShown()).toBe(false)
resolve()
})
popover.show()
})
})
})
describe('dispose', () => {
it('should dispose popover', () => {
fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el)
popover.dispose()
expect(Popover.getInstance(el)).toBeNull()
})
})
describe('_isWithContent', () => {
it('should return true with title and content', () => {
fixtureEl.innerHTML = '<a href="#" title="Title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { content: 'Content' })
expect(popover._isWithContent()).toBe(true)
})
it('should return true with only content', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { content: 'Content only' })
expect(popover._isWithContent()).toBe(true)
})
it('should return true with only title', () => {
fixtureEl.innerHTML = '<a href="#" title="Title only">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el)
expect(popover._isWithContent()).toBe(true)
})
it('should return false without title or content', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el)
expect(popover._isWithContent()).toBe(false)
})
})
describe('_getContentForTemplate', () => {
it('should return object with header and body selectors', () => {
fixtureEl.innerHTML = '<a href="#" title="Title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { content: 'Body content' })
const templateContent = popover._getContentForTemplate()
expect(templateContent['.popover-header']).toBeDefined()
expect(templateContent['.popover-body']).toBe('Body content')
})
})
describe('_getContent', () => {
it('should return string content', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { content: 'Test content' })
expect(popover._getContent()).toBe('Test content')
})
it('should resolve function content', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { content: () => 'Function content' })
expect(popover._getContent()).toBe('Function content')
})
})
describe('getInstance', () => {
it('should return null if no instance', () => {
expect(Popover.getInstance(fixtureEl)).toBeNull()
})
it('should return popover instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el)
expect(Popover.getInstance(el)).toBe(popover)
})
})
describe('getOrCreateInstance', () => {
it('should return existing instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el)
expect(Popover.getOrCreateInstance(el)).toBe(popover)
})
it('should create new instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
expect(Popover.getInstance(el)).toBeNull()
expect(Popover.getOrCreateInstance(el)).toBeInstanceOf(Popover)
})
})
})
+681
View File
@@ -0,0 +1,681 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import ScrollSpy from '../../src/bootstrap/scrollspy'
import { clearFixture, createEvent, getFixture } from '../helpers/fixture'
class MockIntersectionObserver implements IntersectionObserver {
readonly root: Element | Document | null = null
readonly rootMargin: string = ''
readonly thresholds: ReadonlyArray<number> = []
callback: IntersectionObserverCallback
options: IntersectionObserverInit | undefined
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.callback = callback
this.options = options
this.root = options?.root as Element | null ?? null
this.rootMargin = options?.rootMargin ?? ''
this.thresholds = Array.isArray(options?.threshold) ? options!.threshold : [options?.threshold ?? 0]
}
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
takeRecords = vi.fn().mockReturnValue([])
}
const getDummyFixture = () => [
'<nav id="navBar" class="navbar">',
' <ul class="nav">',
' <li class="nav-item"><a id="li-jsm-1" class="nav-link" href="#div-jsm-1">div 1</a></li>',
' </ul>',
'</nav>',
'<div class="content" data-bs-target="#navBar" style="overflow-y: auto">',
' <div id="div-jsm-1">div 1</div>',
'</div>'
].join('')
describe('ScrollSpy', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof ScrollSpy.VERSION).toBe('string')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(typeof ScrollSpy.Default).toBe('object')
})
})
describe('DATA_KEY', () => {
it('should return plugin data key', () => {
expect(ScrollSpy.DATA_KEY).toBe('bs.scrollspy')
})
})
describe('constructor', () => {
it('should accept element as CSS selector or DOM element', () => {
fixtureEl.innerHTML = getDummyFixture()
const sSpyEl = fixtureEl.querySelector('.content')!
const sSpyBySelector = new ScrollSpy('.content')
const sSpyByElement = new ScrollSpy(sSpyEl)
expect(sSpyBySelector._element).toBe(sSpyEl)
expect(sSpyByElement._element).toBe(sSpyEl)
})
it('should set _rootElement to null if overflowY is visible', () => {
fixtureEl.innerHTML = [
'<nav id="navigation" class="navbar">',
' <ul class="navbar-nav">',
' <li class="nav-item"><a class="nav-link" href="#one">One</a></li>',
' </ul>',
'</nav>',
'<div id="content" style="overflow-y: visible;">',
' <div id="one" style="height: 300px;">test</div>',
'</div>'
].join('')
const contentEl = fixtureEl.querySelector('#content')!
const originalGetComputedStyle = window.getComputedStyle
vi.spyOn(window, 'getComputedStyle').mockImplementation((el, pseudoElt?) => {
const result = originalGetComputedStyle(el, pseudoElt ?? undefined)
if (el === contentEl) {
return new Proxy(result, {
get(target, prop) {
if (prop === 'overflowY') return 'visible'
return (target as any)[prop]
}
}) as CSSStyleDeclaration
}
return result
})
const scrollSpy = new ScrollSpy(contentEl, {
target: '#navigation'
})
expect(scrollSpy._rootElement).toBeNull()
})
it('should respect threshold option', () => {
fixtureEl.innerHTML = [
'<ul id="navigation" class="navbar">',
' <a class="nav-link" href="#one">One</a>',
'</ul>',
'<div id="content">',
' <div id="one">test</div>',
'</div>'
].join('')
const scrollSpy = new ScrollSpy('#content', {
target: '#navigation',
threshold: [1]
})
expect(scrollSpy._observer!.thresholds).toEqual([1])
})
it('should parse string threshold from data attribute', () => {
fixtureEl.innerHTML = [
'<ul id="navigation" class="navbar">',
' <a class="nav-link" href="#one">One</a>',
'</ul>',
'<div id="content" data-bs-threshold="0,0.2,1">',
' <div id="one">test</div>',
'</div>'
].join('')
const scrollSpy = new ScrollSpy('#content', {
target: '#navigation'
})
expect(scrollSpy._observer!.thresholds).toEqual([0, 0.2, 1])
})
it('should initialize with empty maps when sections are not visible', () => {
fixtureEl.innerHTML = [
'<nav id="navigation" class="navbar">',
' <ul class="navbar-nav">',
' <li class="nav-item"><a class="nav-link" href="#">One</a></li>',
' <li class="nav-item"><a class="nav-link" href="#two">Two</a></li>',
' </ul>',
'</nav>',
'<div id="content" style="height: 200px; overflow-y: auto;">',
' <div id="two" style="height: 300px;">test</div>',
'</div>'
].join('')
const scrollSpy = new ScrollSpy(fixtureEl.querySelector('#content')!, {
target: '#navigation'
})
// jsdom elements are not "visible" (getClientRects returns empty), so maps are empty
expect(scrollSpy._targetLinks).toBeInstanceOf(Map)
expect(scrollSpy._observableSections).toBeInstanceOf(Map)
})
})
describe('refresh', () => {
it('should disconnect existing observer', () => {
fixtureEl.innerHTML = getDummyFixture()
const el = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(el)
const spy = vi.spyOn(scrollSpy._observer!, 'disconnect')
scrollSpy.refresh()
expect(spy).toHaveBeenCalled()
})
})
describe('dispose', () => {
it('should dispose a scrollspy', () => {
fixtureEl.innerHTML = getDummyFixture()
const el = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(el)
expect(ScrollSpy.getInstance(el)).not.toBeNull()
scrollSpy.dispose()
expect(ScrollSpy.getInstance(el)).toBeNull()
})
})
describe('getInstance', () => {
it('should return scrollspy instance', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div, { target: fixtureEl.querySelector('#navBar')! })
expect(ScrollSpy.getInstance(div)).toBe(scrollSpy)
expect(ScrollSpy.getInstance(div)).toBeInstanceOf(ScrollSpy)
})
it('should return null if no instance', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
expect(ScrollSpy.getInstance(div)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should return scrollspy instance', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
expect(ScrollSpy.getOrCreateInstance(div)).toBe(scrollSpy)
expect(ScrollSpy.getOrCreateInstance(div)).toBeInstanceOf(ScrollSpy)
})
it('should return new instance when there is no scrollspy instance', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
expect(ScrollSpy.getInstance(div)).toBeNull()
expect(ScrollSpy.getOrCreateInstance(div)).toBeInstanceOf(ScrollSpy)
})
it('should return new instance with given configuration', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollspy = ScrollSpy.getOrCreateInstance(div, { offset: 1 })
expect(scrollspy).toBeInstanceOf(ScrollSpy)
expect(scrollspy._config.offset).toBe(1)
})
it('should return existing instance ignoring new config', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollspy = new ScrollSpy(div, { offset: 1 })
const scrollspy2 = ScrollSpy.getOrCreateInstance(div, { offset: 2 })
expect(scrollspy2).toBe(scrollspy)
expect(scrollspy2._config.offset).toBe(1)
})
})
describe('event handler', () => {
it('should create scrollspy on window load event', () => {
fixtureEl.innerHTML = [
'<div id="nav"></div>',
'<div id="wrapper" data-bs-spy="scroll" data-bs-target="#nav" style="overflow-y: auto"></div>'
].join('')
const scrollSpyEl = fixtureEl.querySelector('#wrapper')!
window.dispatchEvent(createEvent('load'))
expect(ScrollSpy.getInstance(scrollSpyEl)).not.toBeNull()
})
})
describe('_observerCallback', () => {
it('should activate target on intersecting entry (scroll down)', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link = fixtureEl.querySelector('#li-jsm-1') as HTMLElement
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
scrollSpy._targetLinks.set('#div-jsm-1', link)
scrollSpy._observableSections.set('#div-jsm-1', section)
scrollSpy._previousScrollData.parentScrollTop = 0
const entry = {
isIntersecting: true,
target: section,
intersectionRatio: 1
} as unknown as IntersectionObserverEntry
Object.defineProperty(section, 'offsetTop', { value: 100, configurable: true })
scrollSpy._observerCallback([entry])
expect(link.classList.contains('active')).toBe(true)
expect(scrollSpy._activeTarget).toBe(link)
})
it('should clear active class on non-intersecting entry', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link = fixtureEl.querySelector('#li-jsm-1') as HTMLElement
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
link.classList.add('active')
scrollSpy._activeTarget = link
scrollSpy._targetLinks.set('#div-jsm-1', link)
const entry = {
isIntersecting: false,
target: section
} as unknown as IntersectionObserverEntry
scrollSpy._observerCallback([entry])
expect(scrollSpy._activeTarget).toBeNull()
})
it('should activate on scroll up when entry is higher', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link = fixtureEl.querySelector('#li-jsm-1') as HTMLElement
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
scrollSpy._targetLinks.set('#div-jsm-1', link)
scrollSpy._observableSections.set('#div-jsm-1', section)
scrollSpy._previousScrollData.parentScrollTop = 200
scrollSpy._previousScrollData.visibleEntryTop = 300
Object.defineProperty(section, 'offsetTop', { value: 100, configurable: true })
Object.defineProperty(div, 'scrollTop', { value: 100, configurable: true, writable: true })
const entry = {
isIntersecting: true,
target: section,
intersectionRatio: 1
} as unknown as IntersectionObserverEntry
scrollSpy._observerCallback([entry])
expect(link.classList.contains('active')).toBe(true)
})
it('should not activate on scroll up when entry is lower', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link = fixtureEl.querySelector('#li-jsm-1') as HTMLElement
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
scrollSpy._targetLinks.set('#div-jsm-1', link)
scrollSpy._observableSections.set('#div-jsm-1', section)
scrollSpy._previousScrollData.parentScrollTop = 200
scrollSpy._previousScrollData.visibleEntryTop = 50
Object.defineProperty(section, 'offsetTop', { value: 100, configurable: true })
Object.defineProperty(div, 'scrollTop', { value: 100, configurable: true, writable: true })
const entry = {
isIntersecting: true,
target: section,
intersectionRatio: 1
} as unknown as IntersectionObserverEntry
scrollSpy._observerCallback([entry])
expect(link.classList.contains('active')).toBe(false)
})
it('should not re-process if activeTarget is same', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link = fixtureEl.querySelector('#li-jsm-1') as HTMLElement
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
scrollSpy._targetLinks.set('#div-jsm-1', link)
scrollSpy._observableSections.set('#div-jsm-1', section)
link.classList.add('active')
scrollSpy._activeTarget = link
scrollSpy._previousScrollData.parentScrollTop = 0
Object.defineProperty(section, 'offsetTop', { value: 100, configurable: true })
const entry = {
isIntersecting: true,
target: section,
intersectionRatio: 1
} as unknown as IntersectionObserverEntry
const spy = vi.fn()
div.addEventListener('activate.bs.scrollspy', spy)
scrollSpy._observerCallback([entry])
expect(spy).not.toHaveBeenCalled()
})
})
describe('_process', () => {
it('should add active class and trigger event', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link = fixtureEl.querySelector('#li-jsm-1') as HTMLElement
const spy = vi.fn()
div.addEventListener('activate.bs.scrollspy', spy)
scrollSpy._process(link)
expect(link.classList.contains('active')).toBe(true)
expect(scrollSpy._activeTarget).toBe(link)
expect(spy).toHaveBeenCalled()
})
})
describe('_activateParents', () => {
it('should activate dropdown-toggle for dropdown-item target', () => {
fixtureEl.innerHTML = [
'<nav class="navbar">',
' <div class="dropdown">',
' <a class="dropdown-toggle" href="#">Dropdown</a>',
' <div class="dropdown-menu">',
' <a class="dropdown-item" id="drop1" href="#one">One</a>',
' </div>',
' </div>',
'</nav>',
'<div class="content" style="overflow-y: auto">',
' <div id="one">one</div>',
'</div>'
].join('')
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const dropItem = fixtureEl.querySelector('#drop1') as HTMLElement
const dropToggle = fixtureEl.querySelector('.dropdown-toggle') as HTMLElement
scrollSpy._activateParents(dropItem)
expect(dropToggle.classList.contains('active')).toBe(true)
})
it('should activate nav parents for nav-link target', () => {
fixtureEl.innerHTML = [
'<nav class="navbar">',
' <nav class="nav">',
' <a class="nav-link" id="a1" href="#one">One</a>',
' </nav>',
'</nav>',
'<div class="content" style="overflow-y: auto">',
' <div id="one">one</div>',
'</div>'
].join('')
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link = fixtureEl.querySelector('#a1') as HTMLElement
scrollSpy._activateParents(link)
// nav-link itself is handled by _process, parents via SelectorEngine.prev
expect(link).toBeDefined()
})
})
describe('_clearActiveClass', () => {
it('should remove active class from parent and children', () => {
fixtureEl.innerHTML = [
'<nav class="navbar active">',
' <a class="nav-link active" href="#one">One</a>',
' <a class="nav-link active" href="#two">Two</a>',
'</nav>',
'<div class="content" style="overflow-y: auto">',
' <div id="one">one</div>',
'</div>'
].join('')
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const navbar = fixtureEl.querySelector('.navbar') as HTMLElement
scrollSpy._clearActiveClass(navbar)
expect(navbar.classList.contains('active')).toBe(false)
const activeLinks = fixtureEl.querySelectorAll('.nav-link.active')
expect(activeLinks).toHaveLength(0)
})
})
describe('_initializeTargetsAndObservables', () => {
it('should populate maps when sections are visible', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
Object.defineProperty(section, 'getClientRects', {
value: () => [{ width: 100, height: 100 }],
configurable: true
})
scrollSpy._initializeTargetsAndObservables()
expect(scrollSpy._targetLinks.size).toBe(1)
expect(scrollSpy._observableSections.size).toBe(1)
})
it('should skip disabled anchors', () => {
fixtureEl.innerHTML = [
'<nav id="navBar" class="navbar">',
' <ul class="nav">',
' <a class="nav-link" href="#div1" disabled>div 1</a>',
' <a class="nav-link disabled" href="#div2">div 2</a>',
' </ul>',
'</nav>',
'<div class="content" style="overflow-y: auto">',
' <div id="div1">div 1</div>',
' <div id="div2">div 2</div>',
'</div>'
].join('')
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
expect(scrollSpy._targetLinks.size).toBe(0)
})
})
describe('_activateParents (nav prev)', () => {
it('should activate prev sibling nav-link in list-group', () => {
fixtureEl.innerHTML = [
'<nav class="navbar">',
' <div class="list-group">',
' <a class="list-group-item" id="a1" href="#one">One</a>',
' <a class="list-group-item" id="a2" href="#two">Two</a>',
' </div>',
'</nav>',
'<div class="content" style="overflow-y: auto">',
' <div id="one">one</div>',
' <div id="two">two</div>',
'</div>'
].join('')
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link2 = fixtureEl.querySelector('#a2') as HTMLElement
scrollSpy._activateParents(link2)
// list-group-item doesn't have .dropdown-item, so it goes through nav parents path
expect(link2).toBeDefined()
})
})
describe('smoothScroll', () => {
it('should not enable smoothScroll by default', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content') as HTMLElement
div.scrollTo = vi.fn()
new ScrollSpy(div, { offset: 1 })
const link = fixtureEl.querySelector('[href="#div-jsm-1"]') as HTMLElement
link.click()
expect(div.scrollTo).not.toHaveBeenCalled()
})
it('should scrollTo observable section on anchor click', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content') as HTMLElement
div.scrollTo = vi.fn()
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
Object.defineProperty(section, 'getClientRects', {
value: () => [{ width: 100, height: 100 }],
configurable: true
})
const scrollSpy = new ScrollSpy(div, { offset: 1, smoothScroll: true })
scrollSpy._initializeTargetsAndObservables()
const link = fixtureEl.querySelector('[href="#div-jsm-1"]') as HTMLElement
link.click()
expect(div.scrollTo).toHaveBeenCalled()
})
it('should fallback to scrollTop if scrollTo is not available', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content') as HTMLElement
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
Object.defineProperty(section, 'getClientRects', {
value: () => [{ width: 100, height: 100 }],
configurable: true
})
// Manually set _rootElement to a plain object without scrollTo
const scrollSpy = new ScrollSpy(div, { offset: 1, smoothScroll: true })
scrollSpy._initializeTargetsAndObservables()
// Remove scrollTo to test fallback
delete (div as any).scrollTo
scrollSpy._rootElement = div
// Re-enable smoothScroll handler
scrollSpy._maybeEnableSmoothScroll()
const link = fixtureEl.querySelector('[href="#div-jsm-1"]') as HTMLElement
link.click()
// Should not throw - scrollTop assignment is the fallback
expect(ScrollSpy.getInstance(div)).not.toBeNull()
})
it('should not scroll if section not found in observables', () => {
fixtureEl.innerHTML = [
'<nav id="navBar" class="navbar">',
' <ul class="nav">',
' <a id="anchor-1" href="#div-jsm-1">div 1</a>',
' <a id="anchor-2" href="#foo">div 2</a>',
' </ul>',
'</nav>',
'<div class="content" data-bs-target="#navBar" style="overflow-y: auto">',
' <div id="div-jsm-1">div 1</div>',
'</div>'
].join('')
const div = fixtureEl.querySelector('.content') as HTMLElement
div.scrollTo = vi.fn()
new ScrollSpy(div, { offset: 1, smoothScroll: true })
const anchor2 = fixtureEl.querySelector('#anchor-2') as HTMLElement
anchor2.click()
expect(div.scrollTo).not.toHaveBeenCalled()
})
})
describe('data-tblr-spy', () => {
it('should create scrollspy on window load with data-tblr-spy="scroll"', () => {
fixtureEl.innerHTML = [
'<div id="nav"></div>',
'<div id="wrapper" data-tblr-spy="scroll" data-bs-target="#nav" style="overflow-y: auto"></div>'
].join('')
const scrollSpyEl = fixtureEl.querySelector('#wrapper')!
window.dispatchEvent(createEvent('load'))
expect(ScrollSpy.getInstance(scrollSpyEl)).not.toBeNull()
})
})
})
+849
View File
@@ -0,0 +1,849 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Tab from '../../src/bootstrap/tab'
import { clearFixture, createEvent, getFixture } from '../helpers/fixture'
describe('Tab', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Tab.VERSION).toBe('string')
})
})
describe('constructor', () => {
it('should accept element as CSS selector or DOM element', () => {
fixtureEl.innerHTML = [
'<ul class="nav">',
' <li><a href="#home" role="tab">Home</a></li>',
'</ul>',
'<ul>',
' <li id="home"></li>',
'</ul>'
].join('')
const tabEl = fixtureEl.querySelector('[href="#home"]')!
const tabBySelector = new Tab('[href="#home"]')
const tabByElement = new Tab(tabEl)
expect(tabBySelector._element).toBe(tabEl)
expect(tabByElement._element).toBe(tabEl)
})
it('should not throw if no parent', () => {
fixtureEl.innerHTML = '<div class=""><div class="nav-link"></div></div>'
const navEl = fixtureEl.querySelector('.nav-link')!
expect(() => {
new Tab(navEl)
}).not.toThrow()
})
})
describe('show', () => {
it('should activate element by tab id using buttons', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><button type="button" data-bs-target="#home" role="tab">Home</button></li>',
' <li><button type="button" id="triggerProfile" data-bs-target="#profile" role="tab">Profile</button></li>',
'</ul>',
'<ul>',
' <li id="home" role="tabpanel"></li>',
' <li id="profile" role="tabpanel"></li>',
'</ul>'
].join('')
const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')!
const tab = new Tab(profileTriggerEl)
profileTriggerEl.addEventListener('shown.bs.tab', () => {
expect(fixtureEl.querySelector('#profile')!.classList.contains('active')).toBe(true)
expect(profileTriggerEl.getAttribute('aria-selected')).toBe('true')
resolve()
})
tab.show()
})
})
it('should activate element by tab id using links', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><a href="#home" role="tab">Home</a></li>',
' <li><a id="triggerProfile" href="#profile" role="tab">Profile</a></li>',
'</ul>',
'<ul>',
' <li id="home" role="tabpanel"></li>',
' <li id="profile" role="tabpanel"></li>',
'</ul>'
].join('')
const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')!
const tab = new Tab(profileTriggerEl)
profileTriggerEl.addEventListener('shown.bs.tab', () => {
expect(fixtureEl.querySelector('#profile')!.classList.contains('active')).toBe(true)
expect(profileTriggerEl.getAttribute('aria-selected')).toBe('true')
resolve()
})
tab.show()
})
})
it('should activate element in list group', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="list-group" role="tablist">',
' <button type="button" data-bs-target="#home" role="tab">Home</button>',
' <button type="button" id="triggerProfile" data-bs-target="#profile" role="tab">Profile</button>',
'</div>',
'<div>',
' <div id="home" role="tabpanel"></div>',
' <div id="profile" role="tabpanel"></div>',
'</div>'
].join('')
const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')!
const tab = new Tab(profileTriggerEl)
profileTriggerEl.addEventListener('shown.bs.tab', () => {
expect(fixtureEl.querySelector('#profile')!.classList.contains('active')).toBe(true)
resolve()
})
tab.show()
})
})
it('should not fire shown when show is prevented', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const navEl = fixtureEl.querySelector('.nav > div')!
const tab = new Tab(navEl)
navEl.addEventListener('show.bs.tab', ev => {
ev.preventDefault()
setTimeout(resolve, 30)
})
navEl.addEventListener('shown.bs.tab', () => {
reject(new Error('should not trigger shown'))
})
tab.show()
})
})
it('should not fire shown when already active', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#profile" class="nav-link" role="tab">Profile</button></li>',
'</ul>',
'<div class="tab-content">',
' <div class="tab-pane active" id="home" role="tabpanel"></div>',
' <div class="tab-pane" id="profile" role="tabpanel"></div>',
'</div>'
].join('')
const triggerActive = fixtureEl.querySelector('button.active')!
const tab = new Tab(triggerActive)
triggerActive.addEventListener('shown.bs.tab', () => {
reject(new Error('should not trigger shown'))
})
tab.show()
setTimeout(resolve, 30)
})
})
it('show and shown events should reference correct relatedTarget', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>',
' <li class="nav-item" role="presentation"><button type="button" id="triggerProfile" data-bs-target="#profile" class="nav-link" role="tab">Profile</button></li>',
'</ul>',
'<div class="tab-content">',
' <div class="tab-pane active" id="home" role="tabpanel"></div>',
' <div class="tab-pane" id="profile" role="tabpanel"></div>',
'</div>'
].join('')
const secondTabTrigger = fixtureEl.querySelector('#triggerProfile')!
const secondTab = new Tab(secondTabTrigger)
secondTabTrigger.addEventListener('show.bs.tab', ((ev: CustomEvent) => {
expect(ev.relatedTarget!.getAttribute('data-bs-target')).toBe('#home')
}) as EventListener)
secondTabTrigger.addEventListener('shown.bs.tab', ((ev: CustomEvent) => {
expect(ev.relatedTarget!.getAttribute('data-bs-target')).toBe('#home')
expect(secondTabTrigger.getAttribute('aria-selected')).toBe('true')
resolve()
}) as EventListener)
secondTab.show()
})
})
it('should fire hide and hidden events', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><button type="button" data-bs-target="#home" role="tab">Home</button></li>',
' <li><button type="button" data-bs-target="#profile" role="tab">Profile</button></li>',
'</ul>'
].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: CustomEvent) => {
hideCalled = true
expect(ev.relatedTarget!.getAttribute('data-bs-target')).toBe('#profile')
}) as EventListener)
triggerList[0].addEventListener('hidden.bs.tab', ((ev: CustomEvent) => {
expect(hideCalled).toBe(true)
expect(ev.relatedTarget!.getAttribute('data-bs-target')).toBe('#profile')
resolve()
}) as EventListener)
firstTab.show()
})
})
it('should not fire hidden when hide is prevented', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><button type="button" data-bs-target="#home" role="tab">Home</button></li>',
' <li><button type="button" data-bs-target="#profile" role="tab">Profile</button></li>',
'</ul>'
].join('')
const triggerList = fixtureEl.querySelectorAll('button')
const firstTab = new Tab(triggerList[0])
const secondTab = new Tab(triggerList[1])
triggerList[0].addEventListener('shown.bs.tab', () => {
secondTab.show()
})
triggerList[0].addEventListener('hide.bs.tab', ev => {
ev.preventDefault()
setTimeout(resolve, 30)
})
triggerList[0].addEventListener('hidden.bs.tab', () => {
reject(new Error('should not trigger hidden'))
})
firstTab.show()
})
})
})
describe('dispose', () => {
it('should dispose a tab', () => {
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const el = fixtureEl.querySelector('.nav > div')!
const tab = new Tab(el)
expect(Tab.getInstance(el)).not.toBeNull()
tab.dispose()
expect(Tab.getInstance(el)).toBeNull()
})
})
describe('_activate', () => {
it('should not be called if element is null', () => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li class="nav-link"></li>',
'</ul>'
].join('')
const tabEl = fixtureEl.querySelector('.nav-link')!
const tab = new Tab(tabEl)
const spy = vi.spyOn(tab, '_queueCallback')
tab._activate(null)
expect(spy).not.toHaveBeenCalled()
})
})
describe('_setInitialAttributes', () => {
it('should set aria attributes', () => {
fixtureEl.innerHTML = [
'<ul class="nav">',
' <li class="nav-link" id="foo" data-bs-target="#panel"></li>',
' <li class="nav-link" data-bs-target="#panel2"></li>',
'</ul>',
'<div id="panel"></div>',
'<div id="panel2"></div>'
].join('')
const tabEl = fixtureEl.querySelector('.nav-link')!
const parent = fixtureEl.querySelector('.nav') as HTMLElement
const children = Array.from(fixtureEl.querySelectorAll('.nav-link')) as HTMLElement[]
const tabPanel = fixtureEl.querySelector('#panel')!
const tabPanel2 = fixtureEl.querySelector('#panel2')!
expect(parent.getAttribute('role')).toBeNull()
const tab = new Tab(tabEl)
tab._setInitialAttributes(parent, children)
expect(parent.getAttribute('role')).toBe('tablist')
expect(tabEl.getAttribute('role')).toBe('tab')
expect(tabPanel.getAttribute('role')).toBe('tabpanel')
expect(tabPanel2.getAttribute('role')).toBe('tabpanel')
expect(tabPanel.getAttribute('aria-labelledby')).toBe('foo')
expect(tabPanel2.hasAttribute('aria-labelledby')).toBe(false)
})
})
describe('_keydown', () => {
it('should ignore non-arrow keys', () => {
fixtureEl.innerHTML = [
'<ul class="nav">',
' <li class="nav-link" data-bs-toggle="tab"></li>',
'</ul>'
].join('')
const tabEl = fixtureEl.querySelector('.nav-link')!
const tab = new Tab(tabEl)
const spyStop = vi.spyOn(Event.prototype, 'stopPropagation')
const spyPrevent = vi.spyOn(Event.prototype, 'preventDefault')
const keydown = createEvent('keydown') as any
keydown.key = 'Enter'
tabEl.dispatchEvent(keydown)
expect(spyStop).not.toHaveBeenCalled()
expect(spyPrevent).not.toHaveBeenCalled()
})
it('should handle right/down arrow', () => {
fixtureEl.innerHTML = [
'<div class="nav">',
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab3" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')
const tabEl1 = fixtureEl.querySelector('#tab1')!
const tabEl2 = fixtureEl.querySelector('#tab2')!
const tabEl3 = fixtureEl.querySelector('#tab3')!
new Tab(tabEl1)
new Tab(tabEl2)
new Tab(tabEl3)
const spyFocus2 = vi.spyOn(tabEl2, 'focus')
const spyFocus3 = vi.spyOn(tabEl3, 'focus')
const keydown = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
tabEl1.dispatchEvent(keydown)
expect(spyFocus2).toHaveBeenCalled()
})
it('should handle left/up arrow', () => {
fixtureEl.innerHTML = [
'<div class="nav">',
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')
const tabEl1 = fixtureEl.querySelector('#tab1')!
const tabEl2 = fixtureEl.querySelector('#tab2')!
new Tab(tabEl1)
new Tab(tabEl2)
const spyFocus1 = vi.spyOn(tabEl1, 'focus')
const keydown = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
tabEl2.dispatchEvent(keydown)
expect(spyFocus1).toHaveBeenCalled()
})
it('should handle Home key', () => {
fixtureEl.innerHTML = [
'<div class="nav">',
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab3" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')
const tabEl1 = fixtureEl.querySelector('#tab1')!
const tabEl3 = fixtureEl.querySelector('#tab3')!
new Tab(tabEl1)
new Tab(tabEl3)
const spyFocus1 = vi.spyOn(tabEl1, 'focus')
const keydown = new KeyboardEvent('keydown', { key: 'Home', bubbles: true })
tabEl3.dispatchEvent(keydown)
expect(spyFocus1).toHaveBeenCalled()
})
it('should handle End key', () => {
fixtureEl.innerHTML = [
'<div class="nav">',
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab3" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')
const tabEl1 = fixtureEl.querySelector('#tab1')!
const tabEl3 = fixtureEl.querySelector('#tab3')!
new Tab(tabEl1)
new Tab(tabEl3)
const spyFocus3 = vi.spyOn(tabEl3, 'focus')
const keydown = new KeyboardEvent('keydown', { key: 'End', bubbles: true })
tabEl1.dispatchEvent(keydown)
expect(spyFocus3).toHaveBeenCalled()
})
it('should skip disabled elements', () => {
fixtureEl.innerHTML = [
'<div class="nav">',
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab2" class="nav-link" data-bs-toggle="tab" disabled></span>',
' <span id="tab3" class="nav-link disabled" data-bs-toggle="tab"></span>',
' <span id="tab4" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')
const tabEl1 = fixtureEl.querySelector('#tab1')!
const tabEl4 = fixtureEl.querySelector('#tab4')!
new Tab(tabEl1)
new Tab(tabEl4)
const spyFocus4 = vi.spyOn(tabEl4, 'focus')
const keydown = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
tabEl1.dispatchEvent(keydown)
expect(spyFocus4).toHaveBeenCalled()
})
})
describe('getInstance', () => {
it('should return null if no instance', () => {
expect(Tab.getInstance(fixtureEl)).toBeNull()
})
it('should return this instance', () => {
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const divEl = fixtureEl.querySelector('.nav > div')!
const tab = new Tab(divEl)
expect(Tab.getInstance(divEl)).toBe(tab)
expect(Tab.getInstance(divEl)).toBeInstanceOf(Tab)
})
})
describe('getOrCreateInstance', () => {
it('should return tab instance', () => {
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const div = fixtureEl.querySelector('div')!
const tab = new Tab(div)
expect(Tab.getOrCreateInstance(div)).toBe(tab)
expect(Tab.getOrCreateInstance(div)).toBeInstanceOf(Tab)
})
it('should return new instance when there is no tab instance', () => {
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
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<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>',
' <li class="nav-item" role="presentation"><button type="button" id="triggerProfile" data-bs-toggle="tab" data-bs-target="#profile" class="nav-link" role="tab">Profile</button></li>',
'</ul>',
'<div class="tab-content">',
' <div class="tab-pane active" id="home" role="tabpanel"></div>',
' <div class="tab-pane" id="profile" role="tabpanel"></div>',
'</div>'
].join('')
const secondTabTrigger = fixtureEl.querySelector('#triggerProfile')!
secondTabTrigger.addEventListener('shown.bs.tab', () => {
expect(secondTabTrigger.classList.contains('active')).toBe(true)
expect(fixtureEl.querySelector('#profile')!.classList.contains('active')).toBe(true)
resolve()
})
;(secondTabTrigger as HTMLElement).click()
})
})
it('should prevent default when trigger is <a>', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><a type="button" href="#test" class="active" role="tab" data-bs-toggle="tab">Home</a></li>',
' <li><a type="button" href="#test2" role="tab" data-bs-toggle="tab">Profile</a></li>',
'</ul>'
].join('')
const tabEl = fixtureEl.querySelector('[href="#test2"]') as HTMLElement
const spy = vi.spyOn(Event.prototype, 'preventDefault')
tabEl.addEventListener('shown.bs.tab', () => {
expect(tabEl.classList.contains('active')).toBe(true)
expect(spy).toHaveBeenCalled()
resolve()
})
tabEl.click()
})
})
it('should not fire shown for disabled button', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab">Home</button></li>',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#profile" class="nav-link" disabled role="tab" data-bs-toggle="tab">Profile</button></li>',
'</ul>'
].join('')
const triggerDisabled = fixtureEl.querySelector('button[disabled]')!
triggerDisabled.addEventListener('shown.bs.tab', () => {
reject(new Error('should not fire shown'))
})
;(triggerDisabled as HTMLElement).click()
setTimeout(resolve, 30)
})
})
it('should not fire shown for disabled link', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
' <li class="nav-item" role="presentation"><a href="#home" class="nav-link active" role="tab" data-bs-toggle="tab">Home</a></li>',
' <li class="nav-item" role="presentation"><a href="#profile" class="nav-link disabled" role="tab" data-bs-toggle="tab">Profile</a></li>',
'</ul>'
].join('')
const triggerDisabled = fixtureEl.querySelector('a.disabled')!
triggerDisabled.addEventListener('shown.bs.tab', () => {
reject(new Error('should not fire shown'))
})
;(triggerDisabled as HTMLElement).click()
setTimeout(resolve, 30)
})
})
it('should handle nested tabs', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<nav class="nav nav-tabs" role="tablist">',
' <button type="button" id="tab1" data-bs-target="#x-tab1" class="nav-link" data-bs-toggle="tab" role="tab">Tab 1</button>',
' <button type="button" data-bs-target="#x-tab2" class="nav-link active" data-bs-toggle="tab" role="tab" aria-selected="true">Tab 2</button>',
'</nav>',
'<div class="tab-content">',
' <div class="tab-pane" id="x-tab1" role="tabpanel">',
' <nav class="nav nav-tabs" role="tablist">',
' <button type="button" data-bs-target="#nested-tab1" class="nav-link active" data-bs-toggle="tab" role="tab" aria-selected="true">Nested 1</button>',
' <button type="button" id="tabNested2" data-bs-target="#nested-tab2" class="nav-link" data-bs-toggle="tab" role="tab">Nested 2</button>',
' </nav>',
' <div class="tab-content">',
' <div class="tab-pane active" id="nested-tab1" role="tabpanel">Nested 1</div>',
' <div class="tab-pane" id="nested-tab2" role="tabpanel">Nested 2</div>',
' </div>',
' </div>',
' <div class="tab-pane active" id="x-tab2" role="tabpanel">Tab2</div>',
'</div>'
].join('')
const tab1El = fixtureEl.querySelector('#tab1') as HTMLElement
const tabNested2El = fixtureEl.querySelector('#tabNested2') as HTMLElement
const xTab1El = fixtureEl.querySelector('#x-tab1')!
tabNested2El.addEventListener('shown.bs.tab', () => {
expect(xTab1El.classList.contains('active')).toBe(true)
resolve()
})
tab1El.addEventListener('shown.bs.tab', () => {
expect(xTab1El.classList.contains('active')).toBe(true)
tabNested2El.click()
})
tab1El.click()
})
})
it('selected tab should deactivate previous selected link in dropdown', () => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs">',
' <li class="nav-item"><a class="nav-link" href="#home" data-bs-toggle="tab">Home</a></li>',
' <li class="nav-item"><a class="nav-link" href="#profile" data-bs-toggle="tab">Profile</a></li>',
' <li class="nav-item dropdown">',
' <a class="nav-link dropdown-toggle active" data-bs-toggle="dropdown" href="#">Dropdown</a>',
' <div class="dropdown-menu">',
' <a class="dropdown-item active" href="#dropdown1" id="dropdown1-tab" data-bs-toggle="tab">@fat</a>',
' <a class="dropdown-item" href="#dropdown2" id="dropdown2-tab" data-bs-toggle="tab">@mdo</a>',
' </div>',
' </li>',
'</ul>'
].join('')
const firstLiLinkEl = fixtureEl.querySelector('li:first-child a') as HTMLElement
firstLiLinkEl.click()
expect(firstLiLinkEl.classList.contains('active')).toBe(true)
expect(fixtureEl.querySelector('li:last-child a')!.classList.contains('active')).toBe(false)
expect(fixtureEl.querySelector('li:last-child .dropdown-menu a:first-child')!.classList.contains('active')).toBe(false)
})
it('selecting dropdown tab does not activate another nav', () => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" id="nav1">',
' <li class="nav-item active"><a class="nav-link" href="#home" data-bs-toggle="tab">Home</a></li>',
' <li class="nav-item dropdown">',
' <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">Dropdown</a>',
' <div class="dropdown-menu">',
' <a class="dropdown-item" href="#dropdown1" id="dropdown1-tab" data-bs-toggle="tab">@fat</a>',
' </div>',
' </li>',
'</ul>',
'<ul class="nav nav-tabs" id="nav2">',
' <li class="nav-item active"><a class="nav-link" href="#home2" data-bs-toggle="tab">Home</a></li>',
' <li class="nav-item dropdown">',
' <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">Dropdown</a>',
' <div class="dropdown-menu">',
' <a class="dropdown-item" href="#dropdown2" id="dropdown2-tab" data-bs-toggle="tab">@fat</a>',
' </div>',
' </li>',
'</ul>'
].join('')
const firstDropItem = fixtureEl.querySelector('#nav1 .dropdown-item') as HTMLElement
firstDropItem.click()
expect(firstDropItem.classList.contains('active')).toBe(true)
expect(fixtureEl.querySelector('#nav1 .dropdown-toggle')!.classList.contains('active')).toBe(true)
expect(fixtureEl.querySelector('#nav2 .dropdown-toggle')!.classList.contains('active')).toBe(false)
expect(fixtureEl.querySelector('#nav2 .dropdown-item')!.classList.contains('active')).toBe(false)
})
it('should support li > .dropdown-item', () => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs">',
' <li class="nav-item"><a class="nav-link active" href="#home" data-bs-toggle="tab">Home</a></li>',
' <li class="nav-item dropdown">',
' <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">Dropdown</a>',
' <ul class="dropdown-menu">',
' <li><a class="dropdown-item" href="#dropdown1" data-bs-toggle="tab">@fat</a></li>',
' <li><a class="dropdown-item" href="#dropdown2" data-bs-toggle="tab">@mdo</a></li>',
' </ul>',
' </li>',
'</ul>'
].join('')
const dropItems = fixtureEl.querySelectorAll('.dropdown-item')
;(dropItems[1] as HTMLElement).click()
expect(dropItems[0].classList.contains('active')).toBe(false)
expect(dropItems[1].classList.contains('active')).toBe(true)
expect(fixtureEl.querySelector('.nav-link')!.classList.contains('active')).toBe(false)
})
it('should add show class to pane without fade', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
' <li class="nav-item" role="presentation">',
' <button type="button" class="nav-link" data-bs-target="#home" role="tab" data-bs-toggle="tab">Home</button>',
' </li>',
' <li class="nav-item" role="presentation">',
' <button type="button" id="secondNav" class="nav-link" data-bs-target="#profile" role="tab" data-bs-toggle="tab">Profile</button>',
' </li>',
'</ul>',
'<div class="tab-content">',
' <div role="tabpanel" class="tab-pane" id="home">test 1</div>',
' <div role="tabpanel" class="tab-pane" id="profile">test 2</div>',
'</div>'
].join('')
const secondNavEl = fixtureEl.querySelector('#secondNav') as HTMLElement
secondNavEl.addEventListener('shown.bs.tab', () => {
expect(fixtureEl.querySelectorAll('.tab-content .show')).toHaveLength(1)
resolve()
})
secondNavEl.click()
})
})
})
describe('data-tblr-toggle', () => {
it('should create tab via data-tblr-toggle="tab"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>',
' <li class="nav-item" role="presentation"><button type="button" id="triggerProfile" data-tblr-toggle="tab" data-bs-target="#profile" class="nav-link" role="tab">Profile</button></li>',
'</ul>',
'<div class="tab-content">',
' <div class="tab-pane active" id="home" role="tabpanel"></div>',
' <div class="tab-pane" id="profile" role="tabpanel"></div>',
'</div>'
].join('')
const trigger = fixtureEl.querySelector('#triggerProfile') as HTMLElement
trigger.addEventListener('shown.bs.tab', () => {
expect(trigger.classList.contains('active')).toBe(true)
expect(fixtureEl.querySelector('#profile')!.classList.contains('active')).toBe(true)
resolve()
})
trigger.click()
})
})
it('should create tab via data-tblr-toggle="pill"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav nav-pills" role="tablist">',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>',
' <li class="nav-item" role="presentation"><button type="button" id="triggerProfile" data-tblr-toggle="pill" data-bs-target="#profile" class="nav-link" role="tab">Profile</button></li>',
'</ul>',
'<div class="tab-content">',
' <div class="tab-pane active" id="home" role="tabpanel"></div>',
' <div class="tab-pane" id="profile" role="tabpanel"></div>',
'</div>'
].join('')
const trigger = fixtureEl.querySelector('#triggerProfile') as HTMLElement
trigger.addEventListener('shown.bs.tab', () => {
expect(trigger.classList.contains('active')).toBe(true)
resolve()
})
trigger.click()
})
})
it('should initialize active tabs with data-tblr-toggle on load', () => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><button class="nav-link active" data-tblr-toggle="tab" data-bs-target="#home" role="tab">Home</button></li>',
'</ul>',
'<div id="home" role="tabpanel"></div>'
].join('')
const trigger = fixtureEl.querySelector('button') as HTMLElement
window.dispatchEvent(new Event('load'))
expect(trigger.classList.contains('active')).toBe(true)
})
it('should create tab via data-tblr-toggle="list"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="list-group" role="tablist">',
' <a class="list-group-item list-group-item-action active" data-tblr-toggle="list" href="#home" role="tab">Home</a>',
' <a id="triggerProfile" class="list-group-item list-group-item-action" data-tblr-toggle="list" href="#profile" role="tab">Profile</a>',
'</div>',
'<div class="tab-content">',
' <div class="tab-pane active" id="home" role="tabpanel">Home</div>',
' <div class="tab-pane" id="profile" role="tabpanel">Profile</div>',
'</div>'
].join('')
const trigger = fixtureEl.querySelector('#triggerProfile') as HTMLElement
trigger.addEventListener('shown.bs.tab', () => {
expect(trigger.classList.contains('active')).toBe(true)
resolve()
})
trigger.click()
})
})
it('should switch tabs via data-tblr-toggle with data-tblr-target', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><button class="nav-link active" data-tblr-toggle="tab" data-tblr-target="#home" role="tab">Home</button></li>',
' <li><button id="triggerProfile" class="nav-link" data-tblr-toggle="tab" data-tblr-target="#profile" role="tab">Profile</button></li>',
'</ul>',
'<div class="tab-content">',
' <div class="tab-pane active" id="home" role="tabpanel">Home</div>',
' <div class="tab-pane" id="profile" role="tabpanel">Profile</div>',
'</div>'
].join('')
const trigger = fixtureEl.querySelector('#triggerProfile') as HTMLElement
trigger.addEventListener('shown.bs.tab', () => {
expect(trigger.classList.contains('active')).toBe(true)
resolve()
})
trigger.click()
})
})
})
})
+587
View File
@@ -0,0 +1,587 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Toast from '../../src/bootstrap/toast'
import { clearFixture, createEvent, getFixture } from '../helpers/fixture'
describe('Toast', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Toast.VERSION).toBe('string')
})
})
describe('DATA_KEY', () => {
it('should return plugin data key', () => {
expect(Toast.DATA_KEY).toBe('bs.toast')
})
})
describe('constructor', () => {
it('should accept element as CSS selector or DOM element', () => {
fixtureEl.innerHTML = '<div class="toast"></div>'
const toastEl = fixtureEl.querySelector('.toast')!
const toastBySelector = new Toast('.toast')
const toastByElement = new Toast(toastEl)
expect(toastBySelector._element).toBe(toastEl)
expect(toastByElement._element).toBe(toastEl)
})
it('should allow config in js', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('div')!
const toast = new Toast(toastEl, { delay: 1 })
toastEl.addEventListener('shown.bs.toast', () => {
expect(toastEl.classList.contains('show')).toBe(true)
resolve()
})
toast.show()
})
})
it('should close toast when dismiss button is clicked', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="1" data-bs-autohide="false" data-bs-animation="false">',
' <button type="button" class="btn-close" data-bs-dismiss="toast"></button>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('div')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
expect(toastEl.classList.contains('show')).toBe(true)
const button = toastEl.querySelector('.btn-close') as HTMLElement
button.click()
})
toastEl.addEventListener('hidden.bs.toast', () => {
expect(toastEl.classList.contains('show')).toBe(false)
resolve()
})
toast.show()
})
})
it('should close toast via data-tblr-dismiss', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="1" data-bs-autohide="false" data-bs-animation="false">',
' <button type="button" class="btn-close" data-tblr-dismiss="toast"></button>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('div')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
expect(toastEl.classList.contains('show')).toBe(true)
const button = toastEl.querySelector('.btn-close') as HTMLElement
button.click()
})
toastEl.addEventListener('hidden.bs.toast', () => {
expect(toastEl.classList.contains('show')).toBe(false)
resolve()
})
toast.show()
})
})
})
describe('Default', () => {
it('should expose default settings', () => {
const defaultDelay = 1000
const origDelay = Toast.Default.delay
Toast.Default.delay = defaultDelay
fixtureEl.innerHTML = [
'<div class="toast" data-bs-autohide="false" data-bs-animation="false">',
' <button type="button" class="btn-close" data-bs-dismiss="toast"></button>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('div')!
const toast = new Toast(toastEl)
expect(toast._config.delay).toBe(defaultDelay)
Toast.Default.delay = origDelay
})
})
describe('DefaultType', () => {
it('should expose default setting types', () => {
expect(typeof Toast.DefaultType).toBe('object')
})
})
describe('show', () => {
it('should auto hide', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="1">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('hidden.bs.toast', () => {
expect(toastEl.classList.contains('show')).toBe(false)
resolve()
})
toast.show()
})
})
it('should not add fade class when animation is false', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="1" data-bs-animation="false">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
expect(toastEl.classList.contains('fade')).toBe(false)
resolve()
})
toast.show()
})
})
it('should not trigger shown if show is prevented', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="1" data-bs-animation="false">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('show.bs.toast', event => {
event.preventDefault()
setTimeout(() => {
expect(toastEl.classList.contains('show')).toBe(false)
resolve()
}, 20)
})
toastEl.addEventListener('shown.bs.toast', () => {
reject(new Error('shown should not fire'))
})
toast.show()
})
})
it('should clear timeout on mouse interaction', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
const clearSpy = vi.spyOn(toast, '_clearTimeout' as any)
toastEl.dispatchEvent(createEvent('mouseover'))
setTimeout(() => {
expect(clearSpy).toHaveBeenCalled()
expect(toast._timeout).toBeNull()
resolve()
}, 10)
})
toast.show()
})
})
it('should clear timeout on keyboard interaction', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button id="outside">outside</button>',
'<div class="toast">',
' <div class="toast-body">a simple toast <button>inside</button></div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
const clearSpy = vi.spyOn(toast, '_clearTimeout' as any)
const insideBtn = toastEl.querySelector('button')!
insideBtn.focus()
setTimeout(() => {
expect(clearSpy).toHaveBeenCalled()
expect(toast._timeout).toBeNull()
resolve()
}, 10)
})
toast.show()
})
})
it('should still auto hide after mouse and keyboard leave', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button id="outside-focusable">outside</button>',
'<div class="toast">',
' <div class="toast-body">a simple toast <button>inside</button></div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
toastEl.dispatchEvent(createEvent('mouseover'))
const insideBtn = toastEl.querySelector('button')!
insideBtn.focus()
const mouseOutEvent = new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.querySelector('#outside-focusable') })
toastEl.dispatchEvent(mouseOutEvent)
const outsideFocusable = document.querySelector('#outside-focusable') as HTMLElement
outsideFocusable.focus()
setTimeout(() => {
expect(toast._timeout).not.toBeNull()
resolve()
}, 10)
})
toast.show()
})
})
it('should not auto hide if focus leaves but mouse remains inside', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button id="outside-focusable">outside</button>',
'<div class="toast">',
' <div class="toast-body">a simple toast <button>inside</button></div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
toastEl.dispatchEvent(createEvent('mouseover'))
const insideBtn = toastEl.querySelector('button')!
insideBtn.focus()
const outsideFocusable = document.querySelector('#outside-focusable') as HTMLElement
outsideFocusable.focus()
setTimeout(() => {
expect(toast._timeout).toBeNull()
resolve()
}, 10)
})
toast.show()
})
})
it('should not auto hide if mouse leaves but focus remains inside', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button id="outside-focusable">outside</button>',
'<div class="toast">',
' <div class="toast-body">a simple toast <button>inside</button></div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
toastEl.dispatchEvent(createEvent('mouseover'))
const insideBtn = toastEl.querySelector('button')!
insideBtn.focus()
const mouseOutEvent = new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.querySelector('#outside-focusable') })
toastEl.dispatchEvent(mouseOutEvent)
setTimeout(() => {
expect(toast._timeout).toBeNull()
resolve()
}, 10)
})
toast.show()
})
})
it('should handle _onInteraction with unknown event type', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
const scheduleSpy = vi.spyOn(toast, '_maybeScheduleHide' as any)
toast._onInteraction(createEvent('click'), false)
expect(toast._hasMouseInteraction).toBe(false)
expect(toast._hasKeyboardInteraction).toBe(false)
expect(scheduleSpy).toHaveBeenCalled()
resolve()
})
toast.show()
})
})
it('should not schedule hide when relatedTarget is within the toast', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button id="outside-focusable">outside</button>',
'<div class="toast">',
' <div class="toast-body">a simple toast <button id="inside-btn">inside</button></div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const insideBtn = toastEl.querySelector('#inside-btn')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
const scheduleSpy = vi.spyOn(toast, '_maybeScheduleHide' as any)
const focusOutEvent = new FocusEvent('focusout', {
bubbles: true,
relatedTarget: insideBtn
})
toast._onInteraction(focusOutEvent, false)
expect(scheduleSpy).not.toHaveBeenCalled()
resolve()
})
toast.show()
})
})
})
describe('hide', () => {
it('should allow to hide toast manually', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="1" data-bs-autohide="false">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
toast.hide()
})
toastEl.addEventListener('hidden.bs.toast', () => {
expect(toastEl.classList.contains('show')).toBe(false)
resolve()
})
toast.show()
})
})
it('should do nothing on a non shown toast', () => {
fixtureEl.innerHTML = '<div></div>'
const toastEl = fixtureEl.querySelector('div')!
const toast = new Toast(toastEl)
const spy = vi.spyOn(toastEl.classList, 'contains')
toast.hide()
expect(spy).toHaveBeenCalled()
})
it('should not trigger hidden if hide is prevented', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="1" data-bs-animation="false">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
toast.hide()
})
toastEl.addEventListener('hide.bs.toast', event => {
event.preventDefault()
setTimeout(() => {
expect(toastEl.classList.contains('show')).toBe(true)
resolve()
}, 20)
})
toastEl.addEventListener('hidden.bs.toast', () => {
reject(new Error('hidden should not fire'))
})
toast.show()
})
})
})
describe('dispose', () => {
it('should allow to destroy toast', () => {
fixtureEl.innerHTML = '<div></div>'
const toastEl = fixtureEl.querySelector('div')!
const toast = new Toast(toastEl)
expect(Toast.getInstance(toastEl)).not.toBeNull()
toast.dispose()
expect(Toast.getInstance(toastEl)).toBeNull()
})
it('should destroy and hide shown toast', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="0" data-bs-autohide="false">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('div')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
setTimeout(() => {
expect(toastEl.classList.contains('show')).toBe(true)
expect(Toast.getInstance(toastEl)).not.toBeNull()
toast.dispose()
expect(Toast.getInstance(toastEl)).toBeNull()
expect(toastEl.classList.contains('show')).toBe(false)
resolve()
}, 1)
})
toast.show()
})
})
})
describe('getInstance', () => {
it('should return toast instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const toast = new Toast(div)
expect(Toast.getInstance(div)).toBe(toast)
expect(Toast.getInstance(div)).toBeInstanceOf(Toast)
})
it('should return null when there is no toast instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Toast.getInstance(div)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should return toast instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const toast = new Toast(div)
expect(Toast.getOrCreateInstance(div)).toBe(toast)
expect(Toast.getOrCreateInstance(div)).toBeInstanceOf(Toast)
})
it('should return new instance when there is no toast instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Toast.getInstance(div)).toBeNull()
expect(Toast.getOrCreateInstance(div)).toBeInstanceOf(Toast)
})
it('should return new instance with given configuration', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const toast = Toast.getOrCreateInstance(div, { delay: 1 })
expect(toast).toBeInstanceOf(Toast)
expect(toast._config.delay).toBe(1)
})
it('should return existing instance ignoring new configuration', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const toast = new Toast(div, { delay: 1 })
const toast2 = Toast.getOrCreateInstance(div, { delay: 2 })
expect(toast2).toBe(toast)
expect(toast2._config.delay).toBe(1)
})
})
})
+679
View File
@@ -0,0 +1,679 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Tooltip from '../../src/bootstrap/tooltip'
import { clearFixture, getFixture } from '../helpers/fixture'
vi.mock('@popperjs/core', () => ({
createPopper: vi.fn(() => ({
destroy: vi.fn(),
update: vi.fn(),
setOptions: vi.fn()
}))
}))
describe('Tooltip', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
for (const el of document.querySelectorAll('.tooltip')) {
el.remove()
}
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Tooltip.VERSION).toBe('string')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(Tooltip.Default).toBeDefined()
expect(Tooltip.Default.animation).toBe(true)
expect(Tooltip.Default.trigger).toBe('hover focus')
})
})
describe('DefaultType', () => {
it('should return plugin default type config', () => {
expect(Tooltip.DefaultType).toBeDefined()
expect(Tooltip.DefaultType.animation).toBe('boolean')
})
})
describe('NAME', () => {
it('should return plugin name', () => {
expect(Tooltip.NAME).toBe('tooltip')
})
})
describe('constructor', () => {
it('should create tooltip instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip).toBeInstanceOf(Tooltip)
expect(Tooltip.getInstance(el)).toBe(tooltip)
})
it('should fix title on construction', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
new Tooltip(el)
expect(el.getAttribute('title')).toBeNull()
expect(el.getAttribute('data-bs-original-title')).toBe('Tooltip title')
})
it('should set aria-label when element has title but no text', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title"></a>'
const el = fixtureEl.querySelector('a')!
new Tooltip(el)
expect(el.getAttribute('aria-label')).toBe('Tooltip title')
})
it('should not set aria-label when element has text content', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Link text</a>'
const el = fixtureEl.querySelector('a')!
new Tooltip(el)
expect(el.getAttribute('aria-label')).toBeNull()
})
it('should not set aria-label when element already has aria-label', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title" aria-label="Existing label"></a>'
const el = fixtureEl.querySelector('a')!
new Tooltip(el)
expect(el.getAttribute('aria-label')).toBe('Existing label')
})
})
describe('enable', () => {
it('should enable tooltip', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip.disable()
tooltip.enable()
expect(tooltip._isEnabled).toBe(true)
})
})
describe('disable', () => {
it('should disable tooltip', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip.disable()
expect(tooltip._isEnabled).toBe(false)
})
})
describe('toggleEnabled', () => {
it('should toggle enabled state', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._isEnabled).toBe(true)
tooltip.toggleEnabled()
expect(tooltip._isEnabled).toBe(false)
tooltip.toggleEnabled()
expect(tooltip._isEnabled).toBe(true)
})
})
describe('toggle', () => {
it('should do nothing when disabled', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip.disable()
tooltip.toggle()
expect(tooltip._isShown()).toBe(false)
})
it('should toggle tooltip visibility', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('shown.bs.tooltip', () => {
expect(tooltip._isShown()).toBe(true)
tooltip.toggle()
})
el.addEventListener('hidden.bs.tooltip', () => {
expect(tooltip._isShown()).toBe(false)
resolve()
})
tooltip.toggle()
})
})
})
describe('show', () => {
it('should show tooltip', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('shown.bs.tooltip', () => {
expect(tooltip.tip).not.toBeNull()
expect(tooltip.tip!.classList.contains('show')).toBe(true)
expect(el.getAttribute('aria-describedby')).not.toBeNull()
resolve()
})
tooltip.show()
})
})
it('should throw if element is hidden', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip" style="display: none">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(() => tooltip.show()).toThrow('Please use show on visible elements')
})
it('should not show if show event is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('show.bs.tooltip', event => {
event.preventDefault()
setTimeout(() => {
expect(tooltip._isShown()).toBe(false)
resolve()
}, 30)
})
tooltip.show()
})
})
it('should not show without content', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip.show()
expect(tooltip._isShown()).toBe(false)
})
it('should fire inserted event', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('inserted.bs.tooltip', () => {
resolve()
})
tooltip.show()
})
})
})
describe('hide', () => {
it('should hide tooltip', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('shown.bs.tooltip', () => {
tooltip.hide()
})
el.addEventListener('hidden.bs.tooltip', () => {
expect(tooltip._isShown()).toBe(false)
resolve()
})
tooltip.show()
})
})
it('should not hide if not shown', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip.hide()
expect(tooltip._isShown()).toBe(false)
})
it('should not hide if hide event is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('shown.bs.tooltip', () => {
el.addEventListener('hide.bs.tooltip', event => {
event.preventDefault()
setTimeout(() => {
expect(tooltip._isShown()).toBe(true)
resolve()
}, 30)
})
tooltip.hide()
})
tooltip.show()
})
})
})
describe('update', () => {
it('should call popper update', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('shown.bs.tooltip', () => {
tooltip.update()
expect(tooltip._popper!.update).toHaveBeenCalled()
resolve()
})
tooltip.show()
})
})
it('should do nothing if popper is null', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(() => tooltip.update()).not.toThrow()
})
})
describe('dispose', () => {
it('should dispose tooltip', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip.dispose()
expect(Tooltip.getInstance(el)).toBeNull()
})
it('should restore original title on dispose', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(el.getAttribute('data-bs-original-title')).toBe('Tooltip title')
tooltip.dispose()
expect(el.getAttribute('title')).toBe('Tooltip title')
})
it('should destroy popper on dispose', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('shown.bs.tooltip', () => {
const destroySpy = tooltip._popper!.destroy
tooltip.dispose()
expect(destroySpy).toHaveBeenCalled()
resolve()
})
tooltip.show()
})
})
})
describe('setContent', () => {
it('should set new content', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip.setContent({ '.tooltip-inner': 'New content' })
expect(tooltip._newContent).toEqual({ '.tooltip-inner': 'New content' })
})
it('should update shown tooltip', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
let shownCount = 0
el.addEventListener('shown.bs.tooltip', () => {
shownCount++
if (shownCount === 1) {
tooltip.setContent({ '.tooltip-inner': 'Updated content' })
} else {
resolve()
}
})
tooltip.show()
})
})
})
describe('_isWithContent', () => {
it('should return true when title exists', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._isWithContent()).toBe(true)
})
it('should return false when no title', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._isWithContent()).toBe(false)
})
})
describe('_getTitle', () => {
it('should return config title', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { title: 'Config title' })
expect(tooltip._getTitle()).toBe('Config title')
})
it('should return data-bs-original-title', () => {
fixtureEl.innerHTML = '<a href="#" data-bs-original-title="Original title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._getTitle()).toBe('Original title')
})
it('should return data-tblr-original-title', () => {
fixtureEl.innerHTML = '<a href="#" data-tblr-original-title="Tblr title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._getTitle()).toBe('Tblr title')
})
it('should prefer data-bs-original-title over data-tblr-original-title', () => {
fixtureEl.innerHTML = '<a href="#" data-bs-original-title="BS title" data-tblr-original-title="Tblr title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._getTitle()).toBe('BS title')
})
it('should resolve function title', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { title: () => 'Function title' })
expect(tooltip._getTitle()).toBe('Function title')
})
})
describe('_isAnimated', () => {
it('should return true when animation is enabled', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: true })
expect(tooltip._isAnimated()).toBe(true)
})
it('should return false when animation is disabled', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
expect(tooltip._isAnimated()).toBe(false)
})
})
describe('_configAfterMerge', () => {
it('should convert number delay to object', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { delay: 200 })
expect(tooltip._config.delay).toEqual({ show: 200, hide: 200 })
})
it('should convert number title to string', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { title: 123 })
expect(tooltip._config.title).toBe('123')
})
it('should use document.body when container is false', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._config.container).toBe(document.body)
})
})
describe('_getOffset', () => {
it('should handle string offset', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { offset: '10,20' as any })
expect(tooltip._getOffset()).toEqual([10, 20])
})
it('should handle function offset', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const offsetFn = vi.fn().mockReturnValue([5, 10])
const tooltip = new Tooltip(el, { offset: offsetFn })
const offset = tooltip._getOffset()
expect(typeof offset).toBe('function')
})
it('should handle array offset', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { offset: [5, 15] })
expect(tooltip._getOffset()).toEqual([5, 15])
})
})
describe('_getDelegateConfig', () => {
it('should return config with non-default values', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { placement: 'bottom' })
const delegateConfig = tooltip._getDelegateConfig()
expect(delegateConfig.selector).toBe(false)
expect(delegateConfig.trigger).toBe('manual')
expect(delegateConfig.placement).toBe('bottom')
})
})
describe('_disposePopper', () => {
it('should destroy popper and remove tip', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('shown.bs.tooltip', () => {
expect(tooltip._popper).not.toBeNull()
expect(tooltip.tip).not.toBeNull()
tooltip._disposePopper()
expect(tooltip._popper).toBeNull()
expect(tooltip.tip).toBeNull()
resolve()
})
tooltip.show()
})
})
})
describe('getInstance', () => {
it('should return null if no instance', () => {
expect(Tooltip.getInstance(fixtureEl)).toBeNull()
})
it('should return tooltip instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(Tooltip.getInstance(el)).toBe(tooltip)
})
})
describe('getOrCreateInstance', () => {
it('should return existing instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(Tooltip.getOrCreateInstance(el)).toBe(tooltip)
})
it('should create new instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
expect(Tooltip.getInstance(el)).toBeNull()
expect(Tooltip.getOrCreateInstance(el)).toBeInstanceOf(Tooltip)
})
})
describe('data-tblr-original-title', () => {
it('should restore title from data-tblr-original-title on dispose', () => {
fixtureEl.innerHTML = '<a href="#" data-tblr-original-title="Tblr tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { title: 'test' })
tooltip.dispose()
expect(el.getAttribute('title')).toBe('Tblr tooltip')
})
it('should use data-tblr-original-title as fallback for _getTitle', () => {
fixtureEl.innerHTML = '<a href="#" data-tblr-original-title="Tblr tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._getTitle()).toBe('Tblr tooltip title')
})
})
describe('_enter and _leave', () => {
it('should set _isHovered on enter', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { delay: { show: 500, hide: 500 } })
tooltip._enter()
expect(tooltip._isHovered).toBe(true)
})
it('should not double-enter if already hovered', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { delay: { show: 500, hide: 500 } })
tooltip._isHovered = true
tooltip._enter()
expect(tooltip._isHovered).toBe(true)
})
it('should set _isHovered to false on leave', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { delay: { show: 500, hide: 500 } })
tooltip._leave()
expect(tooltip._isHovered).toBe(false)
})
it('should not leave if active trigger exists', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip._activeTrigger.click = true
tooltip._isHovered = true
tooltip._leave()
expect(tooltip._isHovered).toBe(true)
})
})
describe('_isWithActiveTrigger', () => {
it('should return false with no active triggers', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._isWithActiveTrigger()).toBe(false)
})
it('should return true with active trigger', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip._activeTrigger.click = true
expect(tooltip._isWithActiveTrigger()).toBe(true)
})
})
describe('trigger listeners', () => {
it('should set up click trigger', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { trigger: 'click', animation: false })
expect(tooltip._config.trigger).toBe('click')
})
it('should set up manual trigger', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { trigger: 'manual' })
expect(tooltip._config.trigger).toBe('manual')
})
})
})
+211
View File
@@ -0,0 +1,211 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
import Backdrop from '../../../src/bootstrap/util/backdrop'
import { clearFixture, getFixture } from '../../helpers/fixture'
const CLASS_BACKDROP = '.modal-backdrop'
const CLASS_NAME_FADE = 'fade'
const CLASS_NAME_SHOW = 'show'
describe('Backdrop', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
for (const el of document.querySelectorAll(CLASS_BACKDROP)) {
el.remove()
}
})
describe('show', () => {
it('should append the backdrop and include the "show" class', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true, isAnimated: false })
const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
expect(getElements()).toHaveLength(0)
instance.show()
instance.show(() => {
expect(getElements()).toHaveLength(1)
for (const el of getElements()) {
expect(el.classList.contains(CLASS_NAME_SHOW)).toBe(true)
}
resolve()
})
})
})
it('should not append the backdrop if not visible', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: false, isAnimated: true })
const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
expect(getElements()).toHaveLength(0)
instance.show(() => {
expect(getElements()).toHaveLength(0)
resolve()
})
})
})
it('should include the "fade" class if animated', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true, isAnimated: true })
const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
expect(getElements()).toHaveLength(0)
instance.show(() => {
expect(getElements()).toHaveLength(1)
for (const el of getElements()) {
expect(el.classList.contains(CLASS_NAME_FADE)).toBe(true)
}
resolve()
})
})
})
})
describe('hide', () => {
it('should remove the backdrop html', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true, isAnimated: true })
const getElements = () => document.body.querySelectorAll(CLASS_BACKDROP)
expect(getElements()).toHaveLength(0)
instance.show(() => {
expect(getElements()).toHaveLength(1)
instance.hide(() => {
expect(getElements()).toHaveLength(0)
resolve()
})
})
})
})
it('should remove the "show" class', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true, isAnimated: true })
const elem = instance._getElement()
instance.show()
instance.hide(() => {
expect(elem.classList.contains(CLASS_NAME_SHOW)).toBe(false)
resolve()
})
})
})
it('should not try to remove if not visible', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: false, isAnimated: true })
const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
expect(getElements()).toHaveLength(0)
expect(instance._isAppended).toBe(false)
instance.show(() => {
instance.hide(() => {
expect(getElements()).toHaveLength(0)
expect(instance._isAppended).toBe(false)
resolve()
})
})
})
})
})
describe('click callback', () => {
it('should execute callback on click', () => {
return new Promise<void>(resolve => {
let called = false
const instance = new Backdrop({
isVisible: true,
isAnimated: false,
clickCallback: () => { called = true }
})
instance.show(() => {
const clickEvent = new Event('mousedown', { bubbles: true, cancelable: true })
document.querySelector(CLASS_BACKDROP)!.dispatchEvent(clickEvent)
setTimeout(() => {
expect(called).toBe(true)
resolve()
}, 10)
})
})
})
})
describe('Config', () => {
it('should be appended on document.body by default', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true })
instance.show(() => {
expect(document.querySelector(CLASS_BACKDROP)!.parentElement).toBe(document.body)
resolve()
})
})
})
it('should find rootElement if passed as a string', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true, rootElement: 'body' })
instance.show(() => {
expect(document.querySelector(CLASS_BACKDROP)!.parentElement).toBe(document.body)
resolve()
})
})
})
it('should be appended on custom rootElement', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<div id="wrapper"></div>'
const wrapper = fixtureEl.querySelector('#wrapper')!
const instance = new Backdrop({ isVisible: true, rootElement: wrapper })
instance.show(() => {
expect(document.querySelector(CLASS_BACKDROP)!.parentElement).toBe(wrapper)
resolve()
})
})
})
it('should allow configuring className', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true, className: 'foo' })
instance.show(() => {
expect(document.querySelector('.foo')).toBe(instance._getElement())
instance.dispose()
resolve()
})
})
})
})
describe('dispose', () => {
it('should do nothing if not appended', () => {
const instance = new Backdrop({ isVisible: true, isAnimated: false })
expect(instance._isAppended).toBe(false)
instance.dispose()
expect(instance._isAppended).toBe(false)
})
it('should remove element and reset _isAppended after show', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true, isAnimated: false })
instance.show(() => {
expect(instance._isAppended).toBe(true)
instance.dispose()
expect(instance._isAppended).toBe(false)
expect(document.querySelectorAll(CLASS_BACKDROP)).toHaveLength(0)
resolve()
})
})
})
})
})
@@ -0,0 +1,104 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import BaseComponent from '../../../src/bootstrap/base-component'
import { enableDismissTrigger } from '../../../src/bootstrap/util/component-functions'
import { clearFixture, createEvent, getFixture } from '../../helpers/fixture'
class DummyClass extends BaseComponent {
static get NAME(): string {
return 'test'
}
hide() {
return true
}
testMethod() {
return true
}
}
describe('Component Functions', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
describe('data-bs-dismiss', () => {
it('should get plugin and execute given method on click', () => {
fixtureEl.innerHTML = [
'<div id="foo" class="test">',
' <button type="button" data-bs-dismiss="test" data-bs-target="#foo"></button>',
'</div>'
].join('')
const spyGet = vi.spyOn(DummyClass, 'getOrCreateInstance')
const spyTest = vi.spyOn(DummyClass.prototype, 'testMethod')
enableDismissTrigger(DummyClass, 'testMethod')
fixtureEl.querySelector('[data-bs-dismiss="test"]')!.dispatchEvent(createEvent('click'))
expect(spyGet).toHaveBeenCalled()
expect(spyTest).toHaveBeenCalled()
vi.restoreAllMocks()
})
it('should use closest class when no data-bs-target', () => {
fixtureEl.innerHTML = [
'<div id="foo" class="test">',
' <button type="button" data-bs-dismiss="test"></button>',
'</div>'
].join('')
const spyGet = vi.spyOn(DummyClass, 'getOrCreateInstance')
const spyHide = vi.spyOn(DummyClass.prototype, 'hide')
enableDismissTrigger(DummyClass)
fixtureEl.querySelector('[data-bs-dismiss="test"]')!.dispatchEvent(createEvent('click'))
expect(spyGet).toHaveBeenCalled()
expect(spyHide).toHaveBeenCalled()
vi.restoreAllMocks()
})
it('should not trigger if disabled', () => {
fixtureEl.innerHTML = [
'<div id="foo" class="test">',
' <button type="button" disabled data-bs-dismiss="test"></button>',
'</div>'
].join('')
const spy = vi.spyOn(DummyClass, 'getOrCreateInstance')
enableDismissTrigger(DummyClass)
fixtureEl.querySelector('[data-bs-dismiss="test"]')!.dispatchEvent(createEvent('click'))
expect(spy).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('should preventDefault for <a> elements', () => {
fixtureEl.innerHTML = [
'<div id="foo" class="test">',
' <a type="button" data-bs-dismiss="test"></a>',
'</div>'
].join('')
enableDismissTrigger(DummyClass)
const preventSpy = vi.spyOn(Event.prototype, 'preventDefault')
fixtureEl.querySelector('[data-bs-dismiss="test"]')!.dispatchEvent(createEvent('click'))
expect(preventSpy).toHaveBeenCalled()
vi.restoreAllMocks()
})
})
})
+147
View File
@@ -0,0 +1,147 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Config from '../../../src/bootstrap/util/config'
import { clearFixture, getFixture } from '../../helpers/fixture'
class DummyConfigClass extends Config {
static get NAME(): string {
return 'dummy'
}
}
describe('Config', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
describe('NAME', () => {
it('should return plugin NAME', () => {
expect(DummyConfigClass.NAME).toBe('dummy')
})
})
describe('Default', () => {
it('should return plugin defaults', () => {
expect(typeof DummyConfigClass.Default).toBe('object')
})
})
describe('DefaultType', () => {
it('should return plugin default type', () => {
expect(typeof DummyConfigClass.DefaultType).toBe('object')
})
})
describe('mergeConfigObj', () => {
it('should parse data attributes and merge with defaults, data attributes excel defaults', () => {
fixtureEl.innerHTML = '<div id="test" data-bs-test-bool="false" data-bs-test-int="8" data-bs-test-string1="bar"></div>'
vi.spyOn(DummyConfigClass, 'Default', 'get').mockReturnValue({
testBool: true,
testString: 'foo',
testString1: 'foo',
testInt: 7
})
const instance = new DummyConfigClass()
const result = instance._mergeConfigObj({}, fixtureEl.querySelector('#test')!)
expect(result.testBool).toBe(false)
expect(result.testString).toBe('foo')
expect(result.testString1).toBe('bar')
expect(result.testInt).toBe(8)
vi.restoreAllMocks()
})
it('should let programmatic config excel data attributes', () => {
fixtureEl.innerHTML = '<div id="test" data-bs-test-bool="false" data-bs-test-int="8" data-bs-test-string-1="bar"></div>'
vi.spyOn(DummyConfigClass, 'Default', 'get').mockReturnValue({
testBool: true,
testString: 'foo',
testString1: 'foo',
testInt: 7
})
const instance = new DummyConfigClass()
const result = instance._mergeConfigObj({
testString1: 'test',
testInt: 3
}, fixtureEl.querySelector('#test')!)
expect(result.testBool).toBe(false)
expect(result.testString).toBe('foo')
expect(result.testString1).toBe('test')
expect(result.testInt).toBe(3)
vi.restoreAllMocks()
})
it('should omit data-bs-config if it is not an object', () => {
fixtureEl.innerHTML = '<div id="test" data-bs-config="foo" data-bs-test-int="8"></div>'
vi.spyOn(DummyConfigClass, 'Default', 'get').mockReturnValue({
testInt: 7,
testInt2: 79
})
const instance = new DummyConfigClass()
const result = instance._mergeConfigObj({}, fixtureEl.querySelector('#test')!)
expect(result.testInt).toBe(8)
expect(result.testInt2).toBe(79)
vi.restoreAllMocks()
})
})
describe('typeCheckConfig', () => {
it('should throw TypeError for wrong config types', () => {
vi.spyOn(DummyConfigClass, 'DefaultType', 'get').mockReturnValue({
toggle: 'boolean',
parent: '(string|element)'
})
const obj = new DummyConfigClass()
expect(() => {
obj._typeCheckConfig({ toggle: true, parent: 777 })
}).toThrow(TypeError)
vi.restoreAllMocks()
})
it('should accept null when type includes null', () => {
vi.spyOn(DummyConfigClass, 'DefaultType', 'get').mockReturnValue({
toggle: 'boolean',
parent: '(null|element)'
})
const obj = new DummyConfigClass()
expect(() => {
obj._typeCheckConfig({ toggle: true, parent: null })
}).not.toThrow()
vi.restoreAllMocks()
})
it('should accept undefined when type includes undefined', () => {
vi.spyOn(DummyConfigClass, 'DefaultType', 'get').mockReturnValue({
toggle: 'boolean',
parent: '(undefined|element)'
})
const obj = new DummyConfigClass()
expect(() => {
obj._typeCheckConfig({ toggle: true, parent: undefined })
}).not.toThrow()
vi.restoreAllMocks()
})
})
})
+237
View File
@@ -0,0 +1,237 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import FocusTrap from '../../../src/bootstrap/util/focustrap'
import { clearFixture, getFixture } from '../../helpers/fixture'
vi.mock('../../../src/bootstrap/util/index', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/bootstrap/util/index')>()
return {
...actual,
isVisible: () => true
}
})
describe('FocusTrap', () => {
let fixtureEl: HTMLElement
let trapEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
fixtureEl.innerHTML =
'<div id="trap" tabindex="-1">' +
'<button id="btn1">First</button>' +
'<button id="btn2">Second</button>' +
'</div>' +
'<button id="outside">Outside</button>'
trapEl = fixtureEl.querySelector('#trap')!
})
afterEach(() => {
clearFixture()
})
describe('static', () => {
it('NAME should return "focustrap"', () => {
expect(FocusTrap.NAME).toBe('focustrap')
})
it('Default should have correct values', () => {
expect(FocusTrap.Default).toEqual({
autofocus: true,
trapElement: null
})
})
it('DefaultType should have correct values', () => {
expect(FocusTrap.DefaultType).toEqual({
autofocus: 'boolean',
trapElement: 'element'
})
})
})
describe('activate', () => {
it('should focus the trap element when autofocus is true', () => {
const focusSpy = vi.spyOn(trapEl, 'focus')
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
expect(focusSpy).toHaveBeenCalledOnce()
trap.deactivate()
})
it('should not focus the trap element when autofocus is false', () => {
const focusSpy = vi.spyOn(trapEl, 'focus')
const trap = new FocusTrap({ trapElement: trapEl, autofocus: false })
trap.activate()
expect(focusSpy).not.toHaveBeenCalled()
trap.deactivate()
})
it('should set _isActive to true', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
expect(trap._isActive).toBe(true)
trap.deactivate()
})
it('should not re-activate if already active', () => {
const focusSpy = vi.spyOn(trapEl, 'focus')
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
trap.activate()
expect(focusSpy).toHaveBeenCalledOnce()
trap.deactivate()
})
})
describe('deactivate', () => {
it('should set _isActive to false', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
trap.deactivate()
expect(trap._isActive).toBe(false)
})
it('should not throw if not active', () => {
const trap = new FocusTrap({ trapElement: trapEl })
expect(() => trap.deactivate()).not.toThrow()
})
})
describe('_handleFocusin', () => {
it('should do nothing if focus target is the trap element', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
const btn1 = fixtureEl.querySelector('#btn1')! as HTMLElement
const focusSpy = vi.spyOn(btn1, 'focus')
const event = new FocusEvent('focusin', { relatedTarget: null })
Object.defineProperty(event, 'target', { value: trapEl })
document.dispatchEvent(event)
expect(focusSpy).not.toHaveBeenCalled()
trap.deactivate()
})
it('should do nothing if focus target is inside the trap', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
const btn1 = fixtureEl.querySelector('#btn1')! as HTMLElement
const event = new FocusEvent('focusin', { relatedTarget: null })
Object.defineProperty(event, 'target', { value: btn1 })
document.dispatchEvent(event)
trap.deactivate()
})
it('should focus first focusable child when focus leaves trap (forward)', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
const btn1 = fixtureEl.querySelector('#btn1')! as HTMLElement
const focusSpy = vi.spyOn(btn1, 'focus')
const outside = fixtureEl.querySelector('#outside')! as HTMLElement
const event = new FocusEvent('focusin', { relatedTarget: null })
Object.defineProperty(event, 'target', { value: outside })
document.dispatchEvent(event)
expect(focusSpy).toHaveBeenCalled()
trap.deactivate()
})
it('should focus last focusable child when tabbing backward', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
const btn2 = fixtureEl.querySelector('#btn2')! as HTMLElement
const focusSpy = vi.spyOn(btn2, 'focus')
const outside = fixtureEl.querySelector('#outside')! as HTMLElement
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }))
const focusEvent = new FocusEvent('focusin', { relatedTarget: null })
Object.defineProperty(focusEvent, 'target', { value: outside })
document.dispatchEvent(focusEvent)
expect(focusSpy).toHaveBeenCalled()
trap.deactivate()
})
it('should focus trap element if no focusable children', () => {
fixtureEl.innerHTML = '<div id="empty-trap" tabindex="-1"></div><button id="out">Out</button>'
const emptyTrap = fixtureEl.querySelector('#empty-trap')! as HTMLElement
const trap = new FocusTrap({ trapElement: emptyTrap })
trap.activate()
const focusSpy = vi.spyOn(emptyTrap, 'focus')
focusSpy.mockClear()
const out = fixtureEl.querySelector('#out')! as HTMLElement
const event = new FocusEvent('focusin', { relatedTarget: null })
Object.defineProperty(event, 'target', { value: out })
document.dispatchEvent(event)
expect(focusSpy).toHaveBeenCalled()
trap.deactivate()
})
it('should do nothing if focus target is document', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
const event = new FocusEvent('focusin', { relatedTarget: null })
Object.defineProperty(event, 'target', { value: document })
document.dispatchEvent(event)
trap.deactivate()
})
})
describe('_handleKeydown', () => {
it('should track forward tab direction', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
expect(trap._lastTabNavDirection).toBe('forward')
trap.deactivate()
})
it('should track backward tab direction (Shift+Tab)', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }))
expect(trap._lastTabNavDirection).toBe('backward')
trap.deactivate()
})
it('should ignore non-Tab keys', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
expect(trap._lastTabNavDirection).toBeNull()
trap.deactivate()
})
})
})
+495
View File
@@ -0,0 +1,495 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import {
execute,
executeAfterTransition,
findShadowRoot,
getElement,
getNextActiveElement,
getTransitionDurationFromElement,
getUID,
isDisabled,
isElement,
isRTL,
isVisible,
noop,
parseSelector,
reflow,
toType,
triggerTransitionEnd
} from '../../../src/bootstrap/util/index'
import { clearFixture, getFixture } from '../../helpers/fixture'
describe('util/index', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
describe('parseSelector', () => {
it('should return the selector as-is for simple selectors', () => {
expect(parseSelector('.my-class')).toBe('.my-class')
})
it('should escape ID selectors with special characters', () => {
const result = parseSelector('#my.id')
expect(result).toContain('#')
})
it('should use CSS.escape when available', () => {
const original = window.CSS
Object.defineProperty(window, 'CSS', {
value: { escape: vi.fn((s: string) => s) },
configurable: true
})
parseSelector('#test-id')
expect(window.CSS.escape).toHaveBeenCalledWith('test-id')
Object.defineProperty(window, 'CSS', { value: original, configurable: true })
})
})
describe('toType', () => {
it('should return "null" for null', () => {
expect(toType(null)).toBe('null')
})
it('should return "undefined" for undefined', () => {
expect(toType(undefined)).toBe('undefined')
})
it('should return "string" for strings', () => {
expect(toType('hello')).toBe('string')
})
it('should return "number" for numbers', () => {
expect(toType(42)).toBe('number')
})
it('should return "object" for objects', () => {
expect(toType({})).toBe('object')
})
it('should return "array" for arrays', () => {
expect(toType([])).toBe('array')
})
it('should return "boolean" for booleans', () => {
expect(toType(true)).toBe('boolean')
})
it('should return "function" for functions', () => {
expect(toType(() => {})).toBe('function')
})
it('should return "regexp" for regexps', () => {
expect(toType(/test/)).toBe('regexp')
})
})
describe('getUID', () => {
it('should return a string starting with the prefix', () => {
const uid = getUID('test')
expect(uid.startsWith('test')).toBe(true)
})
it('should return unique values', () => {
const a = getUID('uid')
const b = getUID('uid')
expect(a).not.toBe(b)
})
})
describe('getTransitionDurationFromElement', () => {
it('should return 0 for null-like element', () => {
expect(getTransitionDurationFromElement(null as unknown as HTMLElement)).toBe(0)
})
it('should return 0 when no transition is set', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(getTransitionDurationFromElement(div)).toBe(0)
})
it('should return duration + delay in ms', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
transitionDuration: '0.3s',
transitionDelay: '0.1s'
} as CSSStyleDeclaration)
expect(getTransitionDurationFromElement(div)).toBe(400)
vi.restoreAllMocks()
})
it('should handle comma-separated values (use first)', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
transitionDuration: '0.5s, 0.2s',
transitionDelay: '0.1s, 0s'
} as CSSStyleDeclaration)
expect(getTransitionDurationFromElement(div)).toBe(600)
vi.restoreAllMocks()
})
})
describe('triggerTransitionEnd', () => {
it('should dispatch a transitionend event', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const handler = vi.fn()
div.addEventListener('transitionend', handler)
triggerTransitionEnd(div)
expect(handler).toHaveBeenCalledOnce()
})
})
describe('isElement', () => {
it('should return true for DOM elements', () => {
expect(isElement(document.createElement('div'))).toBe(true)
})
it('should return false for null', () => {
expect(isElement(null)).toBe(false)
})
it('should return false for strings', () => {
expect(isElement('div')).toBe(false)
})
it('should return false for plain objects', () => {
expect(isElement({})).toBe(false)
})
it('should return false for non-objects', () => {
expect(isElement(42)).toBe(false)
})
})
describe('getElement', () => {
it('should return the element if given an HTMLElement', () => {
const el = document.createElement('div')
expect(getElement(el)).toBe(el)
})
it('should query by string selector', () => {
fixtureEl.innerHTML = '<div id="test-el"></div>'
const el = getElement('#test-el')
expect(el).not.toBeNull()
expect(el!.id).toBe('test-el')
})
it('should return null for empty string', () => {
expect(getElement('')).toBeNull()
})
it('should return null for null', () => {
expect(getElement(null)).toBeNull()
})
it('should return null for numbers', () => {
expect(getElement(42)).toBeNull()
})
})
describe('isVisible', () => {
it('should return false for non-element', () => {
expect(isVisible(null as unknown as HTMLElement)).toBe(false)
})
it('should return false when getClientRects is empty', () => {
fixtureEl.innerHTML = '<div style="display:none"></div>'
const div = fixtureEl.querySelector('div')!
expect(isVisible(div)).toBe(false)
})
it('should return true for visible element', () => {
fixtureEl.innerHTML = '<div style="visibility:visible">text</div>'
const div = fixtureEl.querySelector('div')!
vi.spyOn(div, 'getClientRects').mockReturnValue([{ width: 100, height: 100 }] as unknown as DOMRectList)
expect(isVisible(div)).toBe(true)
})
it('should return false for hidden visibility', () => {
fixtureEl.innerHTML = '<div style="visibility:hidden">text</div>'
const div = fixtureEl.querySelector('div')!
vi.spyOn(div, 'getClientRects').mockReturnValue([{ width: 100, height: 100 }] as unknown as DOMRectList)
expect(isVisible(div)).toBe(false)
})
it('should return false for element inside closed details', () => {
fixtureEl.innerHTML = '<details><div id="inside">text</div></details>'
const inside = fixtureEl.querySelector('#inside')!
vi.spyOn(inside, 'getClientRects').mockReturnValue([{ width: 100, height: 100 }] as unknown as DOMRectList)
expect(isVisible(inside as HTMLElement)).toBe(false)
})
it('should return true for element inside open details', () => {
fixtureEl.innerHTML = '<details open><div id="inside">text</div></details>'
const inside = fixtureEl.querySelector('#inside')!
vi.spyOn(inside, 'getClientRects').mockReturnValue([{ width: 100, height: 100 }] as unknown as DOMRectList)
expect(isVisible(inside as HTMLElement)).toBe(true)
})
it('should return visible for direct summary in closed details', () => {
fixtureEl.innerHTML = '<details><summary id="sum">title</summary></details>'
const sum = fixtureEl.querySelector('#sum')!
vi.spyOn(sum, 'getClientRects').mockReturnValue([{ width: 100, height: 100 }] as unknown as DOMRectList)
expect(isVisible(sum as HTMLElement)).toBe(true)
})
it('should return false for nested summary inside closed details', () => {
fixtureEl.innerHTML = '<details><div><summary id="sum">title</summary></div></details>'
const sum = fixtureEl.querySelector('#sum')!
vi.spyOn(sum, 'getClientRects').mockReturnValue([{ width: 100, height: 100 }] as unknown as DOMRectList)
expect(isVisible(sum as HTMLElement)).toBe(false)
})
it('should return false for non-summary element inside closed details', () => {
fixtureEl.innerHTML = '<details><span id="inner">content</span></details>'
const inner = fixtureEl.querySelector('#inner')!
vi.spyOn(inner, 'getClientRects').mockReturnValue([{ width: 100, height: 100 }] as unknown as DOMRectList)
expect(isVisible(inner as HTMLElement)).toBe(false)
})
})
describe('isDisabled', () => {
it('should return true for null', () => {
expect(isDisabled(null)).toBe(true)
})
it('should return true for undefined', () => {
expect(isDisabled(undefined)).toBe(true)
})
it('should return true for element with disabled class', () => {
fixtureEl.innerHTML = '<div class="disabled"></div>'
expect(isDisabled(fixtureEl.querySelector('div')!)).toBe(true)
})
it('should return true for disabled button', () => {
fixtureEl.innerHTML = '<button disabled></button>'
expect(isDisabled(fixtureEl.querySelector('button')!)).toBe(true)
})
it('should return false for enabled button', () => {
fixtureEl.innerHTML = '<button></button>'
expect(isDisabled(fixtureEl.querySelector('button')!)).toBe(false)
})
it('should return true for element with disabled attribute', () => {
fixtureEl.innerHTML = '<div disabled></div>'
expect(isDisabled(fixtureEl.querySelector('div')!)).toBe(true)
})
it('should return false for disabled="false"', () => {
fixtureEl.innerHTML = '<div disabled="false"></div>'
expect(isDisabled(fixtureEl.querySelector('div')!)).toBe(false)
})
it('should return false for a normal div', () => {
fixtureEl.innerHTML = '<div></div>'
expect(isDisabled(fixtureEl.querySelector('div')!)).toBe(false)
})
})
describe('findShadowRoot', () => {
it('should return null when element has no shadow root', () => {
fixtureEl.innerHTML = '<div></div>'
expect(findShadowRoot(fixtureEl.querySelector('div')!)).toBeNull()
})
it('should return null for orphan node', () => {
const orphan = document.createTextNode('text')
expect(findShadowRoot(orphan)).toBeNull()
})
it('should return null if attachShadow is not supported', () => {
const original = document.documentElement.attachShadow
Object.defineProperty(document.documentElement, 'attachShadow', { value: undefined, configurable: true })
fixtureEl.innerHTML = '<div></div>'
expect(findShadowRoot(fixtureEl.querySelector('div')!)).toBeNull()
Object.defineProperty(document.documentElement, 'attachShadow', { value: original, configurable: true })
})
it('should return shadow root via getRootNode', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const shadowRoot = host.attachShadow({ mode: 'open' })
const inner = document.createElement('span')
shadowRoot.appendChild(inner)
expect(findShadowRoot(inner)).toBe(shadowRoot)
host.remove()
})
it('should fallback to parentNode traversal if getRootNode is not available', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const shadowRoot = host.attachShadow({ mode: 'open' })
const inner = document.createElement('span')
shadowRoot.appendChild(inner)
const originalGetRootNode = inner.getRootNode
Object.defineProperty(inner, 'getRootNode', { value: undefined, configurable: true })
expect(findShadowRoot(inner)).toBe(shadowRoot)
Object.defineProperty(inner, 'getRootNode', { value: originalGetRootNode, configurable: true })
host.remove()
})
})
describe('noop', () => {
it('should be a function that does nothing', () => {
expect(typeof noop).toBe('function')
expect(noop()).toBeUndefined()
})
})
describe('reflow', () => {
it('should access offsetHeight on the element', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(() => reflow(div)).not.toThrow()
})
})
describe('isRTL', () => {
afterEach(() => {
document.documentElement.dir = ''
})
it('should return false by default (LTR)', () => {
expect(isRTL()).toBe(false)
})
it('should return true when dir is rtl', () => {
document.documentElement.dir = 'rtl'
expect(isRTL()).toBe(true)
})
})
describe('execute', () => {
it('should call a function and return its result', () => {
const fn = () => 42
expect(execute(fn)).toBe(42)
})
it('should return defaultValue if not a function', () => {
expect(execute('not a fn', [], 'default')).toBe('default')
})
it('should return the value itself as default if no defaultValue', () => {
expect(execute('hello')).toBe('hello')
})
it('should pass args to the function', () => {
const fn = vi.fn((..._args: unknown[]) => {})
execute(fn, ['context', 'arg1', 'arg2'])
expect(fn).toHaveBeenCalledWith('arg1', 'arg2')
})
})
describe('executeAfterTransition', () => {
it('should execute callback immediately when waitForTransition is false', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const callback = vi.fn()
executeAfterTransition(callback, div, false)
expect(callback).toHaveBeenCalledOnce()
})
it('should execute callback after transitionend event', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const callback = vi.fn()
executeAfterTransition(callback, div, true)
expect(callback).not.toHaveBeenCalled()
div.dispatchEvent(new Event('transitionend'))
expect(callback).toHaveBeenCalledOnce()
})
it('should ignore transitionend from a different target', () => {
fixtureEl.innerHTML = '<div id="parent"><span id="child"></span></div>'
const parent = fixtureEl.querySelector('#parent')!
const child = fixtureEl.querySelector('#child')!
const callback = vi.fn()
executeAfterTransition(callback, parent, true)
child.dispatchEvent(new Event('transitionend', { bubbles: true }))
expect(callback).not.toHaveBeenCalled()
parent.dispatchEvent(new Event('transitionend'))
expect(callback).toHaveBeenCalledOnce()
})
it('should execute via setTimeout fallback', async () => {
vi.useFakeTimers()
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const callback = vi.fn()
executeAfterTransition(callback, div, true)
expect(callback).not.toHaveBeenCalled()
vi.advanceTimersByTime(10)
expect(callback).toHaveBeenCalledOnce()
vi.useRealTimers()
})
})
describe('getNextActiveElement', () => {
const list = ['a', 'b', 'c', 'd']
it('should return next element', () => {
expect(getNextActiveElement(list, 'b', true, false)).toBe('c')
})
it('should return previous element', () => {
expect(getNextActiveElement(list, 'c', false, false)).toBe('b')
})
it('should cycle to first when at end', () => {
expect(getNextActiveElement(list, 'd', true, true)).toBe('a')
})
it('should cycle to last when at start going back', () => {
expect(getNextActiveElement(list, 'a', false, true)).toBe('d')
})
it('should clamp to last when at end without cycling', () => {
expect(getNextActiveElement(list, 'd', true, false)).toBe('d')
})
it('should clamp to first when at start without cycling', () => {
expect(getNextActiveElement(list, 'a', false, false)).toBe('a')
})
it('should return first element when active is not in list', () => {
expect(getNextActiveElement(list, 'z', true, false)).toBe('a')
})
it('should return last element when active is not in list and going back with cycle', () => {
expect(getNextActiveElement(list, 'z', false, true)).toBe('d')
})
})
})
+118
View File
@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest'
import { sanitizeHtml, DefaultAllowlist } from '../../../src/bootstrap/util/sanitizer'
describe('sanitizer', () => {
describe('DefaultAllowlist', () => {
it('should have a wildcard entry with common attributes', () => {
expect(DefaultAllowlist['*']).toBeDefined()
const wildcardStrings = DefaultAllowlist['*'].filter(a => typeof a === 'string')
expect(wildcardStrings).toContain('class')
expect(wildcardStrings).toContain('id')
expect(wildcardStrings).toContain('role')
})
it('should allow safe tags', () => {
for (const tag of ['a', 'b', 'br', 'div', 'em', 'h1', 'img', 'li', 'ol', 'p', 'span', 'strong', 'ul']) {
expect(tag in DefaultAllowlist).toBe(true)
}
})
it('should allow href/target/title/rel for <a>', () => {
expect(DefaultAllowlist.a).toEqual(expect.arrayContaining(['href', 'target', 'title', 'rel']))
})
})
describe('sanitizeHtml', () => {
it('should return empty string for empty input', () => {
expect(sanitizeHtml('', DefaultAllowlist)).toBe('')
})
it('should use custom sanitize function when provided', () => {
const customFn = (html: string) => html.toUpperCase()
expect(sanitizeHtml('<b>test</b>', DefaultAllowlist, customFn)).toBe('<B>TEST</B>')
})
it('should keep allowed elements', () => {
const result = sanitizeHtml('<b>bold</b>', DefaultAllowlist)
expect(result).toContain('<b>')
expect(result).toContain('bold')
})
it('should remove disallowed elements', () => {
const result = sanitizeHtml('<script>alert("xss")</script><b>safe</b>', DefaultAllowlist)
expect(result).not.toContain('<script>')
expect(result).not.toContain('alert')
expect(result).toContain('<b>')
})
it('should keep allowed attributes', () => {
const result = sanitizeHtml('<a href="https://example.com" title="link">click</a>', DefaultAllowlist)
expect(result).toContain('href')
expect(result).toContain('title')
})
it('should remove disallowed attributes', () => {
const result = sanitizeHtml('<b onclick="alert(1)">bold</b>', DefaultAllowlist)
expect(result).not.toContain('onclick')
expect(result).toContain('<b>')
})
it('should allow class attribute via wildcard', () => {
const result = sanitizeHtml('<div class="my-class">content</div>', DefaultAllowlist)
expect(result).toContain('class="my-class"')
})
it('should allow aria-* attributes via regex', () => {
const result = sanitizeHtml('<div aria-label="test" aria-hidden="true">content</div>', DefaultAllowlist)
expect(result).toContain('aria-label')
expect(result).toContain('aria-hidden')
})
it('should block javascript: URIs in href', () => {
const result = sanitizeHtml('<a href="javascript:alert(1)">xss</a>', DefaultAllowlist)
expect(result).not.toContain('javascript:')
})
it('should allow safe URIs in href', () => {
const result = sanitizeHtml('<a href="https://example.com">link</a>', DefaultAllowlist)
expect(result).toContain('href="https://example.com"')
})
it('should allow relative URIs', () => {
const result = sanitizeHtml('<a href="/path/to/page">link</a>', DefaultAllowlist)
expect(result).toContain('href="/path/to/page"')
})
it('should block javascript: URIs in src', () => {
const result = sanitizeHtml('<img src="javascript:alert(1)">', DefaultAllowlist)
expect(result).not.toContain('javascript:')
})
it('should allow safe img src', () => {
const result = sanitizeHtml('<img src="image.png" alt="pic">', DefaultAllowlist)
expect(result).toContain('src="image.png"')
expect(result).toContain('alt="pic"')
})
it('should handle nested allowed elements', () => {
const result = sanitizeHtml('<div><p><strong>text</strong></p></div>', DefaultAllowlist)
expect(result).toContain('<div>')
expect(result).toContain('<p>')
expect(result).toContain('<strong>')
})
it('should strip disallowed elements but keep text content', () => {
const result = sanitizeHtml('<custom-tag>text</custom-tag><b>bold</b>', DefaultAllowlist)
expect(result).not.toContain('<custom-tag>')
expect(result).toContain('<b>bold</b>')
})
it('should work with custom allowList', () => {
const customList = { b: [], i: [] }
const result = sanitizeHtml('<b>bold</b><div>removed</div><i>italic</i>', customList)
expect(result).toContain('<b>')
expect(result).toContain('<i>')
expect(result).not.toContain('<div>')
})
})
})
+202
View File
@@ -0,0 +1,202 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest'
import ScrollBarHelper from '../../../src/bootstrap/util/scrollbar'
import Manipulator from '../../../src/bootstrap/dom/manipulator'
import { clearBodyAndDocument, clearFixture, getFixture } from '../../helpers/fixture'
describe('ScrollBar', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
fixtureEl.removeAttribute('style')
})
afterAll(() => {
fixtureEl.remove()
})
beforeEach(() => {
clearBodyAndDocument()
})
afterEach(() => {
clearFixture()
clearBodyAndDocument()
vi.restoreAllMocks()
})
describe('getWidth', () => {
it('should return the difference between innerWidth and clientWidth', () => {
const scrollBar = new ScrollBarHelper()
const expected = Math.abs(window.innerWidth - document.documentElement.clientWidth)
expect(scrollBar.getWidth()).toBe(expected)
})
it('should return 0 when innerWidth equals clientWidth', () => {
vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(window.innerWidth)
expect(new ScrollBarHelper().getWidth()).toBe(0)
})
})
describe('isOverflowing', () => {
it('should return true when getWidth > 0', () => {
const scrollBar = new ScrollBarHelper()
vi.spyOn(scrollBar, 'getWidth').mockReturnValue(15)
expect(scrollBar.isOverflowing()).toBe(true)
})
it('should return false when getWidth is 0', () => {
const scrollBar = new ScrollBarHelper()
vi.spyOn(scrollBar, 'getWidth').mockReturnValue(0)
expect(scrollBar.isOverflowing()).toBe(false)
})
})
describe('hide', () => {
it('should set overflow hidden on body', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(0)
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(document.body.style.overflow).toBe('hidden')
scrollBar.reset()
})
it('should adjust body padding-right by scrollbar width', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
document.body.style.paddingRight = '5px'
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(document.body.style.paddingRight).toBe('20px')
scrollBar.reset()
})
it('should save initial padding-right as data attribute', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
document.body.style.paddingRight = '5px'
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(Manipulator.getDataAttribute(document.body, 'padding-right')).toBe('5px')
scrollBar.reset()
})
it('should adjust fixed elements padding', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
fixtureEl.innerHTML = '<div class="fixed-top" style="padding-right: 0px; width: 100vw"></div>'
document.body.appendChild(fixtureEl)
const fixedEl = fixtureEl.querySelector('.fixed-top')! as HTMLElement
// jsdom doesn't compute layout, so mock clientWidth to simulate full-width element
vi.spyOn(fixedEl, 'clientWidth', 'get').mockReturnValue(window.innerWidth - 15)
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(fixedEl.style.paddingRight).toBe('15px')
scrollBar.reset()
})
})
describe('reset', () => {
it('should restore overflow on body', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(0)
document.body.style.overflow = 'scroll'
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(document.body.style.overflow).toBe('hidden')
scrollBar.reset()
expect(document.body.style.overflow).toBe('scroll')
})
it('should restore body padding-right', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
document.body.style.paddingRight = '5px'
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(document.body.style.paddingRight).toBe('20px')
scrollBar.reset()
expect(document.body.style.paddingRight).toBe('5px')
})
it('should remove padding-right if it was not set before', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
document.body.style.removeProperty('padding-right')
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
scrollBar.reset()
expect(document.body.style.paddingRight).toBe('')
})
it('should remove data attribute after reset', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
document.body.style.paddingRight = '5px'
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
scrollBar.reset()
expect(Manipulator.getDataAttribute(document.body, 'padding-right')).toBeNull()
})
it('should preserve other inline styles', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(0)
document.body.style.color = 'red'
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(document.body.style.color).toBe('red')
scrollBar.reset()
expect(document.body.style.color).toBe('red')
})
it('should reset sticky elements margin', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
fixtureEl.innerHTML = '<div class="sticky-top" style="margin-right: 10px; width: 100vw"></div>'
document.body.appendChild(fixtureEl)
const stickyEl = fixtureEl.querySelector('.sticky-top')! as HTMLElement
vi.spyOn(stickyEl, 'clientWidth', 'get').mockReturnValue(window.innerWidth - 15)
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(Manipulator.getDataAttribute(stickyEl, 'margin-right')).toBe('10px')
scrollBar.reset()
expect(stickyEl.style.marginRight).toBe('10px')
expect(Manipulator.getDataAttribute(stickyEl, 'margin-right')).toBeNull()
})
it('should skip non-full-width elements', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
fixtureEl.innerHTML = '<div class="sticky-top" style="margin-right: 0px; padding-right: 0px; width: 50vw"></div>'
document.body.appendChild(fixtureEl)
const stickyEl = fixtureEl.querySelector('.sticky-top')! as HTMLElement
// clientWidth 0 (jsdom default) + scrollbarWidth 15 = 15, which is < innerWidth 1024
// So this element will be skipped
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(stickyEl.style.paddingRight).toBe('0px')
expect(stickyEl.style.marginRight).toBe('0px')
scrollBar.reset()
})
})
})
+311
View File
@@ -0,0 +1,311 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import Swipe from '../../../src/bootstrap/util/swipe'
import { clearFixture, getFixture } from '../../helpers/fixture'
function mockTouchSupport() {
Object.defineProperty(document.documentElement, 'ontouchstart', {
value: () => {},
configurable: true
})
}
function clearTouchSupport() {
delete (document.documentElement as Record<string, unknown>).ontouchstart
}
function createTouchEvent(type: string, touches: Array<{ clientX: number }>, target: EventTarget): TouchEvent {
const touchList = touches.map((t, i) => ({
identifier: i,
target,
clientX: t.clientX,
clientY: 0,
pageX: 0,
pageY: 0,
screenX: 0,
screenY: 0,
radiusX: 0,
radiusY: 0,
rotationAngle: 0,
force: 0
})) as unknown as Touch[]
const event = new Event(type, { bubbles: true, cancelable: true }) as TouchEvent
Object.defineProperty(event, 'touches', { value: touchList })
Object.defineProperty(event, 'changedTouches', { value: touchList })
return event
}
function createPointerEvent(type: string, opts: Partial<PointerEvent> = {}): PointerEvent {
return new PointerEvent(type, {
bubbles: true,
cancelable: true,
clientX: opts.clientX ?? 0,
pointerType: (opts as Record<string, unknown>).pointerType as string ?? 'touch',
...opts
})
}
describe('Swipe', () => {
let fixtureEl: HTMLElement
let div: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
mockTouchSupport()
fixtureEl.innerHTML = '<div style="width:300px;height:300px;"></div>'
div = fixtureEl.querySelector('div')!
})
afterEach(() => {
clearTouchSupport()
clearFixture()
})
describe('constructor', () => {
it('should create a Swipe instance', () => {
const swipe = new Swipe(div)
expect(swipe).toBeInstanceOf(Swipe)
swipe.dispose()
})
it('should not init events when touch is not supported', () => {
vi.spyOn(Swipe, 'isSupported').mockReturnValue(false)
const swipe = new Swipe(div)
expect(swipe._deltaX).toBeUndefined()
vi.restoreAllMocks()
})
})
describe('static', () => {
it('NAME should return "swipe"', () => {
expect(Swipe.NAME).toBe('swipe')
})
it('isSupported should return true when ontouchstart exists', () => {
expect(Swipe.isSupported()).toBe(true)
})
it('isSupported should check ontouchstart and maxTouchPoints', () => {
expect(typeof Swipe.isSupported()).toBe('boolean')
})
})
describe('dispose', () => {
it('should remove event listeners', () => {
const leftCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback })
swipe.dispose()
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 300 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 100 }))
expect(leftCallback).not.toHaveBeenCalled()
})
})
describe('pointer events', () => {
it('should detect a left swipe', () => {
const leftCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback })
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 300 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 100 }))
expect(leftCallback).toHaveBeenCalledOnce()
swipe.dispose()
})
it('should detect a right swipe', () => {
const rightCallback = vi.fn()
const swipe = new Swipe(div, { rightCallback })
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 100 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 300 }))
expect(rightCallback).toHaveBeenCalledOnce()
swipe.dispose()
})
it('should call endCallback after swipe', () => {
const endCallback = vi.fn()
const swipe = new Swipe(div, { endCallback })
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 300 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 100 }))
expect(endCallback).toHaveBeenCalledOnce()
swipe.dispose()
})
it('should not trigger callback when swipe is below threshold', () => {
const leftCallback = vi.fn()
const rightCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback, rightCallback })
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 100 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 120 }))
expect(leftCallback).not.toHaveBeenCalled()
expect(rightCallback).not.toHaveBeenCalled()
swipe.dispose()
})
it('should ignore non-touch/pen pointer types', () => {
const leftCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback })
div.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
clientX: 300,
pointerType: 'mouse'
}))
div.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true,
clientX: 100,
pointerType: 'mouse'
}))
expect(leftCallback).not.toHaveBeenCalled()
swipe.dispose()
})
it('should accept pen pointer type', () => {
const leftCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback })
div.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
clientX: 300,
pointerType: 'pen'
}))
div.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true,
clientX: 100,
pointerType: 'pen'
}))
expect(leftCallback).toHaveBeenCalledOnce()
swipe.dispose()
})
it('should add pointer-event class to element', () => {
const swipe = new Swipe(div)
expect(div.classList.contains('pointer-event')).toBe(true)
swipe.dispose()
})
})
describe('_handleSwipe edge cases', () => {
it('should not trigger callbacks when deltaX is zero', () => {
const leftCallback = vi.fn()
const rightCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback, rightCallback })
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 200 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 200 }))
expect(leftCallback).not.toHaveBeenCalled()
expect(rightCallback).not.toHaveBeenCalled()
swipe.dispose()
})
it('should reset deltaX after handling swipe', () => {
const leftCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback })
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 300 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 100 }))
expect(leftCallback).toHaveBeenCalledOnce()
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 300 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 100 }))
expect(leftCallback).toHaveBeenCalledTimes(2)
swipe.dispose()
})
})
describe('touch events (no PointerEvent)', () => {
const origPointerEvent = globalThis.PointerEvent
beforeEach(() => {
// @ts-expect-error removing PointerEvent to test touch fallback
delete globalThis.PointerEvent
})
afterEach(() => {
globalThis.PointerEvent = origPointerEvent
})
it('should detect a left swipe via touch events', () => {
const leftCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback })
div.dispatchEvent(createTouchEvent('touchstart', [{ clientX: 300 }], div))
div.dispatchEvent(createTouchEvent('touchmove', [{ clientX: 100 }], div))
div.dispatchEvent(createTouchEvent('touchend', [], div))
expect(leftCallback).toHaveBeenCalledOnce()
swipe.dispose()
})
it('should detect a right swipe via touch events', () => {
const rightCallback = vi.fn()
const swipe = new Swipe(div, { rightCallback })
div.dispatchEvent(createTouchEvent('touchstart', [{ clientX: 100 }], div))
div.dispatchEvent(createTouchEvent('touchmove', [{ clientX: 300 }], div))
div.dispatchEvent(createTouchEvent('touchend', [], div))
expect(rightCallback).toHaveBeenCalledOnce()
swipe.dispose()
})
it('should reset deltaX to 0 on multi-touch move', () => {
const leftCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback })
div.dispatchEvent(createTouchEvent('touchstart', [{ clientX: 300 }], div))
div.dispatchEvent(createTouchEvent('touchmove', [{ clientX: 200 }, { clientX: 250 }], div))
div.dispatchEvent(createTouchEvent('touchend', [], div))
expect(leftCallback).not.toHaveBeenCalled()
swipe.dispose()
})
it('should not add pointer-event class', () => {
const swipe = new Swipe(div)
expect(div.classList.contains('pointer-event')).toBe(false)
swipe.dispose()
})
})
describe('Default / DefaultType', () => {
it('should have correct Default values', () => {
expect(Swipe.Default).toEqual({
endCallback: null,
leftCallback: null,
rightCallback: null
})
})
it('should have correct DefaultType values', () => {
expect(Swipe.DefaultType).toEqual({
endCallback: '(function|null)',
leftCallback: '(function|null)',
rightCallback: '(function|null)'
})
})
})
})
@@ -0,0 +1,233 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
import TemplateFactory from '../../../src/bootstrap/util/template-factory'
import { clearFixture, getFixture } from '../../helpers/fixture'
describe('TemplateFactory', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
describe('NAME', () => {
it('should return plugin NAME', () => {
expect(TemplateFactory.NAME).toBe('TemplateFactory')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(typeof TemplateFactory.Default).toBe('object')
})
})
describe('toHtml', () => {
describe('Sanitization', () => {
it('should sanitize template by default', () => {
const factory = new TemplateFactory({
sanitize: true,
template: '<div><a href="javascript:alert(7)">Click me</a></div>'
})
expect(factory.toHtml().innerHTML).not.toContain('href="javascript:alert(7)')
})
it('should not sanitize template if disabled', () => {
const factory = new TemplateFactory({
sanitize: false,
template: '<div><a href="javascript:alert(7)">Click me</a></div>'
})
expect(factory.toHtml().innerHTML).toContain('href="javascript:alert(7)')
})
it('should sanitize html content', () => {
const factory = new TemplateFactory({
sanitize: true,
html: true,
template: '<div id="foo"></div>',
content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' }
})
expect(factory.toHtml().innerHTML).not.toContain('href="javascript:alert(7)')
})
it('should not sanitize content when disabled', () => {
const factory = new TemplateFactory({
sanitize: false,
html: true,
template: '<div id="foo"></div>',
content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' }
})
expect(factory.toHtml().innerHTML).toContain('href="javascript:alert(7)')
})
})
describe('Extra Class', () => {
it('should add extra class', () => {
const factory = new TemplateFactory({ extraClass: 'testClass' })
expect(factory.toHtml().classList.contains('testClass')).toBe(true)
})
it('should add multiple extra classes', () => {
const factory = new TemplateFactory({ extraClass: 'testClass testClass2' })
const el = factory.toHtml()
expect(el.classList.contains('testClass')).toBe(true)
expect(el.classList.contains('testClass2')).toBe(true)
})
it('should resolve class from function', () => {
const factory = new TemplateFactory({
extraClass() {
return 'testClass'
}
})
expect(factory.toHtml().classList.contains('testClass')).toBe(true)
})
})
})
describe('Content', () => {
it('should add simple text content', () => {
const template = '<div><div class="foo"></div><div class="foo2"></div></div>'
const factory = new TemplateFactory({
template,
content: { '.foo': 'bar', '.foo2': 'bar2' }
})
const html = factory.toHtml()
expect(html.querySelector('.foo')!.textContent).toBe('bar')
expect(html.querySelector('.foo2')!.textContent).toBe('bar2')
})
it('should not fill template if selector does not exist', () => {
const factory = new TemplateFactory({
sanitize: true,
html: true,
template: '<div id="foo"></div>',
content: { '#bar': 'test' }
})
expect(factory.toHtml().outerHTML).toBe('<div id="foo"></div>')
})
it('should remove template selector if content is null', () => {
const factory = new TemplateFactory({
sanitize: true,
html: true,
template: '<div><div id="foo"></div></div>',
content: { '#foo': null }
})
expect(factory.toHtml().outerHTML).toBe('<div></div>')
})
it('should resolve content from function', () => {
const factory = new TemplateFactory({
sanitize: true,
html: true,
template: '<div><div id="foo"></div></div>',
content: { '#foo': () => null }
})
expect(factory.toHtml().outerHTML).toBe('<div></div>')
})
it('should use textContent when html is false and content is element', () => {
fixtureEl.innerHTML = '<div>foo<span>bar</span></div>'
const contentElement = fixtureEl.querySelector('div')!
const factory = new TemplateFactory({
html: false,
template: '<div><div id="foo"></div></div>',
content: { '#foo': contentElement }
})
const fooEl = factory.toHtml().querySelector('#foo')!
expect(fooEl.innerHTML).not.toBe(contentElement.innerHTML)
expect(fooEl.textContent).toBe(contentElement.textContent)
expect(fooEl.textContent).toBe('foobar')
})
it('should use outerHTML when html is true and content is element', () => {
fixtureEl.innerHTML = '<div>foo<span>bar</span></div>'
const contentElement = fixtureEl.querySelector('div')!
const factory = new TemplateFactory({
html: true,
template: '<div><div id="foo"></div></div>',
content: { '#foo': contentElement }
})
const fooEl = factory.toHtml().querySelector('#foo')!
expect(fooEl.innerHTML).toBe(contentElement.outerHTML)
})
})
describe('getContent', () => {
it('should get content as array', () => {
const factory = new TemplateFactory({
content: { '.foo': 'bar', '.foo2': 'bar2' }
})
expect(factory.getContent()).toEqual(['bar', 'bar2'])
})
it('should filter empties', () => {
const factory = new TemplateFactory({
content: {
'.foo': 'bar',
'.foo2': '',
'.foo3': null,
'.foo4': () => 2,
'.foo5': () => null
}
})
expect(factory.getContent()).toEqual(['bar', 2])
})
})
describe('hasContent', () => {
it('should return true if it has content', () => {
const factory = new TemplateFactory({
content: { '.foo': 'bar', '.foo2': 'bar2', '.foo3': '' }
})
expect(factory.hasContent()).toBe(true)
})
it('should return false if all content is empty', () => {
const factory = new TemplateFactory({
content: { '.foo2': '', '.foo3': null, '.foo4': () => null }
})
expect(factory.hasContent()).toBe(false)
})
})
describe('changeContent', () => {
it('should change content', () => {
const template = '<div><div class="foo"></div><div class="foo2"></div></div>'
const factory = new TemplateFactory({
template,
content: { '.foo': 'bar', '.foo2': 'bar2' }
})
const text = (sel: string) => factory.toHtml().querySelector(sel)!.textContent
expect(text('.foo')).toBe('bar')
expect(text('.foo2')).toBe('bar2')
factory.changeContent({ '.foo': 'test', '.foo2': 'test2' })
expect(text('.foo')).toBe('test')
expect(text('.foo2')).toBe('test2')
})
it('should change only specified content', () => {
const template = '<div><div class="foo"></div><div class="foo2"></div></div>'
const factory = new TemplateFactory({
template,
content: { '.foo': 'bar', '.foo2': 'bar2' }
})
const text = (sel: string) => factory.toHtml().querySelector(sel)!.textContent
factory.changeContent({ '.foo': 'test' })
expect(text('.foo')).toBe('test')
expect(text('.foo2')).toBe('bar2')
})
})
})