tooltip.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import {Manager, Popper, PopperArrowProps, PopperProps, Reference} from 'react-popper';
  4. import {SerializedStyles} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import {AnimatePresence, motion, MotionStyle} from 'framer-motion';
  7. import memoize from 'lodash/memoize';
  8. import * as PopperJS from 'popper.js';
  9. import {IS_ACCEPTANCE_TEST} from 'app/constants';
  10. import {domId} from 'app/utils/domId';
  11. import testableTransition from 'app/utils/testableTransition';
  12. export const OPEN_DELAY = 50;
  13. /**
  14. * How long to wait before closing the tooltip when isHoverable is set
  15. */
  16. const CLOSE_DELAY = 50;
  17. type DefaultProps = {
  18. /**
  19. * Position for the tooltip.
  20. */
  21. position?: PopperJS.Placement;
  22. /**
  23. * Display mode for the container element
  24. */
  25. containerDisplayMode?: React.CSSProperties['display'];
  26. };
  27. type Props = DefaultProps & {
  28. /**
  29. * The node to attach the Tooltip to
  30. */
  31. children: React.ReactNode;
  32. /**
  33. * Disable the tooltip display entirely
  34. */
  35. disabled?: boolean;
  36. /**
  37. * The content to show in the tooltip popover
  38. */
  39. title: React.ReactNode;
  40. /**
  41. * Additional style rules for the tooltip content.
  42. */
  43. popperStyle?: React.CSSProperties | SerializedStyles;
  44. /**
  45. * Time to wait (in milliseconds) before showing the tooltip
  46. */
  47. delay?: number;
  48. /**
  49. * If true, user is able to hover tooltip without it disappearing.
  50. * (nice if you want to be able to copy tooltip contents to clipboard)
  51. */
  52. isHoverable?: boolean;
  53. /**
  54. * If child node supports ref forwarding, you can skip apply a wrapper
  55. */
  56. skipWrapper?: boolean;
  57. /**
  58. * Stops tooltip from being opened during tooltip visual acceptance.
  59. * Should be set to true if tooltip contains unisolated data (eg. dates)
  60. */
  61. disableForVisualTest?: boolean;
  62. className?: string;
  63. };
  64. type State = {
  65. isOpen: boolean;
  66. usesGlobalPortal: boolean;
  67. };
  68. /**
  69. * Used to compute the transform origin to give the scale-down micro-animation
  70. * a pleasant feeling. Without this the animation can feel somewhat 'wrong'.
  71. */
  72. function computeOriginFromArrow(
  73. placement: PopperProps['placement'],
  74. arrowProps: PopperArrowProps
  75. ): MotionStyle {
  76. // XXX: Bottom means the arrow will be pointing up
  77. switch (placement) {
  78. case 'top':
  79. return {originX: `${arrowProps.style.left}px`, originY: '100%'};
  80. case 'bottom':
  81. return {originX: `${arrowProps.style.left}px`, originY: 0};
  82. case 'left':
  83. return {originX: '100%', originY: `${arrowProps.style.top}px`};
  84. case 'right':
  85. return {originX: 0, originY: `${arrowProps.style.top}px`};
  86. default:
  87. return {originX: `50%`, originY: '100%'};
  88. }
  89. }
  90. class Tooltip extends React.Component<Props, State> {
  91. static defaultProps: DefaultProps = {
  92. position: 'top',
  93. containerDisplayMode: 'inline-block',
  94. };
  95. state: State = {
  96. isOpen: false,
  97. usesGlobalPortal: true,
  98. };
  99. async componentDidMount() {
  100. if (IS_ACCEPTANCE_TEST) {
  101. const TooltipStore = (
  102. await import(/* webpackChunkName: "TooltipStore" */ 'app/stores/tooltipStore')
  103. ).default;
  104. TooltipStore.addTooltip(this);
  105. }
  106. }
  107. async componentWillUnmount() {
  108. const {usesGlobalPortal} = this.state;
  109. if (IS_ACCEPTANCE_TEST) {
  110. const TooltipStore = (
  111. await import(/* webpackChunkName: "TooltipStore" */ 'app/stores/tooltipStore')
  112. ).default;
  113. TooltipStore.removeTooltip(this);
  114. }
  115. if (!usesGlobalPortal) {
  116. document.body.removeChild(this.getPortal(usesGlobalPortal));
  117. }
  118. }
  119. tooltipId: string = domId('tooltip-');
  120. delayTimeout: number | null = null;
  121. delayHideTimeout: number | null = null;
  122. getPortal = memoize(
  123. (usesGlobalPortal): HTMLElement => {
  124. if (usesGlobalPortal) {
  125. let portal = document.getElementById('tooltip-portal');
  126. if (!portal) {
  127. portal = document.createElement('div');
  128. portal.setAttribute('id', 'tooltip-portal');
  129. document.body.appendChild(portal);
  130. }
  131. return portal;
  132. }
  133. const portal = document.createElement('div');
  134. document.body.appendChild(portal);
  135. return portal;
  136. }
  137. );
  138. setOpen = () => {
  139. this.setState({isOpen: true});
  140. };
  141. setClose = () => {
  142. this.setState({isOpen: false});
  143. };
  144. handleOpen = () => {
  145. const {delay} = this.props;
  146. if (this.delayHideTimeout) {
  147. window.clearTimeout(this.delayHideTimeout);
  148. this.delayHideTimeout = null;
  149. }
  150. if (delay === 0) {
  151. this.setOpen();
  152. return;
  153. }
  154. this.delayTimeout = window.setTimeout(this.setOpen, delay ?? OPEN_DELAY);
  155. };
  156. handleClose = () => {
  157. const {isHoverable} = this.props;
  158. if (this.delayTimeout) {
  159. window.clearTimeout(this.delayTimeout);
  160. this.delayTimeout = null;
  161. }
  162. if (isHoverable) {
  163. this.delayHideTimeout = window.setTimeout(this.setClose, CLOSE_DELAY);
  164. } else {
  165. this.setClose();
  166. }
  167. };
  168. renderTrigger(children: React.ReactNode, ref: React.Ref<HTMLElement>) {
  169. const propList: {[key: string]: any} = {
  170. 'aria-describedby': this.tooltipId,
  171. onFocus: this.handleOpen,
  172. onBlur: this.handleClose,
  173. onMouseEnter: this.handleOpen,
  174. onMouseLeave: this.handleClose,
  175. };
  176. // Use the `type` property of the react instance to detect whether we
  177. // have a basic element (type=string) or a class/function component (type=function or object)
  178. // Because we can't rely on the child element implementing forwardRefs we wrap
  179. // it with a span tag so that popper has ref
  180. if (
  181. React.isValidElement(children) &&
  182. (this.props.skipWrapper || typeof children.type === 'string')
  183. ) {
  184. // Basic DOM nodes can be cloned and have more props applied.
  185. return React.cloneElement(children, {
  186. ...propList,
  187. ref,
  188. });
  189. }
  190. propList.containerDisplayMode = this.props.containerDisplayMode;
  191. return (
  192. <Container {...propList} className={this.props.className} ref={ref}>
  193. {children}
  194. </Container>
  195. );
  196. }
  197. render() {
  198. const {disabled, children, title, position, popperStyle, isHoverable} = this.props;
  199. const {isOpen, usesGlobalPortal} = this.state;
  200. if (disabled) {
  201. return children;
  202. }
  203. const modifiers: PopperJS.Modifiers = {
  204. hide: {enabled: false},
  205. preventOverflow: {
  206. padding: 10,
  207. enabled: true,
  208. boundariesElement: 'viewport',
  209. },
  210. applyStyle: {
  211. gpuAcceleration: true,
  212. },
  213. };
  214. const tip = isOpen ? (
  215. <Popper placement={position} modifiers={modifiers}>
  216. {({ref, style, placement, arrowProps}) => (
  217. <PositionWrapper style={style}>
  218. <TooltipContent
  219. id={this.tooltipId}
  220. initial={{opacity: 0}}
  221. animate={{
  222. opacity: 1,
  223. scale: 1,
  224. transition: testableTransition({
  225. type: 'linear',
  226. ease: [0.5, 1, 0.89, 1],
  227. duration: 0.2,
  228. }),
  229. }}
  230. exit={{
  231. opacity: 0,
  232. scale: 0.95,
  233. transition: testableTransition({type: 'spring', delay: 0.1}),
  234. }}
  235. style={computeOriginFromArrow(position, arrowProps)}
  236. transition={{duration: 0.2}}
  237. className="tooltip-content"
  238. aria-hidden={!isOpen}
  239. ref={ref}
  240. hide={!title}
  241. data-placement={placement}
  242. popperStyle={popperStyle}
  243. onMouseEnter={() => isHoverable && this.handleOpen()}
  244. onMouseLeave={() => isHoverable && this.handleClose()}
  245. >
  246. {title}
  247. <TooltipArrow
  248. ref={arrowProps.ref}
  249. data-placement={placement}
  250. style={arrowProps.style}
  251. background={(popperStyle as React.CSSProperties)?.background || '#000'}
  252. />
  253. </TooltipContent>
  254. </PositionWrapper>
  255. )}
  256. </Popper>
  257. ) : null;
  258. return (
  259. <Manager>
  260. <Reference>{({ref}) => this.renderTrigger(children, ref)}</Reference>
  261. {ReactDOM.createPortal(
  262. <AnimatePresence>{tip}</AnimatePresence>,
  263. this.getPortal(usesGlobalPortal)
  264. )}
  265. </Manager>
  266. );
  267. }
  268. }
  269. // Using an inline-block solves the container being smaller
  270. // than the elements it is wrapping
  271. const Container = styled('span')<{
  272. containerDisplayMode?: React.CSSProperties['display'];
  273. }>`
  274. ${p => p.containerDisplayMode && `display: ${p.containerDisplayMode}`};
  275. max-width: 100%;
  276. `;
  277. const PositionWrapper = styled('div')`
  278. z-index: ${p => p.theme.zIndex.tooltip};
  279. `;
  280. const TooltipContent = styled(motion.div)<{hide: boolean} & Pick<Props, 'popperStyle'>>`
  281. will-change: transform, opacity;
  282. position: relative;
  283. color: ${p => p.theme.white};
  284. background: #000;
  285. opacity: 0.9;
  286. padding: 5px 10px;
  287. border-radius: ${p => p.theme.borderRadius};
  288. overflow-wrap: break-word;
  289. max-width: 225px;
  290. font-weight: bold;
  291. font-size: ${p => p.theme.fontSizeSmall};
  292. line-height: 1.4;
  293. margin: 6px;
  294. text-align: center;
  295. ${p => p.popperStyle as any};
  296. ${p => p.hide && `display: none`};
  297. `;
  298. const TooltipArrow = styled('span')<{background: string | number}>`
  299. position: absolute;
  300. width: 10px;
  301. height: 5px;
  302. &[data-placement*='bottom'] {
  303. top: 0;
  304. left: 0;
  305. margin-top: -5px;
  306. &::before {
  307. border-width: 0 5px 5px 5px;
  308. border-color: transparent transparent ${p => p.background} transparent;
  309. }
  310. }
  311. &[data-placement*='top'] {
  312. bottom: 0;
  313. left: 0;
  314. margin-bottom: -5px;
  315. &::before {
  316. border-width: 5px 5px 0 5px;
  317. border-color: ${p => p.background} transparent transparent transparent;
  318. }
  319. }
  320. &[data-placement*='right'] {
  321. left: 0;
  322. margin-left: -5px;
  323. &::before {
  324. border-width: 5px 5px 5px 0;
  325. border-color: transparent ${p => p.background} transparent transparent;
  326. }
  327. }
  328. &[data-placement*='left'] {
  329. right: 0;
  330. margin-right: -5px;
  331. &::before {
  332. border-width: 5px 0 5px 5px;
  333. border-color: transparent transparent transparent ${p => p.background};
  334. }
  335. }
  336. &::before {
  337. content: '';
  338. margin: auto;
  339. display: block;
  340. width: 0;
  341. height: 0;
  342. border-style: solid;
  343. }
  344. `;
  345. export default Tooltip;