mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-12-23 09:52:26 +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 { ModalContent, ModalContentSize } from './modal-content';
|
||||||
export { ModalOverlay } from './modal-overlay';
|
export { ModalOverlay } from './modal-overlay';
|
||||||
export { ModalFocus } from './modal-focus';
|
export { ModalFocus } from './modal-focus';
|
||||||
|
export { Modal, useModalContext } from './modal';
|
||||||
|
|
||||||
// Molecules
|
// Molecules
|
||||||
export { BannerBase } from './banner-base';
|
export { BannerBase } from './banner-base';
|
||||||
|
@ -4,7 +4,7 @@ import { ModalContent } from './modal-content';
|
|||||||
|
|
||||||
# ModalContent
|
# 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>
|
<Canvas>
|
||||||
<Story id="components-componentlibrary-modalcontent--default-story" />
|
<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
|
### 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>
|
<Canvas>
|
||||||
<Story id="components-componentlibrary-modalcontent--children" />
|
<Story id="components-componentlibrary-modalcontent--children" />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
import { ModalContent, Text } from '../../component-library';
|
import React, { useState } from 'react';
|
||||||
|
import { Modal, ModalContent, ModalHeader, Text, Button, BUTTON_VARIANT } from '../../component-library';
|
||||||
|
|
||||||
<ModalContent>
|
const [show, setShow] = useState(false);
|
||||||
<ModalHeader />
|
const handleOnClick = () => {
|
||||||
<Text>
|
setShow(!show);
|
||||||
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.
|
<Button variant={BUTTON_VARIANT.PRIMARY} onClick={handleOnClick}>
|
||||||
</Text>
|
Open
|
||||||
</ModalContent>;
|
</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
|
### 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.
|
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>
|
<Canvas>
|
||||||
<Story id="components-componentlibrary-modalcontent--size" />
|
<Story id="components-componentlibrary-modalcontent--size" />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
import { BLOCK_SIZES } from '../../../helpers/constants/design-system';
|
import { DISPLAY } from '../../../helpers/constants/design-system';
|
||||||
import { ModalContent,s Text } from '../../component-library';
|
|
||||||
|
|
||||||
<ModalContent {...args} marginBottom={4}>
|
import Box from '../../ui/box';
|
||||||
<Text>ModalContentSize.Sm default and only size 360px max-width</Text>
|
|
||||||
|
import { Modal, ModalContent, Text, Button, BUTTON_VARIANT } from '../../component-library';
|
||||||
|
|
||||||
|
enum ModalContentSizeStoryOption {
|
||||||
|
Sm = 'sm',
|
||||||
|
ClassName = 'className',
|
||||||
|
}
|
||||||
|
|
||||||
|
const [show, setShow] = useState({
|
||||||
|
sm: false,
|
||||||
|
className: false,
|
||||||
|
});
|
||||||
|
const handleOnClick = (size: ModalContentSizeStoryOption) => {
|
||||||
|
setShow({ ...show, [size]: !show[size] });
|
||||||
|
};
|
||||||
|
|
||||||
|
<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>
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={show.className}
|
||||||
|
onClose={() => handleOnClick(ModalContentSizeStoryOption.ClassName)}
|
||||||
|
>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
{...args}
|
{...args}
|
||||||
width={[
|
modalDialogProps={{
|
||||||
BLOCK_SIZES.FULL,
|
style: { maxWidth: 800 },
|
||||||
BLOCK_SIZES.THREE_FOURTHS,
|
}}
|
||||||
BLOCK_SIZES.HALF,
|
|
||||||
BLOCK_SIZES.ONE_THIRD,
|
|
||||||
]}
|
|
||||||
marginBottom={4}
|
|
||||||
>
|
>
|
||||||
<Text>
|
<Text marginBottom={4}>
|
||||||
Using width Box props and responsive array props <br /> [
|
Using modalDialogProps and adding a className setting a max width
|
||||||
BLOCK_SIZES.FULL, BLOCK_SIZES.THREE_FOURTHS, BLOCK_SIZES.HALF,
|
(max-width: 800px)
|
||||||
BLOCK_SIZES.ONE_THIRD, ]
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Button onClick={() => setShow({ ...show, className: false })}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
<ModalContent {...args} marginBottom={4} style={{ maxWidth: 800 }}>
|
</Modal>
|
||||||
Adding a className and setting a max width (max-width: 800px)
|
|
||||||
</ModalContent>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modal Content Ref
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
<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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
<Button onClick={() => setShow(true)}>Show ModalContent</Button>
|
|
||||||
{show && (
|
|
||||||
<ModalContent modalContentRef={modalContentRef}>
|
|
||||||
Click outside of this ModalContent to close
|
|
||||||
</ModalContent>
|
|
||||||
)}
|
|
||||||
```
|
```
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`ModalContent should match snapshot 1`] = `
|
exports[`ModalContent should match snapshot 1`] = `
|
||||||
<div>
|
|
||||||
<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"
|
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
|
test
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -1,7 +1,15 @@
|
|||||||
.mm-modal-content {
|
.mm-modal-content {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: $modal-z-index;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
|
||||||
|
&__dialog {
|
||||||
--modal-content-size: var(--size, 360px);
|
--modal-content-size: var(--size, 360px);
|
||||||
|
|
||||||
// Currently there is only use case for one size of ModalContent in the extension
|
// 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
|
// 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
|
// 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-sm {
|
||||||
@ -12,6 +20,5 @@
|
|||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: var(--shadow-size-lg) var(--color-shadow-default);
|
box-shadow: var(--shadow-size-lg) var(--color-shadow-default);
|
||||||
max-height: calc(100% - 32px); // allow for 16px padding on top and bottom
|
}
|
||||||
overflow: auto;
|
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,11 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ComponentStory, ComponentMeta } from '@storybook/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 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 { ModalContent } from './modal-content';
|
||||||
import { ModalContentSize } from './modal-content.types';
|
import { ModalContentSize } from './modal-content.types';
|
||||||
@ -28,117 +21,147 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
className: {
|
|
||||||
control: 'text',
|
|
||||||
},
|
|
||||||
children: {
|
|
||||||
control: 'text',
|
|
||||||
},
|
|
||||||
size: {
|
size: {
|
||||||
control: 'select',
|
control: 'select',
|
||||||
options: Object.values(ModalContentSize).map((value) =>
|
options: Object.values(ModalContentSize),
|
||||||
value.toLowerCase(),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
args: {
|
|
||||||
children: 'Modal Content',
|
|
||||||
},
|
|
||||||
} as ComponentMeta<typeof ModalContent>;
|
} as ComponentMeta<typeof ModalContent>;
|
||||||
|
|
||||||
const Template: ComponentStory<typeof ModalContent> = (args) => (
|
const LoremIpsum = () => (
|
||||||
<ModalContent {...args} />
|
<Text marginBottom={4}>
|
||||||
);
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam euismod
|
||||||
|
tortor vitae nisi blandit, eu aliquam nisl ultricies. Donec euismod
|
||||||
export const DefaultStory = Template.bind({});
|
scelerisque nisl, sit amet aliquet nunc. Donec euismod, nisl vitae
|
||||||
DefaultStory.storyName = 'Default';
|
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
|
||||||
* !!TODO: Replace with ModalHeader component
|
consectetur aliquam, nunc nunc ultricies nunc, eget aliquam nisl nisl vitae
|
||||||
*/
|
nunc. Donec euismod, nisl vitae consectetur aliquam, nunc
|
||||||
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>
|
</Text>
|
||||||
<button>Close</button>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Children: ComponentStory<typeof ModalContent> = (args) => (
|
export const DefaultStory: 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) => {
|
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const modalContentRef = useRef<HTMLDivElement>(null);
|
const handleOnClick = () => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
setShow(!show);
|
||||||
if (
|
|
||||||
modalContentRef?.current &&
|
|
||||||
!modalContentRef.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setShow(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => setShow(true)}>Show ModalContent</Button>
|
<Button variant={BUTTON_VARIANT.PRIMARY} onClick={handleOnClick}>
|
||||||
{show && (
|
Open
|
||||||
<ModalContent {...args} modalContentRef={modalContentRef}>
|
</Button>
|
||||||
Click outside of this ModalContent to close
|
<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>
|
</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 */
|
/* 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 React from 'react';
|
||||||
|
import { Modal } from '..';
|
||||||
import { ModalContent } from './modal-content';
|
import { ModalContent } from './modal-content';
|
||||||
import { ModalContentSize } from './modal-content.types';
|
import { ModalContentSize } from './modal-content.types';
|
||||||
|
|
||||||
describe('ModalContent', () => {
|
describe('ModalContent', () => {
|
||||||
|
const onClose = jest.fn();
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
onClose.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
it('should render with text inside the ModalContent', () => {
|
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')).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', () => {
|
it('should match snapshot', () => {
|
||||||
const { container } = render(<ModalContent>test</ModalContent>);
|
const { getByTestId } = render(
|
||||||
expect(container).toMatchSnapshot();
|
<Modal isOpen onClose={onClose}>
|
||||||
|
<ModalContent data-testid="test">test</ModalContent>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
expect(getByTestId('test')).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
it('should render with and additional className', () => {
|
it('should render with and additional className', () => {
|
||||||
const { getByText } = render(
|
const { getByTestId } = render(
|
||||||
<ModalContent className="test-class">test</ModalContent>,
|
<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', () => {
|
it('should render with size sm', () => {
|
||||||
const { getByText } = render(
|
const { getByText } = render(
|
||||||
<>
|
<>
|
||||||
|
<Modal isOpen onClose={onClose}>
|
||||||
<ModalContent>default</ModalContent>
|
<ModalContent>default</ModalContent>
|
||||||
<ModalContent size={ModalContentSize.Sm}>sm</ModalContent>
|
<ModalContent size={ModalContentSize.Sm}>sm</ModalContent>
|
||||||
|
</Modal>
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
expect(getByText('sm')).toHaveClass('mm-modal-content--size-sm');
|
expect(getByText('sm')).toHaveClass('mm-modal-content__dialog--size-sm');
|
||||||
expect(getByText('default')).toHaveClass('mm-modal-content--size-sm');
|
expect(getByText('default')).toHaveClass(
|
||||||
|
'mm-modal-content__dialog--size-sm',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
it('should render with a ref', () => {
|
it('should render with additional props being passed to modalDialogProps', () => {
|
||||||
const ref = React.createRef<HTMLDivElement>();
|
const { getByTestId } = render(
|
||||||
render(<ModalContent modalContentRef={ref}>test</ModalContent>);
|
<Modal isOpen onClose={onClose}>
|
||||||
expect(ref.current).toBeDefined();
|
<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 classnames from 'classnames';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BackgroundColor,
|
BackgroundColor,
|
||||||
BorderRadius,
|
BorderRadius,
|
||||||
BLOCK_SIZES,
|
BLOCK_SIZES,
|
||||||
|
DISPLAY,
|
||||||
|
JustifyContent,
|
||||||
|
AlignItems,
|
||||||
} from '../../../helpers/constants/design-system';
|
} from '../../../helpers/constants/design-system';
|
||||||
|
|
||||||
import Box from '../../ui/box/box';
|
import Box from '../../ui/box/box';
|
||||||
|
|
||||||
|
import { ModalFocus, useModalContext } from '..';
|
||||||
|
|
||||||
import { ModalContentProps, ModalContentSize } from './modal-content.types';
|
import { ModalContentProps, ModalContentSize } from './modal-content.types';
|
||||||
|
|
||||||
export const ModalContent = ({
|
export const ModalContent = forwardRef(
|
||||||
|
(
|
||||||
|
{
|
||||||
className = '',
|
className = '',
|
||||||
children,
|
children,
|
||||||
size = ModalContentSize.Sm,
|
size = ModalContentSize.Sm,
|
||||||
width,
|
modalDialogProps,
|
||||||
modalContentRef, // Would have preferred to forwardRef but it's not trivial in TypeScript. Will update once we have an established pattern
|
|
||||||
...props
|
...props
|
||||||
}: ModalContentProps) => (
|
}: 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
|
<Box
|
||||||
className={classnames(
|
className={classnames(
|
||||||
'mm-modal-content',
|
'mm-modal-content__dialog',
|
||||||
{ [`mm-modal-content--size-${size}`]: !width },
|
`mm-modal-content__dialog--size-${size}`,
|
||||||
className,
|
|
||||||
)}
|
)}
|
||||||
|
as="section"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
backgroundColor={BackgroundColor.backgroundDefault}
|
backgroundColor={BackgroundColor.backgroundDefault}
|
||||||
borderRadius={BorderRadius.LG}
|
borderRadius={BorderRadius.LG}
|
||||||
width={width || BLOCK_SIZES.FULL}
|
width={BLOCK_SIZES.FULL}
|
||||||
|
marginTop={12}
|
||||||
|
marginBottom={12}
|
||||||
padding={4}
|
padding={4}
|
||||||
ref={modalContentRef}
|
ref={modalDialogRef}
|
||||||
{...props}
|
{...modalDialogProps}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</ModalFocus>
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { BoxProps, BoxWidth, BoxWidthArray } from '../../ui/box/box.d';
|
import type { BoxProps } from '../../ui/box/box.d';
|
||||||
import { Size } from '../../../helpers/constants/design-system';
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* ModalContent sizes
|
* 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
|
* 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 {
|
export enum ModalContentSize {
|
||||||
Sm = Size.SM,
|
Sm = 'sm',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModalContentProps extends BoxProps {
|
export interface ModalContentProps extends BoxProps {
|
||||||
@ -20,7 +19,7 @@ export interface ModalContentProps extends BoxProps {
|
|||||||
/**
|
/**
|
||||||
* The content of the ModalContent component
|
* The content of the ModalContent component
|
||||||
*/
|
*/
|
||||||
children?: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/**
|
/**
|
||||||
* The size of ModalContent
|
* The size of ModalContent
|
||||||
* Currently only one size is supported ModalContentSize.Sm 360px
|
* Currently only one size is supported ModalContentSize.Sm 360px
|
||||||
@ -28,13 +27,7 @@ export interface ModalContentProps extends BoxProps {
|
|||||||
*/
|
*/
|
||||||
size?: ModalContentSize;
|
size?: ModalContentSize;
|
||||||
/**
|
/**
|
||||||
* To override the default width of the ModalContent component
|
* Additional props to pass to the dialog node inside of ModalContent component
|
||||||
* Accepts all BLOCK_SIZES from design-system
|
|
||||||
*/
|
*/
|
||||||
width?: BoxWidth | BoxWidthArray;
|
modalDialogProps?: BoxProps | React.HTMLAttributes<HTMLElement>;
|
||||||
/**
|
|
||||||
* The ref of the ModalContent component
|
|
||||||
* Used with Modal and closeOnOutsideClick prop
|
|
||||||
*/
|
|
||||||
modalContentRef?: React.RefObject<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…
x
Reference in New Issue
Block a user