1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 09:57:02 +01:00

Adding Modal and updating ModalContent component (#19020)

This commit is contained in:
George Marshall 2023-05-19 13:20:15 -07:00 committed by GitHub
parent 2326195324
commit 9d38e537fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1222 additions and 248 deletions

View File

@ -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';

View File

@ -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.
<Canvas>
<Story id="components-componentlibrary-modalcontent--default-story" />
@ -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.
<Canvas>
<Story id="components-componentlibrary-modalcontent--children" />
</Canvas>
```jsx
import { ModalContent, Text } from '../../component-library';
import React, { useState } from 'react';
import { Modal, ModalContent, ModalHeader, Text, Button, BUTTON_VARIANT } from '../../component-library';
<ModalContent>
<ModalHeader />
<Text>
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.
</Text>
</ModalContent>;
const [show, setShow] = useState(false);
const handleOnClick = () => {
setShow(!show);
};
<Button variant={BUTTON_VARIANT.PRIMARY} onClick={handleOnClick}>
Open
</Button>
<Modal isOpen={show} onClose={handleOnClick}>
<ModalContent {...args}>
<ModalHeader marginBottom={4}>Modal Header</ModalHeader>
<Text marginBottom={4}>Modal Content</Text>
<Button variant={BUTTON_VARIANT.PRIMARY} onClick={handleOnClick}>
Close
</Button>
<LoremIpsum />
<LoremIpsum />
<LoremIpsum />
<LoremIpsum />
<LoremIpsum />
</ModalContent>
</Modal>
```
### 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.
<Canvas>
<Story id="components-componentlibrary-modalcontent--size" />
</Canvas>
```jsx
import { BLOCK_SIZES } from '../../../helpers/constants/design-system';
import { ModalContent,s Text } from '../../component-library';
import { DISPLAY } from '../../../helpers/constants/design-system';
<ModalContent {...args} marginBottom={4}>
<Text>ModalContentSize.Sm default and only size 360px max-width</Text>
</ModalContent>
<ModalContent
{...args}
width={[
BLOCK_SIZES.FULL,
BLOCK_SIZES.THREE_FOURTHS,
BLOCK_SIZES.HALF,
BLOCK_SIZES.ONE_THIRD,
]}
marginBottom={4}
>
<Text>
Using width Box props and responsive array props <br /> [
BLOCK_SIZES.FULL, BLOCK_SIZES.THREE_FOURTHS, BLOCK_SIZES.HALF,
BLOCK_SIZES.ONE_THIRD, ]
</Text>
</ModalContent>
<ModalContent {...args} marginBottom={4} style={{ maxWidth: 800 }}>
Adding a className and setting a max width (max-width: 800px)
</ModalContent>
```
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',
}
<Canvas>
<Story id="components-componentlibrary-modalcontent--modal-content-ref" />
</Canvas>
```jsx
import React, { useEffect, useRef, useState } from 'react';
import { ModalContent, Text } from '../../component-library';
const [show, setShow] = useState(false);
const modalContentRef = useRef<HTMLDivElement>(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);
};
}, []);
<Box display={DISPLAY.FLEX} gap={4}>
<Button
variant={BUTTON_VARIANT.SECONDARY}
onClick={() => handleOnClick(ModalContentSizeStoryOption.Sm)}
>
Show sm size
</Button>
<Button
variant={BUTTON_VARIANT.SECONDARY}
onClick={() => handleOnClick(ModalContentSizeStoryOption.ClassName)}
>
Show className
</Button>
</Box>
<Button onClick={() => setShow(true)}>Show ModalContent</Button>
{show && (
<ModalContent modalContentRef={modalContentRef}>
Click outside of this ModalContent to close
<Modal
isOpen={show.sm}
onClose={() => handleOnClick(ModalContentSizeStoryOption.Sm)}
>
<ModalContent {...args}>
<Text marginBottom={4}>
ModalContentSize.Sm default and only size 360px max-width
</Text>
<Button onClick={() => setShow({ ...show, sm: false })}>Close</Button>
</ModalContent>
)}
</Modal>
<Modal
isOpen={show.className}
onClose={() => handleOnClick(ModalContentSizeStoryOption.ClassName)}
>
<ModalContent
{...args}
modalDialogProps={{
style: { maxWidth: 800 },
}}
>
<Text marginBottom={4}>
Using modalDialogProps and adding a className setting a max width
(max-width: 800px)
</Text>
<Button onClick={() => setShow({ ...show, className: false })}>
Close
</Button>
</ModalContent>
</Modal>
```

