<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>
用法
import { Dialog } from '@hina-ui/vue'
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>
关闭按钮、遮罩、停止页面滚动和焦点陷阱都由组件提供。
示例
标题内容
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>
隐藏头部
header 默认为 true。设为 false 后不显示整个头部及其中的关闭按钮,正文与页脚保留原有布局。title 仍必填,标题与提供的说明会以视觉隐藏的形式保留,供辅助技术读取;此时不渲染 icon 和 title 插槽。
<script setup lang="ts">
import { Button, Dialog, Text } from '@hina-ui/vue'
</script>
<template>
<Dialog title="无头部" :header="false">
<Button variant="outline" tone="neutral">无头部</Button>
<template #content>
<Text>标题保留为无障碍名称,正文与页脚正常显示。</Text>
</template>
<template #footer="{ close }">
<Button @click="close">关闭</Button>
</template>
</Dialog>
</template>
关闭按钮
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>
自定义面板内容
#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>
尺寸
size 设置面板的最大宽度,默认 md。五档宽度如下,实际宽度受视口限制。
| size | 最大宽度 |
|---|---|
sm | 24rem |
md | 28rem |
lg | 36rem |
xl | 42rem |
2xl | 56rem |
<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>
自定义宽度
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>
位置
不设置 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>
长内容
默认布局中,超出可用高度的内容在 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>
滚动容器
通过组件 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>
受控
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>
锁定
设置 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>
内含表单
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>
行为
- 对话框打开期间页面停止滚动,焦点被限制在面板内部,关闭后回到触发器。
- 按 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 的实际滚动元素,初始化完成后可用,内容卸载后清空 |