Commit c046e630 by Farhanah Sheets Committed by GitHub

Merge pull request #42 from edx/fsheets/status-alert

Create new StatusAlert accessible component. Bump to version 0.1.0.
parents a4e492b0 3049dae4
...@@ -476,7 +476,11 @@ exports[`Storyshots Modal basic usage 1`] = ` ...@@ -476,7 +476,11 @@ exports[`Storyshots Modal basic usage 1`] = `
onKeyDown={[Function]} onKeyDown={[Function]}
type="button" type="button"
> >
× <span
aria-hidden="true"
>
×
</span>
</button> </button>
</div> </div>
<div <div
...@@ -534,7 +538,11 @@ exports[`Storyshots Modal configurable buttons 1`] = ` ...@@ -534,7 +538,11 @@ exports[`Storyshots Modal configurable buttons 1`] = `
onKeyDown={[Function]} onKeyDown={[Function]}
type="button" type="button"
> >
× <span
aria-hidden="true"
>
×
</span>
</button> </button>
</div> </div>
<div <div
...@@ -619,7 +627,11 @@ exports[`Storyshots Modal configurable buttons that perform actions 1`] = ` ...@@ -619,7 +627,11 @@ exports[`Storyshots Modal configurable buttons that perform actions 1`] = `
onKeyDown={[Function]} onKeyDown={[Function]}
type="button" type="button"
> >
× <span
aria-hidden="true"
>
×
</span>
</button> </button>
</div> </div>
<div <div
...@@ -686,7 +698,11 @@ exports[`Storyshots Modal configurable close button 1`] = ` ...@@ -686,7 +698,11 @@ exports[`Storyshots Modal configurable close button 1`] = `
onKeyDown={[Function]} onKeyDown={[Function]}
type="button" type="button"
> >
× <span
aria-hidden="true"
>
×
</span>
</button> </button>
</div> </div>
<div <div
...@@ -744,7 +760,11 @@ exports[`Storyshots Modal configurable title and body 1`] = ` ...@@ -744,7 +760,11 @@ exports[`Storyshots Modal configurable title and body 1`] = `
onKeyDown={[Function]} onKeyDown={[Function]}
type="button" type="button"
> >
× <span
aria-hidden="true"
>
×
</span>
</button> </button>
</div> </div>
<div <div
...@@ -812,7 +832,11 @@ exports[`Storyshots Modal modal invoked via a button 1`] = ` ...@@ -812,7 +832,11 @@ exports[`Storyshots Modal modal invoked via a button 1`] = `
onKeyDown={[Function]} onKeyDown={[Function]}
type="button" type="button"
> >
× <span
aria-hidden="true"
>
×
</span>
</button> </button>
</div> </div>
<div <div
...@@ -880,7 +904,11 @@ exports[`Storyshots Modal modal with element body 1`] = ` ...@@ -880,7 +904,11 @@ exports[`Storyshots Modal modal with element body 1`] = `
onKeyDown={[Function]} onKeyDown={[Function]}
type="button" type="button"
> >
× <span
aria-hidden="true"
>
×
</span>
</button> </button>
</div> </div>
<div <div
...@@ -982,6 +1010,210 @@ exports[`Storyshots Paragon Welcome 1`] = ` ...@@ -982,6 +1010,210 @@ exports[`Storyshots Paragon Welcome 1`] = `
</div> </div>
`; `;
exports[`Storyshots StatusAlert Non-dismissible alert 1`] = `
<div
className="alert fade alert-danger show"
hidden={false}
role="alert"
>
<div
className="alert-dialog"
>
You can't get rid of me!
</div>
</div>
`;
exports[`Storyshots StatusAlert alert invoked via a button 1`] = `
<div>
<div
className="alert fade alert-dismissible alert-success"
hidden={true}
role="alert"
>
<button
aria-label="Close"
className="btn close"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
<span
aria-hidden="true"
>
×
</span>
</button>
<div
className="alert-dialog"
>
Success! You triggered the alert!
</div>
</div>
<button
className="btn btn-light"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Click me to open a Status Alert!
</button>
</div>
`;
exports[`Storyshots StatusAlert alert with a link 1`] = `
<div
className="alert fade alert-dismissible alert-info show"
hidden={false}
role="alert"
>
<button
aria-label="Close"
className="btn close"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
<span
aria-hidden="true"
>
×
</span>
</button>
<div
className="alert-dialog"
>
<div>
<span>
Love cats?
</span>
<a
href="https://www.factretriever.com/cat-facts"
rel="noopener noreferrer"
target="_blank"
>
Click me!
</a>
</div>
</div>
</div>
`;
exports[`Storyshots StatusAlert basic usage 1`] = `
<div
className="alert fade alert-dismissible alert-warning show"
hidden={false}
role="alert"
>
<button
aria-label="Close"
className="btn close"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
<span
aria-hidden="true"
>
×
</span>
</button>
<div
className="alert-dialog"
>
You have a status alert!
</div>
</div>
`;
exports[`Storyshots StatusAlert danger alert 1`] = `
<div
className="alert fade alert-dismissible alert-danger show"
hidden={false}
role="alert"
>
<button
aria-label="Close"
className="btn close"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
<span
aria-hidden="true"
>
×
</span>
</button>
<div
className="alert-dialog"
>
Error!
</div>
</div>
`;
exports[`Storyshots StatusAlert informational alert 1`] = `
<div
className="alert fade alert-dismissible alert-info show"
hidden={false}
role="alert"
>
<button
aria-label="Close"
className="btn close"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
<span
aria-hidden="true"
>
×
</span>
</button>
<div
className="alert-dialog"
>
Get some info here!
</div>
</div>
`;
exports[`Storyshots StatusAlert success alert 1`] = `
<div
className="alert fade alert-dismissible alert-success show"
hidden={false}
role="alert"
>
<button
aria-label="Close"
className="btn close"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
<span
aria-hidden="true"
>
×
</span>
</button>
<div
className="alert-dialog"
>
Success!
</div>
</div>
`;
exports[`Storyshots Table default heading 1`] = ` exports[`Storyshots Table default heading 1`] = `
<table <table
className="table" className="table"
......
{ {
"name": "@edx/paragon", "name": "@edx/paragon",
"version": "0.0.1", "version": "0.1.0",
"description": "Accessible, responsive UI component library based on Bootstrap.", "description": "Accessible, responsive UI component library based on Bootstrap.",
"main": "src/index.js", "main": "src/index.js",
"author": "arizzitano", "author": "arizzitano",
...@@ -21,7 +21,8 @@ ...@@ -21,7 +21,8 @@
"classnames": "^2.2.5", "classnames": "^2.2.5",
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
"react": "^15.5.4", "react": "^15.5.4",
"react-dom": "^15.5.4" "react-dom": "^15.5.4",
"react-proptype-conditional-require": "^1.0.4"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-actions": "^3.2.12", "@storybook/addon-actions": "^3.2.12",
......
@import "~bootstrap/scss/_buttons"; @import "~bootstrap/scss/_buttons";
@import "~bootstrap/scss/_close";
...@@ -10,6 +10,7 @@ function Button(props) { ...@@ -10,6 +10,7 @@ function Button(props) {
className, className,
display, display,
inputRef, inputRef,
isClose,
onBlur, onBlur,
onClick, onClick,
onKeyDown, onKeyDown,
...@@ -24,6 +25,8 @@ function Button(props) { ...@@ -24,6 +25,8 @@ function Button(props) {
styles.btn, styles.btn,
], { ], {
[styles[`btn-${buttonType}`]]: buttonType !== undefined, [styles[`btn-${buttonType}`]]: buttonType !== undefined,
}, {
[styles.close]: isClose,
})} })}
onBlur={onBlur} onBlur={onBlur}
onClick={onClick} onClick={onClick}
...@@ -40,8 +43,9 @@ function Button(props) { ...@@ -40,8 +43,9 @@ function Button(props) {
export const buttonPropTypes = { export const buttonPropTypes = {
buttonType: PropTypes.string, buttonType: PropTypes.string,
className: PropTypes.arrayOf(PropTypes.string), className: PropTypes.arrayOf(PropTypes.string),
display: PropTypes.string.isRequired, display: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
inputRef: PropTypes.func, inputRef: PropTypes.func,
isClose: PropTypes.bool,
onBlur: PropTypes.func, onBlur: PropTypes.func,
onClick: PropTypes.func, onClick: PropTypes.func,
onKeyDown: PropTypes.func, onKeyDown: PropTypes.func,
...@@ -54,6 +58,7 @@ Button.defaultProps = { ...@@ -54,6 +58,7 @@ Button.defaultProps = {
buttonType: undefined, buttonType: undefined,
className: [], className: [],
inputRef: () => {}, inputRef: () => {},
isClose: false,
onBlur: () => {}, onBlur: () => {},
onClick: () => {}, onClick: () => {},
onKeyDown: () => {}, onKeyDown: () => {},
......
...@@ -96,7 +96,7 @@ class Modal extends React.Component { ...@@ -96,7 +96,7 @@ class Modal extends React.Component {
<div className={styles['modal-header']}> <div className={styles['modal-header']}>
<h5 className={styles['modal-title']} id={this.headerId}>{this.props.title}</h5> <h5 className={styles['modal-title']} id={this.headerId}>{this.props.title}</h5>
<Button <Button
display="&times;" display={<span aria-hidden="true">&times;</span>}
aria-label={this.props.closeText} aria-label={this.props.closeText}
buttonType="light" buttonType="light"
onClick={this.close} onClick={this.close}
......
# StatusAlert
Provides a status alert component with customizable dialog options. StatusAlert has an X button on the right by default (see dismissible option).
## API
### `alertType` (string; optional)
`alertType` specifies the type of alert that is being diplayed. It defaults to "warning". See the other available [bootstrap](https://v4-alpha.getbootstrap.com/components/alerts/) options.
### `className` (string array; optional)
`className` is a string array that defines the classes to be used within the status alert.
### `dialog` (string or element; required)
`dialog` is a string or an element that is rendered inside of the status alert as the main data.
### `dismissible` (boolean; optional)
`dismissible` specifies if the status alert will include the dismissible X button to close the alert. It defaults to true.
### `onClose` (function; conditionally required)
`onClose` is a function that is called on close. It can be used to perform actions upon closing of the status alert, such as restoring focus to the previous logical focusable element. It is only required if `dismissible` is set to `true` and not required if the alert if not `dismissible`.
### `open` (boolean; optional)
`open` specifies whether the status alert renders open or closed on the initial render. It defaults to false.
@import "~bootstrap/scss/_alert";
@import "~bootstrap/scss/_buttons";
@import "~bootstrap/scss/_close";
@import "~bootstrap/scss/_transitions.scss";
/* eslint-disable import/no-extraneous-dependencies */
/* eslint-disable no-console */
import React from 'react';
import { storiesOf } from '@storybook/react';
import PropTypes from 'prop-types';
import StatusAlert from './index';
import Button from '../Button';
class StatusAlertWrapper extends React.Component {
constructor(props) {
super(props);
this.openStatusAlert = this.openStatusAlert.bind(this);
this.resetStatusAlertWrapperState = this.resetStatusAlertWrapperState.bind(this);
this.state = { open: false };
}
openStatusAlert() {
this.setState({ open: true });
}
resetStatusAlertWrapperState() {
this.setState({ open: false });
this.button.focus();
}
render() {
return (
<div>
<StatusAlert
alertType={this.props.alertType}
open={this.state.open}
dialog={this.props.dialog}
onClose={this.resetStatusAlertWrapperState}
/>
<Button
onClick={this.openStatusAlert}
display="Click me to open a Status Alert!"
buttonType="light"
inputRef={(input) => { this.button = input; }}
/>
</div>
);
}
}
StatusAlertWrapper.propTypes = {
alertType: PropTypes.string,
dialog: PropTypes.string.isRequired,
};
StatusAlertWrapper.defaultProps = {
alertType: 'warning',
};
storiesOf('StatusAlert', module)
.add('basic usage', () => (
<StatusAlert
dialog="You have a status alert!"
onClose={() => {}}
open
/>
))
.add('success alert', () => (
<StatusAlert
alertType="success"
dialog="Success!"
onClose={() => {}}
open
/>
))
.add('danger alert', () => (
<StatusAlert
alertType="danger"
dialog="Error!"
onClose={() => {}}
open
/>
))
.add('informational alert', () => (
<StatusAlert
alertType="info"
dialog="Get some info here!"
onClose={() => {}}
open
/>
))
.add('Non-dismissible alert', () => (
<StatusAlert
alertType="danger"
dismissible={false}
dialog="You can't get rid of me!"
open
/>
))
.add('alert invoked via a button', () => (
<StatusAlertWrapper
alertType="success"
dialog="Success! You triggered the alert!"
/>
))
.add('alert with a link', () => (
<StatusAlert
alertType="info"
dialog={(
<div>
<span>Love cats? </span>
<a
href="https://www.factretriever.com/cat-facts"
target="_blank"
rel="noopener noreferrer"
>
Click me!
</a>
</div>
)}
onClose={() => {}}
open
/>
));
import React from 'react';
import { mount } from 'enzyme';
import StatusAlert from './index';
const statusAlertOpen = (isOpen, wrapper) => {
expect(wrapper.hasClass('show')).toEqual(isOpen);
expect(wrapper.state('open')).toEqual(isOpen);
};
const dialog = 'Status Alert dialog';
const defaultProps = {
dialog,
onClose: () => {},
open: true,
};
let wrapper;
describe('<StatusAlert />', () => {
describe('correct rendering', () => {
it('renders default view', () => {
wrapper = mount(
<StatusAlert
{...defaultProps}
/>,
);
const statusAlertDialog = wrapper.find('.alert-dialog');
expect(statusAlertDialog.text()).toEqual(dialog);
expect(wrapper.find('button')).toHaveLength(1);
});
it('renders non-dismissible view', () => {
wrapper = mount(
<StatusAlert
{...defaultProps}
dismissible={false}
/>,
);
const statusAlertDialog = wrapper.find('.alert-dialog');
expect(statusAlertDialog.text()).toEqual(dialog);
expect(wrapper.find('button')).toHaveLength(0);
});
});
describe('props received correctly', () => {
it('component receives props', () => {
wrapper = mount(
<StatusAlert
dialog={dialog}
onClose={() => {}}
/>,
);
statusAlertOpen(false, wrapper);
wrapper.setProps({ open: true });
statusAlertOpen(true, wrapper);
});
it('component receives props and ignores prop change', () => {
wrapper = mount(
<StatusAlert
{...defaultProps}
/>,
);
statusAlertOpen(true, wrapper);
wrapper.setProps({ dialog: 'Changed alert dialog' });
statusAlertOpen(true, wrapper);
});
});
describe('close functions properly', () => {
beforeEach(() => {
wrapper = mount(
<StatusAlert
{...defaultProps}
/>,
);
});
it('closes when x button pressed', () => {
statusAlertOpen(true, wrapper);
wrapper.find('button').at(0).simulate('click');
statusAlertOpen(false, wrapper);
});
it('closes when Enter key pressed', () => {
statusAlertOpen(true, wrapper);
wrapper.find('button').at(0).simulate('keyDown', { key: 'Enter' });
statusAlertOpen(false, wrapper);
});
it('closes when Escape key pressed', () => {
statusAlertOpen(true, wrapper);
wrapper.find('button').at(0).simulate('keyDown', { key: 'Escape' });
statusAlertOpen(false, wrapper);
});
it('calls callback function on close', () => {
const spy = jest.fn();
wrapper = mount(
<StatusAlert
{...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(
<StatusAlert
{...defaultProps}
/>,
);
});
it('does nothing on invalid keystroke q', () => {
const buttons = wrapper.find('button');
expect(buttons.at(0).matchesElement(document.activeElement)).toEqual(true);
statusAlertOpen(true, wrapper);
buttons.at(0).simulate('keyDown', { key: 'q' });
expect(buttons.at(0).matchesElement(document.activeElement)).toEqual(true);
statusAlertOpen(true, wrapper);
});
it('does nothing on invalid keystroke + ctrl', () => {
const buttons = wrapper.find('button');
expect(buttons.at(0).matchesElement(document.activeElement)).toEqual(true);
statusAlertOpen(true, wrapper);
buttons.at(0).simulate('keyDown', { key: 'Tab', ctrlKey: true });
expect(buttons.at(0).matchesElement(document.activeElement)).toEqual(true);
statusAlertOpen(true, wrapper);
});
});
});
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import isRequiredIf from 'react-proptype-conditional-require';
import styles from './StatusAlert.scss';
import Button from '../Button';
class StatusAlert extends React.Component {
constructor(props) {
super(props);
this.close = this.close.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.renderDialog = this.renderDialog.bind(this);
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 === 'Enter' || e.key === 'Escape') {
e.preventDefault();
this.close();
}
}
renderDialog() {
const { dialog } = this.props;
return (
<div className="alert-dialog">
{ dialog }
</div>
);
}
renderDismissible() {
const { dismissible } = this.props;
return (dismissible) ? (
<Button
aria-label="Close"
inputRef={(input) => { this.xButton = input; }}
onClick={this.close}
onKeyDown={this.handleKeyDown}
display={<span aria-hidden="true">&times;</span>}
isClose
/>
) : null;
}
render() {
const { alertType, className, dismissible } = this.props;
return (
<div
className={classNames([
...className,
styles.alert,
styles.fade,
], {
[styles['alert-dismissible']]: dismissible,
}, {
[styles[`alert-${alertType}`]]: alertType !== undefined,
}, {
[styles.show]: this.state.open,
})}
role="alert"
hidden={!this.state.open}
>
{this.renderDismissible()}
{this.renderDialog()}
</div>
);
}
}
StatusAlert.propTypes = {
alertType: PropTypes.string,
className: PropTypes.arrayOf(PropTypes.string),
dialog: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
dismissible: PropTypes.bool,
/* eslint-disable react/require-default-props */
onClose: isRequiredIf(PropTypes.func, props => props.dismissible),
open: PropTypes.bool,
};
StatusAlert.defaultProps = {
alertType: 'warning',
className: [],
dismissible: true,
open: false,
};
export default StatusAlert;
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