useOverlay.tsx 9.3 KB

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