Drawer 抽屉

从屏幕边缘滑入的模态面板。

'use client'

import { SlidersHorizontal } from 'lucide-react'
import { Button, Drawer, Input, Stack, Text } from '@hina-ui/react'

export default function Demo() {
  return (
    <Drawer
      title="筛选"
      description="设定之后只显示符合条件的作品。"
      renderContent={() => (
        <Stack gap="sm">
          <Stack gap="xs">
            <Text size="sm">关键词</Text>
            <Input defaultValue="天文台" />
          </Stack>
          <Stack gap="xs">
            <Text size="sm">发行年份</Text>
            <Input defaultValue="2024" />
          </Stack>
          <Stack gap="xs">
            <Text size="sm">制作方</Text>
            <Input defaultValue="ANIPLEX.EXE" />
          </Stack>
        </Stack>
      )}
      renderFooter={({ close }) => (
        <>
          <Button variant="soft" tone="neutral" onClick={close}>
            重置
          </Button>
          <Button onClick={close}>应用</Button>
        </>
      )}
    >
      <Button variant="outline" tone="neutral" icon={<SlidersHorizontal />}>
        筛选
      </Button>
    </Drawer>
  )
}
tsx

用法

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

title 必填,description 是标题下面的一行说明。children 是触发器,renderContent 渲染正文,renderFooter 渲染底部的操作按钮。renderContent、renderFooter 和接管内部布局的 renderBody 都会收到 close 方法。

'use client'

import { Button, Drawer, Text } from '@hina-ui/react'

export default function Demo() {
  return (
    <Drawer
      title="通知"
      description="最近七天的消息。"
      renderContent={() => <Text>这里放置消息列表,抽屉贴着屏幕边缘展开,四角保持直角。</Text>}
    >
      <Button variant="outline" tone="neutral">
        打开通知
      </Button>
    </Drawer>
  )
}
tsx

抽屉贴着屏幕边缘打开,占满整个高度,四角为直角。遮罩、停止页面滚动和焦点陷阱与 Dialog 相同。

示例

标题内容

与 Dialog 一样,icon 属性在标题前显示装饰图标,titleContent 属性替换标题内容,默认显示 title 属性。自定义标题保留 <h2> 语义与面板名称关联。

示例在标题中加入 Tag。

'use client'

import { Settings } from 'lucide-react'
import { Button, Inline, Drawer, Tag, Text } from '@hina-ui/react'

export default function Demo() {
  return (
    <Drawer
      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>
    </Drawer>
  )
}
tsx

关闭按钮

closable 默认为 true。设为 false 只隐藏标题栏中的关闭按钮,按 Esc 和点击遮罩仍可关闭,locked 控制这些关闭行为。隐藏标题栏或提供 renderBody 时,内置关闭按钮不参与渲染。

'use client'

import { Button, Drawer, Text } from '@hina-ui/react'

export default function Demo() {
  return (
    <Drawer
      title="无关闭按钮"
      closable={false}
      renderContent={() => <Text>按 Esc、点击遮罩或底部按钮均可关闭。</Text>}
      renderFooter={({ close }) => <Button onClick={close}>关闭</Button>}
    >
      <Button variant="outline" tone="neutral">
        无关闭按钮
      </Button>
    </Drawer>
  )
}
tsx

自定义面板内容

renderBody 接管面板内部布局,替换默认标题栏、正文和页脚。组件不再添加内容内边距、区域间距或 ScrollArea 包装,滚动与底部安全区留白由传入的内容控制。返回空内容也不会恢复默认布局。

此时 header、closable 以及 icon、titleContent、renderContent、renderFooter 属性不参与渲染。title 仍必填,标题与提供的说明以视觉隐藏的形式保留;遮罩、焦点约束和 locked 继续生效,渲染函数的 close() 可程序化关闭面板。

示例使用 CloseButton、ScrollArea 和 Button 组织贴边标题栏、独立滚动区域与固定底栏。

'use client'

import { Button, CloseButton, Inline, Drawer, ScrollArea, Stack, Text } from '@hina-ui/react'

