refactor(components): refactor notification (#3495)

* refactor(components): refactor notification

* chore: fix type

* refactor(components): improve vnode

* Update notification.vue
This commit is contained in:
三咲智子 2021-09-22 01:19:35 +08:00 committed by GitHub
parent 6256189100
commit 2431b589af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 215 additions and 203 deletions

View File

@ -1,10 +1,9 @@
import { h, nextTick } from 'vue'
import { rAF } from '@element-plus/test-utils/tick'
import makeMount from '@element-plus/test-utils/make-mount'
import * as domExports from '@element-plus/utils/dom'
import { EVENT_CODE } from '@element-plus/utils/aria'
import PopupManager from '@element-plus/utils/popup-manager'
import Notification from '../src/index.vue'
import Notification from '../src/notification.vue'
import type { ComponentPublicInstance } from 'vue'
import type { VueWrapper } from '@vue/test-utils'
@ -41,7 +40,10 @@ describe('Notification.vue', () => {
expect(vm.visible).toBe(true)
expect(vm.typeClass).toBe('')
expect(vm.horizontalClass).toBe('right')
expect(vm.positionStyle).toEqual({ top: '0px', 'z-index': 0 })
expect(vm.positionStyle).toEqual({
top: '0px',
zIndex: 0,
} as CSSProperties)
})
test('should be able to render VNode', () => {
@ -96,34 +98,10 @@ describe('Notification.vue', () => {
positionStyle: Record<string, string>
}>
expect(vm.positionStyle).toEqual({ top: '0px', 'z-index': zIndex })
})
})
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 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).toHaveBeenCalled()
wrapper.unmount()
expect(domExports.off).toHaveBeenCalled()
expect(vm.positionStyle).toEqual({
top: '0px',
zIndex,
} as CSSProperties)
})
})

View File

