hovercard.tsx 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import {Fragment, useEffect} from 'react';
  2. import {createPortal} from 'react-dom';
  3. import {useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import {AnimatePresence} from 'framer-motion';
  6. import {Overlay, PositionWrapper} from 'sentry/components/overlay';
  7. import space from 'sentry/styles/space';
  8. import {ColorOrAlias} from 'sentry/utils/theme';
  9. import {useHoverOverlay, UseHoverOverlayProps} from 'sentry/utils/useHoverOverlay';
  10. interface HovercardProps extends Omit<UseHoverOverlayProps, 'isHoverable'> {
  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. /**
  24. * Classname to apply to the hovercard container
  25. */
  26. containerClassName?: string;
  27. /**
  28. * Element to display in the header
  29. */
  30. header?: React.ReactNode;
  31. /**
  32. * Color of the arrow tip border
  33. */
  34. tipBorderColor?: ColorOrAlias;
  35. /**
  36. * Color of the arrow tip
  37. */
  38. tipColor?: ColorOrAlias;
  39. }
  40. function Hovercard({
  41. body,
  42. bodyClassName,
  43. children,
  44. className,
  45. containerClassName,
  46. header,
  47. offset = 12,
  48. displayTimeout = 100,
  49. tipBorderColor = 'translucentBorder',
  50. tipColor = 'backgroundElevated',
  51. ...hoverOverlayProps
  52. }: HovercardProps): React.ReactElement {
  53. const theme = useTheme();
  54. const {wrapTrigger, isOpen, overlayProps, placement, arrowData, arrowProps, update} =
  55. useHoverOverlay('hovercard', {
  56. offset,
  57. displayTimeout,
  58. isHoverable: true,
  59. className: containerClassName,
  60. ...hoverOverlayProps,
  61. });
  62. // Changes to header and body can change the size of the overlay content
  63. // which can result in the popper state being out of date
  64. useEffect(() => {
  65. if (isOpen) {
  66. update?.();
  67. }
  68. }, [isOpen, update, header, body]);
  69. // Nothing to render if no header or body. Be consistent with wrapping the
  70. // children with the trigger in the case that the body / header is set while
  71. // the trigger is hovered.
  72. if (!body && !header) {
  73. return <Fragment>{wrapTrigger(children)}</Fragment>;
  74. }
  75. const hovercardContent = isOpen && (
  76. <PositionWrapper zIndex={theme.zIndex.hovercard} {...overlayProps}>
  77. <StyledHovercard
  78. animated
  79. arrowProps={{
  80. ...arrowProps,
  81. size: 20,
  82. background: tipColor,
  83. border: tipBorderColor,
  84. }}
  85. originPoint={arrowData}
  86. placement={placement}
  87. className={className}
  88. >
  89. {header ? <Header>{header}</Header> : null}
  90. {body ? <Body className={bodyClassName}>{body}</Body> : null}
  91. </StyledHovercard>
  92. </PositionWrapper>
  93. );
  94. return (
  95. <Fragment>
  96. {wrapTrigger(children)}
  97. {createPortal(<AnimatePresence>{hovercardContent}</AnimatePresence>, document.body)}
  98. </Fragment>
  99. );
  100. }
  101. const StyledHovercard = styled(Overlay)`
  102. width: 295px;
  103. line-height: 1.2;
  104. h6 {
  105. color: ${p => p.theme.subText};
  106. font-size: ${p => p.theme.fontSizeExtraSmall};
  107. margin-bottom: ${space(1)};
  108. text-transform: uppercase;
  109. }
  110. `;
  111. const Header = styled('div')`
  112. font-size: ${p => p.theme.fontSizeMedium};
  113. background: ${p => p.theme.backgroundSecondary};
  114. border-bottom: 1px solid ${p => p.theme.border};
  115. border-radius: ${p => p.theme.borderRadiusTop};
  116. font-weight: 600;
  117. word-wrap: break-word;
  118. padding: ${space(1.5)};
  119. `;
  120. const Body = styled('div')`
  121. padding: ${space(2)};
  122. min-height: 30px;
  123. `;
  124. const Divider = styled('div')`
  125. position: relative;
  126. margin-top: ${space(1.5)};
  127. margin-bottom: ${space(1)};
  128. &:before {
  129. display: block;
  130. position: absolute;
  131. content: '';
  132. height: 1px;
  133. top: 50%;
  134. left: ${space(2)};
  135. right: ${space(2)};
  136. background: ${p => p.theme.innerBorder};
  137. z-index: -1;
  138. }
  139. h6 {
  140. display: inline;
  141. padding-right: ${space(1)};
  142. background: ${p => p.theme.background};
  143. }
  144. `;
  145. export {Hovercard, Header, Body, Divider};