diff --git a/docs/data/joy/components/modal/AlertDialogModal.js b/docs/data/joy/components/modal/AlertDialogModal.js new file mode 100644 index 00000000000000..79955a4b292875 --- /dev/null +++ b/docs/data/joy/components/modal/AlertDialogModal.js @@ -0,0 +1,58 @@ +import * as React from 'react'; +import Box from '@mui/joy/Box'; +import Button from '@mui/joy/Button'; +import Modal from '@mui/joy/Modal'; +import ModalDialog from '@mui/joy/ModalDialog'; +import DeleteForever from '@mui/icons-material/DeleteForever'; +import WarningRoundedIcon from '@mui/icons-material/WarningRounded'; +import Typography from '@mui/joy/Typography'; + +export default function AlertDialogModal() { + const [open, setOpen] = React.useState(false); + return ( + + + setOpen(false)} + > + + } + > + Confirmation + + + Are you sure you want to discard all of your notes? + + + + + + + + + ); +} diff --git a/docs/data/joy/components/modal/BasicModal.js b/docs/data/joy/components/modal/BasicModal.js new file mode 100644 index 00000000000000..989527efeaa415 --- /dev/null +++ b/docs/data/joy/components/modal/BasicModal.js @@ -0,0 +1,59 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Modal from '@mui/joy/Modal'; +import ModalClose from '@mui/joy/ModalClose'; +import Typography from '@mui/joy/Typography'; +import Sheet from '@mui/joy/Sheet'; + +export default function BasicModal() { + const [open, setOpen] = React.useState(false); + return ( + + + setOpen(false)} + sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }} + > + + + + This is the modal title + + + Make sure to use aria-labelledby on the modal dialog with an + optional aria-describedby attribute. + + + + + ); +} diff --git a/docs/data/joy/components/modal/BasicModalDialog.js b/docs/data/joy/components/modal/BasicModalDialog.js new file mode 100644 index 00000000000000..ea6a3d7775b741 --- /dev/null +++ b/docs/data/joy/components/modal/BasicModalDialog.js @@ -0,0 +1,66 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import TextField from '@mui/joy/TextField'; +import Modal from '@mui/joy/Modal'; +import ModalDialog from '@mui/joy/ModalDialog'; +import Stack from '@mui/joy/Stack'; +import Add from '@mui/icons-material/Add'; +import Typography from '@mui/joy/Typography'; + +export default function BasicModalDialog() { + const [open, setOpen] = React.useState(false); + return ( + + + setOpen(false)}> + + + Create new project + + + Fill in the information of the project. + +
{ + event.preventDefault(); + setOpen(false); + }} + > + + + + + +
+
+
+
+ ); +} diff --git a/docs/data/joy/components/modal/CloseModal.js b/docs/data/joy/components/modal/CloseModal.js new file mode 100644 index 00000000000000..3716bdf27c3ee4 --- /dev/null +++ b/docs/data/joy/components/modal/CloseModal.js @@ -0,0 +1,50 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Modal from '@mui/joy/Modal'; +import ModalClose from '@mui/joy/ModalClose'; +import Typography from '@mui/joy/Typography'; +import Sheet from '@mui/joy/Sheet'; + +export default function CloseModal() { + const [open, setOpen] = React.useState(false); + return ( + + + { + alert(`Reason: ${reason}`); + setOpen(false); + }} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + + + + Modal title + + + + + ); +} diff --git a/docs/data/joy/components/modal/FadeModalDialog.js b/docs/data/joy/components/modal/FadeModalDialog.js new file mode 100644 index 00000000000000..61d1310613f117 --- /dev/null +++ b/docs/data/joy/components/modal/FadeModalDialog.js @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { Transition } from 'react-transition-group'; +import Button from '@mui/joy/Button'; +import Modal from '@mui/joy/Modal'; +import ModalDialog from '@mui/joy/ModalDialog'; +import Typography from '@mui/joy/Typography'; + +export default function FadeModalDialog() { + const [open, setOpen] = React.useState(false); + return ( + + + + {(state) => ( + setOpen(false)} + componentsProps={{ + backdrop: { + sx: { + opacity: 0, + backdropFilter: 'none', + transition: `opacity 400ms, backdrop-filter 400ms`, + ...{ + entering: { opacity: 1, backdropFilter: 'blur(8px)' }, + entered: { opacity: 1, backdropFilter: 'blur(8px)' }, + }[state], + }, + }, + }} + sx={{ + visibility: state === 'exited' ? 'hidden' : 'visible', + }} + > + + + Transition modal + + + Using `react-transition-group` to create a fade animation. + + + + )} + + + ); +} diff --git a/docs/data/joy/components/modal/KeepMountedModal.js b/docs/data/joy/components/modal/KeepMountedModal.js new file mode 100644 index 00000000000000..04dfb37498b0a1 --- /dev/null +++ b/docs/data/joy/components/modal/KeepMountedModal.js @@ -0,0 +1,35 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Modal from '@mui/joy/Modal'; +import ModalDialog from '@mui/joy/ModalDialog'; +import Typography from '@mui/joy/Typography'; + +export default function KeepMountedModal() { + const [open, setOpen] = React.useState(false); + return ( + + + setOpen(false)}> + + + Keep mounted modal + + + This modal is still in the DOM event though it is closed. + + + + + ); +} diff --git a/docs/data/joy/components/modal/LayoutModalDialog.js b/docs/data/joy/components/modal/LayoutModalDialog.js new file mode 100644 index 00000000000000..32ef3eeb82c890 --- /dev/null +++ b/docs/data/joy/components/modal/LayoutModalDialog.js @@ -0,0 +1,49 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Stack from '@mui/joy/Stack'; +import Modal from '@mui/joy/Modal'; +import ModalClose from '@mui/joy/ModalClose'; +import ModalDialog from '@mui/joy/ModalDialog'; +import Typography from '@mui/joy/Typography'; + +export default function LayoutModalDialog() { + const [open, setOpen] = React.useState(''); + return ( + + + + + + setOpen('')}> + + + + Modal Dialog + + + This is a {open} modal dialog. Press esc to + close it. + + + + + ); +} diff --git a/docs/data/joy/components/modal/ModalUsage.js b/docs/data/joy/components/modal/ModalUsage.js new file mode 100644 index 00000000000000..f15a6a117e20e7 --- /dev/null +++ b/docs/data/joy/components/modal/ModalUsage.js @@ -0,0 +1,75 @@ +import * as React from 'react'; +import JoyUsageDemo, { + prependLinesSpace, +} from 'docs/src/modules/components/JoyUsageDemo'; +import Button from '@mui/joy/Button'; +import Modal from '@mui/joy/Modal'; +import ModalClose from '@mui/joy/ModalClose'; +import ModalDialog from '@mui/joy/ModalDialog'; +import Typography from '@mui/joy/Typography'; + +export default function ModalUsage() { + const [open, setOpen] = React.useState(false); + return ( + \n Modal title', + }, + ]} + getCodeBlock={(code) => ` +${prependLinesSpace(code, 2)} +`} + renderDemo={(props) => ( + + + setOpen(false)}> + + + + Modal title + + + + + )} + /> + ); +} diff --git a/docs/data/joy/components/modal/NestedModals.js b/docs/data/joy/components/modal/NestedModals.js new file mode 100644 index 00000000000000..0849b250c72a7d --- /dev/null +++ b/docs/data/joy/components/modal/NestedModals.js @@ -0,0 +1,51 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Modal from '@mui/joy/Modal'; +import ModalDialog from '@mui/joy/ModalDialog'; +import Typography from '@mui/joy/Typography'; + +function randomBetween(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export default function NestedModals({ random }) { + const [open, setOpen] = React.useState(false); + return ( + + + setOpen(false)}> + + + Infinite modals + + + Welcome to the infinite nested modals. + + + + + + ); +} diff --git a/docs/data/joy/components/modal/ServerModal.js b/docs/data/joy/components/modal/ServerModal.js new file mode 100644 index 00000000000000..6bb6058f1f9f84 --- /dev/null +++ b/docs/data/joy/components/modal/ServerModal.js @@ -0,0 +1,53 @@ +import * as React from 'react'; +import Box from '@mui/joy/Box'; +import Modal from '@mui/joy/Modal'; +import ModalDialog from '@mui/joy/ModalDialog'; +import Typography from '@mui/joy/Typography'; + +export default function ServerModal() { + const rootRef = React.useRef(null); + + return ( + + rootRef.current} + > + + + Server-side modal + + + If you disable JavaScript, you will still see me. + + + + + ); +} diff --git a/docs/data/joy/components/modal/SizeModalDialog.js b/docs/data/joy/components/modal/SizeModalDialog.js new file mode 100644 index 00000000000000..37d7f3b649f380 --- /dev/null +++ b/docs/data/joy/components/modal/SizeModalDialog.js @@ -0,0 +1,62 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Stack from '@mui/joy/Stack'; +import Modal from '@mui/joy/Modal'; +import ModalClose from '@mui/joy/ModalClose'; +import ModalDialog from '@mui/joy/ModalDialog'; +import Typography from '@mui/joy/Typography'; + +export default function SizeModalDialog() { + const [open, setOpen] = React.useState(''); + return ( + + + + + + + setOpen('')}> + + + + Modal Dialog + + + This is a `{open}` modal dialog. + + + + + ); +} diff --git a/docs/data/joy/components/modal/VariantModalDialog.js b/docs/data/joy/components/modal/VariantModalDialog.js new file mode 100644 index 00000000000000..d1052945879c36 --- /dev/null +++ b/docs/data/joy/components/modal/VariantModalDialog.js @@ -0,0 +1,54 @@ +import * as React from 'react'; +import Button from '@mui/joy/Button'; +import Stack from '@mui/joy/Stack'; +import Modal from '@mui/joy/Modal'; +import ModalClose from '@mui/joy/ModalClose'; +import ModalDialog from '@mui/joy/ModalDialog'; +import Typography from '@mui/joy/Typography'; + +export default function VariantModalDialog() { + const [open, setOpen] = React.useState(''); + return ( + + + + + + + + setOpen('')}> + + + + Modal Dialog + + + This is a `{open}` modal dialog. + + + + + ); +} diff --git a/docs/data/joy/components/modal/modal.md b/docs/data/joy/components/modal/modal.md new file mode 100644 index 00000000000000..82437c88372e56 --- /dev/null +++ b/docs/data/joy/components/modal/modal.md @@ -0,0 +1,222 @@ +--- +product: joy-ui +title: React Modal component +githubLabel: 'component: modal' +waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/dialogmodal/ +--- + +# Modal + +

The modal component provides a solid foundation for creating dialogs, popovers, lightboxes, or whatever else.

+ +## Introduction + +Joy UI provides three modal-related components: + +- [`Modal`](#basic-usage): A container that renders its `children` node in front of a backdrop component. +- [`ModalClose`](#dialog): A button for closing the modal. +- [`ModalDialog`](#dialog): A component for rendering a modal dialog. + +{{"demo": "ModalUsage.js", "hideToolbar": true}} + +### Features + +- 🥞 Manages modal stacking when more than one is needed. +- 🪟 Automatically creates a backdrop element to disable interaction with the rest of the app. +- 🔐 Disables page scrolling while open. +- ⌨️ Manages focus correctly between the modal and its parent app. +- ♿️ Adds the appropriate ARIA roles automatically. + +{{"component": "modules/components/ComponentLinkHeader.js", "design": false}} + +:::info +**Note:** +The term "modal" is sometimes used interchangeably with "dialog," but this is incorrect. + +A modal [blocks interaction with the rest of the application](https://en.wikipedia.org/wiki/Modal_window), forcing the user to take action. +As such, it should be used sparingly—only when the app _requires_ user input before it can continue. +A dialog, on the other hand, may be _modal_ or _nonmodal (modeless)_. +::: + +## Component + +After [installation](/joy-ui/getting-started/installation/), you can start building with this component using the following basic elements: + +```jsx +import Modal from '@mui/joy/Modal'; + +export default function MyApp() { + return {children}; +} +``` + +### Basic usage + +The `Modal` accepts only a single React element as a child. +That can be either a Joy UI component, e.g. [`Sheet`](/joy-ui/react-sheet/), or any other custom element. + +Use the `ModalClose` component to render a close button that inherits the modal's `onClose` function. + +{{"demo": "BasicModal.js"}} + +:::info +💡 **Quick tip:** The `ModalClose` accepts the variant prop because it uses the same styles as the [`IconButton`](/joy-ui/react-button/#icon-button). +::: + +### Close reason + +The second argument of the `onClose` gives you the information about how the event is triggered. + +The possible values are: + +- `backdropClick`: the user clicks on the modal's backdrop. +- `escapeKeyDown`: the user presses `Escape` on the keyboard. +- `closeClick`: the user clicks on the `ModalClose` element. + +{{"demo": "CloseModal.js"}} + +### Dialog + +To create a modal dialog, renders the `ModalDialog` component inside the `Modal`. + +{{"demo": "BasicModalDialog.js"}} + +#### Layout + +The `ModalDialog`'s layout can be: + +- `center` (default): the modal dialog appears at the center of the viewport. +- `fullScreen`: the modal dialog covers the whole viewport. + +{{"demo": "LayoutModalDialog.js"}} + +To add more layout, apply a style to the theme like this: + +```js +// Add a new `top` layout to the ModalDialog +extendTheme({ + components: { + JoyModalDialog: { + defaultProps: { + layout: 'top', + }, + styleOverrides: { + root: ({ ownerState }) => ({ + ...(ownerState.layout === 'top' && { + top: '12vh', + left: '50%', + transform: 'translateX(-50%)', + }), + }), + }, + }, + }, +}); +``` + +For **TypeScript**, you need module augmentation to include the new values to the `layout` prop: + +```ts +// at the root or theme file +declare module '@mui/joy/ModalDialog' { + interface ModalDialogPropsLayoutOverrides { + top: true; + } +} +``` + +#### Variant + +The `ModalDialog` supports the [global variants](/joy-ui/main-features/global-variants/) feature. + +The `ModalClose`'s variant adapts automatically to have a proper contrast to the `ModalDialog`. + +{{"demo": "VariantModalDialog.js"}} + +#### Size + +The `ModalDialog` comes with 3 sizes, `sm`, `md` (default), and `lg`. + +The `ModalClose` and `ModalDialogTitle` inherits the size from the `ModalDialog` unless it is specified in each component directly. + +{{"demo": "SizeModalDialog.js"}} + +### Alert Dialog + +Use `role="alertdialog"` to create an [alert dialog](https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/) that interrupts the user's workflow. + +{{"demo": "AlertDialogModal.js"}} + +### Nested modals + +The modal components can be nested: + +{{"demo": "NestedModals.js"}} + +:::warning +⚠️ **Keep in mind:** +Though it is possible to create nested modals, stacking more than two at a time is discouraged. +This is because each successive modal blocks interaction with all elements behind it, making prior states inaccessible and overly complicated for the user to navigate through. +::: + +### Transition + +The modal components **do not** come with built-in transitions. + +Here is one example using [`react-transition-group`](https://reactcommunity.org/react-transition-group/transition) to create a fade animation: + +{{"demo": "FadeModalDialog.js"}} + +### Performance + +The modal's content is unmounted when it is not open. +This means that it will need to be re-mounted each time it is opened. + +If you are rendering "expensive" component trees inside your modal, and you want to optimize for interaction responsiveness, change the default behavior by enabling the `keepMounted` prop. + +Use the `keepMounted` prop to make the content of the modal available to search engines (even when the modal is closed). + +The following demo shows how to apply this prop to keep the modal mounted: + +{{"demo": "KeepMountedModal.js", "defaultCodeOpen": false}} + +As with any performance optimization, the `keepMounted` prop won't necessarily solve all of your problems. +Explore other possible bottlenecks in performance where you could make more considerable improvements before implementing this prop. + +### Server-side modal + +React [doesn't support](https://github.com/facebook/react/issues/13097) the [`createPortal()`](https://reactjs.org/docs/portals.html) API on the server. +Therefore, in order to display a modal rendered on the server, disable the portal feature with the `disablePortal` prop, as shown in the following demo: + +{{"demo": "ServerModal.js", "defaultCodeOpen": false}} + +## Limitations + +### Focus trap + +`ModalUnstyled` moves the focus back to the body of the component if the focus tries to escape it. + +This is done for accessibility purposes, but it can potentially create issues for your users. + +If the user needs to interact with another part of the page-for example, to interact with a chatbot window while a modal is open in the parent app-you can disable the default behavior with the `disableEnforceFocus` prop. + +## Accessibility + +See the [WAI-ARIA guide on the Dialog (Modal) pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialogmodal/) for complete details on accessibility best practices. Here are a couple of highlights: + +- All interactive elements must have an [accessible name](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby). Use the `aria-labelledby="id..."` to give your `Modal` component an accessible name. + You can also use `aria-describedby="id..."` to provide a description of the `Modal`: + + ```jsx + + + + + ``` + +- Follow the [WAI-ARIA authoring practices](https://www.w3.org/WAI/ARIA/apg/example-index/dialog-modal/dialog.html) to help you set the initial focus on the most relevant element based on the content of the modal. + :::warning + **Keep in mind:** A modal window can sit on top of either the parent application, or another modal window. + _All_ windows under the topmost modal are **inert**, meaning the user cannot interact with them. + This can lead to [conflicting behaviors](#focus-trap). + ::: diff --git a/docs/data/joy/pages.ts b/docs/data/joy/pages.ts index 36d685a2a73406..62b6eb0eb3d40b 100644 --- a/docs/data/joy/pages.ts +++ b/docs/data/joy/pages.ts @@ -54,7 +54,7 @@ const pages = [ { pathname: '/joy-ui/components/feedback', subheader: 'feedback', - children: [{ pathname: '/joy-ui/react-alert' }], + children: [{ pathname: '/joy-ui/react-alert' }, { pathname: '/joy-ui/react-modal' }], }, { pathname: '/joy-ui/components/surfaces', diff --git a/docs/pages/joy-ui/react-modal.js b/docs/pages/joy-ui/react-modal.js new file mode 100644 index 00000000000000..c83dc77e186644 --- /dev/null +++ b/docs/pages/joy-ui/react-modal.js @@ -0,0 +1,7 @@ +import * as React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; +import { demos, docs, demoComponents } from 'docs/data/joy/components/modal/modal.md?@mui/markdown'; + +export default function Page() { + return ; +} diff --git a/packages/mui-joy/src/IconButton/IconButton.tsx b/packages/mui-joy/src/IconButton/IconButton.tsx index 723aaf3a69cb37..68ebfbc7b7cd6c 100644 --- a/packages/mui-joy/src/IconButton/IconButton.tsx +++ b/packages/mui-joy/src/IconButton/IconButton.tsx @@ -31,7 +31,7 @@ const useUtilityClasses = (ownerState: IconButtonOwnerState) => { return composedClasses; }; -const IconButtonRoot = styled('button', { +export const IconButtonRoot = styled('button', { name: 'JoyIconButton', slot: 'Root', overridesResolver: (props, styles) => styles.root, diff --git a/packages/mui-joy/src/Modal/CloseModalContext.ts b/packages/mui-joy/src/Modal/CloseModalContext.ts new file mode 100644 index 00000000000000..487543623efe95 --- /dev/null +++ b/packages/mui-joy/src/Modal/CloseModalContext.ts @@ -0,0 +1,6 @@ +import * as React from 'react'; +import { ModalProps } from './ModalProps'; + +const CloseModalContext = React.createContext(undefined); + +export default CloseModalContext; diff --git a/packages/mui-joy/src/Modal/Modal.test.tsx b/packages/mui-joy/src/Modal/Modal.test.tsx new file mode 100644 index 00000000000000..757b28f9d2edf8 --- /dev/null +++ b/packages/mui-joy/src/Modal/Modal.test.tsx @@ -0,0 +1,527 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { spy } from 'sinon'; +import { expect } from 'chai'; +import { createRenderer, describeConformance, act, fireEvent, within } from 'test/utils'; +import { ThemeProvider } from '@mui/joy/styles'; +import Modal, { modalClasses as classes, ModalProps } from '@mui/joy/Modal'; + +describe('', () => { + const { clock, render } = createRenderer(); + + describeConformance( + +
+ , + () => ({ + classes, + inheritComponent: 'div', + render, + ThemeProvider, + muiName: 'JoyModal', + refInstanceof: window.HTMLDivElement, + testComponentPropWith: 'header', + testVariantProps: { hideBackdrop: true }, + skip: [ + 'classesRoot', + 'rootClass', // portal, can't determine the root + 'componentsProp', // TODO isRTL is leaking, why do we even have it in the first place? + 'themeDefaultProps', // portal, can't determine the root + 'themeStyleOverrides', // portal, can't determine the root + 'reactTestRenderer', // portal https://github.com/facebook/react/issues/11565 + ], + }), + ); + + describe('props:', () => { + let container: HTMLElement | undefined; + + before(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + after(() => { + document.body.removeChild(container!); + }); + + it('should consume theme default props', () => { + render( + + +

Hello World

+
+
, + ); + + expect(container).to.have.text('Hello World'); + }); + }); + + describe('prop: open', () => { + it('should not render the children by default', () => { + const { queryByTestId } = render( + +

Hello World

+
, + ); + + expect(queryByTestId('content')).to.equal(null); + }); + + it('renders the children inside a div through a portal when open', () => { + const { getByTestId } = render( + +

Hello World

+
, + ); + + expect(getByTestId('Portal')).to.have.tagName('div'); + }); + + it('makes the child focusable without adding a role', () => { + const { getByTestId } = render( + +
Hello World
+
, + ); + + expect(getByTestId('child')).not.to.have.attribute('role'); + expect(getByTestId('child')).to.have.attribute('tabIndex', '-1'); + }); + }); + + describe('backdrop', () => { + it('should render a backdrop component into the portal before the modal content', () => { + const { getByTestId } = render( + +
+ , + ); + + const modal = getByTestId('modal'); + const container = getByTestId('container'); + expect(modal.children).to.have.length(4); + expect(modal.children[0]).not.to.equal(undefined); + expect(modal.children[0]).not.to.equal(null); + expect(modal.children[2]).to.equal(container); + }); + + it('should attach a handler to the backdrop that fires onClose', () => { + const onClose = spy(); + const { getByTestId } = render( + +
+ , + ); + + getByTestId('backdrop').click(); + + expect(onClose).to.have.property('callCount', 1); + }); + + it('should let the user disable backdrop click triggering onClose', () => { + function ModalWithDisabledBackdropClick(props: ModalProps) { + const { onClose, ...other } = props; + const handleClose: ModalProps['onClose'] = (event, reason) => { + if (reason !== 'backdropClick') { + onClose?.(event, reason); + } + }; + + return ( + +
+ + ); + } + const onClose = spy(); + const { getByTestId } = render( + +
+ , + ); + + getByTestId('backdrop').click(); + + expect(onClose).to.have.property('callCount', 0); + }); + }); + + describe('hide backdrop', () => { + it('should not render a backdrop component into the portal before the modal content', () => { + const { getByTestId } = render( + +
+ , + ); + + const modal = getByTestId('modal'); + const container = getByTestId('container'); + expect(modal.children).to.have.length(3); + expect(modal.children[1]).to.equal(container); + }); + }); + + describe('event: keydown', () => { + it('when mounted, TopModal and event not esc should not call given functions', () => { + const onCloseSpy = spy(); + const { getByTestId } = render( + +
+ , + ); + act(() => { + getByTestId('modal').focus(); + }); + + fireEvent.keyDown(getByTestId('modal'), { + key: 'j', // Not escape + }); + + expect(onCloseSpy).to.have.property('callCount', 0); + }); + + it('should call onClose when Esc is pressed and stop event propagation', () => { + const handleKeyDown = spy(); + const onCloseSpy = spy(); + const { getByTestId } = render( +
+ +
+ +
, + ); + act(() => { + getByTestId('modal').focus(); + }); + + fireEvent.keyDown(getByTestId('modal'), { + key: 'Escape', + }); + + expect(onCloseSpy).to.have.property('callCount', 1); + expect(handleKeyDown).to.have.property('callCount', 0); + }); + + it('should not call onClose when `disableEscapeKeyDown={true}`', () => { + const handleKeyDown = spy(); + const onCloseSpy = spy(); + const { getByTestId } = render( +
+ +
+ +
, + ); + act(() => { + getByTestId('modal').focus(); + }); + + fireEvent.keyDown(getByTestId('modal'), { + key: 'Escape', + }); + + expect(onCloseSpy).to.have.property('callCount', 0); + expect(handleKeyDown).to.have.property('callCount', 1); + }); + + it('calls onKeyDown on the Modal', () => { + const handleKeyDown = spy(); + const { getByTestId } = render( + +