├── .eslintrc.cjs ├── .github └── workflows │ ├── node.js.yml │ └── npm-publish.yml ├── .gitignore ├── .prettierrc.json ├── LICENSE.md ├── README.md ├── images └── logo.svg ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── common.js ├── dom.js ├── knobs.js ├── server │ ├── dom.js │ ├── element.js │ └── wafer.js ├── types.js ├── wafer-mixin.js └── wafer.js ├── test ├── client │ ├── changed-updated.test.js │ ├── events.test.js │ ├── helpers.test.js │ ├── instantiation │ │ ├── defined-after-created-with-createelement.test.js │ │ ├── defined-after-created-with-innerhtml.test.js │ │ ├── defined-after-created-with-new.test.js │ │ ├── defined-after-dom-all-types.test.js │ │ ├── defined-before-created-with-createelement.test.js │ │ ├── defined-before-created-with-innerhtml.test.js │ │ ├── defined-before-created-with-new.test.js │ │ ├── defined-before-dom-all-types.test.js │ │ └── general.test.js │ └── rendering.test.js ├── configs.js ├── server │ ├── changed-updated.test.js │ ├── element.test.js │ ├── helpers.test.js │ ├── instantiation │ │ ├── as-object.js │ │ ├── as-string.js │ │ └── general.test.js │ └── rendering.test.js └── testing.js ├── tsconfig.json ├── typedoc-theme └── partials │ └── header.hbs └── web-test-runner.config.js /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: ["eslint:recommended", "prettier"], 8 | parserOptions: { 9 | ecmaVersion: 12, 10 | sourceType: "module", 11 | }, 12 | rules: {}, 13 | }; 14 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Tests 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x, 16.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run lint 31 | - run: npm run tsc 32 | - run: npm run test 33 | - run: npm run test:server 34 | - run: npm run dist 35 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | publish-npm: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: 14 18 | registry-url: https://registry.npmjs.org/ 19 | - run: npm ci 20 | - run: npm run build 21 | - run: npm publish 22 | env: 23 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode/ 3 | node_modules/ 4 | coverage/ 5 | coverage-server/ 6 | dist/ 7 | lib/ 8 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Chris Haynes 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 |

2 | 3 |

4 | 5 | # Wafer 6 | 7 | [![Tests](https://github.com/lamplightdev/wafer/actions/workflows/node.js.yml/badge.svg)](https://github.com/lamplightdev/wafer/actions/workflows/node.js.yml) 8 | [![npm](https://img.shields.io/npm/v/@lamplightdev/wafer)](https://www.npmjs.com/package/@lamplightdev/wafer) 9 | [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@lamplightdev/wafer)](https://bundlephobia.com/package/@lamplightdev/wafer) 10 | 11 | Welcome to Wafer: a simple and lightweight base library for building Web Components that can be used on the browser, server or both. 12 | 13 | Wafer is: 14 | 15 | - **Small** 🪶
16 | <2kb (minified and compressed) 17 | 18 | - **Fast** ⚡️
19 | Template updates are declared using CSS selectors leveraging native browser performance 20 | 21 | - **Efficient** 🔋
22 | Updates are batched preventing any unnecessary renders 23 | 24 | - **Flexible** 💪🏾
25 | Import directly, drop in a ` 62 | 67 | ``` 68 | 69 | On the server Wafer is available as both an ES and a CJS module: 70 | 71 | ```js 72 | // ES module 73 | import WaferServer from "@lamplightdev/wafer/server/wafer.js"; 74 | // or CJS 75 | const WaferServer = require("@lamplightdev/wafer/server/wafer"); 76 | 77 | class MyExample extends WaferServer {} 78 | ``` 79 | 80 | ## Building 81 | 82 | The source files are located in the `/src` folder, and can be built with: 83 | 84 | ``` 85 | npm run build 86 | ``` 87 | 88 | This will lint the code, create Typescript types from JSDoc comments, run tests and finally build the package files. To run these steps individually please see the `scripts` entries in `package.json`. 89 | 90 | ## Contributing 91 | 92 | All contributions are welcome - please file an issue or submit a PR. 93 | 94 | ## License 95 | 96 | The code is released under the [MIT license](./LICENSE.md). 97 | 98 | ## Credits 99 | 100 | Wafer was created by [Chris](https://lamplightdev.com/) ([@lamplightdev](https://twitter.com/lamplightdev)) - say hi! 101 | -------------------------------------------------------------------------------- /images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Group 2 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lamplightdev/wafer", 3 | "version": "1.0.14", 4 | "description": "Web Component Base Class", 5 | "keywords": [ 6 | "webcomponents", 7 | "web-components", 8 | "web components" 9 | ], 10 | "type": "module", 11 | "main": "./lib/wafer.cjs", 12 | "module": "./lib/wafer.js", 13 | "unpkg": "./dist/wafer.js", 14 | "exports": { 15 | ".": { 16 | "import": "./lib/wafer.js", 17 | "default": "./lib/wafer.cjs" 18 | }, 19 | "./dom": { 20 | "import": "./lib/dom.js", 21 | "default": "./lib/dom.cjs" 22 | }, 23 | "./dom.js": { 24 | "import": "./lib/dom.js", 25 | "default": "./lib/dom.cjs" 26 | }, 27 | "./server": { 28 | "import": "./lib/server/wafer.js", 29 | "default": "./lib/server/wafer.cjs" 30 | }, 31 | "./lib/server/wafer": { 32 | "import": "./lib/server/wafer.js", 33 | "default": "./lib/server/wafer.cjs" 34 | }, 35 | "./lib/server/wafer.js": { 36 | "import": "./lib/server/wafer.js", 37 | "default": "./lib/server/wafer.cjs" 38 | }, 39 | "./server/dom": { 40 | "import": "./lib/server/dom.js", 41 | "default": "./lib/server/dom.cjs" 42 | }, 43 | "./lib/server/dom": { 44 | "import": "./lib/server/dom.js", 45 | "default": "./lib/server/dom.cjs" 46 | }, 47 | "./lib/server/dom.js": { 48 | "import": "./lib/server/dom.js", 49 | "default": "./lib/server/dom.cjs" 50 | }, 51 | "./server/element": { 52 | "import": "./lib/server/element.js", 53 | "default": "./lib/server/element.cjs" 54 | }, 55 | "./lib/server/element": { 56 | "import": "./lib/server/element.js", 57 | "default": "./lib/server/element.cjs" 58 | }, 59 | "./lib/server/element.js": { 60 | "import": "./lib/server/element.js", 61 | "default": "./lib/server/element.cjs" 62 | } 63 | }, 64 | "types": "./lib/wafer.d.ts", 65 | "sideEffects": false, 66 | "files": [ 67 | "dist/**", 68 | "lib/**" 69 | ], 70 | "scripts": { 71 | "test": "wtr test/client --recursive --node-resolve", 72 | "test:watch": "wtr test/client --recursive --node-resolve --watch", 73 | "test:coverage": "wtr test/client --recursive --exclude test/configs.js --node-resolve --coverage", 74 | "test:server": "mocha test/server --recursive --exclude test/configs.js", 75 | "test:server:coverage": "c8 --reporter=html --reports-dir=coverage-server/ mocha test/server --recursive --exclude test/configs.js", 76 | "dist": "rollup -c", 77 | "tsc": "tsc", 78 | "typedoc": "typedoc --theme ./typedoc-theme --listInvalidSymbolLinks --logLevel Verbose", 79 | "lint": "eslint \"src/*.js\" \"src/server/*.js\"", 80 | "format": "npm run format:eslint && npm run format:prettier", 81 | "format:eslint": "eslint \"src/*.js\" \"src/server/*.js\" --fix", 82 | "format:prettier": "prettier \"src/*.js\" \"src/server/*.js\" --write", 83 | "build": "npm run lint && npm run tsc && npm run test && npm run test:server && npm run dist" 84 | }, 85 | "repository": { 86 | "type": "git", 87 | "url": "git+https://github.com/lamplightdev/wafer.git" 88 | }, 89 | "author": "", 90 | "license": "MIT", 91 | "bugs": { 92 | "url": "https://github.com/lamplightdev/wafer/issues" 93 | }, 94 | "homepage": "https://github.com/lamplightdev/wafer#readme", 95 | "devDependencies": { 96 | "@open-wc/testing": "^2.5.33", 97 | "@web/test-runner": "^0.13.15", 98 | "c8": "^7.7.3", 99 | "chai": "^4.3.4", 100 | "chai-dom": "^1.9.0", 101 | "chai-html": "^2.0.1", 102 | "eslint": "^7.31.0", 103 | "eslint-config-prettier": "^8.3.0", 104 | "mocha": "^9.0.2", 105 | "prettier": "^2.3.2", 106 | "rollup": "^2.53.2", 107 | "rollup-plugin-filesize": "^9.1.1", 108 | "rollup-plugin-terser": "^7.0.2", 109 | "sinon": "^11.1.1", 110 | "sinon-chai": "^3.7.0", 111 | "typedoc": "^0.21.4", 112 | "typescript": "^4.3.x" 113 | }, 114 | "dependencies": { 115 | "node-html-parser": "^4.1.0" 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { terser } from "rollup-plugin-terser"; 2 | import filesize from "rollup-plugin-filesize"; 3 | 4 | export default [ 5 | { 6 | input: "src/wafer.js", 7 | output: [ 8 | { 9 | file: "dist/wafer.js", 10 | format: "es", 11 | sourcemap: true, 12 | }, 13 | { 14 | file: "dist/wafer.browser.js", 15 | format: "iife", 16 | name: "Wafer", 17 | }, 18 | ], 19 | plugins: [ 20 | terser(), 21 | filesize({ 22 | showBrotliSize: true, 23 | showGzippedSize: true, 24 | showMinifiedSize: true, 25 | }), 26 | ], 27 | }, 28 | { 29 | input: "src/dom.js", 30 | output: [ 31 | { 32 | file: "dist/dom.js", 33 | format: "es", 34 | sourcemap: true, 35 | }, 36 | { 37 | file: "dist/dom.browser.js", 38 | format: "iife", 39 | name: "WaferDOM", 40 | }, 41 | ], 42 | plugins: [ 43 | terser(), 44 | filesize({ 45 | showBrotliSize: true, 46 | showGzippedSize: true, 47 | showMinifiedSize: true, 48 | }), 49 | ], 50 | }, 51 | { 52 | input: "src/knobs.js", 53 | output: [ 54 | { 55 | file: "dist/knobs.js", 56 | format: "es", 57 | sourcemap: true, 58 | }, 59 | { 60 | file: "dist/knobs.browser.js", 61 | format: "iife", 62 | }, 63 | ], 64 | plugins: [ 65 | terser(), 66 | filesize({ 67 | showBrotliSize: true, 68 | showGzippedSize: true, 69 | showMinifiedSize: true, 70 | }), 71 | ], 72 | }, 73 | 74 | // CJS for node environments using CJS 75 | { 76 | input: "src/wafer.js", 77 | output: { 78 | file: "lib/wafer.cjs", 79 | format: "cjs", 80 | exports: "default", 81 | }, 82 | }, 83 | { 84 | input: "src/dom.js", 85 | output: { 86 | file: "lib/dom.cjs", 87 | format: "cjs", 88 | }, 89 | }, 90 | { 91 | input: "src/server/wafer.js", 92 | output: { 93 | file: "lib/server/wafer.cjs", 94 | format: "cjs", 95 | exports: "default", 96 | }, 97 | external: ["node-html-parser", "module"], 98 | }, 99 | { 100 | input: "src/server/dom.js", 101 | output: { 102 | file: "lib/server/dom.cjs", 103 | format: "cjs", 104 | }, 105 | external: ["node-html-parser", "module"], 106 | }, 107 | { 108 | input: "src/server/element.js", 109 | output: { 110 | file: "lib/server/element.cjs", 111 | format: "cjs", 112 | }, 113 | external: ["node-html-parser", "module"], 114 | }, 115 | ]; 116 | -------------------------------------------------------------------------------- /src/common.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Common functionality shared between {@link WaferClient} and {@link WaferServer} 3 | * 4 | * @module Common 5 | */ 6 | 7 | /** 8 | * Updated the the targets of a component 9 | * 10 | * @param {function} apply - function that will query DOM and apply changes to matches 11 | * @param {Element|import("./server/element").ServerElement} el - the DOM root to query 12 | * @param {Object} opts - options for how to update the targets 13 | * @param {any} opts.value - the value to use in updates 14 | * @param {import("./types").Target[]} [opts.targets] - array of selectors and which updates that should be applied 15 | * 16 | * @returns {Promise} Return a promise so the updates can be `await`ed in server context 17 | * 18 | */ 19 | const updateTargets = async (apply, el, { value, targets = [] }) => { 20 | for (const target of targets) { 21 | const { selector, attribute, text, dom, property, use } = target; 22 | 23 | /** 24 | * Apply selector function, or use value directly 25 | */ 26 | const selectorVal = 27 | typeof selector === "function" ? selector(value, el) : selector; 28 | 29 | /** 30 | * Find all matches for `selector` in `el`, and apply updates 31 | */ 32 | await apply( 33 | el, 34 | selectorVal, 35 | /** 36 | * @param {Element} targetEl 37 | */ 38 | async (targetEl) => { 39 | /** 40 | * Apply use function, or use value directly 41 | */ 42 | const useValue = use ? use(value, el, targetEl) : value; 43 | 44 | if (attribute) { 45 | if ([null, false, undefined].includes(useValue)) { 46 | /** 47 | * Remove this attribute if new value is null/false/undefined 48 | */ 49 | targetEl.removeAttribute(attribute); 50 | } else { 51 | /** 52 | * Update attribute with new value. If value is Boolean and true, 53 | * set attribute to empty string for consistency 54 | */ 55 | targetEl.setAttribute(attribute, useValue === true ? "" : useValue); 56 | } 57 | } 58 | 59 | if (property) { 60 | /** 61 | * Update property unless 62 | */ 63 | // @ts-ignore: allow to set any prop 64 | targetEl[property] = useValue; 65 | } 66 | 67 | if (text) { 68 | /** 69 | * Update element's text 70 | */ 71 | targetEl.textContent = useValue; 72 | } 73 | 74 | if (dom) { 75 | /** 76 | * Apply DOM updates if defined 77 | */ 78 | await dom(targetEl, useValue, el); 79 | } 80 | } 81 | ); 82 | } 83 | }; 84 | 85 | export { updateTargets }; 86 | -------------------------------------------------------------------------------- /src/dom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Helpers for rendering in {@link WaferClient} 3 | * 4 | * @module DOMClient 5 | */ 6 | 7 | import { updateTargets } from "./common.js"; 8 | 9 | /** 10 | * Keep a cache of created templates 11 | */ 12 | const templateCache = new Map(); 13 | 14 | /** 15 | * Stamp out an element from a string template 16 | * 17 | * @param {string} html - The string template 18 | * @param {boolean} [firstChild] - if `true` use the `firstChild` of created element, used when a single element is required rather than a DocumentFragment 19 | * 20 | * @returns {DocumentFragment | Element} - The stamped out element 21 | */ 22 | const stamp = (html, firstChild = false) => { 23 | /** 24 | * Keep things tidy 25 | */ 26 | const trimmed = html.trim(); 27 | 28 | /** 29 | * Retrieve from cache if possible 30 | */ 31 | let cached = templateCache.get(trimmed); 32 | if (!cached) { 33 | /** 34 | * Create and populate a template element 35 | */ 36 | const template = document.createElement("template"); 37 | template.innerHTML = trimmed; 38 | 39 | /** 40 | * Update cache 41 | */ 42 | templateCache.set(trimmed, template); 43 | cached = template; 44 | } 45 | 46 | /** 47 | * Clone away! 48 | */ 49 | 50 | if (firstChild) { 51 | return cached.content.firstElementChild.cloneNode(true); 52 | } 53 | 54 | return cached.content.cloneNode(true); 55 | }; 56 | 57 | /** 58 | * Helper function for emitting DOM events 59 | * 60 | * @param {Element} target - the element from which the event should be dispatched 61 | * @param {string} name - the event name 62 | * @param {object} [detail] - the detail object to pass with event 63 | * @param {EventInit} [opts] - event options 64 | */ 65 | const emit = ( 66 | target, 67 | name, 68 | detail = {}, 69 | opts = { 70 | bubbles: true, 71 | composed: true, 72 | } 73 | ) => { 74 | target.dispatchEvent( 75 | new CustomEvent(name, { 76 | ...opts, 77 | detail, 78 | }) 79 | ); 80 | }; 81 | 82 | /** 83 | * Utility to efficiently repeat and update a series of Elements into a 84 | * container, taking in an html template from which the element will be stamped 85 | * out, and a series of items and targets used to create and/or update 86 | * elements 87 | * 88 | * @param {Object} opts - Repeat options 89 | * @param {Element} opts.container - Container into which to render repeated items 90 | * @param {any[]} opts.items - Array of items which are used to create/update elements 91 | * @param {string} opts.html - HTML template which will be used to create elements 92 | * @param {((value: any, index: number) => string)} opts.keyFn - Key function which is used to create unique id for each item/element 93 | * @param {import("./types").Target[]} [opts.targets] - How to update an element when an item changes 94 | * @param { ((el: Element, item?: any, index?: number) => void) | null} [opts.init] - function to apply when an element is first created 95 | * @param { Object.} [opts.events] - list of events to bind to element when created 96 | * 97 | */ 98 | const repeat = async ({ 99 | container, 100 | items, 101 | html, 102 | keyFn, 103 | targets = [], 104 | init = null, 105 | events = {}, 106 | }) => { 107 | /** 108 | * A record of an item's index (position) in the array to its key 109 | * 110 | * @type {Object.} 111 | */ 112 | 113 | const indexToKey = {}; 114 | 115 | /** 116 | * A record of an item's key to it's index (position) and existing element 117 | * 118 | * @type {Object.} 119 | */ 120 | const keyMap = {}; 121 | 122 | /** 123 | * Update records 124 | */ 125 | for (const [index, item] of items.entries()) { 126 | /** 127 | * ensure key is a string so we can compare with key when set as an 128 | * attribute (that will always be a string) 129 | */ 130 | const key = "" + keyFn(item, index); 131 | indexToKey[index] = key; 132 | keyMap[key] = { index, el: null }; 133 | } 134 | 135 | /** 136 | * A record of key to existing elements 137 | * 138 | * @type {Object.} 139 | */ 140 | const existingEls = {}; 141 | 142 | /** 143 | * A record of existing elements to remove (i.e. they are not related by 144 | * key to any current item) 145 | */ 146 | const toRemove = []; 147 | 148 | /** 149 | * All the keys of existing elements in DOM order 150 | */ 151 | const childrenKeys = []; 152 | 153 | for (const el of container.children) { 154 | const key = el.getAttribute("wafer-key"); 155 | if (key) { 156 | childrenKeys.push(key); 157 | 158 | /** 159 | * For each element in the container that has a key, keep a record of 160 | * this element 161 | */ 162 | existingEls[key] = el; 163 | 164 | if (!keyMap[key]) { 165 | /** 166 | * If there's no match with the keys of the current items the element 167 | * should be removed 168 | */ 169 | toRemove.push(el); 170 | } else { 171 | /** 172 | * If there is a match then reference the element in the keyMap so 173 | * it can be used for updates below 174 | */ 175 | keyMap[key].el = el; 176 | } 177 | } 178 | } 179 | 180 | /** 181 | * Remove any unmatched elements 182 | */ 183 | for (const el of toRemove) { 184 | el.remove(); 185 | } 186 | 187 | /** 188 | * Update and move existing elements, moving those with the greatest distance 189 | * to travel first. 190 | */ 191 | 192 | /** 193 | * For existing elements that need to be moved keep a track of where 194 | * they need to move to and how far they have travel 195 | * 196 | * @typedef ElInfo 197 | * @prop {Element} el - The existing element reference 198 | * @prop {number} targetIndex - The index the element should be moved to 199 | * @prop {number} distance - The distance the element needs to move 200 | * 201 | * @type {ElInfo[]} 202 | */ 203 | const elementsToMove = []; 204 | 205 | /** 206 | * A counter indicating the new index position 207 | */ 208 | let targetIndex = 0; 209 | 210 | for (const [index, item] of items.entries()) { 211 | const key = indexToKey[index]; 212 | const currentIndex = childrenKeys.indexOf(key); 213 | 214 | if (existingEls[key]) { 215 | /** 216 | * Apply updates to existing elements 217 | */ 218 | await updateTargets(apply, existingEls[key], { 219 | value: item, 220 | targets, 221 | }); 222 | 223 | /** 224 | * Attach event listeners since the existing elements may have been 225 | * rendered on the server but won't have event listeners attached yet. 226 | * Adding the same bound listener multiple times is a no-op 227 | * TODO: think of a way to only do this if necessary 228 | */ 229 | for (const selector of Object.keys(events)) { 230 | const eventNames = Object.keys(events[selector]); 231 | 232 | for (const name of eventNames) { 233 | const def = events[selector][name]; 234 | 235 | bindEvent(existingEls[key], selector, name, def); 236 | } 237 | } 238 | 239 | if (targetIndex !== currentIndex) { 240 | /** 241 | * If the element has a new position, calculate the distance it has to 242 | * travel 243 | */ 244 | const distance = Math.abs(currentIndex - targetIndex); 245 | 246 | /** 247 | * Update the records 248 | */ 249 | elementsToMove.push({ 250 | el: existingEls[key], 251 | targetIndex, 252 | distance, 253 | }); 254 | } 255 | 256 | /** 257 | * Increment the new index 258 | */ 259 | targetIndex++; 260 | } 261 | } 262 | 263 | /** 264 | * Sort by distance so we move the elements that have the greatest distance 265 | * to travel first 266 | */ 267 | elementsToMove.sort((a, b) => b.distance - a.distance); 268 | 269 | /** 270 | * Move the elements 271 | */ 272 | for (const item of elementsToMove) { 273 | if (item.targetIndex !== [...container.children].indexOf(item.el)) { 274 | /** 275 | * Move the element if it's not already in the correct place 276 | */ 277 | container.children[item.targetIndex].after(item.el); 278 | } 279 | } 280 | 281 | /** 282 | * Add any new elements into their correct position inside the container 283 | */ 284 | 285 | /** 286 | * Reverse items as we want to use `insertBefore` and will need the 287 | * (n+1)th element to exist before we can insert the (n)th element 288 | */ 289 | const reversedItems = items.slice().reverse(); 290 | 291 | for (const [reversedIndex, item] of reversedItems.entries()) { 292 | /** 293 | * Original index 294 | */ 295 | const index = items.length - 1 - reversedIndex; 296 | 297 | const key = indexToKey[index]; 298 | 299 | if (!existingEls[key]) { 300 | /** 301 | * If the element doesn't already exist, create it 302 | */ 303 | const el = /** @type{Element} **/ (stamp(html, true)); 304 | 305 | /** 306 | * Run the initialisation function if passed 307 | */ 308 | if (init) { 309 | init(el, item, index); 310 | } 311 | 312 | /** 313 | * Update the element with desired target updates, and add the wafer-key 314 | * attribute for use later 315 | */ 316 | await updateTargets(apply, el, { 317 | value: item, 318 | targets: targets.concat({ 319 | selector: "self", 320 | use: () => key, 321 | attribute: "wafer-key", 322 | }), 323 | }); 324 | 325 | /** 326 | * Bind events to new element 327 | */ 328 | for (const selector of Object.keys(events)) { 329 | const eventNames = Object.keys(events[selector]); 330 | 331 | for (const name of eventNames) { 332 | const def = events[selector][name]; 333 | bindEvent(el, selector, name, def); 334 | } 335 | } 336 | 337 | /** 338 | * Get a reference to the element after the one to be inserted. 339 | * If there isn't one then the element will be inserted at the end 340 | */ 341 | const afterIndex = index + 1; 342 | const elAfter = 343 | keyMap[indexToKey[afterIndex]] && keyMap[indexToKey[afterIndex]].el; 344 | container.insertBefore(el, elAfter || null); 345 | 346 | /** 347 | * Update the keyMap with the new element reference so it's available 348 | * for the next element that's going to be inserted 349 | */ 350 | keyMap[indexToKey[index]].el = el; 351 | } 352 | } 353 | }; 354 | 355 | /** 356 | * Take a target DOM element and a selector to apply to that element 357 | * and run a function on every match 358 | * 359 | * @param {Element} el - target DOM element 360 | * @param {string} selector - CSS3 selector 361 | * @param {(el: Element) => void} func - function to run on all matches 362 | */ 363 | const apply = async (el, selector, func) => { 364 | if (selector === "self") { 365 | /** 366 | * Special case - 'self' applies function to DOM element itself 367 | */ 368 | await func(el); 369 | } else { 370 | /** 371 | * Special case - the selector should be applied to the 372 | * DOM node's shadow root 373 | */ 374 | const shadow = selector[0] === "$"; 375 | 376 | /** 377 | * Special case - the selector should be applied to the parent document 378 | */ 379 | const doc = selector[0] === "@"; 380 | 381 | /** 382 | * Use the correct target 383 | */ 384 | const target = shadow ? el.shadowRoot || el : doc ? document : el; 385 | 386 | /** 387 | * Remove any leading character used for the special cases above 388 | */ 389 | const targetSelector = shadow || doc ? selector.substr(1) : selector; 390 | 391 | /** 392 | * Apply function to all matches 393 | */ 394 | for (const el of target.querySelectorAll(targetSelector)) { 395 | await func(el); 396 | } 397 | } 398 | }; 399 | 400 | /** 401 | * Bind an event listener to a series of CSS selector matches in a DOM node 402 | * 403 | * @param {Element} el - the element then listener should be attached to (unless overidden by specifying a different target in `def`) 404 | * @param {string} selector - the selector whose matches in `el` this event listener should be added 405 | * @param {string} name - the name of the event whose listener is being added 406 | * @param {((ev: Event) => any) | import("./types").TargetEvent} func - the listener being added, or a `TargetEvent` 407 | */ 408 | const bindEvent = (el, selector, name, func) => { 409 | /** 410 | * The bound function to add as a listener 411 | * @type {(this: Element, ev: Event) => any} 412 | */ 413 | let boundFn; 414 | 415 | /** 416 | * The event listener options to use 417 | * @type {boolean | AddEventListenerOptions | undefined} 418 | */ 419 | let fnOpts = undefined; 420 | 421 | if (typeof func !== "function") { 422 | /** 423 | * `func` is an `TargetEvent`, so pull out the details 424 | */ 425 | const { fn, target = el, opts = {} } = func; 426 | fnOpts = opts; 427 | 428 | /** 429 | * Bind `TargetEvent` function to target 430 | */ 431 | boundFn = fn.bind(target); 432 | } else { 433 | /** 434 | * `func` is a function so bind to `el` 435 | */ 436 | boundFn = func.bind(el); 437 | } 438 | 439 | /** 440 | * Attach event listener to all matches of `selector` in `el` 441 | */ 442 | apply(el, selector, (eventEl) => { 443 | eventEl.addEventListener(name, boundFn, fnOpts); 444 | }); 445 | }; 446 | 447 | export { stamp, emit, repeat, apply, bindEvent }; 448 | -------------------------------------------------------------------------------- /src/knobs.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Component for inspecting {@link WaferClient} elements 3 | * 4 | * @module Knobs 5 | */ 6 | 7 | import Wafer from "./wafer.js"; 8 | import { repeat } from "./dom.js"; 9 | 10 | // define an no-op html function to get syntax highlighting in template strings 11 | const html = String.raw; 12 | 13 | class Knobs extends Wafer { 14 | connectedCallback() { 15 | super.connectedCallback(); 16 | 17 | const root = /** @type {Element} **/ (this.getRootNode()); 18 | 19 | const el = root.querySelector(this.for); 20 | 21 | const componentProps = []; 22 | for (const [name, def] of Object.entries(el.constructor.props)) { 23 | componentProps.push({ 24 | name, 25 | ...def, 26 | }); 27 | } 28 | 29 | this.el = el; 30 | this.componentProps = componentProps; 31 | 32 | this.updateHTML(); 33 | } 34 | 35 | static get template() { 36 | return html` 37 | 170 |

