Dialog 对话框

打断当前任务的模态对话框。

<script setup lang="ts">
  import { Avatar, Button, Dialog, Inline, Input, Stack, Text } from '@hina-ui/vue'
</script>

<template>
  <Dialog title="编辑资料" description="改动会立刻同步到你的主页。">
    <Button variant="outline" tone="neutral">编辑资料</Button>
    <template #content>
      <Stack gap="sm">
        <Inline gap="sm">
          <Avatar size="lg" src="/avatars/glass.webp" alt="星见书音" />
          <Button size="sm" variant="soft" tone="neutral">更换头像</Button>
        </Inline>
        <Stack gap="xs">
          <Text size="sm">昵称</Text>
          <Input model-value="星见书音" />
        </Stack>
        <Stack gap="xs">
          <Text size="sm">简介</Text>
          <Input model-value="行商人与自称丰收之神的少女同行的旅途。" />
        </Stack>
      </Stack>
    </template>
    <template #footer="{ close }">
      <Button variant="soft" tone="neutral" @click="close">取消</Button>
      <Button @click="close">保存</Button>
    </template>
  </Dialog>
</template>
vue

用法

import { Dialog } from '@hina-ui/vue'
ts

title 必填,description 是标题下面的一行说明。默认插槽是触发器,content 插槽是正文,footer 插槽是底部的操作按钮。content、footer 和接管整个内部布局的 body 插槽都会收到 close 方法。

<script setup lang="ts">
  import { Button, Dialog, Text } from '@hina-ui/vue'
</script>

<template>
  <Dialog title="发布这篇文章" description="发布之后所有人都能看到它。">
    <Button variant="outline" tone="neutral">发布</Button>
    <template #content>
      <Text>文章会出现在你的主页与订阅者的时间线上。</Text>
    </template>
    <template #footer="{ close }">
      <Button variant="soft" tone="neutral" @click="close">再想想</Button>
      <Button @click="close">发布</Button>
    </template>
  </Dialog>
</template>
vue

关闭按钮、遮罩、停止页面滚动和焦点陷阱都由组件提供。

示例

标题内容

icon 插槽在标题前显示装饰图标,title 插槽替换标题内容,默认显示 title 属性。自定义标题保留 <h2> 语义与弹窗名称关联。

<script setup lang="ts">
  import { Settings } from '@lucide/vue'
  import { Button, Dialog, Text } from '@hina-ui/vue'
</script>

<template>
  <Dialog title="对话框标题">
    <Button variant="outline" tone="neutral">标题插槽</Button>
    <template #icon><Settings /></template>
    <template #title>自定义标题</template>
    <template #content>
      <Text>图标与标题内容分别由插槽提供。</Text>
    </template>
  </Dialog>
</template>
vue

关闭按钮

closable 默认为 true。设为 false 只隐藏关闭按钮,按 Esc 和点击遮罩仍可关闭;locked 控制这两种关闭行为。:header="false" 时始终不显示头部内的关闭按钮。

<script setup lang="ts">
  import { Button, Dialog, Text } from '@hina-ui/vue'
</script>

<template>
  <Dialog title="无关闭按钮" :closable="false">
    <Button variant="outline" tone="neutral">无关闭按钮</Button>
    <template #content>
      <Text>按 Esc、点击遮罩或底部按钮均可关闭。</Text>
    </template>
    <template #footer="{ close }">
      <Button @click="close">关闭</Button>
    </template>
  </Dialog>
</template>
vue

自定义面板内容

#body="{ close }" 接管整个面板内部,替换默认头部、正文和页脚。组件不再添加内部留白、区域间距或 ScrollArea 包装;内边距与滚动由插槽内容控制。提供空插槽也不会恢复默认布局。

此时 header、closable 以及 icon、title、content、footer 插槽不参与渲染。title 仍必填,标题与提供的说明以视觉隐藏的形式保留。尺寸、位置、遮罩、焦点约束和 locked 继续生效,插槽中的 close() 可程序化关闭弹窗。

示例在顶部排列操作按钮,底部固定工具栏,中间由 Textarea 自适应内容高度,并由 ScrollArea 控制整体滚动。

<script setup lang="ts">
  import { computed, ref } from 'vue'
  import { AtSign, Globe, ImagePlus, Smile } from '@lucide/vue'
  import {
    Avatar,
    Button,
    CloseButton,
    Dialog,
    IconButton,
    Image,
    Inline,
    ScrollArea,
    Stack,
    Text,
    Textarea,
  } from '@hina-ui/vue'

  const content = ref('')
  const attached = ref(false)
  const count = computed(() => Array.from(content.value).length)
  const overLimit = computed(() => count.value > 280)
</script>

