Skip to content

Commit ef03b49

Browse files
authored
feat(container-tooltip): suppress tooltip when trigger has aria-expanded=“true” (#700)
1 parent 59f3a78 commit ef03b49

File tree

2 files changed

+102
-39
lines changed

2 files changed

+102
-39
lines changed

packages/tooltip/src/TooltipContainer.spec.tsx

Lines changed: 71 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55
* found at http://www.apache.org/licenses/LICENSE-2.0.
66
*/
77

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

@@ -194,6 +193,76 @@ describe('TooltipContainer', () => {
194193
expect(getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
195194
});
196195
});
196+
197+
describe('tooltip suppression with expanded popup', () => {
198+
it('should not show tooltip on focus when trigger has expanded popup', async () => {
199+
const { getByText } = render(
200+
<BasicExample triggerProps={{ 'aria-haspopup': true, 'aria-expanded': true }} />
201+
);
202+
203+
await user.tab();
204+
act(() => {
205+
jest.runOnlyPendingTimers();
206+
});
207+
208+
expect(getByText('tooltip')).toHaveAttribute('aria-hidden', 'true');
209+
});
210+
211+
it('should not show tooltip on mouse enter when trigger has expanded popup', async () => {
212+
const { getByText } = render(
213+
<BasicExample triggerProps={{ 'aria-haspopup': true, 'aria-expanded': true }} />
214+
);
215+
216+
await user.hover(getByText('trigger'));
217+
act(() => {
218+
jest.runOnlyPendingTimers();
219+
});
220+
221+
expect(getByText('tooltip')).toHaveAttribute('aria-hidden', 'true');
222+
});
223+
224+
it('should allow tooltip to show when popup collapses', async () => {
225+
const { getByText, getByRole, rerender } = render(
226+
<BasicExample triggerProps={{ 'aria-haspopup': true, 'aria-expanded': true }} />
227+
);
228+
const trigger = getByText('trigger');
229+
230+
// Initially try to show tooltip with expanded popup - should be suppressed
231+
await user.hover(trigger);
232+
act(() => {
233+
jest.runOnlyPendingTimers();
234+
});
235+
236+
expect(getByText('tooltip')).toHaveAttribute('aria-hidden', 'true');
237+
238+
// Collapse popup
239+
rerender(<BasicExample triggerProps={{ 'aria-haspopup': true, 'aria-expanded': false }} />);
240+
241+
// Now try to show tooltip again
242+
await user.unhover(trigger);
243+
await user.hover(trigger);
244+
act(() => {
245+
jest.runOnlyPendingTimers();
246+
});
247+
248+
expect(getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
249+
});
250+
251+
it('should handle trigger without aria-haspopup normally', async () => {
252+
const { getByText, getByRole } = render(
253+
<BasicExample triggerProps={{ 'aria-expanded': true }} />
254+
);
255+
const trigger = getByText('trigger');
256+
257+
await user.hover(trigger);
258+
act(() => {
259+
jest.runOnlyPendingTimers();
260+
});
261+
262+
// Should show tooltip normally since aria-haspopup is not true
263+
expect(getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
264+
});
265+
});
197266
});
198267

199268
describe('getTooltipProps', () => {
@@ -242,23 +311,5 @@ describe('TooltipContainer', () => {
242311

243312
expect(getByText('tooltip')).toHaveAttribute('aria-hidden', 'true');
244313
});
245-
246-
it('should close tooltip if the trigger has an expanded popup', async () => {
247-
const { getByRole, getByText, rerender } = render(<BasicExample />);
248-
const trigger = getByText('trigger');
249-
250-
await user.hover(trigger);
251-
252-
act(() => {
253-
jest.runOnlyPendingTimers();
254-
});
255-
256-
expect(getByRole('tooltip')).toHaveAttribute('aria-hidden', 'false');
257-
258-
// Simulate triggering a popup
259-
rerender(<BasicExample triggerProps={{ 'aria-haspopup': true, 'aria-expanded': true }} />);
260-
261-
expect(getByText('tooltip')).toHaveAttribute('aria-hidden', 'true');
262-
});
263314
});
264315
});

