diff --git a/docs/lib/Components/ToastsPage.js b/docs/lib/Components/ToastsPage.js new file mode 100644 index 000000000..6a79b5f00 --- /dev/null +++ b/docs/lib/Components/ToastsPage.js @@ -0,0 +1,73 @@ +/* eslint react/no-multi-comp: 0, react/prop-types: 0 */ +import React from 'react'; +import { PrismCode } from 'react-prism'; +import PageTitle from '../UI/PageTitle'; +import SectionTitle from '../UI/SectionTitle'; + +import ToastExample from '../examples/Toast'; +const ToastExampleSource = require('!!raw-loader!../examples/Toast'); + +import ToastHeaderIconExample from '../examples/ToastHeaderIcon'; +const ToastHeaderIconExampleSource = require('!!raw-loader!../examples/ToastHeaderIcon'); + +import ToastDismissExample from '../examples/ToastDismiss'; +const ToastDismissExampleSource = require('!!raw-loader!../examples/ToastDismiss'); + +import AlertUncontrolledDismissExample from '../examples/AlertUncontrolledDismiss'; +const AlertUncontrolledDismissExampleSource = require('!!raw-loader!../examples/AlertUncontrolledDismiss'); + +import { AlertFadelessExample, UncontrolledAlertFadelessExample } from '../examples/AlertFadeless'; +const AlertFadelessExampleSource = require('!!raw-loader!../examples/AlertFadeless'); + +export default class ToastsPage extends React.Component { + render() { + return ( +
+ +
+ +
+
+          
+            {ToastExampleSource}
+          
+        
+ + Properties +
+          
+{`Toast.propTypes = {
+  className: PropTypes.string,
+  color: PropTypes.string, // default: 'success'
+  isOpen: PropTypes.bool,  // default: true
+  tag: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+  // Controls the transition of the toast fading in and out
+  // See [Fade](/components/fade/) for more details
+  transition: PropTypes.shape(Fade.propTypes),
+}`}
+          
+        
+ + Header icons +
+ +
+
+          
+            {ToastHeaderIconExampleSource}
+          
+        
+ + Dismissing +
+ +
+
+          
+            {ToastDismissExampleSource}
+          
+        
+
+ ); + } +} diff --git a/docs/lib/Components/index.js b/docs/lib/Components/index.js index 3d9055218..525279579 100644 --- a/docs/lib/Components/index.js +++ b/docs/lib/Components/index.js @@ -86,6 +86,10 @@ const items = [ name: 'Spinners', to: '/components/spinners/' }, + { + name: 'Toasts', + to: '/components/toasts/' + }, { name: 'Pagination', to: '/components/pagination/' diff --git a/docs/lib/examples/Toast.js b/docs/lib/examples/Toast.js new file mode 100644 index 000000000..c2792041e --- /dev/null +++ b/docs/lib/examples/Toast.js @@ -0,0 +1,111 @@ +import React from 'react'; +import { Toast, ToastBody, ToastHeader } from 'reactstrap'; + +const Example = (props) => { + return ( +
+
+ + + Reactstrap + + + This is a toast on a white background — check it out! + + +
+
+ + + Reactstrap + + + This is a toast on a gridded background — check it out! + + +
+
+ + + Reactstrap + + + This is a toast on a primary background — check it out! + + +
+
+ + + Reactstrap + + + This is a toast on a secondary background — check it out! + + +
+
+ + + Reactstrap + + + This is a toast on a success background — check it out! + + +
+
+ + + Reactstrap + + + This is a toast on a danger background — check it out! + + +
+
+ + + Reactstrap + + + This is a toast on a warning background — check it out! + + +
+
+ + + Reactstrap + + + This is a toast on an info background — check it out! + + +
+
+ + + Reactstrap + + + This is a toast on a dark background — check it out! + + +
+
+ + + Reactstrap + + + This is a toast on a black background — check it out! + + +
+
+ ); +}; + +export default Example; diff --git a/docs/lib/examples/ToastDismiss.js b/docs/lib/examples/ToastDismiss.js new file mode 100644 index 000000000..c4097198b --- /dev/null +++ b/docs/lib/examples/ToastDismiss.js @@ -0,0 +1,39 @@ +/* eslint react/no-multi-comp: 0, react/prop-types: 0 */ + +import React from 'react'; +import { Button, Toast, ToastBody, ToastHeader } from 'reactstrap'; + +class ToastDismissExample extends React.Component { + constructor(props) { + super(props); + this.state = { + show: false + }; + + this.toggle = this.toggle.bind(this); + } + + toggle() { + this.setState({ + show: !this.state.show + }); + } + + render() { + return ( +
+ +
+
+ + Toast title + + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + + +
+ ); + } +} + +export default ToastDismissExample; diff --git a/docs/lib/examples/ToastHeaderIcon.js b/docs/lib/examples/ToastHeaderIcon.js new file mode 100644 index 000000000..00a326d02 --- /dev/null +++ b/docs/lib/examples/ToastHeaderIcon.js @@ -0,0 +1,83 @@ +import React from 'react'; +import { Toast, ToastBody, ToastHeader, Spinner } from 'reactstrap'; + +const Example = (props) => { + return ( +
+ + + Reactstrap + + + This is a toast with a primary icon — check it out! + + + + + Reactstrap + + + This is a toast with a secondary icon — check it out! + + + + + Reactstrap + + + This is a toast with a success icon — check it out! + + + + + Reactstrap + + + This is a toast with a danger icon — check it out! + + + + + Reactstrap + + + This is a toast with a warning icon — check it out! + + + + + Reactstrap + + + This is a toast with an info icon — check it out! + + + + + Reactstrap + + + This is a toast with a light icon — check it out! + + + + + Reactstrap + + + This is a toast with a dark icon — check it out! + + + + }> + Reactstrap + + + This is a toast with a custom icon — check it out! + + +
+ ); +}; + +export default Example; diff --git a/docs/lib/routes.js b/docs/lib/routes.js index f96004943..b3ea7c0fb 100644 --- a/docs/lib/routes.js +++ b/docs/lib/routes.js @@ -24,6 +24,7 @@ import PaginationPage from './Components/PaginationPage'; import TabsPage from './Components/TabsPage'; import JumbotronPage from './Components/JumbotronPage'; import AlertsPage from './Components/AlertsPage'; +import ToastsPage from './Components/ToastsPage'; import CollapsePage from './Components/CollapsePage'; import CarouselPage from './Components/CarouselPage'; import ListGroupPage from './Components/ListGroupPage'; @@ -62,6 +63,7 @@ const routes = ( + diff --git a/docs/static/docs.css b/docs/static/docs.css index 2287fe999..82737168b 100644 --- a/docs/static/docs.css +++ b/docs/static/docs.css @@ -224,51 +224,51 @@ pre, code { word-break: normal; word-wrap: normal; line-height: 1.5; - + -moz-tab-size: 4; -o-tab-size: 4; tab-size: 4; - + -webkit-hyphens: none; -moz-hyphens: none; -ms-hyphens: none; hyphens: none; } - + /* Code blocks */ pre[class*="language-"] { padding: 1em; margin: 1.5em 0; overflow: auto; } - + :not(pre) > code[class*="language-"], pre[class*="language-"] { background: #272822; } - + /* Inline code */ :not(pre) > code[class*="language-"] { padding: .1em; border-radius: .3em; white-space: normal; } - + .token.comment, .token.prolog, .token.doctype, .token.cdata { color: slategray; } - + .token.punctuation { color: #f8f8f2; } - + .namespace { opacity: .7; } - + .token.property, .token.tag, .token.constant, @@ -276,12 +276,12 @@ pre, code { .token.deleted { color: #f92672; } - + .token.boolean, .token.number { color: #ae81ff; } - + .token.selector, .token.attr-name, .token.string, @@ -290,7 +290,7 @@ pre, code { .token.inserted { color: #a6e22e; } - + .token.operator, .token.entity, .token.url, @@ -299,22 +299,22 @@ pre, code { .token.variable { color: #f8f8f2; } - + .token.atrule, .token.attr-value, .token.function { color: #e6db74; } - + .token.keyword { color: #66d9ef; } - + .token.regex, .token.important { color: #fd971f; } - + .token.important, .token.bold { font-weight: bold; @@ -322,11 +322,11 @@ pre, code { .token.italic { font-style: italic; } - + .token.entity { cursor: help; } - + .token a { color: inherit; } @@ -336,3 +336,7 @@ code .tag { padding: 0; display: inherit; } + +.bg-docs-transparent-grid { + background: url(./transparent.svg); +} \ No newline at end of file diff --git a/docs/static/transparent.svg b/docs/static/transparent.svg new file mode 100644 index 000000000..103178de2 --- /dev/null +++ b/docs/static/transparent.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/Toast.js b/src/Toast.js new file mode 100644 index 000000000..46961dc0d --- /dev/null +++ b/src/Toast.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { mapToCssModules, tagPropType } from './utils'; +import Fade from './Fade'; + +const propTypes = { + children: PropTypes.node, + className: PropTypes.string, + cssModule: PropTypes.object, + fade: PropTypes.bool, + isOpen: PropTypes.bool, + tag: tagPropType, + transition: PropTypes.shape(Fade.propTypes), + innerRef: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.string, + PropTypes.func, + ]), +}; + +const defaultProps = { + isOpen: true, + tag: 'div', + fade: true, + transition: { + ...Fade.defaultProps, + unmountOnExit: true, + }, +}; + +function Toast(props) { + const { + className, + cssModule, + tag: Tag, + isOpen, + children, + transition, + fade, + innerRef, + ...attributes + } = props; + + const classes = mapToCssModules(classNames(className, 'toast'), cssModule); + + const toastTransition = { + ...Fade.defaultProps, + ...transition, + baseClass: fade ? transition.baseClass : '', + timeout: fade ? transition.timeout : 0, + }; + + return ( + + {children} + + ); +} + +Toast.propTypes = propTypes; +Toast.defaultProps = defaultProps; + +export default Toast; diff --git a/src/ToastBody.js b/src/ToastBody.js new file mode 100644 index 000000000..b8660b5bc --- /dev/null +++ b/src/ToastBody.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { mapToCssModules, tagPropType } from './utils'; + +const propTypes = { + tag: tagPropType, + className: PropTypes.string, + cssModule: PropTypes.object, + innerRef: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.string, + PropTypes.func, + ]), +}; + +const defaultProps = { + tag: 'div' +}; + +const ToastBody = (props) => { + const { + className, + cssModule, + innerRef, + tag: Tag, + ...attributes + } = props; + const classes = mapToCssModules(classNames( + className, + 'toast-body' + ), cssModule); + + return ( + + ); +}; + +ToastBody.propTypes = propTypes; +ToastBody.defaultProps = defaultProps; + +export default ToastBody; diff --git a/src/ToastHeader.js b/src/ToastHeader.js new file mode 100644 index 000000000..d9a35d40d --- /dev/null +++ b/src/ToastHeader.js @@ -0,0 +1,90 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { mapToCssModules, tagPropType } from './utils'; + +const propTypes = { + tag: tagPropType, + icon: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + wrapTag: tagPropType, + toggle: PropTypes.func, + className: PropTypes.string, + cssModule: PropTypes.object, + children: PropTypes.node, + closeAriaLabel: PropTypes.string, + charCode: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + close: PropTypes.object, +}; + +const defaultProps = { + tag: 'strong', + wrapTag: 'div', + tagClassName: 'mr-auto', + closeAriaLabel: 'Close', + charCode: 215, +}; + +const ToastHeader = (props) => { + let closeButton; + let icon; + const { + className, + cssModule, + children, + toggle, + tag: Tag, + wrapTag: WrapTag, + closeAriaLabel, + charCode, + close, + tagClassName, + icon: iconProp, + ...attributes } = props; + + const classes = mapToCssModules(classNames( + className, + 'toast-header' + ), cssModule); + + if (!close && toggle) { + const closeIcon = typeof charCode === 'number' ? String.fromCharCode(charCode) : charCode; + closeButton = ( + + ); + } + + if (typeof iconProp === "string") { + icon = ( + + + + ); + } else if (iconProp) { + icon = iconProp; + } + + return ( + + {icon} + + {children} + + {close || closeButton} + + ); +}; + +ToastHeader.propTypes = propTypes; +ToastHeader.defaultProps = defaultProps; + +export default ToastHeader; diff --git a/src/__tests__/Toast.spec.js b/src/__tests__/Toast.spec.js new file mode 100644 index 000000000..f7562d062 --- /dev/null +++ b/src/__tests__/Toast.spec.js @@ -0,0 +1,59 @@ +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { Toast } from '../'; + +describe('Toast', () => { + it('should render children', () => { + const toast = mount(Yo!); + expect(toast.text()).toBe('Yo!'); + }); + + it('should pass className down', () => { + const toast = mount(Yo!); + expect(toast.find('.toast').hostNodes().prop('className')).toContain('test-class-name'); + }); + + it('should pass other props down', () => { + const toast = mount(Yo!); + expect(toast.find('.toast').hostNodes().prop('data-testprop')).toContain('testvalue'); + }); + + it('should have default transitionTimeouts', () => { + const toast = mount(Yo!); + + const transition = toast.find('Transition'); + expect(transition.prop('timeout')).toEqual(150); + expect(transition.prop('appear')).toBe(true); + expect(transition.prop('enter')).toBe(true); + expect(transition.prop('exit')).toBe(true); + }); + + it('should have support configurable transitionTimeouts', () => { + const toast = mount( + + Yo! + + ); + + const transition = toast.find('Transition'); + expect(transition.prop('timeout')).toEqual(0); + expect(transition.prop('appear')).toBe(false); + expect(transition.prop('enter')).toBe(false); + expect(transition.prop('exit')).toBe(false); + }); + + it('should use a div tag by default', () => { + const toast = mount(Yo!); + expect(toast.find('div').hostNodes().length).toBe(1); + }); + + it('should support custom tag', () => { + const toast = mount(Yo!); + expect(toast.find('p').hostNodes().length).toBe(1); + }); + + it('should be empty if not isOpen', () => { + const toast = shallow(Yo!); + expect(toast.html()).toBe(''); + }); +}); diff --git a/src/__tests__/ToastBody.spec.js b/src/__tests__/ToastBody.spec.js new file mode 100644 index 000000000..0ae57af76 --- /dev/null +++ b/src/__tests__/ToastBody.spec.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ToastBody } from '../'; + +describe('ToastBody', () => { + it('should render with "toast-body" class', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.text()).toBe('Yo!'); + expect(wrapper.hasClass('toast-body')).toBe(true); + }); + + it('should render additional classes', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.hasClass('other')).toBe(true); + expect(wrapper.hasClass('toast-body')).toBe(true); + }); + + it('should render custom tag', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.text()).toBe('Yo!'); + expect(wrapper.hasClass('toast-body')).toBe(true); + expect(wrapper.type()).toBe('main'); + }); +}); diff --git a/src/__tests__/ToastHeader.spec.js b/src/__tests__/ToastHeader.spec.js new file mode 100644 index 000000000..87a22275c --- /dev/null +++ b/src/__tests__/ToastHeader.spec.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ToastHeader } from '../'; + +describe('ToastHeader', () => { + it('should render with "toast-header" class', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.text()).toBe('Yo!'); + expect(wrapper.hasClass('toast-header')).toBe(true); + }); + + it('should render additional classes', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.hasClass('other')).toBe(true); + expect(wrapper.hasClass('toast-header')).toBe(true); + }); + + it('should render close button', () => { + const wrapper = shallow( { }} className="other">Yo!); + + expect(wrapper.hasClass('other')).toBe(true); + expect(wrapper.hasClass('toast-header')).toBe(true); + expect(wrapper.find('button.close').length).toBe(1); + }); + + it('should render custom tag', () => { + const wrapper = shallow(Yo!).childAt(0); + + expect(wrapper.text()).toBe('Yo!'); + expect(wrapper.type()).toBe('p'); + }); + + it('should render custom wrapping tag', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.type()).toBe('main'); + }); + + it('should render close button with custom aria-label', () => { + const wrapper = shallow( { }} className="other" closeAriaLabel="oseclay">Yo!); + + const closeButton = wrapper.find('button.close').first(); + expect(closeButton.prop('aria-label')).toBe('oseclay'); + }); + + it('should render close button with default icon', () => { + const wrapper = shallow( { }}>Yo!); + + const closeButtonIcon = wrapper.find('button.close span'); + const defaultIcon = String.fromCharCode(215); + expect(closeButtonIcon.text()).toEqual(defaultIcon); + }); + + it('should render close button with custom icon', () => { + const wrapper = shallow( { }} charCode={'X'}>Yo!); + + const closeButtonIcon = wrapper.find('button.close span'); + expect(closeButtonIcon.text()).toEqual('X'); + }); + + it('should render icon with a color', () => { + const wrapper = shallow(Yo!); + + const closeButtonIcon = wrapper.find('svg'); + expect(closeButtonIcon.hasClass('text-primary')).toBe(true); + }); + + it('should render a custom icon', () => { + const wrapper = shallow(icon}>Yo!); + + const closeButtonIcon = wrapper.find('span.my-header'); + expect(closeButtonIcon.text()).toEqual("icon"); + }); +}); diff --git a/src/index.js b/src/index.js index a841338bf..dfd75e767 100644 --- a/src/index.js +++ b/src/index.js @@ -76,6 +76,9 @@ export TabContent from './TabContent'; export TabPane from './TabPane'; export Jumbotron from './Jumbotron'; export Alert from './Alert'; +export Toast from './Toast'; +export ToastBody from './ToastBody'; +export ToastHeader from './ToastHeader'; export Collapse from './Collapse'; export ListGroupItem from './ListGroupItem'; export ListGroupItemHeading from './ListGroupItemHeading'; diff --git a/webpack.docs.config.js b/webpack.docs.config.js index 0df6b1f9b..341c75152 100644 --- a/webpack.docs.config.js +++ b/webpack.docs.config.js @@ -34,6 +34,7 @@ const paths = [ '/components/tabs/', '/components/jumbotron/', '/components/alerts/', + '/components/toasts/', '/components/collapse/', '/components/carousel/', '/components/listgroup/',