<template>
  <Dialog title="发布动态" placement="top" class="max-w-[600px]">
    <Button variant="outline" tone="neutral">自定义面板</Button>
    <template #body="{ close }">
      <Stack gap="none" class="min-h-0">
        <Inline justify="between" class="border-line shrink-0 border-b px-3 py-2.5">
          <Button variant="ghost" tone="neutral" size="sm" @click="close">取消</Button>
          <Button size="sm" :disabled="(!content.trim() && !attached) || overLimit" @click="close">
            发布
          </Button>
        </Inline>

        <ScrollArea class="min-h-0">
          <Inline align="start" :wrap="false" class="gap-3 p-4">
            <Avatar src="/avatars/paper.webp" name="星见书音" />
            <Stack gap="sm" class="min-w-0 flex-1">
              <Stack gap="xs">
                <Text weight="medium">星见书音</Text>
                <Inline gap="xs">
                  <Globe class="text-muted size-3.5" aria-hidden="true" />
                  <Text size="xs" tone="muted">公开</Text>
                </Inline>
              </Stack>
              <Textarea
                v-model="content"
                variant="bare"
                :autosize="{ minRows: 5 }"
                :invalid="overLimit"
                aria-label="动态正文"
                placeholder="分享你的发现、推荐或此刻的想法…"
                class="[--hn-textarea-px:0px]"
              />
              <Stack v-if="attached" class="relative">
                <Image src="/sample.webp" alt="示例图片" class="aspect-video w-full rounded-md" />
                <CloseButton
                  label="移除图片"
                  class="bg-surface/90 absolute top-2 right-2 shadow-sm"
                  @click="attached = false"
                />
              </Stack>
            </Stack>
          </Inline>
        </ScrollArea>

        <Inline justify="between" :wrap="false" class="border-line shrink-0 border-t px-3 py-2">
          <Inline gap="xs">
            <IconButton
              label="添加示例图片"
              size="sm"
              :aria-pressed="attached"
              @click="attached = !attached"
            >
              <ImagePlus />
            </IconButton>
            <IconButton label="插入表情" size="sm" @click="content += '😊'">
              <Smile />
            </IconButton>
            <IconButton label="插入 @" size="sm" @click="content += '@'">
              <AtSign />
            </IconButton>
          </Inline>
          <Text
            as="span"
            size="xs"
            :tone="overLimit ? 'danger' : 'muted'"
            class="shrink-0 tabular-nums"
          >
            {{ count }} / 280
          </Text>
        </Inline>
      </Stack>
    </template>
  </Dialog>
</template>
vue

尺寸

size 设置面板的最大宽度,默认 md。五档宽度如下,实际宽度受视口限制。

size最大宽度
sm24rem
md28rem
lg36rem
xl42rem
2xl56rem
<script setup lang="ts">
  import { Button, Dialog, Inline, Text } from '@hina-ui/vue'
</script>

<template>
  <Inline align="center" class="gap-6">
    <Dialog
      v-for="size in ['sm', 'md', 'lg', 'xl', '2xl'] as const"
      :key="size"
      :size="size"
      :title="`${size} 档`"
    >
      <Button variant="outline" tone="neutral">{{ size }}</Button>
      <template #content>
        <Text>面板宽度随 size 变化,高度始终不超出视口。</Text>
      </template>
    </Dialog>
  </Inline>
</template>
vue

自定义宽度

class 作用于面板,可用 max-w-[40rem] 或 max-w-[52rem] 覆盖预设最大宽度。默认定位在窄屏上仍占满可用宽度,并保留两侧留白。

<script setup lang="ts">
  import { Button, Dialog, Inline, Text } from '@hina-ui/vue'

  const widths = [
    { label: '40rem', class: 'max-w-[40rem]' },
    { label: '52rem', class: 'max-w-[52rem]' },
  ]
</script>

<template>
  <Inline>
    <Dialog v-for="width in widths" :key="width.label" :title="width.label" :class="width.class">
      <Button variant="outline" tone="neutral">{{ width.label }}</Button>
      <template #content>
        <Text>通过 class 设置最大宽度,窄屏仍受视口限制。</Text>
      </template>
    </Dialog>
  </Inline>
</template>
vue

位置

不设置 placement 时,宽屏上对话框居中,窄屏上贴底并占满可用宽度。center 始终居中,top 始终贴顶,bottom 始终贴底。top 从顶部滑入,距顶部 1rem,两侧至少保留 1rem 留白,宽度仍由 size 控制并受视口限制。

<script setup lang="ts">
  import { Button, Dialog, Inline, Text } from '@hina-ui/vue'
</script>

