├── .babelrc ├── .editorconfig ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── LICENSE.md ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── index.js ├── karma.conf.js ├── lib ├── WebComponent.js └── dom-model │ ├── DOMDecorators.js │ ├── DOMModel.js │ ├── DOMNode.js │ └── EmbedNode.js ├── package-lock.json ├── package.json ├── test ├── WebComponentTest.js ├── dom-model │ ├── DOMModelTest.js │ └── snippets │ │ └── json-model.html └── index.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 4 9 | end_of_line = lf 10 | insert_final_newline = false 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log 3 | /node_modules 4 | /dist 5 | /target 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Adobe Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at Grp-opensourceoffice@adobe.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for choosing to contribute! 4 | 5 | The following are a set of guidelines to follow when contributing to this project. 6 | 7 | ## Code Of Conduct 8 | 9 | This project adheres to the Adobe [code of conduct](CODE_OF_CONDUCT.md). By participating, 10 | you are expected to uphold this code. Please report unacceptable behavior to 11 | [Grp-opensourceoffice@adobe.com](mailto:Grp-opensourceoffice@adobe.com). 12 | 13 | ## Have A Question? 14 | 15 | Start by filing an issue. The existing committers on this project work to reach 16 | consensus around project direction and issue solutions within issue threads 17 | (when appropriate). 18 | 19 | ## Contributor License Agreement 20 | 21 | All third-party contributions to this project must be accompanied by a signed contributor 22 | license agreement. This gives Adobe permission to redistribute your contributions 23 | as part of the project. [Sign our CLA](http://opensource.adobe.com/cla.html). You 24 | only need to submit an Adobe CLA one time, so if you have submitted one previously, 25 | you are good to go! 26 | 27 | ## Code Reviews 28 | 29 | All submissions should come in the form of pull requests and need to be reviewed 30 | by project committers. Read [GitHub's pull request documentation](https://help.github.com/articles/about-pull-requests/) 31 | for more information on sending pull requests. 32 | 33 | Lastly, please follow the [pull request template](PULL_REQUEST_TEMPLATE.md) when 34 | submitting a pull request! 35 | 36 | ## From Contributor To Committer 37 | 38 | We love contributions from our community! If you'd like to go a step beyond contributor 39 | and become a committer with full write access and a say in the project, you must 40 | be invited to the project. The existing committers employ an internal nomination 41 | process that must reach lazy consensus (silence is approval) before invitations 42 | are issued. If you feel you are qualified and want to get more deeply involved, 43 | feel free to reach out to existing committers to have a conversation about that. 44 | 45 | ## Security Issues 46 | 47 | Security issues shouldn't be reported on this issue tracker. Instead, [file an issue to our security experts](https://helpx.adobe.com/security/alertus.html) -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Expected Behaviour 2 | 3 | ### Actual Behaviour 4 | 5 | ### Reproduce Scenario (including but not limited to) 6 | 7 | #### Steps to Reproduce 8 | 9 | #### Platform and Version 10 | 11 | #### Sample Code that illustrates the problem 12 | 13 | #### Logs taken while reproducing problem -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | 7 | ## Related Issue 8 | 9 | 10 | 11 | 12 | 13 | 14 | ## Motivation and Context 15 | 16 | 17 | 18 | ## How Has This Been Tested? 19 | 20 | 21 | 22 | 23 | 24 | ## Screenshots (if appropriate): 25 | 26 | ## Types of changes 27 | 28 | 29 | 30 | - [ ] Bug fix (non-breaking change which fixes an issue) 31 | - [ ] New feature (non-breaking change which adds functionality) 32 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 33 | 34 | ## Checklist: 35 | 36 | 37 | 38 | 39 | - [ ] I have signed the [Adobe Open Source CLA](http://opensource.adobe.com/cla.html). 40 | - [ ] My code follows the code style of this project. 41 | - [ ] My change requires a change to the documentation. 42 | - [ ] I have updated the documentation accordingly. 43 | - [ ] I have read the **CONTRIBUTING** document. 44 | - [ ] I have added tests to cover my changes. 45 | - [ ] All new and existing tests passed. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Web Component 2 | 3 | This project aims to bridge the connection between a React Component and a CustomElement. 4 | The strategy the library takes is to create a model that defines how the DOM is translated into React props. 5 | 6 | Since the goal is support React components as CustomElements we are not supporting extending from builting elements such as Paragraph, Select etc. 7 | 8 | It supports: 9 | * Updating the React component automatically when attributes get changed 10 | * Supports automatic registration and triggering of events 11 | * Supports multiple render targets, the custom element itself, a container div or shadow root. 12 | * Allows parsing and updating of nested structures of DOM, for example: 13 | ```js 14 | 18 | ``` 19 | This DOM structure can be transformed into a model and automatically injected into a React Component. 20 | You can transform *any* DOM structure into a model that will be passed to the React Component. 21 | This allows you to make use of most of the DOM api and encapsulate React as an implementation detail. 22 | 23 | ### Installing the library 24 | 25 | ``` 26 | npm install @adobe/react-webcomponent 27 | ``` 28 | 29 | If you are using Babel 6: 30 | Because we are targeting CustomElements V1 and we are using Babel to transpile our code there will be a problem with instantiating the CustomElement. 31 | See [this issue](https://github.com/w3c/webcomponents/issues/587) for the discussion. 32 | Include the [custom-elements-es5-adapter](https://github.com/webcomponents/webcomponentsjs#custom-elements-es5-adapterjs) before you load this librabry to fix this issue. 33 | 34 | Is you are using Babel 7: the issues is fixed so you shouldn't need anything else. 35 | 36 | We are using class properties and decorators so make sure you include the appropiat babel plugins to use this. 37 | 38 | ### Defining a Custom Element 39 | The first thing which is need is a React Component to expose as a Custom Element 40 | 41 | ```jsx 42 | import React, { Component } from 'react'; 43 | 44 | import { createCustomElement, DOMModel, byContentVal, byAttrVal, registerEvent } from "@adobe/react-webcomponent"; 45 | 46 | class ReactButton extends Component { 47 | constructor(props) { 48 | super(props); 49 | } 50 | render() { 51 | return (
52 | 53 |

Text

