# Drawer 抽屉

抽屉是贴在屏幕边缘的模态面板，适合承载筛选条件、详情信息、分步表单这类"不离开当前页面"的内容。与对话框相比，它保留了一条与页面相接的边，滑入方向本身就暗示了内容与当前页面的从属关系。

## 代码演示

### 基础用法

默认从右侧滑出，宽度 320px；`#trigger` 插槽负责渲染触发内容。

```vue
<script setup lang="ts">
import { ref } from 'vue'

const visible = ref(false)
const applied = ref(false)

function apply() {
  applied.value = true
  visible.value = false
}
</script>

<template>
  <div class="m-row-demo">
    <ClientOnly>
      <MDrawer v-model="visible" title="筛选条件" :size="320">
        <template #trigger>
          <MButton variant="contained" color="primary">打开抽屉</MButton>
        </template>
        <p style="margin: 0 0 12px">正文放在默认插槽，内容超出高度时只在正文区域内滚动。</p>
        <p style="margin: 0; color: var(--m-text-sub)">
          {{ applied ? '条件已应用' : '尚未应用条件' }}
        </p>
        <template #footer>
          <MButton variant="text" @click="visible = false">取消</MButton>
          <MButton variant="contained" color="primary" @click="apply">应用</MButton>
        </template>
      </MDrawer>
    </ClientOnly>
  </div>
</template>
```

### 展开方向

`placement` 取 `left` / `right` / `top` / `bottom`。左右方向决定宽度，上下方向决定高度。

```vue
<script setup lang="ts">
import { ref } from 'vue'

type Placement = 'left' | 'right' | 'top' | 'bottom'

const visible = ref(false)
const options: { label: string; placement: Placement }[] = [
  { label: '左侧', placement: 'left' },
  { label: '右侧', placement: 'right' },
  { label: '上方', placement: 'top' },
  { label: '下方', placement: 'bottom' }
]
const current = ref(options[0]!)

function open(option: { label: string; placement: Placement }) {
  current.value = option
  visible.value = true
}
</script>

<template>
  <div class="m-row-demo">
    <MButton
      v-for="option in options"
      :key="option.placement"
      variant="outlined"
      @click="open(option)">
      {{ option.label }}
    </MButton>
  </div>

  <ClientOnly>
    <MDrawer
      v-model="visible"
      :placement="current.placement"
      :size="280"
      :title="`placement: ${current.placement}`">
      <p style="margin: 0">
        四个方向共用同一套结构，只是贴边的位置与滑入方向不同；左右方向决定宽度，上下方向决定高度。
      </p>
    </MDrawer>
  </ClientOnly>
</template>
```

### 尺寸

`size` 为数字或纯数字字符串时按 px 处理，其他字符串原样作为 CSS 长度。

**演示说明：** 宽度的百分比是相对视口计算的，因为抽屉使用 `fixed` 定位。

```vue
<script setup lang="ts">
import { ref } from 'vue'

type Placement = 'left' | 'right' | 'top' | 'bottom'

const visible = ref(false)
const options: { label: string; placement: Placement; size: string | number }[] = [
  { label: '右侧 240', placement: 'right', size: 240 },
  { label: '右侧 480', placement: 'right', size: 480 },
  { label: '左侧 30%', placement: 'left', size: '30%' },
  { label: '上方 220', placement: 'top', size: 220 }
]
const current = ref(options[0]!)

function open(option: { label: string; placement: Placement; size: 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>

  <ClientOnly>
    <MDrawer
      v-model="visible"
      :placement="current.placement"
      :size="current.size"
      :title="`size: ${current.size}`">
      <p style="margin: 0">
        size 为数字或纯数字字符串时按 px 处理，其他字符串原样作为 CSS 长度。
      </p>
    </MDrawer>
  </ClientOnly>
</template>
```

### 标题与底部

`title` 插槽与 `footer` 插槽可以完全替换默认外观，`showClose` 用来去掉右上角的关闭按钮。

```vue
<script setup lang="ts">
import { ref } from 'vue'
import { mdiAccountOutline } from '@mdi/js'

const visible = ref(false)
</script>

<template>
  <div class="m-row-demo">
    <MButton variant="outlined" @click="visible = true">自定义标题与底部</MButton>

    <ClientOnly>
      <MDrawer v-model="visible" :size="360" :show-close="false">
        <template #title>
          <span style="display: flex; align-items: center; gap: 8px">
            <MIcon :path="mdiAccountOutline" :size="18" />
            账户设置
          </span>
        </template>
        <p style="margin: 0">
          showClose 设为 false 后右上角不再有关闭按钮，关闭只能依赖遮罩、Esc 或底部的自定义按钮。
        </p>
        <template #footer>
          <MButton variant="text" @click="visible = false">稍后再说</MButton>
          <MButton variant="contained" color="primary" @click="visible = false">保存</MButton>
        </template>
      </MDrawer>
    </ClientOnly>
  </div>
</template>
```

### 事件与关闭方式