<template>
  <Inline align="center" class="gap-6">
    <Dialog title="居中" placement="center">
      <Button variant="outline" tone="neutral">center</Button>
      <template #content>
        <Text>任何屏幕宽度下都居中显示。</Text>
      </template>
    </Dialog>
    <Dialog title="贴顶" placement="top">
      <Button variant="outline" tone="neutral">top</Button>
      <template #content>
        <Text>从顶部滑入,保留顶部留白,窄屏上也保持贴顶。</Text>
      </template>
    </Dialog>
    <Dialog title="贴底" placement="bottom">
      <Button variant="outline" tone="neutral">bottom</Button>
      <template #content>
        <Text>从底部滑入,四角保留圆角。</Text>
      </template>
    </Dialog>
    <Dialog title="随屏幕变化">
      <Button variant="outline" tone="neutral">默认</Button>
      <template #content>
        <Text>宽屏居中,窄屏贴底并占满宽度。缩窄窗口后重新打开即可看到。</Text>
      </template>
    </Dialog>
  </Inline>
</template>
vue

长内容

默认布局中,超出可用高度的内容在 content 插槽内部滚动,标题和页脚保持不动。面板本身不会超出视口。

<script setup lang="ts">
  import { Button, Dialog, Stack, Text } from '@hina-ui/vue'

  const terms = Array.from(
    { length: 30 },
    (_, i) =>
      `第 ${i + 1} 条:使用本服务即表示你同意这一条款,它在这里只用于占位,好让正文足够长,能够看到滚动。`,
  )
</script>

<template>
  <Dialog title="服务条款" description="请阅读之后再继续。">
    <Button variant="outline" tone="neutral">查看条款</Button>
    <template #content>
      <Stack gap="sm">
        <Text v-for="line in terms" :key="line">{{ line }}</Text>
      </Stack>
    </template>
    <template #footer="{ close }">
      <Button variant="soft" tone="neutral" @click="close">拒绝</Button>
      <Button @click="close">同意</Button>
    </template>
  </Dialog>
</template>
vue

滚动容器

通过组件 ref 的 viewport 获取正文内置 ScrollArea 的实际滚动元素。可以读取 scrollTop、调用 scrollTo(),或将它交给滚动监听、观察器。

viewport 的类型为 HTMLElement | undefined。正文滚动区域初始化完成前、没有 content 插槽或内容卸载后为 undefined;退场期间仍返回当前元素,再次打开时更新为新的元素。需要在可用时执行操作或绑定监听,可监听 () => modal.value?.viewport,并在监听清理函数中解除绑定。

使用 body 插槽时,内置滚动区域被替换,viewport 为 undefined;自定义滚动区域由调用方自行引用。

示例中的 Button 通过 viewport.scrollTo() 控制滚动。

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Dialog, Stack, Text } from '@hina-ui/vue'

  const modal = ref<InstanceType<typeof Dialog>>()

  function scrollTo(position: 'start' | 'end') {
    const viewport = modal.value?.viewport
    if (!viewport) return
    viewport.scrollTo({ top: position === 'start' ? 0 : viewport.scrollHeight })
  }
</script>

<template>
  <Dialog ref="modal" title="滚动容器" description="标题与页脚固定,正文独立滚动。">
    <Button variant="outline" tone="neutral">打开</Button>
    <template #content>
      <Stack gap="sm">
        <Text v-for="index in 30" :key="index">
          第 {{ index }} 行:正文在中间滚动,标题与页脚保持不动。
        </Text>
      </Stack>
    </template>
    <template #footer="{ close }">
      <Button variant="soft" tone="neutral" :disabled="!modal?.viewport" @click="scrollTo('start')">
        顶部
      </Button>
      <Button variant="soft" tone="neutral" :disabled="!modal?.viewport" @click="scrollTo('end')">
        底部
      </Button>
      <Button @click="close">关闭</Button>
    </template>
  </Dialog>
</template>
vue

受控

open 支持双向绑定。省略默认插槽时不渲染触发器,对话框只能从外部打开。

当前:已关闭

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Dialog, Inline, Text } from '@hina-ui/vue'

  const open = ref(false)
</script>

<template>
  <Inline align="center">
    <Button variant="outline" tone="neutral" @click="open = true">从外部打开</Button>
    <Text tone="muted" size="sm">当前:{{ open ? '已打开' : '已关闭' }}</Text>
    <Dialog v-model:open="open" title="没有触发器的对话框" description="它由外部的状态控制。">
      <template #content>
        <Text>省略默认插槽时不渲染触发器,只能通过 open 打开。</Text>
      </template>
      <template #footer="{ close }">
        <Button @click="close">关闭</Button>
      </template>
    </Dialog>
  </Inline>
</template>
vue

锁定

设置 locked 后,按 Esc 和点击遮罩都不再关闭对话框,已显示的关闭按钮变为不可用。此时通过 open 关闭仍然有效。对话框内的 Form 提交期间同样处于锁定状态,无论 locked 是否设置。

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Dialog, Text } from '@hina-ui/vue'

  const open = ref(false)
  const submitting = ref(false)

  function submit() {
    submitting.value = true
    setTimeout(() => {
      submitting.value = false
      open.value = false
    }, 2000)
  }
