mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
205 lines
4.8 KiB
TypeScript
205 lines
4.8 KiB
TypeScript
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 type {ColorOrAlias} from 'sentry/utils/theme';
|
|
import type {UseHoverOverlayProps} from 'sentry/utils/useHoverOverlay';
|
|
import {useHoverOverlay} 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: 8px 8px 0 0;
|
|
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};
|