Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
<Tooltip content="Interest tooltip" relationship="label" visible>
<button>Trigger</button>
</Tooltip>,
);

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(
<Tooltip content="Hover me" relationship="description">
<button>Trigger</button>
</Tooltip>,
);

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(
<Tooltip content="Hover me" relationship="description">
<button>Trigger</button>
</Tooltip>,
);

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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand All @@ -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) {
Expand Down Expand Up @@ -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<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()) {
return;
}

if (ev.type === 'focus' && ignoreNextFocusEventRef.current) {
ignoreNextFocusEventRef.current = false;
return;
Expand Down Expand Up @@ -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<HTMLElement> | React.FocusEvent<HTMLElement>) => {
// 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') {
Expand Down Expand Up @@ -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<HTMLButtonElement>(child),
Expand Down
Loading