From efbedc26d347fca86ff58f752d0f47aca4835110 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 4 Dec 2023 14:56:08 -0700 Subject: [PATCH] Merge pull request #32450 from Expensify/revert-31518-refactor/hoverable-component Revert "Hoverable Component refactor" (cherry picked from commit a8c65db4952664cfa84d2a6dc9c64a53726e5e1a) --- src/components/Hoverable/ActiveHoverable.tsx | 137 ------------ src/components/Hoverable/index.tsx | 215 +++++++++++++++++-- src/components/Hoverable/types.ts | 12 +- src/components/Tooltip/BaseTooltip.js | 14 +- src/libs/assignRef.ts | 19 -- src/pages/home/report/ReportActionItem.js | 2 +- 6 files changed, 211 insertions(+), 188 deletions(-) delete mode 100644 src/components/Hoverable/ActiveHoverable.tsx delete mode 100644 src/libs/assignRef.ts diff --git a/src/components/Hoverable/ActiveHoverable.tsx b/src/components/Hoverable/ActiveHoverable.tsx deleted file mode 100644 index 28ae36e8a556..000000000000 --- a/src/components/Hoverable/ActiveHoverable.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import {cloneElement, forwardRef, Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import {DeviceEventEmitter} from 'react-native'; -import assignRef from '@libs/assignRef'; -import CONST from '@src/CONST'; -import HoverableProps from './types'; - -type ActiveHoverableProps = Omit; - -function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, children}: ActiveHoverableProps, outerRef: Ref) { - const [isHovered, setIsHovered] = useState(false); - - const elementRef = useRef(null); - const isScrollingRef = useRef(false); - const isHoveredRef = useRef(false); - - const updateIsHovered = useCallback( - (hovered: boolean) => { - isHoveredRef.current = hovered; - if (shouldHandleScroll && isScrollingRef.current) { - return; - } - setIsHovered(hovered); - }, - [shouldHandleScroll], - ); - - // Expose inner ref to parent through outerRef. This enable us to use ref both in parent and child. - useImperativeHandle(outerRef, () => elementRef.current, []); - - useEffect(() => { - if (isHovered) { - onHoverIn?.(); - } else { - onHoverOut?.(); - } - }, [isHovered, onHoverIn, onHoverOut]); - - useEffect(() => { - if (!shouldHandleScroll) { - return; - } - - const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => { - isScrollingRef.current = scrolling; - if (!isScrollingRef.current) { - setIsHovered(isHoveredRef.current); - } - }); - - return () => scrollingListener.remove(); - }, [shouldHandleScroll]); - - useEffect(() => { - // Do not mount a listener if the component is not hovered - if (!isHovered) { - return; - } - - /** - * Checks the hover state of a component and updates it based on the event target. - * This is necessary to handle cases where the hover state might get stuck due to an unreliable mouseleave trigger, - * such as when an element is removed before the mouseleave event is triggered. - * @param event The hover event object. - */ - const unsetHoveredIfOutside = (event: MouseEvent) => { - if (!elementRef.current || elementRef.current.contains(event.target as Node)) { - return; - } - - setIsHovered(false); - }; - - document.addEventListener('mouseover', unsetHoveredIfOutside); - - return () => document.removeEventListener('mouseover', unsetHoveredIfOutside); - }, [isHovered, elementRef]); - - useEffect(() => { - const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false); - - document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); - - return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); - }, []); - - const child = useMemo(() => (typeof children === 'function' ? children(!isScrollingRef.current && isHovered) : children), [children, isHovered]); - - const childOnMouseEnter = child.props.onMouseEnter; - const childOnMouseLeave = child.props.onMouseLeave; - - const hoverAndForwardOnMouseEnter = useCallback( - (e: MouseEvent) => { - updateIsHovered(true); - childOnMouseEnter?.(e); - }, - [updateIsHovered, childOnMouseEnter], - ); - - const unhoverAndForwardOnMouseLeave = useCallback( - (e: MouseEvent) => { - updateIsHovered(false); - childOnMouseLeave?.(e); - }, - [updateIsHovered, childOnMouseLeave], - ); - - const unhoverAndForwardOnBlur = useCallback( - (event: MouseEvent) => { - // Check if the blur event occurred due to clicking outside the element - // and the wrapperView contains the element that caused the blur and reset isHovered - if (!elementRef.current?.contains(event.target as Node) && !elementRef.current?.contains(event.relatedTarget as Node)) { - setIsHovered(false); - } - - child.props.onBlur?.(event); - }, - [child.props], - ); - - // We need to access the ref of a children from both parent and current component - // So we pass it to current ref and assign it once again to the child ref prop - const hijackRef = (el: HTMLElement) => { - elementRef.current = el; - if (child.ref) { - assignRef(child.ref, el); - } - }; - - return cloneElement(child, { - ref: hijackRef, - onMouseEnter: hoverAndForwardOnMouseEnter, - onMouseLeave: unhoverAndForwardOnMouseLeave, - onBlur: unhoverAndForwardOnBlur, - }); -} - -export default forwardRef(ActiveHoverable); diff --git a/src/components/Hoverable/index.tsx b/src/components/Hoverable/index.tsx index 1dee5a943e35..9c641cfc19be 100644 --- a/src/components/Hoverable/index.tsx +++ b/src/components/Hoverable/index.tsx @@ -1,27 +1,212 @@ -import React, {cloneElement, forwardRef, Ref} from 'react'; -import {hasHoverSupport} from '@libs/DeviceCapabilities'; -import ActiveHoverable from './ActiveHoverable'; +import React, {ForwardedRef, forwardRef, MutableRefObject, ReactElement, RefAttributes, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import {DeviceEventEmitter} from 'react-native'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import CONST from '@src/CONST'; import HoverableProps from './types'; +/** + * Maps the children of a Hoverable component to + * - a function that is called with the parameter + * - the child itself if it is the only child + * @param children The children to map. + * @param callbackParam The parameter to pass to the children function. + * @returns The mapped children. + */ +function mapChildren(children: ((isHovered: boolean) => ReactElement) | ReactElement | ReactElement[], callbackParam: boolean): ReactElement & RefAttributes { + if (Array.isArray(children)) { + return children[0]; + } + + if (typeof children === 'function') { + return children(callbackParam); + } + + return children; +} + +/** + * Assigns a ref to an element, either by setting the current property of the ref object or by calling the ref function + * @param ref The ref object or function. + * @param element The element to assign the ref to. + */ +function assignRef(ref: ((instance: HTMLElement | null) => void) | MutableRefObject, element: HTMLElement) { + if (!ref) { + return; + } + if (typeof ref === 'function') { + ref(element); + } else if ('current' in ref) { + // eslint-disable-next-line no-param-reassign + ref.current = element; + } +} + /** * It is necessary to create a Hoverable component instead of relying solely on Pressable support for hover state, * because nesting Pressables causes issues where the hovered state of the child cannot be easily propagated to the * parent. https://github.com/necolas/react-native-web/issues/1875 */ -function Hoverable({isDisabled, ...props}: HoverableProps, ref: Ref) { - // If Hoverable is disabled, just render the child without additional logic or event listeners. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (isDisabled || !hasHoverSupport()) { - return cloneElement(typeof props.children === 'function' ? props.children(false) : props.children, {ref}); - } +function Hoverable( + {disabled = false, onHoverIn = () => {}, onHoverOut = () => {}, onMouseEnter = () => {}, onMouseLeave = () => {}, children, shouldHandleScroll = false}: HoverableProps, + outerRef: ForwardedRef, +) { + const [isHovered, setIsHovered] = useState(false); + + const isScrolling = useRef(false); + const isHoveredRef = useRef(false); + const ref = useRef(null); + + const updateIsHoveredOnScrolling = useCallback( + (hovered: boolean) => { + if (disabled) { + return; + } + + isHoveredRef.current = hovered; - return ( - + if (shouldHandleScroll && isScrolling.current) { + return; + } + setIsHovered(hovered); + }, + [disabled, shouldHandleScroll], ); + + useEffect(() => { + const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false); + + document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); + + return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); + }, []); + + useEffect(() => { + if (!shouldHandleScroll) { + return; + } + + const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => { + isScrolling.current = scrolling; + if (!scrolling) { + setIsHovered(isHoveredRef.current); + } + }); + + return () => scrollingListener.remove(); + }, [shouldHandleScroll]); + + useEffect(() => { + if (!DeviceCapabilities.hasHoverSupport()) { + return; + } + + /** + * Checks the hover state of a component and updates it based on the event target. + * This is necessary to handle cases where the hover state might get stuck due to an unreliable mouseleave trigger, + * such as when an element is removed before the mouseleave event is triggered. + * @param event The hover event object. + */ + const unsetHoveredIfOutside = (event: MouseEvent) => { + if (!ref.current || !isHovered) { + return; + } + + if (ref.current.contains(event.target as Node)) { + return; + } + + setIsHovered(false); + }; + + document.addEventListener('mouseover', unsetHoveredIfOutside); + + return () => document.removeEventListener('mouseover', unsetHoveredIfOutside); + }, [isHovered]); + + useEffect(() => { + if (!disabled || !isHovered) { + return; + } + setIsHovered(false); + }, [disabled, isHovered]); + + useEffect(() => { + if (disabled) { + return; + } + if (onHoverIn && isHovered) { + return onHoverIn(); + } + if (onHoverOut && !isHovered) { + return onHoverOut(); + } + }, [disabled, isHovered, onHoverIn, onHoverOut]); + + // Expose inner ref to parent through outerRef. This enable us to use ref both in parent and child. + useImperativeHandle(outerRef, () => ref.current, []); + + const child = useMemo(() => React.Children.only(mapChildren(children, isHovered)), [children, isHovered]); + + const enableHoveredOnMouseEnter = useCallback( + (event: MouseEvent) => { + updateIsHoveredOnScrolling(true); + onMouseEnter(event); + + if (typeof child.props.onMouseEnter === 'function') { + child.props.onMouseEnter(event); + } + }, + [child.props, onMouseEnter, updateIsHoveredOnScrolling], + ); + + const disableHoveredOnMouseLeave = useCallback( + (event: MouseEvent) => { + updateIsHoveredOnScrolling(false); + onMouseLeave(event); + + if (typeof child.props.onMouseLeave === 'function') { + child.props.onMouseLeave(event); + } + }, + [child.props, onMouseLeave, updateIsHoveredOnScrolling], + ); + + const disableHoveredOnBlur = useCallback( + (event: MouseEvent) => { + // Check if the blur event occurred due to clicking outside the element + // and the wrapperView contains the element that caused the blur and reset isHovered + if (!ref.current?.contains(event.target as Node) && !ref.current?.contains(event.relatedTarget as Node)) { + setIsHovered(false); + } + + if (typeof child.props.onBlur === 'function') { + child.props.onBlur(event); + } + }, + [child.props], + ); + + // We need to access the ref of a children from both parent and current component + // So we pass it to current ref and assign it once again to the child ref prop + const hijackRef = (el: HTMLElement) => { + ref.current = el; + if (child.ref) { + assignRef(child.ref, el); + } + }; + + if (!DeviceCapabilities.hasHoverSupport()) { + return React.cloneElement(child, { + ref: hijackRef, + }); + } + + return React.cloneElement(child, { + ref: hijackRef, + onMouseEnter: enableHoveredOnMouseEnter, + onMouseLeave: disableHoveredOnMouseLeave, + onBlur: disableHoveredOnBlur, + }); } export default forwardRef(Hoverable); diff --git a/src/components/Hoverable/types.ts b/src/components/Hoverable/types.ts index bea066bdb3bb..430b865f50c5 100644 --- a/src/components/Hoverable/types.ts +++ b/src/components/Hoverable/types.ts @@ -1,11 +1,11 @@ -import {ReactElement, RefAttributes} from 'react'; +import {ReactElement} from 'react'; type HoverableProps = { /** Children to wrap with Hoverable. */ - children: ((isHovered: boolean) => ReactElement & RefAttributes) | (ReactElement & RefAttributes); + children: ((isHovered: boolean) => ReactElement) | ReactElement; /** Whether to disable the hover action */ - isDisabled?: boolean; + disabled?: boolean; /** Function that executes when the mouse moves over the children. */ onHoverIn?: () => void; @@ -13,6 +13,12 @@ type HoverableProps = { /** Function that executes when the mouse leaves the children. */ onHoverOut?: () => void; + /** Direct pass-through of React's onMouseEnter event. */ + onMouseEnter?: (event: MouseEvent) => void; + + /** Direct pass-through of React's onMouseLeave event. */ + onMouseLeave?: (event: MouseEvent) => void; + /** Decides whether to handle the scroll behaviour to show hover once the scroll ends */ shouldHandleScroll?: boolean; }; diff --git a/src/components/Tooltip/BaseTooltip.js b/src/components/Tooltip/BaseTooltip.js index 1aa5fa81e0a4..3eb905e7a3e5 100644 --- a/src/components/Tooltip/BaseTooltip.js +++ b/src/components/Tooltip/BaseTooltip.js @@ -167,16 +167,6 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent, setIsVisible(false); }, []); - const updateTargetPositionOnMouseEnter = useCallback( - (e) => { - updateTargetAndMousePosition(e); - if (children.props.onMouseEnter) { - children.props.onMouseEnter(e); - } - }, - [children.props, updateTargetAndMousePosition], - ); - // Skip the tooltip and return the children if the text is empty, // we don't have a render function or the device does not support hovering if ((_.isEmpty(text) && renderTooltipContent == null) || !hasHoverSupport) { @@ -215,9 +205,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent, onHoverOut={hideTooltip} shouldHandleScroll={shouldHandleScroll} > - {React.cloneElement(children, { - onMouseEnter: updateTargetPositionOnMouseEnter, - })} + {children} diff --git a/src/libs/assignRef.ts b/src/libs/assignRef.ts deleted file mode 100644 index f2c2b488519f..000000000000 --- a/src/libs/assignRef.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {MutableRefObject, RefCallback} from 'react'; - -/** - * Assigns an element to ref, either by setting the `current` property of the ref object or by calling the ref function - * - * @param ref The ref object or function. - * @param element The element to assign the ref to. - */ -export default function assignRef(ref: RefCallback | MutableRefObject | undefined, element: E) { - if (!ref) { - return; - } - if (typeof ref === 'function') { - ref(element); - } else if ('current' in ref) { - // eslint-disable-next-line no-param-reassign - ref.current = element; - } -} diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 2b073d7fee34..92bb370155c9 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -678,7 +678,7 @@ function ReportActionItem(props) { > {(hovered) => (