Improve API and interactivity of new tooltip

This reworks the API the `InteractiveTooltip` component so that it's more
natural to use just like other React components. You can now supply the target
component as a child and the tooltip content as a prop.

In addition, this tweaks the interactivity to keep the tooltip on screen until
you move the mouse away from the tooltip and its target.

Part of https://github.com/vector-im/riot-web/issues/9753
Part of https://github.com/vector-im/riot-web/issues/9716
This commit is contained in:
J. Ryan Stinnett 2019-06-24 17:03:27 +01:00
parent f366f7d2b3
commit 72bfc3b5ea

View file

@ -33,25 +33,30 @@ function getOrCreateContainer() {
return container; return container;
} }
function isInRect(x, y, rect, buffer = 10) {
const { top, right, bottom, left } = rect;
if (x < (left - buffer) || x > (right + buffer)) {
return false;
}
if (y < (top - buffer) || y > (bottom + buffer)) {
return false;
}
return true;
}
/* /*
* This style of tooltip takes a `target` element's rect and centers the tooltip * This style of tooltip takes a "target" element as its child and centers the
* along one edge of the target. * tooltip along one edge of the target.
*/ */
export default class InteractiveTooltip extends React.Component { export default class InteractiveTooltip extends React.Component {
propTypes: { propTypes: {
// A DOMRect from the target element // Content to show in the tooltip
targetRect: PropTypes.object.isRequired, content: PropTypes.node.isRequired,
// Function to be called on menu close // Function to call when visibility of the tooltip changes
onFinished: PropTypes.func, onVisibilityChange: PropTypes.func,
// If true, insert an invisible screen-sized element behind the
// menu that when clicked will close it.
hasBackground: PropTypes.bool,
// The component to render as the context menu
elementClass: PropTypes.element.isRequired,
// on resize callback
windowResize: PropTypes.func,
// method to close menu
closeTooltip: PropTypes.func,
}; };
constructor() { constructor() {
@ -59,9 +64,20 @@ export default class InteractiveTooltip extends React.Component {
this.state = { this.state = {
contentRect: null, contentRect: null,
visible: false,
}; };
} }
componentDidUpdate() {
// Whenever this passthrough component updates, also render the tooltip
// in a separate DOM tree. This allows the tooltip content to participate
// the normal React rendering cycle: when this component re-renders, the
// tooltip content re-renders.
// Once we upgrade to React 16, this could be done a bit more naturally
// using the portals feature instead.
this.renderTooltip();
}
collectContentRect = (element) => { collectContentRect = (element) => {
// We don't need to clean up when unmounting, so ignore // We don't need to clean up when unmounting, so ignore
if (!element) return; if (!element) return;
@ -71,9 +87,55 @@ export default class InteractiveTooltip extends React.Component {
}); });
} }
render() { collectTarget = (element) => {
const props = this.props; this.target = element;
const { targetRect } = props; }
onBackgroundClick = (ev) => {
this.hideTooltip();
}
onBackgroundMouseMove = (ev) => {
const { clientX: x, clientY: y } = ev;
const { contentRect } = this.state;
const targetRect = this.target.getBoundingClientRect();
if (!isInRect(x, y, contentRect) && !isInRect(x, y, targetRect)) {
this.hideTooltip();
return;
}
}
onTargetMouseOver = (ev) => {
this.showTooltip();
}
showTooltip() {
this.setState({
visible: true,
});
if (this.props.onVisibilityChange) {
this.props.onVisibilityChange(true);
}
}
hideTooltip() {
this.setState({
visible: false,
});
if (this.props.onVisibilityChange) {
this.props.onVisibilityChange(false);
}
}
renderTooltip() {
const { visible } = this.state;
if (!visible) {
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
return null;
}
const targetRect = this.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page // The window X and Y offsets are to adjust position when zoomed in to page
const targetLeft = targetRect.left + window.pageXOffset; const targetLeft = targetRect.left + window.pageXOffset;
@ -108,38 +170,29 @@ export default class InteractiveTooltip extends React.Component {
menuStyle.left = `-${this.state.contentRect.width / 2}px`; menuStyle.left = `-${this.state.contentRect.width / 2}px`;
} }
const ElementClass = props.elementClass; const tooltip = <div className="mx_InteractiveTooltip_wrapper" style={{...position}}>
<div className="mx_ContextualMenu_background"
return <div className="mx_InteractiveTooltip_wrapper" style={{...position}}> onMouseMove={this.onBackgroundMouseMove}
<div className={menuClasses} style={menuStyle} ref={this.collectContentRect}> onClick={this.onBackgroundClick}
{ chevron } />
<ElementClass {...props} onFinished={props.closeTooltip} onResize={props.windowResize} /> <div className={menuClasses}
style={menuStyle}
ref={this.collectContentRect}
>
{chevron}
{this.props.content}
</div> </div>
{ props.hasBackground && <div className="mx_InteractiveTooltip_background"
onClick={props.closeTooltip} /> }
</div>; </div>;
ReactDOM.render(tooltip, getOrCreateContainer());
}
render() {
// We use `cloneElement` here to append some props to the child content
// without using a wrapper element which could disrupt layout.
return React.cloneElement(this.props.children, {
ref: this.collectTarget,
onMouseOver: this.onTargetMouseOver,
});
} }
} }
export function createTooltip(ElementClass, props, hasBackground=true) {
const closeTooltip = function(...args) {
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
if (props && props.onFinished) {
props.onFinished.apply(null, args);
}
};
// We only reference closeTooltip once per call to createTooltip
const menu = <InteractiveTooltip
hasBackground={hasBackground}
{...props}
elementClass={ElementClass}
closeTooltip={closeTooltip} // eslint-disable-line react/jsx-no-bind
windowResize={closeTooltip} // eslint-disable-line react/jsx-no-bind
/>;
ReactDOM.render(menu, getOrCreateContainer());
return {close: closeTooltip};
}