From 9d38e537fca4a61643743f6bf3409f20189eb8bb Mon Sep 17 00:00:00 2001 From: George Marshall Date: Fri, 19 May 2023 13:20:15 -0700 Subject: [PATCH] Adding Modal and updating ModalContent component (#19020) --- ui/components/component-library/index.js | 1 + .../modal-content/README.mdx | 152 +++++---- .../__snapshots__/modal-content.test.tsx.snap | 13 +- .../modal-content/modal-content.scss | 35 +- .../modal-content/modal-content.stories.tsx | 241 +++++++------- .../modal-content/modal-content.test.tsx | 150 ++++++++- .../modal-content/modal-content.tsx | 117 +++++-- .../modal-content/modal-content.types.ts | 17 +- .../component-library/modal/README.mdx | 239 ++++++++++++++ .../modal/__snapshots__/modal.test.tsx.snap | 12 + .../component-library/modal/index.ts | 3 + .../component-library/modal/modal.context.ts | 19 ++ .../component-library/modal/modal.stories.tsx | 304 ++++++++++++++++++ .../component-library/modal/modal.test.tsx | 82 +++++ .../component-library/modal/modal.tsx | 52 +++ .../component-library/modal/modal.types.ts | 33 ++ 16 files changed, 1222 insertions(+), 248 deletions(-) create mode 100644 ui/components/component-library/modal/README.mdx create mode 100644 ui/components/component-library/modal/__snapshots__/modal.test.tsx.snap create mode 100644 ui/components/component-library/modal/index.ts create mode 100644 ui/components/component-library/modal/modal.context.ts create mode 100644 ui/components/component-library/modal/modal.stories.tsx create mode 100644 ui/components/component-library/modal/modal.test.tsx create mode 100644 ui/components/component-library/modal/modal.tsx create mode 100644 ui/components/component-library/modal/modal.types.ts diff --git a/ui/components/component-library/index.js b/ui/components/component-library/index.js index 2ebed8871..6117cae00 100644 --- a/ui/components/component-library/index.js +++ b/ui/components/component-library/index.js @@ -35,6 +35,7 @@ export { TextFieldSearch } from './text-field-search'; export { ModalContent, ModalContentSize } from './modal-content'; export { ModalOverlay } from './modal-overlay'; export { ModalFocus } from './modal-focus'; +export { Modal, useModalContext } from './modal'; // Molecules export { BannerBase } from './banner-base'; diff --git a/ui/components/component-library/modal-content/README.mdx b/ui/components/component-library/modal-content/README.mdx index 3d755d954..cd33f72dd 100644 --- a/ui/components/component-library/modal-content/README.mdx +++ b/ui/components/component-library/modal-content/README.mdx @@ -4,7 +4,7 @@ import { ModalContent } from './modal-content'; # ModalContent -`ModalContent` is the container for the modal dialog's content +`ModalContent` is the container for the modal dialog's content. It uses context supplied by the `Modal` component and cannot be used without it. @@ -18,98 +18,114 @@ The `ModalContent` accepts all props below as well as all [Box](/docs/components ### Children -Use the `children` prop to render the content of `ModalContent` +Use the `children` prop to render the content of `ModalContent`. The `ModalContent` should generally be used with the `ModalHeader` component. ```jsx -import { ModalContent, Text } from '../../component-library'; +import React, { useState } from 'react'; +import { Modal, ModalContent, ModalHeader, Text, Button, BUTTON_VARIANT } from '../../component-library'; - - - - Lorem ipsum dolor sit amet consectetur adipisicing elit. Distinctio, - reiciendis assumenda dolorum mollitia saepe, optio at aliquam molestias - omnis quae corporis nesciunt natus, quas tempore ut ullam eaque fuga. Velit. - -; +const [show, setShow] = useState(false); +const handleOnClick = () => { + setShow(!show); +}; + + + + + Modal Header + Modal Content + + + + + + + + ``` ### Size Currently the `ModalContent` supports a single size, this decision was made after we ran an audit on all modal sizes in the extension codebase and found that all modals could be made to fit the `ModalContentSize.Sm`(360px) size. -If you do require a larger modal size you can use the Box props or add a className to override the default size. +If you do require a larger modal size you can use the `modalDialogProps` to add a className to override the default size. ```jsx -import { BLOCK_SIZES } from '../../../helpers/constants/design-system'; -import { ModalContent,s Text } from '../../component-library'; +import { DISPLAY } from '../../../helpers/constants/design-system'; - - ModalContentSize.Sm default and only size 360px max-width - - - - Using width Box props and responsive array props
[ - BLOCK_SIZES.FULL, BLOCK_SIZES.THREE_FOURTHS, BLOCK_SIZES.HALF, - BLOCK_SIZES.ONE_THIRD, ] -
-
- - Adding a className and setting a max width (max-width: 800px) - -``` +import Box from '../../ui/box'; -### Modal Content Ref +import { Modal, ModalContent, Text, Button, BUTTON_VARIANT } from '../../component-library'; -Use the `modalContentRef` prop to pass a ref to the `ModalContent` component. This is primarily used with the `closeOnOutsideClick` prop on the `Modal` component. It allows the `Modal` to close when the user clicks outside of the `ModalContent` component. +enum ModalContentSizeStoryOption { + Sm = 'sm', + ClassName = 'className', +} - - - - -```jsx -import React, { useEffect, useRef, useState } from 'react'; -import { ModalContent, Text } from '../../component-library'; - -const [show, setShow] = useState(false); -const modalContentRef = useRef(null); - -const handleClickOutside = (event: MouseEvent) => { - if ( - modalContentRef?.current && - !modalContentRef.current.contains(event.target as Node) - ) { - setShow(false); - } +const [show, setShow] = useState({ + sm: false, + className: false, +}); +const handleOnClick = (size: ModalContentSizeStoryOption) => { + setShow({ ...show, [size]: !show[size] }); }; -useEffect(() => { - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; -}, []); + + + + - -{show && ( - - Click outside of this ModalContent to close + handleOnClick(ModalContentSizeStoryOption.Sm)} +> + + + ModalContentSize.Sm default and only size 360px max-width + + -)} + + + handleOnClick(ModalContentSizeStoryOption.ClassName)} +> + + + Using modalDialogProps and adding a className setting a max width + (max-width: 800px) + + + + ``` diff --git a/ui/components/component-library/modal-content/__snapshots__/modal-content.test.tsx.snap b/ui/components/component-library/modal-content/__snapshots__/modal-content.test.tsx.snap index c8fa28c96..f4e452905 100644 --- a/ui/components/component-library/modal-content/__snapshots__/modal-content.test.tsx.snap +++ b/ui/components/component-library/modal-content/__snapshots__/modal-content.test.tsx.snap @@ -1,11 +1,16 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ModalContent should match snapshot 1`] = ` -
-
+
+
`; diff --git a/ui/components/component-library/modal-content/modal-content.scss b/ui/components/component-library/modal-content/modal-content.scss index 3c3bc00fb..d9e032140 100644 --- a/ui/components/component-library/modal-content/modal-content.scss +++ b/ui/components/component-library/modal-content/modal-content.scss @@ -1,17 +1,24 @@ .mm-modal-content { - --modal-content-size: var(--size, 360px); - - // Currently there is only use case for one size of ModalContent in the extension - // See audit https://www.figma.com/file/hxYqloYgmVcgsoiVqmGZ8K/Modal-Audit?node-id=481%3A244&t=XITeuRB1pRc09hiG-1 - // Not to say there won't be more in the future, but to prevent redundant code there is only one for now - &--size-sm { - --size: 360px; - - max-width: var(--modal-content-size); - } - - position: relative; - box-shadow: var(--shadow-size-lg) var(--color-shadow-default); - max-height: calc(100% - 32px); // allow for 16px padding on top and bottom + position: fixed; + left: 0; + top: 0; + z-index: $modal-z-index; overflow: auto; + overscroll-behavior-y: none; + + &__dialog { + --modal-content-size: var(--size, 360px); + + // Currently there is only use case size of ModalContent dialog in the extension + // See audit https://www.figma.com/file/hxYqloYgmVcgsoiVqmGZ8K/Modal-Audit?node-id=481%3A244&t=XITeuRB1pRc09hiG-1 + // Not to say there won't be more in the future, but to prevent redundant code there is only one for now + &--size-sm { + --size: 360px; + + max-width: var(--modal-content-size); + } + + position: relative; + box-shadow: var(--shadow-size-lg) var(--color-shadow-default); + } } diff --git a/ui/components/component-library/modal-content/modal-content.stories.tsx b/ui/components/component-library/modal-content/modal-content.stories.tsx index 020e17026..6bcd8e716 100644 --- a/ui/components/component-library/modal-content/modal-content.stories.tsx +++ b/ui/components/component-library/modal-content/modal-content.stories.tsx @@ -1,18 +1,11 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useState } from 'react'; import { ComponentStory, ComponentMeta } from '@storybook/react'; -import { - DISPLAY, - JustifyContent, - AlignItems, - BLOCK_SIZES, - TextVariant, - TEXT_ALIGN, -} from '../../../helpers/constants/design-system'; - import Box from '../../ui/box'; -import { Button, Text } from '..'; +import { DISPLAY } from '../../../helpers/constants/design-system'; + +import { BUTTON_VARIANT, Button, Text, Modal, ModalHeader } from '..'; import { ModalContent } from './modal-content'; import { ModalContentSize } from './modal-content.types'; @@ -28,117 +21,147 @@ export default { }, }, argTypes: { - className: { - control: 'text', - }, - children: { - control: 'text', - }, size: { control: 'select', - options: Object.values(ModalContentSize).map((value) => - value.toLowerCase(), - ), + options: Object.values(ModalContentSize), }, }, - args: { - children: 'Modal Content', - }, } as ComponentMeta; -const Template: ComponentStory = (args) => ( - +const LoremIpsum = () => ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam euismod + tortor vitae nisi blandit, eu aliquam nisl ultricies. Donec euismod + scelerisque nisl, sit amet aliquet nunc. Donec euismod, nisl vitae + consectetur aliquam, nunc nunc ultricies nunc, eget aliquam nisl nisl vitae + nunc. Donec euismod, nisl vitae consectetur aliquam, nunc nunc ultricies + nunc, eget aliquam nisl nisl vitae nunc. Donec euismod, nisl vitae + consectetur aliquam, nunc nunc ultricies nunc, eget aliquam nisl nisl vitae + nunc. Donec euismod, nisl vitae consectetur aliquam, nunc + ); -export const DefaultStory = Template.bind({}); -DefaultStory.storyName = 'Default'; - -/* - * !!TODO: Replace with ModalHeader component - */ -const ModalHeader = () => ( - <> - - - - Modal Header - - - - -); - -export const Children: ComponentStory = (args) => ( - - - - Lorem ipsum dolor sit amet consectetur adipisicing elit. Distinctio, - reiciendis assumenda dolorum mollitia saepe, optio at aliquam molestias - omnis quae corporis nesciunt natus, quas tempore ut ullam eaque fuga. - Velit. - - -); - -export const Size: ComponentStory = (args) => ( - <> - - ModalContentSize.Sm default and only size 360px max-width - - - - Using width Box props and responsive array props
[ - BLOCK_SIZES.FULL, BLOCK_SIZES.THREE_FOURTHS, BLOCK_SIZES.HALF, - BLOCK_SIZES.ONE_THIRD, ] -
-
- - Adding a className and setting a max width (max-width: 800px) - - -); - -export const ModalContentRef: ComponentStory = (args) => { +export const DefaultStory: ComponentStory = (args) => { const [show, setShow] = useState(false); - const modalContentRef = useRef(null); - const handleClickOutside = (event: MouseEvent) => { - if ( - modalContentRef?.current && - !modalContentRef.current.contains(event.target as Node) - ) { - setShow(false); - } + const handleOnClick = () => { + setShow(!show); }; - useEffect(() => { - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); return ( <> - - {show && ( - - Click outside of this ModalContent to close + + + + Modal Header + Modal Content + - )} + + + ); +}; + +DefaultStory.storyName = 'Default'; + +export const Children: ComponentStory = (args) => { + const [show, setShow] = useState(false); + const handleOnClick = () => { + setShow(!show); + }; + return ( + <> + + + + Modal Header + + The ModalContent with ModalHeader and Text components as children + + + + + + + + + + + ); +}; + +enum ModalContentSizeStoryOption { + Sm = 'sm', + ClassName = 'className', +} + +export const Size: ComponentStory = (args) => { + const [show, setShow] = useState({ + sm: false, + className: false, + }); + const handleOnClick = (size: ModalContentSizeStoryOption) => { + setShow({ ...show, [size]: !show[size] }); + }; + + return ( + <> + + + + + + handleOnClick(ModalContentSizeStoryOption.Sm)} + > + + + ModalContentSize.Sm default and only size 360px max-width + + + + + + handleOnClick(ModalContentSizeStoryOption.ClassName)} + > + + + Using modalDialogProps and adding a className setting a max width + (max-width: 800px) + + + + ); }; diff --git a/ui/components/component-library/modal-content/modal-content.test.tsx b/ui/components/component-library/modal-content/modal-content.test.tsx index 64b6a96df..df87ccf89 100644 --- a/ui/components/component-library/modal-content/modal-content.test.tsx +++ b/ui/components/component-library/modal-content/modal-content.test.tsx @@ -1,39 +1,155 @@ /* eslint-disable jest/require-top-level-describe */ -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import React from 'react'; - +import { Modal } from '..'; import { ModalContent } from './modal-content'; import { ModalContentSize } from './modal-content.types'; describe('ModalContent', () => { + const onClose = jest.fn(); + + afterEach(() => { + onClose.mockReset(); + }); + it('should render with text inside the ModalContent', () => { - const { getByText } = render(test); + const { getByText, getByTestId } = render( + + test + , + ); expect(getByText('test')).toBeDefined(); - expect(getByText('test')).toHaveClass('mm-modal-content'); + expect(getByText('test')).toHaveClass('mm-modal-content__dialog'); + expect(getByTestId('modal-content')).toHaveClass('mm-modal-content'); }); it('should match snapshot', () => { - const { container } = render(test); - expect(container).toMatchSnapshot(); + const { getByTestId } = render( + + test + , + ); + expect(getByTestId('test')).toMatchSnapshot(); }); it('should render with and additional className', () => { - const { getByText } = render( - test, + const { getByTestId } = render( + + + test + + , + , ); - expect(getByText('test')).toHaveClass('test-class'); + expect(getByTestId('test')).toHaveClass('test-class'); }); it('should render with size sm', () => { const { getByText } = render( <> - default - sm + + default + sm + , ); - expect(getByText('sm')).toHaveClass('mm-modal-content--size-sm'); - expect(getByText('default')).toHaveClass('mm-modal-content--size-sm'); + expect(getByText('sm')).toHaveClass('mm-modal-content__dialog--size-sm'); + expect(getByText('default')).toHaveClass( + 'mm-modal-content__dialog--size-sm', + ); }); - it('should render with a ref', () => { - const ref = React.createRef(); - render(test); - expect(ref.current).toBeDefined(); + it('should render with additional props being passed to modalDialogProps', () => { + const { getByTestId } = render( + + + test + + , + ); + expect(getByTestId('test')).toBeDefined(); + }); + it('should close when escape key is pressed', () => { + const { getByRole } = render( + + modal dialog + , + ); + fireEvent.keyDown(getByRole('dialog'), { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should not close when isClosedOnEscapeKey is false and escape key is pressed', () => { + const { getByRole } = render( + + modal dialog + , + ); + fireEvent.keyDown(getByRole('dialog'), { key: 'Escape' }); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('should close when clicked outside', () => { + const { getByRole } = render( + + modal dialog + , + ); + // don't close when clicked inside + fireEvent.mouseDown(getByRole('dialog')); + expect(onClose).not.toHaveBeenCalled(); + // close when clicked outside + fireEvent.mouseDown(document.body); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should not close when isClosedOnOutsideClick is false and clicked outside', () => { + const ref: React.RefObject = React.createRef(); + const { getByTestId } = render( + + + modal dialog + + , + ); + // don't close when clicked inside + fireEvent.mouseDown(getByTestId('modal-dialog')); + expect(onClose).not.toHaveBeenCalled(); + // don't close when clicked outside + fireEvent.mouseDown(document.body); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('should focus initial focus ref when autoFocus is false', () => { + const initialRef: React.RefObject = React.createRef(); + const { getByTestId } = render( + + + + + + , + ); + expect(getByTestId('input')).toHaveFocus(); + }); + + it('should focus final focus ref when modal is closed', () => { + const finalRef: React.RefObject = React.createRef(); + const { rerender } = render( + <> + + + modal dialog + + , + ); + rerender( + <> + + + modal dialog + + , + ); + expect(finalRef.current).toHaveFocus(); }); }); diff --git a/ui/components/component-library/modal-content/modal-content.tsx b/ui/components/component-library/modal-content/modal-content.tsx index 064a4b4e7..7fff9e169 100644 --- a/ui/components/component-library/modal-content/modal-content.tsx +++ b/ui/components/component-library/modal-content/modal-content.tsx @@ -1,37 +1,106 @@ -import React from 'react'; +import React, { forwardRef, useRef, useEffect } from 'react'; import classnames from 'classnames'; import { BackgroundColor, BorderRadius, BLOCK_SIZES, + DISPLAY, + JustifyContent, + AlignItems, } from '../../../helpers/constants/design-system'; import Box from '../../ui/box/box'; +import { ModalFocus, useModalContext } from '..'; + import { ModalContentProps, ModalContentSize } from './modal-content.types'; -export const ModalContent = ({ - className = '', - children, - size = ModalContentSize.Sm, - width, - modalContentRef, // Would have preferred to forwardRef but it's not trivial in TypeScript. Will update once we have an established pattern - ...props -}: ModalContentProps) => ( - - {children} - +export const ModalContent = forwardRef( + ( + { + className = '', + children, + size = ModalContentSize.Sm, + modalDialogProps, + ...props + }: ModalContentProps, + ref: React.Ref, + ) => { + const { + onClose, + isClosedOnEscapeKey, + isClosedOnOutsideClick, + initialFocusRef, + finalFocusRef, + restoreFocus, + autoFocus, + } = useModalContext(); + const modalDialogRef = useRef(null); + const handleEscKey = (event: KeyboardEvent) => { + if (isClosedOnEscapeKey && event.key === 'Escape') { + onClose(); + } + }; + + const handleClickOutside = (event: MouseEvent) => { + if ( + isClosedOnOutsideClick && + modalDialogRef?.current && + !modalDialogRef.current.contains(event.target as Node) + ) { + onClose(); + } + }; + + useEffect(() => { + document.addEventListener('keydown', handleEscKey); + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('keydown', handleEscKey); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + return ( + + + + {children} + + + + ); + }, ); diff --git a/ui/components/component-library/modal-content/modal-content.types.ts b/ui/components/component-library/modal-content/modal-content.types.ts index 801a04b36..6d10771b5 100644 --- a/ui/components/component-library/modal-content/modal-content.types.ts +++ b/ui/components/component-library/modal-content/modal-content.types.ts @@ -1,6 +1,5 @@ import React from 'react'; -import type { BoxProps, BoxWidth, BoxWidthArray } from '../../ui/box/box.d'; -import { Size } from '../../../helpers/constants/design-system'; +import type { BoxProps } from '../../ui/box/box.d'; /* * ModalContent sizes @@ -9,7 +8,7 @@ import { Size } from '../../../helpers/constants/design-system'; * Not to say there won't be more in the future, but to prevent redundant code there is only one for now */ export enum ModalContentSize { - Sm = Size.SM, + Sm = 'sm', } export interface ModalContentProps extends BoxProps { @@ -20,7 +19,7 @@ export interface ModalContentProps extends BoxProps { /** * The content of the ModalContent component */ - children?: React.ReactNode; + children: React.ReactNode; /** * The size of ModalContent * Currently only one size is supported ModalContentSize.Sm 360px @@ -28,13 +27,7 @@ export interface ModalContentProps extends BoxProps { */ size?: ModalContentSize; /** - * To override the default width of the ModalContent component - * Accepts all BLOCK_SIZES from design-system + * Additional props to pass to the dialog node inside of ModalContent component */ - width?: BoxWidth | BoxWidthArray; - /** - * The ref of the ModalContent component - * Used with Modal and closeOnOutsideClick prop - */ - modalContentRef?: React.RefObject; + modalDialogProps?: BoxProps | React.HTMLAttributes; } diff --git a/ui/components/component-library/modal/README.mdx b/ui/components/component-library/modal/README.mdx new file mode 100644 index 000000000..d106a78f0 --- /dev/null +++ b/ui/components/component-library/modal/README.mdx @@ -0,0 +1,239 @@ +import { Story, Canvas, ArgsTable } from '@storybook/addon-docs'; + +import { Modal } from './modal'; + +# Modal + +The `Modal` focuses the user's attention exclusively on information via a window that is overlaid on primary content. It should be used with the `ModalOverlay`, `ModalContent` and `ModalHeader` components to create a complete modal. + + + + + +## Props + +The `Modal` accepts all props below as well as all [Box](/docs/components-ui-box--default-story#props) component props + + + +### Usage + +The `Modal` component is a very atomic level component that is meant to be used with `ModalOverlay`, `ModalContent` and `ModalHeader`. + +When the modal opens: + +- Focus is trapped within the modal and set to the first tabbable element. +- Content behind a modal dialog is inert, meaning that users cannot interact with it. +- Use the `isOpen` prop to control whether the modal is open or closed. +- Use the `onClose` prop to fire a callback when the modal is closed. This is used for the `isClosedOnOutsideClick` prop and the `isClosedOnEscapeKey`. + + + + + +```jsx +import React, { useState, useRef } from 'react'; +import { Modal, ModalOverlay, ModalContent, ModalHeader, Text, Button } from '../../component-library'; + +const [open, setOpen] = useState(false); + +const handleOnClick = () => { + setOpen(true); +}; + +const handleOnClose = () => { + setOpen(false); +}; + + + + + + + Modal Header + + Children + + +``` + +### Is Closed On Outside Click + +Use the `isClosedOnOutsideClick` prop to control whether the modal should close when the user clicks outside of the modal. + +Defaults to `true`. + + + + + +```jsx +import { Modal } from '../../component-library'; + +; +``` + +### Is Closed On Escape Key + +Use the `isClosedOnEscapeKey` prop to control whether the modal should close when the user presses the escape key. + +Defaults to `true`. + + + + + +```jsx +import { Modal } from '../../component-library'; + +; +``` + +### Initial Focus Ref + +Use the `initialFocusRef` to set the `ref` of the element to receive focus initially. This is useful for input elements that should receive focus when the modal opens. + + + + + +```jsx +import React, { useState, useRef } from 'react'; +import { Modal, ModalOverlay, ModalContent, ModalHeader, TextFieldSearch, Button } from '../../component-library'; + +// Ref to set initial focus +const inputRef = React.useRef(null); + +const [open, setOpen] = useState(false); + +const handleOnClick = () => { + setOpen(true); +}; + +const handleOnClose = () => { + setOpen(false); +}; + + + + + + + Modal Header + + + + +``` + +### Final Focus Ref + +Use the `finalFocusRef` to set the `ref` of the element to receive focus when the modal closes. + + + + + +```jsx +import React, { useState, useRef } from 'react'; +import { Modal, ModalOverlay, ModalContent, ModalHeader, TextFieldSearch, Button } from '../../component-library'; + +// Ref to set focus after modal closes +const buttonRef = React.useRef(null); + +const [open, setOpen] = useState(false); + +const handleOnClick = () => { + setOpen(true); +}; + +const handleOnClose = () => { + setOpen(false); +}; + + + + + + + + Modal Header + + {args.children} + + +``` + +### Restore Focus + +Use the `restoreFocus` prop to restore focus to the element that triggered the `Modal` once it unmounts + +Defaults to `false` + + + + + +```jsx +import { Modal } from '../../component-library'; + +; +``` + +### Auto Focus + +If `true`, the first focusable element within the `children` will auto-focused once `Modal` mounts. Depending on the content of `Modal` this is usually the back or close button in the `ModalHeader`. + +Defaults to `true` + + + + + +```jsx +import { Modal } from '../../component-library'; + +; +``` + +## Accessibility + +### Keyboard and Focus Management + +- When the modal opens, focus is trapped within it. +- When the modal opens, focus is automatically set to the first enabled element, or the element from `initialFocusRef`. +- When the modal closes, focus returns to the element that was focused before the modal activated, or the element from `finalFocusRef`. +- Clicking on the overlay closes the Modal. +- Pressing ESC closes the Modal. +- Scrolling is blocked on the elements behind the modal. +- The modal is rendered in a portal attached to the end of document.body to break it out of the source order and make it easy to add aria-hidden to its siblings. + +### ARIA + +- The `ModalContent` has aria-modal="true" and role="dialog" +- The `ModalOverlay` has aria-hidden="true" diff --git a/ui/components/component-library/modal/__snapshots__/modal.test.tsx.snap b/ui/components/component-library/modal/__snapshots__/modal.test.tsx.snap new file mode 100644 index 000000000..3cb2e643d --- /dev/null +++ b/ui/components/component-library/modal/__snapshots__/modal.test.tsx.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Modal should match snapshot 1`] = ` +
+
+ modal content +
+
+`; diff --git a/ui/components/component-library/modal/index.ts b/ui/components/component-library/modal/index.ts new file mode 100644 index 000000000..c756c9a54 --- /dev/null +++ b/ui/components/component-library/modal/index.ts @@ -0,0 +1,3 @@ +export { Modal } from './modal'; +export type { ModalProps } from './modal.types'; +export { useModalContext } from './modal.context'; diff --git a/ui/components/component-library/modal/modal.context.ts b/ui/components/component-library/modal/modal.context.ts new file mode 100644 index 000000000..7d097abe1 --- /dev/null +++ b/ui/components/component-library/modal/modal.context.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from 'react'; + +import type { ModalProps } from './modal.types'; + +export type ModalContextType = Omit; + +export const ModalContext = createContext( + undefined, +); + +export const useModalContext = () => { + const context = useContext(ModalContext); + if (!context) { + throw new Error( + 'useModalContext must be used within a ModalProvider, Seems you forgot to wrap the components in ""', + ); + } + return context; +}; diff --git a/ui/components/component-library/modal/modal.stories.tsx b/ui/components/component-library/modal/modal.stories.tsx new file mode 100644 index 000000000..e3e59fbf0 --- /dev/null +++ b/ui/components/component-library/modal/modal.stories.tsx @@ -0,0 +1,304 @@ +import React, { useState } from 'react'; +import { useArgs } from '@storybook/client-api'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; + +import { BLOCK_SIZES, DISPLAY } from '../../../helpers/constants/design-system'; + +import Box from '../../ui/box'; + +import { + ModalOverlay, + ModalContent, + ModalHeader, + Text, + Button, + ButtonLink, + BUTTON_LINK_SIZES, + TextFieldSearch, + IconName, +} from '..'; + +import { Modal } from './modal'; + +import README from './README.mdx'; + +export default { + title: 'Components/ComponentLibrary/Modal', + component: Modal, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + isOpen: { + control: 'boolean', + }, + onClose: { + action: 'onClose', + }, + children: { + control: 'node', + }, + className: { + control: 'string', + }, + isClosedOnOutsideClick: { + control: 'boolean', + }, + isClosedOnEscapeKey: { + control: 'boolean', + }, + initialFocusRef: { + control: 'object', + }, + finalFocusRef: { + control: 'object', + }, + restoreFocus: { + control: 'boolean', + }, + autoFocus: { + control: 'boolean', + }, + }, + args: { + children: ( + ModalContent children after ModalHeader + ), + }, +} as ComponentMeta; + +const LoremIpsum = (props) => ( + + Lorem ipsum dolor sit amet, conse{' '} + + random focusable button + {' '} + ctetur adipiscing elit. Phasellus posuere nunc enim, quis efficitur dolor + tempus viverra. Vivamus pharetra tempor pulvinar. Sed at dui in nisi + fermentum volutpat. Proin ut tortor quis eros tincidunt molestie. + Suspendisse dictum ex vitae metus consequat, et efficitur dolor luctus. + Integer ultricies hendrerit turpis sed faucibus. Nam pellentesque metus a + turpis sollicitudin vehicula. Phasellus rutrum luctus pulvinar. Phasellus + quis accumsan urna. Praesent justo erat, bibendum ac volutpat ac, placerat + in dui. Cras gravida mi et risus feugiat vulputate. Integer vulputate diam + eu vehicula euismod. In laoreet quis eros sed tincidunt. Pellentesque purus + dui, luctus id sem sit amet, varius congue dui + +); + +const Template: ComponentStory = (args) => { + const [{ isOpen }, updateArgs] = useArgs(); + const [showLoremIpsum, setShowLoremIpsum] = useState(false); + const [showMoreModalContent, setShowMoreModalContent] = useState(false); + const handleOnClick = () => { + updateArgs({ isOpen: true }); + }; + const handleOnClose = () => { + updateArgs({ isOpen: false }); + }; + const handleHideLoremIpsum = () => { + setShowLoremIpsum(!showLoremIpsum); + }; + const handleMoreContent = () => { + setShowMoreModalContent(!showMoreModalContent); + }; + + return ( + + + + + {showLoremIpsum ? 'Hide' : 'Show'} scrollable content + + + + + + Modal Header + {args.children} + Show more content to check scrolling + + {showMoreModalContent ? 'Hide' : 'Show more'} + + + {showMoreModalContent && ( + <> + + + + + + + + )} + + + {showLoremIpsum && ( + <> + + + + + + + + )} + + ); +}; +export const DefaultStory = Template.bind({}); +DefaultStory.storyName = 'Default'; + +export const Usage = Template.bind({}); + +export const IsClosedOnOutsideClick = Template.bind({}); +IsClosedOnOutsideClick.args = { + isClosedOnOutsideClick: false, + children: ( + + This Modal has set isClosedOnOutsideClick: false. Clicking outside this + Modal WILL NOT close it + + ), +}; + +export const IsClosedOnEscapeKey = Template.bind({}); +IsClosedOnEscapeKey.args = { + isClosedOnEscapeKey: false, + children: ( + + This Modal has set isClosedOnEscapeKey: false. Pressing the ESC key{' '} + WILL NOT close it + + ), +}; + +export const InitialFocusRef: ComponentStory = (args) => { + const inputRef = React.useRef(null); + const [{ isOpen }, updateArgs] = useArgs(); + const handleOnClick = () => { + updateArgs({ isOpen: true }); + }; + const handleOnClose = () => { + updateArgs({ isOpen: false }); + }; + return ( + <> + + + + + + Modal Header + + + {args.children} + + + + ); +}; + +InitialFocusRef.args = { + children: ( + + This Modal has set initialFocusRef to the TextFieldSearch component. When + the Modal opens, the TextFieldSearch component will be focused. + + ), +}; + +export const FinalFocusRef: ComponentStory = (args) => { + const buttonRef = React.useRef(null); + const [{ isOpen }, updateArgs] = useArgs(); + const handleOnClick = () => { + updateArgs({ isOpen: true }); + }; + const handleOnClose = () => { + updateArgs({ isOpen: false }); + }; + return ( + <> + + + + + + + Modal Header + + {args.children} + + + + ); +}; + +FinalFocusRef.args = { + children: ( + + This Modal has set finalFocusRef to the second button element. When the + Modal closes, the second button component will be focused. Use keyboard + navigation to see it clearly. + + ), +}; + +export const RestoreFocus = Template.bind({}); +RestoreFocus.args = { + restoreFocus: true, + children: ( + + This Modal has set restoreFocus: true. When the Modal closes, the Button + component will be focused. Use keyboard navigation to see it clearly. + + ), +}; + +export const AutoFocus = Template.bind({}); +AutoFocus.args = { + autoFocus: false, + children: ( + + This Modal has set autoFocus: false. When the Modal opens the first + element to focus WILL NOT be the first focusable element + in the Modal. + + ), +}; diff --git a/ui/components/component-library/modal/modal.test.tsx b/ui/components/component-library/modal/modal.test.tsx new file mode 100644 index 000000000..89e8fc745 --- /dev/null +++ b/ui/components/component-library/modal/modal.test.tsx @@ -0,0 +1,82 @@ +/* eslint-disable jest/require-top-level-describe */ +import { render, fireEvent } from '@testing-library/react'; +import React from 'react'; + +import { Modal } from './modal'; + +describe('Modal', () => { + const onClose = jest.fn(); + + beforeEach(() => { + onClose.mockClear(); + }); + + it('should render the Modal without crashing', () => { + const { getByText, getByTestId } = render( + +
modal content
+
, + ); + expect(getByText('modal content')).toBeDefined(); + expect(getByTestId('modal')).toHaveClass('mm-modal'); + }); + + it('should match snapshot', () => { + const { getByTestId } = render( + +
modal content
+
, + ); + expect(getByTestId('test')).toMatchSnapshot(); + }); + + it('should render with and additional className', () => { + const { getByTestId } = render( + +
modal content
+
, + ); + expect(getByTestId('modal')).toHaveClass('mm-modal test-class'); + }); + + it('should render the modal when isOpen is true', () => { + const { getByText } = render( + +
modal content
+
, + ); + + const modalContent = getByText('modal content'); + expect(modalContent).toBeInTheDocument(); + }); + + it('should not render the modal when isOpen is false', () => { + const { queryByText } = render( + +
modal content
+
, + ); + + const modalContent = queryByText('modal content'); + expect(modalContent).not.toBeInTheDocument(); + }); + + it('should call the onClose callback when clicking the close button', () => { + const { getByText } = render( + +
modal content
+ +
, + ); + + const closeButton = getByText('Close'); + fireEvent.click(closeButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/components/component-library/modal/modal.tsx b/ui/components/component-library/modal/modal.tsx new file mode 100644 index 000000000..8160f1a32 --- /dev/null +++ b/ui/components/component-library/modal/modal.tsx @@ -0,0 +1,52 @@ +import React, { forwardRef, Ref } from 'react'; +import ReactDOM from 'react-dom'; +import classnames from 'classnames'; + +import { ModalProps } from './modal.types'; +import { ModalContext } from './modal.context'; + +export const Modal = forwardRef( + ( + { + className = '', + isOpen, + onClose, + children, + isClosedOnOutsideClick = true, + isClosedOnEscapeKey = true, + autoFocus = true, + initialFocusRef, + finalFocusRef, + restoreFocus, + ...props + }: ModalProps, + ref: Ref, + ) => { + const context = { + isOpen, + onClose, + isClosedOnOutsideClick, + isClosedOnEscapeKey, + autoFocus, + initialFocusRef, + finalFocusRef, + restoreFocus, + }; + return isOpen + ? ReactDOM.createPortal( + +
+ {children} +
+
, + document.body, + ) + : null; + }, +); + +export default Modal; diff --git a/ui/components/component-library/modal/modal.types.ts b/ui/components/component-library/modal/modal.types.ts new file mode 100644 index 000000000..025098352 --- /dev/null +++ b/ui/components/component-library/modal/modal.types.ts @@ -0,0 +1,33 @@ +import type { ModalFocusProps } from '../modal-focus'; + +export interface ModalProps extends ModalFocusProps { + /** + * If the modal is open or not + */ + isOpen: boolean; + /** + * Fires when the modal is closed + */ + onClose: () => void; + /** + * The elements to be rendered inside the modal: ModalOverlay and ModalContent + */ + children: React.ReactNode; + /** + * Additional className to be applied to the modal + */ + className?: string; + /** + * isClosedOnOutsideClick enables the ability to close the modal when the user clicks outside of the modal + * + * @default true + */ + isClosedOnOutsideClick?: boolean; + /** + * closeOnEscape enables the ability to close the modal when the user presses the escape key + * If this is disabled there should be a close button in the modal or allow keyboard only users to close the modal with a button that is accessible via the tab key + * + * @default true + */ + isClosedOnEscapeKey?: boolean; +}