diff options
Diffstat (limited to 'js/src/tooltip')
-rw-r--r-- | js/src/tooltip/tooltip.js | 818 | ||||
-rw-r--r-- | js/src/tooltip/tooltip.spec.js | 1005 |
2 files changed, 1823 insertions, 0 deletions
diff --git a/js/src/tooltip/tooltip.js b/js/src/tooltip/tooltip.js new file mode 100644 index 0000000000..1db233e798 --- /dev/null +++ b/js/src/tooltip/tooltip.js @@ -0,0 +1,818 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap (v4.3.1): tooltip.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { + jQuery as $, + TRANSITION_END, + emulateTransitionEnd, + findShadowRoot, + getTransitionDurationFromElement, + getUID, + isElement, + makeArray, + noop, + typeCheckConfig +} from '../util/index' +import { + DefaultWhitelist, + sanitizeHtml +} from '../util/sanitizer' +import Data from '../dom/data' +import EventHandler from '../dom/event-handler' +import Manipulator from '../dom/manipulator' +import Popper from 'popper.js' +import SelectorEngine from '../dom/selector-engine' + +/** + * ------------------------------------------------------------------------ + * Constants + * ------------------------------------------------------------------------ + */ + +const NAME = 'tooltip' +const VERSION = '4.3.1' +const DATA_KEY = 'bs.tooltip' +const EVENT_KEY = `.${DATA_KEY}` +const CLASS_PREFIX = 'bs-tooltip' +const BSCLS_PREFIX_REGEX = new RegExp(`(^|\\s)${CLASS_PREFIX}\\S+`, 'g') +const DISALLOWED_ATTRIBUTES = ['sanitize', 'whiteList', 'sanitizeFn'] + +const DefaultType = { + animation: 'boolean', + template: 'string', + title: '(string|element|function)', + trigger: 'string', + delay: '(number|object)', + html: 'boolean', + selector: '(string|boolean)', + placement: '(string|function)', + offset: '(number|string|function)', + container: '(string|element|boolean)', + fallbackPlacement: '(string|array)', + boundary: '(string|element)', + sanitize: 'boolean', + sanitizeFn: '(null|function)', + whiteList: 'object' +} + +const AttachmentMap = { + AUTO: 'auto', + TOP: 'top', + RIGHT: 'right', + BOTTOM: 'bottom', + LEFT: 'left' +} + +const Default = { + animation: true, + template: '<div class="tooltip" role="tooltip">' + + '<div class="tooltip-arrow"></div>' + + '<div class="tooltip-inner"></div></div>', + trigger: 'hover focus', + title: '', + delay: 0, + html: false, + selector: false, + placement: 'top', + offset: 0, + container: false, + fallbackPlacement: 'flip', + boundary: 'scrollParent', + sanitize: true, + sanitizeFn: null, + whiteList: DefaultWhitelist +} + +const HoverState = { + SHOW: 'show', + OUT: 'out' +} + +const Event = { + HIDE: `hide${EVENT_KEY}`, + HIDDEN: `hidden${EVENT_KEY}`, + SHOW: `show${EVENT_KEY}`, + SHOWN: `shown${EVENT_KEY}`, + INSERTED: `inserted${EVENT_KEY}`, + CLICK: `click${EVENT_KEY}`, + FOCUSIN: `focusin${EVENT_KEY}`, + FOCUSOUT: `focusout${EVENT_KEY}`, + MOUSEENTER: `mouseenter${EVENT_KEY}`, + MOUSELEAVE: `mouseleave${EVENT_KEY}` +} + +const ClassName = { + FADE: 'fade', + SHOW: 'show' +} + +const Selector = { + TOOLTIP_INNER: '.tooltip-inner' +} + +const Trigger = { + HOVER: 'hover', + FOCUS: 'focus', + CLICK: 'click', + MANUAL: 'manual' +} + +/** + * ------------------------------------------------------------------------ + * Class Definition + * ------------------------------------------------------------------------ + */ + +class Tooltip { + constructor(element, config) { + /** + * Check for Popper dependency + * Popper - https://popper.js.org + */ + if (typeof Popper === 'undefined') { + throw new TypeError('Bootstrap\'s tooltips require Popper.js (https://popper.js.org)') + } + + // private + this._isEnabled = true + this._timeout = 0 + this._hoverState = '' + this._activeTrigger = {} + this._popper = null + + // Protected + this.element = element + this.config = this._getConfig(config) + this.tip = null + + this._setListeners() + Data.setData(element, this.constructor.DATA_KEY, this) + } + + // Getters + + static get VERSION() { + return VERSION + } + + static get Default() { + return Default + } + + static get NAME() { + return NAME + } + + static get DATA_KEY() { + return DATA_KEY + } + + static get Event() { + return Event + } + + static get EVENT_KEY() { + return EVENT_KEY + } + + static get DefaultType() { + return DefaultType + } + + // Public + + enable() { + this._isEnabled = true + } + + disable() { + this._isEnabled = false + } + + toggleEnabled() { + this._isEnabled = !this._isEnabled + } + + toggle(event) { + if (!this._isEnabled) { + return + } + + if (event) { + const dataKey = this.constructor.DATA_KEY + let context = Data.getData(event.delegateTarget, dataKey) + + if (!context) { + context = new this.constructor( + event.delegateTarget, + this._getDelegateConfig() + ) + Data.setData(event.delegateTarget, dataKey, context) + } + + context._activeTrigger.click = !context._activeTrigger.click + + if (context._isWithActiveTrigger()) { + context._enter(null, context) + } else { + context._leave(null, context) + } + } else { + if (this.getTipElement().classList.contains(ClassName.SHOW)) { + this._leave(null, this) + return + } + + this._enter(null, this) + } + } + + dispose() { + clearTimeout(this._timeout) + + Data.removeData(this.element, this.constructor.DATA_KEY) + + EventHandler.off(this.element, this.constructor.EVENT_KEY) + EventHandler.off(SelectorEngine.closest(this.element, '.modal'), 'hide.bs.modal', this._hideModalHandler) + + if (this.tip) { + this.tip.parentNode.removeChild(this.tip) + } + + this._isEnabled = null + this._timeout = null + this._hoverState = null + this._activeTrigger = null + if (this._popper !== null) { + this._popper.destroy() + } + + this._popper = null + this.element = null + this.config = null + this.tip = null + } + + show() { + if (this.element.style.display === 'none') { + throw new Error('Please use show on visible elements') + } + + if (this.isWithContent() && this._isEnabled) { + const showEvent = EventHandler.trigger(this.element, this.constructor.Event.SHOW) + const shadowRoot = findShadowRoot(this.element) + const isInTheDom = shadowRoot === null ? + this.element.ownerDocument.documentElement.contains(this.element) : + shadowRoot.contains(this.element) + + if (showEvent.defaultPrevented || !isInTheDom) { + return + } + + const tip = this.getTipElement() + const tipId = getUID(this.constructor.NAME) + + tip.setAttribute('id', tipId) + this.element.setAttribute('aria-describedby', tipId) + + this.setContent() + + if (this.config.animation) { + tip.classList.add(ClassName.FADE) + } + + const placement = typeof this.config.placement === 'function' ? + this.config.placement.call(this, tip, this.element) : + this.config.placement + + const attachment = this._getAttachment(placement) + this._addAttachmentClass(attachment) + + const container = this._getContainer() + Data.setData(tip, this.constructor.DATA_KEY, this) + + if (!this.element.ownerDocument.documentElement.contains(this.tip)) { + container.appendChild(tip) + } + + EventHandler.trigger(this.element, this.constructor.Event.INSERTED) + + this._popper = new Popper(this.element, tip, { + placement: attachment, + modifiers: { + offset: this._getOffset(), + flip: { + behavior: this.config.fallbackPlacement + }, + arrow: { + element: `.${this.constructor.NAME}-arrow` + }, + preventOverflow: { + boundariesElement: this.config.boundary + } + }, + onCreate: data => { + if (data.originalPlacement !== data.placement) { + this._handlePopperPlacementChange(data) + } + }, + onUpdate: data => this._handlePopperPlacementChange(data) + }) + + tip.classList.add(ClassName.SHOW) + + // If this is a touch-enabled device we add extra + // empty mouseover listeners to the body's immediate children; + // only needed because of broken event delegation on iOS + // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html + if ('ontouchstart' in document.documentElement) { + makeArray(document.body.children).forEach(element => { + EventHandler.on(element, 'mouseover', noop()) + }) + } + + const complete = () => { + if (this.config.animation) { + this._fixTransition() + } + + const prevHoverState = this._hoverState + this._hoverState = null + + EventHandler.trigger(this.element, this.constructor.Event.SHOWN) + + if (prevHoverState === HoverState.OUT) { + this._leave(null, this) + } + } + + if (this.tip.classList.contains(ClassName.FADE)) { + const transitionDuration = getTransitionDurationFromElement(this.tip) + EventHandler.one(this.tip, TRANSITION_END, complete) + emulateTransitionEnd(this.tip, transitionDuration) + } else { + complete() + } + } + } + + hide() { + const tip = this.getTipElement() + const complete = () => { + if (this._hoverState !== HoverState.SHOW && tip.parentNode) { + tip.parentNode.removeChild(tip) + } + + this._cleanTipClass() + this.element.removeAttribute('aria-describedby') + EventHandler.trigger(this.element, this.constructor.Event.HIDDEN) + this._popper.destroy() + } + + const hideEvent = EventHandler.trigger(this.element, this.constructor.Event.HIDE) + if (hideEvent.defaultPrevented) { + return + } + + tip.classList.remove(ClassName.SHOW) + + // If this is a touch-enabled device we remove the extra + // empty mouseover listeners we added for iOS support + if ('ontouchstart' in document.documentElement) { + makeArray(document.body.children) + .forEach(element => EventHandler.off(element, 'mouseover', noop)) + } + + this._activeTrigger[Trigger.CLICK] = false + this._activeTrigger[Trigger.FOCUS] = false + this._activeTrigger[Trigger.HOVER] = false + + if (this.tip.classList.contains(ClassName.FADE)) { + const transitionDuration = getTransitionDurationFromElement(tip) + + EventHandler.one(tip, TRANSITION_END, complete) + emulateTransitionEnd(tip, transitionDuration) + } else { + complete() + } + + this._hoverState = '' + } + + update() { + if (this._popper !== null) { + this._popper.scheduleUpdate() + } + } + + // Protected + + isWithContent() { + return Boolean(this.getTitle()) + } + + getTipElement() { + if (this.tip) { + return this.tip + } + + const element = document.createElement('div') + element.innerHTML = this.config.template + + this.tip = element.children[0] + return this.tip + } + + setContent() { + const tip = this.getTipElement() + this.setElementContent(SelectorEngine.findOne(Selector.TOOLTIP_INNER, tip), this.getTitle()) + tip.classList.remove(ClassName.FADE) + tip.classList.remove(ClassName.SHOW) + } + + setElementContent(element, content) { + if (element === null) { + return + } + + if (typeof content === 'object' && isElement(content)) { + if (content.jquery) { + content = content[0] + } + + // content is a DOM node or a jQuery + if (this.config.html) { + if (content.parentNode !== element) { + element.innerHTML = '' + element.appendChild(content) + } + } else { + element.innerText = content.textContent + } + + return + } + + if (this.config.html) { + if (this.config.sanitize) { + content = sanitizeHtml(content, this.config.whiteList, this.config.sanitizeFn) + } + + element.innerHTML = content + } else { + element.innerText = content + } + } + + getTitle() { + let title = this.element.getAttribute('data-original-title') + + if (!title) { + title = typeof this.config.title === 'function' ? + this.config.title.call(this.element) : + this.config.title + } + + return title + } + + // Private + + _addAttachmentClass(attachment) { + this.getTipElement().classList.add(`${CLASS_PREFIX}-${attachment}`) + } + + _getOffset() { + const offset = {} + + if (typeof this.config.offset === 'function') { + offset.fn = data => { + data.offsets = { + ...data.offsets, + ...this.config.offset(data.offsets, this.element) || {} + } + + return data + } + } else { + offset.offset = this.config.offset + } + + return offset + } + + _getContainer() { + if (this.config.container === false) { + return document.body + } + + if (isElement(this.config.container)) { + return this.config.container + } + + return SelectorEngine.findOne(this.config.container) + } + + _getAttachment(placement) { + return AttachmentMap[placement.toUpperCase()] + } + + _setListeners() { + const triggers = this.config.trigger.split(' ') + + triggers.forEach(trigger => { + if (trigger === 'click') { + EventHandler.on(this.element, + this.constructor.Event.CLICK, + this.config.selector, + event => this.toggle(event) + ) + } else if (trigger !== Trigger.MANUAL) { + const eventIn = trigger === Trigger.HOVER ? + this.constructor.Event.MOUSEENTER : + this.constructor.Event.FOCUSIN + const eventOut = trigger === Trigger.HOVER ? + this.constructor.Event.MOUSELEAVE : + this.constructor.Event.FOCUSOUT + + EventHandler.on(this.element, + eventIn, + this.config.selector, + event => this._enter(event) + ) + EventHandler.on(this.element, + eventOut, + this.config.selector, + event => this._leave(event) + ) + } + }) + + this._hideModalHandler = () => { + if (this.element) { + this.hide() + } + } + + EventHandler.on(SelectorEngine.closest(this.element, '.modal'), + 'hide.bs.modal', + this._hideModalHandler + ) + + if (this.config.selector) { + this.config = { + ...this.config, + trigger: 'manual', + selector: '' + } + } else { + this._fixTitle() + } + } + + _fixTitle() { + const titleType = typeof this.element.getAttribute('data-original-title') + + if (this.element.getAttribute('title') || titleType !== 'string') { + this.element.setAttribute( + 'data-original-title', + this.element.getAttribute('title') || '' + ) + + this.element.setAttribute('title', '') + } + } + + _enter(event, context) { + const dataKey = this.constructor.DATA_KEY + context = context || Data.getData(event.delegateTarget, dataKey) + + if (!context) { + context = new this.constructor( + event.delegateTarget, + this._getDelegateConfig() + ) + Data.setData(event.delegateTarget, dataKey, context) + } + + if (event) { + context._activeTrigger[ + event.type === 'focusin' ? Trigger.FOCUS : Trigger.HOVER + ] = true + } + + if (context.getTipElement().classList.contains(ClassName.SHOW) || + context._hoverState === HoverState.SHOW) { + context._hoverState = HoverState.SHOW + return + } + + clearTimeout(context._timeout) + + context._hoverState = HoverState.SHOW + + if (!context.config.delay || !context.config.delay.show) { + context.show() + return + } + + context._timeout = setTimeout(() => { + if (context._hoverState === HoverState.SHOW) { + context.show() + } + }, context.config.delay.show) + } + + _leave(event, context) { + const dataKey = this.constructor.DATA_KEY + context = context || Data.getData(event.delegateTarget, dataKey) + + if (!context) { + context = new this.constructor( + event.delegateTarget, + this._getDelegateConfig() + ) + Data.setData(event.delegateTarget, dataKey, context) + } + + if (event) { + context._activeTrigger[ + event.type === 'focusout' ? Trigger.FOCUS : Trigger.HOVER + ] = false + } + + if (context._isWithActiveTrigger()) { + return + } + + clearTimeout(context._timeout) + + context._hoverState = HoverState.OUT + + if (!context.config.delay || !context.config.delay.hide) { + context.hide() + return + } + + context._timeout = setTimeout(() => { + if (context._hoverState === HoverState.OUT) { + context.hide() + } + }, context.config.delay.hide) + } + + _isWithActiveTrigger() { + for (const trigger in this._activeTrigger) { + if (this._activeTrigger[trigger]) { + return true + } + } + + return false + } + + _getConfig(config) { + const dataAttributes = Manipulator.getDataAttributes(this.element) + + Object.keys(dataAttributes) + .forEach(dataAttr => { + if (DISALLOWED_ATTRIBUTES.indexOf(dataAttr) !== -1) { + delete dataAttributes[dataAttr] + } + }) + + if (config && typeof config.container === 'object' && config.container.jquery) { + config.container = config.container[0] + } + + config = { + ...this.constructor.Default, + ...dataAttributes, + ...typeof config === 'object' && config ? config : {} + } + + if (typeof config.delay === 'number') { + config.delay = { + show: config.delay, + hide: config.delay + } + } + + if (typeof config.title === 'number') { + config.title = config.title.toString() + } + + if (typeof config.content === 'number') { + config.content = config.content.toString() + } + + typeCheckConfig( + NAME, + config, + this.constructor.DefaultType + ) + + if (config.sanitize) { + config.template = sanitizeHtml(config.template, config.whiteList, config.sanitizeFn) + } + + return config + } + + _getDelegateConfig() { + const config = {} + + if (this.config) { + for (const key in this.config) { + if (this.constructor.Default[key] !== this.config[key]) { + config[key] = this.config[key] + } + } + } + + return config + } + + _cleanTipClass() { + const tip = this.getTipElement() + const tabClass = tip.getAttribute('class').match(BSCLS_PREFIX_REGEX) + if (tabClass !== null && tabClass.length) { + tabClass + .map(token => token.trim()) + .forEach(tClass => tip.classList.remove(tClass)) + } + } + + _handlePopperPlacementChange(popperData) { + const popperInstance = popperData.instance + this.tip = popperInstance.popper + this._cleanTipClass() + this._addAttachmentClass(this._getAttachment(popperData.placement)) + } + + _fixTransition() { + const tip = this.getTipElement() + const initConfigAnimation = this.config.animation + if (tip.getAttribute('x-placement') !== null) { + return + } + + tip.classList.remove(ClassName.FADE) + this.config.animation = false + this.hide() + this.show() + this.config.animation = initConfigAnimation + } + + // Static + + static _jQueryInterface(config) { + return this.each(function () { + let data = Data.getData(this, DATA_KEY) + const _config = typeof config === 'object' && config + + if (!data && /dispose|hide/.test(config)) { + return + } + + if (!data) { + data = new Tooltip(this, _config) + } + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError(`No method named "${config}"`) + } + + data[config]() + } + }) + } + + static _getInstance(element) { + return Data.getData(element, DATA_KEY) + } +} + +/** + * ------------------------------------------------------------------------ + * jQuery + * ------------------------------------------------------------------------ + * add .tooltip to jQuery only if jQuery is present + */ +/* istanbul ignore if */ +if (typeof $ !== 'undefined') { + const JQUERY_NO_CONFLICT = $.fn[NAME] + $.fn[NAME] = Tooltip._jQueryInterface + $.fn[NAME].Constructor = Tooltip + $.fn[NAME].noConflict = () => { + $.fn[NAME] = JQUERY_NO_CONFLICT + return Tooltip._jQueryInterface + } +} + +export default Tooltip diff --git a/js/src/tooltip/tooltip.spec.js b/js/src/tooltip/tooltip.spec.js new file mode 100644 index 0000000000..6f02dfa9b7 --- /dev/null +++ b/js/src/tooltip/tooltip.spec.js @@ -0,0 +1,1005 @@ +import Tooltip from './tooltip' +import EventHandler from '../dom/event-handler' +import { makeArray, noop } from '../util/index' + +/** Test helpers */ +import { getFixture, clearFixture, jQueryMock, createEvent } from '../../tests/helpers/fixture' + +describe('Tooltip', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + + const tooltipList = makeArray(document.querySelectorAll('.tooltip')) + + tooltipList.forEach(tooltipEl => { + document.body.removeChild(tooltipEl) + }) + }) + + describe('VERSION', () => { + it('should return plugin version', () => { + expect(Tooltip.VERSION).toEqual(jasmine.any(String)) + }) + }) + + describe('Default', () => { + it('should return plugin default config', () => { + expect(Tooltip.Default).toEqual(jasmine.any(Object)) + }) + }) + + describe('NAME', () => { + it('should return plugin name', () => { + expect(Tooltip.NAME).toEqual(jasmine.any(String)) + }) + }) + + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(Tooltip.DATA_KEY).toEqual('bs.tooltip') + }) + }) + + describe('Event', () => { + it('should return plugin events', () => { + expect(Tooltip.Event).toEqual(jasmine.any(Object)) + }) + }) + + describe('EVENT_KEY', () => { + it('should return plugin event key', () => { + expect(Tooltip.EVENT_KEY).toEqual('.bs.tooltip') + }) + }) + + describe('DefaultType', () => { + it('should return plugin default type', () => { + expect(Tooltip.DefaultType).toEqual(jasmine.any(Object)) + }) + }) + + describe('constructor', () => { + it('should not take care of disallowed data attributes', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" data-sanitize="false" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + expect(tooltip.config.sanitize).toEqual(true) + }) + + it('should convert title and content to string if numbers', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + title: 1, + content: 7 + }) + + expect(tooltip.config.title).toEqual('1') + expect(tooltip.config.content).toEqual('7') + }) + + it('should enable selector delegation', done => { + fixtureEl.innerHTML = '<div></div>' + + const containerEl = fixtureEl.querySelector('div') + const tooltipContainer = new Tooltip(containerEl, { + selector: 'a[rel="tooltip"]', + trigger: 'click' + }) + + containerEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipInContainerEl = containerEl.querySelector('a') + + tooltipInContainerEl.addEventListener('shown.bs.tooltip', () => { + expect(document.querySelector('.tooltip')).not.toBeNull() + tooltipContainer.dispose() + done() + }) + + tooltipInContainerEl.click() + }) + }) + + describe('enable', () => { + it('should enable a tooltip', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + tooltip.enable() + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + expect(document.querySelector('.tooltip')).toBeDefined() + done() + }) + + tooltip.show() + }) + }) + + describe('disable', () => { + it('should disable tooltip', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + tooltip.disable() + + tooltipEl.addEventListener('show.bs.tooltip', () => { + throw new Error('should not show a disabled tooltip') + }) + + tooltip.show() + + setTimeout(() => { + expect().nothing() + done() + }, 10) + }) + }) + + describe('toggleEnabled', () => { + it('should toggle enabled', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + expect(tooltip._isEnabled).toEqual(true) + + tooltip.toggleEnabled() + + expect(tooltip._isEnabled).toEqual(false) + }) + }) + + describe('toggle', () => { + it('should do nothing if disabled', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + tooltip.disable() + + tooltipEl.addEventListener('show.bs.tooltip', () => { + throw new Error('should not show a disabled tooltip') + }) + + tooltip.toggle() + + setTimeout(() => { + expect().nothing() + done() + }, 10) + }) + + it('should show a tooltip', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + expect(document.querySelector('.tooltip')).toBeDefined() + done() + }) + + tooltip.toggle() + }) + + it('should call toggle and show the tooltip when trigger is "click"', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + trigger: 'click' + }) + + spyOn(tooltip, 'toggle').and.callThrough() + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + expect(tooltip.toggle).toHaveBeenCalled() + done() + }) + + tooltipEl.click() + }) + + it('should hide a tooltip', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + tooltip.toggle() + }) + + tooltipEl.addEventListener('hidden.bs.tooltip', () => { + expect(document.querySelector('.tooltip')).toBeNull() + done() + }) + + tooltip.toggle() + }) + + it('should call toggle and hide the tooltip when trigger is "click"', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + trigger: 'click' + }) + + spyOn(tooltip, 'toggle').and.callThrough() + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + tooltipEl.click() + }) + + tooltipEl.addEventListener('hidden.bs.tooltip', () => { + expect(tooltip.toggle).toHaveBeenCalled() + done() + }) + + tooltipEl.click() + }) + }) + + describe('dispose', () => { + it('should destroy a tooltip', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + expect(Tooltip._getInstance(tooltipEl)).toEqual(tooltip) + + tooltip.dispose() + + expect(Tooltip._getInstance(tooltipEl)).toEqual(null) + }) + + it('should destroy a tooltip and remove it from the dom', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + expect(document.querySelector('.tooltip')).toBeDefined() + + tooltip.dispose() + + expect(document.querySelector('.tooltip')).toBeNull() + done() + }) + + tooltip.show() + }) + }) + + describe('show', () => { + it('should show a tooltip', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + const tooltipShown = document.querySelector('.tooltip') + + expect(tooltipShown).toBeDefined() + expect(tooltipEl.getAttribute('aria-describedby')).toEqual(tooltipShown.getAttribute('id')) + expect(tooltipShown.getAttribute('id').indexOf('tooltip') !== -1).toEqual(true) + done() + }) + + tooltip.show() + }) + + it('should show a tooltip on mobile', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + document.documentElement.ontouchstart = noop + + spyOn(EventHandler, 'on') + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + expect(document.querySelector('.tooltip')).not.toBeNull() + expect(EventHandler.on).toHaveBeenCalled() + document.documentElement.ontouchstart = undefined + done() + }) + + tooltip.show() + }) + + it('should show a tooltip relative to placement option', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + placement: 'bottom' + }) + + tooltipEl.addEventListener('inserted.bs.tooltip', () => { + expect(tooltip.getTipElement().classList.contains('bs-tooltip-bottom')).toEqual(true) + }) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + const tooltipShown = document.querySelector('.tooltip') + + expect(tooltipShown.classList.contains('bs-tooltip-bottom')).toEqual(true) + done() + }) + + tooltip.show() + }) + + it('should not error when trying to show a tooltip that has been removed from the dom', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + const firstCallback = () => { + tooltipEl.removeEventListener('shown.bs.tooltip', firstCallback) + let tooltipShown = document.querySelector('.tooltip') + + tooltipShown.parentNode.removeChild(tooltipShown) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + tooltipShown = document.querySelector('.tooltip') + + expect(tooltipShown).not.toBeNull() + done() + }) + + tooltip.show() + } + + tooltipEl.addEventListener('shown.bs.tooltip', firstCallback) + + tooltip.show() + }) + + it('should show a tooltip with a dom element container', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + container: fixtureEl + }) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + expect(fixtureEl.querySelector('.tooltip')).toBeDefined() + done() + }) + + tooltip.show() + }) + + it('should show a tooltip with a jquery element container', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + container: { + 0: fixtureEl, + jquery: 'jQuery' + } + }) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + expect(fixtureEl.querySelector('.tooltip')).toBeDefined() + done() + }) + + tooltip.show() + }) + + it('should show a tooltip with a selector in container', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + container: '#fixture' + }) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + expect(fixtureEl.querySelector('.tooltip')).toBeDefined() + done() + }) + + tooltip.show() + }) + + it('should show a tooltip with placement as a function', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const spy = jasmine.createSpy('placement').and.returnValue('top') + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + placement: spy + }) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + expect(document.querySelector('.tooltip')).toBeDefined() + expect(spy).toHaveBeenCalled() + done() + }) + + tooltip.show() + }) + + it('should show a tooltip with offset as a function', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const spy = jasmine.createSpy('offset').and.returnValue({}) + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + offset: spy + }) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + expect(document.querySelector('.tooltip')).toBeDefined() + expect(spy).toHaveBeenCalled() + done() + }) + + tooltip.show() + }) + + it('should show a tooltip without the animation', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + animation: false + }) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + const tip = document.querySelector('.tooltip') + + expect(tip).toBeDefined() + expect(tip.classList.contains('fade')).toEqual(false) + done() + }) + + tooltip.show() + }) + + it('should throw an error the element is not visible', () => { + fixtureEl.innerHTML = '<a href="#" style="display: none" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + try { + tooltip.show() + } catch (error) { + expect(error.message).toEqual('Please use show on visible elements') + } + }) + + it('should not show a tooltip if show.bs.tooltip is prevented', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + const expectedDone = () => { + setTimeout(() => { + expect(document.querySelector('.tooltip')).toBeNull() + done() + }, 10) + } + + tooltipEl.addEventListener('show.bs.tooltip', ev => { + ev.preventDefault() + expectedDone() + }) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + throw new Error('Tooltip should not be shown') + }) + + tooltip.show() + }) + + it('should show tooltip if leave event hasn\'t occurred before delay expires', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + delay: 150 + }) + + spyOn(tooltip, 'show') + + setTimeout(() => { + expect(tooltip.show).not.toHaveBeenCalled() + }, 100) + + setTimeout(() => { + expect(tooltip.show).toHaveBeenCalled() + done() + }, 200) + + tooltipEl.dispatchEvent(createEvent('mouseover')) + }) + + it('should not show tooltip if leave event occurs before delay expires', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + delay: 150 + }) + + spyOn(tooltip, 'show') + + setTimeout(() => { + expect(tooltip.show).not.toHaveBeenCalled() + tooltipEl.dispatchEvent(createEvent('mouseover')) + }, 100) + + setTimeout(() => { + expect(tooltip.show).toHaveBeenCalled() + expect(document.querySelectorAll('.tooltip').length).toEqual(0) + done() + }, 200) + + tooltipEl.dispatchEvent(createEvent('mouseover')) + }) + + it('should not hide tooltip if leave event occurs and enter event occurs within the hide delay', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + delay: { + show: 0, + hide: 150 + } + }) + + setTimeout(() => { + expect(tooltip.getTipElement().classList.contains('show')).toEqual(true) + tooltipEl.dispatchEvent(createEvent('mouseout')) + + setTimeout(() => { + expect(tooltip.getTipElement().classList.contains('show')).toEqual(true) + tooltipEl.dispatchEvent(createEvent('mouseover')) + }, 100) + + setTimeout(() => { + expect(tooltip.getTipElement().classList.contains('show')).toEqual(true) + done() + }, 200) + }, 0) + + tooltipEl.dispatchEvent(createEvent('mouseover')) + }) + }) + + describe('hide', () => { + it('should hide a tooltip', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + tooltipEl.addEventListener('shown.bs.tooltip', () => tooltip.hide()) + tooltipEl.addEventListener('hidden.bs.tooltip', () => { + expect(document.querySelector('.tooltip')).toBeNull() + expect(tooltipEl.getAttribute('aria-describedby')).toBeNull() + done() + }) + + tooltip.show() + }) + + it('should hide a tooltip on mobile', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + document.documentElement.ontouchstart = noop + spyOn(EventHandler, 'off') + tooltip.hide() + }) + + tooltipEl.addEventListener('hidden.bs.tooltip', () => { + expect(document.querySelector('.tooltip')).toBeNull() + expect(EventHandler.off).toHaveBeenCalled() + document.documentElement.ontouchstart = undefined + done() + }) + + tooltip.show() + }) + + it('should hide a tooltip without animation', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + animation: false + }) + + tooltipEl.addEventListener('shown.bs.tooltip', () => tooltip.hide()) + tooltipEl.addEventListener('hidden.bs.tooltip', () => { + expect(document.querySelector('.tooltip')).toBeNull() + expect(tooltipEl.getAttribute('aria-describedby')).toBeNull() + done() + }) + + tooltip.show() + }) + + it('should not hide a tooltip if hide event is prevented', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const assertDone = () => { + setTimeout(() => { + expect(document.querySelector('.tooltip')).not.toBeNull() + done() + }, 20) + } + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + animation: false + }) + + tooltipEl.addEventListener('shown.bs.tooltip', () => tooltip.hide()) + tooltipEl.addEventListener('hide.bs.tooltip', event => { + event.preventDefault() + assertDone() + }) + tooltipEl.addEventListener('hidden.bs.tooltip', () => { + throw new Error('should not trigger hidden event') + }) + + tooltip.show() + }) + }) + + describe('update', () => { + it('should call popper schedule update', done => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + tooltipEl.addEventListener('shown.bs.tooltip', () => { + spyOn(tooltip._popper, 'scheduleUpdate') + + tooltip.update() + + expect(tooltip._popper.scheduleUpdate).toHaveBeenCalled() + done() + }) + + tooltip.show() + }) + + it('should do nothing if the tooltip is not shown', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + tooltip.update() + expect().nothing() + }) + }) + + describe('isWithContent', () => { + it('should return true if there is content', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + expect(tooltip.isWithContent()).toEqual(true) + }) + + it('should return false if there is no content', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title=""/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + expect(tooltip.isWithContent()).toEqual(false) + }) + }) + + describe('getTipElement', () => { + it('should create the tip element and return it', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + spyOn(document, 'createElement').and.callThrough() + + expect(tooltip.getTipElement()).toBeDefined() + expect(document.createElement).toHaveBeenCalled() + }) + + it('should return the created tip element', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + const spy = spyOn(document, 'createElement').and.callThrough() + + expect(tooltip.getTipElement()).toBeDefined() + expect(spy).toHaveBeenCalled() + + spy.calls.reset() + + expect(tooltip.getTipElement()).toBeDefined() + expect(spy).not.toHaveBeenCalled() + }) + }) + + describe('setContent', () => { + it('should set tip content', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + tooltip.setContent() + + const tip = tooltip.getTipElement() + + expect(tip.classList.contains('show')).toEqual(false) + expect(tip.classList.contains('fade')).toEqual(false) + expect(tip.querySelector('.tooltip-inner').textContent).toEqual('Another tooltip') + }) + }) + + describe('setElementContent', () => { + it('should do nothing if the element is null', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + tooltip.setElementContent(null, null) + expect().nothing() + }) + + it('should add the content as a child of the element', () => { + fixtureEl.innerHTML = [ + '<a href="#" rel="tooltip" title="Another tooltip"/>', + '<div id="childContent"></div>' + ].join('') + + const tooltipEl = fixtureEl.querySelector('a') + const childContent = fixtureEl.querySelector('div') + const tooltip = new Tooltip(tooltipEl, { + html: true + }) + + tooltip.setElementContent(tooltip.getTipElement(), childContent) + + expect(childContent.parentNode).toEqual(tooltip.getTipElement()) + }) + + it('should do nothing if the content is a child of the element', () => { + fixtureEl.innerHTML = [ + '<a href="#" rel="tooltip" title="Another tooltip"/>', + '<div id="childContent"></div>' + ].join('') + + const tooltipEl = fixtureEl.querySelector('a') + const childContent = fixtureEl.querySelector('div') + const tooltip = new Tooltip(tooltipEl, { + html: true + }) + + tooltip.getTipElement().appendChild(childContent) + tooltip.setElementContent(tooltip.getTipElement(), childContent) + + expect().nothing() + }) + + it('should add the content as a child of the element for jQuery elements', () => { + fixtureEl.innerHTML = [ + '<a href="#" rel="tooltip" title="Another tooltip"/>', + '<div id="childContent"></div>' + ].join('') + + const tooltipEl = fixtureEl.querySelector('a') + const childContent = fixtureEl.querySelector('div') + const tooltip = new Tooltip(tooltipEl, { + html: true + }) + + tooltip.setElementContent(tooltip.getTipElement(), { 0: childContent, jquery: 'jQuery' }) + + expect(childContent.parentNode).toEqual(tooltip.getTipElement()) + }) + + it('should add the child text content in the element', () => { + fixtureEl.innerHTML = [ + '<a href="#" rel="tooltip" title="Another tooltip"/>', + '<div id="childContent">Tooltip</div>' + ].join('') + + const tooltipEl = fixtureEl.querySelector('a') + const childContent = fixtureEl.querySelector('div') + const tooltip = new Tooltip(tooltipEl) + + tooltip.setElementContent(tooltip.getTipElement(), childContent) + + expect(childContent.textContent).toEqual(tooltip.getTipElement().textContent) + }) + + it('should add html without sanitize it', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + sanitize: false, + html: true + }) + + tooltip.setElementContent(tooltip.getTipElement(), '<div id="childContent">Tooltip</div>') + + expect(tooltip.getTipElement().querySelector('div').id).toEqual('childContent') + }) + + it('should add html sanitized', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + html: true + }) + + tooltip.setElementContent(tooltip.getTipElement(), [ + '<div id="childContent">', + ' <button type="button">test btn</button>', + '</div>' + ].join('')) + + expect(tooltip.getTipElement().querySelector('div').id).toEqual('childContent') + expect(tooltip.getTipElement().querySelector('button')).toEqual(null) + }) + + it('should add text content', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + tooltip.setElementContent(tooltip.getTipElement(), 'test') + + expect(tooltip.getTipElement().innerText).toEqual('test') + }) + }) + + describe('getTitle', () => { + it('should return the title', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"/>' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + expect(tooltip.getTitle()).toEqual('Another tooltip') + }) + + it('should call title function', () => { + fixtureEl.innerHTML = '<a href="#" rel="tooltip" />' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + title: () => 'test' + }) + + expect(tooltip.getTitle()).toEqual('test') + }) + }) + + describe('_jQueryInterface', () => { + it('should create a tooltip', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + jQueryMock.fn.tooltip = Tooltip._jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.tooltip.call(jQueryMock) + + expect(Tooltip._getInstance(div)).toBeDefined() + }) + + it('should not re create a tooltip', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const tooltip = new Tooltip(div) + + jQueryMock.fn.tooltip = Tooltip._jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.tooltip.call(jQueryMock) + + expect(Tooltip._getInstance(div)).toEqual(tooltip) + }) + + it('should call a tooltip method', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const tooltip = new Tooltip(div) + + spyOn(tooltip, 'show') + + jQueryMock.fn.tooltip = Tooltip._jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.tooltip.call(jQueryMock, 'show') + + expect(Tooltip._getInstance(div)).toEqual(tooltip) + expect(tooltip.show).toHaveBeenCalled() + }) + + it('should do nothing when we call dispose or hide if there is no tooltip created', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + + spyOn(Tooltip.prototype, 'dispose') + + jQueryMock.fn.tooltip = Tooltip._jQueryInterface + jQueryMock.elements = [div] + + jQueryMock.fn.tooltip.call(jQueryMock, 'dispose') + + expect(Tooltip.prototype.dispose).not.toHaveBeenCalled() + }) + + it('should throw error on undefined method', () => { + fixtureEl.innerHTML = '<div></div>' + + const div = fixtureEl.querySelector('div') + const action = 'undefinedMethod' + + jQueryMock.fn.tooltip = Tooltip._jQueryInterface + jQueryMock.elements = [div] + + try { + jQueryMock.fn.tooltip.call(jQueryMock, action) + } catch (error) { + expect(error.message).toEqual(`No method named "${action}"`) + } + }) + }) +}) |