fix(overlay): Fix overlay event triggering issue (#1235)

This commit is contained in:
jeremywu 2021-01-14 17:01:37 +08:00 committed by GitHub
parent e4ced422c6
commit 30f1947c47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 795 additions and 657 deletions

View File

@ -1,8 +1,10 @@
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { rAF } from '@element-plus/test-utils/tick' import { rAF } from '@element-plus/test-utils/tick'
import triggerCompositeClick from '@element-plus/test-utils/composite-click'
import Dialog from '../' import Dialog from '../'
const AXIOM = 'Rem is the best girl' const AXIOM = 'Rem is the best girl'
const _mount = ({ slots, ...rest }: Indexable<any>) => { const _mount = ({ slots, ...rest }: Indexable<any>) => {
@ -150,7 +152,7 @@ describe('Dialog.vue', () => {
await nextTick() await nextTick()
expect(wrapper.find('.el-overlay').exists()).toBe(true) expect(wrapper.find('.el-overlay').exists()).toBe(true)
await wrapper.find('.el-overlay').trigger('click') await triggerCompositeClick(wrapper.find('.el-overlay'))
expect(wrapper.vm.visible).toBe(false) expect(wrapper.vm.visible).toBe(false)
}) })
}) })
@ -249,7 +251,7 @@ describe('Dialog.vue', () => {
await rAF() await rAF()
await nextTick() await nextTick()
await wrapper.find('.el-overlay').trigger('click') await triggerCompositeClick(wrapper.find('.el-overlay'))
await nextTick() await nextTick()
await rAF() await rAF()
await nextTick() await nextTick()

View File

@ -28,7 +28,7 @@
role="dialog" role="dialog"
:aria-label="title || 'dialog'" :aria-label="title || 'dialog'"
:style="style" :style="style"
@click="$event.stopPropagation()" @click.stop=""
> >
<div class="el-dialog__header"> <div class="el-dialog__header">
<slot name="title"> <slot name="title">

View File

@ -9,7 +9,6 @@ const isVisibleMock = jest
import TrapFocus, { import TrapFocus, {
ITrapFocusElement, ITrapFocusElement,
FOCUSABLE_CHILDREN, FOCUSABLE_CHILDREN,
TRAP_FOCUS_HANDLER,
} from '../trap-focus' } from '../trap-focus'
let wrapper let wrapper
@ -45,9 +44,6 @@ describe('v-trap-focus', () => {
expect( expect(
(wrapper.element as ITrapFocusElement)[FOCUSABLE_CHILDREN].length, (wrapper.element as ITrapFocusElement)[FOCUSABLE_CHILDREN].length,
).toBe(1) ).toBe(1)
expect(
(wrapper.element as ITrapFocusElement)[TRAP_FOCUS_HANDLER].length,
).toBeDefined()
}) })
test('should not fetch disabled element', () => { test('should not fetch disabled element', () => {

View File

@ -12,52 +12,62 @@ export interface ITrapFocusElement extends HTMLElement {
[TRAP_FOCUS_HANDLER]: (e: KeyboardEvent) => void [TRAP_FOCUS_HANDLER]: (e: KeyboardEvent) => void
} }
const FOCUS_STACK = []
const FOCUS_HANDLER = (e: KeyboardEvent) => {
// Getting the top layer.
if (FOCUS_STACK.length === 0) return
const focusableElement = FOCUS_STACK[FOCUS_STACK.length - 1][FOCUSABLE_CHILDREN]
if (focusableElement.length > 0 && e.code === EVENT_CODE.tab) {
if (focusableElement.length === 1) {
e.preventDefault()
if (document.activeElement !== focusableElement[0]) {
focusableElement[0].focus()
}
return
}
const goingBackward = e.shiftKey
const isFirst = e.target === focusableElement[0]
const isLast = e.target === focusableElement[focusableElement.length - 1]
if (isFirst && goingBackward) {
e.preventDefault()
focusableElement[focusableElement.length - 1].focus()
}
if (isLast && !goingBackward) {
e.preventDefault()
focusableElement[0].focus()
}
// the is critical since jsdom did not implement user actions, you can only mock it
// DELETE ME: when testing env switches to puppeteer
if (process.env.NODE_ENV === 'test') {
const index = focusableElement.findIndex((element: Element) => element === e.target)
if (index !== -1) {
focusableElement[goingBackward ? index - 1 : index + 1]?.focus()
}
}
}
}
const TrapFocus: ObjectDirective = { const TrapFocus: ObjectDirective = {
beforeMount(el: ITrapFocusElement) { beforeMount(el: ITrapFocusElement) {
el[FOCUSABLE_CHILDREN] = obtainAllFocusableElements(el) el[FOCUSABLE_CHILDREN] = obtainAllFocusableElements(el)
FOCUS_STACK.push(el)
el[TRAP_FOCUS_HANDLER] = (e: KeyboardEvent) => { if (FOCUS_STACK.length <= 1) {
const focusableElement = el[FOCUSABLE_CHILDREN] on(document, 'keydown', FOCUS_HANDLER)
if (focusableElement.length > 0 && e.code === EVENT_CODE.tab) {
if (focusableElement.length === 1) {
e.preventDefault()
if (document.activeElement !== focusableElement[0]) {
focusableElement[0].focus()
}
return
}
const goingBackward = e.shiftKey
const isFirst = e.target === focusableElement[0]
const isLast = e.target === focusableElement[focusableElement.length - 1]
if (isFirst && goingBackward) {
e.preventDefault()
focusableElement[focusableElement.length - 1].focus()
}
if (isLast && !goingBackward) {
e.preventDefault()
focusableElement[0].focus()
}
// the is critical since jsdom did not implement user actions, you can only mock it
// DELETE ME: when testing env switches to puppeteer
if (process.env.NODE_ENV === 'test') {
const index = focusableElement.findIndex(element => element === e.target)
if (index !== -1) {
focusableElement[goingBackward ? index - 1 : index + 1]?.focus()
}
}
}
} }
on(document, 'keydown', el[TRAP_FOCUS_HANDLER])
}, },
updated(el: ITrapFocusElement) { updated(el: ITrapFocusElement) {
nextTick(() => { nextTick(() => {
el[FOCUSABLE_CHILDREN] = obtainAllFocusableElements(el) el[FOCUSABLE_CHILDREN] = obtainAllFocusableElements(el)
}) })
}, },
unmounted(el: ITrapFocusElement) { unmounted() {
off(document, 'keydown', el[TRAP_FOCUS_HANDLER]) FOCUS_STACK.shift()
if (FOCUS_STACK.length === 0) {
off(document, 'keydown', FOCUS_HANDLER)
}
}, },
} }

View File

@ -14,50 +14,41 @@
@click="onModalClick" @click="onModalClick"
> >
<div <div
class="el-drawer__container" ref="drawerRef"
:class="{ 'el-drawer__open': visible }" v-trap-focus
tabindex="-1" aria-modal="true"
role="document" aria-labelledby="el-drawer__title"
:aria-label="title"
:class="['el-drawer', direction, customClass]"
:style="isHorizontal ? 'width: ' + size : 'height: ' + size"
role="dialog"
@click.stop
> >
<div <header
ref="drawerRef" v-if="withHeader"
v-trap-focus id="el-drawer__title"
aria-modal="true" class="el-drawer__header"
aria-labelledby="el-drawer__title"
:aria-label="title"
class="el-drawer"
:class="[direction, customClass]"
:style="isHorizontal ? 'width: ' + size : 'height: ' + size"
role="dialog"
tabindex="-1"
@click.stop
> >
<header <slot name="title">
v-if="withHeader" <span role="heading" :title="title">
id="el-drawer__title" {{ title }}
class="el-drawer__header" </span>
</slot>
<button
v-if="showClose"
:aria-label="'close ' + (title || 'drawer')"
class="el-drawer__close-btn"
type="button"
@click="handleClose"
> >
<slot name="title"> <i class="el-drawer__close el-icon el-icon-close"></i>
<span role="heading" tabindex="-1" :title="title"> </button>
{{ title }} </header>
</span> <template v-if="rendered">
</slot> <section class="el-drawer__body">
<button <slot></slot>
v-if="showClose" </section>
:aria-label="'close ' + (title || 'drawer')" </template>
class="el-drawer__close-btn"
type="button"
@click="handleClose"
>
<i class="el-drawer__close el-icon el-icon-close"></i>
</button>
</header>
<template v-if="rendered">
<section class="el-drawer__body">
<slot></slot>
</section>
</template>
</div>
</div> </div>
</el-overlay> </el-overlay>
</transition> </transition>

View File

@ -0,0 +1,48 @@
import { ref } from 'vue'
import { on, off } from '@element-plus/utils/dom'
import triggerEvent from '@element-plus/test-utils/trigger-event'
import usePreventGlobal from '../use-prevent-global'
describe('usePreventGlobal', () => {
const evtName = 'keydown'
const evt = jest.fn()
beforeAll(() => {
on(document.body, evtName, evt)
})
beforeEach(() => {
evt.mockClear()
})
afterAll(() => {
off(document.body, evtName, evt)
})
it('should prevent global event from happening', () => {
const visible = ref(true)
const evt2Trigger = jest.fn().mockReturnValue(true)
usePreventGlobal(visible, evtName, evt2Trigger)
triggerEvent(document.body, evtName)
expect(evt).not.toBeCalled()
expect(evt2Trigger).toHaveBeenCalled()
visible.value = false
// clean up
})
it('should not prevent global event from happening', () => {
const visible = ref(true)
const evt2Trigger = jest.fn().mockReturnValue(false)
usePreventGlobal(visible, evtName, evt2Trigger)
triggerEvent(document.body, evtName)
expect(evt).toHaveBeenCalled()
expect(evt2Trigger).toHaveBeenCalled()
visible.value = false
})
})

View File

@ -6,3 +6,4 @@ export { default as useModal } from './use-modal'
export { default as useMigrating } from './use-migrating' export { default as useMigrating } from './use-migrating'
export { default as useFocus } from './use-focus' export { default as useFocus } from './use-focus'
export { default as useThrottleRender } from './use-throttle-render' export { default as useThrottleRender } from './use-throttle-render'
export { default as usePreventGlobal } from './use-prevent-global'

View File

@ -14,6 +14,7 @@ const modalStack: ModalInstance[] = []
const closeModal = (e: KeyboardEvent) => { const closeModal = (e: KeyboardEvent) => {
if (modalStack.length === 0) return if (modalStack.length === 0) return
if (e.code === EVENT_CODE.esc) { if (e.code === EVENT_CODE.esc) {
e.stopPropagation()
const topModal = modalStack[modalStack.length - 1] const topModal = modalStack[modalStack.length - 1]
topModal.handleClose() topModal.handleClose()
} }

View File

@ -0,0 +1,19 @@
import { watch } from 'vue'
import { on, off } from '@element-plus/utils/dom'
import type { Ref } from 'vue'
export default (indicator: Ref<boolean>, evt: string, cb: (e: Event) => boolean) => {
const prevent = (e: Event) => {
if (cb(e)) {
e.stopImmediatePropagation()
}
}
watch(() => indicator.value, val => {
if (val) {
on(document, evt, prevent, true)
} else {
off(document, evt, prevent, true)
}
}, { immediate: true })
}

View File

@ -1,18 +1,28 @@
import { mount } from '@vue/test-utils'
import MessageBox from '../src/messageBox' import MessageBox from '../src/messageBox'
import { sleep } from '@element-plus/test-utils' import { rAF } from '@element-plus/test-utils/tick'
import { nextTick } from 'vue' import { triggerNativeCompositeClick } from '@element-plus/test-utils/composite-click'
const selector = '.el-message-box__wrapper' const selector = '.el-overlay'
const _mount = (invoker: () => void) => {
return mount(
{
template: '<div></div>',
mounted() {
invoker()
},
},
{
attachTo: 'body',
},
)
}
describe('MessageBox', () => { describe('MessageBox', () => {
afterEach(async () => {
afterEach(() => {
const el = document.querySelector('.el-message-box__wrapper')
if (!el) return
if (el.parentNode) {
el.parentNode.removeChild(el)
}
MessageBox.close() MessageBox.close()
await rAF()
}) })
test('create and close', async () => { test('create and close', async () => {
@ -22,17 +32,23 @@ describe('MessageBox', () => {
message: '这是一段内容', message: '这是一段内容',
}) })
const msgbox: HTMLElement = document.querySelector(selector) const msgbox: HTMLElement = document.querySelector(selector)
expect(msgbox).toBeDefined() expect(msgbox).toBeDefined()
await sleep() await rAF()
expect(msgbox.querySelector('.el-message-box__title span').textContent).toEqual('消息') expect(
expect(msgbox.querySelector('.el-message-box__message').querySelector('p').textContent).toEqual('这是一段内容') msgbox.querySelector('.el-message-box__title span').textContent,
).toEqual('消息')
expect(
msgbox.querySelector('.el-message-box__message').querySelector('p')
.textContent,
).toEqual('这是一段内容')
MessageBox.close() MessageBox.close()
await sleep(250) await rAF()
expect(msgbox.style.display).toEqual('none') expect(msgbox.style.display).toEqual('none')
}) })
test('invoke with strings', () => { test('invoke with strings', () => {
MessageBox('消息', '这是一段内容') MessageBox({ title: '消息', message: '这是一段内容' })
const msgbox = document.querySelector(selector) const msgbox = document.querySelector(selector)
expect(msgbox).toBeDefined() expect(msgbox).toBeDefined()
}) })
@ -43,7 +59,7 @@ describe('MessageBox', () => {
iconClass: 'el-icon-question', iconClass: 'el-icon-question',
message: '这是一段内容', message: '这是一段内容',
}) })
await sleep() await rAF()
const icon = document.querySelector('.el-message-box__status') const icon = document.querySelector('.el-message-box__status')
expect(icon.classList.contains('el-icon-question')).toBe(true) expect(icon.classList.contains('el-icon-question')).toBe(true)
}) })
@ -54,25 +70,32 @@ describe('MessageBox', () => {
dangerouslyUseHTMLString: true, dangerouslyUseHTMLString: true,
message: '<strong>html string</strong>', message: '<strong>html string</strong>',
}) })
await sleep() await rAF()
const message = document.querySelector('.el-message-box__message strong') const message = document.querySelector('.el-message-box__message strong')
expect(message.textContent).toEqual('html string') expect(message.textContent).toEqual('html string')
}) })
test('distinguish cancel and close', async () => { test('distinguish cancel and close', async () => {
let msgAction = '' let msgAction = ''
MessageBox({ const invoker = () => {
title: '消息', MessageBox({
message: '这是一段内容', title: '消息',
distinguishCancelAndClose: true, message: '这是一段内容',
callback: action => { distinguishCancelAndClose: true,
msgAction = action callback: action => {
}, msgAction = action
}) },
await sleep() })
const btn = document.querySelector('.el-message-box__close') as HTMLButtonElement }
_mount(invoker)
await rAF()
const btn = document.querySelector(
'.el-message-box__close',
) as HTMLButtonElement
btn.click() btn.click()
await sleep() await rAF()
expect(msgAction).toEqual('close') expect(msgAction).toEqual('close')
}) })
@ -81,26 +104,27 @@ describe('MessageBox', () => {
title: '标题名称', title: '标题名称',
type: 'warning', type: 'warning',
}) })
await sleep() await rAF()
const vModal: HTMLElement = document.querySelector('.v-modal') await triggerNativeCompositeClick(document.querySelector(selector))
vModal.click() await rAF()
await sleep(250)
const msgbox: HTMLElement = document.querySelector(selector) const msgbox: HTMLElement = document.querySelector(selector)
expect(msgbox.style.display).toEqual('') expect(msgbox.style.display).toEqual('')
expect(msgbox.querySelector('.el-icon-warning')).toBeDefined() expect(msgbox.querySelector('.el-icon-warning')).toBeDefined()
}) })
test('confirm', async () => { test('confirm', async () => {
MessageBox.confirm('这是一段内容', { MessageBox.confirm('这是一段内容', {
title: '标题名称', title: '标题名称',
type: 'warning', type: 'warning',
}) })
await sleep() await rAF()
const btn = document.querySelector(selector).querySelector('.el-button--primary') as HTMLButtonElement const btn = document
.querySelector(selector)
.querySelector('.el-button--primary') as HTMLButtonElement
btn.click() btn.click()
await sleep(250) await rAF()
const msgbox: HTMLElement = document.querySelector(selector) const msgbox: HTMLElement = document.querySelector(selector)
expect(msgbox.style.display).toEqual('none') expect(msgbox).toBe(null)
}) })
test('prompt', async () => { test('prompt', async () => {
@ -109,9 +133,13 @@ describe('MessageBox', () => {
inputPattern: /test/, inputPattern: /test/,
inputErrorMessage: 'validation failed', inputErrorMessage: 'validation failed',
}) })
await sleep(0) await rAF()
const inputElm = document.querySelector(selector).querySelector('.el-message-box__input') const inputElm = document
const haveFocus = inputElm.querySelector('input').isSameNode(document.activeElement) .querySelector(selector)
.querySelector('.el-message-box__input')
const haveFocus = inputElm
.querySelector('input')
.isSameNode(document.activeElement)
expect(inputElm).toBeDefined() expect(inputElm).toBeDefined()
expect(haveFocus).toBe(true) expect(haveFocus).toBe(true)
}) })
@ -121,8 +149,10 @@ describe('MessageBox', () => {
inputType: 'textarea', inputType: 'textarea',
title: '标题名称', title: '标题名称',
}) })
await sleep() await rAF()
const textareaElm = document.querySelector(selector).querySelector('textarea') const textareaElm = document
.querySelector(selector)
.querySelector('textarea')
const haveFocus = textareaElm.isSameNode(document.activeElement) const haveFocus = textareaElm.isSameNode(document.activeElement)
expect(haveFocus).toBe(true) expect(haveFocus).toBe(true)
}) })
@ -136,55 +166,65 @@ describe('MessageBox', () => {
msgAction = action msgAction = action
}, },
}) })
await sleep() await rAF()
const closeBtn = document.querySelector('.el-message-box__close') as HTMLButtonElement const closeBtn = document.querySelector(
'.el-message-box__close',
) as HTMLButtonElement
closeBtn.click() closeBtn.click()
await sleep() await rAF()
expect(msgAction).toEqual('cancel') expect(msgAction).toEqual('cancel')
}) })
test('beforeClose', async() => { test('beforeClose', async () => {
let msgAction = '' let msgAction = ''
MessageBox({ MessageBox({
callback: action => {
msgAction = action
},
title: '消息', title: '消息',
message: '这是一段内容', message: '这是一段内容',
beforeClose: (action, instance) => { beforeClose: (_, __, done) => {
instance.close() done()
}, },
}, action => {
msgAction = action
}) })
await sleep(); await rAF()
(document.querySelector('.el-message-box__wrapper .el-button--primary') as HTMLButtonElement).click() ;(document.querySelector(
await nextTick() '.el-message-box__btns .el-button--primary',
await sleep() ) as HTMLButtonElement).click()
await rAF()
expect(msgAction).toEqual('confirm') expect(msgAction).toEqual('confirm')
}) })
describe('promise', () => { describe('promise', () => {
test('resolve',async () => { test('resolve', async () => {
let msgAction = '' let msgAction = ''
MessageBox.confirm('此操作将永久删除该文件, 是否继续?', '提示') MessageBox.confirm('此操作将永久删除该文件, 是否继续?', '提示').then(
.then(action => { action => {
msgAction = action msgAction = action
}) },
await sleep() )
const btn = document.querySelector('.el-message-box__btns .el-button--primary') as HTMLButtonElement await rAF()
const btn = document.querySelector(
'.el-message-box__btns .el-button--primary',
) as HTMLButtonElement
btn.click() btn.click()
await sleep() await rAF()
expect(msgAction).toEqual('confirm') expect(msgAction).toEqual('confirm')
}) })
test('reject', async () => { test('reject', async () => {
let msgAction = '' let msgAction = ''
MessageBox.confirm('此操作将永久删除该文件, 是否继续?', '提示') MessageBox.confirm('此操作将永久删除该文件, 是否继续?', '提示').catch(
.catch(action => { action => {
msgAction = action msgAction = action
}) },
await sleep() )
const btn = document.querySelectorAll('.el-message-box__btns .el-button') as NodeListOf<HTMLButtonElement> await rAF()
btn[0].click() const btn = document.querySelector(
await sleep() '.el-message-box__btns .el-button',
)
;(btn as HTMLButtonElement).click()
await rAF()
expect(msgAction).toEqual('cancel') expect(msgAction).toEqual('cancel')
}) })
}) })

