Skip to content

Commit 6de7ec1

Browse files
author
Kubit
committed
Improve Tooltip component
The improvements are: - TrapFocus - Fix clickoutside and scroll functionalities
1 parent 543d2a2 commit 6de7ec1

22 files changed

+710
-158
lines changed

src/components/popover/popoverControlled.tsx

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import * as React from 'react';
22

33
import { CssAnimationExecuteOption } from '@/components/cssAnimation';
4-
import { STYLES_NAME, TAB } from '@/constants';
5-
import { useClickOutside } from '@/hooks/useClickOutside';
6-
import { useEscPressed } from '@/hooks/useKeyPressed/useEscPressed';
7-
import { useMediaDevice } from '@/hooks/useMediaDevice/useMediaDevice';
8-
import { useScrollBlock } from '@/hooks/useScrollBlock/useScrollBlock';
9-
import { useStyles } from '@/hooks/useStyles/useStyles';
4+
import { STYLES_NAME } from '@/constants';
5+
import {
6+
useClickOutside,
7+
useEscPressedV2,
8+
useMediaDevice,
9+
useScrollBlock,
10+
useStyles,
11+
} from '@/hooks';
12+
import { useTrapFocus } from '@/hooks/useTrapFocus/useTrapFocus';
1013
import { ErrorBoundary, FallbackComponent } from '@/provider/errorBoundary';
11-
import { focusFirstDescendant, trapFocus } from '@/utils';
14+
import { focusFirstDescendant, isKeyTabPressed } from '@/utils';
1215
import { convertDurationToNumber } from '@/utils/stringUtility/string.utility';
1316

