diff --git a/.babelrc b/.babelrc index 671a63b..b4a673c 100644 --- a/.babelrc +++ b/.babelrc @@ -1,5 +1,5 @@ { - "presets": ["react", "es2015"], + "presets": ["react", "es2015", "stage-0"], "plugins": ["add-module-exports"], "env": { "development": { diff --git a/README.md b/README.md index a6ac5f2..7cfb847 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ React-portal ## Features -- transports its child into a new React component and appends it to the **document.body** (creates a new independent React tree) +- transports its child into a new React component and appends it to specified DOM element (**document.body** by default) +- creates a new independent React tree - can be opened by the prop **isOpened** - can be opened after a click on an element that you pass through the prop **openByClickOn** (and then it takes care of the open/close state) - doesn't leave any mess in DOM after closing @@ -37,6 +38,7 @@ npm install react react-dom react-portal --save ``` ## Usage + ```jsx import React from 'react'; import ReactDOM from 'react-dom'; @@ -92,6 +94,9 @@ with `onClick` handler that triggers portal opening. **How to close the portal t ### Optional +#### target: HTMLElement +The element you want portal to be appended to. + #### closeOnEsc: bool If true, the portal can be closed by the key ESC. diff --git a/lib/portal.js b/lib/portal.js index fcfb7cc..c633f4e 100644 --- a/lib/portal.js +++ b/lib/portal.js @@ -9,6 +9,27 @@ const KEYCODES = { export default class Portal extends React.Component { + static defaultProps = { + onOpen: () => {}, + onClose: () => {}, + onUpdate: () => {}, + }; + + static propTypes = { + className: React.PropTypes.string, + style: React.PropTypes.object, + children: React.PropTypes.element.isRequired, + target: React.PropTypes.instanceOf(window.HTMLElement), + openByClickOn: React.PropTypes.element, + closeOnEsc: React.PropTypes.bool, + closeOnOutsideClick: React.PropTypes.bool, + isOpened: React.PropTypes.bool, + onOpen: React.PropTypes.func, + onClose: React.PropTypes.func, + beforeClose: React.PropTypes.func, + onUpdate: React.PropTypes.func, + }; + constructor() { super(); this.state = { active: false }; @@ -90,7 +111,7 @@ export default class Portal extends React.Component { const resetPortalState = () => { if (this.node) { ReactDOM.unmountComponentAtNode(this.node); - document.body.removeChild(this.node); + (this.props.target || document.body).removeChild(this.node); } this.portal = null; this.node = null; @@ -145,7 +166,7 @@ export default class Portal extends React.Component { this.node = document.createElement('div'); // apply CSS before the node is added to the DOM to avoid needless reflows this.applyClassNameAndStyle(props); - document.body.appendChild(this.node); + (props.target || document.body).appendChild(this.node); } else { // update CSS when new props arrive this.applyClassNameAndStyle(props); @@ -172,23 +193,3 @@ export default class Portal extends React.Component { return null; } } - -Portal.propTypes = { - className: React.PropTypes.string, - style: React.PropTypes.object, - children: React.PropTypes.element.isRequired, - openByClickOn: React.PropTypes.element, - closeOnEsc: React.PropTypes.bool, - closeOnOutsideClick: React.PropTypes.bool, - isOpened: React.PropTypes.bool, - onOpen: React.PropTypes.func, - onClose: React.PropTypes.func, - beforeClose: React.PropTypes.func, - onUpdate: React.PropTypes.func, -}; - -Portal.defaultProps = { - onOpen: () => {}, - onClose: () => {}, - onUpdate: () => {}, -}; diff --git a/package.json b/package.json index ee65ab1..cb5a3cb 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "build:examples": "npm run clean && npm run build:examples:webpack", "build:examples:webpack": "cross-env NODE_ENV=production webpack --config webpack.config.prod.babel.js", "clean": "rimraf build", - "test": "mocha", + "test": "mocha ./test/portal_spec.js", "lint": "mocha test/eslint_spec.js", "prepublish": "cross-env NODE_ENV=production npm run build" }, @@ -46,6 +46,7 @@ "babel-preset-es2015": "^6.3.13", "babel-preset-react": "^6.3.13", "babel-preset-react-hmre": "^1.0.1", + "babel-preset-stage-0": "^6.5.0", "babel-register": "^6.8.0", "cross-env": "^1.0.7", "enzyme": "^2.3.0", diff --git a/test/mocha.js b/test/mocha.js index f7534b3..3da7742 100644 --- a/test/mocha.js +++ b/test/mocha.js @@ -1 +1,6 @@ +import setup from './setup'; + process.env.NODE_ENV = 'test'; +// Calling setup befere any components are imported in specs +// Otherwise window object can not be used in static methods +setup(); diff --git a/test/portal_spec.js b/test/portal_spec.js index d634eca..df5e66e 100644 --- a/test/portal_spec.js +++ b/test/portal_spec.js @@ -1,18 +1,15 @@ -import jsdom from 'jsdom'; import Portal from '../lib/portal'; import assert from 'assert'; import { spy } from 'sinon'; import { render, unmountComponentAtNode } from 'react-dom'; import { mount } from 'enzyme'; +import setup from './setup'; describe('react-portal', () => { let React; beforeEach(() => { // Set up JSDOM - global.document = jsdom.jsdom(''); - global.window = document.defaultView; - global.navigator = { userAgent: 'node.js' }; - // Enzyme library uses React + setup(); /*eslint-disable */ React = require('react'); /*eslint-enable */ @@ -29,13 +26,6 @@ describe('react-portal', () => { /*eslint-enable */ }); - it('should append portal with children to the document.body', () => { - const wrapper = mount(

Hi

); - assert.equal(wrapper.instance().node.firstElementChild.tagName, 'P'); - assert.equal(document.body.lastElementChild, wrapper.instance().node); - assert.equal(document.body.childElementCount, 1); - }); - it('should open when this.openPortal() is called (used to programmatically open portal)', () => { const wrapper = mount(

Hi

); assert.equal(document.body.childElementCount, 0); @@ -231,4 +221,39 @@ describe('react-portal', () => { assert.equal(document.body.childElementCount, 0); }); }); + + describe('target', () => { + context('when target is not set', () => { + it('should append portal with children to the document.body', () => { + const wrapper = mount(

Hi

); + assert.equal(wrapper.instance().node.firstElementChild.tagName, 'P'); + assert.equal(document.body.lastElementChild, wrapper.instance().node); + assert.equal(document.body.childElementCount, 1); + }); + }); + + context('when target is set', () => { + context('when layer passed to component as a prop', () => { + it('should append portal with children to the target', () => { + const modalLayer = document.createElement('div'); + document.body.appendChild(modalLayer); + + const wrapper = mount(

Hi

); + assert.equal(modalLayer.getElementsByTagName('p')[0].textContent, 'Hi'); + assert.equal(modalLayer.lastElementChild, wrapper.instance().node); + assert.equal(modalLayer.childElementCount, 1); + }); + + it('should remove portal from the target when isOpened set to false', () => { + const modalLayer = document.createElement('div'); + document.body.appendChild(modalLayer); + + const wrapper = mount(

Hi

); + assert.equal(modalLayer.getElementsByTagName('p')[0].textContent, 'Hi'); + wrapper.setProps({ isOpened: false }); + assert(!modalLayer.getElementsByTagName('p')[0]); + }); + }); + }); + }); }); diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 0000000..0600f26 --- /dev/null +++ b/test/setup.js @@ -0,0 +1,7 @@ +import jsdom from 'jsdom'; + +export default function setup() { + global.document = jsdom.jsdom(''); + global.window = document.defaultView; + global.navigator = { userAgent: 'node.js' }; +}