├── .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 |
76 |
77 | ```
78 |
79 | ## Documentation
80 |
81 | For detailed documentation see the [Docs](docs/README.md).
82 |
83 | ## Contributing & Development
84 |
85 | For contributions and development see [contributing docs](.github/CONTRIBUTING.md)
86 |
87 | ## License
88 |
89 | `element-js` is open-sourced software licensed under the MIT [license](LICENSE).
90 |
--------------------------------------------------------------------------------
/docs/concepts/hierarchy.md:
--------------------------------------------------------------------------------
1 | ### Element Hierarchy
2 |
3 | Pleas keep in mind that lifecycles _do not_ wait for child elements to be connected or updated/rendered.
4 |
5 | In the example below we have a simple hierarchy of elements.
6 |
7 | ```html
8 |
9 |
10 |
11 |
12 |
13 | ```
14 |
15 | The full lifecycle would be as follows:
16 |
17 | 1. a-element -> connected()
18 | 2. b-element -> connected()
19 | 3. c-element -> connected()
20 |
21 | This loading/connecting behaviour is compliant with how other (normal) DOM elements are loaded and connected.
22 |
23 | #### Example
24 |
25 | In the example we render a clock and update the time every second.
26 |
27 | ```javascript
28 | import { TemplateElement, defineElement, html } from '@webtides/element-js';
29 |
30 | export class ClockElement extends TemplateElement {
31 | timer = null;
32 |
33 | properties() {
34 | return {
35 | time: Date.now()
36 | };
37 | }
38 |
39 | connected() {
40 | this.timer = window.setInterval(() => {
41 | this.time = Date.now();
42 | }, 1000);
43 | }
44 |
45 | disconnected() {
46 | window.clearInterval(this.timer);
47 | }
48 |
49 | get formattedTime() {
50 | return new Date(this.time).toLocaleTimeString();
51 | }
52 |
53 | template() {
54 | return html` ${this.formattedTime} `;
55 | }
56 | }
57 |
58 | defineElement('clock-element', ClockElement);
59 | ```
60 |
--------------------------------------------------------------------------------
/docs/concepts/lifecycle.md:
--------------------------------------------------------------------------------
1 | ### Lifecycle
2 |
3 | Elements have several lifecycle methods which can be used to hook into different moments. By implementing one of the following methods within the class the element will call them in the right order:
4 |
5 | #### connected
6 |
7 | Called when the element is connected to the DOM and _element-js_ is done initialising. Usually this hook will only be called once for the lifetime of your element.
8 |
9 | > Please note that the `connected` hook however /can/ be called more than once after the first time. It will also be invoked when the element is _attached_ or _moved_ in the DOM or to another DOM.
10 |
11 | Use it to initialise the element and set public or "private" properties, query for other DOM elements, etc.
12 |
13 | #### beforeUpdate
14 |
15 | Called when the element is about to be updated. Whenever a /reactive/ property changed, the element will undergo an update/render cycle. In this hook you can prepare any internal state that might be needed before the update or render actually happens.
16 |
17 | > Although the element will update/render after connecting to the DOM, the `beforeUpdate` hook won't be called for the first time. Only on subsequent update cycles.
18 |
19 | Property changes in this callback will not be applied in the current frame. They will be queued and processed in the next frame.
20 |
21 | #### afterUpdate
22 |
23 | Called after the element was updated/rendered. Implement this hook to perform any tasks that need to be done afterwards like setting/toggling classes on child elements or invoking API methods on other elements.
24 |
25 | #### disconnected
26 |
27 | Called just before the element is about to be disconnected/removed from the DOM. Use this hook to clear or remove everything that might be heavy on the browser like expensive event listeners etc.
28 |
29 | _DISCALIMER_
30 |
31 | The lifecycle hooks described here are custom methods provided by _element-js_. By default custom elements also have two baked in hooks. `connectedCallback()` and `disconnectedCallback()`. We would encourage you to avoid these if possible. Internally we use them of course to connect the element and trigger the `connected()` callback afterwards. We use them to initialise the elements with all the boilerplate and prepare the API that _element-js_ provides. If you absolutely must use one of them please keep in mind to call the original callback on the `super` element.
32 |
--------------------------------------------------------------------------------
/docs/concepts/reactivity.md:
--------------------------------------------------------------------------------
1 | ### Reactivity
2 |
3 | _element-js_ elements update asynchronously when attributes or reactive properties are changed. Changes will also be batched if multiple updates occur. The update itself will always happen in the following frame.
4 |
5 | An update lifecycle roughly looks like this:
6 |
7 | 1. A property is changed
8 | 2. Check whether the value actually changed and if so, request an update
9 | 3. Requested updates are batched in a queue
10 | 4. Perform the update in the next frame
11 | 5. Trigger Watchers for properties if defined
12 | 6. Render the template if defined
13 |
--------------------------------------------------------------------------------
/docs/features/computed-properties.md:
--------------------------------------------------------------------------------
1 | ## Computed properties
2 |
3 | Sometimes it is necessary to compute properties out of multiple other properties. You can simply define a getter on the element and return a value.
4 |
5 | ```javascript
6 | export class MyElement extends BaseElement {
7 | properties() {
8 | return {
9 | reactivePublicProperty: 'I am public & reactive'
10 | };
11 | }
12 |
13 | get computedProperty() {
14 | return this.reactivePublicProperty + ' & computed';
15 | }
16 |
17 | log() {
18 | console.log(this.computedProperty);
19 | }
20 | }
21 | ```
22 |
23 | For every subsequent update/render cycle a computed property will be evaluated again.
24 |
25 | > Keep in mind that properties (public or private) that are not declared via the `properties()` map will not trigger an update/render cycle when changed. If you use only non reactive properties to compute a property, it will not be re-evaluated automatically.
26 |
--------------------------------------------------------------------------------
/docs/features/dependency-injection-context-protocol.md:
--------------------------------------------------------------------------------
1 | ### Dependency Injection
2 |
3 | _element-js_ provides a simple way of Dependency Injection following the idea of the Context [Protocol Proposal](https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md).
4 |
5 | #### Problem "Prop Drilling"
6 |
7 | If Data has to be passed down the DOM Tree to descending Child Elements it is usually done by assigning data to the descendants attributes:
8 |
9 | ```js
10 | export class PropDrilling extends TemplateElement {
11 | properties() {
12 | return {
13 | data: { some: 'data', which: 'bloats', the: 'attributes', in: 'DOM' }
14 | };
15 | }
16 |
17 | template() {
18 | return html`
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | `;
27 | }
28 | }
29 | ```
30 |
31 | While this is possible and totally fine for primitive values it can get confusing very fast with a deep nesting and complex data structures. It also bloats up the DOM Tree as redundant data ist being passed to multiple descending Elements being copied along the way.
32 |
33 | #### Dependency Injection for the rescue
34 |
35 | To address the issues caused by "Prop Drilling" _element-js_ provides a simple Dependency Injection Implementation following the idea of the Context Protocol mentioned above.
36 |
37 | ##### Provide via provideProperties()
38 |
39 | An Element can control whatever is to be "provided" to requesting Elements by implementing the provideProperties function provided by the `BaseElement`.
40 |
41 | ```js
42 | export class AncestorElement extends TemplateElement {
43 | properties() {
44 | return {
45 | data: { some: 'data', which: 'bloats', the: 'attributes', in: 'DOM' }
46 | };
47 | }
48 | provideProperties() {
49 | return {
50 | data: this.data,
51 | in: this.data.in,
52 | static: 'string'
53 | };
54 | }
55 | }
56 | ```
57 |
58 | #### Vanilla JS Provide via Event Listener / Context Protocol
59 |
60 | ```html
61 |
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 | console.log('clicked')}">@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 |
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 |
29 | ${list.map((item) => html`${item} `)}
30 |
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 |
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 |
21 |
22 | server side shadow root content
23 |
24 |
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 |
34 | Home
35 |
36 |
`,
37 | about: html`
38 |
39 | About
40 |
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 |
47 |
48 | Disabled
49 |
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 |
87 | ${list.map((item) => html`${item} `)}
88 |
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`
114 | ${this.list.map((index) => html` ${index} `)}
115 | `;
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 |
142 | `,
143 | result: html`
144 |
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 | head-link
32 | body-style
33 | body-link
34 | async-head-style
35 | async-head-link
36 | async-body-style
37 | async-body-link
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 | head-link
65 | body-style
66 | body-link
67 | async-head-style
68 | async-head-link
69 | async-body-style
70 | async-body-link
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 | count: ${this.count}
41 | sharedStore: ${this.sharedStore.count}
42 | internalStore: ${this.internalStore.count}
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}>${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}>${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}>${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}>${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">${tag}>`);
10 | assert.equal(el.getAttribute('lowercase'), '1');
11 | });
12 |
13 | it('cannot have camelCase attributes', async () => {
14 | const el = await fixture(`<${tag} camelCase="0">${tag}>`);
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">${tag}>`);
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">${tag}>`);
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">${tag}>`);
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">${tag}>`);
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">${tag}>`);
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">${tag}>`);
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">${tag}>`);
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">${tag}>`);
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"}'>${tag}>`);
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]'>${tag}>`);
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]'>${tag}>`);
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'>${tag}>`);
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'>${tag}>`);
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'>${tag}>`);
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'>${tag}>`);
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"}'>${tag}>`);
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]'>${tag}>`);
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'>${tag}>`);
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'>${tag}>`);
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'>${tag}>`);
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}>${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">${propertyOptionsTag}>`);
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} >${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">${tag}>`);
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">${tag}>`);
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">${tag}>`);
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"}'>${tag}>`);
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]'>${tag}>`);
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">${tag}>`);
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">${tag}>`);
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">${tag}>`);
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}>${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}>${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}>server side shadow root content
${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}>${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}>${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}>${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}>${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}> ${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}> ${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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">${tag}>`);
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}> ${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}> ${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}>${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}> ${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}> ${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}> ${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">${innerTag}>
53 | ${outerTag}>
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>${immediateTag}>`);
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}>${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>${immediateTag}>`);
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>${immediateTag}>`);
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>${immediateTag}>`);
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}>${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}>${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}>${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}>${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">${tagB}>
27 | ${tagA}>`);
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">${tagB}>`);
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}>${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}>${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}>${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}>${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}>${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` Click me `;
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` Click me `;
46 | }
47 | },
48 | );
49 |
50 | describe('root-element', () => {
51 | it('binds event listeners for light dom', async () => {
52 | const el = await fixture(`<${lightTag}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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 |
--------------------------------------------------------------------------------