diff --git a/src/components/popover/README.md b/src/components/popover/README.md index 2bdfc3e202e..e7ac93f6326 100644 --- a/src/components/popover/README.md +++ b/src/components/popover/README.md @@ -6,7 +6,17 @@ ```html
- Hover Me + + Hover Me + + + + Hover Me + + + + I am popover component content! +
@@ -17,7 +27,6 @@ Things to know when using popover component: - Popovers rely on the 3rd party library [Popper.js](https://popper.js.org/) for positioning. -- Popovers with zero-length title _and_ content are never displayed. - Specify `container` as `null` (default, appends to ``) to avoid rendering problems in more complex components (like input groups, button groups, etc). You can use `container` to optionally specify a different element to append the rendered popover to. @@ -26,22 +35,10 @@ Things to know when using popover component: - When triggered from hyperlinks that span multiple lines, popovers will be centered. Use `white-space: nowrap;` on your ``s, ``s and ``s to avoid this behavior. -The `` component inserts a hidden (`display: none;`) `
` 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 popover will never open. Always place your `` component lower in the DOM than your target element. -**Note:** _When using slots for content and/or title, `` transfers the rendered DOM from -those slots into the popover's markup when shown, and returns them back to the `` -component when hidden. This may cause some issues in rare circumstances, so please test your -implementation accordingly! The `title` and `content` props do not have this behavior. For simple -popovers, we recommend using the `v-b-popover` directive and enable the `html` modifier if needed._ - ## Positioning Twelve options are available for positioning: `top`, `topleft`, `topright`, `right`, `righttop`, @@ -155,7 +152,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](#programmatically-disabling-popover). 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 @@ -259,13 +257,14 @@ The special `blur` trigger **must** be used in combination with the `click` trig | `disabled` | `false` | Programmatic control of the Popover display state. Recommended to use with [sync modifier](https://vuejs.org/v2/guide/components.html#sync-Modifier). | `true`, `false` | | `triggers` | `'click'` | Space separated list of event(s), which will trigger open/close of popover using built-in handling | `hover`, `focus`, `click`. Note `blur` is a special use case to close popover on next click. | | `no-fade` | `false` | Disable fade animation when set to `true` | `true` or `false` | -| `delay` | `0` | Delay showing and hiding of popover by specified number of milliseconds. Can also be defined 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 popover by specified number of milliseconds. Can also be defined 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 popover by specified number of pixels. Also affects the position of the popover arrow. | Any negative or positive integer | | `container` | `null` | Element string ID to append rendered popover into. If `null` or element not found, popover is appended to `` (default) | Any valid in-document unique element ID. | | `boundary` | `'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 | `'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 popover. This makes sure the popover always has a little padding between the edges of its container. | Any positive number | | `variant` | `null` | Contextual color variant for the popover | Any contextual theme color variant name | -| `customClass` | `null` | A custom classname to apply to the popover outer wrapper element | A string | +| `custom-class` | `null` | A custom classname to apply to the popover outer wrapper element | A string | +| `id` | `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. | A valid unique element ID string | ### Variants and custom class @@ -300,8 +299,7 @@ A custom class can be applied to the popover outer wrapper `
` by using the
``` -**Note:** Custom classes will not work with scoped styles, as the popovers are appended to the -document `` element by default. +`variant` and `custom-class` are reactive and can be changed while the popover is open. Refer to the [popover directive](/docs/directives/popover) docs on applying variants and custom class to the directive version. @@ -582,7 +580,7 @@ Just need quick popovers without too much markup? Use the - Bottom + Bottom @@ -596,9 +594,8 @@ information on the directive usage. ## Advanced `` usage with reactive content -You can even make your `` content interactive. Just remember not to use the `focus`, -`hover` or `blur` triggers (use only `click`), otherwise your popover will close automatically as -soon as someone will try to interact with the content. +You can even make your `` content interactive. Just remember not to use the `focus` or +triggers (use only `click`). If you absolutely must use a trigger other than `click` (or want to disable closing of the popover when the trigger element is clicked a second time), then you can either: @@ -617,7 +614,7 @@ to deal with on mobile devices (such as smart-phones).
- + Reactive Content Using Slots
@@ -798,14 +795,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 via the `id` prop), 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 (show) a **specific popover**, pass the trigger element's `id`, or the `id` of the popover +(if one was provided via the `id` prop), as the first argument when emitting the `bv::show::popover` +event: ```js this.$root.$emit('bv::show::popover', 'my-trigger-button-id') @@ -827,14 +826,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 via the `id` prop), 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 via the `id` prop), 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/components/popover/_popover.scss b/src/components/popover/_popover.scss index 31adedc2e3a..234026be735 100644 --- a/src/components/popover/_popover.scss +++ b/src/components/popover/_popover.scss @@ -1,3 +1,17 @@ +// Some overrides to make popover transitions work with Vue `` +.popover.b-popover { + display: block; + opacity: 1; + + &.fade:not(.show) { + opacity: 0; + } + + &.show { + opacity: 1; + } +} + @if $bv-enable-popover-variants { @each $variant, $value in $theme-colors { .b-popover-#{$variant} { diff --git a/src/components/popover/helpers/bv-popover-template.js b/src/components/popover/helpers/bv-popover-template.js new file mode 100644 index 00000000000..76041090dc2 --- /dev/null +++ b/src/components/popover/helpers/bv-popover-template.js @@ -0,0 +1,48 @@ +import Vue from '../../../utils/vue' +import { isFunction, isUndefinedOrNull } from '../../../utils/inspect' + +import { BVTooltipTemplate } from '../../tooltip/helpers/bv-tooltip-template' + +const NAME = 'BVPopoverTemplate' + +// @vue/component +export const BVPopoverTemplate = /*#__PURE__*/ Vue.extend({ + name: NAME, + extends: BVTooltipTemplate, + computed: { + templateType() { + return 'popover' + } + }, + methods: { + renderTemplate(h) { + // Title and content could be a scoped slot function + const $title = isFunction(this.title) ? this.title({}) : this.title + const $content = isFunction(this.content) ? this.content({}) : this.content + + // Directive usage only + const titleDomProps = this.html && !isFunction(this.title) ? { innerHTML: this.title } : {} + const contentDomProps = + this.html && !isFunction(this.content) ? { innerHTML: this.content } : {} + + return h( + 'div', + { + staticClass: 'popover b-popover', + class: this.templateClasses, + attrs: this.templateAttributes, + on: this.templateListeners + }, + [ + h('div', { ref: 'arrow', staticClass: 'arrow' }), + isUndefinedOrNull($title) + ? h() + : h('h3', { staticClass: 'popover-header', domProps: titleDomProps }, [$title]), + isUndefinedOrNull($content) + ? h() + : h('div', { staticClass: 'popover-body', domProps: contentDomProps }, [$content]) + ] + ) + } + } +}) diff --git a/src/components/popover/helpers/bv-popover.js b/src/components/popover/helpers/bv-popover.js new file mode 100644 index 00000000000..484ff6b1111 --- /dev/null +++ b/src/components/popover/helpers/bv-popover.js @@ -0,0 +1,29 @@ +// Popover "Class" (Built as a renderless Vue instance) +// Inherits from BVTooltip +// +// Handles trigger events, etc. +// Instantiates template on demand + +import Vue from '../../../utils/vue' +import { BVTooltip } from '../../tooltip/helpers/bv-tooltip' +import { BVPopoverTemplate } from './bv-popover-template' + +const NAME = 'BVPopover' + +// @vue/component +export const BVPopover = /*#__PURE__*/ Vue.extend({ + name: NAME, + extends: BVTooltip, + computed: { + // Overwrites BVTooltip + templateType() { + return 'popover' + } + }, + methods: { + getTemplate() { + // Overwrites BVTooltip + return BVPopoverTemplate + } + } +}) diff --git a/src/components/popover/package.json b/src/components/popover/package.json index ef13ceecd84..0c8db929d60 100644 --- a/src/components/popover/package.json +++ b/src/components/popover/package.json @@ -165,7 +165,11 @@ "slots": [ { "name": "title", - "description": "Optional slot for title (html supported)" + "description": "Optional slot for title (HTML supported)" + }, + { + "name": "default", + "description": "Slot for content (HTML supported)" } ] } diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index f1ec0066b90..a0df39ef2d1 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -1,95 +1,71 @@ import Vue from '../../utils/vue' -import PopOver from '../../utils/popover.class' -import warn from '../../utils/warn' -import { isArray, arrayIncludes } from '../../utils/array' import { getComponentConfig } from '../../utils/config' import { HTMLElement } from '../../utils/safe-types' -import normalizeSlotMixin from '../../mixins/normalize-slot' -import toolpopMixin from '../../mixins/toolpop' +import { BTooltip } from '../tooltip/tooltip' +import { BVPopover } from './helpers/bv-popover' const NAME = 'BPopover' -export const props = { - title: { - type: String, - default: '' - }, - content: { - type: String, - default: '' - }, - triggers: { - type: [String, Array], - default: 'click' - }, - placement: { - type: String, - default: 'right' - }, - fallbackPlacement: { - type: [String, Array], - default: 'flip', - validator(value) { - return isArray(value) || arrayIncludes(['flip', 'clockwise', 'counterclockwise'], value) - } - }, - variant: { - type: String, - default: () => getComponentConfig(NAME, 'variant') - }, - customClass: { - type: String, - default: () => getComponentConfig(NAME, 'customClass') - }, - delay: { - type: [Number, Object, String], - default: () => getComponentConfig(NAME, 'delay') - }, - boundary: { - // String: scrollParent, window, or viewport - // Element: element reference - type: [String, HTMLElement], - default: () => getComponentConfig(NAME, 'boundary') - }, - boundaryPadding: { - type: Number, - default: () => getComponentConfig(NAME, 'boundaryPadding') - } -} - -// @vue/component export const BPopover = /*#__PURE__*/ Vue.extend({ name: NAME, - mixins: [toolpopMixin, normalizeSlotMixin], - props, - methods: { - createToolpop() { - // getTarget is in toolpop mixin - const target = this.getTarget() - /* istanbul ignore else */ - if (target) { - this._toolpop = new PopOver(target, this.getConfig(), this) - } else { - this._toolpop = null - warn("b-popover: 'target' element not found!") - } - return this._toolpop + extends: BTooltip, + inheritAttrs: false, + props: { + title: { + type: String + // default: undefined + }, + content: { + type: String + // default: undefined + }, + triggers: { + type: [String, Array], + default: 'click' + }, + placement: { + type: String, + default: 'right' + }, + variant: { + type: String, + default: () => getComponentConfig(NAME, 'variant') + }, + customClass: { + type: String, + default: () => getComponentConfig(NAME, 'customClass') + }, + delay: { + type: [Number, Object, String], + default: () => getComponentConfig(NAME, 'delay') + }, + boundary: { + // String: scrollParent, window, or viewport + // Element: element reference + // Object: Vue component + type: [String, HTMLElement, Object], + default: () => getComponentConfig(NAME, 'boundary') + }, + boundaryPadding: { + type: Number, + default: () => getComponentConfig(NAME, 'boundaryPadding') } }, - render(h) { - return h( - 'div', - { - class: ['d-none'], - style: { display: 'none' }, - attrs: { 'aria-hidden': true } - }, - [ - h('div', { ref: 'title' }, this.normalizeSlot('title')), - h('div', { ref: 'content' }, this.normalizeSlot('default')) - ] - ) + methods: { + getComponent() { + // Overridden by BPopover + return BVPopover + }, + updateContent() { + // 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.setContent(this.$scopedSlots.default || this.content) + this.setTitle(this.$scopedSlots.title || this.title) + } } + // Render function provided by BTooltip }) export default BPopover diff --git a/src/components/popover/popover.spec.js b/src/components/popover/popover.spec.js index 7850b36f0b2..e14fbc8f87a 100644 --- a/src/components/popover/popover.spec.js +++ b/src/components/popover/popover.spec.js @@ -118,25 +118,12 @@ describe('b-popover', () => { 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('aria-describedby')).not.toBeDefined() // wrapper - const $tipHolder = wrapper.find('div#bar') + const $tipHolder = wrapper.find(BPopover) 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') - - // Content placeholders - expect($tipHolder.findAll('div.d-none > div').length).toBe(2) - const $holders = $tipHolder.findAll('div.d-none > div') - expect($holders.at(0).text()).toEqual('title') - expect($holders.at(1).text()).toEqual('content') + expect($tipHolder.element.nodeType).toEqual(Node.COMMENT_NODE) wrapper.destroy() }) @@ -173,34 +160,24 @@ describe('b-popover', () => { 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('aria-describedby')).toBeDefined() + expect($button.attributes('data-original-title')).not.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(BPopover) 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') - - // Content placeholders should be moved - expect($tipHolder.findAll('div.d-none > div').length).toBe(0) - expect($tipHolder.text()).toBe('') + expect($tipHolder.element.nodeType).toEqual(Node.COMMENT_NODE) // Find the popover 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('popover')).toBe(true) + expect(tip.classList.contains('b-popover')).toBe(true) - // Hide the tooltip + // Hide the Popover wrapper.setProps({ show: false }) @@ -211,15 +188,10 @@ describe('b-popover', () => { jest.runOnlyPendingTimers() 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(2) - const $holders = $tipHolder.findAll('div.d-none > div') - expect($holders.at(0).text()).toEqual('title') - expect($holders.at(1).text()).toEqual('content') // Popover 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() }) diff --git a/src/components/tooltip/README.md b/src/components/tooltip/README.md index 6ed93755884..a2542c29ffb 100644 --- a/src/components/tooltip/README.md +++ b/src/components/tooltip/README.md @@ -5,7 +5,16 @@ ```html
- Hover Me + + Hover Me + + + + Hover Me + + + I am tooltip component content! +
@@ -16,7 +25,6 @@ Things to know when using tooltip component: - Tooltips rely on the 3rd party library [Popper.js](https://popper.js.org/) for positioning. -- Tooltips with zero-length titles are never displayed. - Triggering tooltips on hidden elements will not work. - Specify `container` as `null` (default, appends to ``) to avoid rendering problems in more complex components (like input groups, button groups, etc). You can use container to optionally @@ -25,23 +33,11 @@ Things to know when using tooltip component: - When triggered from hyperlinks that span multiple lines, tooltips will be centered. Use white-space: nowrap; on your `
`s, ``s and ``s to avoid this behavior. -The `` component inserts a hidden (`display:none`) `
` 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: `` }) @@ -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: `` }) @@ -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