View File

@ -1,11 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ModalContent should match snapshot 1`] = `
<div>
<div
class="box mm-modal-content mm-modal-content--size-sm box--padding-4 box--flex-direction-row box--width-full box--background-color-background-default box--rounded-lg"
<div
class="box mm-modal-content box--padding-4 box--display-flex box--flex-direction-row box--justify-content-center box--align-items-flex-start box--width-screen box--height-screen"
data-testid="test"
>
<section
aria-modal="true"
class="box mm-modal-content__dialog mm-modal-content__dialog--size-sm box--margin-top-12 box--margin-bottom-12 box--padding-4 box--flex-direction-row box--width-full box--background-color-background-default box--rounded-lg"
role="dialog"
>
test
</div>
</section>
</div>
`;

View File

@ -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);
}
}

View File

@ -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<typeof ModalContent>;
const Template: ComponentStory<typeof ModalContent> = (args) => (
<ModalContent {...args} />
const LoremIpsum = () => (
<Text marginBottom={4}>
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
</Text>
);
export const DefaultStory = Template.bind({});
DefaultStory.storyName = 'Default';
/*
* !!TODO: Replace with ModalHeader component
*/
const ModalHeader = () => (
<>
<Box
className="mm-modal-header"
display={DISPLAY.FLEX}
justifyContent={JustifyContent.spaceBetween}
alignItems={AlignItems.flexStart}
width={BLOCK_SIZES.FULL}
marginBottom={4}
>
<button>Back</button>
<Text variant={TextVariant.headingSm} textAlign={TEXT_ALIGN.CENTER}>
Modal Header
</Text>
<button>Close</button>
</Box>
</>
);
export const Children: ComponentStory<typeof ModalContent> = (args) => (
<ModalContent {...args}>
<ModalHeader />
<Text>
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.
</Text>
</ModalContent>
);
export const Size: ComponentStory<typeof ModalContent> = (args) => (
<>
<ModalContent {...args} marginBottom={4}>
<Text>ModalContentSize.Sm default and only size 360px max-width</Text>
</ModalContent>
<ModalContent
{...args}
width={[
BLOCK_SIZES.FULL,
BLOCK_SIZES.THREE_FOURTHS,
BLOCK_SIZES.HALF,
BLOCK_SIZES.ONE_THIRD,
]}
marginBottom={4}
>
<Text>
Using width Box props and responsive array props <br /> [
BLOCK_SIZES.FULL, BLOCK_SIZES.THREE_FOURTHS, BLOCK_SIZES.HALF,
BLOCK_SIZES.ONE_THIRD, ]
</Text>
</ModalContent>
<ModalContent {...args} marginBottom={4} style={{ maxWidth: 800 }}>
Adding a className and setting a max width (max-width: 800px)
</ModalContent>
</>
);
export const ModalContentRef: ComponentStory<typeof ModalContent> = (args) => {
export const DefaultStory: ComponentStory<typeof ModalContent> = (args) => {
const [show, setShow] = useState(false);
const modalContentRef = useRef<HTMLDivElement>(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 (
<>
<Button onClick={() => setShow(true)}>Show ModalContent</Button>
{show && (
<ModalContent {...args} modalContentRef={modalContentRef}>
Click outside of this ModalContent to close
<Button variant={BUTTON_VARIANT.PRIMARY} onClick={handleOnClick}>
Open
</Button>
<Modal isOpen={show} onClose={handleOnClick}>
<ModalContent {...args}>
<ModalHeader marginBottom={4}>Modal Header</ModalHeader>
<Text marginBottom={4}>Modal Content</Text>
<Button variant={BUTTON_VARIANT.PRIMARY} onClick={handleOnClick}>
Close
</Button>
</ModalContent>
)}
</Modal>
</>
);
};
DefaultStory.storyName = 'Default';
export const Children: ComponentStory<typeof ModalContent> = (args) => {
const [show, setShow] = useState(false);
const handleOnClick = () => {
setShow(!show);
};
return (
<>
<Button variant={BUTTON_VARIANT.PRIMARY} onClick={handleOnClick}>
Open
</Button>
<Modal isOpen={show} onClose={handleOnClick}>
<ModalContent {...args}>
<ModalHeader marginBottom={4}>Modal Header</ModalHeader>
<Text marginBottom={4}>
The ModalContent with ModalHeader and Text components as children
</Text>
<Button
marginBottom={4}
variant={BUTTON_VARIANT.PRIMARY}
onClick={handleOnClick}
>
Close
</Button>
<LoremIpsum />
<LoremIpsum />
<LoremIpsum />
<LoremIpsum />
<LoremIpsum />
</ModalContent>
</Modal>
</>
);
};
enum ModalContentSizeStoryOption {
Sm = 'sm',
ClassName = 'className',
}
export const Size: ComponentStory<typeof ModalContent> = (args) => {
const [show, setShow] = useState({
sm: false,
className: false,
});
const handleOnClick = (size: ModalContentSizeStoryOption) => {
setShow({ ...show, [size]: !show[size] });
};
return (
<>
<Box display={DISPLAY.FLEX} gap={4}>
<Button
variant={BUTTON_VARIANT.SECONDARY}
onClick={() => handleOnClick(ModalContentSizeStoryOption.Sm)}
>
Show sm size
</Button>
<Button
variant={BUTTON_VARIANT.SECONDARY}
onClick={() => handleOnClick(ModalContentSizeStoryOption.ClassName)}
>
Show className
</Button>
</Box>
<Modal
isOpen={show.sm}
onClose={() => handleOnClick(ModalContentSizeStoryOption.Sm)}
>
<ModalContent {...args}>
<Text marginBottom={4}>
ModalContentSize.Sm default and only size 360px max-width
</Text>
<Button onClick={() => setShow({ ...show, sm: false })}>Close</Button>
</ModalContent>
</Modal>
<Modal
isOpen={show.className}
onClose={() => handleOnClick(ModalContentSizeStoryOption.ClassName)}
>
<ModalContent
{...args}
modalDialogProps={{
style: { maxWidth: 800 },
}}
>
<Text marginBottom={4}>
Using modalDialogProps and adding a className setting a max width
(max-width: 800px)
</Text>
<Button onClick={() => setShow({ ...show, className: false })}>
Close
</Button>
</ModalContent>
</Modal>
</>
);
};

View File

@ -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(<ModalContent>test</ModalContent>);
const { getByText, getByTestId } = render(
<Modal isOpen onClose={onClose}>
<ModalContent data-testid="modal-content">test</ModalContent>
</Modal>,
);
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(<ModalContent>test</ModalContent>);
expect(container).toMatchSnapshot();
const { getByTestId } = render(
<Modal isOpen onClose={onClose}>
<ModalContent data-testid="test">test</ModalContent>
</Modal>,
);
expect(getByTestId('test')).toMatchSnapshot();
});
it('should render with and additional className', () => {
const { getByText } = render(
<ModalContent className="test-class">test</ModalContent>,
const { getByTestId } = render(
<Modal isOpen onClose={onClose}>
<ModalContent data-testid="test" className="test-class">
test
</ModalContent>
,
</Modal>,
);
expect(getByText('test')).toHaveClass('test-class');
expect(getByTestId('test')).toHaveClass('test-class');
});
it('should render with size sm', () => {
const { getByText } = render(
<>
<ModalContent>default</ModalContent>
<ModalContent size={ModalContentSize.Sm}>sm</ModalContent>
<Modal isOpen onClose={onClose}>
<ModalContent>default</ModalContent>
<ModalContent size={ModalContentSize.Sm}>sm</ModalContent>
</Modal>
</>,
);
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<HTMLDivElement>();
render(<ModalContent modalContentRef={ref}>test</ModalContent>);
expect(ref.current).toBeDefined();
it('should render with additional props being passed to modalDialogProps', () => {
const { getByTestId } = render(
<Modal isOpen onClose={onClose}>
<ModalContent
modalDialogProps={{ 'data-testid': 'test' }}
data-testid="modal-content"
>
test
</ModalContent>
</Modal>,
);
expect(getByTestId('test')).toBeDefined();
});
it('should close when escape key is pressed', () => {
const { getByRole } = render(
<Modal isOpen={true} onClose={onClose}>
<ModalContent>modal dialog</ModalContent>
</Modal>,
);
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 isOpen={true} onClose={onClose} isClosedOnEscapeKey={false}>
<ModalContent>modal dialog</ModalContent>
</Modal>,
);
fireEvent.keyDown(getByRole('dialog'), { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
});
it('should close when clicked outside', () => {
const { getByRole } = render(
<Modal isOpen={true} onClose={onClose}>
<ModalContent data-testid="modal-dialog">modal dialog</ModalContent>
</Modal>,
);
// 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<HTMLDivElement> = React.createRef();
const { getByTestId } = render(
<Modal isOpen={true} onClose={onClose} isClosedOnOutsideClick={false}>
<ModalContent data-testid="modal-dialog" ref={ref}>
modal dialog
</ModalContent>
</Modal>,
);
// 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<HTMLInputElement> = React.createRef();
const { getByTestId } = render(
<Modal isOpen={true} onClose={onClose} initialFocusRef={initialRef}>
<ModalContent>
<button>modal dialog</button>
<input data-testid="input" ref={initialRef} />
</ModalContent>
</Modal>,
);
expect(getByTestId('input')).toHaveFocus();
});
it('should focus final focus ref when modal is closed', () => {
const finalRef: React.RefObject<HTMLButtonElement> = React.createRef();
const { rerender } = render(
<>
<button ref={finalRef}>button</button>
<Modal isOpen={true} onClose={onClose} finalFocusRef={finalRef}>
<ModalContent data-testid="modal-dialog">modal dialog</ModalContent>
</Modal>
</>,
);
rerender(
<>
<button ref={finalRef}>button</button>
<Modal isOpen={false} onClose={onClose} finalFocusRef={finalRef}>
<ModalContent data-testid="modal-dialog">modal dialog</ModalContent>
</Modal>
</>,
);
expect(finalRef.current).toHaveFocus();
});
});

View File

@ -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) => (
<Box
className={classnames(
'mm-modal-content',
{ [`mm-modal-content--size-${size}`]: !width },
className,
)}
backgroundColor={BackgroundColor.backgroundDefault}
borderRadius={BorderRadius.LG}
width={width || BLOCK_SIZES.FULL}
padding={4}
ref={modalContentRef}
{...props}
>
{children}
</Box>
export const ModalContent = forwardRef(
(
{
className = '',
children,
size = ModalContentSize.Sm,
modalDialogProps,
...props
}: ModalContentProps,
ref: React.Ref<HTMLElement>,
) => {
const {
onClose,
isClosedOnEscapeKey,
isClosedOnOutsideClick,
initialFocusRef,
finalFocusRef,
restoreFocus,
autoFocus,
} = useModalContext();
const modalDialogRef = useRef<HTMLElement>(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 (
<ModalFocus
initialFocusRef={initialFocusRef}
finalFocusRef={finalFocusRef}
restoreFocus={restoreFocus}
autoFocus={autoFocus}
>
<Box
className={classnames('mm-modal-content', className)}
ref={ref}
display={DISPLAY.FLEX}
width={BLOCK_SIZES.SCREEN}
height={BLOCK_SIZES.SCREEN}
justifyContent={JustifyContent.center}
alignItems={AlignItems.flexStart}
padding={4}
{...props}
>
<Box
className={classnames(
'mm-modal-content__dialog',
`mm-modal-content__dialog--size-${size}`,
)}
as="section"
role="dialog"
aria-modal="true"
backgroundColor={BackgroundColor.backgroundDefault}
borderRadius={BorderRadius.LG}
width={BLOCK_SIZES.FULL}
marginTop={12}
marginBottom={12}
padding={4}
ref={modalDialogRef}
{...modalDialogProps}
>
{children}
</Box>
</Box>
</ModalFocus>
);
},
);

View File

@ -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<HTMLElement>;
modalDialogProps?: BoxProps | React.HTMLAttributes<HTMLElement>;
}

View File

@ -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.
<Canvas>
<Story id="components-componentlibrary-modal--default-story" />
</Canvas>
## Props
The `Modal` accepts all props below as well as all [Box](/docs/components-ui-box--default-story#props) component props
<ArgsTable of={Modal} />
### 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`.
<Canvas>
<Story id="components-componentlibrary-modal--usage" />
</Canvas>
```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);
};
<Button onClick={handleOnClick}>OpenModal</Button>
<Modal
isOpen={open}
onClose={handleOnClose}
>
<ModalOverlay />
<ModalContent>
<ModalHeader onClose={handleOnClose} onBack={handleOnClose}>
Modal Header
</ModalHeader>
<Text>Children</Text>
</ModalContent>
</Modal>
```
### 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`.
<Canvas>
<Story id="components-componentlibrary-modal--is-closed-on-outside-click" />
</Canvas>
```jsx
import { Modal } from '../../component-library';
<Modal isClosedOnOutsideClick={false} />;
```
### 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`.
<Canvas>
<Story id="components-componentlibrary-modal--is-closed-on-escape-key" />
</Canvas>
```jsx
import { Modal } from '../../component-library';
<Modal isClosedOnEscapeKey={false} />;
```
### 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.
<Canvas>
<Story id="components-componentlibrary-modal--initial-focus-ref" />
</Canvas>
```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<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const handleOnClick = () => {
setOpen(true);
};
const handleOnClose = () => {
setOpen(false);
};
<Button onClick={handleOnClick}>Open modal</Button>
<Modal
isOpen={isOpen}
onClose={handleOnClose}
initialFocusRef={inputRef}
>
<ModalOverlay />
<ModalContent >
<ModalHeader
onClose={handleOnClose}
onBack={handleOnClose}
marginBottom={4}
>
Modal Header
</ModalHeader>
<TextFieldSearch
placeholder="Search"
inputProps={{ ref: inputRef }}
width={BLOCK_SIZES.FULL}
/>
</ModalContent>
</Modal>
```
### Final Focus Ref
Use the `finalFocusRef` to set the `ref` of the element to receive focus when the modal closes.
<Canvas>
<Story id="components-componentlibrary-modal--final-focus-ref" />
</Canvas>
```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<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const handleOnClick = () => {
setOpen(true);
};
const handleOnClose = () => {
setOpen(false);
};
<Button onClick={handleOnClick} marginRight={4}>
Open modal
</Button>
<button ref={buttonRef}>Receives focus after close</button>
<Modal
isOpen={isOpen}
onClose={handleOnClose}
finalFocusRef={buttonRef}
>
<ModalOverlay />
<ModalContent >
<ModalHeader
onClose={handleOnClose}
onBack={handleOnClose}
marginBottom={4}
>
Modal Header
</ModalHeader>
<Text>{args.children}</Text>
</ModalContent>
</Modal>
```
### Restore Focus
Use the `restoreFocus` prop to restore focus to the element that triggered the `Modal` once it unmounts
Defaults to `false`
<Canvas>
<Story id="components-componentlibrary-modal--restore-focus" />
</Canvas>
```jsx
import { Modal } from '../../component-library';
<Modal restoreFocus={true} />;
```
### 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`
<Canvas>
<Story id="components-componentlibrary-modal--auto-focus" />
</Canvas>
```jsx
import { Modal } from '../../component-library';
<Modal autoFocus={false} />;
```
## 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"

