Skip to content

Commit 9c37875

Browse files
authored
fix(dropdown): focus-out handling when new focus comes from another dropdown-toggle (closes #4113) (#4139)
* fix(dropdown): focus-out handling when new focus somes from another `dropdown-toggle` * Try delaying the show handler * Update dropdown.js * Update dropdown.js * Trying with clickout handler disabled * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * lint * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.spec.js * Update dropdown.spec.js * Update dom.js * Update dom.spec.js * Update dom.js * Update dom.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Update dropdown.js * Correct typos
1 parent d5460c7 commit 9c37875

File tree

4 files changed

+63
-41
lines changed

4 files changed

+63
-41
lines changed

src/components/dropdown/dropdown.spec.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -479,8 +479,10 @@ describe('dropdown', () => {
479479
expect(document.activeElement).toBe($menu.element)
480480

481481
// Close menu by moving focus away from menu
482-
const focusInEvt = new FocusEvent('focusin')
483-
document.dispatchEvent(focusInEvt)
482+
// which triggers a focusout event on menu
483+
$menu.trigger('focusout', {
484+
relatedTarget: document.body
485+
})
484486
await waitNT(wrapper.vm)
485487
await waitRAF()
486488
expect($dropdown.classes()).not.toContain('show')
@@ -494,9 +496,11 @@ describe('dropdown', () => {
494496
expect($toggle.attributes('aria-expanded')).toEqual('true')
495497
expect(document.activeElement).toBe($menu.element)
496498

497-
// Close menu by clicking outside of menu
498-
const clickEvt = new MouseEvent('click')
499-
document.dispatchEvent(clickEvt)
499+
// Close menu by moving focus away from menu
500+
// which triggers a focusout event on menu
501+
$menu.trigger('focusout', {
502+
relatedTarget: document.body
503+
})
500504
await waitNT(wrapper.vm)
501505
await waitRAF()
502506
expect($dropdown.classes()).not.toContain('show')

src/mixins/dropdown.js

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
11
import Popper from 'popper.js'
2-
import { BvEvent } from '../utils/bv-event.class'
32
import KeyCodes from '../utils/key-codes'
43
import warn from '../utils/warn'
5-
import { closest, contains, isVisible, requestAF, selectAll } from '../utils/dom'
4+
import { BvEvent } from '../utils/bv-event.class'
5+
import { closest, contains, isVisible, requestAF, selectAll, eventOn, eventOff } from '../utils/dom'
66
import { isNull } from '../utils/inspect'
7-
import clickOutMixin from './click-out'
8-
import focusInMixin from './focus-in'
7+
import idMixin from './id'
98

109
// Return an array of visible items
1110
const filterVisibles = els => (els || []).filter(isVisible)
1211

12+
// Root dropdown event names
13+
const ROOT_DROPDOWN_PREFIX = 'bv::dropdown::'
14+
const ROOT_DROPDOWN_SHOWN = `${ROOT_DROPDOWN_PREFIX}shown`
15+
const ROOT_DROPDOWN_HIDDEN = `${ROOT_DROPDOWN_PREFIX}hidden`
16+
17+
// Delay when loosing focus before closing menu (in ms)
18+
const FOCUSOUT_DELAY = 100
19+
1320
// Dropdown item CSS selectors
1421
const Selector = {
1522
FORM_CHILD: '.dropdown form',
@@ -40,7 +47,7 @@ const AttachmentMap = {
4047

4148
// @vue/component
4249
export default {
43-
mixins: [clickOutMixin, focusInMixin],
50+
mixins: [idMixin],
4451
provide() {
4552
return {
4653
bvDropdown: this
@@ -136,7 +143,8 @@ export default {
136143
cancelable: true,
137144
vueTarget: this,
138145
target: this.$refs.menu,
139-
relatedTarget: null
146+
relatedTarget: null,
147+
componentId: this.safeId ? this.safeId() : this.id || null
140148
})
141149
this.emitEvent(bvEvt)
142150
if (bvEvt.defaultPrevented) {
@@ -181,16 +189,13 @@ export default {
181189
emitEvent(bvEvt) {
182190
const type = bvEvt.type
183191
this.$emit(type, bvEvt)
184-
this.$root.$emit(`bv::dropdown::${type}`, bvEvt)
192+
this.$root.$emit(`${ROOT_DROPDOWN_PREFIX}${type}`, bvEvt)
185193
},
186194
showMenu() {
187195
if (this.disabled) {
188196
/* istanbul ignore next */
189197
return
190198
}
191-
// Ensure other menus are closed
192-
this.$root.$emit('bv::dropdown::shown', this)
193-
194199
// Are we in a navbar ?
195200
if (isNull(this.inNavbar) && this.isNav) {
196201
// We should use an injection for this
@@ -213,6 +218,9 @@ export default {
213218
}
214219
}
215220

221+
// Ensure other menus are closed
222+
this.$root.$emit(ROOT_DROPDOWN_SHOWN, this)
223+
216224
this.whileOpenListen(true)
217225

218226
// Wrap in nextTick to ensure menu is fully rendered/shown
@@ -225,7 +233,7 @@ export default {
225233
},
226234
hideMenu() {
227235
this.whileOpenListen(false)
228-
this.$root.$emit('bv::dropdown::hidden', this)
236+
this.$root.$emit(ROOT_DROPDOWN_HIDDEN, this)
229237
this.$emit('hidden')
230238
this.removePopper()
231239
},
@@ -263,19 +271,16 @@ export default {
263271
}
264272
return { ...popperConfig, ...(this.popperOpts || {}) }
265273
},
266-
whileOpenListen(open) {
274+
whileOpenListen(isOpen) {
267275
// turn listeners on/off while open
268-
if (open) {
276+
if (isOpen) {
269277
// If another dropdown is opened
270-
this.$root.$on('bv::dropdown::shown', this.rootCloseListener)
271-
// Hide the dropdown when clicked outside
272-
this.listenForClickOut = true
273-
// Hide the dropdown when it loses focus
274-
this.listenForFocusIn = true
278+
this.$root.$on(ROOT_DROPDOWN_SHOWN, this.rootCloseListener)
279+
// Hide the menu when focus moves out
280+
eventOn(this.$el, 'focusout', this.onFocusOut, { passive: true })
275281
} else {
276-
this.$root.$off('bv::dropdown::shown', this.rootCloseListener)
277-
this.listenForClickOut = false
278-
this.listenForFocusIn = false
282+
this.$root.$off(ROOT_DROPDOWN_SHOWN, this.rootCloseListener)
283+
eventOff(this.$el, 'focusout', this.onFocusOut, { passive: true })
279284
}
280285
},
281286
rootCloseListener(vm) {
@@ -360,6 +365,7 @@ export default {
360365
this.focusNext(evt, true)
361366
}
362367
},
368+
// If uses presses ESC to close menu
363369
onEsc(evt) {
364370
if (this.visible) {
365371
this.visible = false
@@ -369,18 +375,25 @@ export default {
369375
this.$once('hidden', this.focusToggler)
370376
}
371377
},
372-
// Document click out listener
373-
clickOutHandler() {
374-
if (this.visible) {
375-
this.visible = false
376-
}
377-
},
378-
// Document focusin listener
379-
focusInHandler(evt) {
380-
const target = evt.target
381-
// If focus leaves dropdown, hide it
382-
if (this.visible && !contains(this.$refs.menu, target) && !contains(this.toggler, target)) {
383-
this.visible = false
378+
// Dropdown wrapper focusOut handler
379+
onFocusOut(evt) {
380+
// `relatedTarget` is the element gaining focus
381+
const relatedTarget = evt.relatedTarget
382+
// If focus moves outside the menu or toggler, then close menu
383+
if (
384+
this.visible &&
385+
!contains(this.$refs.menu, relatedTarget) &&
386+
!contains(this.toggler, relatedTarget)
387+
) {
388+
const doHide = () => {
389+
this.visible = false
390+
}
391+
// When we are in a navbar (which has been responsively stacked), we
392+
// delay the dropdown's closing so that the next element has a chance
393+
// to have it's click handler fired (in case it's position moves on
394+
// the screen do to a navbar menu above it collapsing)
395+
// https://github.com/bootstrap-vue/bootstrap-vue/issues/4113
396+
this.inNavbar ? setTimeout(doHide, FOCUSOUT_DELAY) : doHide()
384397
}
385398
},
386399
// Keyboard nav

src/utils/dom.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,16 @@ export const matches = (el, selector) => {
122122
}
123123

124124
// Finds closest element matching selector. Returns `null` if not found
125-
export const closest = (selector, root) => {
125+
export const closest = (selector, root, includeRoot = false) => {
126126
if (!isElement(root)) {
127127
return null
128128
}
129129
const el = closestEl.call(root, selector)
130-
// Emulate jQuery closest and return `null` if match is the passed in element (root)
131-
return el === root ? null : el
130+
131+
// Native closest behaviour when `includeRoot` is truthy,
132+
// else emulate jQuery closest and return `null` if match is
133+
// the passed in root element when `includeRoot` is falsey
134+
return includeRoot ? el : el === root ? null : el
132135
}
133136

134137
// Returns true if the parent element contains the child element

src/utils/dom.spec.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ describe('utils/dom', () => {
120120
expect(closest('div.baz', $btns.at(0).element)).toBeDefined()
121121
expect(closest('div.baz', $btns.at(0).element)).toBe($baz.element)
122122
expect(closest('div.nothere', $btns.at(0).element)).toBe(null)
123+
expect(closest('div.baz', $baz.element)).toBe(null)
124+
expect(closest('div.baz', $baz.element, true)).toBe($baz.element)
123125

124126
wrapper.destroy()
125127
})

0 commit comments

Comments
 (0)