From a84df684a0eed000947c9ecbaeccb812f93a0417 Mon Sep 17 00:00:00 2001 From: Eddy Hernandez Date: Sat, 12 Mar 2016 19:39:01 -0800 Subject: [PATCH 1/4] feat(Fade): enable fading components --- lib/Fade.jsx | 59 +++++++++++++++++++++++++++++++++++++++++++ lib/index.js | 2 ++ test/Fade.spec.js | 64 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 lib/Fade.jsx create mode 100644 test/Fade.spec.js diff --git a/lib/Fade.jsx b/lib/Fade.jsx new file mode 100644 index 000000000..a52872548 --- /dev/null +++ b/lib/Fade.jsx @@ -0,0 +1,59 @@ +import React, { PropTypes } from 'react'; +import classNames from 'classnames'; + +const propTypes = { + className: PropTypes.string +}; + +const defaultProps = {}; + +class Fade extends React.Component { + constructor(props) { + super(props); + + this.state = { + fadeIn: false + }; + + this.fade = this.fade.bind(this); + this.fadeIn = this.fadeIn.bind(this); + this.fadeOut = this.fadeOut.bind(this); + } + + fade(fade, cb, delay) { + this.setState({ + fadeIn: fade + }); + + if (cb) { + setTimeout(cb, delay); + } + } + + fadeIn(cb, delay) { + this.fade(true, cb, delay); + } + + fadeOut(cb, delay) { + this.fade(false, cb, delay); + } + + render() { + const classes = classNames( + this.props.className, + 'fade', + { + in: this.state.fadeIn + } + ); + + return ( +
+ ); + } +} + +Fade.propTypes = propTypes; +Fade.defaultProps = defaultProps; + +export default Fade; diff --git a/lib/index.js b/lib/index.js index 7f8c84c7f..1d72d7612 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6,6 +6,7 @@ import Dropdown from './Dropdown'; import DropdownItem from './DropdownItem'; import DropdownMenu from './DropdownMenu'; import DropdownToggle from './DropdownToggle'; +import Fade from './Fade'; import Label from './Label'; import Popover from './Popover'; import PopoverTitle from './PopoverTitle'; @@ -22,6 +23,7 @@ export { DropdownItem, DropdownMenu, DropdownToggle, + Fade, Label, Popover, PopoverContent, diff --git a/test/Fade.spec.js b/test/Fade.spec.js new file mode 100644 index 000000000..6920b031f --- /dev/null +++ b/test/Fade.spec.js @@ -0,0 +1,64 @@ +/* eslint react/no-multi-comp: 0, react/prop-types: 0 */ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { shallow, mount } from 'enzyme'; +import { Fade } from '../lib'; + +describe('Fade', () => { + it('should render with "fade" class', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.hasClass('fade')).toBe(true); + expect(wrapper.hasClass('in')).toBe(false); + expect(wrapper.text()).toBe('Yo!'); + }); + + it('should render additional classes', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.hasClass('other')).toBe(true); + expect(wrapper.hasClass('fade')).toBe(true); + expect(wrapper.hasClass('in')).toBe(false); + }); + + it('should toggle "in" class with fadeIn & fadeOut', () => { + const wrapper = mount(Yo!); + const instance = wrapper.instance(); + + expect(wrapper.hasClass('in')).toBe(false); + + instance.fadeIn(); + // hasClass does not pick up updates + // maybe related https://github.com/airbnb/enzyme/issues/134 + expect(ReactDOM.findDOMNode(instance).className).toBe('fade in'); + + instance.fadeOut(); + + expect(ReactDOM.findDOMNode(instance).className).toBe('fade'); + }); + + it('should call fadeIn & fadeOut with delayed callbacks', () => { + jasmine.clock().install(); + + const fadeInCallback = jasmine.createSpy('spy'); + const fadeOutCallback = jasmine.createSpy('spy'); + const wrapper = mount(Yo!); + const instance = wrapper.instance(); + + expect(wrapper.hasClass('in')).toBe(false); + + instance.fadeIn(fadeInCallback, 250); + expect(fadeInCallback).not.toHaveBeenCalled(); + + jasmine.clock().tick(250); + expect(fadeInCallback).toHaveBeenCalled(); + + instance.fadeOut(fadeOutCallback, 250); + expect(fadeOutCallback).not.toHaveBeenCalled(); + + jasmine.clock().tick(250); + expect(fadeOutCallback).toHaveBeenCalled(); + + jasmine.clock().uninstall(); + }); +}); From 6c2293eef591bced9f78af24283f7e6c1dfe4465 Mon Sep 17 00:00:00 2001 From: Eddy Hernandez Date: Sat, 12 Mar 2016 19:40:44 -0800 Subject: [PATCH 2/4] feat(Modal): add modal components --- lib/Modal.jsx | 158 +++++++++++++++++++++++++++ lib/ModalBody.jsx | 21 ++++ lib/ModalFooter.jsx | 21 ++++ lib/ModalHeader.jsx | 46 ++++++++ lib/index.js | 8 ++ test/Modal.spec.js | 225 +++++++++++++++++++++++++++++++++++++++ test/ModalBody.spec.js | 19 ++++ test/ModalFooter.spec.js | 19 ++++ test/ModalHeader.spec.js | 27 +++++ 9 files changed, 544 insertions(+) create mode 100644 lib/Modal.jsx create mode 100644 lib/ModalBody.jsx create mode 100644 lib/ModalFooter.jsx create mode 100644 lib/ModalHeader.jsx create mode 100644 test/Modal.spec.js create mode 100644 test/ModalBody.spec.js create mode 100644 test/ModalFooter.spec.js create mode 100644 test/ModalHeader.spec.js diff --git a/lib/Modal.jsx b/lib/Modal.jsx new file mode 100644 index 000000000..3643238dc --- /dev/null +++ b/lib/Modal.jsx @@ -0,0 +1,158 @@ +import React, { PropTypes } from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import Fade from './Fade'; + +const propTypes = { + isOpen: PropTypes.bool, + size: PropTypes.string, + toggle: PropTypes.func.isRequired, + onEnter: PropTypes.func, + onExit: PropTypes.func +}; + +const defaultProps = { + isOpen: false +}; + +class Modal extends React.Component { + constructor(props) { + super(props); + + this.handleProps = this.handleProps.bind(this); + this.handleBackdropClick = this.handleBackdropClick.bind(this); + this.handleEscape = this.handleEscape.bind(this); + this.destroy = this.destroy.bind(this); + this.onEnter = this.onEnter.bind(this); + this.onExit = this.onExit.bind(this); + } + + componentDidMount() { + if (this.props.isOpen) { + this.show(); + } + } + + componentDidUpdate(prevProps) { + if (this.props.isOpen !== prevProps.isOpen) { + this.handleProps(); + } + } + + componentWillUnmount() { + this.hide(); + } + + onEnter() { + this._modal.fadeIn(); + if (this.props.onEnter) { + this.props.onEnter(); + } + } + + onExit() { + this.destroy(); + if (this.props.onExit) { + this.props.onExit(); + } + } + + handleEscape(e) { + if (e.keyCode === 27) { + this.props.toggle(); + } + } + + handleBackdropClick(e) { + const container = ReactDOM.findDOMNode(this._dialog); + + if (e.target && !container.contains(e.target)) { + this.props.toggle(); + } + } + + handleProps() { + if (this.props.isOpen) { + this.show(); + } else { + this.hide(); + } + } + + destroy() { + let classes = document.body.className.replace('modal-open', ''); + this.removeEvents(); + + if (this._element) { + ReactDOM.unmountComponentAtNode(this._element); + document.body.removeChild(this._element); + this._element = null; + } + + document.body.className = classNames(classes).trim(); + } + + removeEvents() { + document.removeEventListener('click', this.handleBackdropClick, false); + document.removeEventListener('keyup', this.handleEscape, false); + } + + hide() { + this.removeEvents(); + + if (this._modal) { + this._modal.fadeOut(); + } + if (this._backdrop) { + this._backdrop.fadeOut(this.onExit, 250); + } + } + + show() { + const classes = document.body.className; + this._element = document.createElement('div'); + this._element.setAttribute('tabindex', '-1'); + + document.body.appendChild(this._element); + document.addEventListener('click', this.handleBackdropClick, false); + document.addEventListener('keyup', this.handleEscape, false); + + document.body.className = classNames( + classes, + 'modal-open' + ); + + ReactDOM.unstable_renderSubtreeIntoContainer( + this, + this.renderChildren(), + this._element + ); + + this._element.focus(); + this._backdrop.fadeIn(this.onEnter, 100); + } + + renderChildren() { + return ( +
+ this._modal = c }> +
this._dialog = c }> +
+ { this.props.children } +
+
+
+ this._backdrop = c }/> +
+ ); + } + + render() { + return null; + } +} + +Modal.propTypes = propTypes; +Modal.defaultProps = defaultProps; + +export default Modal; diff --git a/lib/ModalBody.jsx b/lib/ModalBody.jsx new file mode 100644 index 000000000..2fb2f83c6 --- /dev/null +++ b/lib/ModalBody.jsx @@ -0,0 +1,21 @@ +import React, { PropTypes } from 'react'; +import classNames from 'classnames'; + +const propTypes = { + className: PropTypes.string +}; + +const ModalBody = (props) => { + const classes = classNames( + props.className, + 'modal-body' + ); + + return ( +
+ ); +}; + +ModalBody.propTypes = propTypes; + +export default ModalBody; diff --git a/lib/ModalFooter.jsx b/lib/ModalFooter.jsx new file mode 100644 index 000000000..fd9a05ab3 --- /dev/null +++ b/lib/ModalFooter.jsx @@ -0,0 +1,21 @@ +import React, { PropTypes } from 'react'; +import classNames from 'classnames'; + +const propTypes = { + className: PropTypes.string +}; + +const ModalFooter = (props) => { + const classes = classNames( + props.className, + 'modal-footer' + ); + + return ( +
+ ); +}; + +ModalFooter.propTypes = propTypes; + +export default ModalFooter; diff --git a/lib/ModalHeader.jsx b/lib/ModalHeader.jsx new file mode 100644 index 000000000..96cabf9cb --- /dev/null +++ b/lib/ModalHeader.jsx @@ -0,0 +1,46 @@ +import React, { PropTypes } from 'react'; +import classNames from 'classnames'; + +const propTypes = { + toggle: PropTypes.func +}; + +const defaultProps = {}; + +class ModalHeader extends React.Component { + render() { + let closeButton; + const { + className, + children, + toggle, + ...props } = this.props; + + const classes = classNames( + className, + 'modal-header' + ); + + if (toggle) { + closeButton = ( + + ); + } + + return ( +
+ { closeButton } +

+ { children } +

+
+ ); + } +} + +ModalHeader.propTypes = propTypes; +ModalHeader.defaultProps = defaultProps; + +export default ModalHeader; diff --git a/lib/index.js b/lib/index.js index 1d72d7612..8763a8f4b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -11,6 +11,10 @@ import Label from './Label'; import Popover from './Popover'; import PopoverTitle from './PopoverTitle'; import PopoverContent from './PopoverContent'; +import Modal from './Modal'; +import ModalHeader from './ModalHeader'; +import ModalBody from './ModalBody'; +import ModalFooter from './ModalFooter'; import TetherContent from './TetherContent'; import Tooltip from './Tooltip'; @@ -28,6 +32,10 @@ export { Popover, PopoverContent, PopoverTitle, + Modal, + ModalHeader, + ModalBody, + ModalFooter, TetherContent, Tooltip }; diff --git a/test/Modal.spec.js b/test/Modal.spec.js new file mode 100644 index 000000000..80ceb9293 --- /dev/null +++ b/test/Modal.spec.js @@ -0,0 +1,225 @@ +/* eslint react/no-multi-comp: 0, react/prop-types: 0 */ +import React from 'react'; +import { mount } from 'enzyme'; +import { Modal } from '../lib'; + +describe('Modal', () => { + let isOpen; + let toggle; + + beforeEach(() => { + isOpen = false; + toggle = () => { isOpen = !isOpen; }; + jasmine.clock().install(); + }); + + afterEach(() => { + // fast forward time for modal to fade out + jasmine.clock().tick(300); + jasmine.clock().uninstall(); + }); + + + it('should render modal when isOpen is true', () => { + isOpen = true; + const wrapper = mount( + + Yo! + + ); + + jasmine.clock().tick(300); + expect(wrapper.children().length).toBe(0); + expect(document.getElementsByClassName('modal').length).toBe(1); + expect(document.getElementsByClassName('modal-backdrop').length).toBe(1); + wrapper.unmount(); + }); + + it('should not render modal when isOpen is false', () => { + const wrapper = mount( + + Yo! + + ); + + jasmine.clock().tick(300); + expect(wrapper.children().length).toBe(0); + expect(document.getElementsByClassName('modal').length).toBe(0); + expect(document.getElementsByClassName('modal-backdrop').length).toBe(0); + wrapper.unmount(); + }); + + it('should toggle modal', () => { + const wrapper = mount( + + Yo! + + ); + + jasmine.clock().tick(300); + expect(isOpen).toBe(false); + expect(document.getElementsByClassName('modal').length).toBe(0); + expect(document.getElementsByClassName('modal-backdrop').length).toBe(0); + + toggle(); + wrapper.setProps({ + isOpen: isOpen + }); + + jasmine.clock().tick(300); + expect(isOpen).toBe(true); + expect(document.getElementsByClassName('modal').length).toBe(1); + expect(document.getElementsByClassName('modal-backdrop').length).toBe(1); + wrapper.unmount(); + }); + + it('should call onExit & onEnter', () => { + spyOn(Modal.prototype, 'onEnter').and.callThrough(); + spyOn(Modal.prototype, 'onExit').and.callThrough(); + const onEnter = jasmine.createSpy('spy'); + const onExit = jasmine.createSpy('spy'); + const wrapper = mount( + + Yo! + + ); + + jasmine.clock().tick(300); + expect(isOpen).toBe(false); + expect(onEnter).not.toHaveBeenCalled(); + expect(Modal.prototype.onEnter).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + expect(Modal.prototype.onExit).not.toHaveBeenCalled(); + + toggle(); + wrapper.setProps({ + isOpen: isOpen + }); + jasmine.clock().tick(300); + + expect(isOpen).toBe(true); + expect(onEnter).toHaveBeenCalled(); + expect(Modal.prototype.onEnter).toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + expect(Modal.prototype.onExit).not.toHaveBeenCalled(); + + toggle(); + wrapper.setProps({ + isOpen: isOpen + }); + jasmine.clock().tick(300); + + expect(isOpen).toBe(false); + expect(onExit).toHaveBeenCalled(); + expect(Modal.prototype.onExit).toHaveBeenCalled(); + + wrapper.unmount(); + }); + + it('should not call handleProps when isOpen does not change', () => { + spyOn(Modal.prototype, 'handleProps').and.callThrough(); + spyOn(Modal.prototype, 'componentDidUpdate').and.callThrough(); + const wrapper = mount( + + Yo! + + ); + + jasmine.clock().tick(300); + expect(isOpen).toBe(false); + expect(Modal.prototype.handleProps).not.toHaveBeenCalled(); + expect(Modal.prototype.componentDidUpdate).not.toHaveBeenCalled(); + + wrapper.setProps({ + isOpen: isOpen + }); + jasmine.clock().tick(300); + + expect(isOpen).toBe(false); + expect(Modal.prototype.handleProps).not.toHaveBeenCalled(); + expect(Modal.prototype.componentDidUpdate).toHaveBeenCalled(); + + wrapper.unmount(); + }); + + it('should close modal when escape key pressed', () => { + isOpen = true; + const wrapper = mount( + + Yo! + + ); + const instance = wrapper.instance(); + + jasmine.clock().tick(300); + + expect(isOpen).toBe(true); + expect(document.getElementsByClassName('modal').length).toBe(1); + + instance.handleEscape({keyCode: 13}); + jasmine.clock().tick(300); + + expect(isOpen).toBe(true); + expect(document.getElementsByClassName('modal').length).toBe(1); + + instance.handleEscape({keyCode: 27}); + jasmine.clock().tick(300); + + expect(isOpen).toBe(false); + + wrapper.setProps({ + isOpen: isOpen + }); + jasmine.clock().tick(300); + + expect(document.getElementsByClassName('modal').length).toBe(0); + + wrapper.unmount(); + }); + + it('should close modal when clicking backdrop', () => { + isOpen = true; + const wrapper = mount( + + + + ); + + jasmine.clock().tick(300); + + expect(isOpen).toBe(true); + expect(document.getElementsByClassName('modal').length).toBe(1); + // + document.getElementById('clicker').click(); + jasmine.clock().tick(300); + + expect(isOpen).toBe(true); + + document.getElementsByClassName('modal-backdrop')[0].click(); + jasmine.clock().tick(300); + + expect(isOpen).toBe(false); + + wrapper.unmount(); + }); + + it('should destroy this._element', () => { + isOpen = true; + const wrapper = mount( + + + + ); + const instance = wrapper.instance(); + + jasmine.clock().tick(300); + expect(instance._element).toBeTruthy() + + instance.destroy(); + + expect(instance._element).toBe(null) + + instance.destroy(); + wrapper.unmount(); + }); +}); diff --git a/test/ModalBody.spec.js b/test/ModalBody.spec.js new file mode 100644 index 000000000..fbc17d286 --- /dev/null +++ b/test/ModalBody.spec.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ModalBody } from '../lib'; + +describe('ModalBody', () => { + it('should render with "modal-body" class', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.text()).toBe('Yo!'); + expect(wrapper.hasClass('modal-body')).toBe(true); + }); + + it('should render additional classes', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.hasClass('other')).toBe(true); + expect(wrapper.hasClass('modal-body')).toBe(true); + }); +}); diff --git a/test/ModalFooter.spec.js b/test/ModalFooter.spec.js new file mode 100644 index 000000000..afad998d3 --- /dev/null +++ b/test/ModalFooter.spec.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ModalFooter } from '../lib'; + +describe('ModalFooter', () => { + it('should render with "modal-footer" class', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.text()).toBe('Yo!'); + expect(wrapper.hasClass('modal-footer')).toBe(true); + }); + + it('should render additional classes', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.hasClass('modal-footer')).toBe(true); + expect(wrapper.hasClass('other')).toBe(true); + }); +}); diff --git a/test/ModalHeader.spec.js b/test/ModalHeader.spec.js new file mode 100644 index 000000000..9d25869b8 --- /dev/null +++ b/test/ModalHeader.spec.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ModalHeader } from '../lib'; + +describe('ModalHeader', () => { + it('should render with "modal-header" class', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.text()).toBe('Yo!'); + expect(wrapper.hasClass('modal-header')).toBe(true); + }); + + it('should render additional classes', () => { + const wrapper = shallow(Yo!); + + expect(wrapper.hasClass('other')).toBe(true); + expect(wrapper.hasClass('modal-header')).toBe(true); + }); + + it('should render close button', () => { + const wrapper = shallow( {}} className="other">Yo!); + + expect(wrapper.hasClass('other')).toBe(true); + expect(wrapper.hasClass('modal-header')).toBe(true); + expect(wrapper.find('button.close').length).toBe(1); + }); +}); From 515a635a0fd8d3a80a3fb06c3171f316883227c3 Mon Sep 17 00:00:00 2001 From: Eddy Hernandez Date: Sat, 12 Mar 2016 19:41:22 -0800 Subject: [PATCH 3/4] docs(Layout): add modal to examples --- example/js/Layout.jsx | 2 ++ example/js/ModalExample.jsx | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 example/js/ModalExample.jsx diff --git a/example/js/Layout.jsx b/example/js/Layout.jsx index 1bc39bdd4..86276833c 100644 --- a/example/js/Layout.jsx +++ b/example/js/Layout.jsx @@ -1,6 +1,7 @@ import React from 'react'; import ButtonsExample from './ButtonsExample'; import DropdownsExample from './DropdownsExample'; +import ModalExample from './ModalExample'; import TetherExample from './TetherExample'; import TooltipExample from './TooltipExample'; import LabelsExample from './LabelsExample'; @@ -19,6 +20,7 @@ class Layout extends React.Component { +
diff --git a/example/js/ModalExample.jsx b/example/js/ModalExample.jsx new file mode 100644 index 000000000..e3604f6ec --- /dev/null +++ b/example/js/ModalExample.jsx @@ -0,0 +1,45 @@ +/* eslint react/no-multi-comp: 0, react/prop-types: 0 */ + +import React from 'react'; +import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'lib/index'; + +class ModalExample extends React.Component { + constructor(props) { + super(props); + this.state = { + modal: false + }; + + this.toggle = this.toggle.bind(this); + } + + toggle() { + this.setState({ + modal: !this.state.modal + }); + } + + render() { + return ( +
+

Modals

+
+

+ +

+ + Modal 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 ModalExample; From 1bd408fe47e09bfd91076185a78e8d2cce72b647 Mon Sep 17 00:00:00 2001 From: Eddy Hernandez Date: Sat, 12 Mar 2016 20:19:41 -0800 Subject: [PATCH 4/4] build(travis): use latest google chrome --- .travis.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 1553c991f..85f704247 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,9 +8,12 @@ addons: firefox: "latest" before_install: - - export CHROME_BIN=chromium-browser + - export CHROME_BIN=/usr/bin/google-chrome - export DISPLAY=:99.0 - sh -e /etc/init.d/xvfb start + - sudo apt-get install -y libappindicator1 fonts-liberation + - wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb + - sudo dpkg -i google-chrome*.deb after_script: - npm run coverage