View File

@ -1,23 +1,32 @@
<template> <template>
<transition name="msgbox-fade"> <transition name="fade-in-linear" @after-leave="$emit('vanish')">
<div <el-overlay
v-show="visible" v-show="visible"
ref="root" :z-index="state.zIndex"
:aria-label="title || 'dialog'" :overlay-class="modalClass"
class="el-message-box__wrapper" :mask="modal"
tabindex="-1"
role="dialog"
aria-modal="true"
@click.self="handleWrapperClick" @click.self="handleWrapperClick"
> >
<div class="el-message-box" :class="[customClass, center && 'el-message-box--center']"> <div
<div v-if="title !== null && title !== undefined" class="el-message-box__header"> ref="root"
v-trap-focus
:aria-label="title || 'dialog'"
aria-modal="true"
:class="[
'el-message-box',
customClass,
{ 'el-message-box--center': center },
]"
>
<div
v-if="title !== null && title !== undefined"
class="el-message-box__header"
>
<div class="el-message-box__title"> <div class="el-message-box__title">
<div <div
v-if="icon && center" v-if="icon && center"
:class="['el-message-box__status', icon]" :class="['el-message-box__status', icon]"
> ></div>
</div>
<span>{{ title }}</span> <span>{{ title }}</span>
</div> </div>
<button <button
@ -25,8 +34,12 @@
type="button" type="button"
class="el-message-box__headerbtn" class="el-message-box__headerbtn"
aria-label="Close" aria-label="Close"
@click="handleAction(distinguishCancelAndClose ? 'close' : 'cancel')" @click="
@keydown.enter="handleAction(distinguishCancelAndClose ? 'close' : 'cancel')" handleAction(distinguishCancelAndClose ? 'close' : 'cancel')
"
@keydown.enter="
handleAction(distinguishCancelAndClose ? 'close' : 'cancel')
"
> >
<i class="el-message-box__close el-icon-close"></i> <i class="el-message-box__close el-icon-close"></i>
</button> </button>
@ -36,8 +49,7 @@
<div <div
v-if="icon && !center && hasMessage" v-if="icon && !center && hasMessage"
:class="['el-message-box__status', icon]" :class="['el-message-box__status', icon]"
> ></div>
</div>
<div v-if="hasMessage" class="el-message-box__message"> <div v-if="hasMessage" class="el-message-box__message">
<slot> <slot>
<p v-if="!dangerouslyUseHTMLString">{{ message }}</p> <p v-if="!dangerouslyUseHTMLString">{{ message }}</p>
@ -47,47 +59,54 @@
</div> </div>
<div v-show="showInput" class="el-message-box__input"> <div v-show="showInput" class="el-message-box__input">
<el-input <el-input
ref="input" ref="inputRef"
v-model="inputValue" v-model="state.inputValue"
:type="inputType" :type="inputType"
:placeholder="inputPlaceholder" :placeholder="inputPlaceholder"
:class="{ invalid: validateError }" :class="{ invalid: state.validateError }"
@keydown.enter="handleInputEnter" @keydown.prevent.enter="handleInputEnter"
/> />
<div class="el-message-box__errormsg" :style="{ visibility: !!editorErrorMessage ? 'visible' : 'hidden' }">{{ editorErrorMessage }}</div> <div
class="el-message-box__errormsg"
:style="{
visibility: !!state.editorErrorMessage ? 'visible' : 'hidden',
}"
>
{{ state.editorErrorMessage }}
</div>
</div> </div>
</div> </div>
<div class="el-message-box__btns"> <div class="el-message-box__btns">
<el-button <el-button
v-if="showCancelButton" v-if="showCancelButton"
:loading="cancelButtonLoading" :loading="state.cancelButtonLoading"
:class="[ cancelButtonClass ]" :class="[cancelButtonClass]"
:round="roundButton" :round="roundButton"
size="small" size="small"
@click="handleAction('cancel')" @click="handleAction('cancel')"
@keydown.enter="handleAction('cancel')" @keydown.enter="handleAction('cancel')"
> >
{{ cancelButtonText || t('el.messagebox.cancel') }} {{ state.cancelButtonText || t('el.messagebox.cancel') }}
</el-button> </el-button>
<el-button <el-button
v-show="showConfirmButton" v-show="showConfirmButton"
ref="confirm" ref="confirmRef"
:loading="confirmButtonLoading" :loading="state.confirmButtonLoading"
:class="[ confirmButtonClasses ]" :class="[confirmButtonClasses]"
:round="roundButton" :round="roundButton"
:disabled="confirmButtonDisabled" :disabled="state.confirmButtonDisabled"
size="small" size="small"
@click="handleAction('confirm')" @click="handleAction('confirm')"
@keydown.enter="handleAction('confirm')" @keydown.enter="handleAction('confirm')"
> >
{{ confirmButtonText || t('el.messagebox.confirm') }} {{ state.confirmButtonText || t('el.messagebox.confirm') }}
</el-button> </el-button>
</div> </div>
</div> </div>
</div> </el-overlay>
</transition> </transition>
</template> </template>
<script lang='ts'> <script lang="ts">
import { import {
defineComponent, defineComponent,
nextTick, nextTick,
@ -95,19 +114,21 @@ import {
onBeforeUnmount, onBeforeUnmount,
computed, computed,
watch, watch,
onBeforeMount,
getCurrentInstance,
reactive, reactive,
toRefs, ref,
} from 'vue' } from 'vue'
import ElButton from '@element-plus/button' import ElButton from '@element-plus/button'
import ElInput from '@element-plus/input' import ElInput from '@element-plus/input'
import { t } from '@element-plus/locale' import { t } from '@element-plus/locale'
import Dialog from '@element-plus/utils/aria-dialog' import { Overlay as ElOverlay } from '@element-plus/overlay'
import usePopup from '@element-plus/utils/popup/usePopup' import { useModal, useLockScreen, useRestoreActive, usePreventGlobal } from '@element-plus/hooks'
import { TrapFocus } from '@element-plus/directives'
import PopupManager from '@element-plus/utils/popup-manager'
import { on, off } from '@element-plus/utils/dom' import { on, off } from '@element-plus/utils/dom'
import { EVENT_CODE } from '@element-plus/utils/aria'
let dialog import type { ComponentPublicInstance, PropType } from 'vue'
import type { Action, MessageBoxState } from './message-box.type'
const TypeMap: Indexable<string> = { const TypeMap: Indexable<string> = {
success: 'success', success: 'success',
@ -121,41 +142,23 @@ export default defineComponent({
components: { components: {
ElButton, ElButton,
ElInput, ElInput,
ElOverlay,
},
directives: {
TrapFocus,
}, },
props: { props: {
openDelay: { beforeClose: {
type: Number, type: Function as PropType<(action: Action, state: MessageBoxState, doClose: () => void) => any>,
default: 0, default: undefined,
}, },
closeDelay: { callback: Function,
type: Number, cancelButtonText: {
default: 0,
},
zIndex: Number,
modalFade: {
type: Boolean,
default: true,
},
modalClass: {
type: String, type: String,
default: '', default: 'Cancel',
},
modalAppendToBody: {
type: Boolean,
default: false,
},
modal: {
type: Boolean,
default: true,
},
lockScroll: {
type: Boolean,
default: true,
},
showClose: {
type: Boolean,
default: true,
}, },
cancelButtonClass: String,
center: Boolean,
closeOnClickModal: { closeOnClickModal: {
type: Boolean, type: Boolean,
default: true, default: true,
@ -168,78 +171,113 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
center: { confirmButtonText: {
default: false, type: String,
type: Boolean, default: 'OK',
}, },
roundButton: { confirmButtonClass: String,
default: false, container: {
type: Boolean, type: String, // default append to body
default: 'body',
}, },
} , customClass: String,
setup(props) { dangerouslyUseHTMLString: Boolean,
let vm distinguishCancelAndClose: Boolean,
const popup = usePopup(props, doClose) iconClass: String,
inputPattern: {
type: Object as PropType<RegExp>,
default: () => undefined,
validator: (val: unknown) => (val instanceof RegExp || val === 'undefined'),
},
inputPlaceholder: {
type: String,
},
inputType: {
type: String,
default: 'text',
},
inputValue: {
type: String,
},
inputValidator: {
type: Function as PropType<(...args: any[]) => boolean | string>,
default: null,
},
inputErrorMessage: String,
lockScroll: {
type: Boolean,
default: true,
},
message: String,
modalFade: { // implement this feature
type: Boolean,
default: true,
},
modalClass: String,
modal: {
type: Boolean,
default: true,
},
roundButton: Boolean,
showCancelButton: Boolean,
showConfirmButton: {
type: Boolean,
default: true,
},
showClose: {
type: Boolean,
default: true,
},
type: String,
title: String,
showInput: Boolean,
zIndex: Number,
},
emits: ['vanish', 'action'],
setup(props, { emit }) {
// const popup = usePopup(props, doClose)
const visible = ref(false)
// s represents state
const state = reactive({ const state = reactive({
uid: 1, action: '' as Action,
title: undefined, inputValue: props.inputValue,
message: '',
type: '',
iconClass: '',
customClass: '',
showInput: false,
inputValue: null,
inputPlaceholder: '',
inputType: 'text',
inputPattern: null,
inputValidator: null,
inputErrorMessage: '',
showConfirmButton: true,
showCancelButton: false,
action: '',
confirmButtonText: '',
cancelButtonText: '',
confirmButtonLoading: false, confirmButtonLoading: false,
cancelButtonLoading: false, cancelButtonLoading: false,
confirmButtonClass: '', cancelButtonText: props.cancelButtonText,
confirmButtonDisabled: false, confirmButtonDisabled: false,
cancelButtonClass: '', confirmButtonText: props.confirmButtonText,
editorErrorMessage: null, editorErrorMessage: '',
callback: null, // refer to: https://github.com/ElemeFE/element/commit/2999279ae34ef10c373ca795c87b020ed6753eed
dangerouslyUseHTMLString: false, // seemed ok for now without this state.
focusAfterClosed: null, // isOnComposition: false, // temporary remove
isOnComposition: false,
distinguishCancelAndClose: false,
type$: '',
visible: false,
validateError: false, validateError: false,
zIndex: PopupManager.nextZIndex(),
}) })
const icon = computed(() => state.iconClass || (state.type && TypeMap[state.type] ? `el-icon-${ TypeMap[state.type] }` : '')) const icon = computed(() => props.iconClass || (props.type && TypeMap[props.type] ? `el-icon-${TypeMap[props.type]}` : ''))
const hasMessage = computed(() => !!state.message) const hasMessage = computed(() => !!props.message)
const inputRef = ref<ComponentPublicInstance>(null)
const confirmRef = ref<ComponentPublicInstance>(null)
const confirmButtonClasses = computed(() => `el-button--primary ${ state.confirmButtonClass }`) const confirmButtonClasses = computed(() => `el-button--primary ${props.confirmButtonClass}`)
watch(() => state.inputValue, async val => { watch(() => state.inputValue, async val => {
await nextTick() await nextTick()
if (state.type$ === 'prompt' && val !== null) { if (props.type === 'prompt' && val !== null) {
validate() validate()
} }
}, { immediate: true }) }, { immediate: true })
watch(() => state.visible, val => { watch(() => visible.value, val => {
popup.state.visible = val
if (val) { if (val) {
state.uid++ if (props.type === 'alert' || props.type === 'confirm') {
if (state.type$ === 'alert' || state.type$ === 'confirm') { nextTick().then(() => { confirmRef.value?.$el?.focus?.() })
nextTick().then(() => { vm.refs.confirm.$el.focus() })
} }
state.focusAfterClosed = document.activeElement state.zIndex = PopupManager.nextZIndex()
dialog = new Dialog(vm.vnode.el, state.focusAfterClosed, getFirstFocus())
} }
if (state.type$ !== 'prompt') return if (props.type !== 'prompt') return
if (val) { if (val) {
nextTick().then(() => { nextTick().then(() => {
if (vm.refs.input && vm.refs.input.$el) { if (inputRef.value && inputRef.value.$el) {
getInputElement().focus() getInputElement().focus()
} }
}) })
@ -249,98 +287,66 @@ export default defineComponent({
} }
}) })
onBeforeMount(() => {
vm = getCurrentInstance()
vm.setupInstall = {
state,
doClose,
}
})
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
if (props.closeOnHashChange) { if (props.closeOnHashChange) {
on(window, 'hashchange', popup.close) on(window, 'hashchange', doClose)
} }
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (props.closeOnHashChange) { if (props.closeOnHashChange) {
off(window, 'hashchange', popup.close) off(window, 'hashchange', doClose)
} }
setTimeout(() => {
dialog.closeDialog()
})
}) })
function getSafeClose () {
const currentId = state.uid
return async () => {
await nextTick()
if (currentId === state.uid) doClose()
}
}
function doClose() { function doClose() {
if (!state.visible) return if (!visible.value) return
state.visible = false visible.value = false
popup.updateClosingFlag(true) nextTick(() => {
dialog.closeDialog() if (state.action) emit('action', state.action)
if (props.lockScroll) {
setTimeout(popup.restoreBodyStyle, 200)
}
popup.state.opened = false
popup.doAfterClose()
setTimeout(() => {
if (state.action) state.callback(state.action, state)
}) })
} }
const getFirstFocus = () => {
const btn = vm.vnode.el.querySelector('.el-message-box__btns .el-button')
const title = vm.vnode.el.querySelector('.el-message-box__btns .el-message-box__title')
return btn || title
}
const handleWrapperClick = () => { const handleWrapperClick = () => {
if (props.closeOnClickModal) { if (props.closeOnClickModal) {
handleAction(state.distinguishCancelAndClose ? 'close' : 'cancel') handleAction(props.distinguishCancelAndClose ? 'close' : 'cancel')
} }
} }
const handleInputEnter = () => { const handleInputEnter = () => {
if (state.inputType !== 'textarea') { if (props.inputType !== 'textarea') {
return handleAction('confirm') return handleAction('confirm')
} }
} }
const handleAction = action => { const handleAction = (action: Action) => {
if (state.type$ === 'prompt' && action === 'confirm' && !validate()) { if (props.type === 'prompt' && action === 'confirm' && !validate()) {
return return
} }
state.action = action state.action = action
if (typeof vm.setupInstall.state.beforeClose === 'function') {
vm.setupInstall.state.close = getSafeClose() if (props.beforeClose) {
vm.setupInstall.state.beforeClose(action, state, popup.close) props.beforeClose?.(action, state, doClose)
} else { } else {
doClose() doClose()
} }
} }
const validate = () => { const validate = () => {
if (state.type$ === 'prompt') { if (props.type === 'prompt') {
const inputPattern = state.inputPattern const inputPattern = props.inputPattern
if (inputPattern && !inputPattern.test(state.inputValue || '')) { if (inputPattern && !inputPattern.test(state.inputValue || '')) {
state.editorErrorMessage = state.inputErrorMessage || t('el.messagebox.error') state.editorErrorMessage = props.inputErrorMessage || t('el.messagebox.error')
state.validateError = true state.validateError = true
return false return false
} }
const inputValidator = state.inputValidator const inputValidator = props.inputValidator
if (typeof inputValidator === 'function') { if (typeof inputValidator === 'function') {
const validateResult = inputValidator(state.inputValue) const validateResult = inputValidator(state.inputValue)
if (validateResult === false) { if (validateResult === false) {
state.editorErrorMessage = state.inputErrorMessage || t('el.messagebox.error') state.editorErrorMessage = props.inputErrorMessage || t('el.messagebox.error')
state.validateError = true state.validateError = true
return false return false
} }
@ -357,25 +363,50 @@ export default defineComponent({
} }
const getInputElement = () => { const getInputElement = () => {
const inputRefs = vm.refs.input.$refs const inputRefs = inputRef.value.$refs
return inputRefs.input || inputRefs.textarea return (inputRefs.input || inputRefs.textarea) as HTMLElement
} }
const handleClose = () => { const handleClose = () => {
handleAction('close') handleAction('close')
} }
// when close on press escape is disabled, pressing esc should not callout
// any other message box and close any other dialog-ish elements
// e.g. Dialog has a close on press esc feature, and when it closes, it calls
// props.beforeClose method to make a intermediate state by callout a message box
// for some verification or alerting. then if we allow global event liek this
// to dispatch, it could callout another message box.
if (props.closeOnPressEscape) {
useModal({
handleClose,
}, visible)
} else {
usePreventGlobal(visible, 'keydown', (e: KeyboardEvent) => e.code === EVENT_CODE.esc)
}
// locks the screen to prevent scroll
if (props.lockScroll) {
useLockScreen(visible)
}
// restore to prev active element.
useRestoreActive(visible)
return { return {
...toRefs(state), state,
visible,
hasMessage, hasMessage,
icon, icon,
confirmButtonClasses, confirmButtonClasses,
inputRef,
confirmRef,
doClose, // for outside usage
handleClose, // for out side usage
handleWrapperClick, handleWrapperClick,
handleInputEnter, handleInputEnter,
handleAction, handleAction,
handleClose,
t, t,
doClose,
} }
}, },
}) })

View File

@ -1,17 +1,31 @@
import { VNode } from 'vue' import type { VNode } from 'vue'
export type MessageType = 'success' | 'warning' | 'info' | 'error'
export type MessageBoxCloseAction = 'confirm' | 'cancel' | 'close'
export type MessageBoxData = MessageBoxInputData
export type Action = 'confirm' | 'close' | 'cancel'
export type MessageType = 'success' | 'warning' | 'info' | 'error'
export type MessageBoxData = MessageBoxInputData & Action
export interface MessageBoxInputData { export interface MessageBoxInputData {
value: string value: string
action: MessageBoxCloseAction action: Action
} }
export interface MessageBoxInputValidator { export interface MessageBoxInputValidator {
(value: string): boolean | string (value: string): boolean | string
} }
export interface MessageBoxState {
action: Action
cancelButtonLoading: boolean
cancelButtonText: string
confirmButtonLoading: boolean
confirmButtonDisabled: boolean
confirmButtonText: string
editorErrorMessage: string
// isOnComposition: boolean temporary commented
inputValue: string
validateError: boolean
zIndex: number
}
export declare class ElMessageBoxComponent { export declare class ElMessageBoxComponent {
title: string title: string
message: string message: string
@ -28,7 +42,7 @@ export declare class ElMessageBoxComponent {
inputErrorMessage: string inputErrorMessage: string
showConfirmButton: boolean showConfirmButton: boolean
showCancelButton: boolean showCancelButton: boolean
action: MessageBoxCloseAction action: Action
dangerouslyUseHTMLString: boolean dangerouslyUseHTMLString: boolean
confirmButtonText: string confirmButtonText: string
cancelButtonText: string cancelButtonText: string
@ -42,40 +56,25 @@ export declare class ElMessageBoxComponent {
close(): any close(): any
} }
export type Callback =
| ((value: string, action: Action) => any)
| ((action: Action) => any)
/** Options used in MessageBox */ /** Options used in MessageBox */
export interface ElMessageBoxOptions { export interface ElMessageBoxOptions {
/** Title of the MessageBox */
title?: string
/** Content of the MessageBox */ /** Callback before MessageBox closes, and it will prevent MessageBox from closing */
message?: string | VNode beforeClose?: (
action: Action,
/** Message type, used for icon display */ instance: ElMessageBoxComponent,
type?: MessageType done: () => void,
) => void
/** Custom icon's class */
iconClass?: string
/** Custom class name for MessageBox */ /** Custom class name for MessageBox */
customClass?: string customClass?: string
/** MessageBox closing callback if you don't prefer Promise */ /** MessageBox closing callback if you don't prefer Promise */
callback?: (action: MessageBoxCloseAction, instance: ElMessageBoxComponent) => void callback?: Callback
/** Callback before MessageBox closes, and it will prevent MessageBox from closing */
beforeClose?: (action: MessageBoxCloseAction, instance: ElMessageBoxComponent, done: (() => void)) => void
/** Whether to lock body scroll when MessageBox prompts */
lockScroll?: boolean
/** Whether to show a cancel button */
showCancelButton?: boolean
/** Whether to show a confirm button */
showConfirmButton?: boolean
/** Whether to show a close button */
showClose?: boolean
/** Text content of cancel button */ /** Text content of cancel button */
cancelButtonText?: string cancelButtonText?: string
@ -92,9 +91,36 @@ export interface ElMessageBoxOptions {
/** Whether to align the content in center */ /** Whether to align the content in center */
center?: boolean center?: boolean
/** Content of the MessageBox */
message?: string | VNode
/** Title of the MessageBox */
title?: string
/** Message type, used for icon display */
type?: MessageType
/** Custom icon's class */
iconClass?: string
/** Whether message is treated as HTML string */ /** Whether message is treated as HTML string */
dangerouslyUseHTMLString?: boolean dangerouslyUseHTMLString?: boolean
/** Whether to distinguish canceling and closing */
distinguishCancelAndClose?: boolean
/** Whether to lock body scroll when MessageBox prompts */
lockScroll?: boolean
/** Whether to show a cancel button */
showCancelButton?: boolean
/** Whether to show a confirm button */
showConfirmButton?: boolean
/** Whether to show a close button */
showClose?: boolean
/** Whether to use round button */ /** Whether to use round button */
roundButton?: boolean roundButton?: boolean
@ -128,18 +154,22 @@ export interface ElMessageBoxOptions {
/** Error message when validation fails */ /** Error message when validation fails */
inputErrorMessage?: string inputErrorMessage?: string
/** Whether to distinguish canceling and closing */
distinguishCancelAndClose?: boolean
} }
export interface ElMessageBoxShortcutMethod { export type ElMessageBoxShortcutMethod =
(message: string, title: string, options?: ElMessageBoxOptions): Promise<MessageBoxData> ((
(message: string, options?: ElMessageBoxOptions): Promise<MessageBoxData> message: string,
} title: string,
options?: ElMessageBoxOptions,
) => Promise<MessageBoxData>)
& ((
message: string,
options?: ElMessageBoxOptions,
) => Promise<MessageBoxData>)
export interface ElMessageBox { export interface ElMessageBox {
/** Show a message box */ /** Show a message box */
(message: string, title?: string, type?: string): Promise<MessageBoxData> // (message: string, title?: string, type?: string): Promise<MessageBoxData>
/** Show a message box */ /** Show a message box */
(options: ElMessageBoxOptions): Promise<MessageBoxData> (options: ElMessageBoxOptions): Promise<MessageBoxData>
@ -153,9 +183,6 @@ export interface ElMessageBox {
/** Show a prompt message box */ /** Show a prompt message box */
prompt: ElMessageBoxShortcutMethod prompt: ElMessageBoxShortcutMethod
/** Set default options of message boxes */
setDefaults (defaults: ElMessageBoxOptions): void
/** Close current message box */ /** Close current message box */
close (): void close(): void
} }

View File

@ -1,179 +1,130 @@
import { createVNode, render } from 'vue' import { h, render } from 'vue'
import MessageBoxConstructor from './index.vue' import MessageBoxConstructor from './index.vue'
import isServer from '@element-plus/utils/isServer' import isServer from '@element-plus/utils/isServer'
import { isVNode } from '../../utils/util' import { isVNode, isString } from '@element-plus/utils/util'
import { ElMessageBoxOptions } from './message-box.type'
let currentMsg, instance import type { ComponentPublicInstance, VNode } from 'vue'
import type {
// component default props Action,
const PROP_KEYS = [ Callback,
'lockScroll', MessageBoxState,
'showClose', ElMessageBox,
'closeOnClickModal', ElMessageBoxOptions,
'closeOnPressEscape', MessageBoxData,
'closeOnHashChange', } from './message-box.type'
'center',
'roundButton',
'closeDelay',
'zIndex',
'modal',
'modalFade',
'modalClass',
'modalAppendToBody',
'lockScroll',
]
// component default merge props & data // component default merge props & data
const defaults = {
title: null,
message: '',
type: '',
iconClass: '',
showInput: false,
showClose: true,
modalFade: true,
lockScroll: true,
closeOnClickModal: true,
closeOnPressEscape: true,
closeOnHashChange: true,
inputValue: null,
inputPlaceholder: '',
inputType: 'text',
inputPattern: null,
inputValidator: null,
inputErrorMessage: '',
showConfirmButton: true,
showCancelButton: false,
confirmButtonPosition: 'right',
confirmButtonHighlight: false,
cancelButtonHighlight: false,
confirmButtonText: '',
cancelButtonText: '',
confirmButtonClass: '',
cancelButtonClass: '',
customClass: '',
beforeClose: null,
dangerouslyUseHTMLString: false,
center: false,
roundButton: false,
distinguishCancelAndClose: false,
}
let msgQueue = [] const messageInstance = new Map<
ComponentPublicInstance<{ doClose: () => void; }>, // marking doClose as function
const defaultCallback = (action, ctx) => { {
if (currentMsg) { options: any
const callback = currentMsg.callback callback: Callback
if (typeof callback === 'function') { resolve: (res: any) => void
if (ctx.showInput) { reject: (reason?: any) => void
callback(ctx.inputValue, action)
} else {
callback(action)
}
}
if (currentMsg.resolve) {
if (action === 'confirm') {
if (ctx.showInput) {
currentMsg.resolve({ value: ctx.inputValue, action })
} else {
currentMsg.resolve(action)
}
} else if (currentMsg.reject && (action === 'cancel' || action === 'close')) {
currentMsg.reject(action)
}
}
} }
} >()
const initInstance = () => {
const container = document.createElement('div') const initInstance = (props: any, container: HTMLElement) => {
const vnode = createVNode(MessageBoxConstructor) const vnode = h(MessageBoxConstructor, props)
render(vnode, container) render(vnode, container)
instance = vnode.component document.body.appendChild(container.firstElementChild)
return vnode.component
} }
const showNextMsg = async () => { const genContainer = () => {
if (!instance) { return document.createElement('div')
initInstance() }
const showMessage = (options: any) => {
const container = genContainer()
// Adding destruct method.
// when transition leaves emitting `vanish` evt. so that we can do the clean job.
options.onVanish = () => {
// not sure if this causes mem leak, need proof to verify that.
// maybe calling out like 1000 msg-box then close them all.
render(null, container)
messageInstance.delete(vm) // Remove vm to avoid mem leak.
// here we were suppose to call document.body.removeChild(container.firstElementChild)
// but render(null, container) did that job for us. so that we do not call that directly
} }
if (instance && instance.setupInstall.state.visible) { return }
if (msgQueue.length > 0) { options.onAction = (action: Action) => {
const props = {}
const state = {} const currentMsg = messageInstance.get(vm)
currentMsg = msgQueue.shift() let resolve: Action | { value: string; action: Action; }
const options = currentMsg.options if (options.showInput) {
Object.keys(options).forEach(key => { resolve = { value: vm.state.inputValue, action }
if (PROP_KEYS.includes(key)) { } else {
props[key] = options[key] resolve = action
}
if (options.callback) {
options.callback(resolve, instance.proxy)
} else {
if (action === 'cancel' || action === 'close') {
if (options.distinguishCancelAndClose && action !== 'cancel') {
currentMsg.reject('close')
} else {
currentMsg.reject('cancel')
}
} else { } else {
state[key] = options[key] currentMsg.resolve(resolve)
}
})
// update props to instance/**/
const vmProps = instance.props
for (const prop in props) {
if (props.hasOwnProperty(prop)) {
vmProps[prop] = props[prop]
} }
} }
const vmState = instance.setupInstall.state
vmState.action = ''
if (options.callback === undefined) {
options.callback = defaultCallback
}
for (const prop in state) {
if (state.hasOwnProperty(prop)) {
vmState[prop] = state[prop]
}
}
if (isVNode(options.message)) {
instance.slots.default = () => [options.message]
}
const oldCb = options.callback
vmState.callback = (action, inst) => {
oldCb(action, inst)
showNextMsg()
}
document.body.appendChild(instance.vnode.el)
vmState.visible = true
} }
const instance = initInstance(options, container)
// This is how we use message box programmably.
// Maybe consider releasing a template version?
// get component instance like v2.
const vm = instance.proxy as ComponentPublicInstance<{
visible: boolean
state: MessageBoxState
doClose: () => void
}>
if (isVNode(options.message)) {
// Override slots since message is vnode type.
instance.slots.default = () => [options.message]
}
// change visibility after everything is settled
vm.visible = true
return vm
} }
const MessageBox = function(options: ElMessageBoxOptions | string, callback?): Promise<any> { async function MessageBox(options: ElMessageBoxOptions): Promise<MessageBoxData>
function MessageBox(
options: ElMessageBoxOptions | string | VNode,
): Promise<{value: string; action: Action;} | Action> {
if (isServer) return if (isServer) return
if (typeof options === 'string' || isVNode(options)) { let callback
if (isString(options) || isVNode(options)) {
options = { options = {
message: options, message: options,
} }
if (typeof callback === 'string') { } else {
options.title = callback
}
} else if (options.callback && !callback) {
callback = options.callback callback = options.callback
} }
if (typeof Promise !== 'undefined') {
return new Promise((resolve, reject) => {
msgQueue.push({
options: Object.assign({}, defaults, options),
callback: callback,
resolve: resolve,
reject: reject,
})
showNextMsg() return new Promise((resolve, reject) => {
const vm = showMessage(options)
// collect this vm in order to handle upcoming events.
messageInstance.set(vm, {
options,
callback,
resolve,
reject,
}) })
} else { })
msgQueue.push({
options: Object.assign({}, defaults, options),
callback: callback,
})
showNextMsg()
}
} }
MessageBox.alert = (message, title, options?: ElMessageBoxOptions) => { MessageBox.alert = (
message: string,
title: string,
options?: ElMessageBoxOptions,
) => {
if (typeof title === 'object') { if (typeof title === 'object') {
options = title options = title
title = '' title = ''
@ -181,51 +132,78 @@ MessageBox.alert = (message, title, options?: ElMessageBoxOptions) => {
title = '' title = ''
} }
return MessageBox(Object.assign({ return MessageBox(
title: title, Object.assign(
message: message, {
type$: 'alert', title: title,
closeOnPressEscape: false, message: message,
closeOnClickModal: false, type: 'alert',
}, options)) closeOnPressEscape: false,
closeOnClickModal: false,
},
options,
),
)
} }
MessageBox.confirm = (message, title, options?: ElMessageBoxOptions) => { MessageBox.confirm = (
message: string,
title: string,
options?: ElMessageBoxOptions,
) => {
if (typeof title === 'object') { if (typeof title === 'object') {
options = title options = title
title = '' title = ''
} else if (title === undefined) { } else if (title === undefined) {
title = '' title = ''
} }
return MessageBox(Object.assign({ return MessageBox(
title: title, Object.assign(
message: message, {
type$: 'confirm', title: title,
showCancelButton: true, message: message,
}, options)) type: 'confirm',
showCancelButton: true,
},
options,
),
)
} }
MessageBox.prompt = (message, title, options?: ElMessageBoxOptions) => { MessageBox.prompt = (
message: string,
title: string,
options?: ElMessageBoxOptions,
) => {
if (typeof title === 'object') { if (typeof title === 'object') {
options = title options = title
title = '' title = ''
} else if (title === undefined) { } else if (title === undefined) {
title = '' title = ''
} }
return MessageBox(Object.assign({ return MessageBox(
title: title, Object.assign(
message: message, {
showCancelButton: true, title: title,
showInput: true, message: message,
type$: 'prompt', showCancelButton: true,
}, options)) showInput: true,
type: 'prompt',
},
options,
),
)
} }
MessageBox.close = () => { MessageBox.close = () => {
instance.setupInstall.doClose() // instance.setupInstall.doClose()
instance.setupInstall.state.visible = false // instance.setupInstall.state.visible = false
msgQueue = []
currentMsg = null messageInstance.forEach((_, vm) => {
vm.doClose()
})
messageInstance.clear()
} }
export default MessageBox export default MessageBox as ElMessageBox

View File

@ -18,9 +18,22 @@ export default defineComponent({
}, },
emits: ['click'], emits: ['click'],
setup(props, { slots, emit }) { setup(props, { slots, emit }) {
const onMaskClick = () => { let mousedownTarget = false
emit('click') let mouseupTarget = false
// refer to this https://javascript.info/mouse-events-basics
// events fired in the order: mousedown -> mouseup -> click
// we need to set the mousedown handle to false after click
// fired.
const onMaskClick = (e: MouseEvent) => {
// due to these two value were set only when props.mask is true
// so there is no need to do any extra judgment here.
// if and only if
if (mousedownTarget && mouseupTarget) {
emit('click', e)
}
mousedownTarget = mouseupTarget = false
} }
// init here // init here
return () => { return () => {
// when the vnode meets the same structure but with different change trigger // when the vnode meets the same structure but with different change trigger
@ -34,21 +47,36 @@ export default defineComponent({
zIndex: props.zIndex, zIndex: props.zIndex,
}, },
onClick: onMaskClick, onClick: onMaskClick,
onMousedown: (e: MouseEvent) => {
// marking current mousedown target.
if (props.mask) {
mousedownTarget = e.target === e.currentTarget
}
},
onMouseup: (e: MouseEvent) => {
if (props.mask) {
mouseupTarget = e.target === e.currentTarget
}
},
}, },
[renderSlot(slots, 'default')], [renderSlot(slots, 'default')],
PatchFlags.STYLE | PatchFlags.CLASS | PatchFlags.PROPS, PatchFlags.STYLE | PatchFlags.CLASS | PatchFlags.PROPS,
['onClick'], ['onClick', 'onMouseup', 'onMousedown'],
) )
: h('div', { : h(
style: { 'div',
zIndex: props.zIndex, {
position: 'fixed', style: {
top: '0px', zIndex: props.zIndex,
right: '0px', position: 'fixed',
bottom: '0px', top: '0px',
left: '0px', right: '0px',
bottom: '0px',
left: '0px',
},
}, },
}, [renderSlot(slots, 'default')]) [renderSlot(slots, 'default')],
)
} }
}, },
}) })

View File

@ -0,0 +1,21 @@
import { nextTick } from 'vue'
import triggerEvent from './trigger-event'
import type { DOMWrapper, VueWrapper } from '@vue/test-utils'
const triggerCompositeClick = async <T extends (VueWrapper<any> | DOMWrapper<Element>)>(wrapper: T) => {
await wrapper.trigger('mousedown')
await wrapper.trigger('mouseup')
await wrapper.trigger('click')
}
export default triggerCompositeClick
export const triggerNativeCompositeClick = async (el: Element) => {
triggerEvent(el, 'mousedown')
await nextTick()
triggerEvent(el, 'mouseup')
await nextTick()
triggerEvent(el, 'click')
return nextTick()
}

View File

@ -14,7 +14,7 @@ export const rAF = async () => {
return new Promise(res => { return new Promise(res => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(async () => { requestAnimationFrame(async () => {
res() res(null)
await nextTick() await nextTick()
}) })
}) })

View File

@ -1,6 +1,6 @@
@import "mixins/mixins"; @import 'mixins/mixins';
@import "common/var"; @import 'common/var';
@import "./overlay.scss"; @import './overlay.scss';
@keyframes el-drawer-fade-in { @keyframes el-drawer-fade-in {
0% { 0% {
@ -12,54 +12,14 @@
} }
@mixin drawer-animation($direction) { @mixin drawer-animation($direction) {
@keyframes #{$direction}-drawer-animation {
@keyframes #{$direction}-drawer-in {
0% {
@if $direction == ltr {
transform: translate(-100%, 0px);
}
@if $direction == rtl {
transform: translate(100%, 0px);
}
@if $direction == ttb {
transform: translate(0px, -100%);
}
@if $direction == btt {
transform: translate(0px, 100%);
}
}
100% {
@if $direction == ltr {
transform: translate(0px, 0px);
}
@if $direction == rtl {
transform: translate(0px, 0px);
}
@if $direction == ttb {
transform: translate(0px, 0px);
}
@if $direction == btt {
transform: translate(0px, 0px);
}
}
}
@keyframes #{$direction}-drawer-out {
0% { 0% {
@if $direction == ltr { @if $direction == ltr {
transform: translate(0px, 0px); transform: translate(0px, 0px);
} }
@if $direction == rtl { @if $direction == rtl {
transform: translate(0px, 0px);; transform: translate(0px, 0px);
} }
@if $direction == ttb { @if $direction == ttb {
@ -67,7 +27,7 @@
} }
@if $direction == btt { @if $direction == btt {
transform: translate(0px, 0); transform: translate(0px, 0px);
} }
} }
@ -92,14 +52,14 @@
} }
@mixin animation-in($direction) { @mixin animation-in($direction) {
.el-drawer__open &.#{$direction} { &.#{$direction} {
animation: #{$direction}-drawer-in .3s 1ms; animation: #{$direction}-drawer-animation 0.3s linear reverse;
} }
} }
@mixin animation-out($direction) { @mixin animation-out($direction) {
&.#{$direction} { &.#{$direction} {
animation: #{$direction}-drawer-out .3s; animation: #{$direction}-drawer-animation 0.3s linear;
} }
} }
@ -111,29 +71,23 @@
$directions: rtl, ltr, ttb, btt; $directions: rtl, ltr, ttb, btt;
@include b(drawer) { @include b(drawer) {
position: fixed; position: absolute;
box-sizing: border-box; box-sizing: border-box;
background-color: $--dialog-background-color; background-color: $--dialog-background-color;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: 0 8px 10px -5px rgba(0, 0, 0, 0.2), box-shadow: 0 8px 10px -5px rgba(0, 0, 0, 0.2),
0 16px 24px 2px rgba(0, 0, 0, 0.14), 0 16px 24px 2px rgba(0, 0, 0, 0.14), 0 6px 30px 5px rgba(0, 0, 0, 0.12);
0 6px 30px 5px rgba(0, 0, 0, 0.12);
overflow: hidden; overflow: hidden;
@each $direction in $directions { @each $direction in $directions {
@include animation-out($direction); .el-drawer-fade-enter-active & {
@include animation-in($direction); @include animation-in($direction);
} }
&__wrapper { .el-drawer-fade-leave-active & {
position: fixed; @include animation-out($direction);
top: 0; }
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
margin: 0;
} }
&__header { &__header {
@ -170,13 +124,15 @@ $directions: rtl, ltr, ttb, btt;
} }
} }
&.ltr, &.rtl { &.ltr,
&.rtl {
height: 100%; height: 100%;
top: 0; top: 0;
bottom: 0; bottom: 0;
} }
&.ttb, &.btt { &.ttb,
&.btt {
width: 100%; width: 100%;
left: 0; left: 0;
right: 0; right: 0;
@ -199,20 +155,12 @@ $directions: rtl, ltr, ttb, btt;
} }
} }
.el-drawer__container {
position: relative;
left: 0;
right: 0;
top: 0;
bottom: 0;
height: 100%;
width: 100%;
}
.el-drawer-fade-enter-active { .el-drawer-fade-enter-active {
animation: el-drawer-fade-in .3s; animation: el-drawer-fade-in 0.3s;
overflow: hidden !important;
} }
.el-drawer-fade-leave-active { .el-drawer-fade-leave-active {
animation: el-drawer-fade-in .3s reverse; overflow: hidden !important;
animation: el-drawer-fade-in 0.3s reverse;
} }

