bootstrap-vue
Version:
With more than 85 components, over 45 available plugins, several directives, and 1000+ icons, BootstrapVue provides one of the most comprehensive implementations of the Bootstrap v4 component and grid system available for Vue.js v2.6, complete with extens
982 lines (962 loc) • 34.4 kB
JavaScript
// Tooltip "Class" (Built as a renderless Vue instance)
//
// Handles trigger events, etc.
// Instantiates template on demand
import { COMPONENT_UID_KEY, extend } from '../../../vue'
import { NAME_MODAL, NAME_TOOLTIP_HELPER } from '../../../constants/components'
import {
EVENT_NAME_DISABLE,
EVENT_NAME_DISABLED,
EVENT_NAME_ENABLE,
EVENT_NAME_ENABLED,
EVENT_NAME_FOCUSIN,
EVENT_NAME_FOCUSOUT,
EVENT_NAME_HIDDEN,
EVENT_NAME_HIDE,
EVENT_NAME_MOUSEENTER,
EVENT_NAME_MOUSELEAVE,
EVENT_NAME_SHOW,
EVENT_NAME_SHOWN,
EVENT_OPTIONS_NO_CAPTURE,
HOOK_EVENT_NAME_BEFORE_DESTROY,
HOOK_EVENT_NAME_DESTROYED
} from '../../../constants/events'
import { useParentMixin } from '../../../mixins/use-parent'
import { arrayIncludes, concat, from as arrayFrom } from '../../../utils/array'
import { getInstanceFromElement } from '../../../utils/element-to-vue-instance-registry'
import {
attemptFocus,
closest,
contains,
getAttr,
getById,
hasAttr,
hasClass,
isDisabled,
isElement,
isVisible,
removeAttr,
requestAF,
select,
setAttr
} from '../../../utils/dom'
import {
eventOff,
eventOn,
eventOnOff,
getRootActionEventName,
getRootEventName
} from '../../../utils/events'
import { getScopeId } from '../../../utils/get-scope-id'
import { identity } from '../../../utils/identity'
import {
isFunction,
isNumber,
isPlainObject,
isString,
isUndefined,
isUndefinedOrNull
} from '../../../utils/inspect'
import { looseEqual } from '../../../utils/loose-equal'
import { mathMax } from '../../../utils/math'
import { noop } from '../../../utils/noop'
import { toInteger } from '../../../utils/number'
import { keys } from '../../../utils/object'
import { warn } from '../../../utils/warn'
import { BvEvent } from '../../../utils/bv-event.class'
import { createNewChildComponent } from '../../../utils/create-new-child-component'
import { listenOnRootMixin } from '../../../mixins/listen-on-root'
import { BVTooltipTemplate } from './bv-tooltip-template'
// --- Constants ---
// Modal container selector for appending tooltip/popover
const MODAL_SELECTOR = '.modal-content'
// Modal `$root` hidden event
const ROOT_EVENT_NAME_MODAL_HIDDEN = getRootEventName(NAME_MODAL, EVENT_NAME_HIDDEN)
// Sidebar container selector for appending tooltip/popover
const SIDEBAR_SELECTOR = '.b-sidebar'
// For finding the container to append to
const CONTAINER_SELECTOR = [MODAL_SELECTOR, SIDEBAR_SELECTOR].join(', ')
// For dropdown sniffing
const DROPDOWN_CLASS = 'dropdown'
const DROPDOWN_OPEN_SELECTOR = '.dropdown-menu.show'
// Data attribute to temporary store the `title` attribute's value
const DATA_TITLE_ATTR = 'data-original-title'
// Data specific to popper and template
// We don't use props, as we need reactivity (we can't pass reactive props)
const templateData = {
// Text string or Scoped slot function
title: '',
// Text string or Scoped slot function
content: '',
// String
variant: null,
// String, Array, Object
customClass: null,
// String or array of Strings (overwritten by BVPopper)
triggers: '',
// String (overwritten by BVPopper)
placement: 'auto',
// String or array of strings
fallbackPlacement: 'flip',
// Element or Component reference (or function that returns element) of
// the element that will have the trigger events bound, and is also
// default element for positioning
target: null,
// HTML ID, Element or Component reference
container: null, // 'body'
// Boolean
noFade: false,
// 'scrollParent', 'viewport', 'window', Element, or Component reference
boundary: 'scrollParent',
// Tooltip/popover will try and stay away from
// boundary edge by this many pixels (Number)
boundaryPadding: 5,
// Arrow offset (Number)
offset: 0,
// Hover/focus delay (Number or Object)
delay: 0,
// Arrow of Tooltip/popover will try and stay away from
// the edge of tooltip/popover edge by this many pixels
arrowPadding: 6,
// Interactive state (Boolean)
interactive: true,
// Disabled state (Boolean)
disabled: false,
// ID to use for tooltip/popover
id: null,
// Flag used by directives only, for HTML content
html: false
}
// --- Main component ---
// @vue/component
export const BVTooltip = /*#__PURE__*/ extend({
name: NAME_TOOLTIP_HELPER,
mixins: [listenOnRootMixin, useParentMixin],
data() {
return {
// BTooltip/BPopover/VBTooltip/VBPopover will update this data
// Via the exposed updateData() method on this instance
// BVPopover will override some of these defaults
...templateData,
// State management data
activeTrigger: {
// manual: false,
hover: false,
click: false,
focus: false
},
localShow: false
}
},
computed: {
templateType() {
// Overwritten by BVPopover
return 'tooltip'
},
computedId() {
return this.id || `__bv_${this.templateType}_${this[COMPONENT_UID_KEY]}__`
},
computedDelay() {
// Normalizes delay into object form
const delay = { show: 0, hide: 0 }
if (isPlainObject(this.delay)) {
delay.show = mathMax(toInteger(this.delay.show, 0), 0)
delay.hide = mathMax(toInteger(this.delay.hide, 0), 0)
} else if (isNumber(this.delay) || isString(this.delay)) {
delay.show = delay.hide = mathMax(toInteger(this.delay, 0), 0)
}
return delay
},
computedTriggers() {
// Returns the triggers in sorted array form
// TODO: Switch this to object form for easier lookup
return concat(this.triggers)
.filter(identity)
.join(' ')
.trim()
.toLowerCase()
.split(/\s+/)
.sort()
},
isWithActiveTrigger() {
for (const trigger in this.activeTrigger) {
if (this.activeTrigger[trigger]) {
return true
}
}
return false
},
computedTemplateData() {
const { title, content, variant, customClass, noFade, interactive } = this
return { title, content, variant, customClass, noFade, interactive }
}
},
watch: {
computedTriggers(newTriggers, oldTriggers) {
// Triggers have changed, so re-register them
/* istanbul ignore next */
if (!looseEqual(newTriggers, oldTriggers)) {
this.$nextTick(() => {
// Disable trigger listeners
this.unListen()
// Clear any active triggers that are no longer in the list of triggers
oldTriggers.forEach(trigger => {
if (!arrayIncludes(newTriggers, trigger)) {
if (this.activeTrigger[trigger]) {
this.activeTrigger[trigger] = false
}
}
})
// Re-enable the trigger listeners
this.listen()
})
}
},
computedTemplateData() {
// If any of the while open reactive "props" change,
// ensure that the template updates accordingly
this.handleTemplateUpdate()
},
title(newValue, oldValue) {
// Make sure to hide the tooltip when the title is set empty
if (newValue !== oldValue && !newValue) {
this.hide()
}
},
disabled(newValue) {
if (newValue) {
this.disable()
} else {
this.enable()
}
}
},
created() {
// Create non-reactive properties
this.$_tip = null
this.$_hoverTimeout = null
this.$_hoverState = ''
this.$_visibleInterval = null
this.$_enabled = !this.disabled
this.$_noop = noop.bind(this)
// Destroy ourselves when the parent is destroyed
if (this.bvParent) {
this.bvParent.$once(HOOK_EVENT_NAME_BEFORE_DESTROY, () => {
this.$nextTick(() => {
// In a `requestAF()` to release control back to application
requestAF(() => {
this.$destroy()
})
})
})
}
this.$nextTick(() => {
const target = this.getTarget()
if (target && contains(document.body, target)) {
// Copy the parent's scoped style attribute
this.scopeId = getScopeId(this.bvParent)
// Set up all trigger handlers and listeners
this.listen()
} else {
/* istanbul ignore next */
warn(
isString(this.target)
? `Unable to find target element by ID "#${this.target}" in document.`
: 'The provided target is no valid HTML element.',
this.templateType
)
}
})
},
/* istanbul ignore next */
updated() {
// Usually called when the slots/data changes
this.$nextTick(this.handleTemplateUpdate)
},
/* istanbul ignore next */
deactivated() {
// In a keepalive that has been deactivated, so hide
// the tooltip/popover if it is showing
this.forceHide()
},
beforeDestroy() {
// Remove all handler/listeners
this.unListen()
this.setWhileOpenListeners(false)
// Clear any timeouts/intervals
this.clearHoverTimeout()
this.clearVisibilityInterval()
// Destroy the template
this.destroyTemplate()
// Remove any other private properties created during create
this.$_noop = null
},
methods: {
// --- Methods for creating and destroying the template ---
getTemplate() {
// Overridden by BVPopover
return BVTooltipTemplate
},
updateData(data = {}) {
// Method for updating popper/template data
// We only update data if it exists, and has not changed
let titleUpdated = false
keys(templateData).forEach(prop => {
if (!isUndefined(data[prop]) && this[prop] !== data[prop]) {
this[prop] = data[prop]
if (prop === 'title') {
titleUpdated = true
}
}
})
// If the title has updated, we may need to handle the `title`
// attribute on the trigger target
// We only do this while the template is open
if (titleUpdated && this.localShow) {
this.fixTitle()
}
},
createTemplateAndShow() {
// Creates the template instance and show it
const container = this.getContainer()
const Template = this.getTemplate()
const $tip = (this.$_tip = createNewChildComponent(this, Template, {
// The following is not reactive to changes in the props data
propsData: {
// These values cannot be changed while template is showing
id: this.computedId,
html: this.html,
placement: this.placement,
fallbackPlacement: this.fallbackPlacement,
target: this.getPlacementTarget(),
boundary: this.getBoundary(),
// Ensure the following are integers
offset: toInteger(this.offset, 0),
arrowPadding: toInteger(this.arrowPadding, 0),
boundaryPadding: toInteger(this.boundaryPadding, 0)
}
}))
// We set the initial reactive data (values that can be changed while open)
this.handleTemplateUpdate()
// Template transition phase events (handled once only)
// When the template has mounted, but not visibly shown yet
$tip.$once(EVENT_NAME_SHOW, this.onTemplateShow)
// When the template has completed showing
$tip.$once(EVENT_NAME_SHOWN, this.onTemplateShown)
// When the template has started to hide
$tip.$once(EVENT_NAME_HIDE, this.onTemplateHide)
// When the template has completed hiding
$tip.$once(EVENT_NAME_HIDDEN, this.onTemplateHidden)
// When the template gets destroyed for any reason
$tip.$once(HOOK_EVENT_NAME_DESTROYED, this.destroyTemplate)
// Convenience events from template
// To save us from manually adding/removing DOM
// listeners to tip element when it is open
$tip.$on(EVENT_NAME_FOCUSIN, this.handleEvent)
$tip.$on(EVENT_NAME_FOCUSOUT, this.handleEvent)
$tip.$on(EVENT_NAME_MOUSEENTER, this.handleEvent)
$tip.$on(EVENT_NAME_MOUSELEAVE, this.handleEvent)
// Mount (which triggers the `show`)
$tip.$mount(container.appendChild(document.createElement('div')))
// Template will automatically remove its markup from DOM when hidden
},
hideTemplate() {
// Trigger the template to start hiding
// The template will emit the `hide` event after this and
// then emit the `hidden` event once it is fully hidden
// The `hook:destroyed` will also be called (safety measure)
this.$_tip && this.$_tip.hide()
// Clear out any stragging active triggers
this.clearActiveTriggers()
// Reset the hover state
this.$_hoverState = ''
},
// Destroy the template instance and reset state
destroyTemplate() {
this.setWhileOpenListeners(false)
this.clearHoverTimeout()
this.$_hoverState = ''
this.clearActiveTriggers()
this.localPlacementTarget = null
try {
this.$_tip.$destroy()
} catch {}
this.$_tip = null
this.removeAriaDescribedby()
this.restoreTitle()
this.localShow = false
},
getTemplateElement() {
return this.$_tip ? this.$_tip.$el : null
},
handleTemplateUpdate() {
// Update our template title/content "props"
// So that the template updates accordingly
const $tip = this.$_tip
if ($tip) {
const props = ['title', 'content', 'variant', 'customClass', 'noFade', 'interactive']
// Only update the values if they have changed
props.forEach(prop => {
if ($tip[prop] !== this[prop]) {
$tip[prop] = this[prop]
}
})
}
},
// --- Show/Hide handlers ---
// Show the tooltip
show() {
const target = this.getTarget()
if (
!target ||
!contains(document.body, target) ||
!isVisible(target) ||
this.dropdownOpen() ||
((isUndefinedOrNull(this.title) || this.title === '') &&
(isUndefinedOrNull(this.content) || this.content === ''))
) {
// If trigger element isn't in the DOM or is not visible, or
// is on an open dropdown toggle, or has no content, then
// we exit without showing
return
}
// If tip already exists, exit early
if (this.$_tip || this.localShow) {
/* istanbul ignore next */
return
}
// In the process of showing
this.localShow = true
// Create a cancelable BvEvent
const showEvent = this.buildEvent(EVENT_NAME_SHOW, { cancelable: true })
this.emitEvent(showEvent)
// Don't show if event cancelled
/* istanbul ignore if */
if (showEvent.defaultPrevented) {
// Destroy the template (if for some reason it was created)
this.destroyTemplate()
return
}
// Fix the title attribute on target
this.fixTitle()
// Set aria-describedby on target
this.addAriaDescribedby()
// Create and show the tooltip
this.createTemplateAndShow()
},
hide(force = false) {
// Hide the tooltip
const tip = this.getTemplateElement()
/* istanbul ignore if */
if (!tip || !this.localShow) {
this.restoreTitle()
return
}
// Emit cancelable BvEvent 'hide'
// We disable cancelling if `force` is true
const hideEvent = this.buildEvent(EVENT_NAME_HIDE, { cancelable: !force })
this.emitEvent(hideEvent)
/* istanbul ignore if: ignore for now */
if (hideEvent.defaultPrevented) {
// Don't hide if event cancelled
return
}
// Tell the template to hide
this.hideTemplate()
},
forceHide() {
// Forcefully hides/destroys the template, regardless of any active triggers
const tip = this.getTemplateElement()
if (!tip || !this.localShow) {
/* istanbul ignore next */
return
}
// Disable while open listeners/watchers
// This is also done in the template `hide` event handler
this.setWhileOpenListeners(false)
// Clear any hover enter/leave event
this.clearHoverTimeout()
this.$_hoverState = ''
this.clearActiveTriggers()
// Disable the fade animation on the template
if (this.$_tip) {
this.$_tip.noFade = true
}
// Hide the tip (with force = true)
this.hide(true)
},
enable() {
this.$_enabled = true
// Create a non-cancelable BvEvent
this.emitEvent(this.buildEvent(EVENT_NAME_ENABLED))
},
disable() {
this.$_enabled = false
// Create a non-cancelable BvEvent
this.emitEvent(this.buildEvent(EVENT_NAME_DISABLED))
},
// --- Handlers for template events ---
// When template is inserted into DOM, but not yet shown
onTemplateShow() {
// Enable while open listeners/watchers
this.setWhileOpenListeners(true)
},
// When template show transition completes
onTemplateShown() {
const prevHoverState = this.$_hoverState
this.$_hoverState = ''
/* istanbul ignore next: occasional Node 10 coverage error */
if (prevHoverState === 'out') {
this.leave(null)
}
// Emit a non-cancelable BvEvent 'shown'
this.emitEvent(this.buildEvent(EVENT_NAME_SHOWN))
},
// When template is starting to hide
onTemplateHide() {
// Disable while open listeners/watchers
this.setWhileOpenListeners(false)
},
// When template has completed closing (just before it self destructs)
onTemplateHidden() {
// Destroy the template
this.destroyTemplate()
// Emit a non-cancelable BvEvent 'shown'
this.emitEvent(this.buildEvent(EVENT_NAME_HIDDEN))
},
// --- Helper methods ---
getTarget() {
let { target } = this
if (isString(target)) {
target = getById(target.replace(/^#/, ''))
} else if (isFunction(target)) {
target = target()
} else if (target) {
target = target.$el || target
}
return isElement(target) ? target : null
},
getPlacementTarget() {
// This is the target that the tooltip will be placed on, which may not
// necessarily be the same element that has the trigger event listeners
// For now, this is the same as target
// TODO:
// Add in child selector support
// Add in visibility checks for this element
// Fallback to target if not found
return this.getTarget()
},
getTargetId() {
// Returns the ID of the trigger element
const target = this.getTarget()
return target && target.id ? target.id : null
},
getContainer() {
// Handle case where container may be a component ref
const container = this.container ? this.container.$el || this.container : false
const body = document.body
const target = this.getTarget()
// If we are in a modal, we append to the modal, If we
// are in a sidebar, we append to the sidebar, else append
// to body, unless a container is specified
// TODO:
// Template should periodically check to see if it is in dom
// And if not, self destruct (if container got v-if'ed out of DOM)
// Or this could possibly be part of the visibility check
return container === false
? closest(CONTAINER_SELECTOR, target) || body
: /*istanbul ignore next */ isString(container)
? /*istanbul ignore next */ getById(container.replace(/^#/, '')) || body
: /*istanbul ignore next */ body
},
getBoundary() {
return this.boundary ? this.boundary.$el || this.boundary : 'scrollParent'
},
isInModal() {
const target = this.getTarget()
return target && closest(MODAL_SELECTOR, target)
},
isDropdown() {
// Returns true if trigger is a dropdown
const target = this.getTarget()
return target && hasClass(target, DROPDOWN_CLASS)
},
dropdownOpen() {
// Returns true if trigger is a dropdown and the dropdown menu is open
const target = this.getTarget()
return this.isDropdown() && target && select(DROPDOWN_OPEN_SELECTOR, target)
},
clearHoverTimeout() {
clearTimeout(this.$_hoverTimeout)
this.$_hoverTimeout = null
},
clearVisibilityInterval() {
clearInterval(this.$_visibleInterval)
this.$_visibleInterval = null
},
clearActiveTriggers() {
for (const trigger in this.activeTrigger) {
this.activeTrigger[trigger] = false
}
},
addAriaDescribedby() {
// Add aria-describedby on trigger element, without removing any other IDs
const target = this.getTarget()
let desc = getAttr(target, 'aria-describedby') || ''
desc = desc
.split(/\s+/)
.concat(this.computedId)
.join(' ')
.trim()
// Update/add aria-described by
setAttr(target, 'aria-describedby', desc)
},
removeAriaDescribedby() {
// Remove aria-describedby on trigger element, without removing any other IDs
const target = this.getTarget()
let desc = getAttr(target, 'aria-describedby') || ''
desc = desc
.split(/\s+/)
.filter(d => d !== this.computedId)
.join(' ')
.trim()
// Update or remove aria-describedby
if (desc) {
/* istanbul ignore next */
setAttr(target, 'aria-describedby', desc)
} else {
removeAttr(target, 'aria-describedby')
}
},
fixTitle() {
// If the target has a `title` attribute,
// remove it and store it on a data attribute
const target = this.getTarget()
if (hasAttr(target, 'title')) {
// Get `title` attribute value and remove it from target
const title = getAttr(target, 'title')
setAttr(target, 'title', '')
// Only set the data attribute when the value is truthy
if (title) {
setAttr(target, DATA_TITLE_ATTR, title)
}
}
},
restoreTitle() {
// If the target had a `title` attribute,
// restore it and remove the data attribute
const target = this.getTarget()
if (hasAttr(target, DATA_TITLE_ATTR)) {
// Get data attribute value and remove it from target
const title = getAttr(target, DATA_TITLE_ATTR)
removeAttr(target, DATA_TITLE_ATTR)
// Only restore the `title` attribute when the value is truthy
if (title) {
setAttr(target, 'title', title)
}
}
},
// --- BvEvent helpers ---
buildEvent(type, options = {}) {
// Defaults to a non-cancellable event
return new BvEvent(type, {
cancelable: false,
target: this.getTarget(),
relatedTarget: this.getTemplateElement() || null,
componentId: this.computedId,
vueTarget: this,
// Add in option overrides
...options
})
},
emitEvent(bvEvent) {
const { type } = bvEvent
this.emitOnRoot(getRootEventName(this.templateType, type), bvEvent)
this.$emit(type, bvEvent)
},
// --- Event handler setup methods ---
listen() {
// Enable trigger event handlers
const el = this.getTarget()
if (!el) {
/* istanbul ignore next */
return
}
// Listen for global show/hide events
this.setRootListener(true)
// Set up our listeners on the target trigger element
this.computedTriggers.forEach(trigger => {
if (trigger === 'click') {
eventOn(el, 'click', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE)
} else if (trigger === 'focus') {
eventOn(el, 'focusin', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE)
eventOn(el, 'focusout', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE)
} else if (trigger === 'blur') {
// Used to close $tip when element loses focus
/* istanbul ignore next */
eventOn(el, 'focusout', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE)
} else if (trigger === 'hover') {
eventOn(el, 'mouseenter', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE)
eventOn(el, 'mouseleave', this.handleEvent, EVENT_OPTIONS_NO_CAPTURE)
}
}, this)
},
/* istanbul ignore next */
unListen() {
// Remove trigger event handlers
const events = ['click', 'focusin', 'focusout', 'mouseenter', 'mouseleave']
const target = this.getTarget()
// Stop listening for global show/hide/enable/disable events
this.setRootListener(false)
// Clear out any active target listeners
events.forEach(event => {
target && eventOff(target, event, this.handleEvent, EVENT_OPTIONS_NO_CAPTURE)
}, this)
},
setRootListener(on) {
// Listen for global `bv::{hide|show}::{tooltip|popover}` hide request event
const method = on ? 'listenOnRoot' : 'listenOffRoot'
const type = this.templateType
this[method](getRootActionEventName(type, EVENT_NAME_HIDE), this.doHide)
this[method](getRootActionEventName(type, EVENT_NAME_SHOW), this.doShow)
this[method](getRootActionEventName(type, EVENT_NAME_DISABLE), this.doDisable)
this[method](getRootActionEventName(type, EVENT_NAME_ENABLE), this.doEnable)
},
setWhileOpenListeners(on) {
// Events that are only registered when the template is showing
// Modal close events
this.setModalListener(on)
// Dropdown open events (if we are attached to a dropdown)
this.setDropdownListener(on)
// Periodic $element visibility check
// For handling when tip target is in <keepalive>, tabs, carousel, etc
this.visibleCheck(on)
// On-touch start listeners
this.setOnTouchStartListener(on)
},
// Handler for periodic visibility check
visibleCheck(on) {
this.clearVisibilityInterval()
const target = this.getTarget()
if (on) {
this.$_visibleInterval = setInterval(() => {
const tip = this.getTemplateElement()
if (tip && this.localShow && (!target.parentNode || !isVisible(target))) {
// Target element is no longer visible or not in DOM, so force-hide the tooltip
this.forceHide()
}
}, 100)
}
},
setModalListener(on) {
// Handle case where tooltip/target is in a modal
if (this.isInModal()) {
// We can listen for modal hidden events on `$root`
this[on ? 'listenOnRoot' : 'listenOffRoot'](ROOT_EVENT_NAME_MODAL_HIDDEN, this.forceHide)
}
},
/* istanbul ignore next: JSDOM doesn't support `ontouchstart` */
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 => {
eventOnOff(on, el, 'mouseover', this.$_noop)
})
}
},
setDropdownListener(on) {
const target = this.getTarget()
if (!target || !this.bvEventRoot || !this.isDropdown) {
return
}
// We can listen for dropdown shown events on its instance
// TODO:
// We could grab the ID from the dropdown, and listen for
// $root events for that particular dropdown id
// Dropdown shown and hidden events will need to emit
// Note: Dropdown auto-ID happens in a `$nextTick()` after mount
// So the ID lookup would need to be done in a `$nextTick()`
const instance = getInstanceFromElement(target)
if (instance) {
instance[on ? '$on' : '$off'](EVENT_NAME_SHOWN, this.forceHide)
}
},
// --- Event handlers ---
handleEvent(event) {
// General trigger event handler
// target is the trigger element
const target = this.getTarget()
if (!target || isDisabled(target) || !this.$_enabled || this.dropdownOpen()) {
// If disabled or not enabled, or if a dropdown that is open, don't do anything
// If tip is shown before element gets disabled, then tip will not
// close until no longer disabled or forcefully closed
return
}
const type = event.type
const triggers = this.computedTriggers
if (type === 'click' && arrayIncludes(triggers, 'click')) {
this.click(event)
} else if (type === 'mouseenter' && arrayIncludes(triggers, 'hover')) {
// `mouseenter` is a non-bubbling event
this.enter(event)
} else if (type === 'focusin' && arrayIncludes(triggers, 'focus')) {
// `focusin` is a bubbling event
// `event` includes `relatedTarget` (element losing focus)
this.enter(event)
} else if (
(type === 'focusout' &&
(arrayIncludes(triggers, 'focus') || arrayIncludes(triggers, 'blur'))) ||
(type === 'mouseleave' && arrayIncludes(triggers, 'hover'))
) {
// `focusout` is a bubbling event
// `mouseleave` is a non-bubbling event
// `tip` is the template (will be null if not open)
const tip = this.getTemplateElement()
// `eventTarget` is the element which is losing focus/hover and
const eventTarget = event.target
// `relatedTarget` is the element gaining focus/hover
const relatedTarget = event.relatedTarget
/* istanbul ignore next */
if (
// From tip to target
(tip && contains(tip, eventTarget) && contains(target, relatedTarget)) ||
// From target to tip
(tip && contains(target, eventTarget) && contains(tip, relatedTarget)) ||
// Within tip
(tip && contains(tip, eventTarget) && contains(tip, relatedTarget)) ||
// Within target
(contains(target, eventTarget) && contains(target, relatedTarget))
) {
// If focus/hover moves within `tip` and `target`, don't trigger a leave
return
}
// Otherwise trigger a leave
this.leave(event)
}
},
doHide(id) {
// Programmatically hide tooltip or popover
if (!id || (this.getTargetId() === id || this.computedId === id)) {
// Close all tooltips or popovers, or this specific tip (with ID)
this.forceHide()
}
},
doShow(id) {
// Programmatically show tooltip or popover
if (!id || (this.getTargetId() === id || this.computedId === id)) {
// Open all tooltips or popovers, or this specific tip (with ID)
this.show()
}
},
/*istanbul ignore next: ignore for now */
doDisable(id) /*istanbul ignore next: ignore for now */ {
// Programmatically disable tooltip or popover
if (!id || (this.getTargetId() === id || this.computedId === id)) {
// Disable all tooltips or popovers (no ID), or this specific tip (with ID)
this.disable()
}
},
/*istanbul ignore next: ignore for now */
doEnable(id) /*istanbul ignore next: ignore for now */ {
// Programmatically enable tooltip or popover
if (!id || (this.getTargetId() === id || this.computedId === id)) {
// Enable all tooltips or popovers (no ID), or this specific tip (with ID)
this.enable()
}
},
click(event) {
if (!this.$_enabled || this.dropdownOpen()) {
/* istanbul ignore next */
return
}
// Get around a WebKit bug where `click` does not trigger focus events
// On most browsers, `click` triggers a `focusin`/`focus` event first
// Needed so that trigger 'click blur' works on iOS
// https://github.com/bootstrap-vue/bootstrap-vue/issues/5099
// We use `currentTarget` rather than `target` to trigger on the
// element, not the inner content
attemptFocus(event.currentTarget)
this.activeTrigger.click = !this.activeTrigger.click
if (this.isWithActiveTrigger) {
this.enter(null)
} else {
/* istanbul ignore next */
this.leave(null)
}
},
/* istanbul ignore next */
toggle() {
// Manual toggle handler
if (!this.$_enabled || this.dropdownOpen()) {
/* istanbul ignore next */
return
}
// Should we register as an active trigger?
// this.activeTrigger.manual = !this.activeTrigger.manual
if (this.localShow) {
this.leave(null)
} else {
this.enter(null)
}
},
enter(event = null) {
// Opening trigger handler
// Note: Click events are sent with event === null
if (event) {
this.activeTrigger[event.type === 'focusin' ? 'focus' : 'hover'] = true
}
/* istanbul ignore next */
if (this.localShow || this.$_hoverState === 'in') {
this.$_hoverState = 'in'
return
}
this.clearHoverTimeout()
this.$_hoverState = 'in'
if (!this.computedDelay.show) {
this.show()
} else {
// Hide any title attribute while enter delay is active
this.fixTitle()
this.$_hoverTimeout = setTimeout(() => {
/* istanbul ignore else */
if (this.$_hoverState === 'in') {
this.show()
} else if (!this.localShow) {
this.restoreTitle()
}
}, this.computedDelay.show)
}
},
leave(event = null) {
// Closing trigger handler
// Note: Click events are sent with event === null
if (event) {
this.activeTrigger[event.type === 'focusout' ? 'focus' : 'hover'] = false
/* istanbul ignore next */
if (event.type === 'focusout' && arrayIncludes(this.computedTriggers, 'blur')) {
// Special case for `blur`: we clear out the other triggers
this.activeTrigger.click = false
this.activeTrigger.hover = false
}
}
/* istanbul ignore next: ignore for now */
if (this.isWithActiveTrigger) {
return
}
this.clearHoverTimeout()
this.$_hoverState = 'out'
if (!this.computedDelay.hide) {
this.hide()
} else {
this.$_hoverTimeout = setTimeout(() => {
if (this.$_hoverState === 'out') {
this.hide()
}
}, this.computedDelay.hide)
}
}
}
})