171 |
KNOBS
172 |
173 | about 174 |
175 |

176 | Knobs lets you inspect and edit both the properties and attributes 177 | of a Wafer component live. The current HTML can also be shown. 178 |

179 |
180 |
181 |

182 |
183 | Properties and attributes 184 |
185 |
186 |
Properties
187 |
188 |
189 |
190 |
Attributes
191 |
192 |
193 |
194 |
195 |
196 | HTML 197 |

198 |       
199 | `; 200 | } 201 | 202 | static get props() { 203 | return { 204 | for: { 205 | type: String, 206 | targets: [ 207 | { 208 | selector: "$h1 > div > span", 209 | /** 210 | * 211 | * @type {import("./types").UseFn} 212 | */ 213 | use: (val) => `[${val}]`, 214 | text: true, 215 | }, 216 | ], 217 | }, 218 | el: { 219 | type: Object, 220 | }, 221 | html: { 222 | type: String, 223 | targets: [ 224 | { 225 | selector: "$#tag pre", 226 | text: true, 227 | }, 228 | ], 229 | }, 230 | componentProps: { 231 | type: Array, 232 | initial: [], 233 | targets: [ 234 | { 235 | selector: "$#props .knobs, #attrs .knobs", 236 | /** 237 | * @type {import("./types").DOMUpdateFn} 238 | */ 239 | dom: (el, props, self) => { 240 | const elWafer = /** @type {Wafer} */ (el); 241 | const selfWafer = /** @type {Wafer} */ (self); 242 | 243 | const isProps = 244 | elWafer.parentElement && elWafer.parentElement.id === "props"; 245 | 246 | if (selfWafer._firstUpdate) { 247 | const updatedFn = selfWafer.el.updated.bind(selfWafer.el); 248 | /** @type {Wafer}*/ (selfWafer.el).updated = (changed) => { 249 | updatedFn(changed); 250 | 251 | for (const name of changed.keys()) { 252 | const input = /** @type {HTMLInputElement} */ ( 253 | elWafer.querySelector(`#input[data-name=${name}]`) 254 | ); 255 | if (!input) continue; 256 | 257 | const prop = selfWafer.componentProps.find( 258 | (/** @type {{name:string}} **/ info) => info.name === name 259 | ); 260 | 261 | if (isProps) { 262 | if (prop.type === Boolean) { 263 | input.checked = selfWafer.el[name]; 264 | } else if ([Number, String].includes(prop.type)) { 265 | input.value = selfWafer.el[name]; 266 | } else { 267 | input.value = JSON.stringify( 268 | selfWafer.el[name], 269 | null, 270 | 2 271 | ); 272 | } 273 | } else { 274 | if (prop.type === Boolean) { 275 | input.checked = selfWafer.el.hasAttribute( 276 | prop.attributeName || name 277 | ); 278 | } else { 279 | input.value = selfWafer.el.getAttribute( 280 | prop.attributeName || name 281 | ); 282 | } 283 | } 284 | } 285 | 286 | selfWafer.updateHTML(); 287 | }; 288 | } 289 | 290 | return repeat({ 291 | container: elWafer, 292 | items: props, 293 | html: html` 294 |
295 | 300 |
301 | `, 302 | keyFn: (prop) => prop.name, 303 | targets: [ 304 | { 305 | selector: "$div.propname", 306 | text: true, 307 | use: (prop) => 308 | isProps ? prop.name : prop.attributeName || prop.name, 309 | }, 310 | { 311 | selector: "$div.propinfo", 312 | text: true, 313 | use: (prop) => ` 314 | ${prop.type.name}, reflect: ${ 315 | prop.reflect ? "true" : "false" 316 | }, initial: ${JSON.stringify(prop.initial)} 317 | `, 318 | }, 319 | { 320 | selector: "$#input", 321 | property: "value", 322 | use: (prop) => { 323 | if (prop.type !== Boolean) { 324 | if (isProps) { 325 | if ([Number, String].includes(prop.type)) { 326 | return selfWafer.el[prop.name]; 327 | } else { 328 | return JSON.stringify( 329 | selfWafer.el[prop.name], 330 | null, 331 | 2 332 | ); 333 | } 334 | } else { 335 | if (prop.reflect) { 336 | return selfWafer.el.getAttribute( 337 | prop.attributeName || prop.name 338 | ); 339 | } 340 | } 341 | } 342 | }, 343 | }, 344 | { 345 | selector: "$#input", 346 | attribute: "checked", 347 | use: (prop) => { 348 | if (prop.type === Boolean) { 349 | if (isProps) { 350 | return selfWafer.el[prop.name] ? "" : null; 351 | } else { 352 | if (prop.reflect) { 353 | return selfWafer.el.hasAttribute( 354 | prop.attributeName || prop.name 355 | ) 356 | ? "" 357 | : null; 358 | } 359 | } 360 | } 361 | }, 362 | }, 363 | { 364 | selector: "$#input", 365 | attribute: "data-name", 366 | use: (prop) => prop.name, 367 | }, 368 | { 369 | selector: "$#input", 370 | attribute: "type", 371 | use: (prop) => { 372 | switch (prop.type) { 373 | case Number: 374 | if (isProps) { 375 | return "number"; 376 | } 377 | return "text"; 378 | case Boolean: 379 | return "checkbox"; 380 | case String: 381 | return "text"; 382 | default: 383 | return; 384 | } 385 | }, 386 | }, 387 | ], 388 | events: { 389 | "#input": { 390 | change: (event) => { 391 | const input = /** @type {HTMLInputElement} */ ( 392 | event.target 393 | ); 394 | const name = input.dataset.name || ""; 395 | const prop = selfWafer.componentProps.find( 396 | (/** @type {{name: string}}*/ a) => a.name === name 397 | ); 398 | 399 | if (prop) { 400 | if (prop.type === Boolean) { 401 | if (isProps) { 402 | selfWafer.el[name] = input.checked; 403 | } else { 404 | if (input.checked) { 405 | selfWafer.el.setAttribute( 406 | prop.attributeName || name, 407 | "" 408 | ); 409 | } else { 410 | selfWafer.el.removeAttribute( 411 | prop.attributeName || name 412 | ); 413 | } 414 | } 415 | } else { 416 | const value = input.value.trim(); 417 | 418 | if (isProps) { 419 | if (prop.type === Number) { 420 | selfWafer.el[name] = Number(value); 421 | } else if (prop.type === String) { 422 | selfWafer.el[name] = value; 423 | } else { 424 | selfWafer.el[name] = JSON.parse(value); 425 | } 426 | } else { 427 | const value = input.value.trim(); 428 | selfWafer.el.setAttribute( 429 | prop.attributeName || name, 430 | value 431 | ); 432 | } 433 | } 434 | } 435 | }, 436 | }, 437 | }, 438 | init: (el, prop) => { 439 | if ([Boolean, Number, String].includes(prop.type)) { 440 | const input = document.createElement("input"); 441 | input.id = "input"; 442 | const container = el.querySelector("#input-container"); 443 | if (container) { 444 | container.append(input); 445 | } 446 | } else { 447 | const input = document.createElement("textarea"); 448 | input.id = "input"; 449 | const container = el.querySelector("#input-container"); 450 | if (container) { 451 | container.append(input); 452 | } 453 | } 454 | }, 455 | }); 456 | }, 457 | }, 458 | ], 459 | }, 460 | }; 461 | } 462 | 463 | updateHTML() { 464 | this.html = this.el.outerHTML; 465 | } 466 | } 467 | 468 | customElements.define("wafer-knobs", Knobs); 469 | -------------------------------------------------------------------------------- /src/server/dom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Helpers for rendering in {@link WaferServer} 3 | * 4 | * @module DOMServer 5 | */ 6 | 7 | import { ServerElement, parse } from "./element.js"; 8 | import { updateTargets } from "../common.js"; 9 | 10 | /** 11 | * Utility to render a series of items as Elements into a container 12 | * 13 | * @param {Object} opts - Repeat options 14 | * @param {ServerElement} opts.container - Container into which to render repeated items 15 | * @param {any[]} opts.items - Array of items which are used to create/update elements 16 | * @param {string} opts.html - HTML template which will be used to create elements 17 | * @param {((value: any, index: number) => string)} opts.keyFn - Key function which is used to create unique id for each item/element 18 | * @param {import("../types").Target[]} [opts.targets] - How to update an element when an item changes 19 | * @param { ((el: ServerElement, item?: any, index?: number) => void) | null} [opts.init] - function to apply when an element is first created 20 | * @param {import("../types").Registry} [opts.registry] - Object of tag names to Wafer component definitions 21 | * @returns 22 | */ 23 | const repeat = async ({ 24 | container, 25 | items, 26 | html, 27 | keyFn, 28 | targets = [], 29 | init = null, 30 | registry = {}, 31 | }) => { 32 | /** 33 | * Empty the container 34 | */ 35 | container.innerHTML = ""; 36 | 37 | for (const [index, item] of items.entries()) { 38 | const key = "" + keyFn(item, index); 39 | 40 | /** 41 | * Create a {@link ServerElement} for each item by rendering from the 42 | * template and extracting the first {@link ServerElement} instance 43 | */ 44 | const el = /** @type {ServerElement[]} */ ( 45 | (await parse(html.trim(), registry)).childNodes 46 | ).filter((node) => node instanceof ServerElement)[0]; 47 | 48 | /** 49 | * Run the initialisation function if passed 50 | */ 51 | if (init) { 52 | init(el, item, index); 53 | } 54 | 55 | /** 56 | * Update the element with desired target updates, and add the wafer-key 57 | * attribute for use later. The result is `await`ed so that asynchronous 58 | * updates can complete before response is sent 59 | */ 60 | await updateTargets(apply, el, { 61 | value: item, 62 | targets: targets.concat({ 63 | selector: "self", 64 | use: () => key, 65 | attribute: "wafer-key", 66 | }), 67 | }); 68 | 69 | /** 70 | * Add element into container 71 | */ 72 | container.appendChild(el); 73 | } 74 | }; 75 | 76 | /** 77 | * Take a target DOM element and a selector to apply to that element 78 | * and run a function on every match. A promise is returned so that 79 | * the result can be `await`ed - important in the server context 80 | * where we want all asynchronous updates can complete before the 81 | * response is sent 82 | * 83 | * @param {Element|import("./wafer").default} el 84 | * @param {string} selector 85 | * @param {(el: Element|import("./element").ServerElement) => void} func 86 | * 87 | * @returns {Promise} 88 | */ 89 | const apply = (el, selector, func) => { 90 | /** 91 | * An array containing the results of each update 92 | */ 93 | const promises = []; 94 | 95 | if (selector === "self") { 96 | /** 97 | * Special case - 'self' applies function to DOM element itself 98 | */ 99 | promises.push(func(el)); 100 | } else { 101 | /** 102 | * Special case - the selector should be applied to the 103 | * DOM node's shadow root 104 | */ 105 | const shadow = selector[0] === "$"; 106 | 107 | /** 108 | * Special case - the selector should be applied to the parent 'document' 109 | * 'document' in a server context is defined as the root element, whatever 110 | * element that is - i.e. no necessarily the document, which is the case 111 | * for the client implementation 112 | */ 113 | const doc = selector[0] === "@"; 114 | 115 | /** 116 | * The element on which the selector will be run 117 | */ 118 | let target; 119 | 120 | if (doc) { 121 | /** 122 | * If we are applying the selector to the 'document', then ascent the 123 | * hierarchy until we reach the top 124 | */ 125 | target = el; 126 | while (target.parentNode) { 127 | target = target.parentNode; 128 | } 129 | } else { 130 | /** 131 | * Use the shadow root as the target in the special case 132 | */ 133 | target = shadow ? el.shadowRoot : el; 134 | } 135 | 136 | if (target) { 137 | /** 138 | * Remove any leading character used for the special cases above 139 | */ 140 | const targetSelector = shadow || doc ? selector.substr(1) : selector; 141 | 142 | /** 143 | * Apply function to all matches, store in results array 144 | */ 145 | for (const el of target.querySelectorAll(targetSelector)) { 146 | promises.push(func(el)); 147 | } 148 | } 149 | } 150 | 151 | /** 152 | * Return a promise that will resolve when all updates have resolved 153 | */ 154 | return Promise.all(promises); 155 | }; 156 | 157 | export { repeat, apply }; 158 | -------------------------------------------------------------------------------- /src/server/element.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Server implementation of DOM Element 3 | * 4 | * @module ServerElement 5 | */ 6 | 7 | import { createRequire } from "module"; 8 | const require = createRequire(import.meta.url); 9 | 10 | const { parse, HTMLElement } = require("node-html-parser"); 11 | 12 | /** 13 | * Escape values for attributes and text content 14 | * @param {any} unsafe 15 | * @returns {any} 16 | */ 17 | const escape = (unsafe) => { 18 | if (typeof unsafe !== "string") { 19 | return unsafe; 20 | } 21 | 22 | return unsafe 23 | .replace(/&/g, "&") 24 | .replace(//g, ">") 26 | .replace(/"/g, """) 27 | .replace(/'/g, "'"); 28 | }; 29 | 30 | /** 31 | * Provides an interface compatible with the browser `HTMLElement` - at least 32 | * in so much as it provides the capabilities Wafer requires. The actual 33 | * implementation is proxied to node-html-parser implementation of 34 | * `HTMLElement`. It should be relatively straight forward to swap this 35 | * implementation out for an alternative one if required. 36 | */ 37 | class ServerElement { 38 | /** 39 | * @param {string} tagName - the tag name of this element 40 | * @param {Object.} attrs - Object of initial attribute name/value pairs 41 | */ 42 | constructor(tagName, attrs = {}) { 43 | /** 44 | * Element containing reference to underlying `HTMLElement` implementation. 45 | * The element is proxied in this class to: 46 | * 47 | * - avoid clashes with `HTMLElement` implementation details 48 | * 49 | * - make it easy to swap out for alternative implementations 50 | */ 51 | this._element = /** @type { ServerElement} **/ ( 52 | /** @type { unknown } **/ 53 | (new HTMLElement(tagName, {}, "", null)) 54 | ); 55 | this._element.setAttributes(attrs); 56 | } 57 | 58 | /** 59 | * Update the underlying element to new element 60 | * @param {ServerElement} element 61 | */ 62 | setElement(element) { 63 | this._element = element; 64 | } 65 | 66 | /** 67 | * Retrieve the underlying element 68 | * 69 | * @returns {ServerElement} 70 | */ 71 | getElement() { 72 | return this._element; 73 | } 74 | 75 | /** 76 | * Does this element have an attribute set with named `key` 77 | * 78 | * @param {string} key - Attribute name 79 | * @returns {boolean} 80 | */ 81 | hasAttribute(key) { 82 | return this._element.hasAttribute(key); 83 | } 84 | 85 | /** 86 | * Return the current value of names attribute 87 | * 88 | * @param {string} key - Attribute name 89 | * @returns {string} 90 | */ 91 | getAttribute(key) { 92 | return this._element.getAttribute(key); 93 | } 94 | 95 | /** 96 | * Sets the value for the name attribute 97 | * 98 | * @param {string} key - Attribute name 99 | * @param {string} value - Value to set 100 | * @returns 101 | */ 102 | setAttribute(key, value) { 103 | this._element.setAttribute(key, escape(value)); 104 | } 105 | 106 | /** 107 | * Sets all attributes at once 108 | * 109 | * @param {Object.} attrs - Value to set 110 | * @returns 111 | */ 112 | setAttributes(attrs) { 113 | /** 114 | * @type {Object.} 115 | */ 116 | const escaped = {}; 117 | 118 | for (const [name, value] of Object.entries(attrs)) { 119 | escaped[name] = escape(value); 120 | } 121 | 122 | this._element.setAttributes(escaped); 123 | } 124 | 125 | /** 126 | * Remove names attribute 127 | * 128 | * @param {string} key - Attribute name 129 | */ 130 | removeAttribute(key) { 131 | this._element.removeAttribute(key); 132 | } 133 | 134 | /** 135 | * Append a child to the underlying element implementation 136 | * 137 | * @param {ServerElement} el - Element to append 138 | * @returns {void} 139 | */ 140 | appendChild(el) { 141 | return this._element.appendChild(el.getElement()); 142 | } 143 | 144 | /** 145 | * Query underlying element for single match 146 | * 147 | * @param {string} selector - CSS3 selector 148 | * @returns {ServerElement} 149 | */ 150 | querySelector(selector) { 151 | return this._element.querySelector(selector); 152 | } 153 | 154 | /** 155 | * Query underlying element for all matches 156 | * 157 | * @param {string} selector 158 | * @returns {ServerElement[]} 159 | */ 160 | querySelectorAll(selector) { 161 | return this._element.querySelectorAll(selector); 162 | } 163 | 164 | /** 165 | * The element's tag name 166 | * 167 | * @returns {string} 168 | */ 169 | get tagName() { 170 | return this._element.tagName; 171 | } 172 | 173 | /** 174 | * Object containing the element's attributes 175 | * 176 | * @returns {Object.} 177 | */ 178 | get attributes() { 179 | return this._element.attributes; 180 | } 181 | 182 | /** 183 | * The first child of this element 184 | * 185 | * @returns {ServerElement} 186 | */ 187 | get firstChild() { 188 | return this._element.firstChild; 189 | } 190 | 191 | /** 192 | * Set the child nodes of this element 193 | * 194 | * @returns 195 | */ 196 | set childNodes(nodes) { 197 | this._element.childNodes = nodes; 198 | } 199 | 200 | /** 201 | * The child nodes of this element 202 | * 203 | * @returns {ServerElement[]} 204 | */ 205 | get childNodes() { 206 | return this._element.childNodes; 207 | } 208 | 209 | /** 210 | * The parent of this element 211 | * 212 | * @returns {ServerElement} 213 | */ 214 | set parentNode(node) { 215 | this._element.parentNode = node; 216 | } 217 | 218 | /** 219 | * The parent of this element 220 | * 221 | * @returns {ServerElement} 222 | */ 223 | get parentNode() { 224 | return this._element.parentNode; 225 | } 226 | 227 | /** 228 | * Element's node type 229 | * 230 | * @returns {import('node-html-parser').NodeType} 231 | */ 232 | get nodeType() { 233 | return this._element.nodeType; 234 | } 235 | 236 | /** 237 | * The tag name of the original element. This is used internally by 238 | * node-html-parser and needs to br proxied here so a ServerElement can 239 | * be used everywhere node-html-parser's HTMLElement is 240 | * 241 | * @returns {string} 242 | */ 243 | get rawTagName() { 244 | return this._element.rawTagName; 245 | } 246 | 247 | /** 248 | * Set the text content of underlying element 249 | * @param {string} content 250 | */ 251 | set textContent(content) { 252 | this._element.textContent = escape(content); 253 | } 254 | 255 | /** 256 | * Get the text content of underlying element 257 | * 258 | * @returns {string} 259 | */ 260 | get textContent() { 261 | return this._element.textContent; 262 | } 263 | 264 | /** 265 | * Set the innerHTML of underlying element 266 | * 267 | * @param {string} html 268 | */ 269 | set innerHTML(html) { 270 | this._element.innerHTML = html; 271 | } 272 | 273 | /** 274 | * Render this element to an HTML string 275 | * 276 | * @returns {string} 277 | */ 278 | toString() { 279 | return this._element.toString(); 280 | } 281 | 282 | /** 283 | * Promise resolves when all pending updates have been processed 284 | * 285 | * @param {import("../types").Registry} registry - registry of tag names to Wafer component definitions 286 | * 287 | * @returns {Promise} 288 | */ 289 | async updateDone(registry) { 290 | for (const child of this.childNodes) { 291 | await child.updateDone(registry); 292 | } 293 | 294 | if (registry[this.tagName && this.tagName.toLowerCase()]) { 295 | await this.updateDone(registry); 296 | } 297 | } 298 | } 299 | 300 | /** 301 | * Convert an instance of node-html-parser's HTMLElement into a ServerElement 302 | * instance 303 | * 304 | * @param {ServerElement} el - the HTMLElement instance 305 | * @param {import("../types").Registry} registry - registry of tag names to Wafer component definitions 306 | * 307 | * @returns {Promise} 308 | */ 309 | const convert = async (el, registry = {}) => { 310 | /** 311 | * Convert any child nodes of `el` into `ServerElement` instances too 312 | */ 313 | const children = []; 314 | 315 | for (const node of el.childNodes) { 316 | children.push(await convert(node, registry)); 317 | } 318 | 319 | /** 320 | * Swap current children with their `ServerElement` counterparts 321 | */ 322 | el.childNodes = children; 323 | 324 | /** 325 | * Lower case tag name of the element 326 | */ 327 | const tagName = el.tagName && el.tagName.toLowerCase(); 328 | 329 | /** 330 | * The new ServerElement 331 | */ 332 | let newEl; 333 | 334 | if (Object.keys(registry).includes(tagName)) { 335 | /** 336 | * If this element has a tag name of a Wafer component, create Wafer instance 337 | */ 338 | newEl = new registry[tagName].def({ tagName }, registry); 339 | 340 | /** 341 | * Set as underlying element 342 | */ 343 | newEl.setElement(el); 344 | 345 | /** 346 | * Await construction 347 | */ 348 | await newEl.construct(); 349 | 350 | /** 351 | * Take attributes of element and copy them over to new ServerElement 352 | */ 353 | for (const [attribute, value] of Object.entries(el.attributes)) { 354 | newEl.setAttribute(attribute, value); 355 | } 356 | 357 | /** 358 | * Simulate connection to the 'DOM' 359 | */ 360 | await newEl.connectedCallback(); 361 | } else { 362 | /** 363 | * Not a Wafer component, so just creating a ServerElement requires no 364 | * special setup 365 | */ 366 | newEl = new ServerElement(tagName); 367 | newEl.setElement(el); 368 | } 369 | 370 | return newEl; 371 | }; 372 | 373 | /** 374 | * Render an HTML string as a tree of `ServerElement`s 375 | * 376 | * @param {string} htmlString - string to parse 377 | * @param {import("../types").Registry} registry - registry of tag names to Wafer component definitions 378 | * 379 | * @returns {Promise} 380 | */ 381 | const waferParse = async (htmlString, registry = {}) => { 382 | /** 383 | * Parse string into a tree of `HTMLElement`s 384 | */ 385 | const tree = /** @type {ServerElement} **/ ( 386 | /** @type {unknown} **/ (parse(htmlString)) 387 | ); 388 | 389 | /** 390 | * Convert `HTMLElement` tree into a `ServerElement` tree 391 | */ 392 | return convert(tree, registry); 393 | }; 394 | 395 | export { ServerElement, waferParse as parse }; 396 | -------------------------------------------------------------------------------- /src/server/wafer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Server implementation of Wafer 3 | * 4 | * @module WaferServer 5 | */ 6 | 7 | import { WaferMixin } from "../wafer-mixin.js"; 8 | import { ServerElement, parse } from "./element.js"; 9 | import { updateTargets } from "../common.js"; 10 | import { apply } from "./dom.js"; 11 | 12 | export default class WaferServer extends WaferMixin(ServerElement) { 13 | /** 14 | * 15 | * @param {import("../types").ServerOpts} opts 16 | * @param {import("../types").Registry} registry 17 | * Registry of components and their Wafer definitions that should be upgraded if 18 | * found in the component template 19 | */ 20 | constructor({ shadow = "open", tagName, attrs = {} }, registry = {}) { 21 | /** 22 | * Initialise parent {@link ServerElement} with the tag name and any attributes 23 | * to set on initialisation 24 | */ 25 | super(tagName, attrs); 26 | 27 | /** 28 | * Type of shadow root (open/closed) or falsey for none 29 | * 30 | * @type {import("../types").ShadowOpts} 31 | */ 32 | this._shadow = shadow; 33 | 34 | /** 35 | * Tag name that this component will be rendered within 36 | * 37 | * @type {string} 38 | */ 39 | this._tagName = tagName; 40 | 41 | /** 42 | * @type {import('../types').Registry} 43 | */ 44 | this._registry = registry; 45 | 46 | /** 47 | * In the server context only remove unreflected attributes 48 | * if there is no intention to rehydrate on the client (since attribute 49 | * values are the source for rehydrating) 50 | */ 51 | super._removeUnreflectedAttributes = !!( 52 | this._registry && 53 | this._registry[tagName] && 54 | this._registry[tagName].serverOnly 55 | ); 56 | 57 | /** 58 | * Holds the server implementation of a shadow root if required 59 | * 60 | * @type {ServerElement | null} 61 | */ 62 | this.shadowRoot = null; 63 | } 64 | 65 | /** 66 | * Called to initialise the component 67 | */ 68 | async construct() { 69 | if (this._shadow) { 70 | /** 71 | * Attach a shadow root 72 | */ 73 | const shadowRoot = this.attachShadow({ mode: this._shadow }); 74 | 75 | /** 76 | * Render template as a series of {@link ServerElement}s 77 | */ 78 | const template = await parse( 79 | /** @type {typeof WaferServer} */ (this.constructor).template, 80 | this._registry 81 | ); 82 | 83 | /** 84 | * Append template contents into shadow root 85 | */ 86 | for (const el of template.childNodes) { 87 | shadowRoot.appendChild(el); 88 | } 89 | } 90 | 91 | this.initialiseProps(); 92 | } 93 | 94 | /** 95 | * Create and attach a shadow root, which on the server is an instance 96 | * of a template {@link ServerElement} 97 | * 98 | * @param {Object} opts 99 | * @param {"open"|"closed"} opts.mode 100 | * @returns {ServerElement} 101 | */ 102 | attachShadow({ mode = "open" }) { 103 | this.shadowRoot = new ServerElement("template", { 104 | shadowroot: mode, 105 | }); 106 | this.appendChild(this.shadowRoot); 107 | 108 | return this.shadowRoot; 109 | } 110 | 111 | /** 112 | * Called when an element is 'attached' to the DOM on the server. This is 113 | * called explicitly on the server before rendering takes place 114 | * 115 | * @returns {Promise} 116 | */ 117 | async connectedCallback() { 118 | if (!this._connectedOnce) { 119 | /** 120 | * Initialise the component 121 | */ 122 | if (!this.shadowRoot) { 123 | /** 124 | * The Light DOM is being used so stamp out the template 125 | * and append to element content as a series of {@link ServerElement}s 126 | */ 127 | 128 | const container = await parse( 129 | /** @type {typeof WaferServer} */ (this.constructor).template, 130 | this._registry 131 | ); 132 | 133 | /** 134 | * Append template contents into shadow root 135 | */ 136 | for (const el of container.childNodes) { 137 | this.appendChild(el); 138 | } 139 | } 140 | 141 | if (!this._removeUnreflectedAttributes) { 142 | /** 143 | * Indicate to the client that this component should be 144 | * rehydrated on client 145 | */ 146 | this.setAttribute("wafer-ssr", ""); 147 | } 148 | 149 | this.setupPropValues(); 150 | } 151 | 152 | /** 153 | * Wait for all initial rendering to complete 154 | */ 155 | await this.updateDone(); 156 | 157 | /** 158 | * Indicate the initial set up has been completed 159 | */ 160 | super._connectedOnce = true; 161 | } 162 | 163 | /** 164 | * Returns true if the component has been rendered on the server 165 | * and has not yet been hydrated. Only true in client context 166 | * 167 | * @type {boolean} 168 | */ 169 | get _needsRehydrating() { 170 | return false; 171 | } 172 | 173 | /** 174 | * Update the targets for property 175 | * 176 | * @param {string} name 177 | */ 178 | updateTargets(name) { 179 | const value = this._props[name]; 180 | const { targets = [] } = this.props[name]; 181 | 182 | return updateTargets(apply, this, { value, targets }); 183 | } 184 | 185 | /** 186 | * Proxy for `setAttribute` in {@link ServerElement} ensuring 187 | * `attributeChangedCallback` is called on this component 188 | * 189 | * @param {string} key 190 | * @param {string} value 191 | */ 192 | setAttribute(key, value) { 193 | const oldValue = this.getAttribute(key); 194 | super.setAttribute(key, value); 195 | 196 | if ( 197 | /** @type {typeof WaferServer} */ ( 198 | this.constructor 199 | ).observedAttributes.includes(key) 200 | ) { 201 | this.attributeChangedCallback(key, oldValue, value); 202 | } 203 | } 204 | 205 | /** 206 | * Proxy for `getAttribute` in {@link ServerElement} ensuring 207 | * `attributeChangedCallback` is called on this component 208 | * 209 | * @param {string} key 210 | */ 211 | removeAttribute(key) { 212 | const oldValue = this.getAttribute(key); 213 | super.removeAttribute(key); 214 | 215 | if ( 216 | /** @type {typeof WaferServer} */ ( 217 | this.constructor 218 | ).observedAttributes.includes(key) 219 | ) { 220 | this.attributeChangedCallback(key, oldValue, null); 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Collection of type definitions used throughout Wafer 3 | * 4 | * @module Types 5 | */ 6 | 7 | /** 8 | * @typedef { import("node-html-parser").Node } ParserNode 9 | * @typedef { import("node-html-parser").HTMLElement } ParserHTMLElement 10 | * @typedef { import("./server/element").ServerElement } ServerElement 11 | * @typedef { import("./server/wafer").default } WaferServer 12 | */ 13 | 14 | /** 15 | * @typedef Prop - Property declaration 16 | * @prop {StringConstructor | NumberConstructor | BooleanConstructor | ObjectConstructor | ArrayConstructor} type - Property type 17 | * @prop {any} [initial] - Initial value for property 18 | * @prop {boolean} [reflect=false] - Should the property be reflected to an attribute with the same name 19 | * @prop {Target[]} [targets=[]] - A list of targets that should be updated when the property value changes 20 | * @prop {string[]} [triggers=[]] - A list of other properties whose targets should be updated when this property changes 21 | * @prop {string} [attributeName] - The name of the attribute this property can be initialised from / reflected to 22 | */ 23 | 24 | /** 25 | * @typedef Target 26 | * Target declaration defining how to update elements matching `selector` 27 | * @prop {string | ((value: any, el: ServerElement | Element) => string)} selector CSS3 selector 28 | * @prop {string} [attribute] - which attribute on matches to update with the property value 29 | * @prop {string} [property] - which property on matches to update with the property value 30 | * @prop {boolean} [text=false] - should the textContent of matches be set to the property value 31 | * @prop {DOMUpdateFn} [dom] - a function that will be called when the property value changes - usually used for general DOM updates 32 | * @prop {UseFn} [use] - a function that returns the value that should be used in updates (defaults to the property value itself) 33 | */ 34 | 35 | /** 36 | * @callback DOMUpdateFn - Function to run for a {@link ./Target} when a property value changes 37 | * @param {Element | ServerElement} target - The target element itself 38 | * @param {any} value - The property value (or the value returned from `use` if defined) 39 | * @param {Element | ServerElement} el - Reference to the component 40 | * @returns {Promise | void} - In the server context only a returned promise will be `await`ed 41 | */ 42 | 43 | /** 44 | * @callback UseFn - Function to run before updating {@link ./Target}s, returning the value to use in updates 45 | * @param {any} value - The new property value 46 | * @param {HTMLElement | {}} el - Reference to the component 47 | * @param {HTMLElement | {}} [targetEl] - Reference to the target element 48 | * @returns {any} - The value to use in updates, in place of the new property value 49 | */ 50 | 51 | /** 52 | * @typedef ShadowOpts - If an what type of Shadow Root to attach 53 | * @type {'open' | 'closed' | false} - False indicates no Shadow Root 54 | */ 55 | 56 | /** 57 | * @typedef ClientOpts - Possible arguments passed to client constructor 58 | * @prop {ShadowOpts} [shadow] - What type of Shadow Root to attach 59 | */ 60 | 61 | /** 62 | * @typedef ServerOpts - Possible arguments passed to server constructor 63 | * @prop {ShadowOpts} [shadow] - What type of Shadow Root to attach 64 | * @prop {string} tagName - What tag should this component be defined with 65 | * @prop {Object.} [attrs] - Object of initial attribute name/values 66 | */ 67 | 68 | /** 69 | * @typedef TargetEvent - Explicit event handler 70 | * @prop {(this: Element, ev: Event) => any} fn - Function to call 71 | * @prop {HTMLElement} [target] - Element to bind the function to 72 | * @prop {boolean | AddEventListenerOptions | undefined} [opts] - Event listener options 73 | */ 74 | 75 | /** 76 | * @typedef TargetEvents - Object of event names to event handlers 77 | * @type {Object. any) | TargetEvent>} - explicit {@link TargetEvent} definition, or a function that will be automatically bound to the component 78 | */ 79 | 80 | /** 81 | * @typedef RegistryEntry 82 | * @prop {new (...args: any[]) => WaferServer} def - The component definition 83 | * @prop {boolean} [serverOnly] - If this component is intended only to be rendered on server (i.e. not upgraded or rehydrated on client) 84 | */ 85 | 86 | /** 87 | * @typedef Registry - Object of tag names to Wafer component definitions 88 | * @type {Object.} 89 | */ 90 | 91 | export {}; 92 | -------------------------------------------------------------------------------- /src/wafer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Client implementation of Wafer 3 | * 4 | * @module WaferClient 5 | */ 6 | 7 | import { WaferMixin } from "./wafer-mixin.js"; 8 | import { stamp, apply, bindEvent } from "./dom.js"; 9 | import { updateTargets } from "./common.js"; 10 | 11 | let isSSR = false; 12 | 13 | export default class WaferClient extends WaferMixin(HTMLElement) { 14 | /** 15 | * Does the current client context support Declarative Shadow DOM 16 | * 17 | * @type {boolean} 18 | */ 19 | static get supportsDSD() { 20 | // eslint-disable-next-line no-prototype-builtins 21 | return HTMLTemplateElement.prototype.hasOwnProperty("shadowRoot"); 22 | } 23 | 24 | /** 25 | * @param {boolean} ssr 26 | */ 27 | static set isSSR(ssr) { 28 | isSSR = ssr; 29 | } 30 | 31 | /** 32 | * Object keyed by a CSS Selector to an object mapping DOM event names 33 | * to the function that should be called when the event occurs. The function 34 | * will be automatically bound to the current element. Alternatively 35 | * the event name can map to a {@link TargetEvent} to enable binding to 36 | * different targets and/or use different event options 37 | * 38 | * 39 | * @type {Object.} 40 | */ 41 | get events() { 42 | return {}; 43 | } 44 | 45 | /** 46 | * 47 | * @param {import("./types").ClientOpts} opts 48 | * 49 | */ 50 | constructor({ shadow = "open" } = {}) { 51 | super(); 52 | this.init(shadow); 53 | } 54 | 55 | /** 56 | * 57 | * @param {import("./types").ShadowOpts} shadow 58 | */ 59 | init(shadow) { 60 | /** 61 | * If we want to use the ShadowDOM (else use LightDOM) 62 | */ 63 | if (shadow) { 64 | /** 65 | * There will be no Shadow root yet, unless the component was rendered 66 | * on the server, and the browser supports DSD 67 | */ 68 | if (!this.shadowRoot) { 69 | if (this._needsRehydrating) { 70 | /** 71 | * The component was rendered on the server and hasn't been 72 | * rehydrated yet 73 | **/ 74 | if ( 75 | !(/** @type {typeof WaferClient} */ (this.constructor).supportsDSD) 76 | ) { 77 | /** 78 | * There's no DSD support in the browser so move any shadowroot 79 | * template element into the Shadow DOM 80 | */ 81 | const template = /** @type {HTMLTemplateElement} */ ( 82 | this.querySelector("template[shadowroot]") 83 | ); 84 | if (template) { 85 | const shadowRoot = this.attachShadow({ 86 | mode: shadow, 87 | }); 88 | shadowRoot.appendChild(template.content); 89 | template.remove(); 90 | } 91 | } 92 | } else { 93 | /** 94 | * The component wasn't rendered on the server, so stamp out template 95 | * and append to Shadow DOM 96 | */ 97 | this.attachShadow({ mode: shadow }).appendChild( 98 | stamp(/** @type {typeof WaferClient} */ (this.constructor).template) 99 | ); 100 | } 101 | } 102 | } 103 | 104 | this.initialiseProps(); 105 | } 106 | 107 | /** 108 | * Called when an element is attached to the DOM 109 | */ 110 | connectedCallback() { 111 | if (!this._connectedOnce) { 112 | /** 113 | * If the component is being rendered on the server, but not 114 | * using WaferServer then unreflected attributes should not be removed 115 | */ 116 | super._removeUnreflectedAttributes = 117 | !this.hasAttribute("x-ssr") || !isSSR; 118 | 119 | if (!this.hasAttribute("x-ssr") && isSSR) { 120 | this.setAttribute("wafer-ssr", ""); 121 | } 122 | 123 | /** 124 | * Initialise the component 125 | */ 126 | if (!this.shadowRoot && !this._needsRehydrating) { 127 | /** 128 | * The Light DOM is being used and wasn't rendered on the server so 129 | * stamp out the template and append to element content 130 | */ 131 | this.appendChild( 132 | stamp(/** @type {typeof WaferClient} */ (this.constructor).template) 133 | ); 134 | } 135 | 136 | this.setupPropValues(); 137 | 138 | /** 139 | * Bind declared events 140 | */ 141 | for (const selector of Object.keys(this.events)) { 142 | const eventNames = Object.keys(this.events[selector]); 143 | 144 | for (const name of eventNames) { 145 | bindEvent(this, selector, name, this.events[selector][name]); 146 | } 147 | } 148 | } 149 | 150 | /** 151 | * Indicate the initial set up has been completed 152 | */ 153 | super._connectedOnce = true; 154 | } 155 | 156 | /** 157 | * Returns true if the component has been rendered on the server 158 | * and has not yet been rehydrated 159 | * 160 | * @type {boolean} 161 | */ 162 | get _needsRehydrating() { 163 | return this._firstUpdate && this.hasAttribute("wafer-ssr") && !isSSR; 164 | } 165 | 166 | /** 167 | * Update the targets for property 168 | * 169 | * @param {string} name 170 | */ 171 | updateTargets(name) { 172 | return updateTargets(apply, this, { 173 | value: this._props[name], 174 | targets: this.props[name].targets, 175 | }); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /test/client/changed-updated.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, expect } from "@open-wc/testing"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../src/wafer.js"; 5 | 6 | describe("Wafer update/changed calls", () => { 7 | it(`should call changed once, updated once when one prop changes (not connected to DOM)`, async () => { 8 | class Test extends Wafer { 9 | static props = { 10 | test: { 11 | type: String, 12 | reflect: true, 13 | initial: "foo", 14 | }, 15 | }; 16 | } 17 | customElements.define(`wafer-test-0`, Test); 18 | 19 | /** 20 | * @type {Test} 21 | */ 22 | const el = new Test(); 23 | 24 | const spyChanged = sinon.spy(el, "changed"); 25 | const spyUpdated = sinon.spy(el, "updated"); 26 | 27 | el.test = "bar"; 28 | 29 | await el.updateDone(); 30 | 31 | expect(el._connectedOnce).to.equal(false); 32 | 33 | expect(spyChanged).to.have.callCount(1); 34 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 35 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 36 | expect(spyChanged.args[0][0].size).equal(1); 37 | expect(spyChanged.args[0][0].has("test")).equal(true); 38 | expect(spyChanged.args[0][0].get("test")).equal(undefined); 39 | 40 | expect(spyUpdated).to.have.callCount(1); 41 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 42 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 43 | expect(spyUpdated.args[0][0].size).equal(1); 44 | expect(spyUpdated.args[0][0].has("test")).equal(true); 45 | expect(spyUpdated.args[0][0].get("test")).equal(undefined); 46 | 47 | expect(el).attr("test").to.equal("bar"); 48 | expect(el.test).to.deep.equal("bar"); 49 | }); 50 | 51 | it(`should call changed once, updated once when prop changes twice in same tick`, async () => { 52 | class Test extends Wafer { 53 | static props = { 54 | test: { 55 | type: String, 56 | reflect: true, 57 | initial: "foo", 58 | }, 59 | }; 60 | } 61 | customElements.define(`wafer-test-1`, Test); 62 | 63 | /** 64 | * @type {Test} 65 | */ 66 | const el = new Test(); 67 | document.body.append(el); 68 | await el.updateDone(); 69 | 70 | const spyChanged = sinon.spy(el, "changed"); 71 | const spyUpdated = sinon.spy(el, "updated"); 72 | 73 | el.test = "baz"; 74 | el.test = "bar"; 75 | 76 | await el.updateDone(); 77 | 78 | expect(spyChanged).to.have.callCount(1); 79 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 80 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 81 | expect(spyChanged.args[0][0].size).equal(1); 82 | expect(spyChanged.args[0][0].has("test")).equal(true); 83 | expect(spyChanged.args[0][0].get("test")).equal("foo"); 84 | 85 | expect(spyUpdated).to.have.callCount(1); 86 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 87 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 88 | expect(spyUpdated.args[0][0].size).equal(1); 89 | expect(spyUpdated.args[0][0].has("test")).equal(true); 90 | expect(spyUpdated.args[0][0].get("test")).equal("foo"); 91 | 92 | expect(el).attr("test").to.equal("bar"); 93 | expect(el.test).to.deep.equal("bar"); 94 | }); 95 | 96 | it(`should call changed twice, updated once when prop changes in response to changed prop`, async () => { 97 | class Test extends Wafer { 98 | static props = { 99 | test: { 100 | type: String, 101 | reflect: true, 102 | initial: "foo", 103 | }, 104 | }; 105 | 106 | changed(changed) { 107 | if (changed.has("test") && this.test === "baz") { 108 | this.test = "bar"; 109 | } 110 | } 111 | } 112 | customElements.define(`wafer-test-2`, Test); 113 | 114 | /** 115 | * @type {Test} 116 | */ 117 | const el = new Test(); 118 | document.body.append(el); 119 | await el.updateDone(); 120 | 121 | const spyChanged = sinon.spy(el, "changed"); 122 | const spyUpdated = sinon.spy(el, "updated"); 123 | 124 | el.test = "baz"; 125 | 126 | await el.updateDone(); 127 | 128 | expect(spyChanged).to.have.callCount(2); 129 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 130 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 131 | expect(spyChanged.args[0][0].size).equal(1); 132 | expect(spyChanged.args[0][0].has("test")).equal(true); 133 | expect(spyChanged.args[0][0].get("test")).equal("foo"); 134 | 135 | expect(spyChanged.args[1][0].size).equal(1); 136 | expect(spyChanged.args[1][0].has("test")).equal(true); 137 | expect(spyChanged.args[1][0].get("test")).equal("baz"); 138 | 139 | expect(spyUpdated).to.have.callCount(1); 140 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 141 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 142 | expect(spyUpdated.args[0][0].size).equal(1); 143 | expect(spyUpdated.args[0][0].has("test")).equal(true); 144 | expect(spyUpdated.args[0][0].get("test")).equal("foo"); 145 | 146 | expect(el).attr("test").to.equal("bar"); 147 | expect(el.test).to.deep.equal("bar"); 148 | }); 149 | 150 | it(`should not call changed/updated if prop changes result in original value`, async () => { 151 | class Test extends Wafer { 152 | static props = { 153 | test: { 154 | type: String, 155 | reflect: true, 156 | initial: "foo", 157 | }, 158 | }; 159 | } 160 | customElements.define(`wafer-test-3`, Test); 161 | 162 | /** 163 | * @type {Test} 164 | */ 165 | const el = new Test(); 166 | document.body.append(el); 167 | await el.updateDone(); 168 | 169 | const spyChanged = sinon.spy(el, "changed"); 170 | const spyUpdated = sinon.spy(el, "updated"); 171 | 172 | el.test = "foo"; 173 | el.test = "bar"; 174 | el.test = "baz"; 175 | el.test = "foo"; 176 | 177 | await el.updateDone(); 178 | 179 | expect(spyChanged).to.have.callCount(0); 180 | expect(spyUpdated).to.have.callCount(0); 181 | 182 | expect(el).attr("test").to.equal("foo"); 183 | expect(el.test).to.deep.equal("foo"); 184 | }); 185 | 186 | it(`should call changed/updated twice if prop changes in updated`, async () => { 187 | class Test extends Wafer { 188 | static props = { 189 | test: { 190 | type: String, 191 | reflect: true, 192 | initial: "foo", 193 | }, 194 | }; 195 | 196 | updated(updated) { 197 | if (updated.has("test") && this.test === "bar") { 198 | this.test = "baz"; 199 | } 200 | } 201 | } 202 | customElements.define(`wafer-test-4`, Test); 203 | 204 | /** 205 | * @type {Test} 206 | */ 207 | const el = new Test(); 208 | document.body.append(el); 209 | await el.updateDone(); 210 | 211 | const spyChanged = sinon.spy(el, "changed"); 212 | const spyUpdated = sinon.spy(el, "updated"); 213 | 214 | el.test = "bar"; 215 | 216 | await el.updateDone(); 217 | 218 | expect(spyChanged).to.have.callCount(2); 219 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 220 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 221 | expect(spyChanged.args[0][0].size).equal(1); 222 | expect(spyChanged.args[0][0].has("test")).equal(true); 223 | expect(spyChanged.args[0][0].get("test")).equal("foo"); 224 | 225 | expect(spyChanged.args[1][0].size).equal(1); 226 | expect(spyChanged.args[1][0].has("test")).equal(true); 227 | expect(spyChanged.args[1][0].get("test")).equal("bar"); 228 | 229 | expect(spyUpdated).to.have.callCount(2); 230 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 231 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 232 | expect(spyUpdated.args[0][0].size).equal(1); 233 | expect(spyUpdated.args[0][0].has("test")).equal(true); 234 | expect(spyUpdated.args[0][0].get("test")).equal("foo"); 235 | 236 | expect(spyUpdated.args[1][0].size).equal(1); 237 | expect(spyUpdated.args[1][0].has("test")).equal(true); 238 | expect(spyUpdated.args[1][0].get("test")).equal("bar"); 239 | 240 | expect(el).attr("test").to.equal("baz"); 241 | expect(el.test).to.deep.equal("baz"); 242 | }); 243 | 244 | it(`should not update if prop name returned from changed`, async () => { 245 | class Test extends Wafer { 246 | static props = { 247 | test: { 248 | type: String, 249 | reflect: true, 250 | initial: "foo", 251 | }, 252 | }; 253 | 254 | changed(changed) { 255 | return ["test"]; 256 | } 257 | } 258 | customElements.define(`wafer-test-5`, Test); 259 | 260 | /** 261 | * @type {Test} 262 | */ 263 | const el = new Test(); 264 | document.body.append(el); 265 | await el.updateDone(); 266 | 267 | const spyChanged = sinon.spy(el, "changed"); 268 | const spyUpdated = sinon.spy(el, "updated"); 269 | 270 | el.test = "bar"; 271 | 272 | await el.updateDone(); 273 | 274 | expect(spyChanged).to.have.callCount(1); 275 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 276 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 277 | expect(spyChanged.args[0][0].size).equal(1); 278 | expect(spyChanged.args[0][0].has("test")).equal(true); 279 | expect(spyChanged.args[0][0].get("test")).equal("foo"); 280 | 281 | expect(spyUpdated).to.have.callCount(0); 282 | 283 | expect(el).attr("test").to.equal("bar"); 284 | expect(el.test).to.deep.equal("bar"); 285 | }); 286 | 287 | it(`should trigger declared triggers on prop change`, async () => { 288 | class Test extends Wafer { 289 | static props = { 290 | test: { 291 | type: String, 292 | reflect: true, 293 | initial: "foo", 294 | triggers: ["test2"], 295 | }, 296 | test2: { 297 | type: String, 298 | initial: "baz", 299 | reflect: true, 300 | }, 301 | }; 302 | } 303 | customElements.define(`wafer-test-6`, Test); 304 | 305 | /** 306 | * @type {Test} 307 | */ 308 | const el = new Test(); 309 | document.body.append(el); 310 | await el.updateDone(); 311 | 312 | const spyChanged = sinon.spy(el, "changed"); 313 | const spyUpdated = sinon.spy(el, "updated"); 314 | const spyUpdateTargets = sinon.spy(el, "updateTargets"); 315 | 316 | el.test = "bar"; 317 | 318 | await el.updateDone(); 319 | 320 | expect(spyChanged).to.have.callCount(1); 321 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 322 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 323 | expect(spyChanged.args[0][0].size).equal(1); 324 | expect(spyChanged.args[0][0].has("test")).equal(true); 325 | expect(spyChanged.args[0][0].get("test")).equal("foo"); 326 | 327 | expect(spyUpdated).to.have.callCount(1); 328 | 329 | expect(spyUpdated.args[0][0].size).equal(1); 330 | expect(spyUpdated.args[0][0].has("test")).equal(true); 331 | expect(spyUpdated.args[0][0].get("test")).equal("foo"); 332 | 333 | expect(spyUpdateTargets).to.have.callCount(2); 334 | expect(spyUpdateTargets.args[0][0]).equal("test"); 335 | expect(spyUpdateTargets.args[1][0]).equal("test2"); 336 | 337 | expect(el).attr("test").to.equal("bar"); 338 | expect(el.test).to.equal("bar"); 339 | 340 | expect(el).attr("test2").to.equal("baz"); 341 | expect(el.test2).to.equal("baz"); 342 | }); 343 | 344 | it(`should do nothing on requestUpdate(null)`, async () => { 345 | class Test extends Wafer { 346 | static props = { 347 | test: { 348 | type: String, 349 | reflect: true, 350 | initial: "foo", 351 | }, 352 | }; 353 | } 354 | customElements.define(`wafer-test-7`, Test); 355 | 356 | /** 357 | * @type {Test} 358 | */ 359 | const el = new Test(); 360 | document.body.append(el); 361 | 362 | el.test = "bar"; 363 | await el.updateDone(); 364 | 365 | const spyChanged = sinon.spy(el, "changed"); 366 | const spyUpdated = sinon.spy(el, "updated"); 367 | const spyUpdateTargets = sinon.spy(el, "updateTargets"); 368 | 369 | await el.requestUpdate(null); 370 | 371 | expect(spyChanged).to.have.callCount(0); 372 | expect(spyUpdated).to.have.callCount(0); 373 | expect(spyUpdateTargets).to.have.callCount(0); 374 | 375 | expect(el).attr("test").to.equal("bar"); 376 | expect(el.test).to.equal("bar"); 377 | }); 378 | 379 | it(`should force update on default requestUpdate`, async () => { 380 | class Test extends Wafer { 381 | static props = { 382 | test: { 383 | type: String, 384 | reflect: true, 385 | initial: "foo", 386 | }, 387 | test2: { 388 | type: String, 389 | reflect: true, 390 | initial: "baz", 391 | }, 392 | }; 393 | } 394 | customElements.define(`wafer-test-8`, Test); 395 | 396 | /** 397 | * @type {Test} 398 | */ 399 | const el = new Test(); 400 | document.body.append(el); 401 | 402 | el.test = "bar"; 403 | await el.updateDone(); 404 | 405 | const spyChanged = sinon.spy(el, "changed"); 406 | const spyUpdated = sinon.spy(el, "updated"); 407 | const spyUpdateTargets = sinon.spy(el, "updateTargets"); 408 | 409 | await el.requestUpdate([]); 410 | await el.updateDone(); 411 | 412 | expect(spyChanged).to.have.callCount(1); 413 | expect(spyChanged.args[0][0].size).equal(2); 414 | expect(spyChanged.args[0][0].has("test")).equal(true); 415 | expect(spyChanged.args[0][0].get("test")).equal("bar"); 416 | expect(spyChanged.args[0][0].has("test2")).equal(true); 417 | expect(spyChanged.args[0][0].get("test2")).equal("baz"); 418 | 419 | expect(spyUpdated).to.have.callCount(1); 420 | expect(spyUpdated.args[0][0].size).equal(2); 421 | expect(spyUpdated.args[0][0].has("test")).equal(true); 422 | expect(spyUpdated.args[0][0].get("test")).equal("bar"); 423 | expect(spyUpdated.args[0][0].has("test2")).equal(true); 424 | expect(spyUpdated.args[0][0].get("test2")).equal("baz"); 425 | 426 | expect(spyUpdateTargets).to.have.callCount(2); 427 | expect(spyUpdateTargets.args[0][0]).equal("test"); 428 | expect(spyUpdateTargets.args[1][0]).equal("test2"); 429 | 430 | expect(el).attr("test").to.equal("bar"); 431 | expect(el.test).to.equal("bar"); 432 | 433 | expect(el).attr("test2").to.equal("baz"); 434 | expect(el.test2).to.equal("baz"); 435 | }); 436 | 437 | it(`should force update with passed prop only on requestUpdate`, async () => { 438 | class Test extends Wafer { 439 | static props = { 440 | test: { 441 | type: String, 442 | reflect: true, 443 | initial: "foo", 444 | }, 445 | test2: { 446 | type: String, 447 | reflect: true, 448 | initial: "baz", 449 | }, 450 | }; 451 | } 452 | customElements.define(`wafer-test-9`, Test); 453 | 454 | /** 455 | * @type {Test} 456 | */ 457 | const el = new Test(); 458 | document.body.append(el); 459 | 460 | el.test = "bar"; 461 | await el.updateDone(); 462 | 463 | const spyChanged = sinon.spy(el, "changed"); 464 | const spyUpdated = sinon.spy(el, "updated"); 465 | const spyUpdateTargets = sinon.spy(el, "updateTargets"); 466 | 467 | await el.requestUpdate(["test"]); 468 | await el.updateDone(); 469 | 470 | expect(spyChanged).to.have.callCount(1); 471 | expect(spyChanged.args[0][0].size).equal(1); 472 | expect(spyChanged.args[0][0].has("test")).equal(true); 473 | expect(spyChanged.args[0][0].get("test")).equal("bar"); 474 | 475 | expect(spyUpdated).to.have.callCount(1); 476 | expect(spyUpdated.args[0][0].size).equal(1); 477 | expect(spyUpdated.args[0][0].has("test")).equal(true); 478 | expect(spyUpdated.args[0][0].get("test")).equal("bar"); 479 | 480 | expect(spyUpdateTargets).to.have.callCount(1); 481 | expect(spyUpdateTargets.args[0][0]).equal("test"); 482 | 483 | expect(el).attr("test").to.equal("bar"); 484 | expect(el.test).to.equal("bar"); 485 | 486 | expect(el).attr("test2").to.equal("baz"); 487 | expect(el.test2).to.equal("baz"); 488 | }); 489 | 490 | it(`updateDone should wait if updated returns promise that updates a property in updated`, async () => { 491 | class Test extends Wafer { 492 | static props = { 493 | test: { 494 | type: String, 495 | reflect: true, 496 | initial: "foo", 497 | }, 498 | }; 499 | 500 | updated(changed) { 501 | if (changed.has("test") && this.test === "bar") { 502 | return new Promise((resolve) => { 503 | setTimeout(() => { 504 | this.test = "baz"; 505 | resolve(); 506 | }, 100); 507 | }); 508 | } 509 | } 510 | } 511 | customElements.define(`wafer-test-10`, Test); 512 | 513 | /** 514 | * @type {Test} 515 | */ 516 | const el = new Test(); 517 | 518 | document.body.append(el); 519 | await el.updateDone(); 520 | 521 | const spyUpdate = sinon.spy(el, "update"); 522 | 523 | el.test = "bar"; 524 | 525 | await el.updateDone(); 526 | 527 | expect(spyUpdate).to.have.callCount(2); 528 | expect(el.test).to.equal("baz"); 529 | }); 530 | }); 531 | -------------------------------------------------------------------------------- /test/client/events.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, expect } from "@open-wc/testing"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../src/wafer.js"; 5 | 6 | describe("Wafer handles events", () => { 7 | it("binds events to element by default in Shadow DOM", async () => { 8 | class Test extends Wafer { 9 | static template = ""; 10 | static props = { 11 | count: { 12 | type: Number, 13 | initial: 0, 14 | }, 15 | }; 16 | 17 | get events() { 18 | return { 19 | $button: { 20 | click: () => { 21 | this.count++; 22 | }, 23 | }, 24 | }; 25 | } 26 | } 27 | customElements.define("wafer-test-0", Test); 28 | 29 | /** 30 | * @type {Test} 31 | */ 32 | const el = await fixture(""); 33 | const button = el.shadowRoot.querySelector("button"); 34 | 35 | expect(el.count).to.equal(0); 36 | 37 | button.click(); 38 | 39 | await el.updateDone(); 40 | 41 | expect(el.count).to.equal(1); 42 | 43 | button.click(); 44 | 45 | await el.updateDone(); 46 | 47 | expect(el.count).to.equal(2); 48 | }); 49 | 50 | it("binds events to element by default in light DOM", async () => { 51 | class Test extends Wafer { 52 | static template = ""; 53 | static props = { 54 | count: { 55 | type: Number, 56 | initial: 0, 57 | }, 58 | }; 59 | 60 | constructor() { 61 | super({ shadow: false }); 62 | } 63 | 64 | get events() { 65 | return { 66 | button: { 67 | click: () => { 68 | this.count++; 69 | }, 70 | }, 71 | }; 72 | } 73 | } 74 | customElements.define("wafer-test-1", Test); 75 | 76 | /** 77 | * @type {Test} 78 | */ 79 | const el = await fixture(""); 80 | const button = el.querySelector("button"); 81 | 82 | expect(el.count).to.equal(0); 83 | 84 | button.click(); 85 | 86 | await el.updateDone(); 87 | 88 | expect(el.count).to.equal(1); 89 | 90 | button.click(); 91 | 92 | await el.updateDone(); 93 | 94 | expect(el.count).to.equal(2); 95 | }); 96 | 97 | it("can pass event options", async () => { 98 | class Test extends Wafer { 99 | static template = ""; 100 | static props = { 101 | count: { 102 | type: Number, 103 | initial: 0, 104 | }, 105 | }; 106 | 107 | get events() { 108 | return { 109 | $button: { 110 | click: { 111 | fn: () => { 112 | this.count++; 113 | }, 114 | opts: { 115 | once: true, 116 | }, 117 | }, 118 | }, 119 | }; 120 | } 121 | } 122 | customElements.define("wafer-test-2", Test); 123 | 124 | /** 125 | * @type {Test} 126 | */ 127 | const el = await fixture(""); 128 | const button = el.shadowRoot.querySelector("button"); 129 | 130 | expect(el.count).to.equal(0); 131 | 132 | button.click(); 133 | 134 | await el.updateDone(); 135 | 136 | expect(el.count).to.equal(1); 137 | 138 | button.click(); 139 | 140 | await el.updateDone(); 141 | 142 | expect(el.count).to.equal(1); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /test/client/helpers.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, expect, oneEvent } from "@open-wc/testing"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../src/wafer.js"; 5 | import { emit, repeat } from "../../src/dom.js"; 6 | 7 | describe("Wafer DOM", () => { 8 | it("emit fires custom events with defaults", async () => { 9 | class Test extends Wafer { 10 | static template = ""; 11 | static props = { 12 | count: { 13 | type: Number, 14 | initial: 0, 15 | }, 16 | }; 17 | 18 | get events() { 19 | return { 20 | $button: { 21 | click: () => { 22 | emit(this, "test-event"); 23 | }, 24 | }, 25 | }; 26 | } 27 | } 28 | customElements.define("wafer-test-0", Test); 29 | 30 | /** 31 | * @type {Test} 32 | */ 33 | const el = await fixture(""); 34 | const button = el.shadowRoot.querySelector("button"); 35 | 36 | const listener = oneEvent(el, "test-event"); 37 | 38 | button.click(); 39 | 40 | const { detail, composed, bubbles } = await listener; 41 | 42 | expect(detail).to.deep.equal({}); 43 | expect(composed).to.be.true; 44 | expect(bubbles).to.be.true; 45 | }); 46 | 47 | it("emit fires custom events with parameters", async () => { 48 | class Test extends Wafer { 49 | static template = ""; 50 | static props = { 51 | count: { 52 | type: Number, 53 | initial: 0, 54 | }, 55 | }; 56 | 57 | get events() { 58 | return { 59 | $button: { 60 | click: () => { 61 | emit( 62 | this, 63 | "test-event", 64 | { foo: "bar" }, 65 | { 66 | bubbles: false, 67 | composed: false, 68 | } 69 | ); 70 | }, 71 | }, 72 | }; 73 | } 74 | } 75 | customElements.define("wafer-test-1", Test); 76 | 77 | /** 78 | * @type {Test} 79 | */ 80 | const el = await fixture(""); 81 | const button = el.shadowRoot.querySelector("button"); 82 | 83 | const listener = oneEvent(el, "test-event"); 84 | 85 | button.click(); 86 | 87 | const { detail, composed, bubbles } = await listener; 88 | 89 | expect(detail).to.deep.equal({ foo: "bar" }); 90 | expect(composed).to.be.false; 91 | expect(bubbles).to.be.false; 92 | }); 93 | 94 | it("can render elements in container with repeat", async () => { 95 | class Test extends Wafer { 96 | static template = "
"; 97 | static props = { 98 | items: { 99 | type: Array, 100 | initial: [1, 2, 3], 101 | targets: [ 102 | { 103 | selector: "$div", 104 | dom: (targetEl, items, el) => { 105 | return repeat({ 106 | container: targetEl, 107 | items, 108 | html: "", 109 | keyFn: (item) => item, 110 | targets: [ 111 | { 112 | selector: "self", 113 | text: true, 114 | }, 115 | ], 116 | }); 117 | }, 118 | }, 119 | ], 120 | }, 121 | }; 122 | } 123 | customElements.define("wafer-test-2", Test); 124 | 125 | /** 126 | * @type {Test} 127 | */ 128 | const el = await fixture(""); 129 | 130 | expect(el).shadowDom.to.equal(` 131 |
132 | 1 133 | 2 134 | 3 135 |
136 | `); 137 | }); 138 | 139 | it("can remove elements in container with repeat", async () => { 140 | class Test extends Wafer { 141 | static template = "
"; 142 | static props = { 143 | items: { 144 | type: Array, 145 | initial: [1, 2, 3], 146 | targets: [ 147 | { 148 | selector: "$div", 149 | dom: (targetEl, items, el) => { 150 | return repeat({ 151 | container: targetEl, 152 | items, 153 | html: "", 154 | keyFn: (item) => item, 155 | targets: [ 156 | { 157 | selector: "self", 158 | text: true, 159 | }, 160 | ], 161 | }); 162 | }, 163 | }, 164 | ], 165 | }, 166 | }; 167 | } 168 | customElements.define("wafer-test-3", Test); 169 | 170 | /** 171 | * @type {Test} 172 | */ 173 | const el = await fixture(""); 174 | 175 | expect(el).shadowDom.to.equal(` 176 |
177 | 1 178 | 2 179 | 3 180 |
181 | `); 182 | 183 | el.items = [1, 2]; 184 | 185 | await el.updateDone(); 186 | 187 | expect(el).shadowDom.to.equal(` 188 |
189 | 1 190 | 2 191 |
192 | `); 193 | }); 194 | 195 | it("can update elements in container with repeat", async () => { 196 | class Test extends Wafer { 197 | static template = "
"; 198 | static props = { 199 | items: { 200 | type: Array, 201 | initial: [1, 2, 3], 202 | targets: [ 203 | { 204 | selector: "$div", 205 | dom: (targetEl, items, el) => { 206 | return repeat({ 207 | container: targetEl, 208 | items, 209 | html: "", 210 | keyFn: (item) => item, 211 | targets: [ 212 | { 213 | selector: "self", 214 | text: true, 215 | }, 216 | ], 217 | }); 218 | }, 219 | }, 220 | ], 221 | }, 222 | }; 223 | } 224 | customElements.define("wafer-test-4", Test); 225 | 226 | /** 227 | * @type {Test} 228 | */ 229 | const el = await fixture(""); 230 | 231 | expect(el).shadowDom.to.equal(` 232 |
233 | 1 234 | 2 235 | 3 236 |
237 | `); 238 | 239 | el.items = [4, 3, 5, 2, 6, 1]; 240 | 241 | await el.updateDone(); 242 | 243 | expect(el).shadowDom.to.equal(` 244 |
245 | 4 246 | 3 247 | 5 248 | 2 249 | 6 250 | 1 251 |
252 | `); 253 | 254 | el.items = [3, 4, 5, 2, 6, 1]; 255 | 256 | await el.updateDone(); 257 | 258 | expect(el).shadowDom.to.equal(` 259 |
260 | 3 261 | 4 262 | 5 263 | 2 264 | 6 265 | 1 266 |
267 | `); 268 | }); 269 | 270 | it("can add new elements in container with repeat", async () => { 271 | class Test extends Wafer { 272 | static template = "
"; 273 | static props = { 274 | items: { 275 | type: Array, 276 | initial: [1, 2, 3], 277 | targets: [ 278 | { 279 | selector: "$div", 280 | dom: (targetEl, items, el) => { 281 | return repeat({ 282 | container: targetEl, 283 | items, 284 | html: "", 285 | keyFn: (item) => item, 286 | targets: [ 287 | { 288 | selector: "self", 289 | text: true, 290 | }, 291 | ], 292 | }); 293 | }, 294 | }, 295 | ], 296 | }, 297 | }; 298 | } 299 | customElements.define("wafer-test-5", Test); 300 | 301 | /** 302 | * @type {Test} 303 | */ 304 | const el = await fixture(""); 305 | 306 | expect(el).shadowDom.to.equal(` 307 |
308 | 1 309 | 2 310 | 3 311 |
312 | `); 313 | 314 | el.items = [1, 2, 3, 4]; 315 | 316 | await el.updateDone(); 317 | 318 | expect(el).shadowDom.to.equal(` 319 |
320 | 1 321 | 2 322 | 3 323 | 4 324 |
325 | `); 326 | }); 327 | 328 | it("can bind events to elements in repeat", async () => { 329 | class Test extends Wafer { 330 | static template = "
"; 331 | static props = { 332 | count: { 333 | type: Number, 334 | initial: 0, 335 | }, 336 | items: { 337 | type: Array, 338 | initial: [1, 2, 3], 339 | targets: [ 340 | { 341 | selector: "$div", 342 | dom: (targetEl, items, el) => { 343 | return repeat({ 344 | container: targetEl, 345 | items, 346 | html: "
", 347 | keyFn: (item) => item, 348 | targets: [ 349 | { 350 | selector: "span", 351 | text: true, 352 | }, 353 | ], 354 | events: { 355 | button: { 356 | click: () => el.count++, 357 | }, 358 | }, 359 | }); 360 | }, 361 | }, 362 | ], 363 | }, 364 | }; 365 | } 366 | customElements.define("wafer-test-6", Test); 367 | 368 | /** 369 | * @type {Test} 370 | */ 371 | const el = await fixture(""); 372 | 373 | expect(el).shadowDom.to.equal(` 374 |
375 |
1
376 |
2
377 |
3
378 |
379 | `); 380 | 381 | el.shadowRoot.querySelector("button").click(); 382 | el.shadowRoot.querySelector("button").click(); 383 | 384 | await el.updateDone(); 385 | 386 | expect(el.count).to.equal(2); 387 | }); 388 | 389 | it("runs init function only when adding element", async () => { 390 | class Test extends Wafer { 391 | static template = "
"; 392 | static props = { 393 | items: { 394 | type: Array, 395 | initial: [1, 2, 3], 396 | targets: [ 397 | { 398 | selector: "$:scope>div", 399 | dom: (targetEl, items, self) => { 400 | return repeat({ 401 | container: targetEl, 402 | items, 403 | html: "
", 404 | keyFn: (item) => item, 405 | targets: [ 406 | { 407 | selector: ":scope>span", 408 | text: true, 409 | }, 410 | ], 411 | init: (el, item, index) => { 412 | const div = document.createElement("div"); 413 | div.textContent = `d${item} (${index})`; 414 | el.append(div); 415 | }, 416 | }); 417 | }, 418 | }, 419 | ], 420 | }, 421 | }; 422 | } 423 | customElements.define("wafer-test-7", Test); 424 | 425 | /** 426 | * @type {Test} 427 | */ 428 | const el = await fixture(""); 429 | 430 | expect(el).shadowDom.to.equal(` 431 |
432 |
1
d1 (0)
433 |
2
d2 (1)
434 |
3
d3 (2)
435 |
436 | `); 437 | 438 | el.items = [4, 3, 5, 2, 6, 1]; 439 | await el.updateDone(); 440 | 441 | expect(el).shadowDom.to.equal(` 442 |
443 |
4
d4 (0)
444 |
3
d3 (2)
445 |
5
d5 (2)
446 |
2
d2 (1)
447 |
6
d6 (4)
448 |
1
d1 (0)
449 |
450 | `); 451 | }); 452 | 453 | it("can re-add elements in container with repeat", async () => { 454 | class Test extends Wafer { 455 | static template = "
"; 456 | static props = { 457 | items: { 458 | type: Array, 459 | initial: [1, 2, 3], 460 | targets: [ 461 | { 462 | selector: "$div", 463 | dom: (targetEl, items, el) => { 464 | return repeat({ 465 | container: targetEl, 466 | items, 467 | html: "", 468 | keyFn: (item) => item, 469 | targets: [ 470 | { 471 | selector: "self", 472 | text: true, 473 | }, 474 | ], 475 | }); 476 | }, 477 | }, 478 | ], 479 | }, 480 | }; 481 | } 482 | customElements.define("wafer-test-8", Test); 483 | 484 | /** 485 | * @type {Test} 486 | */ 487 | const el = await fixture(""); 488 | 489 | expect(el).shadowDom.to.equal(` 490 |
491 | 1 492 | 2 493 | 3 494 |
495 | `); 496 | 497 | el.items = [3]; 498 | 499 | await el.updateDone(); 500 | 501 | expect(el).shadowDom.to.equal(` 502 |
503 | 3 504 |
505 | `); 506 | 507 | el.items = [1, 2, 3]; 508 | 509 | await el.updateDone(); 510 | 511 | expect(el).shadowDom.to.equal(` 512 |
513 | 1 514 | 2 515 | 3 516 |
517 | `); 518 | }); 519 | 520 | it("can re-add elements in container with repeat (random)", async () => { 521 | const start = [5, 8, 9, 45, 33, 12, 1, 99, 74, 53, 77]; 522 | const middle = start.sort((a, b) => b - a).slice(2, 4); 523 | const end = [5, 9, 33, 22, 12, 134, 1, 99, 53, 45, 77, 8]; 524 | 525 | class Test extends Wafer { 526 | static template = "
"; 527 | static props = { 528 | items: { 529 | type: Array, 530 | initial: start, 531 | targets: [ 532 | { 533 | selector: "$div", 534 | dom: (targetEl, items, el) => { 535 | return repeat({ 536 | container: targetEl, 537 | items, 538 | html: "", 539 | keyFn: (item) => item, 540 | targets: [ 541 | { 542 | selector: "self", 543 | text: true, 544 | }, 545 | ], 546 | }); 547 | }, 548 | }, 549 | ], 550 | }, 551 | }; 552 | } 553 | customElements.define("wafer-test-9", Test); 554 | 555 | /** 556 | * @type {Test} 557 | */ 558 | const el = await fixture(""); 559 | 560 | expect(el).shadowDom.to.equal(` 561 |
562 | ${start 563 | .map((item) => { 564 | return `${item}`; 565 | }) 566 | .join("")} 567 |
568 | `); 569 | 570 | el.items = middle; 571 | await el.updateDone(); 572 | 573 | expect(el).shadowDom.to.equal(` 574 |
575 | ${middle 576 | .map((item) => { 577 | return `${item}`; 578 | }) 579 | .join("")} 580 |
581 | `); 582 | 583 | el.items = end; 584 | await el.updateDone(); 585 | 586 | expect(el).shadowDom.to.equal(` 587 |
588 | ${end 589 | .map((item) => { 590 | return `${item}`; 591 | }) 592 | .join("")} 593 |
594 | `); 595 | }); 596 | }); 597 | -------------------------------------------------------------------------------- /test/client/instantiation/defined-after-created-with-createelement.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, expect } from "@open-wc/testing"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../../src/wafer.js"; 5 | 6 | class Test extends Wafer { 7 | static props = { 8 | test: { 9 | type: String, 10 | reflect: true, 11 | initial: "foo", 12 | }, 13 | }; 14 | } 15 | 16 | describe("Wafer sets attributes and properties on element when defined before created", () => { 17 | it(`initialises props`, async () => { 18 | /** 19 | * @type {Test} 20 | */ 21 | const el = document.createElement("wafer-test"); 22 | customElements.define(`wafer-test`, Test); 23 | await customElements.whenDefined(`wafer-test`); 24 | customElements.upgrade(el); 25 | 26 | const spyChanged = sinon.spy(el, "changed"); 27 | const spyUpdated = sinon.spy(el, "updated"); 28 | 29 | el.test = "bar"; 30 | 31 | await el.updateDone(); 32 | document.body.appendChild(el); 33 | 34 | expect(spyChanged).to.have.callCount(1); 35 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 36 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 37 | expect(spyChanged.args[0][0].size).equal(1); 38 | expect(spyChanged.args[0][0].has("test")).equal(true); 39 | 40 | expect(spyUpdated).to.have.callCount(1); 41 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 42 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 43 | expect(spyUpdated.args[0][0].size).equal(1); 44 | expect(spyUpdated.args[0][0].has("test")).equal(true); 45 | 46 | expect(el).attr("test").to.equal("bar"); 47 | expect(el.test).to.deep.equal("bar"); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/client/instantiation/defined-after-created-with-innerhtml.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, expect } from "@open-wc/testing"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../../src/wafer.js"; 5 | 6 | describe("Wafer sets attributes and properties on element when defined before created", () => { 7 | class Test extends Wafer { 8 | static props = { 9 | test: { 10 | type: String, 11 | reflect: true, 12 | initial: "foo", 13 | }, 14 | }; 15 | } 16 | 17 | it(`initialises props`, async () => { 18 | const div = document.createElement("div"); 19 | div.innerHTML = ""; 20 | 21 | /** 22 | * @type {Test} 23 | */ 24 | const el = div.querySelector("wafer-test"); 25 | 26 | customElements.define(`wafer-test`, Test); 27 | await customElements.whenDefined(`wafer-test`); 28 | customElements.upgrade(el); 29 | 30 | const spyChanged = sinon.spy(el, "changed"); 31 | const spyUpdated = sinon.spy(el, "updated"); 32 | 33 | el.test = "bar"; 34 | 35 | await el.updateDone(); 36 | document.body.appendChild(el); 37 | 38 | expect(spyChanged).to.have.callCount(1); 39 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 40 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 41 | expect(spyChanged.args[0][0].size).equal(1); 42 | expect(spyChanged.args[0][0].has("test")).equal(true); 43 | expect(spyChanged.args[0][0].get("test")).equal(undefined); 44 | 45 | expect(spyUpdated).to.have.callCount(1); 46 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 47 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 48 | expect(spyUpdated.args[0][0].size).equal(1); 49 | expect(spyUpdated.args[0][0].has("test")).equal(true); 50 | expect(spyUpdated.args[0][0].get("test")).equal(undefined); 51 | 52 | expect(el).attr("test").to.equal("bar"); 53 | expect(el.test).to.deep.equal("bar"); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/client/instantiation/defined-after-created-with-new.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, expect } from "@open-wc/testing"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../../src/wafer.js"; 5 | 6 | describe("Wafer sets attributes and properties on element when defined before created", () => { 7 | it(`initialises props`, async () => { 8 | class Test extends Wafer { 9 | static props = { 10 | test: { 11 | type: String, 12 | reflect: true, 13 | initial: "foo", 14 | }, 15 | }; 16 | } 17 | customElements.define(`wafer-test`, Test); 18 | 19 | /** 20 | * @type {Test} 21 | */ 22 | const el = new Test(); 23 | 24 | const spyChanged = sinon.spy(el, "changed"); 25 | const spyUpdated = sinon.spy(el, "updated"); 26 | 27 | el.test = "bar"; 28 | 29 | await el.updateDone(); 30 | document.body.appendChild(el); 31 | 32 | expect(spyChanged).to.have.callCount(1); 33 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 34 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 35 | expect(spyChanged.args[0][0].size).equal(1); 36 | expect(spyChanged.args[0][0].has("test")).equal(true); 37 | expect(spyChanged.args[0][0].get("test")).equal(undefined); 38 | 39 | expect(spyUpdated).to.have.callCount(1); 40 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 41 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 42 | expect(spyUpdated.args[0][0].size).equal(1); 43 | expect(spyUpdated.args[0][0].has("test")).equal(true); 44 | expect(spyUpdated.args[0][0].get("test")).equal(undefined); 45 | 46 | expect(el).attr("test").to.equal("bar"); 47 | expect(el.test).to.deep.equal("bar"); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/client/instantiation/defined-after-dom-all-types.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, expect } from "@open-wc/testing"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../../src/wafer.js"; 5 | import configs from "../../configs.js"; 6 | 7 | describe("Wafer sets attributes and properties on element when defined after DOM", () => { 8 | for (const [configIndex, config] of configs.entries()) { 9 | for (const [testIndex, test] of config.tests.entries()) { 10 | let itFunc = it; 11 | if (test.only) { 12 | itFunc = it.only; 13 | } 14 | 15 | itFunc( 16 | `with ${test.html(`${configIndex}-${testIndex}`).trim()} (${ 17 | config.description 18 | }, ${test.description})`, 19 | async () => { 20 | class Test extends Wafer { 21 | static props = config.props; 22 | } 23 | 24 | const attrName = config.props.test.attributeName || "test"; 25 | 26 | /** 27 | * @type {Test} 28 | */ 29 | const el = await fixture(test.html(`${configIndex}-${testIndex}`)); 30 | customElements.define(`wafer-test-${configIndex}-${testIndex}`, Test); 31 | 32 | const spyChanged = sinon.spy(el, "changed"); 33 | const spyUpdated = sinon.spy(el, "updated"); 34 | 35 | await customElements.whenDefined( 36 | `wafer-test-${configIndex}-${testIndex}` 37 | ); 38 | 39 | await el.updateDone(); 40 | 41 | expect(spyChanged).to.have.callCount(test.expected.changed.length); 42 | for (const expected of test.expected.changed) { 43 | expect(spyChanged).calledWith(expected.value); 44 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 45 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 46 | expect(spyChanged.args[0][0].size).equal(expected.value.size); 47 | 48 | for (const [key, value] of expected.value) { 49 | expect(spyChanged.args[0][0].has(key)).equal(true); 50 | expect(spyChanged.args[0][0].get(key)).equal(value); 51 | } 52 | } 53 | 54 | expect(spyUpdated).to.have.callCount(test.expected.updated.length); 55 | for (const expected of test.expected.updated) { 56 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 57 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 58 | expect(spyUpdated.args[0][0].size).equal(expected.value.size); 59 | 60 | for (const [key, value] of expected.value) { 61 | expect(spyUpdated.args[0][0].has(key)).equal(true); 62 | expect(spyUpdated.args[0][0].get(key)).equal(value); 63 | } 64 | } 65 | 66 | if (test.expected.attribute !== null) { 67 | expect(el).attr(attrName).to.equal(test.expected.attribute); 68 | } else { 69 | expect(el).not.to.have.attr(attrName); 70 | } 71 | expect(el.test).to.deep.equal(test.expected.prop); 72 | } 73 | ); 74 | } 75 | } 76 | }); 77 | -------------------------------------------------------------------------------- /test/client/instantiation/defined-before-created-with-createelement.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, expect } from "@open-wc/testing"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../../src/wafer.js"; 5 | 6 | describe("Wafer sets attributes and properties on element when defined before created", () => { 7 | class Test extends Wafer { 8 | static props = { 9 | test: { 10 | type: String, 11 | reflect: true, 12 | initial: "foo", 13 | }, 14 | }; 15 | } 16 | customElements.define(`wafer-test`, Test); 17 | 18 | it(`initialises props`, async () => { 19 | /** 20 | * @type {Test} 21 | */ 22 | const el = document.createElement("wafer-test"); 23 | 24 | const spyChanged = sinon.spy(el, "changed"); 25 | const spyUpdated = sinon.spy(el, "updated"); 26 | 27 | el.test = "bar"; 28 | 29 | await el.updateDone(); 30 | document.body.appendChild(el); 31 | 32 | expect(spyChanged).to.have.callCount(1); 33 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 34 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 35 | expect(spyChanged.args[0][0].size).equal(1); 36 | expect(spyChanged.args[0][0].has("test")).equal(true); 37 | 38 | expect(spyUpdated).to.have.callCount(1); 39 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 40 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 41 | expect(spyUpdated.args[0][0].size).equal(1); 42 | expect(spyUpdated.args[0][0].has("test")).equal(true); 43 | 44 | expect(el).attr("test").to.equal("bar"); 45 | expect(el.test).to.deep.equal("bar"); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/client/instantiation/defined-before-created-with-innerhtml.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, expect } from "@open-wc/testing"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../../src/wafer.js"; 5 | 6 | describe("Wafer sets attributes and properties on element when defined before created", () => { 7 | class Test extends Wafer { 8 | static props = { 9 | test: { 10 | type: String, 11 | reflect: true, 12 | initial: "foo", 13 | }, 14 | }; 15 | } 16 | customElements.define(`wafer-test`, Test); 17 | 18 | it(`initialises props`, async () => { 19 | const div = document.createElement("div"); 20 | div.innerHTML = ""; 21 | 22 | /** 23 | * @type {Test} 24 | */ 25 | const el = div.querySelector("wafer-test"); 26 | 27 | const spyChanged = sinon.spy(el, "changed"); 28 | const spyUpdated = sinon.spy(el, "updated"); 29 | 30 | el.test = "bar"; 31 | 32 | await el.updateDone(); 33 | document.body.appendChild(el); 34 | 35 | expect(spyChanged).to.have.callCount(1); 36 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 37 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 38 | expect(spyChanged.args[0][0].size).equal(1); 39 | expect(spyChanged.args[0][0].has("test")).equal(true); 40 | expect(spyChanged.args[0][0].get("test")).equal(undefined); 41 | 42 | expect(spyUpdated).to.have.callCount(1); 43 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 44 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 45 | expect(spyUpdated.args[0][0].size).equal(1); 46 | expect(spyUpdated.args[0][0].has("test")).equal(true); 47 | expect(spyUpdated.args[0][0].get("test")).equal(undefined); 48 | 49 | expect(el).attr("test").to.equal("bar"); 50 | expect(el.test).to.deep.equal("bar"); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/client/instantiation/defined-before-created-with-new.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, expect } from "@open-wc/testing"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../../src/wafer.js"; 5 | 6 | describe("Wafer sets attributes and properties on element when defined before created", () => { 7 | class Test extends Wafer { 8 | static props = { 9 | test: { 10 | type: String, 11 | reflect: true, 12 | initial: "foo", 13 | }, 14 | }; 15 | } 16 | customElements.define(`wafer-test`, Test); 17 | 18 | it(`initialises props`, async () => { 19 | /** 20 | * @type {Test} 21 | */ 22 | const el = new Test(); 23 | 24 | const spyChanged = sinon.spy(el, "changed"); 25 | const spyUpdated = sinon.spy(el, "updated"); 26 | 27 | el.test = "bar"; 28 | 29 | await el.updateDone(); 30 | document.body.appendChild(el); 31 | 32 | expect(spyChanged).to.have.callCount(1); 33 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 34 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 35 | expect(spyChanged.args[0][0].size).equal(1); 36 | expect(spyChanged.args[0][0].has("test")).equal(true); 37 | expect(spyChanged.args[0][0].get("test")).equal(undefined); 38 | 39 | expect(spyUpdated).to.have.callCount(1); 40 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 41 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 42 | expect(spyUpdated.args[0][0].size).equal(1); 43 | expect(spyUpdated.args[0][0].has("test")).equal(true); 44 | expect(spyUpdated.args[0][0].get("test")).equal(undefined); 45 | 46 | expect(el).attr("test").to.equal("bar"); 47 | expect(el.test).to.deep.equal("bar"); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/client/instantiation/defined-before-dom-all-types.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, expect } from "@open-wc/testing"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../../src/wafer.js"; 5 | import configs from "../../configs.js"; 6 | 7 | describe("Wafer sets attributes and properties on element when defined before DOM", () => { 8 | for (const [index, config] of configs.entries()) { 9 | class Test extends Wafer { 10 | static props = config.props; 11 | } 12 | customElements.define(`wafer-test-${index}`, Test); 13 | 14 | const attrName = config.props.test.attributeName || "test"; 15 | 16 | for (const test of config.tests) { 17 | let itFunc = it; 18 | if (test.only) { 19 | itFunc = it.only; 20 | } 21 | 22 | itFunc( 23 | `with ${test.html(index).trim()} (${config.description}, ${ 24 | test.description 25 | })`, 26 | async () => { 27 | /** 28 | * @type {Test} 29 | */ 30 | const el = await fixture(test.html(index)); 31 | 32 | // can't spy on changed/updated as they will have already been 33 | // called before we can spy on them 34 | 35 | await el.updateDone(); 36 | 37 | if (test.expected.attribute !== null) { 38 | expect(el).attr(attrName).to.equal(test.expected.attribute); 39 | } else { 40 | expect(el).not.to.have.attr(attrName); 41 | } 42 | expect(el.test).to.deep.equal(test.expected.prop); 43 | } 44 | ); 45 | } 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /test/client/instantiation/general.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, expect } from "@open-wc/testing"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../../src/wafer.js"; 5 | 6 | describe("Wafer behaviour on instantiation", () => { 7 | it(`should update even if not connected`, async () => { 8 | class Test extends Wafer { 9 | static props = { 10 | test: { 11 | type: String, 12 | reflect: true, 13 | initial: "foo", 14 | }, 15 | }; 16 | } 17 | customElements.define(`wafer-test-0`, Test); 18 | 19 | /** 20 | * @type {Test} 21 | */ 22 | const el = new Test(); 23 | 24 | const spyChanged = sinon.spy(el, "changed"); 25 | const spyUpdated = sinon.spy(el, "updated"); 26 | 27 | el.test = "bar"; 28 | 29 | await el.updateDone(); 30 | 31 | expect(el._connectedOnce).to.equal(false); 32 | 33 | expect(spyChanged).to.have.callCount(1); 34 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 35 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 36 | expect(spyChanged.args[0][0].size).equal(1); 37 | expect(spyChanged.args[0][0].has("test")).equal(true); 38 | expect(spyChanged.args[0][0].get("test")).equal(undefined); 39 | 40 | expect(spyUpdated).to.have.callCount(1); 41 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 42 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 43 | expect(spyUpdated.args[0][0].size).equal(1); 44 | expect(spyUpdated.args[0][0].has("test")).equal(true); 45 | expect(spyUpdated.args[0][0].get("test")).equal(undefined); 46 | 47 | expect(el).attr("test").to.equal("bar"); 48 | expect(el.test).to.deep.equal("bar"); 49 | }); 50 | 51 | it(`should update reflected property when attribute changes`, async () => { 52 | class Test extends Wafer { 53 | static props = { 54 | test: { 55 | type: String, 56 | reflect: true, 57 | initial: "foo", 58 | }, 59 | }; 60 | } 61 | customElements.define(`wafer-test-1`, Test); 62 | 63 | /** 64 | * @type {Test} 65 | */ 66 | const el = await fixture(""); 67 | 68 | const spyChanged = sinon.spy(el, "changed"); 69 | const spyUpdated = sinon.spy(el, "updated"); 70 | 71 | el.setAttribute("test", "bar"); 72 | 73 | await el.updateDone(); 74 | 75 | expect(spyChanged).to.have.callCount(1); 76 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 77 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 78 | expect(spyChanged.args[0][0].size).equal(1); 79 | expect(spyChanged.args[0][0].has("test")).equal(true); 80 | expect(spyChanged.args[0][0].get("test")).equal("foo"); 81 | 82 | expect(spyUpdated).to.have.callCount(1); 83 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 84 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 85 | expect(spyUpdated.args[0][0].size).equal(1); 86 | expect(spyUpdated.args[0][0].has("test")).equal(true); 87 | expect(spyUpdated.args[0][0].get("test")).equal("foo"); 88 | 89 | expect(el).attr("test").to.equal("bar"); 90 | expect(el.test).to.deep.equal("bar"); 91 | }); 92 | 93 | it(`should update un-reflected property when attribute changes`, async () => { 94 | class Test extends Wafer { 95 | static props = { 96 | test: { 97 | type: String, 98 | initial: "foo", 99 | }, 100 | }; 101 | } 102 | customElements.define(`wafer-test-2`, Test); 103 | 104 | /** 105 | * @type {Test} 106 | */ 107 | const el = await fixture(""); 108 | 109 | const spyChanged = sinon.spy(el, "changed"); 110 | const spyUpdated = sinon.spy(el, "updated"); 111 | 112 | el.setAttribute("test", "bar"); 113 | 114 | await el.updateDone(); 115 | 116 | expect(spyChanged).to.have.callCount(1); 117 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 118 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 119 | expect(spyChanged.args[0][0].size).equal(1); 120 | expect(spyChanged.args[0][0].has("test")).equal(true); 121 | expect(spyChanged.args[0][0].get("test")).equal("foo"); 122 | 123 | expect(spyUpdated).to.have.callCount(1); 124 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 125 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 126 | expect(spyUpdated.args[0][0].size).equal(1); 127 | expect(spyUpdated.args[0][0].has("test")).equal(true); 128 | expect(spyUpdated.args[0][0].get("test")).equal("foo"); 129 | 130 | expect(el).attr("test").to.equal("bar"); 131 | expect(el.test).to.deep.equal("bar"); 132 | }); 133 | 134 | it(`should not reflect un-reflected property to attribute`, async () => { 135 | class Test extends Wafer { 136 | static props = { 137 | test: { 138 | type: String, 139 | initial: "foo", 140 | }, 141 | }; 142 | } 143 | customElements.define(`wafer-test-3`, Test); 144 | 145 | /** 146 | * @type {Test} 147 | */ 148 | const el = await fixture(""); 149 | 150 | const spyChanged = sinon.spy(el, "changed"); 151 | const spyUpdated = sinon.spy(el, "updated"); 152 | 153 | el.test = "bar"; 154 | 155 | await el.updateDone(); 156 | 157 | expect(spyChanged).to.have.callCount(1); 158 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 159 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 160 | expect(spyChanged.args[0][0].size).equal(1); 161 | expect(spyChanged.args[0][0].has("test")).equal(true); 162 | expect(spyChanged.args[0][0].get("test")).equal("foo"); 163 | 164 | expect(spyUpdated).to.have.callCount(1); 165 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 166 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 167 | expect(spyUpdated.args[0][0].size).equal(1); 168 | expect(spyUpdated.args[0][0].has("test")).equal(true); 169 | expect(spyUpdated.args[0][0].get("test")).equal("foo"); 170 | 171 | expect(el).not.to.have.attr("test"); 172 | expect(el.test).to.deep.equal("bar"); 173 | }); 174 | 175 | it(`should not call _setFromAttribute when attribute is set to existing value when connected`, async () => { 176 | class Test extends Wafer { 177 | static props = { 178 | test: { 179 | type: String, 180 | initial: "foo", 181 | }, 182 | }; 183 | } 184 | customElements.define(`wafer-test-4`, Test); 185 | 186 | /** 187 | * @type {Test} 188 | */ 189 | const el = await fixture(""); 190 | 191 | const spySetFromAttribute = sinon.spy(el, "_setFromAttribute"); 192 | 193 | el.setAttribute("test", "bar"); 194 | await el.updateDone(); 195 | 196 | expect(spySetFromAttribute).to.have.callCount(1); 197 | spySetFromAttribute.resetHistory(); 198 | 199 | el.setAttribute("test", "bar"); 200 | await el.updateDone(); 201 | 202 | expect(spySetFromAttribute).to.have.callCount(0); 203 | }); 204 | 205 | it(`should not call _setFromAttribute when not connected`, async () => { 206 | class Test extends Wafer { 207 | static props = { 208 | test: { 209 | type: String, 210 | initial: "foo", 211 | }, 212 | }; 213 | } 214 | customElements.define(`wafer-test-5`, Test); 215 | 216 | /** 217 | * @type {Test} 218 | */ 219 | const el = new Test(); 220 | 221 | const spySetFromAttribute = sinon.spy(el, "_setFromAttribute"); 222 | 223 | el.setAttribute("test", "bar"); 224 | await el.updateDone(); 225 | 226 | expect(spySetFromAttribute).to.have.callCount(0); 227 | spySetFromAttribute.resetHistory(); 228 | 229 | el.setAttribute("test", "bar"); 230 | await el.updateDone(); 231 | 232 | expect(spySetFromAttribute).to.have.callCount(0); 233 | }); 234 | 235 | it("should use initial value set before upgraded", async () => { 236 | class Test extends Wafer { 237 | static props = { 238 | test: { 239 | type: String, 240 | initial: "foo", 241 | }, 242 | }; 243 | } 244 | 245 | /** 246 | * @type {Test} 247 | */ 248 | const el = await fixture(""); 249 | el.test = "bar"; 250 | 251 | customElements.define(`wafer-test-6`, Test); 252 | 253 | await el.updateDone(); 254 | 255 | expect(el._initials.test).equal("bar"); 256 | 257 | expect(el.test).to.equal("bar"); 258 | }); 259 | 260 | it("should not call changed/updated when prop is changed to a new value then back to the old one", async () => { 261 | class Test extends Wafer { 262 | static props = { 263 | test: { 264 | type: String, 265 | initial: "foo", 266 | }, 267 | }; 268 | } 269 | 270 | customElements.define(`wafer-test-7`, Test); 271 | 272 | /** 273 | * @type {Test} 274 | */ 275 | const el = await fixture(""); 276 | 277 | const spyChanged = sinon.spy(el, "changed"); 278 | const spyUpdated = sinon.spy(el, "updated"); 279 | 280 | el.test = "bar"; 281 | el.test = "foo"; 282 | 283 | await el.updateDone(); 284 | 285 | expect(spyChanged).to.have.callCount(0); 286 | expect(spyUpdated).to.have.callCount(0); 287 | 288 | expect(el.test).to.equal("foo"); 289 | }); 290 | 291 | it("should not call changed/updated when prop is changed to the same value twice", async () => { 292 | class Test extends Wafer { 293 | static props = { 294 | test: { 295 | type: String, 296 | initial: "foo", 297 | }, 298 | }; 299 | } 300 | 301 | customElements.define(`wafer-test-8`, Test); 302 | 303 | /** 304 | * @type {Test} 305 | */ 306 | const el = await fixture(""); 307 | 308 | const spyChanged = sinon.spy(el, "changed"); 309 | const spyUpdated = sinon.spy(el, "updated"); 310 | 311 | el.test = "bar"; 312 | el.test = "bar"; 313 | 314 | await el.updateDone(); 315 | 316 | expect(spyChanged).to.have.callCount(1); 317 | expect(spyUpdated).to.have.callCount(1); 318 | 319 | expect(el.test).to.equal("bar"); 320 | }); 321 | 322 | it("should set prop to null if attribute removed", async () => { 323 | class Test extends Wafer { 324 | static props = { 325 | test: { 326 | type: String, 327 | initial: "foo", 328 | reflect: true, 329 | }, 330 | }; 331 | } 332 | 333 | customElements.define(`wafer-test-9`, Test); 334 | 335 | /** 336 | * @type {Test} 337 | */ 338 | const el = await fixture(""); 339 | 340 | const spyChanged = sinon.spy(el, "changed"); 341 | const spyUpdated = sinon.spy(el, "updated"); 342 | 343 | el.removeAttribute("test"); 344 | 345 | await el.updateDone(); 346 | 347 | expect(spyChanged).to.have.callCount(1); 348 | expect(spyUpdated).to.have.callCount(1); 349 | 350 | expect(el.test).to.equal(null); 351 | }); 352 | 353 | it("should remove attribute when non boolean prop set to null", async () => { 354 | class Test extends Wafer { 355 | static props = { 356 | test: { 357 | type: String, 358 | initial: "foo", 359 | reflect: true, 360 | }, 361 | }; 362 | } 363 | 364 | customElements.define(`wafer-test-10`, Test); 365 | 366 | /** 367 | * @type {Test} 368 | */ 369 | const el = await fixture(""); 370 | 371 | const spyChanged = sinon.spy(el, "changed"); 372 | const spyUpdated = sinon.spy(el, "updated"); 373 | 374 | expect(el).attr("test").to.equal("foo"); 375 | 376 | el.test = null; 377 | 378 | await el.updateDone(); 379 | 380 | expect(spyChanged).to.have.callCount(1); 381 | expect(spyUpdated).to.have.callCount(1); 382 | 383 | expect(el.test).to.equal(null); 384 | expect(el).not.to.have.attr("test"); 385 | }); 386 | 387 | it("should remove attribute when non boolean prop set to undefined", async () => { 388 | class Test extends Wafer { 389 | static props = { 390 | test: { 391 | type: String, 392 | initial: "foo", 393 | reflect: true, 394 | }, 395 | }; 396 | } 397 | 398 | customElements.define(`wafer-test-10a`, Test); 399 | 400 | /** 401 | * @type {Test} 402 | */ 403 | const el = await fixture(""); 404 | 405 | const spyChanged = sinon.spy(el, "changed"); 406 | const spyUpdated = sinon.spy(el, "updated"); 407 | 408 | expect(el).attr("test").to.equal("foo"); 409 | 410 | el.test = undefined; 411 | 412 | await el.updateDone(); 413 | 414 | expect(spyChanged).to.have.callCount(1); 415 | expect(spyUpdated).to.have.callCount(1); 416 | 417 | expect(el.test).to.equal(undefined); 418 | expect(el).not.to.have.attr("test"); 419 | }); 420 | 421 | it("should remove attribute when boolean prop set to null", async () => { 422 | class Test extends Wafer { 423 | static props = { 424 | test: { 425 | type: Boolean, 426 | initial: true, 427 | reflect: true, 428 | }, 429 | }; 430 | } 431 | 432 | customElements.define(`wafer-test-11`, Test); 433 | 434 | /** 435 | * @type {Test} 436 | */ 437 | const el = await fixture(""); 438 | 439 | const spyChanged = sinon.spy(el, "changed"); 440 | const spyUpdated = sinon.spy(el, "updated"); 441 | 442 | expect(el).attr("test").to.equal(""); 443 | 444 | el.test = null; 445 | 446 | await el.updateDone(); 447 | 448 | expect(spyChanged).to.have.callCount(1); 449 | expect(spyUpdated).to.have.callCount(1); 450 | 451 | expect(el.test).to.equal(null); 452 | expect(el).not.to.have.attr("test"); 453 | }); 454 | 455 | it("should remove attribute when boolean prop set to undefined", async () => { 456 | class Test extends Wafer { 457 | static props = { 458 | test: { 459 | type: Boolean, 460 | initial: true, 461 | reflect: true, 462 | }, 463 | }; 464 | } 465 | 466 | customElements.define(`wafer-test-11a`, Test); 467 | 468 | /** 469 | * @type {Test} 470 | */ 471 | const el = await fixture(""); 472 | 473 | const spyChanged = sinon.spy(el, "changed"); 474 | const spyUpdated = sinon.spy(el, "updated"); 475 | 476 | expect(el).attr("test").to.equal(""); 477 | 478 | el.test = undefined; 479 | 480 | await el.updateDone(); 481 | 482 | expect(spyChanged).to.have.callCount(1); 483 | expect(spyUpdated).to.have.callCount(1); 484 | 485 | expect(el.test).to.equal(undefined); 486 | expect(el).not.to.have.attr("test"); 487 | }); 488 | 489 | it("should remove attribute when boolean prop set to false", async () => { 490 | class Test extends Wafer { 491 | static props = { 492 | test: { 493 | type: Boolean, 494 | initial: true, 495 | reflect: true, 496 | }, 497 | }; 498 | } 499 | 500 | customElements.define(`wafer-test-12`, Test); 501 | 502 | /** 503 | * @type {Test} 504 | */ 505 | const el = await fixture(""); 506 | 507 | const spyChanged = sinon.spy(el, "changed"); 508 | const spyUpdated = sinon.spy(el, "updated"); 509 | 510 | expect(el).attr("test").to.equal(""); 511 | 512 | el.test = false; 513 | 514 | await el.updateDone(); 515 | 516 | expect(spyChanged).to.have.callCount(1); 517 | expect(spyUpdated).to.have.callCount(1); 518 | 519 | expect(el.test).to.equal(false); 520 | expect(el).not.to.have.attr("test"); 521 | }); 522 | 523 | it(`should reflect un-reflected property to attribute when SSRd outside Wafer SSR (otherwise prop info lost on rehydration)`, async () => { 524 | class Test extends Wafer { 525 | static props = { 526 | test: { 527 | type: String, 528 | initial: "foo", 529 | }, 530 | }; 531 | } 532 | 533 | Wafer.isSSR = true; 534 | 535 | customElements.define(`wafer-test-13`, Test); 536 | 537 | /** 538 | * @type {Test} 539 | */ 540 | const el = await fixture( 541 | "" 542 | ); 543 | 544 | await el.updateDone(); 545 | 546 | expect(el).attr("test").to.equal("bar"); 547 | expect(el.test).to.deep.equal("bar"); 548 | expect(el).attr("x-ssr").to.equal(""); 549 | }); 550 | }); 551 | -------------------------------------------------------------------------------- /test/server/element.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "../testing.js"; 2 | 3 | import { ServerElement, parse } from "../../src/server/element.js"; 4 | 5 | describe("Wafer element", () => { 6 | it("can set content using innerHTML", async () => { 7 | const el = new ServerElement("div"); 8 | 9 | el.innerHTML = `

