Repository: mapbox/mr-ui
Pull request #228 (2024-12-02 10:45:13)

Support configuring the click behavior on underlay of Modal
Added a new prop `exitOnUnderlayClicked` for Modal. When it is `false`, a click on the underlay will not close the modal


Patch:
diff --git a/package-lock.json b/package-lock.json
index 44ea86a..d0ec137 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,7 +6,7 @@
   "packages": {
     "": {
       "name": "@mapbox/mr-ui",
-      "version": "2.11.0",
+      "version": "2.12.0",
       "license": "BSD-2-Clause",
       "dependencies": {
         "@mapbox/mbx-assembly": "^1.6.0",
diff --git a/src/components/modal/examples/modal-b.tsx b/src/components/modal/examples/modal-b.tsx
index 5206f25..c47ef7e 100644
--- a/src/components/modal/examples/modal-b.tsx
+++ b/src/components/modal/examples/modal-b.tsx
@@ -1,6 +1,7 @@
 /*
 A small modal, unpadded to allow for custom background coloring, with
 initial focus set on an input. Also features its own action special button.
+It does not exit on overlay clicked
 */
 import React, { ReactElement, useState } from 'react';
 import Modal from '../modal';
@@ -13,6 +14,7 @@ export default function Example(): ReactElement {
       <Modal
         accessibleTitle="Animals"
         onExit={() => setModal(false)}
+        exitOnUnderlayClicked={false}
         padding="none"
         size="small"
         initialFocus="#modal-input"
diff --git a/src/components/modal/modal.tsx b/src/components/modal/modal.tsx
index ee9dd88..18dd2bd 100644
--- a/src/components/modal/modal.tsx
+++ b/src/components/modal/modal.tsx
@@ -15,6 +15,7 @@ interface Props {
   padding?: 'large' | 'none';
   margin?: 'large' | 'default';
   onExit?: () => void;
+  exitOnUnderlayClicked?: boolean;
   allowEventBubbling?: boolean;
   initialFocus?: string;
   primaryAction?: {
@@ -22,17 +23,17 @@ interface Props {
     callback: () => void;
     destructive?: boolean;
     disabled?: boolean;
-  },
+  };
   secondaryAction?: {
     text: string;
     callback: () => void;
     disabled?: boolean;
-  },
+  };
   tertiaryAction?: {
     text: string;
     callback: () => void;
     disabled?: boolean;
-  }
+  };
 }
 
 /**
@@ -53,13 +54,13 @@ export default function Modal({
   padding = 'large',
   margin = 'default',
   allowEventBubbling = false,
+  exitOnUnderlayClicked = true,
   initialFocus,
   primaryAction,
   secondaryAction,
   tertiaryAction,
   onExit
 }: Props): ReactElement {
-
   const renderActions = (): ReactElement => {
     if (!primaryAction) {
       return null;
@@ -74,7 +75,7 @@ export default function Modal({
         />
       </div>
     );
-  }
+  };
 
   let widthClass = 'wmax-full';
   if (size === 'small') {
@@ -83,77 +84,79 @@ export default function Modal({
     widthClass = 'wmax600 w-11/12';
   }
 
-  let marginClass = 'my12 my60-mm'
+  let marginClass = 'my12 my60-mm';
   if (margin === 'large') {
     marginClass = 'my120';
   }
 
   const overlayProps: {
-    className: string,
-    style: CSSProperties
+    className: string;
+    style: CSSProperties;
+    'data-testid': string;
   } = {
     className: 'fixed top bottom left right bg-darken50',
     style: {
       display: 'grid',
       overflowY: 'auto',
       placeItems: 'start center'
-    }
+    },
+    'data-testid': 'modal-overlay'
   };
 
   const rootProps: {
-    defaultOpen: true,
-    onOpenChange?: () => void
+    defaultOpen: true;
+    onOpenChange?: () => void;
   } = {
     defaultOpen: true
   };
 
-  if (onExit) {
-    rootProps.onOpenChange = onExit
+  if (onExit && exitOnUnderlayClicked) {
+    rootProps.onOpenChange = onExit;
   }
 
   const contentProps: {
-    className: string,
-    onOpenAutoFocus?: (e) => void
+    className: string;
+    onOpenAutoFocus?: (e) => void;
   } = {
     className: classnames(
       `relative ${marginClass} ${widthClass} bg-white round`,
       { 'px36 py36': padding === 'large' }
     )
-  }
+  };
 
   if (initialFocus) {
-    contentProps.onOpenAutoFocus = e => {
+    contentProps.onOpenAutoFocus = (e) => {
       const el: HTMLElement | null = document.querySelector(initialFocus);
       if (el !== null) {
         e.preventDefault();
         el.focus();
       }
-    }
+    };
   }
 
   const modal = (
-    <DialogPrimitive.Root {...rootProps}>
+    <DialogPrimitive.Root {...rootProps} open={true}>
       <DialogPrimitive.Portal>
-        <DialogPrimitive.Overlay { ...overlayProps}>
-          <DialogPrimitive.Content { ...contentProps}>
+        <DialogPrimitive.Overlay {...overlayProps}>
+          <DialogPrimitive.Content {...contentProps}>
             <VisuallyHidden.Root>
-              <DialogPrimitive.Title>
-                {accessibleTitle}
-              </DialogPrimitive.Title>
+              <DialogPrimitive.Title>{accessibleTitle}</DialogPrimitive.Title>
             </VisuallyHidden.Root>
             {children}
             {renderActions()}
-            {onExit && <Tooltip content="Close">
-              <button
-                onClick={onExit}
-                type="button"
-                data-testid="modal-close"
-                aria-label="Close"
-                className="btn btn--transparent unround-t unround-br color-gray py12 px12 absolute top right"
+            {onExit && (
+              <Tooltip content="Close">
+                <button
+                  onClick={onExit}
+                  type="button"
+                  data-testid="modal-close"
+                  aria-label="Close"
+                  className="btn btn--transparent unround-t unround-br color-gray py12 px12 absolute top right"
                 >
-                <Icon name="close" />
-              </button>
-            </Tooltip>}
+                  <Icon name="close" />
+                </button>
+              </Tooltip>
+            )}
           </DialogPrimitive.Content>
         </DialogPrimitive.Overlay>
       </DialogPrimitive.Portal>
@@ -161,11 +164,7 @@ export default function Modal({
   );
 
   if (!allowEventBubbling) {
-    return (
-      <EventTrap>
-        {modal}
-      </EventTrap>
-    );
+    return <EventTrap>{modal}</EventTrap>;
   }
 
   return modal;
@@ -190,6 +189,11 @@ Modal.propTypes = {
    * to do something, instead of allowing them to sneak out of the modal.
    */
   onExit: PropTypes.func,
+  /**
+   * If `onExit` is provided but this prop is set as false, a click on the underlay will
+   * not close the modal. The only way of closing the modal is clicking on the close button.
+   */
+  exitOnUnderlayClicked: PropTypes.bool,
   /**
    * Modal container size. Options are `small`, `large`, or `auto`. If `auto`
    * is provided, a width is not specified.


Test patch:
diff --git a/src/components/modal/__snapshots__/modal.test.tsx.snap b/src/components/modal/__snapshots__/modal.test.tsx.snap
index 562e639..f7f36b8 100644
--- a/src/components/modal/__snapshots__/modal.test.tsx.snap
+++ b/src/components/modal/__snapshots__/modal.test.tsx.snap
@@ -23,14 +23,15 @@ exports[`Modal all options renders 1`] = `
   <div
     class="fixed top bottom left right bg-darken50"
     data-state="open"
+    data-testid="modal-overlay"
     style="pointer-events: auto; display: grid; overflow-y: auto; place-items: start center;"
   >
     <div
-      aria-describedby="radix-:r13:"
-      aria-labelledby="radix-:r12:"
+      aria-describedby="radix-:r17:"
+      aria-labelledby="radix-:r16:"
       class="relative my120 wmax360 w-11/12 bg-white round"
       data-state="open"
-      id="radix-:r11:"
+      id="radix-:r15:"
       role="dialog"
       style="pointer-events: auto;"
       tabindex="-1"
@@ -39,7 +40,7 @@ exports[`Modal all options renders 1`] = `
         style="position: absolute; border: 0px; width: 1px; height: 1px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; word-wrap: normal;"
       >
         <h2
-          id="radix-:r12:"
+          id="radix-:r16:"
         >
           All options
         </h2>
@@ -120,6 +121,7 @@ exports[`Modal basic renders 1`] = `
     class="fixed top bottom left right bg-darken50"
     data-aria-hidden="true"
     data-state="open"
+    data-testid="modal-overlay"
     style="pointer-events: auto; display: grid; overflow-y: auto; place-items: start center;"
   >
     <div
@@ -157,6 +159,7 @@ exports[`Modal basic renders 1`] = `
   <div
     class="fixed top bottom left right bg-darken50"
     data-state="open"
+    data-testid="modal-overlay"
     style="pointer-events: auto; display: grid; overflow-y: auto; place-items: start center;"
   >
     <div
@@ -261,14 +264,15 @@ exports[`Modal basic, auto renders 1`] = `
   <div
     class="fixed top bottom left right bg-darken50"
     data-state="open"
+    data-testid="modal-overlay"
     style="pointer-events: auto; display: grid; overflow-y: auto; place-items: start center;"
   >
     <div
-      aria-describedby="radix-:rr:"
-      aria-labelledby="radix-:rq:"
+      aria-describedby="radix-:rv:"
+      aria-labelledby="radix-:ru:"
       class="relative my12 my60-mm wmax-full bg-white round px36 py36"
       data-state="open"
-      id="radix-:rp:"
+      id="radix-:rt:"
       role="dialog"
       style="pointer-events: auto;"
       tabindex="-1"
@@ -277,7 +281,7 @@ exports[`Modal basic, auto renders 1`] = `
         style="position: absolute; border: 0px; width: 1px; height: 1px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; word-wrap: normal;"
       >
         <h2
-          id="radix-:rq:"
+          id="radix-:ru:"
         >
           Auto modal title
         </h2>
@@ -286,7 +290,7 @@ exports[`Modal basic, auto renders 1`] = `
         No fixed width on the modal container. But very tall.
       </div>
       <button
-        aria-describedby="radix-:rs:"
+        aria-describedby="radix-:r10:"
         aria-label="Close"
         class="btn btn--transparent unround-t unround-br color-gray py12 px12 absolute top right"
         data-state="instant-open"
@@ -350,7 +354,7 @@ exports[`Modal basic, auto renders 1`] = `
         </svg>
       </span>
       <span
-        id="radix-:rs:"
+        id="radix-:r10:"
         role="tooltip"
         style="position: absolute; border: 0px; width: 1px; height: 1px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; word-wrap: normal;"
       >
@@ -384,14 +388,15 @@ exports[`Modal basic, large renders 1`] = `
   <div
     class="fixed top bottom left right bg-darken50"
     data-state="open"
+    data-testid="modal-overlay"
     style="pointer-events: auto; display: grid; overflow-y: auto; place-items: start center;"
   >
     <div
-      aria-describedby="radix-:rj:"
-      aria-labelledby="radix-:ri:"
+      aria-describedby="radix-:rn:"
+      aria-labelledby="radix-:rm:"
       class="relative my12 my60-mm wmax600 w-11/12 bg-white round px36 py36"
       data-state="open"
-      id="radix-:rh:"
+      id="radix-:rl:"
       role="dialog"
       style="pointer-events: auto;"
       tabindex="-1"
@@ -400,7 +405,7 @@ exports[`Modal basic, large renders 1`] = `
         style="position: absolute; border: 0px; width: 1px; height: 1px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; word-wrap: normal;"
       >
         <h2
-          id="radix-:ri:"
+          id="radix-:rm:"
         >
           Large modal title
         </h2>
diff --git a/src/components/modal/modal.test.tsx b/src/components/modal/modal.test.tsx
index 97a8128..34d8c85 100644
--- a/src/components/modal/modal.test.tsx
+++ b/src/components/modal/modal.test.tsx
@@ -1,6 +1,6 @@
 import React from 'react';
 import Modal from './modal';
-import { render, screen, fireEvent } from '@testing-library/react';
+import { render, screen, fireEvent, cleanup } from '@testing-library/react';
 
 describe('Modal', () => {
   describe('basic', () => {
@@ -16,6 +16,10 @@ describe('Modal', () => {
       }
     } as const;
 
+    afterEach(() => {
+      cleanup();
+    });
+
     test('renders', () => {
       const { baseElement } = render(<Modal {...props} />);
       expect(baseElement).toMatchSnapshot();
@@ -27,6 +31,14 @@ describe('Modal', () => {
       expect(mockedOnExit).toHaveBeenCalledTimes(1);
     });
 
+    test('does not fire onExit when underlay is clicked if exitOnUnderlayClicked is false', () => {
+      render(<Modal {...props} exitOnUnderlayClicked={false}/>);
+      fireEvent.click(screen.getByTestId('modal-overlay'));
+      expect(mockedOnExit).toHaveBeenCalledTimes(0);
+      fireEvent.click(screen.getByTestId('modal-close'));
+      expect(mockedOnExit).toHaveBeenCalledTimes(1);
+    });
+
     test('allowEventBubbling disables event trapping', () => {
       const props = {
         children: 'content',
@@ -34,7 +46,7 @@ describe('Modal', () => {
       };
       const { rerender } = render(<Modal {...props} />);
       expect(screen.getByTestId('event-trap')).toBeInTheDocument();
-      rerender(<Modal {...props } allowEventBubbling={true} />);
+      rerender(<Modal {...props} allowEventBubbling={true} />);
       expect(screen.queryByTestId('event-trap')).not.toBeInTheDocument();
     });
   });