mirror of
https://github.com/element-plus/element-plus.git
synced 2025-01-12 10:45:10 +08:00
feat(loading): add loading component (#447)
* refactor(loading): refactored loading component * refactor(loading): use render & createVNode instead of createApp Co-authored-by: Ryan <ryanzhao2128@gmail.com>
This commit is contained in:
parent
d8882d839f
commit
ee3b42fb09
@ -34,6 +34,7 @@ import ElTabs from '@element-plus/tabs'
|
||||
import ElTooltip from '@element-plus/tooltip'
|
||||
import ElSlider from '@element-plus/slider'
|
||||
import ElInput from '@element-plus/input'
|
||||
import ElLoading from '@element-plus/loading'
|
||||
import ElTransfer from '@element-plus/transfer'
|
||||
import ElDialog from '@element-plus/dialog'
|
||||
import ElCalendar from '@element-plus/calendar'
|
||||
@ -91,6 +92,7 @@ export {
|
||||
ElTooltip,
|
||||
ElSlider,
|
||||
ElInput,
|
||||
ElLoading,
|
||||
ElTransfer,
|
||||
ElDialog,
|
||||
ElCalendar,
|
||||
@ -148,6 +150,7 @@ const install = (app: App): void => {
|
||||
ElTooltip(app)
|
||||
ElSlider(app)
|
||||
ElInput(app)
|
||||
ElLoading(app)
|
||||
ElTransfer(app)
|
||||
ElDialog(app)
|
||||
ElCalendar(app)
|
||||
|
256
packages/loading/__tests__/loading.spec.ts
Normal file
256
packages/loading/__tests__/loading.spec.ts
Normal file
@ -0,0 +1,256 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Loading from '../src/index'
|
||||
import vLoading from '../src/directive'
|
||||
import { nextTick } from 'vue'
|
||||
import { sleep } from '@element-plus/test-utils'
|
||||
|
||||
function destroyLoadingInstance(loadingInstance) {
|
||||
if(!loadingInstance) return
|
||||
loadingInstance.close()
|
||||
loadingInstance.$el &&
|
||||
loadingInstance.$el.parentNode &&
|
||||
loadingInstance.$el.parentNode.removeChild(loadingInstance.$el)
|
||||
}
|
||||
|
||||
describe('Loading', () => {
|
||||
let loadingInstance, loadingInstance2
|
||||
|
||||
afterEach(() => {
|
||||
destroyLoadingInstance(loadingInstance)
|
||||
destroyLoadingInstance(loadingInstance2)
|
||||
})
|
||||
|
||||
test('create directive', async () => {
|
||||
const wrapper = mount({
|
||||
directives: {
|
||||
loading: vLoading,
|
||||
},
|
||||
template: `<div v-loading="loading"></div>`,
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
const vm = wrapper.vm
|
||||
|
||||
const maskWrapper = wrapper.find('.el-loading-mask')
|
||||
expect(maskWrapper.exists()).toBeTruthy()
|
||||
vm.loading = false
|
||||
|
||||
await sleep(100)
|
||||
expect(vm.$el.querySelector('.el-loading-mask').style.display).toEqual('none')
|
||||
})
|
||||
|
||||
test('unmounted directive', async () => {
|
||||
const wrapper1 = mount({
|
||||
directives: {
|
||||
loading: vLoading,
|
||||
},
|
||||
template: `<div v-if="show" v-loading="loading"></div>`,
|
||||
data() {
|
||||
return {
|
||||
show: true,
|
||||
loading: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper2 = mount({
|
||||
directives: {
|
||||
loading: vLoading,
|
||||
},
|
||||
template: `<div v-if="show" v-loading="loading"></div>`,
|
||||
data() {
|
||||
return {
|
||||
show: true,
|
||||
loading: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
const vm1 = wrapper1.vm
|
||||
const vm2 = wrapper2.vm
|
||||
|
||||
vm1.loading = false
|
||||
vm2.loading = false
|
||||
|
||||
await nextTick()
|
||||
vm1.show = false
|
||||
vm2.show = false
|
||||
|
||||
await nextTick()
|
||||
expect(document.querySelector('.el-loading-mask')).toBeFalsy()
|
||||
})
|
||||
|
||||
test('body directive', async () => {
|
||||
const wrapper = mount({
|
||||
directives: {
|
||||
loading: vLoading,
|
||||
},
|
||||
template: `<div v-loading.body="loading"></div>`,
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
const mask = document.querySelector('.el-loading-mask')
|
||||
expect(mask.parentNode === document.body).toBeTruthy()
|
||||
wrapper.vm.loading = false
|
||||
document.body.removeChild(mask)
|
||||
})
|
||||
|
||||
test('fullscreen directive', async () => {
|
||||
const wrapper = mount({
|
||||
directives: {
|
||||
loading: vLoading,
|
||||
},
|
||||
template: `<div v-loading.fullscreen="loading"></div>`,
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
const mask = document.querySelector('.el-loading-mask')
|
||||
expect(mask.parentNode === document.body).toBeTruthy()
|
||||
expect(mask.classList.contains('is-fullscreen')).toBeTruthy()
|
||||
wrapper.vm.loading = false
|
||||
document.body.removeChild(mask)
|
||||
})
|
||||
|
||||
test('lock directive', async () => {
|
||||
const wrapper = mount({
|
||||
directives: {
|
||||
loading: vLoading,
|
||||
},
|
||||
template: `<div v-loading.fullscreen.lock="loading"></div>`,
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
const vm = wrapper.vm
|
||||
await nextTick()
|
||||
expect(document.body.classList.contains('el-loading-parent--hidden')).toBeTruthy()
|
||||
vm.loading = false
|
||||
document.body.removeChild(document.querySelector('.el-loading-mask'))
|
||||
})
|
||||
|
||||
test('text directive', async () => {
|
||||
const wrapper = mount({
|
||||
directives: {
|
||||
loading: vLoading,
|
||||
},
|
||||
template: `<div v-loading="loading" element-loading-text="loading..."></div>`,
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
expect(wrapper.find('.el-loading-text').text()).toEqual('loading...')
|
||||
})
|
||||
|
||||
test('customClass directive', async () => {
|
||||
const wrapper = mount({
|
||||
directives: {
|
||||
loading: vLoading,
|
||||
},
|
||||
template: `<div v-loading="loading" element-loading-custom-class="loading-custom-class"></div>`,
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
}
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
expect(wrapper.find('.loading-custom-class').exists()).toBeTruthy()
|
||||
})
|
||||
|
||||
test('create service', async () => {
|
||||
loadingInstance = Loading()
|
||||
expect(document.querySelector('.el-loading-mask')).toBeTruthy()
|
||||
})
|
||||
|
||||
test('close service', async () => {
|
||||
loadingInstance = Loading()
|
||||
loadingInstance.close()
|
||||
expect(loadingInstance.visible.value).toBeFalsy()
|
||||
})
|
||||
|
||||
test('target service', async () => {
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.className = 'loading-container'
|
||||
document.body.appendChild(container)
|
||||
|
||||
loadingInstance = Loading({ target: '.loading-container' })
|
||||
const mask = container.querySelector('.el-loading-mask')
|
||||
expect(mask).toBeTruthy()
|
||||
expect(mask.parentNode).toEqual(container)
|
||||
|
||||
expect(container.classList.contains('el-loading-parent--relative')).toBeTruthy()
|
||||
loadingInstance.close()
|
||||
await sleep(500)
|
||||
expect(container.classList.contains('el-loading-parent--relative')).toBeFalsy()
|
||||
})
|
||||
|
||||
test('body service', async () => {
|
||||
const container = document.createElement('div')
|
||||
container.className = 'loading-container'
|
||||
document.body.appendChild(container)
|
||||
|
||||
loadingInstance = Loading({ target: '.loading-container', body: true })
|
||||
const mask = document.querySelector('.el-loading-mask')
|
||||
expect(mask).toBeTruthy()
|
||||
expect(mask.parentNode).toEqual(document.body)
|
||||
})
|
||||
|
||||
test('fullscreen service', async () => {
|
||||
loadingInstance = Loading({ fullscreen: true })
|
||||
const mask = document.querySelector('.el-loading-mask')
|
||||
expect(mask.parentNode).toEqual(document.body)
|
||||
expect(mask.classList.contains('is-fullscreen')).toBeTruthy()
|
||||
})
|
||||
|
||||
test('fullscreen singleton service', async () => {
|
||||
loadingInstance = Loading({ fullscreen: true })
|
||||
await sleep(50)
|
||||
loadingInstance2 = Loading({ fullscreen: true })
|
||||
await sleep(500)
|
||||
let masks = document.querySelectorAll('.el-loading-mask')
|
||||
expect(masks.length).toEqual(1)
|
||||
loadingInstance2.close()
|
||||
await sleep(500)
|
||||
masks = document.querySelectorAll('.el-loading-mask')
|
||||
expect(masks.length).toEqual(0)
|
||||
})
|
||||
|
||||
test('lock service', async () => {
|
||||
loadingInstance = Loading({ lock: true })
|
||||
expect(document.body.classList.contains('el-loading-parent--hidden')).toBeTruthy()
|
||||
})
|
||||
|
||||
test('text service', async () => {
|
||||
loadingInstance = Loading({ text: 'Loading...' })
|
||||
const text = document.querySelector('.el-loading-text')
|
||||
expect(text).toBeTruthy()
|
||||
expect(text.textContent).toEqual('Loading...')
|
||||
})
|
||||
|
||||
test('customClass service', async () => {
|
||||
loadingInstance = Loading({ customClass: 'el-loading-custom-class' })
|
||||
const customClass = document.querySelector('.el-loading-custom-class')
|
||||
expect(customClass).toBeTruthy()
|
||||
})
|
||||
|
||||
})
|
8
packages/loading/index.ts
Normal file
8
packages/loading/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { App } from 'vue'
|
||||
import Loading from './src/index'
|
||||
import vLoading from './src/directive'
|
||||
|
||||
export default (app: App): void => {
|
||||
app.directive('loading', vLoading)
|
||||
app.config.globalProperties.$loading = Loading
|
||||
}
|
12
packages/loading/package.json
Normal file
12
packages/loading/package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@element-plus/loading",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/index.js",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/test-utils": "^2.0.0-beta.3"
|
||||
}
|
||||
}
|
117
packages/loading/src/createLoadingComponent.ts
Normal file
117
packages/loading/src/createLoadingComponent.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { createVNode, reactive, ref, toRefs, h, Transition, render, VNode } from 'vue'
|
||||
import { removeClass } from '@element-plus/utils/dom'
|
||||
import type { ILoadingCreateComponentParams, ILoadingInstance } from './loading'
|
||||
|
||||
export function createLoadingComponent({ options , globalLoadingOption }: ILoadingCreateComponentParams): ILoadingInstance {
|
||||
let vm: VNode = null
|
||||
let afterLeaveTimer: Nullable<number> = null
|
||||
|
||||
const afterLeaveFlag = ref(false)
|
||||
const data = reactive({
|
||||
...options,
|
||||
originalPosition: '',
|
||||
originalOverflow: '',
|
||||
visible: options.hasOwnProperty('visible') ? options.visible : true,
|
||||
})
|
||||
|
||||
function setText(text: string) {
|
||||
data.text = text
|
||||
}
|
||||
|
||||
function destorySelf() {
|
||||
const target = data.parent
|
||||
if(!target.vLoadingAddClassList) {
|
||||
removeClass(target, 'el-loading-parent--relative')
|
||||
removeClass(target, 'el-loading-parent--hidden')
|
||||
}
|
||||
if (vm.el && vm.el.parentNode) {
|
||||
vm.el.parentNode.removeChild(vm.el)
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
const target = data.parent
|
||||
target.vLoadingAddClassList = null
|
||||
if (data.fullscreen) {
|
||||
globalLoadingOption.fullscreenLoading = undefined
|
||||
}
|
||||
afterLeaveFlag.value = true
|
||||
clearTimeout(afterLeaveTimer)
|
||||
|
||||
afterLeaveTimer = window.setTimeout(() => {
|
||||
if (afterLeaveFlag.value) {
|
||||
afterLeaveFlag.value = false
|
||||
destorySelf()
|
||||
}
|
||||
}, 400)
|
||||
data.visible = false
|
||||
}
|
||||
|
||||
function handleAfterLeave() {
|
||||
if (!afterLeaveFlag.value) return
|
||||
afterLeaveFlag.value = false
|
||||
destorySelf()
|
||||
}
|
||||
|
||||
const componetSetupConfig = {
|
||||
...toRefs(data),
|
||||
setText,
|
||||
close,
|
||||
handleAfterLeave,
|
||||
}
|
||||
|
||||
const elLoadingComponent = {
|
||||
name: 'ElLoading',
|
||||
setup() {
|
||||
return componetSetupConfig
|
||||
},
|
||||
render() {
|
||||
const spinner = h('svg', {
|
||||
class: 'circular',
|
||||
viewBox: '25 25 50 50',
|
||||
}, [
|
||||
h('circle', { class: 'path', cx: '50', cy: '50', r: '20', fill: 'none' }),
|
||||
])
|
||||
|
||||
const noSpinner = h('i', { class: this.spinner })
|
||||
|
||||
const spinnerText = h('p', { class: 'el-loading-text' }, [this.text])
|
||||
|
||||
return h(Transition, {
|
||||
name: 'el-loading-fade',
|
||||
onAfterLeave: this.handleAfterLeave,
|
||||
},{
|
||||
default: () => h('div', {
|
||||
style: {
|
||||
backgroundColor: this.background || '',
|
||||
display: this.visible ? 'inherit' : 'none',
|
||||
},
|
||||
class: [
|
||||
'el-loading-mask',
|
||||
this.customClass,
|
||||
this.fullscreen ? 'is-fullscreen' : '',
|
||||
],
|
||||
}, [
|
||||
h('div', {
|
||||
class: 'el-loading-spinner',
|
||||
}, [
|
||||
!this.spinner ? spinner : noSpinner,
|
||||
this.text ? spinnerText : null,
|
||||
]),
|
||||
]),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
vm = createVNode(elLoadingComponent)
|
||||
|
||||
render(vm, document.createElement('div'))
|
||||
|
||||
return {
|
||||
...componetSetupConfig,
|
||||
vm,
|
||||
get $el(){
|
||||
return vm.el as HTMLElement
|
||||
},
|
||||
}
|
||||
}
|
40
packages/loading/src/directive.ts
Normal file
40
packages/loading/src/directive.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import Loading from './index'
|
||||
|
||||
const vLoading = {
|
||||
mounted(el, binding) {
|
||||
const textExr = el.getAttribute('element-loading-text')
|
||||
const spinnerExr = el.getAttribute('element-loading-spinner')
|
||||
const backgroundExr = el.getAttribute('element-loading-background')
|
||||
const customClassExr = el.getAttribute('element-loading-custom-class')
|
||||
const vm = binding.instance
|
||||
const instance = Loading({
|
||||
text: vm && vm[textExr] || textExr,
|
||||
spinner: vm && vm[spinnerExr] || spinnerExr,
|
||||
background: vm && vm[backgroundExr] || backgroundExr,
|
||||
customClass: vm && vm[customClassExr] || customClassExr,
|
||||
fullscreen: !!binding.modifiers.fullscreen,
|
||||
target: !!binding.modifiers.fullscreen ? null : el,
|
||||
body: !!binding.modifiers.body,
|
||||
visible: !!binding.value,
|
||||
lock: !!binding.modifiers.lock,
|
||||
})
|
||||
el.instance = instance
|
||||
},
|
||||
updated(el, binding) {
|
||||
const instance = el.instance
|
||||
if(!instance) return
|
||||
instance.setText(el.getAttribute('element-loading-text'))
|
||||
if (binding.oldValue !== binding.value) {
|
||||
if(binding.value && !instance.visible.value) {
|
||||
instance.visible.value = true
|
||||
} else {
|
||||
instance.visible.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
unmounted(el) {
|
||||
el?.instance?.close()
|
||||
},
|
||||
}
|
||||
|
||||
export default vLoading
|
105
packages/loading/src/index.ts
Normal file
105
packages/loading/src/index.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { createLoadingComponent } from './createLoadingComponent'
|
||||
import type { ILoadingOptions, ILoadingInstance, ILoadingGlobalConfig } from './loading'
|
||||
import { addClass, getStyle, removeClass } from '@element-plus/utils/dom'
|
||||
import PopupManager from '@element-plus/utils/popup-manager'
|
||||
import isServer from '@element-plus/utils/isServer'
|
||||
|
||||
const defaults: ILoadingOptions = {
|
||||
parent: null,
|
||||
background: '',
|
||||
spinner: false,
|
||||
text: null,
|
||||
fullscreen: true,
|
||||
body: false,
|
||||
lock: false,
|
||||
customClass: '',
|
||||
}
|
||||
|
||||
const globalLoadingOption: ILoadingGlobalConfig = {
|
||||
fullscreenLoading: null,
|
||||
}
|
||||
|
||||
const addStyle = (options: ILoadingOptions, parent: HTMLElement, instance: ILoadingInstance) => {
|
||||
const maskStyle: Partial<CSSStyleDeclaration> = {}
|
||||
if (options.fullscreen) {
|
||||
instance.originalPosition.value = getStyle(document.body, 'position')
|
||||
instance.originalOverflow.value = getStyle(document.body, 'overflow')
|
||||
maskStyle.zIndex = String(PopupManager.nextZIndex())
|
||||
} else if (options.body) {
|
||||
instance.originalPosition.value = getStyle(document.body, 'position');
|
||||
['top', 'left'].forEach(property => {
|
||||
const scroll = property === 'top' ? 'scrollTop' : 'scrollLeft'
|
||||
maskStyle[property] = (options.target as HTMLElement).getBoundingClientRect()[property] +
|
||||
document.body[scroll] +
|
||||
document.documentElement[scroll] -
|
||||
parseInt(getStyle(document.body, `margin-${ property }`), 10) +
|
||||
'px'
|
||||
});
|
||||
['height', 'width'].forEach(property => {
|
||||
maskStyle[property] = (options.target as HTMLElement).getBoundingClientRect()[property] + 'px'
|
||||
})
|
||||
} else {
|
||||
instance.originalPosition.value = getStyle(parent, 'position')
|
||||
}
|
||||
Object.keys(maskStyle).forEach(property => {
|
||||
instance.$el.style[property] = maskStyle[property]
|
||||
})
|
||||
}
|
||||
|
||||
const addClassList = (options: ILoadingOptions, parent: HTMLElement, instance: ILoadingInstance) => {
|
||||
if (instance.originalPosition.value !== 'absolute' && instance.originalPosition.value !== 'fixed') {
|
||||
addClass(parent, 'el-loading-parent--relative')
|
||||
} else {
|
||||
removeClass(parent, 'el-loading-parent--relative')
|
||||
}
|
||||
if (options.fullscreen && options.lock) {
|
||||
addClass(parent, 'el-loading-parent--hidden')
|
||||
} else {
|
||||
removeClass(parent, 'el-loading-parent--hidden')
|
||||
}
|
||||
}
|
||||
|
||||
const Loading = function(options: ILoadingOptions = {}): ILoadingInstance{
|
||||
if(isServer) return
|
||||
options = {
|
||||
...defaults,
|
||||
...options,
|
||||
}
|
||||
|
||||
if (typeof options.target === 'string') {
|
||||
options.target = document.querySelector(options.target) as HTMLElement
|
||||
}
|
||||
options.target = options.target || document.body
|
||||
if (options.target !== document.body) {
|
||||
options.fullscreen = false
|
||||
} else {
|
||||
options.body = true
|
||||
}
|
||||
|
||||
if (options.fullscreen && globalLoadingOption.fullscreenLoading) {
|
||||
globalLoadingOption.fullscreenLoading.close()
|
||||
}
|
||||
|
||||
const parent = options.body ? document.body : options.target
|
||||
options.parent = parent
|
||||
|
||||
const instance = createLoadingComponent({
|
||||
options,
|
||||
globalLoadingOption,
|
||||
})
|
||||
|
||||
addStyle(options, parent, instance)
|
||||
addClassList(options, parent, instance)
|
||||
|
||||
options.parent.vLoadingAddClassList = () => {
|
||||
addClassList(options, parent, instance)
|
||||
}
|
||||
|
||||
parent.appendChild(instance.$el)
|
||||
if (options.fullscreen) {
|
||||
globalLoadingOption.fullscreenLoading = instance
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
export default Loading
|
47
packages/loading/src/loading.d.ts
vendored
Normal file
47
packages/loading/src/loading.d.ts
vendored
Normal file
@ -0,0 +1,47 @@
|
||||
import type { Ref, VNode } from 'vue'
|
||||
|
||||
export type ILoadingOptions = {
|
||||
parent?: ILoadingParentElement
|
||||
background?: string
|
||||
spinner?: boolean | string
|
||||
text?: string
|
||||
fullscreen?: boolean
|
||||
body?: boolean
|
||||
lock?: boolean
|
||||
customClass?: string
|
||||
visible?: boolean
|
||||
target?: string | HTMLElement
|
||||
}
|
||||
|
||||
export type ILoadingInstance = {
|
||||
parent?: Ref<ILoadingParentElement>
|
||||
background?: Ref<string>
|
||||
spinner?: Ref<boolean | string>
|
||||
text?: Ref<string>
|
||||
fullscreen?: Ref<boolean>
|
||||
body?: Ref<boolean>
|
||||
lock?: Ref<boolean>
|
||||
customClass?: Ref<string>
|
||||
visible?: Ref<boolean>
|
||||
target?: Ref<string | HTMLElement>
|
||||
originalPosition?: Ref<string>
|
||||
originalOverflow?: Ref<string>
|
||||
setText: (text: string) => void
|
||||
close: () => void
|
||||
handleAfterLeave: () => void
|
||||
vm: VNode
|
||||
$el: HTMLElement
|
||||
}
|
||||
|
||||
export type ILoadingGlobalConfig = {
|
||||
fullscreenLoading: ILoadingInstance
|
||||
}
|
||||
|
||||
export type ILoadingCreateComponentParams = {
|
||||
options: ILoadingOptions
|
||||
globalLoadingOption: ILoadingGlobalConfig
|
||||
}
|
||||
|
||||
export interface ILoadingParentElement extends HTMLElement {
|
||||
vLoadingAddClassList?: () => void
|
||||
}
|
Loading…
Reference in New Issue
Block a user