refactor: Refactor message component with VCA (#1387)

This commit is contained in:
jeremywu
2021-01-31 18:43:33 +08:00
committed by GitHub
parent 11d40fa791
commit d2ec0b9abb
10 changed files with 385 additions and 329 deletions

View File

@@ -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)

View File

@@ -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)
})
})

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -39,7 +39,6 @@ export type MessageVM = VNode
type MessageQueueItem = {
vm: MessageVM
$el: HTMLElement
}
export type MessageQueue = Array<MessageQueueItem>

View File

@@ -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)
})
})
})

View File

@@ -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)
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
})
}
}