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:
parent
2326195324
commit
9d38e537fc
@ -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';
|
||||
|
@ -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>
|
||||
```
|
||||
|
@ -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>
|
||||
`;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -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>;
|
||||
}
|
||||
|
239
ui/components/component-library/modal/README.mdx
Normal file
239
ui/components/component-library/modal/README.mdx
Normal 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"
|
@ -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>
|
||||
`;
|
3
ui/components/component-library/modal/index.ts
Normal file
3
ui/components/component-library/modal/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { Modal } from './modal';
|
||||
export type { ModalProps } from './modal.types';
|
||||
export { useModalContext } from './modal.context';
|
19
ui/components/component-library/modal/modal.context.ts
Normal file
19
ui/components/component-library/modal/modal.context.ts
Normal 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;
|
||||
};
|
304
ui/components/component-library/modal/modal.stories.tsx
Normal file
304
ui/components/component-library/modal/modal.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
82
ui/components/component-library/modal/modal.test.tsx
Normal file
82
ui/components/component-library/modal/modal.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
52
ui/components/component-library/modal/modal.tsx
Normal file
52
ui/components/component-library/modal/modal.tsx
Normal 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;
|
33
ui/components/component-library/modal/modal.types.ts
Normal file
33
ui/components/component-library/modal/modal.types.ts
Normal 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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user