diff --git a/docs/nuxt.config.js b/docs/nuxt.config.js index a6eeedc4f1c..5fe90f2b7c9 100644 --- a/docs/nuxt.config.js +++ b/docs/nuxt.config.js @@ -117,6 +117,7 @@ module.exports = { .readdirSync(`${root}/${dir}`) .filter(c => c !== 'index.js' && c[0] !== '_') .filter(c => excludeDirs.indexOf(c) === -1) + .filter(c => !/\.s?css$/.test(c)) .map(page => `/docs/${dir}/${page}`) return [] diff --git a/src/directives/modal/modal.js b/src/directives/modal/modal.js index 6b26c4e54c0..0b64b7828eb 100644 --- a/src/directives/modal/modal.js +++ b/src/directives/modal/modal.js @@ -1,25 +1,38 @@ -import { bindTargets, unbindTargets } from '../../utils/target' import { setAttr, removeAttr } from '../../utils/dom' +import { bindTargets, unbindTargets } from '../../utils/target' +// Target listen types const listenTypes = { click: true } +// Emitted show event for modal +const EVENT_SHOW = 'bv::show::modal' + +const setRole = (el, binding, vnode) => { + if (el.tagName !== 'BUTTON') { + setAttr(el, 'role', 'button') + } +} + +/* + * Export our directive + */ export default { // eslint-disable-next-line no-shadow-restricted-names bind(el, binding, vnode) { bindTargets(vnode, binding, listenTypes, ({ targets, vnode }) => { targets.forEach(target => { - vnode.context.$root.$emit('bv::show::modal', target, vnode.elm) + vnode.context.$root.$emit(EVENT_SHOW, target, vnode.elm) }) }) - if (el.tagName !== 'BUTTON') { - // If element is not a button, we add `role="button"` for accessibility - setAttr(el, 'role', 'button') - } + // If element is not a button, we add `role="button"` for accessibility + setRole(el, binding, vnode) }, + updated: setRole, + componentUpdated: setRole, unbind(el, binding, vnode) { unbindTargets(vnode, binding, listenTypes) + // If element is not a button, we add `role="button"` for accessibility if (el.tagName !== 'BUTTON') { - // If element is not a button, we add `role="button"` for accessibility removeAttr(el, 'role', 'button') } } diff --git a/src/directives/modal/modal.spec.js b/src/directives/modal/modal.spec.js new file mode 100644 index 00000000000..530cff5dc32 --- /dev/null +++ b/src/directives/modal/modal.spec.js @@ -0,0 +1,88 @@ +import modalDirective from './modal' +import { mount, createLocalVue as CreateLocalVue } from '@vue/test-utils' + +const EVENT_SHOW = 'bv::show::modal' + +describe('v-b-modal directive', () => { + it('works on buttons', async () => { + const localVue = new CreateLocalVue() + const spy = jest.fn() + + const App = localVue.extend({ + directives: { + bModal: modalDirective + }, + data() { + return {} + }, + mounted() { + this.$root.$on(EVENT_SHOW, spy) + }, + beforeDestroy() { + this.$root.$off(EVENT_SHOW, spy) + }, + template: '' + }) + const wrapper = mount(App, { + localVue: localVue + }) + + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.is('button')).toBe(true) + expect(spy).not.toHaveBeenCalled() + + const $button = wrapper.find('button') + $button.trigger('click') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toBeCalledWith('test', $button.element) + + wrapper.destroy() + }) + + it('works on non-buttons', async () => { + const localVue = new CreateLocalVue() + const spy = jest.fn() + + const App = localVue.extend({ + directives: { + bModal: modalDirective + }, + data() { + return { + text: 'span' + } + }, + mounted() { + this.$root.$on(EVENT_SHOW, spy) + }, + beforeDestroy() { + this.$root.$off(EVENT_SHOW, spy) + }, + template: '{{ text }}' + }) + const wrapper = mount(App, { + localVue: localVue + }) + + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.is('span')).toBe(true) + expect(spy).not.toHaveBeenCalled() + expect(wrapper.find('span').attributes('role')).toBe('button') + expect(wrapper.find('span').text()).toBe('span') + + const $span = wrapper.find('span') + $span.trigger('click') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toBeCalledWith('test', $span.element) + expect(wrapper.find('span').attributes('role')).toBe('button') + + // Test updating component. should maintain role attribute + wrapper.setData({ + text: 'foobar' + }) + expect(wrapper.find('span').text()).toBe('foobar') + expect(wrapper.find('span').attributes('role')).toBe('button') + + wrapper.destroy() + }) +}) diff --git a/src/directives/popover/popover.js b/src/directives/popover/popover.js index 5991f7ddd77..36337ee8c62 100644 --- a/src/directives/popover/popover.js +++ b/src/directives/popover/popover.js @@ -1,12 +1,11 @@ import Popper from 'popper.js' import PopOver from '../../utils/popover.class' +import { inBrowser } from '../../utils/env' import { keys } from '../../utils/object' import warn from '../../utils/warn' -const inBrowser = typeof window !== 'undefined' && typeof document !== 'undefined' - // Key which we use to store tooltip object on element -const BVPO = '__BV_PopOver__' +const BV_POPOVER = '__BV_PopOver__' // Valid event triggers const validTriggers = { @@ -17,9 +16,9 @@ const validTriggers = { } // Build a PopOver config based on bindings (if any) -// Arguments and modifiers take precedence over pased value config object +// Arguments and modifiers take precedence over passed value config object /* istanbul ignore next: not easy to test */ -function parseBindings(bindings) { +const parseBindings = bindings => /* istanbul ignore next: not easy to test */ { // We start out with a blank config let config = {} @@ -35,9 +34,10 @@ function parseBindings(bindings) { config = { ...config, ...bindings.value } } - // If Argument, assume element ID of container element + // If argument, assume element ID of container element if (bindings.arg) { - // Element ID specified as arg. We must prepend '#' to become a CSS selector + // Element ID specified as arg + // We must prepend '#' to become a CSS selector config.container = `#${bindings.arg}` } @@ -55,16 +55,16 @@ function parseBindings(bindings) { // placement of popover config.placement = mod } else if (/^(window|viewport)$/.test(mod)) { - // bounday of popover + // Boundary of popover config.boundary = mod } else if (/^d\d+$/.test(mod)) { - // delay value + // Delay value const delay = parseInt(mod.slice(1), 10) || 0 if (delay) { config.delay = delay } } else if (/^o-?\d+$/.test(mod)) { - // offset value (negative allowed) + // Offset value (negative allowed) const offset = parseInt(mod.slice(1), 10) || 0 if (offset) { config.offset = offset @@ -72,10 +72,11 @@ function parseBindings(bindings) { } }) - // Special handling of event trigger modifiers Trigger is a space separated list + // Special handling of event trigger modifiers trigger is + // a space separated list const selectedTriggers = {} - // parse current config object trigger + // Parse current config object trigger let triggers = typeof config.trigger === 'string' ? config.trigger.trim().split(/\s+/) : [] triggers.forEach(trigger => { if (validTriggers[trigger]) { @@ -83,7 +84,7 @@ function parseBindings(bindings) { } }) - // Parse Modifiers for triggers + // Parse modifiers for triggers keys(validTriggers).forEach(trigger => { if (bindings.modifiers[trigger]) { selectedTriggers[trigger] = true @@ -97,70 +98,64 @@ function parseBindings(bindings) { config.trigger = 'focus' } if (!config.trigger) { - // remove trigger config + // Remove trigger config delete config.trigger } return config } -// -// Add or Update popover on our element -// -/* istanbul ignore next: not easy to test */ -function applyBVPO(el, bindings, vnode) { +// Add or update PopOver on our element +const applyPopover = (el, bindings, vnode) => { if (!inBrowser) { + /* istanbul ignore next */ return } + // Popper is required for PopOvers to work if (!Popper) { - // Popper is required for tooltips to work - warn('v-b-popover: Popper.js is required for popovers to work') + /* istanbul ignore next */ + warn('v-b-popover: Popper.js is required for PopOvers to work') + /* istanbul ignore next */ return } - if (el[BVPO]) { - el[BVPO].updateConfig(parseBindings(bindings)) + const config = parseBindings(bindings) + if (el[BV_POPOVER]) { + el[BV_POPOVER].updateConfig(config) } else { - el[BVPO] = new PopOver(el, parseBindings(bindings), vnode.context.$root) + el[BV_POPOVER] = new PopOver(el, config, vnode.context.$root) } } -// -// Remove popover on our element -// -/* istanbul ignore next */ -function removeBVPO(el) { - if (!inBrowser) { - return - } - if (el[BVPO]) { - el[BVPO].destroy() - el[BVPO] = null - delete el[BVPO] +// Remove PopOver on our element +const removePopover = el => { + if (el[BV_POPOVER]) { + el[BV_POPOVER].destroy() + el[BV_POPOVER] = null + delete el[BV_POPOVER] } } /* * Export our directive */ -/* istanbul ignore next: not easy to test */ export default { bind(el, bindings, vnode) { - applyBVPO(el, bindings, vnode) + applyPopover(el, bindings, vnode) }, inserted(el, bindings, vnode) { - applyBVPO(el, bindings, vnode) + applyPopover(el, bindings, vnode) }, - update(el, bindings, vnode) { + update(el, bindings, vnode) /* istanbul ignore next: not easy to test */ { if (bindings.value !== bindings.oldValue) { - applyBVPO(el, bindings, vnode) + applyPopover(el, bindings, vnode) } }, - componentUpdated(el, bindings, vnode) { + componentUpdated(el, bindings, vnode) /* istanbul ignore next: not easy to test */ { if (bindings.value !== bindings.oldValue) { - applyBVPO(el, bindings, vnode) + applyPopover(el, bindings, vnode) } }, unbind(el) { - removeBVPO(el) + removePopover(el) } } diff --git a/src/directives/popover/popover.spec.js b/src/directives/popover/popover.spec.js new file mode 100644 index 00000000000..b301c23db2d --- /dev/null +++ b/src/directives/popover/popover.spec.js @@ -0,0 +1,37 @@ +import popoverDirective from './popover' +import PopOver from '../../utils/popover.class' +import { mount, createLocalVue as CreateLocalVue } from '@vue/test-utils' + +// Key which we use to store tooltip object on element +const BV_POPOVER = '__BV_PopOver__' + +describe('v-b-popover directive', () => { + it('should have PopOver class instance', async () => { + const localVue = new CreateLocalVue() + + const App = localVue.extend({ + directives: { + bPopover: popoverDirective + }, + data() { + return {} + }, + template: `` + }) + + const wrapper = mount(App, { + localVue: localVue, + attachToDocument: true + }) + + expect(wrapper.isVueInstance()).toBe(true) + 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) + + wrapper.destroy() + }) +}) diff --git a/src/directives/scrollspy/scrollspy.js b/src/directives/scrollspy/scrollspy.js index 60e252c1e55..e5ea5d74864 100644 --- a/src/directives/scrollspy/scrollspy.js +++ b/src/directives/scrollspy/scrollspy.js @@ -1,28 +1,27 @@ -/* - * ScrollSpy directive v-b-scrollspy - */ - import ScrollSpy from './scrollspy.class' +import { inBrowser } from '../../utils/env' import { keys } from '../../utils/object' -import { isServer } from '../../utils/env' -// Key we use to store our Instance -const BVSS = '__BV_ScrollSpy__' +// Key we use to store our instance +const BV_SCROLLSPY = '__BV_ScrollSpy__' -// Generate config from bindings -function makeConfig(binding) /* istanbul ignore next: not easy to test */ { +// Build a ScrollSpy 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 config = {} - // If Argument, assume element ID - if (binding.arg) { - // Element ID specified as arg. We must pre-pend # - config.element = '#' + binding.arg + // If argument, assume element ID + if (bindings.arg) { + // Element ID specified as arg + // We must prepend '#' to become a CSS selector + config.element = `#${bindings.arg}` } // Process modifiers - keys(binding.modifiers).forEach(mod => { + keys(bindings.modifiers).forEach(mod => { if (/^\d+$/.test(mod)) { - // Offest value + // Offset value config.offset = parseInt(mod, 10) } else if (/^(auto|position|offset)$/.test(mod)) { // Offset method @@ -31,67 +30,69 @@ function makeConfig(binding) /* istanbul ignore next: not easy to test */ { }) // Process value - if (typeof binding.value === 'string') { + if (typeof bindings.value === 'string') { // Value is a CSS ID or selector - config.element = binding.value - } else if (typeof binding.value === 'number') { + config.element = bindings.value + } else if (typeof bindings.value === 'number') { // Value is offset - config.offset = Math.round(binding.value) - } else if (typeof binding.value === 'object') { + config.offset = Math.round(bindings.value) + } else if (typeof bindings.value === 'object') { // Value is config object // Filter the object based on our supported config options - keys(binding.value) + keys(bindings.value) .filter(k => Boolean(ScrollSpy.DefaultType[k])) .forEach(k => { - config[k] = binding.value[k] + config[k] = bindings.value[k] }) } return config } -function addBVSS(el, binding, vnode) /* istanbul ignore next: not easy to test */ { - if (isServer) { +// Add or update ScrollSpy on our element +const applyScrollspy = (el, bindings, vnode) => { + if (!inBrowser) { + /* istanbul ignore next */ return } - const cfg = makeConfig(binding) - if (!el[BVSS]) { - el[BVSS] = new ScrollSpy(el, cfg, vnode.context.$root) + const config = parseBindings(bindings) + if (el[BV_SCROLLSPY]) { + el[BV_SCROLLSPY].updateConfig(config, vnode.context.$root) } else { - el[BVSS].updateConfig(cfg, vnode.context.$root) + el[BV_SCROLLSPY] = new ScrollSpy(el, config, vnode.context.$root) } - return el[BVSS] } -function removeBVSS(el) /* istanbul ignore next: not easy to test */ { - if (el[BVSS]) { - el[BVSS].dispose() - el[BVSS] = null +// Remove ScrollSpy on our element +const removeScrollspy = el => { + if (el[BV_SCROLLSPY]) { + el[BV_SCROLLSPY].dispose() + el[BV_SCROLLSPY] = null + delete el[BV_SCROLLSPY] } } /* * Export our directive */ - export default { - bind(el, binding, vnode) /* istanbul ignore next: not easy to test */ { - addBVSS(el, binding, vnode) + bind(el, bindings, vnode) { + applyScrollspy(el, bindings, vnode) }, - inserted(el, binding, vnode) /* istanbul ignore next: not easy to test */ { - addBVSS(el, binding, vnode) + inserted(el, bindings, vnode) { + applyScrollspy(el, bindings, vnode) }, - update(el, binding, vnode) /* istanbul ignore next: not easy to test */ { - addBVSS(el, binding, vnode) - }, - componentUpdated(el, binding, vnode) /* istanbul ignore next: not easy to test */ { - addBVSS(el, binding, vnode) + update(el, bindings, vnode) /* istanbul ignore next: not easy to test */ { + if (bindings.value !== bindings.oldValue) { + applyScrollspy(el, bindings, vnode) + } }, - unbind(el) /* istanbul ignore next: not easy to test */ { - if (isServer) { - return + componentUpdated(el, bindings, vnode) /* istanbul ignore next: not easy to test */ { + if (bindings.value !== bindings.oldValue) { + applyScrollspy(el, bindings, vnode) } - // Remove scroll event listener on scrollElId - removeBVSS(el) + }, + unbind(el) { + removeScrollspy(el) } } diff --git a/src/directives/scrollspy/scrollspy.spec.js b/src/directives/scrollspy/scrollspy.spec.js new file mode 100644 index 00000000000..93db2855590 --- /dev/null +++ b/src/directives/scrollspy/scrollspy.spec.js @@ -0,0 +1,38 @@ +import scrollspyDirective from './scrollspy' +import ScrollSpy from './scrollspy.class' +import { mount, createLocalVue as CreateLocalVue } from '@vue/test-utils' + +// Key we use to store our instance +const BV_SCROLLSPY = '__BV_ScrollSpy__' + +describe('v-b-scrollspy directive', () => { + it('should have ScrollSpy class instance', async () => { + const localVue = new CreateLocalVue() + + const App = localVue.extend({ + directives: { + bScrollspy: scrollspyDirective + }, + data() { + return {} + }, + // Assumes watching body element for scrolls + template: '
content
' + }) + + const wrapper = mount(App, { + localVue: localVue, + attachToDocument: true + }) + + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.is('div')).toBe(true) + const $div = wrapper.find('div') + + // Should have instance of popover class on it + expect($div.element[BV_SCROLLSPY]).toBeDefined() + expect($div.element[BV_SCROLLSPY]).toBeInstanceOf(ScrollSpy) + + wrapper.destroy() + }) +}) diff --git a/src/directives/toggle/toggle.js b/src/directives/toggle/toggle.js index 4e2337cbb16..b8a0d066d72 100644 --- a/src/directives/toggle/toggle.js +++ b/src/directives/toggle/toggle.js @@ -1,41 +1,52 @@ -import target from '../../utils/target' import { setAttr, removeAttr, addClass, removeClass } from '../../utils/dom' import { inBrowser } from '../../utils/env' +import { bindTargets, unbindTargets } from '../../utils/target' -// target listen types +// Target listen types const listenTypes = { click: true } // Property key for handler storage -const BVT = '__BV_toggle__' -const BVT_STATE = '__BV_toggle_STATE__' -const BVT_CONTROLS = '__BV_toggle_CONTROLS__' +const BV_TOGGLE = '__BV_toggle__' +const BV_TOGGLE_STATE = '__BV_toggle_STATE__' +const BV_TOGGLE_CONTROLS = '__BV_toggle_CONTROLS__' -// Emitted Control Event for collapse (emitted to collapse) +// Emitted control event for collapse (emitted to collapse) const EVENT_TOGGLE = 'bv::toggle::collapse' -// Listen to Event for toggle state update (Emited by collapse) +// Listen to event for toggle state update (emitted by collapse) const EVENT_STATE = 'bv::collapse::state' -/* istanbul ignore next */ +// Reset and remove a property from the provided element +const resetProp = (el, prop) => { + el[prop] = null + delete el[prop] +} + +// Handle directive updates +/* istanbul ignore next: not easy to test */ const handleUpdate = (el, binding, vnode) => { + if (!inBrowser) { + return + } // Ensure the collapse class and aria-* attributes persist - // after element is updated (eitehr by parent re-rendering - // or changes to this element or it's contents. - if (inBrowser) { - if (el[BVT_STATE] === true) { - addClass(el, 'collapsed') - setAttr(el, 'aria-expanded', 'true') - } else if (el[BVT_STATE] === false) { - removeClass(el, 'collapsed') - setAttr(el, 'aria-expanded', 'false') - } - setAttr(el, 'aria-controls', el[BVT_CONTROLS]) + // after element is updated (either by parent re-rendering + // or changes to this element or it's contents + if (el[BV_TOGGLE_STATE] === true) { + addClass(el, 'collapsed') + setAttr(el, 'aria-expanded', 'true') + } else if (el[BV_TOGGLE_STATE] === false) { + removeClass(el, 'collapsed') + setAttr(el, 'aria-expanded', 'false') } + setAttr(el, 'aria-controls', el[BV_TOGGLE_CONTROLS]) } +/* + * Export our directive + */ export default { bind(el, binding, vnode) { - const targets = target(vnode, binding, listenTypes, ({ targets, vnode }) => { + const targets = bindTargets(vnode, binding, listenTypes, ({ targets, vnode }) => { targets.forEach(target => { vnode.context.$root.$emit(EVENT_TOGGLE, target) }) @@ -43,23 +54,23 @@ export default { if (inBrowser && vnode.context && targets.length > 0) { // Add aria attributes to element - el[BVT_CONTROLS] = targets.join(' ') - // state is initialy collapsed until we receive a state event - el[BVT_STATE] = false - setAttr(el, 'aria-controls', el[BVT_CONTROLS]) + el[BV_TOGGLE_CONTROLS] = targets.join(' ') + // State is initially collapsed until we receive a state event + el[BV_TOGGLE_STATE] = false + setAttr(el, 'aria-controls', el[BV_TOGGLE_CONTROLS]) setAttr(el, 'aria-expanded', 'false') + // If element is not a button, we add `role="button"` for accessibility if (el.tagName !== 'BUTTON') { - // If element is not a button, we add `role="button"` for accessibility setAttr(el, 'role', 'button') } - // Toggle state hadnler, stored on element - el[BVT] = function toggleDirectiveHandler(id, state) { + // Toggle state handler, stored on element + el[BV_TOGGLE] = function toggleDirectiveHandler(id, state) { if (targets.indexOf(id) !== -1) { // Set aria-expanded state setAttr(el, 'aria-expanded', state ? 'true' : 'false') // Set/Clear 'collapsed' class state - el[BVT_STATE] = state + el[BV_TOGGLE_STATE] = state if (state) { removeClass(el, 'collapsed') } else { @@ -69,21 +80,25 @@ export default { } // Listen for toggle state changes - vnode.context.$root.$on(EVENT_STATE, el[BVT]) + vnode.context.$root.$on(EVENT_STATE, el[BV_TOGGLE]) } }, componentUpdated: handleUpdate, updated: handleUpdate, unbind(el, binding, vnode) /* istanbul ignore next */ { - if (el[BVT]) { - // Remove our $root listener - vnode.context.$root.$off(EVENT_STATE, el[BVT]) - el[BVT] = null - el[BVT_STATE] = null - el[BVT_CONTROLS] = null - removeClass(el, 'collapsed') - removeAttr(el, 'aria-expanded') - removeAttr(el, 'aria-controls') + unbindTargets(vnode, binding, listenTypes) + // Remove our $root listener + if (el[BV_TOGGLE]) { + vnode.context.$root.$off(EVENT_STATE, el[BV_TOGGLE]) } + // Reset custom props + resetProp(el, BV_TOGGLE) + resetProp(el, BV_TOGGLE_STATE) + resetProp(el, BV_TOGGLE_CONTROLS) + // Reset classes/attrs + removeClass(el, 'collapsed') + removeAttr(el, 'aria-expanded') + removeAttr(el, 'aria-controls') + removeAttr(el, 'role') } } diff --git a/src/directives/toggle/toggle.spec.js b/src/directives/toggle/toggle.spec.js new file mode 100644 index 00000000000..7baaeac3a66 --- /dev/null +++ b/src/directives/toggle/toggle.spec.js @@ -0,0 +1,150 @@ +import toggleDirective from './toggle' +import { mount, createLocalVue as CreateLocalVue } from '@vue/test-utils' + +// Emitted control event for collapse (emitted to collapse) +const EVENT_TOGGLE = 'bv::toggle::collapse' + +// Listen to event for toggle state update (emitted by collapse) +const EVENT_STATE = 'bv::collapse::state' + +describe('v-b-toggle directive', () => { + it('works on buttons', async () => { + const localVue = new CreateLocalVue() + const spy = jest.fn() + + const App = localVue.extend({ + directives: { + bToggle: toggleDirective + }, + data() { + return {} + }, + mounted() { + this.$root.$on(EVENT_TOGGLE, spy) + }, + beforeDestroy() { + this.$root.$off(EVENT_TOGGLE, spy) + }, + template: '' + }) + + const wrapper = mount(App, { + localVue: localVue + }) + + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.is('button')).toBe(true) + expect(wrapper.find('button').attributes('aria-controls')).toBe('test') + expect(wrapper.find('button').attributes('aria-expanded')).toBe('false') + expect(wrapper.find('button').classes()).not.toContain('collapsed') + expect(spy).not.toHaveBeenCalled() + + const $button = wrapper.find('button') + $button.trigger('click') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toBeCalledWith('test') + expect(wrapper.find('button').attributes('aria-controls')).toBe('test') + expect(wrapper.find('button').attributes('aria-expanded')).toBe('false') + expect(wrapper.find('button').classes()).not.toContain('collapsed') + + wrapper.destroy() + }) + + it('works on non-buttons', async () => { + const localVue = new CreateLocalVue() + const spy = jest.fn() + + const App = localVue.extend({ + directives: { + bToggle: toggleDirective + }, + data() { + return { + text: 'span' + } + }, + mounted() { + this.$root.$on(EVENT_TOGGLE, spy) + }, + beforeDestroy() { + this.$root.$off(EVENT_TOGGLE, spy) + }, + template: '{{ text }}' + }) + + const wrapper = mount(App, { + localVue: localVue + }) + + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.is('span')).toBe(true) + expect(spy).not.toHaveBeenCalled() + expect(wrapper.find('span').attributes('role')).toBe('button') + expect(wrapper.find('span').attributes('aria-controls')).toBe('test') + expect(wrapper.find('span').attributes('aria-expanded')).toBe('false') + expect(wrapper.find('span').classes()).not.toContain('collapsed') + expect(wrapper.find('span').text()).toBe('span') + + const $span = wrapper.find('span') + $span.trigger('click') + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toBeCalledWith('test') + expect(wrapper.find('span').attributes('role')).toBe('button') + expect(wrapper.find('span').attributes('aria-controls')).toBe('test') + expect(wrapper.find('span').attributes('aria-expanded')).toBe('false') + expect(wrapper.find('span').classes()).not.toContain('collapsed') + + // Test updating component. should maintain role attribute + wrapper.setData({ + text: 'foobar' + }) + expect(wrapper.find('span').text()).toBe('foobar') + expect(wrapper.find('span').attributes('role')).toBe('button') + expect(wrapper.find('span').attributes('aria-controls')).toBe('test') + expect(wrapper.find('span').attributes('aria-expanded')).toBe('false') + expect(wrapper.find('span').classes()).not.toContain('collapsed') + + wrapper.destroy() + }) + it('responds to state update events', async () => { + const localVue = new CreateLocalVue() + + const App = localVue.extend({ + directives: { + bToggle: toggleDirective + }, + data() { + return {} + }, + template: '' + }) + + const wrapper = mount(App, { + localVue: localVue + }) + + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.is('button')).toBe(true) + expect(wrapper.find('button').attributes('aria-controls')).toBe('test') + expect(wrapper.find('button').attributes('aria-expanded')).toBe('false') + expect(wrapper.find('button').classes()).not.toContain('collapsed') + + const $root = wrapper.vm.$root + + $root.$emit(EVENT_STATE, 'test', true) + await wrapper.vm.$nextTick() + + expect(wrapper.find('button').attributes('aria-controls')).toBe('test') + expect(wrapper.find('button').attributes('aria-expanded')).toBe('true') + expect(wrapper.find('button').classes()).not.toContain('collapsed') + + $root.$emit(EVENT_STATE, 'test', false) + await wrapper.vm.$nextTick() + + expect(wrapper.find('button').attributes('aria-controls')).toBe('test') + expect(wrapper.find('button').attributes('aria-expanded')).toBe('false') + expect(wrapper.find('button').classes()).toContain('collapsed') + + wrapper.destroy() + }) +}) diff --git a/src/directives/tooltip/tooltip.js b/src/directives/tooltip/tooltip.js index a16e447a79f..60b20cd53d8 100644 --- a/src/directives/tooltip/tooltip.js +++ b/src/directives/tooltip/tooltip.js @@ -1,12 +1,11 @@ import Popper from 'popper.js' import ToolTip from '../../utils/tooltip.class' +import { inBrowser } from '../../utils/env' import { keys } from '../../utils/object' import warn from '../../utils/warn' -const inBrowser = typeof window !== 'undefined' && typeof document !== 'undefined' - // Key which we use to store tooltip object on element -const BVTT = '__BV_ToolTip__' +const BV_TOOLTIP = '__BV_ToolTip__' // Valid event triggers const validTriggers = { @@ -19,7 +18,7 @@ const validTriggers = { // Build a ToolTip config based on bindings (if any) // Arguments and modifiers take precedence over passed value config object /* istanbul ignore next: not easy to test */ -function parseBindings(bindings) { +const parseBindings = bindings => /* istanbul ignore next: not easy to test */ { // We start out with a blank config let config = {} @@ -35,9 +34,10 @@ function parseBindings(bindings) { config = { ...config, ...bindings.value } } - // If Argument, assume element ID of container element + // If argument, assume element ID of container element if (bindings.arg) { - // Element ID specified as arg. We must prepend '#' to become a CSS selector + // Element ID specified as arg + // We must prepend '#' to become a CSS selector config.container = `#${bindings.arg}` } @@ -47,24 +47,24 @@ function parseBindings(bindings) { // Title allows HTML config.html = true } else if (/^nofade$/.test(mod)) { - // no animation + // No animation config.animation = false } else if ( /^(auto|top(left|right)?|bottom(left|right)?|left(top|bottom)?|right(top|bottom)?)$/.test(mod) ) { - // placement of tooltip + // Placement of tooltip config.placement = mod } else if (/^(window|viewport)$/.test(mod)) { - // bounday of tooltip + // Boundary of tooltip config.boundary = mod } else if (/^d\d+$/.test(mod)) { - // delay value + // Delay value const delay = parseInt(mod.slice(1), 10) || 0 if (delay) { config.delay = delay } } else if (/^o-?\d+$/.test(mod)) { - // offset value. Negative allowed + // Offset value, negative allowed const offset = parseInt(mod.slice(1), 10) || 0 if (offset) { config.offset = offset @@ -72,10 +72,11 @@ function parseBindings(bindings) { } }) - // Special handling of event trigger modifiers Trigger is a space separated list + // Special handling of event trigger modifiers trigger is + // a space separated list const selectedTriggers = {} - // parse current config object trigger + // Parse current config object trigger let triggers = typeof config.trigger === 'string' ? config.trigger.trim().split(/\s+/) : [] triggers.forEach(trigger => { if (validTriggers[trigger]) { @@ -83,7 +84,7 @@ function parseBindings(bindings) { } }) - // Parse Modifiers for triggers + // Parse modifiers for triggers keys(validTriggers).forEach(trigger => { if (bindings.modifiers[trigger]) { selectedTriggers[trigger] = true @@ -97,70 +98,64 @@ function parseBindings(bindings) { config.trigger = 'focus' } if (!config.trigger) { - // remove trigger config + // Remove trigger config delete config.trigger } return config } -// -// Add or Update tooltip on our element -// -/* istanbul ignore next: not easy to test */ -function applyBVTT(el, bindings, vnode) { +// Add or update ToolTip on our element +const applyTooltip = (el, bindings, vnode) => { if (!inBrowser) { + /* istanbul ignore next */ return } if (!Popper) { - // Popper is required for tooltips to work - warn('v-b-tooltip: Popper.js is required for tooltips to work') + // Popper is required for ToolTips to work + /* istanbul ignore next */ + warn('v-b-tooltip: Popper.js is required for ToolTips to work') + /* istanbul ignore next */ return } - if (el[BVTT]) { - el[BVTT].updateConfig(parseBindings(bindings)) + const config = parseBindings(bindings) + if (el[BV_TOOLTIP]) { + el[BV_TOOLTIP].updateConfig(config) } else { - el[BVTT] = new ToolTip(el, parseBindings(bindings), vnode.context.$root) + el[BV_TOOLTIP] = new ToolTip(el, config, vnode.context.$root) } } -// -// Remove tooltip on our element -// -/* istanbul ignore next: not easy to test */ -function removeBVTT(el) { - if (!inBrowser) { - return - } - if (el[BVTT]) { - el[BVTT].destroy() - el[BVTT] = null - delete el[BVTT] +// Remove ToolTip on our element +const removeTooltip = el => { + if (el[BV_TOOLTIP]) { + el[BV_TOOLTIP].destroy() + el[BV_TOOLTIP] = null + delete el[BV_TOOLTIP] } } /* * Export our directive */ -/* istanbul ignore next: not easy to test */ export default { bind(el, bindings, vnode) { - applyBVTT(el, bindings, vnode) + applyTooltip(el, bindings, vnode) }, inserted(el, bindings, vnode) { - applyBVTT(el, bindings, vnode) + applyTooltip(el, bindings, vnode) }, - update(el, bindings, vnode) { + update(el, bindings, vnode) /* istanbul ignore next: not easy to test */ { if (bindings.value !== bindings.oldValue) { - applyBVTT(el, bindings, vnode) + applyTooltip(el, bindings, vnode) } }, - componentUpdated(el, bindings, vnode) { + componentUpdated(el, bindings, vnode) /* istanbul ignore next: not easy to test */ { if (bindings.value !== bindings.oldValue) { - applyBVTT(el, bindings, vnode) + applyTooltip(el, bindings, vnode) } }, unbind(el) { - removeBVTT(el) + removeTooltip(el) } } diff --git a/src/directives/tooltip/tooltip.spec.js b/src/directives/tooltip/tooltip.spec.js new file mode 100644 index 00000000000..08eb172d9b1 --- /dev/null +++ b/src/directives/tooltip/tooltip.spec.js @@ -0,0 +1,37 @@ +import tooltipDirective from './tooltip' +import ToolTip from '../../utils/tooltip.class' +import { mount, createLocalVue as CreateLocalVue } from '@vue/test-utils' + +// Key which we use to store tooltip object on element +const BV_TOOLTIP = '__BV_ToolTip__' + +describe('v-b-tooltip directive', () => { + it('should have ToolTip class instance', async () => { + const localVue = new CreateLocalVue() + + const App = localVue.extend({ + directives: { + bTooltip: tooltipDirective + }, + data() { + return {} + }, + template: '' + }) + + const wrapper = mount(App, { + localVue: localVue, + attachToDocument: true + }) + + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.is('button')).toBe(true) + const $button = wrapper.find('button') + + // Should have instance of popover class on it + expect($button.element[BV_TOOLTIP]).toBeDefined() + expect($button.element[BV_TOOLTIP]).toBeInstanceOf(ToolTip) + + wrapper.destroy() + }) +})