Hi!!

Bye!!

`; 10 | 11 | const children = el.childNodes; 12 | 13 | expect(children.length).to.equal(2); 14 | 15 | expect(children[0].tagName).to.equal("H1"); 16 | expect(children[0].textContent).to.equal("Hi!!"); 17 | 18 | expect(children[1].tagName).to.equal("H2"); 19 | expect(children[1].textContent).to.equal("Bye!!"); 20 | }); 21 | 22 | it("can get firstChild", async () => { 23 | const el = new ServerElement("div"); 24 | 25 | el.innerHTML = `

Hi!!

Bye!!

`; 26 | 27 | const firstChild = el.firstChild; 28 | 29 | expect(firstChild.tagName).to.equal("H1"); 30 | expect(firstChild.textContent).to.equal("Hi!!"); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /test/server/helpers.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "../testing.js"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../src/server/wafer.js"; 5 | import { repeat } from "../../src/server/dom.js"; 6 | import { ServerElement, parse } from "../../src/server/element.js"; 7 | 8 | describe("Wafer DOM", () => { 9 | it("can render elements in container with repeat", async () => { 10 | class Test extends Wafer { 11 | static template = "
"; 12 | static props = { 13 | items: { 14 | type: Array, 15 | initial: [1, 2, 3], 16 | targets: [ 17 | { 18 | selector: "$div", 19 | dom: async (targetEl, items, el) => { 20 | await repeat({ 21 | container: targetEl, 22 | items, 23 | html: "", 24 | keyFn: (item) => item, 25 | targets: [ 26 | { 27 | selector: "self", 28 | text: true, 29 | }, 30 | ], 31 | registry: el._registry, 32 | }); 33 | }, 34 | }, 35 | ], 36 | }, 37 | }; 38 | } 39 | 40 | const html = await parse( 41 | ` 42 | 43 | `, 44 | { "wafer-test": { def: Test, serverOnly: false } } 45 | ); 46 | 47 | expect(html.toString()).html.to.equal( 48 | ` 49 | 56 | ` 57 | ); 58 | }); 59 | 60 | it("can remove elements in container with repeat", async () => { 61 | class Test extends Wafer { 62 | static template = "
"; 63 | static props = { 64 | items: { 65 | type: Array, 66 | initial: [1, 2, 3], 67 | targets: [ 68 | { 69 | selector: "$div", 70 | dom: async (targetEl, items, el) => { 71 | await repeat({ 72 | container: targetEl, 73 | items, 74 | html: "", 75 | keyFn: (item) => item, 76 | targets: [ 77 | { 78 | selector: "self", 79 | text: true, 80 | }, 81 | ], 82 | }); 83 | }, 84 | }, 85 | ], 86 | }, 87 | }; 88 | } 89 | 90 | const html = await parse( 91 | ` 92 | 93 | `, 94 | { "wafer-test": { def: Test } } 95 | ); 96 | 97 | const el = html.querySelector("wafer-test"); 98 | 99 | expect(el.toString()).html.to.equal( 100 | ` 101 | 108 | ` 109 | ); 110 | 111 | el.items = [1, 2]; 112 | 113 | await el.updateDone(); 114 | 115 | expect(el.toString()).html.to.equal( 116 | ` 117 | 123 | ` 124 | ); 125 | }); 126 | 127 | it("can update elements in container with repeat", async () => { 128 | class Test extends Wafer { 129 | static template = "
"; 130 | static props = { 131 | items: { 132 | type: Array, 133 | initial: [1, 2, 3], 134 | targets: [ 135 | { 136 | selector: "$div", 137 | dom: async (targetEl, items, el) => { 138 | await repeat({ 139 | container: targetEl, 140 | items, 141 | html: "", 142 | keyFn: (item) => item, 143 | targets: [ 144 | { 145 | selector: "self", 146 | text: true, 147 | }, 148 | ], 149 | }); 150 | }, 151 | }, 152 | ], 153 | }, 154 | }; 155 | } 156 | 157 | const html = await parse( 158 | ` 159 | 160 | `, 161 | { "wafer-test": { def: Test } } 162 | ); 163 | 164 | const el = html.querySelector("wafer-test"); 165 | 166 | expect(el.toString()).html.to.equal( 167 | ` 168 | 175 | ` 176 | ); 177 | 178 | el.items = [4, 3, 5, 2, 6, 1]; 179 | 180 | await el.updateDone(); 181 | 182 | expect(el.toString()).html.to.equal( 183 | ` 184 | 194 | ` 195 | ); 196 | }); 197 | 198 | it("can add new elements in container with repeat", async () => { 199 | class Test extends Wafer { 200 | static template = "
"; 201 | static props = { 202 | items: { 203 | type: Array, 204 | initial: [1, 2, 3], 205 | targets: [ 206 | { 207 | selector: "$div", 208 | dom: async (targetEl, items, el) => { 209 | await repeat({ 210 | container: targetEl, 211 | items, 212 | html: "", 213 | keyFn: (item) => item, 214 | targets: [ 215 | { 216 | selector: "self", 217 | text: true, 218 | }, 219 | ], 220 | }); 221 | }, 222 | }, 223 | ], 224 | }, 225 | }; 226 | } 227 | 228 | const html = await parse( 229 | ` 230 | 231 | `, 232 | { "wafer-test": { def: Test } } 233 | ); 234 | 235 | const el = html.querySelector("wafer-test"); 236 | 237 | expect(el.toString()).html.to.equal( 238 | ` 239 | 246 | ` 247 | ); 248 | 249 | el.items = [1, 2, 3, 4]; 250 | 251 | await el.updateDone(); 252 | 253 | expect(el.toString()).html.to.equal( 254 | ` 255 | 263 | ` 264 | ); 265 | }); 266 | 267 | it("runs init function only when adding element", async () => { 268 | class Test extends Wafer { 269 | static template = "
"; 270 | static props = { 271 | items: { 272 | type: Array, 273 | initial: [1, 2, 3], 274 | targets: [ 275 | { 276 | selector: "$:scope>div", 277 | dom: async (targetEl, items, self) => { 278 | await repeat({ 279 | container: targetEl, 280 | items, 281 | html: "
", 282 | keyFn: (item) => item, 283 | targets: [ 284 | { 285 | selector: ":scope>span", 286 | text: true, 287 | }, 288 | ], 289 | init: async (el, item, index) => { 290 | const div = new ServerElement("div"); 291 | div.textContent = `d${item} (${index})`; 292 | el.appendChild(div); 293 | }, 294 | }); 295 | }, 296 | }, 297 | ], 298 | }, 299 | }; 300 | } 301 | 302 | const html = await parse( 303 | ` 304 | 305 | `, 306 | { "wafer-test": { def: Test } } 307 | ); 308 | 309 | const el = html.querySelector("wafer-test"); 310 | 311 | expect(el.toString()).html.to.equal( 312 | ` 313 | 320 | ` 321 | ); 322 | 323 | el.items = [4, 3, 5, 2, 6, 1]; 324 | await el.updateDone(); 325 | 326 | expect(el.toString()).html.to.equal(` 327 | 328 | 338 | 339 | `); 340 | }); 341 | 342 | it("shouldn't choke on leaading whitespace in repeat html", async () => { 343 | class Test extends Wafer { 344 | static template = "
"; 345 | static props = { 346 | items: { 347 | type: Array, 348 | initial: [1, 2, 3], 349 | targets: [ 350 | { 351 | selector: "$div", 352 | dom: async (container, items) => { 353 | await repeat({ 354 | container, 355 | items, 356 | html: ` 357 | 358 | `, 359 | keyFn: (item) => item, 360 | targets: [ 361 | { 362 | selector: "self", 363 | text: true, 364 | }, 365 | ], 366 | }); 367 | }, 368 | }, 369 | ], 370 | }, 371 | }; 372 | } 373 | 374 | const html = await parse( 375 | ` 376 | 377 | `, 378 | { "wafer-test": { def: Test } } 379 | ); 380 | 381 | const el = html.querySelector("wafer-test"); 382 | 383 | expect(el.toString()).html.to.equal( 384 | ` 385 | 392 | ` 393 | ); 394 | }); 395 | }); 396 | -------------------------------------------------------------------------------- /test/server/instantiation/as-object.js: -------------------------------------------------------------------------------- 1 | import { expect } from "../../testing.js"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../../src/server/wafer.js"; 5 | import configs from "../../configs.js"; 6 | 7 | describe("Wafer sets attributes and properties on element when defined as object", () => { 8 | for (const [configIndex, config] of configs.entries()) { 9 | class Test extends Wafer { 10 | static props = config.props; 11 | } 12 | 13 | const attrName = config.props.test.attributeName || "test"; 14 | 15 | for (const [testIndex, test] of config.tests.entries()) { 16 | let itFunc = it; 17 | if (test.only) { 18 | itFunc = it.only; 19 | } 20 | 21 | itFunc( 22 | `with ${test.html(`${configIndex}-${testIndex}`).trim()} (${ 23 | config.description 24 | }, ${test.description})`, 25 | async () => { 26 | /** 27 | * @type {Test} 28 | */ 29 | const el = new Test( 30 | { 31 | tagName: `wafer-test-${configIndex}-${testIndex}`, 32 | attrs: test.attrs, 33 | }, 34 | { 35 | [`wafer-test-${configIndex}-${testIndex}`]: { def: Test }, 36 | } 37 | ); 38 | await el.construct(); 39 | await el.connectedCallback(); 40 | 41 | // ssr elements will always have attributes set if prop has a value 42 | if (test.expected.prop !== undefined) { 43 | const attrValue = 44 | config.props.test.type === Boolean 45 | ? test.expected.prop 46 | ? "" 47 | : undefined 48 | : config.props.test.type === Object || 49 | config.props.test.type === Array 50 | ? JSON.stringify(test.expected.prop) 51 | : `${test.expected.prop}`; 52 | 53 | expect(el.getAttribute(attrName)).to.equal(attrValue); 54 | } else { 55 | expect(el.getAttribute(attrName)).to.equal(undefined); 56 | } 57 | 58 | expect(el.test).to.deep.equal(test.expected.prop); 59 | } 60 | ); 61 | } 62 | } 63 | }); 64 | -------------------------------------------------------------------------------- /test/server/instantiation/as-string.js: -------------------------------------------------------------------------------- 1 | import { expect } from "../../testing.js"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../../src/server/wafer.js"; 5 | import { parse } from "../../../src/server/element.js"; 6 | import configs from "../../configs.js"; 7 | 8 | describe("Wafer sets attributes and properties on element when defined as string", () => { 9 | for (const [configIndex, config] of configs.entries()) { 10 | for (const [testIndex, test] of config.tests.entries()) { 11 | let itFunc = it; 12 | if (test.only) { 13 | itFunc = it.only; 14 | } 15 | 16 | itFunc( 17 | `with ${test.html(`${configIndex}-${testIndex}`).trim()} (${ 18 | config.description 19 | }, ${test.description})`, 20 | async () => { 21 | class Test extends Wafer { 22 | static props = config.props; 23 | } 24 | 25 | const attrName = config.props.test.attributeName || "test"; 26 | 27 | const html = await parse(test.html(`${configIndex}-${testIndex}`), { 28 | [`wafer-test-${configIndex}-${testIndex}`]: { def: Test }, 29 | }); 30 | 31 | const el = html.querySelector( 32 | `wafer-test-${configIndex}-${testIndex}` 33 | ); 34 | 35 | // ssr elements will always have attributes set if prop has a value 36 | if (test.expected.prop !== undefined) { 37 | const attrValue = 38 | config.props.test.type === Boolean 39 | ? test.expected.prop 40 | ? "" 41 | : undefined 42 | : config.props.test.type === Object || 43 | config.props.test.type === Array 44 | ? JSON.stringify(test.expected.prop) 45 | : `${test.expected.prop}`; 46 | 47 | expect(el.getAttribute(attrName)).to.equal(attrValue); 48 | } else { 49 | expect(el.getAttribute(attrName)).to.equal(undefined); 50 | } 51 | 52 | expect(el.test).to.deep.equal(test.expected.prop); 53 | } 54 | ); 55 | } 56 | } 57 | }); 58 | -------------------------------------------------------------------------------- /test/server/instantiation/general.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "../../testing.js"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../../src/server/wafer.js"; 5 | 6 | describe("WaferServer behaviour on instantiation", () => { 7 | it(`should update even if not connected`, async () => { 8 | class Test extends Wafer { 9 | static props = { 10 | test: { 11 | type: String, 12 | reflect: true, 13 | initial: "foo", 14 | }, 15 | }; 16 | } 17 | 18 | /** 19 | * @type {Test} 20 | */ 21 | const el = new Test({ tagName: "wafer-test-0" }); 22 | await el.construct(); 23 | 24 | const spyChanged = sinon.spy(el, "changed"); 25 | const spyUpdated = sinon.spy(el, "updated"); 26 | 27 | el.test = "bar"; 28 | 29 | await el.updateDone(); 30 | 31 | expect(spyChanged).to.have.callCount(1); 32 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 33 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 34 | expect(spyChanged.args[0][0].size).equal(1); 35 | expect(spyChanged.args[0][0].has("test")).equal(true); 36 | expect(spyChanged.args[0][0].get("test")).equal(undefined); 37 | 38 | expect(spyUpdated).to.have.callCount(1); 39 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 40 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 41 | expect(spyUpdated.args[0][0].size).equal(1); 42 | expect(spyUpdated.args[0][0].has("test")).equal(true); 43 | expect(spyUpdated.args[0][0].get("test")).equal(undefined); 44 | 45 | expect(el).attr("test").to.equal("bar"); 46 | expect(el.test).to.deep.equal("bar"); 47 | 48 | expect(el.toString()).html.to.equal( 49 | ` 50 | 51 | 52 | 53 | ` 54 | ); 55 | }); 56 | 57 | it(`should update reflected property when attribute changes`, async () => { 58 | class Test extends Wafer { 59 | static props = { 60 | test: { 61 | type: String, 62 | reflect: true, 63 | initial: "foo", 64 | }, 65 | }; 66 | } 67 | 68 | /** 69 | * @type {Test} 70 | */ 71 | const el = new Test({ tagName: "wafer-test-1" }); 72 | await el.construct(); 73 | await el.connectedCallback(); 74 | 75 | const spyChanged = sinon.spy(el, "changed"); 76 | const spyUpdated = sinon.spy(el, "updated"); 77 | 78 | el.setAttribute("test", "bar"); 79 | 80 | await el.updateDone(); 81 | 82 | expect(spyChanged).to.have.callCount(1); 83 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 84 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 85 | expect(spyChanged.args[0][0].size).equal(1); 86 | expect(spyChanged.args[0][0].has("test")).equal(true); 87 | expect(spyChanged.args[0][0].get("test")).equal("foo"); 88 | 89 | expect(spyUpdated).to.have.callCount(1); 90 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 91 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 92 | expect(spyUpdated.args[0][0].size).equal(1); 93 | expect(spyUpdated.args[0][0].has("test")).equal(true); 94 | expect(spyUpdated.args[0][0].get("test")).equal("foo"); 95 | 96 | expect(el).attr("test").to.equal("bar"); 97 | expect(el.test).to.deep.equal("bar"); 98 | }); 99 | 100 | it(`should update un-reflected property when attribute changes`, async () => { 101 | class Test extends Wafer { 102 | static props = { 103 | test: { 104 | type: String, 105 | initial: "foo", 106 | }, 107 | }; 108 | } 109 | 110 | /** 111 | * @type {Test} 112 | */ 113 | const el = new Test({ tagName: "wafer-test-2" }); 114 | await el.construct(); 115 | await el.connectedCallback(); 116 | 117 | const spyChanged = sinon.spy(el, "changed"); 118 | const spyUpdated = sinon.spy(el, "updated"); 119 | 120 | el.setAttribute("test", "bar"); 121 | 122 | await el.updateDone(); 123 | 124 | expect(spyChanged).to.have.callCount(1); 125 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 126 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 127 | expect(spyChanged.args[0][0].size).equal(1); 128 | expect(spyChanged.args[0][0].has("test")).equal(true); 129 | expect(spyChanged.args[0][0].get("test")).equal("foo"); 130 | 131 | expect(spyUpdated).to.have.callCount(1); 132 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 133 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 134 | expect(spyUpdated.args[0][0].size).equal(1); 135 | expect(spyUpdated.args[0][0].has("test")).equal(true); 136 | expect(spyUpdated.args[0][0].get("test")).equal("foo"); 137 | 138 | expect(el).attr("test").to.equal("bar"); 139 | expect(el.test).to.deep.equal("bar"); 140 | }); 141 | 142 | it(`should reflect un-reflected property to attribute (server always reflect attributes as they are the source of truth in ssr)`, async () => { 143 | class Test extends Wafer { 144 | static props = { 145 | test: { 146 | type: String, 147 | initial: "foo", 148 | }, 149 | }; 150 | } 151 | 152 | /** 153 | * @type {Test} 154 | */ 155 | const el = new Test({ tagName: "wafer-test-3" }); 156 | await el.construct(); 157 | await el.connectedCallback(); 158 | 159 | const spyChanged = sinon.spy(el, "changed"); 160 | const spyUpdated = sinon.spy(el, "updated"); 161 | 162 | el.test = "bar"; 163 | 164 | await el.updateDone(); 165 | 166 | expect(spyChanged).to.have.callCount(1); 167 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 168 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 169 | expect(spyChanged.args[0][0].size).equal(1); 170 | expect(spyChanged.args[0][0].has("test")).equal(true); 171 | expect(spyChanged.args[0][0].get("test")).equal("foo"); 172 | 173 | expect(spyUpdated).to.have.callCount(1); 174 | // can't check for equal maps, since a key set to undefined is the same as a key that's not set (with chai) 175 | // expect(spyUpdated).calledWith(new Map([['test', undefined]])) === expect(spyUpdated).calledWith(new Map([['blah', undefined]])) 176 | expect(spyUpdated.args[0][0].size).equal(1); 177 | expect(spyUpdated.args[0][0].has("test")).equal(true); 178 | expect(spyUpdated.args[0][0].get("test")).equal("foo"); 179 | 180 | expect(el).attr("test").to.equal("bar"); 181 | expect(el.test).to.deep.equal("bar"); 182 | }); 183 | 184 | it(`should not call _setFromAttribute when attribute is set to existing value when connected`, async () => { 185 | class Test extends Wafer { 186 | static props = { 187 | test: { 188 | type: String, 189 | initial: "foo", 190 | }, 191 | }; 192 | } 193 | 194 | /** 195 | * @type {Test} 196 | */ 197 | const el = new Test({ tagName: "wafer-test-4" }); 198 | await el.construct(); 199 | await el.connectedCallback(); 200 | 201 | const spySetFromAttribute = sinon.spy(el, "_setFromAttribute"); 202 | 203 | el.setAttribute("test", "bar"); 204 | 205 | await el.updateDone(); 206 | 207 | expect(spySetFromAttribute).to.have.callCount(1); 208 | spySetFromAttribute.resetHistory(); 209 | 210 | el.setAttribute("test", "bar"); 211 | await el.updateDone(); 212 | 213 | expect(spySetFromAttribute).to.have.callCount(0); 214 | }); 215 | 216 | it(`should not call _setFromAttribute when not connected`, async () => { 217 | class Test extends Wafer { 218 | static props = { 219 | test: { 220 | type: String, 221 | initial: "foo", 222 | }, 223 | }; 224 | } 225 | 226 | /** 227 | * @type {Test} 228 | */ 229 | const el = new Test({ tagName: "wafer-test-5" }); 230 | await el.construct(); 231 | 232 | const spySetFromAttribute = sinon.spy(el, "_setFromAttribute"); 233 | 234 | el.setAttribute("test", "bar"); 235 | 236 | await el.updateDone(); 237 | 238 | expect(spySetFromAttribute).to.have.callCount(0); 239 | spySetFromAttribute.resetHistory(); 240 | 241 | el.setAttribute("test", "bar"); 242 | await el.updateDone(); 243 | 244 | expect(spySetFromAttribute).to.have.callCount(0); 245 | }); 246 | 247 | it("should use initial value set before upgraded", async () => { 248 | class Test extends Wafer { 249 | static props = { 250 | test: { 251 | type: String, 252 | initial: "foo", 253 | }, 254 | }; 255 | } 256 | 257 | /** 258 | * @type {Test} 259 | */ 260 | const el = new Test({ tagName: "wafer-test-6" }); 261 | el.test = "bar"; 262 | 263 | await el.construct(); 264 | await el.connectedCallback(); 265 | 266 | expect(el._initials.test).equal("bar"); 267 | expect(el.test).to.equal("bar"); 268 | }); 269 | 270 | it("should not call changed/updated when prop is changed to a new value then back to the old one", async () => { 271 | class Test extends Wafer { 272 | static props = { 273 | test: { 274 | type: String, 275 | initial: "foo", 276 | }, 277 | }; 278 | } 279 | 280 | /** 281 | * @type {Test} 282 | */ 283 | const el = new Test({ tagName: "wafer-test-7" }); 284 | await el.construct(); 285 | await el.connectedCallback(); 286 | 287 | const spyChanged = sinon.spy(el, "changed"); 288 | const spyUpdated = sinon.spy(el, "updated"); 289 | 290 | el.test = "bar"; 291 | el.test = "foo"; 292 | 293 | await el.updateDone(); 294 | 295 | expect(spyChanged).to.have.callCount(0); 296 | expect(spyUpdated).to.have.callCount(0); 297 | 298 | expect(el.test).to.equal("foo"); 299 | }); 300 | 301 | it("should not call changed/updated when prop is changed to the same value twice", async () => { 302 | class Test extends Wafer { 303 | static props = { 304 | test: { 305 | type: String, 306 | initial: "foo", 307 | }, 308 | }; 309 | } 310 | 311 | /** 312 | * @type {Test} 313 | */ 314 | const el = new Test({ tagName: "wafer-test-8" }); 315 | await el.construct(); 316 | await el.connectedCallback(); 317 | 318 | const spyChanged = sinon.spy(el, "changed"); 319 | const spyUpdated = sinon.spy(el, "updated"); 320 | 321 | el.test = "bar"; 322 | el.test = "bar"; 323 | 324 | await el.updateDone(); 325 | 326 | expect(spyChanged).to.have.callCount(1); 327 | expect(spyUpdated).to.have.callCount(1); 328 | 329 | expect(el.test).to.equal("bar"); 330 | }); 331 | 332 | it("should set prop to null if attribute removed", async () => { 333 | class Test extends Wafer { 334 | static props = { 335 | test: { 336 | type: String, 337 | initial: "foo", 338 | reflect: true, 339 | }, 340 | }; 341 | } 342 | 343 | /** 344 | * @type {Test} 345 | */ 346 | const el = new Test({ tagName: "wafer-test-9" }); 347 | await el.construct(); 348 | await el.connectedCallback(); 349 | 350 | const spyChanged = sinon.spy(el, "changed"); 351 | const spyUpdated = sinon.spy(el, "updated"); 352 | 353 | el.removeAttribute("test"); 354 | 355 | await el.updateDone(); 356 | 357 | expect(spyChanged).to.have.callCount(1); 358 | expect(spyUpdated).to.have.callCount(1); 359 | 360 | expect(el.test).to.equal(null); 361 | }); 362 | 363 | it("should remove attribute when non boolean prop set to null", async () => { 364 | class Test extends Wafer { 365 | static props = { 366 | test: { 367 | type: String, 368 | initial: "foo", 369 | reflect: true, 370 | }, 371 | }; 372 | } 373 | 374 | /** 375 | * @type {Test} 376 | */ 377 | const el = new Test({ tagName: "wafer-test-10" }); 378 | await el.construct(); 379 | await el.connectedCallback(); 380 | 381 | const spyChanged = sinon.spy(el, "changed"); 382 | const spyUpdated = sinon.spy(el, "updated"); 383 | 384 | expect(el).attr("test").to.equal("foo"); 385 | 386 | el.test = null; 387 | 388 | await el.updateDone(); 389 | 390 | expect(spyChanged).to.have.callCount(1); 391 | expect(spyUpdated).to.have.callCount(1); 392 | 393 | expect(el.test).to.equal(null); 394 | expect(el.getAttribute("test")).to.equal(undefined); 395 | }); 396 | 397 | it("should remove attribute when non boolean prop set to undefined", async () => { 398 | class Test extends Wafer { 399 | static props = { 400 | test: { 401 | type: String, 402 | initial: "foo", 403 | reflect: true, 404 | }, 405 | }; 406 | } 407 | 408 | /** 409 | * @type {Test} 410 | */ 411 | const el = new Test({ tagName: "wafer-test-10" }); 412 | await el.construct(); 413 | await el.connectedCallback(); 414 | 415 | const spyChanged = sinon.spy(el, "changed"); 416 | const spyUpdated = sinon.spy(el, "updated"); 417 | 418 | expect(el).attr("test").to.equal("foo"); 419 | 420 | el.test = undefined; 421 | 422 | await el.updateDone(); 423 | 424 | expect(spyChanged).to.have.callCount(1); 425 | expect(spyUpdated).to.have.callCount(1); 426 | 427 | expect(el.test).to.equal(undefined); 428 | expect(el.getAttribute("test")).to.equal(undefined); 429 | }); 430 | 431 | it("should remove attribute when boolean prop set to null", async () => { 432 | class Test extends Wafer { 433 | static props = { 434 | test: { 435 | type: Boolean, 436 | initial: true, 437 | reflect: true, 438 | }, 439 | }; 440 | } 441 | 442 | /** 443 | * @type {Test} 444 | */ 445 | const el = new Test({ tagName: "wafer-test-11" }); 446 | await el.construct(); 447 | await el.connectedCallback(); 448 | 449 | const spyChanged = sinon.spy(el, "changed"); 450 | const spyUpdated = sinon.spy(el, "updated"); 451 | 452 | expect(el.getAttribute("test")).to.equal(""); 453 | 454 | el.test = null; 455 | 456 | await el.updateDone(); 457 | 458 | expect(spyChanged).to.have.callCount(1); 459 | expect(spyUpdated).to.have.callCount(1); 460 | 461 | expect(el.test).to.equal(null); 462 | expect(el.getAttribute("test")).to.equal(undefined); 463 | }); 464 | 465 | it("should remove attribute when boolean prop set to undefined", async () => { 466 | class Test extends Wafer { 467 | static props = { 468 | test: { 469 | type: Boolean, 470 | initial: true, 471 | reflect: true, 472 | }, 473 | }; 474 | } 475 | 476 | /** 477 | * @type {Test} 478 | */ 479 | const el = new Test({ tagName: "wafer-test-11" }); 480 | await el.construct(); 481 | await el.connectedCallback(); 482 | 483 | const spyChanged = sinon.spy(el, "changed"); 484 | const spyUpdated = sinon.spy(el, "updated"); 485 | 486 | expect(el.getAttribute("test")).to.equal(""); 487 | 488 | el.test = undefined; 489 | 490 | await el.updateDone(); 491 | 492 | expect(spyChanged).to.have.callCount(1); 493 | expect(spyUpdated).to.have.callCount(1); 494 | 495 | expect(el.test).to.equal(undefined); 496 | expect(el.getAttribute("test")).to.equal(undefined); 497 | }); 498 | 499 | it("should remove attribute when boolean prop set to false", async () => { 500 | class Test extends Wafer { 501 | static props = { 502 | test: { 503 | type: Boolean, 504 | initial: true, 505 | reflect: true, 506 | }, 507 | }; 508 | } 509 | 510 | /** 511 | * @type {Test} 512 | */ 513 | const el = new Test({ tagName: "wafer-test-12" }); 514 | await el.construct(); 515 | await el.connectedCallback(); 516 | 517 | const spyChanged = sinon.spy(el, "changed"); 518 | const spyUpdated = sinon.spy(el, "updated"); 519 | 520 | expect(el.getAttribute("test")).to.equal(""); 521 | 522 | el.test = false; 523 | 524 | await el.updateDone(); 525 | 526 | expect(spyChanged).to.have.callCount(1); 527 | expect(spyUpdated).to.have.callCount(1); 528 | 529 | expect(el.test).to.equal(false); 530 | expect(el.getAttribute("test")).to.equal(undefined); 531 | }); 532 | 533 | it("should use initial value set before connected", async () => { 534 | class Test extends Wafer { 535 | static props = { 536 | test: { 537 | type: String, 538 | initial: "foo", 539 | }, 540 | }; 541 | } 542 | 543 | /** 544 | * @type {Test} 545 | */ 546 | const el = new Test({ tagName: "wafer-test-13" }); 547 | await el.construct(); 548 | 549 | el.test = "bar"; 550 | 551 | await el.connectedCallback(); 552 | 553 | expect(el).attr("test").to.equal("bar"); 554 | expect(el.test).to.equal("bar"); 555 | }); 556 | 557 | it("should set boolean prop to false on attribute removal", async () => { 558 | class Test extends Wafer { 559 | static props = { 560 | test: { 561 | type: Boolean, 562 | initial: true, 563 | reflect: true, 564 | }, 565 | }; 566 | } 567 | 568 | /** 569 | * @type {Test} 570 | */ 571 | const el = new Test({ tagName: "wafer-test" }); 572 | await el.construct(); 573 | await el.connectedCallback(); 574 | 575 | expect(el.getAttribute("test")).to.equal(""); 576 | expect(el.test).to.equal(true); 577 | 578 | el.removeAttribute("test"); 579 | await el.updateDone(); 580 | 581 | expect(el.getAttribute("test")).to.equal(undefined); 582 | expect(el.test).to.equal(false); 583 | }); 584 | }); 585 | -------------------------------------------------------------------------------- /test/server/rendering.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from "../testing.js"; 2 | import sinon from "sinon"; 3 | 4 | import Wafer from "../../src/server/wafer.js"; 5 | import { ServerElement, parse } from "../../src/server/element.js"; 6 | 7 | describe("Wafer renders expected content", () => { 8 | it("renders in shadow DOM", async () => { 9 | class Test extends Wafer { 10 | static template = "

Test

"; 11 | } 12 | 13 | /** 14 | * @type {Test} 15 | */ 16 | const el = new Test({ tagName: "wafer-test" }); 17 | await el.construct(); 18 | await el.connectedCallback(); 19 | 20 | expect(el.toString()).html.to.equal( 21 | ` 22 | 25 | ` 26 | ); 27 | 28 | expect(el.shadowRoot.toString()).html.to.equal(` 29 | 32 | `); 33 | }); 34 | 35 | it("renders in light DOM", async () => { 36 | class Test extends Wafer { 37 | static template = "

Test

"; 38 | 39 | constructor({ tagName }) { 40 | super({ tagName, shadow: false }); 41 | } 42 | } 43 | 44 | /** 45 | * @type {Test} 46 | */ 47 | const el = new Test({ tagName: "wafer-test" }); 48 | await el.construct(); 49 | await el.connectedCallback(); 50 | 51 | expect(el.toString()).html.to.equal(` 52 | 53 |

