Popover 气泡卡片

点击触发器后浮出的面板。

<script setup lang="ts">
  import { Settings2 } from '@lucide/vue'
  import { Button, Heading, Input, Popover, Stack, Text } from '@hina-ui/vue'
</script>

<template>
  <Popover align="start">
    <Button variant="outline" tone="neutral">
      <template #icon>
        <Settings2 />
      </template>
      阅读设置
    </Button>
    <template #content>
      <Stack gap="sm" class="w-60">
        <Stack gap="xs">
          <Heading :level="4" size="sm">阅读设置</Heading>
          <Text tone="muted" size="sm">调整正文的宽度与行距。</Text>
        </Stack>
        <Stack gap="xs">
          <Text size="sm">正文宽度</Text>
          <Input model-value="720" size="sm" />
        </Stack>
        <Stack gap="xs">
          <Text size="sm">行距</Text>
          <Input model-value="1.8" size="sm" />
        </Stack>
        <Button size="sm">保存</Button>
      </Stack>
    </template>
  </Popover>
</template>
vue

用法

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

默认插槽是触发器,content 插槽是浮出的内容。点击触发器打开面板,再次点击触发器或点击面板外部关闭。面板中的内容可以自由排布,也可以获得焦点。

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

<template>
  <Popover>
    <Button variant="outline" tone="neutral">关于这本书</Button>
    <template #content>
      <Text size="sm">ATRI -My Dear Moments- 由 ANIPLEX.EXE 发行,2020 年 6 月 19 日上市。</Text>
    </template>
  </Popover>
</template>
vue

示例

位置

side 指定面板朝哪个方向浮出,align 指定它与触发器的对齐方式。默认在正下方,空间不足时翻转到相反一侧。

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

<template>
  <Inline align="center" class="gap-6">
    <Popover align="start">
      <Button variant="outline" tone="neutral">start</Button>
      <template #content>
        <Text size="sm">与触发器的起始边对齐。</Text>
      </template>
    </Popover>
    <Popover align="center">
      <Button variant="outline" tone="neutral">center</Button>
      <template #content>
        <Text size="sm">与触发器居中对齐。</Text>
      </template>
    </Popover>
    <Popover side="right" align="start">
      <Button variant="outline" tone="neutral">right</Button>
      <template #content>
        <Text size="sm">在触发器右侧展开。</Text>
      </template>
    </Popover>
    <Popover side="top">
      <Button variant="outline" tone="neutral">top</Button>
      <template #content>
        <Text size="sm">在触发器上方展开。</Text>
      </template>
    </Popover>
  </Inline>
</template>
vue

间距

sideOffset 是面板与触发器之间的距离,单位为像素。

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

<template>
  <Inline align="center" class="gap-6">
    <Popover :side-offset="0">
      <Button variant="outline" tone="neutral">紧贴</Button>
      <template #content>
        <Text size="sm">sideOffset 为 0。</Text>
      </template>
    </Popover>
    <Popover :side-offset="16">
      <Button variant="outline" tone="neutral">远离</Button>
      <template #content>
        <Text size="sm">sideOffset 为 16。</Text>
      </template>
    </Popover>
  </Inline>
</template>
vue

受控

open 支持双向绑定,既可以从外部打开或关闭面板,面板内部的按钮也可以关闭它。

当前:收起

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

  const open = ref(false)
</script>

<template>
  <Inline align="center">
    <Popover v-model:open="open">
      <Button variant="outline" tone="neutral">详情</Button>
      <template #content>
        <Stack gap="sm" class="w-56">
          <Text size="sm">这段内容可以从外部展开或者收起。</Text>
          <Button size="sm" variant="soft" tone="neutral" @click="open = false">知道了</Button>
        </Stack>
      </template>
    </Popover>
    <Button size="sm" variant="soft" tone="neutral" @click="open = !open">
      从外部{{ open ? '收起' : '展开' }}
    </Button>
    <Text tone="muted" size="sm">当前:{{ open ? '展开' : '收起' }}</Text>
  </Inline>
</template>
vue

外部锚点

