mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
194
docs/examples/dialog/custom-animation.vue
Normal file
194
docs/examples/dialog/custom-animation.vue
Normal 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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,3 +12,5 @@ export type DialogContext = {
|
||||
|
||||
export const dialogInjectionKey: InjectionKey<DialogContext> =
|
||||
Symbol('dialogInjectionKey')
|
||||
|
||||
export const DEFAULT_DIALOG_TRANSITION = 'dialog-fade'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user