Skip to main content

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.tsx
  • src/components/DropdownMenu/DropdownMenu.tsx
  • src/components/Popover/Popover.tsx
  • src/components/HoverCard/HoverCard.tsx
  • src/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 into
    • forwarded: composable events (including onTouchStart) + 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 neither forwarded nor rest, to avoid polluting the downstream Root / Content.
    • Conditional id forwarding: only when injected by a Slot (with the data-state beacon present) and the outer layer is a menu type (accompanied by aria-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 in rest—ensuring that when DropdownMenu is the outer layer its Content's aria-labelledby is 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 is data-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 / id themselves 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 contain data-state, and id additionally 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 takes HTMLElement, asserted to the Radix Trigger's ref type when passed to the inner Trigger); it wires in the helper, landing forwarded and the ref onto the inner Trigger / Anchor (in Popover anchor mode, onto PopoverAnchor).
  • Tooltip / Popover / HoverCard's content == null early-return changes from "bare return of triggerChild" to wrapping with @radix-ui/react-slot's Slot, letting its composeEventHandlers / composeRefs merge the injected items and the ref, avoiding a bare cloneElement overriding the business's own onClick / 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-labelledby points 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-state can 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's aria-describedby + Menu's aria-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 id on the inner overlay component itself or on the real trigger element (it would override the injected outer triggerId and break the aria-labelledby link); use data-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 own Trigger + Content from business code, the component itself does not render a Trigger; although the component is changed to forwardRef, in this branch both the forwardedRef and 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 the items mode.
  • data-state is 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-state beacon), 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 on children (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.