diff --git a/src/components/dropdown/dropdown.spec.js b/src/components/dropdown/dropdown.spec.js index 99c08225945..85ae3bbbe2c 100644 --- a/src/components/dropdown/dropdown.spec.js +++ b/src/components/dropdown/dropdown.spec.js @@ -417,8 +417,9 @@ describe('dropdown', () => { const localVue = new CreateLocalVue() const App = localVue.extend({ render(h) { - return h('div', {}, [ - h(BDropdown, { props: { id: 'test' } }, [h(BDropdownItem, {}, 'item')]) + return h('div', { attrs: { id: 'container' } }, [ + h(BDropdown, { props: { id: 'test' } }, [h(BDropdownItem, {}, 'item')]), + h('input', { attrs: { id: 'input' } }) ]) } }) @@ -434,10 +435,12 @@ describe('dropdown', () => { expect(wrapper.findAll('.dropdown-menu').length).toBe(1) expect(wrapper.findAll('.dropdown-menu .dropdown-item').length).toBe(1) + const $container = wrapper.find('#container') const $dropdown = wrapper.find('.dropdown') const $toggle = wrapper.find('.dropdown-toggle') const $menu = wrapper.find('.dropdown-menu') const $item = wrapper.find('.dropdown-item') + const $input = wrapper.find('#input') expect($dropdown.isVueInstance()).toBe(true) @@ -480,21 +483,21 @@ describe('dropdown', () => { expect($toggle.attributes('aria-expanded')).toEqual('false') expect($dropdown.classes()).not.toContain('show') - // Open menu via .show() method + // Open menu via ´.show()´ method $dropdown.vm.show() await waitNT(wrapper.vm) await waitRAF() expect($toggle.attributes('aria-expanded')).toEqual('true') expect($dropdown.classes()).toContain('show') - // Close menu via .hide() method + // Close menu via ´.hide()´ method $dropdown.vm.hide() await waitNT(wrapper.vm) await waitRAF() expect($toggle.attributes('aria-expanded')).toEqual('false') expect($dropdown.classes()).not.toContain('show') - // Open menu via .show() method again + // Open menu via ´.show()´ method again $dropdown.vm.show() await waitNT(wrapper.vm) await waitRAF() @@ -503,10 +506,7 @@ describe('dropdown', () => { expect(document.activeElement).toBe($menu.element) // Close menu by moving focus away from menu - // which triggers a focusout event on menu - $menu.trigger('focusout', { - relatedTarget: document.body - }) + $input.trigger('focusin') await waitNT(wrapper.vm) await waitRAF() expect($dropdown.classes()).not.toContain('show') @@ -520,17 +520,14 @@ describe('dropdown', () => { expect($toggle.attributes('aria-expanded')).toEqual('true') expect(document.activeElement).toBe($menu.element) - // Close menu by moving focus away from menu - // which triggers a focusout event on menu - $menu.trigger('focusout', { - relatedTarget: document.body - }) + // Close menu by clicking outside + $container.trigger('click') await waitNT(wrapper.vm) await waitRAF() expect($dropdown.classes()).not.toContain('show') expect($toggle.attributes('aria-expanded')).toEqual('false') - // Open menu via .show() method again + // Open menu via ´.show()´ method again $dropdown.vm.show() await waitNT(wrapper.vm) await waitRAF() @@ -544,7 +541,7 @@ describe('dropdown', () => { expect($dropdown.classes()).not.toContain('show') expect($toggle.attributes('aria-expanded')).toEqual('false') - // Open menu via .show() method again + // Open menu via ´.show()´ method again $dropdown.vm.show() await waitNT(wrapper.vm) await waitRAF() diff --git a/src/components/dropdown/package.json b/src/components/dropdown/package.json index 0810bb67daf..55a603944e0 100644 --- a/src/components/dropdown/package.json +++ b/src/components/dropdown/package.json @@ -41,7 +41,7 @@ }, { "prop": "offset", - "description": "Specify the number of pixes to shift the menu by. Negative values supported" + "description": "Specify the number of pixels to shift the menu by. Negative values supported" }, { "prop": "lazy", @@ -69,6 +69,7 @@ }, { "prop": "block", + "version": "2.1.0", "description": "Renders a 100% width toggle button (expands to the width of it's parent container)" }, { diff --git a/src/mixins/click-out.js b/src/mixins/click-out.js index 1d98af4024c..308ee26d6dd 100644 --- a/src/mixins/click-out.js +++ b/src/mixins/click-out.js @@ -1,5 +1,7 @@ import { contains, eventOff, eventOn } from '../utils/dom' +const eventOptions = { passive: true, capture: false } + // @vue/component export default { data() { @@ -10,9 +12,9 @@ export default { watch: { listenForClickOut(newValue, oldValue) { if (newValue !== oldValue) { - eventOff(this.clickOutElement, this.clickOutEventName, this._clickOutHandler, false) + eventOff(this.clickOutElement, this.clickOutEventName, this._clickOutHandler, eventOptions) if (newValue) { - eventOn(this.clickOutElement, this.clickOutEventName, this._clickOutHandler, false) + eventOn(this.clickOutElement, this.clickOutEventName, this._clickOutHandler, eventOptions) } } } @@ -30,11 +32,11 @@ export default { this.clickOutEventName = 'ontouchstart' in document.documentElement ? 'touchstart' : 'click' } if (this.listenForClickOut) { - eventOn(this.clickOutElement, this.clickOutEventName, this._clickOutHandler, false) + eventOn(this.clickOutElement, this.clickOutEventName, this._clickOutHandler, eventOptions) } }, beforeDestroy() /* istanbul ignore next */ { - eventOff(this.clickOutElement, this.clickOutEventName, this._clickOutHandler, false) + eventOff(this.clickOutElement, this.clickOutEventName, this._clickOutHandler, eventOptions) }, methods: { isClickOut(evt) { diff --git a/src/mixins/dropdown.js b/src/mixins/dropdown.js index c950298e7f4..0d58aed484e 100644 --- a/src/mixins/dropdown.js +++ b/src/mixins/dropdown.js @@ -2,8 +2,11 @@ import Popper from 'popper.js' import KeyCodes from '../utils/key-codes' import warn from '../utils/warn' import { BvEvent } from '../utils/bv-event.class' -import { closest, contains, isVisible, requestAF, selectAll, eventOn, eventOff } from '../utils/dom' +import { closest, contains, isVisible, requestAF, selectAll } from '../utils/dom' +import { hasTouchSupport } from '../utils/env' import { isNull } from '../utils/inspect' +import clickOutMixin from './click-out' +import focusInMixin from './focus-in' import idMixin from './id' // Return an array of visible items @@ -15,7 +18,7 @@ const ROOT_DROPDOWN_SHOWN = `${ROOT_DROPDOWN_PREFIX}shown` const ROOT_DROPDOWN_HIDDEN = `${ROOT_DROPDOWN_PREFIX}hidden` // Delay when loosing focus before closing menu (in ms) -const FOCUSOUT_DELAY = 100 +const FOCUSOUT_DELAY = hasTouchSupport ? 450 : 150 // Dropdown item CSS selectors const Selector = { @@ -47,7 +50,7 @@ const AttachmentMap = { // @vue/component export default { - mixins: [idMixin], + mixins: [idMixin, clickOutMixin, focusInMixin], provide() { return { bvDropdown: this @@ -171,18 +174,21 @@ export default { }, created() { // Create non-reactive property - this._popper = null + this.$_popper = null + this.$_hideTimeout = null + this.$_noop = () => {} }, deactivated() /* istanbul ignore next: not easy to test */ { // In case we are inside a `` this.visible = false this.whileOpenListen(false) - this.removePopper() + this.destroyPopper() }, beforeDestroy() { this.visible = false this.whileOpenListen(false) - this.removePopper() + this.destroyPopper() + this.clearHideTimeout() }, methods: { // Event emitter @@ -235,18 +241,25 @@ export default { this.whileOpenListen(false) this.$root.$emit(ROOT_DROPDOWN_HIDDEN, this) this.$emit('hidden') - this.removePopper() + this.destroyPopper() }, createPopper(element) { - this.removePopper() - this._popper = new Popper(element, this.$refs.menu, this.getPopperConfig()) + this.destroyPopper() + this.$_popper = new Popper(element, this.$refs.menu, this.getPopperConfig()) }, - removePopper() { - if (this._popper) { + destroyPopper() { + if (this.$_popper) { // Ensure popper event listeners are removed cleanly - this._popper.destroy() + this.$_popper.destroy() + } + this.$_popper = null + }, + clearHideTimeout() { + /* istanbul ignore next */ + if (this.$_hideTimeout) { + clearTimeout(this.$_hideTimeout) + this.$_hideTimeout = null } - this._popper = null }, getPopperConfig() { let placement = AttachmentMap.BOTTOM @@ -271,17 +284,15 @@ export default { } return { ...popperConfig, ...(this.popperOpts || {}) } }, + // Turn listeners on/off while open whileOpenListen(isOpen) { - // turn listeners on/off while open - if (isOpen) { - // If another dropdown is opened - this.$root.$on(ROOT_DROPDOWN_SHOWN, this.rootCloseListener) - // Hide the menu when focus moves out - eventOn(this.$el, 'focusout', this.onFocusOut, { passive: true }) - } else { - this.$root.$off(ROOT_DROPDOWN_SHOWN, this.rootCloseListener) - eventOff(this.$el, 'focusout', this.onFocusOut, { passive: true }) - } + // Hide the dropdown when clicked outside + this.listenForClickOut = isOpen + // Hide the dropdown when it loses focus + this.listenForFocusIn = isOpen + // Hide the dropdown when another dropdown is opened + const method = isOpen ? '$on' : '$off' + this.$root[method](ROOT_DROPDOWN_SHOWN, this.rootCloseListener) }, rootCloseListener(vm) { if (vm !== this) { @@ -375,27 +386,28 @@ export default { this.$once('hidden', this.focusToggler) } }, - // Dropdown wrapper focusOut handler - onFocusOut(evt) { - // `relatedTarget` is the element gaining focus - const relatedTarget = evt.relatedTarget - // If focus moves outside the menu or toggler, then close menu - if ( - this.visible && - !contains(this.$refs.menu, relatedTarget) && - !contains(this.toggler, relatedTarget) - ) { + // Document click out listener + clickOutHandler(evt) { + const target = evt.target + if (this.visible && !contains(this.$refs.menu, target) && !contains(this.toggler, target)) { const doHide = () => { this.visible = false + return null } // When we are in a navbar (which has been responsively stacked), we // delay the dropdown's closing so that the next element has a chance // to have it's click handler fired (in case it's position moves on // the screen do to a navbar menu above it collapsing) // https://github.com/bootstrap-vue/bootstrap-vue/issues/4113 - this.inNavbar ? setTimeout(doHide, FOCUSOUT_DELAY) : doHide() + this.clearHideTimeout() + this.$_hideTimeout = this.inNavbar ? setTimeout(doHide, FOCUSOUT_DELAY) : doHide() } }, + // Document focusin listener + focusInHandler(evt) { + // Shared logic with click-out handler + this.clickOutHandler(evt) + }, // Keyboard nav focusNext(evt, up) { // Ignore key up/down on form elements diff --git a/src/mixins/focus-in.js b/src/mixins/focus-in.js index 4860c0c6962..94b84b75c31 100644 --- a/src/mixins/focus-in.js +++ b/src/mixins/focus-in.js @@ -1,5 +1,7 @@ import { eventOff, eventOn } from '../utils/dom' +const eventOptions = { passive: true, capture: false } + // @vue/component export default { data() { @@ -10,9 +12,9 @@ export default { watch: { listenForFocusIn(newValue, oldValue) { if (newValue !== oldValue) { - eventOff(this.focusInElement, 'focusin', this._focusInHandler, false) + eventOff(this.focusInElement, 'focusin', this._focusInHandler, eventOptions) if (newValue) { - eventOn(this.focusInElement, 'focusin', this._focusInHandler, false) + eventOn(this.focusInElement, 'focusin', this._focusInHandler, eventOptions) } } } @@ -26,11 +28,11 @@ export default { this.focusInElement = document } if (this.listenForFocusIn) { - eventOn(this.focusInElement, 'focusin', this._focusInHandler, false) + eventOn(this.focusInElement, 'focusin', this._focusInHandler, eventOptions) } }, beforeDestroy() /* istanbul ignore next */ { - eventOff(this.focusInElement, 'focusin', this._focusInHandler, false) + eventOff(this.focusInElement, 'focusin', this._focusInHandler, eventOptions) }, methods: { _focusInHandler(evt) {