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:
parent
f366f7d2b3
commit
72bfc3b5ea
1 changed files with 102 additions and 49 deletions
|
@ -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};
|
|
||||||
}
|
|
||||||
|
|
Loading…
Reference in a new issue