Skip to content
Merged
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
91 changes: 71 additions & 20 deletions packages/tooltip/src/TooltipContainer.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
* found at http://www.apache.org/licenses/LICENSE-2.0.
*/

import React, { createRef, HTMLAttributes } from 'react';
import React, { act, createRef, HTMLAttributes } from 'react';
import userEvent from '@testing-library/user-event';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { KEYS } from '@zendeskgarden/container-utilities';
import { TooltipContainer, ITooltipContainerProps } from './';

Expand Down Expand Up @@ -194,6 +193,76 @@ describe('TooltipContainer', () => {
expect(getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
});
});

describe('tooltip suppression with expanded popup', () => {
it('should not show tooltip on focus when trigger has expanded popup', async () => {
const { getByText } = render(
<BasicExample triggerProps={{ 'aria-haspopup': true, 'aria-expanded': true }} />
);

await user.tab();
act(() => {
jest.runOnlyPendingTimers();
});

expect(getByText('tooltip')).toHaveAttribute('aria-hidden', 'true');
});

it('should not show tooltip on mouse enter when trigger has expanded popup', async () => {
const { getByText } = render(
<BasicExample triggerProps={{ 'aria-haspopup': true, 'aria-expanded': true }} />
);

await user.hover(getByText('trigger'));
act(() => {
jest.runOnlyPendingTimers();
});

expect(getByText('tooltip')).toHaveAttribute('aria-hidden', 'true');
});

it('should allow tooltip to show when popup collapses', async () => {
const { getByText, getByRole, rerender } = render(
<BasicExample triggerProps={{ 'aria-haspopup': true, 'aria-expanded': true }} />
);
const trigger = getByText('trigger');

// Initially try to show tooltip with expanded popup - should be suppressed
await user.hover(trigger);
act(() => {
jest.runOnlyPendingTimers();
});

expect(getByText('tooltip')).toHaveAttribute('aria-hidden', 'true');

// Collapse popup
rerender(<BasicExample triggerProps={{ 'aria-haspopup': true, 'aria-expanded': false }} />);

// Now try to show tooltip again
await user.unhover(trigger);
await user.hover(trigger);
act(() => {
jest.runOnlyPendingTimers();
});

expect(getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
});

it('should handle trigger without aria-haspopup normally', async () => {
const { getByText, getByRole } = render(
<BasicExample triggerProps={{ 'aria-expanded': true }} />
);
const trigger = getByText('trigger');

await user.hover(trigger);
act(() => {
jest.runOnlyPendingTimers();
});

// Should show tooltip normally since aria-haspopup is not true
expect(getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
});
});
});

describe('getTooltipProps', () => {
Expand Down Expand Up @@ -242,23 +311,5 @@ describe('TooltipContainer', () => {

expect(getByText('tooltip')).toHaveAttribute('aria-hidden', 'true');
});

it('should close tooltip if the trigger has an expanded popup', async () => {
const { getByRole, getByText, rerender } = render(<BasicExample />);
const trigger = getByText('trigger');

await user.hover(trigger);

act(() => {
jest.runOnlyPendingTimers();
});

expect(getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');

// Simulate triggering a popup
rerender(<BasicExample triggerProps={{ 'aria-haspopup': true, 'aria-expanded': true }} />);

expect(getByText('tooltip')).toHaveAttribute('aria-hidden', 'true');
});
});
});
50 changes: 31 additions & 19 deletions packages/tooltip/src/useTooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,32 @@ export const useTooltip = <T extends HTMLElement = HTMLElement>({
}: IUseTooltipProps<T>): IUseTooltipReturnValue => {
const _id = useId(id);
const [visibility, setVisibility] = useState(isVisible);
const [isTriggerPopupExpanded, setIsTriggerPopupExpanded] = useState(false);
const isMounted = useRef(false);
const openTooltipTimeoutId = useRef<number>();
const closeTooltipTimeoutId = useRef<number>();

const isTriggerPopupExpanded = useRef(false);

/**
* 1. Prevent scheduling a tooltip open if a popup is already expanded.
* This avoids creating unnecessary timeouts when we know the tooltip shouldn't show.
* 2. Popup state may have changed during the delay period, so we need to check again
* because the popup could have expanded after the timeout was set.
*
* Notes: This implementation suppresses tooltips immediately after collapsing,
* when focus returns to the trigger. It relies on the fact that the trigger’s onFocus event
* (which calls openTooltip) fires before the MutationObserver detects changes to aria-expanded.
*/
const openTooltip = useCallback(
(delayMs = delayMilliseconds) => {
if (isTriggerPopupExpanded.current) return; // [1]

clearTimeout(closeTooltipTimeoutId.current);

const timerId = setTimeout(() => {
if (isMounted.current) {
if (
isMounted.current &&
!isTriggerPopupExpanded.current // [2]
) {
setVisibility(true);
}
}, delayMs);
Expand Down Expand Up @@ -80,13 +95,17 @@ export const useTooltip = <T extends HTMLElement = HTMLElement>({
const triggerElement =
triggerRef?.current?.getAttribute('aria-haspopup') === 'true' ? triggerRef.current : null;

const updateTriggerPopupExpandedState = () => {
if (triggerElement) {
setIsTriggerPopupExpanded(triggerElement.getAttribute('aria-expanded') === 'true');
const handleTriggerPopupChange = () => {
const isExpanded = triggerElement?.getAttribute('aria-expanded') === 'true';

if (triggerElement && isExpanded) {
setVisibility(false); // suppress existing tooltip
}

isTriggerPopupExpanded.current = isExpanded;
};

const mutationObserver = new MutationObserver(updateTriggerPopupExpandedState);
const mutationObserver = new MutationObserver(handleTriggerPopupChange);

if (triggerElement) {
mutationObserver.observe(triggerElement, {
Expand All @@ -95,7 +114,7 @@ export const useTooltip = <T extends HTMLElement = HTMLElement>({
});
}

updateTriggerPopupExpandedState(); // initial render
handleTriggerPopupChange(); // initial render

return () => mutationObserver.disconnect();
}, [triggerRef]);
Expand Down Expand Up @@ -131,28 +150,21 @@ export const useTooltip = <T extends HTMLElement = HTMLElement>({
role,
onMouseEnter: composeEventHandlers(onMouseEnter, () => openTooltip()),
onMouseLeave: composeEventHandlers(onMouseLeave, () => closeTooltip()),
'aria-hidden': !visibility || isTriggerPopupExpanded,
'aria-hidden': !visibility,
id: _id,
...other
}),
[_id, closeTooltip, openTooltip, visibility, isTriggerPopupExpanded]
[_id, closeTooltip, openTooltip, visibility]
);

return useMemo<IUseTooltipReturnValue>(
() => ({
isVisible: visibility && !isTriggerPopupExpanded,
isVisible: visibility,
getTooltipProps,
getTriggerProps,
openTooltip,
closeTooltip
}),
[
closeTooltip,
getTooltipProps,
getTriggerProps,
openTooltip,
visibility,
isTriggerPopupExpanded
]
[closeTooltip, getTooltipProps, getTriggerProps, openTooltip, visibility]
);
};