Sheet 底部面板

从屏幕底部升起、可以拖动关闭的面板。

<script setup lang="ts">
  import { ref } from 'vue'
  import { Link2, Mail, MessageCircle, QrCode } from '@lucide/vue'
  import { Button, ListItem, List, Sheet, Stack, Text } from '@hina-ui/vue'

  const targets = [
    { label: '复制链接', icon: Link2 },
    { label: '发送私信', icon: MessageCircle },
    { label: '通过邮件', icon: Mail },
    { label: '生成二维码', icon: QrCode },
  ]
  const picked = ref('')
</script>

<template>
  <Stack gap="sm" align="start">
    <Sheet title="分享这篇文章" description="选择一个去处。">
      <Button variant="outline" tone="neutral">分享</Button>
      <template #content="{ close }">
        <List>
          <ListItem v-for="target in targets" :key="target.label">
            <Button
              variant="ghost"
              tone="neutral"
              class="w-full justify-start"
              @click="((picked = target.label), close())"
            >
              <template #icon><component :is="target.icon" /></template>
              {{ target.label }}
            </Button>
          </ListItem>
        </List>
      </template>
    </Sheet>
    <Text v-if="picked" tone="muted" size="sm">选择了:{{ picked }}</Text>
  </Stack>
</template>
vue

用法

import { Sheet } from '@hina-ui/vue'
ts

title 必填,description 是标题下面的一行说明。默认插槽是触发器,content 插槽是正文,footer 插槽是底部操作按钮,content、footer 和接管内部布局的 body 插槽都会收到 close 方法。按住顶部把手或标题区域向下拖动,距离足够或快速下滑时关闭,否则弹回。有把手时默认不显示关闭按钮。

<script setup lang="ts">
  import { Button, Sheet, Text } from '@hina-ui/vue'
</script>

<template>
  <Sheet 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>
  </Sheet>
</template>
vue

示例

标题内容

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

示例在标题中加入 Tag。

<script setup lang="ts">
  import { Settings } from '@lucide/vue'
  import { Button, Inline, Sheet, Tag, Text } from '@hina-ui/vue'
</script>

<template>
  <Sheet title="面板标题">
    <Button variant="outline" tone="neutral">标题插槽</Button>
    <template #icon><Settings /></template>
    <template #title>
      <Inline as="span" :wrap="false" class="inline-flex gap-2">
        自定义标题
        <Tag size="sm" tone="neutral">可选</Tag>
      </Inline>
    </template>
    <template #content>
      <Text>图标独立显示,标题插槽可以组合文字和标签。</Text>
    </template>
  </Sheet>
</template>
vue

关闭按钮

closable 默认为 true。设为 false 只隐藏标题栏中的关闭按钮,按 Esc 和点击遮罩仍可关闭,locked 控制这些关闭行为。隐藏标题栏或提供 body 时,内置关闭按钮不参与渲染。 Sheet 保持有把手时不显示关闭按钮的默认行为;:handle="false" 且标题栏可见时,才由 closable 控制按钮是否显示,标题区域仍可拖动。

<script setup lang="ts">
  import { Button, Sheet, Text } from '@hina-ui/vue'
</script>

<template>
  <Sheet title="无关闭按钮" :closable="false" :handle="false">
    <Button variant="outline" tone="neutral">无关闭按钮</Button>
    <template #content>
      <Text>按 Esc、点击遮罩或底部按钮均可关闭。</Text>
    </template>
    <template #footer="{ close }">
      <Button @click="close">关闭</Button>
    </template>
  </Sheet>
</template>
vue

自定义面板内容

#body="{ close }" 接管面板内部布局,替换默认标题栏、正文和页脚。组件不再添加内容内边距、区域间距或 ScrollArea 包装,滚动与底部安全区留白由插槽内容控制。提供空插槽也不会恢复默认布局。

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

handle 仍独立控制把手:默认保留在自定义内容上方,只有把手区域可以拖动。设置 :handle="false" 后不渲染顶部拖动区域,也不保留它的留白;自定义正文不会成为拖动区域。

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

<script setup lang="ts">
  import { Button, CloseButton, Sheet, Inline, ScrollArea, Stack, Text } from '@hina-ui/vue'
</script>

<template>
  <Inline>
    <Sheet
      v-for="handle in [true, false]"
      :key="String(handle)"
      :handle="handle"
      title="自定义布局"
      description="标题与说明仍保留为无障碍内容。"
    >
      <Button variant="outline" tone="neutral">{{ handle ? '保留把手' : '无把手' }}</Button>
      <template #body="{ close }">
        <Stack gap="none" class="h-[min(32rem,70dvh)] min-h-0">
          <Inline
            justify="between"
            :wrap="false"
            class="border-line bg-inset shrink-0 gap-3 border-b p-4"
          >
            <Stack gap="xs" class="min-w-0">
              <Text weight="medium">自定义布局</Text>
              <Text size="sm" tone="muted">标题栏、滚动区域和底栏均由插槽提供。</Text>
            </Stack>
            <CloseButton @click="close" />
          </Inline>
          <ScrollArea class="min-h-0 flex-1">
            <Stack gap="none" class="divide-line divide-y px-4">
              <Text v-for="index in 30" :key="index" class="py-3">内容 {{ index }}</Text>
            </Stack>
          </ScrollArea>
          <Inline
            justify="between"
            :wrap="false"
            class="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" @click="close">完成</Button>
          </Inline>
        </Stack>
      </template>
    </Sheet>
  </Inline>
