HoverCard 悬停卡片

悬停在链接上时浮出的预览卡片。

这条评论来自 @星见书音 ,发表于三天前。

<script setup lang="ts">
  import { Avatar, HoverCard, Inline, Link, Stack, Text } from '@hina-ui/vue'
</script>

<template>
  <Text>
    这条评论来自
    <HoverCard>
      <Link href="#">@星见书音</Link>
      <template #content>
        <Stack gap="sm" class="w-64">
          <Inline gap="sm" align="center">
            <Avatar src="/avatars/selfie.webp" name="星见书音" size="md" />
            <Stack gap="none">
              <Text weight="medium">星见书音</Text>
              <Text tone="muted" size="sm">@shion</Text>
            </Stack>
          </Inline>
          <Text size="sm">读书、写字、偶尔画画。正在补完今年的新番。</Text>
          <Inline gap="md">
            <Text size="sm">
              <Text as="span" weight="medium">128</Text>
              关注
            </Text>
            <Text size="sm">
              <Text as="span" weight="medium">2,048</Text>
              粉丝
            </Text>
          </Inline>
        </Stack>
      </template>
    </HoverCard>
    ,发表于三天前。
  </Text>
</template>
vue

用法

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

悬停卡片给一个链接配上预览:指针在触发器上停留一段时间后浮出卡片,移开后收回;用键盘聚焦触发器时也按 openDelay 延时浮出。默认插槽是触发器,通常是一个链接;content 插槽是卡片内容。它只是看一眼的东西,不停止页面滚动,也不接管焦点。

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

<template>
  <HoverCard>
    <Link href="#">Reka UI</Link>
    <template #content>
      <Stack gap="xs" class="w-64">
        <Text weight="medium">Reka UI</Text>
        <Text tone="muted" size="sm">
          一套无样式、可访问的 Vue 组件基础,本库的大部分交互件建在它上面。
        </Text>
      </Stack>
    </template>
  </HoverCard>
</template>
vue

示例

位置

side 指定卡片朝哪个方向浮出,align 指定它与触发器的对齐方式,与 Popover 相同,默认在正下方。

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

  const sides = ['top', 'right', 'bottom', 'left'] as const
  const names = { top: '上方', right: '右侧', bottom: '下方', left: '左侧' }
</script>

<template>
  <Inline gap="lg">
    <HoverCard v-for="side in sides" :key="side" :side="side">
      <Link href="#">{{ names[side] }}</Link>
      <template #content>
        <Text size="sm">从{{ names[side] }}浮出的卡片。</Text>
      </template>
    </HoverCard>
  </Inline>
</template>
vue

延时

openDelay 是指针停留多久后浮出,closeDelay 是移开多久后收回,单位毫秒。指针从触发器移进卡片时不会收回。

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

<template>
  <Inline gap="lg">
    <HoverCard :open-delay="0" :close-delay="0">
      <Link href="#">立即浮出</Link>
      <template #content>
        <Text size="sm">没有延时,指针一到就出现。</Text>
      </template>
    </HoverCard>
    <HoverCard :open-delay="800" :close-delay="400">
      <Link href="#">停留久一点</Link>
      <template #content>
        <Text size="sm">停留 800 毫秒才出现,移开 400 毫秒后收回。</Text>
      </template>
    </HoverCard>
  </Inline>
</template>
vue

受控

open 支持双向绑定。卡片仍然会因指针移开、点击外部或者按 Esc 而收回,并把 false 写回,外部的控件只负责打开。

查看预览

已收回

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

  const open = ref(false)
</script>

<template>
  <Inline gap="lg" align="center">
    <Button variant="outline" tone="neutral" :disabled="open" @click="open = true">打开卡片</Button>
    <HoverCard v-model:open="open">
      <Link href="#">查看预览</Link>
      <template #content>
        <Text size="sm">卡片由外部打开;指针移开、点击外部或者按 Esc 都会收回。</Text>
      </template>
    </HoverCard>
    <Text tone="muted" size="sm">{{ open ? '已打开' : '已收回' }}</Text>
  </Inline>
</template>
vue

外部锚点

默认插槽留空时,anchor 接收外部元素,用 v-model:open 控制打开。切换 anchor 与卡片内容即可让多个触发点共用一个卡片。提供默认插槽时,仍使用插槽中的触发器。

外部锚点不会自动打开卡片。直接设置 open = true 会立即打开,不经过 openDelay;移开后仍按 closeDelay 收回,移入卡片保持打开,键盘焦点离开锚点后收回。锚点清空、从文档移除,或其外层发生滚动时,卡片关闭。

示例使用 Button 绑定指针和键盘焦点事件。