1417
import { PopoverStandAlone } from './popoverStandAlone';
@@ -39,7 +42,8 @@ const PopoverControlledComponent = React.forwardRef(
3942
const { blockScroll, allowScroll } = useScrollBlock();
4043
const currentFocus = React.useRef<HTMLElement | null>(null);
4144
const innerRef = React.useRef<HTMLDivElement | null>(null);
42-
const setRef = React.useCallback(
45+
const forwardedRef = React.useRef<HTMLDivElement | null>(null);
46+
const setForwareddRef = React.useCallback(
4347
node => {
4448
if (node && focusFirstDescendantAutomatically) {
4549
focusFirstDescendant(node);
@@ -53,7 +57,7 @@ const PopoverControlledComponent = React.forwardRef(
5357
if (!node && blockBack) {
5458
allowScroll();
5559
}
56-
innerRef.current = node;
60+
forwardedRef.current = node;
5761
},
5862
[props.forwardedRef, focusFirstDescendantAutomatically]
5963
);
@@ -71,6 +75,11 @@ const PopoverControlledComponent = React.forwardRef(
7175
const [openAnimation, setOpenAnimation] = React.useState(props.open);
7276
const openAnimationRef = React.useRef(props.open);
7377

78+
// Expose the ref to the parent component
79+
React.useImperativeHandle(ref, () => {
80+
return innerRef.current as HTMLDivElement;
81+
}, []);
82+
7483
// To improve: this ref has been created to avoid update a state when the component does not longer exists.
7584
// Check waitForAnimation and setOpenAnimation in the onClose method
7685
const componentAlive = React.useRef(true);
@@ -110,8 +119,8 @@ const PopoverControlledComponent = React.forwardRef(
110119
}
111120
};
112121

113-
useClickOutside(innerRef, handleClickOutside, props.preventCloseOnClickElements);
114-
useEscPressed({ execute: handlePressScape, element: innerRef });
122+
useClickOutside(forwardedRef, handleClickOutside, props.preventCloseOnClickElements);
123+
useEscPressedV2({ ref: innerRef, onEscPress: handlePressScape });
115124

116125
const beforeModalFocus = () => {
117126
currentFocus.current = document.activeElement as HTMLElement;
@@ -128,12 +137,18 @@ const PopoverControlledComponent = React.forwardRef(
128137
}
129138
};
130139

140+
// to force rerender to update ref in useTrapFocus
141+
const [tabPressed, setTabPressed] = React.useState(false);
142+
131143
const handleKeyDown: React.KeyboardEventHandler<HTMLElement> = event => {
132-
if (event.key === TAB.key && innerRef.current && props.trapFocusInsideModal) {
133-
trapFocus(innerRef.current, event);
144+
if (isKeyTabPressed(event.key) && forwardedRef.current) {
145+
setTabPressed(true);
146+
props.onKeyDown?.(event);
134147
}
135148
};
136149

150+
useTrapFocus({ element: forwardedRef, hasFocusTrap: props.trapFocusInsideModal, tabPressed });
151+
137152
React.useEffect(() => {
138153
if (!props.open && openAnimationRef.current) {
139154
onClose();
@@ -182,14 +197,14 @@ const PopoverControlledComponent = React.forwardRef(
182197
return (
183198
<PopoverStandAlone
184199
{...props}
185-
ref={ref}
200+
ref={innerRef}
186201
animationConfig={animationConfig}
187202
animationExecution={
188203
showAnimationEnd ? CssAnimationExecuteOption.END : CssAnimationExecuteOption.START
189204
}
190205
component={component}
191206
device={device}
192-
forwardedRef={setRef}
207+
forwardedRef={setForwareddRef}
193208
open={openAnimation}
194209
styles={styles}
195210
onKeyDown={handleKeyDown}

src/components/tooltip/__tests__/tooltip.test.tsx

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fireEvent, screen } from '@testing-library/react';
1+
import { act, fireEvent, screen } from '@testing-library/react';
22
import * as React from 'react';
33

44
import { axe } from 'jest-axe';
@@ -9,18 +9,18 @@ import * as mediaHooks from '@/hooks/useMediaDevice/useMediaDevice';
99
import { renderProvider } from '@/tests/renderProvider/renderProvider.utility';
1010
import { windowMatchMedia } from '@/tests/windowMatchMedia';
1111
import { DeviceBreakpointsType, ROLES } from '@/types';
12-
import * as focusHandlers from '@/utils/focusHandlers/focusHandlers';
1312

1413
import { TooltipUnControlled as Tooltip } from '../tooltipUnControlled';
1514
import { ITooltipUnControlled, TooltipAlignType } from '../types';
1615

17-
window.matchMedia = windowMatchMedia();
18-
1916
const mockProps: ITooltipUnControlled = {
2017
children: 'text',
2118
variant: 'DEFAULT',
2219
title: { content: 'title' },
2320
content: { content: 'content' },
21+
triggerAsButton: {
22+
'aria-label': 'Tooltip trigger',
23+
},
2424
closeIcon: { icon: 'UNICORN', altText: 'close icon' },
2525
};
2626

@@ -162,6 +162,23 @@ describe('Tooltip', () => {
162162
expect(closeIcon).toBeVisible();
163163
});
164164

165+
it('Tooltip - it does not show tooltip on focus label if its being clicked at the same time', () => {
166+
renderProvider(<Tooltip {...mockProps} tooltipAsModal={false} />);
167+
const label = screen.getByText(mockProps.children as string);
168+
169+
act(() => {
170+
// Open and close the tooltip first in order the inline styles to be applied
171+
fireEvent.mouseEnter(label);
172+
fireEvent.mouseLeave(label);
173+
fireEvent.mouseDown(label);
174+
fireEvent.focus(label);
175+
fireEvent.mouseUp(label);
176+
});
177+
178+
const title = screen.getByText(mockProps.title?.content as string);
179+
expect(title).not.toBeVisible();
180+
});
181+
165182
it('Tooltip - it hides tooltip on blur tooltip', () => {
166183
renderProvider(<Tooltip {...mockProps} tooltipAsModal={false} />);
167184
const label = screen.getByText(mockProps.children as string);
@@ -215,23 +232,7 @@ describe('Tooltip', () => {
215232
expect(closeIcon).not.toBeVisible();
216233
});
217234

218-
it('Tooltip - it traps the focus', () => {
219-
const spyTrapFocus = jest.spyOn(focusHandlers, 'trapFocus');
220-
renderProvider(<Tooltip {...mockProps} dataTestId={'testId'} tooltipAsModal={false} />);
221-
const label = screen.getByText(mockProps.children as string);
222-
223-
fireEvent.mouseEnter(label);
224-
const content = screen.getByTestId('testIdTooltipContent');
225-
// press tab
226-
fireEvent.keyDown(content, {
227-
key: 'Tab',
228-
code: 'Tab',
229-
});
230-
231-
expect(spyTrapFocus).toHaveBeenCalled();
232-
});
233-
234-
it('Tooltip - onClick will not produce any effect if desktop', () => {
235+
it('Tooltip - onClick will not produce any effect if desktop and tooltip is not a modal', () => {
235236
renderProvider(<Tooltip {...mockProps} tooltipAsModal={false} />);
236237
const label = screen.getByText(mockProps.children as string);
237238
// Have to show and hide the tooltip first because it does not detect the tooltip as invisible when starting due to styled-component
@@ -248,6 +249,24 @@ describe('Tooltip', () => {
248249
expect(content).not.toBeVisible();
249250
});
250251

252+
it('Tooltip - onClick in the label will open / close the tooltip in desktop if configured as modal', () => {
253+
renderProvider(<Tooltip {...mockProps} tooltipAsModal={true} />);
254+
const label = screen.getByText(mockProps.children as string);
255+
256+
fireEvent.click(label);
257+
258+
const title = screen.getByText(mockProps.title?.content as string);
259+
const content = screen.getByText(mockProps.content?.content as string);
260+
261+
expect(title).toBeVisible();
262+
expect(content).toBeVisible();
263+
264+
fireEvent.click(label);
265+
266+
expect(title).not.toBeVisible();
267+
expect(content).not.toBeVisible();
268+
});
269+
251270
it('Tooltip - it shows content as JSX.Element on mouse enter label', () => {
252271
renderProvider(
253272
<Tooltip {...mockProps} content={{ content: <h1>ELEMENT</h1> }} tooltipAsModal={false} />
@@ -433,7 +452,8 @@ describe('Tooltip', () => {
433452
const title = screen.getByText(mockProps.title?.content as string);
434453
expect(title).toBeInTheDocument();
435454

436-
fireEvent.keyDown(window, {
455+
// Internal popover element fire the escape keydown
456+
fireEvent.keyDown(title, {
437457
key: 'Escape',
438458
code: 'Escape',
439459
});

src/components/tooltip/__tests__/tooltip.utils.test.ts

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { PopoverComponentType } from '@/components/popover';
22
import { DeviceBreakpointsType } from '@/types';
33

4-
import { getAriaDescriptorsBy, getHtmlTagForTooltip, useTooltipAsModal } from '../utils';
4+
import { getAriaDescriptorsBy, getHtmlTagForTooltip } from '../utils';
55

66
describe('getAriaDescriptorsBy utility', () => {
77
it('should return both titleId and contentId when title and content are provided', () => {
@@ -82,36 +82,3 @@ describe('getHtmlTagForTooltip utility', () => {
8282
expect(result).toBeUndefined();
8383
});
8484
});
85-
86-
describe('useTooltipAsModal utility', () => {
87-
it('should return propTooltipAsModal when both propTooltipAsModal and styleTooltipAsModal are provided', () => {
88-
const result = useTooltipAsModal({
89-
propTooltipAsModal: true,
90-
styleTooltipAsModal: false,
91-
});
92-
93-
expect(result).toBe(true);
94-
});
95-
96-
it('should return propTooltipAsModal when only propTooltipAsModal is provided', () => {
97-
const result = useTooltipAsModal({
98-
propTooltipAsModal: true,
99-
});
100-
101-
expect(result).toBe(true);
102-
});
103-
104-
it('should return styleTooltipAsModal when only styleTooltipAsModal is provided', () => {
105-
const result = useTooltipAsModal({
106-
styleTooltipAsModal: true,
107-
});
108-
109-
expect(result).toBe(true);
110-
});
111-
112-
it('should return false when neither propTooltipAsModal nor styleTooltipAsModal is provided', () => {
113-
const result = useTooltipAsModal({});
114-
115-
expect(result).toBe(false);
116-
});
117-
});

src/components/tooltip/__tests__/useTooltip.test.tsx

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { act } from '@testing-library/react';
1+
import { act, fireEvent } from '@testing-library/react';
22

33
import { renderHookProvider } from '@/tests/renderProvider/renderProvider.utility';
44
import { windowMatchMedia } from '@/tests/windowMatchMedia';
@@ -180,20 +180,88 @@ describe('useTooltip', () => {
180180
.spyOn(useMediaDevice, 'useMediaDevice')
181181
.mockImplementation(() => DeviceBreakpointsType.DESKTOP);
182182

183+
tooltipRef.current && jest.spyOn(tooltipRef.current, 'contains').mockReturnValueOnce(false);
184+
tooltipRef.current &&
185+
jest.spyOn(tooltipRef.current, 'style', 'get').mockReturnValueOnce({} as CSSStyleDeclaration);
186+
187+
const { result } = renderHookProvider(() => useTooltip({ labelRef, tooltipRef, variant }));
188+
189+
act(() => {
190+
result.current.showTooltip();
191+
});
192+
act(() => {
193+
window.dispatchEvent(new Event('scroll'));
194+
});
195+
196+
expect(tooltipRef.current?.style).not.toEqual({});
197+
});
198+
199+
it('Use Tooltip - will be hided esc is pressed', () => {
200+
window.matchMedia = windowMatchMedia('onlyDesktop');
201+
jest
202+
.spyOn(useMediaDevice, 'useMediaDevice')
203+
.mockImplementation(() => DeviceBreakpointsType.DESKTOP);
204+
205+
tooltipRef.current && jest.spyOn(tooltipRef.current, 'contains').mockReturnValueOnce(false);
206+
tooltipRef.current &&
207+
jest.spyOn(tooltipRef.current, 'style', 'get').mockReturnValueOnce({} as CSSStyleDeclaration);
208+
209+
const { result } = renderHookProvider(() => useTooltip({ labelRef, tooltipRef, variant }));
210+
211+
act(() => {
212+
result.current.showTooltip();
213+
});
214+
act(() => {
215+
fireEvent.keyDown(labelRef.current, { key: 'Escape', code: 'Escape' });
216+
});
217+
218+
expect(tooltipRef.current?.style).not.toEqual({});
219+
});
220+
221+
it('Use Tooltip - if press esc, but already hidden, onOpenClose wont be called', () => {
222+
window.matchMedia = windowMatchMedia('onlyDesktop');
223+
jest
224+
.spyOn(useMediaDevice, 'useMediaDevice')
225+
.mockImplementation(() => DeviceBreakpointsType.DESKTOP);
226+
183227
const onOpenClose = jest.fn();
228+
184229
tooltipRef.current && jest.spyOn(tooltipRef.current, 'contains').mockReturnValueOnce(false);
230+
tooltipRef.current &&
231+
jest.spyOn(tooltipRef.current, 'style', 'get').mockReturnValueOnce({} as CSSStyleDeclaration);
185232

186233
const { result } = renderHookProvider(() =>
187234
useTooltip({ labelRef, tooltipRef, variant, onOpenClose })
188235
);
236+
act(() => {
237+
fireEvent.keyDown(labelRef.current, { key: 'Escape', code: 'Escape' });
238+
});
239+
240+
expect(onOpenClose).not.toHaveBeenCalled();
241+
});
242+
243+
it('Use Tooltip - when tooltip is a modal, when hidding focus in the last focusable element before opening the modal', () => {
244+
window.matchMedia = windowMatchMedia('onlyDesktop');
245+
jest
246+
.spyOn(useMediaDevice, 'useMediaDevice')
247+
.mockImplementation(() => DeviceBreakpointsType.DESKTOP);
248+
249+
const { result } = renderHookProvider(() =>
250+
useTooltip({ labelRef, tooltipRef, variant, tooltipAsModal: true })
251+
);
252+
253+
const focusableElement1 = document.createElement('button');
254+
document.body.appendChild(focusableElement1);
255+
focusableElement1.focus();
256+
const focusSpy = jest.spyOn(focusableElement1, 'focus');
189257

190258
act(() => {
191259
result.current.showTooltip();
192260
});
193261
act(() => {
194-
window.dispatchEvent(new Event('scroll'));
262+
result.current.hideTooltip();
195263
});
196264

197-
expect(onOpenClose).toHaveBeenCalledWith(false);
265+
expect(focusSpy).toHaveBeenCalled();
198266
});
199267
});

0 commit comments

Comments
 (0)