-
Notifications
You must be signed in to change notification settings - Fork 3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #33257 from callstack-internal/refactor/hoverable-…
…component Refactor Hoverable component v2
- Loading branch information
Showing
8 changed files
with
243 additions
and
228 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
import type {Ref} from 'react'; | ||
import {cloneElement, forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; | ||
import {DeviceEventEmitter} from 'react-native'; | ||
import mergeRefs from '@libs/mergeRefs'; | ||
import {getReturnValue} from '@libs/ValueUtils'; | ||
import CONST from '@src/CONST'; | ||
import type HoverableProps from './types'; | ||
|
||
type ActiveHoverableProps = Omit<HoverableProps, 'disabled'>; | ||
|
||
function ActiveHoverable({onHoverIn, onHoverOut, shouldHandleScroll, children}: ActiveHoverableProps, outerRef: Ref<HTMLElement>) { | ||
const [isHovered, setIsHovered] = useState(false); | ||
|
||
const elementRef = useRef<HTMLElement | null>(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], | ||
); | ||
|
||
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(() => getReturnValue(children, !isScrollingRef.current && isHovered), [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], | ||
); | ||
|
||
return cloneElement(child, { | ||
ref: mergeRefs(elementRef, outerRef, child.ref), | ||
onMouseEnter: hoverAndForwardOnMouseEnter, | ||
onMouseLeave: unhoverAndForwardOnMouseLeave, | ||
onBlur: unhoverAndForwardOnBlur, | ||
}); | ||
} | ||
|
||
export default forwardRef(ActiveHoverable); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,213 +1,29 @@ | ||
import type {ForwardedRef, MutableRefObject, ReactElement, RefAttributes} from 'react'; | ||
import React, {forwardRef, 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 type {Ref} from 'react'; | ||
import React, {cloneElement, forwardRef} from 'react'; | ||
import {hasHoverSupport} from '@libs/DeviceCapabilities'; | ||
import {getReturnValue} from '@libs/ValueUtils'; | ||
import ActiveHoverable from './ActiveHoverable'; | ||
import type 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<HTMLElement> { | ||
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<HTMLElement | null>, 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<HTMLElement>, | ||
) { | ||
const [isHovered, setIsHovered] = useState(false); | ||
|
||
const isScrolling = useRef(false); | ||
const isHoveredRef = useRef(false); | ||
const ref = useRef<HTMLElement | null>(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<HTMLElement | null, HTMLElement | null>(outerRef, () => ref.current, []); | ||
|
||
const child = useMemo(() => React.Children.only(mapChildren(children as ReactElement, 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<HTMLElement>) { | ||
// 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(getReturnValue(props.children, false), {ref}); | ||
} | ||
|
||
return React.cloneElement(child, { | ||
ref: hijackRef, | ||
onMouseEnter: enableHoveredOnMouseEnter, | ||
onMouseLeave: disableHoveredOnMouseLeave, | ||
onBlur: disableHoveredOnBlur, | ||
}); | ||
return ( | ||
<ActiveHoverable | ||
// eslint-disable-next-line react/jsx-props-no-spreading | ||
{...props} | ||
ref={ref} | ||
/> | ||
); | ||
} | ||
|
||
export default forwardRef(Hoverable); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,26 +1,25 @@ | ||
import type {ReactNode} from 'react'; | ||
import type {ReactElement, RefAttributes} from 'react'; | ||
|
||
type HoverableChild = ReactElement & RefAttributes<HTMLElement>; | ||
type HoverableChildren = ((isHovered: boolean) => HoverableChild) | HoverableChild; | ||
|
||
type HoverableProps = { | ||
/** Children to wrap with Hoverable. */ | ||
children: ((isHovered: boolean) => ReactNode) | ReactNode; | ||
children: HoverableChildren; | ||
|
||
/** Whether to disable the hover action */ | ||
disabled?: boolean; | ||
isDisabled?: 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 default HoverableProps; | ||
|
||
export type {HoverableChild}; |
Oops, something went wrong.