-
Notifications
You must be signed in to change notification settings - Fork 5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adding Modal component with improved accessibility
- Loading branch information
1 parent
062a8b3
commit 232972c
Showing
19 changed files
with
1,037 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
3 changes: 3 additions & 0 deletions
3
ui/components/component-library/modal/__snapshots__/modal.test.tsx.snap
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 />`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
117
ui/components/component-library/modal/modal-focus.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
// }); | ||
}); |
Oops, something went wrong.