Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tooltip): add ignoreNonKeyboardFocus prop #2919

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
5 changes: 5 additions & 0 deletions .yarn/versions/d3e5160f.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
releases:
"@radix-ui/react-tooltip": minor

declined:
- primitives
54 changes: 54 additions & 0 deletions packages/react/tooltip/src/Tooltip.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,60 @@ export const DisableHoverableContent = () => (
</>
);

export const IgnoreNonKeyboardFocus = () => {
return (
<>
<h1>Without ignoring non-keyboard focus</h1>
<p>Try clicking on the button and switch browser tabs.</p>
<div style={{ display: 'flex', gap: 50 }}>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger className={triggerClass()}>Focus me</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className={contentClass()} sideOffset={5}>
Nicely done!
<Tooltip.Arrow className={arrowClass()} offset={10} />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</div>

<h1>Ignore non-keyboard focus</h1>
<p>Focus event is ignored if it didn't come from keyboard - try switching tabs again</p>
<h2>Inherited from provider</h2>
<div style={{ display: 'flex', gap: 50 }}>
<Tooltip.Provider ignoreNonKeyboardFocus>
<Tooltip.Root>
<Tooltip.Trigger className={triggerClass()}>Focus me</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className={contentClass()} sideOffset={5}>
Nicely done!
<Tooltip.Arrow className={arrowClass()} offset={10} />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</div>

<h2>Can be overriden by the prop on tooltip</h2>
<div style={{ display: 'flex', gap: 50 }}>
<Tooltip.Provider ignoreNonKeyboardFocus>
<Tooltip.Root ignoreNonKeyboardFocus={false}>
<Tooltip.Trigger className={triggerClass()}>Focus me</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className={contentClass()} sideOffset={5}>
Nicely done!
<Tooltip.Arrow className={arrowClass()} offset={10} />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
</div>
</>
);
};

// change order slightly for more pleasing visual
const SIDES = SIDE_OPTIONS.filter((side) => side !== 'bottom').concat(['bottom']);

Expand Down
24 changes: 22 additions & 2 deletions packages/react/tooltip/src/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type TooltipProviderContextValue = {
onPointerInTransitChange(inTransit: boolean): void;
isPointerInTransitRef: React.MutableRefObject<boolean>;
disableHoverableContent: boolean;
ignoreNonKeyboardFocus: boolean;
};

const [TooltipProviderContextProvider, useTooltipProviderContext] =
Expand All @@ -60,6 +61,11 @@ interface TooltipProviderProps {
* @defaultValue false
*/
disableHoverableContent?: boolean;
/**
* Prevent the tooltip from opening if the focus did not come from the keyboard by matching against the `:focus-visible` selector. This is useful if you want to avoid opening it when switching browser tabs or closing a dialog.
* @defaultValue false
*/
ignoreNonKeyboardFocus?: boolean;
}

const TooltipProvider: React.FC<TooltipProviderProps> = (
Expand All @@ -70,6 +76,7 @@ const TooltipProvider: React.FC<TooltipProviderProps> = (
delayDuration = DEFAULT_DELAY_DURATION,
skipDelayDuration = 300,
disableHoverableContent = false,
ignoreNonKeyboardFocus = false,
children,
} = props;
const [isOpenDelayed, setIsOpenDelayed] = React.useState(true);
Expand Down Expand Up @@ -102,6 +109,7 @@ const TooltipProvider: React.FC<TooltipProviderProps> = (
isPointerInTransitRef.current = inTransit;
}, [])}
disableHoverableContent={disableHoverableContent}
ignoreNonKeyboardFocus={ignoreNonKeyboardFocus}
>
{children}
</TooltipProviderContextProvider>
Expand All @@ -127,6 +135,7 @@ type TooltipContextValue = {
onOpen(): void;
onClose(): void;
disableHoverableContent: boolean;
ignoreNonKeyboardFocus: boolean;
};

const [TooltipContextProvider, useTooltipContext] =
Expand All @@ -148,6 +157,11 @@ interface TooltipProps {
* @defaultValue false
*/
disableHoverableContent?: boolean;
/**
* Prevent the tooltip from opening if the focus did not come from the keyboard by matching against the `:focus-visible` selector. This is useful if you want to avoid opening it when switching browser tabs or closing a dialog.
* @defaultValue false
*/
ignoreNonKeyboardFocus?: boolean;
}

const Tooltip: React.FC<TooltipProps> = (props: ScopedProps<TooltipProps>) => {
Expand All @@ -159,6 +173,7 @@ const Tooltip: React.FC<TooltipProps> = (props: ScopedProps<TooltipProps>) => {
onOpenChange,
disableHoverableContent: disableHoverableContentProp,
delayDuration: delayDurationProp,
ignoreNonKeyboardFocus: ignoreNonKeyboardFocusProp,
} = props;
const providerContext = useTooltipProviderContext(TOOLTIP_NAME, props.__scopeTooltip);
const popperScope = usePopperScope(__scopeTooltip);
Expand All @@ -167,6 +182,8 @@ const Tooltip: React.FC<TooltipProps> = (props: ScopedProps<TooltipProps>) => {
const openTimerRef = React.useRef(0);
const disableHoverableContent =
disableHoverableContentProp ?? providerContext.disableHoverableContent;
const ignoreNonKeyboardFocus =
ignoreNonKeyboardFocusProp ?? providerContext.ignoreNonKeyboardFocus;
const delayDuration = delayDurationProp ?? providerContext.delayDuration;
const wasOpenDelayedRef = React.useRef(false);
const [open = false, setOpen] = useControllableState({
Expand Down Expand Up @@ -236,6 +253,7 @@ const Tooltip: React.FC<TooltipProps> = (props: ScopedProps<TooltipProps>) => {
onOpen={handleOpen}
onClose={handleClose}
disableHoverableContent={disableHoverableContent}
ignoreNonKeyboardFocus={ignoreNonKeyboardFocus}
>
{children}
</TooltipContextProvider>
Expand Down Expand Up @@ -298,8 +316,10 @@ const TooltipTrigger = React.forwardRef<TooltipTriggerElement, TooltipTriggerPro
isPointerDownRef.current = true;
document.addEventListener('pointerup', handlePointerUp, { once: true });
})}
onFocus={composeEventHandlers(props.onFocus, () => {
if (!isPointerDownRef.current) context.onOpen();
onFocus={composeEventHandlers(props.onFocus, (event) => {
const isTriggerableFocus =
!context.ignoreNonKeyboardFocus || event.target.matches(':focus-visible');
if (isTriggerableFocus && !isPointerDownRef.current) context.onOpen();
})}
onBlur={composeEventHandlers(props.onBlur, context.onClose)}
onClick={composeEventHandlers(props.onClick, context.onClose)}
Expand Down