useOverlay.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  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. // Get props for trigger button
  99. const openState = useOverlayTriggerState({isOpen, defaultOpen, onOpenChange});
  100. const {buttonProps} = useButton({onPress: openState.open}, triggerRef);
  101. const {triggerProps, overlayProps: overlayTriggerProps} = useOverlayTrigger(
  102. {type},
  103. openState,
  104. triggerRef
  105. );
  106. // Get props for overlay element
  107. const {overlayProps} = useAriaOverlay(
  108. {
  109. onClose: () => {
  110. onClose?.();
  111. openState.close();
  112. },
  113. isOpen: openState.isOpen,
  114. isDismissable,
  115. shouldCloseOnBlur,
  116. isKeyboardDismissDisabled,
  117. shouldCloseOnInteractOutside,
  118. },
  119. overlayRef
  120. );
  121. const modifiers = useMemo(
  122. () => [
  123. {
  124. name: 'hide',
  125. enabled: false,
  126. },
  127. {
  128. name: 'computeStyles',
  129. options: {
  130. // Using the `transform` attribute causes our borders to get blurry
  131. // in chrome. See [0]. This just causes it to use `top` / `left`
  132. // positions, which should be fine.
  133. //
  134. // [0]: https://stackoverflow.com/questions/29543142/css3-transformation-blurry-borders
  135. gpuAcceleration: false,
  136. },
  137. },
  138. {
  139. name: 'arrow',
  140. options: {
  141. element: arrowElement,
  142. // Set padding to avoid the arrow reaching the side of the tooltip
  143. // and overflowing out of the rounded border
  144. padding: 4,
  145. },
  146. },
  147. {
  148. name: 'offset',
  149. options: {
  150. offset: [0, offset],
  151. },
  152. },
  153. {
  154. name: 'preventOverflow',
  155. enabled: true,
  156. options: {
  157. padding: 16,
  158. ...preventOverflowOptions,
  159. },
  160. },
  161. {
  162. ...maxSize,
  163. options: {
  164. padding: 16,
  165. ...preventOverflowOptions,
  166. },
  167. },
  168. applyMaxSize,
  169. ],
  170. [arrowElement, offset, preventOverflowOptions]
  171. );
  172. const {styles: popperStyles, state: popperState} = usePopper(
  173. triggerElement,
  174. overlayElement,
  175. {modifiers, placement: position}
  176. );
  177. return {
  178. isOpen: openState.isOpen,
  179. state: openState,
  180. triggerRef,
  181. triggerProps: {
  182. ref: setTriggerElement,
  183. ...mergeProps(buttonProps, triggerProps),
  184. },
  185. overlayRef,
  186. overlayProps: {
  187. ref: setOverlayElement,
  188. style: popperStyles.popper,
  189. ...mergeProps(overlayTriggerProps, overlayProps),
  190. },
  191. arrowProps: {
  192. ref: setArrowElement,
  193. style: popperStyles.arrow,
  194. placement: popperState?.placement,
  195. },
  196. };
  197. }
  198. export default useOverlay;