UNPKG

bootstrap-vue

Version:

BootstrapVue provides one of the most comprehensive implementations of Bootstrap 4 components and grid system for Vue.js and with extensive and automated WAI-ARIA accessibility markup.

975 lines (884 loc) 27.1 kB
import Popper from 'popper.js' import BvEvent from './bv-event.class' import { assign } from './object' import { from as arrayFrom } from './array' import { closest, select, isVisible, isDisabled, getCS, addClass, removeClass, hasClass, setAttr, removeAttr, getAttr, eventOn, eventOff } from './dom' const NAME = 'tooltip' const CLASS_PREFIX = 'bs-tooltip' const BSCLS_PREFIX_REGEX = new RegExp(`\\b${CLASS_PREFIX}\\S+`, 'g') const TRANSITION_DURATION = 150 // Modal $root hidden event const MODAL_CLOSE_EVENT = 'bv::modal::hidden' // Modal container for appending tip/popover const MODAL_CLASS = '.modal-content' const AttachmentMap = { AUTO: 'auto', TOP: 'top', RIGHT: 'right', BOTTOM: 'bottom', LEFT: 'left', TOPLEFT: 'top', TOPRIGHT: 'top', RIGHTTOP: 'right', RIGHTBOTTOM: 'right', BOTTOMLEFT: 'bottom', BOTTOMRIGHT: 'bottom', LEFTTOP: 'left', LEFTBOTTOM: 'left' } const OffsetMap = { AUTO: 0, TOPLEFT: -1, TOP: 0, TOPRIGHT: +1, RIGHTTOP: -1, RIGHT: 0, RIGHTBOTTOM: +1, BOTTOMLEFT: -1, BOTTOM: 0, BOTTOMRIGHT: +1, LEFTTOP: -1, LEFT: 0, LEFTBOTTOM: +1 } const HoverState = { SHOW: 'show', OUT: 'out' } const ClassName = { FADE: 'fade', SHOW: 'show' } const Selector = { TOOLTIP: '.tooltip', TOOLTIP_INNER: '.tooltip-inner', ARROW: '.arrow' } // ESLINT: Not used // const Trigger = { // HOVER: 'hover', // FOCUS: 'focus', // CLICK: 'click', // BLUR: 'blur', // MANUAL: 'manual' // } const Defaults = { animation: true, template: '<div class="tooltip" role="tooltip">' + '<div class="arrow"></div>' + '<div class="tooltip-inner"></div>' + '</div>', trigger: 'hover focus', title: '', delay: 0, html: false, placement: 'top', offset: 0, arrowPadding: 6, container: false, fallbackPlacement: 'flip', callbacks: {}, boundary: 'scrollParent' } // Transition Event names const TransitionEndEvents = { WebkitTransition: ['webkitTransitionEnd'], MozTransition: ['transitionend'], OTransition: ['otransitionend', 'oTransitionEnd'], transition: ['transitionend'] } // Client Side Tip ID counter for aria-describedby attribute // Could use Alex's uid generator util // Each tooltip requires a unique client side ID let NEXTID = 1 /* istanbul ignore next */ function generateId (name) { return `__BV_${name}_${NEXTID++}__` } /* * ToolTip Class definition */ /* istanbul ignore next: difficult to test in Jest/JSDOM environment */ class ToolTip { // Main constructor constructor (element, config, $root) { // New tooltip object this.$isEnabled = true this.$fadeTimeout = null this.$hoverTimeout = null this.$visibleInterval = null this.$hoverState = '' this.$activeTrigger = {} this.$popper = null this.$element = element this.$tip = null this.$id = generateId(this.constructor.NAME) this.$root = $root || null this.$routeWatcher = null // We use a bound version of the following handlers for root/modal listeners to maintain the 'this' context this.$forceHide = this.forceHide.bind(this) this.$doHide = this.doHide.bind(this) this.$doShow = this.doShow.bind(this) this.$doDisable = this.doDisable.bind(this) this.$doEnable = this.doEnable.bind(this) // Set the configuration this.updateConfig(config) } // NOTE: Overridden by PopOver class static get Default () { return Defaults } // NOTE: Overridden by PopOver class static get NAME () { return NAME } // Update config updateConfig (config) { // Merge config into defaults. We use "this" here because PopOver overrides Default let updatedConfig = assign({}, this.constructor.Default, config) // Sanitize delay if (config.delay && typeof config.delay === 'number') { updatedConfig.delay = { show: config.delay, hide: config.delay } } // Title for tooltip and popover if (config.title && typeof config.title === 'number') { updatedConfig.title = config.title.toString() } // Content only for popover if (config.content && typeof config.content === 'number') { updatedConfig.content = config.content.toString() } // Hide element original title if needed this.fixTitle() // Update the config this.$config = updatedConfig // Stop/Restart listening this.unListen() this.listen() } // Destroy this instance destroy () { // Stop listening to trigger events this.unListen() // Disable while open listeners/watchers this.setWhileOpenListeners(false) // Clear any timouts clearTimeout(this.$hoverTimeout) this.$hoverTimeout = null clearTimeout(this.$fadeTimeout) this.$fadeTimeout = null // Remove popper if (this.$popper) { this.$popper.destroy() } this.$popper = null // Remove tip from document if (this.$tip && this.$tip.parentElement) { this.$tip.parentElement.removeChild(this.$tip) } this.$tip = null // Null out other properties this.$id = null this.$isEnabled = null this.$root = null this.$element = null this.$config = null this.$hoverState = null this.$activeTrigger = null this.$forceHide = null this.$doHide = null this.$doShow = null this.$doDisable = null this.$doEnable = null } enable () { // Create a non-cancelable BvEvent const enabledEvt = new BvEvent('enabled', { cancelable: false, target: this.$element, relatedTarget: null }) this.$isEnabled = true this.emitEvent(enabledEvt) } disable () { // Create a non-cancelable BvEvent const disabledEvt = new BvEvent('disabled', { cancelable: false, target: this.$element, relatedTarget: null }) this.$isEnabled = false this.emitEvent(disabledEvt) } // Click toggler toggle (event) { if (!this.$isEnabled) { return } if (event) { this.$activeTrigger.click = !this.$activeTrigger.click if (this.isWithActiveTrigger()) { this.enter(null) } else { this.leave(null) } } else { if (hasClass(this.getTipElement(), ClassName.SHOW)) { this.leave(null) } else { this.enter(null) } } } // Show tooltip show () { if (!document.body.contains(this.$element) || !isVisible(this.$element)) { // If trigger element isn't in the DOM or is not visible return } // Build tooltip element (also sets this.$tip) const tip = this.getTipElement() this.fixTitle() this.setContent(tip) if (!this.isWithContent(tip)) { // if No content, don't bother showing this.$tip = null return } // Set ID on tip and aria-describedby on element setAttr(tip, 'id', this.$id) this.addAriaDescribedby() // Set animation on or off if (this.$config.animation) { addClass(tip, ClassName.FADE) } else { removeClass(tip, ClassName.FADE) } const placement = this.getPlacement() const attachment = this.constructor.getAttachment(placement) this.addAttachmentClass(attachment) // Create a cancelable BvEvent const showEvt = new BvEvent('show', { cancelable: true, target: this.$element, relatedTarget: tip }) this.emitEvent(showEvt) if (showEvt.defaultPrevented) { // Don't show if event cancelled this.$tip = null return } // Insert tooltip if needed const container = this.getContainer() if (!document.body.contains(tip)) { container.appendChild(tip) } // Refresh popper this.removePopper() this.$popper = new Popper(this.$element, tip, this.getPopperConfig(placement, tip)) // Transitionend Callback const complete = () => { if (this.$config.animation) { this.fixTransition(tip) } const prevHoverState = this.$hoverState this.$hoverState = null if (prevHoverState === HoverState.OUT) { this.leave(null) } // Create a non-cancelable BvEvent const shownEvt = new BvEvent('shown', { cancelable: false, target: this.$element, relatedTarget: tip }) this.emitEvent(shownEvt) } // Enable while open listeners/watchers this.setWhileOpenListeners(true) // Show tip addClass(tip, ClassName.SHOW) // Start the transition/animation this.transitionOnce(tip, complete) } // handler for periodic visibility check visibleCheck (on) { clearInterval(this.$visibleInterval) this.$visibleInterval = null if (on) { this.$visibleInterval = setInterval(() => { const tip = this.getTipElement() if (tip && !isVisible(this.$element) && hasClass(tip, ClassName.SHOW)) { // Element is no longer visible, so force-hide the tooltip this.forceHide() } }, 100) } } setWhileOpenListeners (on) { // Modal close events this.setModalListener(on) // Periodic $element visibility check // For handling when tip is in <keepalive>, tabs, carousel, etc this.visibleCheck(on) // Route change events this.setRouteWatcher(on) // Ontouch start listeners this.setOnTouchStartListener(on) if (on && /(focus|blur)/.test(this.$config.trigger)) { // If focus moves between trigger element and tip container, dont close eventOn(this.$tip, 'focusout', this) } else { eventOff(this.$tip, 'focusout', this) } } // force hide of tip (internal method) forceHide () { if (!this.$tip || !hasClass(this.$tip, ClassName.SHOW)) { return } // Disable while open listeners/watchers this.setWhileOpenListeners(false) // Clear any hover enter/leave event clearTimeout(this.$hoverTimeout) this.$hoverTimeout = null this.$hoverState = '' // Hide the tip this.hide(null, true) } // Hide tooltip hide (callback, force) { const tip = this.$tip if (!tip) { return } // Create a canelable BvEvent const hideEvt = new BvEvent('hide', { // We disable cancelling if force is true cancelable: !force, target: this.$element, relatedTarget: tip }) this.emitEvent(hideEvt) if (hideEvt.defaultPrevented) { // Don't hide if event cancelled return } // Transitionend Callback /* istanbul ignore next */ const complete = () => { if (this.$hoverState !== HoverState.SHOW && tip.parentNode) { // Remove tip from dom, and force recompile on next show tip.parentNode.removeChild(tip) this.removeAriaDescribedby() this.removePopper() this.$tip = null } if (callback) { callback() } // Create a non-cancelable BvEvent const hiddenEvt = new BvEvent('hidden', { cancelable: false, target: this.$element, relatedTarget: null }) this.emitEvent(hiddenEvt) } // Disable while open listeners/watchers this.setWhileOpenListeners(false) // If forced close, disable animation if (force) { removeClass(tip, ClassName.FADE) } // Hide tip removeClass(tip, ClassName.SHOW) this.$activeTrigger.click = false this.$activeTrigger.focus = false this.$activeTrigger.hover = false // Start the hide transition this.transitionOnce(tip, complete) this.$hoverState = '' } emitEvent (evt) { const evtName = evt.type if (this.$root && this.$root.$emit) { // Emit an event on $root this.$root.$emit(`bv::${this.constructor.NAME}::${evtName}`, evt) } const callbacks = this.$config.callbacks || {} if (typeof callbacks[evtName] === 'function') { callbacks[evtName](evt) } } getContainer () { const container = this.$config.container const body = document.body // If we are in a modal, we append to the modal instead of body, unless a container is specified return container === false ? (closest(MODAL_CLASS, this.$element) || body) : (select(container, body) || body) } // Will be overritten by popover if needed addAriaDescribedby () { // Add aria-describedby on trigger element, without removing any other IDs let desc = getAttr(this.$element, 'aria-describedby') || '' desc = desc.split(/\s+/).concat(this.$id).join(' ').trim() setAttr(this.$element, 'aria-describedby', desc) } // Will be overritten by popover if needed removeAriaDescribedby () { let desc = getAttr(this.$element, 'aria-describedby') || '' desc = desc.split(/\s+/).filter(d => d !== this.$id).join(' ').trim() if (desc) { setAttr(this.$element, 'aria-describedby', desc) } else { removeAttr(this.$element, 'aria-describedby') } } removePopper () { if (this.$popper) { this.$popper.destroy() } this.$popper = null } /* istanbul ignore next */ transitionOnce (tip, complete) { const transEvents = this.getTransitionEndEvents() let called = false clearTimeout(this.$fadeTimeout) this.$fadeTimeout = null const fnOnce = () => { if (called) { return } called = true clearTimeout(this.$fadeTimeout) this.$fadeTimeout = null transEvents.forEach(evtName => { eventOff(tip, evtName, fnOnce) }) // Call complete callback complete() } if (hasClass(tip, ClassName.FADE)) { transEvents.forEach(evtName => { eventOn(tip, evtName, fnOnce) }) // Fallback to setTimeout this.$fadeTimeout = setTimeout(fnOnce, TRANSITION_DURATION) } else { fnOnce() } } // What transitionend event(s) to use? (returns array of event names) getTransitionEndEvents () { for (const name in TransitionEndEvents) { if (this.$element.style[name] !== undefined) { return TransitionEndEvents[name] } } // fallback return [] } update () { if (this.$popper !== null) { this.$popper.scheduleUpdate() } } // NOTE: Overridden by PopOver class isWithContent (tip) { tip = tip || this.$tip if (!tip) { return false } return Boolean((select(Selector.TOOLTIP_INNER, tip) || {}).innerHTML) } // NOTE: Overridden by PopOver class addAttachmentClass (attachment) { addClass(this.getTipElement(), `${CLASS_PREFIX}-${attachment}`) } getTipElement () { if (!this.$tip) { // Try and compile user supplied template, or fallback to default template this.$tip = this.compileTemplate(this.$config.template) || this.compileTemplate(this.constructor.Default.template) } // Add tab index so tip can be focused, and to allow it to be set as relatedTargt in focusin/out events this.$tip.tabIndex = -1 return this.$tip } compileTemplate (html) { if (!html || typeof html !== 'string') { return null } let div = document.createElement('div') div.innerHTML = html.trim() const node = div.firstElementChild ? div.removeChild(div.firstElementChild) : null div = null return node } // NOTE: Overridden by PopOver class setContent (tip) { this.setElementContent(select(Selector.TOOLTIP_INNER, tip), this.getTitle()) removeClass(tip, ClassName.FADE) removeClass(tip, ClassName.SHOW) } setElementContent (container, content) { if (!container) { // If container element doesn't exist, just return return } const allowHtml = this.$config.html if (typeof content === 'object' && content.nodeType) { // content is a DOM node if (allowHtml) { if (content.parentElement !== container) { container.innerHtml = '' container.appendChild(content) } } else { container.innerText = content.innerText } } else { // We have a plain HTML string or Text container[allowHtml ? 'innerHTML' : 'innerText'] = content } } // NOTE: Overridden by PopOver class getTitle () { let title = this.$config.title || '' if (typeof title === 'function') { // Call the function to get the title value title = title(this.$element) } if (typeof title === 'object' && title.nodeType && !title.innerHTML.trim()) { // We have a DOM node, but without inner content, so just return empty string title = '' } if (typeof title === 'string') { title = title.trim() } if (!title) { // If an explicit title is not given, try element's title atributes title = getAttr(this.$element, 'title') || getAttr(this.$element, 'data-original-title') || '' title = title.trim() } return title } static getAttachment (placement) { return AttachmentMap[placement.toUpperCase()] } listen () { const triggers = this.$config.trigger.trim().split(/\s+/) const el = this.$element // Listen for global show/hide events this.setRootListener(true) // Using 'this' as the handler will get automagically directed to this.handleEvent // And maintain our binding to 'this' triggers.forEach(trigger => { if (trigger === 'click') { eventOn(el, 'click', this) } else if (trigger === 'focus') { eventOn(el, 'focusin', this) eventOn(el, 'focusout', this) } else if (trigger === 'blur') { // Used to close $tip when element looses focus eventOn(el, 'focusout', this) } else if (trigger === 'hover') { eventOn(el, 'mouseenter', this) eventOn(el, 'mouseleave', this) } }, this) } unListen () { const events = ['click', 'focusin', 'focusout', 'mouseenter', 'mouseleave'] // Using "this" as the handler will get automagically directed to this.handleEvent events.forEach(evt => { eventOff(this.$element, evt, this) }, this) // Stop listening for global show/hide/enable/disable events this.setRootListener(false) } handleEvent (e) { // This special method allows us to use "this" as the event handlers if (isDisabled(this.$element)) { // If disabled, don't do anything. Note: if tip is shown before element gets // disabled, then tip not close until no longer disabled or forcefully closed. return } if (!this.$isEnabled) { // If not enable return } const type = e.type const target = e.target const relatedTarget = e.relatedTarget const $element = this.$element const $tip = this.$tip if (type === 'click') { this.toggle(e) } else if (type === 'focusin' || type === 'mouseenter') { this.enter(e) } else if (type === 'focusout') { // target is the element which is loosing focus // And relatedTarget is the element gaining focus if ($tip && $element && $element.contains(target) && $tip.contains(relatedTarget)) { // If focus moves from $element to $tip, don't trigger a leave return } if ($tip && $element && $tip.contains(target) && $element.contains(relatedTarget)) { // If focus moves from $tip to $element, don't trigger a leave return } if ($tip && $tip.contains(target) && $tip.contains(relatedTarget)) { // If focus moves within $tip, don't trigger a leave return } if ($element && $element.contains(target) && $element.contains(relatedTarget)) { // If focus moves within $element, don't trigger a leave return } // Otherwise trigger a leave this.leave(e) } else if (type === 'mouseleave') { this.leave(e) } } /* istanbul ignore next */ setRouteWatcher (on) { if (on) { this.setRouteWatcher(false) if (this.$root && Boolean(this.$root.$route)) { this.$routeWatcher = this.$root.$watch('$route', (newVal, oldVal) => { if (newVal === oldVal) { return } // If route has changed, we force hide the tooltip/popover this.forceHide() }) } } else { if (this.$routeWatcher) { // cancel the route watcher by calling hte stored reference this.$routeWatcher() this.$routeWatcher = null } } } /* istanbul ignore next */ setModalListener (on) { const modal = closest(MODAL_CLASS, this.$element) if (!modal) { // If we are not in a modal, don't worry. be happy return } // We can listen for modal hidden events on $root if (this.$root) { this.$root[on ? '$on' : '$off'](MODAL_CLOSE_EVENT, this.$forceHide) } } /* istanbul ignore next */ setRootListener (on) { // Listen for global 'bv::{hide|show}::{tooltip|popover}' hide request event if (this.$root) { this.$root[on ? '$on' : '$off'](`bv::hide::${this.constructor.NAME}`, this.$doHide) this.$root[on ? '$on' : '$off'](`bv::show::${this.constructor.NAME}`, this.$doShow) this.$root[on ? '$on' : '$off'](`bv::disable::${this.constructor.NAME}`, this.$doDisable) this.$root[on ? '$on' : '$off'](`bv::enable::${this.constructor.NAME}`, this.$doEnable) } } doHide (id) { // Programmatically hide tooltip or popover if (!id) { // Close all tooltips or popovers this.forceHide() } else if (this.$element && this.$element.id && this.$element.id === id) { // Close this specific tooltip or popover this.hide() } } doShow (id) { // Programmatically show tooltip or popover if (!id) { // Open all tooltips or popovers this.show() } else if (id && this.$element && this.$element.id && this.$element.id === id) { // Show this specific tooltip or popover this.show() } } doDisable (id) { // Programmatically disable tooltip or popover if (!id) { // Disable all tooltips or popovers this.disable() } else if (this.$element && this.$element.id && this.$element.id === id) { // Disable this specific tooltip or popover this.disable() } } doEnable (id) { // Programmatically enable tooltip or popover if (!id) { // Enable all tooltips or popovers this.enable() } else if (this.$element && this.$element.id && this.$element.id === id) { // Enable this specific tooltip or popover this.enable() } } /* istanbul ignore next */ setOnTouchStartListener (on) { // 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) { arrayFrom(document.body.children).forEach(el => { if (on) { eventOn(el, 'mouseover', this._noop) } else { eventOff(el, 'mouseover', this._noop) } }) } } /* istanbul ignore next */ _noop () { // Empty noop handler for ontouchstart devices } fixTitle () { const el = this.$element const titleType = typeof getAttr(el, 'data-original-title') if (getAttr(el, 'title') || titleType !== 'string') { setAttr(el, 'data-original-title', getAttr(el, 'title') || '') setAttr(el, 'title', '') } } // Enter handler /* istanbul ignore next */ enter (e) { if (e) { this.$activeTrigger[e.type === 'focusin' ? 'focus' : 'hover'] = true } if (hasClass(this.getTipElement(), ClassName.SHOW) || this.$hoverState === HoverState.SHOW) { this.$hoverState = HoverState.SHOW return } clearTimeout(this.$hoverTimeout) this.$hoverState = HoverState.SHOW if (!this.$config.delay || !this.$config.delay.show) { this.show() return } this.$hoverTimeout = setTimeout(() => { if (this.$hoverState === HoverState.SHOW) { this.show() } }, this.$config.delay.show) } // Leave handler /* istanbul ignore next */ leave (e) { if (e) { this.$activeTrigger[e.type === 'focusout' ? 'focus' : 'hover'] = false if (e.type === 'focusout' && /blur/.test(this.$config.trigger)) { // Special case for `blur`: we clear out the other triggers this.$activeTrigger.click = false this.$activeTrigger.hover = false } } if (this.isWithActiveTrigger()) { return } clearTimeout(this.$hoverTimeout) this.$hoverState = HoverState.OUT if (!this.$config.delay || !this.$config.delay.hide) { this.hide() return } this.$hoverTimeout = setTimeout(() => { if (this.$hoverState === HoverState.OUT) { this.hide() } }, this.$config.delay.hide) } getPopperConfig (placement, tip) { return { placement: this.constructor.getAttachment(placement), modifiers: { offset: { offset: this.getOffset(placement, tip) }, flip: { behavior: this.$config.fallbackPlacement }, arrow: { element: '.arrow' }, preventOverflow: { boundariesElement: this.$config.boundary } }, onCreate: data => { // Handle flipping arrow classes if (data.originalPlacement !== data.placement) { this.handlePopperPlacementChange(data) } }, onUpdate: data => { // Handle flipping arrow classes this.handlePopperPlacementChange(data) } } } getOffset (placement, tip) { if (!this.$config.offset) { const arrow = select(Selector.ARROW, tip) const arrowOffset = parseFloat(getCS(arrow).width) + parseFloat(this.$config.arrowPadding) switch (OffsetMap[placement.toUpperCase()]) { case +1: return `+50%p - ${arrowOffset}px` case -1: return `-50%p + ${arrowOffset}px` default: return 0 } } return parseFloat(this.$config.offset) } getPlacement () { const placement = this.$config.placement if (typeof placement === 'function') { return placement.call(this, this.$tip, this.$element) } return placement } isWithActiveTrigger () { for (const trigger in this.$activeTrigger) { if (this.$activeTrigger[trigger]) { return true } } return false } // NOTE: Overridden by PopOver class cleanTipClass () { const tip = this.getTipElement() const tabClass = tip.className.match(BSCLS_PREFIX_REGEX) if (tabClass !== null && tabClass.length > 0) { tabClass.forEach(cls => { removeClass(tip, cls) }) } } handlePopperPlacementChange (data) { this.cleanTipClass() this.addAttachmentClass(this.constructor.getAttachment(data.placement)) } fixTransition (tip) { const initConfigAnimation = this.$config.animation || false if (getAttr(tip, 'x-placement') !== null) { return } removeClass(tip, ClassName.FADE) this.$config.animation = false this.hide() this.show() this.$config.animation = initConfigAnimation } } export default ToolTip