modules_Tooltip_index.js

import { React, PropTypes, classnames } from '@gravityforms/libraries';
import { debounce, spacerClasses, uniqueId } from '@gravityforms/utils';
import Icon from '../../elements/Icon';
import Text from '../../elements/Text';

const { useEffect, useRef, useState, forwardRef } = React;

// Constants set in CSS.
const TRIGGER_HEIGHT = 16;
const TRIGGER_WIDTH = 16;
const TOOLTIP_OFFSET = 14;

/**
 * @module Tooltip
 * @description A tooltip component to display contextual messages.
 *
 * @since 1.1.15
 *
 * @param {object}                     props                         Component props.
 * @param {number}                     props.buffer                  The buffer from the edge of the window, in px.
 * @param {JSX.Element}                props.children                React element children.
 * @param {string}                     props.content                 Tooltip content. Can only be strings. Use React children for html.
 * @param {object}                     props.contentAttributes       Attributes for the tooltip content text component.
 * @param {object}                     props.customAttributes        Custom attributes for the component.
 * @param {string|Array|object}        props.customClasses           Custom classes for the component.
 * @param {string}                     props.icon                    The name for the icon to use from the Gform admin icon library.
 * @param {string}                     props.iconPrefix              The prefix for the icon class.
 * @param {string}                     props.iconPreset              The preset for the icon (optional).
 * @param {number}                     props.intentDelay             The delay to detect intent before the tooltip displays, in ms.
 * @param {string}                     props.id                      The id for the tooltip. If not passed auto generated using uniqueId from our utils with a prefix of `tooltip`.
 * @param {number}                     props.maxWidth                The max width of the tooltip, in px.
 * @param {string}                     props.position                The position of the tooltip, one of `top`, `right`, `bottom`, or `left`.
 * @param {string|number|Array|object} props.spacing                 The spacing for the component, as a string, number, array, or object.
 * @param {string}                     props.tagName                 The tag to use for the tooltip. Defaults to `div`.
 * @param {string}                     props.theme                   The theme of the tooltip, one of `chathams` or `port`.
 * @param {object}                     props.tooltipCustomAttributes Custom attributes for the tooltip.
 * @param {string}                     type                          The type of the tooltip button, one of `default`, `success`, or `error`.
 * @param {object|null}                ref                           Ref to the component.
 *
 * @return {JSX.Element} Return the functional tooltip component in React.
 *
 * @example
 * import Tooltip from '@gravityforms/components/react/admin/modules/Tooltip';
 *
 * return (
 *      <Tooltip customClasses={ [ 'example-class' ] } maxWidth={ 200 }>
 *          { children }
 *      </Tooltip>
 * );
 *
 */
