跳到主要内容

Overlay 可组合 trigger(DES-128)

版本: 0.3.0 · 类型: ✨ 新功能

DES-128(Overlay composable triggers)

方案文档:packages/design/docs/overlay-composable-triggers.md

问题

业务存在「同一个元素既要 hover 出 Tooltip、又要点击弹 DropdownMenu」这类诉求,期望直接写 <Tooltip><DropdownMenu>…</DropdownMenu></Tooltip>(或反向嵌套)。但四个 children-as-trigger 的高层 overlay 组件本体都没有 forwardRef,且各自的剩余 props 流向 Root 或 Content、没有一个转发给内部 Trigger——外层 Radix Trigger(Slot)注入的事件 / aria 关系属性 / ref 全部丢失,导致无法组合。

改动文件

  • src/utils/slottable-trigger.ts(新增)
  • src/utils/__tests__/slottable-trigger.test.ts(新增)
  • src/components/Tooltip/Tooltip.tsx
  • src/components/DropdownMenu/DropdownMenu.tsx
  • src/components/Popover/Popover.tsx
  • src/components/HoverCard/HoverCard.tsx
  • src/components/__tests__/overlay-composable-triggers.test.tsx(新增)

改动内容

  • 新增内部 helper extractForwardableTriggerProps(不对外导出):把组件解构掉已知配置后的剩余 props 分离为
    • forwarded:可组合事件(含 onTouchStart)+ 指向 content 的 aria 关系属性,转发到内部 Trigger / Anchor;
    • rest:其余维持组件原有去向(Tooltip / DropdownMenu → Root,Popover / HoverCard → Content);
    • data-state:视为 Slot 状态注入,命中即丢弃,不进 forwarded / rest,避免污染下游 Root / Content。
    • id 条件转发:仅当被 Slot 注入(含 data-state 信标)且外层为 menu 类(伴随 aria-haspopup / aria-controls / aria-expanded)时判定为 Radix 注入的 triggerId 并转发,否则视为业务直传留在 rest——保证 DropdownMenu 作外层时其 Content 的 aria-labelledby 不断链。
  • 提取带「Slot 注入信标」前置条件:白名单事件 / aria 关系属性从值本身无法区分「外层 Slot 注入」与「业务直传」,而 Popover / HoverCard 的 rest → Content唯一可靠信标是 data-state(Radix 各 overlay Trigger 作外层时必带、业务不传,且本工具会丢弃它);aria 关系属性 / id 本身不作信标——否则会把 standalone 时业务直传给 Content 的 aria / id 误判为注入。helper 仅在 props 含 data-state 时才提取事件 / aria 关系属性到 Trigger,id 还需外层为 menu 类;无信标视为业务直传、维持原去向——修复「无条件提取吞掉业务直传给 Content 的事件」以及「aria 关系属性 / id 自证为信标导致 standalone Content aria / id 被误转发」两轮回归,独立 / 受控等既有用法零影响。
  • 四个高层组件统一改造:组件本体改 forwardRef(公开 ref 取 HTMLElement,传内部 Trigger 时断言到 Radix Trigger 的 ref 类型);接入 helper,把 forwarded 与 ref 落到内部 Trigger / Anchor(Popover anchor 模式落到 PopoverAnchor)。
  • Tooltip / Popover / HoverCard 的 content == null early-return 由「裸返回 triggerChild」改为用 @radix-ui/react-slotSlot 包裹,由其 composeEventHandlers / composeRefs 合并注入项与 ref,避免裸 cloneElement 覆盖业务自有 onClick / ref
  • 单测:helper 10 条(信标存在时转发事件 / 无信标时事件留 rest / onTouchStart / 信标存在时转发 aria 关系属性 / standalone aria 关系属性留 rest / data-state 丢弃 / id 单独留 rest / 信标+关系属性时转发 id / 关系属性存在但无 data-state 时 id+aria 留 rest / unknown 留 rest);跨组件集成 6 条(Tooltip 在外、DropdownMenu 在外两向嵌套均能 hover 出浮层 + click 开菜单 + 业务 onClick 生效、业务 ref 透传到真实元素、DropdownMenu 作外层时 aria-labelledby 指向真实 trigger、独立 Popover 业务事件落到 Content 而非 trigger、组合 open 态 axe 无违规)。

边界与说明

  • 真正能在单一 DOM 上叠加的只有事件 handler 与 ref;aria-* / data-state 等同名属性只能保留一个值。本方案只保证事件 / ref 组合与典型组合(属性名不重叠,如 Tooltip 的 aria-describedby + Menu 的 aria-expanded/haspopup/controls)的 aria 关联,不承诺任意两个 menu 类 overlay 叠加的双关联。
  • 嵌套且外层为 DropdownMenu 时,业务不要在内层 overlay 本体或真实 trigger 元素上手传 id(会覆盖外层注入的 triggerId 致 aria-labelledby 断链);测试 / 定位标识改用 data-testid / data-*
  • DropdownMenu 组合式分支(items == null)不支持作为内层被组合:该分支由业务自带 Trigger + Content,本体不渲染 Trigger,本体虽改为 forwardRef,但此分支 forwardedRef 与外层注入项均无落点,会被静默丢弃(无报错)。需要把 DropdownMenu 作为内层组合(如 <Tooltip><DropdownMenu ref={r}>…</DropdownMenu></Tooltip>)时改用 items 模式。
  • data-state 不再支持作为高层组件直传属性透传到下游(视为 Slot 状态注入)。
  • 提取的残留边界:业务给 Popover / HoverCard 的 Content 直传白名单事件 / aria 关系属性、该组件同时被外层组合(props 既有业务直传项又有 data-state 信标)时,业务直传项会被一并提取到 Trigger。属值层不可区分的固有边界、极罕见;此时把事件 / aria 直接绑在 children(真实 trigger)或 content 节点上,不要走高层组件的剩余 props。
  • 范围内:Tooltip / DropdownMenu / Popover / HoverCard。不在范围:ContextMenu(仅低层组合式导出,需新增高层 API 才能纳入)、Select / MultiSelect、Menu。
  • DropdownMenu「未列出的 Content props 误入 Root」为既有遗留行为,本次维持现状、不扩大也不修,单列待定。