tooltip.tsx 11 KB

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