mirror of
https://github.com/element-plus/element-plus.git
synced 2024-11-21 01:02:59 +08:00
refactor(components): refactor notification (#3495)
* refactor(components): refactor notification * chore: fix type * refactor(components): improve vnode * Update notification.vue
This commit is contained in:
parent
6256189100
commit
2431b589af
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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'
|
||||
|
107
packages/components/notification/src/notification.ts
Normal file
107
packages/components/notification/src/notification.ts
Normal 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[]
|
@ -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>
|
@ -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,
|
@ -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
|
||||
|
@ -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>
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user