'use client'
import { Avatar, Button, Dialog, Inline, Input, Stack, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Dialog
title="编辑资料"
description="改动会立刻同步到你的主页。"
renderContent={() => (
<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 defaultValue="星见书音" />
</Stack>
<Stack gap="xs">
<Text size="sm">简介</Text>
<Input defaultValue="行商人与自称丰收之神的少女同行的旅途。" />
</Stack>
</Stack>
)}
renderFooter={({ close }) => (
<>
<Button variant="soft" tone="neutral" onClick={close}>
取消
</Button>
<Button onClick={close}>保存</Button>
</>
)}
>
<Button variant="outline" tone="neutral">
编辑资料
</Button>
</Dialog>
)
}
用法
import { Dialog } from '@hina-ui/react'
title 必填,description 是标题下面的一行说明。children 是触发器,renderContent 渲染正文,renderFooter 渲染底部的操作按钮。renderContent、renderFooter 和接管整个内部布局的 renderBody 都会收到 close 方法。
'use client'
import { Button, Dialog, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Dialog
title="发布这篇文章"
description="发布之后所有人都能看到它。"
renderContent={() => <Text>文章会出现在你的主页与订阅者的时间线上。</Text>}
renderFooter={({ close }) => (
<>
<Button variant="soft" tone="neutral" onClick={close}>
再想想
</Button>
<Button onClick={close}>发布</Button>
</>
)}
>
<Button variant="outline" tone="neutral">
发布
</Button>
</Dialog>
)
}
关闭按钮、遮罩、停止页面滚动和焦点陷阱都由组件提供。
示例
标题内容
icon 属性在标题前显示装饰图标,titleContent 属性替换标题内容,默认显示 title 属性。自定义标题保留 <h2> 语义与弹窗名称关联。
'use client'
import { Settings } from 'lucide-react'
import { Button, Dialog, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Dialog
title="对话框标题"
icon={<Settings />}
titleContent="自定义标题"
renderContent={() => <Text>图标与标题内容分别由插槽提供。</Text>}
>
<Button variant="outline" tone="neutral">
标题插槽
</Button>
</Dialog>
)
}
隐藏头部
header 默认为 true。设为 false 后不显示整个头部及其中的关闭按钮,正文与页脚保留原有布局。title 仍必填,标题与提供的说明会以视觉隐藏的形式保留,供辅助技术读取;此时不渲染 icon 和 titleContent 属性。
'use client'
import { Button, Dialog, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Dialog
title="无头部"
header={false}
renderContent={() => <Text>标题保留为无障碍名称,正文与页脚正常显示。</Text>}
renderFooter={({ close }) => <Button onClick={close}>关闭</Button>}
>
<Button variant="outline" tone="neutral">
无头部
</Button>
</Dialog>
)
}
关闭按钮
closable 默认为 true。设为 false 只隐藏关闭按钮,按 Esc 和点击遮罩仍可关闭;locked 控制这两种关闭行为。header={false} 时始终不显示头部内的关闭按钮。
'use client'
import { Button, Dialog, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Dialog
title="无关闭按钮"
closable={false}
renderContent={() => <Text>按 Esc、点击遮罩或底部按钮均可关闭。</Text>}
renderFooter={({ close }) => <Button onClick={close}>关闭</Button>}
>
<Button variant="outline" tone="neutral">
无关闭按钮
</Button>
</Dialog>
)
}
自定义面板内容
renderBody 接管整个面板内部,替换默认头部、正文和页脚。组件不再添加内部留白、区域间距或 ScrollArea 包装;内边距与滚动由传入的内容控制。返回空内容也不会恢复默认布局。
此时 header、closable 以及 icon、titleContent、renderContent、renderFooter 属性不参与渲染。title 仍必填,标题与提供的说明以视觉隐藏的形式保留。尺寸、位置、遮罩、焦点约束和 locked 继续生效,渲染函数中的 close() 可程序化关闭弹窗。
示例在顶部排列操作按钮,底部固定工具栏,中间由 Textarea 自适应内容高度,并由 ScrollArea 控制整体滚动。
'use client'
import { useState } from 'react'
import { AtSign, Globe, ImagePlus, Smile } from 'lucide-react'
import {
Avatar,
Button,
CloseButton,
Dialog,
IconButton,
Image,
Inline,
ScrollArea,
Stack,
Text,
Textarea,
} from '@hina-ui/react'
export default function Demo() {
const [content, setContent] = useState('')
const [attached, setAttached] = useState(false)
const count = Array.from(content).length
const overLimit = count > 280
return (
<Dialog
title="发布动态"
placement="top"
className="max-w-[600px]"
renderBody={({ close }) => (
<Stack gap="none" className="min-h-0">
<Inline justify="between" className="border-line shrink-0 border-b px-3 py-2.5">
<Button variant="ghost" tone="neutral" size="sm" onClick={close}>
取消
</Button>
<Button
size="sm"
disabled={(!content.trim() && !attached) || overLimit}
onClick={close}
>
发布
</Button>
</Inline>
<ScrollArea className="min-h-0">
<Inline align="start" wrap={false} className="gap-3 p-4">
<Avatar src="/avatars/paper.webp" name="星见书音" />
<Stack gap="sm" className="min-w-0 flex-1">
<Stack gap="xs">
<Text weight="medium">星见书音</Text>
<Inline gap="xs">
<Globe className="text-muted size-3.5" aria-hidden="true" />
<Text size="xs" tone="muted">
公开
</Text>
</Inline>
</Stack>
<Textarea
value={content}
onValueChange={setContent}
variant="bare"
autosize={{ minRows: 5 }}
invalid={overLimit}
aria-label="动态正文"
placeholder="分享你的发现、推荐或此刻的想法…"
className="[--hn-textarea-px:0px]"
/>
{attached && (
<Stack className="relative">
<Image
src="/sample.webp"
alt="示例图片"
className="aspect-video w-full rounded-md"
/>
<CloseButton
label="移除图片"
className="bg-surface/90 absolute top-2 right-2 shadow-sm"
onClick={() => setAttached(false)}
/>
</Stack>
)}
</Stack>
</Inline>
</ScrollArea>
<Inline
justify="between"
wrap={false}
className="border-line shrink-0 border-t px-3 py-2"
>
<Inline gap="xs">
<IconButton
label="添加示例图片"
size="sm"
aria-pressed={attached}
onClick={() => setAttached(!attached)}
>
<ImagePlus />
</IconButton>
<IconButton label="插入表情" size="sm" onClick={() => setContent(content + '😊')}>
<Smile />
</IconButton>
<IconButton label="插入 @" size="sm" onClick={() => setContent(content + '@')}>
<AtSign />
</IconButton>
</Inline>
<Text
as="span"
size="xs"
tone={overLimit ? 'danger' : 'muted'}
className="shrink-0 tabular-nums"
>
{count} / 280
</Text>
</Inline>
</Stack>
)}
>
<Button variant="outline" tone="neutral">
自定义面板
</Button>
</Dialog>
)
}
尺寸
size 设置面板的最大宽度,默认 md。五档宽度如下,实际宽度受视口限制。
| size | 最大宽度 |
|---|---|
sm | 24rem |
md | 28rem |
lg | 36rem |
xl | 42rem |
2xl | 56rem |
'use client'
import { Button, Dialog, Inline, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Inline align="center" className="gap-6">
{(['sm', 'md', 'lg', 'xl', '2xl'] as const).map(size => (
<Dialog
key={size}
size={size}
title={`${size} 档`}
renderContent={() => <Text>面板宽度随 size 变化,高度始终不超出视口。</Text>}
>
<Button variant="outline" tone="neutral">
{size}
</Button>
</Dialog>
))}
</Inline>
)
}
自定义宽度
className 作用于面板,可用 max-w-[40rem] 或 max-w-[52rem] 覆盖预设最大宽度。默认定位在窄屏上仍占满可用宽度,并保留两侧留白。
'use client'
import { Button, Dialog, Inline, Text } from '@hina-ui/react'
const widths = [
{ label: '40rem', class: 'max-w-[40rem]' },
{ label: '52rem', class: 'max-w-[52rem]' },
]
export default function Demo() {
return (
<Inline>
{widths.map(width => (
<Dialog
key={width.label}
title={width.label}
className={width.class}
renderContent={() => <Text>通过 class 设置最大宽度,窄屏仍受视口限制。</Text>}
>
<Button variant="outline" tone="neutral">
{width.label}
</Button>
</Dialog>
))}
</Inline>
)
}
位置
不设置 placement 时,宽屏上对话框居中,窄屏上贴底并占满可用宽度。center 始终居中,top 始终贴顶,bottom 始终贴底。top 从顶部滑入,距顶部 1rem,两侧至少保留 1rem 留白,宽度仍由 size 控制并受视口限制。
'use client'
import { Button, Dialog, Inline, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Inline align="center" className="gap-6">
<Dialog
title="居中"
placement="center"
renderContent={() => <Text>任何屏幕宽度下都居中显示。</Text>}
>
<Button variant="outline" tone="neutral">
center
</Button>
</Dialog>
<Dialog
title="贴顶"
placement="top"
renderContent={() => <Text>从顶部滑入,保留顶部留白,窄屏上也保持贴顶。</Text>}
>
<Button variant="outline" tone="neutral">
top
</Button>
</Dialog>
<Dialog
title="贴底"
placement="bottom"
renderContent={() => <Text>从底部滑入,四角保留圆角。</Text>}
>
<Button variant="outline" tone="neutral">
bottom
</Button>
</Dialog>
<Dialog
title="随屏幕变化"
renderContent={() => (
<Text>宽屏居中,窄屏贴底并占满宽度。缩窄窗口后重新打开即可看到。</Text>
)}
>
<Button variant="outline" tone="neutral">
默认
</Button>
</Dialog>
</Inline>
)
}
长内容
默认布局中,超出可用高度的内容在 renderContent 渲染的正文区域内滚动,标题和页脚保持不动。面板本身不会超出视口。
'use client'
import { Button, Dialog, Stack, Text } from '@hina-ui/react'
const terms = Array.from(
{ length: 30 },
(_, i) =>
`第 ${i + 1} 条:使用本服务即表示你同意这一条款,它在这里只用于占位,好让正文足够长,能够看到滚动。`,
)
export default function Demo() {
return (
<Dialog
title="服务条款"
description="请阅读之后再继续。"
renderContent={() => (
<Stack gap="sm">
{terms.map(line => (
<Text key={line}>{line}</Text>
))}
</Stack>
)}
renderFooter={({ close }) => (
<>
<Button variant="soft" tone="neutral" onClick={close}>
拒绝
</Button>
<Button onClick={close}>同意</Button>
</>
)}
>
<Button variant="outline" tone="neutral">
查看条款
</Button>
</Dialog>
)
}
滚动容器
通过组件 ref 的 viewport 获取正文内置 ScrollArea 的实际滚动元素。可以读取 scrollTop、调用 scrollTo(),或将它交给滚动监听、观察器。
viewport 的类型为 HTMLElement | undefined。正文滚动区域初始化完成前、没有 renderContent 或内容卸载后为 undefined;退场期间仍返回当前元素,再次打开时更新为新的元素。viewport 可用或变化时 ref 句柄会重新传入;需要在可用时执行操作或绑定监听,可用 useState 的 setter 作为回调 ref,在依赖 modal?.viewport 的 effect 中绑定,并在 effect 的清理函数中解除绑定。
使用 renderBody 属性时,内置滚动区域被替换,viewport 为 undefined;自定义滚动区域由调用方自行引用。
示例中的 Button 通过 viewport.scrollTo() 控制滚动。
'use client'
import { useState } from 'react'
import { Button, Dialog, Stack, Text, type DialogHandle } from '@hina-ui/react'
export default function Demo() {
const [modal, setModal] = useState<DialogHandle | null>(null)
function scrollTo(position: 'start' | 'end') {
const viewport = modal?.viewport
if (!viewport) return
viewport.scrollTo({ top: position === 'start' ? 0 : viewport.scrollHeight })
}
return (
<Dialog
ref={setModal}
title="滚动容器"
description="标题与页脚固定,正文独立滚动。"
renderContent={() => (
<Stack gap="sm">
{Array.from({ length: 30 }, (_, i) => i + 1).map(index => (
<Text key={index}>第 {index} 行:正文在中间滚动,标题与页脚保持不动。</Text>
))}
</Stack>
)}
renderFooter={({ close }) => (
<>
<Button
variant="soft"
tone="neutral"
disabled={!modal?.viewport}
onClick={() => scrollTo('start')}
>
顶部
</Button>
<Button
variant="soft"
tone="neutral"
disabled={!modal?.viewport}
onClick={() => scrollTo('end')}
>
底部
</Button>
<Button onClick={close}>关闭</Button>
</>
)}
>
<Button variant="outline" tone="neutral">
打开
</Button>
</Dialog>
)
}
受控
open 可受控。省略 children 时不渲染触发器,对话框只能从外部打开。
当前:已关闭
'use client'
import { useState } from 'react'
import { Button, Dialog, Inline, Text } from '@hina-ui/react'
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<Inline align="center">
<Button variant="outline" tone="neutral" onClick={() => setOpen(true)}>
从外部打开
</Button>
<Text tone="muted" size="sm">
当前:{open ? '已打开' : '已关闭'}
</Text>
<Dialog
open={open}
onOpenChange={setOpen}
title="没有触发器的对话框"
description="它由外部的状态控制。"
renderContent={() => <Text>省略默认插槽时不渲染触发器,只能通过 open 打开。</Text>}
renderFooter={({ close }) => <Button onClick={close}>关闭</Button>}
/>
</Inline>
)
}
锁定
设置 locked 后,按 Esc 和点击遮罩都不再关闭对话框,已显示的关闭按钮变为不可用。此时通过 open 关闭仍然有效。
'use client'
import { useState } from 'react'
import { Button, Dialog, Text } from '@hina-ui/react'
export default function Demo() {
const [open, setOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
function submit() {
setSubmitting(true)
setTimeout(() => {
setSubmitting(false)
setOpen(false)
}, 2000)
}
return (
<Dialog
open={open}
onOpenChange={setOpen}
title="导入书库"
description="导入过程中请不要关闭这个对话框。"
locked={submitting}
renderContent={() => (
<Text>
{submitting ? '正在导入,两秒后自动关闭。' : '点击开始导入之后对话框会锁定两秒。'}
</Text>
)}
renderFooter={({ close }) => (
<>
<Button variant="soft" tone="neutral" disabled={submitting} onClick={close}>
取消
</Button>
<Button loading={submitting} onClick={submit}>
开始导入
</Button>
</>
)}
>
<Button variant="outline" tone="neutral">
导入
</Button>
</Dialog>
)
}
行为
- 对话框打开期间页面停止滚动,焦点被限制在面板内部,关闭后回到触发器。
- 按 Esc 或点击遮罩关闭对话框,
locked会同时禁用这两种方式。 - 默认正文区域使用 ScrollArea。
无障碍
- 面板是
role="dialog",title和description分别关联到aria-labelledby和aria-describedby。 - 标题渲染为
<h2>;隐藏头部或提供renderBody时,保留由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 | — | 是否打开,可受控 |
className | string | — | 追加到面板上的类名 |
| 属性 | 参数 | 说明 |
|---|---|---|
children | — | 触发器,省略时不渲染 |
icon | — | 标题前的装饰图标 |
titleContent | — | 标题内容,默认显示 title 属性 |
renderBody | { close } | 整个面板内部,替换默认头部、正文和页脚 |
renderContent | { close } | 正文,过高时在内部滚动 |
renderFooter | { close } | 底部的操作按钮 |
Ref
| 属性 | 类型 | 说明 |
|---|---|---|
viewport | HTMLElement | undefined | 正文内置 ScrollArea 的实际滚动元素,初始化完成后可用,内容卸载后清空 |