From 39f439e43631856531af39c679e1185a53cdcef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=A5=E6=99=93=E5=90=8C=E4=B8=B6?= Date: Thu, 24 Jul 2025 17:47:40 +0800 Subject: [PATCH] feat(components): [dialog] add `transition` prop (#21479) * feat(components): [dialog] add `transition` prop * fix: revert export * chore: add test * chore: remove useless mode * refactor: use built-in utils & simpify code * refactor: update use-dialog.ts * refactor: update use-dialog.ts * fix: add warn tip * chore: change warn description * refactor: remove classes & add doc tip --- docs/en-US/component/dialog.md | 81 +++++--- docs/examples/dialog/custom-animation.vue | 194 ++++++++++++++++++ .../dialog/__tests__/dialog.test.tsx | 43 ++++ packages/components/dialog/src/constants.ts | 2 + packages/components/dialog/src/dialog.ts | 17 +- packages/components/dialog/src/dialog.vue | 11 +- packages/components/dialog/src/use-dialog.ts | 56 ++++- 7 files changed, 362 insertions(+), 42 deletions(-) create mode 100644 docs/examples/dialog/custom-animation.vue diff --git a/docs/en-US/component/dialog.md b/docs/en-US/component/dialog.md index d7190b549a..ef498468e1 100644 --- a/docs/en-US/component/dialog.md +++ b/docs/en-US/component/dialog.md @@ -131,6 +131,26 @@ dialog/modal ::: +## Custom Animation ^(2.10.5) + +Customize dialog animation through the `transition` attribute, which accepts either: + +- ​Transition name​​ (string) + +- ​​Vue transition configuration​​ (object) + +:::demo Examples include scale, slide, fade, bounce animations and object-based configurations with custom event handlers. + +dialog/custom-animation + +::: + +:::tip + +Animation classes are dynamically generated based on the transition name. For granular control over animation behavior, you may explicitly define these classes. Refer to [custom-transition-classes](https://vuejs.org/guide/built-ins/transition.html#custom-transition-classes) for details. + +::: + ## Events Open developer console (ctrl + shift + J), to see order of events. @@ -145,36 +165,37 @@ dialog/events ### Attributes -| Name | Description | Type | Default | -| -------------------------- | ---------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- | -| model-value / v-model | visibility of Dialog | ^[boolean] | — | -| title | title of Dialog. Can also be passed with a named slot (see the following table) | ^[string] | '' | -| width | width of Dialog, default is 50% | ^[string] / ^[number] | '' | -| fullscreen | whether the Dialog takes up full screen | ^[boolean] | false | -| top | value for `margin-top` of Dialog CSS, default is 15vh | ^[string] | '' | -| modal | whether a mask is displayed | ^[boolean] | true | -| modal-class | custom class names for mask | ^[string] | — | -| header-class ^(2.9.3) | custom class names for header wrapper | ^[string] | — | -| body-class ^(2.9.3) | custom class names for body wrapper | ^[string] | — | -| footer-class ^(2.9.3) | custom class names for footer wrapper | ^[string] | — | -| append-to-body | whether to append Dialog itself to body. A nested Dialog should have this attribute set to `true` | ^[boolean] | false | -| append-to ^(2.4.3) | which element the Dialog appends to. Will override `append-to-body` | ^[CSSSelector] / ^[HTMLElement] | body | -| lock-scroll | whether scroll of body is disabled while Dialog is displayed | ^[boolean] | true | -| open-delay | the Time(milliseconds) before open | ^[number] | 0 | -| close-delay | the Time(milliseconds) before close | ^[number] | 0 | -| close-on-click-modal | whether the Dialog can be closed by clicking the mask | ^[boolean] | true | -| close-on-press-escape | whether the Dialog can be closed by pressing ESC | ^[boolean] | true | -| show-close | whether to show a close button | ^[boolean] | true | -| before-close | callback before Dialog closes, and it will prevent Dialog from closing, use done to close the dialog | ^[Function]`(done: DoneFn) => void` | — | -| draggable | enable dragging feature for Dialog | ^[boolean] | false | -| overflow ^(2.5.4) | draggable Dialog can overflow the viewport | ^[boolean] | false | -| center | whether to align the header and footer in center | ^[boolean] | false | -| align-center ^(2.2.16) | whether to align the dialog both horizontally and vertically | ^[boolean] | false | -| destroy-on-close | destroy elements in Dialog when closed | ^[boolean] | false | -| close-icon | custom close icon, default is Close | ^[string] / ^[Component] | — | -| z-index | same as z-index in native CSS, z-order of dialog | ^[number] | — | -| header-aria-level ^(a11y) | header's `aria-level` attribute | ^[string] | 2 | -| custom-class ^(deprecated) | custom class names for Dialog | ^[string] | '' | +| Name | Description | Type | Default | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | ----------- | +| model-value / v-model | visibility of Dialog | ^[boolean] | — | +| title | title of Dialog. Can also be passed with a named slot (see the following table) | ^[string] | '' | +| width | width of Dialog, default is 50% | ^[string] / ^[number] | '' | +| fullscreen | whether the Dialog takes up full screen | ^[boolean] | false | +| top | value for `margin-top` of Dialog CSS, default is 15vh | ^[string] | '' | +| modal | whether a mask is displayed | ^[boolean] | true | +| modal-class | custom class names for mask | ^[string] | — | +| header-class ^(2.9.3) | custom class names for header wrapper | ^[string] | — | +| body-class ^(2.9.3) | custom class names for body wrapper | ^[string] | — | +| footer-class ^(2.9.3) | custom class names for footer wrapper | ^[string] | — | +| append-to-body | whether to append Dialog itself to body. A nested Dialog should have this attribute set to `true` | ^[boolean] | false | +| append-to ^(2.4.3) | which element the Dialog appends to. Will override `append-to-body` | ^[CSSSelector] / ^[HTMLElement] | body | +| lock-scroll | whether scroll of body is disabled while Dialog is displayed | ^[boolean] | true | +| open-delay | the Time(milliseconds) before open | ^[number] | 0 | +| close-delay | the Time(milliseconds) before close | ^[number] | 0 | +| close-on-click-modal | whether the Dialog can be closed by clicking the mask | ^[boolean] | true | +| close-on-press-escape | whether the Dialog can be closed by pressing ESC | ^[boolean] | true | +| show-close | whether to show a close button | ^[boolean] | true | +| before-close | callback before Dialog closes, and it will prevent Dialog from closing, use done to close the dialog | ^[Function]`(done: DoneFn) => void` | — | +| draggable | enable dragging feature for Dialog | ^[boolean] | false | +| overflow ^(2.5.4) | draggable Dialog can overflow the viewport | ^[boolean] | false | +| center | whether to align the header and footer in center | ^[boolean] | false | +| align-center ^(2.2.16) | whether to align the dialog both horizontally and vertically | ^[boolean] | false | +| destroy-on-close | destroy elements in Dialog when closed | ^[boolean] | false | +| close-icon | custom close icon, default is Close | ^[string] / ^[Component] | — | +| z-index | same as z-index in native CSS, z-order of dialog | ^[number] | — | +| header-aria-level ^(a11y) | header's `aria-level` attribute | ^[string] | 2 | +| transition ^(2.10.5) | custom transition configuration for dialog animation. Can be a string (transition name) or an object with Vue transition props | ^[string] / ^[object]`TransitionProps` | dialog-fade | +| custom-class ^(deprecated) | custom class names for Dialog | ^[string] | '' | :::warning diff --git a/docs/examples/dialog/custom-animation.vue b/docs/examples/dialog/custom-animation.vue new file mode 100644 index 0000000000..10a21de417 --- /dev/null +++ b/docs/examples/dialog/custom-animation.vue @@ -0,0 +1,194 @@ + + + + + + + diff --git a/packages/components/dialog/__tests__/dialog.test.tsx b/packages/components/dialog/__tests__/dialog.test.tsx index 14d1ccd0bf..9f29605b09 100644 --- a/packages/components/dialog/__tests__/dialog.test.tsx +++ b/packages/components/dialog/__tests__/dialog.test.tsx @@ -417,4 +417,47 @@ describe('Dialog.vue', () => { ) }) }) + + describe('transition', () => { + test('dialog supports transition as string', async () => { + const wrapper = mount( + + {AXIOM} + + ) + await nextTick() + expect(wrapper.find('.slide-enter-active').exists()).toBe(true) + }) + + test('dialog supports transition as object config', async () => { + vi.useRealTimers() + const afterEnter = vi.fn() + const transitionConfig = { + name: 'dialog-custom-object', + appear: true, + duration: 500, + enterActiveClass: 'dialog-custom-object-enter-active', + leaveActiveClass: 'dialog-custom-object-leave-active', + enterFromClass: 'dialog-custom-object-enter-from', + leaveToClass: 'dialog-custom-object-leave-to', + onAfterEnter: afterEnter, + } + + const wrapper = mount( + + {AXIOM} + + ) + + await nextTick() + expect(wrapper.find('.dialog-custom-object-enter-active').exists()).toBe( + true + ) + + await new Promise((resolve) => setTimeout(resolve, 500)) + await nextTick() + await rAF() + expect(afterEnter).toHaveBeenCalled() + }) + }) }) diff --git a/packages/components/dialog/src/constants.ts b/packages/components/dialog/src/constants.ts index d2d4e0bfb9..db812860de 100644 --- a/packages/components/dialog/src/constants.ts +++ b/packages/components/dialog/src/constants.ts @@ -12,3 +12,5 @@ export type DialogContext = { export const dialogInjectionKey: InjectionKey = Symbol('dialogInjectionKey') + +export const DEFAULT_DIALOG_TRANSITION = 'dialog-fade' diff --git a/packages/components/dialog/src/dialog.ts b/packages/components/dialog/src/dialog.ts index 75dd406484..2e7ff22c75 100644 --- a/packages/components/dialog/src/dialog.ts +++ b/packages/components/dialog/src/dialog.ts @@ -1,14 +1,22 @@ import { buildProps, definePropType, isBoolean } from '@element-plus/utils' import { UPDATE_MODEL_EVENT } from '@element-plus/constants' import { teleportProps } from '@element-plus/components/teleport' +import { DEFAULT_DIALOG_TRANSITION } from './constants' import { dialogContentProps } from './dialog-content' -import type { ExtractPropTypes, __ExtractPublicPropTypes } from 'vue' +import type { + ExtractPropTypes, + TransitionProps, + __ExtractPublicPropTypes, +} from 'vue' import type Dialog from './dialog.vue' type DoneFn = (cancel?: boolean) => void + export type DialogBeforeCloseFn = (done: DoneFn) => void +export type DialogTransition = string | TransitionProps + export const dialogProps = buildProps({ ...dialogContentProps, /** @@ -120,6 +128,13 @@ export const dialogProps = buildProps({ type: String, default: '2', }, + /** + * @description custom transition configuration for dialog animation, it can be a string (transition name) or an object with Vue transition props + */ + transition: { + type: definePropType([String, Object]), + default: DEFAULT_DIALOG_TRANSITION, + }, } as const) export type DialogProps = ExtractPropTypes diff --git a/packages/components/dialog/src/dialog.vue b/packages/components/dialog/src/dialog.vue index 2e86d95c10..091c430fa5 100644 --- a/packages/components/dialog/src/dialog.vue +++ b/packages/components/dialog/src/dialog.vue @@ -3,12 +3,7 @@ :to="appendTo" :disabled="appendTo !== 'body' ? false : !appendToBody" > - + @@ -62,6 +73,46 @@ export const useDialog = ( return {} }) + const transitionConfig = computed(() => { + const baseConfig = { + name: props.transition, + onAfterEnter: afterEnter, + onBeforeLeave: beforeLeave, + onAfterLeave: afterLeave, + } + if (isObject(props.transition)) { + const config = { ...props.transition } as TransitionProps + const _mergeHook = ( + userHook: Arrayable<(el: Element) => void> | undefined, + defaultHook: () => void + ) => { + return (el: Element) => { + if (isArray(userHook)) { + userHook.forEach((fn) => { + if (isFunction(fn)) fn(el) + }) + } else if (isFunction(userHook)) { + userHook(el) + } + defaultHook() + } + } + config.onAfterEnter = _mergeHook(config.onAfterEnter, afterEnter) + config.onBeforeLeave = _mergeHook(config.onBeforeLeave, beforeLeave) + config.onAfterLeave = _mergeHook(config.onAfterLeave, afterLeave) + if (!config.name) { + config.name = DEFAULT_DIALOG_TRANSITION + debugWarn( + COMPONENT_NAME, + `transition.name is missing when using object syntax, fallback to '${DEFAULT_DIALOG_TRANSITION}'` + ) + } + return config + } + + return baseConfig + }) + function afterEnter() { emit('opened') } @@ -227,5 +278,6 @@ export const useDialog = ( rendered, visible, zIndex, + transitionConfig, } }