From babbffc8f9175a8bd85c323f75f0a264638676a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rudolf=20Gr=C3=B6hling?= <47554112+czabaj@users.noreply.github.com> Date: Tue, 16 Feb 2021 13:05:53 +0100 Subject: [PATCH] improve hover outside handler logic (#115) Co-authored-by: Vaclav Grohling Co-authored-by: mohsinulhaq --- .size-snapshot.json | 8 +-- README.md | 2 +- package.json | 4 +- src/types.ts | 2 +- src/usePopperTooltip.ts | 86 +++++++++++++++++++----------- src/utils.ts | 42 +++++++++++++++ stories/basic.stories.tsx | 27 ++++++++-- tests/usePopperTooltip.spec.tsx | 15 ++++-- tests/utils.spec.ts | 94 +++++++++++++++++++++++++++++++++ 9 files changed, 233 insertions(+), 47 deletions(-) create mode 100644 tests/utils.spec.ts diff --git a/.size-snapshot.json b/.size-snapshot.json index 9ba9a6a..fcc7668 100644 --- a/.size-snapshot.json +++ b/.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 } } } diff --git a/README.md b/README.md index 93e67fd..fc05111 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/package.json b/package.json index 7cf63c7..13d2218 100644 --- a/package.json +++ b/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 ", "license": "MIT", @@ -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": { diff --git a/src/types.ts b/src/types.ts index 92d4ead..16e15f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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]; }; diff --git a/src/usePopperTooltip.ts b/src/usePopperTooltip.ts index 490ab6d..a949b84 100644 --- a/src/usePopperTooltip.ts +++ b/src/usePopperTooltip.ts @@ -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(), }; @@ -24,7 +27,7 @@ const defaultConfig: Config = { childList: true, subtree: true, }, - offset: [0, 7], + offset: [0, 6], trigger: 'hover', }; @@ -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 = { @@ -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(() => { @@ -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(() => { @@ -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 ( @@ -249,9 +276,6 @@ export function usePopperTooltip( style: { ...args.style, ...styles.popper, - ...(finalConfig.followCursor && { - pointerEvents: 'none' as React.CSSProperties['pointerEvents'], - }), }, ...attributes.popper, }; diff --git a/src/utils.ts b/src/utils.ts index ac1611d..c492992 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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) + ); +} diff --git a/stories/basic.stories.tsx b/stories/basic.stories.tsx index 1661455..145ad4e 100644 --- a/stories/basic.stories.tsx +++ b/stories/basic.stories.tsx @@ -37,10 +37,26 @@ export const Example: Story = (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: { @@ -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], }, }, }; diff --git a/tests/usePopperTooltip.spec.tsx b/tests/usePopperTooltip.spec.tsx index 9ecdf76..abd0885 100644 --- a/tests/usePopperTooltip.spec.tsx +++ b/tests/usePopperTooltip.spec.tsx @@ -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(); }); @@ -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); @@ -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(); }); diff --git a/tests/utils.spec.ts b/tests/utils.spec.ts new file mode 100644 index 0000000..375359f --- /dev/null +++ b/tests/utils.spec.ts @@ -0,0 +1,94 @@ +import { isMouseOutside } from '../src/utils'; + +describe('isMouseOutside', () => { + const mouseEvent = (x: number, y: number) => + (({ clientX: x, clientY: y, pageX: x, pageY: y } as unknown) as MouseEvent); + const element = (x: number, y: number, width: number, height: number) => + (({ + getBoundingClientRect: () => ({ + bottom: y + height, + height, + left: x, + right: x + width, + top: y, + width, + x, + y, + }), + } as unknown) as HTMLElement); + it('should detect mouse inside Trigger', () => { + const event = mouseEvent(20, 20); + const trigger = element(0, 0, 40, 40); + expect(isMouseOutside(event, trigger)).toBe(false); + }); + it('should detect mouse outside Trigger', () => { + const trigger = element(0, 0, 40, 40); + expect(isMouseOutside(mouseEvent(60, 20), trigger)).toBe(true); + expect(isMouseOutside(mouseEvent(20, 60), trigger)).toBe(true); + }); + it('should detect mouse at the edge as _inside_ the Trigger', () => { + const trigger = element(0, 0, 40, 40); + expect(isMouseOutside(mouseEvent(40, 20), trigger)).toBe(false); + expect(isMouseOutside(mouseEvent(20, 40), trigger)).toBe(false); + expect(isMouseOutside(mouseEvent(40, 40), trigger)).toBe(false); + expect(isMouseOutside(mouseEvent(0, 0), trigger)).toBe(false); + }); + it('should round the size of the Trigger up (expand) for mouse event does not support fractional pixels', () => { + const trigger = element(0.1, 0.1, 39.1, 39.1); + expect(isMouseOutside(mouseEvent(40, 20), trigger)).toBe(false); + expect(isMouseOutside(mouseEvent(0, 20), trigger)).toBe(false); + expect(isMouseOutside(mouseEvent(20, 40), trigger)).toBe(false); + expect(isMouseOutside(mouseEvent(20, 0), trigger)).toBe(false); + expect(isMouseOutside(mouseEvent(0, 0), trigger)).toBe(false); + }); + it('should detect mouse inside the tooltip, when provided', () => { + const trigger = element(100, 100, 40, 40); + const tooltipAbove = element(100, 50, 40, 40); + // inside the Tooltip + expect(isMouseOutside(mouseEvent(120, 80), trigger, tooltipAbove)).toBe( + false + ); + // insite the Trigger + expect(isMouseOutside(mouseEvent(120, 120), trigger, tooltipAbove)).toBe( + false + ); + // at the edge of the Tooltip + expect(isMouseOutside(mouseEvent(100, 50), trigger, tooltipAbove)).toBe( + false + ); + // at the edge of the Trigger + expect(isMouseOutside(mouseEvent(100, 100), trigger, tooltipAbove)).toBe( + false + ); + }); + it('should detect mouse outside the tooltip, when provided', () => { + const trigger = element(100, 100, 40, 40); + const tooltipAbove = element(100, 50, 40, 40); + expect(isMouseOutside(mouseEvent(0, 0), trigger, tooltipAbove)).toBe(true); + expect(isMouseOutside(mouseEvent(99, 70), trigger, tooltipAbove)).toBe( + true + ); + expect(isMouseOutside(mouseEvent(120, 49), trigger, tooltipAbove)).toBe( + true + ); + }); + it('should detect mouse in the gap between Tooltip and the Trigger', () => { + const trigger = element(100, 100, 40, 40); + const tooltipAbove = element(100, 50, 40, 40); + expect(isMouseOutside(mouseEvent(120, 95), trigger, tooltipAbove)).toBe( + false + ); + const tooltipRight = element(150, 110, 40, 20); + expect(isMouseOutside(mouseEvent(145, 120), trigger, tooltipRight)).toBe( + false + ); + const tooltipBottom = element(100, 150, 40, 20); + expect(isMouseOutside(mouseEvent(120, 145), trigger, tooltipBottom)).toBe( + false + ); + const tooltipLeft = element(50, 110, 40, 20); + expect(isMouseOutside(mouseEvent(95, 120), trigger, tooltipLeft)).toBe( + false + ); + }); +});