Skip to content

Commit

Permalink
Merge pull request #31518 from callstack-internal/refactor/hoverable-…
Browse files Browse the repository at this point in the history
…component
  • Loading branch information
roryabraham authored Nov 30, 2023
2 parents 2b9de34 + 783c64a commit 0f91ade
Show file tree
Hide file tree
Showing 6 changed files with 188 additions and 211 deletions.
137 changes: 137 additions & 0 deletions src/components/Hoverable/ActiveHoverable.tsx
Original file line number Diff line number Diff line change
@@ -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<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],
);

// Expose inner ref to parent through outerRef. This enable us to use ref both in parent and child.
useImperativeHandle<HTMLElement | null, HTMLElement | null>(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);
215 changes: 15 additions & 200 deletions src/components/Hoverable/index.tsx
Original file line number Diff line number Diff line change
@@ -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<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, 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(typeof props.children === 'function' ? props.children(false) : props.children, {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);
12 changes: 3 additions & 9 deletions src/components/Hoverable/types.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,18 @@
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<HTMLElement>) | (ReactElement & RefAttributes<HTMLElement>);

/** 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;
};
Expand Down
Loading

0 comments on commit 0f91ade

Please sign in to comment.