@ -2,7 +2,7 @@ import { nextTick, h } from 'vue'
import { rAF } from '@element-plus/test-utils/tick'
import Notification, { closeAll } from '../src/notify'
import type { INotificationHandle } from '../src/notification.type'
import type { NotificationHandle } from '../src/notification'
const selector = '.el-notification'
@ -53,7 +53,7 @@ describe('Notification on command', () => {
})
test('it should close all notifications', async () => {
const notifications: INotificationHandle[] = []
const notifications: NotificationHandle[] = []
const onClose = jest.fn()
for (let i = 0; i < 4; i++) {
notifications.push(

View File

@ -1,15 +1,8 @@
import { withInstallFunction } from '@element-plus/utils/with-install'
import Notify from './src/notify'
import type { App } from 'vue'
import type { SFCWithInstall } from '@element-plus/utils/types'
export const ElNotification = withInstallFunction(Notify, '$notify')
export default ElNotification
const _Notify = Notify as SFCWithInstall<typeof Notify>
_Notify.install = (app: App) => {
app.config.globalProperties.$notify = _Notify
}
export default _Notify
export const ElNotification = _Notify
export * from './src/notification.type'
export * from './src/notification'

View File

@ -0,0 +1,107 @@
import { buildProp } from '@element-plus/utils/props'
import type { VNode, ExtractPropTypes } from 'vue'
export const notificationTypes = [
'success',
'info',
'warning',
'error',
] as const
export const notificationProps = {
customClass: {
type: String,
default: '',
},
dangerouslyUseHTMLString: {
type: Boolean,
default: false,
},
duration: {
type: Number,
default: 4500,
},
iconClass: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
message: buildProp<string | VNode>({
type: [String, Object],
default: '',
}),
offset: {
type: Number,
default: 0,
},
onClick: buildProp<() => void>({
type: Function,
default: () => undefined,
}),
onClose: buildProp<() => void, boolean>({
type: Function,
required: true,
}),
position: buildProp({
type: String,
values: ['top-right', 'top-left', 'bottom-right', 'bottom-left'],
default: 'top-right',
} as const),
showClose: {
type: Boolean,
default: true,
},
title: {
type: String,
default: '',
},
type: buildProp({
type: String,
values: [...notificationTypes, ''],
default: '',
} as const),
zIndex: {
type: Number,
default: 0,
},
} as const
export type NotificationProps = ExtractPropTypes<typeof notificationProps>
export const notificationEmits = {
destroy: () => true,
}
export type NotificationEmits = typeof notificationEmits
export type NotificationOptions = Omit<NotificationProps, 'id'>
export type NotificationOptionsTyped = Omit<NotificationOptions, 'type'>
export interface NotificationHandle {
close: () => void
}
export type NotificationParams = Partial<NotificationOptions> | string | VNode
export type NotificationParamsTyped =
| Partial<NotificationOptionsTyped>
| string
| VNode
export interface NotifyPartial {
(options?: NotificationParams): NotificationHandle
closeAll: () => void
success?: (options: NotificationParamsTyped) => NotificationHandle
warning?: (options: NotificationParamsTyped) => NotificationHandle
error?: (options: NotificationParamsTyped) => NotificationHandle
info?: (options: NotificationParamsTyped) => NotificationHandle
}
export type Notify = Required<NotifyPartial>
export interface NotificationQueueItem {
vm: VNode
}
export type NotificationQueue = NotificationQueueItem[]

View File

@ -1,45 +0,0 @@
import type { VNode } from 'vue'
export type Position = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
export type NotificationType = 'success' | 'warning' | 'info' | 'error' | ''
export type TypedNotificationOptions =
| Omit<INotificationOptions, 'type'>
| string
export interface INotificationHandle {
close: () => void
}
export interface INotification {
(options?: INotificationOptions): INotificationHandle
success?: (options: TypedNotificationOptions) => INotificationHandle
warning?: (options: TypedNotificationOptions) => INotificationHandle
error?: (options: TypedNotificationOptions) => INotificationHandle
info?: (options: TypedNotificationOptions) => INotificationHandle
closeAll: () => void
}
export type INotificationOptions = {
customClass?: string
dangerouslyUseHTMLString?: boolean // default false
duration?: number // default 4500
iconClass?: string
id?: string
message?: string | VNode
zIndex?: number
onClose?: () => void
onClick?: () => void
offset?: number // defaults 0
position?: Position // default top-right
showClose?: boolean
type?: NotificationType
title?: string
}
export type NotificationVM = VNode
type NotificationQueueItem = {
vm: NotificationVM
}
export type NotificationQueue = Array<NotificationQueueItem>

View File

@ -27,7 +27,7 @@
<div
v-show="message"
class="el-notification__content"
:style="!!title ? null : 'margin: 0'"
:style="!!title ? undefined : { margin: 0 }"
>
<slot>
<p v-if="!dangerouslyUseHTMLString">{{ message }}</p>
@ -46,91 +46,59 @@
</transition>
</template>
<script lang="ts">
import { defineComponent, computed, ref, onMounted, onBeforeUnmount } from 'vue'
// notificationVM is an alias of vue.VNode
import { defineComponent, computed, ref, onMounted } from 'vue'
import { useEventListener, useTimeoutFn } from '@vueuse/core'
import { EVENT_CODE } from '@element-plus/utils/aria'
import { on, off } from '@element-plus/utils/dom'
import { notificationProps, notificationEmits } from './notification'
import type { CSSProperties, PropType } from 'vue'
import type { Indexable } from '@element-plus/utils/types'
import type { NotificationVM, Position } from './notification.type'
import type { CSSProperties } from 'vue'
import type { NotificationProps } from './notification'
const TypeMap: Indexable<string> = {
success: 'success',
info: 'info',
warning: 'warning',
error: 'error',
}
export const typeMap: Record<NotificationProps['type'], string> = {
'': '',
success: 'el-icon-success',
info: 'el-icon-info',
warning: 'el-icon-warning',
error: 'el-icon-error',
} as const
export default defineComponent({
name: 'ElNotification',
props: {
customClass: { type: String, default: '' },
dangerouslyUseHTMLString: { type: Boolean, default: false },
duration: { type: Number, default: 4500 },
iconClass: { type: String, default: '' },
id: { type: String, default: '' },
message: {
type: [String, Object] as PropType<string | NotificationVM>,
default: '',
},
offset: { type: Number, default: 0 },
onClick: {
type: Function as PropType<() => void>,
default: () => undefined,
},
onClose: {
type: Function as PropType<() => void>,
required: true,
},
position: {
type: String as PropType<Position>,
default: 'top-right',
},
showClose: { type: Boolean, default: true },
title: { type: String, default: '' },
type: { type: String, default: '' },
zIndex: { type: Number, default: 0 },
},
emits: ['destroy'],
props: notificationProps,
emits: notificationEmits,
setup(props) {
const visible = ref(false)
let timer = null
let timer: (() => void) | undefined = undefined
const typeClass = computed(() => {
const type = props.type
return type && TypeMap[type] ? `el-icon-${TypeMap[type]}` : ''
})
const typeClass = computed(() => typeMap[props.type])
const horizontalClass = computed(() => {
return props.position.indexOf('right') > 1 ? 'right' : 'left'
})
const horizontalClass = computed(() =>
props.position.endsWith('right') ? 'right' : 'left'
)
const verticalProperty = computed(() => {
return props.position.startsWith('top') ? 'top' : 'bottom'
})
const verticalProperty = computed(() =>
props.position.startsWith('top') ? 'top' : 'bottom'
)
const positionStyle = computed(() => {
const positionStyle = computed<CSSProperties>(() => {
return {
[verticalProperty.value]: `${props.offset}px`,
'z-index': props.zIndex,
} as CSSProperties
zIndex: props.zIndex,
}
})
function startTimer() {
if (props.duration > 0) {
timer = setTimeout(() => {
if (visible.value) {
close()
}
}, props.duration)
;({ stop: timer } = useTimeoutFn(() => {
if (visible.value) close()
}, props.duration))
}
}
function clearTimer() {
clearTimeout(timer)
timer = null
timer?.()
}
function close() {
@ -154,12 +122,9 @@ export default defineComponent({
onMounted(() => {
startTimer()
visible.value = true
on(document, 'keydown', onKeydown)
})
onBeforeUnmount(() => {
off(document, 'keydown', onKeydown)
})
useEventListener(document, 'keydown', onKeydown)
return {
horizontalClass,

View File

@ -2,19 +2,23 @@ import { createVNode, render } from 'vue'
import isServer from '@element-plus/utils/isServer'
import PopupManager from '@element-plus/utils/popup-manager'
import { isVNode } from '@element-plus/utils/util'
import NotificationConstructor from './index.vue'
import NotificationConstructor from './notification.vue'
import { notificationTypes } from './notification'
import type { ComponentPublicInstance } from 'vue'
import type { ComponentPublicInstance, VNode } from 'vue'
import type {
INotificationOptions,
INotification,
NotificationOptions,
Notify,
NotifyPartial,
NotificationQueue,
NotificationVM,
Position,
} from './notification.type'
NotificationProps,
} from './notification'
// This should be a queue but considering there were `non-autoclosable` notifications.
const notifications: Record<Position, NotificationQueue> = {
const notifications: Record<
NotificationOptions['position'],
NotificationQueue
> = {
'top-left': [],
'top-right': [],
'bottom-left': [],
@ -25,74 +29,78 @@ const notifications: Record<Position, NotificationQueue> = {
const GAP_SIZE = 16
let seed = 1
const Notification: INotification = function (options = {}) {
if (isServer) return
const notify: NotifyPartial = function (options = {}) {
if (isServer) return { close: () => undefined }
if (typeof options === 'string' || isVNode(options)) {
options = { message: options }
}
const position = options.position || 'top-right'
let verticalOffset = options.offset || 0
notifications[position].forEach(({ vm }) => {
verticalOffset += (vm.el.offsetHeight || 0) + GAP_SIZE
verticalOffset += (vm.el?.offsetHeight || 0) + GAP_SIZE
})
verticalOffset += GAP_SIZE
const id = `notification_${seed++}`
const userOnClose = options.onClose
options = {
const props: Partial<NotificationProps> = {
// default options end
zIndex: PopupManager.nextZIndex(),
offset: verticalOffset,
...options,
id,
onClose: () => {
close(id, position, userOnClose)
},
offset: verticalOffset,
id,
zIndex: PopupManager.nextZIndex(),
}
const container = document.createElement('div')
const vm = createVNode(
NotificationConstructor,
options,
isVNode(options.message)
props,
isVNode(props.message)
? {
default: () => options.message,
default: () => props.message,
}
: null
)
// clean notification element preventing mem leak
vm.props.onDestroy = () => {
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[position].push({ vm })
document.body.appendChild(container.firstElementChild)
document.body.appendChild(container.firstElementChild!)
return {
// 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 }>
vm.component!.proxy as ComponentPublicInstance<{ visible: boolean }>
).visible = false
},
}
}
;(['success', 'warning', 'info', 'error'] as const).forEach((type) => {
Object.assign(Notification, {
[type]: (options: NotificationVM | INotificationOptions | string = {}) => {
if (typeof options === 'string' || isVNode(options)) {
options = {
message: options,
}
notificationTypes.forEach((type) => {
notify[type] = (options = {}) => {
if (typeof options === 'string' || isVNode(options)) {
options = {
message: options,
}
options.type = type
return Notification(options)
},
})
}
return notify({
...options,
type,
})
}
})
/**
@ -105,13 +113,13 @@ const Notification: INotification = function (options = {}) {
*/
export function close(
id: string,
position: Position,
userOnClose?: (vm: NotificationVM) => void
position: NotificationOptions['position'],
userOnClose?: (vm: VNode) => void
): void {
// maybe we can store the index when inserting the vm to notification list.
const orientedNotifications = notifications[position]
const idx = orientedNotifications.findIndex(
({ vm }) => vm.component.props.id === id
({ vm }) => vm.component?.props.id === id
)
if (idx === -1) return
const { vm } = orientedNotifications[idx]
@ -120,7 +128,7 @@ export function close(
userOnClose?.(vm)
// note that this is called @before-leave, that's why we were able to fetch this property.
const removedHeight = vm.el.offsetHeight
const removedHeight = vm.el!.offsetHeight
const verticalPos = position.split('-')[0]
orientedNotifications.splice(idx, 1)
const len = orientedNotifications.length
@ -129,24 +137,23 @@ export function close(
for (let i = idx; i < len; i++) {
// new position equals the current offsetTop minus removed height plus 16px(the gap size between each item)
const { el, component } = orientedNotifications[i].vm
const pos = parseInt(el.style[verticalPos], 10) - removedHeight - GAP_SIZE
component.props.offset = pos
const pos = parseInt(el!.style[verticalPos], 10) - removedHeight - GAP_SIZE
component!.props.offset = pos
}
}
export function closeAll(): void {
// loop through all directions, close them at once.
for (const key in notifications) {
const orientedNotifications = notifications[key as Position]
for (const orientedNotifications of Object.values(notifications)) {
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 }>
vm.component!.proxy as ComponentPublicInstance<{ visible: boolean }>
).visible = false
})
}
}
Notification.closeAll = closeAll
notify.closeAll = closeAll
export default Notification
export default notify as Notify

View File

@ -1,11 +1,10 @@
import type { App } from 'vue'
import type { SFCWithInstall } from './types'
export const withInstall = <T, E extends Record<string, any>>(
main: T,
extra?: E
) => {
;(main as SFCWithInstall<T>).install = (app: App): void => {
;(main as SFCWithInstall<T>).install = (app): void => {
for (const comp of [main, ...Object.values(extra ?? {})]) {
app.component(comp.name, comp)
}
@ -18,3 +17,11 @@ export const withInstall = <T, E extends Record<string, any>>(
}
return main as SFCWithInstall<T> & E
}
export const withInstallFunction = <T>(fn: T, name: string) => {
;(fn as SFCWithInstall<T>).install = (app) => {
app.config.globalProperties[name] = fn
}
return fn as SFCWithInstall<T>
}