<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>
用法
import { Popover } from '@hina-ui/vue'
默认插槽是触发器,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>
示例
位置
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>
间距
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>
受控
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>
外部锚点
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>
虚拟锚点与持续跟随
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>
模态
modal 默认为 true,打开时锁定页面滚动并限制外部交互。设置 :modal="false" 后,页面可以继续滚动和交互,点击外部仍会关闭面板。
焦点与关闭
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>
触发器
触发器不限于按钮,任何能获得焦点的元素都可以。用图标按钮作为触发器时,关闭它自带的提示,以免两层浮层叠在一起。
<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>
行为
- 默认模态下,面板打开期间页面停止滚动。
- 触发器在面板打开期间保持按下时的样式。
- 面板打开后焦点移入面板,按 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 | 外部按下或焦点移出时触发,可取消关闭 |