anchor 接受 OverlayAnchor | null。设置后可以省略默认插槽,通过 v-model:open 控制开关。锚点尚未就绪时面板不显示;打开期间更换锚点会更新位置,关闭时清空锚点会保留退场位置。

同时提供默认插槽与 anchor 时,插槽负责触发,anchor 负责定位。外部元素的点击与键盘行为、aria-haspopup 和 aria-expanded 由调用方设置。

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

  const open = ref(false)
  const current = ref(0)
  const anchor = shallowRef<HTMLElement | null>(null)

  function toggle(event: MouseEvent, index: number) {
    const target = event.currentTarget as HTMLElement
    open.value = anchor.value === target ? !open.value : true
    anchor.value = target
    current.value = index
  }
</script>

<template>
  <Inline>
    <Button
      v-for="index in 2"
      :key="index"
      variant="outline"
      tone="neutral"
      aria-haspopup="dialog"
      :aria-expanded="open && current === index"
      @click="toggle($event, index)"
    >
      锚点 {{ index }}
    </Button>
    <Popover v-model:open="open" :anchor="anchor" :modal="false" aria-label="外部锚点">
      <template #content>
        <Stack gap="sm" class="w-56">
          <Text size="sm">默认插槽为空,位置由 anchor 决定。</Text>
          <Button size="sm" variant="soft" tone="neutral" @click="open = false">关闭</Button>
        </Stack>
      </template>
    </Popover>
  </Inline>
</template>
vue

虚拟锚点与持续跟随

anchor 也接受带 getBoundingClientRect() 的对象,返回视口坐标中的矩形。OverlayAnchor 类型可从包根导入;回调需要返回最新坐标,同一个对象不必反复替换。

可选的 contextElement 指定坐标所属的元素,用于识别滚动祖先和裁剪边界;它不会成为触发器,也不会扩大浮层的交互区域。

updatePositionStrategy 默认是 'optimized',在滚动、尺寸和布局变化时更新位置。设置为 'always' 后,挂载期间逐帧检查矩形,持续跟随仅有坐标变化的锚点。可以在打开期间切换策略。退场期间继续跟随,锚点清空或所属元素移除后保留最后的位置,卸载后停止测量。

需要保持外部焦点时,设置 :modal="false" 并使用 @open-auto-focus.prevent。

示例用 Button 打开面板,并用 ScrollArea 提供滚动容器。面板同时跟随坐标变化和容器滚动。

向下滚动可观察定位变化。

<script setup lang="ts">
  import { ref, shallowRef, watch } from 'vue'
  import { useRafFn } from '@vueuse/core'
  import {
    Button,
    ScrollArea,
    Inline,
    Stack,
    Text,
    Popover,
    type OverlayAnchor,
  } from '@hina-ui/vue'

  const open = ref(false)
  const surface = ref<InstanceType<typeof Stack>>()
  const anchor = shallowRef<OverlayAnchor | null>(null)
  const x = ref(80)
  const { pause, resume } = useRafFn(
    ({ delta }) => {
      x.value = 80 + ((x.value - 80 + delta / 35) % 120)
    },
    { immediate: false },
  )
  watch(open, value => (value ? resume() : pause()))

  function show() {
    const element = surface.value?.$el as HTMLElement | undefined
    if (!element) return
    anchor.value = {
      contextElement: element,
      getBoundingClientRect: () => {
        const rect = element.getBoundingClientRect()
        return new DOMRect(rect.left + x.value, rect.top + 100, 2, 20)
      },
    }
    open.value = true
  }
</script>

<template>
  <Stack class="w-full">
    <Inline>
      <Button variant="outline" tone="neutral" @click="show">打开</Button>
      <Text size="sm" tone="muted">向下滚动可观察定位变化。</Text>
    </Inline>
    <ScrollArea class="h-64 rounded-lg border border-line bg-inset">
      <Stack ref="surface" class="relative h-128 shrink-0">
        <Text
          as="span"
          aria-hidden="true"
          class="absolute top-25 h-5 w-0.5 bg-accent"
          :style="{ left: `${x}px` }"
        />
      </Stack>
    </ScrollArea>
    <Popover
      v-model:open="open"
      :anchor="anchor"
      update-position-strategy="always"
      align="start"
      :modal="false"
      @open-auto-focus.prevent
    >
      <template #content>
        <Text size="sm">移动坐标</Text>
      </template>
    </Popover>
  </Stack>
