Skip to content

Commit babbffc

Browse files
czabajmohsinulhaq
andauthored
improve hover outside handler logic (#115)
Co-authored-by: Vaclav Grohling <[email protected]> Co-authored-by: mohsinulhaq <[email protected]>
1 parent 3dfa356 commit babbffc

9 files changed

+233
-47
lines changed

.size-snapshot.json

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
{
22
"react-popper-tooltip.js": {
3-
"bundled": 10503,
4-
"minified": 5012,
5-
"gzipped": 1723,
3+
"bundled": 12843,
4+
"minified": 5628,
5+
"gzipped": 1966,
66
"treeshaked": {
77
"rollup": {
88
"code": 142,
99
"import_statements": 142
1010
},
1111
"webpack": {
12-
"code": 1355
12+
"code": 1369
1313
}
1414
}
1515
}

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ Options to [MutationObserver
164164
visible and its content changes, it automatically repositions itself. In some cases
165165
you may need to change which parameters to observe or opt-out of tracking the changes at all.
166166

167-
- `offset: [number, number]`, defaults to `[0, 7]`
167+
- `offset: [number, number]`, defaults to `[0, 6]`
168168

169169
This is a shorthand for `popperOptions.modifiers` offset modifier option. The default value means the tooltip will be
170170
placed 7px away from the trigger element (to reserve enough space for the arrow element).

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-popper-tooltip",
3-
"version": "4.0.1",
3+
"version": "4.1.0",
44
"description": "React tooltip library built around react-popper",
55
"author": "Mohsin Ul Haq <[email protected]>",
66
"license": "MIT",
@@ -47,7 +47,7 @@
4747
},
4848
"husky": {
4949
"hooks": {
50-
"pre-commit": "yarn typecheck && yarn build && yarn test && lint-staged"
50+
"pre-commit": "yarn typecheck && yarn build && yarn test && lint-staged && git add .size-snapshot.json"
5151
}
5252
},
5353
"lint-staged": {

src/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export type Config = {
6565
placement?: PopperJS.Placement;
6666
/**
6767
* Shorthand for popper.js offset modifier, see https://popper.js.org/docs/v2/modifiers/offset/
68-
* @default [0, 7]
68+
* @default [0, 6]
6969
*/
7070
offset?: [number, number];
7171
};

src/usePopperTooltip.ts

+55-31
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import {
44
useControlledState,
55
useGetLatest,
66
generateBoundingClientRect,
7+
isMouseOutside,
78
} from './utils';
89
import { Config, PopperOptions, PropsGetterArgs, TriggerType } from './types';
910

11+
const { isArray } = Array;
12+
1013
const virtualElement = {
1114
getBoundingClientRect: generateBoundingClientRect(),
1215
};
@@ -24,7 +27,7 @@ const defaultConfig: Config = {
2427
childList: true,
2528
subtree: true,
2629
},
27-
offset: [0, 7],
30+
offset: [0, 6],
2831
trigger: 'hover',
2932
};
3033

@@ -47,7 +50,8 @@ export function usePopperTooltip(
4750

4851
const defaultModifiers = React.useMemo(
4952
() => [{ name: 'offset', options: { offset: finalConfig.offset } }],
50-
[finalConfig.offset]
53+
// eslint-disable-next-line react-hooks/exhaustive-deps
54+
isArray(finalConfig.offset) ? finalConfig.offset : []
5155
);
5256

5357
const finalPopperOptions = {
@@ -83,11 +87,12 @@ export function usePopperTooltip(
8387

8488
const isTriggeredBy = React.useCallback(
8589
(trigger: TriggerType) => {
86-
return Array.isArray(finalConfig.trigger)
90+
return isArray(finalConfig.trigger)
8791
? finalConfig.trigger.includes(trigger)
8892
: finalConfig.trigger === trigger;
8993
},
90-
[finalConfig.trigger]
94+
// eslint-disable-next-line react-hooks/exhaustive-deps
95+
isArray(finalConfig.trigger) ? finalConfig.trigger : [finalConfig.trigger]
9196
);
9297

9398
const hideTooltip = React.useCallback(() => {
@@ -180,12 +185,56 @@ export function usePopperTooltip(
180185
if (triggerRef == null || !isTriggeredBy('hover')) return;
181186

182187
triggerRef.addEventListener('mouseenter', showTooltip);
183-
triggerRef.addEventListener('mouseleave', hideTooltip);
184188
return () => {
185189
triggerRef.removeEventListener('mouseenter', showTooltip);
186-
triggerRef.removeEventListener('mouseleave', hideTooltip);
187190
};
188191
}, [triggerRef, isTriggeredBy, showTooltip, hideTooltip]);
192+
// Listen for mouse exiting the hover area &&
193+
// handle the followCursor
194+
React.useEffect(() => {
195+
if (
196+
!visible ||
197+
triggerRef == null ||
198+
(!isTriggeredBy('hover') && !finalConfig.followCursor)
199+
) {
200+
return;
201+
}
202+
203+
let lastMouseOutside = false;
204+
const handleMouseMove = (event: MouseEvent) => {
205+
const mouseOutside = isMouseOutside(
206+
event,
207+
triggerRef,
208+
!finalConfig.followCursor &&
209+
getLatest().finalConfig.interactive &&
210+
tooltipRef
211+
);
212+
if (mouseOutside && lastMouseOutside !== mouseOutside) {
213+
hideTooltip();
214+
}
215+
if (!mouseOutside && finalConfig.followCursor) {
216+
virtualElement.getBoundingClientRect = generateBoundingClientRect(
217+
event.clientX,
218+
event.clientY
219+
);
220+
update?.();
221+
}
222+
lastMouseOutside = mouseOutside;
223+
};
224+
window.addEventListener('mousemove', handleMouseMove);
225+
return () => {
226+
window.removeEventListener('mousemove', handleMouseMove);
227+
};
228+
}, [
229+
finalConfig.followCursor,
230+
getLatest,
231+
hideTooltip,
232+
isTriggeredBy,
233+
tooltipRef,
234+
triggerRef,
235+
update,
236+
visible,
237+
]);
189238

190239
// Trigger: hover on tooltip, keep it open if hovered
191240
React.useEffect(() => {
@@ -206,28 +255,6 @@ export function usePopperTooltip(
206255
if (finalConfig.closeOnTriggerHidden && isReferenceHidden) hideTooltip();
207256
}, [finalConfig.closeOnTriggerHidden, hideTooltip, isReferenceHidden]);
208257

209-
// Handle follow cursor
210-
React.useEffect(() => {
211-
if (!finalConfig.followCursor || triggerRef == null) return;
212-
213-
function setMousePosition({
214-
clientX,
215-
clientY,
216-
}: {
217-
clientX: number;
218-
clientY: number;
219-
}) {
220-
virtualElement.getBoundingClientRect = generateBoundingClientRect(
221-
clientX,
222-
clientY
223-
);
224-
update?.();
225-
}
226-
227-
triggerRef.addEventListener('mousemove', setMousePosition);
228-
return () => triggerRef.removeEventListener('mousemove', setMousePosition);
229-
}, [finalConfig.followCursor, triggerRef, update]);
230-
231258
// Handle tooltip DOM mutation changes (aka mutation observer)
232259
React.useEffect(() => {
233260
if (
@@ -249,9 +276,6 @@ export function usePopperTooltip(
249276
style: {
250277
...args.style,
251278
...styles.popper,
252-
...(finalConfig.followCursor && {
253-
pointerEvents: 'none' as React.CSSProperties['pointerEvents'],
254-
}),
255279
},
256280
...attributes.popper,
257281
};

src/utils.ts

+42
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,45 @@ export function generateBoundingClientRect(x = 0, y = 0) {
6060
left: x,
6161
});
6262
}
63+
64+
// pageX cannot be supplied in the tests, so we fallback to clientX
65+
// @see https://github.com/testing-library/dom-testing-library/issues/144
66+
const mouseOutsideRect = (
67+
{ clientX, clientY, pageX, pageY }: MouseEvent,
68+
{ bottom, left, right, top }: DOMRect
69+
) =>
70+
// DOMRect contains fractional pixel values but MouseEvent reports integers,
71+
// so we round DOMRect boundaries to make DOMRect slightly bigger
72+
(pageX || clientX) < Math.floor(left) ||
73+
(pageX || clientX) > Math.ceil(right) ||
74+
(pageY || clientY) < Math.floor(top) ||
75+
(pageY || clientY) > Math.ceil(bottom);
76+
77+
/**
78+
* Checks if mouseevent is triggered outside triggerRef and tooltipRef.
79+
* Counts with potential offset between them.
80+
* @param {MouseEvent} mouseEvent
81+
* @param {HTMLElement} triggerRef
82+
* @param {HTMLElement} tooltipRef - provide only when prop `interactive` is on
83+
*/
84+
export function isMouseOutside(
85+
mouseEvent: MouseEvent,
86+
triggerRef: HTMLElement,
87+
tooltipRef?: HTMLElement | false | null
88+
): boolean {
89+
const triggerRect = triggerRef.getBoundingClientRect();
90+
if (!tooltipRef) return mouseOutsideRect(mouseEvent, triggerRect);
91+
const tooltipRect = tooltipRef.getBoundingClientRect();
92+
// triggerRect extended to the tooltipRect boundary, thus will contain cursor
93+
// moving from triggerRect to tooltipRect over some non zero offset.
94+
const triggerRectExtendedToTooltip = {
95+
bottom: Math.max(triggerRect.bottom, tooltipRect.top),
96+
left: Math.min(triggerRect.left, tooltipRect.right),
97+
right: Math.max(triggerRect.right, tooltipRect.left),
98+
top: Math.min(triggerRect.top, tooltipRect.bottom),
99+
};
100+
return (
101+
mouseOutsideRect(mouseEvent, triggerRectExtendedToTooltip as DOMRect) &&
102+
mouseOutsideRect(mouseEvent, tooltipRect)
103+
);
104+
}

stories/basic.stories.tsx

+22-5
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,26 @@ export const Example: Story<Config> = (props) => {
3737
};
3838

3939
Example.argTypes = {
40-
trigger: {
40+
delayHide: {
4141
control: {
42-
type: 'select',
43-
options: ['hover', 'click', 'right-click', 'focus', null],
42+
type: 'number',
43+
options: { min: 0, step: 1 },
44+
},
45+
},
46+
delayShow: {
47+
control: {
48+
type: 'number',
49+
options: { min: 0, step: 1 },
50+
},
51+
},
52+
followCursor: {
53+
control: {
54+
type: 'boolean',
55+
},
56+
},
57+
interactive: {
58+
control: {
59+
type: 'boolean',
4460
},
4561
},
4662
placement: {
@@ -49,9 +65,10 @@ Example.argTypes = {
4965
options: ['top', 'right', 'bottom', 'left'],
5066
},
5167
},
52-
followCursor: {
68+
trigger: {
5369
control: {
54-
type: 'boolean',
70+
type: 'select',
71+
options: ['hover', 'click', 'right-click', 'focus', null],
5572
},
5673
},
5774
};

tests/usePopperTooltip.spec.tsx

+12-3
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ describe('trigger option', () => {
4747
expect(await screen.findByText(TooltipText)).toBeInTheDocument();
4848

4949
// tooltip hidden on hover out
50-
userEvent.unhover(screen.getByText(TriggerText));
50+
userEvent.unhover(screen.getByText(TriggerText), {
51+
clientX: 1,
52+
clientY: 1,
53+
});
5154
await waitFor(() => {
5255
expect(screen.queryByText(TooltipText)).not.toBeInTheDocument();
5356
});
@@ -197,7 +200,10 @@ test('delayHide option removes tooltip after specified delay', async () => {
197200
});
198201
expect(await screen.findByText(TooltipText)).toBeInTheDocument();
199202

200-
userEvent.unhover(screen.getByText(TriggerText));
203+
userEvent.unhover(screen.getByText(TriggerText), {
204+
clientX: 1,
205+
clientY: 1,
206+
});
201207
// Still present after 2000ms
202208
act(() => {
203209
jest.advanceTimersByTime(2000);
@@ -235,7 +241,10 @@ test('onVisibleChange option called when state changes', async () => {
235241
expect(onVisibleChange).toHaveBeenLastCalledWith(true);
236242

237243
// Now visible, change visible to false when unhover
238-
userEvent.unhover(screen.getByText(TriggerText));
244+
userEvent.unhover(screen.getByText(TriggerText), {
245+
clientX: 1,
246+
clientY: 1,
247+
});
239248
await waitFor(() => {
240249
expect(screen.queryByText(TooltipText)).not.toBeInTheDocument();
241250
});

0 commit comments

Comments
 (0)