const Tooltip = forwardRef( ( {
	buffer = 0,
	children = null,
	content = '',
	contentAttributes = {},
	customAttributes = {},
	customClasses = [],
	icon = 'question-mark',
	iconPrefix = 'gform-icon',
	iconPreset = '',
	intentDelay = 500,
	id = '',
	maxWidth = 0,
	position = 'top',
	spacing = '',
	tagName = 'div',
	theme = 'chathams',
	tooltipCustomAttributes = {},
	type = 'default',
}, ref ) => {
	const tooltipId = id || uniqueId( 'tooltip' );

	const tooltipRef = useRef();
	const [ width, setWidth ] = useState( 0 );
	const [ intent, setIntent ] = useState( false );
	const [ animationReady, setAnimationReady ] = useState( false );
	const [ animationActive, setAnimationActive ] = useState( false );
	const [ tooltipPosition, setTooltipPosition ] = useState( 'top' );

	const setIntentTrue = () => {
		setIntent( true );
		if ( tooltipRef.current && ! width ) {
			setWidth( tooltipRef.current.offsetWidth + 1 );
		}
	};
	const setIntentFalse = () => {
		setIntent( false );
		if ( animationReady ) {
			setAnimationActive( false );
		}
		debouncedFadeIn.cancel();
	};

	const debouncedFadeIn = debounce( () => {
		setAnimationReady( true );
		setTooltipPosition( position );
		requestAnimationFrame( () => {
			const smartPosition = getSmartPosition( buffer, position, tooltipRef );
			setTooltipPosition( smartPosition );
			setAnimationActive( true );
		} );
	}, { wait: intentDelay } );

	useEffect( () => {
		if ( intent ) {
			debouncedFadeIn();
		}
	}, [ intent ] );

	/**
	 * @function getPercentageInFrame
	 * @description Get the percentage of an area that is within frame, whether the box area is defined by width and height.
	 *
	 * @param {number} w            The width of the box.
	 * @param {number} h            The width of the box.
	 * @param {number} offsetLeft   The left offset of the box, which is subtracted from the box area.
	 * @param {number} offsetRight  The right offset of the box, which is subtracted from the box area.
	 * @param {number} offsetTop    The top offset of the box, which is subtracted from the box area.
	 * @param {number} offsetBottom The bottom offset of the box, which is subtracted from the box area.
	 *
	 * @return {number} The percentage of the box area that is within frame, as a decimal.
	 */
	const getPercentageInFrame = (
		w,
		h,
		offsetLeft,
		offsetRight,
		offsetTop,
		offsetBottom
	) => {
		const area = w * h;
		let offsetArea = 0;

		if ( offsetLeft > 0 ) {
			offsetArea += offsetLeft * h;
		}
		if ( offsetRight > 0 ) {
			offsetArea += offsetRight * h;
		}
		if ( offsetTop > 0 ) {
			offsetArea += offsetTop * w;
		}
		if ( offsetBottom > 0 ) {
			offsetArea += offsetBottom * w;
		}
		if ( offsetTop > 0 && offsetLeft > 0 ) {
			offsetArea -= offsetTop * offsetLeft;
		}
		if ( offsetTop > 0 && offsetRight > 0 ) {
			offsetArea -= offsetTop * offsetRight;
		}
		if ( offsetBottom > 0 && offsetLeft > 0 ) {
			offsetArea -= offsetBottom * offsetLeft;
		}
		if ( offsetBottom > 0 && offsetRight > 0 ) {
			offsetArea -= offsetBottom * offsetRight;
		}

		return ( area - offsetArea ) / area;
	};

	/**
	 * @function getNewPositionPercentageInFrame
	 * @description Get the new position percentage in frame from the old position.
	 *
	 * @param {string}  from  Initial position of rect, one of `top`, `bottom`, `left`, or `right`.
	 * @param {string}  to    Final position of rect, one of `top`, `bottom`, `left`, or `right`.
	 * @param {DOMRect} rect  The DOMRect object of rect.
	 * @param {Window}  frame The DOM element of the frame.
	 * @param {number}  bf    The buffer from the edge of the frame, in px.
	 *
	 * @return {number} The new position percentage in frame.
	 */
	const getNewPositionPercentageInFrame = ( from, to, rect, frame, bf ) => {
		const frameWidth = frame.innerWidth;
		const frameHeight = frame.innerHeight;
		const fromRectTopDiff = bf - rect.top;
		const fromRectBottomDiff = rect.bottom - ( frameHeight - bf );
		const fromRectLeftDiff = bf - rect.left;
		const fromRectRightDiff = rect.right - ( frameWidth - bf );
		let toRectTopY, toRectBottomY, toRectLeftX, toRectRightX;
		let toRectTopDiff, toRectBottomDiff, toRectLeftDiff, toRectRightDiff;

		switch ( from ) {
			case 'top':
				switch ( to ) {
					case 'top':
						// Top to Top.
						return true;
					case 'bottom':
						// Top to Bottom.
						toRectTopY = rect.bottom + ( 2 * ( TOOLTIP_OFFSET + ( TRIGGER_HEIGHT / 2 ) ) );
						toRectBottomY = toRectTopY + rect.height;
						toRectTopDiff = bf - toRectTopY;
						toRectBottomDiff = toRectBottomY - ( frameHeight - bf );
						toRectLeftDiff = fromRectLeftDiff;
						toRectRightDiff = fromRectRightDiff;
						break;
					case 'left':
						// Top to Left.
						toRectTopY = rect.bottom + TOOLTIP_OFFSET + ( TRIGGER_WIDTH / 2 ) - ( rect.height / 2 );
						toRectBottomY = toRectTopY + rect.height;
						toRectLeftX = rect.left + ( rect.width / 2 ) - ( TRIGGER_WIDTH / 2 ) - TOOLTIP_OFFSET - rect.width;
						toRectRightX = toRectLeftX + rect.width;
						toRectTopDiff = bf - toRectTopY;
						toRectBottomDiff = toRectBottomY - ( frameHeight - bf );
						toRectLeftDiff = bf - toRectLeftX;
						toRectRightDiff = toRectRightX - ( frameWidth - bf );
						break;
					case 'right': // Top to Right.
						toRectTopY = rect.bottom + TOOLTIP_OFFSET + ( TRIGGER_WIDTH / 2 ) - ( rect.height / 2 );
						toRectBottomY = toRectTopY + rect.height;
						toRectLeftX = rect.left + ( rect.width / 2 ) + ( TRIGGER_WIDTH / 2 ) + TOOLTIP_OFFSET;
						toRectRightX = toRectLeftX + rect.width;
						toRectTopDiff = bf - toRectTopY;
						toRectBottomDiff = toRectBottomY - ( frameHeight - bf );
						toRectLeftDiff = bf - toRectLeftX;
						toRectRightDiff = toRectRightX - ( frameWidth - bf );
						break;
					default:
						// Top to anything else.
						return false;
				}
				break;
			case 'bottom':
				switch ( to ) {
					case 'top':
						// Bottom to Top.
						toRectTopY = rect.top - ( 2 * ( TOOLTIP_OFFSET + ( TRIGGER_HEIGHT / 2 ) ) ) - rect.height;
						toRectBottomY = toRectTopY + rect.height;
						toRectTopDiff = bf - toRectTopY;
						toRectBottomDiff = toRectBottomY - ( frameHeight - bf );
						toRectLeftDiff = fromRectLeftDiff;
						toRectRightDiff = fromRectRightDiff;
						break;
					case 'bottom':
						// Bottom to Bottom.
						return true;
					case 'left':
						// Bottom to Left.
						toRectTopY = rect.top - TOOLTIP_OFFSET - ( TRIGGER_WIDTH / 2 ) - ( rect.height / 2 );
						toRectBottomY = toRectTopY + rect.height;
						toRectLeftX = rect.left + ( rect.width / 2 ) - ( TRIGGER_WIDTH / 2 ) - TOOLTIP_OFFSET - rect.width;
						toRectRightX = toRectLeftX + rect.width;
						toRectTopDiff = bf - toRectTopY;
						toRectBottomDiff = toRectBottomY - ( frameHeight - bf );
						toRectLeftDiff = bf - toRectLeftX;
						toRectRightDiff = toRectRightX - ( frameWidth - bf );
						break;
					case 'right':
						// Bottom to Right.
						toRectTopY = rect.top - TOOLTIP_OFFSET - ( TRIGGER_WIDTH / 2 ) - ( rect.height / 2 );
						toRectBottomY = toRectTopY + rect.height;
						toRectLeftX = rect.left + ( rect.width / 2 ) + ( TRIGGER_WIDTH / 2 ) + TOOLTIP_OFFSET;
						toRectRightX = toRectLeftX + rect.width;
						toRectTopDiff = bf - toRectTopY;
						toRectBottomDiff = toRectBottomY - ( frameHeight - bf );
						toRectLeftDiff = bf - toRectLeftX;
						toRectRightDiff = toRectRightX - ( frameWidth - bf );
						break;
					default:
						// Bottom to anything else.
						return false;
				}
				break;
			case 'left':
				switch ( to ) {
					case 'top':
						// Left to Top.
						toRectTopY = rect.top + ( rect.height / 2 ) - ( TRIGGER_HEIGHT / 2 ) - TOOLTIP_OFFSET - rect.height;
						toRectBottomY = toRectTopY + rect.height;
						toRectLeftX = rect.right + TOOLTIP_OFFSET + ( TRIGGER_WIDTH / 2 ) - ( rect.width / 2 );
						toRectRightX = toRectLeftX + rect.width;
						toRectTopDiff = bf - toRectTopY;
						toRectBottomDiff = toRectBottomY - ( frameHeight - bf );
						toRectLeftDiff = bf - toRectLeftX;
						toRectRightDiff = toRectRightX - ( frameWidth - bf );
						break;
					case 'bottom':
						// Left to Bottom.
						toRectTopY = rect.top + ( rect.height / 2 ) + ( TRIGGER_HEIGHT / 2 ) + TOOLTIP_OFFSET;
						toRectBottomY = toRectTopY + rect.height;
						toRectLeftX = rect.right + TOOLTIP_OFFSET + ( TRIGGER_WIDTH / 2 ) - ( rect.width / 2 );
						toRectRightX = toRectLeftX + rect.width;
						toRectTopDiff = bf - toRectTopY;
						toRectBottomDiff = toRectBottomY - ( frameHeight - bf );
						toRectLeftDiff = bf - toRectLeftX;
						toRectRightDiff = toRectRightX - ( frameWidth - bf );
						break;
					case 'left':
						// Left to Left.
						return true;
					case 'right':
						// Left to Right.
						toRectLeftX = rect.right + ( 2 * ( TOOLTIP_OFFSET + ( TRIGGER_WIDTH / 2 ) ) );
						toRectRightX = toRectLeftX + rect.width;
						toRectTopDiff = fromRectTopDiff;
						toRectBottomDiff = fromRectBottomDiff;
						toRectLeftDiff = bf - toRectLeftX;
						toRectRightDiff = toRectRightX - ( frameWidth - bf );
						break;
					default:
						// Left to anything else.
						return false;
				}
				break;
			case 'right':
				switch ( to ) {
					case 'top':
						// Right to Top.
						toRectTopY = rect.top + ( rect.height / 2 ) - ( TRIGGER_HEIGHT / 2 ) - TOOLTIP_OFFSET - rect.height;
						toRectBottomY = toRectTopY + rect.height;
						toRectLeftX = rect.left - TOOLTIP_OFFSET - ( TRIGGER_WIDTH / 2 ) - ( rect.width / 2 );
						toRectRightX = toRectLeftX + rect.width;
						toRectTopDiff = bf - toRectTopY;
						toRectBottomDiff = toRectBottomY - ( frameHeight - bf );
						toRectLeftDiff = bf - toRectLeftX;
						toRectRightDiff = toRectRightX - ( frameWidth - bf );
						break;
					case 'bottom':
						// Right to Bottom.
						toRectTopY = rect.top + ( rect.height / 2 ) + ( TRIGGER_HEIGHT / 2 ) + TOOLTIP_OFFSET;
						toRectBottomY = toRectTopY + rect.height;
						toRectLeftX = rect.left - TOOLTIP_OFFSET - ( TRIGGER_WIDTH / 2 ) - ( rect.width / 2 );
						toRectRightX = toRectLeftX + rect.width;
						toRectTopDiff = bf - toRectTopY;
						toRectBottomDiff = toRectBottomY - ( frameHeight - bf );
						toRectLeftDiff = bf - toRectLeftX;
						toRectRightDiff = toRectRightX - ( frameWidth - bf );
						break;
					case 'left':
						// Right to Left.
						toRectLeftX = rect.left - ( 2 * ( TOOLTIP_OFFSET + ( TRIGGER_WIDTH / 2 ) ) ) - rect.width;
						toRectRightX = toRectLeftX + rect.width;
						toRectTopDiff = fromRectTopDiff;
						toRectBottomDiff = fromRectBottomDiff;
						toRectLeftDiff = bf - toRectLeftX;
						toRectRightDiff = toRectRightX - ( frameWidth - bf );
						break;
					case 'right':
						// Right to Right.
						return true;
					default:
						// Right to anything else.
						return false;
				}
				break;
			default:
				return false;
		}

		return getPercentageInFrame(
			rect.width,
			rect.height,
			toRectLeftDiff,
			toRectRightDiff,
			toRectTopDiff,
			toRectBottomDiff,
		);
	};

	/**
	 * @function getSmartPosition
	 * @description Get smart position based on area in the frame.
	 *
	 * @param {number} bf   The buffer from the edge of the frame, in px.
	 * @param {string} pos  The current position of the tooltip.
	 * @param {object} tRef The ref object of the tooltip.
	 *
	 * @return {string} The smart position based on area.
	 */
	const getSmartPosition = ( bf, pos, tRef ) => {
		if ( ! tRef.current ) {
			// No tooltip ref, return early.
			return pos;
		}

		const tooltipRect = tRef.current.getBoundingClientRect();
		const frame = tRef.current.ownerDocument.defaultView;
		const frameWidth = frame.innerWidth;
		const frameHeight = frame.innerHeight;
		const currentRectTopDiff = bf - tooltipRect.top;
		const currentRectBottomDiff = tooltipRect.bottom - ( frameHeight - bf );
		const currentRectLeftDiff = bf - tooltipRect.left;
		const currentRectRightDiff = tooltipRect.right - ( frameWidth - bf );

		const currentPercentageInFrame = getPercentageInFrame(
			tooltipRect.width,
			tooltipRect.height,
			currentRectLeftDiff,
			currentRectRightDiff,
			currentRectTopDiff,
			currentRectBottomDiff,
		);
		const positions = {};

		switch ( pos ) {
			case 'top':
				positions.top = currentPercentageInFrame;
				positions.bottom = getNewPositionPercentageInFrame( 'top', 'bottom', tooltipRect, frame, bf );
				positions.left = getNewPositionPercentageInFrame( 'top', 'left', tooltipRect, frame, bf );
				positions.right = getNewPositionPercentageInFrame( 'top', 'right', tooltipRect, frame, bf );
				// Current position is largest in frame, return original position.
				break;
			case 'bottom':
				positions.top = getNewPositionPercentageInFrame( 'bottom', 'top', tooltipRect, frame, bf );
				positions.bottom = currentPercentageInFrame;
				positions.left = getNewPositionPercentageInFrame( 'bottom', 'left', tooltipRect, frame, bf );
				positions.right = getNewPositionPercentageInFrame( 'bottom', 'right', tooltipRect, frame, bf );
				// Current position is largest in frame, return original position.
				break;
			case 'left':
				positions.top = getNewPositionPercentageInFrame( 'left', 'top', tooltipRect, frame, bf );
				positions.bottom = getNewPositionPercentageInFrame( 'left', 'bottom', tooltipRect, frame, bf );
				positions.left = currentPercentageInFrame;
				positions.right = getNewPositionPercentageInFrame( 'left', 'right', tooltipRect, frame, bf );
				// Current position is largest in frame, return original position.
				break;
			case 'right':
				positions.top = getNewPositionPercentageInFrame( 'right', 'top', tooltipRect, frame, bf );
				positions.bottom = getNewPositionPercentageInFrame( 'right', 'bottom', tooltipRect, frame, bf );
				positions.left = getNewPositionPercentageInFrame( 'right', 'left', tooltipRect, frame, bf );
				positions.right = currentPercentageInFrame;
				// Current position is largest in frame, return original position.
				break;
			default:
				return pos;
		}

		const smartPosition = Object.keys( positions ).reduce( ( carry, ps ) => {
			if ( positions[ ps ] > positions[ carry ] ) {
				return ps;
			}

			return carry;
		}, pos );

		return smartPosition;
	};

	/**
	 * @function TooltipContent
	 * @description The tooltip content if content exists, otherwise null.
	 *
	 * @param {string} con         Tooltip content. Can only be strings.
	 * @param {object} cAttributes Custom attributes for the tooltip content.
	 *
	 * @return {JSX.Element|null}
	 */
	const TooltipContent = ( {
		con = '',
		cAttributes = { size: 'text-xs' },
	} ) => {
		if ( ! con ) {
			return null;
		}

		const { customClasses: cClasses, ...rest } = cAttributes;
		const className = classnames( [
			'gform-tooltip__tooltip-content',
		], cClasses );

		const attributes = {
			customClasses: className,
			color: 'white',
			size: 'text-xs',
			...rest,
		};

		return <Text { ...attributes }>{ con }</Text>;
	};

	const attributes = {
		className: classnames( {
			'gform-tooltip': true,
			[ `gform-tooltip--position-${ tooltipPosition }` ]: true,
			[ `gform-tooltip--theme-${ theme }` ]: true,
			[ `gform-tooltip--type-${ type }` ]: true,
			'gform-tooltip--initialized': !! width,
			'gform-tooltip--anim-in-ready': animationReady,
			'gform-tooltip--anim-in-active': animationReady && animationActive,
			...spacerClasses( spacing ),
		}, customClasses ),
		...customAttributes,
	};

	const triggerAttributes = {
		className: 'gform-tooltip__trigger',
		'aria-describedby': tooltipId,
		onBlur: setIntentFalse,
		onFocus: setIntentTrue,
		onMouseEnter: setIntentTrue,
		onMouseLeave: setIntentFalse,
	};

	const tooltipAttributes = {
		className: 'gform-tooltip__tooltip',
		role: 'tooltip',
		id: tooltipId,
		onTransitionEnd: () => {
			if ( ! animationActive ) {
				setAnimationReady( false );
			}
		},
		...tooltipCustomAttributes,
	};
	const iconAttributes = {
		icon,
		iconPrefix,
		preset: iconPreset,
	};
	const style = {};
	if ( width ) {
		style.width = width + 'px';
	}
	if ( maxWidth ) {
		style.maxWidth = maxWidth + 'px';
	}
	tooltipAttributes.style = style;

	const Container = tagName;

	return (
		<Container { ...attributes } ref={ ref }>
			<button { ...triggerAttributes }>
				<Icon { ...iconAttributes } />
			</button>
			<div ref={ tooltipRef } { ...tooltipAttributes }>
				<TooltipContent con={ content } cAttributes={ contentAttributes } />
				{ children }
				<span className="gform-tooltip__tooltip-arrow" />
			</div>
		</Container>
	);
} );

Tooltip.propTypes = {
	buffer: PropTypes.number,
	children: PropTypes.oneOfType( [
		PropTypes.arrayOf( PropTypes.node ),
		PropTypes.node,
	] ),
	content: PropTypes.string,
	contentAttributes: PropTypes.object,
	customAttributes: PropTypes.object,
	customClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	icon: PropTypes.string,
	iconPrefix: PropTypes.string,
	iconPreset: PropTypes.string,
	intentDelay: PropTypes.number,
	id: PropTypes.string,
	maxWidth: PropTypes.number,
	position: PropTypes.string,
	spacing: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.number,
		PropTypes.array,
		PropTypes.object,
	] ),
	theme: PropTypes.string,
	tooltipCustomAttributes: PropTypes.object,
	type: PropTypes.string,
};

Tooltip.displayName = 'Tooltip';

export default Tooltip;