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"