import { Settings2 } from 'lucide-react'
import { Button, Heading, Input, Popover, Stack, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Popover
align="start"
content={
<Stack gap="sm" className="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 defaultValue="720" size="sm" />
</Stack>
<Stack gap="xs">
<Text size="sm">行距</Text>
<Input defaultValue="1.8" size="sm" />
</Stack>
<Button size="sm">保存</Button>
</Stack>
}
>
<Button variant="outline" tone="neutral" icon={<Settings2 />}>
阅读设置
</Button>
</Popover>
)
}
用法
import { Popover } from '@hina-ui/react'
children 是触发器,content 属性是浮出的内容。点击触发器打开面板,再次点击触发器或点击面板外部关闭。面板中的内容可以自由排布,也可以获得焦点。
import { Button, Popover, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Popover
content={
<Text size="sm">ATRI -My Dear Moments- 由 ANIPLEX.EXE 发行,2020 年 6 月 19 日上市。</Text>
}
>
<Button variant="outline" tone="neutral">
关于这本书
</Button>
</Popover>
)
}
示例
位置
side 指定面板朝哪个方向浮出,align 指定它与触发器的对齐方式。默认在正下方,空间不足时翻转到相反一侧。
import { Button, Inline, Popover, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Inline align="center" className="gap-6">
<Popover align="start" content={<Text size="sm">与触发器的起始边对齐。</Text>}>
<Button variant="outline" tone="neutral">
start
</Button>
</Popover>
<Popover align="center" content={<Text size="sm">与触发器居中对齐。</Text>}>
<Button variant="outline" tone="neutral">
center
</Button>
</Popover>
<Popover side="right" align="start" content={<Text size="sm">在触发器右侧展开。</Text>}>
<Button variant="outline" tone="neutral">
right
</Button>
</Popover>
<Popover side="top" content={<Text size="sm">在触发器上方展开。</Text>}>
<Button variant="outline" tone="neutral">
top
</Button>
</Popover>
</Inline>
)
}
间距
sideOffset 是面板与触发器之间的距离,单位为像素。
import { Button, Inline, Popover, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Inline align="center" className="gap-6">
<Popover sideOffset={0} content={<Text size="sm">sideOffset 为 0。</Text>}>
<Button variant="outline" tone="neutral">
紧贴
</Button>
</Popover>
<Popover sideOffset={16} content={<Text size="sm">sideOffset 为 16。</Text>}>
<Button variant="outline" tone="neutral">
远离
</Button>
</Popover>
</Inline>
)
}
受控
open 可受控,既可以从外部打开或关闭面板,面板内部的按钮也可以关闭它。
当前:收起
'use client'
import { useState } from 'react'
import { Button, Inline, Popover, Stack, Text } from '@hina-ui/react'
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<Inline align="center">
<Popover
open={open}
onOpenChange={setOpen}
content={
<Stack gap="sm" className="w-56">
<Text size="sm">这段内容可以从外部展开或者收起。</Text>
<Button size="sm" variant="soft" tone="neutral" onClick={() => setOpen(false)}>
知道了
</Button>
</Stack>
}
>
<Button variant="outline" tone="neutral">
详情
</Button>
</Popover>
<Button size="sm" variant="soft" tone="neutral" onClick={() => setOpen(!open)}>
从外部{open ? '收起' : '展开'}
</Button>
<Text tone="muted" size="sm">
当前:{open ? '展开' : '收起'}
</Text>
</Inline>
)
}
外部锚点
anchor 接受 OverlayAnchor | null。设置后可以省略 children,通过 open / onOpenChange 控制开关。锚点尚未就绪时面板不显示;打开期间更换锚点会更新位置,关闭时清空锚点会保留退场位置。
同时提供 children 与 anchor 时,children 负责触发,anchor 负责定位。外部元素的点击与键盘行为、aria-haspopup 和 aria-expanded 由调用方设置。
'use client'
import { useState, type MouseEvent } from 'react'
import { Button, Inline, Popover, Stack, Text } from '@hina-ui/react'
export default function Demo() {
const [open, setOpen] = useState(false)
const [current, setCurrent] = useState(0)
const [anchor, setAnchor] = useState<HTMLElement | null>(null)
function toggle(event: MouseEvent<HTMLElement>, index: number) {
const target = event.currentTarget
setOpen(anchor === target ? !open : true)
setAnchor(target)
setCurrent(index)
}
return (
<Inline>
{[1, 2].map(index => (
<Button
key={index}
variant="outline"
tone="neutral"
aria-haspopup="dialog"
aria-expanded={open && current === index}
onClick={event => toggle(event, index)}
>
锚点 {index}
</Button>
))}
<Popover
open={open}
onOpenChange={setOpen}
anchor={anchor}
modal={false}
aria-label="外部锚点"
content={
<Stack gap="sm" className="w-56">
<Text size="sm">默认插槽为空,位置由 anchor 决定。</Text>
<Button size="sm" variant="soft" tone="neutral" onClick={() => setOpen(false)}>
关闭
</Button>
</Stack>
}
/>
</Inline>
)
}
虚拟锚点与持续跟随
anchor 也接受带 getBoundingClientRect() 的对象,返回视口坐标中的矩形。OverlayAnchor 类型可从包根导入;回调需要返回最新坐标,同一个对象不必反复替换。
可选的 contextElement 指定坐标所属的元素,用于识别滚动祖先和裁剪边界;它不会成为触发器,也不会扩大浮层的交互区域。
updatePositionStrategy 默认是 'optimized',在滚动、尺寸和布局变化时更新位置。设置为 'always' 后,挂载期间逐帧检查矩形,持续跟随仅有坐标变化的锚点。可以在打开期间切换策略。退场期间继续跟随,锚点清空或所属元素移除后保留最后的位置,卸载后停止测量。
需要保持外部焦点时,设置 modal={false},并在 onOpenAutoFocus 中调用 event.preventDefault()。
示例用 Button 打开面板,并用 ScrollArea 提供滚动容器。面板同时跟随坐标变化和容器滚动。
向下滚动可观察定位变化。
'use client'
import { useEffect, useRef, useState } from 'react'
import {
Button,
ScrollArea,
Inline,
Stack,
Text,
Popover,
type OverlayAnchor,
} from '@hina-ui/react'
export default function Demo() {
const [open, setOpen] = useState(false)
const surface = useRef<HTMLElement>(null)
const [anchor, setAnchor] = useState<OverlayAnchor | null>(null)
const x = useRef(80)
const [left, setLeft] = useState(80)
useEffect(() => {
if (!open) return
let frame = 0
let previous = 0
const loop = (timestamp: number) => {
if (!previous) previous = timestamp
const delta = timestamp - previous
previous = timestamp
x.current = 80 + ((x.current - 80 + delta / 35) % 120)
setLeft(x.current)
frame = requestAnimationFrame(loop)
}
frame = requestAnimationFrame(loop)
return () => cancelAnimationFrame(frame)
}, [open])
function show() {
const element = surface.current
if (!element) return
setAnchor({
contextElement: element,
getBoundingClientRect: () => {
const rect = element.getBoundingClientRect()
return new DOMRect(rect.left + x.current, rect.top + 100, 2, 20)
},
})
setOpen(true)
}
return (
<Stack className="w-full">
<Inline>
<Button variant="outline" tone="neutral" onClick={show}>
打开
</Button>
<Text size="sm" tone="muted">
向下滚动可观察定位变化。
</Text>
</Inline>
<ScrollArea className="h-64 rounded-lg border border-line bg-inset">
<Stack ref={surface} className="relative h-128 shrink-0">
<Text
as="span"
aria-hidden="true"
className="absolute top-25 h-5 w-0.5 bg-accent"
style={{ left: `${left}px` }}
/>
</Stack>
</ScrollArea>
<Popover
open={open}
onOpenChange={setOpen}
anchor={anchor}
updatePositionStrategy="always"
align="start"
modal={false}
onOpenAutoFocus={event => event.preventDefault()}
content={<Text size="sm">移动坐标</Text>}
/>
</Stack>
)
}
模态
modal 默认为 true,打开时锁定页面滚动并限制外部交互。设置 modal={false} 后,页面可以继续滚动和交互,点击外部仍会关闭面板。
焦点与关闭
onOpenAutoFocus、onCloseAutoFocus 可以通过 event.preventDefault() 取消默认聚焦。没有默认触发器时,关闭后恢复打开前的焦点;锚点仅用于定位。调用方已经将焦点移到面板外时,不会再次恢复旧焦点。
通过 onInteractOutside 可以取消外部点击或焦点移出引起的关闭,onEscapeKeyDown 可以取消 Esc 关闭。aria-label、aria-describedby 和 data-* 属性会传给面板。
自定义内边距
面板默认带内边距。内容需要延伸到边缘时设置 padded={false},由内容自行安排留白。
import { Button, Divider, Popover, Stack, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Popover
padded={false}
align="start"
content={
<Stack gap="none" className="w-64">
<Stack gap="none" className="bg-inset px-4 py-3">
<Text size="sm" className="font-medium">
第 42 话
</Text>
</Stack>
<Divider />
<Stack gap="xs" className="px-4 py-3">
<Text tone="muted" size="sm">
作者:支倉凍砂
</Text>
<Text tone="muted" size="sm">
更新于三小时前
</Text>
</Stack>
</Stack>
}
>
<Button variant="outline" tone="neutral">
最近更新
</Button>
</Popover>
)
}
触发器
触发器不限于按钮,任何能获得焦点的元素都可以。用图标按钮作为触发器时,关闭它自带的提示,以免两层浮层叠在一起。
import { Info } from 'lucide-react'
import { IconButton, Inline, Link, Popover, Text } from '@hina-ui/react'
export default function Demo() {
return (
<Inline align="center" className="gap-6">
<Popover content={<Text size="sm">触发器是一个图标按钮。</Text>}>
<IconButton variant="ghost" tone="neutral" label="查看说明" tooltip={false}>
<Info />
</IconButton>
</Popover>
<Popover align="start" content={<Text size="sm">触发器是一段行内的链接文字。</Text>}>
<Link href="#">星见书音</Link>
</Popover>
</Inline>
)
}
行为
- 默认模态下,面板打开期间页面停止滚动。
- 触发器在面板打开期间保持按下时的样式。
- 面板打开后焦点移入面板,按 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 | 面板是否带内边距 |
className | string | — | 追加到面板上的类名 |
| 属性 | 说明 |
|---|---|
children | 可选触发器 |
content | 面板中的内容 |
| 回调 | 参数 | 说明 |
|---|---|---|
onOpenAutoFocus | Event | 打开时聚焦前触发,可取消 |
onCloseAutoFocus | Event | 关闭时恢复焦点前触发,可取消 |
onEscapeKeyDown | KeyboardEvent | 按 Esc 时触发,可取消关闭 |
onPointerDownOutside | PointerDownOutsideEvent | 外部按下时触发,可取消关闭 |
onFocusOutside | FocusOutsideEvent | 焦点移到外部时触发,可取消关闭 |
onInteractOutside | PointerDownOutsideEvent | FocusOutsideEvent | 外部按下或焦点移出时触发,可取消关闭 |