</template>
vue

焦点与关闭

openAutoFocus、closeAutoFocus 可以通过 event.preventDefault() 取消默认聚焦。没有默认触发器时,关闭后恢复打开前的焦点;锚点仅用于定位。调用方已经将焦点移到面板外时,不会再次恢复旧焦点。

通过 interactOutside 可以取消外部点击或焦点移出引起的关闭,escapeKeyDown 可以取消 Esc 关闭。aria-label、aria-describedby 和 data-* 属性会传给面板。

自定义内边距

面板默认带内边距。内容需要延伸到边缘时设置 padded="false",由内容自行安排留白。

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

<template>
  <Popover :padded="false" align="start">
    <Button variant="outline" tone="neutral">最近更新</Button>
    <template #content>
      <Stack gap="none" class="w-64">
        <Stack gap="none" class="bg-inset px-4 py-3">
          <Text size="sm" class="font-medium">第 42 话</Text>
        </Stack>
        <Divider />
        <Stack gap="xs" class="px-4 py-3">
          <Text tone="muted" size="sm">作者:支倉凍砂</Text>
          <Text tone="muted" size="sm">更新于三小时前</Text>
        </Stack>
      </Stack>
    </template>
  </Popover>
</template>
vue

触发器

触发器不限于按钮,任何能获得焦点的元素都可以。用图标按钮作为触发器时,关闭它自带的提示,以免两层浮层叠在一起。

<script setup lang="ts">
  import { Info } from '@lucide/vue'
  import { IconButton, Inline, Link, Popover, Text } from '@hina-ui/vue'
</script>

<template>
  <Inline align="center" class="gap-6">
    <Popover>
      <IconButton variant="ghost" tone="neutral" label="查看说明" :tooltip="false">
        <Info />
      </IconButton>
      <template #content>
        <Text size="sm">触发器是一个图标按钮。</Text>
      </template>
    </Popover>
    <Popover align="start">
      <Link href="#">星见书音</Link>
      <template #content>
        <Text size="sm">触发器是一段行内的链接文字。</Text>
      </template>
    </Popover>
  </Inline>
</template>
vue

行为

  • 默认模态下,面板打开期间页面停止滚动。
  • 触发器在面板打开期间保持按下时的样式。
  • 面板打开后焦点移入面板,按 Esc 关闭并把焦点交还给触发器。
  • 默认模态下,点击面板外部关闭面板,这次点击不会传到下层的元素上。

无障碍

  • 触发器带有 aria-haspopup="dialog" 和 aria-expanded,面板是 role="dialog"。
  • 面板中的标题、说明和表单控件都按普通页面内容处理,屏幕阅读器逐项播报。
  • 没有默认触发器时,通过 aria-label 为面板提供名称;关闭后默认恢复打开前的焦点。

API

Popover

属性
类型
默认值
说明
open
boolean
—
是否打开,支持双向绑定
anchor
OverlayAnchor | null
—
定位元素或虚拟锚点
updatePositionStrategy
'optimized' | 'always'
'optimized'
定位更新策略
modal
boolean
true
是否限制外部交互并锁滚
side
'top' | 'right' | 'bottom' | 'left'
'bottom'
朝哪个方向浮出
align
'start' | 'center' | 'end'
'center'
与触发器的对齐方式
sideOffset
number
8
与触发器的距离
padded
boolean
true
面板是否带内边距
class
string
—
追加到面板上的类名
插槽
说明
default
可选触发器
content
面板中的内容
事件
参数
说明
openAutoFocus
Event
打开时聚焦前触发,可取消
closeAutoFocus
Event
关闭时恢复焦点前触发,可取消
escapeKeyDown
KeyboardEvent
按 Esc 时触发,可取消关闭
pointerDownOutside
PointerDownOutsideEvent
外部按下时触发,可取消关闭
focusOutside
FocusOutsideEvent
焦点移到外部时触发,可取消关闭
interactOutside
PointerDownOutsideEvent | FocusOutsideEvent
外部按下或焦点移出时触发,可取消关闭