View File

@ -0,0 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Modal should match snapshot 1`] = `
<div
class="mm-modal"
data-testid="test"
>
<div>
modal content
</div>
</div>
`;

View File

@ -0,0 +1,3 @@
export { Modal } from './modal';
export type { ModalProps } from './modal.types';
export { useModalContext } from './modal.context';

View File

@ -0,0 +1,19 @@
import { createContext, useContext } from 'react';
import type { ModalProps } from './modal.types';
export type ModalContextType = Omit<ModalProps, 'children'>;
export const ModalContext = createContext<ModalContextType | undefined>(
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 "<Modal />"',
);
}
return context;
};

View File

@ -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: (
<Text paddingTop={4}>ModalContent children after ModalHeader</Text>
),
},
} as ComponentMeta<typeof Modal>;
const LoremIpsum = (props) => (
<Text marginBottom={8} {...props}>
Lorem ipsum dolor sit amet, conse{' '}
<ButtonLink size={BUTTON_LINK_SIZES.INHERIT}>
random focusable button
</ButtonLink>{' '}
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
</Text>
);
const Template: ComponentStory<typeof Modal> = (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 (
<Box width={BLOCK_SIZES.FULL} style={{ maxWidth: '700px' }}>
<Box display={DISPLAY.FLEX} gap={4}>
<Button onClick={handleOnClick}>Open modal</Button>
<ButtonLink
endIconName={showLoremIpsum ? IconName.Arrow2Up : IconName.Arrow2Down}
onClick={handleHideLoremIpsum}
>
{showLoremIpsum ? 'Hide' : 'Show'} scrollable content
</ButtonLink>
</Box>
<Modal {...args} isOpen={isOpen} onClose={handleOnClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader onClose={handleOnClose}>Modal Header</ModalHeader>
{args.children}
<Text>Show more content to check scrolling</Text>
<ButtonLink
endIconName={
showLoremIpsum ? IconName.Arrow2Up : IconName.Arrow2Down
}
onClick={handleMoreContent}
size={BUTTON_LINK_SIZES.INHERIT}
marginBottom={2}
>
{showMoreModalContent ? 'Hide' : 'Show more'}
</ButtonLink>
{showMoreModalContent && (
<>
<LoremIpsum marginTop={8} />
<LoremIpsum />
<LoremIpsum />
<LoremIpsum />
<LoremIpsum />
<LoremIpsum />
</>
)}
</ModalContent>
</Modal>
{showLoremIpsum && (
<>
<LoremIpsum marginTop={8} />
<LoremIpsum />
<LoremIpsum />
<LoremIpsum />
<LoremIpsum />
<LoremIpsum />
</>
)}
</Box>
);
};
export const DefaultStory = Template.bind({});
DefaultStory.storyName = 'Default';
export const Usage = Template.bind({});
export const IsClosedOnOutsideClick = Template.bind({});
IsClosedOnOutsideClick.args = {
isClosedOnOutsideClick: false,
children: (
<Text paddingTop={4}>
This Modal has set isClosedOnOutsideClick: false. Clicking outside this
Modal <strong>WILL NOT</strong> close it
</Text>
),
};
export const IsClosedOnEscapeKey = Template.bind({});
IsClosedOnEscapeKey.args = {
isClosedOnEscapeKey: false,
children: (
<Text paddingTop={4}>
This Modal has set isClosedOnEscapeKey: false. Pressing the ESC key{' '}
<strong>WILL NOT</strong> close it
</Text>
),
};
export const InitialFocusRef: ComponentStory<typeof Modal> = (args) => {
const inputRef = React.useRef<HTMLDivElement>(null);
const [{ isOpen }, updateArgs] = useArgs();
const handleOnClick = () => {
updateArgs({ isOpen: true });
};
const handleOnClose = () => {
updateArgs({ isOpen: false });
};
return (
<>
<Button onClick={handleOnClick}>Open modal</Button>
<Modal
{...args}
isOpen={isOpen}
onClose={handleOnClose}
initialFocusRef={inputRef}
>
<ModalOverlay />
<ModalContent>
<ModalHeader
onClose={handleOnClose}
onBack={handleOnClose}
marginBottom={4}
>
Modal Header
</ModalHeader>
<TextFieldSearch
placeholder="Search"
inputProps={{ ref: inputRef }}
width={BLOCK_SIZES.FULL}
/>
{args.children}
</ModalContent>
</Modal>
</>
);
};
InitialFocusRef.args = {
children: (
<Text paddingTop={4}>
This Modal has set initialFocusRef to the TextFieldSearch component. When
the Modal opens, the TextFieldSearch component will be focused.
</Text>
),
};
export const FinalFocusRef: ComponentStory<typeof Modal> = (args) => {
const buttonRef = React.useRef<HTMLButtonElement>(null);
const [{ isOpen }, updateArgs] = useArgs();
const handleOnClick = () => {
updateArgs({ isOpen: true });
};
const handleOnClose = () => {
updateArgs({ isOpen: false });
};
return (
<>
<Button onClick={handleOnClick} marginRight={4}>
Open modal
</Button>
<button ref={buttonRef}>Receives focus after close</button>
<Modal
{...args}
isOpen={isOpen}
onClose={handleOnClose}
finalFocusRef={buttonRef}
>
<ModalOverlay />
<ModalContent>
<ModalHeader
onClose={handleOnClose}
onBack={handleOnClose}
marginBottom={4}
>
Modal Header
</ModalHeader>
<Text>{args.children}</Text>
</ModalContent>
</Modal>
</>
);
};
FinalFocusRef.args = {
children: (
<Text paddingTop={4}>
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.
</Text>
),
};
export const RestoreFocus = Template.bind({});
RestoreFocus.args = {
restoreFocus: true,
children: (
<Text paddingTop={4}>
This Modal has set restoreFocus: true. When the Modal closes, the Button
component will be focused. Use keyboard navigation to see it clearly.
</Text>
),
};
export const AutoFocus = Template.bind({});
AutoFocus.args = {
autoFocus: false,
children: (
<Text paddingTop={4}>
This Modal has set autoFocus: false. When the Modal opens the first
element to focus <strong>WILL NOT</strong> be the first focusable element
in the Modal.
</Text>
),
};

View File

@ -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 onClose={onClose} isOpen data-testid="modal">
<div>modal content</div>
</Modal>,
);
expect(getByText('modal content')).toBeDefined();
expect(getByTestId('modal')).toHaveClass('mm-modal');
});
it('should match snapshot', () => {
const { getByTestId } = render(
<Modal onClose={onClose} isOpen={true} data-testid="test">
<div>modal content</div>
</Modal>,
);
expect(getByTestId('test')).toMatchSnapshot();
});
it('should render with and additional className', () => {
const { getByTestId } = render(
<Modal
onClose={onClose}
isOpen
className="test-class"
data-testid="modal"
>
<div>modal content</div>
</Modal>,
);
expect(getByTestId('modal')).toHaveClass('mm-modal test-class');
});
it('should render the modal when isOpen is true', () => {
const { getByText } = render(
<Modal isOpen={true} onClose={onClose}>
<div>modal content</div>
</Modal>,
);
const modalContent = getByText('modal content');
expect(modalContent).toBeInTheDocument();
});
it('should not render the modal when isOpen is false', () => {
const { queryByText } = render(
<Modal isOpen={false} onClose={onClose}>
<div>modal content</div>
</Modal>,
);
const modalContent = queryByText('modal content');
expect(modalContent).not.toBeInTheDocument();
});
it('should call the onClose callback when clicking the close button', () => {
const { getByText } = render(
<Modal isOpen={true} onClose={onClose}>
<div>modal content</div>
<button onClick={() => onClose()}>Close</button>
</Modal>,
);
const closeButton = getByText('Close');
fireEvent.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@ -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<HTMLDivElement>,
) => {
const context = {
isOpen,
onClose,
isClosedOnOutsideClick,
isClosedOnEscapeKey,
autoFocus,
initialFocusRef,
finalFocusRef,
restoreFocus,
};
return isOpen
? ReactDOM.createPortal(
<ModalContext.Provider value={context}>
<div
className={classnames('mm-modal', className)}
ref={ref}
{...props}
>
{children}
</div>
</ModalContext.Provider>,
document.body,
)
: null;
},
);
export default Modal;

View File

@ -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;
}