useHoverOverlay.tsx 9.0 KB

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