refactor(components): refactor loading (#4750)

This commit is contained in:
三咲智子 2021-12-10 17:52:11 +08:00 committed by GitHub
parent b6c077e2c1
commit 3d019cfbac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 410 additions and 418 deletions

View File

@ -1,9 +1,9 @@
import { nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import { sleep } from '@element-plus/test-utils'
import Loading from '../src/index'
import { Loading } from '../src/service'
import { vLoading } from '../src/directive'
import ElInput from '../../input'
import vLoading from '../src/directive'
function destroyLoadingInstance(loadingInstance) {
if (!loadingInstance) return

View File

@ -1,10 +1,10 @@
import Loading from './src'
import vLoading from './src/directive'
import { Loading } from './src/service'
import { vLoading } from './src/directive'
import type { App } from 'vue'
// installer and everything in all
const ElLoading = {
export const ElLoading = {
install(app: App) {
app.directive('loading', vLoading)
app.config.globalProperties.$loading = Loading
@ -15,9 +15,7 @@ const ElLoading = {
export default ElLoading
export { ElLoading }
export const ElLoadingDirective = vLoading
export const ElLoadingService = Loading
export * from './src/loading.type'
export * from './src/types'

View File

@ -1,167 +0,0 @@
import {
createVNode,
h,
reactive,
ref,
render,
toRefs,
Transition,
vShow,
withCtx,
withDirectives,
} from 'vue'
import { removeClass } from '@element-plus/utils/dom'
import type { VNode } from 'vue'
import type { Nullable } from '@element-plus/utils/types'
import type {
ILoadingCreateComponentParams,
ILoadingInstance,
} from './loading.type'
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: false,
})
function setText(text: string) {
data.text = text
}
function destroySelf() {
const target = data.parent
if (!target.vLoadingAddClassList) {
let loadingNumber: number | string = target.getAttribute('loading-number')
loadingNumber = Number.parseInt(loadingNumber) - 1
if (!loadingNumber) {
removeClass(target, 'el-loading-parent--relative')
target.removeAttribute('loading-number')
} else {
target.setAttribute('loading-number', loadingNumber.toString())
}
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
destroySelf()
}
}, 400)
data.visible = false
}
function handleAfterLeave() {
if (!afterLeaveFlag.value) return
afterLeaveFlag.value = false
destroySelf()
}
const componentSetupConfig = {
...toRefs(data),
setText,
close,
handleAfterLeave,
}
const elLoadingComponent = {
name: 'ElLoading',
setup() {
return componentSetupConfig
},
render() {
const svg = this.spinner || this.svg
const spinner = h(
'svg',
{
class: 'circular',
viewBox: this.svgViewBox ? this.svgViewBox : '25 25 50 50',
...(svg ? { innerHTML: svg } : {}),
},
[
h('circle', {
class: 'path',
cx: '50',
cy: '50',
r: '20',
fill: 'none',
}),
]
)
const spinnerText = h('p', { class: 'el-loading-text' }, [this.text])
return h(
Transition,
{
name: 'el-loading-fade',
onAfterLeave: this.handleAfterLeave,
},
{
default: withCtx(() => [
withDirectives(
createVNode(
'div',
{
style: {
backgroundColor: this.background || '',
},
class: [
'el-loading-mask',
this.customClass,
this.fullscreen ? 'is-fullscreen' : '',
],
},
[
h(
'div',
{
class: 'el-loading-spinner',
},
[spinner, this.text ? spinnerText : null]
),
]
),
[[vShow, this.visible]]
),
]),
}
)
},
}
vm = createVNode(elLoadingComponent)
render(vm, document.createElement('div'))
return {
...componentSetupConfig,
vm,
get $el() {
return vm.el as HTMLElement
},
}
}

View File

@ -1,56 +1,94 @@
import Loading from './index'
import { isRef, ref } from 'vue'
import { isObject, isString, hyphenate } from '@vue/shared'
import { Loading } from './service'
import type { Directive, DirectiveBinding, UnwrapRef } from 'vue'
import type { LoadingOptions } from './types'
import type { LoadingInstance } from './loading'
import type { DirectiveBinding } from 'vue'
import type { ILoadingInstance } from './loading.type'
const INSTANCE_NAME = 'ElLoading'
const INSTANCE_KEY = Symbol('ElLoading')
export type LoadingBinding = boolean | UnwrapRef<LoadingOptions>
export interface ElementLoading extends HTMLElement {
[INSTANCE_NAME]?: ILoadingInstance
[INSTANCE_KEY]?: {
instance: LoadingInstance
options: LoadingOptions
}
}
const createInstance = (el: ElementLoading, binding: DirectiveBinding) => {
const textExr = el.getAttribute('element-loading-text')
const spinnerExr = el.getAttribute('element-loading-spinner')
const svgExr = el.getAttribute('element-loading-svg')
const svgViewBoxExr = el.getAttribute('element-loading-svg-view-box')
const backgroundExr = el.getAttribute('element-loading-background')
const customClassExr = el.getAttribute('element-loading-custom-class')
const createInstance = (
el: ElementLoading,
binding: DirectiveBinding<LoadingBinding>
) => {
const vm = binding.instance
el[INSTANCE_NAME] = Loading({
text: (vm && vm[textExr]) || textExr,
svg: (vm && vm[svgExr]) || svgExr,
svgViewBox: (vm && vm[svgViewBoxExr]) || svgViewBoxExr,
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: true,
lock: !!binding.modifiers.lock,
})
const getBindingProp = <K extends keyof LoadingOptions>(
key: K
): LoadingOptions[K] =>
isObject(binding.value) ? binding.value[key] : undefined
const resolveExpression = (key: any) => {
const data = (isString(key) && vm?.[key]) || key
if (data) return ref(data)
else return data
}
const getProp = <K extends keyof LoadingOptions>(name: K) =>
resolveExpression(
getBindingProp(name) ||
el.getAttribute(`element-loading-${hyphenate(name)}`)
)
const fullscreen =
getBindingProp('fullscreen') ?? binding.modifiers.fullscreen
const options: LoadingOptions = {
text: getProp('text'),
svg: getProp('svg'),
svgViewBox: getProp('svgViewBox'),
spinner: getProp('spinner'),
background: getProp('background'),
customClass: getProp('customClass'),
fullscreen,
target: getBindingProp('target') ?? (fullscreen ? undefined : el),
body: getBindingProp('body') ?? binding.modifiers.body,
lock: getBindingProp('lock') ?? binding.modifiers.lock,
}
el[INSTANCE_KEY] = {
options,
instance: Loading(options),
}
}
const vLoading = {
mounted(el: ElementLoading, binding: DirectiveBinding) {
const updateOptions = (
newOptions: UnwrapRef<LoadingOptions>,
originalOptions: LoadingOptions
) => {
for (const key of Object.keys(originalOptions)) {
if (isRef(originalOptions[key]))
originalOptions[key].value = newOptions[key]
}
}
export const vLoading: Directive<ElementLoading, LoadingBinding> = {
mounted(el, binding) {
if (binding.value) {
createInstance(el, binding)
}
},
updated(el: ElementLoading, binding: DirectiveBinding) {
const instance = el[INSTANCE_NAME]
updated(el, binding) {
const instance = el[INSTANCE_KEY]
if (binding.oldValue !== binding.value) {
if (binding.value) {
if (binding.value && !binding.oldValue) {
createInstance(el, binding)
} else if (binding.value && binding.oldValue) {
if (isObject(binding.value))
updateOptions(binding.value, instance!.options)
} else {
instance?.close()
instance?.instance.close()
}
}
},
unmounted(el: ElementLoading) {
el[INSTANCE_NAME]?.close()
unmounted(el) {
el[INSTANCE_KEY]?.instance.close()
},
}
export default vLoading

View File

@ -1,158 +0,0 @@
import { nextTick } from 'vue'
import { hasOwn } from '@vue/shared'
import { addClass, getStyle, removeClass } from '@element-plus/utils/dom'
import PopupManager from '@element-plus/utils/popup-manager'
import isServer from '@element-plus/utils/isServer'
import { createLoadingComponent } from './createLoadingComponent'
import type { CSSProperties } from 'vue'
import type {
ILoadingGlobalConfig,
ILoadingInstance,
ILoadingOptions,
} from './loading.type'
const defaults: ILoadingOptions = {
parent: null,
background: '',
svg: null,
svgViewBox: null,
spinner: false,
text: null,
fullscreen: true,
body: false,
lock: false,
customClass: '',
}
const globalLoadingOption: ILoadingGlobalConfig = {
fullscreenLoading: null,
}
const addStyle = async (
options: ILoadingOptions,
parent: HTMLElement,
instance: ILoadingInstance
) => {
const maskStyle: Partial<CSSProperties> = {}
if (options.fullscreen) {
instance.originalPosition.value = getStyle(document.body, 'position')
instance.originalOverflow.value = getStyle(document.body, 'overflow')
maskStyle.zIndex = PopupManager.nextZIndex()
} else if (options.body) {
instance.originalPosition.value = getStyle(document.body, 'position')
/**
* await dom render when visible is true in init,
* because some component's height maybe 0.
* e.g. el-table.
*/
await nextTick()
;['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)
}
/**
* add loading-number to parent.
* because if a fullscreen loading is triggered when somewhere
* a v-loading.body was triggered before and it's parent is
* document.body which with a margin , the fullscreen loading's
* destroySelf function will remove 'el-loading-parent--relative',
* and then the position of v-loading.body will be error.
*/
let loadingNumber: number | string = parent.getAttribute('loading-number')
if (!loadingNumber) {
loadingNumber = 1
} else {
loadingNumber = Number.parseInt(loadingNumber) + 1
}
parent.setAttribute('loading-number', loadingNumber.toString())
parent.appendChild(instance.$el)
// after instance render, then modify visible to trigger transition
nextTick().then(() => {
instance.visible.value = hasOwn(options, 'visible') ? options.visible : true
})
if (options.fullscreen) {
globalLoadingOption.fullscreenLoading = instance
}
return instance
}
export default Loading

View File

@ -0,0 +1,155 @@
import {
createApp,
h,
reactive,
ref,
createVNode,
toRefs,
Transition,
vShow,
withCtx,
withDirectives,
} from 'vue'
import { removeClass } from '@element-plus/utils/dom'
import type { LoadingOptionsResolved } from './types'
export function createLoadingComponent(options: LoadingOptionsResolved) {
let afterLeaveTimer: number
const afterLeaveFlag = ref(false)
const data = reactive({
...options,
originalPosition: '',
originalOverflow: '',
visible: false,
})
function setText(text: string) {
data.text = text
}
function destroySelf() {
const target = data.parent
if (!target.vLoadingAddClassList) {
let loadingNumber: number | string | null =
target.getAttribute('loading-number')
loadingNumber = Number.parseInt(loadingNumber as any) - 1
if (!loadingNumber) {
removeClass(target, 'el-loading-parent--relative')
target.removeAttribute('loading-number')
} else {
target.setAttribute('loading-number', loadingNumber.toString())
}
removeClass(target, 'el-loading-parent--hidden')
}
vm.$el?.parentNode?.removeChild(vm.$el)
}
function close() {
if (options.beforeClose && !options.beforeClose()) return
const target = data.parent
target.vLoadingAddClassList = undefined
afterLeaveFlag.value = true
clearTimeout(afterLeaveTimer)
afterLeaveTimer = window.setTimeout(() => {
if (afterLeaveFlag.value) {
afterLeaveFlag.value = false
destroySelf()
}
}, 400)
data.visible = false
options.closed?.()
}
function handleAfterLeave() {
if (!afterLeaveFlag.value) return
afterLeaveFlag.value = false
destroySelf()
}
const elLoadingComponent = {
name: 'ElLoading',
setup() {
return () => {
const svg = data.spinner || data.svg
const spinner = h(
'svg',
{
class: 'circular',
viewBox: data.svgViewBox ? data.svgViewBox : '25 25 50 50',
...(svg ? { innerHTML: svg } : {}),
},
[
h('circle', {
class: 'path',
cx: '50',
cy: '50',
r: '20',
fill: 'none',
}),
]
)
const spinnerText = data.text
? h('p', { class: 'el-loading-text' }, [data.text])
: undefined
return h(
Transition,
{
name: 'el-loading-fade',
onAfterLeave: handleAfterLeave,
},
{
default: withCtx(() => [
withDirectives(
createVNode(
'div',
{
style: {
backgroundColor: data.background || '',
},
class: [
'el-loading-mask',
data.customClass,
data.fullscreen ? 'is-fullscreen' : '',
],
},
[
h(
'div',
{
class: 'el-loading-spinner',
},
[spinner, spinnerText]
),
]
),
[[vShow, data.visible]]
),
]),
}
)
}
},
}
const vm = createApp(elLoadingComponent).mount(document.createElement('div'))
return {
...toRefs(data),
setText,
close,
handleAfterLeave,
vm,
get $el(): HTMLElement {
return vm.$el
},
}
}
export type LoadingInstance = ReturnType<typeof createLoadingComponent>

View File

@ -1,49 +0,0 @@
import type { Ref, VNode } from 'vue'
export type ILoadingOptions = {
parent?: ILoadingParentElement
background?: string
svg?: string
svgViewBox?: 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
}

View File

@ -0,0 +1,148 @@
import { nextTick } from 'vue'
import { isString } from '@vue/shared'
import { isClient } from '@vueuse/core'
import { addClass, getStyle, removeClass } from '@element-plus/utils/dom'
import PopupManager from '@element-plus/utils/popup-manager'
import { createLoadingComponent } from './loading'
import type { LoadingInstance } from './loading'
import type { LoadingOptionsResolved } from '..'
import type { LoadingOptions } from './types'
import type { CSSProperties } from 'vue'
let fullscreenInstance: LoadingInstance | undefined = undefined
export const Loading = function (
options: LoadingOptions = {}
): LoadingInstance {
if (!isClient) return undefined as any
const resolved = resolveOptions(options)
if (resolved.fullscreen && fullscreenInstance) {
fullscreenInstance.close()
}
const instance = createLoadingComponent({
...resolved,
closed: () => {
resolved.closed?.()
if (resolved.fullscreen) fullscreenInstance = undefined
},
})
addStyle(resolved, resolved.parent, instance)
addClassList(resolved, resolved.parent, instance)
resolved.parent.vLoadingAddClassList = () =>
addClassList(resolved, resolved.parent, instance)
/**
* add loading-number to parent.
* because if a fullscreen loading is triggered when somewhere
* a v-loading.body was triggered before and it's parent is
* document.body which with a margin , the fullscreen loading's
* destroySelf function will remove 'el-loading-parent--relative',
* and then the position of v-loading.body will be error.
*/
let loadingNumber: string | null =
resolved.parent.getAttribute('loading-number')
if (!loadingNumber) {
loadingNumber = '1'
} else {
loadingNumber = `${Number.parseInt(loadingNumber) + 1}`
}
resolved.parent.setAttribute('loading-number', loadingNumber)
resolved.parent.appendChild(instance.$el)
// after instance render, then modify visible to trigger transition
nextTick(() => (instance.visible.value = resolved.visible))
if (resolved.fullscreen) {
fullscreenInstance = instance
}
return instance
}
const resolveOptions = (options: LoadingOptions): LoadingOptionsResolved => {
let target: HTMLElement
if (isString(options.target)) {
target =
document.querySelector<HTMLElement>(options.target) ?? document.body
} else {
target = options.target || document.body
}
return {
parent: target === document.body || options.body ? document.body : target,
background: options.background || '',
svg: options.svg || '',
svgViewBox: options.svgViewBox || '',
spinner: options.spinner || false,
text: options.text || '',
fullscreen: target === document.body && (options.fullscreen ?? true),
lock: options.lock ?? false,
customClass: options.customClass || '',
visible: options.visible ?? true,
target,
}
}
const addStyle = async (
options: LoadingOptionsResolved,
parent: HTMLElement,
instance: LoadingInstance
) => {
const maskStyle: CSSProperties = {}
if (options.fullscreen) {
instance.originalPosition.value = getStyle(document.body, 'position')
instance.originalOverflow.value = getStyle(document.body, 'overflow')
maskStyle.zIndex = PopupManager.nextZIndex()
} else if (options.parent === document.body) {
instance.originalPosition.value = getStyle(document.body, 'position')
/**
* await dom render when visible is true in init,
* because some component's height maybe 0.
* e.g. el-table.
*/
await nextTick()
for (const property of ['top', 'left']) {
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`
}
for (const property of ['height', 'width']) {
maskStyle[property] = `${
(options.target as HTMLElement).getBoundingClientRect()[property]
}px`
}
} else {
instance.originalPosition.value = getStyle(parent, 'position')
}
for (const [key, value] of Object.entries(maskStyle)) {
instance.$el.style[key] = value
}
}
const addClassList = (
options: LoadingOptions,
parent: HTMLElement,
instance: LoadingInstance
) => {
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')
}
}

View File

@ -0,0 +1,27 @@
import type { MaybeRef } from '@vueuse/core'
export type LoadingOptionsResolved = {
parent: LoadingParentElement
background: MaybeRef<string>
svg: MaybeRef<string>
svgViewBox: MaybeRef<string>
spinner: MaybeRef<boolean | string>
text: MaybeRef<string>
fullscreen: boolean
lock: boolean
customClass: MaybeRef<string>
visible: boolean
target: HTMLElement
beforeClose?: () => boolean
closed?: () => void
}
export type LoadingOptions = Partial<
Omit<LoadingOptionsResolved, 'parent' | 'target'> & {
target: HTMLElement | string
body: boolean
}
>
export interface LoadingParentElement extends HTMLElement {
vLoadingAddClassList?: () => void
}