Tooltip/HoverCard focus-open guarded to :focus-visible (DES-134)
Version: 0.4.2 · Type: 🐛 Bug Fix
DES-134 (follow-up to DES-128 Overlay composable triggers)
Related:
docs/overlay-composable-triggers.md
Problem
In a <Tooltip><DropdownMenu items>…</DropdownMenu></Tooltip> composition (or reverse nesting), both overlays share the same underlying trigger DOM node. After closing the DropdownMenu by selecting an item, the Tooltip reopens.
Root cause: when the menu closes, Radix Menu's onCloseAutoFocus programmatically restores focus to the shared trigger. Overlays that open on onFocus treat this programmatic restoration as keyboard focus and reopen the overlay. This is not specific to nested overlays — it affects any scenario where a popover-type layer (DropdownMenu / Dialog / Popover) opens from a trigger and then closes, returning focus.
Affected components (determined by whether the trigger opens on focus):
- Tooltip:
onFocus → onOpen, guarded only byisPointerDownRef(blocks mouse-press focus, not programmatic focus) → affected. - HoverCard:
onFocus → onOpen(viareact-hover-cardTrigger), noisPointerDownRefguard at all, opens on any focus → affected, more severely. - Popover / DropdownMenu: triggered by click / keydown, no
onFocusopen → not affected.
Changed Files
src/utils/focus-visible-trigger.ts(new, shared helper)test/focus-visible-mock.ts(new, shared:focus-visibletest stub utility)src/components/Tooltip/Tooltip.tsxsrc/components/Tooltip/__tests__/Tooltip.test.tsxsrc/components/HoverCard/HoverCard.tsxsrc/components/HoverCard/__tests__/HoverCard.test.tsxCLAUDE.md(§8.3 adds "focus-open overlay only passes:focus-visible" convention)
Changes
New shared helper createFocusVisibleTriggerGuard
Added src/utils/focus-visible-trigger.ts (internal, not exported from the package). Returns an onFocus guard that only allows Radix to open the overlay when focus is keyboard-visible (event.currentTarget.matches(':focus-visible')); calls event.preventDefault() for mouse or programmatic focus.
Leverages Radix Tooltip / HoverCard Trigger's onFocus: composeEventHandlers(props.onFocus, onOpen) (default checkForDefaultPrevented: true) — once the injected onFocus calls preventDefault, the subsequent onOpen is skipped. This is a stable official injection point requiring no fork or controlled mode.
Falls back to "preserve Radix default behavior, no throw" in environments where :focus-visible is unsupported (e.g. happy-dom).
Applied to Tooltip and HoverCard
Both Tooltip and HoverCard wire the guard to the internal TooltipTrigger / HoverCardTrigger's onFocus. The guard executes the composable-injection's forwarded.onFocus first (preserving any preventDefault semantics it may carry), then applies the visibility check.
Tests
Each of Tooltip / HoverCard adds 2 test cases under a "focus-visible guard" block:
- Visible focus (
matches(':focus-visible')stubbed totrue) still opens on focus. - Non-visible focus (stubbed to
false) does not open.
The stub logic is extracted into the shared test/focus-visible-mock.ts utility as mockFocusVisible(visible): intercepts only :focus-visible, delegates all other selectors to the original implementation to avoid breaking getByRole role queries.
Scope and notes
- This is a behavioral tightening for all Tooltip / HoverCard usage (not just composed scenarios): the outer nesting direction cannot reliably detect whether the child is a menu, so global tightening is the only way to cover both directions. "Only show on keyboard-visible focus" is also semantically more correct for accessibility.
- Hover-open (via
onPointerMove/onPointerEnter) and keyboard Tab focus (:focus-visibleis true) are unaffected. The only change is that programmatic / non-visible focus no longer opens the overlay. - When a menu item is selected via keyboard Enter, the resulting focus restoration is keyboard-visible → overlay shows; this is appropriate feedback for keyboard users and is not considered a problem.
- happy-dom cannot evaluate real
:focus-visible; tests stubElement.prototype.matchesto verify guard logic. Real browser behavior should be covered by a future CT case (verify overlay does not reappear after menu close).