diff --git a/change/@fluentui-react-tabster-29c7edb4-01af-423b-afff-67460b819e63.json b/change/@fluentui-react-tabster-29c7edb4-01af-423b-afff-67460b819e63.json new file mode 100644 index 0000000000000..b95f191567df1 --- /dev/null +++ b/change/@fluentui-react-tabster-29c7edb4-01af-423b-afff-67460b819e63.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "chore: Export KeyborgFocusInEvent and KEYBORG_FOCUSIN", + "packageName": "@fluentui/react-tabster", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-tooltip-38280ac4-f10b-4a12-8abf-c8de8489ab9b.json b/change/@fluentui-react-tooltip-38280ac4-f10b-4a12-8abf-c8de8489ab9b.json new file mode 100644 index 0000000000000..c7bc1186f71a8 --- /dev/null +++ b/change/@fluentui-react-tooltip-38280ac4-f10b-4a12-8abf-c8de8489ab9b.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "fix: Tooltips are no longer triggered by programmatic focus() moves.", + "packageName": "@fluentui/react-tooltip", + "email": "behowell@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-tabster/etc/react-tabster.api.md b/packages/react-components/react-tabster/etc/react-tabster.api.md index 0f4a485a4aa46..6a12cb68cb473 100644 --- a/packages/react-components/react-tabster/etc/react-tabster.api.md +++ b/packages/react-components/react-tabster/etc/react-tabster.api.md @@ -5,6 +5,8 @@ ```ts import type { GriffelStyle } from '@griffel/react'; +import { KEYBORG_FOCUSIN } from 'keyborg'; +import { KeyborgFocusInEvent } from 'keyborg'; import { makeResetStyles } from '@griffel/react'; import * as React_2 from 'react'; import type { RefObject } from 'react'; @@ -45,6 +47,10 @@ export type FocusOutlineStyleOptions = { outlineOffset?: string | FocusOutlineOffset; }; +export { KEYBORG_FOCUSIN } + +export { KeyborgFocusInEvent } + // @public (undocumented) export type TabsterDOMAttribute = Types.TabsterDOMAttribute; diff --git a/packages/react-components/react-tabster/src/index.ts b/packages/react-components/react-tabster/src/index.ts index 2fe856afbed88..59ee7c5b6598f 100644 --- a/packages/react-components/react-tabster/src/index.ts +++ b/packages/react-components/react-tabster/src/index.ts @@ -32,3 +32,6 @@ export { applyFocusVisiblePolyfill } from './focus/index'; import { Types as TabsterTypes } from 'tabster'; export type TabsterDOMAttribute = TabsterTypes.TabsterDOMAttribute; + +export type { KeyborgFocusInEvent } from 'keyborg'; +export { KEYBORG_FOCUSIN } from 'keyborg'; diff --git a/packages/react-components/react-tooltip/package.json b/packages/react-components/react-tooltip/package.json index 00e812cfc9834..941d823675abf 100644 --- a/packages/react-components/react-tooltip/package.json +++ b/packages/react-components/react-tooltip/package.json @@ -38,6 +38,7 @@ "@fluentui/react-portal": "^9.4.1", "@fluentui/react-positioning": "^9.10.0", "@fluentui/react-shared-contexts": "^9.12.0", + "@fluentui/react-tabster": "^9.14.5", "@fluentui/react-theme": "^9.1.16", "@fluentui/react-utilities": "^9.15.2", "@griffel/react": "^1.5.14", diff --git a/packages/react-components/react-tooltip/src/components/Tooltip/useTooltip.tsx b/packages/react-components/react-tooltip/src/components/Tooltip/useTooltip.tsx index 28b5b9febbb1d..82f8179939ed6 100644 --- a/packages/react-components/react-tooltip/src/components/Tooltip/useTooltip.tsx +++ b/packages/react-components/react-tooltip/src/components/Tooltip/useTooltip.tsx @@ -4,6 +4,8 @@ import { useTooltipVisibility_unstable as useTooltipVisibility, useFluent_unstable as useFluent, } from '@fluentui/react-shared-contexts'; +import type { KeyborgFocusInEvent } from '@fluentui/react-tabster'; +import { KEYBORG_FOCUSIN } from '@fluentui/react-tabster'; import { applyTriggerPropsToChildren, useControllableState, @@ -149,11 +151,8 @@ export const useTooltip_unstable = (props: TooltipProps): TooltipState => { } }, [context, targetDocument, visible, setVisible]); - // The focused element gets a blur event when the document loses focus - // (e.g. switching tabs in the browser), but we don't want to show the - // tooltip again when the document gets focus back. Handle this case by - // checking if the blurred element is still the document's activeElement. - // See https://github.com/microsoft/fluentui/issues/13541 + // Used to skip showing the tooltip in certain situations when the trigger is focued. + // See comments where this is set for more info. const ignoreNextFocusEventRef = React.useRef(false); // Listener for onPointerEnter and onFocus on the trigger element @@ -176,6 +175,29 @@ export const useTooltip_unstable = (props: TooltipProps): TooltipState => { [setDelayTimeout, setVisible, state.showDelay, context], ); + // Callback ref that attaches a keyborg:focusin event listener. + const [keyborgListenerCallbackRef] = React.useState(() => { + const onKeyborgFocusIn = ((ev: KeyborgFocusInEvent) => { + // Skip showing the tooltip if focus moved programmatically. + // For example, we don't want to show the tooltip when a dialog is closed + // and Tabster programmatically restores focus to the trigger button. + // See https://github.com/microsoft/fluentui/issues/27576 + if (ev.details?.isFocusedProgrammatically) { + ignoreNextFocusEventRef.current = true; + } + }) as EventListener; + + // Save the current element to remove the listener when the ref changes + let current: Element | null = null; + + // Callback ref that attaches the listener to the element + return (element: Element | null) => { + current?.removeEventListener(KEYBORG_FOCUSIN, onKeyborgFocusIn); + element?.addEventListener(KEYBORG_FOCUSIN, onKeyborgFocusIn); + current = element; + }; + }); + // Listener for onPointerLeave and onBlur on the trigger element const onLeaveTrigger = React.useCallback( (ev: React.PointerEvent | React.FocusEvent) => { @@ -185,6 +207,11 @@ export const useTooltip_unstable = (props: TooltipProps): TooltipState => { // Hide immediately when losing focus delay = 0; + // The focused element gets a blur event when the document loses focus + // (e.g. switching tabs in the browser), but we don't want to show the + // tooltip again when the document gets focus back. Handle this case by + // checking if the blurred element is still the document's activeElement. + // See https://github.com/microsoft/fluentui/issues/13541 ignoreNextFocusEventRef.current = targetDocument?.activeElement === ev.target; } @@ -228,14 +255,16 @@ export const useTooltip_unstable = (props: TooltipProps): TooltipState => { state.shouldRenderTooltip = false; } - const childTargetRef = useMergedRefs(child?.ref, targetRef); - // Apply the trigger props to the child, either by calling the render function, or cloning with the new props state.children = applyTriggerPropsToChildren(children, { ...triggerAriaProps, ...child?.props, - // If the target prop is not provided, attach targetRef to the trigger element's ref prop - ref: positioningOptions.target === undefined ? childTargetRef : child?.ref, + ref: useMergedRefs( + child?.ref, + keyborgListenerCallbackRef, + // If the target prop is not provided, attach targetRef to the trigger element's ref prop + positioningOptions.target === undefined ? targetRef : undefined, + ), onPointerEnter: useEventCallback(mergeCallbacks(child?.props?.onPointerEnter, onEnterTrigger)), onPointerLeave: useEventCallback(mergeCallbacks(child?.props?.onPointerLeave, onLeaveTrigger)), onFocus: useEventCallback(mergeCallbacks(child?.props?.onFocus, onEnterTrigger)),