|
@@ -1,30 +1,34 @@
|
|
|
import * as React from 'react';
|
|
|
import ReactDOM from 'react-dom';
|
|
|
import {Manager, Popper, PopperProps, Reference} from 'react-popper';
|
|
|
-import {keyframes} from '@emotion/react';
|
|
|
import styled from '@emotion/styled';
|
|
|
import classNames from 'classnames';
|
|
|
+import {motion} from 'framer-motion';
|
|
|
|
|
|
-import {fadeIn} from 'sentry/styles/animations';
|
|
|
import space from 'sentry/styles/space';
|
|
|
import {domId} from 'sentry/utils/domId';
|
|
|
|
|
|
-const VALID_DIRECTIONS = ['top', 'bottom', 'left', 'right'] as const;
|
|
|
+export const HOVERCARD_PORTAL_ID = 'hovercard-portal';
|
|
|
|
|
|
-type Direction = typeof VALID_DIRECTIONS[number];
|
|
|
+function findOrCreatePortal(): HTMLElement {
|
|
|
+ let portal = document.getElementById(HOVERCARD_PORTAL_ID);
|
|
|
|
|
|
-type DefaultProps = {
|
|
|
- /**
|
|
|
- * Time in ms until hovercard is hidden
|
|
|
- */
|
|
|
- displayTimeout: number;
|
|
|
+ if (portal) {
|
|
|
+ return portal;
|
|
|
+ }
|
|
|
+
|
|
|
+ portal = document.createElement('div');
|
|
|
+ portal.setAttribute('id', HOVERCARD_PORTAL_ID);
|
|
|
+ document.body.appendChild(portal);
|
|
|
+
|
|
|
+ return portal;
|
|
|
+}
|
|
|
+
|
|
|
+interface HovercardProps {
|
|
|
/**
|
|
|
- * Position tooltip should take relative to the child element
|
|
|
+ * Classname to apply to the hovercard
|
|
|
*/
|
|
|
- position: Direction;
|
|
|
-};
|
|
|
-
|
|
|
-type Props = DefaultProps & {
|
|
|
+ children: React.ReactNode;
|
|
|
/**
|
|
|
* Element to display in the body
|
|
|
*/
|
|
@@ -33,14 +37,15 @@ type Props = DefaultProps & {
|
|
|
* Classname to apply to body container
|
|
|
*/
|
|
|
bodyClassName?: string;
|
|
|
- /**
|
|
|
- * Classname to apply to the hovercard
|
|
|
- */
|
|
|
className?: string;
|
|
|
/**
|
|
|
* Classname to apply to the hovercard container
|
|
|
*/
|
|
|
containerClassName?: string;
|
|
|
+ /**
|
|
|
+ * Time in ms until hovercard is hidden
|
|
|
+ */
|
|
|
+ displayTimeout?: number;
|
|
|
/**
|
|
|
* Element to display in the header
|
|
|
*/
|
|
@@ -53,6 +58,10 @@ type Props = DefaultProps & {
|
|
|
* Offset for the arrow
|
|
|
*/
|
|
|
offset?: string;
|
|
|
+ /**
|
|
|
+ * Position tooltip should take relative to the child element
|
|
|
+ */
|
|
|
+ position?: PopperProps['placement'];
|
|
|
/**
|
|
|
* If set, is used INSTEAD OF the hover action to determine whether the hovercard is shown
|
|
|
*/
|
|
@@ -65,82 +74,43 @@ type Props = DefaultProps & {
|
|
|
* Color of the arrow tip
|
|
|
*/
|
|
|
tipColor?: string;
|
|
|
-};
|
|
|
-
|
|
|
-type State = {
|
|
|
- visible: boolean;
|
|
|
-};
|
|
|
-
|
|
|
-class Hovercard extends React.Component<Props, State> {
|
|
|
- static defaultProps: DefaultProps = {
|
|
|
- displayTimeout: 100,
|
|
|
- position: 'top',
|
|
|
- };
|
|
|
-
|
|
|
- constructor(args: Props) {
|
|
|
- super(args);
|
|
|
-
|
|
|
- let portal = document.getElementById('hovercard-portal');
|
|
|
- if (!portal) {
|
|
|
- portal = document.createElement('div');
|
|
|
- portal.setAttribute('id', 'hovercard-portal');
|
|
|
- document.body.appendChild(portal);
|
|
|
- }
|
|
|
- this.portalEl = portal;
|
|
|
- this.tooltipId = domId('hovercard-');
|
|
|
- this.scheduleUpdate = null;
|
|
|
- }
|
|
|
-
|
|
|
- state: State = {
|
|
|
- visible: false,
|
|
|
- };
|
|
|
-
|
|
|
- componentDidUpdate(prevProps: Props) {
|
|
|
- const {body, header} = this.props;
|
|
|
-
|
|
|
- if (body !== prevProps.body || header !== prevProps.header) {
|
|
|
- // We had a problem with popper not recalculating position when body/header changed while hovercard still opened.
|
|
|
- // This can happen for example when showing a loading spinner in a hovercard and then changing it to the actual content once fetch finishes.
|
|
|
- this.scheduleUpdate?.();
|
|
|
- }
|
|
|
- }
|
|
|
+}
|
|
|
|
|
|
- portalEl: HTMLElement;
|
|
|
- tooltipId: string;
|
|
|
- hoverWait: number | null = null;
|
|
|
- scheduleUpdate: (() => void) | null;
|
|
|
+function Hovercard(props: HovercardProps): React.ReactElement {
|
|
|
+ const [visible, setVisible] = React.useState(false);
|
|
|
|
|
|
- handleToggleOn = () => this.toggleHovercard(true);
|
|
|
- handleToggleOff = () => this.toggleHovercard(false);
|
|
|
+ const inTimeout = React.useRef<number | null>(null);
|
|
|
+ const scheduleUpdateRef = React.useRef<(() => void) | null>(null);
|
|
|
|
|
|
- toggleHovercard = (visible: boolean) => {
|
|
|
- const {displayTimeout} = this.props;
|
|
|
+ const portalEl = React.useMemo(() => findOrCreatePortal(), []);
|
|
|
+ const tooltipId = React.useMemo(() => domId('hovercard-'), []);
|
|
|
|
|
|
- if (this.hoverWait) {
|
|
|
- clearTimeout(this.hoverWait);
|
|
|
+ React.useEffect(() => {
|
|
|
+ // We had a problem with popper not recalculating position when body/header changed while hovercard still opened.
|
|
|
+ // This can happen for example when showing a loading spinner in a hovercard and then changing it to the actual content once fetch finishes.
|
|
|
+ if (scheduleUpdateRef.current) {
|
|
|
+ scheduleUpdateRef.current();
|
|
|
}
|
|
|
-
|
|
|
- this.hoverWait = window.setTimeout(() => this.setState({visible}), displayTimeout);
|
|
|
- };
|
|
|
-
|
|
|
- render() {
|
|
|
- const {
|
|
|
- bodyClassName,
|
|
|
- containerClassName,
|
|
|
- className,
|
|
|
- header,
|
|
|
- body,
|
|
|
- position,
|
|
|
- show,
|
|
|
- tipColor,
|
|
|
- tipBorderColor,
|
|
|
- offset,
|
|
|
- modifiers,
|
|
|
- } = this.props;
|
|
|
-
|
|
|
- // Maintain the hovercard class name for BC with less styles
|
|
|
- const cx = classNames('hovercard', className);
|
|
|
- const popperModifiers: PopperProps['modifiers'] = {
|
|
|
+ }, [props.body, props.header]);
|
|
|
+
|
|
|
+ const toggleHovercard = React.useCallback(
|
|
|
+ (value: boolean) => {
|
|
|
+ // If a previous timeout is set, then clear it
|
|
|
+ if (typeof inTimeout.current === 'number') {
|
|
|
+ clearTimeout(inTimeout.current);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Else enqueue a new timeout
|
|
|
+ inTimeout.current = window.setTimeout(
|
|
|
+ () => setVisible(value),
|
|
|
+ props.displayTimeout ?? 100
|
|
|
+ );
|
|
|
+ },
|
|
|
+ [props.displayTimeout]
|
|
|
+ );
|
|
|
+
|
|
|
+ const popperModifiers = React.useMemo(() => {
|
|
|
+ const modifiers: PopperProps['modifiers'] = {
|
|
|
hide: {
|
|
|
enabled: false,
|
|
|
},
|
|
@@ -149,91 +119,163 @@ class Hovercard extends React.Component<Props, State> {
|
|
|
enabled: true,
|
|
|
boundariesElement: 'viewport',
|
|
|
},
|
|
|
- ...(modifiers || {}),
|
|
|
+ ...(props.modifiers || {}),
|
|
|
};
|
|
|
-
|
|
|
- const visible = show !== undefined ? show : this.state.visible;
|
|
|
- const hoverProps =
|
|
|
- show !== undefined
|
|
|
- ? {}
|
|
|
- : {onMouseEnter: this.handleToggleOn, onMouseLeave: this.handleToggleOff};
|
|
|
-
|
|
|
- return (
|
|
|
- <Manager>
|
|
|
- <Reference>
|
|
|
- {({ref}) => (
|
|
|
- <span
|
|
|
- ref={ref}
|
|
|
- aria-describedby={this.tooltipId}
|
|
|
- className={containerClassName}
|
|
|
- {...hoverProps}
|
|
|
- >
|
|
|
- {this.props.children}
|
|
|
- </span>
|
|
|
- )}
|
|
|
- </Reference>
|
|
|
- {visible &&
|
|
|
- (header || body) &&
|
|
|
- ReactDOM.createPortal(
|
|
|
- <Popper placement={position} modifiers={popperModifiers}>
|
|
|
- {({ref, style, placement, arrowProps, scheduleUpdate}) => {
|
|
|
- this.scheduleUpdate = scheduleUpdate;
|
|
|
- return (
|
|
|
+ return modifiers;
|
|
|
+ }, [props.modifiers]);
|
|
|
+
|
|
|
+ // If show is not set, then visibility state is uncontrolled
|
|
|
+ const isVisible = props.show === undefined ? visible : props.show;
|
|
|
+
|
|
|
+ const hoverProps = React.useMemo((): Pick<
|
|
|
+ React.HTMLProps<HTMLDivElement>,
|
|
|
+ 'onMouseEnter' | 'onMouseLeave'
|
|
|
+ > => {
|
|
|
+ // If show is not set, then visibility state is controlled by mouse events
|
|
|
+ if (props.show === undefined) {
|
|
|
+ return {
|
|
|
+ onMouseEnter: () => toggleHovercard(true),
|
|
|
+ onMouseLeave: () => toggleHovercard(false),
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return {};
|
|
|
+ }, [props.show, toggleHovercard]);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Manager>
|
|
|
+ <Reference>
|
|
|
+ {({ref}) => (
|
|
|
+ <span
|
|
|
+ ref={ref}
|
|
|
+ aria-describedby={tooltipId}
|
|
|
+ className={props.containerClassName}
|
|
|
+ {...hoverProps}
|
|
|
+ >
|
|
|
+ {props.children}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </Reference>
|
|
|
+ {ReactDOM.createPortal(
|
|
|
+ <Popper placement={props.position ?? 'top'} modifiers={popperModifiers}>
|
|
|
+ {({ref, style, placement, arrowProps, scheduleUpdate}) => {
|
|
|
+ scheduleUpdateRef.current = scheduleUpdate;
|
|
|
+
|
|
|
+ // Element is not visible in neither controlled and uncontrolled state (show prop is not passed and card is not hovered)
|
|
|
+ if (!isVisible) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Nothing to render
|
|
|
+ if (!props.body && !props.header) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <HovercardContainer style={style}>
|
|
|
+ <SlideInAnimation visible={isVisible} placement={placement}>
|
|
|
<StyledHovercard
|
|
|
- id={this.tooltipId}
|
|
|
- visible={visible}
|
|
|
ref={ref}
|
|
|
- style={style}
|
|
|
- placement={placement as Direction}
|
|
|
- offset={offset}
|
|
|
- className={cx}
|
|
|
+ id={tooltipId}
|
|
|
+ placement={placement}
|
|
|
+ offset={props.offset}
|
|
|
+ // Maintain the hovercard class name for BC with less styles
|
|
|
+ className={classNames('hovercard', props.className)}
|
|
|
{...hoverProps}
|
|
|
>
|
|
|
- {header && <Header>{header}</Header>}
|
|
|
- {body && <Body className={bodyClassName}>{body}</Body>}
|
|
|
+ {props.header ? <Header>{props.header}</Header> : null}
|
|
|
+ {props.body ? (
|
|
|
+ <Body className={props.bodyClassName}>{props.body}</Body>
|
|
|
+ ) : null}
|
|
|
<HovercardArrow
|
|
|
ref={arrowProps.ref}
|
|
|
style={arrowProps.style}
|
|
|
- placement={placement as Direction}
|
|
|
- tipColor={tipColor}
|
|
|
- tipBorderColor={tipBorderColor}
|
|
|
+ placement={placement}
|
|
|
+ tipColor={props.tipColor}
|
|
|
+ tipBorderColor={props.tipBorderColor}
|
|
|
/>
|
|
|
</StyledHovercard>
|
|
|
- );
|
|
|
- }}
|
|
|
- </Popper>,
|
|
|
- this.portalEl
|
|
|
- )}
|
|
|
- </Manager>
|
|
|
- );
|
|
|
- }
|
|
|
+ </SlideInAnimation>
|
|
|
+ </HovercardContainer>
|
|
|
+ );
|
|
|
+ }}
|
|
|
+ </Popper>,
|
|
|
+ portalEl
|
|
|
+ )}
|
|
|
+ </Manager>
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
-// Slide in from the same direction as the placement
|
|
|
-// so that the card pops into place.
|
|
|
-const slideIn = (p: StyledHovercardProps) => keyframes`
|
|
|
- from {
|
|
|
- ${p.placement === 'top' ? 'top: -10px;' : ''}
|
|
|
- ${p.placement === 'bottom' ? 'top: 10px;' : ''}
|
|
|
- ${p.placement === 'left' ? 'left: -10px;' : ''}
|
|
|
- ${p.placement === 'right' ? 'left: 10px;' : ''}
|
|
|
- }
|
|
|
- to {
|
|
|
- ${p.placement === 'top' ? 'top: 0;' : ''}
|
|
|
- ${p.placement === 'bottom' ? 'top: 0;' : ''}
|
|
|
- ${p.placement === 'left' ? 'left: 0;' : ''}
|
|
|
- ${p.placement === 'right' ? 'left: 0;' : ''}
|
|
|
+export {Hovercard};
|
|
|
+
|
|
|
+const SLIDE_DISTANCE = 10;
|
|
|
+
|
|
|
+function SlideInAnimation({
|
|
|
+ visible,
|
|
|
+ placement,
|
|
|
+ children,
|
|
|
+}: {
|
|
|
+ children: React.ReactNode;
|
|
|
+ placement: PopperProps['placement'];
|
|
|
+ visible: boolean;
|
|
|
+}): React.ReactElement {
|
|
|
+ const narrowedPlacement = getTipDirection(placement);
|
|
|
+
|
|
|
+ const x =
|
|
|
+ narrowedPlacement === 'left'
|
|
|
+ ? [-SLIDE_DISTANCE, 0]
|
|
|
+ : narrowedPlacement === 'right'
|
|
|
+ ? [SLIDE_DISTANCE, 0]
|
|
|
+ : [0, 0];
|
|
|
+
|
|
|
+ const y =
|
|
|
+ narrowedPlacement === 'top'
|
|
|
+ ? [-SLIDE_DISTANCE, 0]
|
|
|
+ : narrowedPlacement === 'bottom'
|
|
|
+ ? [SLIDE_DISTANCE, 0]
|
|
|
+ : [0, 0];
|
|
|
+
|
|
|
+ return (
|
|
|
+ <motion.div
|
|
|
+ initial="hidden"
|
|
|
+ variants={{
|
|
|
+ hidden: {
|
|
|
+ opacity: 0,
|
|
|
+ },
|
|
|
+ visible: {
|
|
|
+ opacity: [0, 1],
|
|
|
+ x,
|
|
|
+ y,
|
|
|
+ },
|
|
|
+ }}
|
|
|
+ animate={visible ? 'visible' : 'hidden'}
|
|
|
+ transition={{duration: 0.1, ease: 'easeInOut'}}
|
|
|
+ >
|
|
|
+ {children}
|
|
|
+ </motion.div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function getTipDirection(
|
|
|
+ placement: HovercardArrowProps['placement']
|
|
|
+): 'top' | 'bottom' | 'left' | 'right' {
|
|
|
+ if (!placement) {
|
|
|
+ return 'top';
|
|
|
}
|
|
|
-`;
|
|
|
|
|
|
-const getTipDirection = (p: HovercardArrowProps) =>
|
|
|
- VALID_DIRECTIONS.includes(p.placement) ? p.placement : 'top';
|
|
|
+ const prefix = ['top', 'bottom', 'left', 'right'].find(pl => {
|
|
|
+ return placement.startsWith(pl);
|
|
|
+ });
|
|
|
+
|
|
|
+ return (prefix || 'top') as 'top' | 'bottom' | 'left' | 'right';
|
|
|
+}
|
|
|
|
|
|
-const getOffset = (p: StyledHovercardProps) => p.offset ?? space(2);
|
|
|
+const HovercardContainer = styled('div')`
|
|
|
+ /* Some hovercards overlap the toplevel header and sidebar, and we need to appear on top */
|
|
|
+ z-index: ${p => p.theme.zIndex.hovercard};
|
|
|
+`;
|
|
|
|
|
|
type StyledHovercardProps = {
|
|
|
- placement: Direction;
|
|
|
- visible: boolean;
|
|
|
+ placement: PopperProps['placement'];
|
|
|
offset?: string;
|
|
|
};
|
|
|
|
|
@@ -242,8 +284,6 @@ const StyledHovercard = styled('div')<StyledHovercardProps>`
|
|
|
text-align: left;
|
|
|
padding: 0;
|
|
|
line-height: 1;
|
|
|
- /* Some hovercards overlap the toplevel header and sidebar, and we need to appear on top */
|
|
|
- z-index: ${p => p.theme.zIndex.hovercard};
|
|
|
white-space: initial;
|
|
|
color: ${p => p.theme.textColor};
|
|
|
border: 1px solid ${p => p.theme.border};
|
|
@@ -255,17 +295,11 @@ const StyledHovercard = styled('div')<StyledHovercardProps>`
|
|
|
/* The hovercard may appear in different contexts, don't inherit fonts */
|
|
|
font-family: ${p => p.theme.text.family};
|
|
|
|
|
|
- position: absolute;
|
|
|
- visibility: ${p => (p.visible ? 'visible' : 'hidden')};
|
|
|
-
|
|
|
- animation: ${fadeIn} 100ms, ${slideIn} 100ms ease-in-out;
|
|
|
- animation-play-state: ${p => (p.visible ? 'running' : 'paused')};
|
|
|
-
|
|
|
/* Offset for the arrow */
|
|
|
- ${p => (p.placement === 'top' ? `margin-bottom: ${getOffset(p)}` : '')};
|
|
|
- ${p => (p.placement === 'bottom' ? `margin-top: ${getOffset(p)}` : '')};
|
|
|
- ${p => (p.placement === 'left' ? `margin-right: ${getOffset(p)}` : '')};
|
|
|
- ${p => (p.placement === 'right' ? `margin-left: ${getOffset(p)}` : '')};
|
|
|
+ ${p => (p.placement === 'top' ? `margin-bottom: ${p.offset ?? space(2)}` : '')};
|
|
|
+ ${p => (p.placement === 'bottom' ? `margin-top: ${p.offset ?? space(2)}` : '')};
|
|
|
+ ${p => (p.placement === 'left' ? `margin-right: ${p.offset ?? space(2)}` : '')};
|
|
|
+ ${p => (p.placement === 'right' ? `margin-left: ${p.offset ?? space(2)}` : '')};
|
|
|
`;
|
|
|
|
|
|
const Header = styled('div')`
|
|
@@ -278,13 +312,17 @@ const Header = styled('div')`
|
|
|
padding: ${space(1.5)};
|
|
|
`;
|
|
|
|
|
|
+export {Header};
|
|
|
+
|
|
|
const Body = styled('div')`
|
|
|
padding: ${space(2)};
|
|
|
min-height: 30px;
|
|
|
`;
|
|
|
|
|
|
+export {Body};
|
|
|
+
|
|
|
type HovercardArrowProps = {
|
|
|
- placement: Direction;
|
|
|
+ placement: PopperProps['placement'];
|
|
|
tipBorderColor?: string;
|
|
|
tipColor?: string;
|
|
|
};
|
|
@@ -293,12 +331,10 @@ const HovercardArrow = styled('span')<HovercardArrowProps>`
|
|
|
position: absolute;
|
|
|
width: 20px;
|
|
|
height: 20px;
|
|
|
- z-index: -1;
|
|
|
-
|
|
|
- ${p => (p.placement === 'top' ? 'bottom: -20px; left: 0' : '')};
|
|
|
- ${p => (p.placement === 'bottom' ? 'top: -20px; left: 0' : '')};
|
|
|
- ${p => (p.placement === 'left' ? 'right: -20px' : '')};
|
|
|
- ${p => (p.placement === 'right' ? 'left: -20px' : '')};
|
|
|
+ right: ${p => (p.placement === 'left' ? '-3px' : 'auto')};
|
|
|
+ left: ${p => (p.placement === 'right' ? '-3px' : 'auto')};
|
|
|
+ bottom: ${p => (p.placement === 'top' ? '-3px' : 'auto')};
|
|
|
+ top: ${p => (p.placement === 'bottom' ? '-3px' : 'auto')};
|
|
|
|
|
|
&::before,
|
|
|
&::after {
|
|
@@ -316,18 +352,15 @@ const HovercardArrow = styled('span')<HovercardArrowProps>`
|
|
|
&::before {
|
|
|
top: 1px;
|
|
|
border: 10px solid transparent;
|
|
|
- border-${getTipDirection}-color: ${p =>
|
|
|
- p.tipBorderColor || p.tipColor || p.theme.border};
|
|
|
-
|
|
|
- ${p => (p.placement === 'bottom' ? 'top: -1px' : '')};
|
|
|
- ${p => (p.placement === 'left' ? 'top: 0; left: 1px;' : '')};
|
|
|
- ${p => (p.placement === 'right' ? 'top: 0; left: -1px' : '')};
|
|
|
- }
|
|
|
- &::after {
|
|
|
- border: 10px solid transparent;
|
|
|
- border-${getTipDirection}-color: ${p => p.tipColor ?? p.theme.background};
|
|
|
- }
|
|
|
+ border-${p => getTipDirection(p.placement)}-color:
|
|
|
+ ${p => p.tipBorderColor || p.tipColor || p.theme.border};
|
|
|
+ ${p => (p.placement === 'bottom' ? 'top: -1px' : '')};
|
|
|
+ ${p => (p.placement === 'left' ? 'top: 0; left: 1px;' : '')};
|
|
|
+ ${p => (p.placement === 'right' ? 'top: 0; left: -1px' : '')};
|
|
|
+ }
|
|
|
+ &::after {
|
|
|
+ border: 10px solid transparent;
|
|
|
+ border-${p => getTipDirection(p.placement)}-color: ${p =>
|
|
|
+ p.tipColor ?? p.theme.background};
|
|
|
+ }
|
|
|
`;
|
|
|
-
|
|
|
-export {Body, Header, Hovercard};
|
|
|
-export default Hovercard;
|