feat(notification): add new component notification

This commit is contained in:
JeremyWuuuuu 2020-08-04 19:03:20 +08:00 committed by zazzaz
parent 1dd262d316
commit 25d6bd4667
23 changed files with 1133 additions and 32 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ lerna-debug.json
lerna-debug.log
yarn-error.log
storybook-static
coverage/

View File

@ -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'],
}

View File

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

View File

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

View File

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

View File

@ -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<string, unknown>) => {
const onClose = jest.fn()
return mount(Notification, {
...props,
props: {
onClose,
...props.props as Record<string, unknown>,
},
})
}
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: `<strong class=${tagClass}>${AXIOM}</strong>`,
},
})
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: `<strong class=${tagClass}>${AXIOM}</strong>`,
},
})
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)
})
})
})

View File

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

View File

@ -0,0 +1,34 @@
<template>
<div>
<el-button class="item" @click="onClick">With timer</el-button>
<el-button class="item" @click="onClickNoTimer">Without timer</el-button>
</div>
</template>
<script lang="ts">
export default {
methods: {
onClick(): void {
this.$notify({
title: 'With Timer',
message: 'I\'ll go away within 4500ms ',
})
},
onClickNoTimer(): void {
this.$notify({
duration: 0,
message: 'I will not disappear unless you click close or press esc',
title: 'No Timer',
})
},
},
}
</script>
<style>
.item {
margin-top: 10px;
margin-right: 40px;
}
</style>

View File

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

View File

@ -0,0 +1,54 @@
<template>
<div>
<el-button class="item" @click="onClick">Normal</el-button>
<el-button class="item" @click="onClickSuccess">Success</el-button>
<el-button class="item" @click="onClickWarning">Warning</el-button>
<el-button class="item" @click="onClickInfo">Info</el-button>
<el-button class="item" @click="onClickError">Error</el-button>
</div>
</template>
<script lang="ts">
import Notify from '../src/notify'
export default {
methods: {
onClick(): void {
Notify({
title: 'Normal title',
message: 'HI',
})
},
onClickSuccess(): void {
Notify.success({
title: 'Success',
message: 'Success notification!',
})
},
onClickWarning(): void {
Notify.warning({
title: 'Warning',
message: 'Warning notification',
})
},
onClickInfo(): void {
Notify.info({
title: 'Info',
message: 'Info notification',
})
},
onClickError(): void {
Notify.error({
title: 'Error',
message: 'Error notification',
})
},
},
}
</script>
<style>
.item {
margin-top: 10px;
margin-right: 40px;
}
</style>

View File

@ -0,0 +1,35 @@
<template>
<div>
<el-button class="item" @click="onClick">HTML tags</el-button>
<el-button class="item" @click="onClickVNode"> VNode </el-button>
</div>
</template>
<script lang="ts">
import { h } from 'vue'
import Notify from '../src/notify'
export default {
methods: {
onClick(): void {
Notify({
title: 'This is title',
message: '<strong>This is <i>HTML</i> segement</strong>',
dangerouslyUseHTMLString: true,
})
},
onClickVNode(): void {
Notify({
title: 'This is a vnode',
message: h('strong', ['This is ', h('i', 'VNode'), ' segment']),
})
},
},
}
</script>
<style>
.item {
margin-top: 10px;
margin-right: 40px;
}
</style>

View File

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

View File

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

View File

