From 85b70cef3cc2b76978274521f0601a5bc1e4f852 Mon Sep 17 00:00:00 2001 From: Johann-S Date: Fri, 10 May 2019 21:57:27 +0200 Subject: rewrite tooltip unit tests --- js/src/popover/popover.js | 2 +- js/src/popover/popover.spec.js | 4 +- js/src/tooltip.js | 824 -------------------------------- js/src/tooltip/tooltip.js | 818 ++++++++++++++++++++++++++++++++ js/src/tooltip/tooltip.spec.js | 1005 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1826 insertions(+), 827 deletions(-) delete mode 100644 js/src/tooltip.js create mode 100644 js/src/tooltip/tooltip.js create mode 100644 js/src/tooltip/tooltip.spec.js (limited to 'js/src') diff --git a/js/src/popover/popover.js b/js/src/popover/popover.js index 3a55083c4..c357a6e52 100644 --- a/js/src/popover/popover.js +++ b/js/src/popover/popover.js @@ -8,7 +8,7 @@ import { jQuery as $ } from '../util/index' import Data from '../dom/data' import SelectorEngine from '../dom/selector-engine' -import Tooltip from '../tooltip' +import Tooltip from '../tooltip/tooltip' /** * ------------------------------------------------------------------------ diff --git a/js/src/popover/popover.spec.js b/js/src/popover/popover.spec.js index cb16bf271..003c0168f 100644 --- a/js/src/popover/popover.spec.js +++ b/js/src/popover/popover.spec.js @@ -16,8 +16,8 @@ describe('Popover', () => { const popoverList = makeArray(document.querySelectorAll('.popover')) - popoverList.forEach(backdrop => { - document.body.removeChild(backdrop) + popoverList.forEach(popoverEl => { + document.body.removeChild(popoverEl) }) }) diff --git a/js/src/tooltip.js b/js/src/tooltip.js deleted file mode 100644 index 6cca7d75a..000000000 --- a/js/src/tooltip.js +++ /dev/null @@ -1,824 +0,0 @@ -/** - * -------------------------------------------------------------------------- - * 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: '', - 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(callback) { - 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) - if (this._popper !== null) { - this._popper.destroy() - } - - if (callback) { - callback() - } - } - - 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()) - } - - addAttachmentClass(attachment) { - this.getTipElement().classList.add(`${CLASS_PREFIX}-${attachment}`) - } - - 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' && (content.nodeType || content.jquery)) { - 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 - - _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 - */ - -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.js b/js/src/tooltip/tooltip.js new file mode 100644 index 000000000..1db233e79 --- /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: '', + 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 000000000..6f02dfa9b --- /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 = '' + + 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 = '' + + 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 = '
' + + const containerEl = fixtureEl.querySelector('div') + const tooltipContainer = new Tooltip(containerEl, { + selector: 'a[rel="tooltip"]', + trigger: 'click' + }) + + containerEl.innerHTML = '
' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = [ + '', + '
' + ].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 = [ + '
', + '
' + ].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 = [ + '
', + '
' + ].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 = [ + '
', + '
Tooltip
' + ].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 = '
' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + sanitize: false, + html: true + }) + + tooltip.setElementContent(tooltip.getTipElement(), '
Tooltip
') + + expect(tooltip.getTipElement().querySelector('div').id).toEqual('childContent') + }) + + it('should add html sanitized', () => { + fixtureEl.innerHTML = '
' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl, { + html: true + }) + + tooltip.setElementContent(tooltip.getTipElement(), [ + '
', + ' ', + '
' + ].join('')) + + expect(tooltip.getTipElement().querySelector('div').id).toEqual('childContent') + expect(tooltip.getTipElement().querySelector('button')).toEqual(null) + }) + + it('should add text content', () => { + fixtureEl.innerHTML = '
' + + 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 = '' + + const tooltipEl = fixtureEl.querySelector('a') + const tooltip = new Tooltip(tooltipEl) + + expect(tooltip.getTitle()).toEqual('Another tooltip') + }) + + it('should call title function', () => { + fixtureEl.innerHTML = '' + + 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 = '
' + + 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 = '
' + + 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 = '
' + + 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 = '
' + + 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 = '
' + + 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}"`) + } + }) + }) +}) -- cgit v1.2.3