├── .editorconfig ├── .eslintrc ├── .github ├── CONTRIBUTING.md └── workflows │ ├── github-publish.yml │ ├── npm-publish.yml │ └── npm-run-test.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── README.md ├── concepts │ ├── classes.md │ ├── hierarchy.md │ ├── lifecycle.md │ └── reactivity.md ├── features │ ├── computed-properties.md │ ├── dependency-injection-context-protocol.md │ ├── events.md │ ├── global-styles.md │ ├── methods.md │ ├── properties.md │ ├── refs.md │ ├── renderless.md │ ├── store.md │ ├── styles.md │ ├── templates.md │ └── watch.md ├── getting-started │ └── upgrade-to-v1.md └── guide │ └── guide.md ├── examples ├── attributes │ ├── attributes-element.js │ └── index.html ├── child-nodes │ ├── child-nodes-element.js │ └── index.html ├── context │ ├── context.html │ ├── other-provide-context.js │ ├── provide-context.js │ └── request-context.js ├── directives │ ├── attribute-element.js │ ├── index.html │ ├── unsafe-element.js │ └── unsafe.html ├── dsd-rendering │ ├── dsd-rendering.js │ └── index.html ├── example-base-element.js ├── example-directives.js ├── example-input-element.js ├── example-slotted-element.js ├── example-template-element.js ├── example-test-element.js ├── global-styles │ ├── index.html │ ├── shadow-element.js │ ├── styles.css │ ├── styles2.css │ ├── styles3.css │ └── styles4.css ├── index.html ├── raw-text-node │ ├── index.html │ └── raw-text-node-element.js └── state-hydration │ ├── index.html │ └── state-hydration.js ├── index.js ├── package-lock.json ├── package.json ├── src ├── BaseElement.js ├── StyledElement.js ├── TemplateElement.js ├── dom-parts │ ├── AttributePart.js │ ├── ChildNodePart.js │ ├── NodePart.js │ ├── Part.js │ ├── PartMarkers.js │ ├── RawTextNodePart.js │ ├── TemplatePart.js │ ├── TemplateResult.js │ ├── directives.js │ ├── html.js │ └── render.js └── util │ ├── AttributeParser.js │ ├── DOMHelper.js │ ├── Directive.js │ ├── GlobalStylesStore.js │ ├── SerializeStateHelper.js │ ├── Store.js │ ├── crypto.js │ ├── defineElement.js │ └── toString.js ├── test ├── unit │ ├── append-styles.test.js │ ├── attribute-naming.test.js │ ├── attribute-parsing.test.js │ ├── attribute-reflection.test.js │ ├── attribute-types.test.js │ ├── attributes-to-properties.test.js │ ├── computed-properties.test.js │ ├── context-protocol-shadow.test.html │ ├── context-protocol.definitions.js │ ├── context-protocol.test.html │ ├── directive-choose.test.js │ ├── directive-class-map.test.js │ ├── directive-optional-attribute.test.js │ ├── directive-spread-attributes.test.js │ ├── directive-style-map.test.js │ ├── directive-unsafe-html.test.js │ ├── directive-when.test.js │ ├── dsd-template-rendering.test.js │ ├── element-styles.test.html │ ├── element-styles1.test.css │ ├── element-styles2.test.css │ ├── events-dispatcher.test.js │ ├── events-map.test.js │ ├── global-styles.test.js │ ├── lifecycle-hooks.test.js │ ├── mutation-observer.test.js │ ├── nesting-elements.test.js │ ├── options.test.js │ ├── property-change-events.test.js │ ├── property-registration.test.js │ ├── raw-text-node-part.test.js │ ├── ref-registration.test.js │ ├── root-element.test.js │ ├── state-serialization.test.js │ ├── store.primitive.test.js │ ├── store.test.js │ ├── store.watch.test.js │ ├── template-bindings.test.js │ ├── template-result.test.js │ ├── updates.test.js │ ├── vanilla-render-slots.test.html │ ├── vanilla-renderer.test.js │ └── watched-properties.test.js └── util │ └── testing-helpers.js ├── web-dev-server.config.mjs └── web-test-runner.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{css,md}] 13 | indent_size = 2 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["plugin:prettier/recommended", "plugin:import/recommended"], 4 | "parser": "@babel/eslint-parser", 5 | "rules": { 6 | "prettier/prettier": ["error", { "semi": true }], 7 | "import/extensions": ["error", "always"] 8 | }, 9 | "globals": { 10 | "document": true, 11 | "window": true, 12 | "customElements": true, 13 | "HTMLElement": true, 14 | "requestAnimationFrame": true 15 | }, 16 | "parserOptions": { 17 | "ecmaVersion": 2022, 18 | "sourceType": "module", 19 | "requireConfigFile": false, 20 | "babelOptions": { 21 | "babelrc": false, 22 | "configFile": false 23 | } 24 | }, 25 | "env": { 26 | "es6": true, 27 | "browser": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing & Development 2 | 3 | Thanks for your interest in contributing to `@webtides/element-js`! Please take a moment to review this document **before submitting a pull request**. 4 | 5 | ## Pull requests 6 | 7 | **Please ask first before starting work on any significant new features.** 8 | 9 | It's never a fun experience to have your pull request declined after investing a lot of time and effort into a new feature. To avoid this from happening, we request that contributors create [an issue](https://github.com/webtides/element-js/issues) to first discuss any significant new features. 10 | 11 | ## Coding standards 12 | 13 | We use ESLint and Prettier to ensure good code quality. 14 | 15 | ESLint & Prettier will run automatically when staging files via `git`. 16 | 17 | ## Running tests 18 | 19 | You can run the test suite using the following commands: 20 | 21 | ```sh 22 | npm test 23 | ``` 24 | 25 | Please ensure that the tests are passing when submitting a pull request. If you're adding new features to `@webtides/element-js`, please include tests. 26 | 27 | ## Git Branching 28 | 29 | We use a trunk-based development workflow. 30 | 31 | > In the trunk-based development model, all developers work on a single branch with open access to it. Often it’s simply the `main` branch. They commit code to it and run it. It’s super simple. In some cases, they create short-lived feature branches. Once code on their branch compiles and passes all tests, they merge it straight to `main`. It ensures that development is truly continuous and prevents developers from creating merge conflicts that are difficult to resolve. 32 | 33 | As a Release is complete the `main` branch will be tagged with the new release version. 34 | 35 | ### Pull Requests 36 | 37 | Pull requests should take place whenever a: 38 | 39 | - FEATURE is about to be finished 40 | - RELEASE is about to be finished 41 | 42 | When all Reviewers approved a PR the feature/release may be finished locally and pushed to the remote 43 | -------------------------------------------------------------------------------- /.github/workflows/github-publish.yml: -------------------------------------------------------------------------------- 1 | name: GITHUB publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | - run: npm install 17 | - run: npm run test 18 | 19 | publish: 20 | needs: test 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | registry-url: https://npm.pkg.github.com/ 28 | - run: npm install 29 | - run: npm publish 30 | env: 31 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 32 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: NPM publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | - run: npm install 17 | - run: npm run test 18 | 19 | publish: 20 | needs: test 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | - run: npm ci 28 | - uses: JS-DevTools/npm-publish@v3 29 | with: 30 | token: ${{ secrets.NPM_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/npm-run-test.yml: -------------------------------------------------------------------------------- 1 | name: NPM run test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | - '1.0' 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | - run: npm install 21 | - run: npm run test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | test/coverage 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | test/coverage 3 | .idea 4 | *.iml 5 | .github 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "printWidth": 120, 4 | "trailingComma": "all", 5 | "tabWidth": 4, 6 | "semi": true, 7 | "singleQuote": true, 8 | "arrowParens": "always", 9 | "overrides": [ 10 | { 11 | "files": "*.md", 12 | "options": { 13 | "useTabs": false, 14 | "trailingComma": "none", 15 | "proseWrap": "never" 16 | } 17 | }, 18 | { 19 | "files": ["*.css", "*.yaml", "*.yml"], 20 | "options": { 21 | "tabWidth": 2 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Eddy Löwen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # element-js 2 | 3 | Simple and lightweight base classes for web components with a beautiful API. Dependency free. 4 | 5 | ## Introduction 6 | 7 | `element-js` lets you write simple, declarative and beautiful web components without the boilerplate. 8 | 9 | ## How to use 10 | 11 | #### Installation 12 | 13 | install `element-js` 14 | 15 | ```sh 16 | npm install --save @webtides/element-js 17 | ``` 18 | 19 | #### Use / Example Element 20 | 21 | `element-js` elements are plain ES6 classes (vanilla JS) with some nice mappings (eg. properties, watch, etc.). 22 | 23 | ```javascript 24 | // Import from a CDN 25 | // import { BaseElement, defineElement } from 'https://unpkg.com/@webtides/element-js'; 26 | // import { BaseElement, defineElement } from 'https://cdn.skypack.dev/@webtides/element-js'; 27 | // or when installed via npm 28 | import { BaseElement, defineElement, Store } from '@webtides/element-js'; 29 | 30 | const sharedDate = new Store({ date: Date.now() }); 31 | 32 | class ExampleElement extends BaseElement { 33 | // normal public properties 34 | greeting = 'Hello'; 35 | name = 'John'; 36 | 37 | // lifecycle hook 38 | connected() { 39 | this.greet(); 40 | } 41 | 42 | // reactive attributes/properties 43 | properties() { 44 | return { 45 | familyName: 'Doe', 46 | sharedDate 47 | }; 48 | } 49 | 50 | // watchers for property changes 51 | watch() { 52 | return { 53 | familyName: (newValue, oldValue) => { 54 | console.log('familyName changed', newValue, oldValue); 55 | } 56 | }; 57 | } 58 | 59 | // computed property 60 | get computedMsg() { 61 | return `${this.greeting} ${this.name} ${this.familyName} ${this.sharedDate.date}`; 62 | } 63 | 64 | // method 65 | greet() { 66 | alert('greeting: ' + this.computedMsg); 67 | } 68 | } 69 | defineElement('example-element', ExampleElement); 70 | ``` 71 | 72 | To use this element, just import it and use it like any other HTML element 73 | 74 | ```html 75 | 78 | ``` 79 | 80 | ##### Inject Properties via injectProperties() 81 | 82 | An Element can define injection requests by implementing the injectProperties function provided by the `BaseElement`. The initial values will also function as default values and will be replaced after the injection request is fulfilled by an Ancestor that provides a property with the requested name. Injected properties will automatically become reactive properties within the elements scope. DEVs do not have to care about where the Value is actually coming from. It will be just there at some Point after the Injection will trigger changes / updates just like regular [properties](./properties.md).. 83 | 84 | ```js 85 | export class DescendingElement extends TemplateElement { 86 | injectProperties() { 87 | return { 88 | in: '', 89 | data: {}, 90 | static: '', 91 | vanillaContext: '' 92 | }; 93 | } 94 | } 95 | ``` 96 | 97 | ##### Request Context Values via manual context Request 98 | 99 | If you want more control over what happens with an injected value after the Injection you can do a manual context Request by using the builtin requestContext function of the BaseElement. 100 | 101 | ```js 102 | export class ManualRequestElement extends TemplateElement { 103 | properties() { 104 | return { 105 | reversedArray: [] 106 | }; 107 | } 108 | connected() { 109 | this.requestContext('regularArray', (injectedArray) => { 110 | // do whatever you like with the value 111 | const processedArray = injectedArray.reverse(); 112 | // OPTIONAL assign to a property to trigger an update and render the reversed values´ 113 | this.reversedArray = processedArray; 114 | }); 115 | } 116 | } 117 | ``` 118 | 119 | Still looking for more Information ? 120 | You might take a look at the [Test Implementation](../../test/unit/context-protocol.test.html) which should cover all cases 121 | -------------------------------------------------------------------------------- /docs/features/global-styles.md: -------------------------------------------------------------------------------- 1 | #### Global Styles / CSS 2 | 3 | More often than not you will be building elements for a specific design/layout rather than encapsulated and abstract elements. It is therefore common to style things globally - especially since the rise of utility based CSS frameworks. When elements render in shadow DOM - they will be encapsulated and won't get any styles applied to them like the rest of the document. 4 | 5 | There is no official and preferred way of sharing styles between documents today. _element-js_ therefore has a custom solution to this problem. The `TemplateElement` from _element-js_ has another constructor option named: `adoptGlobalStyles` which is `true` by default. When set to `true` _element-js_ will look for all styles ( and ) in the document (head and body) and apply them before any custom/element styles inside the shadow DOM. 6 | 7 | > Under the hood _element-js_ will copy/clone the global styles and cache them efficiently as `CSSStyleSheets` so that they can be adopted by multiple shadow DOM elements. 8 | 9 | Example: 10 | 11 | ```html 12 | 13 | 14 | 19 | 20 | 21 | 22 | 27 | 28 | 29 | 30 | ``` 31 | 32 | ```javascript 33 | export class ShadowElement extends TemplateElement { 34 | constructor() { 35 | super({ shadowRender: true, adoptGlobalStyles: true }); 36 | } 37 | 38 | template() { 39 | return html` 40 |

41 | I will be red although I am in shadow DOM and my element did not provide any CSS itself :) 42 |

43 |

44 | I will be green although I am in shadow DOM and my element did not provide any CSS itself :) 45 |

46 |

47 | I will be blue although I am in shadow DOM and my element did not provide any CSS itself :) 48 |

49 |

50 | I will be yellow although I am in shadow DOM and my element did not provide any CSS itself :) 51 |

52 | `; 53 | } 54 | } 55 | ``` 56 | 57 | Instead of adopting all global styles it is also possible to only apply a selection of styles from the global document. 58 | 59 | ```javascript 60 | export class ShadowElement extends TemplateElement { 61 | constructor() { 62 | super({ shadowRender: true, adoptGlobalStyles: '#globalStylesId' }); 63 | } 64 | 65 | template() { 66 | return html` 67 |

68 | I will be red although I am in shadow DOM and my element did not provide any CSS itself :) 69 |

70 | `; 71 | } 72 | } 73 | ``` 74 | 75 | or 76 | 77 | ```javascript 78 | export class ShadowElement extends TemplateElement { 79 | constructor() { 80 | super({ shadowRender: true, adoptGlobalStyles: ['#globalStylesId', '.globalStylesClass'] }); 81 | } 82 | 83 | template() { 84 | return html` 85 |

86 | I will be red although I am in shadow DOM and my element did not provide any CSS itself :) 87 |

88 |

89 | I will be blue although I am in shadow DOM and my element did not provide any CSS itself :) 90 |

91 | `; 92 | } 93 | } 94 | ``` 95 | 96 | There is one special case for adopted styles in the document. Not only can shadow roots have `.adoptedStyleSheets`, but also the `document`. In case there would be global styles added there you can adopt them by using the special `document` selector. 97 | 98 | ```javascript 99 | const globalStyle = new CSSStyleSheet(); 100 | globalStyle.replaceSync(`.red { color: red;}`); 101 | document.adoptedStyleSheets.push(globalStyle); 102 | 103 | export class ShadowElement extends TemplateElement { 104 | constructor() { 105 | super({ shadowRender: true, adoptGlobalStyles: 'document' }); 106 | } 107 | 108 | template() { 109 | return html` 110 |

111 | I will be red although I am in shadow DOM and my element did not provide any CSS itself :) 112 |

