Skip to content

Commit

Permalink
Adding Modal component with improved accessibility
Browse files Browse the repository at this point in the history
  • Loading branch information
georgewrmarshall committed Apr 26, 2023
1 parent 062a8b3 commit 232972c
Show file tree
Hide file tree
Showing 19 changed files with 1,037 additions and 4 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@
"qrcode.react": "^1.0.1",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-focus-lock": "^2.9.4",
"react-idle-timer": "^4.2.5",
"react-inspector": "^2.3.0",
"react-markdown": "^6.0.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@
@import 'banner-tip/banner-tip';
@import 'modal-content/modal-content';
@import 'modal-overlay/modal-overlay';

@import 'modal/modal';
1 change: 1 addition & 0 deletions ui/components/component-library/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export { TextField, TEXT_FIELD_TYPES, TEXT_FIELD_SIZES } from './text-field';
export { TextFieldSearch } from './text-field-search';
export { ModalContent, ModalContentSize } from './modal-content';
export { ModalOverlay } from './modal-overlay';
export { Modal } from './modal';

// Molecules
export { BannerBase } from './banner-base';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

exports[`ModalContent should match snapshot 1`] = `
<div>
<div
<section
aria-modal="true"
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"
role="dialog"
>
test
</div>
</section>
</div>
`;
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export const ModalContent = ({
{ [`mm-modal-content--size-${size}`]: !width },
className,
)}
as="section"
role="dialog"
aria-modal="true"
backgroundColor={BackgroundColor.backgroundDefault}
borderRadius={BorderRadius.LG}
width={width || BLOCK_SIZES.FULL}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
exports[`ModalOverlay should match snapshot 1`] = `
<div>
<div
aria-hidden="true"
class="box mm-modal-overlay box--flex-direction-row box--width-full box--height-full box--background-color-overlay-default"
data-aria-hidden="true"
/>
</div>
`;
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export const ModalOverlay: React.FC<ModalOverlayProps> = ({
width={BLOCK_SIZES.FULL}
height={BLOCK_SIZES.FULL}
onClick={onClick}
aria-hidden="true"
data-aria-hidden="true"
{...props}
/>
);
Expand Down
180 changes: 180 additions & 0 deletions ui/components/component-library/modal/README.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { Story, Canvas, ArgsTable } from '@storybook/addon-docs';

import { Modal } from './modal';

# Modal

The `Modal` is a component that renders a child outside of the normal document flow. It is useful for rendering a child on top of other content.

<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`.
- Use the `modalContentRef` prop to pass a ref to `ModalContent` component. This is used to lock focus to `ModalContent` when the `Modal` is open. It is also used for the `isClosedOnOutsideClick` prop. If the ref is not equal to `modalContentRef`, the modal will close.

<Canvas>
<Story id="components-componentlibrary-modal--usage" />
</Canvas>

```jsx
import React, { useState, useRef } from 'react';
import { Modal, ModalOverlay, ModalContent, ModalHeader, Text } from '../../component-library';

const modalContentRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);

const handleOnClick = () => {
setOpen(true);
};

const handleOnClose = () => {
setOpen(false);
};

<button onClick={handleOnClick}>OpenModal</button>
<Modal
isOpen={open}
modalContentRef={modalContentRef}
onClose={handleOnClose}
>
<ModalOverlay />
<ModalContent ref={modalContentRef}>
<ModalHeader onClose={handleOnClose} onBack={handleOnClose}>
Modal Header
</ModalHeader>
<Text>Children</Text>
</ModalContent>
</Modal>
```

### Children

The `Modal` component is intended to be used with `ModalOverlay`, `ModalContent` and `ModalHeader`. However, if you want to use the `Modal` component as a portal to render a custom component without the other components, you can pass a custom child to the `Modal` component.

<Canvas>
<Story id="components-componentlibrary-modal--children" />
</Canvas>

```jsx
import React, { useState, useRef } from 'react';

import { BackgroundColor, BorderRadius, TextColor } from '../../../helpers/constants/design-system';

import Box from '../../ui/box'

import { Modal, ModalOverlay, ModalContent, ModalHeader, Text } from '../../component-library';

const modalContentRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);

const handleOnClick = () => {
setOpen(true);
};

const handleOnClose = () => {
setOpen(false);
};

