index.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. import {Fragment, useCallback, useEffect, useRef} from 'react';
  2. import {createPortal} from 'react-dom';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import type {FocusTrap} from 'focus-trap';
  6. import {createFocusTrap} from 'focus-trap';
  7. import {AnimatePresence, motion} from 'framer-motion';
  8. import {closeModal as actionCloseModal} from 'sentry/actionCreators/modal';
  9. import {ROOT_ELEMENT} from 'sentry/constants';
  10. import ModalStore from 'sentry/stores/modalStore';
  11. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  12. import {space} from 'sentry/styles/space';
  13. import getModalPortal from 'sentry/utils/getModalPortal';
  14. import testableTransition from 'sentry/utils/testableTransition';
  15. import {useEffectAfterFirstRender} from 'sentry/utils/useEffectAfterFirstRender';
  16. import {useLocation} from 'sentry/utils/useLocation';
  17. import {makeClosableHeader, makeCloseButton, ModalBody, ModalFooter} from './components';
  18. type ModalOptions = {
  19. /**
  20. * Set to `false` to disable the backdrop from being rendered.
  21. * Set to `true` (the default) to show a translucent backdrop.
  22. */
  23. backdrop?: boolean;
  24. /**
  25. * By default, the modal is closed when the backdrop is clicked or the
  26. * escape key is pressed. This prop allows you to modify that behavior.
  27. * Only use when completely necessary, the defaults are important for
  28. * accessibility.
  29. *
  30. * 'all' (default) - the modal is automatically closed on backdrop click or
  31. * escape key.
  32. * 'none' - the modal cannot be dismissed with either the mouse or the
  33. * keyboard. The modal will need to be closed manually with `closeModal()`.
  34. * This should only be used when a modal requires user input and cannot be
  35. * dismissed, which is rare.
  36. * 'backdrop-click' - the modal cannot be dimissed by pressing the escape key.
  37. * 'escape-key' - the modal cannot be dismissed by clicking on the backdrop.
  38. * This is useful for modals containing user input which will disappear on an
  39. * accidental click.
  40. */
  41. closeEvents?: 'all' | 'none' | 'backdrop-click' | 'escape-key';
  42. /**
  43. * Additional CSS which will be applied to the modals `role="dialog"`
  44. * component. You may use the `[role="document"]` selector to target the
  45. * actual modal content to style the visual element of the modal.
  46. */
  47. modalCss?: ReturnType<typeof css>;
  48. /**
  49. * Callback for when the modal is closed
  50. */
  51. onClose?: () => void;
  52. };
  53. type ModalRenderProps = {
  54. /**
  55. * Body container for the modal
  56. */
  57. Body: typeof ModalBody;
  58. /**
  59. * Looks like a close button. Useful for when you don't want to render the
  60. * header which can include the close button.
  61. */
  62. CloseButton: ReturnType<typeof makeCloseButton>;
  63. /**
  64. * Footer container for the modal, typically for actions
  65. */
  66. Footer: typeof ModalFooter;
  67. /**
  68. * The modal header, optionally includes a close button which will close the
  69. * modal.
  70. */
  71. Header: ReturnType<typeof makeClosableHeader>;
  72. /**
  73. * Closes the modal
  74. */
  75. closeModal: () => void;
  76. /**
  77. * Reference to the modal's container.
  78. */
  79. modalContainerRef?: React.RefObject<HTMLDivElement>;
  80. };
  81. /**
  82. * Meta-type to make re-exporting these in the action creator easy without
  83. * polluting the global API namespace with duplicate type names.
  84. *
  85. * eg. you won't accidentally import ModalRenderProps from here.
  86. */
  87. export type ModalTypes = {
  88. options: ModalOptions;
  89. renderProps: ModalRenderProps;
  90. };
  91. type Props = {
  92. /**
  93. * Note this is the callback for the main App container and NOT the calling
  94. * component. GlobalModal is never used directly, but is controlled via
  95. * stores. To access the onClose callback from the component, you must
  96. * specify it when using the action creator.
  97. */
  98. onClose?: () => void;
  99. };
  100. function GlobalModal({onClose}: Props) {
  101. const {renderer, options} = useLegacyStore(ModalStore);
  102. const location = useLocation();
  103. const closeEvents = options.closeEvents ?? 'all';
  104. const visible = typeof renderer === 'function';
  105. const closeModal = useCallback(() => {
  106. // Option close callback, from the thing which opened the modal
  107. options.onClose?.();
  108. // Action creator, actually closes the modal
  109. actionCloseModal();
  110. // GlobalModal onClose prop callback
  111. onClose?.();
  112. }, [options, onClose]);
  113. const handleEscapeClose = useCallback(
  114. (e: KeyboardEvent) => {
  115. if (
  116. e.key !== 'Escape' ||
  117. closeEvents === 'none' ||
  118. closeEvents === 'backdrop-click'
  119. ) {
  120. return;
  121. }
  122. closeModal();
  123. },
  124. [closeModal, closeEvents]
  125. );
  126. const portal = getModalPortal();
  127. const focusTrap = useRef<FocusTrap>();
  128. // SentryApp might be missing on tests
  129. if (window.SentryApp) {
  130. window.SentryApp.modalFocusTrap = focusTrap;
  131. }
  132. useEffect(() => {
  133. focusTrap.current = createFocusTrap(portal, {
  134. preventScroll: true,
  135. escapeDeactivates: false,
  136. fallbackFocus: portal,
  137. });
  138. }, [portal]);
  139. useEffect(() => {
  140. const body = document.querySelector('body');
  141. const root = document.getElementById(ROOT_ELEMENT);
  142. const reset = () => {
  143. body?.style.removeProperty('overflow');
  144. root?.removeAttribute('aria-hidden');
  145. focusTrap.current?.deactivate();
  146. document.removeEventListener('keydown', handleEscapeClose);
  147. };
  148. if (visible) {
  149. if (body) {
  150. body.style.overflow = 'hidden';
  151. }
  152. root?.setAttribute('aria-hidden', 'true');
  153. focusTrap.current?.activate();
  154. document.addEventListener('keydown', handleEscapeClose);
  155. } else {
  156. reset();
  157. }
  158. return reset;
  159. }, [portal, handleEscapeClose, visible]);
  160. // Close the modal when the browser history changes.
  161. //
  162. // XXX: We're using useEffectAfterFirstRender primarily to support tests
  163. // which render the GlobalModal after a modal has already been registered in
  164. // the modal store, meaning it would be closed immediately.
  165. useEffectAfterFirstRender(() => actionCloseModal(), [location.pathname]);
  166. // Default to enabled backdrop
  167. const backdrop = options.backdrop ?? true;
  168. const allowBackdropClickClose =
  169. closeEvents === 'all' || closeEvents === 'backdrop-click';
  170. // Only close when we directly click outside of the modal.
  171. const containerRef = useRef<HTMLDivElement>(null);
  172. const clickClose = (e: React.MouseEvent) =>
  173. containerRef.current === e.target && allowBackdropClickClose && closeModal();
  174. const renderedChild = renderer?.({
  175. CloseButton: makeCloseButton(closeModal),
  176. Header: makeClosableHeader(closeModal),
  177. Body: ModalBody,
  178. Footer: ModalFooter,
  179. modalContainerRef: containerRef,
  180. closeModal,
  181. });
  182. return createPortal(
  183. <Fragment>
  184. <Backdrop
  185. style={backdrop && visible ? {opacity: 0.5, pointerEvents: 'auto'} : {}}
  186. />
  187. <Container
  188. data-test-id="modal-backdrop"
  189. ref={containerRef}
  190. style={{pointerEvents: visible ? 'auto' : 'none'}}
  191. onClick={backdrop ? clickClose : undefined}
  192. >
  193. <AnimatePresence>
  194. {visible && (
  195. <Modal role="dialog" aria-modal css={options.modalCss}>
  196. <Content role="document">{renderedChild}</Content>
  197. </Modal>
  198. )}
  199. </AnimatePresence>
  200. </Container>
  201. </Fragment>,
  202. portal
  203. );
  204. }
  205. const fullPageCss = css`
  206. position: fixed;
  207. top: 0;
  208. right: 0;
  209. bottom: 0;
  210. left: 0;
  211. `;
  212. const Backdrop = styled('div')`
  213. ${fullPageCss};
  214. z-index: ${p => p.theme.zIndex.modal};
  215. background: ${p => p.theme.black};
  216. will-change: opacity;
  217. transition: opacity 200ms;
  218. pointer-events: none;
  219. opacity: 0;
  220. `;
  221. const Container = styled('div')`
  222. ${fullPageCss};
  223. z-index: ${p => p.theme.zIndex.modal};
  224. display: flex;
  225. justify-content: center;
  226. align-items: flex-start;
  227. overflow-y: auto;
  228. `;
  229. const Modal = styled(motion.div)`
  230. max-width: 100%;
  231. width: 640px;
  232. pointer-events: auto;
  233. margin-top: 64px;
  234. padding: ${space(2)} ${space(1.5)};
  235. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  236. margin-top: 50px;
  237. padding: ${space(4)} ${space(2)};
  238. }
  239. `;
  240. Modal.defaultProps = {
  241. initial: {opacity: 0, y: -10},
  242. animate: {opacity: 1, y: 0},
  243. exit: {opacity: 0, y: 15},
  244. transition: testableTransition({
  245. opacity: {duration: 0.2},
  246. y: {duration: 0.25},
  247. }),
  248. };
  249. const Content = styled('div')`
  250. background: ${p => p.theme.background};
  251. border-radius: ${p => p.theme.modalBorderRadius};
  252. box-shadow:
  253. 0 0 0 1px ${p => p.theme.translucentBorder},
  254. ${p => p.theme.dropShadowHeavy};
  255. position: relative;
  256. padding: ${space(4)} ${space(3)};
  257. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  258. padding: ${space(4)};
  259. }
  260. `;
  261. export default GlobalModal;