mirror of
https://github.com/tabler/tabler.git
synced 2026-09-01 23:01:29 +04:00
Remove Bootstrap dependency and vendor JS/SCSS sources into Tabler (#2627)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user