mirror of
https://gitee.com/element-plus/element-plus.git
synced 2024-11-29 17:58:08 +08:00
feat(notification): add new component notification
This commit is contained in:
parent
1dd262d316
commit
25d6bd4667
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,3 +9,4 @@ lerna-debug.json
|
||||
lerna-debug.log
|
||||
yarn-error.log
|
||||
storybook-static
|
||||
coverage/
|
@ -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'],
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
291
packages/notification/__tests__/notification.spec.ts
Normal file
291
packages/notification/__tests__/notification.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
37
packages/notification/__tests__/notify.spec.ts
Normal file
37
packages/notification/__tests__/notify.spec.ts
Normal 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)
|
||||
})
|
||||
|
||||
})
|
34
packages/notification/doc/closable.vue
Normal file
34
packages/notification/doc/closable.vue
Normal 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>
|
7
packages/notification/doc/index.stories.ts
Normal file
7
packages/notification/doc/index.stories.ts
Normal 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'
|
54
packages/notification/doc/standealone.vue
Normal file
54
packages/notification/doc/standealone.vue
Normal 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>
|
35
packages/notification/doc/vnode.vue
Normal file
35
packages/notification/doc/vnode.vue
Normal 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>
|
8
packages/notification/index.ts
Normal file
8
packages/notification/index.ts
Normal 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)
|
||||
}
|
12
packages/notification/package.json
Normal file
12
packages/notification/package.json
Normal 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"
|
||||
}
|
||||
}
|
190
packages/notification/src/index.vue
Normal file
190
packages/notification/src/index.vue
Normal 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>
|
53
packages/notification/src/notification.constants.ts
Normal file
53
packages/notification/src/notification.constants.ts
Normal 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',
|
||||
}
|
||||
|
128
packages/notification/src/notify.ts
Normal file
128
packages/notification/src/notify.ts
Normal 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
|
@ -7,6 +7,8 @@ export const eventKeys = {
|
||||
right: 39,
|
||||
down: 40,
|
||||
esc: 27,
|
||||
backspace: 8,
|
||||
delete: 46,
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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<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()
|
||||
|
@ -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)
|
||||
|
237
packages/utils/popup-manager.ts
Normal file
237
packages/utils/popup-manager.ts
Normal 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
1
packages/utils/popup.ts
Normal file
@ -0,0 +1 @@
|
||||
export const PopupManager = {}
|
4
typings/vue-shim.d.ts
vendored
4
typings/vue-shim.d.ts
vendored
@ -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
|
||||
};
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user