From d2ec0b9abbb33dee529de09df9b5df1847713241 Mon Sep 17 00:00:00 2001 From: jeremywu <15975785+JeremyWuuuuu@users.noreply.github.com> Date: Sun, 31 Jan 2021 18:43:33 +0800 Subject: [PATCH] refactor: Refactor message component with VCA (#1387) --- .../message/__tests__/message-manager.spec.ts | 44 +++--- packages/message/__tests__/message.spec.ts | 93 ++++++------ packages/message/src/index.vue | 107 +++++++------- packages/message/src/message.ts | 33 +++-- packages/message/src/types.ts | 1 - .../__tests__/notification.spec.ts | 138 ++++++++++-------- .../notification/__tests__/notify.spec.ts | 49 +++++-- packages/notification/src/index.vue | 125 ++++++++-------- .../notification/src/notification.type.ts | 4 +- packages/notification/src/notify.ts | 120 +++++++++------ 10 files changed, 385 insertions(+), 329 deletions(-) diff --git a/packages/message/__tests__/message-manager.spec.ts b/packages/message/__tests__/message-manager.spec.ts index c872ff8941..79cda85b87 100644 --- a/packages/message/__tests__/message-manager.spec.ts +++ b/packages/message/__tests__/message-manager.spec.ts @@ -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 +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) diff --git a/packages/message/__tests__/message.spec.ts b/packages/message/__tests__/message.spec.ts index ac79397a09..91582dd4d8 100644 --- a/packages/message/__tests__/message.spec.ts +++ b/packages/message/__tests__/message.spec.ts @@ -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) => { - const onClose = jest.fn() - return mount(Message, { - ...props, - props: { - onClose, - ...(props.props as Record), - }, - }) -} +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) }) }) diff --git a/packages/message/src/index.vue b/packages/message/src/index.vue index e3752a171a..48b13aad99 100644 --- a/packages/message/src/index.vue +++ b/packages/message/src/index.vue @@ -1,5 +1,5 @@ diff --git a/packages/message/src/message.ts b/packages/message/src/message.ts index bf23394522..3f28cada69 100644 --- a/packages/message/src/message.ts +++ b/packages/message/src/message.ts @@ -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 } } diff --git a/packages/message/src/types.ts b/packages/message/src/types.ts index f16f4a120a..67ad24626e 100644 --- a/packages/message/src/types.ts +++ b/packages/message/src/types.ts @@ -39,7 +39,6 @@ export type MessageVM = VNode type MessageQueueItem = { vm: MessageVM - $el: HTMLElement } export type MessageQueue = Array diff --git a/packages/notification/__tests__/notification.spec.ts b/packages/notification/__tests__/notification.spec.ts index ff0094d704..ecc2cb8758 100644 --- a/packages/notification/__tests__/notification.spec.ts +++ b/packages/notification/__tests__/notification.spec.ts @@ -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) => { - const onClose = jest.fn() - return mount(Notification, { - ...props, - props: { - onClose, - ...props.props as Record, - }, - }) -} +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 + }> + 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) }) }) }) diff --git a/packages/notification/__tests__/notify.spec.ts b/packages/notification/__tests__/notify.spec.ts index c12e4b535d..9bf9fc8504 100644 --- a/packages/notification/__tests__/notify.spec.ts +++ b/packages/notification/__tests__/notify.spec.ts @@ -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) }) diff --git a/packages/notification/src/index.vue b/packages/notification/src/index.vue index fb06521801..42692de93d 100644 --- a/packages/notification/src/index.vue +++ b/packages/notification/src/index.vue @@ -1,14 +1,14 @@ diff --git a/packages/notification/src/notification.type.ts b/packages/notification/src/notification.type.ts index 140f866df9..2e6ff4044f 100644 --- a/packages/notification/src/notification.type.ts +++ b/packages/notification/src/notification.type.ts @@ -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 diff --git a/packages/notification/src/notify.ts b/packages/notification/src/notify.ts index fe3d90d67e..6053731ef8 100644 --- a/packages/notification/src/notify.ts +++ b/packages/notification/src/notify.ts @@ -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 = { + '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 + }) } }