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:
bastarder 2020-10-23 10:15:49 +08:00 committed by GitHub
parent d8882d839f
commit ee3b42fb09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 588 additions and 0 deletions

View File

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

View 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()
})
})

View 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
}

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

View 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
},
}
}

View 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

View 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
View 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
}