hovercard.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. import * as React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import {Manager, Popper, PopperProps, Reference} from 'react-popper';
  4. import {keyframes} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import classNames from 'classnames';
  7. import {fadeIn} from 'app/styles/animations';
  8. import space from 'app/styles/space';
  9. import {domId} from 'app/utils/domId';
  10. const VALID_DIRECTIONS = ['top', 'bottom', 'left', 'right'] as const;
  11. type Direction = typeof VALID_DIRECTIONS[number];
  12. type DefaultProps = {
  13. /**
  14. * Time in ms until hovercard is hidden
  15. */
  16. displayTimeout: number;
  17. /**
  18. * Position tooltip should take relative to the child element
  19. */
  20. position: Direction;
  21. };
  22. type Props = DefaultProps & {
  23. /**
  24. * Classname to apply to the hovercard
  25. */
  26. className?: string;
  27. /**
  28. * Classname to apply to the hovercard container
  29. */
  30. containerClassName?: string;
  31. /**
  32. * Element to display in the header
  33. */
  34. header?: React.ReactNode;
  35. /**
  36. * Element to display in the body
  37. */
  38. body?: React.ReactNode;
  39. /**
  40. * Classname to apply to body container
  41. */
  42. bodyClassName?: string;
  43. /**
  44. * If set, is used INSTEAD OF the hover action to determine whether the hovercard is shown
  45. */
  46. show?: boolean;
  47. /**
  48. * Color of the arrow tip
  49. */
  50. tipColor?: string;
  51. /**
  52. * Color of the arrow tip border
  53. */
  54. tipBorderColor?: string;
  55. /**
  56. * Offset for the arrow
  57. */
  58. offset?: string;
  59. /**
  60. * Popper Modifiers
  61. */
  62. modifiers?: PopperProps['modifiers'];
  63. };
  64. type State = {
  65. visible: boolean;
  66. };
  67. class Hovercard extends React.Component<Props, State> {
  68. static defaultProps: DefaultProps = {
  69. displayTimeout: 100,
  70. position: 'top',
  71. };
  72. constructor(args: Props) {
  73. super(args);
  74. let portal = document.getElementById('hovercard-portal');
  75. if (!portal) {
  76. portal = document.createElement('div');
  77. portal.setAttribute('id', 'hovercard-portal');
  78. document.body.appendChild(portal);
  79. }
  80. this.portalEl = portal;
  81. this.tooltipId = domId('hovercard-');
  82. this.scheduleUpdate = null;
  83. }
  84. state: State = {
  85. visible: false,
  86. };
  87. componentDidUpdate(prevProps: Props) {
  88. const {body, header} = this.props;
  89. if (body !== prevProps.body || header !== prevProps.header) {
  90. // We had a problem with popper not recalculating position when body/header changed while hovercard still opened.
  91. // This can happen for example when showing a loading spinner in a hovercard and then changing it to the actual content once fetch finishes.
  92. this.scheduleUpdate?.();
  93. }
  94. }
  95. portalEl: HTMLElement;
  96. tooltipId: string;
  97. hoverWait: number | null = null;
  98. scheduleUpdate: (() => void) | null;
  99. handleToggleOn = () => this.toggleHovercard(true);
  100. handleToggleOff = () => this.toggleHovercard(false);
  101. toggleHovercard = (visible: boolean) => {
  102. const {displayTimeout} = this.props;
  103. if (this.hoverWait) {
  104. clearTimeout(this.hoverWait);
  105. }
  106. this.hoverWait = window.setTimeout(() => this.setState({visible}), displayTimeout);
  107. };
  108. render() {
  109. const {
  110. bodyClassName,
  111. containerClassName,
  112. className,
  113. header,
  114. body,
  115. position,
  116. show,
  117. tipColor,
  118. tipBorderColor,
  119. offset,
  120. modifiers,
  121. } = this.props;
  122. // Maintain the hovercard class name for BC with less styles
  123. const cx = classNames('hovercard', className);
  124. const popperModifiers: PopperProps['modifiers'] = {
  125. hide: {
  126. enabled: false,
  127. },
  128. preventOverflow: {
  129. padding: 10,
  130. enabled: true,
  131. boundariesElement: 'viewport',
  132. },
  133. ...(modifiers || {}),
  134. };
  135. const visible = show !== undefined ? show : this.state.visible;
  136. const hoverProps =
  137. show !== undefined
  138. ? {}
  139. : {onMouseEnter: this.handleToggleOn, onMouseLeave: this.handleToggleOff};
  140. return (
  141. <Manager>
  142. <Reference>
  143. {({ref}) => (
  144. <span
  145. ref={ref}
  146. aria-describedby={this.tooltipId}
  147. className={containerClassName}
  148. {...hoverProps}
  149. >
  150. {this.props.children}
  151. </span>
  152. )}
  153. </Reference>
  154. {visible &&
  155. (header || body) &&
  156. ReactDOM.createPortal(
  157. <Popper placement={position} modifiers={popperModifiers}>
  158. {({ref, style, placement, arrowProps, scheduleUpdate}) => {
  159. this.scheduleUpdate = scheduleUpdate;
  160. return (
  161. <StyledHovercard
  162. id={this.tooltipId}
  163. visible={visible}
  164. ref={ref}
  165. style={style}
  166. placement={placement as Direction}
  167. offset={offset}
  168. className={cx}
  169. {...hoverProps}
  170. >
  171. {header && <Header>{header}</Header>}
  172. {body && <Body className={bodyClassName}>{body}</Body>}
  173. <HovercardArrow
  174. ref={arrowProps.ref}
  175. style={arrowProps.style}
  176. placement={placement as Direction}
  177. tipColor={tipColor}
  178. tipBorderColor={tipBorderColor}
  179. />
  180. </StyledHovercard>
  181. );
  182. }}
  183. </Popper>,
  184. this.portalEl
  185. )}
  186. </Manager>
  187. );
  188. }
  189. }
  190. // Slide in from the same direction as the placement
  191. // so that the card pops into place.
  192. const slideIn = (p: StyledHovercardProps) => keyframes`
  193. from {
  194. ${p.placement === 'top' ? 'top: -10px;' : ''}
  195. ${p.placement === 'bottom' ? 'top: 10px;' : ''}
  196. ${p.placement === 'left' ? 'left: -10px;' : ''}
  197. ${p.placement === 'right' ? 'left: 10px;' : ''}
  198. }
  199. to {
  200. ${p.placement === 'top' ? 'top: 0;' : ''}
  201. ${p.placement === 'bottom' ? 'top: 0;' : ''}
  202. ${p.placement === 'left' ? 'left: 0;' : ''}
  203. ${p.placement === 'right' ? 'left: 0;' : ''}
  204. }
  205. `;
  206. const getTipDirection = (p: HovercardArrowProps) =>
  207. VALID_DIRECTIONS.includes(p.placement) ? p.placement : 'top';
  208. const getOffset = (p: StyledHovercardProps) => p.offset ?? space(2);
  209. type StyledHovercardProps = {
  210. visible: boolean;
  211. placement: Direction;
  212. offset?: string;
  213. };
  214. const StyledHovercard = styled('div')<StyledHovercardProps>`
  215. border-radius: ${p => p.theme.borderRadius};
  216. text-align: left;
  217. padding: 0;
  218. line-height: 1;
  219. /* Some hovercards overlap the toplevel header and sidebar, and we need to appear on top */
  220. z-index: ${p => p.theme.zIndex.hovercard};
  221. white-space: initial;
  222. color: ${p => p.theme.textColor};
  223. border: 1px solid ${p => p.theme.border};
  224. background: ${p => p.theme.background};
  225. background-clip: padding-box;
  226. box-shadow: 0 0 35px 0 rgba(67, 62, 75, 0.2);
  227. width: 295px;
  228. /* The hovercard may appear in different contexts, don't inherit fonts */
  229. font-family: ${p => p.theme.text.family};
  230. position: absolute;
  231. visibility: ${p => (p.visible ? 'visible' : 'hidden')};
  232. animation: ${fadeIn} 100ms, ${slideIn} 100ms ease-in-out;
  233. animation-play-state: ${p => (p.visible ? 'running' : 'paused')};
  234. /* Offset for the arrow */
  235. ${p => (p.placement === 'top' ? `margin-bottom: ${getOffset(p)}` : '')};
  236. ${p => (p.placement === 'bottom' ? `margin-top: ${getOffset(p)}` : '')};
  237. ${p => (p.placement === 'left' ? `margin-right: ${getOffset(p)}` : '')};
  238. ${p => (p.placement === 'right' ? `margin-left: ${getOffset(p)}` : '')};
  239. `;
  240. const Header = styled('div')`
  241. font-size: ${p => p.theme.fontSizeMedium};
  242. background: ${p => p.theme.backgroundSecondary};
  243. border-bottom: 1px solid ${p => p.theme.border};
  244. border-radius: ${p => p.theme.borderRadiusTop};
  245. font-weight: 600;
  246. word-wrap: break-word;
  247. padding: ${space(1.5)};
  248. `;
  249. const Body = styled('div')`
  250. padding: ${space(2)};
  251. min-height: 30px;
  252. `;
  253. type HovercardArrowProps = {
  254. placement: Direction;
  255. tipColor?: string;
  256. tipBorderColor?: string;
  257. };
  258. const HovercardArrow = styled('span')<HovercardArrowProps>`
  259. position: absolute;
  260. width: 20px;
  261. height: 20px;
  262. z-index: -1;
  263. ${p => (p.placement === 'top' ? 'bottom: -20px; left: 0' : '')};
  264. ${p => (p.placement === 'bottom' ? 'top: -20px; left: 0' : '')};
  265. ${p => (p.placement === 'left' ? 'right: -20px' : '')};
  266. ${p => (p.placement === 'right' ? 'left: -20px' : '')};
  267. &::before,
  268. &::after {
  269. content: '';
  270. margin: auto;
  271. position: absolute;
  272. display: block;
  273. width: 0;
  274. height: 0;
  275. top: 0;
  276. left: 0;
  277. }
  278. /* before element is the hairline border, it is repositioned for each orientation */
  279. &::before {
  280. top: 1px;
  281. border: 10px solid transparent;
  282. border-${getTipDirection}-color: ${p =>
  283. p.tipBorderColor || p.tipColor || p.theme.border};
  284. ${p => (p.placement === 'bottom' ? 'top: -1px' : '')};
  285. ${p => (p.placement === 'left' ? 'top: 0; left: 1px;' : '')};
  286. ${p => (p.placement === 'right' ? 'top: 0; left: -1px' : '')};
  287. }
  288. &::after {
  289. border: 10px solid transparent;
  290. border-${getTipDirection}-color: ${p =>
  291. p.tipColor || (p.placement === 'bottom' ? p.theme.backgroundSecondary : p.theme.white)};
  292. }
  293. `;
  294. export {Body, Header, Hovercard};
  295. export default Hovercard;