Skip to content

Commit

Permalink
Merge pull request #23292 from jczekalski/migrate-tooltip
Browse files Browse the repository at this point in the history
Migrate Tooltip/index.js to function component
  • Loading branch information
jasperhuangg authored Aug 9, 2023
2 parents 649f5af + 58e6de5 commit d449072
Show file tree
Hide file tree
Showing 2 changed files with 113 additions and 126 deletions.
235 changes: 113 additions & 122 deletions src/components/Tooltip/index.js
Original file line number Diff line number Diff line change
@@ -1,121 +1,114 @@
import _ from 'underscore';
import React, {PureComponent} from 'react';
import React, {memo, useCallback, useEffect, useRef, useState} from 'react';
import {Animated} from 'react-native';
import {BoundsObserver} from '@react-ng/bounds-observer';
import TooltipRenderedOnPageBody from './TooltipRenderedOnPageBody';
import Hoverable from '../Hoverable';
import withWindowDimensions from '../withWindowDimensions';
import * as tooltipPropTypes from './tooltipPropTypes';
import TooltipSense from './TooltipSense';
import * as DeviceCapabilities from '../../libs/DeviceCapabilities';
import compose from '../../libs/compose';
import withLocalize from '../withLocalize';
import usePrevious from '../../hooks/usePrevious';
import useLocalize from '../../hooks/useLocalize';
import useWindowDimensions from '../../hooks/useWindowDimensions';

const hasHoverSupport = DeviceCapabilities.hasHoverSupport();