@ -0,0 +1,190 @@
<template>
<transition name="el-notification-fade">
<div
v-show="visible"
:id="id"
:class="['el-notification', customClass, horizontalClass]"
:style="positionStyle"
role="alert"
@mouseenter="clearTimer()"
@mouseleave="startTimer()"
@click="click"
>
<i
v-if="type || iconClass"
class="el-notification__icon"
:class="[typeClass, iconClass]"
></i>
<div
class="el-notification__group"
:class="{ 'is-with-icon': typeClass || iconClass }"
>
<h2 class="el-notification__title" v-text="title"></h2>
<div v-show="message" class="el-notification__content">
<slot>
<p v-if="!dangerouslyUseHTMLString">{{ message }}</p>
<!-- Caution here, message could've been compromized, nerver use user's input as message -->
<!-- eslint-disable-next-line -->
<p v-else v-html="message"></p>
</slot>
</div>
<div
v-if="showClose"
class="el-notification__closeBtn el-icon-close"
@click.stop="close"
></div>
</div>
</div>
</transition>
</template>
<script lang="ts">
import { defineComponent, reactive, computed, ref, PropType } from 'vue'
import { defaultProps } from './notification.constants'
import type { NotificationVM } from './notification.constants'
import { eventKeys } from '../../utils/aria'
import { on, off } from '../../utils/dom'
const TypeMap: Indexable<string> = {
success: 'success',
info: 'info',
warning: 'warning',
error: 'error',
}
export default defineComponent({
name: 'ElNotification',
props: {
customClass: { type: String, default: '' },
dangerouslyUseHTMLString: { type: Boolean, default: false }, // default false
duration: { type: Number, default: 4500 }, // default 4500
iconClass: { type: String, default: '' },
id: { type: String, default: '' },
message: { type: [String, Object] as PropType<>, default: '' },
offset: { type: Number, default: 0 }, // defaults 0
onClick: { type: Function, default: () => void 0 },
onClose: { type: Function, required: true },
position: { type: String, default: 'top-right' }, // default top-right
showClose: { type: Boolean, default: true },
title: { type: String, default: '' },
type: { type: String, default: '' },
// disabling linter next two lines due to this is a internal prop which should not be accessed by end user
// eslint-disable-next-line
_idx: { type: Number, required: false, default: null },
// eslint-disable-next-line
_init: { type: Function as (idx: number, vm: NotificationVM) => void, required: false, default: null }
},
emits: ['close', 'click'],
setup(props) {
const data = reactive({
...defaultProps,
...props,
})
const typeClass = computed(() => {
const { type } = data
return type && TypeMap[type] ? `el-icon-${TypeMap[type]}` : ''
})
const horizontalClass = computed(() => {
const { position } = data
return position.indexOf('right') > 1 ? 'right' : 'left'
})
const verticalProperty = computed(() => {
const { position } = data
return position.startsWith('top') ? 'top' : 'bottom'
})
const positionStyle = ref({
[verticalProperty.value]: props.offset,
})
const visible = ref(true)
return {
...data,
visible,
typeClass,
horizontalClass,
verticalProperty,
positionStyle,
}
},
watch: {
closed(newVal: boolean) {
if (newVal) {
this.visible = false
on(this.$el, 'transitionend', this.destroyElement)
}
},
offset(newVal: number) {
this.positionStyle = {
[this.verticalProperty]: `${newVal}px`,
}
},
},
mounted() {
if (this.duration > 0) {
this.timer = setTimeout(() => {
if (!this.closed) {
this.close()
}
}, this.duration)
}
// When using notification programmably, this is line of code is critical
// to obtain the public component instance
if (typeof this._init === 'function') {
this._init(this._idx, this)
}
on(document, 'keydown', this.keydown)
},
beforeUnmount() {
off(document, 'keydown', this.keydown)
},
methods: {
destroyElement() {
off(this.$el, 'transitionend', this.destroyElement)
this.$destroy(true)
this.$el.parentNode.removeChild(this.$el)
},
// 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()
this.$emit('click')
},
close() {
this.closed = true
this.timer = null
this.onClose()
this.$emit('close')
},
keydown({ keyCode }: KeyboardEvent) {
if (keyCode === eventKeys.delete || keyCode === eventKeys.backspace) {
this.clearTimer() // press detele/backspace clear timer
} else if (keyCode === eventKeys.esc) {
// press esc to close the notification
if (!this.closed) {
this.close()
}
} else {
this.startTimer() // resume timer
}
},
},
})
</script>

View File

@ -0,0 +1,53 @@
import type { ComponentPublicInstance, VNode } from 'vue'
export interface INotification<P = (options: INotificationOptions) => 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<INotificationOptions, unknown, { verticalProperty: string; }>
export type NotificationQueue = Array<{
vm: Nullable<NotificationVM>
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',
}

View File

@ -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<string, unknown>) => {
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

View File

@ -7,6 +7,8 @@ export const eventKeys = {
right: 39,
down: 40,
esc: 27,
backspace: 8,
delete: 46,
}
/**

View File

@ -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,
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,
export const off = function(
element: HTMLElement | Document | Window,
event: string,
handler: EventListenerOrEventListenerObject,
) {
if (element && event) {
): 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<boolean>): RegExpMatchArray => {
export const isScroll = (
el: HTMLElement,
isVertical?: Nullable<boolean>,
): 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()

View File

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

View File

@ -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<unknown, ComponentMethods>;
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

1
packages/utils/popup.ts Normal file
View File

@ -0,0 +1 @@
export const PopupManager = {}

View File

@ -10,3 +10,7 @@ declare module '*.vue' {
declare type Nullable<T> = T | null;
declare type CustomizedHTMLElement<T> = HTMLElement & T;
declare type Indexable<T> = {
[key: string]: T
};

View File

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