├── .babelrc ├── .eslintrc.json ├── .gitignore ├── README.md ├── dev-assets └── index.html ├── examples ├── Select.js └── elm │ ├── Main.elm │ ├── elm-package.json │ ├── elm.js │ └── index.html ├── package-lock.json ├── package.json ├── src ├── index.js └── util.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "extends": [ 6 | "airbnb", 7 | "prettier" 8 | ], 9 | "plugins": [ 10 | "prettier" 11 | ], 12 | "rules": { 13 | "prettier/prettier": ["error", { "trailingComma": "es5" }] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | elm-stuff/ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Web React Components 2 | 3 | Put your React Components into a neat Web components wrapper and render them 4 | anywhere, not just in your React code. 5 | 6 | ## Motivation 7 | 8 | We have lots of React code and really wanted to write Elm. Putting React inside Elm 9 | is not trivial and not being able to use our tried-and-tested components 10 | would have been a big reason against using Elm. 11 | So after watching Richard Feldman's [talk](https://www.youtube.com/watch?v=ar3TakwE8o0) 12 | we thought "what if Elm rendered just Web Components and the Web Components render 13 | whatever they want inside(in our case React)". So how to convert all of our React 14 | components into Web Components? Well, that is what this repo is for. 15 | 16 | ## Dependencies 17 | 18 | This package requires the following dependencies: 19 | 20 | Polyfills: 21 | These polyfills are needed for this to work in all evergreen browsers(including IE11). 22 | We use polyfills for [Web Components V1](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Custom_Elements). 23 | 24 | - Polyfills for Web Components features, namely custom elements(CE) and shady DOM(SD) 25 | - Adapter to transform non-native ES2015 classes into true ES2015 classes(needed for CE) 26 | - Everything you need to provide an ES2015 environment in the browser 27 | 28 | Libraries: 29 | - React 30 | - ReactDOM 31 | 32 | If you don't want to assemble all these polyfills yourself and just want to get 33 | started quickly, just drop these script tags into your page. They contain everything 34 | you need to get going. 35 | 36 | **NOTE: even if you use Chrome which supports Web Components, you will still need 37 | the `custom-elements-es5-adapter`.** 38 | 39 | 40 | ```html 41 | 42 | 43 | 44 | 45 | 46 | ``` 47 | 48 | ## Usage 49 | 50 | ```sh 51 | npm install -S web-react-components 52 | ``` 53 | 54 | This module will expose functions to hook up your React component with a web component. 55 | 56 | Then in your code, import the registering function: 57 | ```js 58 | import React from 'react'; 59 | // import the registering function 60 | import { register } from 'web-react-components'; 61 | 62 | const YourComponent = ({ name, isDisabled, onButtonClick }) => ( 63 | 66 | ); 67 | 68 | // call it to register the web component 69 | // this will transform all -tags in the markup 70 | // ATTENTION: all custom element tag names MUST contain a dash 71 | // use it anywhere like this: 72 | // 73 | register(YourComponent, 'your-component', [ 74 | // these attribute values will be json parsed 75 | 'name', 76 | // this will define a boolean attribute 77 | '!!isDisabled', 78 | ], 79 | // handlers can be created here. The functions of the object can return any 80 | // new instance of `Event` or `CustomEvent` 81 | { 82 | onButtonClick: e => new Event('buttonclick', { bubbles: true }), 83 | } 84 | ); 85 | ``` 86 | 87 | ## API 88 | 89 | ### Register 90 | 91 | The `register` function takes a ReactComponent and registers it using 92 | `customElements.define(...)`. 93 | 94 | 95 | ``` 96 | register(reactComponent, nodeName, propsList, eventMappers = {}) 97 | ``` 98 | | Param | Description | 99 | | --- | --- | 100 | | `reactComponent` | An actual React Component Class / Stateless Function | 101 | | `nodeName` | A tag name for the web component to be created (Must contain a dash)| 102 | | `propsList` | An array of strings which represents the props that should be wired up to React. There are 3 ways to declare a prop.
- `'propName'`: with a regular name the attribute value be JSON parsed and passed to React, if that fails, then it will be passed as a String. This let you pass arbitrary data to the React component, even through DOM attributes.
- `'!!propName'`: With leading bangs this property will be considered a boolean and pass `true` to React or `false` if the attribute is not present on the DOM node.
- `'propName()'`: With trailing parens the property will be considered an event handler and set up event proxying between react and the DOM, so that it's possible to listen to React props handlers from the DOM.| 103 | | `eventMappers` | An optional object with function values. The keys are handler property names (e.g. `onChange`) and the values are functions with the following signature `(...args) => Event\|null`. The returned event will then be dispatched on the Web Component. If null is returned nothing is dispatched. *Note: EventMappers will override any event definitions in the propertyList parameter.* | 104 | | `options` | An optional object. The only options right now is `useShadowDOM` which defaults to true. You can opt out of using shadow DOM by setting this to false. | 105 | | returns `WebComponent class` | An class which is already registered using `customElements.define(...)` | 106 | 107 | ### Convert 108 | 109 | Convert is almost the same as `register` except you have to register the 110 | Component yourself. Do this when you want further extend the component before 111 | registering it. 112 | 113 | ``` 114 | // This function will return you a webcomponent instance 115 | convert(reactComponent, propsList, eventMappers = {}) 116 | ``` 117 | | Param | Description | 118 | | --- | --- | 119 | | `reactComponent` | An actual React Component Class / Stateless Function | 120 | | `propsList` | An array of strings which represents the props that should be wired up to React. There are 3 ways to declare a prop.
- `'propName'`: with a regular name the attribute value be JSON parsed and passed to React, if that fails, then it will be passed as a String. This let you pass arbitrary data to the React component, even through DOM attributes.
- `'!!propName'`: With leading bangs this property will be considered a boolean and pass `true` to React or `false` if the attribute is not present on the DOM node.
- `'propName()'`: With trailing parens the property will be considered an event handler and set up event proxying between react and the DOM, so that it's possible to listen to React props handlers from the DOM.| 121 | | `eventMappers` | An optional object with function values. The keys are handler property names (e.g. `onChange`) and the values are functions with the following signature `(...args) => Event\|null`. The returned event will then be dispatched on the Web Component. If null is returned nothing is dispatched. *Note: EventMappers will override any event definitions in the propertyList parameter.* | 122 | | `options` | An optional object. The only options right now is `useShadowDOM` which defaults to true. You can opt out of using shadow DOM by setting this to false. | 123 | | returns `WebComponent class` | An class which can then be used to register using `customElements.define(...)` | 124 | 125 | You can the use it like this: 126 | ```js 127 | import { convert } from 'web-react-components'; 128 | 129 | const MyComponent = convert(MyReactComponet, ['name', 'type'], 130 | { 131 | onChange: (e) => new Event('crazyChange', { bubbles: true }), 132 | }); 133 | 134 | // register it here 135 | customElements.define('my-component', MyComponent); 136 | ``` 137 | ## Examples 138 | 139 | Then you can render the component from anywhere (even Elm, React, plain HTML, Angular if you really have to :-)) 140 | 141 | Elm: 142 | ```elm 143 | -- In the view do this: 144 | ... 145 | type Msg 146 | = ... 147 | | ButtonClick 148 | 149 | {-| Define a shortcut for your component -} 150 | yourComponent : List (Attribute msg) -> List (Html msg) -> Html msg 151 | yourComponent = node "your-component" 152 | 153 | view model = div [] [ 154 | yourComponent 155 | [ attribute "name" "Peter" 156 | , property "isDisabled" (Json.Encode.bool True) 157 | , on "buttonclick" (Decode.succeed ButtonClick) 158 | ] 159 | [ span [style ("color", "green")] [text "Click Me"] 160 | ] 161 | ] 162 | ``` 163 | 164 | Plain HTML: 165 | ```html 166 | ... 167 |
168 | 169 | 170 | Click Me 171 | 172 |
173 | ``` 174 | 175 | ## Passing props 176 | 177 | ### Attributes and Properties 178 | Since in HTML attribute values can only be strings, other values need to be 179 | encoded. The created web component will try a `JSON.parse()` on each attribute, so all 180 | JSON values are valid inside the string. If the parsing fails the value will 181 | just be passed to React as a string. 182 | 183 | `Example: passing '{ "name": Peter }' is fine.` 184 | 185 | For each attribute you register, a matching property will be defined on the DOM 186 | node. These properties will have getters and setters that automatically do JSON 187 | parsing and updating the corresponding attribute as well. 188 | 189 | You can also use JS to pass properties like this: 190 | ```js 191 | document.getElementById('your-dom-id').numbers = [1, 2, 3, 4]; 192 | ``` 193 | 194 | ### Events 195 | 196 | Events can be listened to in 3 different ways that you should be familiar with from 197 | the DOM. 198 | 199 | ```js 200 | // with `addEventListener()`. The event name will be used here, so use the type of 201 | the returned event from the `eventMappers` parameter. 202 | document.getElementById('#my-component').addEventListener('change', function() { ... }, false); 203 | 204 | // with the DOM Property (notice the uppercase `C`, because the name has to be the same as 205 | // the property in React) 206 | document.getElementById('#my-component').onChange = function() { ... }; 207 | 208 | // with the HTML Attributes 209 | 210 | ``` 211 | To access data from the original event from React you will have to 212 | do something like this: 213 | 214 | ```js 215 | document.getElementById('#my-component').addEventListener('change', function(event) { 216 | // data is an array of arguments that were passed to the react event handler 217 | const data = event.detail; 218 | // log the first arg of the react event handler 219 | console.log(data[0]); 220 | }, false); 221 | ``` 222 | 223 | ### Children 224 | Children are passed like you would expect by simple add child nodes to the 225 | element or programmatically changing the `innerHMTL` or `childNodes` of a 226 | custom compoenent. 227 | 228 | The children will be part of the shadow DOM of the custom components and are rendered 229 | into a [``-tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/slot). 230 | That `` will be passed to the React components as `children`, 231 | that you can render wherever you want. 232 | 233 | ```html 234 | 235 | I am a child // will be passed as `children` to React 236 | 237 | ``` 238 | 239 | ## What about CSS? 240 | 241 | Since the React components, which are wrapped by the Web Component, will live in the 242 | [shadow DOM](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Shadow_DOM), 243 | global css will not have any effect on them(at least in browsers, which are correcly 244 | implementing it). Thus we recommend shipping the components with inline styles, an internal stylesheet, or 245 | or if you want to include an external stylesheet, use an `@import` declaration in 246 | an internal style tag, like this. 247 | 248 | ```html 249 | // inside render method of your React component 250 | 253 | ``` 254 | 255 | ## How does it work under the hood? 256 | 257 | For the ultimate source of truth, the source code is pretty much all this this 258 | [file](https://github.com/ChristophP/web-react-components/blob/master/src/index.js). 259 | 260 | But here is a quick write-up: 261 | 262 | The whole React component will be inserted into the Shadow DOM. 263 | For each property that is declared with the exposed register function, a DOM 264 | attribute is created, that is being listened to for changes through 265 | the [`attributeChangedCallback`](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Custom_Elements). 266 | Also, a corresponding DOM node property is set up with getters and setters, that 267 | keeps the property and the attribute in sync. Registering a property with a 268 | leading `!!` will declare a boolean attribute. Then the getters and setters 269 | will work slightly different and pass a boolean value to React depending on the 270 | existence of the attribute. 271 | 272 | When a property is registered with a trailing `()`, a handler will be created. 273 | A handler is attached to the wrapped React component, that will 274 | trigger a [`CustomEvent`](https://developer.mozilla.org/de/docs/Web/API/CustomEvent) 275 | on the actual web component DOM node and proxy data data to the web component. 276 | This allows you to listen to react event simply by listening to DOM events. 277 | 278 | Children of the web component somehow have to be inserted into the children 279 | of the React components. For this, we use a ``-tag, which is standard 280 | web component shadow DOM technology and built to handle cases like that. 281 | 282 | ## More Examples 283 | 284 | You can see an example [here](https://github.com/ChristophP/web-react-components/blob/master/dev-assets/index.html). 285 | You can also clone the repo and run `npm i` and `npm start`. 286 | Open your browser at `http://localhost:8080` 287 | 288 | There are even more examples in the 289 | [`examples` folder](https://github.com/ChristophP/web-react-components/tree/master/examples). 290 | 291 | ## Contributing 292 | 293 | PRs are highly welcome. If you need features or find bugs please submit an 294 | issue. 295 | 296 | ## Credits 297 | 298 | Made with countless hours of bouncing around ideas with `layflags`. Also intially 299 | inspired by talks with `tkreis` and `rtfeldman`, `tomekwi` at the Elm Europe 2017. 300 | -------------------------------------------------------------------------------- /dev-assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

Web React Components

16 | 17 | 18 | 27 | I'm a native child 28 | 29 | 30 | 108 | 109 | -------------------------------------------------------------------------------- /examples/Select.js: -------------------------------------------------------------------------------- 1 | function Select(props) { 2 | var h = React.createElement; 3 | return h('div', null, 4 | // childern will be rendern into the label 5 | h('label', { style: props.hasError ? { color: 'red' } : {} }, props.children), 6 | h('select', { 7 | name: props.name, 8 | value: props.value, 9 | disabled: props.disabled, 10 | onChange: props.onChange, 11 | }, 12 | props.options && props.options.map(function(item) { 13 | return h('option', { value: item.value }, item.label); 14 | }) 15 | ) 16 | ) 17 | }; 18 | -------------------------------------------------------------------------------- /examples/elm/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (..) 2 | 3 | import Html exposing (Html, Attribute, h3, div, label, beginnerProgram, input, button, text, node) 4 | import Html.Attributes exposing (style, id, name, value, disabled, attribute, property) 5 | import Html.Events exposing (onClick, onInput, on) 6 | import Json.Encode as Encode 7 | import Json.Decode as Decode exposing (Decoder) 8 | 9 | 10 | main : Program Never Model Msg 11 | main = 12 | beginnerProgram { model = model, update = update, view = view } 13 | 14 | 15 | type alias Model = 16 | { disabled : Bool 17 | , value : String 18 | , name : String 19 | , hasError : Bool 20 | } 21 | 22 | 23 | type Msg 24 | = ToggleDisabled 25 | | ToggleValue 26 | | ChangeValue String 27 | | ChangeName String 28 | | ToggleError 29 | 30 | 31 | {-| Encoded JSON data to pass to the web component 32 | -} 33 | optionsValue : Encode.Value 34 | optionsValue = 35 | Encode.list 36 | [ Encode.object 37 | [ ( "value", Encode.string "one" ) 38 | , ( "label", Encode.string "One" ) 39 | ] 40 | , Encode.object 41 | [ ( "value", Encode.string "two" ) 42 | , ( "label", Encode.string "Two" ) 43 | ] 44 | ] 45 | 46 | 47 | model : Model 48 | model = 49 | Model False "one" "form-field-name" False 50 | 51 | 52 | update : Msg -> Model -> Model 53 | update msg model = 54 | case msg of 55 | ToggleDisabled -> 56 | { model | disabled = not model.disabled } 57 | 58 | ToggleValue -> 59 | { model 60 | | value = 61 | if model.value == "one" then 62 | "two" 63 | else 64 | "one" 65 | } 66 | 67 | ChangeValue val -> 68 | let 69 | _ = 70 | Debug.log "value changed from Elm" val 71 | in 72 | { model | value = val } 73 | 74 | ChangeName name -> 75 | { model | name = name } 76 | 77 | ToggleError -> 78 | { model | hasError = not model.hasError } 79 | 80 | 81 | onChange : (String -> Msg) -> Html.Attribute Msg 82 | onChange tagger = 83 | on "onChange" (Decode.map tagger detailTargetValueDecoder) 84 | 85 | 86 | {-| IMPORTANT: We need to get the data of the original React event like 87 | this : event.detail[0].target.value 88 | -} 89 | detailTargetValueDecoder : Decoder String 90 | detailTargetValueDecoder = 91 | Decode.field "detail" <| 92 | Decode.index 0 <| 93 | Decode.at [ "target", "value" ] Decode.string 94 | 95 | 96 | {-| Create shorthand for custom element 97 | -} 98 | customSelect : List (Attribute msg) -> List (Html msg) -> Html msg 99 | customSelect = 100 | node "custom-select" 101 | 102 | 103 | {-| Create shorthand for custom property. Property is easier to use 104 | than attribute for Boolean values. 105 | -} 106 | hasError : Bool -> Attribute Msg 107 | hasError = 108 | property "hasError" << Encode.bool 109 | 110 | 111 | {-| Create shorthand for options property 112 | -} 113 | options : Encode.Value -> Attribute Msg 114 | options = 115 | property "options" 116 | 117 | 118 | view : Model -> Html Msg 119 | view model = 120 | div [] 121 | [ h3 [] [ text "Custom select component" ] 122 | , div [] 123 | [ customSelect 124 | [ id "customComponent" 125 | , name model.name 126 | , value model.value 127 | , disabled model.disabled 128 | , hasError model.hasError 129 | , options optionsValue 130 | , onChange ChangeValue 131 | ] 132 | [ text "Select cool stuff" ] 133 | ] 134 | , div [] 135 | [ button [ onClick ToggleDisabled ] [ text "toggle disabled" ] 136 | , button [ onClick ToggleValue ] [ text "toggle value" ] 137 | , button [ onClick ToggleError ] [ text "toggle error" ] 138 | , label [] 139 | [ text "Change name" 140 | , input [ onInput ChangeName, value model.name ] [] 141 | ] 142 | ] 143 | ] 144 | -------------------------------------------------------------------------------- /examples/elm/elm-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "summary": "helpful summary of your project, less than 80 characters", 4 | "repository": "https://github.com/user/project.git", 5 | "license": "BSD3", 6 | "source-directories": [ 7 | "." 8 | ], 9 | "exposed-modules": [], 10 | "dependencies": { 11 | "elm-lang/core": "5.1.1 <= v < 6.0.0", 12 | "elm-lang/html": "2.0.0 <= v < 3.0.0" 13 | }, 14 | "elm-version": "0.18.0 <= v < 0.19.0" 15 | } 16 | -------------------------------------------------------------------------------- /examples/elm/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 19 | 20 | 21 |
22 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-react-components", 3 | "repository": { 4 | "type": "git", 5 | "url": "https://github.com/ChristophP/web-react-components" 6 | }, 7 | "version": "1.4.2", 8 | "description": "A web component wrapper around React Components", 9 | "main": "dist/bundle.js", 10 | "scripts": { 11 | "start": "webpack-dev-server --mode=development", 12 | "build": "webpack --mode=production", 13 | "test": "echo \"TODO: add tests\"", 14 | "lint": "eslint webpack.config.js src", 15 | "prepublishOnly": "npm run build" 16 | }, 17 | "files": [ 18 | "dist/bundle.js" 19 | ], 20 | "author": "", 21 | "license": "ISC", 22 | "peerDependencies": { 23 | "react": ">=0.14.0", 24 | "react-dom": ">=0.14.0" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.4.4", 28 | "@babel/preset-env": "^7.4.4", 29 | "babel-loader": "^8.0.5", 30 | "eslint": "^5.16.0", 31 | "eslint-config-airbnb": "^17.1.0", 32 | "eslint-config-prettier": "^4.2.0", 33 | "eslint-plugin-import": "^2.17.2", 34 | "eslint-plugin-jsx-a11y": "^6.2.1", 35 | "eslint-plugin-prettier": "^3.0.1", 36 | "eslint-plugin-react": "^7.13.0", 37 | "pre-commit": "^1.2.2", 38 | "prettier": "^1.17.0", 39 | "webpack": "^4.30.0", 40 | "webpack-cli": "^3.3.1" 41 | }, 42 | "pre-commit": { 43 | "run": "lint, test", 44 | "silent": true 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unresolved 2 | import React from "react"; 3 | // eslint-disable-next-line import/no-unresolved 4 | import ReactDOM from "react-dom"; 5 | import { 6 | pipe, 7 | isBoolConvention, 8 | isHandlerConvention, 9 | objectFromArray, 10 | mapObject, 11 | mapObjectKeys, 12 | sanitizeAttributeName, 13 | setBooleanAttribute, 14 | } from "./util"; 15 | 16 | const Types = { 17 | bool: "bool", 18 | event: "event", 19 | json: "json", 20 | }; 21 | 22 | const mapAttributeToProp = (node, name) => 23 | // return 'undefined' instead of 'null' for missing attributes / properties 24 | // so that React's default props apply 25 | node[name] === null ? undefined : node[name]; 26 | 27 | const mapEventToProp = (node, name) => { 28 | // accessing properties instead of attributes here 29 | // (autom. attribute parsing) 30 | const handler = node[name]; 31 | 32 | return (...origArgs) => { 33 | // dispatch DOM event 34 | const domEvent = new CustomEvent(name, { 35 | bubbles: true, 36 | cancelable: true, 37 | detail: origArgs, // store original arguments from handler 38 | }); 39 | node.dispatchEvent(domEvent); 40 | 41 | // call event handler if defined 42 | if (typeof handler === "function") { 43 | handler.call(node, domEvent); 44 | } 45 | }; 46 | }; 47 | 48 | const mapToProps = (node, mapping) => { 49 | const mapFunc = (type, name) => 50 | type === Types.event 51 | ? mapEventToProp(node, name) 52 | : mapAttributeToProp(node, name); 53 | return mapObject(mapFunc, mapping); 54 | }; 55 | 56 | const mapToPropertyDescriptor = (name, type) => { 57 | const defaults = { enumerable: true, configurable: true }; 58 | 59 | // handlers 60 | if (type === Types.event) { 61 | let eventHandler; 62 | return { 63 | ...defaults, 64 | get() { 65 | // return event handler assigned via propery if available 66 | if (typeof eventHandler !== "undefined") return eventHandler; 67 | 68 | // return null if event handler attribute wasn't defined 69 | const value = this.getAttribute(name); 70 | if (value === null) return null; 71 | 72 | // try to return a function representation of the event handler attr. 73 | try { 74 | // eslint-disable-next-line no-new-func 75 | return new Function(value); 76 | } catch (err) { 77 | return null; 78 | } 79 | }, 80 | set(value) { 81 | eventHandler = typeof value === "function" ? value : null; 82 | this.attributeChangedCallback(); 83 | }, 84 | }; 85 | } 86 | 87 | // booleans 88 | if (type === Types.bool) { 89 | return { 90 | ...defaults, 91 | get() { 92 | return this.hasAttribute(name); 93 | }, 94 | set(value) { 95 | setBooleanAttribute(this, name, value); 96 | }, 97 | }; 98 | } 99 | 100 | // json 101 | return { 102 | ...defaults, 103 | get() { 104 | const value = this.getAttribute(name); 105 | 106 | // try to parse as JSON 107 | try { 108 | return JSON.parse(value); 109 | } catch (e) { 110 | return value; // original string as fallback 111 | } 112 | }, 113 | set(value) { 114 | const str = typeof value === "string" ? value : JSON.stringify(value); 115 | this.setAttribute(name, str); 116 | }, 117 | }; 118 | }; 119 | 120 | const definePropertiesFor = (WebComponent, mapping) => { 121 | Object.keys(mapping).forEach(name => { 122 | const type = mapping[name]; 123 | 124 | Object.defineProperty( 125 | WebComponent.prototype, 126 | name, 127 | mapToPropertyDescriptor(name, type) 128 | ); 129 | }); 130 | }; 131 | 132 | const getType = name => { 133 | if (isBoolConvention(name)) { 134 | return Types.bool; 135 | } 136 | if (isHandlerConvention(name)) { 137 | return Types.event; 138 | } 139 | return Types.json; 140 | }; 141 | 142 | /** 143 | * Function to convert a React Components to a Web Components 144 | * @param {class} ReactComponent - A react component 145 | * @param {string[]} [propNames] - An optional list of property names to be 146 | * connected with the React component. 147 | * @param {Object} [eventMappers] - An optional map of functions which can 148 | * return an event to be dispatched 149 | * @returns {class} - The custom element class 150 | */ 151 | function convert( 152 | ReactComponent, 153 | propNames = [], 154 | eventMappers = {}, 155 | options = { useShadowDOM: true } 156 | ) { 157 | const createMap = obj => objectFromArray(getType, obj); 158 | const cleanKeys = obj => mapObjectKeys(sanitizeAttributeName, obj); 159 | const mapping = pipe( 160 | createMap, 161 | cleanKeys 162 | )(propNames); 163 | 164 | const attributeNames = Object.keys(mapping).map(name => name.toLowerCase()); 165 | 166 | const dispatcher = component => mapper => (...args) => { 167 | const event = mapper(...args); 168 | if (event) { 169 | component.dispatchEvent(event); 170 | } 171 | }; 172 | 173 | // render should be private 174 | const render = component => { 175 | const props = { 176 | ...mapToProps(component, mapping), 177 | // add event mappers, will possibly override the ones in attribute 178 | ...mapObject(dispatcher(component), eventMappers), 179 | }; 180 | 181 | const rootElement = options.useShadowDOM ? component.shadowRoot : component; 182 | 183 | ReactDOM.render( 184 | React.createElement(ReactComponent, props, React.createElement("slot")), 185 | rootElement 186 | ); 187 | }; 188 | 189 | class WebReactComponent extends HTMLElement { 190 | static get observedAttributes() { 191 | return attributeNames; 192 | } 193 | 194 | constructor() { 195 | super(); 196 | if (options.useShadowDOM) { 197 | this.attachShadow({ mode: "open" }); 198 | } 199 | } 200 | 201 | connectedCallback() { 202 | render(this); 203 | } 204 | 205 | attributeChangedCallback() { 206 | render(this); 207 | } 208 | 209 | disconnectedCallback() { 210 | const rootElement = options.useShadowDOM ? this.shadowRoot : this; 211 | 212 | ReactDOM.unmountComponentAtNode(rootElement); 213 | } 214 | } 215 | 216 | // dynamically create property getters and setters for attributes 217 | // and event handlers 218 | definePropertiesFor(WebReactComponent, mapping); 219 | 220 | return WebReactComponent; 221 | } 222 | 223 | /** 224 | * Function to register React Components as Web Components 225 | * @param {class} ReactComponent - A react component 226 | * @param {string} tagName - A name for the new custom tag 227 | * @param {string[]} [propNames] - An optional list of property names to be 228 | * connected with the React component. 229 | * @param {Object} [eventMappers] - An optional map of functions which can 230 | * return an event to be dispatched 231 | * @returns {class} - The custom element class 232 | */ 233 | function register( 234 | ReactComponent, 235 | tagName, 236 | propNames = [], 237 | eventMappers = {}, 238 | options = { useShadowDOM: true } 239 | ) { 240 | return customElements.define( 241 | tagName, 242 | convert(ReactComponent, propNames, eventMappers, options) 243 | ); 244 | } 245 | 246 | export default { 247 | register, 248 | convert, 249 | }; 250 | 251 | export { register, convert }; 252 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | // GENERAL PURPOSE 2 | 3 | // compose two functions left to right 4 | export const pipe = (f, g) => x => g(f(x)); 5 | 6 | // create an object from an array where the keys are the array items 7 | // and the values are created from a function that is passed which gets 8 | // the array entry as an argument 9 | export const objectFromArray = (createValue, arr) => 10 | arr.reduce((obj, val) => ({ ...obj, [val]: createValue(val) }), {}); 11 | 12 | // map an object's values, the callback function gets the value, 13 | // the key and the input object 14 | export const mapObject = (fn, obj) => 15 | Object.keys(obj).reduce( 16 | (acc, key) => ({ ...acc, [key]: fn(obj[key], key, obj) }), 17 | {} 18 | ); 19 | 20 | // map an object's keys, the callback function gets the current key 21 | export const mapObjectKeys = (fn, obj) => 22 | Object.keys(obj).reduce((acc, key) => ({ ...acc, [fn(key)]: obj[key] }), {}); 23 | 24 | // ATTRIBUTE CONVENTIONS 25 | const boolRegex = /^!!/; 26 | const handlerRegex = /\(\)$/; 27 | 28 | // check if a propName corresponds to a boolean convention, 29 | // starting with "!!" 30 | export const isBoolConvention = prop => boolRegex.test(prop); 31 | 32 | // check if a propName corresponds to a handler convention, 33 | // ending in "()" 34 | export const isHandlerConvention = prop => handlerRegex.test(prop); 35 | 36 | // clean an attribute name from modifiers like !! and () 37 | export const sanitizeAttributeName = prop => 38 | prop.replace(boolRegex, "").replace(handlerRegex, ""); 39 | 40 | // IMPURE 41 | 42 | // properly `set the value` for boolean attributes 43 | export const setBooleanAttribute = (node, name, value) => { 44 | if (value) { 45 | node.setAttribute(name, ""); 46 | } else { 47 | node.removeAttribute(name); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | entry: "./src/index.js", 5 | output: { 6 | path: path.resolve("dist"), 7 | filename: "bundle.js", 8 | library: "WebReactComponents", 9 | libraryTarget: "umd", 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.js$/, 15 | exclude: /node_modules/, 16 | loaders: ["babel-loader"], 17 | }, 18 | ], 19 | }, 20 | externals: { 21 | react: { 22 | commonjs: "react", 23 | commonjs2: "react", 24 | amd: "react", 25 | root: "React", 26 | }, 27 | "react-dom": { 28 | commonjs: "react-dom", 29 | commonjs2: "react-dom", 30 | amd: "react-dom", 31 | root: "ReactDOM", 32 | }, 33 | }, 34 | devServer: { 35 | contentBase: path.resolve("dev-assets"), 36 | port: process.env.PORT || 8080, 37 | host: "0.0.0.0", 38 | disableHostCheck: true, 39 | }, 40 | }; 41 | --------------------------------------------------------------------------------