index.tsx 8.1 KB

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