Test

54 |
`); 55 | 56 | expect(el.shadowRoot).to.be.null; 57 | }); 58 | 59 | it("renders slots", async () => { 60 | class Test extends Wafer { 61 | static template = "

Hi

"; 62 | } 63 | 64 | const html = await parse( 65 | ` 66 | 67 | Chris 68 | 69 | `, 70 | { "wafer-test": { def: Test } } 71 | ); 72 | 73 | expect(html.toString()).html.to.equal( 74 | ` 75 | Chris 76 | 79 | ` 80 | ); 81 | }); 82 | 83 | it("updates DOM text in shadow DOM", async () => { 84 | class Test extends Wafer { 85 | static template = '

Hi

'; 86 | static props = { 87 | name: { 88 | type: String, 89 | reflect: true, 90 | targets: [ 91 | { 92 | selector: "$#name", 93 | text: true, 94 | }, 95 | ], 96 | }, 97 | }; 98 | } 99 | 100 | const html = await parse( 101 | ` 102 | 103 | `, 104 | { "wafer-test": { def: Test } } 105 | ); 106 | 107 | expect(html.toString()).html.to.equal( 108 | ` 109 | 112 | ` 113 | ); 114 | }); 115 | 116 | it("updates DOM text in light DOM", async () => { 117 | class Test extends Wafer { 118 | static template = ""; 119 | static props = { 120 | name: { 121 | type: String, 122 | reflect: true, 123 | targets: [ 124 | { 125 | selector: "#name", 126 | text: true, 127 | }, 128 | ], 129 | }, 130 | }; 131 | } 132 | 133 | const html = await parse( 134 | ` 135 | 136 | 137 | 138 | `, 139 | { "wafer-test": { def: Test } } 140 | ); 141 | 142 | expect(html.toString()).html.to.equal( 143 | ` 144 | Chris 145 | 147 | ` 148 | ); 149 | }); 150 | 151 | it("updates DOM property in shadow DOM", async () => { 152 | class Test extends Wafer { 153 | static template = ''; 154 | static props = { 155 | on: { 156 | type: Boolean, 157 | reflect: true, 158 | targets: [ 159 | { 160 | selector: "$input", 161 | property: "checked", 162 | }, 163 | ], 164 | }, 165 | }; 166 | } 167 | 168 | /** 169 | * @type {Test} 170 | */ 171 | const el = new Test({ tagName: "wafer-test", attrs: { on: "" } }); 172 | await el.construct(); 173 | await el.connectedCallback(); 174 | 175 | expect(el.toString()).html.to.equal( 176 | ` 177 | 180 | ` 181 | ); 182 | 183 | expect(el.shadowRoot.querySelector("input")).property("checked").to.be.true; 184 | }); 185 | 186 | it("updates DOM property in light DOM", async () => { 187 | class Test extends Wafer { 188 | static template = ''; 189 | static props = { 190 | on: { 191 | type: Boolean, 192 | reflect: true, 193 | targets: [ 194 | { 195 | selector: "input", 196 | property: "checked", 197 | }, 198 | ], 199 | }, 200 | }; 201 | 202 | constructor({ tagName, attrs }) { 203 | super({ tagName, attrs, shadow: false }); 204 | } 205 | } 206 | 207 | /** 208 | * @type {Test} 209 | */ 210 | const el = new Test({ tagName: "wafer-test", attrs: { on: "" } }); 211 | await el.construct(); 212 | await el.connectedCallback(); 213 | 214 | expect(el.toString()).html.to.equal( 215 | ` 216 | 217 | ` 218 | ); 219 | 220 | expect(el.querySelector("input")).property("checked").to.be.true; 221 | }); 222 | 223 | it("updates DOM attribute in shadow DOM", async () => { 224 | class Test extends Wafer { 225 | static template = ''; 226 | static props = { 227 | on: { 228 | type: Boolean, 229 | reflect: true, 230 | targets: [ 231 | { 232 | selector: "$input", 233 | attribute: "checked", 234 | }, 235 | ], 236 | }, 237 | }; 238 | } 239 | 240 | const html = await parse( 241 | ` 242 | 243 | `, 244 | { "wafer-test": { def: Test } } 245 | ); 246 | 247 | expect(html.toString()).html.to.equal( 248 | ` 249 | 252 | ` 253 | ); 254 | }); 255 | 256 | it("updates DOM property in light DOM", async () => { 257 | class Test extends Wafer { 258 | static template = ''; 259 | static props = { 260 | on: { 261 | type: Boolean, 262 | reflect: true, 263 | targets: [ 264 | { 265 | selector: "input", 266 | attribute: "checked", 267 | }, 268 | ], 269 | }, 270 | }; 271 | 272 | constructor({ tagName }) { 273 | super({ tagName, shadow: false }); 274 | } 275 | } 276 | 277 | const html = await parse( 278 | ` 279 | 280 | `, 281 | { "wafer-test": { def: Test } } 282 | ); 283 | 284 | expect(html.toString()).html.to.equal( 285 | ` 286 | 287 | 288 | ` 289 | ); 290 | }); 291 | 292 | it("uses custom value in shadow DOM", async () => { 293 | class Test extends Wafer { 294 | static template = "
test
"; 295 | static props = { 296 | test: { 297 | type: String, 298 | reflect: true, 299 | targets: [ 300 | { 301 | selector: "$div", 302 | use: (value) => `bar ${value}`, 303 | attribute: "class", 304 | }, 305 | ], 306 | }, 307 | }; 308 | } 309 | 310 | const html = await parse( 311 | ` 312 | 313 | `, 314 | { "wafer-test": { def: Test } } 315 | ); 316 | 317 | expect(html.toString()).html.to.equal( 318 | ` 319 | 320 | 323 | ` 324 | ); 325 | }); 326 | 327 | it("uses custom value in light DOM", async () => { 328 | class Test extends Wafer { 329 | static template = "
test
"; 330 | static props = { 331 | test: { 332 | type: String, 333 | reflect: true, 334 | targets: [ 335 | { 336 | selector: "div", 337 | use: (value) => `bar ${value}`, 338 | attribute: "class", 339 | }, 340 | ], 341 | }, 342 | }; 343 | 344 | constructor({ tagName }) { 345 | super({ tagName, shadow: false }); 346 | } 347 | } 348 | 349 | const html = await parse( 350 | ` 351 | 352 | `, 353 | { "wafer-test": { def: Test } } 354 | ); 355 | 356 | expect(html.toString()).html.to.equal( 357 | ` 358 | 359 |
test
360 |
` 361 | ); 362 | }); 363 | 364 | it("updates DOM in shadow DOM", async () => { 365 | class Test extends Wafer { 366 | static template = "
test
"; 367 | static props = { 368 | test: { 369 | type: String, 370 | reflect: true, 371 | targets: [ 372 | { 373 | selector: "$div", 374 | dom: (el, value) => { 375 | const child = new ServerElement("h1"); 376 | child.textContent = value; 377 | el.innerHTML = ""; 378 | el.appendChild(child); 379 | }, 380 | }, 381 | ], 382 | }, 383 | }; 384 | } 385 | 386 | const html = await parse( 387 | ` 388 | 389 | `, 390 | { "wafer-test": { def: Test } } 391 | ); 392 | 393 | expect(html.toString()).html.to.equal( 394 | ` 395 | 396 | 399 | ` 400 | ); 401 | }); 402 | 403 | it("updates DOM in light DOM", async () => { 404 | class Test extends Wafer { 405 | static template = "
test
"; 406 | static props = { 407 | test: { 408 | type: String, 409 | reflect: true, 410 | targets: [ 411 | { 412 | selector: "div", 413 | dom: (el, value) => { 414 | const child = new ServerElement("h1"); 415 | child.textContent = value; 416 | el.innerHTML = ""; 417 | el.appendChild(child); 418 | }, 419 | }, 420 | ], 421 | }, 422 | }; 423 | 424 | constructor({ tagName }) { 425 | super({ tagName, shadow: false }); 426 | } 427 | } 428 | 429 | const html = await parse( 430 | ` 431 | 432 | `, 433 | { "wafer-test": { def: Test } } 434 | ); 435 | 436 | expect(html.toString()).html.to.equal( 437 | ` 438 | 439 |