View File

@ -18,23 +18,6 @@
overflow: hidden; overflow: hidden;
backface-visibility: hidden; backface-visibility: hidden;
@include e(wrapper) {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
text-align: center;
&::after {
content: "";
display: inline-block;
height: 100%;
width: 0;
vertical-align: middle;
}
}
@include e(header) { @include e(header) {
position: relative; position: relative;
padding: $--msgbox-padding-primary; padding: $--msgbox-padding-primary;
@ -195,12 +178,16 @@
} }
} }
.msgbox-fade-enter-active { .fade-in-linear-enter-active {
animation: msgbox-fade-in .3s; .el-message-box {
animation: msgbox-fade-in .3s;
}
} }
.msgbox-fade-leave-active { .fade-in-linear-leave-active {
animation: msgbox-fade-out .3s; .el-message-box {
animation: msgbox-fade-in .3s reverse;
}
} }
@keyframes msgbox-fade-in { @keyframes msgbox-fade-in {

View File

@ -12,6 +12,15 @@
left: 0; left: 0;
z-index: 2000; z-index: 2000;
height: 100%; height: 100%;
text-align: center;
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
overflow: auto; overflow: auto;
&::after {
content: "";
display: inline-block;
height: 100%;
width: 0;
vertical-align: middle;
}
} }

View File

@ -23,9 +23,10 @@ export const off = function(
element: HTMLElement | Document | Window, element: HTMLElement | Document | Window,
event: string, event: string,
handler: EventListenerOrEventListenerObject, handler: EventListenerOrEventListenerObject,
useCapture = false,
): void { ): void {
if (element && event && handler) { if (element && event && handler) {
element.removeEventListener(event, handler, false) element.removeEventListener(event, handler, useCapture)
} }
} }