Dialog 对话框

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

'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>
  )
}
tsx

用法

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

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>
  )
}
tsx

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

示例

标题内容

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>
  )
}
tsx

关闭按钮

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>
  )
}
tsx

自定义面板内容

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>
  )
}
tsx

尺寸

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

size最大宽度
sm24rem
md28rem
lg36rem
xl42rem
2xl56rem
'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>
  )
}
tsx

自定义宽度

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>
  )
}
tsx

位置

不设置 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>
  )
}
tsx

长内容

默认布局中,超出可用高度的内容在 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>
  )
}
tsx

滚动容器

通过组件 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>
  )
}
tsx

受控

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>
  )
}
tsx

锁定

设置 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>
  )
}
tsx

行为

  • 对话框打开期间页面停止滚动,焦点被限制在面板内部,关闭后回到触发器。
  • 按 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 的实际滚动元素,初始化完成后可用,内容卸载后清空