packages/tooltip/src/useTooltip.ts

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,32 @@ export const useTooltip = <T extends HTMLElement = HTMLElement>({
1717
}: IUseTooltipProps<T>): IUseTooltipReturnValue => {
1818
const _id = useId(id);
1919
const [visibility, setVisibility] = useState(isVisible);
20-
const [isTriggerPopupExpanded, setIsTriggerPopupExpanded] = useState(false);
2120
const isMounted = useRef(false);
2221
const openTooltipTimeoutId = useRef<number>();
2322
const closeTooltipTimeoutId = useRef<number>();
24-
23+
const isTriggerPopupExpanded = useRef(false);
24+
25+
/**
26+
* 1. Prevent scheduling a tooltip open if a popup is already expanded.
27+
* This avoids creating unnecessary timeouts when we know the tooltip shouldn't show.
28+
* 2. Popup state may have changed during the delay period, so we need to check again
29+
* because the popup could have expanded after the timeout was set.
30+
*
31+
* Notes: This implementation suppresses tooltips immediately after collapsing,
32+
* when focus returns to the trigger. It relies on the fact that the trigger’s onFocus event
33+
* (which calls openTooltip) fires before the MutationObserver detects changes to aria-expanded.
34+
*/
2535
const openTooltip = useCallback(
2636
(delayMs = delayMilliseconds) => {
37+
if (isTriggerPopupExpanded.current) return; // [1]
38+
2739
clearTimeout(closeTooltipTimeoutId.current);
2840

2941
const timerId = setTimeout(() => {
30-
if (isMounted.current) {
42+
if (
43+
isMounted.current &&
44+
!isTriggerPopupExpanded.current // [2]
45+
) {
3146
setVisibility(true);
3247
}
3348
}, delayMs);
@@ -80,13 +95,17 @@ export const useTooltip = <T extends HTMLElement = HTMLElement>({
8095
const triggerElement =
8196
triggerRef?.current?.getAttribute('aria-haspopup') === 'true' ? triggerRef.current : null;
8297

83-
const updateTriggerPopupExpandedState = () => {
84-
if (triggerElement) {
85-
setIsTriggerPopupExpanded(triggerElement.getAttribute('aria-expanded') === 'true');
98+
const handleTriggerPopupChange = () => {
99+
const isExpanded = triggerElement?.getAttribute('aria-expanded') === 'true';
100+
101+
if (triggerElement && isExpanded) {
102+
setVisibility(false); // suppress existing tooltip
86103
}
104+
105+
isTriggerPopupExpanded.current = isExpanded;
87106
};
88107

89-
const mutationObserver = new MutationObserver(updateTriggerPopupExpandedState);
108+
const mutationObserver = new MutationObserver(handleTriggerPopupChange);
90109

91110
if (triggerElement) {
92111
mutationObserver.observe(triggerElement, {
@@ -95,7 +114,7 @@ export const useTooltip = <T extends HTMLElement = HTMLElement>({
95114
});
96115
}
97116

98-
updateTriggerPopupExpandedState(); // initial render
117+
handleTriggerPopupChange(); // initial render
99118

100119
return () => mutationObserver.disconnect();
101120
}, [triggerRef]);
@@ -131,28 +150,21 @@ export const useTooltip = <T extends HTMLElement = HTMLElement>({
131150
role,
132151
onMouseEnter: composeEventHandlers(onMouseEnter, () => openTooltip()),
133152
onMouseLeave: composeEventHandlers(onMouseLeave, () => closeTooltip()),
134-
'aria-hidden': !visibility || isTriggerPopupExpanded,
153+
'aria-hidden': !visibility,
135154
id: _id,
136155
...other
137156
}),
138-
[_id, closeTooltip, openTooltip, visibility, isTriggerPopupExpanded]
157+
[_id, closeTooltip, openTooltip, visibility]
139158
);
140159

141160
return useMemo<IUseTooltipReturnValue>(
142161
() => ({
143-
isVisible: visibility && !isTriggerPopupExpanded,
162+
isVisible: visibility,
144163
getTooltipProps,
145164
getTriggerProps,
146165
openTooltip,
147166
closeTooltip
148167
}),
149-
[
150-
closeTooltip,
151-
getTooltipProps,
152-
getTriggerProps,
153-
openTooltip,
154-
visibility,
155-
isTriggerPopupExpanded
156-
]
168+
[closeTooltip, getTooltipProps, getTriggerProps, openTooltip, visibility]
157169
);
158170
};

0 commit comments

Comments
 (0)