/**
* A component used to wrap an element intended for displaying a tooltip. The term "tooltip's target" refers to the
* wrapped element, which, upon hover, triggers the tooltip to be shown.
* @param {propTypes} props
* @returns {ReactNodeLike}
*/
class Tooltip extends PureComponent {
constructor(props) {
super(props);

this.state = {
// Is tooltip already rendered on the page's body? This happens once.
isRendered: false,

// Is the tooltip currently visible?
isVisible: false,

// The distance between the left side of the wrapper view and the left side of the window
xOffset: 0,

// The distance between the top of the wrapper view and the top of the window
yOffset: 0,

// The width and height of the wrapper view
wrapperWidth: 0,
wrapperHeight: 0,
};

// Whether the tooltip is first tooltip to activate the TooltipSense
this.isTooltipSenseInitiator = false;
this.animation = new Animated.Value(0);
this.hasHoverSupport = DeviceCapabilities.hasHoverSupport();

this.showTooltip = this.showTooltip.bind(this);
this.hideTooltip = this.hideTooltip.bind(this);
this.updateBounds = this.updateBounds.bind(this);
this.isAnimationCanceled = React.createRef(false);
}

// eslint-disable-next-line rulesdir/prefer-early-return
componentDidUpdate(prevProps) {
// if the tooltip text changed before the initial animation was finished, then the tooltip won't be shown
// we need to show the tooltip again
if (this.state.isVisible && this.isAnimationCanceled.current && this.props.text && prevProps.text !== this.props.text) {
this.isAnimationCanceled.current = false;
this.showTooltip();
}
}

/**
* Update the tooltip bounding rectangle
*
* @param {Object} bounds - updated bounds
*/
updateBounds(bounds) {
if (bounds.width === 0) {
this.setState({isRendered: false});
}
this.setState({
wrapperWidth: bounds.width,
wrapperHeight: bounds.height,
xOffset: bounds.x,
yOffset: bounds.y,
});
}
function Tooltip(props) {
const {children, numberOfLines, maxWidth, text, renderTooltipContent, renderTooltipContentKey} = props;

const {preferredLocale} = useLocalize();
const {windowWidth} = useWindowDimensions();

// Is tooltip already rendered on the page's body? happens once.
const [isRendered, setIsRendered] = useState(false);
// Is the tooltip currently visible?
const [isVisible, setIsVisible] = useState(false);
// The distance between the left side of the wrapper view and the left side of the window
const [xOffset, setXOffset] = useState(0);
// The distance between the top of the wrapper view and the top of the window
const [yOffset, setYOffset] = useState(0);
// The width and height of the wrapper view
const [wrapperWidth, setWrapperWidth] = useState(0);
const [wrapperHeight, setWrapperHeight] = useState(0);

// Whether the tooltip is first tooltip to activate the TooltipSense
const isTooltipSenseInitiator = useRef(false);
const animation = useRef(new Animated.Value(0));
const isAnimationCanceled = useRef(false);
const prevText = usePrevious(text);

/**
* Display the tooltip in an animation.
*/
showTooltip() {
if (!this.state.isRendered) {
this.setState({isRendered: true});
const showTooltip = useCallback(() => {
if (!isRendered) {
setIsRendered(true);
}

this.setState({isVisible: true});
setIsVisible(true);

this.animation.stopAnimation();
animation.current.stopAnimation();

// When TooltipSense is active, immediately show the tooltip
if (TooltipSense.isActive()) {
this.animation.setValue(1);
animation.current.setValue(1);
} else {
this.isTooltipSenseInitiator = true;
Animated.timing(this.animation, {
isTooltipSenseInitiator.current = true;
Animated.timing(animation.current, {
toValue: 1,
duration: 140,
delay: 500,
useNativeDriver: false,
}).start(({finished}) => {
this.isAnimationCanceled.current = !finished;
isAnimationCanceled.current = !finished;
});
}
TooltipSense.activate();
}
}, [isRendered]);

// eslint-disable-next-line rulesdir/prefer-early-return
useEffect(() => {
// if the tooltip text changed before the initial animation was finished, then the tooltip won't be shown
// we need to show the tooltip again
if (isVisible && isAnimationCanceled.current && text && prevText !== text) {
isAnimationCanceled.current = false;
showTooltip();
}
}, [isVisible, text, prevText, showTooltip]);

/**
* Update the tooltip bounding rectangle
*
* @param {Object} bounds - updated bounds
*/
const updateBounds = (bounds) => {
if (bounds.width === 0) {
setIsRendered(false);
}
setWrapperWidth(bounds.width);
setWrapperHeight(bounds.height);
setXOffset(bounds.x);
setYOffset(bounds.y);
};

/**
* Hide the tooltip in an animation.
*/
hideTooltip() {
this.animation.stopAnimation();
const hideTooltip = () => {
animation.current.stopAnimation();

if (TooltipSense.isActive() && !this.isTooltipSenseInitiator) {
this.animation.setValue(0);
if (TooltipSense.isActive() && !isTooltipSenseInitiator.current) {
animation.current.setValue(0);
} else {
// Hide the first tooltip which initiated the TooltipSense with animation
this.isTooltipSenseInitiator = false;
Animated.timing(this.animation, {
isTooltipSenseInitiator.current = false;
Animated.timing(animation.current, {
toValue: 0,
duration: 140,
useNativeDriver: false,
Expand All @@ -124,53 +117,51 @@ class Tooltip extends PureComponent {

TooltipSense.deactivate();

this.setState({isVisible: false});
}
setIsVisible(false);
};

render() {
// Skip the tooltip and return the children if the text is empty,
// we don't have a render function or the device does not support hovering
if ((_.isEmpty(this.props.text) && this.props.renderTooltipContent == null) || !this.hasHoverSupport) {
return this.props.children;
}
// Skip the tooltip and return the children if the text is empty,
// we don't have a render function or the device does not support hovering
if ((_.isEmpty(text) && renderTooltipContent == null) || !hasHoverSupport) {
return children;
}

return (
<>
{this.state.isRendered && (
<TooltipRenderedOnPageBody
animation={this.animation}
windowWidth={this.props.windowWidth}
xOffset={this.state.xOffset}
yOffset={this.state.yOffset}
targetWidth={this.state.wrapperWidth}
targetHeight={this.state.wrapperHeight}
shiftHorizontal={_.result(this.props, 'shiftHorizontal')}
shiftVertical={_.result(this.props, 'shiftVertical')}
text={this.props.text}
maxWidth={this.props.maxWidth}
numberOfLines={this.props.numberOfLines}
renderTooltipContent={this.props.renderTooltipContent}
// We pass a key, so whenever the content changes this component will completely remount with a fresh state.
// This prevents flickering/moving while remaining performant.
key={[this.props.text, ...this.props.renderTooltipContentKey, this.props.preferredLocale]}
/>
)}
<BoundsObserver
enabled={this.state.isVisible}
onBoundsChange={this.updateBounds}
return (
<>
{isRendered && (
<TooltipRenderedOnPageBody
animation={animation.current}
windowWidth={windowWidth}
xOffset={xOffset}
yOffset={yOffset}
targetWidth={wrapperWidth}
targetHeight={wrapperHeight}
shiftHorizontal={_.result(props, 'shiftHorizontal')}
shiftVertical={_.result(props, 'shiftVertical')}
text={text}
maxWidth={maxWidth}
numberOfLines={numberOfLines}
renderTooltipContent={renderTooltipContent}
// We pass a key, so whenever the content changes this component will completely remount with a fresh state.
// This prevents flickering/moving while remaining performant.
key={[text, ...renderTooltipContentKey, preferredLocale]}
/>
)}
<BoundsObserver
enabled={isVisible}
onBoundsChange={updateBounds}
>
<Hoverable
onHoverIn={showTooltip}
onHoverOut={hideTooltip}
>
<Hoverable
onHoverIn={this.showTooltip}
onHoverOut={this.hideTooltip}
>
{this.props.children}
</Hoverable>
</BoundsObserver>
</>
);
}
{children}
</Hoverable>
</BoundsObserver>
</>
);
}

Tooltip.propTypes = tooltipPropTypes.propTypes;
Tooltip.defaultProps = tooltipPropTypes.defaultProps;
export default compose(withWindowDimensions, withLocalize)(Tooltip);
export default memo(Tooltip);
4 changes: 0 additions & 4 deletions src/components/Tooltip/tooltipPropTypes.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import PropTypes from 'prop-types';
import {windowDimensionsPropTypes} from '../withWindowDimensions';
import variables from '../../styles/variables';
import CONST from '../../CONST';

Expand All @@ -13,9 +12,6 @@ const propTypes = {
/** Children to wrap with Tooltip. */
children: PropTypes.node.isRequired,

/** Props inherited from withWindowDimensions */
...windowDimensionsPropTypes,

/** Any additional amount to manually adjust the horizontal position of the tooltip.
A positive value shifts the tooltip to the right, and a negative value shifts it to the left. */
shiftHorizontal: PropTypes.oneOfType([PropTypes.number, PropTypes.func]),
Expand Down

0 comments on commit d449072

Please sign in to comment.