`open` / `close` 用于同步外部状态，遮罩与 Esc 关闭各有独立开关。

```vue
<script setup lang="ts">
import { ref } from 'vue'

const visible = ref(false)
const log = ref('尚未触发事件')
</script>

<template>
  <div class="m-row-demo">
    <MButton variant="outlined" @click="visible = true">打开试试</MButton>

    <ClientOnly>
      <MDrawer
        v-model="visible"
        title="事件回调"
        :size="320"
        :close-on-press-escape="false"
        @open="log = 'open：抽屉打开，body 滚动被锁定'"
        @close="log = 'close：抽屉关闭，滚动锁定被释放'">
        <p style="margin: 0">
          这条抽屉关闭了 Esc 关闭，请用遮罩点击、右上角按钮或下方按钮。
        </p>
        <template #footer>
          <MButton variant="contained" color="primary" @click="visible = false">关闭</MButton>
        </template>
      </MDrawer>
    </ClientOnly>

    <span style="color: var(--m-text-sub)">{{ log }}</span>
  </div>
</template>
```

## API

### Props

| 属性 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `modelValue` | `boolean` | `undefined` | 抽屉开关，配合 `v-model` 使用 |
| `placement` | `'left' \| 'right' \| 'top' \| 'bottom'` | `'right'` | 展开方向 |
| `size` | `string \| number` | `320` | 左右方向为宽度、上下方向为高度；数字与纯数字字符串按 px 处理，其他字符串原样作为 CSS 长度 |
| `title` | `string` | `undefined` | 标题文本；`title` 插槽有内容时优先用插槽 |
| `showClose` | `boolean` | `true` | 显示关闭按钮（左右方向在右上角，上下方向在左侧） |
| `closeOnClickOverlay` | `boolean` | `true` | 点击遮罩是否关闭 |
| `closeOnPressEscape` | `boolean` | `true` | 按 Esc 是否关闭 |
| `appendToBody` | `boolean` | `true` | 是否 Teleport 到 body |

### Events

| 事件 | 参数 | 说明 |
| --- | --- | --- |
| `update:modelValue` | `value: boolean` | 关闭请求 |
| `open` | 无 | 抽屉打开时 |
| `close` | 无 | 抽屉关闭时 |

### Slots

| 插槽 | 参数 | 说明 |
| --- | --- | --- |
| `trigger` | 无 | 触发内容，点击后直接打开抽屉（不负责关闭） |
| `title` | 无 | 自定义标题；无内容时回退到 `title` prop |
| `default` | 无 | 抽屉正文 |
| `footer` | 无 | 底部操作区；仅当该插槽存在时才渲染 footer |

### 类型

```ts
export type DrawerPlacement = 'left' | 'right' | 'top' | 'bottom'
```

## 使用建议

::: tip `#trigger` 只负责打开
插槽内的点击等价于 `emit('update:modelValue', true)`，想用同一个按钮关掉抽屉请改用受控写法：

```vue
<script setup lang="ts">
import { ref } from 'vue'

const visible = ref(false)
</script>

<template>
  <MButton variant="outlined" @click="visible = !visible">切换抽屉</MButton>
  <MDrawer v-model="visible" title="详情">...</MDrawer>
</template>
```
:::

::: warning 初始值为 true 会立即生效
抽屉内部对 `modelValue` 的监听带 `immediate: true`，所以初始渲染就把 `v-model` 绑成 `true` 时，会立刻抛出 `open` 事件并锁定页面滚动。需要"默认收起"就必须显式初始化为 `false`。
:::

::: tip SSR / SSG 下请用客户端组件包裹
同一个 `immediate` 侦听在关闭分支里会调用滚动锁，而滚动锁会访问 `document`。因此在服务端渲染（Nuxt、VitePress、`vite-ssg` 等）时直接渲染 `MDrawer` 会抛 `document is not defined`。

解决办法是让抽屉只在客户端挂载 —— 本站的演示就是这么做的：

```vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'

const ready = ref(false)
onMounted(() => (ready.value = true))
</script>

<template>
  <ClientOnly>
    <MDrawer v-model="visible" title="详情">...</MDrawer>
  </ClientOnly>
</template>
```

组件本身的渲染产物不依赖 `document`，所以只要跳过服务端这一次挂载即可，功能不受影响。
:::

::: warning 上下方向的尺寸含义不同
`size` 是"长度"而不是"宽度"：`placement` 为 `top` / `bottom` 时它表示高度，传一个很大的值会把整块屏幕盖住，推荐不超过 `60vh` 一类的可视高度。滚动锁定通过共享的引用计数锁实现，与 Dropdown、Select 等弹层可以安全叠加。
:::

## 相关组件

- [Dialog 对话框](/components/dialog)：需要用户集中注意力做决策时用对话框，从触发元素 Morph 展开。
- [Dropdown 下拉菜单](/components/dropdown)：轻量菜单，没有遮罩与滚动锁定。
- [Button 按钮](/components/button)：抽屉底部操作区通常由按钮组成。