</script>

<template>
  <Dialog
    v-model:open="open"
    title="导入书库"
    description="导入过程中请不要关闭这个对话框。"
    :locked="submitting"
  >
    <Button variant="outline" tone="neutral">导入</Button>
    <template #content>
      <Text>
        {{ submitting ? '正在导入,两秒后自动关闭。' : '点击开始导入之后对话框会锁定两秒。' }}
      </Text>
    </template>
    <template #footer="{ close }">
      <Button variant="soft" tone="neutral" :disabled="submitting" @click="close">取消</Button>
      <Button :loading="submitting" @click="submit">开始导入</Button>
    </template>
  </Dialog>
</template>
vue

内含表单

content 插槽中的 Form 提交期间,对话框自动锁定,提交结束或表单卸载后解除。body、content 与 footer 插槽的参数 submitting 在此期间为 true。

示例的页脚按钮位于表单之外:保存按钮设置 type="submit",用 form 属性指向表单的 id,loading 绑定 submitting;取消按钮的 disabled 绑定 submitting。提交完成后通过 open 关闭对话框。

<script setup lang="ts">
  import { reactive, ref } from 'vue'
  import * as v from 'valibot'
  import { Button, Dialog, Form, FormField, Input } from '@hina-ui/vue'

  const schema = v.object({
    name: v.pipe(v.string('请输入名称'), v.nonEmpty('请输入名称')),
    email: v.pipe(v.string('请输入邮箱'), v.nonEmpty('请输入邮箱'), v.email('邮箱格式不正确')),
  })

  const open = ref(false)
  const values = reactive({ name: '星见书音', email: 'shion@example.com' })

  async function save() {
    await new Promise(resolve => setTimeout(resolve, 1500))
    open.value = false
  }
</script>

<template>
  <Dialog v-model:open="open" title="编辑资料" description="保存期间对话框锁定,完成后关闭。">
    <Button variant="outline" tone="neutral">编辑资料</Button>
    <template #content>
      <Form id="profile-form" :values="values" :rules="schema" @submit="save">
        <FormField name="name" label="名称">
          <Input v-model="values.name" />
        </FormField>
        <FormField name="email" label="邮箱">
          <Input v-model="values.email" type="email" />
        </FormField>
      </Form>
    </template>
    <template #footer="{ close, submitting }">
      <Button variant="soft" tone="neutral" :disabled="submitting" @click="close">取消</Button>
      <Button type="submit" form="profile-form" :loading="submitting">保存</Button>
    </template>
  </Dialog>
</template>
vue

行为

  • 对话框打开期间页面停止滚动,焦点被限制在面板内部,关闭后回到触发器。
  • 按 Esc 或点击遮罩关闭对话框,locked 会同时禁用这两种方式。
  • 点击遮罩或焦点移到外部时先触发 interactOutside,调用 preventDefault() 后不关闭。OverlayBranch 里的内容不算外部。
  • 对话框内的 Form 提交期间按 locked 处理;对话框之外的表单不影响它,嵌套的对话框只跟随自身内部的表单。
  • 默认正文区域使用 ScrollArea。

无障碍

  • 面板是 role="dialog",title 和 description 分别关联到 aria-labelledby 和 aria-describedby。
  • 标题渲染为 <h2>;隐藏头部或提供 body 时,保留由 title 属性生成的视觉隐藏标题。
  • 关闭按钮带有无障碍名称,文字取自当前语言。

API

Dialog

属性
类型
默认值
说明
title
string
—
必填。对话框标题
description
string
—
标题下面的说明
size
'sm' | 'md' | 'lg' | 'xl' | '2xl'
'md'
面板的最大宽度
placement
'center' | 'top' | 'bottom'
—
不设置时随屏幕宽度变化
header
boolean
true
是否显示头部及其中的关闭按钮
closable
boolean
true
是否显示头部内的关闭按钮
locked
boolean
false
是否禁止用户关闭
open
boolean
—
是否打开,支持双向绑定
class
string
—
追加到面板上的类名
插槽
参数
说明
default
—
触发器,省略时不渲染
icon
—
标题前的装饰图标
title
—
标题内容,默认显示 title 属性
body
{ close, submitting }
整个面板内部,替换默认头部、正文和页脚
content
{ close, submitting }
正文,过高时在内部滚动
footer
{ close, submitting }
底部的操作按钮

close 关闭对话框;submitting 在对话框内的 Form 提交期间为 true。

事件
参数
说明
interactOutside
PointerDownOutsideEvent | FocusOutsideEvent
外部按下或焦点移出时触发,preventDefault() 后不关闭

实例

属性
类型
说明
viewport
HTMLElement | undefined
正文内置 ScrollArea 的实际滚动元素,初始化完成后可用,内容卸载后清空