# Dialog 对话框

模态对话框用于必须让用户先给出结论才能继续的场景，例如二次确认、表单填写。MDialog 的展开不是简单的居中出现：白色盒子会以触发元素的中心为原点缩放展开，关闭时缩回同一起点，形成"从哪里点开、就收回到哪里"的 Morph 观感。

## 代码演示

### 基础用法

`#trigger` 插槽渲染触发内容并留在原位，点击时组件自动记录该元素的位置作为动画起点。

**演示说明：** `#trigger` 是推荐的打开方式，键盘可聚焦、位置自然。

```vue
<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%` 约束。

```vue
<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）。

```vue
<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` 的语法糖。

```vue
<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 关闭各有独立开关，用来区分"可以随手关掉"和"必须做出选择"两类对话框。

```vue
<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` 都失效。

```vue
<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"`），分两段顺序衔接：

1. **背景成型**：遮罩淡入，白色盒子 surface 从 `scale(0.25)` 放大到 `1` 并同步淡入，时长 `--m-dur-entering`（225ms）× 1.4。盒子的 `transform-origin` 指向起点元素中心。
2. **内容浮现**：正文层只做透明度淡入，起点是背景动画播到 80% 的时刻，时长 `--m-dur-entering` × 0.7，所以视觉上是"先有盒子，再出现文字"。

关闭动画是镜像的：正文先快速淡出（`--m-dur-leaving` × 0.45），随后盒子缩回同一起点并淡出（`--m-dur-leaving` × 1.0）。起点在打开时就被定格（`activeAnchor`），中途点击其他触发按钮不会让关闭动画跳位；只有当离开动画彻底结束才清空。

滚动锁定由组件直接写 `document.body.style.overflow = 'hidden'` 完成，关闭与卸载时恢复为空字符串。

## 使用建议

::: tip 优先用 `#trigger` 插槽
`#trigger` 会随内容留在文档流里，位置和鼠标点击点一致，展开动画最自然；只有触发元素不在对话框同级（例如在表格行内、由别的组件渲染）时才需要自己拿 `ref` 传 `anchor`。

```vue
<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`）时会被忽略，动画退化为居中展开，不会报错。
:::

::: warning 源码未实现焦点陷阱
对话框提供了 `role="dialog"` 与 `aria-modal="true"`，并在 `closeOnPressEscape` 为真时响应 Esc，但没有把焦点限制在对话框内部：打开后焦点不会自动移到对话框，Tab 仍可走到页面其他元素。对焦点管理有要求的场景，请自行在 `open` 事件里聚焦首个可聚焦元素，并在 `close` 后把焦点还给触发元素。
:::

::: warning 滚动锁没有引用计数
对话框直接改写 `document.body.style.overflow`，没有参与 Drawer / Dropdown 使用的共享滚动锁，也不补偿隐藏滚动条带来的宽度变化。它与抽屉、下拉菜单同时打开时，谁先关闭谁的还原值会生效，可能出现页面仍锁着或提前解锁的情况，业务上尽量避免叠开。
:::

## 相关组件

- [Drawer 抽屉](/components/drawer)：同样需要遮罩与 Esc 关闭，但面板从屏幕边缘滑出，适合承载列表与表单。
- [Dropdown 下拉菜单](/components/dropdown)：轻量的浮层，不需要遮罩，点击外部即收起。
- [Toast 轻提示](/components/toast)：只做结果反馈，不打断操作，无需用户确认。
