跳到主要内容

可组合浮层(Composable Overlays)

@plaud/design 中「把 children 当 trigger」的浮层组件 —— Tooltip / DropdownMenu / Popover / HoverCard —— 现在可以互相自由嵌套:多个浮层会把各自的事件 handler 与 ref 合并到同一个真实 DOM 元素上。业务侧只需自然嵌套,无需手动拼 asChild

// 同一个按钮:hover 出 Tooltip,click 弹 DropdownMenu。两种嵌套顺序等价。
<Tooltip content="更多操作">
<DropdownMenu items={items}>
<Button></Button>
</DropdownMenu>
</Tooltip>
  • 实现机制:由 src/utils/slottable-trigger.tsextractForwardableTriggerProps 统一消化。 每个高层浮层组件本体用 forwardRef,把可组合的注入项(事件 + aria 关系属性 + 条件 id) 转发到自己内部的 Trigger / Anchor,再由 Radix Slot 把事件与 ref 合并到真实 DOM。
  • 能真正叠加的只有事件与 ref:同名 aria-* / data-state 在单个 DOM 上只能保留一个值, 这是 Web 平台限制,不是本层能消除的。
  • 完整方案packages/design/docs/overlay-composable-triggers.md

下面的可运行 case 同时充当手动测试矩阵,每个都标注了预期结果;屏幕上的计数器用于证明业务 onClick 没有被吞掉。

:::caution 焦点交互(Tooltip / HoverCard + DropdownMenu) 当 focus 即打开的浮层(Tooltip / HoverCard)与 DropdownMenu 共用同一个 trigger 时,会撞上 Radix 的一个焦点交互:modalDropdownMenu 在关闭时会把焦点还给 trigger,从而再次触发 trigger 的 onFocus,让 Tooltip / HoverCard 闪现重新弹出。合成层只合并事件与 ref,并不打通两个浮层的 open 状态。该组合的推荐写法:给 DropdownMenumodal={false},这样点击外部关闭时不会重新聚焦 trigger。下面的 case 已经应用了它。 :::

Case 1 — Tooltip(外)+ DropdownMenu(内)

同一个按钮 hover 出 Tooltip、click 弹菜单,按钮保留自己的 onClick

预期:hover → 出现 Tooltip;click → 菜单打开且 Tooltip 自动收起;每次点击打开计数器都 +1。

结果
Loading...
实时编辑器
const Demo = () => {
  const [count, setCount] = useState(0)
  return (
    <div style={{ display: 'flex', gap: 16, alignItems: 'center', padding: '48px 0' }}>
      <Tooltip content="更多操作">
        <DropdownMenu
          modal={false}
          items={[{ label: '重命名' }, { label: '创建副本' }, { type: 'separator' }, { label: '删除', variant: 'danger' }]}
        >
          <Button variant="secondary" onClick={() => setCount((c) => c + 1)}>
            Hover + Click me
          </Button>
        </DropdownMenu>
      </Tooltip>
      <span className="composable-overlays__counter">按钮 onClick 触发次数:{count}</span>
    </div>
  )
}
render(<Demo />)

Case 2 — DropdownMenu(外)+ Tooltip(内)

反向嵌套顺序,行为必须与 Case 1 等价。

预期:与 Case 1 完全一致 —— hover 出 Tooltip、click 弹菜单、业务 onClick 仍然触发。

结果
Loading...
实时编辑器
const Demo = () => {
  const [count, setCount] = useState(0)
  return (
    <div style={{ display: 'flex', gap: 16, alignItems: 'center', padding: '48px 0' }}>
      <DropdownMenu modal={false} items={[{ label: '分享' }, { label: '移动到…' }, { label: '归档' }]}>
        <Tooltip content="更多操作">
          <Button variant="secondary" onClick={() => setCount((c) => c + 1)}>
            Hover + Click me
          </Button>
        </Tooltip>
      </DropdownMenu>
      <span className="composable-overlays__counter">按钮 onClick 触发次数:{count}</span>
    </div>
  )
}
render(<Demo />)

Case 3 — Tooltip(外)+ Popover(内)

hover 给出提示,click 打开 Popover 卡片。

预期:hover → Tooltip「编辑资料」;click → Popover 打开;Tooltip 不会拦截点击。