退场期间仍跟随页面上有效的锚点;锚点清空或移除后,使用最后的位置完成退场。

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

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

  function show(event: PointerEvent | FocusEvent, index: number) {
    const target = event.currentTarget as HTMLElement
    if ('pointerType' in event ? event.pointerType === 'touch' : !target.matches(':focus-visible'))
      return
    anchor.value = target
    current.value = index
    open.value = true
  }
</script>

<template>
  <Inline>
    <Button
      v-for="index in 3"
      :key="index"
      variant="outline"
      tone="neutral"
      @pointerenter="show($event, index)"
      @focus="show($event, index)"
    >
      锚点 {{ index }}
    </Button>
    <HoverCard v-model:open="open" :anchor="anchor">
      <template #content>
        <Stack gap="xs" class="w-56">
          <Text weight="medium">锚点 {{ current }}</Text>
          <Text tone="muted" size="sm">同一个卡片随当前锚点切换位置与内容。</Text>
        </Stack>
      </template>
    </HoverCard>
  </Inline>
</template>
vue

虚拟锚点与持续跟随

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

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

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

虚拟锚点没有悬停区域,开关由 v-model:open 控制;指针移开和祖先滚动不会关闭卡片,点击外部或按 Esc 仍会关闭。元素锚点的悬停、延迟关闭和滚动关闭行为不变。

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

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

<script setup lang="ts">
  import { ref, shallowRef, watch } from 'vue'
  import { useRafFn } from '@vueuse/core'
  import {
    Button,
    ScrollArea,
    Inline,
    Stack,
    Text,
    HoverCard,
    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>
    <HoverCard v-model:open="open" :anchor="anchor" update-position-strategy="always" align="start">
      <template #content>
        <Text size="sm">移动坐标</Text>
      </template>
    </HoverCard>
  </Stack>
</template>
vue

定位层样式

positionerClass 为外层定位节点追加类名,class 仍作用于内层卡片。可以使用 utility 或全局 CSS 类定义移动过渡,与卡片的入退场动画分别控制。

示例在卡片已打开且切换锚点时启用 hn-transition-base,首次打开和关闭时移除移动过渡;减少动态效果偏好下不播放移动动画。触发器使用 Button。

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

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

  function show(event: PointerEvent | FocusEvent, index: number) {
    const target = event.currentTarget as HTMLElement
    if ('pointerType' in event ? event.pointerType === 'touch' : !target.matches(':focus-visible'))
      return
    if (!open.value) moving.value = false
    else if (anchor.value !== target) moving.value = true
    anchor.value = target
    current.value = index
    open.value = true
  }
</script>

<template>
  <Inline>
    <Button
      v-for="index in 3"
      :key="index"
      variant="outline"
      tone="neutral"
      @pointerenter="show($event, index)"
      @focus="show($event, index)"
    >
      锚点 {{ index }}
    </Button>
    <HoverCard
      v-model:open="open"
      :anchor="anchor"
      :positioner-class="
        open && moving ? 'hn-transition-base motion-reduce:transition-none' : undefined
      "
    >
      <template #content>
        <Stack gap="xs" class="w-56">
          <Text weight="medium">锚点 {{ current }}</Text>
          <Text tone="muted" size="sm">同一个卡片平滑移动到当前锚点。</Text>
        </Stack>
      </template>
    </HoverCard>
  </Inline>
</template>
vue

行为

  • 指针停留 openDelay 后浮出,移开 closeDelay 后收回;移进卡片内保持打开。
  • 键盘聚焦插槽中的触发器时按 openDelay 浮出,焦点离开后按 closeDelay 收回。
  • 关闭延迟内移回卡片会取消关闭;退场动画开始后,卡片内容不再响应交互,移入原区域不会重新打开。
  • 点击卡片外部或者按 Esc 也会收回。
  • 卡片不停止页面滚动,页面其他部分照常可以交互。
  • 触屏设备上不会因触摸浮出。

无障碍

  • 卡片只是补充信息,其中的内容不应该是到达某处的唯一途径。
  • 触发器应当是可以聚焦的元素,键盘用户才能看到卡片。

API

Props

属性
类型
默认值
说明
anchor
OverlayAnchor | null
—
定位元素或虚拟锚点,默认插槽为空时使用
updatePositionStrategy
'optimized' | 'always'
'optimized'
定位更新策略
side
'top' | 'right' | 'bottom' | 'left'
'bottom'
浮出的方向
align
'start' | 'center' | 'end'
'center'
与触发器的对齐方式
sideOffset
number
8
与触发器的距离,像素
openDelay
number
300
停留多久后浮出,毫秒
closeDelay
number
150
移开多久后收回,毫秒
padded
boolean
true
卡片是否带内边距
open
boolean
—
是否打开,支持双向绑定
class
string
—
追加至卡片的类名
positionerClass
string
—
追加至外层定位节点的类名

插槽

插槽
说明
default
可选触发器;留空时使用 anchor
content
卡片内容