` intermediate container element
-at the point in the DOM where the `
` component is placed. This may affect layout and/or
-styling of components such as ``, ``, and ``. To
-avoid these possible layout issues, place the `` component **outside** of these types of
-components.
-
The target element **must** exist in the document before `` is mounted. If the target
element is not found during mount, the tooltip will never open. Always place your ``
component lower in the DOM than your target element. This rule also applies if a callback is used as
target element, since that callback is called only once on mount.
-**Note:** _When using the default slot for the title, `` transfers the rendered DOM from
-that slot into the tooltip's markup when shown, and returns the content back to the ``
-component when hidden. This may cause some issues in rare circumstances, so please test your
-implementation accordingly! The `title` prop does not have this behavior. For simple tooltips, we
-recommend using the `v-b-tooltip` directive and enable the `html` modifier if needed._
-
## Positioning
Twelve options are available for positioning: `top`, `topleft`, `topright`, `right`, `righttop`,
@@ -102,12 +98,42 @@ The default position is `top`. Positioning is relative to the trigger element.
## Triggers
Tooltips can be triggered (opened/closed) via any combination of `click`, `hover` and `focus`. The
-default trigger is `hover focus`.
+default trigger is `hover focus`. Or a trigger of `manual` can be specified, where the popover can
+only be opened or closed [programmatically](#programmatically-disabling-tooltip).
If a tooltip has more than one trigger, then all triggers must be cleared before the tooltip will
close. I.e. if a tooltip has the trigger `focus click`, and it was opened by `focus`, and the user
then clicks the trigger element, they must click it again **and** move focus to close the tooltip.
+### Making tooltips work for keyboard and assistive technology users
+
+You should only add tooltips to HTML elements that are traditionally keyboard-focusable and
+interactive (such as links, buttons, or form controls). Although arbitrary HTML elements (such as
+``s) can be made focusable by adding the `tabindex="0"` attribute, this will add potentially
+annoying and confusing tab stops on non-interactive elements for keyboard users. In addition, most
+assistive technologies currently do not announce the tooltip in this situation.
+
+Additionally, do not rely solely on `hover` as the trigger for your tooltip, as this will make your
+tooltips _impossible to trigger for keyboard-only users_.
+
+### Disabled elements
+
+Elements with the `disabled` attribute aren’t interactive, meaning users cannot focus, hover, or
+click them to trigger a tooltip (or popover). As a workaround, you’ll want to trigger the tooltip
+from a wrapper `` or `
`, ideally made keyboard-focusable using `tabindex="0"`, and
+override the `pointer-events` on the disabled element.
+
+```html
+
+
+ Disabled button
+
+ Disabled tooltip
+
+
+
+```
+
## `` component usage
```html
@@ -149,13 +175,14 @@ then clicks the trigger element, they must click it again **and** move focus to
| `fallback-placement` | `'flip'` | Auto-flip placement behaviour of the tooltip, relative to the trigger element. | `flip`, `clockwise`, `counterclockwise`, or an array of valid placements evaluated from left to right |
| `triggers` | `'hover focus'` | Space separated list of event(s), which will trigger open/close of tooltip | `hover`, `focus`, `click`. Note `blur` is a special use case to close tooltip on next click, usually used in conjunction with `click`. |
| `no-fade` | `false` | Disable fade animation when set to `true` | `true` or `false` |
-| `delay` | `0` | Delay showing and hiding of tooltip by specified number of milliseconds. Can also be specified as an object in the form of `{ show: 100, hide: 400 }` allowing different show and hide delays | `0` and up, integers only. |
+| `delay` | `50` | Delay showing and hiding of tooltip by specified number of milliseconds. Can also be specified as an object in the form of `{ show: 100, hide: 400 }` allowing different show and hide delays | `0` and up, integers only. |
| `offset` | `0` | Shift the center of the tooltip by specified number of pixels | Any negative or positive integer |
| `container` | `null` | Element string ID to append rendered tooltip into. If `null` or element not found, tooltip is appended to `` (default) | Any valid in-document unique element ID. |
| `boundary` | `'scrollParent'` | The container that the tooltip will be constrained visually. The default should suffice in most cases, but you may need to change this if your target element is in a small container with overflow scroll | `'scrollParent'` (default), `'viewport'`, `'window'`, or a reference to an HTML element. |
| `boundary-padding` | `5` | Amount of pixel used to define a minimum distance between the boundaries and the tooltip. This makes sure the tooltip always has a little padding between the edges of its container. | Any positive number |
| `variant` | `null` | Contextual color variant for the tooltip | Any contextual theme color variant name |
-| `customClass` | `null` | A custom classname to apply to the tooltip outer wrapper element | A string |
+| `custom-class` | `null` | A custom classname to apply to the tooltip outer wrapper element | A string |
+| `id` | `null` | An ID to use on the tooltip root element. If none is provided, one will automatically be generated. If you do provide an ID, it _must_ be guaranteed to be unique on the rendered page. | A valid unique element ID string |
### Variants and custom class
@@ -184,8 +211,7 @@ A custom class can be applied to the tooltip outer wrapper `` by using the
```
-**Note:** Custom classes will not work with scoped styles, as the tooltips are appended to the
-document `` element by default.
+`variant` and `custom-class` are reactive and can be changed while the tooltip is open.
Refer to the [tooltip directive](/docs/directives/tooltip) docs on applying variants and custom
class to the directive version.
@@ -379,14 +405,15 @@ You can close (hide) **all open tooltips** by emitting the `bv::hide::tooltip` e
this.$root.$emit('bv::hide::tooltip')
```
-To close a **specific tooltip**, pass the trigger element's `id` as the argument:
+To close a **specific tooltip**, pass the trigger element's `id`, or the `id` of the tooltip (if one
+was provided via the `id` prop), as the argument:
```js
this.$root.$emit('bv::show::tooltip', 'my-trigger-button-id')
```
-To open a **specific tooltip**, pass the trigger element's `id` as the argument when emitting the
-`bv::show::tooltip` \$root event:
+To open a **specific tooltip**, pass the trigger element's `id`, or the `id` of the tooltip (if one
+was provided via the `id` prop), as the argument when emitting the `bv::show::tooltip` \$root event:
```js
this.$root.$emit('bv::show::tooltip', 'my-trigger-button-id')
@@ -408,14 +435,16 @@ You can disable **all open tooltips** by emitting the `bv::disable::tooltip` eve
this.$root.$emit('bv::disable::tooltip')
```
-To disable a **specific tooltip**, pass the trigger element's `id` as the argument:
+To disable a **specific tooltip**, pass the trigger element's `id`, or the `id` of the tooltip (if
+one was provided via the `id` prop), as the argument:
```js
this.$root.$emit('bv::disable::tooltip', 'my-trigger-button-id')
```
-To enable a **specific tooltip**, pass the trigger element's `id` as the argument when emitting the
-`bv::enable::tooltip` \$root event:
+To enable a **specific tooltip**, pass the trigger element's `id`, or the `id` of the tooltip (if
+one was provided via the `id` prop), as the argument when emitting the `bv::enable::tooltip` \$root
+event:
```js
this.$root.$emit('bv::enable::tooltip', 'my-trigger-button-id')
@@ -446,14 +475,4 @@ export default {
Refer to the [Events](/docs/components/tooltip#component-reference) section of documentation for the
full list of events.
-## Making tooltips work for keyboard and assistive technology users
-
-You should only add tooltips to HTML elements that are traditionally keyboard-focusable and
-interactive (such as links, buttons, or form controls). Although arbitrary HTML elements (such as
-``s) can be made focusable by adding the `tabindex="0"` attribute, this will add potentially
-annoying and confusing tab stops on non-interactive elements for keyboard users, and most assistive
-technologies currently do not announce the tooltip in this situation. Additionally, do not rely
-solely on `hover` as the trigger for your tooltip, as this will make your tooltips impossible to
-trigger for keyboard users.
-
diff --git a/src/components/tooltip/_tooltip.scss b/src/components/tooltip/_tooltip.scss
index 86036863f27..681b6a8a531 100644
--- a/src/components/tooltip/_tooltip.scss
+++ b/src/components/tooltip/_tooltip.scss
@@ -1,3 +1,17 @@
+// Some overrides to make tooltip transitions work with Vue ``
+.tooltip.b-tooltip {
+ display: block;
+ opacity: $tooltip-opacity;
+
+ &.fade:not(.show) {
+ opacity: 0;
+ }
+
+ &.show {
+ opacity: $tooltip-opacity;
+ }
+}
+
// Create custom variants for tooltips
@if $bv-enable-tooltip-variants {
@each $variant, $value in $theme-colors {
diff --git a/src/components/tooltip/helpers/bv-popper.js b/src/components/tooltip/helpers/bv-popper.js
new file mode 100644
index 00000000000..ef2178d62fd
--- /dev/null
+++ b/src/components/tooltip/helpers/bv-popper.js
@@ -0,0 +1,237 @@
+// Base on-demand component for tooltip / popover templates
+//
+// Currently:
+// Responsible for positioning and transitioning the template
+// Templates are only instantiated when shown, and destroyed when hidden
+//
+
+import Vue from '../../../utils/vue'
+import Popper from 'popper.js'
+import { getCS, select } from '../../../utils/dom'
+import { HTMLElement } from '../../../utils/safe-types'
+import { BVTransition } from '../../../utils/bv-transition'
+
+const NAME = 'BVPopper'
+
+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
+}
+
+// @vue/component
+export const BVPopper = /*#__PURE__*/ Vue.extend({
+ name: NAME,
+ props: {
+ target: {
+ // Element that the tooltip/popover is positioned relative to
+ type: HTMLElement,
+ default: null
+ },
+ placement: {
+ type: String,
+ default: 'top'
+ },
+ fallbackPlacement: {
+ type: [String, Array],
+ default: 'flip'
+ },
+ offset: {
+ type: Number,
+ default: 0
+ },
+ boundary: {
+ // 'scrollParent', 'viewport', 'window', or Element
+ type: [String, HTMLElement],
+ default: 'scrollParent'
+ },
+ boundaryPadding: {
+ // Tooltip/popover will try and stay away from
+ // boundary edge by this many pixels
+ type: Number,
+ default: 5
+ },
+ arrowPadding: {
+ // The minimum distance (in `px`) from the edge of the
+ // tooltip/popover that the arrow can be positioned
+ type: Number,
+ default: 6
+ }
+ },
+ data() {
+ return {
+ // reactive props set by parent
+ noFade: false,
+ // State related data
+ localShow: true,
+ attachment: this.getAttachment(this.placement)
+ }
+ },
+ computed: {
+ templateType() /* istanbul ignore next */ {
+ // Overridden by template component
+ return 'unknown'
+ },
+ popperConfig() {
+ const placement = this.placement
+ return {
+ placement: this.getAttachment(placement),
+ modifiers: {
+ offset: { offset: this.getOffset(placement) },
+ flip: { behavior: this.fallbackPlacement },
+ // `arrow.element` can also be a reference to an HTML Element
+ // maybe we should make this a `$ref` in the templates?
+ arrow: { element: '.arrow' },
+ preventOverflow: {
+ padding: this.boundaryPadding,
+ boundariesElement: this.boundary
+ }
+ },
+ onCreate: data => {
+ // Handle flipping arrow classes
+ if (data.originalPlacement !== data.placement) {
+ /* istanbul ignore next: can't test in JSDOM */
+ this.popperPlacementChange(data)
+ }
+ },
+ onUpdate: data => {
+ // Handle flipping arrow classes
+ this.popperPlacementChange(data)
+ }
+ }
+ }
+ },
+ created() {
+ // Note: We are created on-demand, and should be guaranteed that
+ // DOM is rendered/ready by the time the created hook runs
+ this.$_popper = null
+ // Ensure we show as we mount
+ this.localShow = true
+ // Create popper instance before shown
+ this.$on('show', el => {
+ this.popperCreate(el)
+ })
+ // Self destruct once hidden
+ this.$on('hidden', () => {
+ this.$nextTick(this.$destroy)
+ })
+ // If parent is destroyed, ensure we are destroyed
+ this.$parent.$once('hook:destroyed', this.$destroy)
+ },
+ beforeMount() {
+ // Ensure that the attachment position is correct before mounting
+ // as our propsData is added after `new Template({...})`
+ this.attachment = this.getAttachment(this.placement)
+ },
+ mounted() {
+ // TBD
+ },
+ updated() {
+ // Update popper if needed
+ // TODO: Should this be a watcher on `this.popperConfig` instead?
+ this.popperUpdate()
+ },
+ beforeDestroy() {
+ this.popperDestroy()
+ },
+ destroyed() {
+ // Make sure template is removed from DOM
+ const el = this.$el
+ el && el.parentNode && el.parentNode.removeChild(el)
+ },
+ methods: {
+ // "Public" method to trigger hide template
+ hide() {
+ this.localShow = false
+ },
+ // Private
+ getAttachment(placement) {
+ return AttachmentMap[String(placement).toUpperCase()] || 'auto'
+ },
+ getOffset(placement) {
+ if (!this.offset) {
+ // Could set a ref for the arrow element
+ const arrow = this.$refs.arrow || select('.arrow', this.$el)
+ const arrowOffset =
+ (parseFloat(getCS(arrow).width) || 0) + (parseFloat(this.arrowPadding) || 0)
+ switch (OffsetMap[String(placement).toUpperCase()] || 0) {
+ case +1:
+ /* istanbul ignore next: can't test in JSDOM */
+ return `+50%p - ${arrowOffset}px`
+ case -1:
+ /* istanbul ignore next: can't test in JSDOM */
+ return `-50%p + ${arrowOffset}px`
+ default:
+ return 0
+ }
+ }
+ /* istanbul ignore next */
+ return this.offset
+ },
+ popperCreate(el) {
+ this.popperDestroy()
+ // We use `el` rather than `this.$el` just in case the original
+ // mountpoint root element type was changed by the template
+ this.$_popper = new Popper(this.target, el, this.popperConfig)
+ },
+ popperDestroy() {
+ this.$_popper && this.$_popper.destroy()
+ this.$_popper = null
+ },
+ popperUpdate() {
+ this.$_popper && this.$_popper.scheduleUpdate()
+ },
+ popperPlacementChange(data) {
+ // Callback used by popper to adjust the arrow placement
+ this.attachment = this.getAttachment(data.placement)
+ },
+ renderTemplate(h) /* istanbul ignore next */ {
+ // Will be overridden by templates
+ return h('div')
+ }
+ },
+ render(h) {
+ // Note: `show` and 'fade' classes are only appled during transition
+ return h(
+ BVTransition,
+ {
+ // Transitions as soon as mounted
+ props: { appear: true, noFade: this.noFade },
+ on: {
+ // Events used by parent component/instance
+ beforeEnter: el => this.$emit('show', el),
+ afterEnter: el => this.$emit('shown', el),
+ beforeLeave: el => this.$emit('hide', el),
+ afterLeave: el => this.$emit('hidden', el)
+ }
+ },
+ [this.localShow ? this.renderTemplate(h) : h()]
+ )
+ }
+})
diff --git a/src/components/tooltip/helpers/bv-tooltip-template.js b/src/components/tooltip/helpers/bv-tooltip-template.js
new file mode 100644
index 00000000000..f1fbdd2e1e8
--- /dev/null
+++ b/src/components/tooltip/helpers/bv-tooltip-template.js
@@ -0,0 +1,108 @@
+import Vue from '../../../utils/vue'
+import { isFunction, isUndefinedOrNull } from '../../../utils/inspect'
+import { BVPopper } from './bv-popper'
+
+const NAME = 'BVTooltipTemplate'
+
+// @vue/component
+export const BVTooltipTemplate = /*#__PURE__*/ Vue.extend({
+ name: NAME,
+ extends: BVPopper,
+ props: {
+ // Other non-reactive (while open) props are pulled in from BVPopper
+ id: {
+ type: String,
+ default: null
+ },
+ html: {
+ // Used only by the directive versions
+ type: Boolean,
+ default: false
+ }
+ },
+ data() {
+ // We use data, rather than props to ensure reactivity
+ // Parent component will directly set this data
+ return {
+ title: '',
+ content: '',
+ variant: null,
+ customClass: null
+ }
+ },
+ computed: {
+ templateType() {
+ return 'tooltip'
+ },
+ templateClasses() {
+ return [
+ {
+ [`b-${this.templateType}-${this.variant}`]: this.variant,
+ // `attachment` will come from BVToolpop
+ [`bs-${this.templateType}-${this.attachment}`]: this.attachment
+ },
+ this.customClass
+ ]
+ },
+ templateAttributes() {
+ const attrs = {
+ id: this.id,
+ role: 'tooltip',
+ tabindex: '-1'
+ }
+ if (this.$parent && this.$parent.$options && this.$parent.$options._scopeId) {
+ // Add the scoped style data attribute to the template root element
+ attrs[this.$parent.$options._scopeId] = ''
+ }
+ return attrs
+ },
+ templateListeners() {
+ // Used for hover/focus trigger listeners
+ return {
+ mouseenter: evt => {
+ /* istanbul ignore next: difficult to test in JSDOM */
+ this.$emit('mouseenter', evt)
+ },
+ mouseleave: evt => {
+ /* istanbul ignore next: difficult to test in JSDOM */
+ this.$emit('mouseleave', evt)
+ },
+ focusin: evt => {
+ /* istanbul ignore next: difficult to test in JSDOM */
+ this.$emit('focusin', evt)
+ },
+ focusout: evt => {
+ /* istanbul ignore next: difficult to test in JSDOM */
+ this.$emit('focusout', evt)
+ }
+ }
+ }
+ },
+ methods: {
+ renderTemplate(h) {
+ // Title can be a scoped slot function
+ const $title = isFunction(this.title)
+ ? this.title({})
+ : isUndefinedOrNull(this.title)
+ ? h()
+ : this.title
+
+ // Directive versions only
+ const domProps = this.html && !isFunction(this.title) ? { innerHTML: this.title } : {}
+
+ return h(
+ 'div',
+ {
+ staticClass: 'tooltip b-tooltip',
+ class: this.templateClasses,
+ attrs: this.templateAttributes,
+ on: this.templateListeners
+ },
+ [
+ h('div', { ref: 'arrow', staticClass: 'arrow' }),
+ h('div', { staticClass: 'tooltip-inner', domProps }, [$title])
+ ]
+ )
+ }
+ }
+})
diff --git a/src/components/tooltip/helpers/bv-tooltip.js b/src/components/tooltip/helpers/bv-tooltip.js
new file mode 100644
index 00000000000..e13d8e70f74
--- /dev/null
+++ b/src/components/tooltip/helpers/bv-tooltip.js
@@ -0,0 +1,903 @@
+// Tooltip "Class" (Built as a renderless Vue instance)
+//
+// Handles trigger events, etc.
+// Instantiates template on demand
+
+import Vue from '../../../utils/vue'
+import looseEqual from '../../../utils/loose-equal'
+import { arrayIncludes, concat, from as arrayFrom } from '../../../utils/array'
+import {
+ isElement,
+ isDisabled,
+ isVisible,
+ closest,
+ select,
+ getById,
+ hasClass,
+ getAttr,
+ hasAttr,
+ setAttr,
+ removeAttr,
+ eventOn,
+ eventOff
+} from '../../../utils/dom'
+import { isFunction, isNumber, isPlainObject, isString, isUndefined } from '../../../utils/inspect'
+import { keys } from '../../../utils/object'
+import { warn } from '../../../utils/warn'
+import { BvEvent } from '../../../utils/bv-event.class'
+
+import { BVTooltipTemplate } from './bv-tooltip-template'
+
+const NAME = 'BVTooltip'
+
+// Modal container selector for appending tooltip/popover
+const MODAL_SELECTOR = '.modal-content'
+// Modal `$root` hidden event
+const MODAL_CLOSE_EVENT = 'bv::modal::hidden'
+
+// For dropdown sniffing
+const DROPDOWN_CLASS = 'dropdown'
+const DROPDOWN_OPEN_SELECTOR = '.dropdown-menu.show'
+
+// Options for Native Event Listeners (since we never call preventDefault)
+const EvtOpts = { passive: true, capture: false }
+
+// 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,
+ // Disabled state (Boolean)
+ disabled: false,
+ // ID to use for tooltip/popover
+ id: null,
+ // Flag used by directives only, for HTML content
+ html: false
+}
+
+// @vue/component
+export const BVTooltip = /*#__PURE__*/ Vue.extend({
+ name: NAME,
+ props: {
+ // None
+ },
+ 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._uid}__`
+ },
+ computedDelay() {
+ // Normalizes delay into object form
+ const delay = { show: 0, hide: 0 }
+ if (isPlainObject(this.delay)) {
+ delay.show = Math.max(parseInt(this.delay.show, 10) || 0, 0)
+ delay.hide = Math.max(parseInt(this.delay.hide, 10) || 0, 0)
+ } else if (isNumber(this.delay) || isString(this.delay)) {
+ delay.show = delay.hide = Math.max(parseInt(this.delay, 10) || 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(Boolean)
+ .join(' ')
+ .trim()
+ .toLowerCase()
+ .split(/\s+/)
+ .sort()
+ },
+ isWithActiveTrigger() {
+ for (const trigger in this.activeTrigger) {
+ if (this.activeTrigger[trigger]) {
+ return true
+ }
+ }
+ return false
+ },
+ computedTemplateData() {
+ return {
+ title: this.title,
+ content: this.content,
+ variant: this.variant,
+ customClass: this.customClass,
+ noFade: this.noFade
+ }
+ }
+ },
+ 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()
+ },
+ disabled(newVal) {
+ newVal ? this.disable() : this.enable()
+ }
+ },
+ created() {
+ // Create non-reactive properties
+ this.$_tip = null
+ this.$_hoverTimeout = null
+ this.$_hoverState = ''
+ this.$_visibleInterval = null
+ this.$_enabled = !this.disabled
+ this.$_noop = () => {}
+
+ // Destroy ourselves when the parent is destroyed
+ if (this.$parent) {
+ this.$parent.$once('hook:beforeDestroy', this.$destroy)
+ }
+
+ this.$nextTick(() => {
+ const target = this.getTarget()
+ if (target && document.contains(target)) {
+ // Copy the parent's scoped style attribute
+ this.scopeId = this.$parent.$options._scopeId || null
+ // Set up all trigger handlers and listeners
+ this.listen()
+ } else {
+ /* istanbul ignore next */
+ warn(`${this.templateType} unable to find target element in document`)
+ }
+ })
+ },
+ updated() /* istanbul ignore next */ {
+ // Usually called when the slots/data changes
+ this.$nextTick(this.handleTemplateUpdate)
+ },
+ deactivated() /* istanbul ignore next */ {
+ // In a keepalive that has been deactivated, so hide
+ // the tooltip/popover if it is showing
+ this.forceHide()
+ },
+ beforeDestroy() /* istanbul ignore next */ {
+ // Remove all handler/listeners
+ this.unListen()
+ this.setWhileOpenListeners(false)
+
+ // Clear any timeouts/Timers
+ clearTimeout(this.$_hoverTimeout)
+ this.$_hoverTimeout = null
+
+ this.destroyTemplate()
+ this.restoreTitle()
+ },
+ 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 (titleUpdated && this.localShow) {
+ // 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
+ this.fixTitle()
+ }
+ },
+ createTemplateAndShow() {
+ // Creates the template instance and show it
+ // this.destroyTemplate()
+ const container = this.getContainer()
+ const Template = this.getTemplate()
+ const $tip = (this.$_tip = new Template({
+ parent: this,
+ // 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,
+ offset: this.offset,
+ arrowPadding: this.arrowPadding,
+ boundaryPadding: this.boundaryPadding,
+ boundary: this.getBoundary(),
+ target: this.getPlacementTarget()
+ }
+ }))
+ // 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('show', this.onTemplateShow)
+ // When the template has completed showing
+ $tip.$once('shown', this.onTemplateShown)
+ // When the template has started to hide
+ $tip.$once('hide', this.onTemplateHide)
+ // When the template has completed hiding
+ $tip.$once('hidden', this.onTemplateHidden)
+ // When the template gets destroyed for any reason
+ $tip.$once('hook: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('focusin', this.handleEvent)
+ $tip.$on('focusout', this.handleEvent)
+ $tip.$on('mouseenter', this.handleEvent)
+ $tip.$on('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()
+ },
+ destroyTemplate() {
+ // Destroy the template instance and reset state
+ this.setWhileOpenListeners(false)
+ clearTimeout(this.$_hoverTimeout)
+ this.$_hoverTimout = null
+ this.$_hoverState = ''
+ this.clearActiveTriggers()
+ this.localPlacementTarget = null
+ try {
+ this.$_tip && this.$_tip.$destroy()
+ } catch {}
+ this.$_tip = null
+ 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']
+ // Only update the values if they have changed
+ props.forEach(prop => {
+ if ($tip[prop] !== this[prop]) {
+ $tip[prop] = this[prop]
+ }
+ })
+ }
+ },
+ //
+ // Show and Hide handlers
+ //
+ show() {
+ // Show the tooltip
+ const target = this.getTarget()
+
+ if (!target || !document.body.contains(target) || !isVisible(target) || this.dropdownOpen()) {
+ // If trigger element isn't in the DOM or is not visible, or is on an open dropdown toggle
+ return
+ }
+
+ if (this.$_tip || this.localShow) {
+ // If tip already exists, exit early
+ /* istanbul ignore next */
+ return
+ }
+
+ // In the process of showing
+ this.localShow = true
+
+ // Create a cancelable BvEvent
+ const showEvt = this.buildEvent('show', { cancelable: true })
+ this.emitEvent(showEvt)
+ /* istanbul ignore next: ignore for now */
+ if (showEvt.defaultPrevented) {
+ // Don't show if event cancelled
+ // Destroy the template (if for some reason it was created)
+ this.destroyTemplate()
+ // Clear the localShow flag
+ this.localShow = false
+ 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()
+ if (!tip || !this.localShow) {
+ /* istanbul ignore next */
+ return
+ }
+
+ // Emit cancelable BvEvent 'hide'
+ // We disable cancelling if `force` is true
+ const hideEvt = this.buildEvent('hide', { cancelable: !force })
+ this.emitEvent(hideEvt)
+ /* istanbul ignore next: ignore for now */
+ if (hideEvt.defaultPrevented) {
+ // Don't hide if event cancelled
+ return
+ }
+
+ // Tell the template to hide
+ this.hideTemplate()
+ // TODO: The following could be added to `hideTemplate()`
+ // Clear out any stragging active triggers
+ this.clearActiveTriggers()
+ // Reset the hover state
+ this.$_hoverState = ''
+ },
+ 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` evt handler
+ this.setWhileOpenListeners(false)
+ // Clear any hover enter/leave event
+ clearTimeout(this.hoverTimeout)
+ this.$_hoverTimeout = null
+ 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('enabled', {}))
+ },
+ disable() {
+ this.$_enabled = false
+ // Create a non-cancelable BvEvent
+ this.emitEvent(this.buildEvent('disabled', {}))
+ },
+ //
+ // Handlers for template events
+ //
+ onTemplateShow() {
+ // When template is inserted into DOM, but not yet shown
+ // Enable while open listeners/watchers
+ this.setWhileOpenListeners(true)
+ },
+ onTemplateShown() {
+ // When template show transition completes
+ const prevHoverState = this.$_hoverState
+ this.$_hoverState = ''
+ if (prevHoverState === 'out') {
+ this.leave(null)
+ }
+ // Emit a non-cancelable BvEvent 'shown'
+ this.emitEvent(this.buildEvent('shown', {}))
+ },
+ onTemplateHide() {
+ // When template is starting to hide
+ // Disable while open listeners/watchers
+ this.setWhileOpenListeners(false)
+ },
+ onTemplateHidden() {
+ // When template has completed closing (just before it self destructs)
+ // TODO:
+ // The next two lines could be moved into `destroyTemplate()`
+ this.removeAriaDescribedby()
+ this.restoreTitle()
+ this.destroyTemplate()
+ // Emit a non-cancelable BvEvent 'shown'
+ this.emitEvent(this.buildEvent('hidden', {}))
+ },
+ //
+ // Utility methods
+ //
+ getTarget() {
+ // Handle case where target may be a component ref
+ let target = this.target ? this.target.$el || this.target : null
+ // If an ID
+ target = isString(target) ? getById(target.replace(/^#/, '')) : target
+ // If a function
+ target = isFunction(target) ? target() : target
+ // If an element ref
+ 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 instead
+ // of 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(MODAL_SELECTOR, target) || body
+ : isString(container)
+ ? getById(container.replace(/^#/, '')) || body
+ : 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)
+ },
+ 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, null it out and
+ // store on data-title
+ const target = this.getTarget()
+ if (target && getAttr(target, 'title')) {
+ // We only update title attribute if it has a value
+ setAttr(target, 'data-original-title', getAttr(target, 'title') || '')
+ setAttr(target, 'title', '')
+ }
+ },
+ restoreTitle() {
+ // If target had a title, restore the title attribute
+ // and remove the data-title attribute
+ const target = this.getTarget()
+ if (target && hasAttr(target, 'data-original-title')) {
+ setAttr(target, 'title', getAttr(target, 'data-original-title') || '')
+ setAttr(target, 'data-original-title', '')
+ }
+ },
+ //
+ // BvEvent helpers
+ //
+ buildEvent(type, opts = {}) {
+ // 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
+ ...opts
+ })
+ },
+ emitEvent(bvEvt) {
+ // Emits a BvEvent on $root and this instance
+ const evtName = bvEvt.type
+ const $root = this.$root
+ if ($root && $root.$emit) {
+ // Emit an event on $root
+ $root.$emit(`bv::${this.templateType}::${evtName}`, bvEvt)
+ }
+ this.$emit(evtName, bvEvt)
+ },
+ //
+ // 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, EvtOpts)
+ } else if (trigger === 'focus') {
+ eventOn(el, 'focusin', this.handleEvent, EvtOpts)
+ eventOn(el, 'focusout', this.handleEvent, EvtOpts)
+ } else if (trigger === 'blur') {
+ // Used to close $tip when element looses focus
+ /* istanbul ignore next */
+ eventOn(el, 'focusout', this.handleEvent, EvtOpts)
+ } else if (trigger === 'hover') {
+ eventOn(el, 'mouseenter', this.handleEvent, EvtOpts)
+ eventOn(el, 'mouseleave', this.handleEvent, EvtOpts)
+ }
+ }, this)
+ },
+ unListen() /* istanbul ignore next */ {
+ // 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(evt => {
+ target && eventOff(target, evt, this.handleEvent, EvtOpts)
+ }, this)
+ },
+ setRootListener(on) {
+ // Listen for global `bv::{hide|show}::{tooltip|popover}` hide request event
+ const $root = this.$root
+ if ($root) {
+ const method = on ? '$on' : '$off'
+ const type = this.templateType
+ $root[method](`bv::hide::${type}`, this.doHide)
+ $root[method](`bv::show::${type}`, this.doShow)
+ $root[method](`bv::disable::${type}`, this.doDisable)
+ $root[method](`bv::enable::${type}`, 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 , tabs, carousel, etc
+ this.visibleCheck(on)
+ // On-touch start listeners
+ this.setOnTouchStartListener(on)
+ },
+ visibleCheck(on) {
+ // Handler for periodic visibility check
+ clearInterval(this.$_visibleInterval)
+ this.$_visibleInterval = null
+ const target = this.getTarget()
+ const tip = this.getTemplateElement()
+ if (on) {
+ this.visibleInterval = setInterval(() => {
+ 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.$root[on ? '$on' : '$off'](MODAL_CLOSE_EVENT, this.forceHide)
+ }
+ },
+ setOnTouchStartListener(on) /* istanbul ignore next: JSDOM doesn't support `ontouchstart` */ {
+ // 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) {
+ const method = on ? eventOn : eventOff
+ arrayFrom(document.body.children).forEach(el => {
+ method(el, 'mouseover', this.$_noop)
+ })
+ }
+ },
+ setDropdownListener(on) {
+ const target = this.getTarget()
+ if (!target || !this.$root || !this.isDropdown) {
+ return
+ }
+ // We can listen for dropdown shown events on it's 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()`
+ if (target.__vue__) {
+ target.__vue__[on ? '$on' : '$off']('shown', this.forceHide)
+ }
+ },
+ //
+ // Event handlers
+ //
+ handleEvent(evt) {
+ // 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 = evt.type
+ const triggers = this.computedTriggers
+
+ if (type === 'click' && arrayIncludes(triggers, 'click')) {
+ this.click(evt)
+ } else if (type === 'mouseenter' && arrayIncludes(triggers, 'hover')) {
+ // `mouseenter` is a non-bubbling event
+ this.enter(evt)
+ } else if (type === 'focusin' && arrayIncludes(triggers, 'focus')) {
+ // `focusin` is a bubbling event
+ // `evt` includes `relatedTarget` (element loosing focus)
+ this.enter(evt)
+ } 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()
+ // `evtTarget` is the element which is loosing focus/hover and
+ const evtTarget = evt.target
+ // `relatedTarget` is the element gaining focus/hover
+ const relatedTarget = evt.relatedTarget
+ /* istanbul ignore next */
+ if (
+ // From tip to target
+ (tip && tip.contains(evtTarget) && target.contains(relatedTarget)) ||
+ // From target to tip
+ (tip && target.contains(evtTarget) && tip.contains(relatedTarget)) ||
+ // Within tip
+ (tip && tip.contains(evtTarget) && tip.contains(relatedTarget)) ||
+ // Within target
+ (target.contains(evtTarget) && target.contains(relatedTarget))
+ ) {
+ // If focus/hover moves within `tip` and `target`, don't trigger a leave
+ return
+ }
+ // Otherwise trigger a leave
+ this.leave(evt)
+ }
+ },
+ 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()
+ }
+ },
+ 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()
+ }
+ },
+ 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(evt) {
+ if (!this.$_enabled || this.dropdownOpen()) {
+ /* istanbul ignore next */
+ return
+ }
+ this.activeTrigger.click = !this.activeTrigger.click
+ if (this.isWithActiveTrigger) {
+ this.enter(null)
+ } else {
+ /* istanbul ignore next */
+ this.leave(null)
+ }
+ },
+ toggle() /* istanbul ignore next */ {
+ // 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(evt = null) {
+ // Opening trigger handler
+ // Note: Click events are sent with evt === null
+ if (evt) {
+ this.activeTrigger[evt.type === 'focusin' ? 'focus' : 'hover'] = true
+ }
+ /* istanbul ignore next */
+ if (this.localShow || this.$_hoverState === 'in') {
+ this.$_hoverState = 'in'
+ return
+ }
+ clearTimeout(this.hoverTimeout)
+ this.$_hoverState = 'in'
+ if (!this.computedDelay.show) {
+ this.show()
+ } else {
+ this.hoverTimeout = setTimeout(() => {
+ if (this.$_hoverState === 'in') {
+ this.show()
+ }
+ }, this.computedDelay.show)
+ }
+ },
+ leave(evt = null) {
+ // Closing trigger handler
+ // Note: Click events are sent with evt === null
+ if (evt) {
+ this.activeTrigger[evt.type === 'focusout' ? 'focus' : 'hover'] = false
+ /* istanbul ignore next */
+ if (evt.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
+ }
+ clearTimeout(this.hoverTimeout)
+ this.$_hoverState = 'out'
+ if (!this.computedDelay.hide) {
+ this.hide()
+ } else {
+ this.$hoverTimeout = setTimeout(() => {
+ if (this.$_hoverState === 'out') {
+ this.hide()
+ }
+ }, this.computedDelay.hide)
+ }
+ }
+ }
+})
diff --git a/src/components/tooltip/package.json b/src/components/tooltip/package.json
index e58a9a1930f..b911f1c9a67 100644
--- a/src/components/tooltip/package.json
+++ b/src/components/tooltip/package.json
@@ -161,6 +161,12 @@
}
]
}
+ ],
+ "slots": [
+ {
+ "name": "default",
+ "description": "Slot for tooltip content (HTML supported)"
+ }
]
}
]
diff --git a/src/components/tooltip/tooltip.js b/src/components/tooltip/tooltip.js
index 5b4ee512754..2084cc900d6 100644
--- a/src/components/tooltip/tooltip.js
+++ b/src/components/tooltip/tooltip.js
@@ -1,22 +1,31 @@
import Vue from '../../utils/vue'
-import ToolTip from '../../utils/tooltip.class'
-import warn from '../../utils/warn'
import { isArray, arrayIncludes } from '../../utils/array'
import { getComponentConfig } from '../../utils/config'
+import { isString, isUndefinedOrNull } from '../../utils/inspect'
import { HTMLElement } from '../../utils/safe-types'
-import normalizeSlotMixin from '../../mixins/normalize-slot'
-import toolpopMixin from '../../mixins/toolpop'
+import { BVTooltip } from './helpers/bv-tooltip'
const NAME = 'BTooltip'
// @vue/component
export const BTooltip = /*#__PURE__*/ Vue.extend({
name: NAME,
- mixins: [toolpopMixin, normalizeSlotMixin],
props: {
title: {
- type: String,
- default: ''
+ type: String
+ // default: undefined
+ },
+ // Added in by BPopover
+ // content: {
+ // type: String,
+ // default: undefined
+ // },
+ target: {
+ // String ID of element, or element/component reference
+ // Or function that returns one of the above
+ type: [String, HTMLElement, Function, Object],
+ // default: undefined,
+ required: true
},
triggers: {
type: [String, Array],
@@ -30,7 +39,10 @@ export const BTooltip = /*#__PURE__*/ Vue.extend({
type: [String, Array],
default: 'flip',
validator(value) {
- return isArray(value) || arrayIncludes(['flip', 'clockwise', 'counterclockwise'], value)
+ return (
+ (isArray(value) && value.every(v => isString(v))) ||
+ arrayIncludes(['flip', 'clockwise', 'counterclockwise'], value)
+ )
}
},
variant: {
@@ -48,34 +60,267 @@ export const BTooltip = /*#__PURE__*/ Vue.extend({
boundary: {
// String: scrollParent, window, or viewport
// Element: element reference
- type: [String, HTMLElement],
+ // Object: Vue component
+ type: [String, HTMLElement, Object],
default: () => getComponentConfig(NAME, 'boundary')
},
boundaryPadding: {
type: Number,
default: () => getComponentConfig(NAME, 'boundaryPadding')
+ },
+ offset: {
+ type: [Number, String],
+ default: 0
+ },
+ noFade: {
+ type: Boolean,
+ default: false
+ },
+ container: {
+ // String: HTML ID of container, if null body is used (default)
+ // HTMLElement: element reference reference
+ // Object: Vue Component
+ type: [String, HTMLElement, Object]
+ // default: undefined
+ },
+ show: {
+ type: Boolean,
+ default: false
+ },
+ disabled: {
+ type: Boolean,
+ default: false
+ },
+ id: {
+ // ID to use for tooltip element
+ // If not provided on will automatically be generated
+ type: String,
+ default: null
}
},
- methods: {
- createToolpop() {
- // getTarget is in toolpop mixin
- const target = this.getTarget()
- /* istanbul ignore else */
- if (target) {
- this._toolpop = new ToolTip(target, this.getConfig(), this)
+ data() {
+ return {
+ localShow: this.show,
+ localTitle: '',
+ localContent: ''
+ }
+ },
+ computed: {
+ templateData() {
+ // Data that will be passed to the template and popper
+ return {
+ // We use massaged versions of the title and content props/slots
+ title: this.localTitle,
+ content: this.localContent,
+ // Pass these props as is
+ target: this.target,
+ triggers: this.triggers,
+ placement: this.placement,
+ fallbackPlacement: this.fallbackPlacement,
+ variant: this.variant,
+ customClass: this.customClass,
+ container: this.container,
+ boundary: this.boundary,
+ delay: this.delay,
+ offset: this.offset,
+ noFade: this.noFade,
+ disabled: this.disabled,
+ id: this.id
+ }
+ },
+ templateTitleContent() {
+ // Used to watch for changes to the title and content props
+ return {
+ title: this.title,
+ content: this.content
+ }
+ }
+ },
+ watch: {
+ show(show, oldVal) {
+ if (show !== oldVal && show !== this.localShow && this.$_bv_toolpop) {
+ if (show) {
+ this.$_bv_toolpop.show()
+ } else {
+ // We use `forceHide()` to override any active triggers
+ this.$_bv_toolpop.forceHide()
+ }
+ }
+ },
+ disabled(newVal, oldVal) {
+ if (newVal) {
+ this.doDisable()
} else {
- this._toolpop = null
- warn("b-tooltip: 'target' element not found!")
+ this.doEnable()
}
- return this._toolpop
+ },
+ localShow(show, oldVal) {
+ // TODO: May need to be done in a `$nextTick()`
+ this.$emit('update:show', show)
+ },
+ templateData(newVal, oldVal) {
+ this.$nextTick(() => {
+ if (this.$_bv_toolpop) {
+ this.$_bv_toolpop.updateData(this.templateData)
+ }
+ })
+ },
+ // Watchers for title/content props (prop changes do not trigger the `updated()` hook)
+ templateTitleContent(newVal, oldVal) {
+ this.$nextTick(this.updateContent)
+ }
+ },
+ created() {
+ // Non reactive properties
+ this.$_bv_toolpop = null
+ },
+ updated() {
+ // Update the `propData` object
+ // Done in a `$nextTick()` to ensure slot(s) have updated
+ this.$nextTick(this.updateContent)
+ },
+ beforeDestroy() {
+ // Shutdown our local event listeners
+ this.$off('open', this.doOpen)
+ this.$off('close', this.doClose)
+ this.$off('disable', this.doDisable)
+ this.$off('enable', this.doEnable)
+ // Destroy the tip instance
+ this.$_bv_toolpop && this.$_bv_toolpop.$destroy()
+ this.$_bv_toolpop = null
+ },
+ mounted() {
+ // Instantiate a new BVTooltip instance
+ // Done in a `$nextTick()` to ensure DOM has completed rendering
+ // so that target can be found
+ this.$nextTick(() => {
+ // Load the on demand child instance
+ const Component = this.getComponent()
+ // Ensure we have initial content
+ this.updateContent()
+ // Pass down the scoped style attribute if available
+ const scopeId = this.$options._scopeId
+ ? this.$options._scopeId
+ : this.$parent && this.$parent.$options
+ ? this.$parent.$options._scopeId
+ : null
+ // Create the instance
+ const $toolpop = (this.$_bv_toolpop = new Component({
+ parent: this,
+ // Pass down the scoped style ID
+ _scopeId: scopeId || undefined
+ }))
+ // Set the initial data
+ $toolpop.updateData(this.templateData)
+ // Set listeners
+ $toolpop.$on('show', this.onShow)
+ $toolpop.$on('shown', this.onShown)
+ $toolpop.$on('hide', this.onHide)
+ $toolpop.$on('hidden', this.onHidden)
+ $toolpop.$on('disabled', this.onDisabled)
+ $toolpop.$on('enabled', this.onEnabled)
+ // Initially disabled?
+ if (this.disabled) {
+ // Initially disabled
+ this.doDisable()
+ }
+ // Listen to open signals from others
+ this.$on('open', this.doOpen)
+ // Listen to close signals from others
+ this.$on('close', this.doClose)
+ // Listen to disable signals from others
+ this.$on('disable', this.doDisable)
+ // Listen to enable signals from others
+ this.$on('enable', this.doEnable)
+ // Initially show tooltip?
+ if (this.localShow) {
+ this.$_bv_toolpop && this.$_bv_toolpop.show()
+ }
+ })
+ },
+ methods: {
+ getComponent() {
+ // Overridden by BPopover
+ return BVTooltip
+ },
+ updateContent() {
+ // Overridden by BPopover
+ // Tooltip: Default slot is `title`
+ // Popover: Default slot is `content`, `title` slot is title
+ // We pass a scoped slot function by default (v2.6x)
+ // And pass the title prop as a fallback
+ this.setTitle(this.$scopedSlots.default || this.title)
+ },
+ // Helper methods for `updateContent()`
+ setTitle(val) {
+ val = isUndefinedOrNull(val) ? '' : val
+ if (this.localTitle !== val) {
+ this.localTitle = val
+ }
+ },
+ setContent(val) {
+ val = isUndefinedOrNull(val) ? '' : val
+ if (this.localContent !== val) {
+ this.localContent = val
+ }
+ },
+ // --- Template event handlers ---
+ onShow(bvEvt) {
+ // Placeholder
+ this.$emit('show', bvEvt)
+ if (bvEvt) {
+ this.localShow = !bvEvt.defaultPrevented
+ }
+ },
+ onShown(bvEvt) {
+ // Tip is now showing
+ this.localShow = true
+ this.$emit('shown', bvEvt)
+ },
+ onHide(bvEvt) {
+ this.$emit('hide', bvEvt)
+ },
+ onHidden(bvEvt) {
+ // Tip is no longer showing
+ this.$emit('hidden', bvEvt)
+ this.localShow = false
+ },
+ onDisabled(bvEvt) {
+ // Prevent possible endless loop if user mistakenly
+ // fires `disabled` instead of `disable`
+ if (bvEvt && bvEvt.type === 'disabled') {
+ this.$emit('update:disabled', true)
+ this.$emit('disabled', bvEvt)
+ }
+ },
+ onEnabled(bvEvt) {
+ // Prevent possible endless loop if user mistakenly
+ // fires `enabled` instead of `enable`
+ if (bvEvt && bvEvt.type === 'enabled') {
+ this.$emit('update:disabled', false)
+ this.$emit('enabled', bvEvt)
+ }
+ },
+ // --- Local event listeners ---
+ doOpen() {
+ !this.localShow && this.$_bv_toolpop && this.$_bv_toolpop.show()
+ },
+ doClose() {
+ this.localShow && this.$_bv_toolpop && this.$_bv_toolpop.hide()
+ },
+ doDisable(evt) {
+ this.$_bv_toolpop && this.$_bv_toolpop.disable()
+ },
+ doEnable() {
+ this.$_bv_toolpop && this.$_bv_toolpop.enable()
}
},
render(h) {
- return h(
- 'div',
- { class: ['d-none'], style: { display: 'none' }, attrs: { 'aria-hidden': true } },
- [h('div', { ref: 'title' }, this.normalizeSlot('default'))]
- )
+ // Always renders a comment node
+ // TODO:
+ // Future: Possibly render a target slot (single root element)
+ // which we can apply the listeners to (pass `this.$el` to BVTooltip)
+ return h()
}
})
diff --git a/src/components/tooltip/tooltip.spec.js b/src/components/tooltip/tooltip.spec.js
index 895b5f512b9..c8122b80da6 100644
--- a/src/components/tooltip/tooltip.spec.js
+++ b/src/components/tooltip/tooltip.spec.js
@@ -1,4 +1,4 @@
-import { mount, createLocalVue as CreateLocalVue } from '@vue/test-utils'
+import { mount, createLocalVue as CreateLocalVue, createWrapper } from '@vue/test-utils'
import { waitNT, waitRAF } from '../../../tests/utils'
import BTooltip from './tooltip'
@@ -15,9 +15,21 @@ const appDef = {
'titleAttr',
'btnDisabled',
'variant',
- 'customClass'
+ 'customClass',
+ 'delay'
],
render(h) {
+ const tipProps = {
+ target: 'foo',
+ triggers: this.triggers,
+ show: this.show,
+ disabled: this.disabled,
+ noFade: this.noFade || false,
+ title: this.title || null,
+ variant: this.variant,
+ customClass: this.customClass,
+ delay: this.delay
+ }
return h('article', { attrs: { id: 'wrapper' } }, [
h(
'button',
@@ -31,23 +43,9 @@ const appDef = {
},
'text'
),
- h(
- BTooltip,
- {
- attrs: { id: 'bar' },
- props: {
- target: 'foo',
- triggers: this.triggers,
- show: this.show,
- disabled: this.disabled,
- noFade: this.noFade || false,
- title: this.title || null,
- variant: this.variant,
- customClass: this.customClass
- }
- },
- this.$slots.default || ''
- )
+ typeof this.$slots.default === `undefined` || !this.$slots.default
+ ? h(BTooltip, { props: tipProps })
+ : h(BTooltip, { props: tipProps }, this.$slots.default)
])
}
}
@@ -114,23 +112,14 @@ describe('b-tooltip', () => {
expect($button.exists()).toBe(true)
expect($button.attributes('id')).toBeDefined()
expect($button.attributes('id')).toEqual('foo')
- expect($button.attributes('title')).toBeDefined()
- expect($button.attributes('title')).toEqual('')
- expect($button.attributes('data-original-title')).toBeDefined()
- expect($button.attributes('data-original-title')).toEqual('')
+ expect($button.attributes('title')).not.toBeDefined()
+ expect($button.attributes('data-original-title')).not.toBeDefined()
expect($button.attributes('aria-describedby')).not.toBeDefined()
// wrapper
- const $tipHolder = wrapper.find('div#bar')
+ const $tipHolder = wrapper.find(BTooltip)
expect($tipHolder.exists()).toBe(true)
- expect($tipHolder.classes()).toContain('d-none')
- expect($tipHolder.attributes('aria-hidden')).toBeDefined()
- expect($tipHolder.attributes('aria-hidden')).toEqual('true')
- expect($tipHolder.element.style.display).toEqual('none')
-
- // Title placeholder (from default slot)
- expect($tipHolder.findAll('div.d-none > div').length).toBe(1)
- expect($tipHolder.find('div.d-none > div').text()).toBe('title')
+ expect($tipHolder.element.nodeType).toEqual(Node.COMMENT_NODE)
wrapper.destroy()
})
@@ -166,34 +155,24 @@ describe('b-tooltip', () => {
expect($button.exists()).toBe(true)
expect($button.attributes('id')).toBeDefined()
expect($button.attributes('id')).toEqual('foo')
- expect($button.attributes('title')).toBeDefined()
- expect($button.attributes('title')).toEqual('')
- expect($button.attributes('data-original-title')).toBeDefined()
- expect($button.attributes('data-original-title')).toEqual('')
+ expect($button.attributes('title')).not.toBeDefined()
+ expect($button.attributes('data-original-title')).not.toBeDefined()
expect($button.attributes('aria-describedby')).toBeDefined()
// ID of the tooltip that will be in the body
const adb = $button.attributes('aria-describedby')
// wrapper
- const $tipHolder = wrapper.find('div#bar')
+ const $tipHolder = wrapper.find(BTooltip)
expect($tipHolder.exists()).toBe(true)
- expect($tipHolder.classes()).toContain('d-none')
- expect($tipHolder.attributes('aria-hidden')).toBeDefined()
- expect($tipHolder.attributes('aria-hidden')).toEqual('true')
- expect($tipHolder.element.style.display).toEqual('none')
-
- // Title placeholder (from default slot) will have been
- // moved to tooltip element
- expect($tipHolder.findAll('div.d-none > div').length).toBe(0)
- // Title text will be moved into the tooltip
- expect($tipHolder.text()).toBe('')
+ expect($tipHolder.element.nodeType).toEqual(Node.COMMENT_NODE)
// Find the tooltip element in the document
- const tip = document.querySelector(`#${adb}`)
+ const tip = document.getElementById(adb)
expect(tip).not.toBe(null)
expect(tip).toBeInstanceOf(HTMLElement)
expect(tip.tagName).toEqual('DIV')
expect(tip.classList.contains('tooltip')).toBe(true)
+ expect(tip.classList.contains('b-tooltip')).toBe(true)
// Hide the tooltip
wrapper.setProps({
@@ -204,16 +183,112 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect($button.attributes('aria-describedby')).not.toBeDefined()
- // Title placeholder (from default slot) will be back here
- expect($tipHolder.findAll('div.d-none > div').length).toBe(1)
- // Title text will be moved into the tooltip
- expect($tipHolder.find('div.d-none > div').text()).toBe('title')
// Tooltip element should not be in the document
expect(document.body.contains(tip)).toBe(false)
- expect(document.querySelector(`#${adb}`)).toBe(null)
+ expect(document.querySelector(adb)).toBe(null)
+
+ // Show the tooltip
+ wrapper.setProps({
+ show: true
+ })
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
+ expect($button.attributes('aria-describedby')).toBeDefined()
+
+ // Note tip always has the same ID
+ const tip2 = document.getElementById(adb)
+ expect(tip2).not.toBe(null)
+ expect(tip2).toBeInstanceOf(HTMLElement)
+ expect(tip2.tagName).toEqual('DIV')
+ expect(tip2.classList.contains('tooltip')).toBe(true)
+ expect(tip2.classList.contains('b-tooltip')).toBe(true)
+
+ wrapper.destroy()
+ })
+
+ it('title prop is reactive', async () => {
+ jest.useFakeTimers()
+ const App = localVue.extend(appDef)
+ const wrapper = mount(App, {
+ attachToDocument: true,
+ localVue: localVue,
+ propsData: {
+ triggers: 'click',
+ show: true,
+ title: 'hello'
+ }
+ })
+
+ expect(wrapper.isVueInstance()).toBe(true)
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
+ expect(wrapper.is('article')).toBe(true)
+ expect(wrapper.attributes('id')).toBeDefined()
+ expect(wrapper.attributes('id')).toEqual('wrapper')
+
+ // The trigger button
+ const $button = wrapper.find('button')
+ expect($button.exists()).toBe(true)
+ expect($button.attributes('id')).toBeDefined()
+ expect($button.attributes('id')).toEqual('foo')
+ expect($button.attributes('title')).not.toBeDefined()
+ expect($button.attributes('data-original-title')).not.toBeDefined()
+ expect($button.attributes('aria-describedby')).toBeDefined()
+ // ID of the tooltip that will be in the body
+ const adb = $button.attributes('aria-describedby')
+
+ // wrapper
+ const $tipHolder = wrapper.find(BTooltip)
+ expect($tipHolder.exists()).toBe(true)
+ expect($tipHolder.element.nodeType).toEqual(Node.COMMENT_NODE)
+
+ // Find the tooltip element in the document
+ const tip = document.getElementById(adb)
+ expect(tip).not.toBe(null)
+ expect(tip).toBeInstanceOf(HTMLElement)
+ const $tip = createWrapper(tip)
+ expect($tip.is('div')).toBe(true)
+ expect($tip.classes()).toContain('tooltip')
+ expect($tip.classes()).toContain('b-tooltip')
+ // Should contain our title prop value
+ expect($tip.text()).toContain('hello')
+
+ // Change the title prop
+ wrapper.setProps({
+ title: 'world'
+ })
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
+ // Tooltip element should still be in the document
+ expect(document.body.contains(tip)).toBe(true)
+ expect($tip.classes()).toContain('tooltip')
+ expect($tip.classes()).toContain('b-tooltip')
+ // Should contain the new updated content
+ expect($tip.text()).toContain('world')
wrapper.destroy()
})
@@ -252,20 +327,20 @@ describe('b-tooltip', () => {
expect($button.attributes('aria-describedby')).not.toBeDefined()
// wrapper
- const $tipHolder = wrapper.find('div#bar')
+ const $tipHolder = wrapper.find(BTooltip)
expect($tipHolder.exists()).toBe(true)
- // Title placeholder will be here until opened
- expect($tipHolder.findAll('div.d-none > div').length).toBe(1)
- expect($tipHolder.text()).toBe('title')
-
// Activate tooltip by trigger
$button.trigger('click')
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect($button.attributes('id')).toBeDefined()
expect($button.attributes('id')).toEqual('foo')
@@ -274,17 +349,14 @@ describe('b-tooltip', () => {
const adb = $button.attributes('aria-describedby')
// Find the tooltip element in the document
- const tip = document.querySelector(`#${adb}`)
+ const tip = document.getElementById(adb)
expect(tip).not.toBe(null)
expect(tip).toBeInstanceOf(HTMLElement)
expect(tip.tagName).toEqual('DIV')
expect(tip.classList.contains('tooltip')).toBe(true)
+ expect(tip.classList.contains('b-tooltip')).toBe(true)
wrapper.destroy()
-
- // Tooltip element should not be in the document
- expect(document.body.contains(tip)).toBe(false)
- expect(document.querySelector(`#${adb}`)).toBe(null)
})
it('activating trigger element (focus) opens tooltip', async () => {
@@ -295,7 +367,8 @@ describe('b-tooltip', () => {
localVue: localVue,
propsData: {
triggers: 'focus',
- show: false
+ show: false,
+ delay: 0
},
slots: {
default: 'title'
@@ -307,7 +380,11 @@ describe('b-tooltip', () => {
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect(wrapper.is('article')).toBe(true)
expect(wrapper.attributes('id')).toBeDefined()
@@ -321,13 +398,9 @@ describe('b-tooltip', () => {
expect($button.attributes('aria-describedby')).not.toBeDefined()
// wrapper
- const $tipHolder = wrapper.find('div#bar')
+ const $tipHolder = wrapper.find(BTooltip)
expect($tipHolder.exists()).toBe(true)
- // Title placeholder will be here until opened
- expect($tipHolder.findAll('div.d-none > div').length).toBe(1)
- expect($tipHolder.text()).toBe('title')
-
// Activate tooltip by trigger
$button.trigger('focusin')
await waitNT(wrapper.vm)
@@ -335,7 +408,8 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
- jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect($button.attributes('id')).toBeDefined()
expect($button.attributes('id')).toEqual('foo')
@@ -344,25 +418,27 @@ describe('b-tooltip', () => {
const adb = $button.attributes('aria-describedby')
// Find the tooltip element in the document
- const tip = document.querySelector(`#${adb}`)
+ const tip = document.getElementById(adb)
expect(tip).not.toBe(null)
expect(tip).toBeInstanceOf(HTMLElement)
expect(tip.tagName).toEqual('DIV')
expect(tip.classList.contains('tooltip')).toBe(true)
+ expect(tip.classList.contains('b-tooltip')).toBe(true)
// Deactivate tooltip by trigger
- $button.trigger('focusout')
+ $button.trigger('focusout', { relatedTarget: document.body })
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
- jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
// Tooltip element should not be in the document
expect($button.attributes('aria-describedby')).not.toBeDefined()
expect(document.body.contains(tip)).toBe(false)
- expect(document.querySelector(`#${adb}`)).toBe(null)
+ expect(document.getElementById(adb)).toBe(null)
wrapper.destroy()
})
@@ -390,6 +466,8 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect(wrapper.is('article')).toBe(true)
expect(wrapper.attributes('id')).toBeDefined()
@@ -403,13 +481,9 @@ describe('b-tooltip', () => {
expect($button.attributes('aria-describedby')).not.toBeDefined()
// wrapper
- const $tipHolder = wrapper.find('div#bar')
+ const $tipHolder = wrapper.find(BTooltip)
expect($tipHolder.exists()).toBe(true)
- // Title placeholder will be here until opened
- expect($tipHolder.findAll('div.d-none > div').length).toBe(1)
- expect($tipHolder.text()).toBe('title')
-
// Activate tooltip by trigger
$button.trigger('mouseenter')
await waitNT(wrapper.vm)
@@ -417,7 +491,8 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
- jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect($button.attributes('id')).toBeDefined()
expect($button.attributes('id')).toEqual('foo')
@@ -426,25 +501,27 @@ describe('b-tooltip', () => {
const adb = $button.attributes('aria-describedby')
// Find the tooltip element in the document
- const tip = document.querySelector(`#${adb}`)
+ const tip = document.getElementById(adb)
expect(tip).not.toBe(null)
expect(tip).toBeInstanceOf(HTMLElement)
expect(tip.tagName).toEqual('DIV')
expect(tip.classList.contains('tooltip')).toBe(true)
+ expect(tip.classList.contains('b-tooltip')).toBe(true)
// Deactivate tooltip by trigger
- $button.trigger('mouseleave')
+ $button.trigger('mouseleave', { relatedTarget: document.body })
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ jest.runOnlyPendingTimers()
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
- jest.runOnlyPendingTimers()
- jest.runOnlyPendingTimers()
// Tooltip element should not be in the document
expect($button.attributes('aria-describedby')).not.toBeDefined()
expect(document.body.contains(tip)).toBe(false)
- expect(document.querySelector(`#${adb}`)).toBe(null)
+ expect(document.getElementById(adb)).toBe(null)
wrapper.destroy()
})
@@ -471,6 +548,8 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect(wrapper.is('article')).toBe(true)
expect(wrapper.attributes('id')).toBeDefined()
@@ -484,13 +563,9 @@ describe('b-tooltip', () => {
expect($button.attributes('aria-describedby')).not.toBeDefined()
// b-tooltip wrapper
- const $tipHolder = wrapper.find('div#bar')
+ const $tipHolder = wrapper.find(BTooltip)
expect($tipHolder.exists()).toBe(true)
- // title placeholder will be here until opened
- expect($tipHolder.findAll('div.d-none > div').length).toBe(1)
- expect($tipHolder.text()).toBe('title')
-
// Try to activate tooltip by trigger
$button.trigger('click')
await waitNT(wrapper.vm)
@@ -498,13 +573,13 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
// Tooltip should not have opened
expect($button.attributes('aria-describedby')).not.toBeDefined()
- expect($tipHolder.findAll('div.d-none > div').length).toBe(1)
- expect($tipHolder.text()).toBe('title')
- // Now enabled the tooltip
+ // Now enable the tooltip
wrapper.setProps({
disabled: false
})
@@ -513,6 +588,8 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
// Try to activate tooltip by trigger
$button.trigger('click')
@@ -521,19 +598,134 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
- jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect($button.attributes('aria-describedby')).toBeDefined()
- // expect($tipHolder.findAll('div.d-none > div').length).toBe(0)
const adb = $button.attributes('aria-describedby')
// Find the tooltip element in the document
- const tip = document.querySelector(`#${adb}`)
+ const tip = document.getElementById(adb)
expect(tip).not.toBe(null)
expect(tip).toBeInstanceOf(HTMLElement)
expect(tip.tagName).toEqual('DIV')
expect(tip.classList.contains('tooltip')).toBe(true)
+ // Now disable the tooltip
+ wrapper.setProps({
+ disabled: true
+ })
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
+ // Try to close tooltip by trigger
+ $button.trigger('click')
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
+ // expect($button.attributes('aria-describedby')).not.toBeDefined()
+
+ wrapper.destroy()
+ })
+
+ it('closes/opens on instance events', async () => {
+ jest.useFakeTimers()
+ const App = localVue.extend(appDef)
+ const wrapper = mount(App, {
+ attachToDocument: true,
+ localVue: localVue,
+ propsData: {
+ triggers: 'click',
+ show: true,
+ disabled: false,
+ titleAttr: 'ignored'
+ },
+ slots: {
+ default: 'title'
+ }
+ })
+
+ expect(wrapper.isVueInstance()).toBe(true)
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
+ expect(wrapper.is('article')).toBe(true)
+ expect(wrapper.attributes('id')).toBeDefined()
+ expect(wrapper.attributes('id')).toEqual('wrapper')
+
+ // The trigger button
+ const $button = wrapper.find('button')
+ expect($button.exists()).toBe(true)
+ const adb = $button.attributes('aria-describedby')
+
+ // wrapper
+ const $tipHolder = wrapper.find(BTooltip)
+ expect($tipHolder.exists()).toBe(true)
+
+ // Find the tooltip element in the document
+ const tip = document.getElementById(adb)
+ expect(tip).not.toBe(null)
+ expect(tip).toBeInstanceOf(HTMLElement)
+ expect(tip.tagName).toEqual('DIV')
+ expect(tip.classList.contains('tooltip')).toBe(true)
+ expect(tip.classList.contains('b-tooltip')).toBe(true)
+
+ // Hide the tooltip by emitting event on instance
+ const btooltip = wrapper.find(BTooltip)
+ expect(btooltip.exists()).toBe(true)
+ btooltip.vm.$emit('close')
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
+ expect($button.attributes('aria-describedby')).not.toBeDefined()
+
+ // Tooltip element should not be in the document
+ expect(document.body.contains(tip)).toBe(false)
+ expect(document.getElementById(adb)).toBe(null)
+
+ // Show the tooltip by emitting event on instance
+ btooltip.vm.$emit('open')
+
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
+ // Tooltip element should be in the document
+ expect($button.attributes('aria-describedby')).toBeDefined()
+ expect(document.getElementById(adb)).not.toBe(null)
+
wrapper.destroy()
})
@@ -560,6 +752,8 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect(wrapper.is('article')).toBe(true)
expect(wrapper.attributes('id')).toBeDefined()
@@ -579,22 +773,16 @@ describe('b-tooltip', () => {
const adb = $button.attributes('aria-describedby')
// wrapper
- const $tipHolder = wrapper.find('div#bar')
+ const $tipHolder = wrapper.find(BTooltip)
expect($tipHolder.exists()).toBe(true)
- expect($tipHolder.classes()).toContain('d-none')
- expect($tipHolder.attributes('aria-hidden')).toBeDefined()
- expect($tipHolder.attributes('aria-hidden')).toEqual('true')
- expect($tipHolder.element.style.display).toEqual('none')
-
- // Title placeholder...
- expect($tipHolder.text()).toBe('')
// Find the tooltip element in the document
- const tip = document.querySelector(`#${adb}`)
+ const tip = document.getElementById(adb)
expect(tip).not.toBe(null)
expect(tip).toBeInstanceOf(HTMLElement)
expect(tip.tagName).toEqual('DIV')
expect(tip.classList.contains('tooltip')).toBe(true)
+ expect(tip.classList.contains('b-tooltip')).toBe(true)
// Hide the tooltip by emitting root event with correct ID (forceHide)
wrapper.vm.$root.$emit('bv::hide::tooltip', 'foo')
@@ -603,13 +791,14 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
- jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect($button.attributes('aria-describedby')).not.toBeDefined()
// Tooltip element should not be in the document
expect(document.body.contains(tip)).toBe(false)
- expect(document.querySelector(`#${adb}`)).toBe(null)
+ expect(document.getElementById(adb)).toBe(null)
wrapper.destroy()
})
@@ -637,6 +826,8 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect(wrapper.is('article')).toBe(true)
expect(wrapper.attributes('id')).toBeDefined()
@@ -656,18 +847,11 @@ describe('b-tooltip', () => {
const adb = $button.attributes('aria-describedby')
// b-tooltip wrapper
- const $tipHolder = wrapper.find('div#bar')
+ const $tipHolder = wrapper.find(BTooltip)
expect($tipHolder.exists()).toBe(true)
- expect($tipHolder.classes()).toContain('d-none')
- expect($tipHolder.attributes('aria-hidden')).toBeDefined()
- expect($tipHolder.attributes('aria-hidden')).toEqual('true')
- expect($tipHolder.element.style.display).toEqual('none')
-
- // title placeholder...
- expect($tipHolder.text()).toBe('')
// Find the tooltip element in the document
- const tip = document.querySelector(`#${adb}`)
+ const tip = document.getElementById(adb)
expect(tip).not.toBe(null)
expect(tip).toBeInstanceOf(HTMLElement)
expect(tip.tagName).toEqual('DIV')
@@ -680,13 +864,14 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
- jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect($button.attributes('aria-describedby')).toBeDefined()
- // Tooltip element should not be in the document
+ // Tooltip element should still be in the document
expect(document.body.contains(tip)).toBe(true)
- expect(document.querySelector(`#${adb}`)).not.toBe(null)
+ expect(document.getElementById(adb)).not.toBe(null)
wrapper.destroy()
})
@@ -714,6 +899,8 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect(wrapper.is('article')).toBe(true)
expect(wrapper.attributes('id')).toBeDefined()
@@ -729,26 +916,21 @@ describe('b-tooltip', () => {
expect($button.attributes('data-original-title')).toBeDefined()
expect($button.attributes('data-original-title')).toEqual('ignored')
expect($button.attributes('aria-describedby')).toBeDefined()
+
// ID of the tooltip that will be in the body
const adb = $button.attributes('aria-describedby')
// wrapper
- const $tipHolder = wrapper.find('div#bar')
+ const $tipHolder = wrapper.find(BTooltip)
expect($tipHolder.exists()).toBe(true)
- expect($tipHolder.classes()).toContain('d-none')
- expect($tipHolder.attributes('aria-hidden')).toBeDefined()
- expect($tipHolder.attributes('aria-hidden')).toEqual('true')
- expect($tipHolder.element.style.display).toEqual('none')
-
- // Title placeholder...
- expect($tipHolder.text()).toBe('')
// Find the tooltip element in the document
- const tip = document.querySelector(`#${adb}`)
+ const tip = document.getElementById(adb)
expect(tip).not.toBe(null)
expect(tip).toBeInstanceOf(HTMLElement)
expect(tip.tagName).toEqual('DIV')
expect(tip.classList.contains('tooltip')).toBe(true)
+ expect(tip.classList.contains('b-tooltip')).toBe(true)
// Hide the tooltip by emitting root event with no ID (forceHide)
wrapper.vm.$root.$emit('bv::hide::tooltip')
@@ -757,19 +939,23 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
- jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect($button.attributes('aria-describedby')).not.toBeDefined()
// Tooltip element should not be in the document
expect(document.body.contains(tip)).toBe(false)
- expect(document.querySelector(`#${adb}`)).toBe(null)
+ expect(document.getElementById(adb)).toBe(null)
wrapper.destroy()
})
it('closes when trigger element is no longer visible', async () => {
jest.useFakeTimers()
+ // Prevent warns from appearing in the test logs
+ jest.spyOn(console, 'warn').mockImplementation(() => {})
+
const App = localVue.extend(appDef)
const wrapper = mount(App, {
attachToDocument: true,
@@ -790,6 +976,10 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect(wrapper.is('article')).toBe(true)
expect(wrapper.attributes('id')).toBeDefined()
@@ -800,25 +990,17 @@ describe('b-tooltip', () => {
expect($button.exists()).toBe(true)
expect($button.attributes('id')).toBeDefined()
expect($button.attributes('id')).toEqual('foo')
- expect($button.attributes('title')).toBeDefined()
- expect($button.attributes('data-original-title')).toBeDefined()
expect($button.attributes('aria-describedby')).toBeDefined()
+
// ID of the tooltip that will be in the body
const adb = $button.attributes('aria-describedby')
// b-tooltip wrapper
- const $tipHolder = wrapper.find('div#bar')
+ const $tipHolder = wrapper.find(BTooltip)
expect($tipHolder.exists()).toBe(true)
- expect($tipHolder.classes()).toContain('d-none')
- expect($tipHolder.attributes('aria-hidden')).toBeDefined()
- expect($tipHolder.attributes('aria-hidden')).toEqual('true')
- expect($tipHolder.element.style.display).toEqual('none')
-
- // Title placeholder...
- expect($tipHolder.text()).toBe('')
// Find the tooltip element in the document
- const tip = document.querySelector(`#${adb}`)
+ const tip = document.getElementById(adb)
expect(tip).not.toBe(null)
expect(tip).toBeInstanceOf(HTMLElement)
expect(tip.tagName).toEqual('DIV')
@@ -830,26 +1012,42 @@ describe('b-tooltip', () => {
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
+ jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
// The visibility check runs on an interval of 100ms
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
jest.runOnlyPendingTimers()
-
- expect($button.attributes('aria-describedby')).not.toBeDefined()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
// Tooltip element should not be in the document
expect(document.body.contains(tip)).toBe(false)
- expect(document.querySelector(`#${adb}`)).toBe(null)
+ expect(document.getElementById(`adb`)).toBe(null)
// Try and show element via root event (using ID of trigger button)
+ // Note that this generates a console warning
wrapper.vm.$root.$emit('bv::show::tooltip', 'foo')
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
// Tooltip element should not be in the document
- expect(document.querySelector(`#${adb}`)).toBe(null)
+ expect(document.getElementById(adb)).toBe(null)
// Try and show element via root event (using show all)
wrapper.vm.$root.$emit('bv::show::tooltip')
@@ -858,9 +1056,14 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
// Tooltip element should not be in the document
- expect(document.querySelector(`#${adb}`)).toBe(null)
+ expect(document.getElementById(adb)).toBe(null)
wrapper.destroy()
})
@@ -886,6 +1089,8 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect(wrapper.is('article')).toBe(true)
expect(wrapper.attributes('id')).toBeDefined()
@@ -894,17 +1099,29 @@ describe('b-tooltip', () => {
// The trigger button
const $button = wrapper.find('button')
expect($button.exists()).toBe(true)
+
// ID of the tooltip that will be in the body
const adb = $button.attributes('aria-describedby')
+ expect(adb).not.toBe(null)
// Find the tooltip element in the document
- const tip = document.querySelector(`#${adb}`)
+ const tip = document.getElementById(adb)
expect(tip).not.toBe(null)
expect(tip).toBeInstanceOf(HTMLElement)
expect(tip.tagName).toEqual('DIV')
expect(tip.classList.contains('tooltip')).toBe(true)
expect(tip.classList.contains('b-tooltip-danger')).toBe(true)
+ // Change variant type. Should be reactive
+ wrapper.setProps({
+ variant: 'success'
+ })
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ expect(tip.classList.contains('tooltip')).toBe(true)
+ expect(tip.classList.contains('b-tooltip-success')).toBe(true)
+ expect(tip.classList.contains('b-tooltip-danger')).toBe(false)
+
wrapper.destroy()
})
@@ -929,6 +1146,8 @@ describe('b-tooltip', () => {
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect(wrapper.is('article')).toBe(true)
expect(wrapper.attributes('id')).toBeDefined()
@@ -937,17 +1156,32 @@ describe('b-tooltip', () => {
// The trigger button
const $button = wrapper.find('button')
expect($button.exists()).toBe(true)
+
// ID of the tooltip that will be in the body
const adb = $button.attributes('aria-describedby')
+ expect(adb).toBeDefined()
+ expect(adb).not.toBe('')
+ expect(adb).not.toBe(null)
// Find the tooltip element in the document
- const tip = document.querySelector(`#${adb}`)
+ const tip = document.getElementById(adb)
expect(tip).not.toBe(null)
expect(tip).toBeInstanceOf(HTMLElement)
expect(tip.tagName).toEqual('DIV')
expect(tip.classList.contains('tooltip')).toBe(true)
+ expect(tip.classList.contains('b-tooltip')).toBe(true)
expect(tip.classList.contains('foobar-class')).toBe(true)
+ // Change custom class. Should be reactive
+ wrapper.setProps({
+ customClass: 'barbaz-class'
+ })
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ expect(tip.classList.contains('tooltip')).toBe(true)
+ expect(tip.classList.contains('barbaz-class')).toBe(true)
+ expect(tip.classList.contains('foobar-class')).toBe(false)
+
wrapper.destroy()
})
})
diff --git a/src/directives/popover/README.md b/src/directives/popover/README.md
index 823834053da..b2fe6bea666 100644
--- a/src/directives/popover/README.md
+++ b/src/directives/popover/README.md
@@ -21,7 +21,6 @@ to appear.
Things to know when using popovers:
- Popovers rely on the 3rd party library [Popper.js](https://popper.js.org/) for positioning.
-- Zero-length title and content values will never show a popover.
- Specify container: 'body' (default) to avoid rendering problems in more complex components (like
input groups, button groups, etc).
- Triggering popovers on hidden elements will not work.
@@ -172,7 +171,8 @@ Positioning is relative to the trigger element.
## Triggers
Popovers can be triggered (opened/closed) via any combination of `click`, `hover` and `focus`. The
-default trigger is `click`.
+default trigger is `click`. Or a trigger of `manual` can be specified, where the popover can only be
+opened or closed [programmatically](#hiding-and-showing-popovers-via-root-events).
If a popover has more than one trigger, then all triggers must be cleared before the popover will
close. I.e. if a popover has the trigger `focus click`, and it was opened by `focus`, and the user
@@ -202,6 +202,17 @@ then clicks the trigger element, they must click it again **and** move focus to
```
+### Making popovers work for keyboard and assistive technology users
+
+You should only add popovers to HTML elements that are traditionally keyboard-focusable and
+interactive (such as links, buttons, or form controls). Although arbitrary HTML elements (such as
+``s) can be made focusable by adding the `tabindex="0"` attribute, this will add potentially
+annoying and confusing tab stops on non-interactive elements for keyboard users. In addition, most
+assistive technologies currently do not announce the popover in this situation.
+
+Additionally, do not rely solely on `hover` as the trigger for your popover, as this will make your
+popovers _impossible to trigger for keyboard-only users_.
+
### Dismiss on next click (self dismissing)
Use the `focus` trigger by itself to dismiss popovers on the next click that the user makes. `focus`
@@ -214,7 +225,7 @@ document_ - will close the popover.
This `blur` trigger must be used in combination with the `click` trigger.
-Th following example shows the `click blur` use case. Popovers will only open on click of the
+The following example shows the `click blur` use case. Popovers will only open on click of the
button, and will close either on click of the button, or a click anywhere else (or a focus change
via pressing the TAB key). Some call this behavior _self dismissing_.
@@ -395,22 +406,16 @@ property:
```
-**Note:** Custom classes will not work with scoped styles, as the popovers are appended to the
-document `` element by default.
-
## Directive syntax and usage
-```
-v-b-popover:[container].[mod].[mod].[...].[mod]=""
+```html
+">Button
```
-Where `` can be (optional):
+Where `[container]` can be (optional):
-- A string containing the **content** of the popover
-- A function reference to generate the **content** of the popover (receives one argument which is a
- reference to the DOM element triggering the popover)
-- An object containing more complex configuration of popover, See Bootstrap docs for possible
- values/structure)
+- An element ID (minus the `#`) to place the popover markup in, when visible
+- If not provided, popovers are appended to the `` when visible
Where `[mod]` can be (all optional):
@@ -419,21 +424,49 @@ Where `[mod]` can be (all optional):
`rightbottom` (last one found wins, defaults to `right`).
- Event trigger: `click`, `hover`, `focus`, `blur` (if none specified, defaults to `click`. The
`blur` trigger is a close handler only, and if specified by itself, will be converted to `focus`).
+ Use `manual` if you only want to control the visibility manually.
- `nofade` to turn off animation.
- `html` to enable rendering raw HTML. by default HTML is escaped and converted to text.
-- A delay value in the format of `d###` (where `###` is in ms, defaults to 0).
+- A delay value in the format of `d###` (where `###` is in ms, defaults to `50`), applied to both
+ `hide` and `show` (affects `hover` and `focus` only)
+- A show delay value in the format of `ds###` (where `###` is in ms, defaults to `50`), applied to
+ `show` trigger only (affects `hover` and `focus` only)
+- A hide delay value in the format of `dh###` (where `###` is in ms, defaults to `50`), applied to
+ `hide` trigger only (affects `hover` and `focus` only)
- An offset value in pixels in the format of `o###` (where `###` is the number of pixels, defaults
- to 0. Negative values are allowed). Note if an offset is supplied, then the alignment positions
+ to `0`. Negative values are allowed). Note if an offset is supplied, then the alignment positions
will fallback to one of `top`, `bottom`, `left`, or `right`.
- A boundary setting of `window` or `viewport`. The element to constrain the visual placement of the
popover. If not specified, the boundary defaults to the trigger element's scroll parent (in most
cases this will suffice).
- A contextual variant in the form of `v-XXX` (where `XXX` is the color variant name).
-Where `[container]` can be (optional):
+Where `` can be (optional):
-- An element ID (minus the #) to place the popover markup in, when visible
-- If not provided, popovers are appended to the `` when visible
+- A string containing the **content** of the popover
+- A function reference to generate the **content** of the popover (receives one argument which is a
+ reference to the DOM element triggering the popover)
+- An object containing more complex configuration of popover, See below for available options.
+
+**Options configuration object properties:**
+
+| Property | Type | Default | Description |
+| ------------------- | ----------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `animation` | Boolean | `true` | Apply a CSS fade transition to the popover. |
+| `container` | String ID or HTMLElement or `false` | `false` | Appends the popover to a specific element. Example: `container: '#body'`. This option is particularly useful in that it allows you to position the popover in the flow of the document near the triggering element - which will prevent the popover from floating away from the triggering element during a window resize. When set to `false` the popover will be appended to `body`, or if the trigger element is inside a modal it will append to the modal's container. |
+| `delay` | Number or Object | `50` | Delay showing and hiding the popover (ms). If a number is supplied, delay is applied to both hide/show. Object structure is: `delay: { "show": 500, "hide": 100 }` |
+| `html` | Boolean | `false` | Allow HTML in the popover. If true, HTML tags in the popover's title and content will be rendered in the tooltip. If false, the title and content will be inserted as plain text. Use text if you're worried about XSS attacks. |
+| `placement` | String or Function | `'top'` | How to position the popover - `auto`, `top`, `bottom`, `left`, `right`, `topleft`, `topright`, `bottomleft`, `bottomright`, `lefttop`, `leftbottom`, `righttop`, or `rightbottom`. When `auto` is specified, it will dynamically reorient the tooltip. |
+| `title` | String or Function | `''` | Default title value if title attribute isn't present. If a function is given, it must return a string. |
+| `content` | String or Function | `''` | Default content value. If a function is given, it must return a string. |
+| `trigger` | String | `'hover focus'` | How tooltip is triggered: `click`, `hover`, `focus`. You may pass multiple triggers; separate them with a space. Specify `'manual'` if you are only going to show and hide the tooltip programmatically. |
+| `offset` | Number or String | `0` | Offset of the popover relative to its target. For more information refer to Popper.js's offset docs. |
+| `fallbackPlacement` | String or Array | `'flip'` | Allow to specify which position Popper will use on fallback. Can be `flip`, `clockwise`, `counterclockwise` or an array of placements. For more information refer to Popper.js's behavior docs. |
+| `boundary` | String ID or HTMLElement | `'scrollParent'` | The container that the popover will be constrained visually. The default should suffice in most cases, but you may need to change this if your target element is in a small container with overflow scroll. Supported values: `'scrollParent'` (default), `'viewport'`, `'window'`, or a reference to an HTML element. |
+| `boundaryPadding` | Number | `5` | Amount of pixel used to define a minimum distance between the boundaries and the popover. This makes sure the popover always has a little padding between the edges of its container. |
+| `variant` | String | `null` | Contextual color variant for the popover. |
+| `customClass` | String | `null` | A custom classname to apply to the popover outer wrapper element. |
+| `id` | String | `null` | An ID to use on the popover root element. If none is provided, one will automatically be generated. If you do provide an ID, it _must_ be guaranteed to be unique on the rendered page. |
### Usage
@@ -493,14 +526,16 @@ You can close (hide) **all open popovers** by emitting the `bv::hide::popover` e
this.$root.$emit('bv::hide::popover')
```
-To close a **specific popover**, pass the trigger element's `id` as the first argument:
+To close a **specific popover**, pass the trigger element's `id`, or the `id` of the popover (if one
+was provided in the config object) as the first argument:
```js
this.$root.$emit('bv::hide::popover', 'my-trigger-button-id')
```
-To open (show) a **specific popover**, pass the trigger element's `id` as the first argument when
-emitting the `bv::show::popover` event:
+To open a **specific popover**, pass the trigger element's `id`, or the `id` of the popover (if one
+was provided in the config object) as the first argument when emitting the `bv::show::popover`
+event:
```js
this.$root.$emit('bv::show::popover', 'my-trigger-button-id')
@@ -522,14 +557,16 @@ You can disable **all** popovers by emitting the `bv::disable::popover` event on
this.$root.$emit('bv::disable::popover')
```
-To disable a **specific popover**, pass the trigger element's `id` as the first argument:
+To disable a **specific popover**, pass the trigger element's `id`, or the `id` of the popover (if
+one was provided in the config object) as the first argument:
```js
this.$root.$emit('bv::disable::popover', 'my-trigger-button-id')
```
-To enable a **specific popover**, pass the trigger element's `id` as the first argument when
-emitting the `bv::enable::popover` event:
+To enable a **specific popover**, pass the trigger element's `id`, or the `id` of the popover (if
+one was provided in the config object) as the first argument when emitting the `bv::enable::popover`
+event:
```js
this.$root.$emit('bv::enable::popover', 'my-trigger-button-id')
diff --git a/src/directives/popover/popover.js b/src/directives/popover/popover.js
index decb23995c7..05ebe68bc69 100644
--- a/src/directives/popover/popover.js
+++ b/src/directives/popover/popover.js
@@ -1,38 +1,53 @@
-import Popper from 'popper.js'
-import PopOver from '../../utils/popover.class'
-import warn from '../../utils/warn'
+import { BVPopover } from '../../components/popover/helpers/bv-popover'
+import looseEqual from '../../utils/loose-equal'
+import { concat } from '../../utils/array'
import { getComponentConfig } from '../../utils/config'
import { isBrowser } from '../../utils/env'
-import { isFunction, isObject, isString } from '../../utils/inspect'
+import { isFunction, isObject, isString, isUndefined } from '../../utils/inspect'
import { keys } from '../../utils/object'
// Key which we use to store tooltip object on element
-const BV_POPOVER = '__BV_PopOver__'
+const BV_POPOVER = '__BV_Popover__'
+
+// Default trigger
+const DefaultTrigger = 'click'
// Valid event triggers
const validTriggers = {
focus: true,
hover: true,
click: true,
- blur: true
+ blur: true,
+ manual: true
}
// Directive modifier test regular expressions. Pre-compile for performance
-const htmlRE = /^html$/
+const htmlRE = /^html$/i
const noFadeRE = /^nofade$/i
-const placementRE = /^(auto|top(left|right)?|bottom(left|right)?|left(top|bottom)?|right(top|bottom)?)$/
-const boundaryRE = /^(window|viewport|scrollParent)$/
-const delayRE = /^d\d+$/
-const offsetRE = /^o-?\d+$/
-const variantRE = /^v-.+$/
-
-// Build a PopOver config based on bindings (if any)
+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
+
+// Build a Popover config based on bindings (if any)
// Arguments and modifiers take precedence over passed value config object
-/* istanbul ignore next: not easy to test */
-const parseBindings = bindings => /* istanbul ignore next: not easy to test */ {
+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,
+ id: null,
+ html: false,
delay: getComponentConfig(NAME, 'delay'),
boundary: String(getComponentConfig(NAME, 'boundary')),
boundaryPadding: parseInt(getComponentConfig(NAME, 'boundaryPadding'), 10) || 0,
@@ -40,7 +55,7 @@ const parseBindings = bindings => /* istanbul ignore next: not easy to test */ {
customClass: getComponentConfig(NAME, 'customClass')
}
- // Process bindings.value
+ // Process `bindings.value`
if (isString(bindings.value)) {
// Value is popover content (html optionally supported)
config.content = bindings.value
@@ -59,32 +74,50 @@ const parseBindings = bindings => /* istanbul ignore next: not easy to test */ {
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 && data.attrs.title ? data.attrs.title : undefined
+ }
+
+ // Normalize delay
+ if (!isObject(config.delay)) {
+ config.delay = {
+ show: config.delay,
+ hide: config.delay
+ }
+ }
+
// Process modifiers
keys(bindings.modifiers).forEach(mod => {
if (htmlRE.test(mod)) {
- // Title allows HTML
+ // Title/content allows HTML
config.html = true
} else if (noFadeRE.test(mod)) {
- // no animation
+ // No animation
config.animation = false
} else if (placementRE.test(mod)) {
- // placement of popover
+ // 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 = parseInt(mod.slice(1), 10) || 0
- if (delay) {
- config.delay = delay
- }
+ config.delay.show = delay
+ config.delay.hide = delay
+ } else if (delayShowRE.test(mod)) {
+ // Delay show value
+ config.delay.show = parseInt(mod.slice(2), 10) || 0
+ } else if (delayHideRE.test(mod)) {
+ // Delay hide value
+ config.delay.hide = parseInt(mod.slice(2), 10) || 0
} else if (offsetRE.test(mod)) {
- // Offset value (negative allowed)
- const offset = parseInt(mod.slice(1), 10) || 0
- if (offset) {
- config.offset = offset
- }
+ // Offset value, negative allowed
+ config.offset = parseInt(mod.slice(1), 10) || 0
} else if (variantRE.test(mod)) {
// Variant
config.variant = mod.slice(2) || null
@@ -96,83 +129,115 @@ const parseBindings = bindings => /* istanbul ignore next: not easy to test */ {
const selectedTriggers = {}
// Parse current config object trigger
- const triggers = isString(config.trigger) ? config.trigger.trim().split(/\s+/) : []
- triggers.forEach(trigger => {
- if (validTriggers[trigger]) {
- selectedTriggers[trigger] = true
- }
- })
+ concat(config.trigger || '')
+ .filter(Boolean)
+ .join(' ')
+ .trim()
+ .toLowerCase()
+ .split(/\s+/)
+ .forEach(trigger => {
+ if (validTriggers[trigger]) {
+ selectedTriggers[trigger] = true
+ }
+ })
// Parse modifiers for triggers
- keys(validTriggers).forEach(trigger => {
- if (bindings.modifiers[trigger]) {
- selectedTriggers[trigger] = true
+ 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
+ // Blur by itself is useless, so convert it to 'focus'
config.trigger = 'focus'
}
if (!config.trigger) {
- // Remove trigger config
- delete config.trigger
+ // Use default trigger
+ config.trigger = DefaultTrigger
}
return config
}
-// Add or update PopOver on our element
+// Add or update Popover on our element
const applyPopover = (el, bindings, vnode) => {
if (!isBrowser) {
/* istanbul ignore next */
return
}
- // Popper is required for PopOvers to work
- if (!Popper) {
- /* istanbul ignore next */
- warn('v-b-popover: Popper.js is required for PopOvers to work')
- /* 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: $parent && $parent.$options._scopeId ? $parent.$options._scopeId : undefined
+ })
+ el[BV_POPOVER].__bv_prev_data__ = {}
}
- const config = parseBindings(bindings)
- if (el[BV_POPOVER]) {
- el[BV_POPOVER].updateConfig(config)
- } else {
- el[BV_POPOVER] = new PopOver(el, config, vnode.context)
+ 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,
+ 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])
+ ? data[prop]()
+ : data[prop]
+ }
+ })
+ el[BV_POPOVER].updateData(newData)
}
}
-// Remove PopOver on our element
+// Remove Popover from our element
const removePopover = el => {
if (el[BV_POPOVER]) {
- el[BV_POPOVER].destroy()
+ el[BV_POPOVER].$destroy()
el[BV_POPOVER] = null
- delete el[BV_POPOVER]
}
+ delete el[BV_POPOVER]
}
-/*
- * Export our directive
- */
+// Export our directive
export const VBPopover = {
bind(el, bindings, vnode) {
applyPopover(el, bindings, vnode)
},
- inserted(el, bindings, vnode) {
- applyPopover(el, bindings, vnode)
- },
- update(el, bindings, vnode) /* istanbul ignore next: not easy to test */ {
- if (bindings.value !== bindings.oldValue) {
- applyPopover(el, bindings, vnode)
- }
- },
- componentUpdated(el, bindings, vnode) /* istanbul ignore next: not easy to test */ {
- if (bindings.value !== bindings.oldValue) {
+ // 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)
diff --git a/src/directives/popover/popover.spec.js b/src/directives/popover/popover.spec.js
index 32ca53a2200..44215099b37 100644
--- a/src/directives/popover/popover.spec.js
+++ b/src/directives/popover/popover.spec.js
@@ -1,10 +1,10 @@
import { mount, createLocalVue as CreateLocalVue } from '@vue/test-utils'
import { waitNT, waitRAF } from '../../../tests/utils'
-import PopOver from '../../utils/popover.class'
-import popoverDirective from './popover'
+import { VBPopover } from './popover'
+import { BVPopover } from '../../components/popover/helpers/bv-popover'
// Key which we use to store tooltip object on element
-const BV_POPOVER = '__BV_PopOver__'
+const BV_POPOVER = '__BV_Popover__'
describe('v-b-popover directive', () => {
const originalCreateRange = document.createRange
@@ -41,12 +41,13 @@ describe('v-b-popover directive', () => {
Element.prototype.getBoundingClientRect = origGetBCR
})
- it('should have PopOver class instance', async () => {
+ it('should have BVPopover Vue instance', async () => {
+ jest.useFakeTimers()
const localVue = new CreateLocalVue()
const App = localVue.extend({
directives: {
- bPopover: popoverDirective
+ bPopover: VBPopover
},
template: `button `
})
@@ -57,12 +58,22 @@ describe('v-b-popover directive', () => {
})
expect(wrapper.isVueInstance()).toBe(true)
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
expect(wrapper.is('button')).toBe(true)
const $button = wrapper.find('button')
// Should have instance of popover class on it
expect($button.element[BV_POPOVER]).toBeDefined()
- expect($button.element[BV_POPOVER]).toBeInstanceOf(PopOver)
+ expect($button.element[BV_POPOVER]).toBeInstanceOf(BVPopover)
wrapper.destroy()
})
@@ -73,7 +84,7 @@ describe('v-b-popover directive', () => {
const App = localVue.extend({
directives: {
- bPopover: popoverDirective
+ bPopover: VBPopover
},
template: `button `
})
@@ -90,11 +101,15 @@ describe('v-b-popover directive', () => {
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
// Should have instance of popover class on it
expect($button.element[BV_POPOVER]).toBeDefined()
- expect($button.element[BV_POPOVER]).toBeInstanceOf(PopOver)
+ expect($button.element[BV_POPOVER]).toBeInstanceOf(BVPopover)
expect($button.attributes('aria-describedby')).not.toBeDefined()
@@ -104,17 +119,20 @@ describe('v-b-popover directive', () => {
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect($button.attributes('aria-describedby')).toBeDefined()
const adb = $button.attributes('aria-describedby')
- const pop = document.querySelector(`#${adb}`)
+ const pop = document.getElementById(adb)
expect(pop).not.toBe(null)
expect(pop.classList.contains('popover')).toBe(true)
+ expect(pop.classList.contains('b-popover')).toBe(true)
wrapper.destroy()
-
- expect(document.contains(pop)).toBe(false)
})
})
diff --git a/src/directives/tooltip/README.md b/src/directives/tooltip/README.md
index 9af91ea8477..12d217e5f41 100644
--- a/src/directives/tooltip/README.md
+++ b/src/directives/tooltip/README.md
@@ -19,9 +19,8 @@ appear.
Things to know when using tooltips:
- Tooltips rely on the 3rd party library [Popper.js](https://popper.js.org/) for positioning.
-- Tooltips with zero-length titles are never displayed.
-- Specify container: 'body' (default) to avoid rendering problems in more complex components (like
- input groups, button groups, etc).
+- Specify container: 'body' (the default) to avoid rendering problems in more complex components
+ (like input groups, button groups, etc).
- Triggering tooltips on hidden elements will not work.
- Tooltips for `disabled` elements must be triggered on a wrapper element.
- When triggered from hyperlinks that span multiple lines, tooltips will be centered. Use
@@ -92,16 +91,16 @@ The default position is `top`. Positioning is relative to the trigger element.
- Top
+ Top
- Right
+ Right
- Left
+ Left
- Bottom
+ Bottom
@@ -113,7 +112,8 @@ The default position is `top`. Positioning is relative to the trigger element.
## Triggers
Tooltips can be triggered (opened/closed) via any combination of `click`, `hover` and `focus`. The
-default trigger is `hover focus`.
+default trigger is `hover focus`. Or a trigger of manual can be specified, where the popover can
+only be opened or closed [programmatically](#hiding-and-showing-tooltips-via-root-events).
If a tooltip has more than one trigger, then all triggers must be cleared before the tooltip will
close. I.e. if a tooltip has the trigger `focus click`, and it was opened by `focus`, and the user
@@ -124,16 +124,16 @@ then clicks the trigger element, they must click it again **and** move focus to
- Hover + Focus
+ Hover + Focus
- Hover
+ Hover
- Click
+ Click
- Focus
+ Focus
@@ -142,6 +142,34 @@ then clicks the trigger element, they must click it again **and** move focus to
```
+### Making tooltips work for keyboard and assistive technology users
+
+You should only add tooltips to HTML elements that are traditionally keyboard-focusable and
+interactive (such as links, buttons, or form controls). Although arbitrary HTML elements (such as
+``s) can be made focusable by adding the `tabindex="0"` attribute, this will add potentially
+annoying and confusing tab stops on non-interactive elements for keyboard users. In addition, most
+assistive technologies currently do not announce the tooltip in this situation.
+
+Additionally, do not rely solely on `hover` as the trigger for your tooltip, as this will make your
+tooltips _impossible to trigger for keyboard-only users_.
+
+### Disabled elements
+
+Elements with the `disabled` attribute aren’t interactive, meaning users cannot focus, hover, or
+click them to trigger a tooltip (or popover). As a workaround, you’ll want to trigger the tooltip
+from a wrapper `` or `
`, ideally made keyboard-focusable using `tabindex="0"`, and
+override the `pointer-events` on the disabled element.
+
+```html
+
+
+ Disabled button
+
+
+
+
+```
+
### Dismiss on next click
Use both `click` and `blur` if you would like a tooltip that opens only on click of the element, but
@@ -178,14 +206,16 @@ const options = {
}
```
-Title can also be a function reference, which is called each time the tooltip is opened.
+Title can also be a function reference, which is called _once_ each time the tooltip is opened. To
+make a title returned by a function reactive, set the title to a _new_ function reference whenever
+the content changes.
```html
- Title
+ Title
String
@@ -204,11 +234,22 @@ Title can also be a function reference, which is called each time the tooltip is
export default {
data() {
return {
- tipData: 'Tooltip Message '
+ tipData: { title: 'Tooltip Message ' },
+ date: new Date(),
+ timer: null
}
},
+ mounted() {
+ this.timer = setInterval(() => {
+ this.date = new Date()
+ }, 1000)
+ },
+ beforeDestroy() {
+ clearInterval(this.timer)
+ },
methods: {
tipMethod() {
+ // Note this is called only once when the tooltip is opened
return '' + new Date() + ' '
}
}
@@ -250,33 +291,36 @@ property:
Button
```
-**Note:** Custom classes will not work with scoped styles, as the tooltips are appended to the
-document `` element by default.
-
## Directive syntax and usage
-```
-v-b-tooltip:[container].[mod1].[mod2].[...].[modN]=""
+```html
+">Button
```
-Where [container] can be (optional)
+Where `[container]` can be (optional):
- An element ID (minus the #) to place the tooltip markup in
- If not provided, tooltips are appended to the `body`. If the trigger element is inside a modal,
the tooltip will be appended to the modal's container
-Where [modX] can be (all optional):
+Where `[modX]` can be (all optional):
- Positioning: `top`, `bottom`, `left`, `right`, `auto`, `topleft`, `topright`, `bottomleft`,
`bottomright`, `lefttop`, `leftbottom`, `righttop`, or `rightbottom` (last one found wins,
defaults to `top`)
- Event trigger: `click`, `hover`, `focus`, `blur` (if none specified, defaults to `focus` and
- `hover`. `blur` is a close handler only, and if specified by itself, will be converted to `focus`)
+ `hover`. `blur` is a close handler only, and if specified by itself, will be converted to
+ `focus`). Use `manual` if you only want to control the visibility manually.
- `nofade` to turn off animation
- `html` to enable rendering raw HTML. By default HTML is escaped and converted to text
-- A delay value in the format of `d###` (where `###` is in ms, defaults to 0)
+- A delay value in the format of `d###` (where `###` is in ms, defaults to `50`), applied to both
+ `hide` and `show` (affects `hover` and `focus` only)
+- A show delay value in the format of `ds###` (where `###` is in ms, defaults to `50`), applied to
+ `show` trigger only (affects `hover` and `focus` only)
+- A hide delay value in the format of `dh###` (where `###` is in ms, defaults to `50`), applied to
+ `hide` trigger only (affects `hover` and `focus` only)
- An offset value in pixels in the format of `o###` (where `###` is the number of pixels, defaults
- to 0. Negative values allowed)
+ to `0`. Negative values allowed)
- A boundary setting of `window` or `viewport`. The element to constrain the visual placement of the
tooltip. If not specified, the boundary defaults to the trigger element's scroll parent (in most
cases this will suffice)
@@ -292,21 +336,22 @@ Where `` can be (optional):
**Options configuration object properties:**
-| Property | Type | Default | Description |
-| ------------------- | ------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `animation` | boolean | `true` | Apply a CSS fade transition to the tooltip. |
-| `container` | string or Element or `false` | `false` | Appends the tooltip to a specific element. Example: `container: 'body'`. This option is particularly useful in that it allows you to position the tooltip in the flow of the document near the triggering element - which will prevent the tooltip from floating away from the triggering element during a window resize. When set to `false` the tooltip will be appended to `body`, or if the trigger element is inside a modal it will append to the modal's container. |
-| `delay` | Number or Object | `0` | Delay showing and hiding the tooltip (ms). If a number is supplied, delay is applied to both hide/show. Object structure is: `delay: { "show": 500, "hide": 100 }` |
-| `html` | Boolean | `false` | Allow HTML in the tooltip. If true, HTML tags in the tooltip's title will be rendered in the tooltip. If false, the title will be inserted as plain text. Use text if you're worried about XSS attacks. |
-| `placement` | String or Function | `'top'` | How to position the tooltip - `auto`, `top`, `bottom`, `left`, `right`, `topleft`, `topright`, `bottomleft`, `bottomright`, `lefttop`, `leftbottom`, `righttop`, or `rightbottom`. When `auto` is specified, it will dynamically reorient the tooltip. |
-| `title` | String or Element or function | `''` | Default title value if title attribute isn't present. If a function is given, it must return a string. |
-| `trigger` | String | `'hover focus'` | How tooltip is triggered: `click`, `hover`, `focus`. You may pass multiple triggers; separate them with a space. |
-| `offset` | Number or String | `0` | Offset of the tooltip relative to its target. For more information refer to Popper.js's offset docs. |
-| `fallbackPlacement` | String or Array | `'flip'` | Allow to specify which position Popper will use on fallback. Can be `flip`, `clockwise`, `counterclockwise` or an array of placements. For more information refer to Popper.js's behavior docs. |
-| `boundary` | String or HTMLElement reference | `'scrollParent'` | The container that the tooltip will be constrained visually. The default should suffice in most cases, but you may need to change this if your target element is in a small container with overflow scroll. Supported values: `'scrollParent'` (default), `'viewport'`, `'window'`, or a reference to an HTML element. |
-| `boundaryPadding` | Number | `5` | Amount of pixel used to define a minimum distance between the boundaries and the tooltip. This makes sure the tooltip always has a little padding between the edges of its container. |
-| `variant` | String | `null` | Contextual color variant for the tooltip. |
-| `customClass` | String | `null` | A custom classname to apply to the tooltip outer wrapper element. |
+| Property | Type | Default | Description |
+| ------------------- | ----------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `animation` | Boolean | `true` | Apply a CSS fade transition to the tooltip. |
+| `container` | String ID or HTMLElement or `false` | `false` | Appends the tooltip to a specific element. Example: `container: '#body'`. This option is particularly useful in that it allows you to position the tooltip in the flow of the document near the triggering element - which will prevent the tooltip from floating away from the triggering element during a window resize. When set to `false` the tooltip will be appended to `body`, or if the trigger element is inside a modal it will append to the modal's container. |
+| `delay` | Number or Object | `50` | Delay showing and hiding the tooltip (ms). If a number is supplied, delay is applied to both hide/show. Object structure is: `delay: { "show": 500, "hide": 100 }` |
+| `html` | Boolean | `false` | Allow HTML in the tooltip. If true, HTML tags in the tooltip's title will be rendered in the tooltip. If false, the title will be inserted as plain text. Use text if you're worried about XSS attacks. |
+| `placement` | String or Function | `'top'` | How to position the tooltip - `auto`, `top`, `bottom`, `left`, `right`, `topleft`, `topright`, `bottomleft`, `bottomright`, `lefttop`, `leftbottom`, `righttop`, or `rightbottom`. When `auto` is specified, it will dynamically reorient the tooltip. |
+| `title` | String or Element or Function | `''` | Default title value if title attribute isn't present. If a function is given, it must return a string. |
+| `trigger` | String | `'hover focus'` | How tooltip is triggered: `click`, `hover`, `focus`. You may pass multiple triggers; separate them with a space. |
+| `offset` | Number or String | `0` | Offset of the tooltip relative to its target. For more information refer to Popper.js's offset docs. |
+| `fallbackPlacement` | String or Array | `'flip'` | Allow to specify which position Popper will use on fallback. Can be `flip`, `clockwise`, `counterclockwise` or an array of placements. For more information refer to Popper.js's behavior docs. |
+| `boundary` | String ID or HTMLElement | `'scrollParent'` | The container that the tooltip will be constrained visually. The default should suffice in most cases, but you may need to change this if your target element is in a small container with overflow scroll. Supported values: `'scrollParent'` (default), `'viewport'`, `'window'`, or a reference to an HTML element. |
+| `boundaryPadding` | Number | `5` | Amount of pixel used to define a minimum distance between the boundaries and the tooltip. This makes sure the tooltip always has a little padding between the edges of its container. |
+| `variant` | String | `null` | Contextual color variant for the tooltip. |
+| `customClass` | String | `null` | A custom classname to apply to the tooltip outer wrapper element. |
+| `id` | String | `null` | An ID to use on the tooltip root element. If none is provided, one will automatically be generated. If you do provide an ID, it _must_ be guaranteed to be unique on the rendered page. |
### Usage
@@ -366,14 +411,16 @@ You can close (hide) **all open tooltips** by emitting the `bv::hide::tooltip` e
this.$root.$emit('bv::hide::tooltip')
```
-To close a **specific tooltip**, pass the trigger element's `id` as the first argument:
+To close a **specific tooltip**, pass the trigger element's `id`, or the `id` of the tooltip (if one
+was provided in the config object) as the first argument:
```js
this.$root.$emit('bv::show::tooltip', 'my-trigger-button-id')
```
-To open a **specific tooltip**, pass the trigger element's `id` as the first argument when emitting
-the `bv::show::tooltip` \$root event:
+To open a **specific tooltip**, pass the trigger element's `id`, or the `id` of the tooltip (if one
+was provided in the config object) as the first argument when emitting the `bv::show::tooltip`
+\$root event:
```js
this.$root.$emit('bv::show::tooltip', 'my-trigger-button-id')
@@ -395,14 +442,16 @@ You can disable **all open tooltips** by emitting the `bv::disable::tooltip` eve
this.$root.$emit('bv::disable::tooltip')
```
-To disable a **specific tooltip**, pass the trigger element's `id` as the first argument:
+To disable a **specific tooltip**, pass the trigger element's `id`, or the `id` of the tooltip (if
+one was provided in the config object) as the first argument:
```js
this.$root.$emit('bv::disable::tooltip', 'my-trigger-button-id')
```
-To enable a **specific tooltip**, pass the trigger element's `id` as the first argument when
-emitting the `bv::enable::tooltip` \$root event:
+To enable a **specific tooltip**, pass the trigger element's `id`, or the `id` of the tooltip (if
+one was provided in the config object) as the first argument when emitting the `bv::enable::tooltip`
+\$root event:
```js
this.$root.$emit('bv::enable::tooltip', 'my-trigger-button-id')
diff --git a/src/directives/tooltip/tooltip.js b/src/directives/tooltip/tooltip.js
index f960d226f92..1fcf2be43b3 100644
--- a/src/directives/tooltip/tooltip.js
+++ b/src/directives/tooltip/tooltip.js
@@ -1,38 +1,53 @@
-import Popper from 'popper.js'
-import ToolTip from '../../utils/tooltip.class'
-import warn from '../../utils/warn'
+import { BVTooltip } from '../../components/tooltip/helpers/bv-tooltip'
+import looseEqual from '../../utils/loose-equal'
+import { concat } from '../../utils/array'
import { getComponentConfig } from '../../utils/config'
import { isBrowser } from '../../utils/env'
-import { isFunction, isObject, isString } from '../../utils/inspect'
+import { isFunction, isObject, isString, isUndefined } from '../../utils/inspect'
import { keys } from '../../utils/object'
// Key which we use to store tooltip object on element
-const BV_TOOLTIP = '__BV_ToolTip__'
+const BV_TOOLTIP = '__BV_Tooltip__'
+
+// Default trigger
+const DefaultTrigger = 'hover focus'
// Valid event triggers
const validTriggers = {
focus: true,
hover: true,
click: true,
- blur: true
+ blur: true,
+ manual: true
}
// Directive modifier test regular expressions. Pre-compile for performance
-const htmlRE = /^html$/
+const htmlRE = /^html$/i
const noFadeRE = /^nofade$/i
-const placementRE = /^(auto|top(left|right)?|bottom(left|right)?|left(top|bottom)?|right(top|bottom)?)$/
-const boundaryRE = /^(window|viewport|scrollParent)$/
-const delayRE = /^d\d+$/
-const offsetRE = /^o-?\d+$/
-const variantRE = /^v-.+$/
-
-// Build a ToolTip config based on bindings (if any)
+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
+
+// Build a Tooltip config based on bindings (if any)
// Arguments and modifiers take precedence over passed value config object
-/* istanbul ignore next: not easy to test */
-const parseBindings = bindings => /* istanbul ignore next: not easy to test */ {
+const parseBindings = (bindings, vnode) => /* istanbul ignore next: not easy to test */ {
// We start out with a basic config
const NAME = 'BTooltip'
+ // Default config
let config = {
+ title: undefined,
+ trigger: '', // Default set below if needed
+ placement: 'top',
+ fallbackPlacement: 'flip',
+ container: false, // Default of body
+ animation: true,
+ offset: 0,
+ id: null,
+ html: false,
delay: getComponentConfig(NAME, 'delay'),
boundary: String(getComponentConfig(NAME, 'boundary')),
boundaryPadding: parseInt(getComponentConfig(NAME, 'boundaryPadding'), 10) || 0,
@@ -40,9 +55,9 @@ const parseBindings = bindings => /* istanbul ignore next: not easy to test */ {
customClass: getComponentConfig(NAME, 'customClass')
}
- // Process bindings.value
+ // Process `bindings.value`
if (isString(bindings.value)) {
- // Value is tooltip content (html optionally supported)
+ // Value is tooltip content (HTML optionally supported)
config.title = bindings.value
} else if (isFunction(bindings.value)) {
// Title generator function
@@ -52,6 +67,21 @@ const parseBindings = bindings => /* istanbul ignore next: not easy to test */ {
config = { ...config, ...bindings.value }
}
+ // If title is not provided, try title attribute
+ if (isUndefined(config.title)) {
+ // Try attribute
+ const data = vnode.data || {}
+ config.title = data.attrs && data.attrs.title ? data.attrs.title : ''
+ }
+
+ // Normalize delay
+ if (!isObject(config.delay)) {
+ config.delay = {
+ show: config.delay,
+ hide: config.delay
+ }
+ }
+
// If argument, assume element ID of container element
if (bindings.arg) {
// Element ID specified as arg
@@ -72,19 +102,22 @@ const parseBindings = bindings => /* istanbul ignore next: not easy to test */ {
config.placement = mod
} else if (boundaryRE.test(mod)) {
// Boundary of tooltip
+ mod = mod === 'scrollparent' ? 'scrollParent' : mod
config.boundary = mod
} else if (delayRE.test(mod)) {
// Delay value
const delay = parseInt(mod.slice(1), 10) || 0
- if (delay) {
- config.delay = delay
- }
+ config.delay.show = delay
+ config.delay.hide = delay
+ } else if (delayShowRE.test(mod)) {
+ // Delay show value
+ config.delay.show = parseInt(mod.slice(2), 10) || 0
+ } else if (delayHideRE.test(mod)) {
+ // Delay hide value
+ config.delay.hide = parseInt(mod.slice(2), 10) || 0
} else if (offsetRE.test(mod)) {
// Offset value, negative allowed
- const offset = parseInt(mod.slice(1), 10) || 0
- if (offset) {
- config.offset = offset
- }
+ config.offset = parseInt(mod.slice(1), 10) || 0
} else if (variantRE.test(mod)) {
// Variant
config.variant = mod.slice(2) || null
@@ -96,17 +129,24 @@ const parseBindings = bindings => /* istanbul ignore next: not easy to test */ {
const selectedTriggers = {}
// Parse current config object trigger
- const triggers = isString(config.trigger) ? config.trigger.trim().split(/\s+/) : []
- triggers.forEach(trigger => {
- if (validTriggers[trigger]) {
- selectedTriggers[trigger] = true
- }
- })
+ concat(config.trigger || '')
+ .filter(Boolean)
+ .join(' ')
+ .trim()
+ .toLowerCase()
+ .split(/\s+/)
+ .forEach(trigger => {
+ if (validTriggers[trigger]) {
+ selectedTriggers[trigger] = true
+ }
+ })
// Parse modifiers for triggers
- keys(validTriggers).forEach(trigger => {
- if (bindings.modifiers[trigger]) {
- selectedTriggers[trigger] = true
+ keys(bindings.modifiers).forEach(mod => {
+ mod = mod.toLowerCase()
+ if (validTriggers[mod]) {
+ // If modifier is a valid trigger
+ selectedTriggers[mod] = true
}
})
@@ -117,62 +157,84 @@ const parseBindings = bindings => /* istanbul ignore next: not easy to test */ {
config.trigger = 'focus'
}
if (!config.trigger) {
- // Remove trigger config
- delete config.trigger
+ // Use default trigger
+ config.trigger = DefaultTrigger
}
+ // Return the config
return config
}
-// Add or update ToolTip on our element
+// Add/update Tooltip on our element
const applyTooltip = (el, bindings, vnode) => {
if (!isBrowser) {
/* istanbul ignore next */
return
}
- if (!Popper) {
- // Popper is required for ToolTips to work
- /* istanbul ignore next */
- warn('v-b-tooltip: Popper.js is required for ToolTips to work')
- /* istanbul ignore next */
- return
+ const config = parseBindings(bindings, vnode)
+ if (!el[BV_TOOLTIP]) {
+ const $parent = vnode.context
+ el[BV_TOOLTIP] = new BVTooltip({
+ parent: $parent,
+ // Add the parent's scoped style attribute data
+ _scopeId: $parent && $parent.$options._scopeId ? $parent.$options._scopeId : undefined
+ })
+ el[BV_TOOLTIP].__bv_prev_data__ = {}
}
- const config = parseBindings(bindings)
- if (el[BV_TOOLTIP]) {
- el[BV_TOOLTIP].updateConfig(config)
- } else {
- el[BV_TOOLTIP] = new ToolTip(el, config, vnode.context)
+ const data = {
+ title: config.title,
+ 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,
+ html: config.html
+ }
+ const oldData = el[BV_TOOLTIP].__bv_prev_data__
+ el[BV_TOOLTIP].__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 is a function, we execute it here
+ newData[prop] = prop === 'title' && isFunction(data[prop]) ? data[prop]() : data[prop]
+ }
+ })
+ el[BV_TOOLTIP].updateData(newData)
}
}
-// Remove ToolTip on our element
+// Remove Tooltip on our element
const removeTooltip = el => {
if (el[BV_TOOLTIP]) {
- el[BV_TOOLTIP].destroy()
+ el[BV_TOOLTIP].$destroy()
el[BV_TOOLTIP] = null
- delete el[BV_TOOLTIP]
}
+ delete el[BV_TOOLTIP]
}
-/*
- * Export our directive
- */
+// Export our directive
export const VBTooltip = {
bind(el, bindings, vnode) {
applyTooltip(el, bindings, vnode)
},
- inserted(el, bindings, vnode) {
- applyTooltip(el, bindings, vnode)
- },
- update(el, bindings, vnode) /* istanbul ignore next: not easy to test */ {
- if (bindings.value !== bindings.oldValue) {
- applyTooltip(el, bindings, vnode)
- }
- },
- componentUpdated(el, bindings, vnode) /* istanbul ignore next: not easy to test */ {
- if (bindings.value !== bindings.oldValue) {
+ // 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 render update loops
+ vnode.context.$nextTick(() => {
applyTooltip(el, bindings, vnode)
- }
+ })
},
unbind(el) {
removeTooltip(el)
diff --git a/src/directives/tooltip/tooltip.spec.js b/src/directives/tooltip/tooltip.spec.js
index 7239a67f14f..d7d621b2ece 100644
--- a/src/directives/tooltip/tooltip.spec.js
+++ b/src/directives/tooltip/tooltip.spec.js
@@ -1,10 +1,10 @@
import { mount, createLocalVue as CreateLocalVue } from '@vue/test-utils'
import { waitNT, waitRAF } from '../../../tests/utils'
-import ToolTip from '../../utils/tooltip.class'
-import tooltipDirective from './tooltip'
+import { VBTooltip } from './tooltip'
+import { BVTooltip } from '../../components/tooltip/helpers/bv-tooltip'
// Key which we use to store tooltip object on element
-const BV_TOOLTIP = '__BV_ToolTip__'
+const BV_TOOLTIP = '__BV_Tooltip__'
describe('v-b-tooltip directive', () => {
const originalCreateRange = document.createRange
@@ -41,12 +41,13 @@ describe('v-b-tooltip directive', () => {
Element.prototype.getBoundingClientRect = origGetBCR
})
- it('should have ToolTip class instance', async () => {
+ it('should have BVTooltip Vue class instance', async () => {
+ jest.useFakeTimers()
const localVue = new CreateLocalVue()
const App = localVue.extend({
directives: {
- bTooltip: tooltipDirective
+ bTooltip: VBTooltip
},
template: 'button '
})
@@ -57,12 +58,22 @@ describe('v-b-tooltip directive', () => {
})
expect(wrapper.isVueInstance()).toBe(true)
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
expect(wrapper.is('button')).toBe(true)
const $button = wrapper.find('button')
// Should have instance of popover class on it
expect($button.element[BV_TOOLTIP]).toBeDefined()
- expect($button.element[BV_TOOLTIP]).toBeInstanceOf(ToolTip)
+ expect($button.element[BV_TOOLTIP]).toBeInstanceOf(BVTooltip)
wrapper.destroy()
})
@@ -73,7 +84,7 @@ describe('v-b-tooltip directive', () => {
const App = localVue.extend({
directives: {
- bTooltip: tooltipDirective
+ bTooltip: VBTooltip
},
template: 'button '
})
@@ -84,17 +95,22 @@ describe('v-b-tooltip directive', () => {
})
expect(wrapper.isVueInstance()).toBe(true)
- expect(wrapper.is('button')).toBe(true)
- const $button = wrapper.find('button')
+ await waitNT(wrapper.vm)
+ await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
+ expect(wrapper.is('button')).toBe(true)
+ const $button = wrapper.find('button')
// Should have instance of popover class on it
expect($button.element[BV_TOOLTIP]).toBeDefined()
- expect($button.element[BV_TOOLTIP]).toBeInstanceOf(ToolTip)
+ expect($button.element[BV_TOOLTIP]).toBeInstanceOf(BVTooltip)
expect($button.attributes('aria-describedby')).not.toBeDefined()
@@ -104,7 +120,11 @@ describe('v-b-tooltip directive', () => {
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect($button.attributes('aria-describedby')).toBeDefined()
const adb = $button.attributes('aria-describedby')
@@ -122,7 +142,7 @@ describe('v-b-tooltip directive', () => {
const App = localVue.extend({
directives: {
- bTooltip: tooltipDirective
+ bTooltip: VBTooltip
},
template: `button `
})
@@ -139,7 +159,11 @@ describe('v-b-tooltip directive', () => {
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
// Trigger click
$button.trigger('click')
@@ -147,7 +171,11 @@ describe('v-b-tooltip directive', () => {
await waitRAF()
await waitNT(wrapper.vm)
await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
jest.runOnlyPendingTimers()
+ await waitNT(wrapper.vm)
+ await waitRAF()
expect($button.attributes('aria-describedby')).toBeDefined()
const adb = $button.attributes('aria-describedby')
diff --git a/src/mixins/toolpop.js b/src/mixins/toolpop.js
deleted file mode 100644
index e2757538a3c..00000000000
--- a/src/mixins/toolpop.js
+++ /dev/null
@@ -1,346 +0,0 @@
-/*
- * Tooltip/Popover component mixin
- * Common props
- */
-
-import observeDom from '../utils/observe-dom'
-import { isElement, getById } from '../utils/dom'
-import { isArray, isFunction, isObject, isString } from '../utils/inspect'
-import { HTMLElement } from '../utils/safe-types'
-
-// --- Constants ---
-
-const PLACEMENTS = {
- top: 'top',
- topleft: 'topleft',
- topright: 'topright',
- right: 'right',
- righttop: 'righttop',
- rightbottom: 'rightbottom',
- bottom: 'bottom',
- bottomleft: 'bottomleft',
- bottomright: 'bottomright',
- left: 'left',
- lefttop: 'lefttop',
- leftbottom: 'leftbottom',
- auto: 'auto'
-}
-
-const OBSERVER_CONFIG = {
- subtree: true,
- childList: true,
- characterData: true,
- attributes: true,
- attributeFilter: ['class', 'style']
-}
-
-// @vue/component
-export default {
- props: {
- target: {
- // String ID of element, or element/component reference
- type: [String, Object, HTMLElement, Function]
- // default: undefined
- },
- offset: {
- type: [Number, String],
- default: 0
- },
- noFade: {
- type: Boolean,
- default: false
- },
- container: {
- // String ID of container, if null body is used (default)
- type: String,
- default: null
- },
- show: {
- type: Boolean,
- default: false
- },
- disabled: {
- type: Boolean,
- default: false
- }
- },
- data() {
- return {
- // semaphore for preventing multiple show events
- localShow: false
- }
- },
- computed: {
- baseConfig() {
- const cont = this.container
- const delay = isObject(this.delay) ? this.delay : parseInt(this.delay, 10) || 0
- return {
- // Title prop
- title: (this.title || '').trim() || '',
- // Content prop (if popover)
- content: (this.content || '').trim() || '',
- // Tooltip/Popover placement
- placement: PLACEMENTS[this.placement] || 'auto',
- // Tooltip/popover fallback placement
- fallbackPlacement: this.fallbackPlacement || 'flip',
- // Container currently needs to be an ID with '#' prepended, if null then body is used
- container: cont ? (/^#/.test(cont) ? cont : `#${cont}`) : false,
- // boundariesElement passed to popper
- boundary: this.boundary,
- // boundariesElement padding passed to popper
- boundaryPadding: this.boundaryPadding,
- // Show/Hide delay
- delay: delay || 0,
- // Offset can be css distance. if no units, pixels are assumed
- offset: this.offset || 0,
- // Disable fade Animation?
- animation: !this.noFade,
- // Variant
- variant: this.variant,
- // Custom class
- customClass: this.customClass,
- // Open/Close Trigger(s)
- trigger: isArray(this.triggers) ? this.triggers.join(' ') : this.triggers,
- // Callbacks so we can trigger events on component
- callbacks: {
- show: this.onShow,
- shown: this.onShown,
- hide: this.onHide,
- hidden: this.onHidden,
- enabled: this.onEnabled,
- disabled: this.onDisabled
- }
- }
- }
- },
- watch: {
- show(show, old) {
- if (show !== old) {
- show ? this.onOpen() : this.onClose()
- }
- },
- disabled(disabled, old) {
- if (disabled !== old) {
- disabled ? this.onDisable() : this.onEnable()
- }
- },
- localShow(show, old) {
- if (show !== this.show) {
- this.$emit('update:show', show)
- }
- }
- },
- created() {
- // Create non-reactive property
- this._toolpop = null
- this._obs_title = null
- this._obs_content = null
- },
- mounted() {
- // We do this in a next tick to ensure DOM has rendered first
- this.$nextTick(() => {
- // Instantiate ToolTip/PopOver on target
- // The createToolpop method must exist in main component
- if (this.createToolpop()) {
- if (this.disabled) {
- // Initially disabled
- this.onDisable()
- }
- // Listen to open signals from others
- this.$on('open', this.onOpen)
- // Listen to close signals from others
- this.$on('close', this.onClose)
- // Listen to disable signals from others
- this.$on('disable', this.onDisable)
- // Listen to enable signals from others
- this.$on('enable', this.onEnable)
- // Observe content Child changes so we can notify popper of possible size change
- this.setObservers(true)
- // Set initially open state
- if (this.show) {
- this.onOpen()
- }
- }
- })
- },
- updated() {
- // If content/props changes, etc
- if (this._toolpop) {
- this._toolpop.updateConfig(this.getConfig())
- }
- },
- activated() /* istanbul ignore next: can't easily test in JSDOM */ {
- // Called when component is inside a and component brought offline
- this.setObservers(true)
- },
- deactivated() /* istanbul ignore next: can't easily test in JSDOM */ {
- // Called when component is inside a and component taken offline
- if (this._toolpop) {
- this.setObservers(false)
- this._toolpop.hide()
- }
- },
- beforeDestroy() {
- // Shutdown our local event listeners
- this.$off('open', this.onOpen)
- this.$off('close', this.onClose)
- this.$off('disable', this.onDisable)
- this.$off('enable', this.onEnable)
- this.setObservers(false)
- // bring our content back if needed
- this.bringItBack()
- if (this._toolpop) {
- this._toolpop.destroy()
- this._toolpop = null
- }
- },
- methods: {
- getConfig() {
- const cfg = { ...this.baseConfig }
- if (this.$refs.title && this.$refs.title.innerHTML.trim()) {
- // If slot has content, it overrides 'title' prop
- // We use the DOM node as content to allow components!
- cfg.title = this.$refs.title
- cfg.html = true
- }
- if (this.$refs.content && this.$refs.content.innerHTML.trim()) {
- // If slot has content, it overrides 'content' prop
- // We use the DOM node as content to allow components!
- cfg.content = this.$refs.content
- cfg.html = true
- }
- return cfg
- },
- onOpen() {
- if (this._toolpop && !this.localShow) {
- this.localShow = true
- this._toolpop.show()
- }
- },
- onClose(callback) {
- // What is callback for ? it is not documented
- /* istanbul ignore else */
- if (this._toolpop && this.localShow) {
- this._toolpop.hide(callback)
- } else if (isFunction(callback)) {
- // Is this even used?
- callback()
- }
- },
- onDisable() {
- if (this._toolpop) {
- this._toolpop.disable()
- }
- },
- onEnable() {
- if (this._toolpop) {
- this._toolpop.enable()
- }
- },
- updatePosition() {
- /* istanbul ignore next: can't test in JSDOM until mutation observer is implemented */
- if (this._toolpop) {
- // Instruct popper to reposition popover if necessary
- this._toolpop.update()
- }
- },
- getTarget() {
- let target = this.target
- if (isFunction(target)) {
- /* istanbul ignore next */
- target = target()
- }
- /* istanbul ignore else */
- if (isString(target)) {
- // Assume ID of element
- return getById(target)
- } else if (isObject(target) && isElement(target.$el)) {
- // Component reference
- /* istanbul ignore next */
- return target.$el
- } else if (isObject(target) && isElement(target)) {
- // Element reference
- /* istanbul ignore next */
- return target
- }
- /* istanbul ignore next */
- return null
- },
- // Callbacks called by Tooltip/Popover class instance
- onShow(evt) {
- this.$emit('show', evt)
- this.localShow = !(evt && evt.defaultPrevented)
- },
- onShown(evt) {
- this.setObservers(true)
- this.$emit('shown', evt)
- this.localShow = true
- },
- onHide(evt) {
- this.$emit('hide', evt)
- this.localShow = !!(evt && evt.defaultPrevented)
- },
- onHidden(evt) {
- this.setObservers(false)
- // bring our content back if needed to keep Vue happy
- // Tooltip class will move it back to tip when shown again
- this.bringItBack()
- this.$emit('hidden', evt)
- this.localShow = false
- },
- onEnabled(evt) {
- /* istanbul ignore next */
- if (!evt || evt.type !== 'enabled') {
- // Prevent possible endless loop if user mistakenly fires enabled instead of enable
- return
- }
- this.$emit('update:disabled', false)
- this.$emit('disabled')
- },
- onDisabled(evt) {
- /* istanbul ignore next */
- if (!evt || evt.type !== 'disabled') {
- // Prevent possible endless loop if user mistakenly fires disabled instead of disable
- return
- }
- this.$emit('update:disabled', true)
- this.$emit('enabled')
- },
- bringItBack() {
- // bring our content back if needed to keep Vue happy
- if (this.$el && this.$refs.title) {
- this.$el.appendChild(this.$refs.title)
- }
- if (this.$el && this.$refs.content) {
- this.$el.appendChild(this.$refs.content)
- }
- },
- setObservers(on) {
- if (on) {
- if (this.$refs.title) {
- this._obs_title = observeDom(
- this.$refs.title,
- this.updatePosition.bind(this),
- OBSERVER_CONFIG
- )
- }
- if (this.$refs.content) {
- this._obs_content = observeDom(
- this.$refs.content,
- this.updatePosition.bind(this),
- OBSERVER_CONFIG
- )
- }
- } else {
- if (this._obs_title) {
- this._obs_title.disconnect()
- this._obs_title = null
- }
- if (this._obs_content) {
- this._obs_content.disconnect()
- this._obs_content = null
- }
- }
- }
- }
-}
diff --git a/src/utils/bv-transition.js b/src/utils/bv-transition.js
index 753205b1be9..320acb80b7e 100644
--- a/src/utils/bv-transition.js
+++ b/src/utils/bv-transition.js
@@ -1,4 +1,8 @@
// Generic Bootstrap v4 fade (no-fade) transition component
+//
+// Assumes that `show` class is not required when
+// the transition has finished the enter transition
+// (show and fade classes are only applied during transition)
import Vue from './vue'
import { mergeData } from 'vue-functional-data-merge'
@@ -30,7 +34,13 @@ export const BVTransition = /*#__PURE__*/ Vue.extend({
type: Boolean,
default: false
},
+ appear: {
+ // Has no effect if `trans-props` provided
+ type: Boolean,
+ default: false
+ },
mode: {
+ // Can be overridden by user supplied trans-props
type: String
// default: undefined
},
@@ -44,6 +54,16 @@ export const BVTransition = /*#__PURE__*/ Vue.extend({
let transProps = props.transProps
if (!isPlainObject(transProps)) {
transProps = props.noFade ? NO_FADE_PROPS : FADE_PROPS
+ if (props.appear) {
+ // Default the appear classes to equal the enter classes
+ transProps = {
+ ...transProps,
+ appear: true,
+ appearClass: transProps.enterClass,
+ appearActiveClass: transProps.enterActiveClass,
+ appearToClass: transProps.enterToClass
+ }
+ }
}
transProps = {
mode: props.mode,
@@ -53,7 +73,7 @@ export const BVTransition = /*#__PURE__*/ Vue.extend({
}
return h(
'transition',
- // Any listeners will get merged here
+ // Any transition event listeners will get merged here
mergeData(data, { props: transProps }),
children
)
diff --git a/src/utils/config-defaults.js b/src/utils/config-defaults.js
index 7ce890b46fc..f0e29e2657c 100644
--- a/src/utils/config-defaults.js
+++ b/src/utils/config-defaults.js
@@ -139,7 +139,7 @@ export default deepFreeze({
boundary: 'scrollParent',
boundaryPadding: 5,
customClass: null,
- delay: 0,
+ delay: 50,
variant: null
},
BProgress: {
@@ -173,7 +173,7 @@ export default deepFreeze({
boundary: 'scrollParent',
boundaryPadding: 5,
customClass: null,
- delay: 0,
+ delay: 50,
variant: null
}
})
diff --git a/src/utils/popover.class.js b/src/utils/popover.class.js
deleted file mode 100644
index 31d6441288b..00000000000
--- a/src/utils/popover.class.js
+++ /dev/null
@@ -1,121 +0,0 @@
-import ToolTip from './tooltip.class'
-import { select, addClass, removeClass, getAttr } from './dom'
-import { isFunction, isNull, isObject, isString } from './inspect'
-
-const NAME = 'popover'
-const CLASS_PREFIX = 'bs-popover'
-const BS_CLASS_PREFIX_REGEX = new RegExp(`\\b${CLASS_PREFIX}\\S+`, 'g')
-
-const Defaults = {
- ...ToolTip.Default,
- placement: 'right',
- trigger: 'click',
- content: '',
- template:
- ''
-}
-
-const ClassName = {
- FADE: 'fade',
- SHOW: 'show'
-}
-
-const Selector = {
- TITLE: '.popover-header',
- CONTENT: '.popover-body'
-}
-
-class PopOver extends ToolTip {
- // --- Getter overrides ---
-
- static get Default() {
- return Defaults
- }
-
- static get NAME() {
- return NAME
- }
-
- // --- Method overrides ---
-
- isWithContent(tip) {
- tip = tip || this.$tip
- if (!tip) {
- /* istanbul ignore next */
- return false
- }
- const hasTitle = Boolean((select(Selector.TITLE, tip) || {}).innerHTML)
- const hasContent = Boolean((select(Selector.CONTENT, tip) || {}).innerHTML)
- return hasTitle || hasContent
- }
-
- addAttachmentClass(attachment) /* istanbul ignore next */ {
- addClass(this.getTipElement(), `${CLASS_PREFIX}-${attachment}`)
- }
-
- setContent(tip) {
- // we use append for html objects to maintain js events/components
- this.setElementContent(select(Selector.TITLE, tip), this.getTitle())
- this.setElementContent(select(Selector.CONTENT, tip), this.getContent())
-
- removeClass(tip, ClassName.FADE)
- removeClass(tip, ClassName.SHOW)
- }
-
- // This method may look identical to ToolTip version, but it uses a different RegEx defined above
- cleanTipClass() /* istanbul ignore next */ {
- const tip = this.getTipElement()
- const tabClass = tip.className.match(BS_CLASS_PREFIX_REGEX)
- if (!isNull(tabClass) && tabClass.length > 0) {
- tabClass.forEach(cls => {
- removeClass(tip, cls)
- })
- }
- }
-
- getTitle() {
- let title = this.$config.title || ''
- /* istanbul ignore next */
- if (isFunction(title)) {
- title = title(this.$element)
- }
- /* istanbul ignore next */
- if (isObject(title) && title.nodeType && !title.innerHTML.trim()) {
- // We have a dom node, but without inner content, so just return an empty string
- title = ''
- }
- if (isString(title)) {
- title = title.trim()
- }
- if (!title) {
- // Try and grab element's title attribute
- title = getAttr(this.$element, 'title') || getAttr(this.$element, 'data-original-title') || ''
- title = title.trim()
- }
- return title
- }
-
- // New methods
-
- getContent() {
- let content = this.$config.content || ''
- /* istanbul ignore next */
- if (isFunction(content)) {
- content = content(this.$element)
- }
- /* istanbul ignore next */
- if (isObject(content) && content.nodeType && !content.innerHTML.trim()) {
- // We have a dom node, but without inner content, so just return an empty string
- content = ''
- }
- if (isString(content)) {
- content = content.trim()
- }
- return content
- }
-}
-
-export default PopOver
diff --git a/src/utils/tooltip.class.js b/src/utils/tooltip.class.js
deleted file mode 100644
index 888896003ab..00000000000
--- a/src/utils/tooltip.class.js
+++ /dev/null
@@ -1,1079 +0,0 @@
-import Popper from 'popper.js'
-import BvEvent from './bv-event.class'
-import noop from './noop'
-import { from as arrayFrom } from './array'
-import {
- closest,
- select,
- isVisible,
- isDisabled,
- getCS,
- addClass,
- removeClass,
- hasClass,
- setAttr,
- removeAttr,
- getAttr,
- eventOn,
- eventOff
-} from './dom'
-import { isFunction, isNull, isNumber, isObject, isString, isUndefined } from './inspect'
-
-const NAME = 'tooltip'
-const CLASS_PREFIX = 'bs-tooltip'
-const BS_CLASS_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 selector for appending tooltip/popover
-const MODAL_SELECTOR = '.modal-content'
-
-// For dropdown sniffing
-const DROPDOWN_CLASS = 'dropdown'
-const DROPDOWN_OPEN_SELECTOR = '.dropdown-menu.show'
-
-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'
-}
-
-// Defaults
-const Defaults = {
- animation: true,
- template:
- '',
- trigger: 'hover focus',
- title: '',
- delay: 0,
- html: false,
- placement: 'top',
- offset: 0,
- arrowPadding: 6,
- container: false,
- fallbackPlacement: 'flip',
- callbacks: {},
- boundary: 'scrollParent',
- boundaryPadding: 5,
- variant: null,
- customClass: null
-}
-
-// Transition event names
-const TransitionEndEvents = {
- WebkitTransition: ['webkitTransitionEnd'],
- MozTransition: ['transitionend'],
- OTransition: ['otransitionend', 'oTransitionEnd'],
- transition: ['transitionend']
-}
-
-// Options for Native Event Listeners (since we never call preventDefault)
-const EvtOpts = { passive: true, capture: false }
-
-// Client-side tip ID counter for aria-describedby attribute
-// Each tooltip requires a unique client side ID
-let NEXTID = 1
-/* istanbul ignore next */
-const generateId = name => `__BV_${name}_${NEXTID++}__`
-
-/*
- * ToolTip class definition
- */
-class ToolTip {
- // Main constructor
- constructor(element, config, $parent) {
- // 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.$parent = $parent || null
- this.$root = $parent && $parent.$root ? $parent.$root : null
- this.$routeWatcher = null
- // We use a bound version of the following handlers for root/modal
- // listeners to maintain the correct `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)
- this._noop = noop.bind(this)
- // Set the configuration
- this.updateConfig(config)
- // Destroy ourselves if the parent is destroyed
- if ($parent) {
- $parent.$once('hook:beforeDestroy', this.destroy.bind(this))
- }
- }
-
- // 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
- const updatedConfig = { ...this.constructor.Default, ...config }
-
- // Sanitize delay
- if (config.delay && isNumber(config.delay)) {
- /* istanbul ignore next */
- updatedConfig.delay = {
- show: config.delay,
- hide: config.delay
- }
- }
-
- // Title for tooltip and popover
- if (config.title && isNumber(config.title)) {
- /* istanbul ignore next */
- updatedConfig.title = config.title.toString()
- }
-
- // Content only for popover
- if (config.content && isNumber(config.content)) {
- /* istanbul ignore next */
- 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 timeouts
- 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.$parent = 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) {
- /* istanbul ignore next */
- return
- }
- // Prevent showing if tip/popover is on a dropdown and the menu is open
- if (this.dropdownOpen()) {
- /* istanbul ignore next */
- return
- }
- /* istanbul ignore else */
- 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
- }
-
- // Prevent showing if tip/popover is on a dropdown and the menu is open
- if (this.dropdownOpen()) {
- /* istanbul ignore next */
- 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
- /* istanbul ignore next */
- this.$tip = null
- /* istanbul ignore next */
- 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.$tip
- 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)
- // Dropdown open events (if we are attached to a dropdown)
- this.setDropdownListener(on)
- // Periodic $element visibility check
- // For handling when tip is in , tabs, carousel, etc
- this.visibleCheck(on)
- // On-touch start listeners
- this.setOnTouchStartListener(on)
- if (on && /(focus|blur)/.test(this.$config.trigger)) {
- // If focus moves between trigger element and tip container, don't close
- eventOn(this.$tip, 'focusout', this, EvtOpts)
- } else {
- eventOff(this.$tip, 'focusout', this, EvtOpts)
- }
- }
-
- // Force hide of tip (internal method)
- forceHide() {
- if (!this.$tip || !hasClass(this.$tip, ClassName.SHOW)) {
- /* istanbul ignore next */
- return
- }
- // Disable while open listeners/watchers
- this.setWhileOpenListeners(false)
- // Clear any hover enter/leave event
- clearTimeout(this.$hoverTimeout)
- this.$hoverTimeout = null
- this.$hoverState = ''
- this.$activeTrigger = {}
- // Hide the tip
- this.hide(null, true)
- }
-
- // Hide tooltip
- hide(callback, force) {
- const tip = this.$tip
- if (!tip) {
- /* istanbul ignore next */
- return
- }
-
- // Create a cancelable 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
- 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) {
- /* istanbul ignore next */
- 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)
-
- // Clear any active triggers
- this.$activeTrigger = {}
-
- // Start the hide transition
- this.transitionOnce(tip, complete)
-
- this.$hoverState = ''
- }
-
- emitEvent(evt) {
- const evtName = evt.type
- const $root = this.$root
- if ($root && $root.$emit) {
- // Emit an event on $root
- $root.$emit(`bv::${this.constructor.NAME}::${evtName}`, evt)
- }
- const callbacks = this.$config.callbacks || {}
- if (isFunction(callbacks[evtName])) {
- 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_SELECTOR, this.$element) || body
- : select(container, body) || body
- }
-
- // Will be overridden 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 overridden 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) {
- /* istanbul ignore next */
- setAttr(this.$element, 'aria-describedby', desc)
- } else {
- removeAttr(this.$element, 'aria-describedby')
- }
- }
-
- removePopper() {
- if (this.$popper) {
- this.$popper.destroy()
- }
- this.$popper = null
- }
-
- transitionOnce(tip, complete) {
- const transEvents = this.getTransitionEndEvents()
- let called = false
- clearTimeout(this.$fadeTimeout)
- this.$fadeTimeout = null
- const fnOnce = () => {
- if (called) {
- /* istanbul ignore next */
- return
- }
- called = true
- clearTimeout(this.$fadeTimeout)
- this.$fadeTimeout = null
- transEvents.forEach(evtName => {
- eventOff(tip, evtName, fnOnce, EvtOpts)
- })
- // Call complete callback
- complete()
- }
- if (hasClass(tip, ClassName.FADE)) {
- transEvents.forEach(evtName => {
- eventOn(tip, evtName, fnOnce, EvtOpts)
- })
- // 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 (!isUndefined(this.$element.style[name])) {
- return TransitionEndEvents[name]
- }
- }
- // Fallback
- /* istanbul ignore next */
- return []
- }
-
- /* istanbul ignore next */
- update() {
- if (!isNull(this.$popper)) {
- this.$popper.scheduleUpdate()
- }
- }
-
- // NOTE: Overridden by PopOver class
- isWithContent(tip) {
- tip = tip || this.$tip
- if (!tip) {
- /* istanbul ignore next */
- 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 relatedTarget in focusin/out events
- this.$tip.tabIndex = -1
- // Add variant if specified
- if (this.$config.variant) {
- addClass(this.$tip, `b-${this.constructor.NAME}-${this.$config.variant}`)
- }
- if (this.$config.customClass) {
- addClass(this.$tip, String(this.$config.customClass))
- }
- return this.$tip
- }
-
- compileTemplate(html) {
- if (!html || !isString(html)) {
- /* istanbul ignore next */
- 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
- /* istanbul ignore next */
- return
- }
- const allowHtml = this.$config.html
- if (isObject(content) && content.nodeType) {
- // Content is a DOM node
- if (allowHtml) {
- if (content.parentElement !== container) {
- container.innerHTML = ''
- container.appendChild(content)
- }
- } else {
- /* istanbul ignore next */
- 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 (isFunction(title)) {
- // Call the function to get the title value
- /* istanbul ignore next */
- title = title(this.$element)
- }
- if (isObject(title) && title.nodeType && !title.innerHTML.trim()) {
- // We have a DOM node, but without inner content,
- // so just return empty string
- /* istanbul ignore next */
- title = ''
- }
- if (isString(title)) {
- title = title.trim()
- }
- if (!title) {
- // If an explicit title is not given, try element's title attributes
- 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 el = this.$element
- /* istanbul ignore next */
- if (!el) {
- return
- }
- const triggers = this.$config.trigger.trim().split(/\s+/)
-
- // Listen for global show/hide events
- this.setRootListener(true)
-
- // Using `this` as the handler will get automatically directed to
- // this.handleEvent and maintain our binding to `this`
- triggers.forEach(trigger => {
- if (trigger === 'click') {
- eventOn(el, 'click', this, EvtOpts)
- } else if (trigger === 'focus') {
- eventOn(el, 'focusin', this, EvtOpts)
- eventOn(el, 'focusout', this, EvtOpts)
- } else if (trigger === 'blur') {
- // Used to close $tip when element looses focus
- eventOn(el, 'focusout', this, EvtOpts)
- } else if (trigger === 'hover') {
- eventOn(el, 'mouseenter', this, EvtOpts)
- eventOn(el, 'mouseleave', this, EvtOpts)
- }
- }, this)
- }
-
- unListen() {
- const el = this.$element
- /* istanbul ignore next */
- if (!el) {
- return
- }
- const events = ['click', 'focusin', 'focusout', 'mouseenter', 'mouseleave']
- // Using `this` as the handler will get automatically directed to this.handleEvent
- events.forEach(evt => {
- eventOff(el, evt, this, EvtOpts)
- }, this)
-
- // Stop listening for global show/hide/enable/disable events
- this.setRootListener(false)
- }
-
- // This special method allows us to use `this` as the event handlers
- handleEvent(e) {
- // If disabled, don't do anything
- // If tip is shown before element gets disabled, then tip will not
- // close until no longer disabled or forcefully closed
- if (isDisabled(this.$element)) {
- /* istanbul ignore next */
- return
- }
- // Exit if not enabled
- if (!this.$isEnabled) {
- return
- }
- // Prevent showing if tip/popover is on a dropdown and the menu is open
- if (this.dropdownOpen()) {
- /* istanbul ignore next */
- 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 focus moves from `$element` to `$tip`, don't trigger a leave
- if ($tip && $element && $element.contains(target) && $tip.contains(relatedTarget)) {
- /* istanbul ignore next */
- return
- }
- // If focus moves from `$tip` to `$element`, don't trigger a leave
- if ($tip && $element && $tip.contains(target) && $element.contains(relatedTarget)) {
- /* istanbul ignore next */
- return
- }
- // If focus moves within `$tip`, don't trigger a leave
- if ($tip && $tip.contains(target) && $tip.contains(relatedTarget)) {
- /* istanbul ignore next */
- return
- }
- // If focus moves within `$element`, don't trigger a leave
- if ($element && $element.contains(target) && $element.contains(relatedTarget)) {
- /* istanbul ignore next */
- return
- }
- // Otherwise trigger a leave
- this.leave(e)
- } else if (type === 'mouseleave') {
- this.leave(e)
- }
- }
-
- /* istanbul ignore next */
- setModalListener(on) {
- const el = this.$element
- if (!el || !this.$root) {
- return
- }
- const modal = closest(MODAL_SELECTOR, el)
- // If we are not in a modal, don't worry
- if (!modal) {
- return
- }
- // We can listen for modal hidden events on `$root`
- this.$root[on ? '$on' : '$off'](MODAL_CLOSE_EVENT, this.$forceHide)
- }
-
- /* istanbul ignore next */
- setDropdownListener(on) {
- const el = this.$element
- if (!el || !this.$root) {
- return
- }
- // If we are not on a dropdown menu, don't worry
- if (!hasClass(el, DROPDOWN_CLASS)) {
- return
- }
- // We can listen for dropdown shown events on it's instance
- if (el && el.__vue__) {
- el.__vue__[on ? '$on' : '$off']('shown', this.$forceHide)
- }
- }
-
- setRootListener(on) {
- // Listen for global `bv::{hide|show}::{tooltip|popover}` hide request event
- const $root = this.$root
- if ($root) {
- const method = on ? '$on' : '$off'
- $root[method](`bv::hide::${this.constructor.NAME}`, this.$doHide)
- $root[method](`bv::show::${this.constructor.NAME}`, this.$doShow)
- $root[method](`bv::disable::${this.constructor.NAME}`, this.$doDisable)
- $root[method](`bv::enable::${this.constructor.NAME}`, this.$doEnable)
- }
- }
-
- dropdownOpen() {
- // Returns true if trigger is a dropdown and the dropdown menu is open
- return hasClass(this.$element, DROPDOWN_CLASS) && select(DROPDOWN_OPEN_SELECTOR, this.$element)
- }
-
- // Programmatically hide tooltip or popover
- doHide(id) {
- 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()
- }
- }
-
- // Programmatically show tooltip or popover
- doShow(id) {
- 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()
- }
- }
-
- // Programmatically disable tooltip or popover
- doDisable(id) {
- 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()
- }
- }
-
- // Programmatically enable tooltip or popover
- doEnable(id) {
- 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()
- }
- }
-
- 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) {
- /* istanbul ignore next: JSDOM does not support `ontouchstart` event */
- arrayFrom(document.body.children).forEach(el => {
- if (on) {
- eventOn(el, 'mouseover', this._noop)
- } else {
- eventOff(el, 'mouseover', this._noop)
- }
- })
- }
- }
-
- fixTitle() {
- const el = this.$element
- if (getAttr(el, 'title') || !isString(getAttr(el, 'data-original-title'))) {
- setAttr(el, 'data-original-title', getAttr(el, 'title') || '')
- setAttr(el, 'title', '')
- }
- }
-
- // Enter handler
- 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
- 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: {
- padding: this.$config.boundaryPadding,
- boundariesElement: this.$config.boundary
- }
- },
- onCreate: data => {
- // Handle flipping arrow classes
- /* istanbul ignore next */
- if (data.originalPlacement !== data.placement) {
- this.handlePopperPlacementChange(data)
- }
- },
- onUpdate: data => {
- // Handle flipping arrow classes
- /* istanbul ignore next */
- this.handlePopperPlacementChange(data)
- }
- }
- }
-
- /* istanbul ignore next */
- 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 this.$config.offset
- }
-
- getPlacement() {
- const placement = this.$config.placement
- if (isFunction(placement)) {
- /* istanbul ignore next */
- 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
- /* istanbul ignore next */
- cleanTipClass() {
- const tip = this.getTipElement()
- const tabClass = tip.className.match(BS_CLASS_PREFIX_REGEX)
- if (!isNull(tabClass) && tabClass.length > 0) {
- tabClass.forEach(cls => {
- removeClass(tip, cls)
- })
- }
- }
-
- /* istanbul ignore next */
- handlePopperPlacementChange(data) {
- this.cleanTipClass()
- this.addAttachmentClass(this.constructor.getAttachment(data.placement))
- }
-
- /* istanbul ignore next */
- fixTransition(tip) {
- const initConfigAnimation = this.$config.animation || false
- if (!isNull(getAttr(tip, 'x-placement'))) {
- return
- }
- removeClass(tip, ClassName.FADE)
- this.$config.animation = false
- this.hide()
- this.show()
- this.$config.animation = initConfigAnimation
- }
-}
-
-export default ToolTip