foo

440 |
` 441 | ); 442 | }); 443 | 444 | it("can use a function for selector", async () => { 445 | class Test extends Wafer { 446 | static template = ` 447 |
1
448 |
2
449 |
3
450 | `; 451 | static props = { 452 | active: { 453 | type: Number, 454 | reflect: true, 455 | targets: [ 456 | { 457 | selector: (value) => `$#item-${value}`, 458 | attribute: "active", 459 | }, 460 | ], 461 | }, 462 | }; 463 | } 464 | 465 | const html = await parse( 466 | ` 467 | 468 | `, 469 | { "wafer-test": { def: Test } } 470 | ); 471 | 472 | expect(html.toString()).html.to.equal( 473 | ` 474 | 475 | 480 | ` 481 | ); 482 | }); 483 | 484 | it("can use self as selector", async () => { 485 | class Test extends Wafer { 486 | static props = { 487 | active: { 488 | type: Number, 489 | reflect: true, 490 | targets: [ 491 | { 492 | selector: "self", 493 | attribute: "test", 494 | }, 495 | ], 496 | }, 497 | }; 498 | } 499 | 500 | const html = await parse( 501 | ` 502 | 503 | `, 504 | { "wafer-test": { def: Test } } 505 | ); 506 | 507 | expect(html.toString()).html.to.equal( 508 | ` 509 | 510 | 511 | 512 | ` 513 | ); 514 | }); 515 | 516 | it("will remove attribute if selecting attribute with null", async () => { 517 | class Test extends Wafer { 518 | static template = '
'; 519 | static props = { 520 | active: { 521 | type: Number, 522 | reflect: true, 523 | targets: [ 524 | { 525 | selector: "$div", 526 | attribute: "test", 527 | }, 528 | ], 529 | }, 530 | }; 531 | } 532 | 533 | const html = await parse( 534 | ` 535 | 536 | `, 537 | { "wafer-test": { def: Test } } 538 | ); 539 | 540 | expect(html.toString()).html.to.equal( 541 | ` 542 | 543 | 546 | 547 | ` 548 | ); 549 | 550 | const el = html.querySelector("wafer-test"); 551 | el.active = null; 552 | await el.updateDone(); 553 | 554 | expect(html.toString()).html.to.equal( 555 | ` 556 | 557 | 560 | 561 | ` 562 | ); 563 | }); 564 | 565 | it("will update elements outside component", async () => { 566 | class Test extends Wafer { 567 | static props = { 568 | title: { 569 | type: String, 570 | targets: [ 571 | { 572 | selector: "@title", 573 | text: true, 574 | }, 575 | ], 576 | }, 577 | }; 578 | } 579 | 580 | const html = await parse( 581 | ` 582 | 583 | 584 | `, 585 | { "wafer-test": { def: Test } } 586 | ); 587 | 588 | const el = html.querySelector("wafer-test"); 589 | const titleEl = html.querySelector("title"); 590 | 591 | el.title = "Yo!!"; 592 | await el.updateDone(); 593 | 594 | expect(titleEl).to.have.text("Yo!!"); 595 | }); 596 | 597 | it("should remove attributes that are not reflected if not being client side rendered (serverOnly)", async () => { 598 | class Test extends Wafer { 599 | static props = { 600 | title: { 601 | type: String, 602 | initial: "bar", 603 | }, 604 | }; 605 | } 606 | 607 | const html = await parse( 608 | ` 609 | 610 | `, 611 | { "wafer-test": { def: Test, serverOnly: true } } 612 | ); 613 | 614 | // title attribute removed since not reflected and not needed for client side hydration 615 | // also no wafer-ssr attribute present as not needed 616 | expect(html.toString()).html.to.equal( 617 | ` 618 | 619 | 620 | 621 | ` 622 | ); 623 | 624 | const el = html.querySelector("wafer-test"); 625 | expect(el.title).to.equal("foo"); 626 | }); 627 | 628 | it("should escape attribute values and textContent", async () => { 629 | class Test extends Wafer { 630 | static props = { 631 | title: { 632 | type: String, 633 | initial: "fo>o", 634 | targets: [ 635 | { 636 | selector: "$span", 637 | text: true, 638 | attribute: "test2", 639 | }, 640 | ], 641 | }, 642 | }; 643 | 644 | static get template() { 645 | return ""; 646 | } 647 | } 648 | 649 | const html = await parse( 650 | ` 651 | 652 | `, 653 | { "wafer-test": { def: Test } } 654 | ); 655 | 656 | expect(html.toString()).html.to.equal( 657 | ` 658 | 659 | 660 | 661 | ` 662 | ); 663 | }); 664 | }); 665 | -------------------------------------------------------------------------------- /test/testing.js: -------------------------------------------------------------------------------- 1 | import chai, { expect } from 'chai'; 2 | import sinonChai from 'sinon-chai'; 3 | import chaiHTML from 'chai-html'; 4 | import chaiDOM from 'chai-dom'; 5 | 6 | chai.use(sinonChai); 7 | chai.use(chaiHTML); 8 | chai.use(chaiDOM); 9 | 10 | export { expect }; 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "checkJs": true, 5 | "target": "esnext", 6 | "module": "esnext", 7 | "strict": true, 8 | "moduleResolution": "node", 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "declaration": true, 13 | "outDir": "lib" 14 | }, 15 | "include": ["src/**/*.js"], 16 | "exclude": ["node_modules"], 17 | "typedocOptions": { 18 | "entryPoints": ["src/"], 19 | "excludeExternals": true, 20 | "out": "../wafer-docs/api" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /typedoc-theme/partials/header.hbs: -------------------------------------------------------------------------------- 1 | 125 | 126 |
127 |
128 |
129 |
130 | 136 | 152 |
153 |
154 |
155 |
156 | 171 | 172 |
173 |
174 | Options 176 |
177 |
178 | All 179 |
    180 |
  • Public
  • 181 |
  • Public/Protected
  • 182 |
  • All 183 |
  • 184 |
