diff --git a/src/components/Hoverable/index.js b/src/components/Hoverable/index.js index 5cba52db5a7b7..2ded0e52e94da 100644 --- a/src/components/Hoverable/index.js +++ b/src/components/Hoverable/index.js @@ -1,197 +1,216 @@ import _ from 'underscore'; -import React, {Component} from 'react'; +import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle} from 'react'; import {DeviceEventEmitter} from 'react-native'; import {propTypes, defaultProps} from './hoverablePropTypes'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; import CONST from '../../CONST'; +/** + * 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 {Array|Function|ReactNode} children - The children to map. + * @param {Object} callbackParam - The parameter to pass to the children function. + * @returns {ReactNode} The mapped children. + */ +function mapChildren(children, callbackParam) { + if (_.isArray(children) && children.length === 1) { + return children[0]; + } + + if (_.isFunction(children)) { + 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 {Object|Function} ref - The ref object or function. + * @param {HTMLElement} el - The element to assign the ref to. + */ +function assignRef(ref, el) { + if (!ref) { + return; + } + + if (_.has(ref, 'current')) { + // eslint-disable-next-line no-param-reassign + ref.current = el; + } + + if (_.isFunction(ref)) { + ref(el); + } +} + /** * 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 */ -class Hoverable extends Component { - constructor(props) { - super(props); - this.handleVisibilityChange = this.handleVisibilityChange.bind(this); - this.checkHover = this.checkHover.bind(this); +const Hoverable = React.forwardRef(({disabled, onHoverIn, onHoverOut, onMouseEnter, onMouseLeave, children, shouldHandleScroll}, outerRef) => { + const [isHovered, setIsHovered] = useState(false); - this.state = { - isHovered: false, - }; + const isScrolling = useRef(false); + const isHoveredRef = useRef(false); + const ref = useRef(null); - this.isHoveredRef = false; - this.isScrollingRef = false; - this.wrapperView = null; - } + const updateIsHoveredOnScrolling = useCallback( + (hovered) => { + if (disabled) { + return; + } - componentDidMount() { - document.addEventListener('visibilitychange', this.handleVisibilityChange); - document.addEventListener('mouseover', this.checkHover); + isHoveredRef.current = hovered; - /** - * Only add the scrolling listener if the shouldHandleScroll prop is true - * and the scrollingListener is not already set. - */ - if (!this.scrollingListener && this.props.shouldHandleScroll) { - this.scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => { - /** - * If user has stopped scrolling and the isHoveredRef is true, then we should update the hover state. - */ - if (!scrolling && this.isHoveredRef) { - this.setState({isHovered: this.isHoveredRef}, this.props.onHoverIn); - } else if (scrolling && this.isHoveredRef) { - /** - * If the user has started scrolling and the isHoveredRef is true, then we should set the hover state to false. - * This is to hide the existing hover and reaction bar. - */ - this.setState({isHovered: false}, this.props.onHoverOut); - } - this.isScrollingRef = scrolling; - }); - } - } + if (shouldHandleScroll && isScrolling.current) { + return; + } + setIsHovered(hovered); + }, + [disabled, shouldHandleScroll], + ); + + useEffect(() => { + const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false); + + document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); - componentDidUpdate(prevProps) { - if (prevProps.disabled === this.props.disabled) { + return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); + }, []); + + useEffect(() => { + if (!shouldHandleScroll) { return; } - if (this.props.disabled && this.state.isHovered) { - this.setState({isHovered: false}); - } - } + const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => { + isScrolling.current = scrolling; + if (!scrolling) { + setIsHovered(isHoveredRef.current); + } + }); - componentWillUnmount() { - document.removeEventListener('visibilitychange', this.handleVisibilityChange); - document.removeEventListener('mouseover', this.checkHover); - if (this.scrollingListener) { - this.scrollingListener.remove(); - } - } + return () => scrollingListener.remove(); + }, [shouldHandleScroll]); - /** - * Sets the hover state of this component to true and execute the onHoverIn callback. - * - * @param {Boolean} isHovered - Whether or not this component is hovered. - */ - setIsHovered(isHovered) { - if (this.props.disabled) { + useEffect(() => { + if (!DeviceCapabilities.hasHoverSupport()) { return; } /** - * Capture whther or not the user is hovering over the component. - * We will use this to determine if we should update the hover state when the user has stopped scrolling. + * 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} e - The hover event object. */ - this.isHoveredRef = isHovered; + const unsetHoveredIfOutside = (e) => { + if (!ref.current || !isHovered) { + return; + } - /** - * If the isScrollingRef is true, then the user is scrolling and we should not update the hover state. - */ - if (this.isScrollingRef && this.props.shouldHandleScroll && !this.state.isHovered) { - return; - } + if (ref.current.contains(e.target)) { + return; + } - if (isHovered !== this.state.isHovered) { - this.setState({isHovered}, isHovered ? this.props.onHoverIn : this.props.onHoverOut); - } - } + setIsHovered(false); + }; - /** - * 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} e - The hover event object. - */ - checkHover(e) { - if (!this.wrapperView || !this.state.isHovered) { - return; - } + document.addEventListener('mouseover', unsetHoveredIfOutside); - if (this.wrapperView.contains(e.target)) { - return; - } - - this.setIsHovered(false); - } + return () => document.removeEventListener('mouseover', unsetHoveredIfOutside); + }, [isHovered]); - handleVisibilityChange() { - if (document.visibilityState !== 'hidden') { + useEffect(() => { + if (!disabled || !isHovered) { return; } + setIsHovered(false); + }, [disabled, isHovered]); - this.setIsHovered(false); - } - - render() { - let child = this.props.children; - if (_.isArray(this.props.children) && this.props.children.length === 1) { - child = this.props.children[0]; + useEffect(() => { + if (disabled) { + return; } - - if (_.isFunction(child)) { - child = child(this.state.isHovered); + if (onHoverIn && isHovered) { + return onHoverIn(); } - - if (!DeviceCapabilities.hasHoverSupport()) { - return child; + if (onHoverOut && !isHovered) { + return onHoverOut(); } - - return React.cloneElement(React.Children.only(child), { - ref: (el) => { - this.wrapperView = el; - - // Call the original ref, if any - const {ref} = child; - if (_.isFunction(ref)) { - ref(el); - return; - } - - if (_.isObject(ref)) { - ref.current = el; - } - }, - onMouseEnter: (el) => { - if (_.isFunction(this.props.onMouseEnter)) { - this.props.onMouseEnter(el); - } - - this.setIsHovered(true); - - if (_.isFunction(child.props.onMouseEnter)) { - child.props.onMouseEnter(el); - } - }, - onMouseLeave: (el) => { - if (_.isFunction(this.props.onMouseLeave)) { - this.props.onMouseLeave(el); - } - - this.setIsHovered(false); - - if (_.isFunction(child.props.onMouseLeave)) { - child.props.onMouseLeave(el); - } - }, - onBlur: (el) => { - // 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 (!this.wrapperView.contains(el.target) && !this.wrapperView.contains(el.relatedTarget)) { - this.setIsHovered(false); - } - - if (_.isFunction(child.props.onBlur)) { - child.props.onBlur(el); - } - }, - }); + }, [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( + (el) => { + updateIsHoveredOnScrolling(true); + + if (_.isFunction(onMouseEnter)) { + onMouseEnter(el); + } + + if (_.isFunction(child.props.onMouseEnter)) { + child.props.onMouseEnter(el); + } + }, + [child.props, onMouseEnter, updateIsHoveredOnScrolling], + ); + + const disableHoveredOnMouseLeave = useCallback( + (el) => { + updateIsHoveredOnScrolling(false); + + if (_.isFunction(onMouseLeave)) { + onMouseLeave(el); + } + + if (_.isFunction(child.props.onMouseLeave)) { + child.props.onMouseLeave(el); + } + }, + [child.props, onMouseLeave, updateIsHoveredOnScrolling], + ); + + const disableHoveredOnBlur = useCallback( + (el) => { + // 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(el.target) && !ref.current.contains(el.relatedTarget)) { + setIsHovered(false); + } + + if (_.isFunction(child.props.onBlur)) { + child.props.onBlur(el); + } + }, + [child.props], + ); + + if (!DeviceCapabilities.hasHoverSupport()) { + return child; } -} + + return React.cloneElement(child, { + ref: (el) => { + ref.current = el; + assignRef(child.ref, el); + }, + onMouseEnter: enableHoveredOnMouseEnter, + onMouseLeave: disableHoveredOnMouseLeave, + onBlur: disableHoveredOnBlur, + }); +}); Hoverable.propTypes = propTypes; Hoverable.defaultProps = defaultProps; +Hoverable.displayName = 'Hoverable'; export default Hoverable; diff --git a/src/components/Tooltip/BaseTooltip.js b/src/components/Tooltip/BaseTooltip.js index f8d1cf87fc42c..c46988af7dc49 100644 --- a/src/components/Tooltip/BaseTooltip.js +++ b/src/components/Tooltip/BaseTooltip.js @@ -88,10 +88,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent, * Display the tooltip in an animation. */ const showTooltip = useCallback(() => { - if (!isRendered) { - setIsRendered(true); - } - + setIsRendered(true); setIsVisible(true); animation.current.stopAnimation(); @@ -111,7 +108,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent, }); } TooltipSense.activate(); - }, [isRendered]); + }, []); // eslint-disable-next-line rulesdir/prefer-early-return useEffect(() => { @@ -132,11 +129,17 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent, if (bounds.width === 0) { setIsRendered(false); } + if (!target.current) { + return; + } // Choose a bounding box for the tooltip to target. // In the case when the target is a link that has wrapped onto // multiple lines, we want to show the tooltip over the part // of the link that the user is hovering over. const betterBounds = chooseBoundingBox(target.current, initialMousePosition.current.x, initialMousePosition.current.y); + if (!betterBounds) { + return; + } setWrapperWidth(betterBounds.width); setWrapperHeight(betterBounds.height); setXOffset(betterBounds.x); @@ -146,7 +149,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent, /** * Hide the tooltip in an animation. */ - const hideTooltip = () => { + const hideTooltip = useCallback(() => { animation.current.stopAnimation(); if (TooltipSense.isActive() && !isTooltipSenseInitiator.current) { @@ -164,7 +167,7 @@ function Tooltip({children, numberOfLines, maxWidth, text, renderTooltipContent, TooltipSense.deactivate(); setIsVisible(false); - }; + }, []); // 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