diff --git a/src/components/modal/README.md b/src/components/modal/README.md
index 94e7f62846c..363af3df571 100644
--- a/src/components/modal/README.md
+++ b/src/components/modal/README.md
@@ -458,6 +458,23 @@ prop `scrollable` to `true`.
```
+### Draggable modal
+
+You can make your modal draggable by setting the `draggable` prop to `true`. This will let you drag
+it over the screen by grabbing the header.
+
+```html
+
+
Launch draggable modal
+
+
+ Draggable modal!
+
+
+
+
+```
+
### Vertically centered modal
Vertically center your modal in the viewport by setting the `centered` prop.
diff --git a/src/components/modal/_modal.scss b/src/components/modal/_modal.scss
index 31fc8bd614d..0f22508f832 100644
--- a/src/components/modal/_modal.scss
+++ b/src/components/modal/_modal.scss
@@ -3,3 +3,9 @@
.modal-backdrop {
opacity: $modal-backdrop-opacity;
}
+
+// When draggable property is set to true
+// cursor is all-scroll on modal-header hover
+.modal-drag {
+ cursor: all-scroll;
+}
diff --git a/src/components/modal/modal.js b/src/components/modal/modal.js
index 071c9933c78..6fd485f3cf6 100644
--- a/src/components/modal/modal.js
+++ b/src/components/modal/modal.js
@@ -125,6 +125,7 @@ export const props = makePropsConfigurable(
centered: makeProp(PROP_TYPE_BOOLEAN, false),
contentClass: makeProp(PROP_TYPE_ARRAY_OBJECT_STRING),
dialogClass: makeProp(PROP_TYPE_ARRAY_OBJECT_STRING),
+ draggable: makeProp(PROP_TYPE_BOOLEAN, false),
footerBgVariant: makeProp(PROP_TYPE_STRING),
footerBorderVariant: makeProp(PROP_TYPE_STRING),
footerClass: makeProp(PROP_TYPE_ARRAY_OBJECT_STRING),
@@ -267,7 +268,8 @@ export const BModal = /*#__PURE__*/ extend({
{
[`bg-${this.headerBgVariant}`]: this.headerBgVariant,
[`text-${this.headerTextVariant}`]: this.headerTextVariant,
- [`border-${this.headerBorderVariant}`]: this.headerBorderVariant
+ [`border-${this.headerBorderVariant}`]: this.headerBorderVariant,
+ 'modal-drag': this.draggable
},
this.headerClass
]
@@ -611,6 +613,23 @@ export const BModal = /*#__PURE__*/ extend({
}
eventOn(modal, 'mouseup', onceModalMouseup, EVENT_OPTIONS_NO_CAPTURE)
},
+ onHeaderMouseDown(event) {
+ const modal = this.$refs.modal
+ const header = this.$refs.header
+ // If modal is draggable and clicked target is the header
+ // Then modal modal can be dragged
+ if (this.draggable && event.target === header) {
+ this.onDrag(modal)
+ }
+ },
+ onHeaderMouseUp() {
+ const modal = this.$refs.modal
+ if (this.draggable) {
+ // Removes the mousedown event from modal when dragging is over
+ // This prevents being able to drag the modal by another part than the header when is has been drag before
+ modal.onmousedown = null
+ }
+ },
onClickOut(event) {
if (this.ignoreBackdropClick) {
// Click was initiated inside the modal content, but finished outside.
@@ -643,6 +662,41 @@ export const BModal = /*#__PURE__*/ extend({
this.hide(TRIGGER_ESC)
}
},
+ onDrag(element) {
+ let pos1 = 0
+ let pos2 = 0
+ let pos3 = 0
+ let pos4 = 0
+ element.onmousedown = dragMouseDown
+
+ function dragMouseDown(e) {
+ e = e || window.event
+ e.preventDefault()
+ pos3 = e.clientX
+ pos4 = e.clientY
+ document.onmouseup = _event => closeDragElement(_event, element)
+ document.onmousemove = elementDrag
+ }
+
+ function elementDrag(e) {
+ e = e || window.event
+ e.preventDefault()
+ // calculate the new cursor position:
+ pos1 = pos3 - e.clientX
+ pos2 = pos4 - e.clientY
+ pos3 = e.clientX
+ pos4 = e.clientY
+ // set the element's new position:
+ element.style.top = element.offsetTop - pos2 + 'px'
+ element.style.left = element.offsetLeft - pos1 + 'px'
+ }
+
+ function closeDragElement() {
+ // stop moving when mouse button is released:
+ document.onmouseup = null
+ document.onmousemove = null
+ }
+ },
// Document focusin listener
focusHandler(event) {
// If focus leaves modal content, bring it back
@@ -821,6 +875,7 @@ export const BModal = /*#__PURE__*/ extend({
staticClass: 'modal-header',
class: this.headerClasses,
attrs: { id: this.modalHeaderId },
+ on: { mousedown: this.onHeaderMouseDown, mouseup: this.onHeaderMouseUp },
ref: 'header'
},
[$modalHeader]
diff --git a/src/components/modal/modal.spec.js b/src/components/modal/modal.spec.js
index 7c7b4877944..2e01882eb92 100644
--- a/src/components/modal/modal.spec.js
+++ b/src/components/modal/modal.spec.js
@@ -780,6 +780,192 @@ describe('modal', () => {
wrapper.destroy()
})
+ it('mousedown inside header and mousemove when modal is draggable moves the modal', async () => {
+ let trigger = null
+ let called = false
+ const wrapper = mount(BModal, {
+ attachTo: document.body,
+ propsData: {
+ static: true,
+ id: 'test',
+ visible: true,
+ draggable: true
+ },
+ listeners: {
+ hide: bvEvent => {
+ called = true
+ trigger = bvEvent.trigger
+ }
+ }
+ })
+
+ expect(wrapper.vm).toBeDefined()
+
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
+ const $modal = wrapper.find('div.modal')
+ expect($modal.exists()).toBe(true)
+
+ const $header = wrapper.find('.modal-header')
+ expect($header.exists()).toBe(true)
+
+ const $dialog = wrapper.find('div.modal-dialog')
+ expect($dialog.exists()).toBe(true)
+
+ const $footer = wrapper.find('footer.modal-footer')
+ expect($footer.exists()).toBe(true)
+
+ expect($modal.element.style.display).toEqual('block')
+
+ expect(wrapper.emitted('hide')).toBeUndefined()
+ expect(trigger).toEqual(null)
+
+ // Try and move modal via a "dragged" click
+ // starting from inside modal header
+ await $header.trigger('mousedown', { clientX: 0, clientY: 0 })
+ await $header.trigger('mousemove', { clientX: 100, clientY: 100 })
+ await $header.trigger('mouseup')
+ await $header.trigger('click')
+ await waitRAF()
+ await waitRAF()
+ expect(called).toEqual(false)
+ expect(trigger).toEqual(null)
+
+ // Modal should not be closed
+ expect($modal.element.style.display).toEqual('block')
+
+ // Modal should have been moved away
+ expect($modal.element.style.top).toEqual('100px')
+
+ wrapper.destroy()
+ })
+
+ it('mousedown inside body and mousemove when modal is draggable does not move the modal', async () => {
+ let trigger = null
+ let called = false
+ const wrapper = mount(BModal, {
+ attachTo: document.body,
+ propsData: {
+ static: true,
+ id: 'test',
+ visible: true,
+ draggable: true
+ },
+ listeners: {
+ hide: bvEvent => {
+ called = true
+ trigger = bvEvent.trigger
+ }
+ }
+ })
+
+ expect(wrapper.vm).toBeDefined()
+
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
+ const $modal = wrapper.find('div.modal')
+ expect($modal.exists()).toBe(true)
+
+ const $dialog = wrapper.find('div.modal-dialog')
+ expect($dialog.exists()).toBe(true)
+
+ const $footer = wrapper.find('footer.modal-footer')
+ expect($footer.exists()).toBe(true)
+
+ expect($modal.element.style.display).toEqual('block')
+
+ expect(wrapper.emitted('hide')).toBeUndefined()
+ expect(trigger).toEqual(null)
+
+ // Try and move modal via a "dragged"
+ // starting from inside modal body
+ await $dialog.trigger('mousedown')
+ await $dialog.trigger('mousemove')
+ await $modal.trigger('mouseup')
+ await $modal.trigger('click')
+ await waitRAF()
+ await waitRAF()
+ expect(called).toEqual(false)
+ expect(trigger).toEqual(null)
+
+ // Modal should not be closed
+ expect($modal.element.style.display).toEqual('block')
+ console.log('[click in body]', $modal.element.style.top)
+ // Modal should not have been moved away
+ expect($modal.element.style.top).toBe('')
+
+ wrapper.destroy()
+ })
+
+ it('mousedown inside header and mousemove when modal is not draggable does not move the modal', async () => {
+ let trigger = null
+ let called = false
+ const wrapper = mount(BModal, {
+ attachTo: document.body,
+ propsData: {
+ static: true,
+ id: 'test',
+ visible: true,
+ draggable: false
+ },
+ listeners: {
+ hide: bvEvent => {
+ called = true
+ trigger = bvEvent.trigger
+ }
+ }
+ })
+
+ expect(wrapper.vm).toBeDefined()
+
+ await waitNT(wrapper.vm)
+ await waitRAF()
+ await waitNT(wrapper.vm)
+ await waitRAF()
+
+ const $modal = wrapper.find('div.modal')
+ expect($modal.exists()).toBe(true)
+
+ const $header = wrapper.find('.modal-header')
+ expect($header.exists()).toBe(true)
+
+ const $dialog = wrapper.find('div.modal-dialog')
+ expect($dialog.exists()).toBe(true)
+
+ const $footer = wrapper.find('footer.modal-footer')
+ expect($footer.exists()).toBe(true)
+
+ expect($modal.element.style.display).toEqual('block')
+
+ expect(wrapper.emitted('hide')).toBeUndefined()
+ expect(trigger).toEqual(null)
+
+ // Try and move modal via a "dragged" click
+ // starting from inside modal header
+ await $header.trigger('mousedown', { clientX: 0, clientY: 0 })
+ await $header.trigger('mousemove', { clientX: 100, clientY: 100 })
+ await $header.trigger('mouseup')
+ await $header.trigger('click')
+ await waitRAF()
+ await waitRAF()
+ expect(called).toEqual(false)
+ expect(trigger).toEqual(null)
+
+ // Modal should not be closed
+ expect($modal.element.style.display).toEqual('block')
+ console.log('[click not draggable]', $modal.element.style.top)
+ // Modal should not have been moved away
+ expect($modal.element.style.top).toBe('')
+
+ wrapper.destroy()
+ })
+
it('$root bv::show::modal and bv::hide::modal work', async () => {
const wrapper = mount(BModal, {
attachTo: document.body,
diff --git a/src/components/modal/package.json b/src/components/modal/package.json
index ad68026f38b..81a03ee5526 100644
--- a/src/components/modal/package.json
+++ b/src/components/modal/package.json
@@ -88,6 +88,10 @@
"prop": "dialogClass",
"description": "CSS class (or classes) to apply to the '.modal-dialog' wrapper element"
},
+ {
+ "prop": "draggable",
+ "description": "Enables dragging of the modal by the header"
+ },
{
"prop": "footerBgVariant",
"description": "Applies one of the Bootstrap theme color variants to the footer background"