</template>
vue

长内容

超出可用高度的正文在 content 插槽内部滚动,标题与页脚保持不动;面板最高占到视口减去顶部留白。

<script setup lang="ts">
  import { Button, Sheet, Stack, Text } from '@hina-ui/vue'
</script>

<template>
  <Sheet title="用户协议" description="请阅读全文后再同意。">
    <Button variant="outline" tone="neutral">查看协议</Button>
    <template #content>
      <Stack gap="md">
        <Text v-for="n in 24" :key="n">
          第 {{ n }} 条:本条款用于演示面板内部的滚动,标题与页脚保持不动,正文在中间滚动。
        </Text>
      </Stack>
    </template>
    <template #footer="{ close }">
      <Button @click="close">同意</Button>
    </template>
  </Sheet>
</template>
vue

滚动容器

通过组件 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, Sheet, Stack, Text } from '@hina-ui/vue'

  const modal = ref<InstanceType<typeof Sheet>>()

  function scrollTo(position: 'start' | 'end') {
    const viewport = modal.value?.viewport
    if (!viewport) return
    viewport.scrollTo({ top: position === 'start' ? 0 : viewport.scrollHeight })
  }
</script>

<template>
  <Sheet 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>
  </Sheet>
</template>
vue

受控

open 支持双向绑定。省略默认插槽时不渲染触发器,面板只能从外部打开。

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Inline, Sheet, Text } from '@hina-ui/vue'

  const open = ref(false)
</script>

<template>
  <Inline gap="sm">
    <Button variant="outline" tone="neutral" @click="open = true">从外部打开</Button>
    <Sheet v-model:open="open" title="已收藏" description="这篇文章已加入你的收藏。">
      <template #content>
        <Text>没有触发器的面板,由外部的按钮打开。</Text>
      </template>
      <template #footer="{ close }">
        <Button @click="close">知道了</Button>
      </template>
    </Sheet>
  </Inline>
</template>
vue

锁定

设置 locked 后,拖动、Esc 与点击遮罩都不再关闭面板,把手变淡表示暂时不可用;通过 open 关闭仍然有效。面板内的 Form 提交期间同样处于锁定状态,无论 locked 是否设置,用法见 Dialog。

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Sheet, Text } from '@hina-ui/vue'

  const open = ref(false)
  const saving = ref(false)

  async function save() {
    saving.value = true
    await new Promise(resolve => setTimeout(resolve, 1500))
    saving.value = false
    open.value = false
  }
</script>

<template>
  <Sheet v-model:open="open" title="保存修改" description="保存期间面板不能关闭。" :locked="saving">
    <Button variant="outline" tone="neutral">打开</Button>
    <template #content>
      <Text>点「保存」后一秒半内,拖动、Esc 与遮罩都不会关闭它。</Text>
    </template>
    <template #footer="{ close }">
      <Button variant="soft" tone="neutral" :disabled="saving" @click="close">取消</Button>
      <Button :loading="saving" @click="save">保存</Button>
    </template>
  </Sheet>
</template>
vue

去掉把手

handle 设为 false 不显示顶部的把手;标题栏可见时,右上角改为显示关闭按钮,标题区域仍然可以拖动;closable=false 可隐藏关闭按钮。

<script setup lang="ts">
  import { Button, Sheet, Text } from '@hina-ui/vue'
</script>

<template>
  <Sheet title="没有把手" description="右上角改为关闭按钮,标题区域仍然可以拖动。" :handle="false">
    <Button variant="outline" tone="neutral">打开</Button>
    <template #content>
      <Text>按住标题向下拖动试试。</Text>
    </template>
  </Sheet>
</template>
vue

行为

  • 多个浮层按打开顺序叠放,后打开的在上方;组件的挂载先后不影响叠放。关闭后保留完整退场动画,再移除浮层。
  • 面板从底边滑入,宽屏上居中并限制最大宽度,窄屏上占满宽度;默认布局底部留出设备的安全区,body 模式由自定义内容控制。
  • 拖动只从把手与标题区域开始,正文区域留给滚动;松手时位移超过面板高度的三成,或者下滑速度足够快,面板从松手的位置继续滑出关闭,否则弹回原位。
  • 打开期间页面停止滚动,焦点被限制在面板内,关闭后回到触发器。
  • 按 Esc 或者点击遮罩关闭,locked 会同时禁用这两种方式与拖动。
  • 点击遮罩或焦点移到外部时先触发 interactOutside,调用 preventDefault() 后不关闭。OverlayBranch 里的内容不算外部。

无障碍

  • 面板是 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
—
是否打开,支持双向绑定
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 的实际滚动元素,初始化完成后可用,内容卸载后清空