From 25d6bd4667813c84b8c4bba181d9f1b5b2d6fa96 Mon Sep 17 00:00:00 2001 From: JeremyWuuuuu <591449570@qq.com> Date: Tue, 4 Aug 2020 19:03:20 +0800 Subject: [PATCH] feat(notification): add new component notification --- .gitignore | 1 + jest.config.js | 3 + package.json | 1 + packages/element-plus/index.ts | 3 + packages/element-plus/package.json | 3 +- .../__tests__/notification.spec.ts | 291 ++++++++++++++++++ .../notification/__tests__/notify.spec.ts | 37 +++ packages/notification/doc/closable.vue | 34 ++ packages/notification/doc/index.stories.ts | 7 + packages/notification/doc/standealone.vue | 54 ++++ packages/notification/doc/vnode.vue | 35 +++ packages/notification/index.ts | 8 + packages/notification/package.json | 12 + packages/notification/src/index.vue | 190 ++++++++++++ .../src/notification.constants.ts | 53 ++++ packages/notification/src/notify.ts | 128 ++++++++ packages/utils/aria.ts | 2 + packages/utils/dom.ts | 53 ++-- packages/utils/isDef.ts | 3 - packages/utils/popup-manager.ts | 237 ++++++++++++++ packages/utils/popup.ts | 1 + typings/vue-shim.d.ts | 4 + yarn.lock | 5 + 23 files changed, 1133 insertions(+), 32 deletions(-) create mode 100644 packages/notification/__tests__/notification.spec.ts create mode 100644 packages/notification/__tests__/notify.spec.ts create mode 100644 packages/notification/doc/closable.vue create mode 100644 packages/notification/doc/index.stories.ts create mode 100644 packages/notification/doc/standealone.vue create mode 100644 packages/notification/doc/vnode.vue create mode 100644 packages/notification/index.ts create mode 100644 packages/notification/package.json create mode 100644 packages/notification/src/index.vue create mode 100644 packages/notification/src/notification.constants.ts create mode 100644 packages/notification/src/notify.ts create mode 100644 packages/utils/popup-manager.ts create mode 100644 packages/utils/popup.ts diff --git a/.gitignore b/.gitignore index 74ea0faeb43a8..95761c17c658c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ lerna-debug.json lerna-debug.log yarn-error.log storybook-static +coverage/ \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index 55212076121c5..55ad3199f64c2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,5 +12,8 @@ module.exports = { '^.+\\.vue$': 'vue-jest', '^.+\\.(t|j)sx?$': ['@swc-node/jest'], }, + moduleNameMapper: { + '^lodash-es$': 'lodash', + }, moduleFileExtensions: ['vue', 'json', 'ts', 'tsx', 'js', 'json'], } diff --git a/package.json b/package.json index 8a08d3d0b6a46..822eeb8134bea 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "repository": "git@github.com:element-plus/element-plus.git", "license": "MIT", "dependencies": { + "@popperjs/core": "^2.4.4", "lodash-es": "^4.17.15" } } diff --git a/packages/element-plus/index.ts b/packages/element-plus/index.ts index 8c3ce072d5f7a..b4adeae233a57 100644 --- a/packages/element-plus/index.ts +++ b/packages/element-plus/index.ts @@ -14,6 +14,7 @@ import ElLink from '@element-plus/link' import ElRate from '@element-plus/rate' import ElSwitch from '@element-plus/switch' import ElContainer from '@element-plus/container' +import ElNotification from '@element-plus/notification' export { ElAvatar, @@ -31,6 +32,7 @@ export { ElRate, ElSwitch, ElContainer, + ElNotification, } export default function install(app: App): void { @@ -49,4 +51,5 @@ export default function install(app: App): void { ElRate(app) ElSwitch(app) ElContainer(app) + ElNotification(app) } diff --git a/packages/element-plus/package.json b/packages/element-plus/package.json index fa77935e1dd43..7c77e61268002 100644 --- a/packages/element-plus/package.json +++ b/packages/element-plus/package.json @@ -30,6 +30,7 @@ "@element-plus/rate": "^0.0.0", "@element-plus/breadcrumb": "^0.0.0", "@element-plus/icon": "^0.0.0", - "@element-plus/switch": "^0.0.0" + "@element-plus/switch": "^0.0.0", + "@element-plus/notification": "^0.0.0" } } diff --git a/packages/notification/__tests__/notification.spec.ts b/packages/notification/__tests__/notification.spec.ts new file mode 100644 index 0000000000000..e6bb3e9c1c8f1 --- /dev/null +++ b/packages/notification/__tests__/notification.spec.ts @@ -0,0 +1,291 @@ +import { mount } from '@vue/test-utils' +import { h } from 'vue' +import * as domExports from '../../utils/dom' +import { eventKeys } from '../../utils/aria' +import Notification from '../src/index.vue' + +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, + }, + }) +} + +describe('Notification.vue', () => { + + describe('render', () => { + test('basic render test', () => { + const wrapper = _mount({ + slots: { + default: AXIOM, + }, + }) + 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: 0 }) + }) + + test('should be able to render VNode', () => { + const wrapper = _mount({ + slots: { + default: h('span', { + class: 'text-node', + }, AXIOM), + }, + }) + + expect(wrapper.find('.text-node').exists()).toBe(true) + }) + + test('should be able to render raw HTML tag with dangerouslyUseHTMLString flag', () => { + const tagClass = 'test-class' + const HTMLWrapper = _mount({ + props: { + dangerouslyUseHTMLString: true, + message: `${AXIOM}`, + }, + }) + + expect(HTMLWrapper.find(`.${tagClass}`).exists()).toBe(true) + }) + + test('should not be able to render raw HTML tag without dangerouslyUseHTMLString flag', () => { + const tagClass = 'test-class' + const HTMLWrapper = _mount({ + props: { + dangerouslyUseHTMLString: false, + message: `${AXIOM}`, + }, + }) + + expect(HTMLWrapper.find(`.${tagClass}`).exists()).toBe(false) + }) + }) + + describe('lifecycle', () => { + let onMock + let offMock + beforeEach(() => { + onMock = jest.spyOn(domExports, 'on').mockReset() + offMock = jest.spyOn(domExports, 'off').mockReset() + }) + + afterEach(() => { + onMock.mockRestore() + offMock.mockRestore() + }) + + test('should call init function when it\'s provided', () => { + const _init = jest.fn() + const wrapper = _mount({ + slots: { + default: AXIOM, + }, + props: { + _init, + _idx: 0, + }, + }) + expect(_init).toHaveBeenCalled() + wrapper.unmount() + }) + + test('should add event listener to target element when init', () => { + + jest.spyOn(domExports, 'on') + jest.spyOn(domExports, 'off') + const wrapper = _mount({ + slots: { + default: AXIOM, + }, + }) + expect(domExports.on).toHaveBeenCalledWith(document, 'keydown', wrapper.vm.keydown) + wrapper.unmount() + expect(domExports.off).toHaveBeenCalled() + }) + }) + + describe('Notification.type', () => { + test('should be able to render success notification', () => { + const type = 'success' + const wrapper = _mount({ + props: { + type, + }, + }) + + expect(wrapper.find(`.el-icon-${type}`).exists()).toBe(true) + }) + + test('should be able to render warning notification', () => { + const type = 'warning' + const wrapper = _mount({ + props: { + type, + }, + }) + + expect(wrapper.find(`.el-icon-${type}`).exists()).toBe(true) + }) + + test('should be able to render info notification', () => { + const type = 'info' + const wrapper = _mount({ + props: { + type, + }, + }) + + expect(wrapper.find(`.el-icon-${type}`).exists()).toBe(true) + }) + + test('should be able to render error notification', () => { + const type = 'error' + const wrapper = _mount({ + props: { + type, + }, + }) + + expect(wrapper.find(`.el-icon-${type}`).exists()).toBe(true) + }) + + test('should not be able to render invalid type icon', () => { + const type = 'some-type' + const wrapper = _mount({ + props: { + type, + }, + }) + + expect(wrapper.find(`.el-icon-${type}`).exists()).toBe(false) + }) + }) + + describe('event handlers', () => { + test('it should be able to close the notification by clicking close button', async () => { + const onClose = jest.fn() + const wrapper = _mount({ + slots: { + default: AXIOM, + }, + props: { onClose }, + }) + + const closeBtn = wrapper.find('.el-notification__closeBtn') + expect(closeBtn.exists()).toBe(true) + await closeBtn.trigger('click') + expect(onClose).toHaveBeenCalled() + }) + + test('should be able to close after duration', () => { + 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) + jest.runAllTimers() + expect(wrapper.vm.close).toHaveBeenCalled() + }) + + test('should be able to prevent close itself when hover over', async () => { + const duration = 100 + const wrapper = _mount({ + props: { + duration, + }, + }) + expect(wrapper.vm.timer).not.toBe(null) + expect(wrapper.vm.closed).toBe(false) + await wrapper.find('[role=alert]').trigger('mouseenter') + jest.runAllTimers() + expect(wrapper.vm.timer).toBe(null) + expect(wrapper.vm.closed).toBe(false) + await wrapper.find('[role=alert]').trigger('mouseleave') + expect(wrapper.vm.timer).not.toBe(null) + expect(wrapper.vm.closed).toBe(false) + jest.runAllTimers() + 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', () => { + const duration = 0 + const wrapper = _mount({ + props: { + duration, + }, + }) + expect(wrapper.vm.timer).toBe(null) + expect(wrapper.vm.closed).toBe(false) + jest.runAllTimers() + expect(wrapper.vm.timer).toBe(null) + expect(wrapper.vm.closed).toBe(false) + }) + + test('should be able to handle click event', async () => { + const wrapper = _mount({ + props: { + duration: 0, + }, + }) + await wrapper.trigger('click') + expect(wrapper.emitted('click')).toHaveLength(1) + }) + + test('should be able to delete timer when press delete', async () => { + const wrapper = _mount({ + slots: { + default: AXIOM, + }, + }) + + // disable eslint to allow us using any, due to the lack of KeyboardEventInit member `keyCode` + // see https://github.com/Microsoft/TypeScript/issues/15228 + const event = new KeyboardEvent('keydown', { + keyCode: eventKeys.backspace, + babels: true, + // eslint-disable-next-line + } as any) + document.dispatchEvent(event) + + jest.runOnlyPendingTimers() + expect(wrapper.vm.closed).toBe(false) + expect(wrapper.emitted('close')).toBeUndefined() + }) + + test('should be able to close the notification immediately when press esc', async () => { + const wrapper = _mount({ + slots: { + default: AXIOM, + }, + }) + + // Same as above + const event = new KeyboardEvent('keydown', { + keyCode: eventKeys.esc, + // eslint-disable-next-line + } as any) + document.dispatchEvent(event) + jest.runAllTimers() + expect(wrapper.vm.closed).toBe(true) + expect(wrapper.emitted('close')).toHaveLength(1) + }) + }) +}) diff --git a/packages/notification/__tests__/notify.spec.ts b/packages/notification/__tests__/notify.spec.ts new file mode 100644 index 0000000000000..cffbb4985308a --- /dev/null +++ b/packages/notification/__tests__/notify.spec.ts @@ -0,0 +1,37 @@ +import Notification, { close, closeAll } from '../src/notify' + +jest.useFakeTimers() + +const selector = '.el-notification' + +describe('Notification on command', () => { + afterEach(() => { + closeAll() + }) + + test('it should get component instance when calling notification constructor', async () => { + const vm = Notification({}) + expect(vm).toBeNull() + expect(document.querySelector(selector)).toBeDefined() + jest.runAllTicks() + }) + + + test('it should be able to close notification by manually close', () => { + Notification({}) + const element = document.querySelector(selector) + expect(element).toBeDefined() + close(element.id) + expect(document.querySelector(selector)).toBeNull() + }) + + test('it should close all notifications', () => { + for (let i = 0; i < 4; i++) { + Notification({}) + } + expect(document.querySelectorAll(selector).length).toBe(4) + closeAll() + expect(document.querySelectorAll(selector).length).toBe(0) + }) + +}) diff --git a/packages/notification/doc/closable.vue b/packages/notification/doc/closable.vue new file mode 100644 index 0000000000000..80e32d897b4c5 --- /dev/null +++ b/packages/notification/doc/closable.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/packages/notification/doc/index.stories.ts b/packages/notification/doc/index.stories.ts new file mode 100644 index 0000000000000..b4e29a0ccb23f --- /dev/null +++ b/packages/notification/doc/index.stories.ts @@ -0,0 +1,7 @@ +export default { + title: 'Notification', +} + +export { default as BasicNotification } from './standealone.vue' +export { default as AdvancedNotification } from './vnode.vue' +export { default as Closable } from './closable.vue' diff --git a/packages/notification/doc/standealone.vue b/packages/notification/doc/standealone.vue new file mode 100644 index 0000000000000..1a5ec4fae6ba0 --- /dev/null +++ b/packages/notification/doc/standealone.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/notification/doc/vnode.vue b/packages/notification/doc/vnode.vue new file mode 100644 index 0000000000000..ea66b70f883ab --- /dev/null +++ b/packages/notification/doc/vnode.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/packages/notification/index.ts b/packages/notification/index.ts new file mode 100644 index 0000000000000..a2b3d38bc6edb --- /dev/null +++ b/packages/notification/index.ts @@ -0,0 +1,8 @@ +import { App } from 'vue' +import Notification from './src/index.vue' +import Notify from './src/notify' +export default (app: App): void => { + app.component(Notification.name, Notification) + app.config.globalProperties.$notify = Notify + console.log(app.config.globalProperties) +} diff --git a/packages/notification/package.json b/packages/notification/package.json new file mode 100644 index 0000000000000..f8a72ae39b3bc --- /dev/null +++ b/packages/notification/package.json @@ -0,0 +1,12 @@ +{ + "name": "@element-plus/notification", + "version": "0.0.0", + "main": "dist/index.js", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.0-rc.1" + }, + "devDependencies": { + "@vue/test-utils": "^2.0.0-beta.0" + } +} diff --git a/packages/notification/src/index.vue b/packages/notification/src/index.vue new file mode 100644 index 0000000000000..b05ccccf4a725 --- /dev/null +++ b/packages/notification/src/index.vue @@ -0,0 +1,190 @@ + + diff --git a/packages/notification/src/notification.constants.ts b/packages/notification/src/notification.constants.ts new file mode 100644 index 0000000000000..88e4c612ad527 --- /dev/null +++ b/packages/notification/src/notification.constants.ts @@ -0,0 +1,53 @@ +import type { ComponentPublicInstance, VNode } from 'vue' + +export interface INotification

void> { + (options: INotificationOptions): void + success?: P + warning?: P + info?: P + error?: P +} +export type INotificationOptions = { + customClass?: string + dangerouslyUseHTMLString?: boolean // default false + duration?: number // default 4500 + iconClass?: string + id?: string + message?: string | VNode + onClose?: () => unknown + onClick?: () => unknown + offset?: number // defaults 0 + position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' // default top-right + showClose?: boolean + title?: string + type?: 'success' | 'warning' | 'info' | 'error' + _idx?: number + _init?: (idx: number, vm: NotificationVM) => void +} + +export type NotificationVM = ComponentPublicInstance + +export type NotificationQueue = Array<{ + vm: Nullable + container: HTMLElement +}> + + +export const defaultProps = { + visible: false, + title: '', + message: '', + duration: 4500, + type: '', + showClose: true, + customClass: '', + iconClass: '', + onClose: null, + onClick: null, + closed: false, + verticalOffset: 0, + timer: null, + dangerouslyUseHTMLString: false, + position: 'top-right', +} + diff --git a/packages/notification/src/notify.ts b/packages/notification/src/notify.ts new file mode 100644 index 0000000000000..c80f24bf39e88 --- /dev/null +++ b/packages/notification/src/notify.ts @@ -0,0 +1,128 @@ +import { createApp, h, reactive } from 'vue' +import NotificationConstructor from './index.vue' +import type { INotificationOptions, INotification, NotificationQueue, NotificationVM } from './notification.constants' +import isServer from '../../utils/isServer' +import PopupManager from '../../utils/popup-manager' +import { isVNode } from '../../utils/util' + +const NotificationRenderer = (props: Record) => { + if (isVNode(props.message)) { + return h(NotificationConstructor, props, { default: () => h(props.message) }) + } + return h(NotificationConstructor, props) +} + +let vm: NotificationVM +const notifications: NotificationQueue = [] +let seed = 1 + +const Notification: INotification = function(options: INotificationOptions): NotificationVM { + if (isServer) return + const id = 'notification_' + seed++ + const userOnClose = options.onClose + options.onClose = function() { + close(id, userOnClose) + } + const defaultOptions: INotificationOptions = { + dangerouslyUseHTMLString: false, + duration: 4500, + position: 'top-right', + showClose: true, + offset: 0, + _idx: notifications.length, + _init: function(idx: number, vm: NotificationVM): void { + obtainInstance(idx, vm) + let verticalOffset = options.offset || 0 + notifications + .filter(item => item.vm.$props.position === position) + .forEach(({ vm }) => { + verticalOffset += (vm.$el.offsetHeight || 0) + 16 + }) + verticalOffset += 16 + this.offset = verticalOffset + }, + } + options = { + ...defaultOptions, + ...options, + id, + } + + options = reactive(options) + + const position = options.position || 'top-right' + + const container = document.createElement('div') + container.className = `container_${id}` + container.style.zIndex = String(PopupManager.nextZIndex()) + notifications.push({ vm: null, container }) + vm = createApp(NotificationRenderer, { ...options }).mount( + container, + ) as NotificationVM + + document.body.appendChild(container) + + return vm +}; + +['success', 'warning', 'info', 'error'].forEach(type => { + Notification[type] = options => { + if (typeof options === 'string' || isVNode(options)) { + options = { + message: options, + } + } + options.type = type + return Notification(options) + } +}) + +export function close( + id: string, + userOnClose?: (vm: NotificationVM) => void, +): void { + const idx = notifications.findIndex(i => { + const { id: _id } = i.vm.$props + return id === _id + }) + if (idx === -1) { + return + } + + const notification = notifications[idx] + const { vm } = notification + if (!vm) return + userOnClose?.(vm) + const removedHeight = vm.$el.offsetHeight + document.body.removeChild(notification.container) + notification.container = null + + notifications.splice(idx, 1) + const len = notifications.length + if (len < 1) return + const position = vm.$props.position + for (let i = idx; i < len; i++) { + if (notifications[i].vm.$props.position === position) { + notifications[i].vm.$el.style[vm.verticalProperty] = + parseInt( + notifications[i].vm.$el.style[vm.verticalProperty], + 10, + ) - + removedHeight - + 16 + + 'px' + } + } +} + +export function closeAll(): void { + for (let i = notifications.length - 1; i >= 0; i--) { + notifications[i].vm.onClose() + } +} + +function obtainInstance(idx: number, vm: NotificationVM): void { + notifications[idx].vm = vm +} + +export default Notification diff --git a/packages/utils/aria.ts b/packages/utils/aria.ts index bf7350684dcc2..59024d38e1862 100644 --- a/packages/utils/aria.ts +++ b/packages/utils/aria.ts @@ -7,6 +7,8 @@ export const eventKeys = { right: 39, down: 40, esc: 27, + backspace: 8, + delete: 46, } /** diff --git a/packages/utils/dom.ts b/packages/utils/dom.ts index adea236984432..d9fec1c3ac95b 100644 --- a/packages/utils/dom.ts +++ b/packages/utils/dom.ts @@ -17,35 +17,26 @@ const camelCase = function(name: string) { } /* istanbul ignore next */ -export const on = (function() { - // Since Vue3 does not support < IE11, we don't need to support it as well. - if (!isServer) { - return function( - element: HTMLElement | Document, - event: string, - handler: EventListenerOrEventListenerObject, - ) { - if (element && event && handler) { - element.addEventListener(event, handler, false) - } - } +export const on = function( + element: HTMLElement | Document | Window, + event: string, + handler: EventListenerOrEventListenerObject, +): void { + if (element && event && handler) { + element.addEventListener(event, handler, false) } -})() +} /* istanbul ignore next */ -export const off = (function() { - if (!isServer) { - return function( - element: HTMLElement, - event: string, - handler: EventListenerOrEventListenerObject, - ) { - if (element && event) { - element.removeEventListener(event, handler, false) - } - } +export const off = function( + element: HTMLElement | Document | Window, + event: string, + handler: EventListenerOrEventListenerObject, +): void { + if (element && event && handler) { + element.removeEventListener(event, handler, false) } -})() +} /* istanbul ignore next */ export const once = function( @@ -118,7 +109,7 @@ export function removeClass(el: HTMLElement, cls: string): void { /* istanbul ignore next */ // Here I want to use the type CSSStyleDeclaration, but the definition for CSSStyleDeclaration -// has { [index: number]: string } in its type annotation, which does not satisfiy the method +// has { [index: number]: string } in its type annotation, which does not satisfy the method // camelCase(s: string) // Same as the return type export const getStyle = function( @@ -160,7 +151,10 @@ export function setStyle( } } -export const isScroll = (el: HTMLElement, isVertical?: Nullable): RegExpMatchArray => { +export const isScroll = ( + el: HTMLElement, + isVertical?: Nullable, +): RegExpMatchArray => { if (isServer) return const determinedDirection = isVertical !== null || isVertical !== undefined @@ -193,7 +187,10 @@ export const getScrollContainer = ( return parent } -export const isInContainer = (el: HTMLElement, container: HTMLElement): boolean => { +export const isInContainer = ( + el: HTMLElement, + container: HTMLElement, +): boolean => { if (isServer || !el || !container) return false const elRect = el.getBoundingClientRect() diff --git a/packages/utils/isDef.ts b/packages/utils/isDef.ts index a1ca8a624a710..2ab4d41867a9f 100644 --- a/packages/utils/isDef.ts +++ b/packages/utils/isDef.ts @@ -1,6 +1,3 @@ -export function isDef(val: unknown): boolean { - return val !== undefined && val !== null -} export function isKorean(text: string): boolean { const reg = /([(\uAC00-\uD7AF)|(\u3130-\u318F)])+/gi return reg.test(text) diff --git a/packages/utils/popup-manager.ts b/packages/utils/popup-manager.ts new file mode 100644 index 0000000000000..b622e1dacabaa --- /dev/null +++ b/packages/utils/popup-manager.ts @@ -0,0 +1,237 @@ +import { ComponentPublicInstance } from 'vue' +import isServer from './isServer' +import { getConfig } from './config' +import { addClass, removeClass, on } from './dom' +import { eventKeys } from './aria' + +interface ComponentMethods { + closeOnClickModal: boolean + close: () => void + closeOnPressEscape: boolean + handleClose: () => void + handleAction: (action: string) => void +} + +type Instance = ComponentPublicInstance; + +type StackFrame = { id: string; zIndex: number; modalClass: string; }; + +interface IPopupManager { + getInstance: (id: string) => Instance + zIndex: number + modalDom?: HTMLElement + modalFade: boolean + modalStack: StackFrame[] + nextZIndex: () => number + register: (id: string, instance: Instance) => void + deregister: (id: string) => void + doOnModalClick: () => void + openModal: ( + id: string, + zIndex: number, + dom: HTMLElement, + modalClass: string, + modalFade: boolean + ) => void + closeModal: (id: string) => void +} + +const onTouchMove = (e: Event) => { + e.preventDefault() + e.stopPropagation() +} + +const onModalClick = () => { + PopupManager?.doOnModalClick() +} + +let hasModal = false +let zIndex: number + +const getModal = function(): HTMLElement { + if (isServer) return + let modalDom = PopupManager.modalDom + if (modalDom) { + hasModal = true + } else { + hasModal = false + modalDom = document.createElement('div') + PopupManager.modalDom = modalDom + + on(modalDom, 'touchmove', onTouchMove) + on(modalDom, 'click', onModalClick) + } + + return modalDom +} + +const instances = {} + +const PopupManager: IPopupManager = { + modalFade: true, + modalDom: undefined, + zIndex, + + getInstance: function(id) { + return instances[id] + }, + + register: function(id, instance) { + if (id && instance) { + instances[id] = instance + } + }, + + deregister: function(id) { + if (id) { + instances[id] = null + delete instances[id] + } + }, + + nextZIndex: function() { + return PopupManager.zIndex++ + }, + + modalStack: [], + + doOnModalClick: function() { + const topItem = PopupManager.modalStack[PopupManager.modalStack.length - 1] + if (!topItem) return + + const instance = PopupManager.getInstance(topItem.id) + if (instance && instance.closeOnClickModal) { + instance.close() + } + }, + + openModal: function(id, zIndex, dom, modalClass, modalFade) { + if (isServer) return + if (!id || zIndex === undefined) return + this.modalFade = modalFade + + const modalStack = this.modalStack + + for (let i = 0, j = modalStack.length; i < j; i++) { + const item = modalStack[i] + if (item.id === id) { + return + } + } + + const modalDom = getModal() + + addClass(modalDom, 'v-modal') + if (this.modalFade && !hasModal) { + addClass(modalDom, 'v-modal-enter') + } + if (modalClass) { + const classArr = modalClass.trim().split(/\s+/) + classArr.forEach(item => addClass(modalDom, item)) + } + setTimeout(() => { + removeClass(modalDom, 'v-modal-enter') + }, 200) + + if (dom && dom.parentNode && dom.parentNode.nodeType !== 11) { + dom.parentNode.appendChild(modalDom) + } else { + document.body.appendChild(modalDom) + } + + if (zIndex) { + modalDom.style.zIndex = String(zIndex) + } + modalDom.tabIndex = 0 + modalDom.style.display = '' + + this.modalStack.push({ id: id, zIndex: zIndex, modalClass: modalClass }) + }, + + closeModal: function(id) { + const modalStack = this.modalStack + const modalDom = getModal() + + if (modalStack.length > 0) { + const topItem = modalStack[modalStack.length - 1] + if (topItem.id === id) { + if (topItem.modalClass) { + const classArr = topItem.modalClass.trim().split(/\s+/) + classArr.forEach(item => removeClass(modalDom, item)) + } + + modalStack.pop() + if (modalStack.length > 0) { + modalDom.style.zIndex = modalStack[modalStack.length - 1].zIndex + } + } else { + for (let i = modalStack.length - 1; i >= 0; i--) { + if (modalStack[i].id === id) { + modalStack.splice(i, 1) + break + } + } + } + } + + if (modalStack.length === 0) { + if (this.modalFade) { + addClass(modalDom, 'v-modal-leave') + } + setTimeout(() => { + if (modalStack.length === 0) { + if (modalDom.parentNode) modalDom.parentNode.removeChild(modalDom) + modalDom.style.display = 'none' + // off(modalDom, 'touchmove', onTouchMove) + // off(modalDom, 'click', onModalClick) + PopupManager.modalDom = undefined + } + removeClass(modalDom, 'v-modal-leave') + }, 200) + } + }, +} + +Object.defineProperty(PopupManager, 'zIndex', { + configurable: true, + get() { + if (zIndex === undefined) { + zIndex = getConfig('zIndex') as number || 2000 + } + return zIndex + }, + set(value) { + zIndex = value + }, +}) + +const getTopPopup = function() { + if (isServer) return + if (PopupManager.modalStack.length > 0) { + const topPopup = + PopupManager.modalStack[PopupManager.modalStack.length - 1] + if (!topPopup) return + const instance = PopupManager.getInstance(topPopup.id) + + return instance + } +} + +if (!isServer) { + // handle `esc` key when the popup is shown + on(window, 'keydown', function(event: KeyboardEvent) { + if (event.keyCode === eventKeys.esc) { + const topPopup = getTopPopup() + + if (topPopup && topPopup.closeOnPressEscape) { + topPopup.handleClose + ? topPopup.handleClose() + : topPopup.handleAction + ? topPopup.handleAction('cancel') + : topPopup.close() + } + } + }) +} + +export default PopupManager diff --git a/packages/utils/popup.ts b/packages/utils/popup.ts new file mode 100644 index 0000000000000..ca4a4a6982085 --- /dev/null +++ b/packages/utils/popup.ts @@ -0,0 +1 @@ +export const PopupManager = {} diff --git a/typings/vue-shim.d.ts b/typings/vue-shim.d.ts index 0f9330f090984..3efc4c2c30634 100644 --- a/typings/vue-shim.d.ts +++ b/typings/vue-shim.d.ts @@ -10,3 +10,7 @@ declare module '*.vue' { declare type Nullable = T | null; declare type CustomizedHTMLElement = HTMLElement & T; + +declare type Indexable = { + [key: string]: T +}; diff --git a/yarn.lock b/yarn.lock index 466447973d2db..50fe5a66b460b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2114,6 +2114,11 @@ dependencies: "@types/node" ">= 8" +"@popperjs/core@^2.4.4": + version "2.4.4" + resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.4.4.tgz#11d5db19bd178936ec89cd84519c4de439574398" + integrity sha512-1oO6+dN5kdIA3sKPZhRGJTfGVP4SWV6KqlMOwry4J3HfyD68sl/3KmG7DeYUzvN+RbhXDnv/D8vNNB8168tAMg== + "@reach/router@^1.2.1": version "1.3.4" resolved "https://registry.npm.taobao.org/@reach/router/download/@reach/router-1.3.4.tgz#d2574b19370a70c80480ed91f3da840136d10f8c"