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;