185 |
186 | 187 | 188 | 190 | 191 | {{#unless settings.excludeExternals}} 192 | 193 | 195 | {{/unless}} 196 |
197 |
198 | 199 | Menu 200 |
201 |
202 |
203 |
204 |
205 |
206 | {{#if model.parent}} {{! Don't show breadcrumbs on main project page, it is the root page. !}} 207 |
    208 | {{#with model}}{{> breadcrumb}}{{/with}} 209 |
210 | {{/if}} 211 |

{{#compact}} 212 | {{#ifCond model.kindString "!==" "Project" }} 213 | {{model.kindString}}  214 | {{/ifCond}} 215 | {{model.name}} 216 | {{#if model.typeParameters}} 217 | < 218 | {{#each model.typeParameters}} 219 | {{#if @index}}, {{/if}} 220 | {{name}} 221 | {{/each}} 222 | > 223 | {{/if}} 224 | {{/compact}}

225 |
226 |
227 |
228 | -------------------------------------------------------------------------------- /web-test-runner.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | coverageConfig: { 3 | reporters: ["html", "text"], 4 | report: true, 5 | reportDir: "coverage", 6 | 7 | threshold: { 8 | statements: 70, 9 | branches: 40, 10 | functions: 70, 11 | lines: 70, 12 | }, 13 | }, 14 | }; 15 | --------------------------------------------------------------------------------