Commit ccb5986a by Michael Roytman Committed by GitHub

Merge pull request #34 from edx/mroytman/modal-component

add a modal component
parents 35d2bf83 cb7d0048
import '@storybook/addon-options/register';
import '@storybook/addon-actions/register';
......@@ -7,7 +7,7 @@ import CssJail from '../src/CssJail';
setTimeout(() => setOptions({
name: '💎 PARAGON',
url: 'https://github.com/edx/paragon',
showDownPanel: false,
showDownPanel: true,
}), 1000);
const req = require.context('../src', true, /\.stories\.jsx$/);
......
......@@ -24,6 +24,7 @@
"react-dom": "^15.5.4"
},
"devDependencies": {
"@storybook/addon-actions": "^3.2.12",
"@storybook/addon-options": "^3.2.6",
"@storybook/addon-storyshots": "^3.2.8",
"@storybook/react": "3.2.8",
......
......@@ -37,7 +37,7 @@ function Button(props) {
);
}
Button.propTypes = {
export const buttonPropTypes = {
buttonType: PropTypes.string,
className: PropTypes.arrayOf(PropTypes.string),
display: PropTypes.string.isRequired,
......@@ -48,6 +48,8 @@ Button.propTypes = {
type: PropTypes.string,
};
Button.propTypes = buttonPropTypes;
Button.defaultProps = {
buttonType: undefined,
className: [],
......
......@@ -149,6 +149,16 @@ describe('<Dropdown />', () => {
});
});
describe('invalid key in open menu', () => {
it('test', () => {
menuOpen(true, wrapper);
expect(wrapper.find('a').at(0).matchesElement(document.activeElement)).toEqual(true);
wrapper.find('a').at(0).simulate('keyDown', { key: 'q' });
menuOpen(true, wrapper);
expect(wrapper.find('a').at(0).matchesElement(document.activeElement)).toEqual(true);
});
});
it('first menu item after looping through', () => {
wrapper.find('a').at(0).simulate('keyDown', { key: triggerKeys.NAVIGATE_DOWN[0] });
wrapper.find('a').at(1).simulate('keyDown', { key: triggerKeys.NAVIGATE_DOWN[0] });
......@@ -156,9 +166,29 @@ describe('<Dropdown />', () => {
expect(wrapper.find('a').at(0).matchesElement(document.activeElement)).toEqual(true);
});
it('toggle on close', () => {
wrapper.find('a').at(0).simulate('keyDown', { key: triggerKeys.CLOSE_MENU[0] });
expect(wrapper.find('[type="button"]').matchesElement(document.activeElement)).toEqual(true);
describe('toggle', () => {
it('toggle on close', () => {
wrapper.find('a').at(0).simulate('keyDown', { key: triggerKeys.CLOSE_MENU[0] });
expect(wrapper.find('[type="button"]').matchesElement(document.activeElement)).toEqual(true);
});
it('does not toggle with invalid key', () => {
wrapper = mount(
<Dropdown
{...props}
/>,
);
menuOpen(false, wrapper);
// open and close button to get focus on button
wrapper.find('[type="button"]').simulate('click');
wrapper.find('[type="button"]').simulate('click');
expect(wrapper.find('[type="button"]').matchesElement(document.activeElement)).toEqual(true);
wrapper.find('[type="button"]').simulate('keyDown', { key: 'q' });
menuOpen(false, wrapper);
expect(wrapper.find('[type="button"]').matchesElement(document.activeElement)).toEqual(true);
});
});
});
});
@import "~bootstrap/scss/_modal";
.modal-open {
display: block;
}
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import PropTypes from 'prop-types';
import Modal from './index';
import Button from '../Button';
import InputText from '../InputText';
class ModalWrapper extends React.Component {
constructor(props) {
super(props);
this.openModal = this.openModal.bind(this);
this.resetModalWrapperState = this.resetModalWrapperState.bind(this);
this.state = { open: false };
}
openModal() {
this.setState({ open: true });
}
resetModalWrapperState() {
this.setState({ open: false });
this.button.focus();
}
render() {
return (
<div>
<Modal
open={this.state.open}
title={this.props.title}
body={this.props.body}
onClose={this.resetModalWrapperState}
/>
<Button
onClick={this.openModal}
display="Click me to open a modal!"
buttonType="light"
inputRef={(input) => { this.button = input; }}
/>
</div>
);
}
}
ModalWrapper.propTypes = {
title: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
body: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
};
ModalWrapper.defaultProps = {
open: false,
};
storiesOf('Modal', module)
.add('basic usage', () => (
<Modal
open
title="Modal title."
body="Modal body."
onClose={() => {}}
/>
))
.add('configurable buttons', () => (
<Modal
open
title="Modal title."
body="Modal body."
buttons={[
<Button
display="Blue button!"
buttonType="primary"
/>,
{
display: 'Red button!',
buttonType: 'danger',
},
<Button
display="Green button!"
buttonType="success"
/>,
]}
onClose={() => {}}
/>
))
.add('configurable title and body', () => (
<Modal
open
title="Custom title!"
body="Custom body!"
buttons={[
<Button
display="Dark button!"
buttonType="dark"
/>,
]}
onClose={() => {}}
/>
))
.add('configurable buttons that perform actions', () => (
<Modal
open
title="Modal title."
body="Modal body."
buttons={[
<Button
display="Click me and check the console!"
buttonType="light"
onClick={action('button-click')}
/>,
]}
onClose={() => {}}
/>
))
.add('configurable close button', () => (
<Modal
open
title="Modal title."
body="Modal body."
closeText="SHOO!"
onClose={() => {}}
/>
))
.add('modal invoked via a button', () => (
<ModalWrapper
title="I am the modal!"
body="I was invoked by a button!"
/>
))
.add('modal with element body', () => (
<Modal
open
title="Modal title."
body={(
<div>
<p>Enter your e-mail address to receive free cat facts!</p>
<InputText
name="e-mail"
label="E-Mail Address"
/>
<Button
display="Get my facts!"
/>
</div>
)}
onClose={() => {}}
/>
));
/* eslint-disable import/no-extraneous-dependencies */
import React from 'react';
import { mount } from 'enzyme';
import Modal from './index';
import Button from '../Button';
const modalOpen = (isOpen, wrapper) => {
expect(wrapper.hasClass('modal-open')).toEqual(isOpen);
expect(wrapper.state('open')).toEqual(isOpen);
};
const title = 'Modal title';
const body = 'Modal body';
const defaultProps = {
title,
body,
open: true,
onClose: () => {},
};
let wrapper;
describe('<Modal />', () => {
describe('correct rendering', () => {
const buttons = [
<Button
display="Blue button!"
buttonType="primary"
/>,
{
display: 'Red button!',
buttonType: 'danger',
},
<Button
display="Green button!"
buttonType="success"
/>,
];
it('renders default buttons', () => {
wrapper = mount(
<Modal
{...defaultProps}
/>,
);
const modalTitle = wrapper.find('.modal-title');
const modalBody = wrapper.find('.modal-body');
expect(modalTitle.text()).toEqual(title);
expect(modalBody.text()).toEqual(body);
expect(wrapper.find('button')).toHaveLength(2);
});
it('renders custom buttons', () => {
wrapper = mount(
<Modal
{...defaultProps}
buttons={buttons}
/>,
);
expect(wrapper.find('button')).toHaveLength(buttons.length + 2);
});
});
describe('props received correctly', () => {
it('component receives props', () => {
wrapper = mount(
<Modal
title={title}
body={body}
onClose={() => {}}
/>,
);
modalOpen(false, wrapper);
wrapper.setProps({ open: true });
modalOpen(true, wrapper);
});
it('component receives props and ignores prop change', () => {
wrapper = mount(
<Modal
{...defaultProps}
/>,
);
modalOpen(true, wrapper);
wrapper.setProps({ title: 'Changed modal title' });
modalOpen(true, wrapper);
});
});
describe('close functions properly', () => {
beforeEach(() => {
wrapper = mount(
<Modal
{...defaultProps}
/>,
);
});
it('closes when x button pressed', () => {
modalOpen(true, wrapper);
wrapper.find('button').at(0).simulate('click');
modalOpen(false, wrapper);
});
it('closes when Close button pressed', () => {
modalOpen(true, wrapper);
wrapper.find('button').at(1).simulate('click');
modalOpen(false, wrapper);
});
it('closes when Escape key pressed', () => {
modalOpen(true, wrapper);
wrapper.find('button').at(0).simulate('keyDown', { key: 'Escape' });
modalOpen(false, wrapper);
});
it('calls callback function on close', () => {
const spy = jest.fn();
wrapper = mount(
<Modal
{...defaultProps}
onClose={spy}
/>,
);
expect(spy).toHaveBeenCalledTimes(0);
// press X button
wrapper.find('button').at(0).simulate('click');
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe('invalid keystrokes do nothing', () => {
beforeEach(() => {
wrapper = mount(
<Modal
{...defaultProps}
/>,
);
});
it('does nothing on invalid keystroke q', () => {
const buttons = wrapper.find('button');
expect(buttons.at(0).matchesElement(document.activeElement)).toEqual(true);
modalOpen(true, wrapper);
buttons.at(0).simulate('keyDown', { key: 'q' });
expect(buttons.at(0).matchesElement(document.activeElement)).toEqual(true);
modalOpen(true, wrapper);
});
it('does nothing on invalid keystroke + ctrl', () => {
const buttons = wrapper.find('button');
expect(buttons.at(0).matchesElement(document.activeElement)).toEqual(true);
modalOpen(true, wrapper);
buttons.at(0).simulate('keyDown', { key: 'Tab', ctrlKey: true });
expect(buttons.at(0).matchesElement(document.activeElement)).toEqual(true);
modalOpen(true, wrapper);
});
});
describe('focus changes correctly', () => {
let buttons;
beforeEach(() => {
wrapper = mount(
<Modal
{...defaultProps}
/>,
);
buttons = wrapper.find('button');
});
it('has correct initial focus', () => {
expect(buttons.at(0).matchesElement(document.activeElement)).toEqual(true);
});
it('has reset focus after close and reopen', () => {
expect(buttons.at(0).matchesElement(document.activeElement)).toEqual(true);
wrapper.setProps({ open: false });
modalOpen(false, wrapper);
wrapper.setProps({ open: true });
modalOpen(true, wrapper);
expect(buttons.at(0).matchesElement(document.activeElement)).toEqual(true);
});
it('traps focus forwards on tab keystroke', () => {
expect(buttons.at(0).matchesElement(document.activeElement)).toEqual(true);
buttons.last().simulate('keyDown', { key: 'Tab' });
expect(buttons.at(0).matchesElement(document.activeElement)).toEqual(true);
});
it('traps focus backwards on shift + tab keystroke', () => {
expect(buttons.at(0).matchesElement(document.activeElement)).toEqual(true);
buttons.at(0).simulate('keyDown', { key: 'Tab', shiftKey: true });
expect(buttons.last().matchesElement(document.activeElement)).toEqual(true);
});
});
});
# Modal
Provides a basic modal component with customizable title, body, and footer buttons. Modal has X button in top right and Close button in bottom right by default.
## API
### `open` (boolean; optional)
`open` specifies whether the modal renders open or closed on the initial render. It defaults to false.
### `title` (string or element; required)
`title` is a string or an element that is rendered inside of the modal title, above the modal body.
### `body` (string or element; required)
`body` is a string or an element that is rendered inside of the modal body, between the title and the footer.
### `buttons` (element or shape in form of buttonPropTypes array; optional)
`buttons` is an array of either elements or shapes that take the form of the buttonPropTypes. See the [buttonPropTypes](https://github.com/edx/paragon/blob/master/src/Button/index.jsx#L40) for a list of acceptable props to pass as part of a button.
### `closeText` (string; optional)
`closeText` specifies the display text of the default Close button. It defaults to "Close".
### `onClose` (function; required)
`onClose` is a function that is called on close. It can be used to perform actions upon closing of the modal, such as restoring focus to the previous logical focusable element.
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import styles from './Modal.scss';
import Button, { buttonPropTypes } from '../Button';
import newId from '../utils/newId';
class Modal extends React.Component {
constructor(props) {
super(props);
this.close = this.close.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.headerId = newId();
this.state = {
open: props.open,
};
}
componentDidMount() {
if (this.xButton) {
this.xButton.focus();
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.open !== this.props.open) {
this.setState({ open: nextProps.open });
}
}
componentDidUpdate(prevState) {
if (this.state.open && !prevState.open) {
this.xButton.focus();
}
}
close() {
this.setState({ open: false });
this.props.onClose();
}
handleKeyDown(e) {
if (e.key === 'Escape') {
this.close();
} else if (e.key === 'Tab') {
if (e.shiftKey) {
if (e.target === this.xButton) {
e.preventDefault();
this.closeButton.focus();
}
} else if (e.target === this.closeButton) {
e.preventDefault();
this.xButton.focus();
}
}
}
renderButtons() {
return this.props.buttons.map((button, i) => {
let buttonElement = button;
let buttonProps = button.props;
if (button.type !== Button) {
buttonProps = button;
}
buttonElement = (<Button
{...buttonProps}
key={i}
onKeyDown={this.handleKeyDown}
/>);
return buttonElement;
});
}
renderBody() {
let body;
if (typeof this.props.body === 'string') {
body = <p>{this.props.body}</p>;
} else {
body = this.props.body;
}
return body;
}
render() {
return (
<div className={classNames(styles.modal, styles.show, { [styles['modal-open']]: this.state.open })} role="dialog" aria-modal aria-labelledby={this.headerId}>
<div className={styles['modal-dialog']}>
<div className={styles['modal-content']}>
<div className={styles['modal-header']}>
<h5 className={styles['modal-title']} id={this.headerId}>{this.props.title}</h5>
<Button
display="&times;"
aria-label={this.props.closeText}
buttonType="light"
onClick={this.close}
inputRef={(input) => { this.xButton = input; }}
onKeyDown={this.handleKeyDown}
/>
</div>
<div className={styles['modal-body']}>
{this.renderBody()}
</div>
<div className={styles['modal-footer']}>
{this.renderButtons()}
<Button
display={this.props.closeText}
buttonType="secondary"
onClick={this.close}
inputRef={(input) => { this.closeButton = input; }}
onKeyDown={this.handleKeyDown}
/>
</div>
</div>
</div>
</div>
);
}
}
Modal.propTypes = {
open: PropTypes.bool,
title: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
body: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
buttons: PropTypes.arrayOf(PropTypes.oneOfType([
PropTypes.element,
PropTypes.shape(buttonPropTypes),
])),
closeText: PropTypes.string,
onClose: PropTypes.func.isRequired,
};
Modal.defaultProps = {
open: false,
buttons: [],
closeText: 'Close',
};
export default Modal;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment