Skip to content

Commit

Permalink
fix: Tooltips are no longer triggered by programmatic focus() moves (m…
Browse files Browse the repository at this point in the history
…icrosoft#29791)

Use the keyborg:focusin event to detect when focus was programmatically moved to the trigger element. Since this is not a React event, use a callback ref to manage attaching and detaching the event listener.
  • Loading branch information
behowell authored Nov 15, 2023
1 parent 88efc19 commit 5103727
Show file tree
Hide file tree
Showing 6 changed files with 62 additions and 9 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "chore: Export KeyborgFocusInEvent and KEYBORG_FOCUSIN",
"packageName": "@fluentui/react-tabster",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "fix: Tooltips are no longer triggered by programmatic focus() moves.",
"packageName": "@fluentui/react-tooltip",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -45,6 +47,10 @@ export type FocusOutlineStyleOptions = {
outlineOffset?: string | FocusOutlineOffset;
};

export { KEYBORG_FOCUSIN }

export { KeyborgFocusInEvent }

// @public (undocumented)
export type TabsterDOMAttribute = Types.TabsterDOMAttribute;

Expand Down
3 changes: 3 additions & 0 deletions packages/react-components/react-tabster/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
1 change: 1 addition & 0 deletions packages/react-components/react-tooltip/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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<HTMLElement> | React.FocusEvent<HTMLElement>) => {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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)),
Expand Down

0 comments on commit 5103727

Please sign in to comment.