Skip to content

Commit

Permalink
Merge pull request #33257 from callstack-internal/refactor/hoverable-…
Browse files Browse the repository at this point in the history
…component

Refactor Hoverable component v2
  • Loading branch information
mountiny authored Jan 9, 2024
2 parents abd28cd + b88474a commit bddf15d
Show file tree
Hide file tree
Showing 8 changed files with 243 additions and 228 deletions.
127 changes: 127 additions & 0 deletions src/components/Hoverable/ActiveHoverable.tsx
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);
218 changes: 17 additions & 201 deletions src/components/Hoverable/index.tsx
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);
17 changes: 8 additions & 9 deletions src/components/Hoverable/types.ts
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};
Loading

0 comments on commit bddf15d

Please sign in to comment.