123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203 |
- 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<UseHoverOverlayProps, 'isHoverable'> {
- /**
- * 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<typeof useHoverOverlay>;
- interface HovercardContentProps
- extends Pick<
- HovercardProps,
- 'bodyClassName' | 'className' | 'header' | 'body' | 'tipColor' | 'tipBorderColor'
- > {
- hoverOverlayState: Omit<UseOverOverlayState, 'isOpen' | 'wrapTrigger'>;
- }
- function useUpdateOverlayPositionOnContentChange({
- update,
- }: Pick<UseOverOverlayState, 'update'>) {
- const ref = useRef<HTMLDivElement | null>(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 (
- <PositionWrapper zIndex={theme.zIndex.hovercard} {...overlayProps}>
- <StyledHovercard
- animated
- arrowProps={{
- ...arrowProps,
- size: 20,
- background: tipColor,
- border: tipBorderColor,
- }}
- originPoint={arrowData}
- placement={placement}
- className={className}
- ref={ref}
- >
- {header ? <Header>{header}</Header> : null}
- {body ? <Body className={bodyClassName}>{body}</Body> : null}
- </StyledHovercard>
- </PositionWrapper>
- );
- }
- 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 <Fragment>{wrapTrigger(children)}</Fragment>;
- }
- const hovercardContent = isOpen && (
- <HovercardContent
- {...{
- body,
- bodyClassName,
- className,
- tipBorderColor,
- tipColor,
- header,
- hoverOverlayState,
- }}
- />
- );
- return (
- <Fragment>
- {wrapTrigger(children)}
- {createPortal(<AnimatePresence>{hovercardContent}</AnimatePresence>, document.body)}
- </Fragment>
- );
- }
- 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};
|