Skip to content

Commit

Permalink
improve hover outside handler logic (#115)
Browse files Browse the repository at this point in the history
Co-authored-by: Vaclav Grohling <grohling.v@clav.cz>
Co-authored-by: mohsinulhaq <mohsinulhaq01@gmail.com>
  • Loading branch information
3 people committed Feb 16, 2021
1 parent 3dfa356 commit babbffc
Show file tree
Hide file tree
Showing 9 changed files with 233 additions and 47 deletions.
8 changes: 4 additions & 4 deletions .size-snapshot.json
@@ -1,15 +1,15 @@
{
"react-popper-tooltip.js": {
"bundled": 10503,
"minified": 5012,
"gzipped": 1723,
"bundled": 12843,
"minified": 5628,
"gzipped": 1966,
"treeshaked": {
"rollup": {
"code": 142,
"import_statements": 142
},
"webpack": {
"code": 1355
"code": 1369
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion README.md
Expand Up @@ -164,7 +164,7 @@ Options to [MutationObserver
visible and its content changes, it automatically repositions itself. In some cases
you may need to change which parameters to observe or opt-out of tracking the changes at all.

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

This is a shorthand for `popperOptions.modifiers` offset modifier option. The default value means the tooltip will be
placed 7px away from the trigger element (to reserve enough space for the arrow element).
Expand Down
4 changes: 2 additions & 2 deletions package.json
@@ -1,6 +1,6 @@
{
"name": "react-popper-tooltip",
"version": "4.0.1",
"version": "4.1.0",
"description": "React tooltip library built around react-popper",
"author": "Mohsin Ul Haq <mohsinulhaq01@gmail.com>",
"license": "MIT",
Expand Down Expand Up @@ -47,7 +47,7 @@
},
"husky": {
"hooks": {
"pre-commit": "yarn typecheck && yarn build && yarn test && lint-staged"
"pre-commit": "yarn typecheck && yarn build && yarn test && lint-staged && git add .size-snapshot.json"
}
},
"lint-staged": {
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Expand Up @@ -65,7 +65,7 @@ export type Config = {
placement?: PopperJS.Placement;
/**
* Shorthand for popper.js offset modifier, see https://popper.js.org/docs/v2/modifiers/offset/
* @default [0, 7]
* @default [0, 6]
*/
offset?: [number, number];
};
Expand Down
86 changes: 55 additions & 31 deletions src/usePopperTooltip.ts
Expand Up @@ -4,9 +4,12 @@ import {
useControlledState,
useGetLatest,
generateBoundingClientRect,
isMouseOutside,
} from './utils';
import { Config, PopperOptions, PropsGetterArgs, TriggerType } from './types';

const { isArray } = Array;

const virtualElement = {
getBoundingClientRect: generateBoundingClientRect(),
};
Expand All @@ -24,7 +27,7 @@ const defaultConfig: Config = {
childList: true,
subtree: true,
},
offset: [0, 7],
offset: [0, 6],
trigger: 'hover',
};

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

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

const finalPopperOptions = {
Expand Down Expand Up @@ -83,11 +87,12 @@ export function usePopperTooltip(

const isTriggeredBy = React.useCallback(
(trigger: TriggerType) => {
return Array.isArray(finalConfig.trigger)
return isArray(finalConfig.trigger)
? finalConfig.trigger.includes(trigger)
: finalConfig.trigger === trigger;
},
[finalConfig.trigger]
// eslint-disable-next-line react-hooks/exhaustive-deps
isArray(finalConfig.trigger) ? finalConfig.trigger : [finalConfig.trigger]
);

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

triggerRef.addEventListener('mouseenter', showTooltip);
triggerRef.addEventListener('mouseleave', hideTooltip);
return () => {
triggerRef.removeEventListener('mouseenter', showTooltip);
triggerRef.removeEventListener('mouseleave', hideTooltip);
};
}, [triggerRef, isTriggeredBy, showTooltip, hideTooltip]);
// Listen for mouse exiting the hover area &&
// handle the followCursor
React.useEffect(() => {
if (
!visible ||
triggerRef == null ||
(!isTriggeredBy('hover') && !finalConfig.followCursor)
) {
return;
}

let lastMouseOutside = false;
const handleMouseMove = (event: MouseEvent) => {
const mouseOutside = isMouseOutside(
event,
triggerRef,
!finalConfig.followCursor &&
getLatest().finalConfig.interactive &&
tooltipRef
);
if (mouseOutside && lastMouseOutside !== mouseOutside) {
hideTooltip();
}
if (!mouseOutside && finalConfig.followCursor) {
virtualElement.getBoundingClientRect = generateBoundingClientRect(
event.clientX,
event.clientY
);
update?.();
}
lastMouseOutside = mouseOutside;
};
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, [
finalConfig.followCursor,
getLatest,
hideTooltip,
isTriggeredBy,
tooltipRef,
triggerRef,
update,
visible,
]);

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

// Handle follow cursor
React.useEffect(() => {
if (!finalConfig.followCursor || triggerRef == null) return;

function setMousePosition({
clientX,
clientY,
}: {
clientX: number;
clientY: number;
}) {
virtualElement.getBoundingClientRect = generateBoundingClientRect(
clientX,
clientY
);
update?.();
}

triggerRef.addEventListener('mousemove', setMousePosition);
return () => triggerRef.removeEventListener('mousemove', setMousePosition);
}, [finalConfig.followCursor, triggerRef, update]);

// Handle tooltip DOM mutation changes (aka mutation observer)
React.useEffect(() => {
if (
Expand All @@ -249,9 +276,6 @@ export function usePopperTooltip(
style: {
...args.style,
...styles.popper,
...(finalConfig.followCursor && {
pointerEvents: 'none' as React.CSSProperties['pointerEvents'],
}),
},
...attributes.popper,
};
Expand Down
42 changes: 42 additions & 0 deletions src/utils.ts
Expand Up @@ -60,3 +60,45 @@ export function generateBoundingClientRect(x = 0, y = 0) {
left: x,
});
}

// pageX cannot be supplied in the tests, so we fallback to clientX
// @see https://github.com/testing-library/dom-testing-library/issues/144
const mouseOutsideRect = (
{ clientX, clientY, pageX, pageY }: MouseEvent,
{ bottom, left, right, top }: DOMRect
) =>
// DOMRect contains fractional pixel values but MouseEvent reports integers,
// so we round DOMRect boundaries to make DOMRect slightly bigger
(pageX || clientX) < Math.floor(left) ||
(pageX || clientX) > Math.ceil(right) ||
(pageY || clientY) < Math.floor(top) ||
(pageY || clientY) > Math.ceil(bottom);

/**
* Checks if mouseevent is triggered outside triggerRef and tooltipRef.
* Counts with potential offset between them.
* @param {MouseEvent} mouseEvent
* @param {HTMLElement} triggerRef
* @param {HTMLElement} tooltipRef - provide only when prop `interactive` is on
*/
export function isMouseOutside(
mouseEvent: MouseEvent,
triggerRef: HTMLElement,
tooltipRef?: HTMLElement | false | null
): boolean {
const triggerRect = triggerRef.getBoundingClientRect();
if (!tooltipRef) return mouseOutsideRect(mouseEvent, triggerRect);
const tooltipRect = tooltipRef.getBoundingClientRect();
// triggerRect extended to the tooltipRect boundary, thus will contain cursor
// moving from triggerRect to tooltipRect over some non zero offset.
const triggerRectExtendedToTooltip = {
bottom: Math.max(triggerRect.bottom, tooltipRect.top),
left: Math.min(triggerRect.left, tooltipRect.right),
right: Math.max(triggerRect.right, tooltipRect.left),
top: Math.min(triggerRect.top, tooltipRect.bottom),
};
return (
mouseOutsideRect(mouseEvent, triggerRectExtendedToTooltip as DOMRect) &&
mouseOutsideRect(mouseEvent, tooltipRect)
);
}
27 changes: 22 additions & 5 deletions stories/basic.stories.tsx
Expand Up @@ -37,10 +37,26 @@ export const Example: Story<Config> = (props) => {
};

Example.argTypes = {
trigger: {
delayHide: {
control: {
type: 'select',
options: ['hover', 'click', 'right-click', 'focus', null],
type: 'number',
options: { min: 0, step: 1 },
},
},
delayShow: {
control: {
type: 'number',
options: { min: 0, step: 1 },
},
},
followCursor: {
control: {
type: 'boolean',
},
},
interactive: {
control: {
type: 'boolean',
},
},
placement: {
Expand All @@ -49,9 +65,10 @@ Example.argTypes = {
options: ['top', 'right', 'bottom', 'left'],
},
},
followCursor: {
trigger: {
control: {
type: 'boolean',
type: 'select',
options: ['hover', 'click', 'right-click', 'focus', null],
},
},
};
Expand Down
15 changes: 12 additions & 3 deletions tests/usePopperTooltip.spec.tsx
Expand Up @@ -47,7 +47,10 @@ describe('trigger option', () => {
expect(await screen.findByText(TooltipText)).toBeInTheDocument();

// tooltip hidden on hover out
userEvent.unhover(screen.getByText(TriggerText));
userEvent.unhover(screen.getByText(TriggerText), {
clientX: 1,
clientY: 1,
});
await waitFor(() => {
expect(screen.queryByText(TooltipText)).not.toBeInTheDocument();
});
Expand Down Expand Up @@ -197,7 +200,10 @@ test('delayHide option removes tooltip after specified delay', async () => {
});
expect(await screen.findByText(TooltipText)).toBeInTheDocument();

userEvent.unhover(screen.getByText(TriggerText));
userEvent.unhover(screen.getByText(TriggerText), {
clientX: 1,
clientY: 1,
});
// Still present after 2000ms
act(() => {
jest.advanceTimersByTime(2000);
Expand Down Expand Up @@ -235,7 +241,10 @@ test('onVisibleChange option called when state changes', async () => {
expect(onVisibleChange).toHaveBeenLastCalledWith(true);

// Now visible, change visible to false when unhover
userEvent.unhover(screen.getByText(TriggerText));
userEvent.unhover(screen.getByText(TriggerText), {
clientX: 1,
clientY: 1,
});
await waitFor(() => {
expect(screen.queryByText(TooltipText)).not.toBeInTheDocument();
});
Expand Down

0 comments on commit babbffc

Please sign in to comment.