bootstrap-vue
Version:
BootstrapVue, with more than 85 custom components, over 45 plugins, several custom directives, and over 300 icons, provides one of the most comprehensive implementations of Bootstrap v4 components and grid system for Vue.js. With extensive and automated W
273 lines (254 loc) • 8.09 kB
JavaScript
import getScopId from '../../utils/get-scope-id'
import identity from '../../utils/identity'
import looseEqual from '../../utils/loose-equal'
import { concat } from '../../utils/array'
import { getComponentConfig } from '../../utils/config'
import { isBrowser } from '../../utils/env'
import {
isFunction,
isNumber,
isPlainObject,
isString,
isUndefined,
isUndefinedOrNull
} from '../../utils/inspect'
import { toInteger } from '../../utils/number'
import { keys } from '../../utils/object'
import { BVPopover } from '../../components/popover/helpers/bv-popover'
// Key which we use to store tooltip object on element
const BV_POPOVER = '__BV_Popover__'
// Default trigger
const DefaultTrigger = 'click'
// Valid event triggers
const validTriggers = {
focus: true,
hover: true,
click: true,
blur: true,
manual: true
}
// Directive modifier test regular expressions. Pre-compile for performance
const htmlRE = /^html$/i
const noFadeRE = /^nofade$/i
const placementRE = /^(auto|top(left|right)?|bottom(left|right)?|left(top|bottom)?|right(top|bottom)?)$/i
const boundaryRE = /^(window|viewport|scrollParent)$/i
const delayRE = /^d\d+$/i
const delayShowRE = /^ds\d+$/i
const delayHideRE = /^dh\d+$/i
const offsetRE = /^o-?\d+$/i
const variantRE = /^v-.+$/i
const spacesRE = /\s+/
// Build a Popover config based on bindings (if any)
// Arguments and modifiers take precedence over passed value config object
const parseBindings = (bindings, vnode) => /* istanbul ignore next: not easy to test */ {
// We start out with a basic config
const NAME = 'BPopover'
let config = {
title: undefined,
content: undefined,
trigger: '', // Default set below if needed
placement: 'right',
fallbackPlacement: 'flip',
container: false, // Default of body
animation: true,
offset: 0,
disabled: false,
id: null,
html: false,
delay: getComponentConfig(NAME, 'delay'),
boundary: String(getComponentConfig(NAME, 'boundary')),
boundaryPadding: toInteger(getComponentConfig(NAME, 'boundaryPadding'), 0),
variant: getComponentConfig(NAME, 'variant'),
customClass: getComponentConfig(NAME, 'customClass')
}
// Process `bindings.value`
if (isString(bindings.value) || isNumber(bindings.value)) {
// Value is popover content (html optionally supported)
config.content = bindings.value
} else if (isFunction(bindings.value)) {
// Content generator function
config.content = bindings.value
} else if (isPlainObject(bindings.value)) {
// Value is config object, so merge
config = { ...config, ...bindings.value }
}
// If argument, assume element ID of container element
if (bindings.arg) {
// Element ID specified as arg
// We must prepend '#' to become a CSS selector
config.container = `#${bindings.arg}`
}
// If title is not provided, try title attribute
if (isUndefined(config.title)) {
// Try attribute
const data = vnode.data || {}
config.title = data.attrs && !isUndefinedOrNull(data.attrs.title) ? data.attrs.title : undefined
}
// Normalize delay
if (!isPlainObject(config.delay)) {
config.delay = {
show: toInteger(config.delay, 0),
hide: toInteger(config.delay, 0)
}
}
// Process modifiers
keys(bindings.modifiers).forEach(mod => {
if (htmlRE.test(mod)) {
// Title/content allows HTML
config.html = true
} else if (noFadeRE.test(mod)) {
// No animation
config.animation = false
} else if (placementRE.test(mod)) {
// Placement of popover
config.placement = mod
} else if (boundaryRE.test(mod)) {
// Boundary of popover
mod = mod === 'scrollparent' ? 'scrollParent' : mod
config.boundary = mod
} else if (delayRE.test(mod)) {
// Delay value
const delay = toInteger(mod.slice(1), 0)
config.delay.show = delay
config.delay.hide = delay
} else if (delayShowRE.test(mod)) {
// Delay show value
config.delay.show = toInteger(mod.slice(2), 0)
} else if (delayHideRE.test(mod)) {
// Delay hide value
config.delay.hide = toInteger(mod.slice(2), 0)
} else if (offsetRE.test(mod)) {
// Offset value, negative allowed
config.offset = toInteger(mod.slice(1), 0)
} else if (variantRE.test(mod)) {
// Variant
config.variant = mod.slice(2) || null
}
})
// Special handling of event trigger modifiers trigger is
// a space separated list
const selectedTriggers = {}
// Parse current config object trigger
concat(config.trigger || '')
.filter(identity)
.join(' ')
.trim()
.toLowerCase()
.split(spacesRE)
.forEach(trigger => {
if (validTriggers[trigger]) {
selectedTriggers[trigger] = true
}
})
// Parse modifiers for triggers
keys(bindings.modifiers).forEach(mod => {
mod = mod.toLowerCase()
if (validTriggers[mod]) {
// If modifier is a valid trigger
selectedTriggers[mod] = true
}
})
// Sanitize triggers
config.trigger = keys(selectedTriggers).join(' ')
if (config.trigger === 'blur') {
// Blur by itself is useless, so convert it to 'focus'
config.trigger = 'focus'
}
if (!config.trigger) {
// Use default trigger
config.trigger = DefaultTrigger
}
return config
}
// Add or update Popover on our element
const applyPopover = (el, bindings, vnode) => {
if (!isBrowser) {
/* istanbul ignore next */
return
}
const config = parseBindings(bindings, vnode)
if (!el[BV_POPOVER]) {
const $parent = vnode.context
el[BV_POPOVER] = new BVPopover({
parent: $parent,
// Add the parent's scoped style attribute data
_scopeId: getScopId($parent, undefined)
})
el[BV_POPOVER].__bv_prev_data__ = {}
el[BV_POPOVER].$on('show', () => /* istanbul ignore next: for now */ {
// Before showing the popover, we update the title
// and content if they are functions
const data = {}
if (isFunction(config.title)) {
data.title = config.title(el)
}
if (isFunction(config.content)) {
data.content = config.content(el)
}
if (keys(data).length > 0) {
el[BV_POPOVER].updateData(data)
}
})
}
const data = {
title: config.title,
content: config.content,
triggers: config.trigger,
placement: config.placement,
fallbackPlacement: config.fallbackPlacement,
variant: config.variant,
customClass: config.customClass,
container: config.container,
boundary: config.boundary,
delay: config.delay,
offset: config.offset,
noFade: !config.animation,
id: config.id,
disabled: config.disabled,
html: config.html
}
const oldData = el[BV_POPOVER].__bv_prev_data__
el[BV_POPOVER].__bv_prev_data__ = data
if (!looseEqual(data, oldData)) {
// We only update the instance if data has changed
const newData = {
target: el
}
keys(data).forEach(prop => {
// We only pass data properties that have changed
if (data[prop] !== oldData[prop]) {
// If title/content is a function, we execute it here
newData[prop] =
(prop === 'title' || prop === 'content') && isFunction(data[prop])
? /* istanbul ignore next */ data[prop](el)
: data[prop]
}
})
el[BV_POPOVER].updateData(newData)
}
}
// Remove Popover from our element
const removePopover = el => {
if (el[BV_POPOVER]) {
el[BV_POPOVER].$destroy()
el[BV_POPOVER] = null
}
delete el[BV_POPOVER]
}
// Export our directive
export const VBPopover = {
bind(el, bindings, vnode) {
applyPopover(el, bindings, vnode)
},
// We use `componentUpdated` here instead of `update`, as the former
// waits until the containing component and children have finished updating
componentUpdated(el, bindings, vnode) {
// Performed in a `$nextTick()` to prevent endless render/update loops
vnode.context.$nextTick(() => {
applyPopover(el, bindings, vnode)
})
},
unbind(el) {
removePopover(el)
}
}