useHoverOverlay.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import {
  2. cloneElement,
  3. isValidElement,
  4. useCallback,
  5. useEffect,
  6. useMemo,
  7. useRef,
  8. useState,
  9. } from 'react';
  10. import {PopperProps, usePopper} from 'react-popper';
  11. import {useTheme} from '@emotion/react';
  12. import styled from '@emotion/styled';
  13. import domId from 'sentry/utils/domId';
  14. import {ColorOrAlias} from 'sentry/utils/theme';
  15. /**
  16. * How long to wait before opening the overlay
  17. */
  18. const OPEN_DELAY = 50;
  19. /**
  20. * How long to wait before closing the overlay when isHoverable is set
  21. */
  22. const CLOSE_DELAY = 50;
  23. interface UseHoverOverlayProps {
  24. /**
  25. * className for when a wrapper is used. Does nothing using skipWrapper.
  26. */
  27. className?: string;
  28. /**
  29. * Display mode for the container element. Does nothing using skipWrapper.
  30. */
  31. containerDisplayMode?: React.CSSProperties['display'];
  32. /**
  33. * Time to wait (in milliseconds) before showing the overlay
  34. */
  35. delay?: number;
  36. /**
  37. * Time in ms until overlay is hidden. When used with isHoverable this is
  38. * used as the time allowed for the user to move their cursor into the overlay)
  39. */
  40. displayTimeout?: number;
  41. /**
  42. * Force the overlay to be visible without hovering
  43. */
  44. forceVisible?: boolean;
  45. /**
  46. * If true, user is able to hover overlay without it disappearing. (nice if
  47. * you want the overlay to be interactive)
  48. */
  49. isHoverable?: boolean;
  50. /**
  51. * Offset along the main axis.
  52. */
  53. offset?: number;
  54. /**
  55. * Position for the overlay.
  56. */
  57. position?: PopperProps<any>['placement'];
  58. /**
  59. * Only display the overlay only if the content overflows
  60. */
  61. showOnlyOnOverflow?: boolean;
  62. /**
  63. * Whether to add a dotted underline to the trigger element, to indicate the
  64. * presence of a overlay.
  65. */
  66. showUnderline?: boolean;
  67. /**
  68. * If child node supports ref forwarding, you can skip apply a wrapper
  69. */
  70. skipWrapper?: boolean;
  71. /**
  72. * Color of the dotted underline, if available. See also: showUnderline.
  73. */
  74. underlineColor?: ColorOrAlias;
  75. }
  76. function isOverflown(el: Element): boolean {
  77. return el.scrollWidth > el.clientWidth || Array.from(el.children).some(isOverflown);
  78. }
  79. /**
  80. * A hook used to trigger a positioned overlay on hover.
  81. */
  82. function useHoverOverlay(
  83. overlayType: string,
  84. {
  85. className,
  86. delay,
  87. displayTimeout,
  88. isHoverable,
  89. showUnderline,
  90. underlineColor,
  91. showOnlyOnOverflow,
  92. skipWrapper,
  93. forceVisible,
  94. offset = 8,
  95. position = 'top',
  96. containerDisplayMode = 'inline-block',
  97. }: UseHoverOverlayProps
  98. ) {
  99. const [isVisible, setVisible] = useState(false);
  100. const describeById = useMemo(() => domId(`${overlayType}-`), [overlayType]);
  101. const theme = useTheme();
  102. const isOpen = forceVisible ?? isVisible;
  103. const [triggerElement, setTriggerElement] = useState<HTMLElement | null>(null);
  104. const [overlayElement, setOverlayElement] = useState<HTMLElement | null>(null);
  105. const [arrowElement, setArrowElement] = useState<HTMLElement | null>(null);
  106. const modifiers = useMemo(
  107. () => [
  108. {
  109. name: 'hide',
  110. enabled: false,
  111. },
  112. {
  113. name: 'computeStyles',
  114. options: {
  115. // Using the `transform` attribute causes our borders to get blurry
  116. // in chrome. See [0]. This just causes it to use `top` / `left`
  117. // positions, which should be fine.
  118. //
  119. // [0]: https://stackoverflow.com/questions/29543142/css3-transformation-blurry-borders
  120. gpuAcceleration: false,
  121. },
  122. },
  123. {
  124. name: 'arrow',
  125. options: {
  126. element: arrowElement,
  127. // Set padding to avoid the arrow reaching the side of the tooltip
  128. // and overflowing out of the rounded border
  129. padding: 4,
  130. },
  131. },
  132. {
  133. name: 'offset',
  134. options: {
  135. offset: [0, offset],
  136. },
  137. },
  138. {
  139. name: 'preventOverflow',
  140. enabled: true,
  141. options: {
  142. padding: 12,
  143. altAxis: true,
  144. },
  145. },
  146. ],
  147. [arrowElement, offset]
  148. );
  149. const {styles, state} = usePopper(triggerElement, overlayElement, {
  150. modifiers,
  151. placement: position,
  152. });
  153. // Delayed open and close time handles
  154. const delayOpenTimeoutRef = useRef<number | undefined>(undefined);
  155. const delayHideTimeoutRef = useRef<number | undefined>(undefined);
  156. // When the component is unmounted, make sure to stop the timeouts
  157. useEffect(() => {
  158. return () => {
  159. window.clearTimeout(delayOpenTimeoutRef.current);
  160. window.clearTimeout(delayHideTimeoutRef.current);
  161. };
  162. }, []);
  163. const handleMouseEnter = useCallback(() => {
  164. // Do nothing if showOnlyOnOverflow and we're not overflowing.
  165. if (showOnlyOnOverflow && triggerElement && !isOverflown(triggerElement)) {
  166. return;
  167. }
  168. window.clearTimeout(delayHideTimeoutRef.current);
  169. window.clearTimeout(delayOpenTimeoutRef.current);
  170. if (delay === 0) {
  171. setVisible(true);
  172. return;
  173. }
  174. delayOpenTimeoutRef.current = window.setTimeout(
  175. () => setVisible(true),
  176. delay ?? OPEN_DELAY
  177. );
  178. }, [delay, showOnlyOnOverflow, triggerElement]);
  179. const handleMouseLeave = useCallback(() => {
  180. window.clearTimeout(delayOpenTimeoutRef.current);
  181. window.clearTimeout(delayHideTimeoutRef.current);
  182. if (!isHoverable && !displayTimeout) {
  183. setVisible(false);
  184. return;
  185. }
  186. delayHideTimeoutRef.current = window.setTimeout(
  187. () => setVisible(false),
  188. displayTimeout ?? CLOSE_DELAY
  189. );
  190. }, [isHoverable, displayTimeout]);
  191. /**
  192. * Wraps the passed in react elements with a container that has the proper
  193. * event handlers to trigger the overlay.
  194. *
  195. * If skipWrapper is used the passed element will be cloned and the events
  196. * will be assigned to that element.
  197. */
  198. const wrapTrigger = useCallback(
  199. (triggerChildren: React.ReactNode) => {
  200. const props = {
  201. 'aria-describedby': describeById,
  202. ref: setTriggerElement,
  203. onFocus: handleMouseEnter,
  204. onBlur: handleMouseLeave,
  205. onPointerEnter: handleMouseEnter,
  206. onPointerLeave: handleMouseLeave,
  207. };
  208. // Use the `type` property of the react instance to detect whether we have
  209. // a basic element (type=string) or a class/function component
  210. // (type=function or object). Because we can't rely on the child element
  211. // implementing forwardRefs we wrap it with a span tag for the ref
  212. if (
  213. isValidElement(triggerChildren) &&
  214. (skipWrapper || typeof triggerChildren.type === 'string')
  215. ) {
  216. const triggerStyle = {
  217. ...triggerChildren.props.style,
  218. ...(showUnderline && theme.tooltipUnderline(underlineColor)),
  219. };
  220. // Basic DOM nodes can be cloned and have more props applied.
  221. return cloneElement<any>(triggerChildren, {...props, style: triggerStyle});
  222. }
  223. const ourContainerProps = {
  224. ...props,
  225. containerDisplayMode,
  226. style: showUnderline ? theme.tooltipUnderline(underlineColor) : undefined,
  227. className,
  228. };
  229. return <Container {...ourContainerProps}>{triggerChildren}</Container>;
  230. },
  231. [
  232. className,
  233. containerDisplayMode,
  234. handleMouseEnter,
  235. handleMouseLeave,
  236. showUnderline,
  237. skipWrapper,
  238. describeById,
  239. theme,
  240. underlineColor,
  241. ]
  242. );
  243. const overlayProps = {
  244. id: describeById,
  245. ref: setOverlayElement,
  246. style: styles.popper,
  247. onMouseEnter: isHoverable ? handleMouseEnter : undefined,
  248. onMouseLeave: isHoverable ? handleMouseLeave : undefined,
  249. };
  250. const arrowProps = {
  251. ref: setArrowElement,
  252. style: styles.arrow,
  253. placement: state?.placement,
  254. };
  255. return {
  256. wrapTrigger,
  257. isOpen,
  258. overlayProps,
  259. arrowProps,
  260. placement: state?.placement,
  261. arrowData: state?.modifiersData?.arrow,
  262. };
  263. }
  264. // Using an inline-block solves the container being smaller
  265. // than the elements it is wrapping
  266. const Container = styled('span')<{containerDisplayMode: React.CSSProperties['display']}>`
  267. ${p => p.containerDisplayMode && `display: ${p.containerDisplayMode}`};
  268. max-width: 100%;
  269. `;
  270. export {useHoverOverlay, UseHoverOverlayProps};