Skip to content

feat(react-headless-components-preview): adopt Interest Invokers (interestfor) for Tooltip intent#36348

Open
micahgodbolt wants to merge 1 commit into
microsoft:masterfrom
micahgodbolt:feat/headless-tooltip-interest-invoker
Open

feat(react-headless-components-preview): adopt Interest Invokers (interestfor) for Tooltip intent#36348
micahgodbolt wants to merge 1 commit into
microsoft:masterfrom
micahgodbolt:feat/headless-tooltip-interest-invoker

Conversation

@micahgodbolt

Copy link
Copy Markdown
Member

Summary

Stretch proposal from #36346. Adopts the native Interest Invokers API (interestfor + interest-delay) so the headless Tooltip's popover="hint" opens on hover/focus declaratively, with no JS in supporting browsers — while keeping the existing JS timer engine as a graceful fallback everywhere else.

This complements (and is independent of) the minimal anchor-name fix in #36347.

What changed

packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts:

  • Wire interestfor on the trigger → the tooltip content id. The attribute is harmlessly ignored where unsupported, and is placed before the child's own props so an author can override it.
  • Feature-gate the JS timers: onEnterTrigger / onLeaveTrigger early-return when CSS.supports('interest-delay: 0s'), so we don't double-drive show/hide when the platform is already handling intent.
  • Sync state on native opens: onToggle now mirrors React state for both open and closed newState, so a browser-driven interest open keeps visible in sync (previously only closed was handled).
  • Idempotent show/hide effect: only showPopover() when not already :popover-open, and only hidePopover() when it is — so the effect never fights the platform (and never throws InvalidStateError on a redundant showPopover()).

Detection is client-only (inside the handlers); the interestfor attribute is rendered deterministically, so there's no SSR/hydration mismatch.

Why progressive enhancement

  • Supported browsers: zero JS for tooltip intent — the platform handles hover/focus, delay, and light-dismiss. Forward-compatible with the standard.
  • Unsupported browsers: identical behavior to today via the existing timer engine.

Tests

Tooltip.test.tsx:

  • interestfor on the trigger equals the tooltip content id.
  • JS fallback still opens the tooltip on hover when Interest Invokers are unsupported (jsdom default).
  • When CSS.supports('interest-delay: 0s') is stubbed true, the JS timer is skipped (the platform would open it instead).

All 19 Tooltip tests pass.

Notes / follow-ups

  • interest-delay matching: I prototyped setting interest-delay: <showDelay> <hideDelay> to match the configured JS cadence, but the positioning engine rewrites the content element's inline style, so the value was clobbered. Matching the delay cleanly belongs in the positioning style writer — happy to follow up if maintainers want it. Browsers fall back to the spec default delay in the meantime.
  • TypeScript doesn't yet ship interestfor in its DOM/React types; the attribute is plain lowercase so React passes it through to the DOM as-is.

Related: #36346 (root cause + proposals), #36347 (minimal anchor-name fix).

…erestfor) for Tooltip intent

Wire the trigger to the hint popover via the native `interestfor` attribute so
supported browsers open/close the tooltip on hover/focus with no JS. The existing
JS timer engine remains as a fallback and is skipped when CSS.supports('interest-delay: 0s')
is true to avoid double-driving. onToggle now syncs React state for native opens as
well as closes, and the show/hide effect is made idempotent against :popover-open.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@micahgodbolt micahgodbolt requested a review from a team as a code owner June 24, 2026 18:27
@github-actions

Copy link
Copy Markdown

Pull request demo site: URL

@dmytrokirpa

Copy link
Copy Markdown
Contributor

I don't think we can add this in a nearest future:

(ev: React.PointerEvent<HTMLElement> | React.FocusEvent<HTMLElement>) => {
// When Interest Invokers are supported the browser shows the hint popover on
// hover/focus via `interestfor`; skip the JS timer so we don't double-drive it.
if (supportsInterestInvokers()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should factor this out into module scope (or useMemo)

// module scope
const SUPPORTS_INTEREST_INVOKERS = supportsInterestInvokers();

or

// Component scope
const supportsInvokers = useMemo(() => supportsInterestInvokers(), []);

This will always be true for the life of the application. No reason to recompute it constantly.

* @see https://open-ui.org/components/interest-invokers.explainer/
*/
const supportsInterestInvokers = () =>
typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('interest-delay: 0s');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an SSR test? This may fail.

@Hotell Hotell added the Resolution: Won't Do Requests reviewed; concluded to not approve for prioritization. label Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Resolution: Won't Do Requests reviewed; concluded to not approve for prioritization.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants