Skip to content

Commit

Permalink
Merge pull request #28642 from Expensify/revert-27223-refactor/16161/…
Browse files Browse the repository at this point in the history
…hoverable-class-to-functional
  • Loading branch information
francoisl authored Oct 2, 2023
2 parents ad5c632 + 3bc74c7 commit 7e2d0e2
Showing 1 changed file with 141 additions and 134 deletions.
275 changes: 141 additions & 134 deletions src/components/Hoverable/index.js
Original file line number Diff line number Diff line change
@@ -1,182 +1,189 @@
import _ from 'underscore';
import React, {useEffect, useCallback, useState, useRef, useMemo, useImperativeHandle} from 'react';
import React, {Component} from 'react';
import {DeviceEventEmitter} from 'react-native';
import {propTypes, defaultProps} from './hoverablePropTypes';
import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
import CONST from '../../CONST';

function mapChildren(children, callbackParam) {
if (_.isArray(children) && children.length === 1) {
return children[0];
}

if (_.isFunction(children)) {
return children(callbackParam);
}

return children;
}

/**
* 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);

function InnerHoverable({disabled, onHoverIn, onHoverOut, children, shouldHandleScroll}, outerRef) {
const [isHovered, setIsHovered] = useState(false);

const isScrolling = useRef(false);
const isHoveredRef = useRef(false);
const ref = useRef(null);
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
this.checkHover = this.checkHover.bind(this);

const updateIsHoveredOnScrolling = useCallback(
(hovered) => {
if (disabled) {
return;
}
this.state = {
isHovered: false,
};

isHoveredRef.current = hovered;
this.isHoveredRef = false;
this.isScrollingRef = false;
this.wrapperView = null;
}

if (shouldHandleScroll && isScrolling.current) {
return;
}
setIsHovered(hovered);
},
[disabled, shouldHandleScroll],
);
componentDidMount() {
document.addEventListener('visibilitychange', this.handleVisibilityChange);
document.addEventListener('mouseover', this.checkHover);

/**
* 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;
});
}
}

useEffect(() => {
const unsetHoveredWhenDocumentIsHidden = () => document.visibilityState === 'hidden' && setIsHovered(false);
componentDidUpdate(prevProps) {
if (prevProps.disabled === this.props.disabled) {
return;
}

document.addEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
if (this.props.disabled && this.state.isHovered) {
this.setState({isHovered: false});
}
}

return () => document.removeEventListener('visibilitychange', unsetHoveredWhenDocumentIsHidden);
}, []);
componentWillUnmount() {
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
document.removeEventListener('mouseover', this.checkHover);
if (this.scrollingListener) {
this.scrollingListener.remove();
}
}

useEffect(() => {
if (!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) {
return;
}

const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling) => {
isScrolling.current = scrolling;
if (!scrolling) {
setIsHovered(isHoveredRef.current);
}
});
/**
* 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.
*/
this.isHoveredRef = isHovered;

return () => scrollingListener.remove();
}, [shouldHandleScroll]);
/**
* 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 (isHovered !== this.state.isHovered) {
this.setState({isHovered}, isHovered ? this.props.onHoverIn : this.props.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} e - The hover event object.
*/
const unsetHoveredIfOutside = useCallback(
(e) => {
if (!ref.current || !isHovered) {
return;
}

if (ref.current.contains(e.target)) {
return;
}

setIsHovered(false);
},
[isHovered],
);

useEffect(() => {
if (!DeviceCapabilities.hasHoverSupport()) {
checkHover(e) {
if (!this.wrapperView || !this.state.isHovered) {
return;
}

document.addEventListener('mouseover', unsetHoveredIfOutside);
if (this.wrapperView.contains(e.target)) {
return;
}

return () => document.removeEventListener('mouseover', unsetHoveredIfOutside);
}, [unsetHoveredIfOutside]);
this.setIsHovered(false);
}

useEffect(() => {
if (!disabled || !isHovered) {
handleVisibilityChange() {
if (document.visibilityState !== 'hidden') {
return;
}
setIsHovered(false);
}, [disabled, isHovered]);

useEffect(() => {
if (disabled) {
return;
this.setIsHovered(false);
}

render() {
let child = this.props.children;
if (_.isArray(this.props.children) && this.props.children.length === 1) {
child = this.props.children[0];
}
if (onHoverIn && isHovered) {
return onHoverIn();

if (_.isFunction(child)) {
child = child(this.state.isHovered);
}
if (onHoverOut && !isHovered) {
return onHoverOut();

if (!DeviceCapabilities.hasHoverSupport()) {
return child;
}
}, [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 onMouseEnter = useCallback(
(el) => {
updateIsHoveredOnScrolling(true);

if (_.isFunction(child.props.onMouseEnter)) {
child.props.onMouseEnter(el);
}
},
[child.props, updateIsHoveredOnScrolling],
);

const onMouseLeave = useCallback(
(el) => {
updateIsHoveredOnScrolling(false);

if (_.isFunction(child.props.onMouseLeave)) {
child.props.onMouseLeave(el);
}
},
[child.props, updateIsHoveredOnScrolling],
);

const onBlur = 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,
onMouseEnter,
onMouseLeave,
onBlur,
});
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) => {
this.setIsHovered(true);

if (_.isFunction(child.props.onMouseEnter)) {
child.props.onMouseEnter(el);
}
},
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);
}
},
});
}
}

const Hoverable = React.forwardRef(InnerHoverable);

Hoverable.propTypes = propTypes;
Hoverable.defaultProps = defaultProps;
Hoverable.displayName = 'Hoverable';

export default Hoverable;

0 comments on commit 7e2d0e2

Please sign in to comment.