├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src ├── Slot.js ├── Slot.test.js ├── index.js ├── mergeProps.js ├── mergeProps.test.js ├── prefixKeys.js ├── prefixKeys.test.js └── renderSlot.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | 4 | *.log 5 | .DS_Store -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 |
21 |
Welcome!
22 |
23 |
Copyright 2017
24 | 25 |
26 | */ 27 | import * as React from 'react' 28 | import { Slot } from 'react-slot' 29 | 30 | export default function LayoutDefault (props) { 31 | const { children } = props 32 | return ( 33 |
34 | Welcome! 35 | 36 | Copyright 2017 37 | 38 |
39 | ) 40 | } 41 | 42 | /* 43 | PageHome.js 44 | Create a page that will insert content into a layout's slots. 45 | Generates HTML like the following (depends on what layout is used): 46 | 47 |
48 |
49 |

Home

50 |
The main content
51 |
Copyright 2017
52 |
53 |
54 | */ 55 | import * as React from 'react' 56 | 57 | export default PageHome extends React.Component { 58 | static propsTypes = { 59 | layout: PropTypes.func.isRequired 60 | } 61 | 62 | render () { 63 | const { layout } = this.props 64 | 65 | return ( 66 |
67 | 68 |

Home

69 |
70 | The main content 71 |
72 |
73 |
74 | ) 75 | } 76 | } 77 | 78 | // App.js 79 | import * as React from 'react' 80 | import * as ReactDOM from 'react-dom' 81 | import PageHome from './PageHome' 82 | 83 | const page = 84 | 85 | ReactDOM.render( 86 | page, 87 | document.getElementById('app') 88 | ) 89 | ``` 90 | 91 | ## API 92 | 93 | ### Slot 94 | 95 | Slot is a component that is meant to compose your layouts with. These act 96 | as the points where a layout can be altered by a parent using the layout 97 | component. The slot without a name is known as the default slot. 98 | 99 | **Props** 100 | 101 | - `content` *[required]* The React children of the parent component 102 | - `children` The default content to render if no content is inserted from the parent component 103 | - `name` The name of this slot (inserted as class name 'slot-${name}') 104 | - `id` The HTML id 105 | - `className` Additional class names 106 | - `dataset` *[default: {}]* An object with keys to set as 'data-' attributes (keys must not contain a 'data-' prefix) 107 | - `role` The HTML role 108 | - `as` *[default: 'div']* The type of React element (string or function) to create the root element as 109 | 110 | Something to keep in mind, Slot elements will render nothing if they don't have 111 | any default content and the parent component didn't insert any content. 112 | 113 | ```jsx 114 | 115 | ``` 116 | 117 | Full Example: 118 | 119 | ```jsx 120 | /* 121 | A layout that will render HTML like the following: 122 | 123 |
124 |
Welcome!
125 |
126 |
Copyright 2017
127 |
128 |
129 | */ 130 | const LayoutDefault = props => { 131 | const { children } = props 132 | return ( 133 | 134 | Welcome! 135 | 136 | Copyright 2017 137 | 138 | 139 | ) 140 | } 141 | ``` 142 | 143 | To insert content into a `Slot` the parent component using the layout needs to 144 | designate React subtrees to use a slot by setting the `slot` prop on an element 145 | to have its children inserted into the slot with the mathcing name (if one 146 | exists). *Only the first matching React subtree will have its children inserted.* 147 | 148 | ```jsx 149 |
...inserted into the slot-name slot...
150 | ``` 151 | 152 | Any React elements with a `slot` set to `"default"`, or `true` will have thier 153 | children inserted into the default slot (if one exists). 154 | 155 | ```jsx 156 |
...inserted into the default slot...
157 |
...inserted into the default slot...
158 | ``` 159 | 160 | Since only the children of a react subtree are inserted into a slot, all props 161 | on the subtree root node are merged with the props defined on the `` 162 | element in the layout. 163 | 164 | ```jsx 165 | // in the layout... 166 | 167 | 168 | // in the parent component... 169 | 170 |
The Footer
171 |
172 | 173 | // Results in the footer slot being rendered as... 174 | 175 | ``` 176 | 177 | Also, if a default slot exists and no slotted subtree is found with the `slot` 178 | prop set to `"default"` or `true`, then all React nodes without a slot 179 | designation will be inserted into the default slot. 180 | 181 | ```jsx 182 |
...
183 |
Default content1
184 |
More default content
185 |
...
186 | ``` 187 | 188 | Example Usage of `LayoutDefault`: 189 | 190 | ```jsx 191 | /* 192 | A page component that uses LayoutDefault to structure its content. This 193 | will render a page that looks like this: 194 | 195 |
196 |
Welcome!
197 |
198 |
Hello World!
199 |

This is some more content inserted into the default slot

200 |
201 |
Copyright 2018
202 |
203 | */ 204 | const Page = props => { 205 | return ( 206 | 207 |
Copyright 2018
208 |
Hello World!
209 |

This is some more content inserted into the default slot

210 |
211 | ) 212 | } 213 | ``` 214 | 215 | Additionally, Slots can be nested to provide parent components with increasing 216 | granularity when overriding slots. 217 | 218 | ```jsx 219 | 220 | This is the content 221 | 222 | This is the inner content 223 | 224 | 225 | ``` 226 | 227 | Then to insert into these slots you have a choice to override the entire `outer` 228 | slot... 229 | 230 | ```jsx 231 |
...
232 | ``` 233 | 234 | ...or just the `inner` slot. 235 | 236 | ```jsx 237 |
....
238 | ``` 239 | 240 | But if the `outer` slot is overrdden then the entirety of its contents will be 241 | replaced. 242 | 243 | ### slot(name, children) 244 | 245 | This function will pull out the children of any React subtree designated by the 246 | `slot` prop that matches the `name` argument. This function will not render a 247 | root node at all, this is left up to the parent component to provide. This gives 248 | you more control over a slot's root element. 249 | 250 | ```jsx 251 | 252 | Copyright {slot('copyrightYear', children) || '2017'} 253 | 254 | ``` 255 | 256 | Then in the parent component: 257 | 258 | ```jsx 259 | // Replace the entire footer... 260 |
...
261 | 262 | // ...or just replace the copyright year 263 |
2018
264 | ``` 265 | 266 | ## Related Modules 267 | 268 | - [react-layout](https://npmjs.org/react-layout) 269 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /src/Slot.test.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as assert from 'assert' 3 | import { shallow, render } from 'enzyme' 4 | import Slot from './Slot' 5 | import prefixKeys from './prefixKeys' 6 | 7 | describe('Slot', function () { 8 | it('should render a
with className, id and data- attributes', function () { 9 | const id = 'my-id' 10 | const name = 'MySlot' 11 | const className = 'testing' 12 | const dataset = { target: '#thing', scroll: 'spy' } 13 | const dataAttributes = prefixKeys(dataset, 'data-') 14 | const wrapper = shallow( 15 | 20 | {''} 21 | 22 | ) 23 | 24 | assert.ok(wrapper.equals( 25 |
29 | {''} 30 |
31 | )) 32 | }) 33 | 34 | it('should render with default content', function () { 35 | const wrapper = render( 36 |
37 |

Layout

38 | 39 | Hello World! 40 | 41 | 42 | The Footer 43 | 44 | 45 | {''} 46 | 47 |
48 | ) 49 | 50 | assert.strictEqual(wrapper.html(), [ 51 | '
', 52 | '

Layout

', 53 | '
', 54 | 'Hello World!', 55 | '
', 56 | '', 59 | '
', 60 | '
' 61 | ].join('')) 62 | }) 63 | 64 | it('should render with concrete and default content', function () { 65 | const LayoutDefault = props => { 66 | const { children } = props 67 | return ( 68 |
69 | 70 | Hello World! 71 | 72 | 73 | The Footer 74 | 75 | 76 |
77 | ) 78 | } 79 | const wrapper = render( 80 | 81 |
82 | The New Footer 83 |
84 |
85 | Gonzo 86 |
87 |
88 | ) 89 | 90 | assert.strictEqual(wrapper.html(), [ 91 | '
', 92 | '
', 93 | 'Hello World!', 94 | '
', 95 | '', 98 | '', 101 | '
' 102 | ].join('')) 103 | }) 104 | 105 | it('should render unamed default slot with concrete content', function () { 106 | const LayoutDefault = props => { 107 | const { children } = props 108 | return ( 109 |
110 | 111 | Hello World! 112 | 113 | 114 | The Footer 115 | 116 |
117 | ) 118 | } 119 | const wrapper = render( 120 | 121 |
122 | The New Body 123 |
124 |
125 | ) 126 | 127 | assert.strictEqual(wrapper.html(), [ 128 | '
', 129 | '
', 130 | 'The New Body', 131 | '
', 132 | '', 135 | '
' 136 | ].join('')) 137 | }) 138 | 139 | it('should render named default slot with concrete content', function () { 140 | const LayoutDefault = props => { 141 | const { children } = props 142 | return ( 143 |
144 | 145 | Hello World! 146 | 147 | 148 | The Footer 149 | 150 |
151 | ) 152 | } 153 | const wrapper = render( 154 | 155 |
156 | The New Body 157 |
158 |
159 | ) 160 | 161 | assert.strictEqual(wrapper.html(), [ 162 | '
', 163 | '
', 164 | 'The New Body', 165 | '
', 166 | '', 169 | '
' 170 | ].join('')) 171 | }) 172 | 173 | it('should render default slot with unslotted concrete content', function () { 174 | const LayoutDefault = props => { 175 | const { children } = props 176 | return ( 177 |
178 | 179 | Hello World! 180 | 181 | 182 | The Footer 183 | 184 |
185 | ) 186 | } 187 | const wrapper = render( 188 | 189 |
The New Body
190 |
191 | The New Footer 192 |
193 |

Headline

194 |
195 | ) 196 | 197 | assert.strictEqual(wrapper.html(), [ 198 | '
', 199 | '
', 200 | '
The New Body
', 201 | '

Headline

', 202 | '
', 203 | '', 206 | '
' 207 | ].join('')) 208 | }) 209 | 210 | it('should render nested elements', function () { 211 | const Footer = props => (