Popover 气泡卡片

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

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>
  )
}
tsx

用法

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

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>
  )
}
tsx

示例

位置

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>
  )
}
tsx

间距

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>
  )
}
tsx

受控

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>
  )
}
tsx

外部锚点

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>
  )
}
tsx

虚拟锚点与持续跟随

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>
  )
}
tsx

焦点与关闭

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>
  )
}
tsx

触发器

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

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>
  )
}
tsx

行为

  • 默认模态下,面板打开期间页面停止滚动。
  • 触发器在面板打开期间保持按下时的样式。
  • 面板打开后焦点移入面板,按 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
外部按下或焦点移出时触发,可取消关闭