@wordpress/components
Version:
UI components for WordPress.
221 lines (191 loc) • 5.58 kB
JavaScript
/**
* External dependencies
*/
import { includes } from 'lodash';
/**
* WordPress dependencies
*/
import {
Children,
cloneElement,
concatChildren,
useEffect,
useState,
} from '@wordpress/element';
import { useDebounce } from '@wordpress/compose';
/**
* Internal dependencies
*/
import Popover from '../popover';
import Shortcut from '../shortcut';
import { withNextComponent } from './next';
/**
* Time over children to wait before showing tooltip
*
* @type {number}
*/
export const TOOLTIP_DELAY = 700;
const eventCatcher = <div className="event-catcher" />;
const getDisabledElement = ( { eventHandlers, child, childrenWithPopover } ) =>
cloneElement(
<span className="disabled-element-wrapper">
{ cloneElement( eventCatcher, eventHandlers ) }
{ cloneElement( child, {
children: childrenWithPopover,
} ) }
,
</span>,
eventHandlers
);
const getRegularElement = ( { child, eventHandlers, childrenWithPopover } ) =>
cloneElement( child, {
...eventHandlers,
children: childrenWithPopover,
} );
const addPopoverToGrandchildren = ( {
grandchildren,
isOver,
position,
text,
shortcut,
} ) =>
concatChildren(
grandchildren,
isOver && (
<Popover
focusOnMount={ false }
position={ position }
className="components-tooltip"
aria-hidden="true"
animate={ false }
noArrow={ true }
>
{ text }
<Shortcut
className="components-tooltip__shortcut"
shortcut={ shortcut }
/>
</Popover>
)
);
const emitToChild = ( children, eventName, event ) => {
if ( Children.count( children ) !== 1 ) {
return;
}
const child = Children.only( children );
if ( typeof child.props[ eventName ] === 'function' ) {
child.props[ eventName ]( event );
}
};
function Tooltip( { children, position, text, shortcut } ) {
/**
* Whether a mouse is currently pressed, used in determining whether
* to handle a focus event as displaying the tooltip immediately.
*
* @type {boolean}
*/
const [ isMouseDown, setIsMouseDown ] = useState( false );
const [ isOver, setIsOver ] = useState( false );
const delayedSetIsOver = useDebounce( setIsOver, TOOLTIP_DELAY );
const createMouseDown = ( event ) => {
// Preserve original child callback behavior
emitToChild( children, 'onMouseDown', event );
// On mouse down, the next `mouseup` should revert the value of the
// instance property and remove its own event handler. The bind is
// made on the document since the `mouseup` might not occur within
// the bounds of the element.
document.addEventListener( 'mouseup', cancelIsMouseDown );
setIsMouseDown( true );
};
const createMouseUp = ( event ) => {
emitToChild( children, 'onMouseUp', event );
document.removeEventListener( 'mouseup', cancelIsMouseDown );
setIsMouseDown( false );
};
const createMouseEvent = ( type ) => {
if ( type === 'mouseUp' ) return createMouseUp;
if ( type === 'mouseDown' ) return createMouseDown;
};
/**
* Prebound `isInMouseDown` handler, created as a constant reference to
* assure ability to remove in component unmount.
*
* @type {Function}
*/
const cancelIsMouseDown = createMouseEvent( 'mouseUp' );
const createToggleIsOver = ( eventName, isDelayed ) => {
return ( event ) => {
// Preserve original child callback behavior
emitToChild( children, eventName, event );
// Mouse events behave unreliably in React for disabled elements,
// firing on mouseenter but not mouseleave. Further, the default
// behavior for disabled elements in some browsers is to ignore
// mouse events. Don't bother trying to to handle them.
//
// See: https://github.com/facebook/react/issues/4251
if ( event.currentTarget.disabled ) {
return;
}
// A focus event will occur as a result of a mouse click, but it
// should be disambiguated between interacting with the button and
// using an explicit focus shift as a cue to display the tooltip.
if ( 'focus' === event.type && isMouseDown ) {
return;
}
// Needed in case unsetting is over while delayed set pending, i.e.
// quickly blur/mouseleave before delayedSetIsOver is called
delayedSetIsOver.cancel();
const _isOver = includes( [ 'focus', 'mouseenter' ], event.type );
if ( _isOver === isOver ) {
return;
}
if ( isDelayed ) {
delayedSetIsOver( _isOver );
} else {
setIsOver( _isOver );
}
};
};
const clearOnUnmount = () => {
delayedSetIsOver.cancel();
};
useEffect( () => clearOnUnmount, [] );
if ( Children.count( children ) !== 1 ) {
if ( 'development' === process.env.NODE_ENV ) {
// eslint-disable-next-line no-console
console.error(
'Tooltip should be called with only a single child element.'
);
}
return children;
}
const eventHandlers = {
onMouseEnter: createToggleIsOver( 'onMouseEnter', true ),
onMouseLeave: createToggleIsOver( 'onMouseLeave' ),
onClick: createToggleIsOver( 'onClick' ),
onFocus: createToggleIsOver( 'onFocus' ),
onBlur: createToggleIsOver( 'onBlur' ),
onMouseDown: createMouseEvent( 'mouseDown' ),
};
const child = Children.only( children );
const { children: grandchildren, disabled } = child.props;
const getElementWithPopover = disabled
? getDisabledElement
: getRegularElement;
const popoverData = {
isOver,
position,
text,
shortcut,
};
const childrenWithPopover = addPopoverToGrandchildren( {
grandchildren,
...popoverData,
} );
return getElementWithPopover( {
child,
eventHandlers,
childrenWithPopover,
} );
}
export default withNextComponent( Tooltip );