54 |
) 55 | } 56 | } 57 | ``` 58 | Then you need to create a model which defines how the DOM is parsed into React properties. 59 | 60 | ```jsx 61 | class ButtonModel extends DOMModel { 62 | @byContentVal text = "something"; 63 | @byAttrVal weight; 64 | @registerEvent("change") change; 65 | } 66 | ``` 67 | You create the custom element 68 | ```jsx 69 | const ButtonCustomElement = createCustomElement(ReactButton, ButtonModel, "container"); 70 | ``` 71 | And then register it 72 | 73 | ```js 74 | window.customElements.define("test-button", ButtonCustomElement); 75 | ``` 76 | 77 | #### Defining where the React component will be rendered 78 | 79 | When defining the CustomElement you have the posibility to specify where the React component will be rendered by specifying the `renderRoot` property. 80 | The possible values are: 81 | * container 82 | This will generate an extra div inside the custom element and the React Component will be rendered there.   83 | This is useful because React will remove all the children of the container element it renders in. 84 | So if you would like to parse values from the provided markup of the custom element and modify them, the elements will be lost after the initial rendering. 85 | For example: 86 | ```js 87 | 88 | My Button 89 | 90 | ``` 91 | > If we wouldn't render in a container the `my-button-label` element would be removed by React when rendering. 92 | 93 | * shadowRoot 94 | This will determine the creation of the custom element shadowRoot and the React component will be rendered in it 95 | 96 | * element 97 | The React component will be rendered directly in the custom element. 98 | 99 | #### Extending the custom element 100 | By default we provide the utility to create a custom element `createCustomElement`. This encapsulates the default behaviour but doesn't allow extension of the element. 101 | This can be bypassed and the customElement can be extended with new capabilities. 102 | 103 | ```js 104 | import { CustomElement } from "@adobe/react-webcomponent"; 105 | 106 | class ButtonCustomElement extends CustomElement { 107 | constructor() { 108 | super(); 109 | this._custom = 3; 110 | } 111 | get custom() { 112 | return this._custom; 113 | } 114 | 115 | set custom(value) { 116 | this._custom = value; 117 | } 118 | }; 119 | ButtonCustomElement.observedAttributes = Model.prototype.attributes; 120 | ButtonCustomElement.domModel = Model; 121 | ButtonCustomElement.ReactComponent = ReactComponent; 122 | ButtonCustomElement.renderRoot = "container"; // optional, defaults to "element" 123 | window.customElements.define("test-button", ButtonCustomElement); 124 | ``` 125 | 126 | ### DOMModel 127 | This utility is reponsible from converting a DOM node to a model. The model is decorated with a series of specialize decorators. Each decorator will parse the dom and construct the model: 128 | * [byAttrVal](#byattrval) 129 | * [byBooleanAttrVal](#bybooleanattrval) 130 | * [byJsonAttrVal](#byjsonattrval) 131 | * [byContentVal](#bycontentval) 132 | * [byContent](#bycontent) 133 | * [byChildContentVal](#bychildcontentval) 134 | * [byChildRef](#bychildref) 135 | * [byModel](#bymodel) 136 | * [byChildModelVal](#bychildmodelval) 137 | * [byChildrenRefArray](#bychildrenrefarray) 138 | * [byChildrenTypeArray](#bychildrentypearray) 139 | * [registerEvent](#registerevent) 140 | 141 | #### byAttrVal . 142 | 143 | Parses the element and sets the value corresponding to the attribute value of element 144 | ```js 145 | @byAttrVal(attrName:string) - defaults to the name of the property that it decorates. 146 | ``` 147 | ```js 148 | class Model extends DOMModel { 149 | @byAttrVal() weight; 150 | @byAttrVal("custom-attribute-name") reactPropName; 151 | } 152 | ``` 153 | Usage: 154 | ```js 155 |
156 | const model = new Model().fromDOM(document.getElementById("elem")); 157 | model ~ { 158 | weight: "3", 159 | reactPropName: "some value" 160 | } 161 | ``` 162 | 163 | #### byBooleanAttrVal 164 | 165 | Parses the element and sets the value corresponding to the presence of the attribute on the element. 166 | The value of the attribute is ignored, only the presence of the attribute determines the value 167 | ```js 168 | @byBooleanAttrVal(attrName:string) - defaults to the name of the property it decorates 169 | ``` 170 | ```js 171 | class Model extends DOMModel { 172 | @byBooleanAttrVal() checked; 173 | @byBooleanAttrVal("is-required") required; 174 | } 175 | ``` 176 | Usage: 177 | ```js 178 |
179 | const model = new Model().fromDOM(document.getElementById("elem")); 180 | model ~ { 181 | checked: true, 182 | required: undefined 183 | } 184 |
185 | model.fromDOM(document.getElementById("elem")); 186 | model ~ { 187 | checked: true, 188 | required: true 189 | } 190 | ``` 191 | 192 | #### byJsonAttrVal 193 | 194 | Parses the element and sets the value by parsing the value using `JSON.parse`. 195 | ```js 196 | class Model extends DOMModel { 197 | @byJsonAttrVal() obj; 198 | @byJsonAttrVal("alias-attr") anotherObj; 199 | } 200 | 201 |
202 | const model = new Model().fromDOM(document.getElementById("elem")); 203 | model ~ { 204 | obj: [{"example":1},{"test":2}], 205 | anotherObj: [{"other":"example},{"test":3}] 206 | } 207 | ``` 208 | 209 | #### byContentVal 210 | Parse the element and sets the value to the `innerText` of the element. 211 | ```js 212 | class Model extends DOMModel { 213 | @byContentVal() label; 214 | } 215 |
My Label
216 | const model = new Model().fromDOM(document.getElementById("elem")); 217 | model ~ { 218 | label: "My Label" 219 | } 220 | ``` 221 | 222 | #### byContent 223 | This decorator allows you to capture a DOM node that is matched by a CSS selector. 224 | This can be used to reparent arbitrary child DOM content, which may not have been 225 | rendered with React, into your web component. Once parsing has occurred, the field 226 | in the model will contain a React component that represents the DOM content that 227 | will be reparented. 228 | 229 | The DOM content will be moved when the React component is mounted. And, the content 230 | will be put back in its original location if the React component is later unmounted. 231 | ```js 232 | @byContent(attrName:selector) - the CSS selector that will match the child node. 233 | ``` 234 | ```js 235 | class Model extends DOMModel { 236 | @byContent('.content') content; 237 | } 238 |
239 |
240 | This will be reparented 241 |
242 |
243 |
244 | 245 | const model = new Model().fromDOM(document.getElementById("elem")); 246 | ReactDOM.render(
{ model.content }
, document.getElementById("mount-point")) 247 | 248 | // Once React has rendered the above component, the DOM will look like this 249 | 250 |
251 | 252 |
253 |
254 |
255 | This will be reparented 256 |
257 |
258 | ``` 259 | 260 | #### byChildContentVal 261 | Parse the element looking for an element that matches the given selector and sets value to the `innerText` of that element 262 | ```js 263 | class Model extends DOMModel { 264 | @byChildContentVal("custom-label") label; 265 | } 266 |
My Label
267 | const model = new Model().fromDOM(document.getElementById("elem")); 268 | model ~ { 269 | label: "My Label" 270 | } 271 | ``` 272 | 273 | #### byChildRef 274 | Parses the element and looks for an child element that matches the given selector and sets the value to result of parsing the child element with the given model 275 | 276 | ```js 277 | class CustomLabelModel extends DOMModel { 278 | @byContentVal() value; 279 | @byAttrVal() required; 280 | } 281 | class Model extends DOMModel { 282 | @byChildContentVal("custom-label", CustomLabelModel) label; 283 | } 284 | 285 |
My Label
286 | const model = new Model().fromDOM(document.getElementById("elem")); 287 | model ~ { 288 | label: { 289 | value: "My Label", 290 | required: true 291 | } 292 | } 293 | ``` 294 | 295 | #### byModel 296 | Assigns the value of running a given model over the element. 297 | This allows the element model to be saved on a different property than directly on the model. 298 | 299 | ```js 300 | class CustomModel extends DOMModel { 301 | @byContentVal() value; 302 | @byAttrVal() required; 303 | } 304 | 305 | class Model extends DOMModel { 306 | @byModelVal() item; 307 | } 308 | 309 |
Content
310 | const model = new Model().fromDOM(document.getElementById("elem")); 311 | model ~ { 312 | item: { 313 | value: "Content", 314 | required: true 315 | } 316 | } 317 | ``` 318 | 319 | #### byChildModelVal 320 | Parse the element and sets the value by getting the model value from the custom elem. 321 | This attribute only returns something if there is a custom element parsed. 322 | 323 | Using the Button defined at the beginning: 324 | ```js 325 | window.customElements.define("test-button", ButtonCustomElement); 326 | ``` 327 | We define a model 328 | ```js 329 | class Model extends DOMModel { 330 | @byChildModelVal("test-button") button; 331 | } 332 | 333 |
Click me
334 | const model = new Model().fromDOM(document.getElementById("elem")); 335 | model ~ { 336 | button: { 337 | weight: 3, 338 | text: "Click me" 339 | } 340 | } 341 | ``` 342 | The fundamental difference here is that the model is defined in another custom element and it is reused in this model. 343 | So it doesn't get redefined. 344 | 345 | #### byChildrenRefArray 346 | Parse the element children and selects all the elements that match the provided selector. 347 | For each element it uses the referenced model to parse the value of the element. 348 | All the resulting array of values is stored as the value on the decorated property. 349 | 350 | ```js 351 | class OptionModel extends DOMModel { 352 | @byContentVal() content; 353 | @byAttrVal() value; 354 | @byBooleanAttrVal() selected; 355 | } 356 | 357 | class SelectModel extends DOMModel { 358 | @byChildrenRefArray("option", OptionModel) options; 359 | } 360 | 361 | 366 | const model = new Model().fromDOM(document.getElementById("elem")); 367 | model ~ { 368 | options: [{value: 1, content: "Amsterdam"}, {value: 2, content: "Berlin"}, {value: 3, content: "London"}] 369 | } 370 | ``` 371 | 372 | #### byChildrenTypeArray 373 | Parses the element children and for each child if the nodeName matches one from the provided map it will parse that child with the corresponding model. 374 | 375 | ```js 376 | class Child1Model extends DOMModel { 377 | @byAttrVal() checked; 378 | } 379 | 380 | class Child2Model extends DOMModel { 381 | @byAttrVal() selected; 382 | } 383 | 384 | class Model extends DOMModel { 385 | @byChildrenTypeArray({ 386 | "child-one": Child1Model, 387 | "child-two": Child2Model 388 | }) items; 389 | } 390 | 391 |
392 | 393 | 394 |
395 | const model = new Model().fromDOM(document.getElementById("elem")); 396 | model ~ { 397 | items: [{checked: true}, {selected: true}] 398 | } 399 | ``` 400 | 401 | #### registerEvent 402 | Registers an event to be registered on the React component and when it is called it a CustomEvent will be triggered on the custom element. 403 | The event name is automatically transformed into camelCase and prefixed with `on` 404 | *This behaviours happens on the CustomElement not the DOMModel, the DOMModel only registers the event* 405 | ```js 406 | class Model extends DOMModel { 407 | @registerEvent("change") change; 408 | } 409 | ``` 410 | Eventually this will be converted the CustomElement in a `onChange` property on the React component. 411 | 412 | 413 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export * from "./lib/WebComponent"; 2 | export * from "./lib/dom-model/DOMModel"; 3 | export * from "./lib/dom-model/DOMDecorators"; -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const testIndexPath = path.resolve("test/index.js"); 3 | 4 | module.exports = function(config) { 5 | let karmaOptions = { 6 | outputDir: "target/testing", 7 | frameworks: [ "mocha", "chai-sinon" ], 8 | browsers: [ "Chrome" ], 9 | // files that Karma will server to the browser 10 | files: [ 11 | // entry file for Webpack 12 | testIndexPath 13 | ], 14 | 15 | // before serving test/index.js to the browser 16 | preprocessors: { 17 | [testIndexPath]: [ 18 | // use karma-webpack to preprocess the file via webpack 19 | "webpack", 20 | // use karma-sourcemap-loader to utilize sourcemaps generated by webpack 21 | "sourcemap" 22 | ] 23 | }, 24 | 25 | // webpack configuration used by karma-webpack 26 | webpack: { 27 | // generate sourcemaps 28 | devtool: "inline-source-map", 29 | mode: "development", 30 | module: { 31 | rules: [ 32 | { 33 | test: /\.(js|jsx)$/, 34 | exclude: /node_modules/, 35 | loader: "babel-loader", 36 | options: { 37 | "presets": ["@babel/preset-env", "@babel/preset-react"], 38 | "plugins": [ 39 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 40 | ["@babel/plugin-proposal-class-properties", { "loose": true }] 41 | ] 42 | } 43 | }, 44 | { 45 | test: /\.html$/, 46 | loader: "html-loader" 47 | } 48 | ] 49 | }, 50 | // relative path starts out at the src folder when importing modules 51 | resolve: { 52 | alias: { 53 | "lib": path.join(__dirname, "lib") 54 | } 55 | } 56 | }, 57 | webpackMiddleware: { 58 | // only output webpack error messages 59 | stats: "errors-only" 60 | } 61 | }; 62 | 63 | config.set(karmaOptions); 64 | }; -------------------------------------------------------------------------------- /lib/WebComponent.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software distributed under 8 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | OF ANY KIND, either express or implied. See the License for the specific language 10 | governing permissions and limitations under the License. 11 | */ 12 | 13 | import React from 'react'; 14 | import ReactDOM from 'react-dom'; 15 | 16 | const _rootShadows = new WeakMap(); 17 | const _models = new WeakMap(); 18 | 19 | /** 20 | * Generates the model for a given CustomElement 21 | * 22 | * @param {CustomElement} component - the component to parse 23 | * @returns {DOMModel} - the generated model 24 | */ 25 | function generateModel(component) { 26 | let model = _models.get(component); 27 | if (!model && component.constructor.domModel) { 28 | model = new component.constructor.domModel(component); 29 | model.fromDOM(component); 30 | _models.set(component, model); 31 | } 32 | return model; 33 | } 34 | 35 | /** 36 | * Generates the events for a CustomElement 37 | * 38 | * @param {CustomElement} component - the component to parse the model fromDO 39 | * @returns {Object} - the events object 40 | */ 41 | function getEvents(component) { 42 | let eventsMap = {}; 43 | let model = _models.get(component); 44 | if (model) { 45 | let events = model.events; 46 | events.forEach((eventName) => { 47 | let eventFn = eventName; 48 | if (!eventFn.startsWith('on')) { 49 | if (!/[A-Z]/.test(eventFn[0])) { 50 | eventFn = eventFn[0].toUpperCase() + eventFn.substr(1); 51 | } 52 | eventFn = 'on' + eventFn; 53 | } 54 | eventsMap[eventFn] = function(event) { 55 | component.dispatchEvent(new CustomEvent(eventName, { 'detail': event.detail, bubbles: true })); 56 | } 57 | }); 58 | } 59 | 60 | return eventsMap; 61 | } 62 | 63 | /** 64 | * Renders a CustomElement 65 | * 66 | * @param {CustomElement} component the component to render 67 | */ 68 | function renderCustomElement(component) { 69 | const ReactComponent = component.constructor.ReactComponent; 70 | const model = generateModel(component); 71 | const properties = model.properties; 72 | const events = getEvents(component); 73 | const reactElem = React.createElement(ReactComponent, Object.assign(properties, events), null); 74 | ReactDOM.render(reactElem, _rootShadows.get(component), function() { 75 | component.__reactComp = this; 76 | }); 77 | } 78 | 79 | export class CustomElement extends HTMLElement { 80 | connectedCallback() { 81 | let rootEl = this; 82 | switch(this.constructor.renderRoot) { 83 | case "container": 84 | rootEl = this.rootDiv = document.createElement("div"); 85 | this.appendChild(rootEl); 86 | break; 87 | case "shadowRoot": 88 | rootEl = this.attachShadow({ mode: "closed" }) 89 | break; 90 | } 91 | _rootShadows.set(this, rootEl); 92 | 93 | renderCustomElement(this); 94 | this.addEventListener('_updateModel', this._updateModel.bind(this)); 95 | } 96 | 97 | _generateModel() { 98 | return generateModel(this); 99 | } 100 | 101 | _updateModel(event) { 102 | let model = _models.get(this); 103 | if (model) { 104 | let changedProperties = event.detail; 105 | changedProperties.forEach((property) => { 106 | model[property.propertyName] = property.value; 107 | }); 108 | } 109 | renderCustomElement(this); 110 | } 111 | 112 | disconnectedCallback() { 113 | const rootEl = _rootShadows.get(this); 114 | if(rootEl) { 115 | ReactDOM.unmountComponentAtNode(_rootShadows.get(this)); 116 | } 117 | 118 | if (this.rootDiv) { 119 | this.removeChild(this.rootDiv); 120 | delete this.rootDiv; 121 | } 122 | let model = _models.get(this); 123 | if (model) { 124 | model.destroy(); 125 | _models.delete(this); 126 | } 127 | } 128 | 129 | attributeChangedCallback(name, oldValue, newValue) { 130 | let model = _models.get(this); 131 | if (model) { 132 | let key = model.getAttributeKey(name); 133 | let property = model.getProperty(key); 134 | model[key] = property ? property.fromDOM(this) : newValue; 135 | renderCustomElement(this); 136 | } 137 | } 138 | } 139 | 140 | /** 141 | * Creates a CustomElement 142 | * @param {function} ReactComponent 143 | * @param {DOMModel} Model 144 | * @param {string} renderRoot 145 | */ 146 | export function createCustomElement(ReactComponent, Model, renderRoot = "element") { 147 | class CustomCustomElement extends CustomElement {}; 148 | CustomCustomElement.domModel = Model; 149 | CustomCustomElement.ReactComponent = ReactComponent; 150 | CustomCustomElement.renderRoot = renderRoot; 151 | if (Model) { 152 | CustomCustomElement.observedAttributes = Model.prototype.attributes; 153 | } 154 | return CustomCustomElement; 155 | } 156 | -------------------------------------------------------------------------------- /lib/dom-model/DOMDecorators.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software distributed under 8 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | OF ANY KIND, either express or implied. See the License for the specific language 10 | governing permissions and limitations under the License. 11 | */ 12 | import DOMNode from './DOMNode'; 13 | import EmbedNode from './EmbedNode'; 14 | import React from 'react'; 15 | 16 | let _idCount = 0; 17 | 18 | function makeDecorator(callback) { 19 | return function (...args) { 20 | if (args.length === 3 && typeof args[2] === "object") { 21 | return callback().apply(this, args) 22 | } else { 23 | return callback(...args); 24 | } 25 | } 26 | } 27 | 28 | function makeDOMNode(target, selector) { 29 | const dataNode = new DOMNode(); 30 | dataNode.node = target; 31 | dataNode.selector = selector; 32 | target._reactComponentDataNode = dataNode; 33 | return dataNode; 34 | } 35 | 36 | function findCommentNode(element, selector) { 37 | for(let i = 0; i < element.childNodes.length; i++) { 38 | let node = element.childNodes[i]; 39 | if( node.nodeType === Node.COMMENT_NODE 40 | && node._reactComponentDataNode 41 | && node._reactComponentDataNode.selector === selector) { 42 | return node; 43 | } 44 | } 45 | } 46 | 47 | function queryChildren(element, selector, all = false) { 48 | if (typeof selector !== 'string') { 49 | console.warn('Query selector must be string!'); 50 | return; 51 | } 52 | let id = element.id, 53 | guid = element.id = id || 'query_children_' + _idCount++, 54 | attr = '#' + guid + ' > ', 55 | scopedSelector = attr + (selector + '').replace(',', ',' + attr, 'g'); 56 | let result = all ? element.querySelectorAll(scopedSelector) : element.querySelector(scopedSelector); 57 | if (!id) { 58 | element.removeAttribute('id'); 59 | } 60 | return result; 61 | } 62 | 63 | /** 64 | * Parses the element and returns the innerText 65 | * 66 | * @returns {function} - the decorator function 67 | */ 68 | let byContentVal = makeDecorator(function() { 69 | return function (target, key, descriptor) { 70 | if (target.addProperty) { 71 | descriptor.writable = true; 72 | target.addProperty(key, (element) => { 73 | let valueFn = () => { 74 | return element && element.innerText; 75 | } 76 | attachContentListObserver(target, key, element, valueFn, { 77 | attributes: false, characterData: false, childList: true, subtree: false 78 | }); 79 | return valueFn(); 80 | }); 81 | } 82 | } 83 | }); 84 | 85 | 86 | /** 87 | * Parses the element and returns the value of the provided attribute 88 | * 89 | * @param {[String} attrName - the attribute we are parsing 90 | * @returns {function} - the decorator function 91 | */ 92 | let byAttrVal = makeDecorator(function(attrName) { 93 | return function (target, key, descriptor) { 94 | descriptor.writable = true; 95 | let attributeName = attrName || key; 96 | target.addAttribute(attributeName); 97 | target.addAttributeKey(attributeName, key); 98 | let defaultValue; 99 | if (descriptor.initializer) { 100 | defaultValue = descriptor.initializer(); 101 | } 102 | 103 | if (target.addProperty) { 104 | target.addProperty(key, (element) => { 105 | return element && (element.hasAttribute(attributeName) 106 | ? element.getAttribute(attributeName) : defaultValue); 107 | }) 108 | } 109 | } 110 | }); 111 | 112 | /** 113 | * Parses the element and returns the JSON parse value of the provided attribute 114 | * 115 | * @param {[String} attrName - the attribute we are parsing 116 | * @returns {function} - the decorator function 117 | */ 118 | let byJsonAttrVal = makeDecorator(function(attrName) { 119 | return function (target, key, descriptor) { 120 | descriptor.writable = true; 121 | let attributeName = attrName || key; 122 | target.addAttribute(attributeName); 123 | target.addAttributeKey(attributeName, key); 124 | let defaultValue; 125 | if (descriptor.initializer) { 126 | defaultValue = descriptor.initializer(); 127 | } 128 | if (target.addProperty) { 129 | target.addProperty(key, (element) => { 130 | return element && (element.hasAttribute(attributeName) 131 | ? JSON.parse(element.getAttribute(attributeName)) : defaultValue); 132 | }) 133 | } 134 | } 135 | }); 136 | 137 | 138 | /** 139 | * Creates a property that sets the value based on the existance of an attribute 140 | * @param {String} attrName - the name of the attribute 141 | * @param {Object} [config = null] - the configuration for the property 142 | */ 143 | let byBooleanAttrVal = makeDecorator(function(attrName) { 144 | return function (target, key, descriptor) { 145 | descriptor.writable = true; 146 | let attributeName = attrName || key; 147 | target.addAttribute(attributeName); 148 | target.addAttributeKey(attributeName, key); 149 | let defaultValue; 150 | if (descriptor.initializer) { 151 | defaultValue = descriptor.initializer(); 152 | } 153 | if (target.addProperty) { 154 | target.addProperty(key, (element) => { 155 | return element && (element.hasAttribute(attributeName)); 156 | }); 157 | }; 158 | } 159 | }); 160 | 161 | /** 162 | * Attaches a content observer to the child, and registers the observer on the target 163 | * 164 | * @private 165 | * @param {DOMModel} target - the dom model we are attaching to 166 | * @param {String} key - the name of the property we are currently on 167 | * @param {HTMLElement} element - the element that we are parsing 168 | * @param {function} valueFn - the function to return the value of the child 169 | * @param {Object} observeOptions - the options passed to observe method 170 | */ 171 | function attachContentListObserver(target, key, element, valueFn, observeOptions ) { 172 | if (element && !element._isObserved) { 173 | const observer = new MutationObserver(() => { 174 | element.dispatchEvent(new CustomEvent('_updateModel', { 175 | detail: [{ 176 | propertyName: key, 177 | value: valueFn && valueFn(element) 178 | }] 179 | })) 180 | }); 181 | element._isObserved = true; 182 | observer.observe(element, observeOptions || { attributes: false, characterData: true, 183 | childList: true, subtree:true }); 184 | if (target.addObserver) { 185 | target.addObserver(observer); 186 | } 187 | } 188 | } 189 | 190 | /** 191 | * Attaches a content observer to the child, and registers the observer on the target 192 | * 193 | * @private 194 | * @param {DOMModel} target - the dom model we are attaching to 195 | * @param {String} key - the name of the property we are currently on 196 | * @param {HTMLElement} element - the element that we are parsing 197 | * @param {HTMLElement} child - the element we are observing 198 | * @param {function} valueFn - the function to return the value of the child 199 | */ 200 | function attachContentObserver(target, key, element, child, valueFn) { 201 | if (child && !child._isObserved) { 202 | const observer = new MutationObserver(() => { 203 | element.dispatchEvent(new CustomEvent('_updateModel', { 204 | detail: [{ 205 | propertyName: key, 206 | value: valueFn && valueFn() 207 | }] 208 | })) 209 | }); 210 | child._isObserved = true; 211 | observer.observe(child.childNodes[0], { characterData: true, childList: false }); 212 | observer.observe(child, { characterData: false, childList: true }); 213 | if (target.addObserver) { 214 | target.addObserver(observer); 215 | } 216 | } 217 | } 218 | 219 | /** 220 | * Adds to the model a property that returns a react component that, when 221 | * rendered, will produce the node matched by the given selector. The 222 | * React component will "steal" the DOM node from it's original parent. 223 | * If the React component is later removed from the render tree, the DOM 224 | * node that was stolen will be replaced in its original parent. 225 | * 226 | * @param {String} selector - the selector for the child to turn into a React component 227 | * 228 | * @returns {function} - the decorator function 229 | */ 230 | let byContent = makeDecorator(function(selector) { 231 | return function (target, key, descriptor) { 232 | descriptor.writable = true; 233 | if (target.addProperty) { 234 | target.addProperty(key, (element) => { 235 | if (element && (element instanceof HTMLElement)) { 236 | let node = null; 237 | 238 | let valueFn = () => { 239 | if (!node) { 240 | let child = queryChildren(element, selector); 241 | if (child) { 242 | node = ; 243 | } 244 | } 245 | return node; 246 | } 247 | let result = valueFn(); 248 | if (!result) { 249 | // We could not match the selector. That is possibly because 250 | // a rendering engine has not filled in the children yet. 251 | // Watch for DOM mutations that add a matching element 252 | attachContentObserver(target, key, element, element, valueFn); 253 | } 254 | return valueFn(); 255 | } 256 | }); 257 | } 258 | } 259 | }); 260 | 261 | /** 262 | * Parses the element and returns the innerText of the child element with the provided name 263 | * 264 | * @param {String} childName - the child element name 265 | * @returns {function} - the decorator function 266 | */ 267 | let byChildContentVal = makeDecorator(function(childName) { 268 | return function (target, key, descriptor) { 269 | descriptor.writable = true; 270 | if (target.addProperty) { 271 | target.addProperty(key, (element) => { 272 | if (element && (element instanceof HTMLElement)) { 273 | let child = element.querySelector(childName); 274 | let valueFn = () => child && child.innerText; 275 | attachContentObserver(target, key, element, child, valueFn); 276 | return valueFn(); 277 | } 278 | }) 279 | } 280 | } 281 | }); 282 | 283 | /** 284 | * Creates a property with parsed by the provided model 285 | * @param {DOMModel} refType - the DOMModel class to parse with 286 | */ 287 | 288 | let byModel = makeDecorator(function(refType) { 289 | return function (target, key, descriptor) { 290 | descriptor.writable = true; 291 | if (target.addProperty) { 292 | target.addProperty(key, (element) => { 293 | if (element && (element instanceof HTMLElement)) { 294 | let value = new refType(); 295 | value.fromDOM(element); 296 | return value; 297 | } 298 | }); 299 | 300 | }; 301 | }; 302 | }); 303 | 304 | /** 305 | * Creates a property with an array of values based on a child exportable model 306 | * @param {String} selector - the selector for the children 307 | * @param {DOMModel} refType - the DOMExportable class of the child model 308 | */ 309 | let byChildrenRefArray = makeDecorator(function(selector, refType) { 310 | return function (target, key, descriptor) { 311 | descriptor.writable = true; 312 | if (target.addProperty) { 313 | target.addProperty(key, (element) => { 314 | let valueFn = function(domElement) { 315 | let result = []; 316 | if (domElement && (domElement instanceof HTMLElement)) { 317 | let children = domElement.querySelectorAll(selector); 318 | 319 | for (let i = 0, l = children.length; i < l; ++i) { 320 | let value = new refType(); 321 | value.fromDOM(children[i]); 322 | result.push(value); 323 | } 324 | } 325 | return result; 326 | } 327 | 328 | attachContentListObserver(target, key, element, valueFn,{ attributes: true, characterData: true, 329 | childList: true, subtree:true }); 330 | return valueFn(element); 331 | }) 332 | } 333 | } 334 | }); 335 | 336 | /** 337 | * Creates a property with an array of values parsed by the map of node name and DOMModel 338 | * @param {Object} childrenMap - a map between a node name string and a DOMModel 339 | * @returns {function} - the decorator function 340 | */ 341 | let byChildrenTypeArray = makeDecorator(function(childrenMap) { 342 | return function (target, key, descriptor) { 343 | descriptor.writable = true; 344 | if (target.addProperty) { 345 | target.addProperty(key, (element) => { 346 | let valueFn = function(domElement, childrenMap) { 347 | var result = []; 348 | if (domElement && (domElement instanceof HTMLElement)) { 349 | let children = domElement.children; 350 | for(let i = 0; i < children.length; ++i) { 351 | let child = children[i]; 352 | let refType = childrenMap[child.nodeName.toLowerCase()]; 353 | if (refType) { 354 | let value = new refType; 355 | value.fromDOM(child); 356 | result.push(value); 357 | } 358 | } 359 | } 360 | return result; 361 | } 362 | let observerFn = (domElement) => { 363 | return valueFn(domElement, childrenMap ) 364 | } 365 | // TODO optimize this so we don't actually observe everything 366 | // One way would be one observer per element and one observer on the parent to disconnect when removed 367 | attachContentListObserver(target, key, element, observerFn); 368 | return valueFn(element, childrenMap); 369 | }) 370 | } 371 | } 372 | }); 373 | 374 | /** 375 | * Creates a property with the value returned by parsing the elements returned by the selector with the given DOMModel 376 | * 377 | * @param {String} selector - the selector that will be ran against the element, and use the first result of the query. 378 | * @returns {function} - the decorator function 379 | */ 380 | let byChildRef = makeDecorator(function(selector, refType) { 381 | return function (target, key, descriptor) { 382 | descriptor.writable = true; 383 | if (target.addProperty) { 384 | target.addProperty(key, (element) => { 385 | let valueFn = (domElement) => { 386 | if (domElement && (domElement instanceof HTMLElement)) { 387 | let child = domElement.querySelector(selector); 388 | if (child) { 389 | let value = new refType(); 390 | value.fromDOM(child); 391 | return value; 392 | } 393 | } 394 | } 395 | return valueFn(element); 396 | }) 397 | } 398 | } 399 | }); 400 | 401 | /** 402 | * Creates a property based on a webcomponent model of a child 403 | * @param {String} selector - the selector used 404 | * @returns {function} - the decorator function 405 | */ 406 | let byChildModelVal = makeDecorator(function(selector) { 407 | return function (target, key, descriptor) { 408 | descriptor.writable = true; 409 | if (target.addProperty) { 410 | target.addProperty(key, (element) => { 411 | if (element && (element instanceof HTMLElement)) { 412 | let child = element.querySelector(selector); 413 | if (child && child._generateModel && typeof child._generateModel === "function") { 414 | // This will trigger toDOM on the component further 415 | return child._generateModel(); 416 | } 417 | } 418 | }); 419 | } 420 | } 421 | }); 422 | /** 423 | * Registers an event on the model 424 | * 425 | * @param {String} eventName - the name of the event 426 | * @returns {function} - the decorator function 427 | */ 428 | let registerEvent = makeDecorator(function(eventName) { 429 | return function (target, key, descriptor) { 430 | if (target.addEvent) { 431 | target.addEvent(eventName || key); 432 | } 433 | } 434 | }); 435 | 436 | export { 437 | byAttrVal, 438 | byBooleanAttrVal, 439 | byContentVal, 440 | byContent, 441 | byChildContentVal, 442 | byJsonAttrVal, 443 | byModel, 444 | byChildrenRefArray, 445 | byChildrenTypeArray, 446 | byChildRef, 447 | registerEvent, 448 | byChildModelVal 449 | }; -------------------------------------------------------------------------------- /lib/dom-model/DOMModel.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software distributed under 8 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | OF ANY KIND, either express or implied. See the License for the specific language 10 | governing permissions and limitations under the License. 11 | */ 12 | 13 | export class DOMModel { 14 | /** 15 | * Registers this property on the model 16 | * 17 | * @param {String} name - the name of the property 18 | * @param {function} fromDOM - the method to convert from DOM to Model 19 | */ 20 | addProperty(name, fromDOM) { 21 | if (!this._exportableProperties) { 22 | this._exportableProperties = []; 23 | } 24 | this._exportableProperties.push({ 25 | name, 26 | fromDOM 27 | }); 28 | } 29 | 30 | /** 31 | * Returns a property based on the name 32 | * 33 | * @param {String} name - the name fo the property we are looking from 34 | * @returns {Object} - the registered property 35 | */ 36 | getProperty(name) { 37 | return this._exportableProperties && this._exportableProperties.find((exportableProperty) => exportableProperty.name === name); 38 | } 39 | 40 | /** 41 | * Registeres an attribute 42 | * 43 | * @param {String} attrName - the attribute name 44 | */ 45 | addAttribute(attrName) { 46 | if (!this._attributes) { 47 | this._attributes = []; 48 | } 49 | this._attributes.push(attrName); 50 | } 51 | 52 | /** 53 | * Adds a attribute key 54 | * 55 | * @param {String} attrName - the name of the attribute 56 | * @param {String} key - the key 57 | */ 58 | addAttributeKey(attrName, key) { 59 | if (!this._attributeKeys) { 60 | this._attributeKeys = {}; 61 | } 62 | this._attributeKeys[attrName] = key; 63 | } 64 | 65 | /** 66 | * Gets the key of an attribute 67 | * 68 | * @param {String} attrName - the attribute name to look for 69 | * @returns {String} - the key of the attribute 70 | */ 71 | getAttributeKey(attrName) { 72 | return this._attributeKeys && this._attributeKeys[attrName]; 73 | } 74 | 75 | /** 76 | * Register an event to the model 77 | * 78 | * @param {String} evtName - the event name 79 | */ 80 | addEvent(evtName) { 81 | if (!this._events) { 82 | this._events = []; 83 | } 84 | 85 | this._events.push(evtName); 86 | } 87 | 88 | /** 89 | * Registers an observer on the Model 90 | * 91 | * @param {MutationObserver} observer - the mutation observer 92 | */ 93 | addObserver(observer) { 94 | if (!this._observers) { 95 | this._observers = []; 96 | } 97 | this._observers.push(observer); 98 | } 99 | 100 | /** 101 | * Returns the registered attributes on the model 102 | * 103 | * @returns {Array} - the registered attributes 104 | */ 105 | get attributes() { 106 | return this._attributes || []; 107 | } 108 | 109 | /** 110 | * Returns the registered events on the model 111 | * 112 | * @returns {Array} - the registered events 113 | */ 114 | get events() { 115 | return this._events || []; 116 | } 117 | 118 | /** 119 | * Generates the model from a DOM element 120 | * 121 | * @param {HTMLElement} element - the element to parse the model from 122 | */ 123 | fromDOM(element) { 124 | if (!this._exportableProperties) { 125 | return; 126 | } 127 | 128 | this._exportableProperties.forEach((exportableProperty) => { 129 | let result = exportableProperty.fromDOM(element); 130 | this[exportableProperty.name] = result; 131 | }); 132 | } 133 | 134 | /** 135 | * Returns the registered properties on the model 136 | * 137 | * @returns {Object} - the registered properties 138 | */ 139 | get properties() { 140 | if (!this._exportableProperties) { 141 | return; 142 | } 143 | 144 | let wrappedProperties = {}; 145 | this._exportableProperties.forEach((property) => { 146 | wrappedProperties[property.name] = this[property.name]; 147 | }); 148 | return wrappedProperties; 149 | } 150 | 151 | /** 152 | * Destroys the model 153 | */ 154 | destroy() { 155 | if (this._observers) { 156 | this._observers.forEach((observer) => { 157 | observer.disconnect(); 158 | }); 159 | } 160 | this._observers = null; 161 | this._attributes = null; 162 | this._exportableProperties = null; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /lib/dom-model/DOMNode.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software distributed under 8 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | OF ANY KIND, either express or implied. See the License for the specific language 10 | governing permissions and limitations under the License. 11 | */ 12 | 13 | /* eslint no-constant-condition: "off" */ 14 | 15 | const domNodeMutationOptions = { childList: true }; 16 | 17 | function handleDOMNodeMutation(mutations, observer) { 18 | const domNode = observer._domNode; 19 | if (!domNode.stolen) { 20 | return; 21 | } 22 | const node = domNode.node; 23 | const parentNode = node.parentNode; 24 | if (!parentNode) { 25 | domNode.applicationDidRemoveItem(); 26 | } 27 | } 28 | /** 29 | * This is a container around a HTMLElement which allows for the element to be removed from the DOM 30 | * replaced with a comment and then returned back to the DOM 31 | */ 32 | export default class DOMNode { 33 | 34 | /** 35 | * Removes the node from the DOM and replaces with a comment 36 | * @returns {HTMLElement} - the HTML DOM node 37 | */ 38 | stealNode() { 39 | if (this.stolen) { 40 | return null; 41 | } 42 | 43 | const node = this.node; 44 | this.returned = false; 45 | 46 | var placeholder = this.placeholder; 47 | if (!placeholder) { 48 | placeholder = this.placeholder = document.createComment('placeholder for ' + node.nodeName); 49 | placeholder._reactComponentDataNode = this; 50 | } 51 | 52 | node.parentNode.replaceChild(placeholder, node); 53 | this.stolen = true; 54 | return this.node; 55 | } 56 | 57 | /** 58 | * Returns the node to the DOM. It replaceses the comment with the node 59 | */ 60 | returnNode() { 61 | if (!this.stolen) { 62 | return; 63 | } 64 | 65 | this.stolen = false; 66 | this.returned = true; 67 | this.stopObserving(); 68 | 69 | const placeholder = this.placeholder; 70 | const placeholderParent = placeholder.parentNode; 71 | if (placeholderParent) { 72 | placeholderParent.replaceChild(this.node, placeholder); 73 | } 74 | } 75 | 76 | /** 77 | * Observes the mutations of the parent node to check if the users are removing the node. 78 | */ 79 | observe() { 80 | if (this.observer) { 81 | this.stopObserving(); 82 | } 83 | const observer = this.observer = new MutationObserver(handleDOMNodeMutation); 84 | observer._domNode = this; 85 | observer.observe(this.node.parentNode, domNodeMutationOptions); 86 | } 87 | 88 | /** 89 | * Stops the mutation observer 90 | */ 91 | stopObserving() { 92 | const observer = this.observer; 93 | if (observer) { 94 | observer.disconnect(); 95 | this.observer = null; 96 | } 97 | } 98 | 99 | /** 100 | * Marks that the application removed the item 101 | */ 102 | applicationDidRemoveItem() { 103 | this.stolen = false; 104 | this.returned = true; 105 | this.stopObserving(); 106 | this.remove(); 107 | } 108 | 109 | /** 110 | * Adds the node to the list of child elements. 111 | * It will look for the correct position in the list of the element. 112 | * @param {Array} - the list of the child element 113 | */ 114 | add(list) { 115 | 116 | this.list = list; 117 | var target = this.span || this.node; 118 | do { 119 | target = target.previousSibling; 120 | if (!target) { 121 | // this is the first item, just insert it at the very top. 122 | list.splice(0, 0, this); 123 | return; 124 | } 125 | const data = target._reactComponentDataNode; 126 | if (data) { 127 | // If the element is stolen, then we need to use the placeholder and not 128 | // the actual element. Otherwise the element might actually be added to the same 129 | // list of elements and might use the wrong position. 130 | if (data.stolen && data.placeholder !== target) { 131 | // Continue until we find the right element. 132 | continue; 133 | } 134 | const index = list.indexOf(data); 135 | if (index !== -1) { 136 | list.splice(index + 1, 0, this); 137 | return; 138 | } 139 | } 140 | } while (true); 141 | } 142 | 143 | /** 144 | * Removes the node 145 | */ 146 | remove() { 147 | const list = this.list; 148 | if (list) { 149 | list.removeItem(this); 150 | this.list = null; 151 | } 152 | const node = this.node; 153 | if (node._reactComponentDataNode === this) { 154 | node._reactComponentDataNode = null; 155 | } 156 | this.removePlaceholder(); 157 | } 158 | 159 | /** 160 | * Removes the placeholder of the node 161 | */ 162 | removePlaceholder() { 163 | const placeholder = this.placeholder; 164 | if (placeholder) { 165 | const placeholderParent = placeholder.parentNode; 166 | if (placeholderParent) { 167 | placeholderParent.removeChild(placeholder); 168 | } 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /lib/dom-model/EmbedNode.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software distributed under 8 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | OF ANY KIND, either express or implied. See the License for the specific language 10 | governing permissions and limitations under the License. 11 | */ 12 | 13 | import React, {Component} from 'react'; 14 | 15 | export default class EmbedNode extends Component { 16 | 17 | render() { 18 | return
this.element = element }/> 19 | } 20 | 21 | componentDidMount() { 22 | this.parent = this.element.parentElement; 23 | this.stolenNode = this.props.item.stealNode(); 24 | this.parent.replaceChild(this.stolenNode, this.element); 25 | } 26 | 27 | componentWillUnmount() { 28 | this.parent.replaceChild(this.element, this.stolenNode); 29 | this.props.item.returnNode(); 30 | delete this.stolenNode; 31 | } 32 | 33 | shouldComponentUpdate() { 34 | // Prevent this node from rendering after it is mounted 35 | return false; 36 | } 37 | 38 | get hasStolenNode() { 39 | return this.stolenNode != null 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adobe/react-webcomponent", 3 | "version": "0.1.4", 4 | "author": "Adobe", 5 | "license": "Apache-2.0", 6 | "scripts": { 7 | "build": "webpack --mode production", 8 | "test": "npm run karma -- --single-run", 9 | "karma": "node_modules/karma/bin/karma start", 10 | "prepublish": "npm run test && npm run build" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/adobe/react-webcomponent.git" 15 | }, 16 | "files": [ 17 | "dist/index.js" 18 | ], 19 | "main": "dist/index.js", 20 | "peerDependencies": { 21 | "react": ">=14.2.0", 22 | "react-dom": ">=14.2.0" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.9.0", 26 | "@babel/plugin-proposal-class-properties": "^7.8.3", 27 | "@babel/plugin-proposal-decorators": "^7.8.3", 28 | "@babel/preset-env": "^7.9.5", 29 | "@babel/preset-react": "^7.9.4", 30 | "babel-loader": "^8.1.0", 31 | "chai": "^4.2.0", 32 | "html-loader": "^1.1.0", 33 | "karma": "^5.0.0", 34 | "karma-chai-sinon": "^0.1.5", 35 | "karma-chrome-launcher": "^3.1.0", 36 | "karma-coverage": "^2.0.2", 37 | "karma-junit-reporter": "^2.0.1", 38 | "karma-mocha": "^2.0.0", 39 | "karma-sourcemap-loader": "^0.3.7", 40 | "karma-webpack": "^4.0.0-beta.0", 41 | "mocha": "^7.1.2", 42 | "react": "^16.2.0", 43 | "react-dom": "^16.2.0", 44 | "sinon": "^9.0.2", 45 | "sinon-chai": "^3.5.0", 46 | "webpack": "^4.43.0", 47 | "webpack-cli": "^3.3.11" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/WebComponentTest.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { expect } from "chai"; 3 | 4 | import { DOMModel, byJsonAttrVal, byAttrVal, byChildContentVal, registerEvent, createCustomElement } from "../index"; 5 | 6 | import JSONSnippet from "./dom-model/snippets/json-model.html"; 7 | 8 | describe("WebComponent", () => { 9 | let element, model; 10 | 11 | class Model extends DOMModel { 12 | @byJsonAttrVal("j-attr") jAttr; 13 | @byAttrVal weight; 14 | @byAttrVal height = 5; 15 | @byChildContentVal("child-value") value; 16 | @registerEvent change; 17 | } 18 | 19 | class ReactComponent extends Component { 20 | constructor(props) { 21 | super(props); 22 | window._reactProps = props; 23 | } 24 | 25 | render() { 26 | return (
27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
weight { this.props.weight }
height { this.props.height }
Json Attribute { JSON.stringify(this.props.jAttr) }
value { this.props.value }
47 |
) 48 | } 49 | } 50 | 51 | window.customElements.define("custom-component", createCustomElement(ReactComponent, Model, "container")); 52 | 53 | beforeEach(() => { 54 | let container = document.createElement("div"); 55 | container.innerHTML = JSONSnippet; 56 | element = container.firstElementChild; 57 | model = new Model(); 58 | model.fromDOM(element); 59 | 60 | }); 61 | 62 | it("should register an element", (done) => { 63 | customElements.whenDefined('custom-component').then(() => { 64 | done(); 65 | }); 66 | }); 67 | 68 | it("should have all the attributes", (done) => { 69 | customElements.whenDefined('custom-component').then(() => { 70 | document.body.appendChild(element); 71 | requestAnimationFrame(() => { 72 | expect(element.querySelector("#weight").textContent).to.equal("3"); 73 | expect(element.querySelector("#value").textContent).to.equal("Test content"); 74 | expect(element.querySelector("#jAttr").textContent).to.equal("[{\"example\":1},{\"test\":2}]"); 75 | document.body.removeChild(element); 76 | done(); 77 | }); 78 | }); 79 | }); 80 | 81 | it("default value should be respected", (done) => { 82 | customElements.whenDefined('custom-component').then(() => { 83 | document.body.appendChild(element); 84 | requestAnimationFrame(() => { 85 | expect(element.querySelector("#height").textContent).to.equal("5"); 86 | done(); 87 | }) 88 | }); 89 | }) 90 | 91 | it("attributes should update", (done) => { 92 | customElements.whenDefined('custom-component').then(() => { 93 | document.body.appendChild(element); 94 | requestAnimationFrame(() => { 95 | element.setAttribute("weight", "5"); 96 | requestAnimationFrame(() => { 97 | expect(element.querySelector("#weight").textContent).to.equal("5"); 98 | element.setAttribute("j-attr", "[{\"example\":3},{\"test\":4}]"); 99 | requestAnimationFrame(() => { 100 | expect(element.querySelector("#jAttr").textContent).to.equal("[{\"example\":3},{\"test\":4}]"); 101 | element.querySelector("child-value").textContent = "SOMETHING"; 102 | requestAnimationFrame(() => { 103 | expect(element.querySelector("#value").textContent).to.equal("SOMETHING"); 104 | document.body.removeChild(element); 105 | requestAnimationFrame(() => { 106 | done(); 107 | }); 108 | }); 109 | }); 110 | }); 111 | }); 112 | }); 113 | }); 114 | 115 | it("should clean up when unmounted", () => { 116 | customElements.whenDefined('custom-component').then(() => { 117 | expect(element.querySelector('div')).to.be.null; 118 | 119 | // CustomElement should create a container div 120 | document.body.appendChild(element); 121 | expect(element.querySelector('div')).to.exist; 122 | 123 | // CustomElement clean up the container div 124 | document.body.removeChild(element); 125 | expect(element.querySelector('div')).to.be.null; 126 | 127 | // Make sure that remounting it works 128 | document.body.appendChild(element); 129 | expect(element.querySelector('div')).to.exist; 130 | 131 | document.body.removeChild(element); 132 | expect(element.querySelector('div')).to.be.null; 133 | }); 134 | }); 135 | }); -------------------------------------------------------------------------------- /test/dom-model/DOMModelTest.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software distributed under 8 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | OF ANY KIND, either express or implied. See the License for the specific language 10 | governing permissions and limitations under the License. 11 | */ 12 | 13 | import { expect, assert } from "chai"; 14 | 15 | import { DOMModel, byJsonAttrVal, byAttrVal, byBooleanAttrVal, 16 | byChildContentVal, byChildrenRefArray, registerEvent, 17 | byChildRef 18 | } from "../../index"; 19 | import JSONSnippet from "./snippets/json-model.html"; 20 | import { byModel, byContentVal, byContent, byChildrenTypeArray, byChildModelVal } from "../../lib/dom-model/DOMDecorators"; 21 | import React from "react"; 22 | import ReactDOM from "react-dom"; 23 | 24 | describe("DOMModel", () => { 25 | let element, model; 26 | 27 | const makeModel = function(Model, snippet) { 28 | let container = document.createElement("div"); 29 | container.innerHTML = snippet; 30 | element = container.firstElementChild; 31 | model = new Model(); 32 | model.fromDOM(element); 33 | return { 34 | model, 35 | element 36 | } 37 | } 38 | 39 | class JSONModel extends DOMModel { 40 | @byJsonAttrVal("j-attr") jAttr; 41 | @byAttrVal weight; 42 | @byBooleanAttrVal required; 43 | @byChildContentVal("child-value") value; 44 | @registerEvent change; 45 | } 46 | 47 | 48 | describe("byJsonAttrVal", () => { 49 | it("parses the value correctly", () => { 50 | let { model } = makeModel(JSONModel, JSONSnippet); 51 | expect(model.jAttr).to.not.equal(undefined); 52 | expect(model.jAttr.length).to.equal(2); 53 | expect(model.jAttr[0].example).to.equal(1); 54 | expect(model.jAttr[1].test).to.equal(2); 55 | }); 56 | }); 57 | 58 | describe("byAttrVal", () => { 59 | it("parses the value correctly", () => { 60 | let { model } = makeModel(JSONModel, JSONSnippet); 61 | expect(model.weight).to.equal("3"); 62 | }); 63 | }); 64 | 65 | describe("byBooleanVal", () => { 66 | it("parses the value correctly", () => { 67 | let { model, element } = makeModel(JSONModel, JSONSnippet); 68 | expect(model.required).to.equal(true); 69 | element.removeAttribute("required"); 70 | model.fromDOM(element); 71 | expect(model.required).to.equal(false); 72 | }); 73 | }); 74 | 75 | describe("byChildContentVal", () => { 76 | it("parses the value correctly", () => { 77 | let { model } = makeModel(JSONModel, JSONSnippet); 78 | expect(model.value).to.equal("Test content"); 79 | }); 80 | 81 | it("child content updates with innerHTML", (done) => { 82 | let { element } = makeModel(JSONModel, JSONSnippet); 83 | element.addEventListener("_updateModel", (event) => { 84 | expect(event.detail[0].propertyName).to.equal("value"); 85 | expect(event.detail[0].value).to.equal("Another content"); 86 | done(); 87 | }); 88 | 89 | element.querySelector("child-value").innerHTML = "Another content"; 90 | }); 91 | 92 | it("child content updates with textContent", (done) => { 93 | let { element } = makeModel(JSONModel, JSONSnippet); 94 | element.addEventListener("_updateModel", (event) => { 95 | expect(event.detail[0].propertyName).to.equal("value"); 96 | expect(event.detail[0].value).to.equal("Another textContent"); 97 | done(); 98 | }); 99 | 100 | element.querySelector("child-value").textContent = "Another textContent"; 101 | }); 102 | }); 103 | 104 | describe("registerEvent", () => { 105 | it("event should be registered", () => { 106 | let { model } = makeModel(JSONModel, JSONSnippet); 107 | expect(model.events.length).to.equal(1); 108 | expect(model.events[0]).to.equal("change"); 109 | }); 110 | }); 111 | 112 | describe("byModel", () => { 113 | 114 | class CustomModel extends DOMModel { 115 | @byModel(JSONModel) customModel; 116 | } 117 | it("parses the value correctly", () => { 118 | let { model, element } = makeModel(CustomModel, JSONSnippet); 119 | 120 | expect(model.customModel.jAttr).to.not.equal(undefined); 121 | expect(model.customModel.jAttr.length).to.equal(2); 122 | expect(model.customModel.jAttr[0].example).to.equal(1); 123 | expect(model.customModel.jAttr[1].test).to.equal(2); 124 | 125 | expect(model.customModel.weight).to.equal("3"); 126 | 127 | expect(model.customModel.required).to.equal(true); 128 | element.removeAttribute("required"); 129 | model.fromDOM(element); 130 | expect(model.customModel.required).to.equal(false); 131 | 132 | expect(model.customModel.value).to.equal("Test content"); 133 | expect(model.customModel.events.length).to.equal(1); 134 | expect(model.customModel.events[0]).to.equal("change"); 135 | }); 136 | }); 137 | 138 | describe("byChildrenRefArray", () => { 139 | let element, model; 140 | 141 | class OptionModel extends DOMModel { 142 | @byContentVal() text; 143 | @byAttrVal() value; 144 | } 145 | 146 | class ChildrenModel extends DOMModel { 147 | @byChildrenRefArray("option", OptionModel) options; 148 | } 149 | beforeEach(() => { 150 | let result = makeModel(ChildrenModel, ``); 156 | element = result.element; 157 | model = result.model; 158 | }); 159 | 160 | const checkOption = function(option, level) { 161 | expect(option.value).to.equal(`option${level}`); 162 | expect(option.text).to.equal(`Option ${level}`); 163 | } 164 | 165 | it("parses the value correctly", () => { 166 | expect(model.options).to.exist; 167 | expect(model.options).to.be.an('array'); 168 | expect(model.options).to.have.lengthOf(4); 169 | for(let i = 0; i < model.options.length; i++) { 170 | checkOption(model.options[i], i + 1); 171 | } 172 | }); 173 | 174 | it("updates the value when attribute changes", (done) => { 175 | let option = element.querySelector(`option[value="option2"]`); 176 | expect(option).to.exist; 177 | element.addEventListener("_updateModel", (event) => { 178 | let newValue = event.detail[0].value; 179 | expect(newValue).to.exist; 180 | expect(newValue).to.be.an('array'); 181 | expect(newValue).to.have.lengthOf(4); 182 | expect(newValue[1].value).to.equal("optionNew"); 183 | done(); 184 | }); 185 | option.setAttribute("value", "optionNew"); 186 | }); 187 | 188 | it("updates the value when content changes", (done) => { 189 | let option = element.querySelector(`option[value="option2"]`); 190 | expect(option).to.exist; 191 | element.addEventListener("_updateModel", (event) => { 192 | let newValue = event.detail[0].value; 193 | expect(newValue).to.exist; 194 | expect(newValue).to.be.an('array'); 195 | expect(newValue).to.have.lengthOf(4); 196 | expect(newValue[1].text).to.equal("Changed Option Label"); 197 | done(); 198 | }); 199 | option.innerText = 'Changed Option Label'; 200 | }); 201 | 202 | it("updates when removing item", (done) => { 203 | let option = element.querySelector(`option[value="option2"]`); 204 | expect(option).to.exist; 205 | element.addEventListener("_updateModel", (event) => { 206 | let newValue = event.detail[0].value; 207 | expect(newValue).to.exist; 208 | expect(newValue).to.be.an('array'); 209 | expect(newValue).to.have.lengthOf(3); 210 | expect(newValue[0].value).to.equal("option1"); 211 | expect(newValue[1].value).to.equal("option3"); 212 | expect(newValue[2].value).to.equal("option4"); 213 | done(); 214 | }); 215 | option.remove(); 216 | }); 217 | 218 | it("updates when adding item", (done) => { 219 | let option = element.querySelector(`option[value="option3"]`); 220 | expect(option).to.exist; 221 | element.addEventListener("_updateModel", (event) => { 222 | let newValue = event.detail[0].value; 223 | expect(newValue).to.exist; 224 | expect(newValue).to.be.an('array'); 225 | expect(newValue).to.have.lengthOf(5); 226 | expect(newValue[0].value).to.equal("option1"); 227 | expect(newValue[1].value).to.equal("option2"); 228 | expect(newValue[2].value).to.equal("option5"); 229 | expect(newValue[3].value).to.equal("option3"); 230 | expect(newValue[4].value).to.equal("option4"); 231 | done(); 232 | }); 233 | let newOption = document.createElement("option"); 234 | newOption.setAttribute("value", "option5"); 235 | newOption.innerText = "Option 5" 236 | element.insertBefore(newOption, option); 237 | }); 238 | }); 239 | 240 | describe("byChildrenTypeArray", () => { 241 | let element, model; 242 | class ChildType1 extends DOMModel { 243 | @byContentVal() content; 244 | @byAttrVal() size; 245 | } 246 | 247 | class ChildType2 extends DOMModel { 248 | @byBooleanAttrVal() selected; 249 | } 250 | 251 | class ParentModel extends DOMModel { 252 | @byChildrenTypeArray({ 253 | "child-one": ChildType1, 254 | "child-two": ChildType2 255 | }) items; 256 | @byBooleanAttrVal() disabled; 257 | } 258 | 259 | beforeEach(() => { 260 | let result = makeModel(ParentModel, ` 261 | Content 262 | Ignored 263 | Content2 264 | `); 265 | element = result.element; 266 | model = result.model; 267 | }); 268 | 269 | it("should parse the value correctly", () => { 270 | expect(model.items).to.exist; 271 | expect(model.items).to.be.an("array"); 272 | expect(model.items).to.have.lengthOf(3); 273 | assert.instanceOf(model.items[0], ChildType1); 274 | assert.instanceOf(model.items[1], ChildType2); 275 | assert.instanceOf(model.items[2], ChildType1); 276 | }); 277 | 278 | it("updates works", () => { 279 | element.addEventListener("_updateModel", (event) => { 280 | expect(event.detail[0].propertyName).to.equal("items"); 281 | let items = event.detail[0].value; 282 | expect(items).to.exist; 283 | expect(items).to.be.an("array"); 284 | expect(items).to.have.lengthOf(4); 285 | assert.instanceOf(items[0], ChildType1); 286 | assert.instanceOf(items[1], ChildType2); 287 | assert.instanceOf(items[2], ChildType1); 288 | assert.instanceOf(items[3], ChildType1); 289 | }); 290 | let newChild = document.createElement("child-one"); 291 | newChild.setAttribute("value", "5"); 292 | newChild.innerText = "new content"; 293 | element.appendChild(newChild); 294 | }); 295 | }); 296 | 297 | describe("byChildRef", () => { 298 | let element, model; 299 | 300 | class ChildModel extends DOMModel { 301 | @byAttrVal() size; 302 | } 303 | 304 | class ParentModel extends DOMModel{ 305 | @byChildRef("child", ChildModel) child; 306 | } 307 | 308 | beforeEach(() => { 309 | let result = makeModel(ParentModel, ` 310 | 311 | `); 312 | element = result.element; 313 | model = result.model; 314 | }); 315 | 316 | it("should parse the model correctly", () => { 317 | expect(model.child).to.exist; 318 | assert.instanceOf(model.child, ChildModel); 319 | }); 320 | }); 321 | 322 | describe("byChildModelVal", () => { 323 | let element, model; 324 | class ChildModel extends DOMModel { 325 | @byAttrVal() size; 326 | } 327 | 328 | class ParentModel extends DOMModel{ 329 | @byChildModelVal("child", ChildModel) child; 330 | } 331 | 332 | beforeEach(() => { 333 | let result = makeModel(ParentModel, ` 334 | 335 | `); 336 | element = result.element; 337 | model = result.model; 338 | }); 339 | 340 | it("parses the model", () => { 341 | let child = element.firstElementChild; 342 | const childModel = { "x" : 3 }; 343 | child._generateModel = function() { 344 | return childModel; 345 | }; 346 | model.fromDOM(element); 347 | expect(model.child).to.exist; 348 | expect(model.child).to.deep.equal(childModel); 349 | }) 350 | }); 351 | 352 | describe("byContentVal", () => { 353 | 354 | class ButtonModel extends DOMModel { 355 | @byAttrVal() variant; 356 | @byContentVal() label; 357 | } 358 | 359 | beforeEach(() => { 360 | let result = makeModel(ButtonModel, ` 361 | Push Me 362 | `); 363 | element = result.element; 364 | model = result.model; 365 | }); 366 | 367 | it("parses the model", () => { 368 | expect(model.variant).to.equal('action'); 369 | expect(model.label).to.equal('Push Me'); 370 | }); 371 | 372 | it("updates when the DOM changes", (done) => { 373 | expect(model.variant).to.equal('action'); 374 | expect(model.label).to.equal('Push Me'); 375 | 376 | element.addEventListener("_updateModel", (event) => { 377 | let change = event.detail[0]; 378 | expect(change.propertyName).to.equal('label'); 379 | expect(change.value).to.equal('New Label'); 380 | done(); 381 | }); 382 | 383 | element.innerHTML = 'New Label'; 384 | }); 385 | }); 386 | 387 | describe("byContent", () => { 388 | let element, model; 389 | 390 | class ComponentItemModel extends DOMModel { 391 | @byContentVal() name; 392 | } 393 | 394 | class ComponentModel extends DOMModel { 395 | @byAttrVal() size; 396 | @byChildrenRefArray('component-item', ComponentItemModel) items; 397 | @byContent('section') content; 398 | } 399 | 400 | beforeEach(() => { 401 | let result = makeModel(ComponentModel, ` 402 |
403 | 404 | Item 1 405 | Item 2 406 |
407 |

my content

408 |
409 |
410 |
411 |
412 | `); 413 | element = result.element; 414 | model = result.model; 415 | }); 416 | 417 | it("reparents captured content", (done) => { 418 | let mountPoint = element.querySelector('#mount-point'); 419 | element = element.firstElementChild; 420 | 421 | model.fromDOM(element); 422 | 423 | // Make sure that the rest of the model is there 424 | expect(model.size).to.equal('L'); 425 | expect(model.items).to.exist; 426 | expect(model.items).to.be.an('array'); 427 | expect(model.items).to.have.lengthOf(2); 428 | 429 | expect(model.content).to.exist; 430 | let component =
{ model.content }
431 | ReactDOM.render(component, mountPoint, () => { 432 | let div = mountPoint.firstElementChild; 433 | let section = div.firstElementChild; 434 | expect(section).to.exist; 435 | expect(section.tagName).to.equal('SECTION'); 436 | let p = section.firstElementChild; 437 | expect(p).to.exist; 438 | expect(p.tagName).to.equal('P'); 439 | expect(p.innerText).to.equal('my content'); 440 | 441 | ReactDOM.unmountComponentAtNode(mountPoint); 442 | 443 | setTimeout(() => { 444 | expect(mountPoint.firstElementChild).to.be.null; 445 | 446 | let component = element.querySelector('component'); 447 | let section = element.querySelector('section'); 448 | 449 | // Make sure that the content has been put back 450 | expect(section).to.exist; 451 | expect(section.tagName).to.equal('SECTION'); 452 | let p = section.firstElementChild; 453 | expect(p).to.exist; 454 | expect(p.tagName).to.equal('P'); 455 | expect(p.innerText).to.equal('my content'); 456 | 457 | done(); 458 | }, 1) 459 | }) 460 | }) 461 | }); 462 | }); 463 | -------------------------------------------------------------------------------- /test/dom-model/snippets/json-model.html: -------------------------------------------------------------------------------- 1 | 2 | Test content 3 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2018 Adobe. All rights reserved. 3 | This file is licensed to you under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. You may obtain a copy 5 | of the License at http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software distributed under 8 | the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS 9 | OF ANY KIND, either express or implied. See the License for the specific language 10 | governing permissions and limitations under the License. 11 | */ 12 | 13 | // require all the test files in the test folder that end with Spec.js or Spec.jsx 14 | const testsContext = require.context(".", true, /Test.jsx?$/); 15 | testsContext.keys().forEach(testsContext); -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | mode: "production", 5 | entry: ["./index.js"], 6 | output: { 7 | globalObject: `typeof self !== 'undefined' ? self : this`, 8 | path: path.resolve(__dirname, "dist"), 9 | filename: "index.js", 10 | library: 'ReactWebcomponent', 11 | libraryTarget: 'umd', 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.js$/, 17 | exclude: /node_modules/, 18 | use: { 19 | loader: "babel-loader" 20 | } 21 | } 22 | ] 23 | }, 24 | externals: { 25 | react: { 26 | commonjs: 'react', 27 | commonjs2: 'react', 28 | amd: 'react', 29 | root: 'React', 30 | }, 31 | 'react-dom': { 32 | commonjs: 'react-dom', 33 | commonjs2: 'react-dom', 34 | amd: 'react-dom', 35 | root: 'ReactDOM', 36 | }, 37 | } 38 | }; --------------------------------------------------------------------------------