Browse Source

ref(modal): Replace react-bootstrap modal (#25768)

Evan Purkhiser 3 years ago
parent
commit
b34c2a9539

+ 1 - 2
package.json

@@ -41,7 +41,6 @@
     "@types/marked": "^0.7.2",
     "@types/papaparse": "^5.2.5",
     "@types/react": "~17.0.3",
-    "@types/react-bootstrap": "^0.32.19",
     "@types/react-date-range": "^1.1.4",
     "@types/react-document-title": "^2.0.4",
     "@types/react-dom": "~17.0.3",
@@ -75,6 +74,7 @@
     "echarts": "4.7.0",
     "echarts-for-react": "2.0.16",
     "file-loader": "^6.2.0",
+    "focus-trap": "^6.4.0",
     "focus-visible": "^5.2.0",
     "fork-ts-checker-webpack-plugin": "^4.1.2",
     "framer-motion": "^4.1.11",
@@ -104,7 +104,6 @@
     "query-string": "7.0.0",
     "react": "17.0.2",
     "react-autosize-textarea": "7.1.0",
-    "react-bootstrap": "^0.32.0",
     "react-date-range": "^1.1.3",
     "react-document-title": "2.0.3",
     "react-dom": "17.0.2",

+ 7 - 26
static/app/actionCreators/modal.tsx

@@ -1,9 +1,7 @@
 import * as React from 'react';
-// eslint-disable-next-line no-restricted-imports
-import {Modal as BoostrapModal} from 'react-bootstrap';
-import {css} from '@emotion/react';
 
 import ModalActions from 'app/actions/modalActions';
+import GlobalModal from 'app/components/globalModal';
 import type {DashboardWidgetModalOptions} from 'app/components/modals/addDashboardWidgetModal';
 import type {ReprocessEventModalOptions} from 'app/components/modals/reprocessEventModal';
 import {
@@ -17,21 +15,10 @@ import {
 } from 'app/types';
 import {Event} from 'app/types/event';
 
-export type ModalRenderProps = {
-  closeModal: () => void;
-  Header: typeof BoostrapModal.Header;
-  Body: typeof BoostrapModal.Body;
-  Footer: typeof BoostrapModal.Footer;
-};
+type ModalProps = Required<React.ComponentProps<typeof GlobalModal>>;
 
-export type ModalOptions = {
-  onClose?: () => void;
-  modalCss?: ReturnType<typeof css>;
-  modalClassName?: string;
-  dialogClassName?: string;
-  type?: string;
-  backdrop?: BoostrapModal['props']['backdrop'];
-};
+export type ModalOptions = ModalProps['options'];
+export type ModalRenderProps = Parameters<NonNullable<ModalProps['children']>>[0];
 
 /**
  * Show a modal
@@ -161,9 +148,7 @@ export async function openRecoveryOptions(options: RecoveryModalOptions) {
   );
   const {default: Modal} = mod;
 
-  openModal(deps => <Modal {...deps} {...options} />, {
-    modalClassName: 'recovery-options',
-  });
+  openModal(deps => <Modal {...deps} {...options} />);
 }
 
 export type TeamAccessRequestModalOptions = {
@@ -178,9 +163,7 @@ export async function openTeamAccessRequestModal(options: TeamAccessRequestModal
   );
   const {default: Modal} = mod;
 
-  openModal(deps => <Modal {...deps} {...options} />, {
-    modalClassName: 'confirm-team-request',
-  });
+  openModal(deps => <Modal {...deps} {...options} />);
 }
 
 export async function redirectToProject(newProjectSlug: string) {
@@ -226,9 +209,7 @@ export async function openDebugFileSourceModal(options: DebugFileSourceModalOpti
   );
   const {default: Modal} = mod;
 
-  openModal(deps => <Modal {...deps} {...options} />, {
-    modalClassName: 'debug-file-source',
-  });
+  openModal(deps => <Modal {...deps} {...options} />);
 }
 
 export async function openInviteMembersModal(options = {}) {

+ 4 - 13
static/app/components/events/interfaces/debugMeta-v2/debugImageDetails/index.tsx

@@ -397,28 +397,19 @@ const StyledButtonBar = styled(ButtonBar)`
 `;
 
 export const modalCss = css`
-  .modal-content {
+  [role='document'] {
     overflow: initial;
   }
 
   @media (min-width: ${theme.breakpoints[0]}) {
-    .modal-dialog {
-      width: 90%;
-      margin-left: -45%;
-    }
+    width: 90%;
   }
 
   @media (min-width: ${theme.breakpoints[3]}) {
-    .modal-dialog {
-      width: 70%;
-      margin-left: -35%;
-    }
+    width: 70%;
   }
 
   @media (min-width: ${theme.breakpoints[4]}) {
-    .modal-dialog {
-      width: 50%;
-      margin-left: -25%;
-    }
+    width: 50%;
   }
 `;

+ 0 - 133
static/app/components/globalModal.tsx

@@ -1,133 +0,0 @@
-import * as React from 'react';
-// eslint-disable-next-line no-restricted-imports
-import Modal from 'react-bootstrap/lib/Modal';
-import {browserHistory} from 'react-router';
-import {ClassNames} from '@emotion/react';
-
-import {closeModal, ModalOptions, ModalRenderProps} from 'app/actionCreators/modal';
-import Confirm from 'app/components/confirm';
-import ModalStore from 'app/stores/modalStore';
-
-type DefaultProps = {
-  options: ModalOptions;
-  visible: boolean;
-};
-
-type Props = DefaultProps & {
-  /**
-   * Needs to be a function that returns a React Element
-   * Function is injected with:
-   * Modal `Header`, `Body`, and `Footer`,
-   * `closeModal`
-   *
-   */
-  children?: null | ((renderProps: ModalRenderProps) => React.ReactNode);
-
-  /**
-   * Note this is the callback for the main App container and
-   * NOT the calling component.  GlobalModal is never used directly,
-   * but is controlled via stores. To access the onClose callback from
-   * the component, you must specify it when using the action creator.
-   */
-  onClose?: () => void;
-};
-
-class GlobalModal extends React.Component<Props> {
-  static defaultProps: DefaultProps = {
-    visible: false,
-    options: {},
-  };
-
-  handleCloseModal = () => {
-    const {options, onClose} = this.props;
-
-    // onClose callback for calling component
-    if (typeof options.onClose === 'function') {
-      options.onClose();
-    }
-
-    // Action creator
-    closeModal();
-
-    if (typeof onClose === 'function') {
-      onClose();
-    }
-  };
-
-  render() {
-    const {visible, children, options} = this.props;
-    const renderedChild =
-      typeof children === 'function'
-        ? children({
-            closeModal: this.handleCloseModal,
-            Header: Modal.Header,
-            Body: Modal.Body,
-            Footer: Modal.Footer,
-          })
-        : undefined;
-
-    if (options && options.type === 'confirm') {
-      return <Confirm onConfirm={() => {}}>{() => renderedChild}</Confirm>;
-    }
-
-    return (
-      <ClassNames>
-        {({css, cx}) => (
-          <Modal
-            className={cx(
-              options?.modalClassName,
-              options?.modalCss && css(options.modalCss)
-            )}
-            dialogClassName={options && options.dialogClassName}
-            show={visible}
-            animation={false}
-            onHide={this.handleCloseModal}
-            backdrop={options?.backdrop}
-          >
-            {renderedChild}
-          </Modal>
-        )}
-      </ClassNames>
-    );
-  }
-}
-
-type State = {
-  modalStore: ReturnType<typeof ModalStore.get>;
-};
-
-class GlobalModalContainer extends React.Component<Partial<Props>, State> {
-  state: State = {
-    modalStore: ModalStore.get(),
-  };
-
-  componentDidMount() {
-    // Listen for route changes so we can dismiss modal
-    this.unlistenBrowserHistory = browserHistory.listen(() => closeModal());
-  }
-
-  componentWillUnmount() {
-    this.unlistenBrowserHistory?.();
-    this.unlistener?.();
-  }
-
-  unlistener = ModalStore.listen(
-    (modalStore: State['modalStore']) => this.setState({modalStore}),
-    undefined
-  );
-
-  unlistenBrowserHistory?: ReturnType<typeof browserHistory.listen>;
-
-  render() {
-    const {modalStore} = this.state;
-    const visible = !!modalStore && typeof modalStore.renderer === 'function';
-
-    return (
-      <GlobalModal {...this.props} {...modalStore} visible={visible}>
-        {visible ? modalStore.renderer : null}
-      </GlobalModal>
-    );
-  }
-}
-
-export default GlobalModalContainer;

+ 99 - 0
static/app/components/globalModal/components.tsx

@@ -0,0 +1,99 @@
+import * as React from 'react';
+import styled from '@emotion/styled';
+
+import Button from 'app/components/button';
+import {IconClose} from 'app/icons/iconClose';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+
+const ModalHeader = styled('header')`
+  position: relative;
+  border-bottom: 1px solid ${p => p.theme.border};
+  padding: ${space(3)} ${space(4)};
+  margin: -${space(4)} -${space(4)} ${space(3)} -${space(4)};
+
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6 {
+    font-size: 20px;
+    font-weight: 600;
+    margin-bottom: 0;
+    line-height: 1.1;
+  }
+`;
+
+const CloseButton = styled(Button)`
+  position: absolute;
+  top: 0;
+  right: 0;
+  transform: translate(50%, -50%);
+  border-radius: 50%;
+  background: ${p => p.theme.background};
+  height: 24px;
+  width: 24px;
+`;
+
+CloseButton.defaultProps = {
+  label: t('Close Modal'),
+  icon: <IconClose size="10px" />,
+  size: 'zero',
+};
+
+const ModalBody = styled('section')`
+  font-size: 15px;
+
+  p:last-child {
+    margin-bottom: 0;
+  }
+
+  img {
+    max-width: 100%;
+  }
+`;
+
+const ModalFooter = styled('footer')`
+  border-top: 1px solid ${p => p.theme.border};
+  display: flex;
+  justify-content: flex-end;
+  padding: ${space(3)} ${space(4)};
+  margin: ${space(3)} -${space(4)} -${space(4)};
+`;
+
+type HeaderProps = {
+  /**
+   * Show a close button in the header
+   */
+  closeButton?: boolean;
+};
+
+/**
+ * Creates a ModalHeader that includes props to enable the close button
+ */
+const makeClosableHeader = (closeModal: () => void) => {
+  const ClosableHeader: React.FC<
+    React.ComponentProps<typeof ModalHeader> & HeaderProps
+  > = ({closeButton, children, ...props}) => (
+    <ModalHeader {...props}>
+      {children}
+      {closeButton && <CloseButton onClick={closeModal} />}
+    </ModalHeader>
+  );
+
+  ClosableHeader.displayName = 'Header';
+
+  return ClosableHeader;
+};
+
+/**
+ * Creates a CloseButton component that is connected to the provided closeModal trigger
+ */
+const makeCloseButton = (
+  closeModal: () => void
+): React.FC<React.ComponentProps<typeof CloseButton>> => props => (
+  <CloseButton {...props} onClick={closeModal} />
+);
+
+export {makeClosableHeader, makeCloseButton, ModalBody, ModalFooter};

+ 279 - 0
static/app/components/globalModal/index.tsx

@@ -0,0 +1,279 @@
+import * as React from 'react';
+import ReactDOM from 'react-dom';
+import {browserHistory} from 'react-router';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+import {createFocusTrap, FocusTrap} from 'focus-trap';
+import {AnimatePresence, motion} from 'framer-motion';
+import memoize from 'lodash/memoize';
+
+import {closeModal as actionCloseModal} from 'app/actionCreators/modal';
+import {ROOT_ELEMENT} from 'app/constants';
+import ModalStore from 'app/stores/modalStore';
+import space from 'app/styles/space';
+import testableTransition from 'app/utils/testableTransition';
+
+import {makeClosableHeader, makeCloseButton, ModalBody, ModalFooter} from './components';
+
+type ModalOptions = {
+  /**
+   * Callback for when the modal is closed
+   */
+  onClose?: () => void;
+  /**
+   * Additional CSS which will be applied to the modals `role="dialog"`
+   * component. You may use the `[role="document"]` selector to target the
+   * actual modal content to style the visual element of the modal.
+   */
+  modalCss?: ReturnType<typeof css>;
+  /**
+   * Set to `false` to disable the backdrop from being rendered. Set to
+   * `static` to disable the 'click outside' behavior from closing the modal.
+   * Set to true (the default) to show a translucent backdrop
+   */
+  backdrop?: 'static' | boolean;
+};
+
+type ModalRenderProps = {
+  /**
+   * Closes the modal
+   */
+  closeModal: () => void;
+  /**
+   * The modal header, optionally includes a close button which will close the
+   * modal.
+   */
+  Header: ReturnType<typeof makeClosableHeader>;
+  /**
+   * Body container for the modal
+   */
+  Body: typeof ModalBody;
+  /**
+   * Footer container for the modal, typically for actions
+   */
+  Footer: typeof ModalFooter;
+  /**
+   * Looks like a close button. Useful for when you don't want to render the
+   * header which can include the close button.
+   */
+  CloseButton: ReturnType<typeof makeCloseButton>;
+};
+
+type Props = {
+  /**
+   * Configuration of the modal
+   */
+  options: ModalOptions;
+  /**
+   * Is the modal visible
+   */
+  visible: boolean;
+  /**
+   * A function that returns a React Element
+   */
+  children?: null | ((renderProps: ModalRenderProps) => React.ReactNode);
+  /**
+   * Note this is the callback for the main App container and NOT the calling
+   * component. GlobalModal is never used directly, but is controlled via
+   * stores. To access the onClose callback from the component, you must
+   * specify it when using the action creator.
+   */
+  onClose?: () => void;
+};
+
+const getModalPortal = memoize(() => {
+  let portal = document.getElementById('modal-portal') as HTMLDivElement;
+  if (!portal) {
+    portal = document.createElement('div');
+    portal.setAttribute('id', 'modal-portal');
+    document.body.appendChild(portal);
+  }
+
+  return portal;
+});
+
+function GlobalModal({visible = false, options = {}, children, onClose}: Props) {
+  const closeModal = React.useCallback(() => {
+    // Option close callback, from the thing which opened the modal
+    options.onClose?.();
+
+    // Action creator, actually closes the modal
+    actionCloseModal();
+
+    // GlobalModal onClose prop callback
+    onClose?.();
+  }, [options]);
+
+  const handleEscapeClose = React.useCallback(
+    (e: KeyboardEvent) => e.key === 'Escape' && closeModal(),
+    [closeModal]
+  );
+
+  const portal = getModalPortal();
+  const focusTrap = React.useRef<FocusTrap>();
+
+  React.useEffect(() => {
+    focusTrap.current = createFocusTrap(portal, {
+      preventScroll: true,
+      escapeDeactivates: false,
+      fallbackFocus: portal,
+    });
+  }, [portal]);
+
+  React.useEffect(() => {
+    const body = document.querySelector('body');
+    const root = document.getElementById(ROOT_ELEMENT);
+
+    if (!body || !root) {
+      return () => void 0;
+    }
+
+    const reset = () => {
+      body.style.removeProperty('overflow');
+      root.removeAttribute('aria-hidden');
+      focusTrap.current?.deactivate();
+      portal.removeEventListener('keydown', handleEscapeClose);
+    };
+
+    if (visible) {
+      body.style.overflow = 'hidden';
+      root.setAttribute('aria-hidden', 'true');
+      focusTrap.current?.activate();
+
+      portal.addEventListener('keydown', handleEscapeClose);
+    } else {
+      reset();
+    }
+
+    return reset;
+  }, [portal, handleEscapeClose, visible]);
+
+  const renderedChild = children?.({
+    CloseButton: makeCloseButton(closeModal),
+    Header: makeClosableHeader(closeModal),
+    Body: ModalBody,
+    Footer: ModalFooter,
+    closeModal,
+  });
+
+  // Default to enabled backdrop
+  const backdrop = options.backdrop ?? true;
+
+  // Only close when we directly click outside of the modal.
+  const containerRef = React.useRef<HTMLDivElement>(null);
+  const clickClose = (e: React.MouseEvent) =>
+    containerRef.current === e.target && closeModal();
+
+  return ReactDOM.createPortal(
+    <React.Fragment>
+      <Backdrop
+        style={backdrop && visible ? {opacity: 0.5, pointerEvents: 'auto'} : {}}
+      />
+      <Container
+        ref={containerRef}
+        style={{pointerEvents: visible ? 'auto' : 'none'}}
+        onClick={backdrop === true ? clickClose : undefined}
+      >
+        <AnimatePresence>
+          {visible && (
+            <Modal role="dialog" css={options.modalCss}>
+              <Content role="document">{renderedChild}</Content>
+            </Modal>
+          )}
+        </AnimatePresence>
+      </Container>
+    </React.Fragment>,
+    portal
+  );
+}
+
+const fullPageCss = css`
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+`;
+
+const Backdrop = styled('div')`
+  ${fullPageCss};
+  z-index: ${p => p.theme.zIndex.modal};
+  background: ${p => p.theme.gray500};
+  will-change: opacity;
+  transition: opacity 200ms;
+  pointer-events: none;
+  opacity: 0;
+`;
+
+const Container = styled('div')`
+  ${fullPageCss};
+  z-index: ${p => p.theme.zIndex.modal};
+  display: flex;
+  justify-content: center;
+  align-items: flex-start;
+  overflow-y: auto;
+`;
+
+const Modal = styled(motion.div)`
+  width: 640px;
+  pointer-events: auto;
+  padding: 80px ${space(2)} ${space(4)} ${space(2)};
+`;
+
+Modal.defaultProps = {
+  initial: {opacity: 0, y: -10},
+  animate: {opacity: 1, y: 0},
+  exit: {opacity: 0, y: 15},
+  transition: testableTransition({
+    opacity: {duration: 0.2},
+    y: {duration: 0.25},
+  }),
+};
+
+const Content = styled('div')`
+  padding: ${space(4)};
+  background: ${p => p.theme.background};
+  border-radius: 8px;
+  border: ${p => p.theme.modalBorder};
+  box-shadow: ${p => p.theme.modalBoxShadow};
+`;
+
+type State = {
+  modalStore: ReturnType<typeof ModalStore.get>;
+};
+
+class GlobalModalContainer extends React.Component<Partial<Props>, State> {
+  state: State = {
+    modalStore: ModalStore.get(),
+  };
+
+  componentDidMount() {
+    // Listen for route changes so we can dismiss modal
+    this.unlistenBrowserHistory = browserHistory.listen(() => actionCloseModal());
+  }
+
+  componentWillUnmount() {
+    this.unlistenBrowserHistory?.();
+    this.unlistener?.();
+  }
+
+  unlistener = ModalStore.listen(
+    (modalStore: State['modalStore']) => this.setState({modalStore}),
+    undefined
+  );
+
+  unlistenBrowserHistory?: ReturnType<typeof browserHistory.listen>;
+
+  render() {
+    const {modalStore} = this.state;
+    const visible = !!modalStore && typeof modalStore.renderer === 'function';
+
+    return (
+      <GlobalModal {...this.props} {...modalStore} visible={visible}>
+        {visible ? modalStore.renderer : null}
+      </GlobalModal>
+    );
+  }
+}
+
+export default GlobalModalContainer;

+ 1 - 0
static/app/components/issueDiff/index.tsx

@@ -175,6 +175,7 @@ const StyledIssueDiff = styled('div', {
     `
         background-color: ${p.theme.background};
         justify-content: center;
+        align-items: center;
       `};
 `;
 

+ 4 - 8
static/app/components/modals/addDashboardWidgetModal.tsx

@@ -308,7 +308,6 @@ class AddDashboardWidgetModal extends React.Component<Props, State> {
       Body,
       Header,
       api,
-      closeModal,
       organization,
       selection,
       tags,
@@ -329,7 +328,7 @@ class AddDashboardWidgetModal extends React.Component<Props, State> {
 
     return (
       <React.Fragment>
-        <Header closeButton onHide={closeModal}>
+        <Header closeButton>
           <h4>{isUpdatingWidget ? t('Edit Widget') : t('Add Widget')}</h4>
         </Header>
         <Body>
@@ -447,12 +446,9 @@ const DoubleFieldWrapper = styled('div')`
 `;
 
 export const modalCss = css`
-  .modal-dialog {
-    position: unset;
-    width: 100%;
-    max-width: 700px;
-    margin: 70px auto;
-  }
+  width: 100%;
+  max-width: 700px;
+  margin: 70px auto;
 `;
 
 export default withApi(withGlobalSelection(withTags(AddDashboardWidgetModal)));

+ 1 - 1
static/app/components/modals/commandPalette.tsx

@@ -61,7 +61,7 @@ class CommandPalette extends Component<Props> {
 export default withTheme(CommandPalette);
 
 export const modalCss = css`
-  .modal-content {
+  [role='document'] {
     padding: 0;
   }
 `;

+ 3 - 8
static/app/components/modals/createOwnershipRuleModal.tsx

@@ -19,9 +19,7 @@ const CreateOwnershipRuleModal = ({Body, Header, closeModal, ...props}: Props) =
 
   return (
     <Fragment>
-      <Header closeButton onHide={closeModal}>
-        {t('Create Ownership Rule')}
-      </Header>
+      <Header closeButton>{t('Create Ownership Rule')}</Header>
       <Body>
         <ProjectOwnershipModal {...props} onSave={handleSuccess} />
       </Body>
@@ -31,12 +29,9 @@ const CreateOwnershipRuleModal = ({Body, Header, closeModal, ...props}: Props) =
 
 export const modalCss = css`
   @media (min-width: ${theme.breakpoints[0]}) {
-    .modal-dialog {
-      width: 80%;
-      margin-left: -40%;
-    }
+    width: 80%;
   }
-  .modal-content {
+  [role='document'] {
     overflow: initial;
   }
 `;

Some files were not shown because too many files changed in this diff