Skip to content

Commit ad57e8c

Browse files
authored
fix(modal): prevent close on backdrop when click initiated inside modal content (fixes #3025) (#3029)
1 parent 891e8cc commit ad57e8c

File tree

2 files changed

+123
-7
lines changed

2 files changed

+123
-7
lines changed

src/components/modal/modal.js

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ const OBSERVER_CONFIG = {
2525
attributeFilter: ['style', 'class']
2626
}
2727

28+
// Options for DOM event listeners
29+
const EVT_OPTIONS = { passive: true, capture: false }
30+
2831
export const props = {
2932
title: {
3033
type: String,
@@ -221,8 +224,9 @@ export default Vue.extend({
221224
is_transitioning: false, // Used for style control
222225
is_show: false, // Used for style control
223226
is_block: false, // Used for style control
224-
is_opening: false, // Semaphore for preventing incorrect modal open counts
225-
is_closing: false, // Semaphore for preventing incorrect modal open counts
227+
is_opening: false, // To sginal that modal is in the process of opening
228+
is_closing: false, // To signal that the modal is in the process of closing
229+
ignoreBackdropClick: false, // Used to signify if click out listener should ignore the click
226230
isModalOverflowing: false,
227231
return_focus: this.returnFocus || null,
228232
// The following items are controlled by the modalManager instance
@@ -517,12 +521,30 @@ export default Vue.extend({
517521
this.emitOnRoot(`bv::modal::${type}`, bvEvt, bvEvt.modalId)
518522
},
519523
// UI event handlers
524+
onDialogMousedown(evt) {
525+
// Watch to see if the matching mouseup event occurs outside the dialog
526+
// And if it does, cancel the clickout handler
527+
const modal = this.$refs.modal
528+
const onceModalMouseup = evt => {
529+
eventOff(modal, 'mouseup', onceModalMouseup, EVT_OPTIONS)
530+
if (evt.target === modal) {
531+
this.ignoreBackdropClick = true
532+
}
533+
}
534+
eventOn(modal, 'mouseup', onceModalMouseup, EVT_OPTIONS)
535+
},
520536
onClickOut(evt) {
521537
// Do nothing if not visible, backdrop click disabled, or element
522538
// that generated click event is no longer in document
523539
if (!this.is_visible || this.noCloseOnBackdrop || !contains(document, evt.target)) {
524540
return
525541
}
542+
if (this.ignoreBackdropClick) {
543+
// Click was initiated inside the modal content, but finished outside
544+
// Set by the above onDialogMousedown handler
545+
this.ignoreBackdropClick = false
546+
return
547+
}
526548
// If backdrop clicked, hide modal
527549
if (!contains(this.$refs.content, evt.target)) {
528550
this.hide('backdrop')
@@ -552,15 +574,14 @@ export default Vue.extend({
552574
// Turn on/off focusin listener
553575
setEnforceFocus(on) {
554576
const method = on ? eventOn : eventOff
555-
method(document, 'focusin', this.focusHandler, { passive: true, capture: false })
577+
method(document, 'focusin', this.focusHandler, EVT_OPTIONS)
556578
},
557579
// Resize listener
558580
setResizeEvent(on) {
559-
const options = { passive: true, capture: false }
560581
const method = on ? eventOn : eventOff
561582
// These events should probably also check if body is overflowing
562-
method(window, 'resize', this.checkModalOverflow, options)
563-
method(window, 'orientationchange', this.checkModalOverflow, options)
583+
method(window, 'resize', this.checkModalOverflow, EVT_OPTIONS)
584+
method(window, 'orientationchange', this.checkModalOverflow, EVT_OPTIONS)
564585
},
565586
// Root listener handlers
566587
showHandler(id, triggerEl) {
@@ -758,7 +779,10 @@ export default Vue.extend({
758779
'div',
759780
{
760781
staticClass: 'modal-dialog',
761-
class: this.dialogClasses
782+
class: this.dialogClasses,
783+
on: {
784+
mousedown: this.onDialogMousedown
785+
}
762786
},
763787
[modalContent]
764788
)

src/components/modal/modal.spec.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,98 @@ describe('modal', () => {
581581
wrapper.destroy()
582582
})
583583

584+
it('mousedown inside followed by mouse up outside (click) does not close modal', async () => {
585+
let trigger = null
586+
let called = false
587+
const wrapper = mount(BModal, {
588+
attachToDocument: true,
589+
stubs: {
590+
transition: false
591+
},
592+
propsData: {
593+
id: 'test',
594+
visible: true
595+
},
596+
listeners: {
597+
hide: bvEvent => {
598+
called = true
599+
trigger = bvEvent.trigger
600+
}
601+
}
602+
})
603+
604+
expect(wrapper.isVueInstance()).toBe(true)
605+
606+
await wrapper.vm.$nextTick()
607+
await waitAF()
608+
await wrapper.vm.$nextTick()
609+
await waitAF()
610+
611+
const $modal = wrapper.find('div.modal')
612+
expect($modal.exists()).toBe(true)
613+
614+
const $dialog = wrapper.find('div.modal-dialog')
615+
expect($dialog.exists()).toBe(true)
616+
617+
const $footer = wrapper.find('footer.modal-footer')
618+
expect($footer.exists()).toBe(true)
619+
620+
expect($modal.element.style.display).toEqual('')
621+
622+
expect(wrapper.emitted('hide')).not.toBeDefined()
623+
expect(trigger).toEqual(null)
624+
625+
// Try and close modal via a "dragged" click out
626+
// starting from inside modal and finishing on backdrop
627+
$dialog.trigger('mousedown')
628+
$modal.trigger('mouseup')
629+
$modal.trigger('click')
630+
631+
await wrapper.vm.$nextTick()
632+
await waitAF()
633+
await wrapper.vm.$nextTick()
634+
await waitAF()
635+
636+
expect(called).toEqual(false)
637+
expect(trigger).toEqual(null)
638+
639+
// Modal should not be closed
640+
expect($modal.element.style.display).toEqual('')
641+
642+
// Try and close modal via a "dragged" click out
643+
// starting from inside modal and finishing on backdrop
644+
$footer.trigger('mousedown')
645+
$modal.trigger('mouseup')
646+
$modal.trigger('click')
647+
648+
await wrapper.vm.$nextTick()
649+
await waitAF()
650+
await wrapper.vm.$nextTick()
651+
await waitAF()
652+
653+
expect(called).toEqual(false)
654+
expect(trigger).toEqual(null)
655+
656+
// Modal should not be closed
657+
expect($modal.element.style.display).toEqual('')
658+
659+
// Try and close modal via click out
660+
$modal.trigger('click')
661+
662+
await wrapper.vm.$nextTick()
663+
await waitAF()
664+
await wrapper.vm.$nextTick()
665+
await waitAF()
666+
667+
expect(called).toEqual(true)
668+
expect(trigger).toEqual('backdrop')
669+
670+
// Modal should now be closed
671+
expect($modal.element.style.display).toEqual('none')
672+
673+
wrapper.destroy()
674+
})
675+
584676
it('$root bv::show::modal and bv::hide::modal work', async () => {
585677
const wrapper = mount(BModal, {
586678
attachToDocument: true,

0 commit comments

Comments
 (0)