Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(b-modal): add dragging (closes #7143) #7170

Open
wants to merge 3 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/components/modal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,23 @@ prop `scrollable` to `true`.
<!-- b-modal-scrollable-content.vue -->
```

### 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
<div>
<b-button v-b-modal.modal-draggable>Launch draggable modal</b-button>

<b-modal id="modal-draggable" draggable title="BootstrapVue">
<p class="my-4">Draggable modal!</p>
</b-modal>
</div>

<!-- b-modal-draggable-content.vue -->
```

### Vertically centered modal

Vertically center your modal in the viewport by setting the `centered` prop.
Expand Down
6 changes: 6 additions & 0 deletions src/components/modal/_modal.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
57 changes: 56 additions & 1 deletion src/components/modal/modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
186 changes: 186 additions & 0 deletions src/components/modal/modal.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/components/modal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down