Skip to content
Permalink
Browse files

fix(b-dropdown): handle issue with touch devices on MacOS using Safar…

…i/Firefox (Fixes #4328, #4344) (#4329)
  • Loading branch information
tmorehouse committed Nov 8, 2019
1 parent f419cb4 commit 2779a0aeeebe0797bb38f3f17c83fb463ee41624
@@ -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()
@@ -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)"
},
{
@@ -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) {
@@ -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 `<keep-alive>`
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
@@ -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) {

0 comments on commit 2779a0a

Please sign in to comment.
You can’t perform that action at this time.