结果
Loading...
实时编辑器
const Demo = () => {
  const [count, setCount] = useState(0)
  return (
    <div style={{ display: 'flex', gap: 16, alignItems: 'center', padding: '48px 0' }}>
      <Tooltip content="编辑资料">
        <Popover
          title="个人资料"
          content="点击外部或按 Esc 关闭这个 Popover。"
          side="bottom"
        >
          <Button variant="secondary" onClick={() => setCount((c) => c + 1)}>
            Hover + Click me
          </Button>
        </Popover>
      </Tooltip>
      <span className="composable-overlays__counter">按钮 onClick 触发次数:{count}</span>
    </div>
  )
}
render(<Demo />)

Case 4 — HoverCard(外)+ DropdownMenu(内)

hover 触发的卡片与 click 触发的菜单共用同一个 trigger。

预期:hover → 延迟后出现 HoverCard;click → 菜单打开。两种交互彼此独立。

结果
Loading...
实时编辑器
const Demo = () => {
  const [count, setCount] = useState(0)
  return (
    <div style={{ display: 'flex', gap: 16, alignItems: 'center', padding: '64px 0' }}>
      <HoverCard
        title="@plaud"
        content="HoverCard 在 hover 时展示更丰富的上下文,菜单则在 click 时处理操作。"
        side="bottom"
        align="start"
      >
        <DropdownMenu modal={false} items={[{ label: '查看资料' }, { label: '复制链接' }, { label: '举报' }]}>
          <Button variant="secondary" onClick={() => setCount((c) => c + 1)}>
            Hover + Click me
          </Button>
        </DropdownMenu>
      </HoverCard>
      <span className="composable-overlays__counter">按钮 onClick 触发次数:{count}</span>
    </div>
  )
}
render(<Demo />)

Case 5 — content == null 的 early-return(Slot 合并)

浮层没有内容时不额外渲染任何东西,但仍必须通过 Radix Slot 合并业务的 onClick / ref, 而不是用裸 cloneElement 把它们吞掉。

预期:不出现 Tooltip(content 为空),但每次点击仍让计数器 +1,且挂载时 ref 能读回真实的 <button> 标签。

结果
Loading...
实时编辑器
const Demo = () => {
  const [count, setCount] = useState(0)
  const [tag, setTag] = useState('—')
  const ref = useRef(null)
  useEffect(() => {
    setTag(ref.current ? ref.current.tagName.toLowerCase() : 'null')
  }, [])
  return (
    <div style={{ display: 'flex', gap: 16, alignItems: 'center', padding: '40px 0' }}>
      <Tooltip ref={ref}>
        <Button variant="secondary" onClick={() => setCount((c) => c + 1)}>
          Click me(无 tooltip)
        </Button>
      </Tooltip>
      <span className="composable-overlays__counter">
        onClick 触发次数:{count} · ref 解析到:&lt;{tag}&gt;
      </span>
    </div>
  )
}
render(<Demo />)

Case 6 — 三层嵌套

三个浮层叠在一个 trigger 上,混合 hover 与 click 来源。

预期:hover → Tooltip;click → DropdownMenu 打开;内层 HoverCard 的 hover 内容也能解析。彼此不互相吞掉。

结果
Loading...
实时编辑器
const Demo = () => {
  const [count, setCount] = useState(0)
  return (
    <div style={{ display: 'flex', gap: 16, alignItems: 'center', padding: '64px 0' }}>
      <Tooltip content="Tooltip 层">
        <DropdownMenu modal={false} items={[{ label: '操作 A' }, { label: '操作 B' }]}>
          <HoverCard title="HoverCard 层" content="最内层的 hover 内容。" side="bottom">
            <Button variant="secondary" onClick={() => setCount((c) => c + 1)}>
              Hover + Click me
            </Button>
          </HoverCard>
        </DropdownMenu>
      </Tooltip>
      <span className="composable-overlays__counter">按钮 onClick 触发次数:{count}</span>
    </div>
  )
}
render(<Demo />)

Case 7 — 独立使用回归(无组合)

每个浮层单独使用时行为必须与之前完全一致 —— 这是「业务 props 透传」的回归护栏。

预期:Popover click 打开并展示自身内容;独立按钮的 onClick 正常触发;与单组件页面相比没有任何变化。

结果
Loading...
实时编辑器
const Demo = () => {
  const [count, setCount] = useState(0)
  return (
    <div style={{ display: 'flex', gap: 24, alignItems: 'center', padding: '48px 0' }}>
      <Popover title="独立使用" content="普通 Popover,无嵌套。">
        <Button variant="secondary" onClick={() => setCount((c) => c + 1)}>
          Standalone Popover
        </Button>
      </Popover>
      <span className="composable-overlays__counter">按钮 onClick 触发次数:{count}</span>
    </div>
  )
}
render(<Demo />)