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
This commit is contained in:
知晓同丶
2025-07-24 17:47:40 +08:00
committed by GitHub
parent 59374e86d0
commit 39f439e436
7 changed files with 362 additions and 42 deletions

View File

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

View File

@@ -0,0 +1,194 @@
<template>
<div class="demo-block">
<div class="demo-buttons">
<el-button plain @click="openDialog('fade')"> Default </el-button>
<el-button plain @click="openDialog('scale')"> Scale </el-button>
<el-button plain @click="openDialog('slide')"> Slide </el-button>
<el-button plain @click="openDialog('bounce')"> Bounce </el-button>
<el-button plain @click="openDialogWithObject"> Object Config </el-button>
</div>
<el-dialog
ref="dialogRef"
v-model="dialogVisible"
class="custom-transition-dialog"
:title="`${currentAnimation} Animation Dialog`"
width="30%"
:transition="transitionConfig"
>
<div class="dialog-content">
<p>
Current animation: <strong>{{ currentAnimation }}</strong>
</p>
<p>
This dialog demonstrates the {{ currentAnimation }} animation effect.
</p>
<p v-if="isObjectConfig">
<strong>Using object configuration:</strong><br />
<code>{{ JSON.stringify(transitionConfig, null, 2) }}</code>
</p>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="dialogVisible = false">
Confirm
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import type { DialogTransition } from 'element-plus'
const dialogVisible = ref(false)
const currentAnimation = ref('fade')
const dialogRef = ref()
const isObjectConfig = ref(false)
const transitionConfig = computed<DialogTransition>(() => {
if (isObjectConfig.value) {
return {
name: 'dialog-custom-object',
appear: true,
mode: 'out-in',
duration: 500,
}
}
return `dialog-${currentAnimation.value}`
})
const openDialog = (type: string) => {
currentAnimation.value = type
isObjectConfig.value = false
dialogVisible.value = true
}
const openDialogWithObject = () => {
currentAnimation.value = 'object-config'
isObjectConfig.value = true
dialogVisible.value = true
}
</script>
<style scoped>
.demo-buttons {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.dialog-content {
line-height: 1.6;
}
.dialog-content p {
margin: 8px 0;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
code {
background: var(--el-bg-color-page);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
display: block;
margin-top: 8px;
}
</style>
<style>
/* Scale Animation */
.dialog-scale-enter-active,
.dialog-scale-leave-active {
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.dialog-scale-enter-active .el-dialog,
.dialog-scale-leave-active .el-dialog {
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.dialog-scale-enter-from .el-dialog {
transform: scale(0.5);
opacity: 0;
}
.dialog-scale-leave-to .el-dialog {
transform: scale(0.5);
opacity: 0;
}
/* Slide Animation */
.dialog-slide-enter-active,
.dialog-slide-leave-active {
transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.dialog-slide-enter-active .el-dialog,
.dialog-slide-leave-active .el-dialog {
transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.dialog-slide-enter-from .el-dialog {
transform: translateY(-100px);
opacity: 0;
}
.dialog-slide-leave-to .el-dialog {
transform: translateY(-100px);
opacity: 0;
}
/* Bounce Animation */
.dialog-bounce-enter-active,
.dialog-bounce-leave-active {
transition: all 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.dialog-bounce-enter-active .el-dialog,
.dialog-bounce-leave-active .el-dialog {
transition: all 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
.dialog-bounce-enter-from .el-dialog {
transform: scale(0.3) translateY(-50px);
opacity: 0;
}
.dialog-bounce-leave-to .el-dialog {
transform: scale(0.3) translateY(-50px);
opacity: 0;
}
/* Object Configuration Animation */
.dialog-custom-object-enter-active,
.dialog-custom-object-leave-active {
transition: all 0.5s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.dialog-custom-object-enter-active .el-dialog,
.dialog-custom-object-leave-active .el-dialog {
transition: all 0.5s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.dialog-custom-object-enter-from .el-dialog {
transform: rotate(180deg) scale(0.5);
opacity: 0;
}
.dialog-custom-object-leave-to .el-dialog {
transform: rotate(-180deg) scale(0.5);
opacity: 0;
}
</style>

View File

@@ -417,4 +417,47 @@ describe('Dialog.vue', () => {
)
})
})
describe('transition', () => {
test('dialog supports transition as string', async () => {
const wrapper = mount(
<Dialog modelValue={true} transition="slide">
{AXIOM}
</Dialog>
)
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(
<Dialog modelValue={true} transition={transitionConfig}>
{AXIOM}
</Dialog>
)
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()
})
})
})

View File

@@ -12,3 +12,5 @@ export type DialogContext = {
export const dialogInjectionKey: InjectionKey<DialogContext> =
Symbol('dialogInjectionKey')
export const DEFAULT_DIALOG_TRANSITION = 'dialog-fade'

View File

@@ -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<DialogTransition>([String, Object]),
default: DEFAULT_DIALOG_TRANSITION,
},
} as const)
export type DialogProps = ExtractPropTypes<typeof dialogProps>

View File

@@ -3,12 +3,7 @@
:to="appendTo"
:disabled="appendTo !== 'body' ? false : !appendToBody"
>
<transition
name="dialog-fade"
@after-enter="afterEnter"
@after-leave="afterLeave"
@before-leave="beforeLeave"
>
<transition v-bind="transitionConfig">
<el-overlay
v-show="visible"
custom-mask-event
@@ -120,10 +115,8 @@ const {
style,
overlayDialogStyle,
rendered,
transitionConfig,
zIndex,
afterEnter,
afterLeave,
beforeLeave,
handleClose,
onModalClick,
onOpenAutoFocus,

View File

@@ -14,12 +14,23 @@ import {
useZIndex,
} from '@element-plus/hooks'
import { UPDATE_MODEL_EVENT } from '@element-plus/constants'
import { addUnit, isClient } from '@element-plus/utils'
import {
addUnit,
debugWarn,
isArray,
isClient,
isFunction,
isObject,
} from '@element-plus/utils'
import { useGlobalConfig } from '@element-plus/components/config-provider'
import { DEFAULT_DIALOG_TRANSITION } from './constants'
import type { CSSProperties, Ref, SetupContext } from 'vue'
import type { CSSProperties, Ref, SetupContext, TransitionProps } from 'vue'
import type { Arrayable } from '@element-plus/utils'
import type { DialogEmits, DialogProps } from './dialog'
const COMPONENT_NAME = 'ElDialog'
export const useDialog = (
props: DialogProps,
targetRef: Ref<HTMLElement | undefined>
@@ -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,
}
}