可组合浮层(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.ts的extractForwardableTriggerProps统一消化。 每个高层浮层组件本体用forwardRef,把可组合的注入项(事件 + aria 关系属性 + 条件id) 转发到自己内部的Trigger/Anchor,再由 RadixSlot把事件与 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 的一个焦点交互:modal 的 DropdownMenu 在关闭时会把焦点还给 trigger,从而再次触发 trigger 的 onFocus,让 Tooltip / HoverCard 闪现重新弹出。合成层只合并事件与 ref,并不打通两个浮层的 open 状态。该组合的推荐写法:给 DropdownMenu 设 modal={false},这样点击外部关闭时不会重新聚焦 trigger。下面的 case 已经应用了它。
:::
Case 1 — Tooltip(外)+ DropdownMenu(内)
同一个按钮 hover 出 Tooltip、click 弹菜单,按钮保留自己的 onClick。
预期:hover → 出现 Tooltip;click → 菜单打开且 Tooltip 自动收起;每次点击打开计数器都 +1。
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仍然触发。
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 不会拦截点击。
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 → 菜单打开。两种交互彼此独立。
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>标签。
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 解析到:<{tag}> </span> </div> ) } render(<Demo />)
Case 6 — 三层嵌套
三个浮层叠在一个 trigger 上,混合 hover 与 click 来源。
预期:hover → Tooltip;click → DropdownMenu 打开;内层 HoverCard 的 hover 内容也能解析。彼此不互相吞掉。
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正常触发;与单组件页面相比没有任何变化。
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 />)