From 80d81a10c3e3d9fc8de274a32713fc5184c58241 Mon Sep 17 00:00:00 2001 From: Dalton <daltojohnso@users.noreply.github.com> Date: Thu, 18 Nov 2021 09:30:02 -0800 Subject: [PATCH] Reimplement tooltips using tippy.js (#18880) * add tippy and react-is * add tippy styles to vendor.css we'll probably override most things but it handles apply the triangle to tooltips * reimplement Tooltip using tippy * reimplement ChartTooltip using Tooltip * add tooltip theme styling * fix StoreLink styling * remove TooltipPopover * add util for easy mapping to innerRef * use innerRef util with StoreLink target * react-is type defs * convert Tooltip into tsx * convert styled-components util into typescript * fix Tooltip fallback return * use Tooltip in Icon over Tooltipify * remove Tooltipify * pass ref all the way through Icon * fix DimensionListItem styling caused by missing ref * fix ChartTooltip event target bug * fix unit test by making Icon.tsx grosser * replace popover() util with tooltip() in cy tests * fix random broken e2e test * improve types in Tooltip * move everything to a separate folder + add tests * add more examples * rmv accidental deps * fix run button styling * add Link tooltip prop to avoid tooltip on icon * wrap MetabotLogo in forwardRef * support reduced motion * remove flow type from dom.js so we can use in ts files * pass isEnabled prop to tippy's disabled prop once you've activated "control mode" in tippy you can't turn it off so using "visible" as a toggle doesn't work. instead, use disabled. * fix uncentered tooltip the right way tippy includes padding/margin in its centering calculations, unlike tether, so we will need to be careful about adding one-sides padding/margins to a tooltip target, otherwise the tooltip will appear offcenter * workaround for absolutely positioned element the tooltip appeared on the far end of the target because of a child div that is absolutely positioned. quick fix is to instead target the icon * fix positioning of HintIcon tooltip * lint fix * add placement prop * fix styled component tooltip target styling * place QuestionNotebookButton tooltip below * Make the NativeQueryButton an actual button * set tooltip placement to bottom * lint fix warning --- .../ModerationReviewBanner.jsx | 6 +- .../ModerationReviewBanner.styled.jsx | 4 +- .../PermissionsSelect.styled.jsx | 7 +- .../PermissionsTable.styled.jsx | 9 +- .../CollectionHeader.styled.jsx | 1 + frontend/src/metabase/components/Button.jsx | 41 +++-- .../src/metabase/components/CopyButton.jsx | 12 +- .../src/metabase/components/ExternalLink.jsx | 42 ++--- frontend/src/metabase/components/Icon.tsx | 49 ++++-- frontend/src/metabase/components/Link.jsx | 9 +- .../src/metabase/components/MetabotLogo.jsx | 10 +- frontend/src/metabase/components/Popover.css | 16 ++ .../src/metabase/components/Tooltip.info.js | 21 --- frontend/src/metabase/components/Tooltip.jsx | 134 --------------- .../components/Tooltip/Tooltip.info.js | 81 +++++++++ .../metabase/components/Tooltip/Tooltip.tsx | 92 +++++++++++ .../components/Tooltip/Tooltip.unit.spec.js | 154 ++++++++++++++++++ .../src/metabase/components/Tooltip/index.ts | 1 + .../metabase/components/TooltipPopover.jsx | 40 ----- .../src/metabase/containers/Overworld.jsx | 5 +- frontend/src/metabase/css/vendor.css | 2 + frontend/src/metabase/hoc/Tooltipify.jsx | 26 --- frontend/src/metabase/lib/dom.js | 2 +- .../components/StoreLink/StoreLink.styled.jsx | 10 +- .../src/metabase/nav/containers/Navbar.jsx | 11 +- .../query_builder/components/RunButton.jsx | 115 ++++++------- .../components/notebook/NotebookStep.jsx | 5 +- .../notebook/steps/JoinStep.styled.jsx | 6 +- .../components/view/NativeQueryButton.jsx | 15 +- .../view/NativeQueryButton.styled.jsx | 22 +++ .../view/QuestionNotebookButton.jsx | 5 +- .../components/view/ViewHeader.jsx | 13 +- .../components/view/ViewHeader.styled.jsx | 11 -- .../AddAggregationButton.styled.jsx | 6 +- .../DimensionListItem.styled.jsx | 13 +- .../src/metabase/styled-components/utils.tsx | 13 ++ .../components/ChartTooltip.jsx | 78 +++++---- .../e2e/helpers/e2e-ui-elements-helpers.js | 4 + .../dashboard/dashboard-drill.cy.spec.js | 5 +- .../scenarios/dashboard/dashboard.cy.spec.js | 9 +- .../drillthroughs/chart_drill.cy.spec.js | 9 +- .../visualizations/line_chart.cy.spec.js | 13 +- ...aps-null-location-wrong-tooltip.cy.spec.js | 4 +- .../visualizations/scatter.cy.spec.js | 6 +- package.json | 4 + yarn.lock | 28 +++- 46 files changed, 718 insertions(+), 451 deletions(-) delete mode 100644 frontend/src/metabase/components/Tooltip.info.js delete mode 100644 frontend/src/metabase/components/Tooltip.jsx create mode 100644 frontend/src/metabase/components/Tooltip/Tooltip.info.js create mode 100644 frontend/src/metabase/components/Tooltip/Tooltip.tsx create mode 100644 frontend/src/metabase/components/Tooltip/Tooltip.unit.spec.js create mode 100644 frontend/src/metabase/components/Tooltip/index.ts delete mode 100644 frontend/src/metabase/components/TooltipPopover.jsx delete mode 100644 frontend/src/metabase/hoc/Tooltipify.jsx create mode 100644 frontend/src/metabase/query_builder/components/view/NativeQueryButton.styled.jsx create mode 100644 frontend/src/metabase/styled-components/utils.tsx diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.jsx b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.jsx index 1cfefdf6e49..acc914e17ca 100644 --- a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.jsx +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.jsx @@ -22,7 +22,6 @@ import { import Tooltip from "metabase/components/Tooltip"; const ICON_BUTTON_SIZE = 20; -const TOOLTIP_X_OFFSET = ICON_BUTTON_SIZE / 4; const mapStateToProps = (state, props) => ({ currentUser: getUser(state), @@ -71,10 +70,7 @@ export function ModerationReviewBanner({ onMouseEnter={() => setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} > - <Tooltip - targetOffsetX={TOOLTIP_X_OFFSET} - tooltip={onRemove && tooltipText} - > + <Tooltip tooltip={onRemove && tooltipText}> {onRemove ? ( <IconButton data-testid="moderation-remove-review-action" diff --git a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.styled.jsx b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.styled.jsx index 60439334ad7..f15398c45e8 100644 --- a/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.styled.jsx +++ b/enterprise/frontend/src/metabase-enterprise/moderation/components/ModerationReviewBanner/ModerationReviewBanner.styled.jsx @@ -4,7 +4,7 @@ import Button from "metabase/components/Button"; import Icon from "metabase/components/Icon"; export const Container = styled.div` - padding: 1rem 1rem 1rem 0.5rem; + padding: 1rem; background-color: ${props => props.backgroundColor}; display: flex; justify-content: space-between; @@ -25,7 +25,7 @@ export const Time = styled.time` `; export const IconButton = styled(Button)` - padding: 0 0 0 0.5rem !important; + padding: 0 !important; border: none; background-color: transparent; diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelect.styled.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelect.styled.jsx index c196e6de434..dda8bbee252 100644 --- a/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelect.styled.jsx +++ b/frontend/src/metabase/admin/permissions/components/PermissionsSelect/PermissionsSelect.styled.jsx @@ -1,7 +1,10 @@ import styled from "styled-components"; + +import { forwardRefToInnerRef } from "metabase/styled-components/utils"; import Label from "metabase/components/type/Label"; import { color, lighten } from "metabase/lib/colors"; import Icon from "metabase/components/Icon"; + import { PermissionsSelectOption } from "./PermissionsSelectOption"; export const PermissionsSelectRoot = styled.div` @@ -47,13 +50,13 @@ export const ToggleLabel = styled.label` margin-right: 1rem; `; -export const WarningIcon = styled(Icon).attrs({ +export const WarningIcon = forwardRefToInnerRef(styled(Icon).attrs({ size: 18, name: "warning", })` margin-right: 0.25rem; color: ${color("text-light")}; -`; +`); export const DisabledPermissionOption = styled(PermissionsSelectOption)` color: ${props => diff --git a/frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.styled.jsx b/frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.styled.jsx index 739e25cd9ea..9578b3522f8 100644 --- a/frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.styled.jsx +++ b/frontend/src/metabase/admin/permissions/components/PermissionsTable/PermissionsTable.styled.jsx @@ -1,7 +1,9 @@ import styled from "styled-components"; + import { color, alpha, lighten } from "metabase/lib/colors"; import Link from "metabase/components/Link"; import Icon from "metabase/components/Icon"; +import { forwardRefToInnerRef } from "metabase/styled-components/utils"; const HORIZONTAL_PADDING_VARIANTS = { sm: "0.5rem", @@ -47,8 +49,11 @@ export const EntityNameLink = styled(Link)` export const PermissionTableHeaderRow = styled.tr``; -export const HintIcon = styled(Icon).attrs({ name: "info", size: 12 })` +export const HintIcon = forwardRefToInnerRef(styled(Icon).attrs({ + name: "info", + size: 12, +})` color: ${lighten("text-dark", 0.3)}; margin-left: 0.375rem; cursor: pointer; -`; +`); diff --git a/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.styled.jsx b/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.styled.jsx index 50ddbb4d665..f1968dc942b 100644 --- a/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.styled.jsx +++ b/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.styled.jsx @@ -40,6 +40,7 @@ export const DescriptionTooltipIcon = styled(Icon).attrs({ })` color: ${color("bg-dark")}; margin-left: ${space(1)}; + margin-right: ${space(1)}; margin-top: ${space(0)}; &:hover { diff --git a/frontend/src/metabase/components/Button.jsx b/frontend/src/metabase/components/Button.jsx index d904874ddb1..d68f02e9d1c 100644 --- a/frontend/src/metabase/components/Button.jsx +++ b/frontend/src/metabase/components/Button.jsx @@ -1,13 +1,14 @@ /* eslint-disable react/prop-types */ -import React from "react"; +import React, { forwardRef } from "react"; import PropTypes from "prop-types"; - -import Icon from "metabase/components/Icon"; import cx from "classnames"; import _ from "underscore"; import styled from "styled-components"; import { color, space } from "styled-system"; +import { forwardRefToInnerRef } from "metabase/styled-components/utils"; +import Icon from "metabase/components/Icon"; + const BUTTON_VARIANTS = [ "small", "medium", @@ -24,18 +25,21 @@ const BUTTON_VARIANTS = [ "onlyIcon", ]; -const BaseButton = ({ - className, - icon, - iconRight, - iconSize, - iconColor, - iconVertical, - labelBreakpoint, - color, - children, - ...props -}) => { +const BaseButton = forwardRef(function BaseButton( + { + className, + icon, + iconRight, + iconSize, + iconColor, + iconVertical, + labelBreakpoint, + color, + children, + ...props + }, + ref, +) { const variantClasses = BUTTON_VARIANTS.filter(variant => props[variant]).map( variant => "Button--" + variant, ); @@ -46,6 +50,7 @@ const BaseButton = ({ className={cx("Button", className, "flex-no-shrink", variantClasses, { p1: !children, })} + ref={ref} > <div className={cx("flex layout-centered", { "flex-column": iconVertical })} @@ -75,7 +80,7 @@ const BaseButton = ({ </div> </button> ); -}; +}); BaseButton.propTypes = { className: PropTypes.string, @@ -95,10 +100,10 @@ BaseButton.propTypes = { borderless: PropTypes.bool, }; -const Button = styled(BaseButton)` +const Button = forwardRefToInnerRef(styled(BaseButton)` ${color}; ${space}; -`; +`); Button.displayName = "Button"; diff --git a/frontend/src/metabase/components/CopyButton.jsx b/frontend/src/metabase/components/CopyButton.jsx index dbb892bca13..ea9b2fedda2 100644 --- a/frontend/src/metabase/components/CopyButton.jsx +++ b/frontend/src/metabase/components/CopyButton.jsx @@ -31,13 +31,13 @@ export default class CopyWidget extends Component { render() { const { value, className, style, ...props } = this.props; return ( - <Tooltip tooltip={t`Copied!`} isOpen={this.state.copied}> - <CopyToClipboard text={value} onCopy={this.onCopy}> - <div className={className} style={style} data-testid="copy-button"> + <CopyToClipboard text={value} onCopy={this.onCopy}> + <div className={className} style={style} data-testid="copy-button"> + <Tooltip tooltip={t`Copied!`} isOpen={this.state.copied}> <Icon name="copy" {...props} /> - </div> - </CopyToClipboard> - </Tooltip> + </Tooltip> + </div> + </CopyToClipboard> ); } } diff --git a/frontend/src/metabase/components/ExternalLink.jsx b/frontend/src/metabase/components/ExternalLink.jsx index 0c55d76cad6..2cadcb73c68 100644 --- a/frontend/src/metabase/components/ExternalLink.jsx +++ b/frontend/src/metabase/components/ExternalLink.jsx @@ -1,27 +1,27 @@ /* eslint-disable react/prop-types */ -import React from "react"; +import React, { forwardRef } from "react"; import { getUrlTarget } from "metabase/lib/dom"; -const ExternalLink = ({ - href, - target = getUrlTarget(href), - className, - children, - ...props -}) => ( - <a - href={href} - className={className || "link"} - target={target} - // prevent malicious pages from navigating us away - rel="noopener noreferrer" - // disables quickfilter in tables - onClickCapture={e => e.stopPropagation()} - {...props} - > - {children} - </a> -); +const ExternalLink = forwardRef(function ExternalLink( + { href, target = getUrlTarget(href), className, children, ...props }, + ref, +) { + return ( + <a + ref={ref} + href={href} + className={className || "link"} + target={target} + // prevent malicious pages from navigating us away + rel="noopener noreferrer" + // disables quickfilter in tables + onClickCapture={e => e.stopPropagation()} + {...props} + > + {children} + </a> + ); +}); export default ExternalLink; diff --git a/frontend/src/metabase/components/Icon.tsx b/frontend/src/metabase/components/Icon.tsx index 93e0ac39da7..7595c1f3fe2 100644 --- a/frontend/src/metabase/components/Icon.tsx +++ b/frontend/src/metabase/components/Icon.tsx @@ -1,14 +1,14 @@ -import React, { Component } from "react"; import PropTypes from "prop-types"; +import React, { Component, forwardRef } from "react"; import styled from "styled-components"; import { color, space, hover } from "styled-system"; import cx from "classnames"; -import { color as c } from "metabase/lib/colors"; +import { color as c } from "metabase/lib/colors"; import { loadIcon } from "metabase/icon_paths"; import { stripLayoutProps } from "metabase/lib/utils"; - -import Tooltipify from "metabase/hoc/Tooltipify"; +import Tooltip from "metabase/components/Tooltip"; +import { forwardRefToInnerRef } from "metabase/styled-components/utils"; const MISSING_ICON_NAME = "unknown"; @@ -57,6 +57,7 @@ export const iconPropTypes = { scale: stringOrNumberPropType, tooltip: PropTypes.string, className: PropTypes.string, + innerRef: PropTypes.func.isRequired, }; type IconProps = PropTypes.InferProps<typeof iconPropTypes>; @@ -65,12 +66,12 @@ class BaseIcon extends Component<IconProps> { static propTypes = iconPropTypes; render() { - const { name, className, ...rest } = this.props; + const { name, className, innerRef, ...rest } = this.props; const icon = loadIcon(name) || loadIcon(MISSING_ICON_NAME); if (!icon) { console.warn(`Icon "${name}" does not exist.`); - return <span />; + return <span ref={innerRef} />; } const props = { @@ -99,6 +100,7 @@ class BaseIcon extends Component<IconProps> { const { _role, ...rest } = props; return ( <img + ref={innerRef} src={icon.img} srcSet={` ${icon.img} 1x, @@ -108,25 +110,46 @@ class BaseIcon extends Component<IconProps> { /> ); } else if (icon.svg) { - return <svg {...props} dangerouslySetInnerHTML={{ __html: icon.svg }} />; + return ( + <svg + {...props} + dangerouslySetInnerHTML={{ __html: icon.svg }} + ref={innerRef} + /> + ); } else if (icon.path) { return ( - <svg {...props}> + <svg {...props} ref={innerRef}> <path d={icon.path} /> </svg> ); } else { console.warn(`Icon "${name}" must have an img, svg, or path`); - return <span />; + return <span ref={innerRef} />; } } } -const Icon = styled(BaseIcon)` +const BaseIconWithRef = forwardRefToInnerRef<IconProps>(BaseIcon); + +const StyledIcon = forwardRefToInnerRef<IconProps>(styled(BaseIconWithRef)` ${space} ${color} ${hover} flex-shrink: 0 -`; - -export default Tooltipify(Icon); +`); + +const Icon = forwardRef(function Icon( + { tooltip, ...props }: IconProps, + ref?: React.Ref<any>, +) { + return tooltip ? ( + <Tooltip tooltip={tooltip}> + <StyledIcon {...props} /> + </Tooltip> + ) : ( + <StyledIcon ref={ref} {...props} /> + ); +}); + +export default Icon; diff --git a/frontend/src/metabase/components/Link.jsx b/frontend/src/metabase/components/Link.jsx index 919b5097af4..689e2b6e81c 100644 --- a/frontend/src/metabase/components/Link.jsx +++ b/frontend/src/metabase/components/Link.jsx @@ -4,17 +4,20 @@ import PropTypes from "prop-types"; import { Link as ReactRouterLink } from "react-router"; import styled from "styled-components"; import { display, color, hover, space } from "styled-system"; + import { stripLayoutProps } from "metabase/lib/utils"; +import Tooltip from "metabase/components/Tooltip"; BaseLink.propTypes = { to: PropTypes.string.isRequired, disabled: PropTypes.bool, className: PropTypes.string, children: PropTypes.node, + tooltip: PropTypes.string, }; -function BaseLink({ to, className, children, disabled, ...props }) { - return ( +function BaseLink({ to, className, children, disabled, tooltip, ...props }) { + const link = ( <ReactRouterLink to={to} className={cx(className || "link", { @@ -26,6 +29,8 @@ function BaseLink({ to, className, children, disabled, ...props }) { {children} </ReactRouterLink> ); + + return tooltip ? <Tooltip tooltip={tooltip}>{link}</Tooltip> : link; } const Link = styled(BaseLink)` diff --git a/frontend/src/metabase/components/MetabotLogo.jsx b/frontend/src/metabase/components/MetabotLogo.jsx index c6ec484b864..d8079b2006d 100644 --- a/frontend/src/metabase/components/MetabotLogo.jsx +++ b/frontend/src/metabase/components/MetabotLogo.jsx @@ -1,7 +1,9 @@ -import React from "react"; +import React, { forwardRef } from "react"; -const MetabotLogo = () => ( - <img className="brand-hue" src="app/assets/img/metabot.svg" /> -); +const MetabotLogo = forwardRef(function MetabotLogo(props, ref) { + return ( + <img ref={ref} className="brand-hue" src="app/assets/img/metabot.svg" /> + ); +}); export default MetabotLogo; diff --git a/frontend/src/metabase/components/Popover.css b/frontend/src/metabase/components/Popover.css index 23f7ad7d388..92c4b23a794 100644 --- a/frontend/src/metabase/components/Popover.css +++ b/frontend/src/metabase/components/Popover.css @@ -22,6 +22,22 @@ overflow: auto; } +.tippy-box[data-theme~="tooltip"] { + color: white; + font-weight: bold; + background-color: var(--color-bg-black); + border: none; + pointer-events: none; + line-height: 1.26; + font-size: 12px; + border-radius: 6px; + box-shadow: 0 4px 10px var(--color-shadow); +} + +.tippy-box[data-theme~="tooltip"] .tippy-content { + padding: 10px 12px; +} + /* remove the max-width in cases where the popover content needs to expand * initially added for date pickers so the dual date picker can fully * expand as necessary - metabase#5971 diff --git a/frontend/src/metabase/components/Tooltip.info.js b/frontend/src/metabase/components/Tooltip.info.js deleted file mode 100644 index d9c5a13055a..00000000000 --- a/frontend/src/metabase/components/Tooltip.info.js +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; -import Tooltip from "./Tooltip"; - -export const component = Tooltip; - -export const description = ` -Add context to a target element. -`; - -export const examples = { - default: ( - <Tooltip tooltip="Action"> - <a className="link">Hover on me</a> - </Tooltip> - ), - longerString: ( - <Tooltip tooltip="This does an action that needs some explaining"> - <a className="link">Hover on me</a> - </Tooltip> - ), -}; diff --git a/frontend/src/metabase/components/Tooltip.jsx b/frontend/src/metabase/components/Tooltip.jsx deleted file mode 100644 index c30a3c65d1e..00000000000 --- a/frontend/src/metabase/components/Tooltip.jsx +++ /dev/null @@ -1,134 +0,0 @@ -import React, { Component } from "react"; -import PropTypes from "prop-types"; -import ReactDOM from "react-dom"; - -import TooltipPopover from "./TooltipPopover"; - -// TOOLTIP_STACK and related functions are to ensure only the most recent tooltip is visible - -let TOOLTIP_STACK = []; - -function pushTooltip(component) { - // if for some reason the tooltip is already in the stack (it shouldn't be) remove it and we'll add it again as if it wasn't - TOOLTIP_STACK = TOOLTIP_STACK.filter(t => t !== component); - // close all other tooltips - TOOLTIP_STACK.filter(t => t.state.isOpen).forEach(t => - t.setState({ isOpen: false }), - ); - // add this tooltip - TOOLTIP_STACK.push(component); -} - -function popTooltip(component) { - // remove the tooltip from the stack - TOOLTIP_STACK = TOOLTIP_STACK.filter(t => t !== component); - // reopen the top tooltip, if any - const top = TOOLTIP_STACK[TOOLTIP_STACK.length - 1]; - if (top && !top.state.isOpen) { - top.setState({ isOpen: true }); - } -} - -export default class Tooltip extends Component { - constructor(props, context) { - super(props, context); - - this.state = { - isOpen: false, - isHovered: false, - }; - } - - static propTypes = { - // the tooltip to show - tooltip: PropTypes.node, - // the element to be tooltipped - children: PropTypes.element.isRequired, - // Can be used to show / hide the tooltip based on outside conditions - // like a menu being open - isEnabled: PropTypes.bool, - verticalAttachments: PropTypes.array, - // Whether the tooltip should be shown - isOpen: PropTypes.bool, - }; - - static defaultProps = { - isEnabled: true, - verticalAttachments: ["top", "bottom"], - horizontalAttachments: ["center", "left", "right"], - }; - - componentDidMount() { - const elem = ReactDOM.findDOMNode(this); - - if (elem) { - elem.addEventListener("mouseenter", this._onMouseEnter, false); - elem.addEventListener("mouseleave", this._onMouseLeave, false); - - // HACK: These two event listeners ensure that if a click on the child causes the tooltip to - // unmount (e.x. navigating away) then the popover is removed by the time this component - // unmounts. Previously we were seeing difficult to debug error messages like - // "Cannot read property 'componentDidUpdate' of null" - elem.addEventListener("mousedown", this._onMouseDown, true); - elem.addEventListener("mouseup", this._onMouseUp, true); - } else { - console.warn( - `Tooltip::componentDidMount: no DOM node for tooltip ${this.props.tooltip}`, - ); - } - } - - componentWillUnmount() { - popTooltip(this); - const elem = ReactDOM.findDOMNode(this); - if (elem) { - elem.removeEventListener("mouseenter", this._onMouseEnter, false); - elem.removeEventListener("mouseleave", this._onMouseLeave, false); - elem.removeEventListener("mousedown", this._onMouseDown, true); - elem.removeEventListener("mouseup", this._onMouseUp, true); - } else { - console.warn( - `Tooltip::componentWillUnmount: no DOM node for tooltip ${this.props.tooltip}`, - ); - } - clearTimeout(this.timer); - } - - _onMouseEnter = e => { - pushTooltip(this); - this.setState({ isOpen: true, isHovered: true }); - }; - - _onMouseLeave = e => { - popTooltip(this); - this.setState({ isOpen: false, isHovered: false }); - }; - - _onMouseDown = e => { - this.setState({ isOpen: false }); - }; - - _onMouseUp = e => { - // This is in a timeout to ensure the component has a chance to fully unmount - this.timer = setTimeout( - () => this.setState({ isOpen: this.state.isHovered }), - 0, - ); - }; - - render() { - const { isEnabled, tooltip } = this.props; - const isOpen = - this.props.isOpen != null ? this.props.isOpen : this.state.isOpen; - return ( - <React.Fragment> - {React.Children.only(this.props.children)} - {tooltip && isEnabled && isOpen && ( - <TooltipPopover isOpen={true} target={this} hasArrow {...this.props}> - {this.props.tooltip} - </TooltipPopover> - )} - </React.Fragment> - ); - } -} diff --git a/frontend/src/metabase/components/Tooltip/Tooltip.info.js b/frontend/src/metabase/components/Tooltip/Tooltip.info.js new file mode 100644 index 00000000000..402298e4c9c --- /dev/null +++ b/frontend/src/metabase/components/Tooltip/Tooltip.info.js @@ -0,0 +1,81 @@ +import React from "react"; +import Tooltip from "./Tooltip"; + +export const component = Tooltip; + +export const description = ` +Add context to a target element. +`; + +const jsxContent = ( + <React.Fragment> + <div style={{ backgroundColor: "blue", opacity: "50%" }}> + blah blah blah + </div> + <div style={{ backgroundColor: "red", opacity: "50%" }}>blah blah blah</div> + </React.Fragment> +); + +function ReferenceTargetDemo() { + const [target, setTarget] = React.useState(); + + const onMouseEnter = () => { + setTarget(document.getElementById("reference-target")); + }; + + const onMouseLeave = () => { + setTarget(null); + }; + + return ( + <span> + <a + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + className="link" + > + Hover on me + </a>{" "} + <span id="reference-target" style={{ backgroundColor: "yellow" }}> + target + </span> + <Tooltip + isOpen={!!target} + reference={target} + tooltip="reference tooltip" + /> + </span> + ); +} + +export const examples = { + default: ( + <Tooltip tooltip="Action"> + <a className="link">Hover on me</a> + </Tooltip> + ), + longerString: ( + <Tooltip tooltip="This does an action that needs some explaining"> + <a className="link">Hover on me</a> + </Tooltip> + ), + "changeable maxWidth": ( + <Tooltip + maxWidth="unset" + tooltip="This does an action that needs some explaining and you will see that it does not wrap" + > + <a className="link">Hover on me</a> + </Tooltip> + ), + controllable: ( + <Tooltip isOpen tooltip="this tooltip is always open"> + <a className="link">Hover on me</a> + </Tooltip> + ), + "jsx content": ( + <Tooltip tooltip={jsxContent}> + <a className="link">Hover on me</a> + </Tooltip> + ), + "reference element": <ReferenceTargetDemo />, +}; diff --git a/frontend/src/metabase/components/Tooltip/Tooltip.tsx b/frontend/src/metabase/components/Tooltip/Tooltip.tsx new file mode 100644 index 00000000000..d08d6250c26 --- /dev/null +++ b/frontend/src/metabase/components/Tooltip/Tooltip.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import PropTypes from "prop-types"; +import * as Tippy from "@tippyjs/react"; +import * as ReactIs from "react-is"; + +import { isReducedMotionPreferred } from "metabase/lib/dom"; + +const TippyComponent = Tippy.default; + +Tooltip.propTypes = { + tooltip: PropTypes.node, + children: PropTypes.node, + reference: PropTypes.instanceOf(Element), + placement: PropTypes.string, + isEnabled: PropTypes.bool, + isOpen: PropTypes.bool, + maxWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), +}; + +interface TooltipProps + extends Partial< + Pick<Tippy.TippyProps, "reference" | "placement" | "maxWidth"> + > { + tooltip?: React.ReactNode; + children?: React.ReactNode; + isEnabled?: boolean; + isOpen?: boolean; + maxWidth?: string | number | undefined; +} + +// checking to see if the `element` is in JSX.IntrinisicElements since they support refs +// tippy's `children` prop seems to complain about anything more specific that React.ReactElement, unfortunately +function isReactDOMTypeElement(element: any): element is React.ReactElement { + return ReactIs.isElement(element) && typeof element.type === "string"; +} + +// Tippy relies on child nodes forwarding refs, so when `children` is neither +// a DOM element or a forwardRef, we need to wrap it in a span. +function getSafeChildren(children: React.ReactNode) { + if (isReactDOMTypeElement(children) || ReactIs.isForwardRef(children)) { + return children; + } else { + return <span data-testid="tooltip-component-wrapper">{children}</span>; + } +} + +function getTargetProps( + reference?: Element | React.RefObject<Element> | null, + children?: React.ReactNode, +) { + if (reference) { + return { reference }; + } else if (children != null) { + return { children: getSafeChildren(children) }; + } +} + +function Tooltip({ + tooltip, + children, + reference, + placement, + isEnabled, + isOpen, + maxWidth = 200, +}: TooltipProps) { + const visible = isOpen != null ? isOpen : undefined; + const animationDuration = isReducedMotionPreferred() ? 0 : undefined; + const disabled = isEnabled === false; + const targetProps = getTargetProps(reference, children); + + if (tooltip && targetProps) { + return ( + <TippyComponent + theme="tooltip" + appendTo={() => document.body} + content={tooltip} + visible={visible} + disabled={disabled} + maxWidth={maxWidth} + reference={reference} + duration={animationDuration} + placement={placement} + {...targetProps} + /> + ); + } else { + return <React.Fragment>{children}</React.Fragment>; + } +} + +export default Tooltip; diff --git a/frontend/src/metabase/components/Tooltip/Tooltip.unit.spec.js b/frontend/src/metabase/components/Tooltip/Tooltip.unit.spec.js new file mode 100644 index 00000000000..c62be7b2b52 --- /dev/null +++ b/frontend/src/metabase/components/Tooltip/Tooltip.unit.spec.js @@ -0,0 +1,154 @@ +import React, { forwardRef } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import Tooltip from "./Tooltip"; + +const defaultTooltip = "tooltip content"; + +const defaultTarget = ( + <div id="child-target" style={{ width: 100, height: 100 }}> + child target element + </div> +); + +function setup({ + target = defaultTarget, + tooltip = defaultTooltip, + ...otherProps +} = {}) { + return render( + <div> + <Tooltip tooltip={tooltip} {...otherProps}> + {target} + </Tooltip> + </div>, + ); +} + +describe("Tooltip", () => { + it("should be visible on hover of child target element", () => { + setup(); + userEvent.hover(screen.getByText("child target element")); + expect(screen.getByText("tooltip content")).toBeInTheDocument(); + }); + + describe("isOpen", () => { + it("should override hover behavior", () => { + setup({ isOpen: false }); + + userEvent.hover(screen.getByText("child target element")); + expect(screen.queryByText("tooltip content")).not.toBeInTheDocument(); + }); + + it("should be visible when isOpen is set to true", () => { + setup({ isOpen: true }); + expect(screen.getByText("tooltip content")).toBeInTheDocument(); + }); + }); + + describe("isEnabled", () => { + it("should override hover behavior when false", () => { + setup({ isEnabled: false }); + + userEvent.hover(screen.getByText("child target element")); + expect(screen.queryByText("tooltip content")).not.toBeInTheDocument(); + }); + + it("should not override hover behavior when true", () => { + setup({ isEnabled: true }); + + userEvent.hover(screen.getByText("child target element")); + expect(screen.getByText("tooltip content")).toBeInTheDocument(); + }); + + it("should override isOpen when false", () => { + setup({ isEnabled: false, isOpen: true }); + + userEvent.hover(screen.getByText("child target element")); + expect(screen.queryByText("tooltip content")).not.toBeInTheDocument(); + }); + + it("should not override isOpen when true", () => { + setup({ isEnabled: true, isOpen: true }); + + userEvent.hover(screen.getByText("child target element")); + expect(screen.getByText("tooltip content")).toBeInTheDocument(); + }); + }); + + it("should wrap target Components without forwarded refs in a <span>", () => { + function BadTarget() { + return <div>bad target</div>; + } + + setup({ isOpen: true, target: <BadTarget /> }); + + const tooltip = screen.getByTestId("tooltip-component-wrapper"); + + expect(tooltip).toBeInTheDocument(); + expect(tooltip.textContent).toEqual("bad target"); + }); + + it("should not wrap target Components with forwarded refs", () => { + const GoodTarget = forwardRef(function GoodTarget(props, ref) { + return <div ref={ref}>good target</div>; + }); + + setup({ isOpen: true, target: <GoodTarget /> }); + + expect( + screen.queryByTestId("tooltip-component-wrapper"), + ).not.toBeInTheDocument(); + expect(screen.getByText("good target")).toBeInTheDocument(); + }); + + it("should not wrap targets that are JSX dom elements", () => { + setup({ isOpen: true, target: <div>good target</div> }); + + expect( + screen.queryByTestId("tooltip-component-wrapper"), + ).not.toBeInTheDocument(); + expect(screen.getByText("good target")).toBeInTheDocument(); + }); + + it("should still render children when not given tooltip content", () => { + setup({ isOpen: true, tooltip: null }); + + expect(screen.getByText("child target element")).toBeInTheDocument(); + expect(document.querySelector(".tippy-box")).toBeNull(); + }); + + it("should be themed as a tooltip", () => { + setup({ isOpen: true }); + expect( + document.querySelector('[data-theme~="tooltip"'), + ).toBeInTheDocument(); + }); + + it("should support using a reference element instead of a child target element", () => { + function ReferenceTooltipTest() { + const [eventTarget, setEventTarget] = React.useState(); + return ( + <div> + <Tooltip reference={eventTarget} tooltip="reference tooltip" isOpen /> + <div + onClick={event => { + setEventTarget(event.target); + }} + style={{ width: 100, height: 100 }} + > + sibling element + </div> + </div> + ); + } + + render(<ReferenceTooltipTest />); + expect(screen.queryByText("reference tooltip")).not.toBeInTheDocument(); + + screen.getByText("sibling element").click(); + + expect(screen.getByText("reference tooltip")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/components/Tooltip/index.ts b/frontend/src/metabase/components/Tooltip/index.ts new file mode 100644 index 00000000000..d6006235128 --- /dev/null +++ b/frontend/src/metabase/components/Tooltip/index.ts @@ -0,0 +1 @@ +export { default } from "./Tooltip"; diff --git a/frontend/src/metabase/components/TooltipPopover.jsx b/frontend/src/metabase/components/TooltipPopover.jsx deleted file mode 100644 index d706955c17f..00000000000 --- a/frontend/src/metabase/components/TooltipPopover.jsx +++ /dev/null @@ -1,40 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from "react"; -import cx from "classnames"; - -import Popover from "./Popover"; - -const TooltipPopover = ({ children, constrainedWidth, ...props }) => { - let popoverContent; - - if (typeof children === "string") { - popoverContent = <span>{children}</span>; - } else { - popoverContent = children; - } - - return ( - <Popover - className={cx("PopoverBody--tooltip", { - "PopoverBody--tooltipConstrainedWidth": constrainedWidth, - })} - targetOffsetY={10} - hasArrow - horizontalAttachments={["center", "left", "right"]} - // OnClickOutsideWrapper is unecessary and causes existing popovers not to - // be dismissed if a tooltip is visisble, so pass noOnClickOutsideWrapper - noOnClickOutsideWrapper - role="tooltip" - {...props} - > - {popoverContent} - </Popover> - ); -}; - -TooltipPopover.defaultProps = { - // default to having a constrained toolip, which limits the width so longer strings wrap. - constrainedWidth: true, -}; - -export default React.memo(TooltipPopover); diff --git a/frontend/src/metabase/containers/Overworld.jsx b/frontend/src/metabase/containers/Overworld.jsx index 424becc6b86..f387b3858e8 100644 --- a/frontend/src/metabase/containers/Overworld.jsx +++ b/frontend/src/metabase/containers/Overworld.jsx @@ -74,7 +74,10 @@ class Overworld extends React.Component { return ( <Box> <Flex px={PAGE_PADDING} pt={3} pb={1} align="center"> - <Tooltip tooltip={t`Don't tell anyone, but you're my favorite.`}> + <Tooltip + tooltip={t`Don't tell anyone, but you're my favorite.`} + placement="bottom" + > <MetabotLogo /> </Tooltip> <Box ml={2}> diff --git a/frontend/src/metabase/css/vendor.css b/frontend/src/metabase/css/vendor.css index 927e26c2b47..a3e93c2ceef 100644 --- a/frontend/src/metabase/css/vendor.css +++ b/frontend/src/metabase/css/vendor.css @@ -3,3 +3,5 @@ /* z-index utils */ @import "z-index/z-index.css"; + +@import "tippy.js/dist/tippy.css"; diff --git a/frontend/src/metabase/hoc/Tooltipify.jsx b/frontend/src/metabase/hoc/Tooltipify.jsx deleted file mode 100644 index d93a0a334ff..00000000000 --- a/frontend/src/metabase/hoc/Tooltipify.jsx +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable react/prop-types */ -import React, { Component } from "react"; - -import Tooltip from "metabase/components/Tooltip"; - -const Tooltipify = ComposedComponent => - class extends Component { - static displayName = - "Tooltipify[" + - (ComposedComponent.displayName || ComposedComponent.name) + - "]"; - render() { - const { tooltip, targetOffsetX, ...props } = this.props; - if (tooltip) { - return ( - <Tooltip tooltip={tooltip} targetOffsetX={targetOffsetX}> - <ComposedComponent {...props} /> - </Tooltip> - ); - } else { - return <ComposedComponent {...props} />; - } - } - }; - -export default Tooltipify; diff --git a/frontend/src/metabase/lib/dom.js b/frontend/src/metabase/lib/dom.js index 02c8503ca1a..4052b3d20d6 100644 --- a/frontend/src/metabase/lib/dom.js +++ b/frontend/src/metabase/lib/dom.js @@ -378,7 +378,7 @@ export function parseDataUri(url) { /** * @returns the clip-path CSS property referencing the clip path in the current document, taking into account the <base> tag. */ -export function clipPathReference(id: string): string { +export function clipPathReference(id) { // add the current page URL (with fragment removed) to support pages with <base> tag. // https://stackoverflow.com/questions/18259032/using-base-tag-on-a-page-that-contains-svg-marker-elements-fails-to-render-marke const url = window.location.href.replace(/#.*$/, "") + "#" + id; diff --git a/frontend/src/metabase/nav/components/StoreLink/StoreLink.styled.jsx b/frontend/src/metabase/nav/components/StoreLink/StoreLink.styled.jsx index 8df56a95afc..01c95032387 100644 --- a/frontend/src/metabase/nav/components/StoreLink/StoreLink.styled.jsx +++ b/frontend/src/metabase/nav/components/StoreLink/StoreLink.styled.jsx @@ -1,12 +1,16 @@ import styled from "styled-components"; + import { color, darken } from "metabase/lib/colors"; import { space } from "metabase/styled-components/theme"; import Icon, { IconWrapper } from "metabase/components/Icon"; import ExternalLink from "metabase/components/ExternalLink"; +import { forwardRefToInnerRef } from "metabase/styled-components/utils"; -export const StoreIconRoot = styled(ExternalLink)` - margin-right: ${space(1)}; -`; +export const StoreIconRoot = forwardRefToInnerRef( + styled(ExternalLink)` + margin-right: ${space(1)}; + `, +); export const StoreIconWrapper = styled(IconWrapper)` color: ${color("white")}; diff --git a/frontend/src/metabase/nav/containers/Navbar.jsx b/frontend/src/metabase/nav/containers/Navbar.jsx index b0ddd972ece..75414ea8f95 100644 --- a/frontend/src/metabase/nav/containers/Navbar.jsx +++ b/frontend/src/metabase/nav/containers/Navbar.jsx @@ -252,13 +252,9 @@ export default class Navbar extends Component { to="browse" className="flex align-center rounded transition-background" data-metabase-event={`NavBar;Data Browse`} + tooltip={t`Browse data`} > - <Icon - name="table_spaced" - size={14} - p={"11px"} - tooltip={t`Browse data`} - /> + <Icon name="table_spaced" size={14} p={"11px"} /> </Link> </IconWrapper> )} @@ -291,8 +287,9 @@ export default class Navbar extends Component { to={this.props.plainNativeQuery.question().getUrl()} className="flex align-center" data-metabase-event={`NavBar;SQL`} + tooltip={t`Write SQL`} > - <Icon size={18} p={"11px"} name="sql" tooltip={t`Write SQL`} /> + <Icon size={18} p={"11px"} name="sql" /> </Link> </IconWrapper> )} diff --git a/frontend/src/metabase/query_builder/components/RunButton.jsx b/frontend/src/metabase/query_builder/components/RunButton.jsx index 71d4c5d31e7..d2b74ba7eaf 100644 --- a/frontend/src/metabase/query_builder/components/RunButton.jsx +++ b/frontend/src/metabase/query_builder/components/RunButton.jsx @@ -1,5 +1,5 @@ /* eslint-disable react/prop-types */ -import React, { Component } from "react"; +import React, { forwardRef } from "react"; import PropTypes from "prop-types"; import { t } from "ttag"; @@ -7,62 +7,65 @@ import Button from "metabase/components/Button"; import cx from "classnames"; -export default class RunButton extends Component { - static propTypes = { - className: PropTypes.string, - isRunning: PropTypes.bool.isRequired, - isDirty: PropTypes.bool.isRequired, - isPreviewing: PropTypes.bool, - onRun: PropTypes.func.isRequired, - onCancel: PropTypes.func, - }; +const propTypes = { + className: PropTypes.string, + isRunning: PropTypes.bool.isRequired, + isDirty: PropTypes.bool.isRequired, + isPreviewing: PropTypes.bool, + onRun: PropTypes.func.isRequired, + onCancel: PropTypes.func, +}; - static defaultProps = {}; - - render() { - const { - isRunning, - isDirty, - isPreviewing, - onRun, - onCancel, - className, - compact, - circular, - hidden, - ...props - } = this.props; - let buttonText = null; - let buttonIcon = null; - if (isRunning) { - buttonIcon = "close"; - if (!compact) { - buttonText = t`Cancel`; - } - } else if (isDirty) { - if (compact) { - buttonIcon = "play"; - } else { - buttonText = isPreviewing ? t`Get Preview` : t`Get Answer`; - } +const RunButton = forwardRef(function RunButton( + { + isRunning, + isDirty, + isPreviewing, + onRun, + onCancel, + className, + compact, + circular, + hidden, + ...props + }, + ref, +) { + let buttonText = null; + let buttonIcon = null; + if (isRunning) { + buttonIcon = "close"; + if (!compact) { + buttonText = t`Cancel`; + } + } else if (isDirty) { + if (compact) { + buttonIcon = "play"; } else { - buttonIcon = "refresh"; + buttonText = isPreviewing ? t`Get Preview` : t`Get Answer`; } - return ( - <Button - {...props} - icon={buttonIcon} - primary={isDirty} - iconSize={16} - className={cx(className, "RunButton", { - "RunButton--hidden": hidden, - "RunButton--compact": circular && !props.borderless && compact, - circular: circular, - })} - onClick={isRunning ? onCancel : onRun} - > - {buttonText} - </Button> - ); + } else { + buttonIcon = "refresh"; } -} + return ( + <Button + {...props} + icon={buttonIcon} + primary={isDirty} + iconSize={16} + className={cx(className, "RunButton", { + "RunButton--hidden": hidden, + "RunButton--compact": circular && !props.borderless && compact, + circular: circular, + })} + onClick={isRunning ? onCancel : onRun} + ref={ref} + > + {buttonText} + </Button> + ); +}); + +RunButton.propTypes = propTypes; + +export default RunButton; diff --git a/frontend/src/metabase/query_builder/components/notebook/NotebookStep.jsx b/frontend/src/metabase/query_builder/components/notebook/NotebookStep.jsx index 65813fb911d..9fc472c5947 100644 --- a/frontend/src/metabase/query_builder/components/notebook/NotebookStep.jsx +++ b/frontend/src/metabase/query_builder/components/notebook/NotebookStep.jsx @@ -12,6 +12,7 @@ import Tooltip from "metabase/components/Tooltip"; import Icon from "metabase/components/Icon"; import Button from "metabase/components/Button"; import ExpandingContent from "metabase/components/ExpandingContent"; +import { forwardRefToInnerRef } from "metabase/styled-components/utils"; import { Box, Flex } from "grid-styled"; @@ -217,7 +218,7 @@ export default class NotebookStep extends React.Component { } } -const ColorButton = styled(Button)` +const ColorButton = forwardRefToInnerRef(styled(Button)` border: none; color: ${({ color }) => (color ? color : c("text-medium"))}; background-color: ${({ color }) => (color ? lighten(color, 0.61) : null)}; @@ -227,7 +228,7 @@ const ColorButton = styled(Button)` color ? lighten(color, 0.5) : lighten(color("brand"), 0.61)}; } transition: background 300ms; -`; +`); const ActionButton = ({ icon, title, color, large, onClick, ...props }) => { const button = ( diff --git a/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.styled.jsx b/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.styled.jsx index c1875a9ae63..315d73a82c9 100644 --- a/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.styled.jsx +++ b/frontend/src/metabase/query_builder/components/notebook/steps/JoinStep.styled.jsx @@ -1,17 +1,19 @@ import styled from "styled-components"; + import { color } from "metabase/lib/colors"; import { space, breakpointMaxMedium } from "metabase/styled-components/theme"; import Icon from "metabase/components/Icon"; +import { forwardRefToInnerRef } from "metabase/styled-components/utils"; export const Row = styled.div` display: flex; align-items: center; `; -export const JoinStepRoot = styled.div` +export const JoinStepRoot = forwardRefToInnerRef(styled.div` display: flex; align-items: center; -`; +`); export const JoinClausesContainer = styled.div` display: flex; diff --git a/frontend/src/metabase/query_builder/components/view/NativeQueryButton.jsx b/frontend/src/metabase/query_builder/components/view/NativeQueryButton.jsx index bb5a7323cb7..d6a48214325 100644 --- a/frontend/src/metabase/query_builder/components/view/NativeQueryButton.jsx +++ b/frontend/src/metabase/query_builder/components/view/NativeQueryButton.jsx @@ -4,14 +4,14 @@ import { t } from "ttag"; import _ from "underscore"; import Modal from "metabase/components/Modal"; -import Icon from "metabase/components/Icon"; import Button from "metabase/components/Button"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; - +import Tooltip from "metabase/components/Tooltip"; import { formatNativeQuery, getEngineNativeType } from "metabase/lib/engine"; - import { MetabaseApi } from "metabase/services"; +import { SqlIconButton } from "./NativeQueryButton.styled"; + const STRINGS = { "": { tooltip: t`View the native query`, @@ -80,12 +80,9 @@ export default class NativeQueryButton extends React.Component { return ( <span {...props}> - <Icon - name="sql" - size={size} - tooltip={tooltip} - onClick={this.handleOpen} - /> + <Tooltip tooltip={tooltip} placement="bottom"> + <SqlIconButton iconSize={size} onClick={this.handleOpen} /> + </Tooltip> <Modal isOpen={this.state.open} title={title} diff --git a/frontend/src/metabase/query_builder/components/view/NativeQueryButton.styled.jsx b/frontend/src/metabase/query_builder/components/view/NativeQueryButton.styled.jsx new file mode 100644 index 00000000000..38b7862fd49 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/view/NativeQueryButton.styled.jsx @@ -0,0 +1,22 @@ +import styled from "styled-components"; + +import { color } from "metabase/lib/colors"; +import { space } from "metabase/styled-components/theme"; +import Button from "metabase/components/Button"; +import { forwardRefToInnerRef } from "metabase/styled-components/utils"; + +export const SqlIconButton = forwardRefToInnerRef(styled(Button).attrs({ + icon: "sql", +})` + margin-left: ${space(2)}; + padding: ${space(1)}; + border: none; + background-color: transparent; + color: ${color("text-medium")}; + cursor: pointer; + + :hover { + background-color: transparent; + color: ${color("brand")}; + } +`); diff --git a/frontend/src/metabase/query_builder/components/view/QuestionNotebookButton.jsx b/frontend/src/metabase/query_builder/components/view/QuestionNotebookButton.jsx index 81c420c6779..184b452a505 100644 --- a/frontend/src/metabase/query_builder/components/view/QuestionNotebookButton.jsx +++ b/frontend/src/metabase/query_builder/components/view/QuestionNotebookButton.jsx @@ -15,7 +15,10 @@ export default function QuestionNotebookButton({ ...props }) { return QuestionNotebookButton.shouldRender({ question }) ? ( - <Tooltip tooltip={isShowingNotebook ? t`Hide editor` : t`Show editor`}> + <Tooltip + tooltip={isShowingNotebook ? t`Hide editor` : t`Show editor`} + placement="bottom" + > <Button borderless={!isShowingNotebook} primary={isShowingNotebook} diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx b/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx index cfea5106ed8..ca3cb076f9b 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader.jsx @@ -32,7 +32,6 @@ import { ViewHeaderMainLeftContentContainer, ViewHeaderLeftSubHeading, ViewHeaderContainer, - ViewSQLButtonContainer, } from "./ViewHeader.styled"; const viewTitleHeaderPropTypes = { @@ -395,13 +394,11 @@ function ViewTitleHeaderRightSide(props) { /> )} {NativeQueryButton.shouldRender(props) && ( - <ViewSQLButtonContainer> - <NativeQueryButton - size={16} - question={question} - data-metabase-event={`Notebook Mode; Convert to SQL Click`} - /> - </ViewSQLButtonContainer> + <NativeQueryButton + size={16} + question={question} + data-metabase-event={`Notebook Mode; Convert to SQL Click`} + /> )} {isNative && isSaved && <ExploreResultsLink question={question} />} {isRunnable && !isNativeEditorOpen && ( diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.jsx b/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.jsx index 633b4f1539e..e5f41dc8383 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.jsx +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader.styled.jsx @@ -48,14 +48,3 @@ export const SavedQuestionHeaderButtonContainer = styled.div` position: relative; right: 0.38rem; `; - -export const ViewSQLButtonContainer = styled.div` - margin-left: ${space(2)}; - padding: ${space(1)}; - - cursor: pointer; - color: ${color("text-medium")}; - :hover { - color: ${color("brand")}; - } -`; diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AddAggregationButton/AddAggregationButton.styled.jsx b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AddAggregationButton/AddAggregationButton.styled.jsx index aaa3491fb65..f554a513100 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AddAggregationButton/AddAggregationButton.styled.jsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AddAggregationButton/AddAggregationButton.styled.jsx @@ -1,7 +1,9 @@ import styled from "styled-components"; import { color } from "metabase/lib/colors"; -export const AddAggregationButtonRoot = styled.button` +import { forwardRefToInnerRef } from "metabase/styled-components/utils"; + +export const AddAggregationButtonRoot = forwardRefToInnerRef(styled.button` display: inline-flex; align-items: center; justify-content: center; @@ -18,4 +20,4 @@ export const AddAggregationButtonRoot = styled.button` &:hover { background-color: ${color("bg-medium")}; } -`; +`); diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/DimensionList/DimensionListItem/DimensionListItem.styled.jsx b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/DimensionList/DimensionListItem/DimensionListItem.styled.jsx index d4c9229e877..f12444040f0 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/DimensionList/DimensionListItem/DimensionListItem.styled.jsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/DimensionList/DimensionListItem/DimensionListItem.styled.jsx @@ -1,6 +1,7 @@ import styled, { css } from "styled-components"; -import { color, alpha } from "metabase/lib/colors"; +import { color, alpha } from "metabase/lib/colors"; +import { forwardRefToInnerRef } from "metabase/styled-components/utils"; import Icon from "metabase/components/Icon"; export const SubDimensionButton = styled.button` @@ -50,7 +51,7 @@ export const DimensionListItemRemoveButton = styled.button` } `; -export const DimensionListItemAddButton = styled.button` +export const _DimensionListItemAddButton = styled.button` display: flex; align-items: center; align-self: stretch; @@ -62,6 +63,10 @@ export const DimensionListItemAddButton = styled.button` cursor: pointer; `; +export const DimensionListItemAddButton = forwardRefToInnerRef( + _DimensionListItemAddButton, +); + export const DimensionListItemIcon = styled(Icon)` color: ${color("text-medium")}; `; @@ -99,12 +104,12 @@ const unselectedStyle = css` &:hover { ${DimensionListItemIcon}, ${DimensionListItemContent}, - ${DimensionListItemAddButton} { + ${_DimensionListItemAddButton} { color: ${color("accent1")}; background-color: ${color("bg-light")}; } - ${DimensionListItemAddButton}:hover { + ${_DimensionListItemAddButton}:hover { background-color: ${color("bg-medium")}; } diff --git a/frontend/src/metabase/styled-components/utils.tsx b/frontend/src/metabase/styled-components/utils.tsx new file mode 100644 index 00000000000..acb9f5cb258 --- /dev/null +++ b/frontend/src/metabase/styled-components/utils.tsx @@ -0,0 +1,13 @@ +import React, { forwardRef } from "react"; + +// this function should be removed after we update to styled-components v4 +export function forwardRefToInnerRef<Props>( + Component: React.ComponentType<Props>, +) { + return forwardRef(function StyledComponentWithInnerRef( + props: Props, + ref?: React.Ref<any>, + ) { + return <Component {...props} innerRef={ref} />; + }); +} diff --git a/frontend/src/metabase/visualizations/components/ChartTooltip.jsx b/frontend/src/metabase/visualizations/components/ChartTooltip.jsx index fe44efebeee..ac9bf72eea2 100644 --- a/frontend/src/metabase/visualizations/components/ChartTooltip.jsx +++ b/frontend/src/metabase/visualizations/components/ChartTooltip.jsx @@ -2,7 +2,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; -import TooltipPopover from "metabase/components/TooltipPopover"; +import Tooltip from "metabase/components/Tooltip"; import { getFriendlyName } from "metabase/visualizations/lib/utils"; import { formatValue } from "metabase/lib/formatting"; @@ -45,35 +45,42 @@ export default class ChartTooltip extends Component { render() { const { hovered, settings } = this.props; const rows = this._getRows(); - const hasEventOrElement = - hovered && - ((hovered.element && document.body.contains(hovered.element)) || - hovered.event); - const isOpen = rows.length > 0 && !!hasEventOrElement; - return ( - <TooltipPopover - target={hovered && hovered.element} - targetEvent={hovered && hovered.event} - verticalAttachments={["bottom", "top"]} + + const hasTargetElement = + hovered?.element != null && document.body.contains(hovered.element); + const hasTargetEvent = hovered?.event != null; + + const isOpen = (rows.length > 0 && hasTargetElement) || hasTargetEvent; + + let target; + if (hasTargetElement) { + target = hovered.element; + } else if (hasTargetEvent) { + target = getEventTarget(hovered.event); + } + + return target ? ( + <Tooltip + reference={target} isOpen={isOpen} - // Make sure that for chart tooltips we don't constrain the width so longer strings don't get cut off - constrainedWidth={false} - > - <table className="py1 px2"> - <tbody> - {rows.map(({ key, value, col }, index) => ( - <TooltipRow - key={index} - name={key} - value={value} - column={col} - settings={settings} - /> - ))} - </tbody> - </table> - </TooltipPopover> - ); + tooltip={ + <table className="py1 px2"> + <tbody> + {rows.map(({ key, value, col }, index) => ( + <TooltipRow + key={index} + name={key} + value={value} + column={col} + settings={settings} + /> + ))} + </tbody> + </table> + } + maxWidth="unset" + /> + ) : null; } } @@ -98,3 +105,16 @@ export function formatValueForTooltip({ value, column, settings }) { majorWidth: 0, }); } + +function getEventTarget(event) { + let target = document.getElementById("popover-event-target"); + if (!target) { + target = document.createElement("div"); + target.id = "popover-event-target"; + document.body.appendChild(target); + } + target.style.left = event.clientX - 3 + "px"; + target.style.top = event.clientY - 3 + "px"; + + return target; +} diff --git a/frontend/test/__support__/e2e/helpers/e2e-ui-elements-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-ui-elements-helpers.js index 6dd1c3b7046..dcc4723bf6a 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-ui-elements-helpers.js +++ b/frontend/test/__support__/e2e/helpers/e2e-ui-elements-helpers.js @@ -3,6 +3,10 @@ export function popover() { return cy.get(".PopoverContainer.PopoverContainer--open"); } +export function tooltip() { + return cy.get(".tippy-box[data-state~='visible']"); +} + export function modal() { return cy.get(".ModalContainer .ModalContent"); } diff --git a/frontend/test/metabase/scenarios/dashboard/dashboard-drill.cy.spec.js b/frontend/test/metabase/scenarios/dashboard/dashboard-drill.cy.spec.js index c6393ea3273..2820b34e4e2 100644 --- a/frontend/test/metabase/scenarios/dashboard/dashboard-drill.cy.spec.js +++ b/frontend/test/metabase/scenarios/dashboard/dashboard-drill.cy.spec.js @@ -2,6 +2,7 @@ import { restore, modal, popover, + tooltip, filterWidget, showDashboardCardActions, } from "__support__/e2e/cypress"; @@ -736,7 +737,7 @@ describe("scenarios > dashboard > dashboard drill", () => { .first() .trigger("mousemove"); - popover().within(() => { + tooltip().within(() => { testPairedTooltipValues("AXIS", "1"); testPairedTooltipValues("VALUE", "5"); }); @@ -745,7 +746,7 @@ describe("scenarios > dashboard > dashboard drill", () => { .last() .trigger("mousemove"); - popover().within(() => { + tooltip().within(() => { testPairedTooltipValues("AXIS", "1"); testPairedTooltipValues("VALUE", "10"); }); diff --git a/frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js b/frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js index 5f73c986482..952e9961abf 100644 --- a/frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js +++ b/frontend/test/metabase/scenarios/dashboard/dashboard.cy.spec.js @@ -7,6 +7,7 @@ import { filterWidget, sidebar, } from "__support__/e2e/cypress"; +import { modal } from "__support__/e2e/helpers/e2e-ui-elements-helpers"; import { SAMPLE_DATASET } from "__support__/e2e/cypress_sample_dataset"; @@ -28,9 +29,11 @@ describe("scenarios > dashboard", () => { cy.visit("/"); cy.icon("add").click(); cy.findByText("New dashboard").click(); - cy.findByLabelText("Name").type("Test Dash"); - cy.findByLabelText("Description").type("Desc"); - cy.findByText("Create").click(); + modal().within(() => { + cy.findByLabelText("Name").type("Test Dash"); + cy.findByLabelText("Description").type("Desc"); + cy.findByText("Create").click(); + }); cy.findByText("This dashboard is looking empty."); cy.findByText("You're editing this dashboard."); diff --git a/frontend/test/metabase/scenarios/visualizations/drillthroughs/chart_drill.cy.spec.js b/frontend/test/metabase/scenarios/visualizations/drillthroughs/chart_drill.cy.spec.js index 3a6e8005790..43ed277e950 100644 --- a/frontend/test/metabase/scenarios/visualizations/drillthroughs/chart_drill.cy.spec.js +++ b/frontend/test/metabase/scenarios/visualizations/drillthroughs/chart_drill.cy.spec.js @@ -3,6 +3,7 @@ import { openProductsTable, openOrdersTable, popover, + tooltip, sidebar, visitQuestionAdhoc, visualize, @@ -397,13 +398,13 @@ describe("scenarios > visualizations > drillthroughs > chart drill", () => { cy.visit(`/question/${QUESTION_ID}`); clickLineDot({ index: 0 }); - popover().within(() => { + tooltip().within(() => { cy.findByText("January 1, 2020"); cy.findByText("10"); }); clickLineDot({ index: 1 }); - popover().within(() => { + tooltip().within(() => { cy.findByText("January 2, 2020"); cy.findByText("5"); }); @@ -430,7 +431,7 @@ describe("scenarios > visualizations > drillthroughs > chart drill", () => { cy.get(".bar") .last() .trigger("mousemove"); - popover().findByText("12"); + tooltip().findByText("12"); }); it.skip("should drill-through a custom question that joins a native SQL question (metabase#14495)", () => { @@ -613,7 +614,7 @@ describe("scenarios > visualizations > drillthroughs > chart drill", () => { .first() .as("doohickeyChart") .trigger("mousemove"); - popover().within(() => { + tooltip().within(() => { cy.findByText("Doohickey"); cy.findByText("42"); }); diff --git a/frontend/test/metabase/scenarios/visualizations/line_chart.cy.spec.js b/frontend/test/metabase/scenarios/visualizations/line_chart.cy.spec.js index 7fc69e7e64e..711d57ad99f 100644 --- a/frontend/test/metabase/scenarios/visualizations/line_chart.cy.spec.js +++ b/frontend/test/metabase/scenarios/visualizations/line_chart.cy.spec.js @@ -1,4 +1,9 @@ -import { restore, visitQuestionAdhoc, popover } from "__support__/e2e/cypress"; +import { + restore, + visitQuestionAdhoc, + popover, + tooltip, +} from "__support__/e2e/cypress"; import { SAMPLE_DATASET } from "__support__/e2e/cypress_sample_dataset"; const { ORDERS, ORDERS_ID, PRODUCTS, PRODUCTS_ID } = SAMPLE_DATASET; @@ -123,7 +128,7 @@ describe("scenarios > visualizations > line chart", () => { .find(".dot") .eq(3) .trigger("mousemove", { force: true }); - popover().within(() => { + tooltip().within(() => { testPairedTooltipValues("Product → Rating", "2.7"); testPairedTooltipValues("Count", "191"); testPairedTooltipValues("Sum of Total", "14,747.05"); @@ -240,13 +245,13 @@ describe("scenarios > visualizations > line chart", () => { assertOnYAxisValues(); showTooltipForFirstCircleInSeries(0); - popover().within(() => { + tooltip().within(() => { testPairedTooltipValues("Created At", "2016"); testPairedTooltipValues(RENAMED_FIRST_SERIES, "42,156.87"); }); showTooltipForFirstCircleInSeries(1); - popover().within(() => { + tooltip().within(() => { testPairedTooltipValues("Created At", "2016"); testPairedTooltipValues(RENAMED_SECOND_SERIES, "54.44"); }); diff --git a/frontend/test/metabase/scenarios/visualizations/reproductions/18063-maps-null-location-wrong-tooltip.cy.spec.js b/frontend/test/metabase/scenarios/visualizations/reproductions/18063-maps-null-location-wrong-tooltip.cy.spec.js index 59303eb4d74..944c0528cc9 100644 --- a/frontend/test/metabase/scenarios/visualizations/reproductions/18063-maps-null-location-wrong-tooltip.cy.spec.js +++ b/frontend/test/metabase/scenarios/visualizations/reproductions/18063-maps-null-location-wrong-tooltip.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, popover } from "__support__/e2e/cypress"; +import { restore, popover, tooltip } from "__support__/e2e/cypress"; const questionDetails = { name: "18063", @@ -39,7 +39,7 @@ describe.skip("issue 18063", () => { cy.get(".leaflet-marker-icon").trigger("mousemove"); - popover().within(() => { + tooltip().within(() => { testPairedTooltipValues("LATITUDE", "55.6761"); testPairedTooltipValues("LONGITUDE", "12.5683"); testPairedTooltipValues("COUNT", "1"); diff --git a/frontend/test/metabase/scenarios/visualizations/scatter.cy.spec.js b/frontend/test/metabase/scenarios/visualizations/scatter.cy.spec.js index 065dfce2939..3ece69151f9 100644 --- a/frontend/test/metabase/scenarios/visualizations/scatter.cy.spec.js +++ b/frontend/test/metabase/scenarios/visualizations/scatter.cy.spec.js @@ -1,4 +1,4 @@ -import { restore, visitQuestionAdhoc, popover } from "__support__/e2e/cypress"; +import { restore, visitQuestionAdhoc, tooltip } from "__support__/e2e/cypress"; import { SAMPLE_DATASET } from "__support__/e2e/cypress_sample_dataset"; const { ORDERS, ORDERS_ID, PRODUCTS } = SAMPLE_DATASET; @@ -38,7 +38,7 @@ describe("scenarios > visualizations > scatter", () => { }); triggerPopoverForBubble(); - popover().within(() => { + tooltip().within(() => { cy.findByText("Created At:"); cy.findByText("Count:"); cy.findByText(/Distinct values of Products? → ID:/); @@ -64,7 +64,7 @@ describe("scenarios > visualizations > scatter", () => { }); triggerPopoverForBubble(); - popover().within(() => { + tooltip().within(() => { cy.findByText("Created At:"); cy.findByText("Orders count:"); cy.findByText("Products count:"); diff --git a/package.json b/package.json index 3470eab0eaa..9a6954ab9fe 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@snowplow/browser-tracker": "^3.1.6", + "@tippyjs/react": "^4.2.6", "@visx/axis": "1.8.0", "@visx/clip-path": "^2.1.0", "@visx/grid": "1.16.0", @@ -66,6 +67,7 @@ "react-element-to-jsx-string": "^13.1.0", "react-grid-layout": "^1.2.5", "react-hot-loader": "^4.13.0", + "react-is": "^17.0.2", "react-markdown": "^6.0.2", "react-motion": "^0.4.5", "react-redux": "^5.0.4", @@ -94,6 +96,7 @@ "styled-components": "3.2.6", "styled-system": "2.2.5", "tether": "^1.2.0", + "tippy.js": "^6.3.5", "ttag": "1.7.15", "underscore": "^1.8.3", "yarn.lock": "^0.0.1-security", @@ -157,6 +160,7 @@ "@types/react-copy-to-clipboard": "^5.0.2", "@types/react-dom": "~17.0.9", "@types/react-grid-layout": "^1.1.3", + "@types/react-is": "^17.0.3", "@types/react-motion": "^0.0.31", "@types/react-redux": "^5.0.22", "@types/react-resizable": "^1.7.4", diff --git a/yarn.lock b/yarn.lock index 31c3d8e21f8..92587f7e219 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2137,6 +2137,11 @@ dependencies: "@percy/logger" "1.0.0-beta.65" +"@popperjs/core@^2.9.0": + version "2.10.2" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.2.tgz#0798c03351f0dea1a5a4cabddf26a55a7cbee590" + integrity sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ== + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.1" resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.1.tgz#a21117b19ee9be70c379ec1877537ef2e1c63301" @@ -2270,6 +2275,13 @@ dependencies: "@babel/runtime" "^7.12.5" +"@tippyjs/react@^4.2.6": + version "4.2.6" + resolved "https://registry.yarnpkg.com/@tippyjs/react/-/react-4.2.6.tgz#971677a599bf663f20bb1c60a62b9555b749cc71" + integrity sha512-91RicDR+H7oDSyPycI13q3b7o4O60wa2oRbjlz2fyRLmHImc4vyDwuUP8NtZaN0VARJY5hybvDYrFzhY9+Lbyw== + dependencies: + tippy.js "^6.3.1" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -2877,6 +2889,13 @@ dependencies: "@types/react" "*" +"@types/react-is@^17.0.3": + version "17.0.3" + resolved "https://registry.yarnpkg.com/@types/react-is/-/react-is-17.0.3.tgz#2d855ba575f2fc8d17ef9861f084acc4b90a137a" + integrity sha512-aBTIWg1emtu95bLTLx0cpkxwGW3ueZv71nE2YFBpL8k/z5czEW8yYpOo8Dp+UUAFAtKwNaOsh/ioSeQnWlZcfw== + dependencies: + "@types/react" "*" + "@types/react-motion@^0.0.31": version "0.0.31" resolved "https://registry.yarnpkg.com/@types/react-motion/-/react-motion-0.0.31.tgz#93e2abfc5056c5c0f47650ff9ed23533857245c5" @@ -13691,7 +13710,7 @@ react-is@^16.12.0, react-is@^16.13.0, react-is@^16.3.1, react-is@^16.6.0, react- resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^17.0.0: +react-is@^17.0.0, react-is@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== @@ -15820,6 +15839,13 @@ tinycolor2@^1.4.1: resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== +tippy.js@^6.3.1, tippy.js@^6.3.5: + version "6.3.5" + resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.5.tgz#cbc99d34f87ccc127e6460032b86c8d47971d38f" + integrity sha512-B9hAQ5KNF+jDJRg6cRysV6Y3J+5fiNfD60GuXR5TP0sfrcltpgdzVc7f1wMtjQ3W0+Xsy80CDvk0Z+Vr0cM4sQ== + dependencies: + "@popperjs/core" "^2.9.0" + tmp@~0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" -- GitLab