Dialog 对话框
模态对话框用于必须让用户先给出结论才能继续的场景,例如二次确认、表单填写。MDialog 的展开不是简单的居中出现:白色盒子会以触发元素的中心为原点缩放展开,关闭时缩回同一起点,形成"从哪里点开、就收回到哪里"的 Morph 观感。
代码演示
基础用法
#trigger 插槽渲染触发内容并留在原位,点击时组件自动记录该元素的位置作为动画起点。
#trigger 是推荐的打开方式,键盘可聚焦、位置自然。
<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
const confirmed = ref(false)
function confirm() {
confirmed.value = true
visible.value = false
}
</script>
<template>
<div class="m-row-demo">
<MDialog v-model="visible" title="发布确认" :width="420">
<template #trigger>
<MButton variant="contained" color="primary">打开对话框</MButton>
</template>
<p style="margin: 0 0 8px">正文放在默认插槽,footer 插槽存在时才会渲染底部操作区。</p>
<p style="margin: 0; color: var(--m-text-sub)">
确认状态:{{ confirmed ? '已确认' : '未确认' }}
</p>
<template #footer>
<MButton variant="text" @click="visible = false">取消</MButton>
<MButton variant="contained" color="primary" @click="confirm">确定</MButton>
</template>
</MDialog>
</div>
</template>尺寸
width 接受数字(按 px 处理)或任意 CSS 长度字符串;不传时为 420px,并且始终受 max-width: 100% 约束。
<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
const options: { label: string; width: string | number }[] = [
{ label: '紧凑 320', width: 320 },
{ label: '默认 420', width: 420 },
{ label: '宽 640', width: 640 },
{ label: 'CSS 长度 60vw', width: '60vw' }
]
const current = ref(options[1]!)
function open(option: { label: string; width: string | number }) {
current.value = option
visible.value = true
}
</script>
<template>
<div class="m-row-demo">
<MButton
v-for="option in options"
:key="option.label"
variant="outlined"
@click="open(option)">
{{ option.label }}
</MButton>
</div>
<MDialog v-model="visible" :width="current.width" :title="`width: ${current.width}`">
<p style="margin: 0">
数字按 px 处理,纯数字字符串会被补上 px,其他字符串原样作为 CSS 宽度。
</p>
</MDialog>
</template>圆角
radius 会作为 --m-dialog-radius 下发给白色盒子,缺省时回退到全局令牌 var(--m-radius)(6px)。
<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
const options: { label: string; radius?: string }[] = [
{ label: '默认圆角' },
{ label: '直角 0' },
{ label: '16px' },
{ label: '28px' }
]
const current = ref(options[0]!)
function open(option: { label: string; radius?: string }) {
current.value = option
visible.value = true
}
</script>
<template>
<div class="m-row-demo">
<MButton
v-for="option in options"
:key="option.label"
variant="outlined"
@click="open(option)">
{{ option.label }}
</MButton>
</div>
<MDialog v-model="visible" :radius="current.radius" :title="`radius: ${current.radius ?? '未设置'}`">
<p style="margin: 0">
不传 radius 时圆角回退到全局令牌 <code>var(--m-radius)</code>(6px);全屏模式下该值被忽略。
</p>
</MDialog>
</template>Morph 起点
起点有两种来源:点击 #trigger 插槽时自动捕获,或用 anchor 显式指定任意元素。
两种写法共用同一套动画,#trigger 只是 anchor 的语法糖。
<script setup lang="ts">
import { ref } from 'vue'
const fromTrigger = ref(false)
const fromAnchor = ref(false)
/** anchor 指向的元素:展开与收回都以它的中心为原点 */
const anchorEl = ref<HTMLElement | null>(null)
</script>
<template>
<div class="m-row-demo">
<MDialog v-model="fromTrigger" title="来自 #trigger" :width="360">
<template #trigger>
<MButton variant="outlined">#trigger 触发</MButton>
</template>
<p style="margin: 0">
点击 #trigger 插槽时组件会记录该元素的包围盒,展开与收回都复用同一份起点。
</p>
</MDialog>
<MDialog v-model="fromAnchor" title="来自 anchor" :width="360" :anchor="anchorEl">
<p style="margin: 0">
显式传入 anchor 后,动画起点改由该元素中心决定,与点击位置无关。
</p>
</MDialog>
<span ref="anchorEl" @click="fromAnchor = true">
<MButton variant="contained" color="secondary">anchor 指定元素</MButton>
</span>
</div>
</template>关闭行为
遮罩关闭与 Esc 关闭各有独立开关,用来区分"可以随手关掉"和"必须做出选择"两类对话框。
<script setup lang="ts">
import { ref } from 'vue'
const normal = ref(false)
const locked = ref(false)
const log = ref('')
function onClosed() {
log.value = 'close 事件已触发'
}
</script>
<template>
<div class="m-row-demo">
<MButton variant="outlined" @click="normal = true">遮罩 / Esc 可关闭</MButton>
<MButton variant="outlined" @click="locked = true">只能点关闭按钮</MButton>
<MDialog v-model="normal" title="默认关闭行为" :width="380" @close="onClosed">
<p style="margin: 0">点击遮罩或按 Esc 都会关闭,并抛出 close 事件。</p>
</MDialog>
<MDialog
v-model="locked"
title="禁用遮罩与 Esc"
:width="380"
:close-on-click-overlay="false"
:close-on-press-escape="false">
<p style="margin: 0">
两个关闭开关都设为 false,遮罩点击与 Esc 都不再关闭,只能点右上角关闭按钮。
</p>
</MDialog>
</div>
<p v-if="log" style="margin: 12px 0 0; color: var(--m-text-sub)">{{ log }}</p>
</template>全屏
fullscreen 铺满视口并从底部向上滑入,此时 width 与 radius 都失效。
<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
</script>
<template>
<div class="m-row-demo">
<MButton variant="contained" color="primary" @click="visible = true">全屏打开</MButton>
<MDialog v-model="visible" fullscreen title="全屏对话框">
<p style="margin: 0 0 12px">
fullscreen 下对话框铺满视口并从底部向上滑入,width 与 radius 都会被忽略。
</p>
<p style="margin: 0; color: var(--m-text-sub)">
内容超出时只在正文区域内滚动,footer 始终贴在底部。
</p>
<template #footer>
<MButton variant="contained" color="primary" @click="visible = false">完成</MButton>
</template>
</MDialog>
</div>
</template>API
Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
modelValue | boolean | undefined | 显示开关,配合 v-model 使用 |
title | string | undefined | 标题文本;title 插槽有内容时优先用插槽 |
width | string | number | 420 | 对话框宽度;数字与纯数字字符串按 px 处理,其他字符串原样作为 CSS 宽度;fullscreen 下忽略 |
radius | string | undefined | 卡片圆角,如 '12px';缺省回退全局 var(--m-radius);fullscreen 下忽略 |
closeOnClickOverlay | boolean | true | 点击遮罩是否关闭 |
closeOnPressEscape | boolean | true | 按 Esc 是否关闭 |
appendToBody | boolean | true | 是否 Teleport 到 body;关闭后对话框留在原位置,容易被父级 overflow 裁剪 |
showClose | boolean | true | 是否显示右上角关闭按钮 |
fullscreen | boolean | false | 全屏模式:铺满视口,从底部向上滑入,忽略 width 与 radius |
anchor | HTMLElement | null | null | 动画起点元素:传入后从该元素中心展开;不传则用 #trigger 记录的点击元素,都没有时居中展开 |
Events
| 事件 | 参数 | 说明 |
|---|---|---|
update:modelValue | value: boolean | 关闭请求:点击关闭按钮、遮罩或按 Esc 时抛 false |
open | 无 | modelValue 变为 true 时 |
close | 无 | modelValue 变为 false 时 |
Slots
| 插槽 | 参数 | 说明 |
|---|---|---|
trigger | 无 | 触发内容,点击后自动记录该元素位置并打开对话框 |
title | 无 | 自定义标题;无内容时回退到 title prop |
default | 无 | 对话框正文 |
footer | 无 | 底部操作区;仅当该插槽存在时才渲染 footer |
动画与滚动
展开动画由 Web Animations API 直接驱动(Transition :css="false"),分两段顺序衔接:
- 背景成型:遮罩淡入,白色盒子 surface 从
scale(0.25)放大到1并同步淡入,时长--m-dur-entering(225ms)× 1.4。盒子的transform-origin指向起点元素中心。 - 内容浮现:正文层只做透明度淡入,起点是背景动画播到 80% 的时刻,时长
--m-dur-entering× 0.7,所以视觉上是"先有盒子,再出现文字"。
关闭动画是镜像的:正文先快速淡出(--m-dur-leaving × 0.45),随后盒子缩回同一起点并淡出(--m-dur-leaving × 1.0)。起点在打开时就被定格(activeAnchor),中途点击其他触发按钮不会让关闭动画跳位;只有当离开动画彻底结束才清空。
滚动锁定由组件直接写 document.body.style.overflow = 'hidden' 完成,关闭与卸载时恢复为空字符串。
使用建议
优先用 #trigger 插槽
#trigger 会随内容留在文档流里,位置和鼠标点击点一致,展开动画最自然;只有触发元素不在对话框同级(例如在表格行内、由别的组件渲染)时才需要自己拿 ref 传 anchor。
<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
const anchorEl = ref<HTMLElement | null>(null)
</script>
<template>
<span ref="anchorEl" @click="visible = true">点我</span>
<MDialog v-model="visible" :anchor="anchorEl" title="详情">...</MDialog>
</template>anchor 指向的元素在关闭动画结束前被卸载(isConnected === false)时会被忽略,动画退化为居中展开,不会报错。
源码未实现焦点陷阱
对话框提供了 role="dialog" 与 aria-modal="true",并在 closeOnPressEscape 为真时响应 Esc,但没有把焦点限制在对话框内部:打开后焦点不会自动移到对话框,Tab 仍可走到页面其他元素。对焦点管理有要求的场景,请自行在 open 事件里聚焦首个可聚焦元素,并在 close 后把焦点还给触发元素。
滚动锁没有引用计数
对话框直接改写 document.body.style.overflow,没有参与 Drawer / Dropdown 使用的共享滚动锁,也不补偿隐藏滚动条带来的宽度变化。它与抽屉、下拉菜单同时打开时,谁先关闭谁的还原值会生效,可能出现页面仍锁着或提前解锁的情况,业务上尽量避免叠开。
相关组件
- Drawer 抽屉:同样需要遮罩与 Esc 关闭,但面板从屏幕边缘滑出,适合承载列表与表单。
- Dropdown 下拉菜单:轻量的浮层,不需要遮罩,点击外部即收起。
- Toast 轻提示:只做结果反馈,不打断操作,无需用户确认。