'use client'
import { useState } from 'react'
import { Link2, Mail, MessageCircle, QrCode } from 'lucide-react'
import { Button, ListItem, List, Sheet, Stack, Text } from '@hina-ui/react'
const targets = [
{ label: '复制链接', icon: Link2 },
{ label: '发送私信', icon: MessageCircle },
{ label: '通过邮件', icon: Mail },
{ label: '生成二维码', icon: QrCode },
]
export default function Demo() {
const [picked, setPicked] = useState('')
return (
<Stack gap="sm" align="start">
<Sheet
title="分享这篇文章"
description="选择一个去处。"
renderContent={({ close }) => (
<List>
{targets.map(target => (
<ListItem key={target.label}>
<Button
variant="ghost"
tone="neutral"
className="w-full justify-start"
icon={<target.icon />}
onClick={() => {
setPicked(target.label)
close()
}}
>
{target.label}
</Button>
</ListItem>
))}
</List>
)}
>
<Button variant="outline" tone="neutral">
分享
</Button>
</Sheet>
{picked && (
<Text tone="muted" size="sm">
选择了:{picked}
</Text>
)}
</Stack>
)
}
用法
import { Sheet } from '@hina-ui/react'
title 必填,description 是标题下面的一行说明。children 是触发器,renderContent 返回正文,renderFooter 返回底部操作按钮,renderContent、renderFooter 和接管内部布局的 renderBody 都会收到 close 方法。按住顶部把手或标题区域向下拖动,距离足够或快速下滑时关闭,否则弹回。有把手时默认不显示关闭按钮。
'use client'
import { Button, Sheet, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Sheet
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>
</Sheet>
)
}
示例
标题内容
与 Dialog 一样,icon 属性在标题前显示装饰图标,titleContent 属性替换标题内容,默认显示 title 属性。自定义标题保留 <h2> 语义与面板名称关联。
示例在标题中加入 Tag。
'use client'
import { Settings } from 'lucide-react'
import { Button, Inline, Sheet, Tag, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Sheet
title="面板标题"
icon={<Settings />}
titleContent={
<Inline as="span" wrap={false} className="inline-flex gap-2">
自定义标题
<Tag size="sm" tone="neutral">
可选
</Tag>
</Inline>
}
renderContent={() => <Text>图标独立显示,标题插槽可以组合文字和标签。</Text>}
>
<Button variant="outline" tone="neutral">
标题插槽
</Button>
</Sheet>
)
}
关闭按钮
closable 默认为 true。设为 false 只隐藏标题栏中的关闭按钮,按 Esc 和点击遮罩仍可关闭,locked 控制这些关闭行为。隐藏标题栏或提供 renderBody 时,内置关闭按钮不参与渲染。Sheet 保持有把手时不显示关闭按钮的默认行为;handle={false} 且标题栏可见时,才由 closable 控制按钮是否显示,标题区域仍可拖动。
'use client'
import { Button, Sheet, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Sheet
title="无关闭按钮"
closable={false}
handle={false}
renderContent={() => <Text>按 Esc、点击遮罩或底部按钮均可关闭。</Text>}
renderFooter={({ close }) => <Button onClick={close}>关闭</Button>}
>
<Button variant="outline" tone="neutral">
无关闭按钮
</Button>
</Sheet>
)
}
自定义面板内容
renderBody 接收 { close },接管面板内部布局,替换默认标题栏、正文和页脚。组件不再添加内容内边距、区域间距或 ScrollArea 包装,滚动与底部安全区留白由返回的内容控制。返回空内容也不会恢复默认布局。
此时 header、closable 以及 icon、titleContent、renderContent、renderFooter 不参与渲染。title 仍必填,标题与提供的说明以视觉隐藏的形式保留;遮罩、焦点约束和 locked 继续生效,renderBody 收到的 close() 可程序化关闭面板。
handle 仍独立控制把手:默认保留在自定义内容上方,只有把手区域可以拖动。设置 handle={false} 后不渲染顶部拖动区域,也不保留它的留白;自定义正文不会成为拖动区域。
示例使用 CloseButton、ScrollArea 和 Button 组织贴边标题栏、独立滚动区域与固定底栏。
'use client'
import { Button, CloseButton, Sheet, Inline, ScrollArea, Stack, Text } from '@hina-ui/react'
const items = Array.from({ length: 30 }, (_, i) => i + 1)
export default function Demo() {
return (
<Inline>
{[true, false].map(handle => (
<Sheet
key={String(handle)}
handle={handle}
title="自定义布局"
description="标题与说明仍保留为无障碍内容。"
renderBody={({ close }) => (
<Stack gap="none" className="h-[min(32rem,70dvh)] min-h-0">
<Inline
justify="between"
wrap={false}
className="border-line bg-inset shrink-0 gap-3 border-b p-4"
>
<Stack gap="xs" className="min-w-0">
<Text weight="medium">自定义布局</Text>
<Text size="sm" tone="muted">
标题栏、滚动区域和底栏均由插槽提供。
</Text>
</Stack>
<CloseButton onClick={close} />
</Inline>
<ScrollArea className="min-h-0 flex-1">
<Stack gap="none" className="divide-line divide-y px-4">
{items.map(index => (
<Text key={index} className="py-3">
内容 {index}
</Text>
))}
</Stack>
</ScrollArea>
<Inline
justify="between"
wrap={false}
className="border-line shrink-0 gap-3 border-t p-4 pb-[max(var(--hn-panel-p),env(safe-area-inset-bottom))]"
>
<Text size="sm" tone="muted">
底栏保持可见
</Text>
<Button size="sm" onClick={close}>
完成
</Button>
</Inline>
</Stack>
)}
>
<Button variant="outline" tone="neutral">
{handle ? '保留把手' : '无把手'}
</Button>
</Sheet>
))}
</Inline>
)
}
长内容
renderContent 返回的正文超出可用高度时在内部滚动,标题与页脚保持不动;面板最高占到视口减去顶部留白。
'use client'
import { Button, Sheet, Stack, Text } from '@hina-ui/react'
const clauses = Array.from({ length: 24 }, (_, i) => i + 1)
export default function Demo() {
return (
<Sheet
title="用户协议"
description="请阅读全文后再同意。"
renderContent={() => (
<Stack gap="md">
{clauses.map(n => (
<Text key={n}>
第 {n} 条:本条款用于演示面板内部的滚动,标题与页脚保持不动,正文在中间滚动。
</Text>
))}
</Stack>
)}
renderFooter={({ close }) => <Button onClick={close}>同意</Button>}
>
<Button variant="outline" tone="neutral">
查看协议
</Button>
</Sheet>
)
}
滚动容器
通过组件 ref 的 viewport 获取正文内置 ScrollArea 的实际滚动元素。可以读取 scrollTop、调用 scrollTo(),或将它交给滚动监听、观察器。
viewport 的类型为 HTMLElement | undefined。正文滚动区域初始化完成前、没有 renderContent 或内容卸载后为 undefined;退场期间仍返回当前元素,再次打开时更新为新的元素。读取 viewport 不会触发重新渲染;需要在可用时执行操作或绑定监听,可传入回调 ref:viewport 变化时它会以新的实例再次调用,在回调返回的清理函数中解除绑定。
使用 renderBody 属性时,内置滚动区域被替换,viewport 为 undefined;自定义滚动区域由调用方自行引用。
示例中的 Button 通过 viewport.scrollTo() 控制滚动。
'use client'
import { useRef } from 'react'
import { Button, Sheet, Stack, Text, type SheetHandle } from '@hina-ui/react'
const lines = Array.from({ length: 30 }, (_, i) => i + 1)
export default function Demo() {
const modal = useRef<SheetHandle>(null)
function scrollTo(position: 'start' | 'end') {
const viewport = modal.current?.viewport
if (!viewport) return
viewport.scrollTo({ top: position === 'start' ? 0 : viewport.scrollHeight })
}
return (
<Sheet
ref={modal}
title="滚动容器"
description="标题与页脚固定,正文独立滚动。"
renderContent={() => (
<Stack gap="sm">
{lines.map(index => (
<Text key={index}>第 {index} 行:正文在中间滚动,标题与页脚保持不动。</Text>
))}
</Stack>
)}
renderFooter={({ close }) => (
<>
<Button
variant="soft"
tone="neutral"
disabled={!modal.current?.viewport}
onClick={() => scrollTo('start')}
>
顶部
</Button>
<Button
variant="soft"
tone="neutral"
disabled={!modal.current?.viewport}
onClick={() => scrollTo('end')}
>
底部
</Button>
<Button onClick={close}>关闭</Button>
</>
)}
>
<Button variant="outline" tone="neutral">
打开
</Button>
</Sheet>
)
}
受控
open 可受控。省略 children 时不渲染触发器,面板只能从外部打开。
'use client'
import { useState } from 'react'
import { Button, Inline, Sheet, Text } from '@hina-ui/react'
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<Inline gap="sm">
<Button variant="outline" tone="neutral" onClick={() => setOpen(true)}>
从外部打开
</Button>
<Sheet
open={open}
onOpenChange={setOpen}
title="已收藏"
description="这篇文章已加入你的收藏。"
renderContent={() => <Text>没有触发器的面板,由外部的按钮打开。</Text>}
renderFooter={({ close }) => <Button onClick={close}>知道了</Button>}
/>
</Inline>
)
}
锁定
设置 locked 后,拖动、Esc 与点击遮罩都不再关闭面板,把手变淡表示暂时不可用;通过 open 关闭仍然有效。
'use client'
import { useState } from 'react'
import { Button, Sheet, Text } from '@hina-ui/react'
export default function Demo() {
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
async function save() {
setSaving(true)
await new Promise(resolve => setTimeout(resolve, 1500))
setSaving(false)
setOpen(false)
}
return (
<Sheet
open={open}
onOpenChange={setOpen}
title="保存修改"
description="保存期间面板不能关闭。"
locked={saving}
renderContent={() => <Text>点「保存」后一秒半内,拖动、Esc 与遮罩都不会关闭它。</Text>}
renderFooter={({ close }) => (
<>
<Button variant="soft" tone="neutral" disabled={saving} onClick={close}>
取消
</Button>
<Button loading={saving} onClick={save}>
保存
</Button>
</>
)}
>
<Button variant="outline" tone="neutral">
打开
</Button>
</Sheet>
)
}
去掉把手
handle 设为 false 不显示顶部的把手;标题栏可见时,右上角改为显示关闭按钮,标题区域仍然可以拖动;closable={false} 可隐藏关闭按钮。
'use client'
import { Button, Sheet, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Sheet
title="没有把手"
description="右上角改为关闭按钮,标题区域仍然可以拖动。"
handle={false}
renderContent={() => <Text>按住标题向下拖动试试。</Text>}
>
<Button variant="outline" tone="neutral">
打开
</Button>
</Sheet>
)
}
隐藏标题栏
与 Dialog 一样,设置 header={false} 隐藏标题栏,包括标题、说明和栏内的关闭按钮。title 仍然必填,与 description 一起保留为辅助技术可读的隐藏内容;此时不渲染 icon 和 titleContent 属性。
handle 独立控制把手。隐藏标题栏后,保留的把手仍可拖动关闭;同时设置 handle={false} 时不渲染顶部拖动区域,正文从正常内边距开始。此时可通过 Esc、遮罩,或 renderContent、renderFooter 收到的 close 方法关闭,locked 的规则不变。
示例通过页脚的 Button 调用 close。
'use client'
import { Button, Inline, Sheet, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Inline>
{[true, false].map(handle => (
<Sheet
key={String(handle)}
title="隐藏标题栏"
description="标题与说明仍保留为无障碍内容。"
header={false}
handle={handle}
renderContent={() => <Text>正文直接显示在把手下方,或从面板内边距开始。</Text>}
renderFooter={({ close }) => (
<Button variant="soft" tone="neutral" onClick={close}>
关闭
</Button>
)}
>
<Button variant="outline" tone="neutral">
{handle ? '保留把手' : '隐藏整个顶部'}
</Button>
</Sheet>
))}
</Inline>
)
}
行为
- 多个浮层按打开顺序叠放,后打开的在上方;组件的挂载先后不影响叠放。关闭后保留完整退场动画,再移除浮层。
- 面板从底边滑入,宽屏上居中并限制最大宽度,窄屏上占满宽度;默认布局底部留出设备的安全区,
renderBody模式由自定义内容控制。 - 拖动只从把手与标题区域开始,正文区域留给滚动;松手时位移超过面板高度的三成,或者下滑速度足够快,面板从松手的位置继续滑出关闭,否则弹回原位。
- 打开期间页面停止滚动,焦点被限制在面板内,关闭后回到触发器。
- 按 Esc 或者点击遮罩关闭,
locked会同时禁用这两种方式与拖动。
无障碍
- 面板是
role="dialog",title与description分别关联到aria-labelledby与aria-describedby。 - 把手只是视觉提示,对辅助技术隐藏;标题栏可见且没有把手时,关闭按钮带有语言包给出的名称。
- 拖动是触屏与鼠标的快捷方式,键盘用户通过 Esc 关闭,页脚里的按钮也可以调用
close。
API
Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
title | string | — | 必填。面板标题 |
description | string | — | 标题下面的说明 |
header | boolean | true | 是否显示标题栏,隐藏时仍保留无障碍名称与说明 |
handle | 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 的实际滚动元素,初始化完成后可用,内容卸载后清空 |