<Button onClick={handleOnClick}>Open modal</Button>
<Modal
modalContentRef={modalContentRef}
isOpen={isOpen}
onClose={handleOnClose}
>
<Box
backgroundColor={BackgroundColor.primaryDefault}
padding={8}
borderRadius={BorderRadius.LG}
ref={modalContentRef}
>
<Text color={TextColor.primaryInverse}>Custom Modal</Text>{' '}
<button onClick={handleOnClose}>Close</button>
</Box>
</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>

### 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>

### 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>

### 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>

### Restore Focus

Use the `restoreFocus` prop to restore focus to the element that triggered the `Modal` once it unmounts

<Canvas>
<Story id="components-componentlibrary-modal--restore-focus" />
</Canvas>

### 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>

## 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 set to true.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Modal should match snapshot 1`] = `<div />`;
4 changes: 4 additions & 0 deletions ui/components/component-library/modal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { Modal } from './modal';
export type { ModalProps } from './modal.types';
export { ModalFocus } from './modal-focus';
export type { ModalFocusProps } from './modal-focus.types';
117 changes: 117 additions & 0 deletions ui/components/component-library/modal/modal-focus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/* eslint-disable jest/require-top-level-describe */
import { render, fireEvent } from '@testing-library/react';
import React from 'react';

import { ModalFocus } from './modal-focus';

describe('ModalFocus', () => {
it('should render with children inside the ModalFocus', () => {
const { getByText } = render(
<ModalFocus>
<div>modal focus</div>
</ModalFocus>,
);
expect(getByText('modal focus')).toBeDefined();
});
it('should render with the initial element focused', () => {
const { getByTestId } = render(
<ModalFocus>
<input data-testid="input" />
</ModalFocus>,
);
expect(getByTestId('input')).toHaveFocus();
});
it('should render with focused with autoFocus is set to false', () => {
const { getByTestId } = render(
<ModalFocus autoFocus={false}>
<input data-testid="input" />
</ModalFocus>,
);
expect(getByTestId('input')).not.toHaveFocus();
});
it('should render with modalContentRef and focus if focusable', () => {
const Component = () => {
const ref = React.useRef<HTMLInputElement>(null);
return (
<ModalFocus autoFocus={false} modalContentRef={ref}>
<input data-testid="modal-content-ref" ref={ref} />
</ModalFocus>
);
};

const { getByTestId } = render(<Component />);
expect(getByTestId('modal-content-ref')).toHaveFocus();
});

it('should focus initialFocusRef on render', () => {
const Component = () => {
const ref = React.useRef<HTMLInputElement>(null);
return (
<ModalFocus initialFocusRef={ref}>
<input />
<input />
<input data-testid="input" ref={ref} />
</ModalFocus>
);
};
const { getByTestId } = render(<Component />);
expect(getByTestId('input')).toHaveFocus();
});
it(' should focus finalFocusRef on unmount', () => {
const Component = () => {
const [show, setShow] = React.useState(true);
const ref = React.useRef<HTMLButtonElement>(null);
return (
<>
<button ref={ref} data-testid="button" onClick={() => setShow(false)}>
Click
</button>
{show && (
<ModalFocus finalFocusRef={ref}>
<input />
</ModalFocus>
)}
</>
);
};
const { getByTestId } = render(<Component />);

const button = getByTestId('button');
// not focused while focus lock is displayed
expect(button).not.toHaveFocus();

// toggle focus lock and check that button is now focused
fireEvent.click(button);
expect(button).toHaveFocus();
});
// TODO: Figure out why this test isn't working
// it('should render restoreFocus to the original element', () => {
// const Component = () => {
// const [show, setShow] = React.useState(false);
// return (
// <>
// <button data-testid="open" tabIndex={0} onClick={() => setShow(true)}>
// Open
// </button>
// <button data-testid="close" onClick={() => setShow(false)}>
// Close
// </button>
// {show && (
// <ModalFocus restoreFocus>
// <div></div>
// </ModalFocus>
// )}
// </>
// );
// };
// const { getByTestId } = render(<Component />);

// const button = getByTestId('open');
// expect(button).not.toHaveFocus();

// fireEvent.click(button);
// fireEvent.click(getByTestId('close'));
// expect(getByTestId('open')).toHaveFocus();
// expect(getByTestId('close')).not.toHaveFocus();
// });
});
Loading

0 comments on commit 232972c

Please sign in to comment.