diff --git a/src/components/Hoverable/ActiveHoverable.tsx b/src/components/Hoverable/ActiveHoverable.tsx new file mode 100644 index 000000000000..28ae36e8a556 --- /dev/null +++ b/src/components/Hoverable/ActiveHoverable.tsx @@ -0,0 +1,137 @@ +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 9c641cfc19be..1dee5a943e35 100644 --- a/src/components/Hoverable/index.tsx +++ b/src/components/Hoverable/index.tsx @@ -1,212 +1,27 @@ -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 React, {cloneElement, forwardRef, Ref} from 'react'; +import {hasHoverSupport} from '@libs/DeviceCapabilities'; +import ActiveHoverable from './ActiveHoverable'; 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( - {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; - - 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, - }); +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}); } - return React.cloneElement(child, { - ref: hijackRef, - onMouseEnter: enableHoveredOnMouseEnter, - onMouseLeave: disableHoveredOnMouseLeave, - onBlur: disableHoveredOnBlur, - }); + return ( + + ); } export default forwardRef(Hoverable); diff --git a/src/components/Hoverable/types.ts b/src/components/Hoverable/types.ts index 430b865f50c5..bea066bdb3bb 100644 --- a/src/components/Hoverable/types.ts +++ b/src/components/Hoverable/types.ts @@ -1,11 +1,11 @@ -import {ReactElement} from 'react'; +import {ReactElement, RefAttributes} from 'react'; type HoverableProps = { /** Children to wrap with Hoverable. */ - children: ((isHovered: boolean) => ReactElement) | ReactElement; + children: ((isHovered: boolean) => ReactElement & RefAttributes) | (ReactElement & RefAttributes); /** Whether to disable the hover action */ - disabled?: boolean; + isDisabled?: boolean; /** Function that executes when the mouse moves over the children. */ onHoverIn?: () => void; @@ -13,12 +13,6 @@ 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 3eb905e7a3e5..1aa5fa81e0a4 100644 --- a/src/components/Tooltip/BaseTooltip.js +++ b/src/components/Tooltip/BaseTooltip.js @@ -167,6 +167,16 @@ 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) { @@ -205,7 +215,9 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent, onHoverOut={hideTooltip} shouldHandleScroll={shouldHandleScroll} > - {children} + {React.cloneElement(children, { + onMouseEnter: updateTargetPositionOnMouseEnter, + })} diff --git a/src/libs/assignRef.ts b/src/libs/assignRef.ts new file mode 100644 index 000000000000..f2c2b488519f --- /dev/null +++ b/src/libs/assignRef.ts @@ -0,0 +1,19 @@ +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 17d49bd0f486..88daec1ab6c7 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -671,7 +671,7 @@ function ReportActionItem(props) { > {(hovered) => (