export default function Demo() {
  return (
    <Drawer
      title="自定义布局"
      description="标题与说明仍保留为无障碍内容。"
      renderBody={({ close }) => (
        <Stack gap="none" className="min-h-0 flex-1">
          <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">
              {Array.from({ length: 30 }, (_, i) => i + 1).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">
        自定义面板
      </Button>
    </Drawer>
  )
}
tsx

方向

side 取 start 或 end,指文字方向上的起始边和结束边。中文和英文环境下分别是左边缘和右边缘,从右向左书写的语言中自动对调。

'use client'

import { Button, Drawer, Inline, Text } from '@hina-ui/react'

export default function Demo() {
  return (
    <Inline align="center" className="gap-6">
      <Drawer
        title="从起始边展开"
        side="start"
        renderContent={() => (
          <Text>中文与英文环境下贴左缘,阿拉伯语等从右到左的语言下贴右缘。</Text>
        )}
      >
        <Button variant="outline" tone="neutral">
          start
        </Button>
      </Drawer>
      <Drawer
        title="从结束边展开"
        side="end"
        renderContent={() => <Text>默认值,中文与英文环境下贴右缘。</Text>}
      >
        <Button variant="outline" tone="neutral">
          end
        </Button>
      </Drawer>
    </Inline>
  )
}
tsx

尺寸

size 设置抽屉的宽度,三档分别为 288、360 和 480 像素。窄屏上宽度不会超过视口宽度减去 48 像素。

'use client'

import { Button, Drawer, Inline, Text } from '@hina-ui/react'

export default function Demo() {
  return (
    <Inline align="center" className="gap-6">
      {(['sm', 'md', 'lg'] as const).map(size => (
        <Drawer
          key={size}
          size={size}
          title={`${size} 档`}
          renderContent={() => <Text>抽屉的宽度随 size 变化,高度始终占满屏幕。</Text>}
        >
          <Button variant="outline" tone="neutral">
            {size}
          </Button>
        </Drawer>
      ))}
    </Inline>
  )
}
tsx

长内容

超出可用高度的内容在 renderContent 渲染的正文区域内滚动,标题和页脚保持不动。

'use client'

import { Button, Drawer, Stack, Text } from '@hina-ui/react'

const items = Array.from(
  { length: 30 },
  (_, i) => `第 ${i + 1} 条通知:这是一段用于占位的文字,好让正文足够长,能够看到滚动。`,
)

export default function Demo() {
  return (
    <Drawer
      title="全部通知"
      description="标题与页脚固定,中间的列表可以滚动。"
      renderContent={() => (
        <Stack gap="sm">
          {items.map(item => (
            <Text key={item}>{item}</Text>
          ))}
        </Stack>
      )}
      renderFooter={({ close }) => (
        <Button variant="soft" tone="neutral" onClick={close}>
          全部标为已读
        </Button>
      )}
    >
      <Button variant="outline" tone="neutral">
        查看全部
      </Button>
    </Drawer>
  )
}
tsx

滚动容器

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

viewport 的类型为 HTMLElement | undefined。正文滚动区域初始化完成前、没有 renderContent 或内容卸载后为 undefined;退场期间仍返回当前元素,再次打开时更新为新的元素。viewport 可用或变化时 ref 句柄会重新传入;需要在可用时执行操作或绑定监听,可用 useState 的 setter 作为回调 ref,在依赖 drawer?.viewport 的 effect 中绑定,并在 effect 的清理函数中解除绑定。

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

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

'use client'

import { useCallback, useState } from 'react'
import { Button, Drawer, Stack, Text, type DrawerHandle } from '@hina-ui/react'

export default function Demo() {
  const [viewport, setViewport] = useState<HTMLElement>()
  const modal = useCallback((handle: DrawerHandle | null) => setViewport(handle?.viewport), [])

  function scrollTo(position: 'start' | 'end') {
    if (!viewport) return
    viewport.scrollTo({ top: position === 'start' ? 0 : viewport.scrollHeight })
  }

  return (
    <Drawer
      ref={modal}
      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={!viewport}
            onClick={() => scrollTo('start')}
          >
            顶部
          </Button>
          <Button
            variant="soft"
            tone="neutral"
            disabled={!viewport}
            onClick={() => scrollTo('end')}
          >
            底部
          </Button>
          <Button onClick={close}>关闭</Button>
        </>
      )}
    >
      <Button variant="outline" tone="neutral">
        打开
      </Button>
    </Drawer>
  )
}
tsx

受控

open 可受控。省略 children 时不渲染触发器,抽屉只能从外部打开。

当前:已关闭

'use client'

import { useState } from 'react'
import { Button, Drawer, 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>
      <Drawer
        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, Drawer, Text } from '@hina-ui/react'

export default function Demo() {
  const [open, setOpen] = useState(false)
  const [saving, setSaving] = useState(false)

  function save() {
    setSaving(true)
    setTimeout(() => {
      setSaving(false)
      setOpen(false)
    }, 2000)
  }

  return (
    <Drawer
      open={open}
      onOpenChange={setOpen}
      title="编辑标签"
      description="保存过程中请不要关闭这个抽屉。"
      locked={saving}
      renderContent={() => (
        <Text>{saving ? '正在保存,两秒后自动关闭。' : '点击保存之后抽屉会锁定两秒。'}</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>
    </Drawer>
  )
}
tsx

行为

  • 多个浮层按打开顺序叠放,后打开的在上方;组件的挂载先后不影响叠放。关闭后保留完整退场动画,再移除浮层。
  • 抽屉打开期间页面停止滚动,焦点被限制在面板内部,关闭后回到触发器。
  • 按 Esc 或点击遮罩关闭抽屉,locked 会同时禁用这两种方式。
  • 默认正文区域使用 ScrollArea;renderBody 的滚动由调用方控制。

无障碍

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

API

Drawer

属性
类型
默认值
说明
title
string
—
必填。抽屉标题
description
string
—
标题下面的说明
side
'start' | 'end'
'end'
从哪一侧滑入
size
'sm' | 'md' | 'lg'
'md'
抽屉的宽度
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 的实际滚动元素,初始化完成后可用,内容卸载后清空