useOverlay.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import {useMemo, useRef, useState} from 'react';
  2. import type {PopperProps} from 'react-popper';
  3. import {usePopper} from 'react-popper';
  4. import type {Modifier} from '@popperjs/core';
  5. import {detectOverflow} from '@popperjs/core';
  6. import type {ArrowModifier} from '@popperjs/core/lib/modifiers/arrow';
  7. import type {FlipModifier} from '@popperjs/core/lib/modifiers/flip';
  8. import type {PreventOverflowModifier} from '@popperjs/core/lib/modifiers/preventOverflow';
  9. import {useButton as useButtonAria} from '@react-aria/button';
  10. import type {AriaOverlayProps, OverlayTriggerProps} from '@react-aria/overlays';
  11. import {
  12. useOverlay as useOverlayAria,
  13. useOverlayTrigger as useOverlayTriggerAria,
  14. } from '@react-aria/overlays';
  15. import {mergeProps} from '@react-aria/utils';
  16. import type {OverlayTriggerProps as OverlayTriggerStateProps} from '@react-stately/overlays';
  17. import {useOverlayTriggerState} from '@react-stately/overlays';
  18. /**
  19. * PopperJS modifier to change the popper element's width/height to prevent
  20. * overflowing. Based on
  21. * https://github.com/atomiks/popper.js/blob/master/src/modifiers/maxSize.js
  22. */
  23. const maxSize: Modifier<'maxSize', NonNullable<PreventOverflowModifier['options']>> = {
  24. name: 'maxSize',
  25. phase: 'main',
  26. requiresIfExists: ['offset', 'preventOverflow', 'flip'],
  27. enabled: false, // will be enabled when overlay is open
  28. fn({state, name, options}) {
  29. const overflow = detectOverflow(state, options);
  30. const {x, y} = state.modifiersData.preventOverflow ?? {x: 0, y: 0};
  31. const {width, height} = state.rects.popper;
  32. const [basePlacement] = state.placement.split('-');
  33. const widthSide = basePlacement === 'left' ? 'left' : 'right';
  34. const heightSide = basePlacement === 'top' ? 'top' : 'bottom';
  35. const flippedWidthSide = basePlacement === 'left' ? 'right' : 'left';
  36. const flippedHeightSide = basePlacement === 'top' ? 'bottom' : 'top';
  37. const maxHeight = ['left', 'right'].includes(basePlacement)
  38. ? // If the main axis is horizontal, then maxHeight = the boundary's height
  39. height - overflow.top - overflow.bottom
  40. : // Otherwise, set max height unless there is enough space on the other side to
  41. // flip the popper to
  42. Math.max(height - overflow[heightSide] - y, -overflow[flippedHeightSide]);
  43. // If there is enough space on the other side, then allow the popper to flip
  44. // without constraining its size
  45. const maxWidth = ['top', 'bottom'].includes(basePlacement)
  46. ? // If the main axis is vertical, then maxWidth = the boundary's width
  47. width - overflow.left - overflow.right
  48. : // Otherwise, set max width unless there is enough space on the other side to
  49. // flip the popper to
  50. Math.max(width - overflow[widthSide] - x, -overflow[flippedWidthSide]);
  51. state.modifiersData[name] = {
  52. width: maxWidth,
  53. height: maxHeight,
  54. };
  55. },
  56. };
  57. const applyMaxSize: Modifier<'applyMaxSize', {}> = {
  58. name: 'applyMaxSize',
  59. phase: 'beforeWrite',
  60. requires: ['maxSize'],
  61. enabled: false, // will be enabled when overlay is open
  62. fn({state}) {
  63. const {width, height} = state.modifiersData.maxSize;
  64. state.styles.popper.maxHeight = height;
  65. state.styles.popper.maxWidth = width;
  66. },
  67. };
  68. const applyMinWidth: Modifier<'applyMinWidth', {}> = {
  69. name: 'applyMinWidth',
  70. phase: 'beforeWrite',
  71. enabled: false, // will be enabled when overlay is open
  72. fn({state}) {
  73. const {reference} = state.rects;
  74. state.styles.popper.minWidth = `${reference.width}px`;
  75. },
  76. };
  77. export interface UseOverlayProps
  78. extends Partial<AriaOverlayProps>,
  79. Partial<OverlayTriggerProps>,
  80. Partial<OverlayTriggerStateProps> {
  81. /**
  82. * Options to pass to the `arrow` modifier.
  83. */
  84. arrowOptions?: ArrowModifier['options'];
  85. disableTrigger?: boolean;
  86. /**
  87. * Options to pass to the `flip` modifier.
  88. */
  89. flipOptions?: FlipModifier['options'];
  90. /**
  91. * Offset value. If a single number, determines the _distance_ along the main axis. If
  92. * an array of two numbers, the first number determines the _skidding_ along the alt
  93. * axis, and the second determines the _distance_ along the main axis.
  94. */
  95. offset?: number | [number, number];
  96. /**
  97. * To be called when the overlay closes because of a user interaction (click) outside
  98. * the overlay. Note: this won't be called when the user presses Escape to dismiss.
  99. */
  100. onInteractOutside?: () => void;
  101. /**
  102. * Position for the overlay.
  103. */
  104. position?: PopperProps<any>['placement'];
  105. /**
  106. * Options to pass to the `preventOverflow` modifier.
  107. */
  108. preventOverflowOptions?: PreventOverflowModifier['options'];
  109. }
  110. function useOverlay({
  111. isOpen,
  112. onClose,
  113. defaultOpen,
  114. onOpenChange,
  115. type = 'dialog',
  116. offset = 8,
  117. position = 'top',
  118. arrowOptions = {},
  119. flipOptions = {},
  120. preventOverflowOptions = {},
  121. isDismissable = true,
  122. shouldCloseOnBlur = false,
  123. isKeyboardDismissDisabled,
  124. shouldCloseOnInteractOutside,
  125. onInteractOutside,
  126. disableTrigger,
  127. }: UseOverlayProps = {}) {
  128. // Callback refs for react-popper
  129. const [triggerElement, setTriggerElement] = useState<HTMLElement | null>(null);
  130. const [overlayElement, setOverlayElement] = useState<HTMLDivElement | null>(null);
  131. const [arrowElement, setArrowElement] = useState<HTMLDivElement | null>(null);
  132. // Initialize open state
  133. const openState = useOverlayTriggerState({
  134. isOpen,
  135. defaultOpen,
  136. onOpenChange: open => {
  137. open && popperUpdate?.();
  138. onOpenChange?.(open);
  139. },
  140. });
  141. // Ref objects for react-aria (useOverlayTrigger & useOverlay)
  142. const triggerRef = useMemo(() => ({current: triggerElement}), [triggerElement]);
  143. const overlayRef = useMemo(() => ({current: overlayElement}), [overlayElement]);
  144. const modifiers = useMemo(
  145. () => [
  146. {
  147. name: 'hide',
  148. enabled: false,
  149. },
  150. {
  151. name: 'computeStyles',
  152. options: {
  153. // Using the `transform` attribute causes our borders to get blurry
  154. // in chrome. See [0]. This just causes it to use `top` / `left`
  155. // positions, which should be fine.
  156. //
  157. // [0]: https://stackoverflow.com/questions/29543142/css3-transformation-blurry-borders
  158. gpuAcceleration: false,
  159. },
  160. },
  161. {
  162. name: 'arrow',
  163. options: {
  164. element: arrowElement,
  165. // Set padding to avoid the arrow reaching the side of the tooltip
  166. // and overflowing out of the rounded border
  167. padding: 4,
  168. ...arrowOptions,
  169. },
  170. },
  171. {
  172. name: 'flip',
  173. options: {
  174. // Only flip on main axis
  175. flipVariations: false,
  176. ...flipOptions,
  177. },
  178. },
  179. {
  180. name: 'offset',
  181. options: {
  182. offset: Array.isArray(offset) ? offset : [0, offset],
  183. },
  184. },
  185. {
  186. name: 'preventOverflow',
  187. enabled: true,
  188. options: {
  189. padding: 16,
  190. ...preventOverflowOptions,
  191. },
  192. },
  193. {
  194. ...maxSize,
  195. enabled: openState.isOpen,
  196. options: {
  197. padding: 16,
  198. ...preventOverflowOptions,
  199. },
  200. },
  201. {
  202. ...applyMinWidth,
  203. enabled: openState.isOpen,
  204. },
  205. {
  206. ...applyMaxSize,
  207. enabled: openState.isOpen,
  208. },
  209. ],
  210. [arrowElement, offset, arrowOptions, flipOptions, preventOverflowOptions, openState]
  211. );
  212. const {
  213. styles: popperStyles,
  214. state: popperState,
  215. update: popperUpdate,
  216. } = usePopper(triggerElement, overlayElement, {modifiers, placement: position});
  217. // Get props for trigger button
  218. const {triggerProps, overlayProps: overlayTriggerAriaProps} = useOverlayTriggerAria(
  219. {type},
  220. openState,
  221. triggerRef
  222. );
  223. const {buttonProps: triggerAriaProps} = useButtonAria(
  224. {...triggerProps, isDisabled: disableTrigger},
  225. triggerRef
  226. );
  227. // Get props for overlay element
  228. const interactedOutside = useRef(false);
  229. const interactOutsideTrigger = useRef<HTMLElement | null>(null);
  230. const {overlayProps: overlayAriaProps} = useOverlayAria(
  231. {
  232. onClose: () => {
  233. onClose?.();
  234. if (interactedOutside.current) {
  235. onInteractOutside?.();
  236. interactedOutside.current = false;
  237. const trigger = interactOutsideTrigger.current;
  238. interactOutsideTrigger.current = null;
  239. requestAnimationFrame(() => {
  240. trigger?.focus();
  241. trigger?.click();
  242. });
  243. }
  244. openState.close();
  245. },
  246. isOpen: openState.isOpen,
  247. isDismissable,
  248. shouldCloseOnBlur,
  249. isKeyboardDismissDisabled,
  250. shouldCloseOnInteractOutside: target => {
  251. if (
  252. target &&
  253. triggerRef.current !== target &&
  254. !triggerRef.current?.contains(target) &&
  255. (shouldCloseOnInteractOutside?.(target) ?? true)
  256. ) {
  257. // Check if the target is inside a different overlay trigger. If yes, then we
  258. // should activate that trigger after this overlay has closed (see the onClose
  259. // prop above). This allows users to quickly jump between adjacent overlays.
  260. const closestOverlayTrigger = target.closest?.<HTMLElement>(
  261. '[aria-expanded="false"]'
  262. );
  263. if (closestOverlayTrigger && closestOverlayTrigger !== triggerRef.current) {
  264. interactOutsideTrigger.current = closestOverlayTrigger;
  265. } else {
  266. interactOutsideTrigger.current = null;
  267. }
  268. interactedOutside.current = true;
  269. return true;
  270. }
  271. return false;
  272. },
  273. },
  274. overlayRef
  275. );
  276. return {
  277. isOpen: openState.isOpen,
  278. state: openState,
  279. update: popperUpdate,
  280. triggerRef,
  281. triggerProps: {
  282. ref: setTriggerElement,
  283. ...triggerAriaProps,
  284. },
  285. overlayRef,
  286. overlayProps: {
  287. ref: setOverlayElement,
  288. style: popperStyles.popper,
  289. ...mergeProps(overlayTriggerAriaProps, overlayAriaProps),
  290. },
  291. arrowProps: {
  292. ref: setArrowElement,
  293. style: popperStyles.arrow,
  294. placement: popperState?.placement,
  295. },
  296. };
  297. }
  298. export default useOverlay;