hovercard.tsx 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. import {Fragment, useCallback, useRef} from 'react';
  2. import {createPortal} from 'react-dom';
  3. import {useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import {useResizeObserver} from '@react-aria/utils';
  6. import {AnimatePresence} from 'framer-motion';
  7. import {Overlay, PositionWrapper} from 'sentry/components/overlay';
  8. import space from 'sentry/styles/space';
  9. import {ColorOrAlias} from 'sentry/utils/theme';
  10. import {useHoverOverlay, UseHoverOverlayProps} from 'sentry/utils/useHoverOverlay';
  11. interface HovercardProps extends Omit<UseHoverOverlayProps, 'isHoverable'> {
  12. /**
  13. * Classname to apply to the hovercard
  14. */
  15. children: React.ReactNode;
  16. /**
  17. * Element to display in the body
  18. */
  19. body?: React.ReactNode;
  20. /**
  21. * Classname to apply to body container
  22. */
  23. bodyClassName?: string;
  24. /**
  25. * Classname to apply to the hovercard container
  26. */
  27. containerClassName?: string;
  28. /**
  29. * Element to display in the header
  30. */
  31. header?: React.ReactNode;
  32. /**
  33. * Color of the arrow tip border
  34. */
  35. tipBorderColor?: ColorOrAlias;
  36. /**
  37. * Color of the arrow tip
  38. */
  39. tipColor?: ColorOrAlias;
  40. }
  41. type UseOverOverlayState = ReturnType<typeof useHoverOverlay>;
  42. interface HovercardContentProps
  43. extends Pick<
  44. HovercardProps,
  45. 'bodyClassName' | 'className' | 'header' | 'body' | 'tipColor' | 'tipBorderColor'
  46. > {
  47. hoverOverlayState: Omit<UseOverOverlayState, 'isOpen' | 'wrapTrigger'>;
  48. }
  49. function useUpdateOverlayPositionOnContentChange({
  50. update,
  51. }: Pick<UseOverOverlayState, 'update'>) {
  52. const ref = useRef<HTMLDivElement | null>(null);
  53. const onResize = useCallback(() => {
  54. update?.();
  55. }, [update]);
  56. useResizeObserver({
  57. ref,
  58. onResize,
  59. });
  60. return ref;
  61. }
  62. function HovercardContent({
  63. body,
  64. bodyClassName,
  65. className,
  66. tipBorderColor,
  67. tipColor,
  68. header,
  69. hoverOverlayState: {arrowData, arrowProps, overlayProps, placement, update},
  70. }: HovercardContentProps) {
  71. const theme = useTheme();
  72. const ref = useUpdateOverlayPositionOnContentChange({update});
  73. return (
  74. <PositionWrapper zIndex={theme.zIndex.hovercard} {...overlayProps}>
  75. <StyledHovercard
  76. animated
  77. arrowProps={{
  78. ...arrowProps,
  79. size: 20,
  80. background: tipColor,
  81. border: tipBorderColor,
  82. }}
  83. originPoint={arrowData}
  84. placement={placement}
  85. className={className}
  86. ref={ref}
  87. >
  88. {header ? <Header>{header}</Header> : null}
  89. {body ? <Body className={bodyClassName}>{body}</Body> : null}
  90. </StyledHovercard>
  91. </PositionWrapper>
  92. );
  93. }
  94. function Hovercard({
  95. body,
  96. bodyClassName,
  97. children,
  98. className,
  99. containerClassName,
  100. header,
  101. offset = 12,
  102. displayTimeout = 100,
  103. tipBorderColor = 'translucentBorder',
  104. tipColor = 'backgroundElevated',
  105. ...hoverOverlayProps
  106. }: HovercardProps): React.ReactElement {
  107. const {wrapTrigger, isOpen, ...hoverOverlayState} = useHoverOverlay('hovercard', {
  108. offset,
  109. displayTimeout,
  110. isHoverable: true,
  111. className: containerClassName,
  112. ...hoverOverlayProps,
  113. });
  114. // Nothing to render if no header or body. Be consistent with wrapping the
  115. // children with the trigger in the case that the body / header is set while
  116. // the trigger is hovered.
  117. if (!body && !header) {
  118. return <Fragment>{wrapTrigger(children)}</Fragment>;
  119. }
  120. const hovercardContent = isOpen && (
  121. <HovercardContent
  122. {...{
  123. body,
  124. bodyClassName,
  125. className,
  126. tipBorderColor,
  127. tipColor,
  128. header,
  129. hoverOverlayState,
  130. }}
  131. />
  132. );
  133. return (
  134. <Fragment>
  135. {wrapTrigger(children)}
  136. {createPortal(<AnimatePresence>{hovercardContent}</AnimatePresence>, document.body)}
  137. </Fragment>
  138. );
  139. }
  140. const StyledHovercard = styled(Overlay)`
  141. width: 295px;
  142. line-height: 1.2;
  143. h6 {
  144. color: ${p => p.theme.subText};
  145. font-size: ${p => p.theme.fontSizeExtraSmall};
  146. margin-bottom: ${space(1)};
  147. text-transform: uppercase;
  148. }
  149. `;
  150. const Header = styled('div')`
  151. font-size: ${p => p.theme.fontSizeMedium};
  152. background: ${p => p.theme.backgroundSecondary};
  153. border-bottom: 1px solid ${p => p.theme.border};
  154. border-radius: ${p => p.theme.borderRadiusTop};
  155. font-weight: 600;
  156. word-wrap: break-word;
  157. padding: ${space(1.5)};
  158. `;
  159. const Body = styled('div')`
  160. padding: ${space(2)};
  161. min-height: 30px;
  162. `;
  163. const Divider = styled('div')`
  164. position: relative;
  165. margin-top: ${space(1.5)};
  166. margin-bottom: ${space(1)};
  167. &:before {
  168. display: block;
  169. position: absolute;
  170. content: '';
  171. height: 1px;
  172. top: 50%;
  173. left: ${space(2)};
  174. right: ${space(2)};
  175. background: ${p => p.theme.innerBorder};
  176. z-index: -1;
  177. }
  178. h6 {
  179. display: inline;
  180. padding-right: ${space(1)};
  181. background: ${p => p.theme.background};
  182. }
  183. `;
  184. export {Hovercard, Header, Body, Divider};