diff --git a/res/css/_components.scss b/res/css/_components.scss index 582dc59517..fa388c4e6a 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -86,6 +86,7 @@ @import "./views/elements/_Field.scss"; @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InlineSpinner.scss"; +@import "./views/elements/_InteractiveTooltip.scss"; @import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_MemberEventListSummary.scss"; @import "./views/elements/_MessageEditor.scss"; diff --git a/res/css/views/elements/_InteractiveTooltip.scss b/res/css/views/elements/_InteractiveTooltip.scss new file mode 100644 index 0000000000..a3f5b6edc2 --- /dev/null +++ b/res/css/views/elements/_InteractiveTooltip.scss @@ -0,0 +1,101 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_InteractiveTooltip_wrapper { + position: fixed; + z-index: 5000; +} + +.mx_InteractiveTooltip_background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 1.0; + z-index: 5000; +} + +.mx_InteractiveTooltip { + border-radius: 3px; + background-color: $interactive-tooltip-bg-color; + color: $interactive-tooltip-fg-color; + position: absolute; + font-size: 10px; + font-weight: 600; + padding: 6px; + z-index: 5001; +} + +.mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_top { + top: 10px; // 8px chevron + 2px spacing +} + +.mx_InteractiveTooltip_chevron_top { + position: absolute; + left: calc(50% - 8px); + top: -8px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-bottom: 8px solid $interactive-tooltip-bg-color; + border-right: 8px solid transparent; +} + +// Adapted from https://codyhouse.co/blog/post/css-rounded-triangles-with-clip-path +// by Sebastiano Guerriero (@guerriero_se) +@supports (clip-path: polygon(0% 0%, 100% 100%, 0% 100%)) { + .mx_InteractiveTooltip_chevron_top { + height: 16px; + width: 16px; + background-color: inherit; + border: none; + clip-path: polygon(0% 0%, 100% 100%, 0% 100%); + transform: rotate(135deg); + border-radius: 0 0 0 3px; + top: calc(-8px / 1.414); // sqrt(2) because of rotation + } +} + +.mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_bottom { + bottom: 10px; // 8px chevron + 2px spacing +} + +.mx_InteractiveTooltip_chevron_bottom { + position: absolute; + left: calc(50% - 8px); + bottom: -8px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-top: 8px solid $interactive-tooltip-bg-color; + border-right: 8px solid transparent; +} + +// Adapted from https://codyhouse.co/blog/post/css-rounded-triangles-with-clip-path +// by Sebastiano Guerriero (@guerriero_se) +@supports (clip-path: polygon(0% 0%, 100% 100%, 0% 100%)) { + .mx_InteractiveTooltip_chevron_bottom { + height: 16px; + width: 16px; + background-color: inherit; + border: none; + clip-path: polygon(0% 0%, 100% 100%, 0% 100%); + transform: rotate(-45deg); + border-radius: 0 0 0 3px; + bottom: calc(-8px / 1.414); // sqrt(2) because of rotation + } +} diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index bdccf71540..ed1cc162a0 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -160,6 +160,9 @@ $reaction-row-button-selected-border-color: $accent-color; $tooltip-timeline-bg-color: $tagpanel-bg-color; $tooltip-timeline-fg-color: #ffffff; +$interactive-tooltip-bg-color: $base-color; +$interactive-tooltip-fg-color: #ffffff; + // ***** Mixins! ***** @define-mixin mx_DialogButton { diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 8244485ee3..361f6fa408 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -272,6 +272,9 @@ $reaction-row-button-selected-border-color: $accent-color; $tooltip-timeline-bg-color: $tagpanel-bg-color; $tooltip-timeline-fg-color: #ffffff; +$interactive-tooltip-bg-color: #27303a; +$interactive-tooltip-fg-color: #ffffff; + // ***** Mixins! ***** @define-mixin mx_DialogButton { diff --git a/src/components/views/elements/InteractiveTooltip.js b/src/components/views/elements/InteractiveTooltip.js new file mode 100644 index 0000000000..b3d0b32fa7 --- /dev/null +++ b/src/components/views/elements/InteractiveTooltip.js @@ -0,0 +1,145 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +const InteractiveTooltipContainerId = "mx_InteractiveTooltip_Container"; + +function getOrCreateContainer() { + let container = document.getElementById(InteractiveTooltipContainerId); + + if (!container) { + container = document.createElement("div"); + container.id = InteractiveTooltipContainerId; + document.body.appendChild(container); + } + + return container; +} + +/* + * This style of tooltip takes a `target` element's rect and centers the tooltip + * along one edge of the target. + */ +export default class InteractiveTooltip extends React.Component { + propTypes: { + // A DOMRect from the target element + targetRect: PropTypes.object.isRequired, + // Function to be called on menu close + onFinished: 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() { + super(); + + this.state = { + contentRect: null, + }; + } + + collectContentRect = (element) => { + // We don't need to clean up when unmounting, so ignore + if (!element) return; + + this.setState({ + contentRect: element.getBoundingClientRect(), + }); + } + + render() { + const props = this.props; + const { targetRect } = props; + + // The window X and Y offsets are to adjust position when zoomed in to page + const targetLeft = targetRect.left + window.pageXOffset; + const targetBottom = targetRect.bottom + window.pageYOffset; + const targetTop = targetRect.top + window.pageYOffset; + + // Align the tooltip vertically on whichever side of the target has more + // space available. + const position = {}; + let chevronFace = null; + if (targetBottom < window.innerHeight / 2) { + position.top = targetBottom; + chevronFace = "top"; + } else { + position.bottom = window.innerHeight - targetTop; + chevronFace = "bottom"; + } + + // Center the tooltip horizontally with the target's center. + position.left = targetLeft + targetRect.width / 2; + + const chevron = <div className={"mx_InteractiveTooltip_chevron_" + chevronFace} />; + + const menuClasses = classNames({ + 'mx_InteractiveTooltip': true, + 'mx_InteractiveTooltip_withChevron_top': chevronFace === 'top', + 'mx_InteractiveTooltip_withChevron_bottom': chevronFace === 'bottom', + }); + + const menuStyle = {}; + if (this.state.contentRect) { + menuStyle.left = `-${this.state.contentRect.width / 2}px`; + } + + const ElementClass = props.elementClass; + + return <div className="mx_InteractiveTooltip_wrapper" style={{...position}}> + <div className={menuClasses} style={menuStyle} ref={this.collectContentRect}> + { chevron } + <ElementClass {...props} onFinished={props.closeTooltip} onResize={props.windowResize} /> + </div> + { props.hasBackground && <div className="mx_InteractiveTooltip_background" + onClick={props.closeTooltip} /> } + </div>; + } +} + +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}; +}