diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..354c4e0 --- /dev/null +++ b/.babelrc @@ -0,0 +1,8 @@ +{ + "sourceMap": "inline", + "presets": ["es2015-loose", "stage-0", "react"], + "plugins": [ + "transform-object-rest-spread", + ["transform-react-jsx", { "pragma":"h" }] + ] +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9c378bd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = tab +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[{package.json,.*rc,*.yml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..44477dd --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +*.md +*.conf.js diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..c56fcd1 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,55 @@ +{ + "parser": "babel-eslint", + "extends": "eslint:recommended", + "env": { + "browser": true, + "mocha": true, + "es6": true + }, + "parserOptions": { + "ecmaFeatures": { + "modules": true, + "jsx": true + } + }, + "rules": { + "no-empty": 0, + "no-console": 0, + "no-unused-vars": [0, { "varsIgnorePattern": "^h$" }], + "no-cond-assign": 1, + "semi": 2, + "camelcase": 0, + "comma-style": 2, + "comma-dangle": [2, "never"], + "indent": [2, "tab", {"SwitchCase": 1}], + "no-mixed-spaces-and-tabs": [2, "smart-tabs"], + "no-trailing-spaces": [2, { "skipBlankLines": true }], + "max-nested-callbacks": [2, 3], + "no-eval": 2, + "no-implied-eval": 2, + "no-new-func": 2, + "guard-for-in": 2, + "eqeqeq": 2, + "no-else-return": 2, + "no-redeclare": 2, + "no-dupe-keys": 2, + "radix": 2, + "strict": [2, "never"], + "no-shadow": 0, + "no-delete-var": 2, + "no-undef-init": 2, + "no-shadow-restricted-names": 2, + "handle-callback-err": 0, + "no-lonely-if": 2, + "keyword-spacing": 2, + "constructor-super": 2, + "no-this-before-super": 2, + "no-dupe-class-members": 2, + "no-const-assign": 2, + "prefer-spread": 2, + "no-useless-concat": 2, + "no-var": 2, + "object-shorthand": 2, + "prefer-arrow-callback": 2 + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72e9b45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/dist +/node_modules +/npm-debug.log +.DS_Store diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..4c2095a --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +.eslintrc diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..8524235 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,3 @@ +language: node_js +node_js: + - 4 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..38d8969 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Jason Miller + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..19dc455 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# preact-portal + +[![NPM](https://img.shields.io/npm/v/preact-portal.svg?style=flat)](https://www.npmjs.org/package/preact-portal) +[![travis-ci](https://travis-ci.org/developit/preact-portal.svg?branch=master)](https://travis-ci.org/developit/preact-portal) + +> Render [Preact] components somewhere in [a different] space. + +Use this if you have a component that needs to render children into some other place in the DOM. + +An example of this would be modal dialogs, where you may need to render `` into ``. + + +**[JSFiddle Demo](http://jsfiddle.net/developit/f1jmxtvg/)** + + +--- + + +## Installation + +Via npm: + +`npm install --save preact-portal` + + + +## Usage + +```js +import { h, Component, render } from 'preact'; +import Portal from 'preact-portal'; + +class Thumbnail extends Component { + open = () => this.setState({ open:true }); + close = () => this.setState({ open:false }); + + render({ url }, { open }) { + return ( +
+ + + { open ? ( + + + + ) : null } +
+ ); + } +} + +render(, document.body); +``` + + +--- + + +Or, wrap up a very common case into a simple high order function: + +```js +const Popup = ({ open, into="body", children }) => ( + open ? { children } : null +); + +// Example: show popup on error. +class Form extends Component { + render({}, { error }) { + return ( +
+ +

Error: {error}

+
+ ...etc +
+ ); + } +} +``` + + +[preact]: https://github.com/developit/preact diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000..aa60ee6 --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,42 @@ +var path = require('path'); + +module.exports = function(config) { + config.set({ + frameworks: ['mocha', 'chai-sinon'], + reporters: ['mocha'], + + browsers: ['PhantomJS'], + + files: [ + 'test/**/*.js' + ], + + preprocessors: { + 'test/**/*.js': ['webpack'], + 'src/**/*.js': ['webpack'], + '**/*.js': ['sourcemap'] + }, + + webpack: { + module: { + loaders: [ + { + test: /\.jsx?$/, + exclude: /node_modules/, + loader: 'babel' + } + ] + }, + resolve: { + modulesDirectories: [__dirname, 'node_modules'], + alias: { + src: __dirname+'/src' + } + } + }, + + webpackMiddleware: { + noInfo: true + } + }); +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..839e582 --- /dev/null +++ b/package.json @@ -0,0 +1,82 @@ +{ + "name": "preact-portal", + "amdName": "preactPortal", + "version": "1.0.0", + "description": "Render Preact components somewhere in [a different] space.", + "main": "dist/preact-portal.js", + "minified:main": "dist/preact-portal.min.js", + "jsnext:main": "src/preact-portal.js", + "scripts": { + "build": "npm-run-all transpile minify size", + "transpile": "rollup -c rollup.config.js", + "minify": "uglifyjs $npm_package_main -cm -o $npm_package_minified_main -p relative --in-source-map ${npm_package_main}.map --source-map ${npm_package_minified_main}.map", + "size": "echo \"gzip size: $(gzip-size $npm_package_minified_main | pretty-bytes)\"", + "test": "npm-run-all lint build test:karma", + "lint": "eslint {src,test}", + "test:karma": "karma start --single-run", + "prepublish": "npm-run-all build test", + "release": "npm run -s build && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish", + "start": "node server.js" + }, + "keywords": [ + "preact", + "react", + "portals" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/developit/preact-portal.git" + }, + "author": "Jason Miller ", + "license": "MIT", + "bugs": { + "url": "https://github.com/developit/preact-portal/issues" + }, + "homepage": "https://github.com/developit/preact-portal", + "devDependencies": { + "babel-core": "^6.6.4", + "babel-eslint": "^5.0.0", + "babel-loader": "^6.2.3", + "babel-plugin-transform-class-properties": "^6.5.2", + "babel-plugin-transform-object-rest-spread": "^6.6.4", + "babel-plugin-transform-react-jsx": "^6.6.5", + "babel-preset-es2015": "^6.6.0", + "babel-preset-es2015-loose": "^7.0.0", + "babel-preset-es2015-loose-rollup": "^7.0.0", + "babel-preset-es2015-minimal": "^1.1.0", + "babel-preset-es2015-minimal-rollup": "^1.1.0", + "babel-preset-react": "^6.5.0", + "babel-preset-stage-0": "^6.5.0", + "chai": "^3.5.0", + "eslint": "~2.2.0", + "gzip-size-cli": "^1.0.0", + "karma": "^0.13.21", + "karma-chai": "^0.1.0", + "karma-chai-sinon": "^0.1.5", + "karma-mocha": "^0.2.2", + "karma-mocha-reporter": "^1.2.1", + "karma-phantomjs-launcher": "^1.0.0", + "karma-sourcemap-loader": "^0.3.7", + "karma-webpack": "^1.7.0", + "mkdirp": "^0.5.1", + "mocha": "^2.4.5", + "npm-run-all": "^1.5.1", + "phantomjs-prebuilt": "^2.1.4", + "preact": "^4.1.1", + "pretty-bytes-cli": "^1.0.0", + "rollup": "^0.25.4", + "rollup-plugin-babel": "^2.4.0", + "rollup-plugin-commonjs": "^2.2.1", + "rollup-plugin-node-resolve": "^1.4.0", + "sinon": "^1.17.2", + "sinon-chai": "^2.8.0", + "uglify-js": "^2.6.2", + "webpack": "^1.12.14" + }, + "peerDependencies": { + "preact": "*" + }, + "directories": { + "test": "test" + } +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..80b90d1 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,40 @@ +import path from 'path'; +import fs from 'fs'; +import babel from 'rollup-plugin-babel'; +import nodeResolve from 'rollup-plugin-node-resolve'; +import commonjs from 'rollup-plugin-commonjs'; + +let babelrc = JSON.parse(fs.readFileSync('./.babelrc')); +let pkg = JSON.parse(fs.readFileSync('./package.json')); +let external = Object.keys(pkg.peerDependencies || {}).concat(Object.keys(pkg.dependencies || {})); + +export default { + entry: pkg['jsnext:main'], + dest: pkg.main, + sourceMap: path.resolve(pkg.main), + moduleName: pkg.amdName, + format: 'umd', + external, + plugins: [ + babel({ + babelrc: false, + comments: false, + exclude: 'node_modules/**', + presets: [ + 'es2015-minimal-rollup' + ].concat(babelrc.presets.slice(1)), + plugins: require('babel-preset-es2015-minimal-rollup').plugins.concat([ + ['transform-react-jsx', { pragma:'h' }] + ]) + }), + nodeResolve({ + jsnext: true, + main: true, + skip: external + }), + commonjs({ + include: 'node_modules/**', + exclude: '**/*.css' + }) + ] +}; diff --git a/src/preact-portal.js b/src/preact-portal.js new file mode 100644 index 0000000..a31efae --- /dev/null +++ b/src/preact-portal.js @@ -0,0 +1,53 @@ +import { h, Component, render } from 'preact'; + +/** Redirect rendering of descendants into the given CSS selector. + * @example + * + *
I am rendered into document.body
+ *
+ */ +export default class Portal extends Component { + componentDidUpdate(props) { + for (let i in props) { + if (props[i]!==this.props[i]) { + return this.renderLayer(); + } + } + } + + componentDidMount() { + this.renderLayer(); + } + + componentWillUnmount() { + this.renderLayer(false); + } + + findNode(node) { + return typeof node==='string' ? document.querySelector(node) : node; + } + + renderLayer(show=true) { + // clean up old node if moving bases: + if (this.props.into!==this.intoPointer) { + this.intoPointer = this.props.into; + if (this.into && this.remote) { + render(, this.into, this.remote); + } + this.into = this.findNode(this.props.into); + } + + this.remote = render(( + { show && this.props.children || null } + ), this.into, this.remote); + } +} + + +// high-order component that renders its first child if it exists. +// used as a conditional rendering proxy. +class PortalProxy extends Component { + render({ children }) { + return children && children[0] || null; + } +} diff --git a/test/preact-portal.js b/test/preact-portal.js new file mode 100644 index 0000000..6f0507a --- /dev/null +++ b/test/preact-portal.js @@ -0,0 +1,39 @@ +import { h, Component, render } from 'preact'; +import Portal from '../src/preact-portal'; + +/*global sinon,expect*/ + +describe('preact-portal', () => { + let scratch; + + before( () => { + scratch = document.createElement('div'); + (document.body || document.documentElement).appendChild(scratch); + }); + + beforeEach( () => { + scratch.innerHTML = ''; + }); + + after( () => { + scratch.parentNode.removeChild(scratch); + scratch = null; + }); + + it('should have tests', () => { + expect(Portal).to.be.a('function'); + }); + + it('should render into target', () => { + let foo = document.createElement('div'); + foo.setAttribute('id', 'foo'); + scratch.appendChild(foo); + + let base = document.createElement('div'); + scratch.appendChild(base); + + render(
hello
, base); + + expect(foo).to.have.property('innerHTML', '
hello
'); + }); +});