mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
refactor: Refactor message component with VCA (#1387)
This commit is contained in:
@@ -1,41 +1,42 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { getStyle } from '@element-plus/utils/dom'
|
||||
import * as Vue from 'vue'
|
||||
import Message, { close } from '../src/message'
|
||||
|
||||
type UnknownProps = Record<string, unknown>
|
||||
import { rAF } from '@element-plus/test-utils/tick'
|
||||
import Message from '../src/message'
|
||||
|
||||
jest.useFakeTimers()
|
||||
|
||||
const selector = '.el-message'
|
||||
// TODO: testing the original transition with `nextTick`
|
||||
const Transition = (_: UnknownProps, { attrs, slots }) =>
|
||||
Vue.h('div', attrs, slots)
|
||||
Transition.displayName = 'Transition'
|
||||
// TODO: testing the original transition with `nextTick`'
|
||||
|
||||
describe('Message on command', () => {
|
||||
const oldTransition = Vue.Transition
|
||||
// const oldTransition = Vue.Transition
|
||||
beforeAll(() => {
|
||||
// eslint-disable-next-line
|
||||
;(Vue as any).Transition = Transition
|
||||
// ;(Vue as any).Transition = Transition
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
// eslint-disable-next-line
|
||||
;(Vue as any).Transition = oldTransition
|
||||
// ;(Vue as any).Transition = oldTransition
|
||||
})
|
||||
|
||||
test('it should get component handle', async () => {
|
||||
const handle = Message()
|
||||
await rAF()
|
||||
expect(document.querySelector(selector)).toBeTruthy()
|
||||
handle.close()
|
||||
await rAF()
|
||||
await nextTick()
|
||||
expect(document.querySelector(selector)).toBeFalsy()
|
||||
})
|
||||
|
||||
test('it should be able to manually close a message', () => {
|
||||
Message()
|
||||
test('it should be able to manually close a message', async () => {
|
||||
const { close } = Message()
|
||||
await rAF()
|
||||
const element = document.querySelector(selector)
|
||||
expect(element).toBeTruthy()
|
||||
close(element.id)
|
||||
close()
|
||||
await rAF()
|
||||
await nextTick()
|
||||
expect(document.querySelector(selector)).toBeNull()
|
||||
})
|
||||
|
||||
@@ -44,27 +45,24 @@ describe('Message on command', () => {
|
||||
const instances = []
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const instance = Message({
|
||||
duration: 0,
|
||||
onClose,
|
||||
})
|
||||
instances.push(instance)
|
||||
}
|
||||
|
||||
await rAF()
|
||||
const elements = document.querySelectorAll(selector)
|
||||
expect(elements.length).toBe(4)
|
||||
Message.closeAll()
|
||||
await Vue.nextTick()
|
||||
for (let i = 0; i < instances.length; i++) {
|
||||
const instance = instances[i]
|
||||
instance.close()
|
||||
}
|
||||
|
||||
await rAF()
|
||||
expect(onClose).toHaveBeenCalledTimes(4)
|
||||
expect(document.querySelectorAll(selector).length).toBe(0)
|
||||
})
|
||||
|
||||
test('it should stack messages', () => {
|
||||
test('it should stack messages', async () => {
|
||||
const messages = [Message(), Message(), Message()]
|
||||
|
||||
await rAF()
|
||||
const elements = document.querySelectorAll(selector)
|
||||
expect(elements.length).toBe(3)
|
||||
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { h, nextTick } from 'vue'
|
||||
import Message from '../src/index.vue'
|
||||
import * as domExports from '../../utils/dom'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import * as domExports from '@element-plus/utils/dom'
|
||||
import makeMount from '@element-plus/test-utils/make-mount'
|
||||
import { EVENT_CODE } from '@element-plus/utils/aria'
|
||||
import Message from '../src/index.vue'
|
||||
|
||||
import type { ComponentPublicInstance, CSSProperties } from 'vue'
|
||||
import { rAF } from '@element-plus/test-utils/tick'
|
||||
|
||||
const AXIOM = 'Rem is the best girl'
|
||||
|
||||
jest.useFakeTimers()
|
||||
|
||||
const _mount = (props: Record<string, unknown>) => {
|
||||
const onClose = jest.fn()
|
||||
return mount(Message, {
|
||||
...props,
|
||||
props: {
|
||||
onClose,
|
||||
...(props.props as Record<string, unknown>),
|
||||
},
|
||||
})
|
||||
}
|
||||
type MessageInstance = ComponentPublicInstance<{visible: boolean; typeClass: string; customStyle: CSSProperties;}>
|
||||
|
||||
const onClose = jest.fn()
|
||||
const _mount = makeMount(Message, {
|
||||
props: {
|
||||
onClose,
|
||||
},
|
||||
})
|
||||
|
||||
describe('Message.vue', () => {
|
||||
describe('render', () => {
|
||||
@@ -28,10 +29,12 @@ describe('Message.vue', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const vm = wrapper.vm as MessageInstance
|
||||
|
||||
expect(wrapper.text()).toEqual(AXIOM)
|
||||
expect(wrapper.vm.visible).toBe(true)
|
||||
expect(wrapper.vm.typeClass).toBe('el-message__icon el-icon-info')
|
||||
expect(wrapper.vm.customStyle).toEqual({ top: '20px', zIndex: 0 })
|
||||
expect(vm.visible).toBe(true)
|
||||
expect(vm.typeClass).toBe('el-message__icon el-icon-info')
|
||||
expect(vm.customStyle).toEqual({ top: '20px', zIndex: 0 })
|
||||
})
|
||||
|
||||
test('should be able to render VNode', () => {
|
||||
@@ -76,11 +79,7 @@ describe('Message.vue', () => {
|
||||
const wrapper = _mount({
|
||||
slots: { default: AXIOM },
|
||||
})
|
||||
expect(domExports.on).toHaveBeenCalledWith(
|
||||
document,
|
||||
'keydown',
|
||||
wrapper.vm.keydown,
|
||||
)
|
||||
expect(domExports.on).toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
expect(domExports.off).toHaveBeenCalled()
|
||||
})
|
||||
@@ -106,7 +105,7 @@ describe('Message.vue', () => {
|
||||
})
|
||||
|
||||
describe('event handlers', () => {
|
||||
test('it should be able to close the message by clicking close button', () => {
|
||||
test('it should be able to close the message by clicking close button', async () => {
|
||||
const onClose = jest.fn()
|
||||
const wrapper = _mount({
|
||||
slots: { default: AXIOM },
|
||||
@@ -118,46 +117,42 @@ describe('Message.vue', () => {
|
||||
|
||||
const closeBtn = wrapper.find('.el-message__closeBtn')
|
||||
expect(closeBtn.exists()).toBe(true)
|
||||
wrapper.vm.visible = false
|
||||
wrapper.vm.onClose()
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
await closeBtn.trigger('click')
|
||||
expect((wrapper.vm as MessageInstance).visible).toBe(false)
|
||||
})
|
||||
|
||||
test('it should close after duration', () => {
|
||||
test('it should close after duration', async () => {
|
||||
const duration = 1000
|
||||
const wrapper = _mount({ props: { duration } })
|
||||
wrapper.vm.close = jest.fn()
|
||||
expect(wrapper.vm.timer).not.toBe(null)
|
||||
expect(wrapper.vm.closed).toBe(false)
|
||||
const vm = wrapper.vm as MessageInstance
|
||||
await nextTick()
|
||||
expect(vm.visible).toBe(true)
|
||||
jest.runAllTimers()
|
||||
expect(wrapper.vm.close).toHaveBeenCalled()
|
||||
await nextTick()
|
||||
expect(vm.visible).toBe(false)
|
||||
})
|
||||
|
||||
test('it should prevent close when hovered', async () => {
|
||||
const duration = 1000
|
||||
const wrapper = _mount({ props: { duration } })
|
||||
expect(wrapper.vm.timer).not.toBe(null)
|
||||
expect(wrapper.vm.closed).toBe(false)
|
||||
const vm = wrapper.vm as MessageInstance
|
||||
expect(vm.visible).toBe(true)
|
||||
await wrapper.find('[role="alert"]').trigger('mouseenter')
|
||||
jest.runAllTimers()
|
||||
expect(wrapper.vm.timer).toBe(null)
|
||||
expect(wrapper.vm.closed).toBe(false)
|
||||
expect(vm.visible).toBe(true)
|
||||
await wrapper.find('[role="alert"]').trigger('mouseleave')
|
||||
expect(wrapper.vm.timer).not.toBe(null)
|
||||
expect(wrapper.vm.closed).toBe(false)
|
||||
expect(vm.visible).toBe(true)
|
||||
jest.runAllTimers()
|
||||
expect(wrapper.vm.timer).toBe(null)
|
||||
expect(wrapper.vm.closed).toBe(true)
|
||||
expect(vm.visible).toBe(false)
|
||||
})
|
||||
|
||||
test('it should not close when duration is set to 0', () => {
|
||||
const duration = 0
|
||||
const wrapper = _mount({ props: { duration } })
|
||||
expect(wrapper.vm.timer).toBe(null)
|
||||
expect(wrapper.vm.closed).toBe(false)
|
||||
const vm = wrapper.vm as MessageInstance
|
||||
expect(vm.visible).toBe(true)
|
||||
jest.runAllTimers()
|
||||
expect(wrapper.vm.timer).toBe(null)
|
||||
expect(wrapper.vm.closed).toBe(false)
|
||||
expect(vm.visible).toBe(true)
|
||||
})
|
||||
|
||||
test('it should close when esc is pressed', async () => {
|
||||
@@ -166,12 +161,9 @@ describe('Message.vue', () => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
code: EVENT_CODE.esc,
|
||||
})
|
||||
const oldClose = wrapper.vm.close
|
||||
wrapper.vm.close = jest.fn(() => oldClose())
|
||||
document.dispatchEvent(event)
|
||||
|
||||
expect(wrapper.vm.closed).toBe(true)
|
||||
expect(wrapper.vm.close).toHaveBeenCalledTimes(1)
|
||||
expect((wrapper.vm as MessageInstance).visible).toBe(false)
|
||||
})
|
||||
|
||||
test('it should call close after transition ends', async () => {
|
||||
@@ -180,12 +172,11 @@ describe('Message.vue', () => {
|
||||
slots: { default: AXIOM },
|
||||
props: { onClose },
|
||||
})
|
||||
await rAF()
|
||||
const vm = wrapper.vm as MessageInstance
|
||||
vm.visible = false
|
||||
await rAF()
|
||||
|
||||
expect(wrapper.vm.closed).toBe(false)
|
||||
wrapper.vm.close()
|
||||
expect(wrapper.vm.closed).toBe(true)
|
||||
await nextTick()
|
||||
await wrapper.vm.onClose()
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<transition name="el-message-fade" @after-leave="onClose">
|
||||
<transition name="el-message-fade" @before-leave="onClose" @after-leave="$emit('destroy')">
|
||||
<div
|
||||
v-show="visible"
|
||||
:id="id"
|
||||
@@ -17,23 +17,17 @@
|
||||
>
|
||||
<i v-if="type || iconClass" :class="[typeClass, iconClass]"></i>
|
||||
<slot>
|
||||
<p v-if="!dangerouslyUseHTMLString" class="el-message__content">
|
||||
{{ message }}
|
||||
</p>
|
||||
<p v-if="!dangerouslyUseHTMLString" class="el-message__content">{{ message }}</p>
|
||||
<!-- Caution here, message could've been compromised, never use user's input as message -->
|
||||
<!-- eslint-disable-next-line -->
|
||||
<p v-else class="el-message__content" v-html="message"></p>
|
||||
</slot>
|
||||
<div
|
||||
v-if="showClose"
|
||||
class="el-message__closeBtn el-icon-close"
|
||||
@click.stop="close"
|
||||
></div>
|
||||
<div v-if="showClose" class="el-message__closeBtn el-icon-close" @click.stop="close"></div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref, PropType } from 'vue'
|
||||
import { defineComponent, computed, ref, PropType, onMounted, onBeforeUnmount } from 'vue'
|
||||
// MessageVM is an alias of vue.VNode
|
||||
import type { MessageVM } from './types'
|
||||
import { EVENT_CODE } from '../../utils/aria'
|
||||
@@ -66,6 +60,7 @@ export default defineComponent({
|
||||
offset: { type: Number, default: 20 },
|
||||
zIndex: { type: Number, default: 0 },
|
||||
},
|
||||
emits: ['destroy'],
|
||||
setup(props) {
|
||||
const typeClass = computed(() => {
|
||||
const type = props.type
|
||||
@@ -79,57 +74,59 @@ export default defineComponent({
|
||||
zIndex: props.zIndex,
|
||||
}
|
||||
})
|
||||
|
||||
const visible = ref(false)
|
||||
const closed = ref(false)
|
||||
const timer = ref(null)
|
||||
let timer = null
|
||||
|
||||
function startTimer() {
|
||||
if (props.duration > 0) {
|
||||
timer = setTimeout(() => {
|
||||
if (visible.value) {
|
||||
close()
|
||||
}
|
||||
}, props.duration)
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimer() {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function keydown({ code }: KeyboardEvent) {
|
||||
if (code === EVENT_CODE.esc) {
|
||||
// press esc to close the message
|
||||
if (visible.value) {
|
||||
close()
|
||||
}
|
||||
} else {
|
||||
startTimer() // resume timer
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startTimer()
|
||||
visible.value = true
|
||||
on(document, 'keydown', keydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
off(document, 'keydown', keydown)
|
||||
})
|
||||
|
||||
return {
|
||||
typeClass,
|
||||
customStyle,
|
||||
visible,
|
||||
closed,
|
||||
timer,
|
||||
|
||||
close,
|
||||
clearTimer,
|
||||
startTimer,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.startTimer()
|
||||
this.visible = true
|
||||
on(document, 'keydown', this.keydown)
|
||||
},
|
||||
beforeUnmount() {
|
||||
off(document, 'keydown', this.keydown)
|
||||
},
|
||||
methods: {
|
||||
// start counting down to destroy message instance
|
||||
startTimer() {
|
||||
if (this.duration > 0) {
|
||||
this.timer = setTimeout(() => {
|
||||
if (!this.closed) {
|
||||
this.close()
|
||||
}
|
||||
}, this.duration)
|
||||
}
|
||||
},
|
||||
// clear timer
|
||||
clearTimer() {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
},
|
||||
// Event handlers
|
||||
close() {
|
||||
this.closed = true
|
||||
this.visible = false
|
||||
this.timer = null
|
||||
},
|
||||
keydown({ code }: KeyboardEvent) {
|
||||
if (code === EVENT_CODE.esc) {
|
||||
// press esc to close the message
|
||||
if (!this.closed) {
|
||||
this.close()
|
||||
}
|
||||
} else {
|
||||
this.startTimer() // resume timer
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createVNode, nextTick, render } from 'vue'
|
||||
import { createVNode, render } from 'vue'
|
||||
import { isVNode } from '@element-plus/utils/util'
|
||||
import PopupManager from '@element-plus/utils/popup-manager'
|
||||
import isServer from '@element-plus/utils/isServer'
|
||||
import MessageConstructor from './index.vue'
|
||||
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import type {
|
||||
IMessage,
|
||||
MessageQueue,
|
||||
@@ -13,10 +14,11 @@ import type {
|
||||
MessageParams,
|
||||
} from './types'
|
||||
|
||||
let vm: MessageVM
|
||||
const instances: MessageQueue = []
|
||||
let seed = 1
|
||||
|
||||
// TODO: Since Notify.ts is basically the same like this file. So we could do some encapsulation against them to
|
||||
// reduce code duplication.
|
||||
const Message: IMessage = function(
|
||||
opts: MessageParams = {} as MessageParams,
|
||||
): IMessageHandle {
|
||||
@@ -54,18 +56,29 @@ const Message: IMessage = function(
|
||||
container.className = `container_${id}`
|
||||
|
||||
const message = options.message
|
||||
vm = createVNode(
|
||||
const vm = createVNode(
|
||||
MessageConstructor,
|
||||
options,
|
||||
isVNode(options.message) ? { default: () => message } : null,
|
||||
)
|
||||
|
||||
// clean message element preventing mem leak
|
||||
vm.props.onDestroy = () => {
|
||||
render(null, container)
|
||||
// since the element is destroy, then the VNode should be collected by GC as well
|
||||
// we do not want cause any mem leak because we have returned vm as a reference to users
|
||||
// so that we manually set it to false.
|
||||
}
|
||||
|
||||
render(vm, container)
|
||||
instances.push({ vm, $el: container })
|
||||
document.body.appendChild(container)
|
||||
// instances will remove this item when close function gets called. So we do not need to worry about it.
|
||||
instances.push({ vm })
|
||||
document.body.appendChild(container.firstElementChild)
|
||||
|
||||
return {
|
||||
close: options.onClose,
|
||||
// instead of calling the onClose function directly, setting this value so that we can have the full lifecycle
|
||||
// for out component, so that all closing steps will not be skipped.
|
||||
close: () => (vm.component.proxy as ComponentPublicInstance<{visible: boolean;}>).visible = false,
|
||||
}
|
||||
} as any
|
||||
|
||||
@@ -78,16 +91,11 @@ export function close(id: string, userOnClose?: (vm: MessageVM) => void): void {
|
||||
return
|
||||
}
|
||||
|
||||
const { vm, $el } = instances[idx]
|
||||
const { vm } = instances[idx]
|
||||
if (!vm) return
|
||||
userOnClose?.(vm)
|
||||
|
||||
const removedHeight = vm.el.offsetHeight
|
||||
render(null, $el)
|
||||
nextTick(() => {
|
||||
document.body.removeChild($el)
|
||||
})
|
||||
|
||||
instances.splice(idx, 1)
|
||||
|
||||
// adjust other instances vertical offset
|
||||
@@ -98,7 +106,6 @@ export function close(id: string, userOnClose?: (vm: MessageVM) => void): void {
|
||||
parseInt(instances[i].vm.el.style['top'], 10) - removedHeight - 16
|
||||
|
||||
instances[i].vm.component.props.offset = pos
|
||||
instances[i].vm.component.props.vertOffset = pos
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ export type MessageVM = VNode
|
||||
|
||||
type MessageQueueItem = {
|
||||
vm: MessageVM
|
||||
$el: HTMLElement
|
||||
}
|
||||
|
||||
export type MessageQueue = Array<MessageQueueItem>
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { h, ComponentPublicInstance } from 'vue'
|
||||
import * as domExports from '../../utils/dom'
|
||||
import { EVENT_CODE } from '../../utils/aria'
|
||||
import { VueWrapper } from '@vue/test-utils'
|
||||
import { h, ComponentPublicInstance, nextTick } from 'vue'
|
||||
import * as domExports from '@element-plus/utils/dom'
|
||||
import { EVENT_CODE } from '@element-plus/utils/aria'
|
||||
import Notification from '../src/index.vue'
|
||||
import makeMount from '@element-plus/test-utils/make-mount'
|
||||
import { rAF } from '@element-plus/test-utils/tick'
|
||||
|
||||
const AXIOM = 'Rem is the best girl'
|
||||
|
||||
jest.useFakeTimers()
|
||||
|
||||
const _mount = (props: Record<string, unknown>) => {
|
||||
const onClose = jest.fn()
|
||||
return mount(Notification, {
|
||||
...props,
|
||||
props: {
|
||||
onClose,
|
||||
...props.props as Record<string, unknown>,
|
||||
},
|
||||
})
|
||||
}
|
||||
const onClose = jest.fn()
|
||||
|
||||
const _mount = makeMount(Notification, {
|
||||
props: {
|
||||
onClose,
|
||||
},
|
||||
})
|
||||
|
||||
describe('Notification.vue', () => {
|
||||
|
||||
describe('render', () => {
|
||||
test('basic render test', () => {
|
||||
const wrapper = _mount({
|
||||
@@ -28,20 +26,31 @@ describe('Notification.vue', () => {
|
||||
default: AXIOM,
|
||||
},
|
||||
})
|
||||
|
||||
const vm = wrapper.vm as ComponentPublicInstance<{
|
||||
visible: boolean
|
||||
typeClass: string
|
||||
horizontalClass: string
|
||||
positionStyle: Record<string, string>
|
||||
}>
|
||||
|
||||
expect(wrapper.text()).toEqual(AXIOM)
|
||||
expect(wrapper.vm.visible).toBe(true)
|
||||
expect(wrapper.vm.typeClass).toBe('')
|
||||
expect(wrapper.vm.horizontalClass).toBe('right')
|
||||
expect(wrapper.vm.verticalProperty).toBe('top')
|
||||
expect(wrapper.vm.positionStyle).toEqual({ top: '0px' })
|
||||
expect(vm.visible).toBe(true)
|
||||
expect(vm.typeClass).toBe('')
|
||||
expect(vm.horizontalClass).toBe('right')
|
||||
expect(vm.positionStyle).toEqual({ top: '0px' })
|
||||
})
|
||||
|
||||
test('should be able to render VNode', () => {
|
||||
const wrapper = _mount({
|
||||
slots: {
|
||||
default: h('span', {
|
||||
class: 'text-node',
|
||||
}, AXIOM),
|
||||
default: h(
|
||||
'span',
|
||||
{
|
||||
class: 'text-node',
|
||||
},
|
||||
AXIOM,
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -87,7 +96,6 @@ describe('Notification.vue', () => {
|
||||
})
|
||||
|
||||
test('should add event listener to target element when init', () => {
|
||||
|
||||
jest.spyOn(domExports, 'on')
|
||||
jest.spyOn(domExports, 'off')
|
||||
const wrapper = _mount({
|
||||
@@ -95,7 +103,7 @@ describe('Notification.vue', () => {
|
||||
default: AXIOM,
|
||||
},
|
||||
})
|
||||
expect(domExports.on).toHaveBeenCalledWith(document, 'keydown', wrapper.vm.keydown)
|
||||
expect(domExports.on).toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
expect(domExports.off).toHaveBeenCalled()
|
||||
})
|
||||
@@ -111,7 +119,9 @@ describe('Notification.vue', () => {
|
||||
type,
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.el-notification__icon').classes()).toContain(`el-icon-${type}`)
|
||||
expect(wrapper.find('.el-notification__icon').classes()).toContain(
|
||||
`el-icon-${type}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -123,7 +133,9 @@ describe('Notification.vue', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('.el-notification__icon').classes()).not.toContain(`el-icon-${type}`)
|
||||
expect(wrapper.find('.el-notification__icon').classes()).not.toContain(
|
||||
`el-icon-${type}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -136,28 +148,29 @@ describe('Notification.vue', () => {
|
||||
},
|
||||
props: { onClose },
|
||||
})
|
||||
await rAF()
|
||||
|
||||
const closeBtn = wrapper.find('.el-notification__closeBtn')
|
||||
expect(closeBtn.exists()).toBe(true)
|
||||
wrapper.vm.visible = false
|
||||
wrapper.vm.onClose()
|
||||
|
||||
await closeBtn.trigger('click')
|
||||
await rAF()
|
||||
await nextTick()
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should be able to close after duration', () => {
|
||||
test('should be able to close after duration', async () => {
|
||||
const duration = 100
|
||||
const wrapper = _mount({
|
||||
props: {
|
||||
duration,
|
||||
},
|
||||
})
|
||||
wrapper.vm.close = jest.fn()
|
||||
// jest.spyOn(wrapper.vm, 'close')
|
||||
expect(wrapper.vm.timer).not.toBe(null)
|
||||
expect(wrapper.vm.closed).toBe(false)
|
||||
const vm = wrapper.vm as ComponentPublicInstance<{ visible: boolean; }>
|
||||
|
||||
jest.runAllTimers()
|
||||
expect(wrapper.vm.close).toHaveBeenCalled()
|
||||
|
||||
await rAF()
|
||||
expect(vm.visible).toBe(false)
|
||||
})
|
||||
|
||||
test('should be able to prevent close itself when hover over', async () => {
|
||||
@@ -167,44 +180,52 @@ describe('Notification.vue', () => {
|
||||
duration,
|
||||
},
|
||||
})
|
||||
expect(wrapper.vm.timer).not.toBe(null)
|
||||
expect(wrapper.vm.closed).toBe(false)
|
||||
|
||||
const vm = wrapper.vm as ComponentPublicInstance<{ visible: boolean; }>
|
||||
|
||||
await rAF()
|
||||
await wrapper.find('[role=alert]').trigger('mouseenter')
|
||||
jest.runAllTimers()
|
||||
expect(wrapper.vm.timer).toBe(null)
|
||||
expect(wrapper.vm.closed).toBe(false)
|
||||
expect(vm.visible).toBe(true)
|
||||
await wrapper.find('[role=alert]').trigger('mouseleave')
|
||||
expect(wrapper.vm.timer).not.toBe(null)
|
||||
expect(wrapper.vm.closed).toBe(false)
|
||||
// expect(wrapper.vm.timer).not.toBe(null)
|
||||
expect(vm.visible).toBe(true)
|
||||
// expect(wrapper.vm.closed).toBe(false)
|
||||
jest.runAllTimers()
|
||||
expect(wrapper.vm.timer).toBe(null)
|
||||
expect(wrapper.vm.closed).toBe(true)
|
||||
expect(vm.visible).toBe(false)
|
||||
// expect(wrapper.vm.timer).toBe(null)
|
||||
// expect(wrapper.vm.closed).toBe(true)
|
||||
})
|
||||
|
||||
test('should not be able to close when duration is set to 0', () => {
|
||||
test('should not be able to close when duration is set to 0', async () => {
|
||||
const duration = 0
|
||||
const wrapper = _mount({
|
||||
props: {
|
||||
duration,
|
||||
},
|
||||
})
|
||||
expect(wrapper.vm.timer).toBe(null)
|
||||
expect(wrapper.vm.closed).toBe(false)
|
||||
|
||||
const vm = wrapper.vm as ComponentPublicInstance<{ visible: boolean; }>
|
||||
await rAF()
|
||||
expect(vm.visible).toBe(true)
|
||||
jest.runAllTimers()
|
||||
expect(wrapper.vm.timer).toBe(null)
|
||||
expect(wrapper.vm.closed).toBe(false)
|
||||
|
||||
await rAF()
|
||||
expect(vm.visible).toBe(true)
|
||||
})
|
||||
|
||||
test('should be able to handle click event', async () => {
|
||||
const onClick = jest.fn()
|
||||
const wrapper = _mount({
|
||||
props: {
|
||||
duration: 0,
|
||||
onClick: jest.fn(),
|
||||
onClick,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.trigger('click')
|
||||
expect(wrapper.vm.onClick).toHaveBeenCalledTimes(1)
|
||||
await rAF()
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('should be able to delete timer when press delete', async () => {
|
||||
@@ -214,6 +235,8 @@ describe('Notification.vue', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const vm = wrapper.vm as ComponentPublicInstance<{ visible: boolean; }>
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
code: EVENT_CODE.backspace,
|
||||
babels: true,
|
||||
@@ -221,8 +244,8 @@ describe('Notification.vue', () => {
|
||||
document.dispatchEvent(event)
|
||||
|
||||
jest.runOnlyPendingTimers()
|
||||
expect(wrapper.vm.closed).toBe(false)
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
await rAF()
|
||||
expect(vm.visible).toBe(true)
|
||||
})
|
||||
|
||||
test('should be able to close the notification immediately when press esc', async () => {
|
||||
@@ -231,18 +254,17 @@ describe('Notification.vue', () => {
|
||||
default: AXIOM,
|
||||
},
|
||||
})
|
||||
|
||||
const vm = wrapper.vm as ComponentPublicInstance<{ visible: boolean; }>
|
||||
// Same as above
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
code: EVENT_CODE.esc,
|
||||
// eslint-disable-next-line
|
||||
} as any)
|
||||
const oldClose = wrapper.vm.close
|
||||
wrapper.vm.close = jest.fn(() => oldClose())
|
||||
|
||||
document.dispatchEvent(event)
|
||||
jest.runAllTimers()
|
||||
expect(wrapper.vm.closed).toBe(true)
|
||||
expect(wrapper.vm.close).toHaveBeenCalledTimes(1)
|
||||
await rAF()
|
||||
expect(vm.visible).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,48 +1,73 @@
|
||||
import Notification, { close, closeAll } from '../src/notify'
|
||||
import { nextTick, h } from 'vue'
|
||||
import Notification, { closeAll } from '../src/notify'
|
||||
import { rAF } from '@element-plus/test-utils/tick'
|
||||
import type { INotificationHandle } from '../src/notification.type'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
jest.useFakeTimers()
|
||||
|
||||
const selector = '.el-notification'
|
||||
|
||||
describe('Notification on command', () => {
|
||||
|
||||
afterEach(() => {
|
||||
closeAll()
|
||||
})
|
||||
|
||||
test('it should get component handle', async () => {
|
||||
const handle = Notification()
|
||||
await rAF()
|
||||
expect(document.querySelector(selector)).toBeDefined()
|
||||
jest.runAllTicks()
|
||||
|
||||
handle.close()
|
||||
await rAF()
|
||||
await nextTick()
|
||||
expect(document.querySelector(selector)).toBeNull()
|
||||
expect(document.querySelector('[class^="container_notification"]')).toBeNull()
|
||||
})
|
||||
|
||||
test('it should be able to render vnode', async () => {
|
||||
const testClassName = 'test-classname'
|
||||
const { close } = Notification({
|
||||
duration: 0,
|
||||
message: h('div', { class: testClassName }, 'test-content'),
|
||||
})
|
||||
|
||||
await rAF()
|
||||
expect(document.querySelector(`.${testClassName}`)).toBeDefined()
|
||||
close()
|
||||
})
|
||||
|
||||
|
||||
test('it should be able to close notification by manually close', async () => {
|
||||
const { close } = Notification({
|
||||
duration: 0,
|
||||
})
|
||||
await rAF()
|
||||
|
||||
test('it should be able to close notification by manually close', () => {
|
||||
Notification()
|
||||
const element = document.querySelector(selector)
|
||||
expect(element).toBeDefined()
|
||||
close(element.id)
|
||||
close()
|
||||
await rAF()
|
||||
await nextTick()
|
||||
|
||||
expect(document.querySelector(selector)).toBeNull()
|
||||
})
|
||||
|
||||
test('it should close all notifications', () => {
|
||||
test('it should close all notifications', async () => {
|
||||
const notifications: INotificationHandle[] = []
|
||||
const onClose = jest.fn()
|
||||
for (let i = 0; i < 4; i++) {
|
||||
notifications.push(Notification({
|
||||
onClose,
|
||||
duration: 0,
|
||||
}))
|
||||
}
|
||||
// jest.runAllTicks()
|
||||
await rAF()
|
||||
|
||||
expect(document.querySelectorAll(selector).length).toBe(4)
|
||||
closeAll()
|
||||
for (let i = 0; i < notifications.length; i++) {
|
||||
expect(onClose).toHaveBeenCalledTimes(4)
|
||||
}
|
||||
// jest.runAllTicks()
|
||||
await rAF()
|
||||
expect(onClose).toHaveBeenCalledTimes(notifications.length)
|
||||
expect(document.querySelectorAll(selector).length).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<transition name="el-notification-fade" @after-leave="onClose">
|
||||
<transition name="el-notification-fade" @before-leave="onClose" @after-leave="$emit('destroy')">
|
||||
<div
|
||||
v-show="visible"
|
||||
:id="id"
|
||||
:class="['el-notification', customClass, horizontalClass]"
|
||||
:style="positionStyle"
|
||||
role="alert"
|
||||
@mouseenter="clearTimer()"
|
||||
@mouseleave="startTimer()"
|
||||
@click="click"
|
||||
@mouseenter="clearTimer"
|
||||
@mouseleave="startTimer"
|
||||
@click="onClick"
|
||||
>
|
||||
<i
|
||||
v-if="type || iconClass"
|
||||
@@ -38,11 +38,13 @@
|
||||
</transition>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref, PropType } from 'vue'
|
||||
import { defineComponent, computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
// notificationVM is an alias of vue.VNode
|
||||
import { EVENT_CODE } from '@element-plus/utils/aria'
|
||||
import { on, off } from '@element-plus/utils/dom'
|
||||
|
||||
import type { PropType } from 'vue'
|
||||
import type { NotificationVM } from './notification.type'
|
||||
import { EVENT_CODE } from '../../utils/aria'
|
||||
import { on, off } from '../../utils/dom'
|
||||
|
||||
const TypeMap: Indexable<string> = {
|
||||
success: 'success',
|
||||
@@ -81,8 +83,11 @@ export default defineComponent({
|
||||
type: { type: String, default: '' },
|
||||
zIndex: { type: Number, default: 0 },
|
||||
},
|
||||
emits: ['close', 'click'],
|
||||
emits: ['destroy'],
|
||||
|
||||
setup(props) {
|
||||
const visible = ref(false)
|
||||
let timer = null
|
||||
|
||||
const typeClass = computed(() => {
|
||||
const type = props.type
|
||||
@@ -103,71 +108,59 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
|
||||
const visible = ref(false)
|
||||
const closed = ref(false)
|
||||
const timer = ref(null)
|
||||
function startTimer() {
|
||||
if (props.duration > 0) {
|
||||
timer = setTimeout(() => {
|
||||
if (visible.value) {
|
||||
close()
|
||||
}
|
||||
}, props.duration)
|
||||
}
|
||||
}
|
||||
|
||||
function clearTimer() {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function onKeydown({ code }: KeyboardEvent) {
|
||||
if (code === EVENT_CODE.delete || code === EVENT_CODE.backspace) {
|
||||
clearTimer() // press delete/backspace clear timer
|
||||
} else if (code === EVENT_CODE.esc) {
|
||||
// press esc to close the notification
|
||||
if (visible.value) {
|
||||
close()
|
||||
}
|
||||
} else {
|
||||
startTimer() // resume timer
|
||||
}
|
||||
}
|
||||
|
||||
// lifecycle
|
||||
onMounted(() => {
|
||||
startTimer()
|
||||
visible.value = true
|
||||
on(document, 'keydown', onKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
off(document, 'keydown', onKeydown)
|
||||
})
|
||||
|
||||
return {
|
||||
horizontalClass,
|
||||
typeClass,
|
||||
positionStyle,
|
||||
verticalProperty,
|
||||
visible,
|
||||
closed,
|
||||
timer,
|
||||
|
||||
close,
|
||||
clearTimer,
|
||||
startTimer,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.duration > 0) {
|
||||
this.timer = setTimeout(() => {
|
||||
if (!this.closed) {
|
||||
this.close()
|
||||
}
|
||||
}, this.duration)
|
||||
}
|
||||
this.visible = true
|
||||
on(document, 'keydown', this.keydown)
|
||||
},
|
||||
beforeUnmount() {
|
||||
off(document, 'keydown', this.keydown)
|
||||
},
|
||||
methods: {
|
||||
// start counting down to destroy notification instance
|
||||
startTimer() {
|
||||
if (this.duration > 0) {
|
||||
this.timer = setTimeout(() => {
|
||||
if (!this.closed) {
|
||||
this.close()
|
||||
}
|
||||
}, this.duration)
|
||||
}
|
||||
},
|
||||
// clear timer
|
||||
clearTimer() {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
},
|
||||
// Event handlers
|
||||
click() {
|
||||
this?.onClick()
|
||||
},
|
||||
close() {
|
||||
this.visible = false
|
||||
this.closed = true
|
||||
this.timer = null
|
||||
},
|
||||
keydown({ code }: KeyboardEvent) {
|
||||
if (code === EVENT_CODE.delete || code === EVENT_CODE.backspace) {
|
||||
this.clearTimer() // press delete/backspace clear timer
|
||||
} else if (code === EVENT_CODE.esc) {
|
||||
// press esc to close the notification
|
||||
if (!this.closed) {
|
||||
this.close()
|
||||
}
|
||||
} else {
|
||||
this.startTimer() // resume timer
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { VNode } from 'vue'
|
||||
|
||||
|
||||
export type Position = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||
export interface INotificationHandle {
|
||||
close: () => void
|
||||
}
|
||||
@@ -18,7 +19,7 @@ export type INotificationOptions = {
|
||||
onClose?: () => void
|
||||
onClick?: () => void
|
||||
offset?: number // defaults 0
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' // default top-right
|
||||
position?: Position // default top-right
|
||||
showClose?: boolean
|
||||
type?: 'success' | 'warning' | 'info' | 'error' | ''
|
||||
title?: string
|
||||
@@ -28,7 +29,6 @@ export type NotificationVM = VNode
|
||||
|
||||
type NotificationQueueItem = {
|
||||
vm: NotificationVM
|
||||
$el: HTMLElement
|
||||
}
|
||||
|
||||
export type NotificationQueue = Array<NotificationQueueItem>
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import { createVNode, render, nextTick } from 'vue'
|
||||
import { createVNode, render } from 'vue'
|
||||
import NotificationConstructor from './index.vue'
|
||||
import type { INotificationOptions, INotification, NotificationQueue, NotificationVM } from './notification.type'
|
||||
import isServer from '../../utils/isServer'
|
||||
import PopupManager from '../../utils/popup-manager'
|
||||
import { isVNode } from '../../utils/util'
|
||||
import isServer from '@element-plus/utils/isServer'
|
||||
import PopupManager from '@element-plus/utils/popup-manager'
|
||||
import { isVNode } from '@element-plus/utils/util'
|
||||
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import type {
|
||||
INotificationOptions,
|
||||
INotification,
|
||||
NotificationQueue,
|
||||
NotificationVM,
|
||||
Position,
|
||||
} from './notification.type'
|
||||
|
||||
// This should be a queue but considering there were `non-autoclosable` notifications.
|
||||
const notifications: Record<Position, NotificationQueue> = {
|
||||
'top-left': [],
|
||||
'top-right': [],
|
||||
'bottom-left': [],
|
||||
'bottom-right': [],
|
||||
}
|
||||
|
||||
let vm: NotificationVM
|
||||
const notifications: NotificationQueue = []
|
||||
let seed = 1
|
||||
|
||||
const Notification: INotification = function(options = {}) {
|
||||
const Notification: INotification = function(options = { }) {
|
||||
if (isServer) return
|
||||
const position = options.position || 'top-right'
|
||||
|
||||
let verticalOffset = options.offset || 0
|
||||
notifications
|
||||
.filter(({ vm }) => vm.component.props.position === position)
|
||||
notifications[position]
|
||||
.forEach(({ vm }) => {
|
||||
verticalOffset += (vm.el.offsetHeight || 0) + 16
|
||||
})
|
||||
@@ -24,14 +37,10 @@ const Notification: INotification = function(options = {}) {
|
||||
const id = 'notification_' + seed++
|
||||
const userOnClose = options.onClose
|
||||
options = {
|
||||
dangerouslyUseHTMLString: false,
|
||||
duration: 4500,
|
||||
position: 'top-right',
|
||||
showClose: true,
|
||||
// default options end
|
||||
...options,
|
||||
onClose: () => {
|
||||
close(id, userOnClose)
|
||||
close(id, position, userOnClose)
|
||||
},
|
||||
offset: verticalOffset,
|
||||
id,
|
||||
@@ -40,10 +49,7 @@ const Notification: INotification = function(options = {}) {
|
||||
|
||||
const container = document.createElement('div')
|
||||
|
||||
container.className = `container_${id}`
|
||||
container.style.zIndex = String()
|
||||
|
||||
vm = createVNode(
|
||||
const vm = createVNode(
|
||||
NotificationConstructor,
|
||||
options,
|
||||
isVNode(options.message)
|
||||
@@ -52,16 +58,27 @@ const Notification: INotification = function(options = {}) {
|
||||
}
|
||||
: null,
|
||||
)
|
||||
|
||||
// clean notification element preventing mem leak
|
||||
vm.props.onDestroy = () => {
|
||||
render(null, container)
|
||||
}
|
||||
|
||||
// instances will remove this item when close function gets called. So we do not need to worry about it.
|
||||
render(vm, container)
|
||||
notifications.push({ vm, $el: container })
|
||||
document.body.appendChild(container)
|
||||
notifications[position].push({ vm })
|
||||
document.body.appendChild(container.firstElementChild)
|
||||
|
||||
return {
|
||||
close: options.onClose,
|
||||
// instead of calling the onClose function directly, setting this value so that we can have the full lifecycle
|
||||
// for out component, so that all closing steps will not be skipped.
|
||||
close: () => {
|
||||
(vm.component.proxy as ComponentPublicInstance<{visible: boolean;}>).visible = false
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
(['success', 'warning', 'info', 'error'] as const).forEach(type => {
|
||||
;(['success', 'warning', 'info', 'error'] as const).forEach(type => {
|
||||
Object.assign(Notification, {
|
||||
[type]: (options: NotificationVM | INotificationOptions | string = {}) => {
|
||||
if (typeof options === 'string' || isVNode(options)) {
|
||||
@@ -75,54 +92,61 @@ const Notification: INotification = function(options = {}) {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* This function gets called when user click `x` button or press `esc` or the time reached its limitation.
|
||||
* Emitted by transition@before-leave event so that we can fetch the current notification.offsetHeight, if this was called
|
||||
* by @after-leave the DOM element will be removed from the page thus we can no longer fetch the offsetHeight.
|
||||
* @param {String} id notification id to be closed
|
||||
* @param {Position} position the positioning strategy
|
||||
* @param {Function} userOnClose the callback called when close passed by user
|
||||
*/
|
||||
export function close(
|
||||
id: string,
|
||||
position: Position,
|
||||
userOnClose?: (vm: NotificationVM) => void,
|
||||
): void {
|
||||
const idx = notifications.findIndex(({ vm }) => {
|
||||
// maybe we can store the index when inserting the vm to notification list.
|
||||
const orientedNotifications = notifications[position]
|
||||
const idx = orientedNotifications.findIndex(({ vm }) => {
|
||||
const { id: _id } = vm.component.props
|
||||
return id === _id
|
||||
})
|
||||
|
||||
if (idx === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const { vm, $el } = notifications[idx]
|
||||
const { vm } = orientedNotifications[idx]
|
||||
if (!vm) return
|
||||
// calling user's on close function before notification gets removed from DOM.
|
||||
userOnClose?.(vm)
|
||||
|
||||
// note that this is called @before-leave, that's why we were able to fetch this property.
|
||||
const removedHeight = vm.el.offsetHeight
|
||||
render(null, $el)
|
||||
|
||||
notifications.splice(idx, 1)
|
||||
const len = notifications.length
|
||||
nextTick(() => {
|
||||
document.body.removeChild($el)
|
||||
})
|
||||
orientedNotifications.splice(idx, 1)
|
||||
const len = orientedNotifications.length
|
||||
if (len < 1) return
|
||||
const position = vm.props.position
|
||||
// starting from the removing item.
|
||||
for (let i = idx; i < len; i++) {
|
||||
if (notifications[i].vm.component.props.position === position) {
|
||||
const verticalPos = vm.props.position.split('-')[0]
|
||||
const pos = parseInt(
|
||||
notifications[i].vm.el.style[verticalPos],
|
||||
10,
|
||||
) -
|
||||
const verticalPos = position.split('-')[0]
|
||||
// new position equals the current offsetTop minus removed height plus 16px(the gap size between each item)
|
||||
const pos =
|
||||
parseInt(orientedNotifications[i].vm.el.style[verticalPos], 10) -
|
||||
removedHeight -
|
||||
16
|
||||
|
||||
notifications[i].vm.component.props.offset = pos
|
||||
requestAnimationFrame(() => {
|
||||
render(notifications[i].vm, notifications[i].$el)
|
||||
})
|
||||
// .vm.el.style[verticalPos] = pos
|
||||
}
|
||||
orientedNotifications[i].vm.component.props.offset = pos
|
||||
}
|
||||
}
|
||||
|
||||
export function closeAll(): void {
|
||||
for (let i = notifications.length - 1; i >= 0; i--) {
|
||||
(notifications[i].vm.component.props as INotificationOptions).onClose()
|
||||
// loop through all directions, close them at once.
|
||||
for (const key in notifications) {
|
||||
const orientedNotifications = notifications[key as Position]
|
||||
orientedNotifications.forEach(({ vm }) => {
|
||||
// same as the previous close method, we'd like to make sure lifecycle gets handle properly.
|
||||
(vm.component.proxy as ComponentPublicInstance<{ visible: boolean; }>).visible = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user