/* eslint-disable react/jsx-props-no-spreading */ import { camelCase, isFunction } from "lodash-es"; import cx from "classnames"; import React, { CSSProperties, isValidElement, PureComponent, ReactElement } from "react"; import { Modifier } from "react-popper"; import Dialog from "../Dialog/Dialog"; import { AnimationType, BASE_SIZES_WITH_NONE, HideShowEvent, JustifyType } from "../../constants"; import { DialogPosition } from "../../constants/positions"; import VibeComponentProps from "../../types/VibeComponentProps"; import { TooltipArrowPosition, TooltipTheme } from "./TooltipConstants"; import { ElementContent } from "../../types/ElementContent"; import { MoveBy } from "../../types/MoveBy"; import { getStyle } from "../../helpers/typesciptCssModulesHelper"; import styles from "./Tooltip.module.scss"; import { ComponentDefaultTestId, getTestId } from "../../tests/test-ids-utils"; import { SubIcon } from "../../types"; import Icon from "../Icon/Icon"; import Flex from "../Flex/Flex"; import { warnDeprecated } from "../../utils/warn-deprecated"; export type TooltipProps = TooltipBaseProps & (TooltipWithChildrenProps | TooltipWithoutChildrenProps); interface TooltipWithoutChildrenProps { /** * When true, the tooltip would be rendered on a Dialog without children * Use case is currently for rendering Tipseen with floating prop */ forceRenderWithoutChildren: boolean; children?: ReactElement | Array; } interface TooltipWithChildrenProps { forceRenderWithoutChildren?: boolean; children: ReactElement | Array; } // TODO TS-migration extend DialogProps, once Dialog is migrated to TS interface TooltipBaseProps extends VibeComponentProps { content: ElementContent; style?: CSSProperties; arrowPosition?: TooltipArrowPosition; /** Class name for a tooltip's arrow */ arrowClassName?: string; paddingSize?: keyof typeof BASE_SIZES_WITH_NONE; /** * How much to move the dialog in relative to children * main is the axis in which the position is aligned to * secondary is the vertical axes to the position */ moveBy?: MoveBy; theme?: TooltipTheme; justify?: JustifyType; getContainer?: () => HTMLElement; /** * how much delay should the Dialog wait until it should trigger the hide in MS */ hideDelay?: number; /** * how much delay should the Dialog wait until it should trigger the show in MS */ showDelay?: number; disableDialogSlide?: boolean; animationType?: AnimationType; withoutDialog?: boolean; /** * the container selector in which to append the dialog * for examples: "body" , ".my-class", "#my-id" */ containerSelector?: string; /** * With which delay tooltip is going to be shown */ immediateShowDelay?: number; /** * when false, the arrow of the tooltip is hidden */ tip?: boolean; /** * Show the Dialog when the children is mounting */ shouldShowOnMount?: boolean; hideWhenReferenceHidden?: boolean; onTooltipHide?: () => void; onTooltipShow?: () => void; /** * PopperJS Modifiers type * https://popper.js.org/docs/v2/modifiers/ */ modifiers?: Array>; /** * Where the tooltip should be in reference to the children: Top, Left, Right, Bottom ... */ position?: DialogPosition; /** * an array of hide/show trigger - Tooltip.hideShowTriggers */ showTrigger?: HideShowEvent | Array; /** * an array of hide/show trigger - Tooltip.hideShowTriggers */ hideTrigger?: HideShowEvent | Array; /** * If true, prevents open Tooltip from closing on mouseEnter and closes Tooltip, when mouse leaves it */ showOnDialogEnter?: boolean; /** * A Classname to be added to element which wraps the children */ referenceWrapperClassName?: string; /** * Treats keyboard focus/blur events as mouse-enter/mouse-leave events */ addKeyboardHideShowTriggersByDefault?: boolean; /** * set the state of the tooltip - open/close - controlled component */ open?: boolean; /** * Overwrites z-index of the tooltip */ zIndex?: number; // TODO: make default next major /** * Limit tooltip to 240px */ withMaxWidth?: boolean; /** * The title of the tooltip */ title?: string; /** * The image of the tooltip */ image?: string; /** * The icon of the tooltip next to the title */ icon?: SubIcon; } // When last tooltip was shown in the last 1.5 second - the next tooltip will be shown immediately const IMMEDIATE_SHOW_THRESHOLD_MS = 1500; // Shared state across multiple tooltip instances (i.e last tooltip shown time) const globalState: { lastTooltipHideTS: number; openTooltipsCount: number } = { lastTooltipHideTS: null, openTooltipsCount: 0 }; export default class Tooltip extends PureComponent { wasShown: boolean; static positions = DialogPosition; static hideShowTriggers = HideShowEvent; static themes = TooltipTheme; static animationTypes = AnimationType; static justifyTypes = JustifyType; static arrowPositions = TooltipArrowPosition; static defaultProps = { arrowPosition: TooltipArrowPosition.CENTER, moveBy: { main: 4, secondary: 0 }, theme: TooltipTheme.Dark, position: Tooltip.positions.TOP, justify: Tooltip.justifyTypes.CENTER, hideDelay: 0, showDelay: 300, disableDialogSlide: true, animationType: AnimationType.EXPAND, withoutDialog: false, containerSelector: "#tooltips-container", tip: true, hideWhenReferenceHidden: false, modifiers: new Array>(), showTrigger: Tooltip.hideShowTriggers.MOUSE_ENTER, hideTrigger: Tooltip.hideShowTriggers.MOUSE_LEAVE, showOnDialogEnter: false, referenceWrapperClassName: "", addKeyboardHideShowTriggersByDefault: false, open: false }; constructor(props: TooltipProps) { super(props); this.renderTooltipContent = this.renderTooltipContent.bind(this); this.getShowDelay = this.getShowDelay.bind(this); this.onTooltipShow = this.onTooltipShow.bind(this); this.onTooltipHide = this.onTooltipHide.bind(this); this.wasShown = false; warnDeprecated({ component: "Tooltip", condition: this.props.theme && !["primary", "dark"].includes(this.props.theme), message: "'theme' prop will be able to get only 'dark' and 'primary' in the upcoming major version. Please consider using these themes." }); warnDeprecated({ component: "Tooltip", condition: this.props.position && !["top", "left", "right", "bottom"].includes(this.props.position), message: "'position' prop will be able to get only 'top', 'bottom', 'left', and 'right' values in the upcoming major version. Please consider using these positions." }); } getContainer() { return document.getElementById("tooltips-container") || document.querySelector("body"); } renderTooltipContent() { const { theme, content, paddingSize, className, style, withMaxWidth, title, image, icon } = this.props; if (!content) { // don't render empty tooltip return null; } let contentValue; if (isFunction(content)) { contentValue = content(); } else if (isValidElement(content)) { contentValue = content; } else if (typeof content === "string" && content) { contentValue = content; } else if (Array.isArray(content) && content.length > 0) { // allow array of elements contentValue = content; } if (!contentValue) { return null; } if (title || image) { return (
{image && }
{title && ( {icon && }
{title}
)} {contentValue}
); } // TODO: remove in next major, use (title || image) variant instead return (
{contentValue}
); } onTooltipShow() { if (!this.wasShown) { const { onTooltipShow } = this.props; globalState.openTooltipsCount++; this.wasShown = true; onTooltipShow && onTooltipShow(); } } onTooltipHide() { if (this.wasShown) { const { onTooltipHide } = this.props; globalState.lastTooltipHideTS = Date.now(); globalState.openTooltipsCount--; this.wasShown = false; onTooltipHide && onTooltipHide(); } } getTimeSinceLastTooltip() { if (globalState.openTooltipsCount > 0) { return 0; } return globalState.lastTooltipHideTS ? Date.now() - globalState.lastTooltipHideTS : Infinity; } getShowDelay() { const { showDelay, immediateShowDelay } = this.props; const timeSinceLastTooltip = this.getTimeSinceLastTooltip(); if ((immediateShowDelay === 0 || immediateShowDelay) && timeSinceLastTooltip < IMMEDIATE_SHOW_THRESHOLD_MS) { // showing the tooltip immediately (without animation) return { showDelay: immediateShowDelay, preventAnimation: true }; } return { showDelay, preventAnimation: false }; } render() { const { withoutDialog, justify, children, forceRenderWithoutChildren, getContainer, theme, paddingSize, tip, arrowClassName, id, "data-testid": dataTestId } = this.props; if (!children && !forceRenderWithoutChildren) { return null; } if (withoutDialog) { return this.renderTooltipContent(); } const content = this.renderTooltipContent; const dialogProps = { ...this.props, "data-testid": dataTestId || getTestId(ComponentDefaultTestId.TOOLTIP, id), startingEdge: justify, tooltip: tip, content, getContainer: getContainer || this.getContainer, tooltipClassName: cx( styles.arrow, getStyle(styles, theme), getStyle(styles, camelCase("padding-size-" + paddingSize)), arrowClassName ), animationType: AnimationType.EXPAND, onDialogDidHide: this.onTooltipHide, onDialogDidShow: this.onTooltipShow, getDynamicShowDelay: this.getShowDelay }; return {children}; } }