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.tsxsrc/components/DropdownMenu/DropdownMenu.tsxsrc/components/Popover/Popover.tsxsrc/components/HoverCard/HoverCard.tsxsrc/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 == nullearly-return 由「裸返回 triggerChild」改为用@radix-ui/react-slot的Slot包裹,由其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」为既有遗留行为,本次维持现状、不扩大也不修,单列待定。