From b342cde96d81aeaaab38a53f3135b791133d8bf8 Mon Sep 17 00:00:00 2001 From: Denis Borovikov Date: Sun, 14 Mar 2021 13:52:33 -0600 Subject: [PATCH] Revert isMouseOutside changes in favor of mouseleave event listener (#127) --- .size-snapshot.json | 8 +-- README.md | 4 +- src/styles.css | 5 ++ src/usePopperTooltip.ts | 94 +++++++++++---------------------- src/utils.ts | 48 ----------------- tests/usePopperTooltip.spec.tsx | 15 ++---- tests/utils.spec.ts | 94 --------------------------------- 7 files changed, 47 insertions(+), 221 deletions(-) delete mode 100644 tests/utils.spec.ts diff --git a/.size-snapshot.json b/.size-snapshot.json index 8985fac..8e0c4c3 100644 --- a/.size-snapshot.json +++ b/.size-snapshot.json @@ -1,15 +1,15 @@ { "react-popper-tooltip.js": { - "bundled": 13338, - "minified": 5751, - "gzipped": 1990, + "bundled": 10744, + "minified": 5095, + "gzipped": 1733, "treeshaked": { "rollup": { "code": 142, "import_statements": 142 }, "webpack": { - "code": 1369 + "code": 1355 } } } diff --git a/README.md b/README.md index fc05111..b8f17df 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Import `react-popper-tooltip/dist/styles.css` to import it into your project. Ad While the tooltip is being displayed, you have access to some attributes on the tooltip container. You can use them in your CSS in specific scenarios. -- `data-popper-placement`: contains the current tooltip placement. You can use it to properly offset and display the +- `data-popper-placement`: contains the current tooltip `placement`. You can use it to properly offset and display the arrow element (e.g., if the tooltip is displayed on the right, the arrow should point to the left and vice versa). - `data-popper-reference-hidden`: set to true when the trigger element is fully clipped and hidden from view, which @@ -95,6 +95,8 @@ in your CSS in specific scenarios. - `data-popper-escaped`: set to true when the tooltip escapes the trigger element's boundary (and so it appears detached). Set to false otherwise. + +- `data-popper-interactive`: contains the current `interactive` option value. ## API reference diff --git a/src/styles.css b/src/styles.css index be076e0..e5cbcab 100644 --- a/src/styles.css +++ b/src/styles.css @@ -14,10 +14,15 @@ z-index: 9999; } +.tooltip-container[data-popper-interactive='false'] { + pointer-events: none; +} + .tooltip-arrow { height: 1rem; position: absolute; width: 1rem; + pointer-events: none; } .tooltip-arrow::before { diff --git a/src/usePopperTooltip.ts b/src/usePopperTooltip.ts index 3298bb6..96aad66 100644 --- a/src/usePopperTooltip.ts +++ b/src/usePopperTooltip.ts @@ -4,12 +4,9 @@ import { useControlledState, useGetLatest, generateBoundingClientRect, - isMouseOutside, } from './utils'; import { Config, PopperOptions, PropsGetterArgs, TriggerType } from './types'; -const { isArray } = Array; - const virtualElement = { getBoundingClientRect: generateBoundingClientRect(), }; @@ -51,7 +48,7 @@ export function usePopperTooltip( const defaultModifiers = React.useMemo( () => [{ name: 'offset', options: { offset: finalConfig.offset } }], // eslint-disable-next-line react-hooks/exhaustive-deps - isArray(finalConfig.offset) ? finalConfig.offset : [] + Array.isArray(finalConfig.offset) ? finalConfig.offset : [] ); const finalPopperOptions = { @@ -69,7 +66,7 @@ export function usePopperTooltip( }); const timer = React.useRef(); - React.useEffect(() => () => clearTimeout(timer.current)); + React.useEffect(() => () => clearTimeout(timer.current), []); const { styles, attributes, ...popperProps } = usePopper( finalConfig.followCursor ? virtualElement : triggerRef, @@ -88,12 +85,14 @@ export function usePopperTooltip( const isTriggeredBy = React.useCallback( (trigger: TriggerType) => { - return isArray(finalConfig.trigger) + return Array.isArray(finalConfig.trigger) ? finalConfig.trigger.includes(trigger) : finalConfig.trigger === trigger; }, // eslint-disable-next-line react-hooks/exhaustive-deps - isArray(finalConfig.trigger) ? finalConfig.trigger : [finalConfig.trigger] + Array.isArray(finalConfig.trigger) + ? finalConfig.trigger + : [finalConfig.trigger] ); const hideTooltip = React.useCallback(() => { @@ -184,64 +183,12 @@ export function usePopperTooltip( if (triggerRef == null || !isTriggeredBy('hover')) return; triggerRef.addEventListener('mouseenter', showTooltip); - let stopTimer: undefined | (() => void); - if (!visible) { - stopTimer = () => clearTimeout(timer.current); - triggerRef.addEventListener('mouseleave', stopTimer); - } + triggerRef.addEventListener('mouseleave', hideTooltip); return () => { triggerRef.removeEventListener('mouseenter', showTooltip); - if (stopTimer) { - triggerRef.removeEventListener('mouseleave', stopTimer); - } - }; - }, [isTriggeredBy, hideTooltip, showTooltip, triggerRef, visible]); - // 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; + triggerRef.removeEventListener('mouseleave', hideTooltip); }; - window.addEventListener('mousemove', handleMouseMove); - return () => { - window.removeEventListener('mousemove', handleMouseMove); - }; - }, [ - finalConfig.followCursor, - getLatest, - hideTooltip, - isTriggeredBy, - tooltipRef, - triggerRef, - update, - visible, - ]); + }, [triggerRef, isTriggeredBy, showTooltip, hideTooltip]); // Trigger: hover on tooltip, keep it open if hovered React.useEffect(() => { @@ -262,6 +209,28 @@ 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 ( @@ -285,6 +254,7 @@ export function usePopperTooltip( ...styles.popper, }, ...attributes.popper, + 'data-popper-interactive': finalConfig.interactive, }; }; diff --git a/src/utils.ts b/src/utils.ts index 74be438..ac1611d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -60,51 +60,3 @@ 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 }: 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. - // Also exceed the DOMRect by 1 pixel to fix Chromium reporting MouseEvent's - // `clientX` and `clientY` by the whole integer away from the DOMRect. - // see https://github.com/mohsinulhaq/react-popper-tooltip/issues/118#issuecomment-782698921 - return ( - clientX < Math.floor(left) - 1 || - clientX > Math.ceil(right) + 1 || - clientY < Math.floor(top) - 1 || - clientY > Math.ceil(bottom) + 1 - ); -}; - -/** - * 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/tests/usePopperTooltip.spec.tsx b/tests/usePopperTooltip.spec.tsx index 328a476..9ecdf76 100644 --- a/tests/usePopperTooltip.spec.tsx +++ b/tests/usePopperTooltip.spec.tsx @@ -47,10 +47,7 @@ describe('trigger option', () => { expect(await screen.findByText(TooltipText)).toBeInTheDocument(); // tooltip hidden on hover out - userEvent.unhover(screen.getByText(TriggerText), { - clientX: 100, - clientY: 100, - }); + userEvent.unhover(screen.getByText(TriggerText)); await waitFor(() => { expect(screen.queryByText(TooltipText)).not.toBeInTheDocument(); }); @@ -200,10 +197,7 @@ test('delayHide option removes tooltip after specified delay', async () => { }); expect(await screen.findByText(TooltipText)).toBeInTheDocument(); - userEvent.unhover(screen.getByText(TriggerText), { - clientX: 100, - clientY: 100, - }); + userEvent.unhover(screen.getByText(TriggerText)); // Still present after 2000ms act(() => { jest.advanceTimersByTime(2000); @@ -241,10 +235,7 @@ 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), { - clientX: 100, - clientY: 100, - }); + userEvent.unhover(screen.getByText(TriggerText)); await waitFor(() => { expect(screen.queryByText(TooltipText)).not.toBeInTheDocument(); }); diff --git a/tests/utils.spec.ts b/tests/utils.spec.ts deleted file mode 100644 index 4889af3..0000000 --- a/tests/utils.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -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(98, 70), trigger, tooltipAbove)).toBe( - true - ); - expect(isMouseOutside(mouseEvent(120, 48), 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 - ); - }); -});