From 0fedb4557aeabf14cb8f03ddf6d3f557ce1bc2b1 Mon Sep 17 00:00:00 2001 From: Micah Godbolt Date: Wed, 24 Jun 2026 11:25:13 -0700 Subject: [PATCH] feat(react-headless-components-preview): adopt Interest Invokers (interestfor) 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> --- ...-fdcfd9eb-8a65-41a2-adbd-d1ec930e0a27.json | 7 ++ .../src/components/Tooltip/Tooltip.test.tsx | 66 ++++++++++++++++++- .../src/components/Tooltip/useTooltip.ts | 38 +++++++++-- 3 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 change/@fluentui-react-headless-components-preview-fdcfd9eb-8a65-41a2-adbd-d1ec930e0a27.json diff --git a/change/@fluentui-react-headless-components-preview-fdcfd9eb-8a65-41a2-adbd-d1ec930e0a27.json b/change/@fluentui-react-headless-components-preview-fdcfd9eb-8a65-41a2-adbd-d1ec930e0a27.json new file mode 100644 index 0000000000000..f3dcb12dad670 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-fdcfd9eb-8a65-41a2-adbd-d1ec930e0a27.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Adopt native Interest Invokers (interestfor) for Tooltip hover/focus intent, with the existing JS engine as a graceful fallback", + "packageName": "@fluentui/react-headless-components-preview", + "email": "mgodbolt+microsoft@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.test.tsx index 5137f1b26b42c..3009f54be2796 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/Tooltip.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import { resetIdsForTests } from '@fluentui/react-utilities'; import { isConformant } from '../../testing/isConformant'; import type { IsConformantOptions } from '@fluentui/react-conformance'; @@ -53,6 +53,70 @@ describe('Tooltip', () => { expect(screen.getByRole('tooltip')).toHaveAttribute('popover', 'hint'); }); + it('wires interestfor on the trigger to the tooltip content (Interest Invokers)', () => { + render( + + + , + ); + + const tooltip = screen.getByRole('tooltip'); + const trigger = screen.getByRole('button'); + expect(trigger).toHaveAttribute('interestfor', tooltip.id); + }); + + it('shows the tooltip via the JS fallback when Interest Invokers are unsupported', () => { + jest.useFakeTimers(); + try { + render( + + + , + ); + + const trigger = screen.getByRole('button'); + const tooltip = screen.getByRole('tooltip', { hidden: true }); + + fireEvent.pointerEnter(trigger); + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(tooltip.matches(':popover-open')).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + + it('defers to the browser (no JS show) when Interest Invokers are supported', () => { + jest.useFakeTimers(); + // jsdom doesn't implement CSS.supports; stub it to report Interest Invoker support. + const originalSupports = (CSS as { supports?: typeof CSS.supports }).supports; + (CSS as { supports: (condition: string) => boolean }).supports = (condition: string) => + condition === 'interest-delay: 0s'; + try { + render( + + + , + ); + + const trigger = screen.getByRole('button'); + const tooltip = screen.getByRole('tooltip', { hidden: true }); + + fireEvent.pointerEnter(trigger); + act(() => { + jest.advanceTimersByTime(1000); + }); + + // The JS timer is skipped; the platform's `interestfor` would open it instead. + expect(tooltip.matches(':popover-open')).toBe(false); + } finally { + (CSS as { supports?: typeof CSS.supports }).supports = originalSupports; + jest.useRealTimers(); + } + }); + it('renders only aria-label for a simple label tooltip', () => { const tooltipText = 'The tooltip text'; render( diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts index 5fe5f4204bf11..01d8023db2f77 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts @@ -22,6 +22,17 @@ import { KEYBORG_FOCUSIN, useIsNavigatingWithKeyboard } from '@fluentui/react-ta import type { OnVisibleChangeData, TooltipProps, TooltipState, TooltipTriggerProps } from './Tooltip.types'; import { resolvePositioningShorthand, usePositioning } from '../../positioning'; +/** + * Feature detection for the Interest Invokers API (`interestfor` + `interest-delay`), + * which lets the platform open a `popover=hint` on hover/focus declaratively, with no JS. + * When supported we let the browser drive show/hide and keep the JS handlers below as a + * fallback for browsers that don't support it. + * + * @see https://open-ui.org/components/interest-invokers.explainer/ + */ +const supportsInterestInvokers = () => + typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('interest-delay: 0s'); + /** * Create the state required to render Tooltip. * @@ -89,9 +100,10 @@ export const useTooltip = (props: TooltipProps): TooltipState => { ); const onToggle = useEventCallback((event: Event) => { - if ((event as ToggleEvent).newState === 'closed') { - setVisible(undefined, { visible: false }); - } + // Keep React state in sync with the native popover regardless of what opened or + // closed it — our own JS handlers, the browser's light-dismiss, or (when supported) + // an Interest Invoker driving the `popover=hint` declaratively via `interestfor`. + setVisible(undefined, { visible: (event as ToggleEvent).newState === 'open' }); }); // Keep the tooltip in sync with the state when it is changed programmatically. @@ -105,9 +117,9 @@ export const useTooltip = (props: TooltipProps): TooltipState => { el.addEventListener('toggle', onToggle); try { - if (visible) { + if (visible && !el.matches(':popover-open')) { el.showPopover(); - } else if (el.matches(':popover-open')) { + } else if (!visible && el.matches(':popover-open')) { el.hidePopover(); } } catch (error) { @@ -136,6 +148,12 @@ export const useTooltip = (props: TooltipProps): TooltipState => { const onEnterTrigger = React.useCallback( // eslint-disable-next-line react-hooks/preserve-manual-memoization (ev: React.PointerEvent | React.FocusEvent) => { + // 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()) { + return; + } + if (ev.type === 'focus' && ignoreNextFocusEventRef.current) { ignoreNextFocusEventRef.current = false; return; @@ -179,6 +197,12 @@ export const useTooltip = (props: TooltipProps): TooltipState => { const onLeaveTrigger = React.useCallback( // eslint-disable-next-line react-hooks/preserve-manual-memoization (ev: React.PointerEvent | React.FocusEvent) => { + // When Interest Invokers are supported the browser hides the hint popover when + // interest is lost; skip the JS timer so we don't double-drive it. + if (supportsInterestInvokers()) { + return; + } + let delay = state.hideDelay; if (ev.type === 'blur') { @@ -248,6 +272,10 @@ export const useTooltip = (props: TooltipProps): TooltipState => { // eslint-disable-next-line react-hooks/immutability state.children = applyTriggerPropsToChildren(children, { ...triggerAriaProps, + // Declarative hover/focus intent via the Interest Invokers API. The attribute is + // harmlessly ignored where unsupported, and the JS handlers below act as the fallback. + // Placed before the child's own props so an author can still override it. + interestfor: state.content.id, ...child?.props, ref: useMergedRefs( getReactElementRef(child),