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 00000000000000..f3dcb12dad6703
--- /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 5137f1b26b42c8..3009f54be2796e 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 5fe5f4204bf110..01d8023db2f77f 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),