tooltip.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. import {
  2. cloneElement,
  3. Fragment,
  4. isValidElement,
  5. useEffect,
  6. useMemo,
  7. useRef,
  8. useState,
  9. } from 'react';
  10. import {createPortal} from 'react-dom';
  11. import {Manager, Popper, PopperArrowProps, PopperProps, Reference} from 'react-popper';
  12. import isPropValid from '@emotion/is-prop-valid';
  13. import {SerializedStyles, useTheme} from '@emotion/react';
  14. import styled from '@emotion/styled';
  15. import {AnimatePresence, motion, MotionProps, MotionStyle} from 'framer-motion';
  16. import {IS_ACCEPTANCE_TEST} from 'sentry/constants/index';
  17. import space from 'sentry/styles/space';
  18. import domId from 'sentry/utils/domId';
  19. import testableTransition from 'sentry/utils/testableTransition';
  20. import {ColorOrAlias} from 'sentry/utils/theme';
  21. import {AcceptanceTestTooltip} from './acceptanceTestTooltip';
  22. export const OPEN_DELAY = 50;
  23. const TOOLTIP_ANIMATION: MotionProps = {
  24. transition: {duration: 0.2},
  25. initial: {opacity: 0},
  26. animate: {
  27. opacity: 1,
  28. scale: 1,
  29. transition: testableTransition({
  30. type: 'linear',
  31. ease: [0.5, 1, 0.89, 1],
  32. duration: 0.2,
  33. }),
  34. },
  35. exit: {
  36. opacity: 0,
  37. scale: 0.95,
  38. transition: testableTransition({type: 'spring', delay: 0.1}),
  39. },
  40. };
  41. /**
  42. * How long to wait before closing the tooltip when isHoverable is set
  43. */
  44. const CLOSE_DELAY = 50;
  45. export interface InternalTooltipProps {
  46. children: React.ReactNode;
  47. /**
  48. * The content to show in the tooltip popover
  49. */
  50. title: React.ReactNode;
  51. className?: string;
  52. /**
  53. * Display mode for the container element
  54. */
  55. containerDisplayMode?: React.CSSProperties['display'];
  56. /**
  57. * Time to wait (in milliseconds) before showing the tooltip
  58. */
  59. delay?: number;
  60. /**
  61. * Disable the tooltip display entirely
  62. */
  63. disabled?: boolean;
  64. /**
  65. * Force the tooltip to be visible without hovering
  66. */
  67. forceVisible?: boolean;
  68. /**
  69. * If true, user is able to hover tooltip without it disappearing.
  70. * (nice if you want to be able to copy tooltip contents to clipboard)
  71. */
  72. isHoverable?: boolean;
  73. /**
  74. * Additional style rules for the tooltip content.
  75. */
  76. overlayStyle?: React.CSSProperties | SerializedStyles;
  77. /**
  78. * Position for the tooltip.
  79. */
  80. position?: PopperProps['placement'];
  81. /**
  82. * Only display the tooltip only if the content overflows
  83. */
  84. showOnlyOnOverflow?: boolean;
  85. /**
  86. * Whether to add a dotted underline to the trigger element, to indicate the
  87. * presence of a tooltip.
  88. */
  89. showUnderline?: boolean;
  90. /**
  91. * If child node supports ref forwarding, you can skip apply a wrapper
  92. */
  93. skipWrapper?: boolean;
  94. /**
  95. * Color of the dotted underline, if available. See also: showUnderline.
  96. */
  97. underlineColor?: ColorOrAlias;
  98. }
  99. /**
  100. * Used to compute the transform origin to give the scale-down micro-animation
  101. * a pleasant feeling. Without this the animation can feel somewhat 'wrong'.
  102. */
  103. function computeOriginFromArrow(
  104. placement: PopperProps['placement'],
  105. arrowProps: PopperArrowProps
  106. ): MotionStyle {
  107. // XXX: Bottom means the arrow will be pointing up
  108. switch (placement) {
  109. case 'top':
  110. return {originX: `${arrowProps.style.left}px`, originY: '100%'};
  111. case 'bottom':
  112. return {originX: `${arrowProps.style.left}px`, originY: 0};
  113. case 'left':
  114. return {originX: '100%', originY: `${arrowProps.style.top}px`};
  115. case 'right':
  116. return {originX: 0, originY: `${arrowProps.style.top}px`};
  117. default:
  118. return {originX: `50%`, originY: '100%'};
  119. }
  120. }
  121. function isOverflown(el: Element): boolean {
  122. return el.scrollWidth > el.clientWidth || Array.from(el.children).some(isOverflown);
  123. }
  124. // Warning: This component is conditionally exported end-of-file based on IS_ACCEPTANCE_TEST env variable
  125. export function DO_NOT_USE_TOOLTIP({
  126. children,
  127. className,
  128. delay,
  129. forceVisible,
  130. isHoverable,
  131. overlayStyle,
  132. showUnderline,
  133. underlineColor,
  134. showOnlyOnOverflow,
  135. skipWrapper,
  136. title,
  137. disabled = false,
  138. position = 'top',
  139. containerDisplayMode = 'inline-block',
  140. }: InternalTooltipProps) {
  141. const [visible, setVisible] = useState(false);
  142. const tooltipId = useMemo(() => domId('tooltip-'), []);
  143. const theme = useTheme();
  144. // Delayed open and close time handles
  145. const delayOpenTimeoutRef = useRef<number | undefined>(undefined);
  146. const delayHideTimeoutRef = useRef<number | undefined>(undefined);
  147. // When the component is unmounted, make sure to stop the timeouts
  148. useEffect(() => {
  149. return () => {
  150. window.clearTimeout(delayOpenTimeoutRef.current);
  151. window.clearTimeout(delayHideTimeoutRef.current);
  152. };
  153. }, []);
  154. function handleMouseEnter() {
  155. if (triggerRef.current && showOnlyOnOverflow && !isOverflown(triggerRef.current)) {
  156. return;
  157. }
  158. window.clearTimeout(delayHideTimeoutRef.current);
  159. window.clearTimeout(delayOpenTimeoutRef.current);
  160. if (delay === 0) {
  161. setVisible(true);
  162. return;
  163. }
  164. delayOpenTimeoutRef.current = window.setTimeout(
  165. () => setVisible(true),
  166. delay ?? OPEN_DELAY
  167. );
  168. }
  169. function handleMouseLeave() {
  170. window.clearTimeout(delayOpenTimeoutRef.current);
  171. window.clearTimeout(delayHideTimeoutRef.current);
  172. if (isHoverable) {
  173. delayHideTimeoutRef.current = window.setTimeout(
  174. () => setVisible(false),
  175. CLOSE_DELAY
  176. );
  177. } else {
  178. setVisible(false);
  179. }
  180. }
  181. // Tracks the triggering element
  182. const triggerRef = useRef<HTMLElement | null>(null);
  183. const modifiers: PopperProps['modifiers'] = useMemo(() => {
  184. return {
  185. hide: {enabled: false},
  186. preventOverflow: {
  187. padding: 10,
  188. enabled: true,
  189. boundariesElement: 'viewport',
  190. },
  191. applyStyle: {
  192. gpuAcceleration: true,
  193. },
  194. };
  195. }, []);
  196. function renderTrigger(triggerChildren: React.ReactNode, ref: React.Ref<HTMLElement>) {
  197. const setRef = (el: HTMLElement) => {
  198. if (typeof ref === 'function') {
  199. ref(el);
  200. }
  201. triggerRef.current = el;
  202. };
  203. const props = {
  204. 'aria-describedby': tooltipId,
  205. onFocus: handleMouseEnter,
  206. onBlur: handleMouseLeave,
  207. onPointerEnter: handleMouseEnter,
  208. onPointerLeave: handleMouseLeave,
  209. ref: setRef,
  210. };
  211. // Use the `type` property of the react instance to detect whether we have
  212. // a basic element (type=string) or a class/function component
  213. // (type=function or object). Because we can't rely on the child element
  214. // implementing forwardRefs we wrap it with a span tag for the ref
  215. if (
  216. isValidElement(triggerChildren) &&
  217. (skipWrapper || typeof triggerChildren.type === 'string')
  218. ) {
  219. const styles = {
  220. ...triggerChildren.props.style,
  221. ...(showUnderline && theme.tooltipUnderline(underlineColor)),
  222. };
  223. // Basic DOM nodes can be cloned and have more props applied.
  224. return cloneElement(triggerChildren, {
  225. ...props,
  226. style: styles,
  227. });
  228. }
  229. const ourContainerProps = {
  230. ...props,
  231. containerDisplayMode,
  232. style: showUnderline ? theme.tooltipUnderline(underlineColor) : undefined,
  233. className,
  234. };
  235. return <Container {...ourContainerProps}>{triggerChildren}</Container>;
  236. }
  237. if (disabled || !title) {
  238. return <Fragment>{children}</Fragment>;
  239. }
  240. // The tooltip visibility state can be controlled through the forceVisible prop
  241. const isVisible = forceVisible || visible;
  242. return (
  243. <Manager>
  244. <Reference>{({ref}) => renderTrigger(children, ref)}</Reference>
  245. {createPortal(
  246. <AnimatePresence>
  247. {isVisible ? (
  248. <Popper placement={position} modifiers={modifiers}>
  249. {({ref, style, placement, arrowProps}) => (
  250. <PositionWrapper style={style}>
  251. <TooltipContent
  252. ref={ref}
  253. id={tooltipId}
  254. data-placement={placement}
  255. style={computeOriginFromArrow(position, arrowProps)}
  256. overlayStyle={overlayStyle}
  257. onMouseEnter={isHoverable ? handleMouseEnter : undefined}
  258. onMouseLeave={isHoverable ? handleMouseLeave : undefined}
  259. {...TOOLTIP_ANIMATION}
  260. >
  261. {title}
  262. <TooltipArrow
  263. ref={arrowProps.ref}
  264. data-placement={placement}
  265. style={arrowProps.style}
  266. />
  267. </TooltipContent>
  268. </PositionWrapper>
  269. )}
  270. </Popper>
  271. ) : null}
  272. </AnimatePresence>,
  273. document.body
  274. )}
  275. </Manager>
  276. );
  277. }
  278. interface ContainerProps {
  279. containerDisplayMode?: React.CSSProperties['display'];
  280. }
  281. // Using an inline-block solves the container being smaller
  282. // than the elements it is wrapping
  283. const Container = styled('span')<ContainerProps>`
  284. ${p => p.containerDisplayMode && `display: ${p.containerDisplayMode}`};
  285. max-width: 100%;
  286. `;
  287. const PositionWrapper = styled('div')`
  288. z-index: ${p => p.theme.zIndex.tooltip};
  289. `;
  290. const animationProps = Object.keys(TOOLTIP_ANIMATION);
  291. const TooltipContent = styled(motion.div, {
  292. shouldForwardProp: (prop: string) =>
  293. typeof prop === 'string' && (animationProps.includes(prop) || isPropValid(prop)),
  294. })<{
  295. overlayStyle: InternalTooltipProps['overlayStyle'];
  296. }>`
  297. will-change: transform, opacity;
  298. position: relative;
  299. background: ${p => p.theme.backgroundElevated};
  300. padding: ${space(1)} ${space(1.5)};
  301. border-radius: ${p => p.theme.borderRadius};
  302. box-shadow: 0 0 0 1px ${p => p.theme.translucentBorder}, ${p => p.theme.dropShadowHeavy};
  303. overflow-wrap: break-word;
  304. max-width: 225px;
  305. color: ${p => p.theme.textColor};
  306. font-size: ${p => p.theme.fontSizeSmall};
  307. line-height: 1.2;
  308. margin: 6px;
  309. text-align: center;
  310. ${p => p.overlayStyle as any};
  311. `;
  312. const TooltipArrow = styled('span')`
  313. position: absolute;
  314. width: 6px;
  315. height: 6px;
  316. border: solid 6px transparent;
  317. pointer-events: none;
  318. &::before {
  319. content: '';
  320. display: block;
  321. position: absolute;
  322. width: 0;
  323. height: 0;
  324. border: solid 6px transparent;
  325. z-index: -1;
  326. }
  327. &[data-placement*='bottom'] {
  328. top: 0;
  329. margin-top: -12px;
  330. border-bottom-color: ${p => p.theme.backgroundElevated};
  331. &::before {
  332. bottom: -5px;
  333. left: -6px;
  334. border-bottom-color: ${p => p.theme.translucentBorder};
  335. }
  336. }
  337. &[data-placement*='top'] {
  338. bottom: 0;
  339. margin-bottom: -12px;
  340. border-top-color: ${p => p.theme.backgroundElevated};
  341. &::before {
  342. top: -5px;
  343. left: -6px;
  344. border-top-color: ${p => p.theme.translucentBorder};
  345. }
  346. }
  347. &[data-placement*='right'] {
  348. left: 0;
  349. margin-left: -12px;
  350. border-right-color: ${p => p.theme.backgroundElevated};
  351. &::before {
  352. top: -6px;
  353. right: -5px;
  354. border-right-color: ${p => p.theme.translucentBorder};
  355. }
  356. }
  357. &[data-placement*='left'] {
  358. right: 0;
  359. margin-right: -12px;
  360. border-left-color: ${p => p.theme.backgroundElevated};
  361. &::before {
  362. top: -6px;
  363. left: -5px;
  364. border-left-color: ${p => p.theme.translucentBorder};
  365. }
  366. }
  367. `;
  368. interface TooltipProps extends InternalTooltipProps {
  369. /**
  370. * Stops tooltip from being opened during tooltip visual acceptance.
  371. * Should be set to true if tooltip contains unisolated data (eg. dates)
  372. */
  373. disableForVisualTest?: boolean;
  374. }
  375. /**
  376. * Tooltip will enhance the internal tooltip with the open/close
  377. * functionality used in src/sentry/utils/pytest/selenium.py so that tooltips
  378. * can be opened and closed for specific snapshots.
  379. */
  380. function Tooltip({disableForVisualTest, ...props}: TooltipProps) {
  381. if (IS_ACCEPTANCE_TEST) {
  382. return disableForVisualTest ? (
  383. <Fragment>{props.children}</Fragment>
  384. ) : (
  385. <AcceptanceTestTooltip {...props} />
  386. );
  387. }
  388. return <DO_NOT_USE_TOOLTIP {...props} />;
  389. }
  390. export default Tooltip;