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 = 'Trigger' 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 = 'Trigger' 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 = '' 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 = 'Link text' 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 = '' const el = fixtureEl.querySelector('a')! new Tooltip(el) expect(el.getAttribute('aria-label')).toBe('Existing label') }) }) describe('enable', () => { it('should enable tooltip', () => { fixtureEl.innerHTML = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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((resolve) => { fixtureEl.innerHTML = 'Trigger' 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((resolve) => { fixtureEl.innerHTML = 'Trigger' 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 = 'Trigger' 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((resolve) => { fixtureEl.innerHTML = 'Trigger' 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 = 'Trigger' 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((resolve) => { fixtureEl.innerHTML = 'Trigger' 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((resolve) => { fixtureEl.innerHTML = 'Trigger' 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 = 'Trigger' 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((resolve) => { fixtureEl.innerHTML = 'Trigger' 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((resolve) => { fixtureEl.innerHTML = 'Trigger' 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 = 'Trigger' const el = fixtureEl.querySelector('a')! const tooltip = new Tooltip(el) expect(() => tooltip.update()).not.toThrow() }) }) describe('dispose', () => { it('should dispose tooltip', () => { fixtureEl.innerHTML = 'Trigger' 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 = 'Trigger' 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((resolve) => { fixtureEl.innerHTML = 'Trigger' 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 = 'Trigger' 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((resolve) => { fixtureEl.innerHTML = 'Trigger' 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 = 'Trigger' const el = fixtureEl.querySelector('a')! const tooltip = new Tooltip(el) expect(tooltip._isWithContent()).toBe(true) }) it('should return false when no title', () => { fixtureEl.innerHTML = 'Trigger' const el = fixtureEl.querySelector('a')! const tooltip = new Tooltip(el) expect(tooltip._isWithContent()).toBe(false) }) }) describe('_getTitle', () => { it('should return config title', () => { fixtureEl.innerHTML = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' const el = fixtureEl.querySelector('a')! const tooltip = new Tooltip(el) expect(tooltip._getTitle()).toBe('BS title') }) it('should resolve function title', () => { fixtureEl.innerHTML = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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((resolve) => { fixtureEl.innerHTML = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' const el = fixtureEl.querySelector('a')! const tooltip = new Tooltip(el) expect(Tooltip.getOrCreateInstance(el)).toBe(tooltip) }) it('should create new instance', () => { fixtureEl.innerHTML = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' const el = fixtureEl.querySelector('a')! const tooltip = new Tooltip(el) expect(tooltip._isWithActiveTrigger()).toBe(false) }) it('should return true with active trigger', () => { fixtureEl.innerHTML = 'Trigger' 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 = 'Trigger' 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 = 'Trigger' const el = fixtureEl.querySelector('a')! const tooltip = new Tooltip(el, { trigger: 'manual' }) expect(tooltip._config.trigger).toBe('manual') }) }) })