import {Fragment, useCallback, useRef} from 'react'; import {createPortal} from 'react-dom'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {useResizeObserver} from '@react-aria/utils'; import {AnimatePresence} from 'framer-motion'; import {Overlay, PositionWrapper} from 'sentry/components/overlay'; import space from 'sentry/styles/space'; import {ColorOrAlias} from 'sentry/utils/theme'; import {useHoverOverlay, UseHoverOverlayProps} from 'sentry/utils/useHoverOverlay'; interface HovercardProps extends Omit { /** * Classname to apply to the hovercard */ children: React.ReactNode; /** * Element to display in the body */ body?: React.ReactNode; /** * Classname to apply to body container */ bodyClassName?: string; /** * Classname to apply to the hovercard container */ containerClassName?: string; /** * Element to display in the header */ header?: React.ReactNode; /** * Color of the arrow tip border */ tipBorderColor?: ColorOrAlias; /** * Color of the arrow tip */ tipColor?: ColorOrAlias; } type UseOverOverlayState = ReturnType; interface HovercardContentProps extends Pick< HovercardProps, 'bodyClassName' | 'className' | 'header' | 'body' | 'tipColor' | 'tipBorderColor' > { hoverOverlayState: Omit; } function useUpdateOverlayPositionOnContentChange({ update, }: Pick) { const ref = useRef(null); const onResize = useCallback(() => { update?.(); }, [update]); useResizeObserver({ ref, onResize, }); return ref; } function HovercardContent({ body, bodyClassName, className, tipBorderColor, tipColor, header, hoverOverlayState: {arrowData, arrowProps, overlayProps, placement, update}, }: HovercardContentProps) { const theme = useTheme(); const ref = useUpdateOverlayPositionOnContentChange({update}); return ( {header ?
{header}
: null} {body ? {body} : null}
); } function Hovercard({ body, bodyClassName, children, className, containerClassName, header, offset = 12, displayTimeout = 100, tipBorderColor = 'translucentBorder', tipColor = 'backgroundElevated', ...hoverOverlayProps }: HovercardProps): React.ReactElement { const {wrapTrigger, isOpen, ...hoverOverlayState} = useHoverOverlay('hovercard', { offset, displayTimeout, isHoverable: true, className: containerClassName, ...hoverOverlayProps, }); // Nothing to render if no header or body. Be consistent with wrapping the // children with the trigger in the case that the body / header is set while // the trigger is hovered. if (!body && !header) { return {wrapTrigger(children)}; } const hovercardContent = isOpen && ( ); return ( {wrapTrigger(children)} {createPortal({hovercardContent}, document.body)} ); } const StyledHovercard = styled(Overlay)` width: 295px; line-height: 1.2; h6 { color: ${p => p.theme.subText}; font-size: ${p => p.theme.fontSizeExtraSmall}; margin-bottom: ${space(1)}; text-transform: uppercase; } `; const Header = styled('div')` font-size: ${p => p.theme.fontSizeMedium}; background: ${p => p.theme.backgroundSecondary}; border-bottom: 1px solid ${p => p.theme.border}; border-radius: ${p => p.theme.borderRadiusTop}; font-weight: 600; word-wrap: break-word; padding: ${space(1.5)}; `; const Body = styled('div')` padding: ${space(2)}; min-height: 30px; `; const Divider = styled('div')` position: relative; margin-top: ${space(1.5)}; margin-bottom: ${space(1)}; &:before { display: block; position: absolute; content: ''; height: 1px; top: 50%; left: ${space(2)}; right: ${space(2)}; background: ${p => p.theme.innerBorder}; z-index: -1; } h6 { display: inline; padding-right: ${space(1)}; background: ${p => p.theme.background}; } `; export {Hovercard, Header, Body, Divider};