diff --git a/packages/components/loading/__tests__/loading.spec.ts b/packages/components/loading/__tests__/loading.spec.ts index 950393f3d3..0233d7f7e1 100644 --- a/packages/components/loading/__tests__/loading.spec.ts +++ b/packages/components/loading/__tests__/loading.spec.ts @@ -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 diff --git a/packages/components/loading/index.ts b/packages/components/loading/index.ts index ec3c45d592..b43e2322d1 100644 --- a/packages/components/loading/index.ts +++ b/packages/components/loading/index.ts @@ -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' diff --git a/packages/components/loading/src/createLoadingComponent.ts b/packages/components/loading/src/createLoadingComponent.ts deleted file mode 100644 index e85a73999a..0000000000 --- a/packages/components/loading/src/createLoadingComponent.ts +++ /dev/null @@ -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 = 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 - }, - } -} diff --git a/packages/components/loading/src/directive.ts b/packages/components/loading/src/directive.ts index aa03454af0..bfa2bb5d02 100644 --- a/packages/components/loading/src/directive.ts +++ b/packages/components/loading/src/directive.ts @@ -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 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 +) => { 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 = ( + 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 = (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, + originalOptions: LoadingOptions +) => { + for (const key of Object.keys(originalOptions)) { + if (isRef(originalOptions[key])) + originalOptions[key].value = newOptions[key] + } +} + +export const vLoading: Directive = { + 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 diff --git a/packages/components/loading/src/index.ts b/packages/components/loading/src/index.ts deleted file mode 100644 index 41913f3c07..0000000000 --- a/packages/components/loading/src/index.ts +++ /dev/null @@ -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 = {} - 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 diff --git a/packages/components/loading/src/loading.ts b/packages/components/loading/src/loading.ts new file mode 100644 index 0000000000..a96af41e4d --- /dev/null +++ b/packages/components/loading/src/loading.ts @@ -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 diff --git a/packages/components/loading/src/loading.type.ts b/packages/components/loading/src/loading.type.ts deleted file mode 100644 index 924599b9d0..0000000000 --- a/packages/components/loading/src/loading.type.ts +++ /dev/null @@ -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 - background?: Ref - spinner?: Ref - text?: Ref - fullscreen?: Ref - body?: Ref - lock?: Ref - customClass?: Ref - visible?: Ref - target?: Ref - originalPosition?: Ref - originalOverflow?: Ref - 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 -} diff --git a/packages/components/loading/src/service.ts b/packages/components/loading/src/service.ts new file mode 100644 index 0000000000..ee452a317a --- /dev/null +++ b/packages/components/loading/src/service.ts @@ -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(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') + } +} diff --git a/packages/components/loading/src/types.ts b/packages/components/loading/src/types.ts new file mode 100644 index 0000000000..7636490dbd --- /dev/null +++ b/packages/components/loading/src/types.ts @@ -0,0 +1,27 @@ +import type { MaybeRef } from '@vueuse/core' + +export type LoadingOptionsResolved = { + parent: LoadingParentElement + background: MaybeRef + svg: MaybeRef + svgViewBox: MaybeRef + spinner: MaybeRef + text: MaybeRef + fullscreen: boolean + lock: boolean + customClass: MaybeRef + visible: boolean + target: HTMLElement + beforeClose?: () => boolean + closed?: () => void +} +export type LoadingOptions = Partial< + Omit & { + target: HTMLElement | string + body: boolean + } +> + +export interface LoadingParentElement extends HTMLElement { + vLoadingAddClassList?: () => void +}