diff --git a/src/components/dropdown/dropdown.spec.js b/src/components/dropdown/dropdown.spec.js index 45af4776cbc..dab55bf7c98 100644 --- a/src/components/dropdown/dropdown.spec.js +++ b/src/components/dropdown/dropdown.spec.js @@ -479,8 +479,10 @@ describe('dropdown', () => { expect(document.activeElement).toBe($menu.element) // Close menu by moving focus away from menu - const focusInEvt = new FocusEvent('focusin') - document.dispatchEvent(focusInEvt) + // which triggers a focusout event on menu + $menu.trigger('focusout', { + relatedTarget: document.body + }) await waitNT(wrapper.vm) await waitRAF() expect($dropdown.classes()).not.toContain('show') @@ -494,9 +496,11 @@ describe('dropdown', () => { expect($toggle.attributes('aria-expanded')).toEqual('true') expect(document.activeElement).toBe($menu.element) - // Close menu by clicking outside of menu - const clickEvt = new MouseEvent('click') - document.dispatchEvent(clickEvt) + // Close menu by moving focus away from menu + // which triggers a focusout event on menu + $menu.trigger('focusout', { + relatedTarget: document.body + }) await waitNT(wrapper.vm) await waitRAF() expect($dropdown.classes()).not.toContain('show') diff --git a/src/mixins/dropdown.js b/src/mixins/dropdown.js index 2ec4e216ca4..c950298e7f4 100644 --- a/src/mixins/dropdown.js +++ b/src/mixins/dropdown.js @@ -1,15 +1,22 @@ import Popper from 'popper.js' -import { BvEvent } from '../utils/bv-event.class' import KeyCodes from '../utils/key-codes' import warn from '../utils/warn' -import { closest, contains, isVisible, requestAF, selectAll } from '../utils/dom' +import { BvEvent } from '../utils/bv-event.class' +import { closest, contains, isVisible, requestAF, selectAll, eventOn, eventOff } from '../utils/dom' 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 const filterVisibles = els => (els || []).filter(isVisible) +// Root dropdown event names +const ROOT_DROPDOWN_PREFIX = 'bv::dropdown::' +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 + // Dropdown item CSS selectors const Selector = { FORM_CHILD: '.dropdown form', @@ -40,7 +47,7 @@ const AttachmentMap = { // @vue/component export default { - mixins: [clickOutMixin, focusInMixin], + mixins: [idMixin], provide() { return { bvDropdown: this @@ -136,7 +143,8 @@ export default { cancelable: true, vueTarget: this, target: this.$refs.menu, - relatedTarget: null + relatedTarget: null, + componentId: this.safeId ? this.safeId() : this.id || null }) this.emitEvent(bvEvt) if (bvEvt.defaultPrevented) { @@ -181,16 +189,13 @@ export default { emitEvent(bvEvt) { const type = bvEvt.type this.$emit(type, bvEvt) - this.$root.$emit(`bv::dropdown::${type}`, bvEvt) + this.$root.$emit(`${ROOT_DROPDOWN_PREFIX}${type}`, bvEvt) }, showMenu() { if (this.disabled) { /* istanbul ignore next */ return } - // Ensure other menus are closed - this.$root.$emit('bv::dropdown::shown', this) - // Are we in a navbar ? if (isNull(this.inNavbar) && this.isNav) { // We should use an injection for this @@ -213,6 +218,9 @@ export default { } } + // Ensure other menus are closed + this.$root.$emit(ROOT_DROPDOWN_SHOWN, this) + this.whileOpenListen(true) // Wrap in nextTick to ensure menu is fully rendered/shown @@ -225,7 +233,7 @@ export default { }, hideMenu() { this.whileOpenListen(false) - this.$root.$emit('bv::dropdown::hidden', this) + this.$root.$emit(ROOT_DROPDOWN_HIDDEN, this) this.$emit('hidden') this.removePopper() }, @@ -263,19 +271,16 @@ export default { } return { ...popperConfig, ...(this.popperOpts || {}) } }, - whileOpenListen(open) { + whileOpenListen(isOpen) { // turn listeners on/off while open - if (open) { + if (isOpen) { // If another dropdown is opened - this.$root.$on('bv::dropdown::shown', this.rootCloseListener) - // Hide the dropdown when clicked outside - this.listenForClickOut = true - // Hide the dropdown when it loses focus - this.listenForFocusIn = true + 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('bv::dropdown::shown', this.rootCloseListener) - this.listenForClickOut = false - this.listenForFocusIn = false + this.$root.$off(ROOT_DROPDOWN_SHOWN, this.rootCloseListener) + eventOff(this.$el, 'focusout', this.onFocusOut, { passive: true }) } }, rootCloseListener(vm) { @@ -360,6 +365,7 @@ export default { this.focusNext(evt, true) } }, + // If uses presses ESC to close menu onEsc(evt) { if (this.visible) { this.visible = false @@ -369,18 +375,25 @@ export default { this.$once('hidden', this.focusToggler) } }, - // Document click out listener - clickOutHandler() { - if (this.visible) { - this.visible = false - } - }, - // Document focusin listener - focusInHandler(evt) { - const target = evt.target - // If focus leaves dropdown, hide it - if (this.visible && !contains(this.$refs.menu, target) && !contains(this.toggler, target)) { - this.visible = false + // 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) + ) { + const doHide = () => { + this.visible = false + } + // 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() } }, // Keyboard nav diff --git a/src/utils/dom.js b/src/utils/dom.js index 33e6c505955..5802bf8a12c 100644 --- a/src/utils/dom.js +++ b/src/utils/dom.js @@ -122,13 +122,16 @@ export const matches = (el, selector) => { } // Finds closest element matching selector. Returns `null` if not found -export const closest = (selector, root) => { +export const closest = (selector, root, includeRoot = false) => { if (!isElement(root)) { return null } const el = closestEl.call(root, selector) - // Emulate jQuery closest and return `null` if match is the passed in element (root) - return el === root ? null : el + + // Native closest behaviour when `includeRoot` is truthy, + // else emulate jQuery closest and return `null` if match is + // the passed in root element when `includeRoot` is falsey + return includeRoot ? el : el === root ? null : el } // Returns true if the parent element contains the child element diff --git a/src/utils/dom.spec.js b/src/utils/dom.spec.js index 74fbacbc172..c697a527215 100644 --- a/src/utils/dom.spec.js +++ b/src/utils/dom.spec.js @@ -120,6 +120,8 @@ describe('utils/dom', () => { expect(closest('div.baz', $btns.at(0).element)).toBeDefined() expect(closest('div.baz', $btns.at(0).element)).toBe($baz.element) expect(closest('div.nothere', $btns.at(0).element)).toBe(null) + expect(closest('div.baz', $baz.element)).toBe(null) + expect(closest('div.baz', $baz.element, true)).toBe($baz.element) wrapper.destroy() })