113 | `; 114 | } 115 | } 116 | ``` 117 | 118 | By default, _element-js_ will only apply all style elements from the global document that are present in the initial HTML. It will not observe the document for any changes regarding style elements. 119 | 120 | > Observing global styles is done via a MutationObserver on the whole document. This might impose performance issues. 121 | 122 | To enable global style observation you can add the following (inline, sync) script block to yor HTML. 123 | 124 | ```html 125 | 131 | ``` 132 | -------------------------------------------------------------------------------- /docs/features/methods.md: -------------------------------------------------------------------------------- 1 | ### Methods 2 | 3 | Besides public properties you can also shape the API for your element with public methods. 4 | 5 | ```javascript 6 | import { BaseElement, defineElement } from '@webtides/element-js'; 7 | 8 | export class ModalElement extends BaseElement { 9 | open() { 10 | // do the things to actually open the modal… 11 | } 12 | } 13 | 14 | defineElement('modal-element', ModalElement); 15 | ``` 16 | 17 | ```javascript 18 | import { BaseElement, defineElement } from '@webtides/element-js'; 19 | 20 | export class OtherElement extends BaseElement { 21 | events() { 22 | return { 23 | this: { 24 | click: () => { 25 | this.$refs.modal.open(); 26 | } 27 | } 28 | }; 29 | } 30 | } 31 | 32 | defineElement('other-element', OtherElement); 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/features/properties.md: -------------------------------------------------------------------------------- 1 | ## Attributes/Properties 2 | 3 | With attributes and properties you can build out and define the public API of an element. _element-js_ elements can have three different types of properties. 4 | 5 | ```javascript 6 | export class MyElement extends BaseElement { 7 | _privatePropery = 'I am private (by convention)'; 8 | publicProperty = 'I am public'; 9 | 10 | properties() { 11 | return { 12 | reactivePublicProperty: 'I am public & reactive' 13 | }; 14 | } 15 | 16 | log() { 17 | console.log(this._privatePropery); 18 | console.log(this.publicProperty); 19 | console.log(this.reactivePublicProperty); 20 | } 21 | } 22 | 23 | defineElement('my-element', MyElement); 24 | ``` 25 | 26 | #### Defining properties in JavaScript 27 | 28 | Public properties for JavaScript classes where recently added by the JavaScript standard committee. For more information see the [Class fields documentation on MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Class_fields) . 29 | 30 | On top of these fields _element-js_ adds another form of properties - reactive properties. By overwriting the `properties()` method on your element and returning an object of key/value pairs you can define a list of reactive public properties. Behind the scenes _element-js_ will automatically generate getters and setters for each property and trigger an update/render for the element when these properties change. 31 | 32 | All types of properties can be accessed via the `this` operator within the element class or the template function. 33 | 34 | #### Using attributes/properties in HTML 35 | 36 | _element-js_ will merge all attributes with the reactive properties map. Values set via attribute will always take precedence over values defined in the properties map. Attributes will still be defined as properties even when they are not set in the properties map. 37 | 38 | HTML only has the concept of attributes and `string` values. _element-js_ will automatically convert attributes to their correct types of `string`, `number`, `boolean`, `array` and `object`. 39 | 40 | > In JavaScript you will typically use camelCase for declaring properties. HTML attributes however only can be lowercase. _element-js_ will therefore convert dash-case names to camelCase variables and vice versa. 41 | 42 | #### Property options 43 | 44 | Reactive properties can be fine-tuned further by providing options via the constructor. See `propertyOptions` in [Constructor options](../concepts/classes.md). 45 | -------------------------------------------------------------------------------- /docs/features/refs.md: -------------------------------------------------------------------------------- 1 | ### Refs 2 | 3 | To avoid constant lookups of (child) elements from your elements _element-js_ will collect all elements with a `[ref=“id”]` attribute and make them available as an object on the element itself via the special `this.$refs` reference. _element-js_ also provides referencing a list of Nodes by adding a dangling `[]` to the refs id `[ref=“entries[]”]`. Be aware that list references will override singular references with the same name as they are considered to be more explicit. 4 | 5 | ```html 6 | 7 | 8 | 9 | A 10 | B 11 | C 12 | 13 | 14 | ``` 15 | 16 | ```javascript 17 | import { BaseElement, defineElement, html } from '@webtides/element-js'; 18 | 19 | export class PlacesSearch extends BaseElement { 20 | events() { 21 | return { 22 | input: { 23 | blur: (e) => { 24 | // fetch places... 25 | const places = []; 26 | // update singular ref 27 | this.$refs.list = places; 28 | // do something with a list reference 29 | this.$refs.entries.forEach((entry) => console.log(entry.innerText)); // A , B , C 30 | } 31 | } 32 | }; 33 | } 34 | } 35 | 36 | defineElement('places-search', PlacesSearch); 37 | ``` 38 | 39 | > _element-js_ will smartly add and remove all refs on every update/render cycle to ensure that they will always be correct even though you might have changed the child DOM tree during render cycles. 40 | -------------------------------------------------------------------------------- /docs/features/renderless.md: -------------------------------------------------------------------------------- 1 | ### Not Rendering Elements / BaseElement 2 | 3 | More often than not you won't actually need to render anything from your element but rather only trigger some changes on other elements, fire events or fetch data and distribute it to related elements. 4 | 5 | A good example for this pattern is to use container elements for fetching data and delegating it to child elements that only take attributes/properties and simply render them. 6 | 7 | ```html 8 | 9 | 10 | 11 | 41 |
  • Profile
  • 42 |
  • Settings
  • 43 |
  • Sign out
  • 44 | 45 | 46 | ``` 47 | 48 | ```javascript 49 | import { BaseElement, defineElement } from '@webtides/element-js'; 50 | 51 | class DropdownElement extends BaseElement { 52 | events() { 53 | return { 54 | button: { 55 | click: () => { 56 | this.$refs.list.classList.remove('hidden'); 57 | } 58 | } 59 | }; 60 | } 61 | } 62 | 63 | defineElement('dropdown-element', DropdownElement); 64 | ``` 65 | -------------------------------------------------------------------------------- /docs/features/store.md: -------------------------------------------------------------------------------- 1 | #### Shared State / Reactive State / Store 2 | 3 | When a certain property in the properties map is an instance of the provided class `Store` it is treated as an external `Store`. A `Store` can be created outside a components´ scope or in a dedicated Module to keep track of shared Application State. When a components´ property is an instance of a `Store` the instance will be added as an observer and automatically updated when the store ist changed. 4 | 5 | Instances of Store provide a way to share global state between as many components in an application as you like. Shared State can be something very simple as (updated) Viewport Dimensions, Media Changes or complex fetched data from a REST Endpoint. 6 | 7 | Stores can also be initialized with a primitive value (Numbers, Booleans). Such stores will switch to a single Property Mode and provide direct access tio the property value via the stores valueOf / toString Function for direct access. 8 | 9 | ##### store.js 10 | 11 | ```js 12 | export const simpleStore = new Store({ 13 | value: 'simple' 14 | }); 15 | 16 | class MoreComplexStore extends Store { 17 | properties() { 18 | return { 19 | storeCount: 1 20 | }; 21 | } 22 | get sum() { 23 | return this.storeCount + this.argumentCount; 24 | } 25 | } 26 | export const exampleStore = new MoreComplexStore({ 27 | argumentCount: 1 28 | }); 29 | 30 | class MediaStore extends Store { 31 | constructor() { 32 | super(); 33 | const mql = window.matchMedia('(max-width: 600px)'); 34 | mql.onchange = (e) => { 35 | this.isMobile = e.matches; 36 | }; 37 | } 38 | properties() { 39 | return { 40 | isMobile: true 41 | }; 42 | } 43 | } 44 | export const mediaStore = new MediaStore(); 45 | 46 | export const scrollYStore = new Store(0); 47 | window.addEventListener('scroll', () => { 48 | scrollYStore.value = window.scrollY; 49 | }); 50 | ``` 51 | 52 | ##### MyElement.js 53 | 54 | ```js 55 | import { exampleStore, simpleStore, mediaStore } from './store.js'; 56 | 57 | export class MyElement extends TemplateElement { 58 | properties() { 59 | return { 60 | store: simpleStore, 61 | exampleStore, 62 | mediaStore, 63 | scrollYStore 64 | }; 65 | } 66 | 67 | template() { 68 | return ` 69 |
    Simple Store: ${this.store.value} == "simple"
    70 |
    Complex Store: ${this.exampleStore.sum} == 2
    71 |
    Media: ${this.mediaStore.isMobile ? 'MOBILE' : 'DESKTOP'}
    72 |
    Scoll Position: ${this.scrollYStore}
    73 | `; 74 | } 75 | } 76 | 77 | export class AnotherElement extends TemplateElement { 78 | properties() { 79 | return { 80 | store: simpleStore 81 | }; 82 | } 83 | 84 | template() { 85 | return ` 86 |
    Simple Store: ${this.store.value} == the same as in MyElement's template.
    87 | `; 88 | } 89 | } 90 | ``` 91 | 92 | ##### storeception 93 | 94 | Changes too nested stores will trigger updates and watcher in ParentStore and ultimately transfer to all elements referencing the parent 95 | 96 | ```js 97 | class NestedStore extends Store { 98 | properties() { 99 | return { 100 | nestedCount: 0 101 | }; 102 | } 103 | } 104 | 105 | class ParentStore extends Store { 106 | properties() { 107 | return { 108 | count: 0, 109 | nestedStore: new NestedStore() 110 | }; 111 | } 112 | 113 | requestUpdate() { 114 | // will be triggered on count OR nestedStore.nestedCount changes 115 | } 116 | 117 | watch() { 118 | return { 119 | count: (newCount, oldCount) => { 120 | // will be triggered on count changes 121 | }, 122 | nestedStore: () => { 123 | // will be triggered on nestedStore changes 124 | } 125 | }; 126 | } 127 | } 128 | ``` 129 | -------------------------------------------------------------------------------- /docs/features/watch.md: -------------------------------------------------------------------------------- 1 | ### Watch Changes 2 | 3 | Alongside defining reactive properties in the `properties()` map, you can define a watcher for every property by implementing a `watch()` map in the same manner. 4 | 5 | ```javascript 6 | export class MyElement extends BaseElement { 7 | properties() { 8 | return { 9 | property: 'I am public & reactive' 10 | }; 11 | } 12 | 13 | watch() { 14 | return { 15 | property: (newValue, oldValue) => { 16 | console.log('property changed', { newValue, oldValue }); 17 | } 18 | }; 19 | } 20 | } 21 | ``` 22 | 23 | Whenever a property is changed (either from within the element or from outside) it will trigger the callback defined in the `watch()` map if present. The callback will be invoked with the old value and the new value as parameters. 24 | 25 | _element-js_ compares values by stringifying them rather than by reference and will only trigger update/render cycles if the value actually changed. 26 | 27 | > `array` or `object` data will not trigger changes if nested elements/keys are changed. Also methods like `push()` and `shift()` won't work. 28 | 29 | Non-mutable operations like `map()` and `shift()` and the spread operator will however change the value and trigger an update cycle. 30 | 31 | ```javascript 32 | export class MyElement extends BaseElement { 33 | properties() { 34 | return { 35 | items: ['one', 'two'] 36 | }; 37 | } 38 | 39 | count() { 40 | this.items = [...this.items, 'three']; 41 | } 42 | } 43 | ``` 44 | 45 | The spread operator works for objects and arrays. 46 | 47 | If you want to make sure that an update will be triggered you can always request it manually by calling the `requestUpdate()` method. 48 | -------------------------------------------------------------------------------- /docs/guide/guide.md: -------------------------------------------------------------------------------- 1 | ## Guides/Tooling 2 | 3 | ### Frontend Stack 4 | 5 | ### Bundling/Publishing 6 | 7 | ### Design System 8 | 9 | ### Style Guide / Best Practices 10 | 11 | This is an element style guide created and enforced for the purpose of standardizing elements and file structures. 12 | 13 | #### File structure 14 | 15 | - One element per file. 16 | - One element per directory. Though it may make sense to group similar elements into the same directory, we've found it' s easier to consume and document elements when each one has its own directory. 17 | - Implementation (.js) and styles (.css) of an element should live in the same directory. 18 | 19 | Example: 20 | 21 | ``` 22 | ├── card-element 23 | │ ├── card-element.css 24 | │ ├── card-element.js 25 | │ └── test 26 | │ ├── card-element.e2e.js 27 | │ └── card-element.spec.js 28 | ├── card-content 29 | │ ├── card-content.css 30 | │ └── card-content.js 31 | ├── card-title 32 | │ ├── card-title.css 33 | │ └── card-title.js 34 | ``` 35 | 36 | #### Naming 37 | 38 | ##### Name 39 | 40 | Elements are not actions, they are conceptually "things". It is better to use nouns, instead of verbs, such us: " animation" instead of "animating". "input", "tab", "nav", "menu" are some examples. 41 | 42 | ##### -element postfix 43 | 44 | The naming has a major role when you are creating a collection of elements intended to be used across different projects. Web Components are not scoped because they are globally declared within the page, which means a "unique" name is needed to prevent collisions. 45 | Additionally, web components are required to contain a "-" dash within the tag name. When using the first section to namespace your components - everything will look the same, and it will be hard to distinguish elements. 46 | 47 | DO NOT do this: 48 | 49 | ``` 50 | company-card 51 | company-card-header 52 | company-card-title 53 | company-card-content 54 | company-card-footer 55 | ``` 56 | 57 | Instead, use -element for elements with a single noun. 58 | 59 | ``` 60 | card-element 61 | card-header 62 | card-title 63 | card-content 64 | card-footer 65 | ``` 66 | 67 | ##### Modifiers 68 | 69 | When several elements are related and/or coupled, it is a good idea to share the name, and then add different modifiers, for example: 70 | 71 | ``` 72 | menu-element 73 | menu-controller 74 | ``` 75 | 76 | ``` 77 | card-element 78 | card-header 79 | card-content 80 | ``` 81 | 82 | ##### Element (JS class) 83 | 84 | The name of the ES6 class of the element should reflect the file name, and the html tag name. 85 | 86 | ```js 87 | export class ButtonElement extends BaseElement {} 88 | 89 | customElements.define('button-element', ButtonElement); 90 | 91 | export class MenuElement extends BaseElement {} 92 | 93 | customElements.define('menu-element', MenuElement); 94 | ``` 95 | 96 | #### Code organization 97 | 98 | ##### Newspaper Metaphor from The Robert C. Martin's _Clean Code_ 99 | 100 | > The source file should be organized like a newspaper article, with the highest level summary at the top, and more and more details further down. Functions called from the top function come directly below it, and so on down to the lowest level, and most detailed functions at the bottom. This is a good way to organize the source code, even though IDE:s make the location of functions less important, since it is so easy to navigate in and out of them. 101 | -------------------------------------------------------------------------------- /examples/attributes/attributes-element.js: -------------------------------------------------------------------------------- 1 | import { TemplateElement, defineElement, html, Directive, defineDirective } from '../../index.js'; 2 | 3 | const directive = defineDirective(class extends Directive {}); 4 | 5 | class AttributesElement extends TemplateElement { 6 | properties() { 7 | return { 8 | text: 'Hello', 9 | }; 10 | } 11 | 12 | template() { 13 | const templateResult = html` 14 |
    no interpolation
    15 |
    single attribute interpolation
    16 |
    multiple attribute interpolations
    17 |
    18 | multiple interpolations in single attribute 19 |
    20 |
    boolean attributes
    21 |
    22 | property attributes .data and .list 23 |
    24 |
    @event listener attribute
    25 |
    directive interpolation
    26 | `; 27 | console.log('templateResult', templateResult.toString()); 28 | return templateResult; 29 | } 30 | } 31 | defineElement('attributes-element', AttributesElement); 32 | -------------------------------------------------------------------------------- /examples/attributes/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
    10 | 11 |
    12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/child-nodes/child-nodes-element.js: -------------------------------------------------------------------------------- 1 | import { TemplateElement, defineElement, html, Directive, defineDirective } from '../../index.js'; 2 | 3 | const directive = defineDirective(class extends Directive {}); 4 | 5 | class ChildNodesElement extends TemplateElement { 6 | properties() { 7 | return { 8 | text: 'Hello', 9 | count: 3, 10 | }; 11 | } 12 | 13 | template() { 14 | const list = []; 15 | for (let i = 0; i < this.count; i++) { 16 | list.push(i); 17 | } 18 | 19 | const p = document.createElement('p'); 20 | p.textContent = 'DOM Element'; 21 | 22 | const templateResult = html` 23 |
    no interpolation
    24 |
    ${'single text interpolation'}
    25 |
    ${'multiple'} text ${'interpolations'}
    26 |
    ${html`template result interpolation with static text`}
    27 |
    ${html`
    template result interpolation with html
    `}
    28 | 31 |
    32 | ${[ 33 | this.count, 34 | 1, 35 | 'Text', 36 | html`Foo`, 37 | html`${this.count}`, 38 | () => 'Function', 39 | p, 40 | ]} 41 |
    42 | `; 43 | console.log('templateResult', templateResult.toString()); 44 | return templateResult; 45 | } 46 | } 47 | defineElement('attributes-element', ChildNodesElement); 48 | -------------------------------------------------------------------------------- /examples/child-nodes/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
    10 | 11 |
    12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/context/context.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example Elements 6 | 7 | 8 | 9 | 10 | 15 | 16 | 32 | 33 | 34 |

    CONTEXT

    35 | 36 | 37 | 38 | 39 |

    OTHER CONTEXT (No Store)

    40 | 41 | 42 | 43 | 44 |

    NO CONTEXT

    45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /examples/context/other-provide-context.js: -------------------------------------------------------------------------------- 1 | import { TemplateElement, defineElement, html } from '../../src/renderer/vanilla'; 2 | 3 | class OtherProvideContext extends TemplateElement { 4 | // reactive attributes/properties 5 | constructor() { 6 | super({ shadowRender: true }); 7 | } 8 | properties() { 9 | return { 10 | otherContext: 'i am a callback context', 11 | }; 12 | } 13 | provideProperties() { 14 | return { 15 | otherContext: this.otherContext, 16 | }; 17 | } 18 | template() { 19 | return html` 20 |
    Other Provider: ${this.otherContext}
    21 | 22 | `; 23 | } 24 | } 25 | defineElement('other-provide-context', OtherProvideContext); 26 | -------------------------------------------------------------------------------- /examples/context/provide-context.js: -------------------------------------------------------------------------------- 1 | import { TemplateElement, defineElement, html } from '../../src/renderer/vanilla'; 2 | import { Store } from '../../src/util/Store.js'; 3 | 4 | class CounterStore extends Store { 5 | constructor(...args) { 6 | super(...args); 7 | globalThis.setInterval(() => { 8 | this.count++; 9 | }, 1000); 10 | } 11 | 12 | properties() { 13 | return { count: 0 }; 14 | } 15 | } 16 | 17 | class ProvideContext extends TemplateElement { 18 | // reactive attributes/properties 19 | constructor() { 20 | super({ shadowRender: true }); 21 | } 22 | properties() { 23 | return { 24 | counterStore: new CounterStore({ count: 1 }), 25 | }; 26 | } 27 | provideProperties() { 28 | return { counterStore: this.counterStore }; 29 | } 30 | template() { 31 | return html` 32 |
    Provider: ${this.counterStore.count}
    33 | 34 | `; 35 | } 36 | } 37 | defineElement('provide-context', ProvideContext); 38 | -------------------------------------------------------------------------------- /examples/context/request-context.js: -------------------------------------------------------------------------------- 1 | import { defineElement, html, TemplateElement } from '../../src/renderer/vanilla/index.js'; 2 | 3 | class RequestContext extends TemplateElement { 4 | properties() { 5 | return { 6 | callBackCalled: '', 7 | }; 8 | } 9 | 10 | // reactive attributes/properties 11 | injectProperties() { 12 | return { 13 | counterStore: {}, 14 | vanillaContext: '', 15 | }; 16 | } 17 | 18 | connected() { 19 | super.connected(); 20 | // make callback requests implicit 21 | this.requestContext('otherContext', (context) => { 22 | this.callBackCalled = context; 23 | }); 24 | } 25 | template() { 26 | return html`
    27 | Context Count: ${this.counterStore?.count ?? 0} 28 |
    29 | Callback: ${this.callBackCalled} 30 |
    31 | Vanilla: ${this.vanillaContext} 32 |
    `; 33 | } 34 | } 35 | defineElement('request-context', RequestContext); 36 | -------------------------------------------------------------------------------- /examples/directives/attribute-element.js: -------------------------------------------------------------------------------- 1 | import { TemplateElement, defineElement, html } from '../../index.js'; 2 | import { spreadAttributes, optionalAttribute, when } from '../../src/dom-parts/directives.js'; 3 | 4 | class AttributeElement extends TemplateElement { 5 | properties() { 6 | return { 7 | label: false, 8 | hasAttr: false, 9 | hasOptional: false, 10 | }; 11 | } 12 | 13 | template() { 14 | return html`
    21 |
    ${when(this.label, this.label, 'Attribute Element')}
    22 |
    hasAttr: ${this.hasAttr}
    23 |
    hasOptional: ${this.hasOptional}
    24 |
    `; 25 | } 26 | } 27 | defineElement('attribute-element', AttributeElement); 28 | -------------------------------------------------------------------------------- /examples/directives/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 16 | 17 | 18 |
    19 |
    attributes:
    20 |
    21 | 22 | 23 | 24 | 25 | 26 | 27 | 33 |
    34 | 35 | 36 | -------------------------------------------------------------------------------- /examples/directives/unsafe-element.js: -------------------------------------------------------------------------------- 1 | import { TemplateElement, defineElement, html } from '../../index.js'; 2 | import { unsafeHTML } from '../../src/dom-parts/directives.js'; 3 | 4 | class UnsafeElement extends TemplateElement { 5 | properties() { 6 | return { 7 | count: 2, 8 | }; 9 | } 10 | 11 | template() { 12 | return html`
    ${unsafeHTML(`

    Unsafe HTML 1

    Unsafe HTML ${this.count}`)}
    `; 13 | } 14 | } 15 | defineElement('unsafe-element', UnsafeElement); 16 | -------------------------------------------------------------------------------- /examples/directives/unsafe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
    10 | 11 |
    12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/dsd-rendering/dsd-rendering.js: -------------------------------------------------------------------------------- 1 | import { TemplateElement, defineElement, html, Store } from '../../index.js'; 2 | 3 | class ShadowElement extends TemplateElement { 4 | constructor() { 5 | super({ shadowRender: true }); 6 | } 7 | 8 | template() { 9 | return html`
    client side shadow root content
    `; 10 | } 11 | } 12 | 13 | defineElement('shadow-element', ShadowElement); 14 | -------------------------------------------------------------------------------- /examples/dsd-rendering/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | State Hydration 6 | 7 | 8 | 13 | 14 | 15 |

    DSD template rendering

    16 |
    client side template
    17 | 18 |
    server side template
    19 | 20 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /examples/example-base-element.js: -------------------------------------------------------------------------------- 1 | import { BaseElement, defineElement } from '../src/BaseElement.js'; 2 | 3 | class ExampleBaseElement extends BaseElement { 4 | // normal public properties 5 | greeting = 'Hello'; 6 | name = 'John'; 7 | 8 | // lifecycle hook 9 | connected() { 10 | this.greet(); 11 | } 12 | 13 | // reactive attributes/properties 14 | properties() { 15 | return { 16 | familyName: 'Doe', 17 | }; 18 | } 19 | 20 | // watchers for property changes 21 | watch() { 22 | return { 23 | familyName: (newValue, oldValue) => { 24 | console.log('familyName changed', newValue, oldValue); 25 | }, 26 | }; 27 | } 28 | 29 | // computed property 30 | get computedMsg() { 31 | return `${this.greeting} ${this.name} ${this.familyName}`; 32 | } 33 | 34 | // method 35 | greet() { 36 | console.log('greeting: ' + this.computedMsg); 37 | } 38 | } 39 | defineElement('example-base-element', ExampleBaseElement); 40 | -------------------------------------------------------------------------------- /examples/example-directives.js: -------------------------------------------------------------------------------- 1 | import { defineElement } from '../src/BaseElement.js'; 2 | import { TemplateElement, html } from '../src/TemplateElement.js'; 3 | import { choose, classMap, styleMap, when, unsafeHTML, spreadAttributes } from '../src/dom-parts/directives.js'; 4 | 5 | class ExampleDirectives extends TemplateElement { 6 | properties() { 7 | return { 8 | enabled: true, 9 | hidden: true, 10 | choose: 'home', 11 | array: [1, 2, 3, 4], 12 | }; 13 | } 14 | 15 | template() { 16 | const classes = { 'text-blue-500': this.enabled, hidden: false }; 17 | const styles = { 'background-color': this.enabled ? 'blue' : 'gray', color: 'white' }; 18 | return html` 19 |
    20 |

    Directives

    21 |
    classMap:
    22 |
    I'm supposed to be blue on white
    23 |
    styleMap:
    24 |
    I'm supposed to be white on blue
    25 |
    when:
    26 |
    ${when(this.enabled, html`is enabled`)}
    27 |
    ${when(this.enabled, 'is enabled', 'in not enabled')}
    28 |
    29 |
    choose:
    30 |
    31 | ${choose(this.choose, { 32 | home: html`
    33 |
    Home
    34 |
    Home
    35 |
    Home
    36 |
    `, 37 | about: html`
    38 |
    About
    39 |
    About
    40 |
    About
    41 |
    `, 42 | })} 43 |
    44 |
    safeHTML:
    45 |
    ${`

    I'm supposed to be escaped

    `}
    46 |
    unsafeHTML:
    47 |
    ${unsafeHTML`

    I'm supposed to be parsed as HTML

    `}
    48 |
    spreadAttributes:
    49 |
    59 |
    map:
    60 |
      61 | ${this.array.map((item) => html`
    • mapped item: ${item}
    • `)} 62 |
    63 |
    64 | `; 65 | } 66 | } 67 | defineElement('example-directives', ExampleDirectives); 68 | -------------------------------------------------------------------------------- /examples/example-input-element.js: -------------------------------------------------------------------------------- 1 | import { defineElement } from '../src/BaseElement'; 2 | import { TemplateElement, html } from '../src/TemplateElement'; 3 | 4 | class TestElement extends TemplateElement { 5 | properties() { 6 | return { text: '' }; 7 | } 8 | 9 | template() { 10 | return html`
    Text: ${this.text}
    `; 11 | } 12 | } 13 | defineElement('test-element', TestElement); 14 | 15 | class ExampleInputElement extends TemplateElement { 16 | properties() { 17 | return { 18 | value: '', 19 | foo: false, 20 | bar: true, 21 | }; 22 | } 23 | 24 | events() { 25 | return { 26 | input: { 27 | keyup: (e) => { 28 | this.value = e.target.value; 29 | }, 30 | }, 31 | }; 32 | } 33 | 34 | disabledChanged(e) { 35 | console.log('disabledChanged', this, e); 36 | // TODO: "this" is not correct... it is the input element rather than the custom element class :( 37 | this.foo = e.target.checked; 38 | } 39 | 40 | template() { 41 | return html` 42 |
    43 |
    Keep Focus: ${this.value}
    44 | 45 |
    Boolean attribute:
    46 | 50 | 51 |
    52 | `; 53 | } 54 | } 55 | defineElement('example-input-element', ExampleInputElement); 56 | -------------------------------------------------------------------------------- /examples/example-slotted-element.js: -------------------------------------------------------------------------------- 1 | import { BaseElement, defineElement } from '../src/BaseElement.js'; 2 | import { TemplateElement, html } from '../src/TemplateElement.js'; 3 | 4 | class UnimportantElement extends BaseElement {} 5 | defineElement('unimportant-element', UnimportantElement); 6 | 7 | class DeeplyExtendedElement extends TemplateElement { 8 | constructor(options) { 9 | super(options); 10 | } 11 | } 12 | 13 | class ExampleShadowElement extends DeeplyExtendedElement { 14 | static baseClass = TemplateElement; 15 | 16 | constructor() { 17 | super({ shadowRender: true }); 18 | } 19 | 20 | template() { 21 | return html` `; 22 | } 23 | } 24 | defineElement('example-shadow-element', ExampleShadowElement); 25 | 26 | class ExampleSlottedElement extends TemplateElement { 27 | properties() { 28 | return { 29 | name: 'John', 30 | }; 31 | } 32 | 33 | template() { 34 | return html` 35 | 36 | ${this.name} 37 | `; 38 | } 39 | } 40 | defineElement('example-slotted-element', ExampleSlottedElement); 41 | -------------------------------------------------------------------------------- /examples/example-test-element.js: -------------------------------------------------------------------------------- 1 | import { defineElement } from '../src/BaseElement.js'; 2 | import { TemplateElement, html } from '../src/TemplateElement.js'; 3 | import { choose, unsafeHTML } from '../src/dom-parts/directives.js'; 4 | 5 | class ExampleTestElement extends TemplateElement { 6 | constructor() { 7 | super(); 8 | } 9 | 10 | properties() { 11 | return { 12 | count: 1, 13 | foo: '', 14 | bar: '', 15 | text: '', 16 | list: [1, 2], 17 | renderArray: true, 18 | state: 'initial', 19 | }; 20 | } 21 | 22 | template() { 23 | const example = new URLSearchParams(window.location.search).get('example'); 24 | 25 | if (example === '0') { 26 | return html`${this.renderArray ? this.list : this.text}`; 27 | } 28 | 29 | if (example === '00') { 30 | return html`${this.renderArray ? this.list : html`${this.text}`}`; 31 | } 32 | 33 | if (example === '000') { 34 | return html` ${this.renderArray ? this.list : html`no list`}`; 35 | } 36 | 37 | if (example === '0000') { 38 | return html`${this.renderArray ? html`${this.list}` : html`no list`}`; 39 | } 40 | 41 | if (example === '00000') { 42 | return html`
    ${unsafeHTML(`Unsafe HTML`)}
    `; 43 | } 44 | 45 | if (example === '000000') { 46 | const paragraphElement = document.createElement('p'); 47 | paragraphElement.textContent = this.text; 48 | return html`
    ${paragraphElement}
    `; 49 | } 50 | 51 | if (example === '0000000') { 52 | const fn = () => this.text; 53 | return html`
    ${fn}
    `; 54 | } 55 | 56 | if (example === '1') { 57 | const p = document.createElement('p'); 58 | p.textContent = 'DOM Element'; 59 | // rendering different things inside arrays 60 | return html` 61 |
    62 | ${[ 63 | this.count, 64 | 1, 65 | 'Text', 66 | html`Foo`, 67 | html`${this.count}`, 68 | () => 'Function', 69 | p, 70 | ]} 71 |
    72 | `; 73 | } 74 | 75 | if (example === '2') { 76 | return html`
    ${html`${this.text}`}
    `; 77 | } 78 | 79 | if (example === '3') { 80 | const list = []; 81 | for (let i = 0; i < this.count; i++) { 82 | list.push(i); 83 | } 84 | 85 | return html` 86 | 89 | `; 90 | } 91 | 92 | if (example === '4') { 93 | const list = []; 94 | for (let i = 0; i < this.count; i++) { 95 | list.push(i); 96 | } 97 | return html`
    ${list.map((item) => item)}
    `; 98 | } 99 | 100 | if (example === '5') { 101 | return html`
    102 | ${this.renderArray 103 | ? html`
      104 | ${this.list.map((index) => html`
    • ${index}
    • `)} 105 |
    ` 106 | : html`no list`} 107 |
    `; 108 | } 109 | 110 | if (example === '6') { 111 | const templateFn = () => { 112 | if (this.renderArray) { 113 | return html``; 116 | } 117 | return html`no list`; 118 | }; 119 | 120 | return html`
    ${templateFn()}
    `; 121 | } 122 | 123 | if (example === '7') { 124 | const templateFn2 = () => { 125 | if (this.renderArray) { 126 | return this.list.map((index) => html`
    ${index}
    `); 127 | } 128 | return html`no list`; 129 | }; 130 | 131 | return html`${templateFn2()}`; 132 | } 133 | 134 | if (example === '8') { 135 | return html` 136 |
    137 | ${choose(this.state,{ 138 | loading: html` 139 |
    140 |

    Loading ...

    141 |
    142 | `, 143 | result: html` 144 |
    145 |

    Result

    146 |
    147 | `, 148 | }, null)} 149 |
    150 | `; 151 | } 152 | 153 | // prettier-ignore 154 | // return html` `; 155 | // return html`${this.text}`; 156 | // return html` 157 | //
    ${[unsafeHTML(`First part`), unsafeHTML(`Second part`)]}
    158 | // `; 159 | } 160 | } 161 | defineElement('example-test-element', ExampleTestElement); 162 | -------------------------------------------------------------------------------- /examples/global-styles/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Adopting Global Styles 4 | 5 | 6 | 7 | 12 | 13 | 19 | 20 | 21 | 26 | 27 |

    Adopting Global Styles

    28 | 29 |

    Adopt no global Styles

    30 | 31 |

    Adopt all global Styles

    32 | 33 |

    Adopt only one selected global style

    34 | 35 |

    Adopt multiple selected global styles

    36 | 37 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /examples/global-styles/shadow-element.js: -------------------------------------------------------------------------------- 1 | import { defineElement } from '../../src/BaseElement.js'; 2 | import { TemplateElement, html } from '../../src/TemplateElement.js'; 3 | 4 | class LightElement extends TemplateElement { 5 | constructor() { 6 | super({ shadowRender: false }); 7 | } 8 | 9 | styles() { 10 | return [ 11 | ` 12 | light-element { 13 | display: flex; 14 | gap: 16px; 15 | flex-wrap: wrap; 16 | } 17 | div { 18 | background: red; 19 | color: white; 20 | padding: 16px; 21 | margin-bottom: 16px; 22 | } 23 | `, 24 | ]; 25 | } 26 | 27 | template() { 28 | return html` 29 |
    document-adopted
    30 |
    head-style
    31 | 32 |
    body-style
    33 | 34 |
    async-head-style
    35 | 36 |
    async-body-style
    37 | 38 | `; 39 | } 40 | } 41 | 42 | class ShadowElement extends TemplateElement { 43 | constructor(options) { 44 | super({ shadowRender: true, adoptGlobalStyles: false, ...options }); 45 | } 46 | 47 | template() { 48 | return html` 49 | 62 |
    document-adopted
    63 |
    head-style
    64 | 65 |
    body-style
    66 | 67 |
    async-head-style
    68 | 69 |
    async-body-style
    70 | 71 | `; 72 | } 73 | } 74 | 75 | class StyledShadowElement extends ShadowElement { 76 | constructor() { 77 | super({ adoptGlobalStyles: true }); 78 | } 79 | } 80 | 81 | class AdoptOneStyleShadowElement extends ShadowElement { 82 | constructor() { 83 | super({ adoptGlobalStyles: '#globalStyles1' }); 84 | } 85 | } 86 | 87 | class AdoptMultipleStylesShadowElement extends ShadowElement { 88 | constructor() { 89 | super({ adoptGlobalStyles: ['document', '#globalStyles1', '.globalStyles2', '[async-style]'] }); 90 | } 91 | } 92 | 93 | defineElement('light-element', LightElement); 94 | defineElement('shadow-element', ShadowElement); 95 | defineElement('styled-shadow-element', StyledShadowElement); 96 | defineElement('adopt-one-style-shadow-element', AdoptOneStyleShadowElement); 97 | defineElement('adopt-multiple-styles-shadow-element', AdoptMultipleStylesShadowElement); 98 | -------------------------------------------------------------------------------- /examples/global-styles/styles.css: -------------------------------------------------------------------------------- 1 | .head-link { 2 | background-color: green; 3 | } 4 | -------------------------------------------------------------------------------- /examples/global-styles/styles2.css: -------------------------------------------------------------------------------- 1 | .body-link { 2 | background-color: green; 3 | } 4 | -------------------------------------------------------------------------------- /examples/global-styles/styles3.css: -------------------------------------------------------------------------------- 1 | .async-head-link { 2 | background-color: green; 3 | } 4 | -------------------------------------------------------------------------------- /examples/global-styles/styles4.css: -------------------------------------------------------------------------------- 1 | .async-body-link { 2 | background-color: green; 3 | } 4 | -------------------------------------------------------------------------------- /examples/raw-text-node/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
    10 | 11 |
    12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/raw-text-node/raw-text-node-element.js: -------------------------------------------------------------------------------- 1 | import { TemplateElement, defineElement, html } from '../../index.js'; 2 | 3 | class RawTextNodeElement extends TemplateElement { 4 | properties() { 5 | return { 6 | text: 'Hello', 7 | }; 8 | } 9 | 10 | template() { 11 | const templateResult = html``; 12 | console.log('templateResult', templateResult.toString()); 13 | return templateResult; 14 | } 15 | } 16 | defineElement('raw-text-node-element', RawTextNodeElement); 17 | -------------------------------------------------------------------------------- /examples/state-hydration/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | State Hydration 6 | 7 | 8 | 13 | 19 | 20 | 21 |

    State Hydration

    22 |
    Counter 1
    23 | 24 |
    Counter 2
    25 | 26 |
    Counter 3 (no serialized state)
    27 | 28 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /examples/state-hydration/state-hydration.js: -------------------------------------------------------------------------------- 1 | import { TemplateElement, defineElement, html, Store } from '../../index.js'; 2 | 3 | class CounterStore extends Store { 4 | properties() { 5 | return { count: 0 }; 6 | } 7 | } 8 | 9 | const sharedStore = new CounterStore({}, { key: 'shared-store' }); 10 | 11 | /** 12 | * @property {number} count 13 | * @property {CounterStore} sharedStore 14 | * @property {CounterStore} internalStore 15 | */ 16 | class CounterElement extends TemplateElement { 17 | properties() { 18 | return { 19 | count: -1, 20 | sharedStore: sharedStore, 21 | internalStore: new CounterStore({ count: 1 }), 22 | }; 23 | } 24 | 25 | events() { 26 | return { 27 | button: { 28 | click: (event) => { 29 | const property = event.target.dataset.store; 30 | if (property === 'count') this.count++; 31 | if (property === 'sharedStore') this.sharedStore.count++; 32 | if (property === 'internalStore') this.internalStore.count++; 33 | }, 34 | }, 35 | }; 36 | } 37 | 38 | template() { 39 | return html`
    40 | 41 | 42 | 43 |
    `; 44 | } 45 | } 46 | 47 | defineElement('counter-element', CounterElement); 48 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { BaseElement } from './src/BaseElement.js'; 2 | import { StyledElement } from './src/StyledElement.js'; 3 | import { TemplateElement, html } from './src/TemplateElement.js'; 4 | import { toString } from './src/util/toString.js'; 5 | import { defineElement } from './src/util/defineElement.js'; 6 | import { Store } from './src/util/Store.js'; 7 | import { 8 | defineDirective, 9 | Directive, 10 | classMap, 11 | styleMap, 12 | when, 13 | choose, 14 | unsafeHTML, 15 | spreadAttributes, 16 | optionalAttribute, 17 | } from './src/dom-parts/directives.js'; 18 | 19 | /** 20 | * Options object for element-js 21 | * @typedef {Object} ElementJsConfig 22 | * @property {boolean} [serializeState] - When set to true all elements and stores will serialize their state on changes to a global JSON object in the document. This is needed when rendering server side, so that elements and stores can hydrate with the correct state. 23 | * @property {boolean} [observeGlobalStyles] - By default element-js will look for all style elements in the global document and apply them before any custom/element styles inside the shadow DOM. When set to true element-js will also observe the document for any changes regarding styles. This is needed if styles will get added async or late. 24 | */ 25 | 26 | export { 27 | BaseElement, 28 | StyledElement, 29 | TemplateElement, 30 | Store, 31 | html, 32 | toString, 33 | defineElement, 34 | defineDirective, 35 | Directive, 36 | classMap, 37 | styleMap, 38 | when, 39 | choose, 40 | unsafeHTML, 41 | spreadAttributes, 42 | optionalAttribute, 43 | }; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webtides/element-js", 3 | "version": "1.1.8", 4 | "module": "index.js", 5 | "main": "index.js", 6 | "type": "module", 7 | "files": [ 8 | "/docs", 9 | "/src", 10 | "index.js" 11 | ], 12 | "exports": { 13 | ".": { 14 | "browser": "./index.js", 15 | "import": "./index.js", 16 | "default": "./index.js" 17 | }, 18 | "./src/*": "./src/*.js", 19 | "./src/util/*": "./src/util/*.js", 20 | "./src/dom-parts/*": "./src/dom-parts/*.js" 21 | }, 22 | "repository": "https://github.com/webtides/element-js.git", 23 | "author": "@webtides", 24 | "license": "MIT", 25 | "keywords": [ 26 | "web components", 27 | "web component", 28 | "components", 29 | "component", 30 | "custom elements", 31 | "elements", 32 | "element", 33 | "element-js", 34 | "shadow-dom" 35 | ], 36 | "dependencies": {}, 37 | "devDependencies": { 38 | "@babel/eslint-parser": "^7.23.3", 39 | "@open-wc/testing": "^4.0.0", 40 | "@web/dev-server": "^0.4.6", 41 | "@web/test-runner": "^0.19.0", 42 | "babel-eslint": "^10.1.0", 43 | "eslint": "8.55.0", 44 | "eslint-config-prettier": "^9.1.0", 45 | "eslint-plugin-import": "^2.29.0", 46 | "eslint-plugin-prettier": "^5.0.1", 47 | "husky": "^8.0.3", 48 | "lint-staged": "^15.1.0", 49 | "prettier": "^3.1.0" 50 | }, 51 | "lint-staged": { 52 | "*.{js,json}": [ 53 | "prettier --write", 54 | "git add" 55 | ] 56 | }, 57 | "scripts": { 58 | "lint": "eslint index.js 'src/**/*.js'", 59 | "test": "web-test-runner test/**/*.test.{html,js} --node-resolve", 60 | "test:coverage": "web-test-runner test/**/*.test.{html,js} --node-resolve --coverage", 61 | "test:watch": "web-test-runner test/**/*.test.{html,js} --node-resolve --watch", 62 | "start": "web-dev-server --config web-dev-server.config.mjs" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/StyledElement.js: -------------------------------------------------------------------------------- 1 | import { BaseElement } from './BaseElement.js'; 2 | import { supportsAdoptingStyleSheets, getShadowParentOrBody } from './util/DOMHelper.js'; 3 | import { globalStylesStore } from './util/GlobalStylesStore.js'; 4 | 5 | /** 6 | * Options object for the StyledElement 7 | * @typedef {Object} StyledElementOptions 8 | * @extends BaseElementOptions 9 | * @property {boolean} [shadowRender] - When set to true the element will render the template (if provided) in the Shadow DOM and therefore encapsulate the element and styles from the rest of the document. Default is `false` 10 | * @property {[]} [styles] - Via the styles option you can add multiple styles/stylesheets to your element. Default is `[]` 11 | * @property {boolean | string | string[]} [adoptGlobalStyles] - When set to true element-js will look for all style elements in the global document with and apply them before any custom/element styles inside the shadow DOM. Default is `true`. 12 | */ 13 | 14 | class StyledElement extends BaseElement { 15 | /** 16 | * @param {StyledElementOptions} options 17 | */ 18 | constructor(options) { 19 | super({ 20 | deferUpdate: false, 21 | shadowRender: false, 22 | styles: [], 23 | adoptGlobalStyles: true, 24 | ...options, 25 | }); 26 | this._styles = [...this._options.styles, ...this.styles()]; 27 | } 28 | 29 | /** 30 | * Overrides the `connectedCallback` to adopt optional styles when the element is connected 31 | */ 32 | connectedCallback() { 33 | super.connectedCallback(); 34 | 35 | if (!this.constructor['elementStyleSheets']) { 36 | this.constructor['elementStyleSheets'] = this._styles.map((style) => { 37 | const cssStyleSheet = new CSSStyleSheet(); 38 | cssStyleSheet.replaceSync(style); 39 | return cssStyleSheet; 40 | }); 41 | } 42 | 43 | if (supportsAdoptingStyleSheets() && this._options.shadowRender) { 44 | // adopting does only make sense in shadow dom. Fall back to append for light elements 45 | this.adoptStyleSheets(); 46 | 47 | if (this._options.adoptGlobalStyles !== false) { 48 | globalStylesStore.subscribe(() => { 49 | this.adoptStyleSheets(); 50 | }); 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * The styles method is another way to return a list of styles to be adopted when the element is connected. 57 | * You can either provide a list in the constructor options or return it here. 58 | * @returns {string[]} 59 | */ 60 | styles() { 61 | return []; 62 | } 63 | 64 | /** 65 | * Overrides the `update` method to adopt optional styles 66 | * @param {PropertyUpdateOptions} options 67 | */ 68 | update(options) { 69 | // We cannot do this in connectedCallback() since the whole template will be overridden in update/render afterward 70 | if (!supportsAdoptingStyleSheets() || this._options.shadowRender === false) { 71 | this.appendStyleSheets(); 72 | } 73 | super.update(options); 74 | } 75 | 76 | /** 77 | * Adopt stylesheets 78 | */ 79 | adoptStyleSheets() { 80 | const adoptGlobalStyleSheets = this._options.shadowRender && this._options.adoptGlobalStyles !== false; 81 | 82 | this.getRoot().adoptedStyleSheets = [ 83 | ...(adoptGlobalStyleSheets ? globalStylesStore.getGlobalStyleSheets(this._options.adoptGlobalStyles) : []), 84 | ...this.constructor['elementStyleSheets'], 85 | ]; 86 | } 87 | 88 | /** 89 | * Custom polyfill for constructable stylesheets by appending styles to the end of an element 90 | */ 91 | appendStyleSheets() { 92 | const parentDocument = getShadowParentOrBody(this.getRoot()); 93 | 94 | const adoptGlobalStyleSheets = 95 | this._options.shadowRender && 96 | this._options.adoptGlobalStyles !== false && 97 | parentDocument !== globalThis.document.body; 98 | 99 | const appendableStyles = [ 100 | ...(adoptGlobalStyleSheets ? globalStylesStore.getGlobalStyleSheets(this._options.adoptGlobalStyles) : []), 101 | ...this.constructor['elementStyleSheets'], 102 | ]; 103 | 104 | appendableStyles.forEach((styleSheet, index) => { 105 | const identifier = this.tagName + index; 106 | 107 | // only append stylesheet if not already appended to shadowRoot or document 108 | if (!parentDocument.querySelector(`#${identifier}`)) { 109 | const styleElement = globalThis.document?.createElement('style'); 110 | styleElement.id = identifier; 111 | styleElement.style.display = 'none'; 112 | styleElement.textContent = Array.from(styleSheet.cssRules) 113 | .map((rule) => rule.cssText) 114 | .join(''); 115 | parentDocument.appendChild(styleElement); 116 | } 117 | }); 118 | } 119 | } 120 | 121 | export { StyledElement }; 122 | -------------------------------------------------------------------------------- /src/TemplateElement.js: -------------------------------------------------------------------------------- 1 | import { StyledElement } from './StyledElement.js'; 2 | import { html } from './dom-parts/html.js'; 3 | import { render } from './dom-parts/render.js'; 4 | 5 | /** 6 | * Options object for the TemplateElement 7 | * @typedef {Object} TemplateElementOptions 8 | * @extends StyledElementOptions 9 | */ 10 | 11 | class TemplateElement extends StyledElement { 12 | /** 13 | * @param {TemplateElementOptions} options 14 | */ 15 | constructor(options) { 16 | super({ 17 | deferUpdate: false, 18 | shadowRender: false, 19 | styles: [], 20 | adoptGlobalStyles: true, 21 | mutationObserverOptions: { 22 | childList: false, 23 | }, 24 | ...options, 25 | }); 26 | this._template = this._options.template; 27 | } 28 | 29 | /** 30 | * Overrides the native `connectedCallback` of the HTMLElement to set up and initialize our element. 31 | * This will attach a shadow DOM if the element is supposed to render in shadow DOM. 32 | */ 33 | connectedCallback() { 34 | if (this._options.shadowRender && !this.shadowRoot) this.attachShadow({ mode: 'open' }); 35 | super.connectedCallback(); 36 | } 37 | 38 | /** 39 | * The template method should be overridden in extending elements and return the template to be rendered to the root 40 | * @returns {TemplateResult | string} 41 | */ 42 | template() { 43 | return html``; 44 | } 45 | 46 | /** 47 | * Override update method to render the template to the root 48 | * @param {PropertyUpdateOptions} options 49 | */ 50 | update(options) { 51 | this.renderTemplate(); 52 | super.update(options); 53 | } 54 | 55 | /** 56 | * Render the template to the root 57 | */ 58 | renderTemplate() { 59 | const template = this._template || this.template(); 60 | render(template, this.getRoot()); 61 | } 62 | 63 | /** 64 | * Get the root element - either the element or the shadow root 65 | * @returns {ShadowRoot | HTMLElement} 66 | */ 67 | getRoot() { 68 | return this.shadowRoot !== null ? this.shadowRoot : this; 69 | } 70 | } 71 | 72 | export { TemplateElement, html, render }; 73 | -------------------------------------------------------------------------------- /src/dom-parts/AttributePart.js: -------------------------------------------------------------------------------- 1 | import { Part } from './Part.js'; 2 | 3 | /** 4 | * @param {Element} node 5 | * @param {String} name 6 | * @param {Boolean} oldValue 7 | * @return {(function(*): void)|*} 8 | */ 9 | const processBooleanAttribute = (node, name, oldValue) => { 10 | return (newValue) => { 11 | const value = !!newValue?.valueOf() && newValue !== 'false'; 12 | if (oldValue !== value) { 13 | node.toggleAttribute(name, (oldValue = !!value)); 14 | } 15 | }; 16 | }; 17 | 18 | /** 19 | * @param {Element} node 20 | * @param {String} name 21 | * @return {(function(*): void)|*} 22 | */ 23 | const processPropertyAttribute = (node, name) => { 24 | return (value) => { 25 | node[name] = value; 26 | }; 27 | }; 28 | 29 | /** 30 | * @param {Element} node 31 | * @param {String} name 32 | * @return {(function(*): void)|*} 33 | */ 34 | const processEventAttribute = (node, name) => { 35 | let oldValue = undefined; 36 | let type = name.startsWith('@') ? name.slice(1) : name.toLowerCase().slice(2); 37 | 38 | return (newValue) => { 39 | if (oldValue !== newValue) { 40 | if (oldValue) node.removeEventListener(type, oldValue); 41 | if ((oldValue = newValue)) node.addEventListener(type, oldValue); 42 | } 43 | }; 44 | }; 45 | 46 | /** 47 | * @param {Element} node 48 | * @param {String} name 49 | * @return {(function(*): void)|*} 50 | */ 51 | const processAttribute = (node, name) => { 52 | let oldValue; 53 | return (newValue) => { 54 | const value = newValue?.valueOf(); 55 | if (oldValue !== value) { 56 | oldValue = value; 57 | if (value == null) { 58 | node.removeAttribute(name); 59 | } else { 60 | node.setAttribute(name, value); 61 | } 62 | } 63 | }; 64 | }; 65 | 66 | /** @type {Map} */ 67 | const attributePartsCache = new WeakMap(); 68 | 69 | /** 70 | * @param {Element} node 71 | * @param {String} name 72 | * @return {(function(*): void)|*} 73 | */ 74 | export const processAttributePart = (node, name) => { 75 | // boolean attribute: ?boolean=${...} 76 | if (name.startsWith('?')) { 77 | return processBooleanAttribute(node, name.slice(1), false); 78 | } 79 | 80 | // property attribute: .property=${...} 81 | if (name.startsWith('.')) { 82 | return processPropertyAttribute(node, name.slice(1)); 83 | } 84 | 85 | // event attribute: @event=${...} || "old school" event attribute: onevent=${...} 86 | if (name.startsWith('@') || name.startsWith('on')) { 87 | return processEventAttribute(node, name); 88 | } 89 | 90 | // normal "string" attribute: attribute=${...} 91 | return processAttribute(node, name); 92 | }; 93 | 94 | /** 95 | * For updating a single attribute 96 | */ 97 | export class AttributePart extends Part { 98 | name = undefined; 99 | interpolations = 1; 100 | values = []; 101 | currentValueIndex = 0; 102 | initialValue = undefined; 103 | 104 | /** 105 | * @param {Comment} node 106 | * @param {String} name 107 | * @param {String} initialValue 108 | */ 109 | constructor(node, name, initialValue) { 110 | // If we have multiple attribute parts with the same name, it means we have multiple 111 | // interpolations inside that attribute. Instead of creating a new part, we will return the same 112 | // as before and let it defer the update until the last interpolation gets updated 113 | const attributePart = attributePartsCache.get(node.nextElementSibling); 114 | if (attributePart && attributePart.name === name) { 115 | attributePart.interpolations++; 116 | node.__part = attributePart; // add Part to comment node for debugging in the browser 117 | return attributePart; 118 | } 119 | 120 | super(); 121 | this.name = name; 122 | this.initialValue = initialValue; 123 | this.processor = processAttributePart(node.nextElementSibling, this.name); 124 | node.__part = this; // add Part to comment node for debugging in the browser 125 | attributePartsCache.set(node.nextElementSibling, this); 126 | } 127 | 128 | /** 129 | * @param {string | number | bigint | boolean | undefined | symbol | null} value 130 | */ 131 | update(value) { 132 | // If we only have one sole interpolation, we can just apply the update 133 | if (this.initialValue === '\x03') { 134 | this.processor(value); 135 | return; 136 | } 137 | 138 | // Instead of applying the update immediately, we check if the part has multiple interpolations 139 | // and store the values for each interpolation in a list 140 | this.values[this.currentValueIndex++] = value; 141 | 142 | // Only the last call to update (for the last interpolation) will actually trigger the update 143 | // on the DOM processor. Here we can reset everything before the next round of updates 144 | if (this.interpolations === this.currentValueIndex) { 145 | this.currentValueIndex = 0; 146 | let replaceIndex = 0; 147 | // Note: this will coarse the values into strings, but it's probably ok since there can only be multiple values in string attributes?! 148 | const parsedValue = this.initialValue.replace(/\x03/g, () => this.values[replaceIndex++]); 149 | this.processor(parsedValue); 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/dom-parts/NodePart.js: -------------------------------------------------------------------------------- 1 | import { Part } from './Part.js'; 2 | import { isObjectLike } from '../util/AttributeParser.js'; 3 | 4 | /** 5 | * For updating a directive on a node 6 | */ 7 | export class NodePart extends Part { 8 | /** @type {Node} */ 9 | node = undefined; 10 | 11 | /** @type {Directive} */ 12 | directive = undefined; 13 | 14 | /** 15 | * @param {Node} node 16 | * @param {() => {}} initialValue 17 | */ 18 | constructor(node, initialValue) { 19 | if (!isObjectLike(initialValue) && !initialValue.directiveClass) { 20 | throw new Error( 21 | `NodePart: value "${initialValue}" is not a wrapped directive function. You must wrap you custom directive class with the defineDirective function.`, 22 | ); 23 | } 24 | super(); 25 | node.__part = this; // add Part to comment node for debugging in the browser 26 | this.node = node.nextElementSibling; 27 | const { directiveClass, values } = initialValue; 28 | this.directive = new directiveClass(node.nextElementSibling); 29 | } 30 | 31 | /** 32 | * @param {() => {}} value 33 | */ 34 | update(value) { 35 | const { directiveClass, values } = value; 36 | this.directive.update(...values); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/dom-parts/Part.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @abstract 3 | */ 4 | export class Part { 5 | processor = undefined; 6 | 7 | /** 8 | * @abstract 9 | * @param {TemplateResult | any[] | any} value 10 | */ 11 | update(value) {} 12 | } 13 | -------------------------------------------------------------------------------- /src/dom-parts/PartMarkers.js: -------------------------------------------------------------------------------- 1 | import { COMMENT_NODE } from '../util/DOMHelper.js'; 2 | 3 | export class PartMarkers { 4 | /** @type {Comment} */ 5 | startNode; 6 | 7 | /** @type {Comment} */ 8 | endNode; 9 | 10 | /** @type {Node[]} */ 11 | childNodes = []; 12 | 13 | /** @type {boolean} */ 14 | serverSideRendered = false; 15 | 16 | /** 17 | * @param {Comment} start 18 | * @param {Comment} end 19 | * @param {Node[]} childNodes 20 | * @return {void} 21 | */ 22 | constructor(start, end, childNodes) { 23 | this.startNode = start; 24 | this.endNode = end; 25 | this.childNodes = childNodes; 26 | 27 | // if not SSRed, childNodes will only ever have two comment nodes, the start and the end marker 28 | if (childNodes.length > 2) { 29 | this.serverSideRendered = true; 30 | } 31 | } 32 | 33 | /** 34 | * @param {string} partType The type of the node to search for. Defaults to 'template-part'. 35 | * @return {Node|undefined} The first comment node matching the specified type, or undefined if no match is found. 36 | */ 37 | findNestedStartNode(partType = 'template-part') { 38 | return this.childNodes.filter((node) => node.nodeType === COMMENT_NODE).find((node) => node.data === partType); 39 | } 40 | 41 | /** 42 | * Creates a new instance of PartMarkers from the given start node. 43 | * @param {Comment} startNode - The starting comment node that marks the beginning of the section. 44 | * @return {PartMarkers} A new instance of the PartMarkers class encapsulating the start and end nodes. 45 | */ 46 | static createFromStartNode(startNode) { 47 | let endNode; 48 | 49 | const placeholder = startNode.data; 50 | const childNodes = [startNode]; 51 | let childNode = startNode.nextSibling; 52 | while (childNode && childNode.data !== `/${placeholder}`) { 53 | childNodes.push(childNode); 54 | childNode = childNode.nextSibling; 55 | } 56 | 57 | endNode = childNode; 58 | childNodes.push(endNode); 59 | 60 | return new PartMarkers(startNode, endNode, childNodes); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/dom-parts/RawTextNodePart.js: -------------------------------------------------------------------------------- 1 | import { Part } from './Part.js'; 2 | 3 | /** @type {Map} */ 4 | const rawTextNodePartsCache = new WeakMap(); 5 | 6 | /** 7 | * For updating a node that can only be updated via node.textContent 8 | * The nodes are: script | style | textarea | title 9 | */ 10 | export class RawTextNodePart extends Part { 11 | /** @type {Node} */ 12 | node = undefined; 13 | 14 | interpolations = 1; 15 | values = []; 16 | currentValueIndex = 0; 17 | initialValue = undefined; 18 | 19 | /** 20 | * @param {Node} node 21 | * @param {string} initialValue 22 | */ 23 | constructor(node, initialValue) { 24 | // If we have multiple raw text node parts for the same node, it means we have multiple 25 | // interpolations inside that node. Instead of creating a new part, we will return the same 26 | // as before and let it defer the update until the last interpolation gets updated 27 | const rawTextNodePart = rawTextNodePartsCache.get(node.nextElementSibling); 28 | if (rawTextNodePart) { 29 | rawTextNodePart.interpolations++; 30 | node.__part = rawTextNodePart; // add Part to comment node for debugging in the browser 31 | return rawTextNodePart; 32 | } 33 | 34 | super(); 35 | this.initialValue = initialValue; 36 | node.__part = this; // add Part to comment node for debugging in the browser 37 | this.node = node.nextElementSibling; 38 | rawTextNodePartsCache.set(node.nextElementSibling, this); 39 | } 40 | 41 | /** 42 | * @param {string} value 43 | */ 44 | update(value) { 45 | // If we only have one sole interpolation, we can just apply the update 46 | if (this.interpolations === 1) { 47 | this.node.textContent = value; 48 | return; 49 | } 50 | 51 | // Instead of applying the update immediately, we check if the part has multiple interpolations 52 | // and store the values for each interpolation in a list 53 | this.values[this.currentValueIndex++] = value; 54 | 55 | // Only the last call to update (for the last interpolation) will actually trigger the update 56 | // on the DOM processor. Here we can reset everything before the next round of updates 57 | if (this.interpolations === this.currentValueIndex) { 58 | this.currentValueIndex = 0; 59 | let replaceIndex = 0; 60 | // Note: this will coarse the values into strings, but it's probably ok since we are writing to raw text (only) nodes?! 61 | this.node.textContent = this.initialValue.replace(/\x03/g, () => this.values[replaceIndex++]); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/dom-parts/TemplatePart.js: -------------------------------------------------------------------------------- 1 | import { COMMENT_NODE, convertStringToTemplate } from '../util/DOMHelper.js'; 2 | import { Part } from './Part.js'; 3 | import { TemplateResult } from './TemplateResult.js'; 4 | import { AttributePart } from './AttributePart.js'; 5 | import { ChildNodePart, getNodesBetweenComments, replaceNodesBetweenComments } from './ChildNodePart.js'; 6 | import { NodePart } from './NodePart.js'; 7 | import { RawTextNodePart } from './RawTextNodePart.js'; 8 | import { PartMarkers } from './PartMarkers.js'; 9 | 10 | /** @type {WeakMap} */ 11 | const partsCache = new WeakMap(); 12 | 13 | /** @type {WeakMap} */ 14 | const fragmentsCache = new WeakMap(); 15 | 16 | export class TemplatePart extends Part { 17 | /** @type {PartMarkers|undefined} */ 18 | markers; 19 | 20 | /** @type {Part[]} */ 21 | parts = []; 22 | 23 | /** @type {TemplateStringsArray} */ 24 | strings = undefined; 25 | 26 | /** @type {Node[]} */ 27 | childNodes = []; 28 | // TODO: there is childNodes here and also in this.markers now... 29 | 30 | /** 31 | * @param {Node|undefined} startNode - the start comment marker node 32 | * @param {TemplateResult} value 33 | */ 34 | constructor(startNode, value) { 35 | if (startNode && startNode?.nodeType !== COMMENT_NODE) { 36 | throw new Error('TemplatePart: startNode is not a comment node'); 37 | } 38 | 39 | super(); 40 | 41 | if (startNode) { 42 | startNode.__part = this; // add Part to comment node for debugging in the browser 43 | this.markers = PartMarkers.createFromStartNode(startNode); 44 | this.childNodes = this.markers.childNodes; 45 | } 46 | 47 | this.parseValue(value); 48 | 49 | if (!this.markers?.serverSideRendered) { 50 | // TODO: this is causing double diffing because of the this.parseValue() before, ChildNode parts will be constructed + processed 51 | // TODO: I think that maybe ChildNode Parts work, but Other Parts like Attributes are not set initially otherwise... 52 | this.updateParts(value.values); 53 | } 54 | 55 | // TODO: this is somewhat strange that we are creating this twice... 56 | this.markers = PartMarkers.createFromStartNode(this.childNodes[0]); 57 | } 58 | 59 | /** 60 | * @param {TemplateResult} templateResult 61 | */ 62 | update(templateResult) { 63 | const shouldUpdateDOM = this.strings !== templateResult.strings; 64 | if (shouldUpdateDOM) { 65 | this.parseValue(templateResult); 66 | // TODO: this is a strange way of replacing the childnodes... 67 | const endNode = this.childNodes[this.childNodes.length - 1]; 68 | // INFO: this is similar to TemplateResult#237 69 | replaceNodesBetweenComments(this.markers?.endNode, getNodesBetweenComments(endNode)); 70 | } 71 | this.updateParts(templateResult.values); 72 | } 73 | 74 | /** 75 | * @param {any[]} values 76 | */ 77 | updateParts(values) { 78 | for (let index = 0; index < values.length; index++) { 79 | this.parts[index]?.update(values[index]); 80 | } 81 | } 82 | 83 | /** 84 | * @param {TemplateResult} templateResult 85 | */ 86 | parseValue(templateResult) { 87 | let fragment = fragmentsCache.get(templateResult.strings); 88 | if (!fragment) { 89 | fragment = convertStringToTemplate(templateResult.templateString); 90 | fragmentsCache.set(templateResult.strings, fragment); 91 | } 92 | const importedFragment = globalThis.document?.importNode(fragment, true); 93 | this.childNodes = importedFragment.childNodes; 94 | 95 | let parts = partsCache.get(templateResult.strings); 96 | if (!parts) { 97 | parts = templateResult.parseParts(this.childNodes); 98 | partsCache.set(templateResult.strings, parts); 99 | } 100 | 101 | this.parts = parts 102 | .map((part) => { 103 | // We currently need the path because the fragment will be cloned via importNode and therefore the node will be a different one 104 | part.node = part.path.reduceRight(({ childNodes }, i) => childNodes[i], this); 105 | return part; 106 | }) 107 | .map((part, index) => { 108 | if (part.type === 'node') { 109 | return new ChildNodePart(part.node, templateResult.values[index]); 110 | } 111 | if (part.type === 'attribute') { 112 | return new AttributePart(part.node, part.name, part.initialValue); 113 | } 114 | if (part.type === 'raw-text-node') { 115 | return new RawTextNodePart(part.node, part.initialValue); 116 | } 117 | if (part.type === 'directive') { 118 | return new NodePart(part.node, templateResult.values[index]); 119 | } 120 | throw `cannot map part: ${part}`; 121 | }); 122 | this.strings = templateResult.strings; 123 | this.childNodes = [...this.childNodes]; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/dom-parts/html.js: -------------------------------------------------------------------------------- 1 | import { TemplateResult } from './TemplateResult.js'; 2 | 3 | /** 4 | * @param {TemplateStringsArray} strings 5 | * @param {any[]} values 6 | * @return {TemplateResult} 7 | */ 8 | const html = function (strings, ...values) { 9 | return new TemplateResult(strings, ...values); 10 | }; 11 | 12 | export { html }; 13 | -------------------------------------------------------------------------------- /src/dom-parts/render.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Render a template string into the given DOM node 3 | * @param {TemplateResult | string} template 4 | * @param {Element} domNode 5 | */ 6 | const render = (template, domNode) => { 7 | if (!template) { 8 | // empty template was returned 9 | domNode.innerHTML = ''; 10 | } else if (typeof template === 'string') { 11 | // just a plain string (or literal) 12 | domNode.innerHTML = template; 13 | } else { 14 | template.renderInto(domNode); 15 | } 16 | }; 17 | 18 | export { render }; 19 | -------------------------------------------------------------------------------- /src/util/AttributeParser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests if a value is of type `object` 3 | * @param {any} value 4 | * @returns {boolean} 5 | */ 6 | export function isObjectLike(value) { 7 | return typeof value == 'object' && value !== null; 8 | } 9 | 10 | /** 11 | * Tests if a value can be parsed as JSON 12 | * @param {any} value 13 | * @returns {boolean} 14 | */ 15 | export function isJSON(value) { 16 | try { 17 | return JSON.parse(value) && !!value; 18 | } catch (e) { 19 | return false; 20 | } 21 | } 22 | 23 | /** 24 | * Tests if a value is `boolean` 25 | * @param {any} value 26 | * @returns {boolean} 27 | */ 28 | export function isBoolean(value) { 29 | return value === 'true' || value === 'false'; 30 | } 31 | 32 | /** 33 | * Parses a value to `boolean` 34 | * @param {any} value 35 | * @returns {boolean} 36 | */ 37 | export function parseBoolean(value) { 38 | return value === 'true'; 39 | } 40 | 41 | /** 42 | * Tests if a value is of type `string` 43 | * @param {any} value 44 | * @returns {boolean} 45 | */ 46 | export function isString(value) { 47 | return ( 48 | typeof value === 'string' || 49 | (!!value && typeof value === 'object' && Object.prototype.toString.call(value) === '[object String]') 50 | ); 51 | } 52 | 53 | /** 54 | * Tests if a value is of type `number` 55 | * @param {any} value 56 | * @returns {boolean} 57 | */ 58 | export function isNumber(value) { 59 | return new RegExp('^-?(0|0\\.\\d+|[1-9]\\d*(\\.\\d+)?)$').test(value); 60 | } 61 | 62 | /** 63 | * Tests if a value is of type `NaN` 64 | * @param {any} value 65 | * @returns {boolean} 66 | */ 67 | export function isNaN(value) { 68 | return Number.isNaN(value); 69 | } 70 | 71 | /** 72 | * Compare two values (deep). It compares primitive or complex values with JSON stringify. 73 | * @param {any} valueA 74 | * @param {any} valueB 75 | * @returns {boolean} 76 | */ 77 | export function deepEquals(valueA, valueB) { 78 | return JSON.stringify(valueA) === JSON.stringify(valueB); 79 | } 80 | 81 | /** 82 | * Parses an attribute value that comes in as a `string` to its corresponding type 83 | * @param {string} value 84 | * @param {PropertyOptions} options 85 | * @returns {string | number | boolean | object | array} 86 | */ 87 | export function parseAttribute(value, options = {}) { 88 | if (options.parse === false || !isString(value)) { 89 | // no-op 90 | return value; 91 | } 92 | if (typeof options.parse === 'function') { 93 | // custom parse fn 94 | return options.parse(value); 95 | } 96 | 97 | // default parse 98 | let parsedValue = value; 99 | 100 | if (isJSON(value)) parsedValue = JSON.parse(value); 101 | else if (isBoolean(value)) parsedValue = parseBoolean(value); 102 | else if (isNumber(value)) parsedValue = parseFloat(value); 103 | 104 | return parsedValue; 105 | } 106 | 107 | /** 108 | * Replaces dashed-expression (i.e. some-value) to a camel-cased expression (i.e. someValue) 109 | * @param {string} string 110 | * @returns {string} 111 | */ 112 | export function dashToCamel(string) { 113 | if (string.indexOf('-') === -1) return string; 114 | 115 | return string.replace(/-[a-z]/g, (matches) => matches[1].toUpperCase()); 116 | } 117 | 118 | /** 119 | * Replaces camel-cased expression (i.e. someValue) to a dashed-expression (i.e. some-value) 120 | * @param {string} string 121 | * @returns {string} 122 | */ 123 | export function camelToDash(string) { 124 | return string.replace(/([A-Z])/g, '-$1').toLowerCase(); 125 | } 126 | 127 | /** 128 | * Decodes an attribute according to {@link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2} 129 | * @param {string} attribute 130 | * @returns {string} 131 | */ 132 | export function decodeAttribute(attribute) { 133 | return `${attribute}`.replace(/"/g, '"').replace(/&/g, '&'); 134 | } 135 | 136 | /** 137 | * Encodes an attribute according to {@link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2} 138 | * @param {string} attribute 139 | * @returns {string} 140 | */ 141 | export function encodeAttribute(attribute) { 142 | return `${attribute}` 143 | .replace(/&/g, '&') 144 | .replace(/"/g, '"') 145 | .replace(/\r\n/g, '\n') 146 | .replace(/[\r\n]/g, '\n'); 147 | } 148 | -------------------------------------------------------------------------------- /src/util/Directive.js: -------------------------------------------------------------------------------- 1 | export class Directive { 2 | /** @type {Element} */ 3 | node = undefined; 4 | 5 | /** 6 | * @param {Node} node 7 | */ 8 | constructor(node) { 9 | this.node = node; 10 | } 11 | 12 | /** 13 | * @abstract 14 | */ 15 | update() {} 16 | 17 | // TODO: this is somewhat custom... maybe we could set the initial values in the constructor and then simply override the default toString() method?! 18 | /** 19 | * Implement the stringify method and return a string for rendering the directive in SSR mode 20 | * @param {...any} values 21 | * @returns {string} 22 | */ 23 | stringify(...values) { 24 | return ''; 25 | } 26 | } 27 | 28 | /** 29 | * @param {Class} directiveClass 30 | */ 31 | export const defineDirective = (directiveClass) => { 32 | return (...values) => { 33 | return { 34 | directiveClass, 35 | values, 36 | toString: () => { 37 | const directive = new directiveClass(); 38 | return directive.stringify(...values); 39 | }, 40 | }; 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/util/GlobalStylesStore.js: -------------------------------------------------------------------------------- 1 | import { Store } from './Store.js'; 2 | 3 | /** 4 | * @property {CSSStyleSheet[]} cssStyleSheets 5 | */ 6 | class GlobalStylesStore extends Store { 7 | /** @type {Map} */ 8 | globalStyleSheetsCache = new WeakMap(); 9 | 10 | constructor() { 11 | super(); 12 | if (globalThis.elementJsConfig?.observeGlobalStyles) { 13 | const mutationObserver = new MutationObserver((mutationRecord) => { 14 | if (!mutationRecord[0]) return; 15 | const filteredNodes = Array.from(mutationRecord[0].addedNodes).filter( 16 | (node) => node.tagName === 'STYLE' || node.tagName === 'LINK', 17 | ); 18 | if (filteredNodes && filteredNodes[0] && filteredNodes[0].tagName === 'LINK') { 19 | filteredNodes[0].onload = () => { 20 | this.requestUpdate(); 21 | }; 22 | } else if (filteredNodes && filteredNodes[0] && filteredNodes[0].tagName === 'STYLE') { 23 | this.requestUpdate(); 24 | } 25 | }); 26 | mutationObserver.observe(globalThis.document, { subtree: true, childList: true }); 27 | } 28 | } 29 | 30 | getGlobalStyleSheets(selector) { 31 | /** @type {CSSStyleSheet[]}*/ 32 | const cssStyleSheets = []; 33 | 34 | if (selector === false) return cssStyleSheets; 35 | 36 | if (typeof selector === 'string') { 37 | selector = [selector]; 38 | } 39 | 40 | if (selector === true || selector.includes('document')) { 41 | cssStyleSheets.push(...globalThis.document?.adoptedStyleSheets); 42 | } 43 | 44 | Array.from(globalThis.document?.styleSheets).map((styleSheet) => { 45 | if (Array.isArray(selector) && !selector.some((cssSelector) => styleSheet.ownerNode.matches(cssSelector))) { 46 | return; 47 | } 48 | 49 | // TODO: this will always be null as we never set anything to the cache... 50 | let cssStyleSheet = this.globalStyleSheetsCache.get(styleSheet.ownerNode); 51 | if (!cssStyleSheet) { 52 | if (styleSheet.ownerNode.tagName === 'STYLE') { 53 | cssStyleSheet = new CSSStyleSheet({ media: styleSheet.media, disabled: styleSheet.disabled }); 54 | cssStyleSheet.replaceSync(styleSheet.ownerNode.textContent); 55 | } else if (styleSheet.ownerNode.tagName === 'LINK') { 56 | cssStyleSheet = new CSSStyleSheet({ 57 | baseURL: styleSheet.href, 58 | media: styleSheet.media, 59 | disabled: styleSheet.disabled, 60 | }); 61 | try { 62 | Array.from(styleSheet?.cssRules ?? []).map((rule, index) => 63 | cssStyleSheet.insertRule(rule.cssText, index), 64 | ); 65 | } catch (e) { 66 | console.error( 67 | 'GlobalStylesStore: cannot read cssRules. Maybe add crossorigin="anonymous" to your style link?', 68 | e, 69 | ); 70 | } 71 | } 72 | } 73 | cssStyleSheets.push(cssStyleSheet); 74 | }); 75 | 76 | return cssStyleSheets; 77 | } 78 | } 79 | 80 | export const globalStylesStore = new GlobalStylesStore(); 81 | -------------------------------------------------------------------------------- /src/util/SerializeStateHelper.js: -------------------------------------------------------------------------------- 1 | import { Store } from './Store.js'; 2 | 3 | /** 4 | * @typedef {Object} Serializable 5 | * An interface that classes should implement to enable serialization and deserialization of their state. 6 | * @property {string} _serializationKey - a unique key to be used for serialization. 7 | * @property {function} serializeState - Function to retrieve the state for serialization. 8 | * @property {function} restoreState - Function to set the state during deserialization. 9 | */ 10 | 11 | // TODO: is it ok to expose this like this? Or should we wrap the cache in helper methods also? 12 | /** @type {Map} */ 13 | export const serializableObjectsCache = new Map(); 14 | 15 | /** @type {HTMLScriptElement} */ 16 | let globalElementJsState; 17 | 18 | /** 19 | * Initializes the global script element that holds the state for all `Serializable` objects. 20 | */ 21 | function initGlobalStateObject() { 22 | if (!globalElementJsState) { 23 | globalElementJsState = Array.from(globalThis.document.scripts).find((script) => script.type === 'ejs/json'); 24 | if (!globalElementJsState) { 25 | const script = document.createElement('script'); 26 | script.setAttribute('type', 'ejs/json'); 27 | script.textContent = '{}'; 28 | document.body.appendChild(script); 29 | globalElementJsState = script; 30 | } 31 | } 32 | } 33 | 34 | /** 35 | * Takes an object that implements the `Serializable` interface and serializes its state. 36 | * @param {Serializable} serializableObject 37 | */ 38 | export function serializeState(serializableObject) { 39 | if (!globalThis.elementJsConfig?.serializeState) return; 40 | 41 | if (!serializableObject._serializationKey && !serializableObject.serializeState) { 42 | throw new Error('serializableObject does not implement the Serializable interface'); 43 | } 44 | 45 | initGlobalStateObject(); 46 | 47 | const currentState = JSON.parse(globalElementJsState.textContent); 48 | currentState[serializableObject._serializationKey] = serializableObject.serializeState(); 49 | globalElementJsState.textContent = JSON.stringify(currentState, (key, value) => { 50 | if (value instanceof Store) { 51 | return 'Store/' + value._serializationKey; 52 | } else { 53 | return value; 54 | } 55 | }); 56 | } 57 | 58 | /** 59 | * Takes an object that implements the `Serializable` interface and deserializes its state and restores the object. 60 | * @param {Serializable} serializableObject 61 | * @param {{[string: any]: *}} [serializedState] 62 | */ 63 | export function deserializeState(serializableObject, serializedState) { 64 | if (!globalThis.elementJsConfig?.serializeState) return; 65 | 66 | if (!serializableObject._serializationKey && !serializableObject.restoreState) { 67 | throw new Error('serializableObject does not implement the Serializable interface'); 68 | } 69 | 70 | if (serializedState) { 71 | serializableObject.restoreState(serializedState); 72 | return; 73 | } 74 | 75 | initGlobalStateObject(); 76 | 77 | const unresolvedState = JSON.parse(globalElementJsState.textContent); 78 | const currentState = JSON.parse(globalElementJsState.textContent, (key, value) => { 79 | if (typeof value === 'string' && value.startsWith('Store/')) { 80 | const [_, storeUuid] = value.split('/'); 81 | const serializedState = unresolvedState[storeUuid]; 82 | return new Store(serializedState, { key: storeUuid, serializedState }); 83 | } else { 84 | return value; 85 | } 86 | }); 87 | 88 | serializableObject.restoreState(currentState[serializableObject._serializationKey]); 89 | } 90 | 91 | /** 92 | * Checks if a serialized state is available for a given key. 93 | * @param key 94 | * @return {boolean} 95 | */ 96 | export function hasSerializedState(key) { 97 | if (!globalThis.elementJsConfig?.serializeState) return false; 98 | 99 | initGlobalStateObject(); 100 | 101 | const serializedState = JSON.parse(globalElementJsState.textContent); 102 | return serializedState.hasOwnProperty(key); 103 | } 104 | -------------------------------------------------------------------------------- /src/util/crypto.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper function to use the crypto.randomUUID() as it will fail in none secure hosts (e.g. localhost) 3 | * @return {`${string}-${string}-${string}-${string}-${string}`|*} 4 | */ 5 | export function randomUUID() { 6 | if (!('randomUUID' in globalThis.crypto)) { 7 | // https://stackoverflow.com/a/2117523/2800218 8 | return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => 9 | (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16), 10 | ); 11 | } 12 | return globalThis.crypto.randomUUID(); 13 | } 14 | -------------------------------------------------------------------------------- /src/util/defineElement.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Wrapper for defining custom elements so that registering an element multiple times won't crash 3 | * @param {string} name - name for the element tag 4 | * @param {CustomElementConstructor} constructor for the custom element 5 | */ 6 | export function defineElement(name, constructor) { 7 | try { 8 | customElements.define(name, constructor); 9 | } catch (e) { 10 | // console.log('error defining custom element', e); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/util/toString.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Little wrapper function for JSON.stringify() to easily convert objects and arrays to strings 3 | * to be able to set them as attributes on custom elements 4 | * @param {object | array} value - to be stringified 5 | * @return {string} 6 | */ 7 | export function toString(value) { 8 | try { 9 | return JSON.stringify(value); 10 | } catch (e) { 11 | return ''; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/unit/append-styles.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, fixtureSync, defineCE, assert, expect, nextFrame } from '@open-wc/testing'; 3 | import { TemplateElement, html } from '../../src/TemplateElement.js'; 4 | 5 | const color = 'rgb(255, 240, 0)'; 6 | const color2 = 'rgb(255, 0, 255)'; 7 | 8 | const shadowTag = defineCE( 9 | class extends TemplateElement { 10 | constructor() { 11 | super({ shadowRender: true }); 12 | } 13 | 14 | styles() { 15 | return [`span {color: ${color2} }`]; 16 | } 17 | 18 | template() { 19 | return html`

    red content

    20 | `; 21 | } 22 | }, 23 | ); 24 | 25 | const lightTag = defineCE( 26 | class extends TemplateElement { 27 | styles() { 28 | return [`span {color: ${color2} }`]; 29 | } 30 | template() { 31 | return html`

    red content

    32 | `; 33 | } 34 | }, 35 | ); 36 | 37 | const shadowCascadeTag = defineCE( 38 | class extends TemplateElement { 39 | constructor() { 40 | super({ shadowRender: true }); 41 | } 42 | styles() { 43 | return [`p {color: ${color2} }`]; 44 | } 45 | template() { 46 | return html`

    red content

    `; 47 | } 48 | }, 49 | ); 50 | 51 | const lightCascadeTag = defineCE( 52 | class extends TemplateElement { 53 | styles() { 54 | return [`p {color: ${color2} }`]; 55 | } 56 | template() { 57 | return html`

    red content

    `; 58 | } 59 | }, 60 | ); 61 | describe('append-styles', () => { 62 | let cssReplace; 63 | 64 | before(() => { 65 | const style = document.createElement('STYLE'); 66 | style.type = 'text/css'; 67 | style.id = 'globalStyles'; 68 | style.appendChild(document.createTextNode(`p{color: ${color}}`)); 69 | document.head.appendChild(style); 70 | 71 | // simulate no constructable stylesheets 72 | cssReplace = CSSStyleSheet.prototype.replace; 73 | delete CSSStyleSheet.prototype.replace; 74 | }); 75 | 76 | after(() => { 77 | CSSStyleSheet.prototype.replace = cssReplace; 78 | }); 79 | 80 | it('appends stylesheets if the browser does not support adopting Styleheets (shadow-dom)', async () => { 81 | const el = fixtureSync(`<${shadowTag}>`); 82 | await nextFrame(); 83 | const computedColor = await window.getComputedStyle(el.$refs.coloredP).getPropertyValue('color'); 84 | const computeSpanColor = await window.getComputedStyle(el.$refs.coloredSpan).getPropertyValue('color'); 85 | assert.equal(computedColor, color); 86 | assert.equal(computeSpanColor, color2); 87 | }); 88 | 89 | it('appends stylesheets if the browser does not support adopting Styleheets (light-dom)', async () => { 90 | const el = fixtureSync(`<${lightTag}>`); 91 | await nextFrame(); 92 | const computedColor = await window.getComputedStyle(el.$refs.coloredP).getPropertyValue('color'); 93 | const computeSpanColor = await window.getComputedStyle(el.$refs.coloredSpan).getPropertyValue('color'); 94 | 95 | assert.equal(computedColor, color); 96 | assert.equal(computeSpanColor, color2); 97 | }); 98 | 99 | it('appends styles in correct order (shadow-dom)', async () => { 100 | const el = fixtureSync(`<${shadowCascadeTag}>`); 101 | await nextFrame(); 102 | const computedColor = await window.getComputedStyle(el.$refs.coloredP).getPropertyValue('color'); 103 | assert.equal(computedColor, color2); 104 | }); 105 | 106 | it('appends styles in correct order (light-dom)', async () => { 107 | const el = fixtureSync(`<${lightCascadeTag}>`); 108 | await nextFrame(); 109 | const computedColor = await window.getComputedStyle(el.$refs.coloredP).getPropertyValue('color'); 110 | assert.equal(computedColor, color2); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /test/unit/attribute-naming.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | const tag = defineCE(class extends BaseElement {}); 6 | 7 | describe('attribute-naming', () => { 8 | it('can have lowercase attributes', async () => { 9 | const el = await fixture(`<${tag} lowercase="1">`); 10 | assert.equal(el.getAttribute('lowercase'), '1'); 11 | }); 12 | 13 | it('cannot have camelCase attributes', async () => { 14 | const el = await fixture(`<${tag} camelCase="0">`); 15 | assert.isNotTrue(el.attributes.hasOwnProperty('camelCase')); 16 | assert.isTrue(el.attributes.hasOwnProperty('camelcase')); 17 | }); 18 | 19 | it('can have dash-case attributes', async () => { 20 | const el = await fixture(`<${tag} dash-case="1">`); 21 | assert.equal(el.getAttribute('dash-case'), '1'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/unit/attribute-parsing.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | const tag = defineCE( 6 | class extends BaseElement { 7 | constructor() { 8 | super({ 9 | propertyOptions: { 10 | parseDisabled: { 11 | parse: false, 12 | }, 13 | parseString: { 14 | parse: (value) => value.toString(), 15 | }, 16 | }, 17 | }); 18 | } 19 | }, 20 | ); 21 | 22 | describe('attribute-parsing', () => { 23 | it('parses string attribute as string property', async () => { 24 | const el = await fixture(`<${tag} string-value="Hello">`); 25 | assert.equal(el.getAttribute('string-value'), 'Hello'); 26 | assert.equal(el.stringValue, 'Hello'); 27 | }); 28 | 29 | it('parses numeric zero attribute as float property', async () => { 30 | const el = await fixture(`<${tag} number-value="0">`); 31 | assert.equal(el.getAttribute('number-value'), '0'); 32 | assert.equal(el.numberValue, 0); 33 | }); 34 | 35 | it('parses integer attribute as float property', async () => { 36 | const el = await fixture(`<${tag} number-value="33">`); 37 | assert.equal(el.getAttribute('number-value'), '33'); 38 | assert.equal(el.numberValue, 33); 39 | }); 40 | 41 | it('parses float attribute as float property', async () => { 42 | const el = await fixture(`<${tag} number-value="0.7">`); 43 | assert.equal(el.getAttribute('number-value'), '0.7'); 44 | assert.equal(el.numberValue, 0.7); 45 | }); 46 | 47 | it('parses negative number attribute as float property', async () => { 48 | const el = await fixture(`<${tag} number-value="-13">`); 49 | assert.equal(el.getAttribute('number-value'), '-13'); 50 | assert.equal(el.numberValue, -13); 51 | }); 52 | 53 | it('parses number attribute with more than one . as string property', async () => { 54 | const el = await fixture(`<${tag} number-value="127.0.0.1">`); 55 | assert.equal(el.getAttribute('number-value'), '127.0.0.1'); 56 | assert.equal(el.numberValue, '127.0.0.1'); 57 | }); 58 | 59 | it('parses boolean attribute as boolean property', async () => { 60 | const el = await fixture(`<${tag} boolean-value="true">`); 61 | assert.equal(el.getAttribute('boolean-value'), 'true'); 62 | assert.equal(el.booleanValue, true); 63 | }); 64 | 65 | it('parses object attribute as object property', async () => { 66 | const el = await fixture(`<${tag} object-value='{"foo":"bar"}'>`); 67 | assert.equal(el.getAttribute('object-value'), '{"foo":"bar"}'); 68 | assert.deepEqual(el.objectValue, { foo: 'bar' }); 69 | }); 70 | 71 | it('parses array attribute as array property', async () => { 72 | const el = await fixture(`<${tag} array-value='["one","two",3]'>`); 73 | assert.equal(el.getAttribute('array-value'), '["one","two",3]'); 74 | assert.deepEqual(el.arrayValue, ['one', 'two', 3]); 75 | }); 76 | 77 | it('wont parses attribute where parse is set to false in propertyOptions', async () => { 78 | const el = await fixture(`<${tag} parse-disabled='["one","two",3]'>`); 79 | assert.equal(el.getAttribute('parse-disabled'), '["one","two",3]'); 80 | assert.isString(el.parseDisabled); 81 | }); 82 | 83 | it('wont parses attribute with a custom parse Function provided in propertyOptions', async () => { 84 | const el = await fixture(`<${tag} parse-string='10000'>`); 85 | assert.equal(el.getAttribute('parse-string'), '10000'); 86 | assert.isString(el.parseString); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /test/unit/attribute-reflection.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, nextFrame } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | const tag = defineCE(class extends BaseElement {}); 6 | 7 | const propertyOptionsTag = defineCE( 8 | class extends BaseElement { 9 | constructor() { 10 | super({ 11 | propertyOptions: { 12 | loaded: { reflect: true }, 13 | unimportant: { reflect: false }, 14 | custom: { 15 | reflect: () => 'custom', 16 | }, 17 | }, 18 | }); 19 | } 20 | 21 | properties() { 22 | return { 23 | loaded: false, 24 | custom: 'initial', 25 | }; 26 | } 27 | }, 28 | ); 29 | 30 | describe('attribute-reflection', () => { 31 | it('reflects string attributes correctly back as attributes from properties when changed', async () => { 32 | const el = await fixture(`<${tag} string-value='Hello'>`); 33 | el.stringValue = 'Holla'; 34 | await nextFrame(); 35 | assert.equal(el.getAttribute('string-value'), 'Holla'); 36 | }); 37 | 38 | it('reflects number attributes correctly back as attributes from properties when changed', async () => { 39 | const el = await fixture(`<${tag} number-value='0'>`); 40 | el.numberValue = 13; 41 | await nextFrame(); 42 | assert.equal(el.getAttribute('number-value'), '13'); 43 | }); 44 | 45 | it('reflects boolean attributes correctly back as attributes from properties when changed', async () => { 46 | const el = await fixture(`<${tag} boolean-value='true'>`); 47 | el.booleanValue = false; 48 | await nextFrame(); 49 | assert.equal(el.getAttribute('boolean-value'), 'false'); 50 | }); 51 | 52 | it('reflects object attributes correctly back as attributes from properties when changed', async () => { 53 | const el = await fixture(`<${tag} object-value='{"foo":"bar"}'>`); 54 | el.objectValue = { bar: 'foo' }; 55 | await nextFrame(); 56 | assert.equal(el.getAttribute('object-value'), '{"bar":"foo"}'); 57 | }); 58 | 59 | it('reflects array attributes correctly back as attributes from properties when changed', async () => { 60 | const el = await fixture(`<${tag} array-value='["one","two",3]'>`); 61 | el.arrayValue = [1, 2, 3, 5, 8, 13, 20]; 62 | await nextFrame(); 63 | assert.equal(el.getAttribute('array-value'), '[1,2,3,5,8,13,20]'); 64 | }); 65 | 66 | it('reflects undefined properties back as empty "" attributes when changed', async () => { 67 | const el = await fixture(`<${tag} undefined-value='Hello'>`); 68 | el.undefinedValue = undefined; 69 | await nextFrame(); 70 | assert.notEqual(el.getAttribute('undefined-value'), 'undefined'); 71 | assert.equal(el.getAttribute('undefined-value'), ''); 72 | }); 73 | 74 | it('reflects null properties back as empty "" attributes when changed', async () => { 75 | const el = await fixture(`<${tag} null-value='Hello'>`); 76 | el.nullValue = null; 77 | await nextFrame(); 78 | assert.notEqual(el.getAttribute('null-value'), 'null'); 79 | assert.equal(el.getAttribute('null-value'), ''); 80 | }); 81 | 82 | it('reflects NaN properties back as empty "" attributes when changed', async () => { 83 | const el = await fixture(`<${tag} nan-value='Hello'>`); 84 | el.nanValue = NaN; 85 | await nextFrame(); 86 | assert.notEqual(el.getAttribute('nan-value'), 'NaN'); 87 | assert.equal(el.getAttribute('nan-value'), ''); 88 | }); 89 | 90 | it('reflects properties as attributes when configured via propertyOptions', async () => { 91 | const el = await fixture(`<${propertyOptionsTag}>`); 92 | await nextFrame(); 93 | assert.equal(el.hasAttribute('loaded'), true); 94 | assert.equal(el.getAttribute('loaded'), 'false'); 95 | }); 96 | 97 | it('removes attributes when configured to not reflect via propertyOptions', async () => { 98 | const el = await fixture(`<${propertyOptionsTag} unimportant="true">`); 99 | await nextFrame(); 100 | assert.equal(el.unimportant, true); 101 | assert.equal(el.hasAttribute('unimportant'), false); 102 | }); 103 | 104 | it('it allows to custom reflect a property via propertyOptions', async () => { 105 | const el = await fixture(`<${propertyOptionsTag} >`); 106 | await nextFrame(); 107 | assert.equal(el.custom, 'initial'); 108 | assert.equal(el.hasAttribute('custom'), true); 109 | assert.equal(el.getAttribute('custom'), 'custom'); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /test/unit/attribute-types.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | const tag = defineCE(class extends BaseElement {}); 6 | 7 | describe('attribute-types', () => { 8 | it('can have strings as attributes', async () => { 9 | const el = await fixture(`<${tag} string-value="Hello">`); 10 | assert.equal(el.getAttribute('string-value'), 'Hello'); 11 | }); 12 | 13 | it('can have numbers as attributes', async () => { 14 | const el = await fixture(`<${tag} number-value="1">`); 15 | assert.equal(el.getAttribute('number-value'), '1'); 16 | }); 17 | 18 | it('can have booleans as attributes', async () => { 19 | const el = await fixture(`<${tag} boolean-value="true">`); 20 | assert.equal(el.getAttribute('boolean-value'), 'true'); 21 | }); 22 | 23 | it('can have objects as attributes', async () => { 24 | const el = await fixture(`<${tag} object-value='{"foo":"bar"}'>`); 25 | assert.equal(el.getAttribute('object-value'), '{"foo":"bar"}'); 26 | }); 27 | 28 | it('can have arrays as attributes', async () => { 29 | const el = await fixture(`<${tag} array-value='["one","two",3]'>`); 30 | assert.equal(el.getAttribute('array-value'), '["one","two",3]'); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/unit/attributes-to-properties.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | const tag = defineCE(class extends BaseElement {}); 6 | 7 | describe('attributes-to-properties', () => { 8 | it('reflects lowercase attributes as lowercase properties', async () => { 9 | const el = await fixture(`<${tag} lowercase="Hello">`); 10 | assert.isTrue(el.hasOwnProperty('lowercase')); 11 | assert.equal(el.lowercase, 'Hello'); 12 | }); 13 | 14 | it('reflects camelCase attributes as lowercase properties', async () => { 15 | const el = await fixture(`<${tag} camelCase="Hello">`); 16 | assert.isNotTrue(el.hasOwnProperty('camelCase')); 17 | assert.isTrue(el.hasOwnProperty('camelcase')); 18 | assert.equal(el.camelcase, 'Hello'); 19 | }); 20 | 21 | it('reflects dash-case attributes as camelCase properties', async () => { 22 | const el = await fixture(`<${tag} dash-case="Hello">`); 23 | assert.isNotTrue(el.hasOwnProperty('dash-case')); 24 | assert.isTrue(el.hasOwnProperty('dashCase')); 25 | assert.equal(el.dashCase, 'Hello'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/unit/computed-properties.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, nextFrame } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | const tag = defineCE( 6 | class extends BaseElement { 7 | properties() { 8 | return { 9 | count: 0, 10 | }; 11 | } 12 | 13 | get nextCount() { 14 | return this.count + 1; 15 | } 16 | }, 17 | ); 18 | 19 | describe('computed-properties', () => { 20 | it('dynamically computes properties based on other properties', async () => { 21 | const el = await fixture(`<${tag}>`); 22 | assert.equal(el.nextCount, 1); 23 | el.count = 2; 24 | await nextFrame(); 25 | assert.equal(el.nextCount, 3); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/unit/context-protocol-shadow.test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /test/unit/context-protocol.definitions.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { runTests } from '@web/test-runner-mocha'; 3 | import { assert, expect, nextFrame, chai } from '@open-wc/testing'; 4 | import { html, defineElement, BaseElement, TemplateElement } from '../../index'; 5 | 6 | import { Store } from '../../src/util/Store'; 7 | 8 | export const primitiveStore = new Store(100); 9 | 10 | export class LateProvider extends BaseElement { 11 | static LATE_CONTEXT = 'lateContext'; 12 | 13 | provideProperties() { 14 | return { 15 | lateContext: LateProvider.LATE_CONTEXT, 16 | }; 17 | } 18 | } 19 | 20 | export class AncestorContextElement extends BaseElement { 21 | static CONTEXT = 'ancestorContext'; 22 | static MULTI = 'ancestorMultiContext'; 23 | 24 | properties() { 25 | return { 26 | ancestorContext: AncestorContextElement.CONTEXT, 27 | multiContext: AncestorContextElement.MULTI, 28 | storeContext: primitiveStore, 29 | }; 30 | } 31 | 32 | provideProperties() { 33 | return { 34 | ancestorContext: this.ancestorContext, 35 | multiContext: this.multiContext, 36 | storeContext: this.storeContext, 37 | }; 38 | } 39 | } 40 | 41 | export class ParentContextElement extends BaseElement { 42 | static SIMPLE_CONTEXT = 'simpleParentContext'; 43 | static MULTI = 'parentMultiContext'; 44 | static INLINE = 'parentInlineContext'; 45 | static STATIC = 'parentStaticContext'; 46 | 47 | properties() { 48 | return { 49 | simpleContext: ParentContextElement.SIMPLE_CONTEXT, 50 | multiContext: ParentContextElement.MULTI, 51 | }; 52 | } 53 | 54 | provideProperties() { 55 | return { 56 | simpleContext: this.simpleContext, 57 | multiContext: this.multiContext, 58 | inlineCallback: ParentContextElement.INLINE, 59 | staticContext: ParentContextElement.STATIC, 60 | booleanContext: false, 61 | }; 62 | } 63 | } 64 | 65 | export class RequestContextElement extends BaseElement { 66 | properties() { 67 | return { 68 | callBackCalled: null, 69 | simpleContext: '', 70 | multiContext: '', 71 | inlineCallbackValue: '', 72 | watcherReflectedValue: 0, 73 | }; 74 | } 75 | 76 | // reactive attributes/properties 77 | injectProperties() { 78 | return { 79 | counterStore: {}, 80 | vanillaContext: '', 81 | missingVanillaContext: null, 82 | simpleContext: '', 83 | ancestorContext: null, 84 | multiContext: null, 85 | storeContext: {}, 86 | noContext: 'noContext', 87 | staticContext: 'noContext', 88 | shadowContext: 'noContext', 89 | booleanContext: true, 90 | lateContext: null, 91 | inlineCallback: (value) => { 92 | this.inlineCallbackValue = value; 93 | }, 94 | }; 95 | } 96 | 97 | watch() { 98 | return { 99 | storeContext: (newValues) => { 100 | this.watcherReflectedValue = newValues.value; 101 | }, 102 | }; 103 | } 104 | 105 | connected() { 106 | // console.log('requester connected', this.id); 107 | super.connected(); 108 | this.requestContext('callbackContext', (context) => { 109 | this.callBackCalled = context === globalThis.callbackContext; 110 | }); 111 | } 112 | } 113 | 114 | export class ShadowProvider extends TemplateElement { 115 | static SHADOW_CONTEXT = 'shadowContext'; 116 | 117 | constructor() { 118 | super({ shadowRender: true }); 119 | } 120 | 121 | provideProperties() { 122 | return { 123 | shadowContext: ShadowProvider.SHADOW_CONTEXT, 124 | }; 125 | } 126 | injectProperties() { 127 | return { 128 | vanillaContext: '', 129 | simpleContext: '', 130 | }; 131 | } 132 | connected() { 133 | // console.log('shadow connected'); 134 | super.connected(); 135 | } 136 | 137 | onRequestContext(event) { 138 | super.onRequestContext(event); 139 | // if (event.detail.shadowContext) { 140 | // console.log(event.composedPath()[0], event.type, event.detail); 141 | // } 142 | } 143 | 144 | template() { 145 | return html`
    146 | 147 | 148 |
    `; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /test/unit/directive-choose.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert } from '@open-wc/testing'; 3 | import { choose } from '../../src/dom-parts/directives.js'; 4 | 5 | describe('choose directive', () => { 6 | it('renders the matching key for the given value', async () => { 7 | const value = 'one'; 8 | const result = choose( 9 | value, 10 | { 11 | one: 'I should be rendered', 12 | tow: 'I should NOT be rendered', 13 | three: 'I should also NOT be rendered', 14 | }, 15 | 'I should definitely NOT be rendered', 16 | ); 17 | assert.equal(result, 'I should be rendered'); 18 | }); 19 | 20 | it('renders the defaultCase if the value has no match', async () => { 21 | const value = 'seven'; 22 | const result = choose( 23 | value, 24 | { 25 | one: 'I should be rendered', 26 | tow: 'I should NOT be rendered', 27 | three: 'I should also NOT be rendered', 28 | }, 29 | 'I should definitely NOT be rendered', 30 | ); 31 | assert.equal(result, 'I should definitely NOT be rendered'); 32 | }); 33 | 34 | it('renders noting when no defaultCase is given and the value has no match', async () => { 35 | const value = 'seven'; 36 | const result = choose(value, { 37 | one: 'I should be rendered', 38 | tow: 'I should NOT be rendered', 39 | three: 'I should also NOT be rendered', 40 | }); 41 | assert.equal(result, undefined); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/unit/directive-class-map.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert } from '@open-wc/testing'; 3 | import { classMap } from '../../src/dom-parts/directives.js'; 4 | 5 | describe('classMap directive', () => { 6 | it('maps a list of classes from an object to a string', async () => { 7 | const classes = classMap({ 8 | active: true, 9 | number: 1, 10 | hidden: true, 11 | }); 12 | assert.equal(classes, 'active number hidden'); 13 | }); 14 | 15 | it('omits classes if the given value is falsy', async () => { 16 | const classes = classMap({ 17 | active: true, 18 | number: 1, 19 | hidden: false, 20 | }); 21 | assert.equal(classes, 'active number'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/unit/directive-optional-attribute.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { assert } from '@open-wc/testing'; 3 | import { optionalAttribute, OptionalAttributeDirective, spreadAttributes } from '../../src/dom-parts/directives.js'; 4 | import { html } from '../../src/dom-parts/html.js'; 5 | import { stripCommentMarkers, stripWhitespace } from '../util/testing-helpers.js'; 6 | 7 | describe('optionalAttribute directive', () => { 8 | it('adds an attributes when condition is truthy', async () => { 9 | let condition = true; 10 | 11 | // SSR 12 | const templateResult = html`
    `; 13 | assert.equal(stripCommentMarkers(templateResult.toString()), "
    "); 14 | // CSR 15 | const el = document.createElement('div'); 16 | const directive = new OptionalAttributeDirective(el); 17 | directive.update(condition, 'attr', 'string'); 18 | assert.isTrue(el.hasAttribute('attr')); 19 | }); 20 | it('does not add an attributes when condition is falsy', async () => { 21 | let condition = false; 22 | // SSR 23 | const templateResult = html`
    `; 24 | assert.equal(stripCommentMarkers(templateResult.toString()), '
    '); 25 | // CSR 26 | const el = document.createElement('div'); 27 | const directive = new OptionalAttributeDirective(el); 28 | directive.update(condition, 'attr', 'string'); 29 | assert.isFalse(el.hasAttribute('attr')); 30 | }); 31 | 32 | it('adds a boolean attributes when condition is truthy and the value is an empty string', async () => { 33 | // https://developer.mozilla.org/en-US/docs/Web/API/Element/setAttribute#:~:text=into%20a%20string.-,Boolean,-attributes%20are%20considered 34 | let condition = true; 35 | 36 | // SSR 37 | const templateResult = html`
    `; 38 | assert.equal(stripCommentMarkers(templateResult.toString()), '
    '); 39 | // CSR 40 | const el = document.createElement('div'); 41 | const directive = new OptionalAttributeDirective(el); 42 | directive.update(condition, 'attr', 'string'); 43 | assert.isTrue(el.hasAttribute('attr')); 44 | }); 45 | 46 | it('does add and remove an attributes when condition is toggled', async () => { 47 | let condition = true; 48 | // SSR 49 | const templateResult = html`
    `; 50 | assert.equal(stripCommentMarkers(templateResult.toString()), "
    "); 51 | // CSR 52 | const el = document.createElement('div'); 53 | const directive = new OptionalAttributeDirective(el); 54 | directive.update(condition, 'attr', 'string'); 55 | assert.isTrue(el.hasAttribute('attr')); 56 | condition = false; 57 | // SSR 58 | const templateResult2 = html`
    `; 59 | assert.equal(stripCommentMarkers(templateResult2.toString()), '
    '); 60 | // CSR 61 | directive.update(condition, 'attr', 'string'); 62 | assert.isFalse(el.hasAttribute('attr')); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /test/unit/directive-spread-attributes.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { assert } from '@open-wc/testing'; 3 | import { html } from '../../src/TemplateElement.js'; 4 | import { spreadAttributes, SpreadAttributesDirective } from '../../src/dom-parts/directives.js'; 5 | import { stripCommentMarkers, stripWhitespace } from '../util/testing-helpers.js'; 6 | 7 | describe('spreadAttributes directive', () => { 8 | it('maps primitive values to string attributes', async () => { 9 | // SSR 10 | const attributes = { 11 | string: 'string', 12 | number: 13, 13 | boolean: true, 14 | }; 15 | const templateResult = html`
    `; 16 | assert.equal( 17 | stripCommentMarkers(templateResult.toString()), 18 | "
    ", 19 | ); 20 | 21 | // CSR 22 | const el = document.createElement('div'); 23 | const directive = new SpreadAttributesDirective(el); 24 | directive.update(attributes); 25 | assert.equal(el.getAttribute('string'), 'string'); 26 | assert.equal(el.getAttribute('number'), '13'); 27 | assert.equal(el.getAttribute('boolean'), 'true'); 28 | }); 29 | 30 | it('maps object like values to encoded and JSON parsable attributes', async () => { 31 | // SSR 32 | const attributes = { 33 | list: [1, '2', 3], 34 | map: { foo: 'bar' }, 35 | }; 36 | const templateResult = html`
    `; 37 | assert.equal( 38 | stripCommentMarkers(templateResult.toString()), 39 | "
    ", 40 | ); 41 | 42 | // CSR 43 | const el = document.createElement('div'); 44 | const directive = new SpreadAttributesDirective(el); 45 | directive.update(attributes); 46 | assert.equal(el.getAttribute('list'), '[1,"2",3]'); 47 | assert.equal(el.getAttribute('map'), '{"foo":"bar"}'); 48 | }); 49 | 50 | it('converts camelCase names to dash-case', async () => { 51 | // SSR 52 | const attributes = { 53 | camelToDash: 'automagically', 54 | }; 55 | const templateResult = html`
    `; 56 | assert.equal(stripCommentMarkers(templateResult.toString()), "
    "); 57 | 58 | // CSR 59 | const el = document.createElement('div'); 60 | const directive = new SpreadAttributesDirective(el); 61 | directive.update(attributes); 62 | assert.equal(el.getAttribute('camel-to-dash'), 'automagically'); 63 | }); 64 | it('removes attributes when set to "null" in spread directive', async () => { 65 | // SSR 66 | const attributes = { 67 | attr: true, 68 | }; 69 | const el = document.createElement('div'); 70 | const directive = new SpreadAttributesDirective(el); 71 | directive.update(attributes); 72 | assert.isTrue(el.hasAttribute('attr')); 73 | attributes.attr = null; 74 | directive.update(attributes); 75 | assert.isFalse(el.hasAttribute('attr')); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/unit/directive-style-map.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert } from '@open-wc/testing'; 3 | import { styleMap } from '../../src/dom-parts/directives.js'; 4 | 5 | describe('styleMap directive', () => { 6 | it('maps a list of styles from an object to a string', async () => { 7 | const isBlue = true; 8 | const style = styleMap({ 9 | 'background-color': isBlue ? 'blue' : 'gray', 10 | color: 'white', 11 | }); 12 | assert.equal(style, 'background-color:blue; color:white;'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /test/unit/directive-unsafe-html.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert } from '@open-wc/testing'; 3 | import { html, render } from '../../src/TemplateElement.js'; 4 | import { unsafeHTML } from '../../src/dom-parts/directives.js'; 5 | import { DOCUMENT_FRAGMENT_NODE, ELEMENT_NODE } from '../../src/util/DOMHelper.js'; 6 | import { stripCommentMarkers, stripWhitespace } from '../util/testing-helpers.js'; 7 | 8 | describe('unsafeHTML directive', () => { 9 | it('returns a function', async () => { 10 | const unsafeContent = unsafeHTML(`Unsafe HTML`); 11 | assert.equal(typeof unsafeContent, 'function'); 12 | }); 13 | 14 | it('returns a string as a result of the function in SSR', async () => { 15 | const originalDomParser = globalThis.DOMParser; 16 | globalThis.DOMParser = undefined; 17 | 18 | const templateResult = html`
    ${unsafeHTML(`Unsafe HTML`)}
    `; 19 | const unsafeContent = templateResult.toString(); 20 | assert.equal(typeof unsafeContent, 'string'); 21 | assert.equal(stripCommentMarkers(unsafeContent), '
    Unsafe HTML
    '); 22 | 23 | globalThis.DOMParser = originalDomParser; 24 | }); 25 | 26 | it('returns a list with one Node as a result of the function in CSR with only one node in it', async () => { 27 | const unsafeContent = unsafeHTML(`Unsafe HTML`); 28 | const nodes = unsafeContent(); 29 | assert.equal(Array.isArray(nodes), true); 30 | assert.equal(nodes.length, 1); 31 | assert.equal(nodes[0].nodeType, ELEMENT_NODE); 32 | }); 33 | 34 | it('returns a list of Nodes as a result of the function in CSR with multiple nodes in it', async () => { 35 | const unsafeContent = unsafeHTML(`Unsafe HTML

    Unsafe HTML 2

    `); 36 | const nodes = unsafeContent(); 37 | assert.equal(nodes.length, 2); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/unit/directive-when.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert } from '@open-wc/testing'; 3 | import { when } from '../../src/dom-parts/directives.js'; 4 | 5 | describe('when directive', () => { 6 | it('renders the trueCase for a truthy value', async () => { 7 | const condition = true; 8 | const result = when(condition, 'I should be rendered', 'I should NOT be rendered'); 9 | assert.equal(result, 'I should be rendered'); 10 | }); 11 | 12 | it('renders the falseCase for a falsy value', async () => { 13 | const condition = false; 14 | const result = when(condition, 'I should be rendered', 'I should NOT be rendered'); 15 | assert.equal(result, 'I should NOT be rendered'); 16 | }); 17 | 18 | it('renders noting for a falsy value when no falseCase is given', async () => { 19 | const condition = false; 20 | const result = when(condition, 'I should be rendered'); 21 | assert.equal(result, undefined); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/unit/dsd-template-rendering.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, assert, defineCE } from '@open-wc/testing'; 2 | import { html, TemplateElement } from '../../src/TemplateElement.js'; 3 | 4 | const shadowTag = defineCE( 5 | class extends TemplateElement { 6 | constructor() { 7 | super({ shadowRender: true }); 8 | } 9 | 10 | template() { 11 | return html`
    client side shadow root content
    `; 12 | } 13 | }, 14 | ); 15 | 16 | describe(`declarative shadow dom template rendering for`, () => { 17 | it('renders the template in shadow dom on the client if no declarative template is available', async () => { 18 | const el = await fixture(`<${shadowTag}>`); 19 | assert.isNotNull(el.shadowRoot); 20 | assert.shadowDom.equal(el, '
    client side shadow root content
    '); 21 | }); 22 | 23 | // it('uses the template in shadow dom from the server if a template element with shadowrootmode was provided', async () => { 24 | // const el = await fixture( 25 | // `<${shadowTag}>`, 26 | // ); 27 | // assert.isNotNull(el.shadowRoot); 28 | // assert.shadowDom.equal(el, '
    server side shadow root content
    '); 29 | // }); 30 | }); 31 | -------------------------------------------------------------------------------- /test/unit/element-styles1.test.css: -------------------------------------------------------------------------------- 1 | .text-violet { 2 | color: rgb(238, 65, 236); 3 | } 4 | -------------------------------------------------------------------------------- /test/unit/element-styles2.test.css: -------------------------------------------------------------------------------- 1 | .text-green-important { 2 | color: rgb(0, 128, 0) !important; 3 | } 4 | -------------------------------------------------------------------------------- /test/unit/events-dispatcher.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, oneEvent } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | const tag = defineCE( 6 | class extends BaseElement { 7 | fireEvent() { 8 | this.dispatch('custom-event', { name: 'Hello' }); 9 | } 10 | 11 | fireEventWithCustomOptions() { 12 | this.dispatch('custom-event', 'foo', { bubbles: false, detail: 'bar' }); 13 | } 14 | 15 | fireEventWithOldSyntax() { 16 | this.dispatch('custom-event', 'foo', true, false, true); 17 | } 18 | }, 19 | ); 20 | 21 | describe('events-dispatcher', () => { 22 | it('can dispatch custom events', async () => { 23 | const el = await fixture(`<${tag}>`); 24 | setTimeout(() => el.fireEvent()); 25 | const event = await oneEvent(el, 'custom-event'); 26 | assert.deepEqual(event.detail, { name: 'Hello' }); 27 | }); 28 | 29 | it('will have all custom event options set to true by default', async () => { 30 | const el = await fixture(`<${tag}>`); 31 | setTimeout(() => el.fireEvent()); 32 | const event = await oneEvent(el, 'custom-event'); 33 | assert.equal(event.bubbles, true); 34 | assert.equal(event.cancelable, true); 35 | assert.equal(event.composed, true); 36 | }); 37 | 38 | it('can override custom event options', async () => { 39 | const el = await fixture(`<${tag}>`); 40 | setTimeout(() => el.fireEventWithCustomOptions()); 41 | const event = await oneEvent(el, 'custom-event'); 42 | assert.equal(event.bubbles, false); 43 | assert.equal(event.cancelable, true); 44 | assert.equal(event.composed, true); 45 | assert.equal(event.detail, 'bar'); 46 | }); 47 | 48 | it('can have custom event options with the old deprecated syntax', async () => { 49 | const el = await fixture(`<${tag}>`); 50 | setTimeout(() => el.fireEventWithOldSyntax()); 51 | const event = await oneEvent(el, 'custom-event'); 52 | assert.equal(event.bubbles, true); 53 | assert.equal(event.cancelable, false); 54 | assert.equal(event.composed, true); 55 | assert.equal(event.detail, 'foo'); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test/unit/events-map.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, nextFrame } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | const tag = defineCE( 6 | class extends BaseElement { 7 | // define as non property to prevent update and rebind 8 | complexEventCount = 0; 9 | properties() { 10 | return { 11 | count: 0, 12 | windowEventCount: 0, 13 | documentEventCount: 0, 14 | clickedByEventComponent: false, 15 | }; 16 | } 17 | 18 | events() { 19 | return { 20 | 'button[ref=nobind]': { 21 | click: this.functionWithoutBind, 22 | }, 23 | 'button[ref=windowbind]': { 24 | click: this.functionWithoutWindowBind.bind(window), 25 | }, 26 | window: { 27 | click: (e) => { 28 | e.stopPropagation(); 29 | e.preventDefault(); 30 | this.windowEventCount++; 31 | }, 32 | scroll: { 33 | listener: (e) => { 34 | this.complexEventCount++; 35 | }, 36 | options: { once: true }, 37 | }, 38 | noOptions: { 39 | listener: (e) => { 40 | this.complexEventCount++; 41 | }, 42 | }, 43 | }, 44 | document: { 45 | click: (e) => { 46 | e.stopPropagation(); 47 | e.preventDefault(); 48 | this.documentEventCount++; 49 | }, 50 | }, 51 | }; 52 | } 53 | 54 | functionWithoutBind() { 55 | this.count++; 56 | } 57 | 58 | functionWithoutWindowBind() { 59 | this.clickedByEventComponent = true; 60 | } 61 | }, 62 | ); 63 | 64 | describe('events-map', async () => { 65 | it('maintains instance context even tho context is not bound explicitly when added via events map', async () => { 66 | const el = await fixture(`<${tag}>`); 67 | assert.equal(el.count, 0); 68 | el.$refs.nobind.click(); 69 | await nextFrame(); 70 | assert.equal(el.count, 1); 71 | }); 72 | 73 | it('allows the auto-bound instance context to be overwritten by rebinding when added via events map', async () => { 74 | const el = await fixture(`<${tag}>`); 75 | assert.equal(el.clickedByEventComponent, false); 76 | el.$refs.windowbind.click(); 77 | await nextFrame(); 78 | assert.equal(el.clickedByEventComponent, false); 79 | assert.equal(window.clickedByEventComponent, true); 80 | }); 81 | 82 | it('listens to events that are dispatched window when added via event map', async () => { 83 | const el = await fixture(`<${tag}>`); 84 | assert.equal(el.windowEventCount, 0); 85 | window.dispatchEvent(new Event('click')); 86 | await nextFrame(); 87 | assert.equal(el.windowEventCount, 1); 88 | await nextFrame(); 89 | window.dispatchEvent(new Event('click')); 90 | await nextFrame(); 91 | assert.equal(el.windowEventCount, 2); 92 | }); 93 | 94 | it('listens to events that are dispachted document when added via event map', async () => { 95 | const el = await fixture(`<${tag}>`); 96 | assert.equal(el.documentEventCount, 0); 97 | window.document.dispatchEvent(new Event('click')); 98 | await nextFrame(); 99 | assert.equal(el.documentEventCount, 1); 100 | window.document.dispatchEvent(new Event('click')); 101 | await nextFrame(); 102 | assert.equal(el.documentEventCount, 2); 103 | }); 104 | 105 | it('understands events defined in complex / detailed notation', async () => { 106 | const el = await fixture(`<${tag}>`); 107 | assert.equal(el.complexEventCount, 0); 108 | window.dispatchEvent(new Event('scroll')); 109 | await nextFrame(); 110 | assert.equal(el.complexEventCount, 1); 111 | }); 112 | 113 | it('understands events defined in complex / detailed notation without options', async () => { 114 | const el = await fixture(`<${tag}>`); 115 | assert.equal(el.complexEventCount, 0); 116 | window.dispatchEvent(new Event('noOptions')); 117 | await nextFrame(); 118 | assert.equal(el.complexEventCount, 1); 119 | }); 120 | 121 | it('understands events defined in complex / detailed notation and applies options', async () => { 122 | const el = await fixture(`<${tag}>`); 123 | assert.equal(el.complexEventCount, 0); 124 | window.dispatchEvent(new Event('scroll')); 125 | await nextFrame(); 126 | assert.equal(el.complexEventCount, 1); 127 | window.dispatchEvent(new Event('scroll')); 128 | await nextFrame(); 129 | assert.equal(el.complexEventCount, 1); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /test/unit/global-styles.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, expect, nextFrame, fixtureSync } from '@open-wc/testing'; 3 | import { TemplateElement, html } from '../../src/TemplateElement.js'; 4 | 5 | const color = 'rgb(255, 240, 0)'; 6 | const color2 = 'rgb(255, 0, 255)'; 7 | const color3 = 'rgb(0, 255, 0)'; 8 | 9 | const shadowTag = defineCE( 10 | class extends TemplateElement { 11 | constructor() { 12 | super({ shadowRender: true }); 13 | } 14 | 15 | styles() { 16 | return [`span {color: ${color2} }`]; 17 | } 18 | 19 | template() { 20 | return html`

    red content

    21 | `; 22 | } 23 | }, 24 | ); 25 | 26 | const shadowCascadeTag = defineCE( 27 | class extends TemplateElement { 28 | constructor() { 29 | super({ shadowRender: true }); 30 | } 31 | 32 | styles() { 33 | return [`p {color: ${color2} }`]; 34 | } 35 | 36 | template() { 37 | return html`

    red content

    `; 38 | } 39 | }, 40 | ); 41 | 42 | const shadowNonAdoptingTag = defineCE( 43 | class extends TemplateElement { 44 | constructor() { 45 | super({ shadowRender: true, adoptGlobalStyles: false }); 46 | } 47 | 48 | styles() { 49 | return [`span {color: ${color2} }`]; 50 | } 51 | 52 | template() { 53 | return html`

    red content

    54 | `; 55 | } 56 | }, 57 | ); 58 | 59 | const lightTag = defineCE( 60 | class extends TemplateElement { 61 | styles() { 62 | return [`span {color: ${color2} }`]; 63 | } 64 | 65 | template() { 66 | return html`

    red content

    67 | `; 68 | } 69 | }, 70 | ); 71 | 72 | describe('global-styles', () => { 73 | before(() => { 74 | const style = document.createElement('STYLE'); 75 | style.type = 'text/css'; 76 | style.id = 'globalStyles'; 77 | style.appendChild(document.createTextNode(`p{color: ${color}}`)); 78 | document.head.appendChild(style); 79 | }); 80 | 81 | after(() => { 82 | document.getElementById('globalStyles').remove(); 83 | }); 84 | 85 | it('adopts globalStyles in lightDom', async () => { 86 | const el = await fixture(`<${lightTag}>`); 87 | await nextFrame; 88 | const computedColor = window.getComputedStyle(el.$refs.coloredP).getPropertyValue('color'); 89 | assert.equal(computedColor, color); 90 | const computeSpanColor = window.getComputedStyle(el.$refs.coloredSpan).getPropertyValue('color'); 91 | assert.equal(computeSpanColor, color2); 92 | }); 93 | 94 | it('adopts all globalStyles in shadowDom', async () => { 95 | const el = await fixture(`<${shadowTag}>`); 96 | //this needs to be called to find the styles added ar runtime 97 | await nextFrame(); 98 | const computedColor = window.getComputedStyle(el.$refs.coloredP).getPropertyValue('color'); 99 | assert.equal(computedColor, color); 100 | 101 | const computeSpanColor = window.getComputedStyle(el.$refs.coloredSpan).getPropertyValue('color'); 102 | assert.equal(computeSpanColor, color2); 103 | }); 104 | 105 | it('adopts globalStyles in shadowDom when they get added asynchronously', async () => { 106 | const el = await fixture(`<${shadowTag}>`); 107 | //this needs to be called to find the styles added ar runtime 108 | await nextFrame(); 109 | const computedColor = window.getComputedStyle(el.$refs.coloredP).getPropertyValue('color'); 110 | assert.equal(computedColor, color); 111 | 112 | const style = document.createElement('STYLE'); 113 | style.type = 'text/css'; 114 | style.id = 'globalStyles2'; 115 | style.appendChild(document.createTextNode(`p{color: ${color3}}`)); 116 | document.head.appendChild(style); 117 | 118 | await nextFrame(); 119 | const computedColor3 = window.getComputedStyle(el.$refs.coloredP).getPropertyValue('color'); 120 | assert.notEqual(computedColor3, color); 121 | assert.equal(computedColor3, color3); 122 | document.getElementById('globalStyles2').remove(); 123 | }); 124 | 125 | it('does not adopt globalStyles in shadowDom if option is false', async () => { 126 | const el = await fixture(`<${shadowNonAdoptingTag}>`); 127 | //this needs to be called to find the styles added ar runtime 128 | await nextFrame(); 129 | const computedColor = window.getComputedStyle(el.$refs.coloredP).getPropertyValue('color'); 130 | assert.notEqual(computedColor, color); 131 | 132 | const computeSpanColor = window.getComputedStyle(el.$refs.coloredSpan).getPropertyValue('color'); 133 | assert.equal(computeSpanColor, color2); 134 | }); 135 | 136 | it('adopts styles in correct order (shadow-dom)', async () => { 137 | const el = fixtureSync(`<${shadowCascadeTag}>`); 138 | await nextFrame(); 139 | const computedColor = window.getComputedStyle(el.$refs.coloredP).getPropertyValue('color'); 140 | assert.equal(computedColor, color2); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /test/unit/lifecycle-hooks.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, oneEvent, nextFrame } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | const tag = defineCE( 6 | class extends BaseElement { 7 | connectedCalled = false; 8 | beforeUpdateCalled = false; 9 | afterUpdateCalled = false; 10 | calledHooks = []; 11 | 12 | constructor() { 13 | super({ autoUpdate: true, deferUpdate: false }); 14 | } 15 | 16 | connected() { 17 | this.connectedCalled = true; 18 | this.calledHooks.push('connected'); 19 | } 20 | 21 | beforeUpdate() { 22 | this.beforeUpdateCalled = true; 23 | this.calledHooks.push('beforeUpdate'); 24 | } 25 | 26 | afterUpdate() { 27 | this.afterUpdateCalled = true; 28 | this.calledHooks.push('afterUpdate'); 29 | } 30 | 31 | disconnected() { 32 | this.dispatch('disconnected'); 33 | this.calledHooks.push('disconnected'); 34 | } 35 | 36 | properties() { 37 | return { 38 | count: 0, 39 | }; 40 | } 41 | }, 42 | ); 43 | 44 | describe('lifecycle-hooks', () => { 45 | it('calls a "connected" hook when the element was connected to a document', async () => { 46 | const el = await fixture(`<${tag}>`); 47 | assert.equal(el.connectedCalled, true); 48 | }); 49 | 50 | it('does not call the "beforeUpdate" hook for the first update after the element was connected', async () => { 51 | const el = await fixture(`<${tag}>`); 52 | assert.equal(el.connectedCalled, true); 53 | assert.equal(el.beforeUpdateCalled, false); 54 | }); 55 | 56 | it('calls a "afterUpdate" hook when the element was updated for the first time while connecting', async () => { 57 | const el = await fixture(`<${tag}>`); 58 | assert.equal(el.connectedCalled, true); 59 | assert.equal(el.afterUpdateCalled, true); 60 | }); 61 | 62 | it('calls a "beforeUpdate" hook when the element is about to be updated', async () => { 63 | const el = await fixture(`<${tag}>`); 64 | assert.equal(el.beforeUpdateCalled, false); 65 | el.count++; 66 | await nextFrame(); 67 | assert.equal(el.beforeUpdateCalled, true); 68 | }); 69 | 70 | it('calls a "afterUpdate" hook when the element was updated', async () => { 71 | const el = await fixture(`<${tag}>`); 72 | el.afterUpdateCalled = false; 73 | assert.equal(el.afterUpdateCalled, false); 74 | el.count++; 75 | await nextFrame(); 76 | assert.equal(el.afterUpdateCalled, true); 77 | }); 78 | 79 | it('calls a "disconnected" hook when the element was removed from a document', async () => { 80 | const el = await fixture(`<${tag}>`); 81 | setTimeout(() => { 82 | el.parentNode.removeChild(el); 83 | }); 84 | await oneEvent(el, 'disconnected'); 85 | }); 86 | 87 | it('calls all hooks in the correct order at connection and after updating the element', async () => { 88 | const el = await fixture(`<${tag}>`); 89 | assert.deepEqual(el.calledHooks, ['connected', 'afterUpdate']); 90 | el.count++; 91 | await nextFrame(); 92 | assert.deepEqual(el.calledHooks, ['connected', 'afterUpdate', 'beforeUpdate', 'afterUpdate']); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /test/unit/mutation-observer.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, oneEvent, nextFrame } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | class UpdateCountElement extends BaseElement { 6 | constructor(options) { 7 | super({ deferUpdate: false, ...options }); 8 | this.updateCount = -1; // -1 because the first afterUpdate after the connected hook 9 | } 10 | 11 | afterUpdate() { 12 | this.updateCount++; 13 | if (this.updateCount > 0) { 14 | this.dispatch('afterUpdate', null); 15 | } 16 | } 17 | } 18 | 19 | const tag = defineCE(class extends UpdateCountElement {}); 20 | 21 | const subtreeObserverTag = defineCE( 22 | class extends UpdateCountElement { 23 | constructor() { 24 | super({ mutationObserverOptions: { subtree: true } }); 25 | } 26 | }, 27 | ); 28 | 29 | describe('mutation-observer', () => { 30 | it('requests an update when an attribute is changed from outside', async () => { 31 | const el = await fixture(`<${tag} count="0">`); 32 | el.setAttribute('count', '1'); 33 | await oneEvent(el, 'afterUpdate'); 34 | assert.equal(el.getAttribute('count'), '1'); 35 | assert.equal(el.count, 1); 36 | }); 37 | 38 | it('does not request an update when an attribute is changed on child elements', async () => { 39 | const el = await fixture(`<${tag}>`); 40 | const changeElement = el.querySelector('#change'); 41 | changeElement.setAttribute('count', '1'); 42 | await nextFrame(); 43 | assert.equal(el.updateCount, 0); 44 | }); 45 | 46 | it('can request an update when an attribute is changed on child elements by setting the "subtree" option in mutationObserverOptions to true', async () => { 47 | const el = await fixture(`<${subtreeObserverTag}>`); 48 | const changeElement = el.querySelector('#change'); 49 | changeElement.setAttribute('count', '1'); 50 | await oneEvent(el, 'afterUpdate'); 51 | assert.equal(el.updateCount, 1); 52 | }); 53 | 54 | it('requests an update when a child node has been added', async () => { 55 | const el = await fixture(`<${tag}>`); 56 | el.innerHTML = `i am nested`; 57 | await oneEvent(el, 'afterUpdate'); 58 | assert.equal(el.updateCount, 1); 59 | }); 60 | 61 | it('does not request an update when a child node gets added to a child element', async () => { 62 | const el = await fixture(`<${tag}>`); 63 | const addElement = el.querySelector('#add'); 64 | addElement.innerHTML = `i am nested`; 65 | await nextFrame(); 66 | assert.equal(el.updateCount, 0); 67 | }); 68 | 69 | it('can request an update when a child node gets added to a child element by setting the "subtree" option in mutationObserverOptions to true', async () => { 70 | const el = await fixture(`<${subtreeObserverTag}>`); 71 | const addElement = el.querySelector('#add'); 72 | addElement.innerHTML = `i am nested`; 73 | await oneEvent(el, 'afterUpdate'); 74 | assert.equal(el.updateCount, 1); 75 | }); 76 | 77 | it('requests an update when a child node has been removed', async () => { 78 | const el = await fixture(`<${tag}>`); 79 | el.removeChild(el.querySelector('#remove')); 80 | await oneEvent(el, 'afterUpdate'); 81 | assert.equal(el.updateCount, 1); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /test/unit/nesting-elements.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, html, nextFrame } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | const outerTag = defineCE( 6 | class extends BaseElement { 7 | constructor() { 8 | super({ shadowRender: true }); 9 | } 10 | 11 | connected() { 12 | this.connectedCalled = true; 13 | } 14 | 15 | properties() { 16 | return { 17 | connectedCalled: false, 18 | }; 19 | } 20 | 21 | template() { 22 | return html` 23 |
    outer content
    24 | 25 | `; 26 | } 27 | }, 28 | ); 29 | 30 | const innerTag = defineCE( 31 | class extends BaseElement { 32 | connected() { 33 | this.connectedCalled = true; 34 | } 35 | 36 | properties() { 37 | return { 38 | connectedCalled: false, 39 | }; 40 | } 41 | 42 | template() { 43 | return html`
    inner content
    `; 44 | } 45 | }, 46 | ); 47 | 48 | describe('nesting-elements', () => { 49 | it('nested elements will get connected along side outer elements', async () => { 50 | const el = await fixture(` 51 | <${outerTag}> 52 | <${innerTag} ref="nested"> 53 | 54 | `); 55 | 56 | await nextFrame(); 57 | 58 | assert.isTrue(el.connectedCalled); 59 | 60 | const innerElement = el.querySelector(`${innerTag}`); 61 | 62 | await nextFrame(); 63 | assert.isTrue(innerElement.connectedCalled); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/unit/options.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, nextFrame } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | class ImmediateElement extends BaseElement { 5 | constructor() { 6 | super({ deferUpdate: false }); 7 | } 8 | 9 | properties() { 10 | return { 11 | updateCalled: false, 12 | count: 0, 13 | }; 14 | } 15 | 16 | update(options = { notify: true }) { 17 | this.updateCalled = true; 18 | super.update(options); 19 | } 20 | } 21 | 22 | const immediateTag = defineCE(ImmediateElement); 23 | 24 | describe('options', () => { 25 | it('can disable initial rendering via attribute', async () => { 26 | const el = await fixture(`<${immediateTag} defer-update>`); 27 | assert.isFalse(el.updateCalled); 28 | }); 29 | 30 | it('will update immediately if deferUpdate:true is passed via constructor options', async () => { 31 | const el = await fixture(`<${immediateTag}>`); 32 | assert.isTrue(el.updateCalled); 33 | }); 34 | 35 | it('can defer initalization in connectedCallback via attribute', async () => { 36 | const el = await fixture(`<${immediateTag} defer-connected>`); 37 | // properties wil not be defined 38 | assert.isUndefined(el.updateCalled); 39 | }); 40 | 41 | it('can initialize by calling connectedCallback manually', async () => { 42 | const el = await fixture(`<${immediateTag} defer-connected>`); 43 | el.connectedCallback(); 44 | // properties wil be defined 45 | assert.isDefined(el.count); 46 | el.count++; 47 | await nextFrame(); 48 | assert.isTrue(el.updateCalled); 49 | }); 50 | 51 | it('can handle both defer-update and defer-connected on the same element', async () => { 52 | const el = await fixture(`<${immediateTag} defer-update defer-connected>`); 53 | el.connectedCallback(); 54 | // properties wil be defined 55 | assert.isFalse(el.updateCalled); 56 | assert.isDefined(el.count); 57 | el.count++; 58 | await nextFrame(); 59 | assert.isTrue(el.updateCalled); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/unit/property-change-events.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, oneEvent, nextFrame } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | const tag = defineCE( 6 | class extends BaseElement { 7 | nameChanged = false; 8 | lastNameChanged = false; 9 | 10 | constructor() { 11 | super({ 12 | propertyOptions: { 13 | lastName: { notify: true }, 14 | }, 15 | }); 16 | } 17 | 18 | properties() { 19 | return { 20 | name: 'John', 21 | lastName: 'Doe', 22 | }; 23 | } 24 | 25 | events() { 26 | return { 27 | this: { 28 | 'name-changed': () => { 29 | this.nameChanged = true; 30 | }, 31 | 'last-name-changed': () => { 32 | this.lastNameChanged = true; 33 | }, 34 | }, 35 | }; 36 | } 37 | }, 38 | ); 39 | 40 | describe('property-change-events', () => { 41 | it('does not notify property changes by default', async () => { 42 | const el = await fixture(`<${tag}>`); 43 | el.name = 'Jane'; 44 | await nextFrame(); 45 | assert.isFalse(el.nameChanged); 46 | }); 47 | 48 | it('notifies property changes when configured via constructor options', async () => { 49 | const el = await fixture(`<${tag}>`); 50 | el.lastName = 'Smith'; 51 | await nextFrame(); 52 | assert.isTrue(el.lastNameChanged); 53 | }); 54 | 55 | it('maps the camel case property name to dash case and adds -changed as the event name', async () => { 56 | const el = await fixture(`<${tag}>`); 57 | setTimeout(() => (el.lastName = 'Smith')); 58 | await oneEvent(el, 'last-name-changed'); 59 | }); 60 | 61 | it('sends the new property value via event.detail', async () => { 62 | const el = await fixture(`<${tag}>`); 63 | 64 | setTimeout(() => (el.lastName = 'Smith')); 65 | const event = await oneEvent(el, 'last-name-changed'); 66 | assert.equal(event.detail, 'Smith'); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/unit/property-registration.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | const tagA = defineCE( 6 | class extends BaseElement { 7 | connected() { 8 | this.$refs.nestedElement.name = 'NestedElement'; 9 | } 10 | }, 11 | ); 12 | 13 | const tagB = defineCE( 14 | class extends BaseElement { 15 | properties() { 16 | return { 17 | name: 'oldName', 18 | }; 19 | } 20 | }, 21 | ); 22 | 23 | describe('property-registration', () => { 24 | it('applies property values that are set before the browser registers the custom element', async () => { 25 | const el = await fixture(`<${tagA}> 26 | <${tagB} ref="nestedElement"> 27 | `); 28 | assert.equal(el.$refs.nestedElement.name, 'NestedElement'); 29 | }); 30 | 31 | it('prefers attribute values over property values when both are defined', async () => { 32 | const el = await fixture(`<${tagB} name="newName">`); 33 | assert.notEqual(el.name, 'oldName'); 34 | assert.equal(el.name, 'newName'); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/unit/raw-text-node-part.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, assert, nextFrame, oneEvent } from '@open-wc/testing'; 2 | import { render } from '../../src/dom-parts/render.js'; 3 | import { html } from '../../src/TemplateElement.js'; 4 | import { stripCommentMarkers, stripWhitespace } from '../util/testing-helpers.js'; 5 | import { RawTextNodePart } from '../../src/dom-parts/RawTextNodePart.js'; 6 | import { convertStringToTemplate } from '../../src/util/DOMHelper.js'; 7 | import { createTemplateString } from '../../src/dom-parts/TemplateResult.js'; 8 | 9 | describe(`RawTextNodePart class`, () => { 10 | it('stores a node to be processed from the comment marker node', async () => { 11 | const el = document.createElement('div'); 12 | el.innerHTML = ``; 13 | const marker = el.childNodes[0]; 14 | const node = el.childNodes[1]; 15 | const part = new RawTextNodePart(marker, ''); 16 | assert.equal(part.node, node); 17 | }); 18 | 19 | it('updates its node with new string content', async () => { 20 | const el = document.createElement('div'); 21 | el.innerHTML = ``; 22 | const marker = el.childNodes[0]; 23 | const part = new RawTextNodePart(marker, ''); 24 | assert.equal(stripCommentMarkers(el.textContent), ''); 25 | part.update('foo bar baz'); 26 | assert.equal(stripCommentMarkers(el.textContent), 'foo bar baz'); 27 | }); 28 | }); 29 | 30 | describe(`RawTextNodePart template string parsing`, () => { 31 | it('adds placeholders as comment nodes before the node for text only nodes', async () => { 32 | const templateResult = html``; 33 | const templateString = createTemplateString(templateResult.strings); 34 | assert.equal( 35 | stripWhitespace(templateString), 36 | '', 37 | ); 38 | }); 39 | 40 | it('adds multiple placeholders as comment nodes before the node for text only nodes', async () => { 41 | const templateResult = html``; 42 | const templateString = createTemplateString(templateResult.strings); 43 | assert.equal( 44 | stripWhitespace(templateString), 45 | '', 46 | ); 47 | }); 48 | }); 49 | 50 | describe(`RawTextNodePart part creation`, () => { 51 | it('creates a raw-text-node part for an interpolation inside a text only element', async () => { 52 | const templateResult = html``; 53 | const documentFragment = convertStringToTemplate(templateResult.templateString); 54 | const childNodes = [...documentFragment.childNodes]; 55 | const parts = templateResult.parseParts(childNodes); 56 | assert.deepEqual(parts, [{ type: 'raw-text-node', path: [2], initialValue: '\x03' }]); 57 | }); 58 | 59 | it('creates multiple raw-text-node parts for multiple interpolations inside a text only element', async () => { 60 | const templateResult = html``; 61 | const documentFragment = convertStringToTemplate(templateResult.templateString); 62 | const childNodes = [...documentFragment.childNodes]; 63 | const parts = templateResult.parseParts(childNodes); 64 | assert.deepEqual(parts, [ 65 | { type: 'raw-text-node', path: [2], initialValue: '\x03 bar \x03' }, 66 | { type: 'raw-text-node', path: [3], initialValue: '\x03 bar \x03' }, 67 | ]); 68 | }); 69 | }); 70 | 71 | describe(`RawTextNodePart template bindings`, () => { 72 | it('can have a single interpolation inside a text only element', async () => { 73 | const el = document.createElement('div'); 74 | const content = 'foo'; 75 | const templateResult = html``; 76 | render(templateResult, el); 77 | assert.equal(stripCommentMarkers(el.innerHTML), ''); 78 | assert.equal( 79 | stripCommentMarkers(el.innerHTML), 80 | stripCommentMarkers(templateResult.toString()), 81 | 'CSR template does not match SSR template', 82 | ); 83 | }); 84 | 85 | it('can have multiple interpolations inside a text only element', async () => { 86 | const el = document.createElement('div'); 87 | const templateResult = html``; 88 | render(templateResult, el); 89 | assert.equal(stripCommentMarkers(el.innerHTML), ''); 90 | assert.equal( 91 | stripCommentMarkers(el.innerHTML), 92 | stripCommentMarkers(templateResult.toString()), 93 | 'CSR template does not match SSR template', 94 | ); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /test/unit/ref-registration.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, nextFrame } from '@open-wc/testing'; 3 | import { TemplateElement, html } from '../../src/TemplateElement.js'; 4 | 5 | const elementTag = defineCE( 6 | class extends TemplateElement { 7 | template() { 8 | return html`

    no content

    9 |

    no content

    10 |

    notuniqueSingle

    11 |

    notunique1

    12 |

    notunique2

    `; 13 | } 14 | }, 15 | ); 16 | 17 | const ignoreFollowingSingularExpression = defineCE( 18 | class extends TemplateElement { 19 | template() { 20 | return html`

    no content

    21 |

    no content

    22 |

    notunique1

    23 |

    notunique2

    24 |

    notuniqueSingle

    `; 25 | } 26 | }, 27 | ); 28 | 29 | describe('ref-registration', () => { 30 | it('it registers a single ref at the components $refs map', async () => { 31 | const el = await fixture(`<${elementTag}>`); 32 | await nextFrame(); 33 | assert.exists(el.$refs.unique); 34 | assert.exists(el.$refs.alsounique); 35 | }); 36 | 37 | it('it fails to register a single ref which is not within the template', async () => { 38 | const el = await fixture(`<${elementTag}>`); 39 | assert.exists(el.$refs.unique); 40 | assert.notExists(el.$refs.foo); 41 | }); 42 | 43 | it('it registers a explicitly marked ref as an array in the $refs map', async () => { 44 | const el = await fixture(`<${elementTag}>`); 45 | assert.exists(el.$refs.notsounique); 46 | assert.equal(el.$refs.notsounique.length, 2); 47 | assert.isTrue(Array.prototype.isPrototypeOf(el.$refs.notsounique)); 48 | }); 49 | 50 | it('it ignores a singular expressions that is followed by a [] expression', async () => { 51 | const el = await fixture(`<${elementTag}>`); 52 | assert.exists(el.$refs.notsounique); 53 | assert.isTrue(Array.prototype.isPrototypeOf(el.$refs.notsounique)); 54 | assert.equal(el.$refs.notsounique.length, 2); 55 | assert.isTrue(el.$refs.notsounique[0] instanceof HTMLElement); 56 | }); 57 | 58 | it('it ignores a singular expressions that follows a [] expression', async () => { 59 | const el = await fixture(`<${ignoreFollowingSingularExpression}>`); 60 | assert.exists(el.$refs.notsounique); 61 | assert.equal(el.$refs.notsounique.length, 2); 62 | assert.isTrue(Array.prototype.isPrototypeOf(el.$refs.notsounique)); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /test/unit/root-element.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, oneEvent, nextFrame } from '@open-wc/testing'; 3 | import { TemplateElement, html } from '../../src/TemplateElement'; 4 | 5 | const lightTag = defineCE( 6 | class extends TemplateElement { 7 | template() { 8 | return html` `; 9 | } 10 | 11 | events() { 12 | return { 13 | '[ref=lightRef]': { 14 | click: () => this.dispatch('light-event'), 15 | }, 16 | }; 17 | } 18 | }, 19 | ); 20 | 21 | const shadowTag = defineCE( 22 | class extends TemplateElement { 23 | constructor() { 24 | super({ shadowRender: true }); 25 | } 26 | 27 | properties() { 28 | return { 29 | count: 0, 30 | }; 31 | } 32 | 33 | events() { 34 | return { 35 | '[ref=shadowRef]': { 36 | click: () => { 37 | this.count++; 38 | this.dispatch('shadow-event'); 39 | }, 40 | }, 41 | }; 42 | } 43 | 44 | template() { 45 | return html` `; 46 | } 47 | }, 48 | ); 49 | 50 | describe('root-element', () => { 51 | it('binds event listeners for light dom', async () => { 52 | const el = await fixture(`<${lightTag}>`); 53 | setTimeout(() => el.$refs.lightRef.click()); 54 | await oneEvent(el, 'light-event'); 55 | }); 56 | 57 | it('binds event listeners for shadow dom', async () => { 58 | const el = await fixture(`<${shadowTag}>`); 59 | setTimeout(() => el.$refs.shadowRef.click()); 60 | await oneEvent(el, 'shadow-event'); 61 | }); 62 | 63 | it('calls event listeners in shadow dom only once', async () => { 64 | const el = await fixture(`<${shadowTag}>`); 65 | assert.equal(el.count, 0); 66 | el.$refs.shadowRef.click(); 67 | await nextFrame(); 68 | assert.equal(el.count, 1); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/unit/store.primitive.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, nextFrame } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | import { Store } from '../../src/util/Store'; 5 | 6 | const primitiveStore = new Store(100); 7 | 8 | class ComplexStore extends Store { 9 | properties() { 10 | return { 11 | anotherCount: 0, 12 | }; 13 | } 14 | 15 | get sum() { 16 | return this.count + this.anotherCount; 17 | } 18 | } 19 | 20 | const complexStoreWithoutParam = new ComplexStore(); 21 | 22 | class StoreElement extends BaseElement { 23 | updateCount = 0; 24 | 25 | properties() { 26 | return { 27 | simpleStore, 28 | }; 29 | } 30 | 31 | afterUpdate() { 32 | super.afterUpdate(); 33 | this.updateCount++; 34 | } 35 | } 36 | 37 | class PrimitiveStoreElement extends StoreElement { 38 | properties() { 39 | return { 40 | primitiveStore, 41 | }; 42 | } 43 | } 44 | 45 | const tagPrimitive = defineCE(PrimitiveStoreElement); 46 | 47 | describe('store-observer', () => { 48 | it('wraps primitive constructor values with a value field', async () => { 49 | assert.property(primitiveStore, 'value'); 50 | assert.equal(primitiveStore.value, 100); 51 | }); 52 | 53 | it('adds a shortcut getter to valueOf if _state contains only one value', async () => { 54 | assert.equal(primitiveStore, 100); 55 | }); 56 | 57 | it('adds a setter to a primitive value via the value setter.', async () => { 58 | const el = await fixture(`<${tagPrimitive}>`); 59 | await nextFrame(); 60 | assert.equal(el.primitiveStore, 100); 61 | 62 | // global change 63 | primitiveStore.value++; 64 | await nextFrame(); 65 | 66 | // updates the element 67 | assert.equal(el.updateCount, 1); 68 | assert.equal(el.primitiveStore, 101); 69 | }); 70 | 71 | it('it switches to primitive mode when a primitive value is passed as argument even if the store has a properties() getter', async () => { 72 | const complexStore = new ComplexStore(100); 73 | assert.equal(complexStore, 100); 74 | assert.isUndefined(complexStore.anotherCount); 75 | }); 76 | 77 | it('it allows to initialize Primitive Stores with 0', async () => { 78 | const primitiveStore = new Store(0); 79 | assert.equal(primitiveStore, 0); 80 | primitiveStore.value = 100; 81 | assert.equal(primitiveStore, 100); 82 | }); 83 | 84 | it('does not enter singlePropertyMode if no argument is passed to constructor', async () => { 85 | assert.equal(complexStoreWithoutParam.anotherCount, 0); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /test/unit/store.watch.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, nextFrame } from '@open-wc/testing'; 3 | import { Store } from '../../src/util/Store'; 4 | 5 | class NestedStore extends Store { 6 | properties() { 7 | return { 8 | nestedCount: 0, 9 | }; 10 | } 11 | } 12 | 13 | class WatchStore extends Store { 14 | echoCount = -1; 15 | echoOldCount = -1; 16 | triggerCount = 0; 17 | echoNestedStoreCount = 0; 18 | updateCount = 0; 19 | properties() { 20 | return { 21 | count: 0, 22 | nestedStore: new NestedStore(), 23 | }; 24 | } 25 | 26 | requestUpdate() { 27 | super.requestUpdate(); 28 | this.updateCount++; 29 | } 30 | 31 | watch() { 32 | return { 33 | count: (newCount, oldCount) => { 34 | this.echoCount = newCount; 35 | this.echoOldCount = oldCount; 36 | this.triggerCount++; 37 | }, 38 | nestedStore: () => { 39 | this.echoNestedStoreCount = this.nestedStore.nestedCount; 40 | }, 41 | }; 42 | } 43 | } 44 | 45 | describe('store-watch', () => { 46 | it('watches changes on store properties and calls callbacks', async () => { 47 | const watchStore = new WatchStore({ count: 0 }); 48 | assert.equal(watchStore.count, 0); 49 | assert.equal(watchStore.echoCount, -1); 50 | watchStore.count++; 51 | assert.equal(watchStore.count, 1); 52 | assert.equal(watchStore.echoCount, 1); 53 | assert.equal(watchStore.echoOldCount, 0); 54 | }); 55 | 56 | it('watches changes on store properties and calls callbacks only if the value did actually change', async () => { 57 | const watchStore = new WatchStore({ count: 0 }); 58 | assert.equal(watchStore.triggerCount, 0); 59 | watchStore.count = 1; 60 | assert.equal(watchStore.triggerCount, 1); 61 | // this should not trigger the watcher as the value is not changed 62 | watchStore.count = 1; 63 | assert.equal(watchStore.triggerCount, 1); 64 | // this should trigger the watcher as the value is changed 65 | watchStore.count = 2; 66 | assert.equal(watchStore.triggerCount, 2); 67 | }); 68 | 69 | it('Call watcher on nested store properties which are itself instances of Store', async () => { 70 | const watchStore = new WatchStore({ count: 0 }); 71 | assert.equal(watchStore.nestedStore.nestedCount, 0); 72 | watchStore.nestedStore.nestedCount = 1; 73 | assert.equal(watchStore.nestedStore.nestedCount, 1); 74 | // store updates contains a potential await requestUpdate so we need to wait a frame here 75 | await nextFrame(); 76 | assert.equal(watchStore.echoNestedStoreCount, watchStore.nestedStore.nestedCount); 77 | }); 78 | 79 | it('updates on nested store properties changes', async () => { 80 | const watchStore = new WatchStore({ count: 0 }); 81 | assert.equal(watchStore.nestedStore.nestedCount, 0); 82 | watchStore.nestedStore.nestedCount++; 83 | await nextFrame(); 84 | assert.equal(watchStore.updateCount, 1); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/unit/updates.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, oneEvent, nextFrame } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | const tag = defineCE( 6 | class extends BaseElement { 7 | constructor(options) { 8 | super(options); 9 | this.connectedCalled = false; 10 | this.beforeUpdateCalled = false; 11 | this.afterUpdateCalled = false; 12 | this.calledHooks = []; 13 | } 14 | 15 | connected() { 16 | this.connectedCalled = true; 17 | this.calledHooks.push('connected'); 18 | } 19 | 20 | beforeUpdate() { 21 | this.beforeUpdateCalled = true; 22 | this.calledHooks.push('beforeUpdate'); 23 | } 24 | 25 | afterUpdate() { 26 | this.afterUpdateCalled = true; 27 | this.calledHooks.push('afterUpdate'); 28 | } 29 | 30 | properties() { 31 | return { 32 | firstName: 'John', 33 | lastName: 'Doe', 34 | age: 42, 35 | ageChanged: false, 36 | }; 37 | } 38 | 39 | watch() { 40 | return { 41 | age: () => { 42 | this.ageChanged = true; 43 | }, 44 | }; 45 | } 46 | }, 47 | ); 48 | 49 | describe('updates', () => { 50 | it('can request an update for the element', async () => { 51 | const el = await fixture(`<${tag}>`); 52 | assert.isFalse(el.beforeUpdateCalled); 53 | assert.isFalse(el.afterUpdateCalled); 54 | await el.requestUpdate(); 55 | assert.isTrue(el.beforeUpdateCalled); 56 | assert.isTrue(el.afterUpdateCalled); 57 | }); 58 | 59 | it('can request a silent update for the element without notifying the hooks', async () => { 60 | const el = await fixture(`<${tag}>`); 61 | assert.isFalse(el.beforeUpdateCalled); 62 | assert.isFalse(el.afterUpdateCalled); 63 | await el.requestUpdate({}, false); 64 | assert.isFalse(el.beforeUpdateCalled); 65 | assert.isFalse(el.afterUpdateCalled); 66 | }); 67 | 68 | // it('updates/renders the element asynchronously', async () => { 69 | // const el = await fixture(`<${tag}>`); 70 | // assert.equal(el.firstName, 'John'); 71 | // el.firstName = 'Jane'; 72 | // assert.equal(el.firstName, 'John'); 73 | // await nextFrame(); 74 | // assert.equal(el.firstName, 'Jane'); 75 | // }); 76 | // 77 | // it('batches multiple updates and only renders once after all updates are done', async () => { 78 | // const el = await fixture(`<${tag}>`); 79 | // el.firstName = 'Jane'; 80 | // el.lastName = 'Smith'; 81 | // await nextFrame(); 82 | // assert.deepEqual(el.calledHooks, ['connected', 'beforeUpdate', 'afterUpdate']); 83 | // }); 84 | // 85 | // it('updates property changes "triggered" in watched properties in the same cycle', async () => { 86 | // const el = await fixture(`<${tag}>`); 87 | // assert.isFalse(el.ageChanged); 88 | // el.age = 32; 89 | // await nextFrame(); 90 | // assert.isTrue(el.ageChanged); 91 | // assert.deepEqual(el.calledHooks, ['connected', 'beforeUpdate', 'afterUpdate']); 92 | // }); 93 | }); 94 | -------------------------------------------------------------------------------- /test/unit/vanilla-render-slots.test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | nested elment: 4 | 5 | 6 |
    7 | standalone elment: 8 | 9 | 10 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /test/unit/watched-properties.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import { fixture, defineCE, assert, oneEvent } from '@open-wc/testing'; 3 | import { BaseElement } from '../../src/BaseElement'; 4 | 5 | const tag = defineCE( 6 | class extends BaseElement { 7 | properties() { 8 | return { 9 | count: 0, 10 | }; 11 | } 12 | 13 | watch() { 14 | return { 15 | count: (newValue, oldValue) => { 16 | this.dispatch('count-changed', { newValue, oldValue }); 17 | }, 18 | }; 19 | } 20 | }, 21 | ); 22 | 23 | describe('watched-properties', () => { 24 | it('watches property changes and invokes callback registered in watch map', async () => { 25 | const el = await fixture(`<${tag}>`); 26 | setTimeout(() => (el.count = 1)); 27 | await oneEvent(el, 'count-changed'); 28 | }); 29 | 30 | it('invokes watch callbacks with oldValue and newValue', async () => { 31 | const el = await fixture(`<${tag}>`); 32 | setTimeout(() => (el.count = 1)); 33 | const event = await oneEvent(el, 'count-changed'); 34 | assert.equal(event.detail.oldValue, 0); 35 | assert.equal(event.detail.newValue, 1); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/util/testing-helpers.js: -------------------------------------------------------------------------------- 1 | // TODO: for testing that SSR and CSR will render the same thing, it would be good to test with whitespace and comment markers to make sure that they perfectly match! 2 | // TODO renderer should actually not output any blanks and whitespaces that have to be removed for testing ... 3 | export const stripCommentMarkers = (html) => 4 | html 5 | .replace(//g, '') 6 | .replace(/\s+/g, ' ') 7 | .replaceAll(' >', '>') 8 | .replaceAll('> ', '>') 9 | .trim(); 10 | 11 | export const stripWhitespace = (html) => html.replace(/\s+/g, ' ').replaceAll('> <', '><').trim(); 12 | -------------------------------------------------------------------------------- /web-dev-server.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | nodeResolve: true, 3 | watch: true, 4 | rootDir: './examples', 5 | plugins: [], 6 | }; 7 | -------------------------------------------------------------------------------- /web-test-runner.config.js: -------------------------------------------------------------------------------- 1 | import { chromeLauncher } from '@web/test-runner'; 2 | 3 | export default { 4 | browsers: [ 5 | chromeLauncher({ 6 | concurrency: 1, // https://github.com/open-wc/open-wc/issues/2813#issuecomment-2317609810 7 | }), 8 | ], 9 | coverageConfig: { 10 | reportDir: 'test/coverage', 11 | }, 12 | testRunnerHtml: (testFramework) => 13 | ` 14 | 15 | 16 | 17 | 18 | `, 19 | }; 20 | --------------------------------------------------------------------------------