UNPKG

@gravityforms/components

Version:

UI components for use in Gravity Forms development. Both React and vanilla js flavors.

574 lines (541 loc) 20.5 kB
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 = 'gravity-component-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, type: 'button', 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;