From d2fcb5e2049a2bf23c898cfa3cbc51c54a0ea871 Mon Sep 17 00:00:00 2001 From: Maciej Dobosz Date: Wed, 25 Oct 2023 15:32:09 +0200 Subject: [PATCH 01/10] Migrate to Typescript --- .../Hoverable/hoverablePropTypes.js | 33 ---------- src/components/Hoverable/index.native.js | 22 ------- src/components/Hoverable/index.native.tsx | 18 ++++++ .../Hoverable/{index.js => index.tsx} | 62 +++++++++---------- src/components/Hoverable/types.ts | 27 ++++++++ 5 files changed, 74 insertions(+), 88 deletions(-) delete mode 100644 src/components/Hoverable/hoverablePropTypes.js delete mode 100644 src/components/Hoverable/index.native.js create mode 100644 src/components/Hoverable/index.native.tsx rename src/components/Hoverable/{index.js => index.tsx} (76%) create mode 100644 src/components/Hoverable/types.ts diff --git a/src/components/Hoverable/hoverablePropTypes.js b/src/components/Hoverable/hoverablePropTypes.js deleted file mode 100644 index a3aeaa597d7a..000000000000 --- a/src/components/Hoverable/hoverablePropTypes.js +++ /dev/null @@ -1,33 +0,0 @@ -import PropTypes from 'prop-types'; - -const propTypes = { - /** Whether to disable the hover action */ - disabled: PropTypes.bool, - - /** Children to wrap with Hoverable. */ - children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, - - /** Function that executes when the mouse moves over the children. */ - onHoverIn: PropTypes.func, - - /** Function that executes when the mouse leaves the children. */ - onHoverOut: PropTypes.func, - - /** Direct pass-through of React's onMouseEnter event. */ - onMouseEnter: PropTypes.func, - - /** Direct pass-through of React's onMouseLeave event. */ - onMouseLeave: PropTypes.func, - - /** Decides whether to handle the scroll behaviour to show hover once the scroll ends */ - shouldHandleScroll: PropTypes.bool, -}; - -const defaultProps = { - disabled: false, - onHoverIn: () => {}, - onHoverOut: () => {}, - shouldHandleScroll: false, -}; - -export {propTypes, defaultProps}; diff --git a/src/components/Hoverable/index.native.js b/src/components/Hoverable/index.native.js deleted file mode 100644 index 26d1d98863d6..000000000000 --- a/src/components/Hoverable/index.native.js +++ /dev/null @@ -1,22 +0,0 @@ -import _ from 'underscore'; -import React from 'react'; -import {View} from 'react-native'; -import {propTypes, defaultProps} from './hoverablePropTypes'; - -/** - * On mobile, there is no concept of hovering, so we return a plain wrapper around the component's children, - * where the hover state is always false. - * - * @param {Object} props - * @returns {React.Component} - */ -function Hoverable(props) { - const childrenWithHoverState = _.isFunction(props.children) ? props.children(false) : props.children; - return {childrenWithHoverState}; -} - -Hoverable.propTypes = propTypes; -Hoverable.defaultProps = defaultProps; -Hoverable.displayName = 'Hoverable'; - -export default Hoverable; diff --git a/src/components/Hoverable/index.native.tsx b/src/components/Hoverable/index.native.tsx new file mode 100644 index 000000000000..b275c927ae6a --- /dev/null +++ b/src/components/Hoverable/index.native.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import {View} from 'react-native'; +import { HoverableProps } from './types'; +import _ from 'underscore'; + +/** + * On mobile, there is no concept of hovering, so we return a plain wrapper around the component's children, + * where the hover state is always false. + */ +function Hoverable({children}: HoverableProps) { + const childrenWithHoverState = _.isFunction(children) ? children(false) : children; + + return {childrenWithHoverState}; +} + +Hoverable.displayName = 'Hoverable'; + +export default Hoverable; diff --git a/src/components/Hoverable/index.js b/src/components/Hoverable/index.tsx similarity index 76% rename from src/components/Hoverable/index.js rename to src/components/Hoverable/index.tsx index 2ded0e52e94d..c3f610798444 100644 --- a/src/components/Hoverable/index.js +++ b/src/components/Hoverable/index.tsx @@ -1,7 +1,7 @@ import _ from 'underscore'; -import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle} from 'react'; +import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle, ReactNode} from 'react'; import {DeviceEventEmitter} from 'react-native'; -import {propTypes, defaultProps} from './hoverablePropTypes'; +import { HoverableProps } from './types'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; import CONST from '../../CONST'; @@ -9,11 +9,11 @@ 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. + * @param children The children to map. + * @param callbackParam The parameter to pass to the children function. + * @returns The mapped children. */ -function mapChildren(children, callbackParam) { + function mapChildren(children: ((isHovered: boolean) => ReactNode) | ReactNode, callbackParam: boolean) { if (_.isArray(children) && children.length === 1) { return children[0]; } @@ -27,21 +27,20 @@ function mapChildren(children, callbackParam) { /** * 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. + * @param ref The ref object or function. + * @param element The element to assign the ref to. */ -function assignRef(ref, el) { +function assignRef(ref: React.MutableRefObject, element: HTMLElement) { if (!ref) { return; } if (_.has(ref, 'current')) { - // eslint-disable-next-line no-param-reassign - ref.current = el; + ref.current = element; } if (_.isFunction(ref)) { - ref(el); + ref(element); } } @@ -50,16 +49,15 @@ function assignRef(ref, el) { * 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 */ - -const Hoverable = React.forwardRef(({disabled, onHoverIn, onHoverOut, onMouseEnter, onMouseLeave, children, shouldHandleScroll}, outerRef) => { + const Hoverable = React.forwardRef(({disabled = false, onHoverIn = () => {}, onHoverOut = () => {}, onMouseEnter = () => {}, onMouseLeave = () => {}, children, shouldHandleScroll = false}, outerRef) => { const [isHovered, setIsHovered] = useState(false); const isScrolling = useRef(false); const isHoveredRef = useRef(false); - const ref = useRef(null); + const ref = useRef(null); const updateIsHoveredOnScrolling = useCallback( - (hovered) => { + (hovered: boolean) => { if (disabled) { return; } @@ -106,14 +104,14 @@ const Hoverable = React.forwardRef(({disabled, onHoverIn, onHoverOut, onMouseEnt * 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. + * @param event The hover event object. */ - const unsetHoveredIfOutside = (e) => { + const unsetHoveredIfOutside = (event: MouseEvent) => { if (!ref.current || !isHovered) { return; } - if (ref.current.contains(e.target)) { + if (ref.current.contains(event.target as Node)) { return; } @@ -150,45 +148,45 @@ const Hoverable = React.forwardRef(({disabled, onHoverIn, onHoverOut, onMouseEnt const child = useMemo(() => React.Children.only(mapChildren(children, isHovered)), [children, isHovered]); const enableHoveredOnMouseEnter = useCallback( - (el) => { + (event: MouseEvent) => { updateIsHoveredOnScrolling(true); if (_.isFunction(onMouseEnter)) { - onMouseEnter(el); + onMouseEnter(event); } if (_.isFunction(child.props.onMouseEnter)) { - child.props.onMouseEnter(el); + child.props.onMouseEnter(event); } }, [child.props, onMouseEnter, updateIsHoveredOnScrolling], ); const disableHoveredOnMouseLeave = useCallback( - (el) => { + (event: MouseEvent) => { updateIsHoveredOnScrolling(false); if (_.isFunction(onMouseLeave)) { - onMouseLeave(el); + onMouseLeave(event); } if (_.isFunction(child.props.onMouseLeave)) { - child.props.onMouseLeave(el); + child.props.onMouseLeave(event); } }, [child.props, onMouseLeave, updateIsHoveredOnScrolling], ); const disableHoveredOnBlur = useCallback( - (el) => { + (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(el.target) && !ref.current.contains(el.relatedTarget)) { + if (!ref.current?.contains(event.target as Node) && !ref.current?.contains(event.relatedTarget as Node)) { setIsHovered(false); } if (_.isFunction(child.props.onBlur)) { - child.props.onBlur(el); + child.props.onBlur(event); } }, [child.props], @@ -199,9 +197,9 @@ const Hoverable = React.forwardRef(({disabled, onHoverIn, onHoverOut, onMouseEnt } return React.cloneElement(child, { - ref: (el) => { - ref.current = el; - assignRef(child.ref, el); + ref: (element: HTMLElement) => { + ref.current = element; + assignRef(child.ref, element); }, onMouseEnter: enableHoveredOnMouseEnter, onMouseLeave: disableHoveredOnMouseLeave, @@ -209,8 +207,6 @@ const Hoverable = React.forwardRef(({disabled, onHoverIn, onHoverOut, onMouseEnt }); }); -Hoverable.propTypes = propTypes; -Hoverable.defaultProps = defaultProps; Hoverable.displayName = 'Hoverable'; export default Hoverable; diff --git a/src/components/Hoverable/types.ts b/src/components/Hoverable/types.ts new file mode 100644 index 000000000000..4f161f945b28 --- /dev/null +++ b/src/components/Hoverable/types.ts @@ -0,0 +1,27 @@ +import { ReactNode } from "react"; +import { NativeMouseEvent } from "react-native"; + +type HoverableProps = { + /** Children to wrap with Hoverable. */ + children: ((isHovered: boolean) => ReactNode) | ReactNode; + + /** Whether to disable the hover action */ + disabled?: boolean; + + /** Function that executes when the mouse moves over the children. */ + onHoverIn?: () => void; + + /** 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; +} + +export type{HoverableProps}; From 019919a4b411812f57bf3e4679edcebe85c3d11d Mon Sep 17 00:00:00 2001 From: Maciej Dobosz Date: Wed, 25 Oct 2023 15:34:21 +0200 Subject: [PATCH 02/10] Fix TS ref.current is not assignable --- src/components/Hoverable/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Hoverable/index.tsx b/src/components/Hoverable/index.tsx index c3f610798444..e9f9a36fcf8a 100644 --- a/src/components/Hoverable/index.tsx +++ b/src/components/Hoverable/index.tsx @@ -143,7 +143,7 @@ function assignRef(ref: React.MutableRefObject, element: HTM }, [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, []); + useImperativeHandle(outerRef, () => ref.current, []); const child = useMemo(() => React.Children.only(mapChildren(children, isHovered)), [children, isHovered]); From 5287856d6709fd59a91642ef3db1fe9bb1c43384 Mon Sep 17 00:00:00 2001 From: Maciej Dobosz Date: Wed, 25 Oct 2023 15:39:59 +0200 Subject: [PATCH 03/10] Remove due to Property 'displayName' does not exist --- src/components/Hoverable/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/Hoverable/index.tsx b/src/components/Hoverable/index.tsx index e9f9a36fcf8a..d85d4ba9a5d6 100644 --- a/src/components/Hoverable/index.tsx +++ b/src/components/Hoverable/index.tsx @@ -207,6 +207,4 @@ function assignRef(ref: React.MutableRefObject, element: HTM }); }); -Hoverable.displayName = 'Hoverable'; - export default Hoverable; From 21c123a5a7ecaebcd37174a5e9cea9bc2ae465d6 Mon Sep 17 00:00:00 2001 From: Maciej Dobosz Date: Wed, 25 Oct 2023 20:51:14 +0200 Subject: [PATCH 04/10] Implement eslint suggestions --- src/components/Hoverable/index.native.tsx | 5 ++-- src/components/Hoverable/index.tsx | 36 +++++++++-------------- src/components/Hoverable/types.ts | 3 +- 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/src/components/Hoverable/index.native.tsx b/src/components/Hoverable/index.native.tsx index b275c927ae6a..b3d49db9d96e 100644 --- a/src/components/Hoverable/index.native.tsx +++ b/src/components/Hoverable/index.native.tsx @@ -1,14 +1,13 @@ import React from 'react'; import {View} from 'react-native'; -import { HoverableProps } from './types'; -import _ from 'underscore'; +import HoverableProps from './types'; /** * On mobile, there is no concept of hovering, so we return a plain wrapper around the component's children, * where the hover state is always false. */ function Hoverable({children}: HoverableProps) { - const childrenWithHoverState = _.isFunction(children) ? children(false) : children; + const childrenWithHoverState = typeof children === 'function' ? children(false) : children; return {childrenWithHoverState}; } diff --git a/src/components/Hoverable/index.tsx b/src/components/Hoverable/index.tsx index d85d4ba9a5d6..8b8bfe5d67d2 100644 --- a/src/components/Hoverable/index.tsx +++ b/src/components/Hoverable/index.tsx @@ -1,7 +1,7 @@ -import _ from 'underscore'; -import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle, ReactNode} from 'react'; +import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle, ReactNode, MutableRefObject} from 'react'; import {DeviceEventEmitter} from 'react-native'; -import { HoverableProps } from './types'; +import _ from 'lodash'; +import HoverableProps from './types'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; import CONST from '../../CONST'; @@ -14,11 +14,11 @@ import CONST from '../../CONST'; * @returns The mapped children. */ function mapChildren(children: ((isHovered: boolean) => ReactNode) | ReactNode, callbackParam: boolean) { - if (_.isArray(children) && children.length === 1) { + if (Array.isArray(children) && children.length === 1) { return children[0]; } - if (_.isFunction(children)) { + if (typeof children === 'function') { return children(callbackParam); } @@ -30,17 +30,15 @@ import CONST from '../../CONST'; * @param ref The ref object or function. * @param element The element to assign the ref to. */ -function assignRef(ref: React.MutableRefObject, element: HTMLElement) { +function assignRef(ref: MutableRefObject | ((element: HTMLElement) => void), element: HTMLElement) { if (!ref) { return; } - if (_.has(ref, 'current')) { - ref.current = element; - } - - if (_.isFunction(ref)) { + if (typeof ref === 'function') { ref(element); + } else if (_.has(ref, 'current')) { + ref.current = element; } } @@ -150,12 +148,9 @@ function assignRef(ref: React.MutableRefObject, element: HTM const enableHoveredOnMouseEnter = useCallback( (event: MouseEvent) => { updateIsHoveredOnScrolling(true); + onMouseEnter(event); - if (_.isFunction(onMouseEnter)) { - onMouseEnter(event); - } - - if (_.isFunction(child.props.onMouseEnter)) { + if (typeof child.props.onMouseEnter === 'function') { child.props.onMouseEnter(event); } }, @@ -165,12 +160,9 @@ function assignRef(ref: React.MutableRefObject, element: HTM const disableHoveredOnMouseLeave = useCallback( (event: MouseEvent) => { updateIsHoveredOnScrolling(false); + onMouseLeave(event); - if (_.isFunction(onMouseLeave)) { - onMouseLeave(event); - } - - if (_.isFunction(child.props.onMouseLeave)) { + if (typeof child.props.onMouseLeave === 'function') { child.props.onMouseLeave(event); } }, @@ -185,7 +177,7 @@ function assignRef(ref: React.MutableRefObject, element: HTM setIsHovered(false); } - if (_.isFunction(child.props.onBlur)) { + if (typeof child.props.onBlur === 'function') { child.props.onBlur(event); } }, diff --git a/src/components/Hoverable/types.ts b/src/components/Hoverable/types.ts index 4f161f945b28..23de83e03012 100644 --- a/src/components/Hoverable/types.ts +++ b/src/components/Hoverable/types.ts @@ -1,5 +1,4 @@ import { ReactNode } from "react"; -import { NativeMouseEvent } from "react-native"; type HoverableProps = { /** Children to wrap with Hoverable. */ @@ -24,4 +23,4 @@ type HoverableProps = { shouldHandleScroll?: boolean; } -export type{HoverableProps}; +export default HoverableProps; From 8432cd4fb6b7494e49dee39239154832e6cea825 Mon Sep 17 00:00:00 2001 From: Maciej Dobosz Date: Tue, 7 Nov 2023 10:39:11 +0100 Subject: [PATCH 05/10] Reorder imports; use lodash --- src/components/Hoverable/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Hoverable/index.tsx b/src/components/Hoverable/index.tsx index 8d9d7d81cd46..cfe6cb94e4ce 100644 --- a/src/components/Hoverable/index.tsx +++ b/src/components/Hoverable/index.tsx @@ -1,9 +1,9 @@ import React, {MutableRefObject, ReactNode, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {DeviceEventEmitter} from 'react-native'; -import _ from 'underscore'; -import HoverableProps from './types'; +import _ from 'lodash'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import CONST from '@src/CONST'; +import HoverableProps from './types'; /** * Maps the children of a Hoverable component to From 8a4b1064a5367745bff153923c02e9384074fc2e Mon Sep 17 00:00:00 2001 From: Maciej Dobosz Date: Tue, 7 Nov 2023 12:17:21 +0100 Subject: [PATCH 06/10] Migrate to Typescript --- src/components/Hoverable/index.tsx | 273 +++++++++++++++-------------- src/components/Hoverable/types.ts | 6 +- 2 files changed, 142 insertions(+), 137 deletions(-) diff --git a/src/components/Hoverable/index.tsx b/src/components/Hoverable/index.tsx index cfe6cb94e4ce..7ce919dc58c3 100644 --- a/src/components/Hoverable/index.tsx +++ b/src/components/Hoverable/index.tsx @@ -1,6 +1,6 @@ -import React, {MutableRefObject, ReactNode, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import {DeviceEventEmitter} from 'react-native'; import _ from 'lodash'; +import React, {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'; @@ -13,7 +13,7 @@ import HoverableProps from './types'; * @param callbackParam The parameter to pass to the children function. * @returns The mapped children. */ - function mapChildren(children: ((isHovered: boolean) => ReactNode) | ReactNode, callbackParam: boolean) { +function mapChildren(children: ((isHovered: boolean) => ReactElement) | ReactElement | ReactElement[], callbackParam: boolean): ReactElement & RefAttributes { if (Array.isArray(children) && children.length === 1) { return children[0]; } @@ -22,7 +22,7 @@ import HoverableProps from './types'; return children(callbackParam); } - return children; + return children as ReactElement; } /** @@ -38,6 +38,7 @@ function assignRef(ref: MutableRefObject | ((element: HTMLElement) if (typeof ref === 'function') { ref(element); } else if (_.has(ref, 'current')) { + // eslint-disable-next-line no-param-reassign ref.current = element; } } @@ -47,162 +48,166 @@ function assignRef(ref: MutableRefObject | ((element: HTMLElement) * 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 */ - const Hoverable = React.forwardRef(({disabled = false, onHoverIn = () => {}, onHoverOut = () => {}, onMouseEnter = () => {}, onMouseLeave = () => {}, children, shouldHandleScroll = false}, outerRef) => { - const [isHovered, setIsHovered] = useState(false); +const Hoverable = React.forwardRef( + ({disabled = false, onHoverIn = () => {}, onHoverOut = () => {}, onMouseEnter = () => {}, onMouseLeave = () => {}, children, shouldHandleScroll = false}, outerRef) => { + const [isHovered, setIsHovered] = useState(false); - const isScrolling = useRef(false); - const isHoveredRef = useRef(false); - const ref = useRef(null); + const isScrolling = useRef(false); + const isHoveredRef = useRef(false); + const ref = useRef(null); - const updateIsHoveredOnScrolling = useCallback( - (hovered: boolean) => { - if (disabled) { - return; - } + const updateIsHoveredOnScrolling = useCallback( + (hovered: boolean) => { + if (disabled) { + return; + } - isHoveredRef.current = hovered; + isHoveredRef.current = hovered; - if (shouldHandleScroll && isScrolling.current) { - return; - } - setIsHovered(hovered); - }, - [disabled, shouldHandleScroll], - ); - - useEffect(() => { - const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false); + if (shouldHandleScroll && isScrolling.current) { + return; + } + setIsHovered(hovered); + }, + [disabled, shouldHandleScroll], + ); - document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); + useEffect(() => { + const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false); - return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); - }, []); + document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); - useEffect(() => { - if (!shouldHandleScroll) { - return; - } + return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); + }, []); - const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => { - isScrolling.current = scrolling; - if (!scrolling) { - setIsHovered(isHoveredRef.current); + useEffect(() => { + if (!shouldHandleScroll) { + return; } - }); - return () => scrollingListener.remove(); - }, [shouldHandleScroll]); + const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => { + isScrolling.current = scrolling; + if (!scrolling) { + setIsHovered(isHoveredRef.current); + } + }); - 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; - } + return () => scrollingListener.remove(); + }, [shouldHandleScroll]); - if (ref.current.contains(event.target as Node)) { + useEffect(() => { + if (!DeviceCapabilities.hasHoverSupport()) { 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]); + /** + * 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; + } - // Expose inner ref to parent through outerRef. This enable us to use ref both in parent and child. - useImperativeHandle(outerRef, () => ref.current, []); + setIsHovered(false); + }; - const child = useMemo(() => React.Children.only(mapChildren(children, isHovered)), [children, isHovered]); + document.addEventListener('mouseover', unsetHoveredIfOutside); - const enableHoveredOnMouseEnter = useCallback( - (event: MouseEvent) => { - updateIsHoveredOnScrolling(true); - onMouseEnter(event); + return () => document.removeEventListener('mouseover', unsetHoveredIfOutside); + }, [isHovered]); - if (typeof child.props.onMouseEnter === 'function') { - child.props.onMouseEnter(event); + useEffect(() => { + if (!disabled || !isHovered) { + return; } - }, - [child.props, onMouseEnter, updateIsHoveredOnScrolling], - ); - - const disableHoveredOnMouseLeave = useCallback( - (event: MouseEvent) => { - updateIsHoveredOnScrolling(false); - onMouseLeave(event); + setIsHovered(false); + }, [disabled, isHovered]); - if (typeof child.props.onMouseLeave === 'function') { - child.props.onMouseLeave(event); + useEffect(() => { + if (disabled) { + return; } - }, - [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 (onHoverIn && isHovered) { + return onHoverIn(); } - - if (typeof child.props.onBlur === 'function') { - child.props.onBlur(event); + 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 as MutableRefObject, el); } - }, - [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; - assignRef(child.ref, el); - }; - - if (!DeviceCapabilities.hasHoverSupport()) { + }; + + if (!DeviceCapabilities.hasHoverSupport()) { + return React.cloneElement(child, { + ref: hijackRef, + }); + } + return React.cloneElement(child, { ref: hijackRef, + onMouseEnter: enableHoveredOnMouseEnter, + onMouseLeave: disableHoveredOnMouseLeave, + onBlur: disableHoveredOnBlur, }); - } - - return React.cloneElement(child, { - ref: hijackRef, - onMouseEnter: enableHoveredOnMouseEnter, - onMouseLeave: disableHoveredOnMouseLeave, - onBlur: disableHoveredOnBlur, - }); -}); + }, +); export default Hoverable; diff --git a/src/components/Hoverable/types.ts b/src/components/Hoverable/types.ts index 23de83e03012..430b865f50c5 100644 --- a/src/components/Hoverable/types.ts +++ b/src/components/Hoverable/types.ts @@ -1,8 +1,8 @@ -import { ReactNode } from "react"; +import {ReactElement} from 'react'; type HoverableProps = { /** Children to wrap with Hoverable. */ - children: ((isHovered: boolean) => ReactNode) | ReactNode; + children: ((isHovered: boolean) => ReactElement) | ReactElement; /** Whether to disable the hover action */ disabled?: boolean; @@ -21,6 +21,6 @@ type HoverableProps = { /** Decides whether to handle the scroll behaviour to show hover once the scroll ends */ shouldHandleScroll?: boolean; -} +}; export default HoverableProps; From cc222e7c945242ec730a1bb718a53b3997fff75a Mon Sep 17 00:00:00 2001 From: Maciej Dobosz Date: Tue, 7 Nov 2023 17:42:59 +0100 Subject: [PATCH 07/10] Better typing - removes 'as' --- src/components/Hoverable/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Hoverable/index.tsx b/src/components/Hoverable/index.tsx index 7ce919dc58c3..35cd12e0852f 100644 --- a/src/components/Hoverable/index.tsx +++ b/src/components/Hoverable/index.tsx @@ -13,8 +13,8 @@ import HoverableProps from './types'; * @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) && children.length === 1) { + function mapChildren(children: ((isHovered: boolean) => ReactElement) | ReactElement | ReactElement[], callbackParam: boolean): ReactElement & RefAttributes { + if (Array.isArray(children)) { return children[0]; } @@ -22,7 +22,7 @@ function mapChildren(children: ((isHovered: boolean) => ReactElement) | ReactEle return children(callbackParam); } - return children as ReactElement; + return children; } /** From edc3ba660dd695f16477aa6327cc2e44b3f7fd7e Mon Sep 17 00:00:00 2001 From: Maciej Dobosz Date: Tue, 7 Nov 2023 17:47:13 +0100 Subject: [PATCH 08/10] Define component as function, not function expression --- src/components/Hoverable/index.tsx | 273 +++++++++++++++-------------- 1 file changed, 137 insertions(+), 136 deletions(-) diff --git a/src/components/Hoverable/index.tsx b/src/components/Hoverable/index.tsx index 35cd12e0852f..c539ccf6959e 100644 --- a/src/components/Hoverable/index.tsx +++ b/src/components/Hoverable/index.tsx @@ -1,5 +1,5 @@ import _ from 'lodash'; -import React, {MutableRefObject, ReactElement, RefAttributes, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +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'; @@ -13,7 +13,7 @@ import HoverableProps from './types'; * @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 { +function mapChildren(children: ((isHovered: boolean) => ReactElement) | ReactElement | ReactElement[], callbackParam: boolean): ReactElement & RefAttributes { if (Array.isArray(children)) { return children[0]; } @@ -48,166 +48,167 @@ function assignRef(ref: MutableRefObject | ((element: HTMLElement) * 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 */ -const Hoverable = React.forwardRef( - ({disabled = false, onHoverIn = () => {}, onHoverOut = () => {}, onMouseEnter = () => {}, onMouseLeave = () => {}, children, shouldHandleScroll = false}, outerRef) => { - const [isHovered, setIsHovered] = useState(false); - - const isScrolling = useRef(false); - const isHoveredRef = useRef(false); - const ref = useRef(null); +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; + } - const updateIsHoveredOnScrolling = useCallback( - (hovered: boolean) => { - if (disabled) { - return; - } + isHoveredRef.current = hovered; - isHoveredRef.current = hovered; + if (shouldHandleScroll && isScrolling.current) { + return; + } + setIsHovered(hovered); + }, + [disabled, shouldHandleScroll], + ); - if (shouldHandleScroll && isScrolling.current) { - return; - } - setIsHovered(hovered); - }, - [disabled, shouldHandleScroll], - ); + useEffect(() => { + const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false); - useEffect(() => { - const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false); + document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); - document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); + return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); + }, []); - return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden); - }, []); + useEffect(() => { + if (!shouldHandleScroll) { + return; + } - useEffect(() => { - if (!shouldHandleScroll) { - return; + const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => { + isScrolling.current = scrolling; + if (!scrolling) { + setIsHovered(isHoveredRef.current); } + }); - const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => { - isScrolling.current = scrolling; - if (!scrolling) { - setIsHovered(isHoveredRef.current); - } - }); + return () => scrollingListener.remove(); + }, [shouldHandleScroll]); - return () => scrollingListener.remove(); - }, [shouldHandleScroll]); + useEffect(() => { + if (!DeviceCapabilities.hasHoverSupport()) { + return; + } - useEffect(() => { - if (!DeviceCapabilities.hasHoverSupport()) { + /** + * 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; } - /** - * 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; - } + if (ref.current.contains(event.target as Node)) { + return; + } - setIsHovered(false); - }; + setIsHovered(false); + }; - document.addEventListener('mouseover', unsetHoveredIfOutside); + document.addEventListener('mouseover', unsetHoveredIfOutside); - return () => document.removeEventListener('mouseover', unsetHoveredIfOutside); - }, [isHovered]); + return () => document.removeEventListener('mouseover', unsetHoveredIfOutside); + }, [isHovered]); - useEffect(() => { - if (!disabled || !isHovered) { - return; - } - setIsHovered(false); - }, [disabled, isHovered]); + useEffect(() => { + if (!disabled || !isHovered) { + return; + } + setIsHovered(false); + }, [disabled, isHovered]); - useEffect(() => { - if (disabled) { - return; - } - if (onHoverIn && isHovered) { - return onHoverIn(); + 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); } - if (onHoverOut && !isHovered) { - return onHoverOut(); + }, + [child.props, onMouseEnter, updateIsHoveredOnScrolling], + ); + + const disableHoveredOnMouseLeave = useCallback( + (event: MouseEvent) => { + updateIsHoveredOnScrolling(false); + onMouseLeave(event); + + if (typeof child.props.onMouseLeave === 'function') { + child.props.onMouseLeave(event); } - }, [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 as MutableRefObject, el); + }, + [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 (!DeviceCapabilities.hasHoverSupport()) { - return React.cloneElement(child, { - ref: hijackRef, - }); + 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 as MutableRefObject, el); } + }; + if (!DeviceCapabilities.hasHoverSupport()) { return React.cloneElement(child, { ref: hijackRef, - onMouseEnter: enableHoveredOnMouseEnter, - onMouseLeave: disableHoveredOnMouseLeave, - onBlur: disableHoveredOnBlur, }); - }, -); + } + + return React.cloneElement(child, { + ref: hijackRef, + onMouseEnter: enableHoveredOnMouseEnter, + onMouseLeave: disableHoveredOnMouseLeave, + onBlur: disableHoveredOnBlur, + }); +} -export default Hoverable; +export default forwardRef(Hoverable); From f60aae14b8dc57819a782e3311d49db4a2ed7ce3 Mon Sep 17 00:00:00 2001 From: Maciej Dobosz Date: Tue, 7 Nov 2023 17:48:36 +0100 Subject: [PATCH 09/10] Better types of assignRef --- src/components/Hoverable/index.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/Hoverable/index.tsx b/src/components/Hoverable/index.tsx index c539ccf6959e..7045888da31d 100644 --- a/src/components/Hoverable/index.tsx +++ b/src/components/Hoverable/index.tsx @@ -1,4 +1,3 @@ -import _ from 'lodash'; 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'; @@ -30,14 +29,13 @@ function mapChildren(children: ((isHovered: boolean) => ReactElement) | ReactEle * @param ref The ref object or function. * @param element The element to assign the ref to. */ -function assignRef(ref: MutableRefObject | ((element: HTMLElement) => void), element: HTMLElement) { +function assignRef(ref: ((instance: HTMLElement | null) => void) | MutableRefObject, element: HTMLElement) { if (!ref) { return; } - if (typeof ref === 'function') { ref(element); - } else if (_.has(ref, 'current')) { + } else if (ref?.current) { // eslint-disable-next-line no-param-reassign ref.current = element; } From a3c156f31a3491d5525eecfa9cc0f9853de4975b Mon Sep 17 00:00:00 2001 From: Maciej Dobosz Date: Tue, 7 Nov 2023 17:50:40 +0100 Subject: [PATCH 10/10] Drop unnecessary 'as' casting --- src/components/Hoverable/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Hoverable/index.tsx b/src/components/Hoverable/index.tsx index 7045888da31d..a52dfa296925 100644 --- a/src/components/Hoverable/index.tsx +++ b/src/components/Hoverable/index.tsx @@ -191,7 +191,7 @@ function Hoverable( const hijackRef = (el: HTMLElement) => { ref.current = el; if (child.ref) { - assignRef(child.ref as MutableRefObject, el); + assignRef(child.ref, el); } };