useOverlay.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import {useMemo, useState} from 'react';
  2. import {PopperProps, usePopper} from 'react-popper';
  3. import {detectOverflow, Modifier, preventOverflow} from '@popperjs/core';
  4. import {useButton} from '@react-aria/button';
  5. import {
  6. OverlayProps,
  7. OverlayTriggerProps,
  8. useOverlay as useAriaOverlay,
  9. useOverlayTrigger,
  10. } from '@react-aria/overlays';
  11. import {mergeProps} from '@react-aria/utils';
  12. import {useOverlayTriggerState} from '@react-stately/overlays';
  13. import {OverlayTriggerProps as OverlayTriggerStateProps} from '@react-types/overlays';
  14. type PreventOverflowOptions = NonNullable<typeof preventOverflow['options']>;
  15. /**
  16. * PopperJS modifier to change the popper element's width/height to prevent
  17. * overflowing. Based on
  18. * https://github.com/atomiks/popper.js/blob/master/src/modifiers/maxSize.js
  19. */
  20. const maxSize: Modifier<'maxSize', PreventOverflowOptions> = {
  21. name: 'maxSize',
  22. enabled: true,
  23. phase: 'main',
  24. requiresIfExists: ['offset', 'preventOverflow', 'flip'],
  25. fn({state, name, options}) {
  26. const overflow = detectOverflow(state, options);
  27. const {x, y} = state.modifiersData.preventOverflow ?? {x: 0, y: 0};
  28. const {width, height} = state.rects.popper;
  29. const [basePlacement] = state.placement.split('-');
  30. const widthSide = basePlacement === 'left' ? 'left' : 'right';
  31. const heightSide = basePlacement === 'top' ? 'top' : 'bottom';
  32. const flippedWidthSide = basePlacement === 'left' ? 'right' : 'left';
  33. const flippedHeightSide = basePlacement === 'top' ? 'bottom' : 'top';
  34. // If there is enough space on the other side, then allow the popper to flip
  35. // without constraining its size
  36. const maxHeight = Math.max(
  37. height - overflow[heightSide] - y,
  38. -overflow[flippedHeightSide]
  39. );
  40. // If there is enough space on the other side, then allow the popper to flip
  41. // without constraining its size
  42. const maxWidth = Math.max(
  43. width - overflow[widthSide] - x,
  44. -overflow[flippedWidthSide]
  45. );
  46. state.modifiersData[name] = {
  47. width: maxWidth,
  48. height: maxHeight,
  49. };
  50. },
  51. };
  52. const applyMaxSize: Modifier<'applyMaxSize', {}> = {
  53. name: 'applyMaxSize',
  54. enabled: true,
  55. phase: 'beforeWrite',
  56. requires: ['maxSize'],
  57. fn({state}) {
  58. const {width, height} = state.modifiersData.maxSize;
  59. state.styles.popper.maxHeight = height;
  60. state.styles.popper.maxWidth = width;
  61. },
  62. };
  63. export interface UseOverlayProps
  64. extends Partial<OverlayProps>,
  65. Partial<OverlayTriggerProps>,
  66. Partial<OverlayTriggerStateProps> {
  67. /**
  68. * Offset along the main axis.
  69. */
  70. offset?: number;
  71. /**
  72. * Position for the overlay.
  73. */
  74. position?: PopperProps<any>['placement'];
  75. preventOverflowOptions?: PreventOverflowOptions;
  76. }
  77. function useOverlay({
  78. isOpen,
  79. onClose,
  80. defaultOpen,
  81. onOpenChange,
  82. type = 'dialog',
  83. offset = 8,
  84. position = 'top',
  85. preventOverflowOptions = {},
  86. isDismissable = true,
  87. shouldCloseOnBlur = false,
  88. isKeyboardDismissDisabled,
  89. shouldCloseOnInteractOutside,
  90. }: UseOverlayProps = {}) {
  91. // Callback refs for react-popper
  92. const [triggerElement, setTriggerElement] = useState<HTMLButtonElement | null>(null);
  93. const [overlayElement, setOverlayElement] = useState<HTMLDivElement | null>(null);
  94. const [arrowElement, setArrowElement] = useState<HTMLDivElement | null>(null);
  95. // Ref objects for react-aria (useOverlayTrigger & useOverlay)
  96. const triggerRef = useMemo(() => ({current: triggerElement}), [triggerElement]);
  97. const overlayRef = useMemo(() => ({current: overlayElement}), [overlayElement]);
  98. const modifiers = useMemo(
  99. () => [
  100. {
  101. name: 'hide',
  102. enabled: false,
  103. },
  104. {
  105. name: 'computeStyles',
  106. options: {
  107. // Using the `transform` attribute causes our borders to get blurry
  108. // in chrome. See [0]. This just causes it to use `top` / `left`
  109. // positions, which should be fine.
  110. //
  111. // [0]: https://stackoverflow.com/questions/29543142/css3-transformation-blurry-borders
  112. gpuAcceleration: false,
  113. },
  114. },
  115. {
  116. name: 'arrow',
  117. options: {
  118. element: arrowElement,
  119. // Set padding to avoid the arrow reaching the side of the tooltip
  120. // and overflowing out of the rounded border
  121. padding: 4,
  122. },
  123. },
  124. {
  125. name: 'offset',
  126. options: {
  127. offset: [0, offset],
  128. },
  129. },
  130. {
  131. name: 'preventOverflow',
  132. enabled: true,
  133. options: {
  134. padding: 16,
  135. ...preventOverflowOptions,
  136. },
  137. },
  138. {
  139. ...maxSize,
  140. options: {
  141. padding: 16,
  142. ...preventOverflowOptions,
  143. },
  144. },
  145. applyMaxSize,
  146. ],
  147. [arrowElement, offset, preventOverflowOptions]
  148. );
  149. const {
  150. styles: popperStyles,
  151. state: popperState,
  152. update: popperUpdate,
  153. } = usePopper(triggerElement, overlayElement, {modifiers, placement: position});
  154. // Get props for trigger button
  155. const openState = useOverlayTriggerState({
  156. isOpen,
  157. defaultOpen,
  158. onOpenChange: open => {
  159. onOpenChange?.(open);
  160. open && popperUpdate?.();
  161. },
  162. });
  163. const {buttonProps} = useButton({onPress: openState.open}, triggerRef);
  164. const {triggerProps, overlayProps: overlayTriggerProps} = useOverlayTrigger(
  165. {type},
  166. openState,
  167. triggerRef
  168. );
  169. // Get props for overlay element
  170. const {overlayProps} = useAriaOverlay(
  171. {
  172. onClose: () => {
  173. onClose?.();
  174. openState.close();
  175. },
  176. isOpen: openState.isOpen,
  177. isDismissable,
  178. shouldCloseOnBlur,
  179. isKeyboardDismissDisabled,
  180. shouldCloseOnInteractOutside,
  181. },
  182. overlayRef
  183. );
  184. return {
  185. isOpen: openState.isOpen,
  186. state: openState,
  187. triggerRef,
  188. triggerProps: {
  189. ref: setTriggerElement,
  190. ...mergeProps(buttonProps, triggerProps),
  191. },
  192. overlayRef,
  193. overlayProps: {
  194. ref: setOverlayElement,
  195. style: popperStyles.popper,
  196. ...mergeProps(overlayTriggerProps, overlayProps),
  197. },
  198. arrowProps: {
  199. ref: setArrowElement,
  200. style: popperStyles.arrow,
  201. placement: popperState?.placement,
  202. },
  203. };
  204. }
  205. export default useOverlay;