hovercard.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
  2. import {createPortal} from 'react-dom';
  3. import {Manager, Popper, PopperProps, Reference} from 'react-popper';
  4. import styled from '@emotion/styled';
  5. import classNames from 'classnames';
  6. import {motion} from 'framer-motion';
  7. import space from 'sentry/styles/space';
  8. import domId from 'sentry/utils/domId';
  9. import {ColorOrAlias} from 'sentry/utils/theme';
  10. interface HovercardProps {
  11. /**
  12. * Classname to apply to the hovercard
  13. */
  14. children: React.ReactNode;
  15. /**
  16. * Element to display in the body
  17. */
  18. body?: React.ReactNode;
  19. /**
  20. * Classname to apply to body container
  21. */
  22. bodyClassName?: string;
  23. className?: string;
  24. /**
  25. * Classname to apply to the hovercard container
  26. */
  27. containerClassName?: string;
  28. /**
  29. * Time in ms until hovercard is hidden
  30. */
  31. displayTimeout?: number;
  32. /**
  33. * Element to display in the header
  34. */
  35. header?: React.ReactNode;
  36. /**
  37. * Offset for the arrow
  38. */
  39. offset?: string;
  40. /**
  41. * Position tooltip should take relative to the child element
  42. */
  43. position?: PopperProps<[]>['placement'];
  44. /**
  45. * If set, is used INSTEAD OF the hover action to determine whether the hovercard is shown
  46. */
  47. show?: boolean;
  48. /**
  49. * Whether to add a dotted underline to the trigger element, to indicate the
  50. * presence of a tooltip.
  51. */
  52. showUnderline?: boolean;
  53. /**
  54. * Color of the arrow tip border
  55. */
  56. tipBorderColor?: string;
  57. /**
  58. * Color of the arrow tip
  59. */
  60. tipColor?: string;
  61. /**
  62. * Color of the dotted underline, if available. See also: showUnderline.
  63. */
  64. underlineColor?: ColorOrAlias;
  65. }
  66. function Hovercard({
  67. body,
  68. bodyClassName,
  69. children,
  70. className,
  71. containerClassName,
  72. header,
  73. offset,
  74. show,
  75. showUnderline,
  76. tipBorderColor,
  77. tipColor,
  78. underlineColor,
  79. displayTimeout = 100,
  80. position = 'top',
  81. }: HovercardProps): React.ReactElement {
  82. const [visible, setVisible] = useState(false);
  83. const tooltipId = useMemo(() => domId('hovercard-'), []);
  84. const showHoverCardTimeoutRef = useRef<number | undefined>(undefined);
  85. useEffect(() => {
  86. return () => {
  87. window.clearTimeout(showHoverCardTimeoutRef.current);
  88. };
  89. }, []);
  90. const toggleHovercard = useCallback(
  91. (value: boolean) => {
  92. window.clearTimeout(showHoverCardTimeoutRef.current);
  93. // Else enqueue a new timeout
  94. showHoverCardTimeoutRef.current = window.setTimeout(
  95. () => setVisible(value),
  96. displayTimeout
  97. );
  98. },
  99. [displayTimeout]
  100. );
  101. const modifiers = useMemo(
  102. () => [
  103. {
  104. name: 'hide',
  105. enabled: false,
  106. },
  107. {
  108. name: 'computeStyles',
  109. options: {
  110. // Using the `transform` attribute causes our borders to get blurry
  111. // in chrome. See [0]. This just causes it to use `top` / `left`
  112. // positions, which should be fine.
  113. //
  114. // [0]: https://stackoverflow.com/questions/29543142/css3-transformation-blurry-borders
  115. gpuAcceleration: false,
  116. },
  117. },
  118. {
  119. name: 'arrow',
  120. options: {
  121. // Set padding to avoid the arrow reaching the side of the tooltip
  122. // and overflowing out of the rounded border
  123. padding: 4,
  124. },
  125. },
  126. {
  127. name: 'preventOverflow',
  128. enabled: true,
  129. options: {
  130. padding: 12,
  131. altAxis: true,
  132. },
  133. },
  134. ],
  135. []
  136. );
  137. // If show is not set, then visibility state is uncontrolled
  138. const isVisible = show === undefined ? visible : show;
  139. const hoverProps = useMemo((): {
  140. onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
  141. onMouseLeave?: React.MouseEventHandler<HTMLDivElement>;
  142. } => {
  143. // If show is not set, then visibility state is controlled by mouse events
  144. if (show === undefined) {
  145. return {
  146. onMouseEnter: () => toggleHovercard(true),
  147. onMouseLeave: () => toggleHovercard(false),
  148. };
  149. }
  150. return {};
  151. }, [show, toggleHovercard]);
  152. return (
  153. <Manager>
  154. <Reference>
  155. {({ref}) => (
  156. <Trigger
  157. ref={ref}
  158. aria-describedby={tooltipId}
  159. className={containerClassName}
  160. showUnderline={showUnderline}
  161. underlineColor={underlineColor}
  162. {...hoverProps}
  163. >
  164. {children}
  165. </Trigger>
  166. )}
  167. </Reference>
  168. {createPortal(
  169. <Popper placement={position} modifiers={modifiers}>
  170. {({ref, style, placement, arrowProps}) => {
  171. // Element is not visible in neither controlled and uncontrolled
  172. // state (show prop is not passed and card is not hovered)
  173. if (!isVisible) {
  174. return null;
  175. }
  176. // Nothing to render
  177. if (!body && !header) {
  178. return null;
  179. }
  180. return (
  181. <HovercardContainer style={style} ref={ref}>
  182. <SlideInAnimation visible={isVisible} placement={placement}>
  183. <StyledHovercard
  184. id={tooltipId}
  185. placement={placement}
  186. offset={offset}
  187. // Maintain the hovercard class name for BC with less styles
  188. className={classNames('hovercard', className)}
  189. {...hoverProps}
  190. >
  191. {header ? <Header>{header}</Header> : null}
  192. {body ? <Body className={bodyClassName}>{body}</Body> : null}
  193. <HovercardArrow
  194. ref={arrowProps.ref}
  195. style={arrowProps.style}
  196. placement={placement}
  197. tipColor={tipColor}
  198. tipBorderColor={tipBorderColor}
  199. />
  200. </StyledHovercard>
  201. </SlideInAnimation>
  202. </HovercardContainer>
  203. );
  204. }}
  205. </Popper>,
  206. document.body
  207. )}
  208. </Manager>
  209. );
  210. }
  211. export {Hovercard};
  212. const SLIDE_DISTANCE = 10;
  213. function SlideInAnimation({
  214. visible,
  215. placement,
  216. children,
  217. }: {
  218. children: React.ReactNode;
  219. placement: PopperProps<[]>['placement'];
  220. visible: boolean;
  221. }): React.ReactElement {
  222. const narrowedPlacement = getTipDirection(placement);
  223. const x =
  224. narrowedPlacement === 'left'
  225. ? [-SLIDE_DISTANCE, 0]
  226. : narrowedPlacement === 'right'
  227. ? [SLIDE_DISTANCE, 0]
  228. : [0, 0];
  229. const y =
  230. narrowedPlacement === 'top'
  231. ? [-SLIDE_DISTANCE, 0]
  232. : narrowedPlacement === 'bottom'
  233. ? [SLIDE_DISTANCE, 0]
  234. : [0, 0];
  235. return (
  236. <motion.div
  237. initial="hidden"
  238. variants={{
  239. hidden: {
  240. opacity: 0,
  241. },
  242. visible: {
  243. opacity: [0, 1],
  244. x,
  245. y,
  246. },
  247. }}
  248. animate={visible ? 'visible' : 'hidden'}
  249. transition={{duration: 0.1, ease: 'easeInOut'}}
  250. >
  251. {children}
  252. </motion.div>
  253. );
  254. }
  255. function getTipDirection(
  256. placement: HovercardArrowProps['placement']
  257. ): 'top' | 'bottom' | 'left' | 'right' {
  258. if (!placement) {
  259. return 'top';
  260. }
  261. const prefix = ['top', 'bottom', 'left', 'right'].find(pl => {
  262. return placement.startsWith(pl);
  263. });
  264. return (prefix || 'top') as 'top' | 'bottom' | 'left' | 'right';
  265. }
  266. const Trigger = styled('span')<{showUnderline?: boolean; underlineColor?: ColorOrAlias}>`
  267. ${p => p.showUnderline && p.theme.tooltipUnderline(p.underlineColor)};
  268. `;
  269. const HovercardContainer = styled('div')`
  270. /* Some hovercards overlap the toplevel header and sidebar, and we need to appear on top */
  271. z-index: ${p => p.theme.zIndex.hovercard};
  272. `;
  273. type StyledHovercardProps = {
  274. placement: PopperProps<[]>['placement'];
  275. offset?: string;
  276. };
  277. const StyledHovercard = styled('div')<StyledHovercardProps>`
  278. position: relative;
  279. border-radius: ${p => p.theme.borderRadius};
  280. text-align: left;
  281. padding: 0;
  282. line-height: 1;
  283. white-space: initial;
  284. color: ${p => p.theme.textColor};
  285. border: 1px solid ${p => p.theme.border};
  286. background: ${p => p.theme.background};
  287. background-clip: padding-box;
  288. box-shadow: 0 0 35px 0 rgba(67, 62, 75, 0.2);
  289. width: 295px;
  290. /* The hovercard may appear in different contexts, don't inherit fonts */
  291. font-family: ${p => p.theme.text.family};
  292. /* Offset for the arrow */
  293. ${p => (p.placement === 'top' ? `margin-bottom: ${p.offset ?? space(2)}` : '')};
  294. ${p => (p.placement === 'bottom' ? `margin-top: ${p.offset ?? space(2)}` : '')};
  295. ${p => (p.placement === 'left' ? `margin-right: ${p.offset ?? space(2)}` : '')};
  296. ${p => (p.placement === 'right' ? `margin-left: ${p.offset ?? space(2)}` : '')};
  297. `;
  298. const Header = styled('div')`
  299. font-size: ${p => p.theme.fontSizeMedium};
  300. background: ${p => p.theme.backgroundSecondary};
  301. border-bottom: 1px solid ${p => p.theme.border};
  302. border-radius: ${p => p.theme.borderRadiusTop};
  303. font-weight: 600;
  304. word-wrap: break-word;
  305. padding: ${space(1.5)};
  306. `;
  307. export {Header};
  308. const Body = styled('div')`
  309. padding: ${space(2)};
  310. min-height: 30px;
  311. `;
  312. export {Body};
  313. type HovercardArrowProps = {
  314. placement: PopperProps<[]>['placement'];
  315. tipBorderColor?: string;
  316. tipColor?: string;
  317. };
  318. const HovercardArrow = styled('span')<HovercardArrowProps>`
  319. position: absolute;
  320. width: 20px;
  321. height: 20px;
  322. right: ${p => (p.placement === 'left' ? '-20px' : 'auto')};
  323. left: ${p => (p.placement === 'right' ? '-20px' : 'auto')};
  324. bottom: ${p => (p.placement === 'top' ? '-20px' : 'auto')};
  325. top: ${p => (p.placement === 'bottom' ? '-20px' : 'auto')};
  326. &::before,
  327. &::after {
  328. content: '';
  329. margin: auto;
  330. position: absolute;
  331. display: block;
  332. width: 0;
  333. height: 0;
  334. top: 0;
  335. left: 0;
  336. }
  337. /* before element is the hairline border, it is repositioned for each orientation */
  338. &::before {
  339. top: 1px;
  340. border: 10px solid transparent;
  341. border-${p => getTipDirection(p.placement)}-color:
  342. ${p => p.tipBorderColor || p.tipColor || p.theme.border};
  343. ${p => (p.placement === 'bottom' ? 'top: -1px' : '')};
  344. ${p => (p.placement === 'left' ? 'top: 0; left: 1px;' : '')};
  345. ${p => (p.placement === 'right' ? 'top: 0; left: -1px' : '')};
  346. }
  347. &::after {
  348. border: 10px solid transparent;
  349. border-${p => getTipDirection(p.placement)}-color: ${p =>
  350. p.tipColor ?? p.theme.background};
  351. }
  352. `;