Composable overlay triggers (DES-128)
Version: 0.3.0 · Type: ✨ Feature
DES-128 (Overlay composable triggers)
Design doc:
packages/design/docs/overlay-composable-triggers.md
Problem
Business code has requirements like "the same element must both show a Tooltip on hover and open a DropdownMenu on click", expecting to simply write <Tooltip><DropdownMenu>…</DropdownMenu></Tooltip> (or the reverse nesting). But none of the four high-level children-as-trigger overlay components had a forwardRef, and each one's remaining props flowed to its Root or Content—none forwarded them to the inner Trigger—so the events / aria relationship attributes / ref injected by the outer Radix Trigger (Slot) were all lost, making composition impossible.
Changed Files
src/utils/slottable-trigger.ts(new)src/utils/__tests__/slottable-trigger.test.ts(new)src/components/Tooltip/Tooltip.tsxsrc/components/DropdownMenu/DropdownMenu.tsxsrc/components/Popover/Popover.tsxsrc/components/HoverCard/HoverCard.tsxsrc/components/__tests__/overlay-composable-triggers.test.tsx(new)
Changes
- Added the internal helper
extractForwardableTriggerProps(not exported): after a component destructures its known configuration, the remaining props are separated intoforwarded: composable events (includingonTouchStart) + aria relationship attributes pointing to content, forwarded to the inner Trigger / Anchor;rest: everything else, keeping the component's original destination (Tooltip / DropdownMenu → Root, Popover / HoverCard → Content);data-state: treated as Slot state injection—if matched it is dropped, entering neitherforwardednorrest, to avoid polluting the downstream Root / Content.- Conditional
idforwarding: only when injected by a Slot (with thedata-statebeacon present) and the outer layer is a menu type (accompanied byaria-haspopup/aria-controls/aria-expanded) is it judged to be the Radix-injected triggerId and forwarded; otherwise it is treated as a business-passed prop and stays inrest—ensuring that when DropdownMenu is the outer layer its Content'saria-labelledbyis not broken.
- The extraction carries a "Slot injection beacon" precondition: whitelisted events / aria relationship attributes cannot, by value alone, distinguish "outer Slot injection" from "business pass-through", whereas Popover / HoverCard's
rest → Content. The only reliable beacon isdata-state(Radix overlay Triggers always carry it when they are the outer layer, business never passes it, and this tool drops it); aria relationship attributes /idthemselves are not used as a beacon—otherwise the aria / id a standalone component passes through to Content would be misjudged as injection. The helper only extracts events / aria relationship attributes to the Trigger when the props containdata-state, andidadditionally requires the outer layer to be a menu type; with no beacon they are treated as business pass-through and keep their original destination—this fixes two rounds of regression ("unconditional extraction swallowing events the business passed through to Content" and "aria relationship attributes / id self-certifying as a beacon causing a standalone Content's aria / id to be misforwarded"), with zero impact on existing usages such as standalone / controlled. - The four high-level components share a unified refactor: the component itself becomes
forwardRef(the public ref takesHTMLElement, asserted to the Radix Trigger's ref type when passed to the inner Trigger); it wires in the helper, landingforwardedand the ref onto the inner Trigger / Anchor (in Popover anchor mode, ontoPopoverAnchor). - Tooltip / Popover / HoverCard's
content == nullearly-return changes from "bare return of triggerChild" to wrapping with@radix-ui/react-slot'sSlot, letting itscomposeEventHandlers/composeRefsmerge the injected items and the ref, avoiding a barecloneElementoverriding the business's ownonClick/ref. - Unit tests: 10 for the helper (forward events when beacon present / events stay in rest when no beacon / onTouchStart / forward aria relationship attributes when beacon present / standalone aria relationship attributes stay in rest / data-state dropped / id alone stays in rest / forward id when beacon + relationship attributes / id + aria stay in rest when relationship attributes present but no data-state / unknown stays in rest); 6 cross-component integration tests (Tooltip outer and DropdownMenu outer, both nesting directions can hover out the overlay + click open the menu + business onClick takes effect, business ref passes through to the real element,
aria-labelledbypoints to the real trigger when DropdownMenu is the outer layer, a standalone Popover's business events land on Content rather than the trigger, and axe has no violations in the composed open state).
Notes
- The only things that can truly stack on a single DOM node are event handlers and the ref; same-named attributes like
aria-*/data-statecan only hold one value. This solution only guarantees event / ref composition and the aria association of typical compositions (where attribute names do not overlap, e.g. Tooltip'saria-describedby+ Menu'saria-expanded/haspopup/controls); it does not promise dual association for any two stacked menu-type overlays. - When nested with DropdownMenu as the outer layer, business code must not manually pass an
idon the inner overlay component itself or on the real trigger element (it would override the injected outer triggerId and break thearia-labelledbylink); usedata-testid/data-*for test / locator identifiers. - The DropdownMenu compositional branch (
items == null) does not support being composed as the inner layer: this branch supplies its ownTrigger+Contentfrom business code, the component itself does not render a Trigger; although the component is changed toforwardRef, in this branch both theforwardedRefand the outer injected items have nowhere to land and are silently dropped (no error). When you need DropdownMenu composed as the inner layer (e.g.<Tooltip><DropdownMenu ref={r}>…</DropdownMenu></Tooltip>), use theitemsmode. data-stateis no longer supported as a high-level component pass-through attribute to the downstream (it is treated as Slot state injection).- Residual edge of the extraction: when business code passes a whitelisted event / aria relationship attribute directly to the Content of Popover / HoverCard, and that component is simultaneously composed by an outer layer (props contain both the business pass-through item and the
data-statebeacon), the business pass-through item will be extracted along to the Trigger. This is an inherent edge that cannot be distinguished at the value level, and is extremely rare; in this case bind the event / aria directly onchildren(the real trigger) or the content node, rather than going through the high-level component's remaining props. - In scope: Tooltip / DropdownMenu / Popover / HoverCard. Out of scope: ContextMenu (only the low-level compositional exports exist; a new high-level API is needed to include it), Select / MultiSelect, Menu.
- DropdownMenu's "unlisted Content props leaking into Root" is existing legacy behavior; this change keeps the status quo, neither expanding nor fixing it, listed separately as pending.