├── .gitignore ├── src ├── index.js ├── renderSlot.js ├── prefixKeys.js ├── prefixKeys.test.js ├── mergeProps.js ├── mergeProps.test.js ├── Slot.js └── Slot.test.js ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | 4 | *.log 5 | .DS_Store -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as slot } from './renderSlot' 2 | export { default as Slot } from './Slot' 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "module": "commonjs", 5 | "target": "es2015", 6 | "moduleResolution": "node", 7 | "jsx": "react", 8 | "reactNamespace": "React", 9 | "outDir": "lib", 10 | "typeRoots": [ 11 | "node_modules/@types" 12 | ] 13 | }, 14 | "include": [ 15 | "./src/**/*.js" 16 | ] 17 | } -------------------------------------------------------------------------------- /src/renderSlot.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export default function slot (name, children) { 4 | const slotNode = findNamedSlotNode(name, children) 5 | 6 | if (slotNode) { 7 | return slotNode.props.children 8 | } else { 9 | return null 10 | } 11 | } 12 | 13 | function findNamedSlotNode (name, children) { 14 | const node = React.Children.toArray(children).filter(child => { 15 | const node = child 16 | return node.props && node.props.slot === name 17 | })[0] 18 | 19 | return node 20 | } 21 | -------------------------------------------------------------------------------- /src/prefixKeys.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Prefix all keys in the specified object and save them on a new object. 3 | * 4 | * Example: 5 | * const o = prefixKeys({ toggle: true, target: 'spy' }, 'data-') 6 | * // o will be: { 'data-toggle': true, 'data-target': 'spy' } 7 | * 8 | * @param {object} obj The object whoes keys/properties will be prefixed 9 | * @param {string} prefix The prefix to apply to each key in obj 10 | * @param {object} dest The optional destination object to save the new keys to 11 | * @return {{[key:string]:any}} 12 | */ 13 | export default function prefixKeys (obj, prefix, dest = {}) { 14 | return Object.keys(obj).reduce((o, key) => { 15 | o[`${prefix}${key}`] = obj[key] 16 | return o 17 | }, dest || {}) 18 | } 19 | -------------------------------------------------------------------------------- /src/prefixKeys.test.js: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import prefixKeys from './prefixKeys' 3 | 4 | describe('prefixKeys', function () { 5 | it('should prefix each key with "data-"', function () { 6 | const src = { one: 'one', two: 'two', three: 37 } 7 | const o = prefixKeys(src, 'data-') 8 | 9 | assert.ok(src !== o, 'result is referentially equal to src object') 10 | assert.deepStrictEqual(o, { 11 | 'data-one': 'one', 12 | 'data-two': 'two', 13 | 'data-three': 37 14 | }) 15 | }) 16 | 17 | it('should prefix each key with "data-" and save each key to dest', function () { 18 | const src = { one: 'one', two: 'two', three: 37 } 19 | const dest = { prop: 'value' } 20 | const o = prefixKeys(src, 'data-', dest) 21 | 22 | assert.ok(o === dest, 'result is not referentially equal to dest object') 23 | assert.deepStrictEqual(o, { 24 | 'data-one': 'one', 25 | 'data-two': 'two', 26 | 'data-three': 37, 27 | 'prop': 'value' 28 | }) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2017 Darren Schnare 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. -------------------------------------------------------------------------------- /src/mergeProps.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Merges two React property objects into a new object and returns it. The 3 | * property names in the ignore array option will not be merged from the second 4 | * property object. 5 | * 6 | * Example: 7 | * const p mergeProps( 8 | * { id: 'id', className: 'one' }, 9 | * { className: 'two three one', id: 'id2 }, 10 | * { ignore: [ 'id' ] } 11 | * ) 12 | * // p will be: { id: 'id', className: 'one two three' } 13 | * 14 | * @param {{[key:string]:any}} a 15 | * @param {{[key:string]:any}} b 16 | * @return {{[key:string]:any}} 17 | */ 18 | export default function mergeProps (a, b, { ignore = [] } = {}) { 19 | b = Object.assign({}, b) 20 | 21 | ignore.forEach(prop => delete b[prop]) 22 | const className = typeof b.className === 'string' 23 | ? b.className.split(' ') 24 | : b.className 25 | delete b.className 26 | 27 | if (a.className || className) { 28 | a.className = [].concat( 29 | typeof a.className === 'string' 30 | ? a.className.split(' ') 31 | : a.className 32 | ) 33 | .concat(className) 34 | .filter(Boolean) 35 | .reduce((a, b) => a.indexOf(b) < 0 ? a.concat(b) : a, []) 36 | .join(' ') 37 | } 38 | 39 | return Object.assign({}, a, b) 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-slot", 3 | "version": "0.1.2", 4 | "description": "Slot-based content distribution component for React", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "lib" 8 | ], 9 | "directories": { 10 | "lib": "lib" 11 | }, 12 | "keywords": [ 13 | "react", 14 | "content distribution", 15 | "layout", 16 | "vuejs", 17 | "vue", 18 | "slots", 19 | "slot" 20 | ], 21 | "scripts": { 22 | "prepublish": "npm run compile", 23 | "compile": "tsc -p .", 24 | "compile:watch": "npm run compile -- --watch", 25 | "test": "mocha lib/*.test.js" 26 | }, 27 | "author": "Darren Schnare", 28 | "license": "MIT", 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/dschnare/react-slot.git" 32 | }, 33 | "bugs": { 34 | "url": "https://github.com/dschnare/react-slot/issues" 35 | }, 36 | "dependencies": { 37 | "prop-types": "^15.5.10" 38 | }, 39 | "peerDependencies": { 40 | "react": "^15" 41 | }, 42 | "devDependencies": { 43 | "@types/prop-types": "^15.5.1", 44 | "@types/react": "^15.0.24", 45 | "enzyme": "^2.8.2", 46 | "mocha": "^3.4.1", 47 | "react": "^15.5.4", 48 | "react-dom": "^15.5.4", 49 | "react-test-renderer": "^15.5.4", 50 | "typescript": "^2.3.2" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/mergeProps.test.js: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert' 2 | import mergeProps from './mergeProps' 3 | 4 | describe('mergeProps', function () { 5 | it('should merge accept arrays as source objects', function () { 6 | const a = { one: 1, two: 2 } 7 | const b = [ 0, 1, 2 ] 8 | const c = mergeProps(a, b) 9 | 10 | assert.ok(c !== a, 'result is referentially equal to source object') 11 | assert.deepStrictEqual(c, { 12 | one: 1, 13 | two: 2, 14 | 0: 0, 15 | 1: 1, 16 | 2: 2 17 | }) 18 | 19 | const e = mergeProps(b, a) 20 | assert.strictEqual(typeof e, 'object') 21 | assert.deepStrictEqual(e, { 22 | one: 1, 23 | two: 2, 24 | 0: 0, 25 | 1: 1, 26 | 2: 2 27 | }) 28 | }) 29 | 30 | it('should merge properties into a new object', function () { 31 | const a = { one: 1, two: 2 } 32 | const b = { three: 3, four: 4, one: 'one' } 33 | const c = mergeProps(a, b) 34 | 35 | assert.ok(c !== a, 'result is referentially equal to source object') 36 | assert.deepStrictEqual(c, { 37 | one: 'one', 38 | two: 2, 39 | three: 3, 40 | four: 4 41 | }) 42 | }) 43 | 44 | it('should merge properties into a new object and ignore the specified properties', function () { 45 | const a = { one: 1, two: 2, className: 'a b' } 46 | const b = { three: 3, four: 4, one: 'one', className: 'a c d' } 47 | const c = mergeProps(a, b, { ignore: [ 'one', 'four' ] }) 48 | 49 | assert.deepStrictEqual(c, { 50 | one: 1, 51 | two: 2, 52 | three: 3, 53 | className: 'a b c d' 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/Slot.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as PropTypes from 'prop-types' 3 | import prefixKeys from './prefixKeys' 4 | import mergeProps from './mergeProps' 5 | 6 | export default class Slot extends React.Component { 7 | static propTypes = { 8 | content: PropTypes.node.isRequired, 9 | name: PropTypes.string, 10 | children: PropTypes.node, 11 | id: PropTypes.string, 12 | className: PropTypes.string, 13 | dataset: PropTypes.object, 14 | role: PropTypes.string, 15 | as: PropTypes.oneOfType([ 16 | PropTypes.string, 17 | PropTypes.func 18 | ]) 19 | } 20 | 21 | static defaultProps = { 22 | name: '', 23 | id: '', 24 | className: '', 25 | dataset: {}, 26 | role: '', 27 | as: 'div' 28 | } 29 | 30 | render () { 31 | if (this.isDefaultSlot()) { 32 | return this.renderDefaultSlot() 33 | } else { 34 | return this.renderNamedSlot() 35 | } 36 | } 37 | 38 | isDefaultSlot () { 39 | const { name = '' } = this.props 40 | return !name 41 | } 42 | 43 | renderDefaultSlot () { 44 | const { role, id, dataset = {}, children, as: slot = 'div' } = this.props 45 | let attrs = prefixKeys(dataset, 'data-') 46 | let slotNode = this.findDefaultSlotNode() 47 | let content = [] 48 | 49 | if (id) attrs.id = id 50 | if (role) attrs.role = role 51 | attrs.className = this.getSlotClassName() 52 | 53 | if (slotNode) { 54 | const opts = { ignore: [ 'slot', 'children' ] } 55 | attrs = mergeProps(attrs, slotNode.props, opts) 56 | content = slotNode.props.children 57 | } else { 58 | content = this.findUnslottedNodes() 59 | } 60 | 61 | content = React.Children.count(content) === 0 ? children : content 62 | 63 | if (React.Children.count(content) > 0) { 64 | return ( 65 | React.createElement(slot, attrs, content) 66 | ) 67 | } else { 68 | return null 69 | } 70 | } 71 | 72 | renderNamedSlot () { 73 | const { 74 | role, 75 | name, 76 | id, 77 | dataset = {}, 78 | children, 79 | as: slot = 'div' 80 | } = this.props 81 | let attrs = prefixKeys(dataset, 'data-') 82 | let slotNode = this.findNamedSlotNode(name) 83 | let content = [] 84 | 85 | if (id) attrs.id = id 86 | if (role) attrs.role = role 87 | attrs.className = this.getSlotClassName() 88 | 89 | if (slotNode) { 90 | const opts = { ignore: [ 'slot', 'children' ] } 91 | attrs = mergeProps(attrs, slotNode.props, opts) 92 | content = slotNode.props.children 93 | } 94 | 95 | content = React.Children.count(content) === 0 ? children : content 96 | 97 | if (React.Children.count(content) > 0) { 98 | return ( 99 | React.createElement(slot, attrs, content) 100 | ) 101 | } else { 102 | return null 103 | } 104 | } 105 | 106 | getSlotClassName () { 107 | const { name = '', className = '' } = this.props 108 | return [ `slot-${name || 'default'}`, className ] 109 | .filter(Boolean) 110 | .reduce((a, b) => a.indexOf(b) < 0 ? a.concat(b) : a,[]) 111 | .join(' ') 112 | } 113 | 114 | findNamedSlotNode (name) { 115 | const { content } = this.props 116 | const node = React.Children.toArray(content).filter(child => { 117 | const node = child 118 | return node.props && node.props.slot === name 119 | })[0] 120 | 121 | return node 122 | } 123 | 124 | findDefaultSlotNode () { 125 | const { content } = this.props 126 | const node = React.Children.toArray(content).filter(node => { 127 | const props = node.props || {} 128 | return props.slot === true || props.slot === 'default' 129 | })[0] 130 | 131 | return node 132 | } 133 | 134 | findUnslottedNodes () { 135 | const { content } = this.props 136 | return React.Children.toArray(content).filter(node => { 137 | return !node.props || !('slot' in node.props) 138 | }).filter(Boolean) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Slot 2 | 3 | Slot-based content distribution component for React. The technique was highly 4 | influenced by the content distribution techniques used by 5 | [Vuejs](http://vuejs.org/v2/guide/components.html#Content-Distribution-with-Slots). 6 | 7 | ## Install 8 | 9 | ```shell 10 | npm install react@">=15" react-dom@">=15" react-slot -S 11 | ``` 12 | 13 | ## Quick Start 14 | 15 | ```jsx 16 | /* 17 | LayoutDefault.js 18 | Generates a slotted HTML layout like the following: 19 | 20 |
This is some more content inserted into the default slot
200 |This is some more content inserted into the default slot
210 |