Skip to content

Commit

Permalink
refactor: tooltip component from classical to functional with hooks (#…
Browse files Browse the repository at this point in the history
…27682)

* refactor: tooltip component from classical to functional with hooks

* changes according to CR

Co-authored-by: grzegorz_marzencki <[email protected]>
  • Loading branch information
grzim and grzegorz_marzencki authored Dec 16, 2020
1 parent 694fe11 commit d917f84
Show file tree
Hide file tree
Showing 2 changed files with 126 additions and 162 deletions.
227 changes: 103 additions & 124 deletions packages/components/src/tooltip/index.js
Original file line number Diff line number Diff line change
@@ -1,83 +1,90 @@
/**
* External dependencies
*/
import { debounce, includes } from 'lodash';
import { includes } from 'lodash';

/**
* WordPress dependencies
*/
import {
Component,
Children,
cloneElement,
concatChildren,
useEffect,
useState,
} from '@wordpress/element';

/**
* Internal dependencies
*/
import Popover from '../popover';
import Shortcut from '../shortcut';
import { useDebounce } from '@wordpress/compose';

/**
* Time over children to wait before showing tooltip
*
* @type {number}
*/
const TOOLTIP_DELAY = 700;

class Tooltip extends Component {
constructor() {
super( ...arguments );

this.delayedSetIsOver = debounce(
( isOver ) => this.setState( { isOver } ),
TOOLTIP_DELAY
);

/**
* Prebound `isInMouseDown` handler, created as a constant reference to
* assure ability to remove in component unmount.
*
* @type {Function}
*/
this.cancelIsMouseDown = this.createSetIsMouseDown( false );

/**
* Whether a the mouse is currently pressed, used in determining whether
* to handle a focus event as displaying the tooltip immediately.
*
* @type {boolean}
*/
this.isInMouseDown = false;

this.state = {
isOver: false,
};
}
export const TOOLTIP_DELAY = 700;

componentWillUnmount() {
this.delayedSetIsOver.cancel();
const emitToChild = ( children, eventName, event ) => {
if ( Children.count( children ) !== 1 ) {
return;
}

document.removeEventListener( 'mouseup', this.cancelIsMouseDown );
const child = Children.only( children );
if ( typeof child.props[ eventName ] === 'function' ) {
child.props[ eventName ]( event );
}
};

emitToChild( eventName, event ) {
const { children } = this.props;
if ( Children.count( children ) !== 1 ) {
return;
}
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;
};

const child = Children.only( children );
if ( typeof child.props[ eventName ] === 'function' ) {
child.props[ eventName ]( event );
}
}
/**
* Prebound `isInMouseDown` handler, created as a constant reference to
* assure ability to remove in component unmount.
*
* @type {Function}
*/
const cancelIsMouseDown = createMouseEvent( 'mouseUp' );

createToggleIsOver( eventName, isDelayed ) {
const createToggleIsOver = ( eventName, isDelayed ) => {
return ( event ) => {
// Preserve original child callback behavior
this.emitToChild( eventName, event );
emitToChild( children, eventName, event );

// Mouse events behave unreliably in React for disabled elements,
// firing on mouseenter but not mouseleave. Further, the default
Expand All @@ -92,99 +99,71 @@ class Tooltip extends Component {
// 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 && this.isInMouseDown ) {
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
this.delayedSetIsOver.cancel();
delayedSetIsOver.cancel();

const isOver = includes( [ 'focus', 'mouseenter' ], event.type );
if ( isOver === this.state.isOver ) {
const _isOver = includes( [ 'focus', 'mouseenter' ], event.type );
if ( _isOver === isOver ) {
return;
}

if ( isDelayed ) {
this.delayedSetIsOver( isOver );
delayedSetIsOver( _isOver );
} else {
this.setState( { isOver } );
setIsOver( _isOver );
}
};
}

/**
* Creates an event callback to handle assignment of the `isInMouseDown`
* instance property in response to a `mousedown` or `mouseup` event.
*
* @param {boolean} isMouseDown Whether handler is to be created for the
* `mousedown` event, as opposed to `mouseup`.
*
* @return {Function} Event callback handler.
*/
createSetIsMouseDown( isMouseDown ) {
return ( event ) => {
// Preserve original child callback behavior
this.emitToChild(
isMouseDown ? 'onMouseDown' : 'onMouseUp',
event
};
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.'
);

// 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[
isMouseDown ? 'addEventListener' : 'removeEventListener'
]( 'mouseup', this.cancelIsMouseDown );

this.isInMouseDown = isMouseDown;
};
}

render() {
const { children, position, text, shortcut } = this.props;
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 child = Children.only( children );
const { isOver } = this.state;
return cloneElement( child, {
onMouseEnter: this.createToggleIsOver( 'onMouseEnter', true ),
onMouseLeave: this.createToggleIsOver( 'onMouseLeave' ),
onClick: this.createToggleIsOver( 'onClick' ),
onFocus: this.createToggleIsOver( 'onFocus' ),
onBlur: this.createToggleIsOver( 'onBlur' ),
onMouseDown: this.createSetIsMouseDown( true ),
children: concatChildren(
child.props.children,
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>
)
),
} );
return children;
}

const child = Children.only( children );
return cloneElement( child, {
onMouseEnter: createToggleIsOver( 'onMouseEnter', true ),
onMouseLeave: createToggleIsOver( 'onMouseLeave' ),
onClick: createToggleIsOver( 'onClick' ),
onFocus: createToggleIsOver( 'onFocus' ),
onBlur: createToggleIsOver( 'onBlur' ),
onMouseDown: createMouseEvent( 'mouseDown' ),
children: concatChildren(
child.props.children,
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>
)
),
} );
}

export default Tooltip;
Loading

0 comments on commit d917f84

Please sign in to comment.