fix(components): [Dialog & MessageBox] support draggable (#5369)

* feat: add use-draggable

* feat: msgbox add draggable

* feat: update

* feat: update

* feat: update

* feat: update

* feat: update

* feat: update
This commit is contained in:
kooriookami
2022-02-03 21:04:25 +08:00
committed by GitHub
parent dc325d7f14
commit fb50b4ff8d
13 changed files with 352 additions and 116 deletions

View File

@@ -69,6 +69,16 @@ dialog/destroy-on-close
:::
## Draggable Dialog
Try to drag the `header` part.
:::demo Set `draggable` to `true` to drag.
dialog/draggable-dialog
:::
:::tip
When using `modal` = false, please make sure that `append-to-body` was set to **true**, because `Dialog` was positioned by `position: relative`, when `modal` gets removed, `Dialog` will position itself based on the current position in the DOM, instead of `Document.Body`, thus the style will be messed up.
@@ -94,6 +104,7 @@ When using `modal` = false, please make sure that `append-to-body` was set to **
| close-on-press-escape | whether the Dialog can be closed by pressing ESC | boolean | — | true |
| show-close | whether to show a close button | boolean | — | true |
| before-close | callback before Dialog closes, and it will prevent Dialog from closing | function(done)done is used to close the Dialog | — | — |
| draggable | enable dragging feature for Dialog | boolean | — | false |
| center | whether to align the header and footer in center | boolean | — | false |
| destroy-on-close | Destroy elements in Dialog when closed | boolean | — | false |

View File

@@ -89,12 +89,22 @@ message-box/distinguishable-close-cancel
Content of MessageBox can be centered.
:::demo Setting `center` to `true` will center the content
:::demo Setting `center` to `true` will center the content.
message-box/centered-content
:::
## Draggable
MessageBox can be draggable.
:::demo Setting `draggable` to `true` allows user to drag MessageBox.
message-box/draggable
:::
## Global method
If Element Plus is fully imported, it will add the following global methods for `app.config.globalProperties`: `$msgbox`, `$alert`, `$confirm` and `$prompt`. So in a Vue instance you can call `MessageBox` like what we did in this page. The parameters are:
@@ -147,5 +157,6 @@ The corresponding methods are: `ElMessageBox`, `ElMessageBox.alert`, `ElMessageB
| input-validator | validation function for the input. Should returns a boolean or string. If a string is returned, it will be assigned to inputErrorMessage | function | — | — |
| input-error-message | error message when validation fails | string | — | Illegal input |
| center | whether to align the content in center | boolean | — | false |
| draggable | whether MessageBox is draggable | boolean | — | false |
| round-button | whether to use round button | boolean | — | false |
| button-size | custom size of confirm and cancel buttons | string | small / default / large | default |

View File

@@ -0,0 +1,23 @@
<template>
<el-button type="text" @click="dialogVisible = true"
>Click to open Dialog
</el-button>
<el-dialog v-model="dialogVisible" title="Tips" width="30%" draggable>
<span>It's a draggable Dialog</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="dialogVisible = false"
>Confirm</el-button
>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const dialogVisible = ref(false)
</script>

View File

@@ -0,0 +1,32 @@
<template>
<el-button type="text" @click="open">Click to open Message Box</el-button>
</template>
<script lang="ts" setup>
import { ElMessageBox, ElMessage } from 'element-plus'
const open = () => {
ElMessageBox.confirm(
'proxy will permanently delete the file. Continue?',
'Warning',
{
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning',
draggable: true,
}
)
.then(() => {
ElMessage({
type: 'success',
message: 'Delete completed',
})
})
.catch(() => {
ElMessage({
type: 'info',
message: 'Delete canceled',
})
})
}
</script>

View File

@@ -275,5 +275,22 @@ describe('Dialog.vue', () => {
const svg = mount(Delete).find('svg').element
expect(closeIcon.element.innerHTML).toBe(svg.innerHTML)
})
test('should render draggable prop', async () => {
const wrapper = _mount({
slots: {
default: AXIOM,
},
props: {
modelValue: true,
draggable: true,
},
})
await nextTick()
await rAF()
await nextTick()
expect(wrapper.find('.is-draggable').exists()).toBe(true)
})
})
})

View File

@@ -39,6 +39,10 @@ export const dialogProps = buildProps({
type: Boolean,
default: false,
},
draggable: {
type: Boolean,
default: false,
},
lockScroll: {
type: Boolean,
default: true,

View File

@@ -26,6 +26,7 @@
'el-dialog',
{
'is-fullscreen': fullscreen,
'is-draggable': draggable,
'el-dialog--center': center,
},
customClass,
@@ -36,7 +37,7 @@
:style="style"
@click.stop=""
>
<div class="el-dialog__header">
<div ref="headerRef" class="el-dialog__header">
<slot name="title">
<span class="el-dialog__title">
{{ title }}
@@ -70,12 +71,12 @@
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
import { computed, defineComponent, ref } from 'vue'
import { TrapFocus } from '@element-plus/directives'
import { ElOverlay } from '@element-plus/components/overlay'
import { ElIcon } from '@element-plus/components/icon'
import { CloseComponents } from '@element-plus/utils/icon'
import { useSameTarget } from '@element-plus/hooks'
import { useDraggable, useSameTarget } from '@element-plus/hooks'
import { dialogProps, dialogEmits } from './dialog'
import { useDialog } from './use-dialog'
@@ -95,11 +96,16 @@ export default defineComponent({
setup(props, ctx) {
const dialogRef = ref<HTMLElement>()
const headerRef = ref<HTMLElement>()
const dialog = useDialog(props, ctx, dialogRef)
const overlayEvent = useSameTarget(dialog.onModalClick)
const draggable = computed(() => props.draggable && !props.fullscreen)
useDraggable(dialogRef, headerRef, draggable)
return {
dialogRef,
headerRef,
overlayEvent,
...dialog,
}

View File

@@ -5,112 +5,122 @@
:z-index="zIndex"
:overlay-class="['is-message-box', modalClass]"
:mask="modal"
@click.self="handleWrapperClick"
>
<div
ref="root"
v-trap-focus
role="dialog"
:aria-label="title || 'dialog'"
aria-modal="true"
:class="[
'el-message-box',
customClass,
{ 'el-message-box--center': center },
]"
:style="customStyle"
class="el-overlay-message-box"
@click="overlayEvent.onClick"
@mousedown="overlayEvent.onMousedown"
@mouseup="overlayEvent.onMouseup"
>
<div
v-if="title !== null && title !== undefined"
class="el-message-box__header"
ref="rootRef"
v-trap-focus
role="dialog"
:aria-label="title || 'dialog'"
aria-modal="true"
:class="[
'el-message-box',
customClass,
{ 'el-message-box--center': center, 'is-draggable': draggable },
]"
:style="customStyle"
@click.stop=""
>
<div class="el-message-box__title">
<el-icon
v-if="iconComponent && center"
class="el-message-box__status"
:class="typeClass"
>
<component :is="iconComponent" />
</el-icon>
<span>{{ title }}</span>
</div>
<button
v-if="showClose"
type="button"
class="el-message-box__headerbtn"
aria-label="Close"
@click="
handleAction(distinguishCancelAndClose ? 'close' : 'cancel')
"
@keydown.prevent.enter="
handleAction(distinguishCancelAndClose ? 'close' : 'cancel')
"
<div
v-if="title !== null && title !== undefined"
ref="headerRef"
class="el-message-box__header"
>
<el-icon class="el-message-box__close"><close /></el-icon>
</button>
</div>
<div class="el-message-box__content">
<div class="el-message-box__container">
<el-icon
v-if="iconComponent && !center && hasMessage"
class="el-message-box__status"
:class="typeClass"
<div class="el-message-box__title">
<el-icon
v-if="iconComponent && center"
class="el-message-box__status"
:class="typeClass"
>
<component :is="iconComponent" />
</el-icon>
<span>{{ title }}</span>
</div>
<button
v-if="showClose"
type="button"
class="el-message-box__headerbtn"
aria-label="Close"
@click="
handleAction(distinguishCancelAndClose ? 'close' : 'cancel')
"
@keydown.prevent.enter="
handleAction(distinguishCancelAndClose ? 'close' : 'cancel')
"
>
<component :is="iconComponent" />
</el-icon>
<div v-if="hasMessage" class="el-message-box__message">
<slot>
<p v-if="!dangerouslyUseHTMLString">{{ message }}</p>
<p v-else v-html="message"></p>
</slot>
<el-icon class="el-message-box__close">
<close />
</el-icon>
</button>
</div>
<div class="el-message-box__content">
<div class="el-message-box__container">
<el-icon
v-if="iconComponent && !center && hasMessage"
class="el-message-box__status"
:class="typeClass"
>
<component :is="iconComponent" />
</el-icon>
<div v-if="hasMessage" class="el-message-box__message">
<slot>
<p v-if="!dangerouslyUseHTMLString">{{ message }}</p>
<p v-else v-html="message"></p>
</slot>
</div>
</div>
<div v-show="showInput" class="el-message-box__input">
<el-input
ref="inputRef"
v-model="inputValue"
:type="inputType"
:placeholder="inputPlaceholder"
:class="{ invalid: validateError }"
@keydown.prevent.enter="handleInputEnter"
/>
<div
class="el-message-box__errormsg"
:style="{
visibility: !!editorErrorMessage ? 'visible' : 'hidden',
}"
>
{{ editorErrorMessage }}
</div>
</div>
</div>
<div v-show="showInput" class="el-message-box__input">
<el-input
ref="inputRef"
v-model="inputValue"
:type="inputType"
:placeholder="inputPlaceholder"
:class="{ invalid: validateError }"
@keydown.prevent.enter="handleInputEnter"
/>
<div
class="el-message-box__errormsg"
:style="{
visibility: !!editorErrorMessage ? 'visible' : 'hidden',
}"
<div class="el-message-box__btns">
<el-button
v-if="showCancelButton"
:loading="cancelButtonLoading"
:class="[cancelButtonClass]"
:round="roundButton"
:size="btnSize"
@click="handleAction('cancel')"
@keydown.prevent.enter="handleAction('cancel')"
>
{{ editorErrorMessage }}
</div>
{{ cancelButtonText || t('el.messagebox.cancel') }}
</el-button>
<el-button
v-show="showConfirmButton"
ref="confirmRef"
type="primary"
:loading="confirmButtonLoading"
:class="[confirmButtonClasses]"
:round="roundButton"
:disabled="confirmButtonDisabled"
:size="btnSize"
@click="handleAction('confirm')"
@keydown.prevent.enter="handleAction('confirm')"
>
{{ confirmButtonText || t('el.messagebox.confirm') }}
</el-button>
</div>
</div>
<div class="el-message-box__btns">
<el-button
v-if="showCancelButton"
:loading="cancelButtonLoading"
:class="[cancelButtonClass]"
:round="roundButton"
:size="btnSize"
@click="handleAction('cancel')"
@keydown.prevent.enter="handleAction('cancel')"
>
{{ cancelButtonText || t('el.messagebox.cancel') }}
</el-button>
<el-button
v-show="showConfirmButton"
ref="confirmRef"
type="primary"
:loading="confirmButtonLoading"
:class="[confirmButtonClasses]"
:round="roundButton"
:disabled="confirmButtonDisabled"
:size="btnSize"
@click="handleAction('confirm')"
@keydown.prevent.enter="handleAction('confirm')"
>
{{ confirmButtonText || t('el.messagebox.confirm') }}
</el-button>
</div>
</div>
</el-overlay>
</transition>
@@ -136,6 +146,8 @@ import {
useRestoreActive,
usePreventGlobal,
useSize,
useDraggable,
useSameTarget,
} from '@element-plus/hooks'
import ElInput from '@element-plus/components/input'
import { ElOverlay } from '@element-plus/components/overlay'
@@ -146,7 +158,7 @@ import { isValidComponentSize } from '@element-plus/utils/validators'
import { ElIcon } from '@element-plus/components/icon'
import { TypeComponents, TypeComponentsMap } from '@element-plus/utils/icon'
import type { ComponentPublicInstance, PropType } from 'vue'
import type { PropType } from 'vue'
import type { ComponentSize } from '@element-plus/utils/types'
import type {
Action,
@@ -197,6 +209,7 @@ export default defineComponent({
default: true,
},
center: Boolean,
draggable: Boolean,
roundButton: {
default: false,
type: Boolean,
@@ -270,8 +283,10 @@ export default defineComponent({
() => state.icon || TypeComponentsMap[state.type] || ''
)
const hasMessage = computed(() => !!state.message)
const inputRef = ref<ComponentPublicInstance>(null)
const confirmRef = ref<ComponentPublicInstance>(null)
const rootRef = ref<HTMLElement>()
const headerRef = ref<HTMLElement>()
const inputRef = ref<HTMLElement>()
const confirmRef = ref<HTMLElement>()
const confirmButtonClasses = computed(() => state.confirmButtonClass)
@@ -311,6 +326,9 @@ export default defineComponent({
}
)
const draggable = computed(() => props.draggable)
useDraggable(rootRef, headerRef, draggable)
onMounted(async () => {
await nextTick()
if (props.closeOnHashChange) {
@@ -338,6 +356,8 @@ export default defineComponent({
}
}
const overlayEvent = useSameTarget(handleWrapperClick)
const handleInputEnter = () => {
if (state.inputType !== 'textarea') {
return handleAction('confirm')
@@ -428,12 +448,15 @@ export default defineComponent({
return {
...toRefs(state),
overlayEvent,
visible,
hasMessage,
typeClass,
btnSize,
iconComponent,
confirmButtonClasses,
rootRef,
headerRef,
inputRef,
confirmRef,
doClose, // for outside usage

View File

@@ -92,6 +92,9 @@ export interface ElMessageBoxOptions {
/** Whether to align the content in center */
center?: boolean
/** Whether MessageBox can be drag */
draggable?: boolean
/** Content of the MessageBox */
message?: string | VNode

View File

@@ -1,5 +1,6 @@
export * from './use-attrs'
export * from './use-common-props'
export * from './use-draggable'
export * from './use-focus'
export * from './use-form-item'
export * from './use-global-config'

View File

@@ -0,0 +1,87 @@
import { onBeforeUnmount, onMounted, watchEffect } from 'vue'
import { addUnit } from '@element-plus/utils/util'
import type { Ref, ComputedRef } from 'vue'
export const useDraggable = (
targetRef: Ref<HTMLElement | undefined>,
dragRef: Ref<HTMLElement | undefined>,
draggable: ComputedRef<boolean>
) => {
let transform = {
offsetX: 0,
offsetY: 0,
}
const onMousedown = (e: MouseEvent) => {
const downX = e.clientX
const downY = e.clientY
const { offsetX, offsetY } = transform
const targetRect = targetRef.value.getBoundingClientRect()
const targetLeft = targetRect.left
const targetTop = targetRect.top
const targetWidth = targetRect.width
const targetHeight = targetRect.height
const clientWidth = document.documentElement.clientWidth
const clientHeight = document.documentElement.clientHeight
const minLeft = -targetLeft + offsetX
const minTop = -targetTop + offsetY
const maxLeft = clientWidth - targetLeft - targetWidth + offsetX
const maxTop = clientHeight - targetTop - targetHeight + offsetY
const onMousemove = (e: MouseEvent) => {
const moveX = Math.min(
Math.max(offsetX + e.clientX - downX, minLeft),
maxLeft
)
const moveY = Math.min(
Math.max(offsetY + e.clientY - downY, minTop),
maxTop
)
transform = {
offsetX: moveX,
offsetY: moveY,
}
targetRef.value.style.transform = `translate(${addUnit(moveX)}, ${addUnit(
moveY
)})`
}
const onMouseup = () => {
document.removeEventListener('mousemove', onMousemove)
document.removeEventListener('mouseup', onMouseup)
}
document.addEventListener('mousemove', onMousemove)
document.addEventListener('mouseup', onMouseup)
}
const onDraggable = () => {
if (dragRef.value && targetRef.value) {
dragRef.value.addEventListener('mousedown', onMousedown)
}
}
const offDraggable = () => {
if (dragRef.value && targetRef.value) {
dragRef.value.removeEventListener('mousedown', onMousedown)
}
}
onMounted(() => {
watchEffect(() => {
if (draggable.value) {
onDraggable()
} else {
offDraggable()
}
})
})
onBeforeUnmount(() => {
offDraggable()
})
}

View File

@@ -35,6 +35,13 @@
margin: 0;
}
@include when(draggable) {
@include e(header) {
cursor: move;
user-select: none;
}
}
@include e(header) {
padding: var(--el-dialog-padding-primary);
padding-bottom: 10px;

View File

@@ -24,13 +24,29 @@
backface-visibility: hidden;
@at-root .#{$namespace}-overlay.is-message-box {
text-align: center;
&::after {
content: '';
display: inline-block;
height: 100%;
width: 0;
vertical-align: middle;
.#{$namespace}-overlay-message-box {
text-align: center;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
overflow: auto;
&::after {
content: '';
display: inline-block;
height: 100%;
width: 0;
vertical-align: middle;
}
}
}
@include when(draggable) {
@include e(header) {
cursor: move;
user-select: none;
}
}
@@ -87,6 +103,7 @@
& div.invalid > input {
border-color: var(--el-color-error);
&:focus {
border-color: var(--el-color-error);
}
@@ -152,12 +169,6 @@
// centerAlign 布局
@include m(center) {
padding-bottom: 30px;
@include e(header) {
padding-top: 30px;
}
@include e(title) {
position: relative;
display: flex;
@@ -191,13 +202,13 @@
}
.fade-in-linear-enter-active {
.#{$namespace}-message-box {
.#{$namespace}-overlay-message-box {
animation: msgbox-fade-in var(--el-transition-duration);
}
}
.fade-in-linear-leave-active {
.#{$namespace}-message-box {
.#{$namespace}-overlay-message-box {
animation: msgbox-fade-in var(--el-transition-duration) reverse;
}
}