├── .gitignore ├── LICENSE.txt ├── README.md └── packages ├── controllers ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── esbuild.config.js ├── package-lock.json ├── package.json ├── src │ ├── DeclarativeActionsController.js │ ├── TargetsController.js │ └── index.js ├── test │ ├── DeclarativeActionsController.test.js │ └── TargetsController.test.js └── web-test-runner.config.mjs ├── reactive-property ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── esbuild.config.js ├── package-lock.json ├── package.json ├── src │ ├── ReactiveProperty.js │ └── index.js ├── test │ └── ReactiveProperty.test.js └── web-test-runner.config.mjs └── shadow-effects ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── esbuild.config.js ├── package-lock.json ├── package.json ├── src ├── ShadowEffects.js ├── directives.js └── index.js ├── test └── ShadowEffects.test.js └── web-test-runner.config.mjs /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | logs 3 | *.log 4 | npm-debug.log* 5 | .eslintcache 6 | /coverage 7 | dist 8 | /local 9 | /reports 10 | /node_modules 11 | .DS_Store 12 | Thumbs.db 13 | .idea 14 | *.iml 15 | .vscode 16 | *.sublime-project 17 | *.sublime-workspace -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020-present Jared White 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ❄️ Crystallized: Enhancements to Web Components 2 | 3 | > [!WARNING] 4 | > I've sunsetting this project in favor of an entirely new, declarative HTML-first web component format. I'll update this README once that's publicly available. In the meantime, consider this repo as-is with no plans to develop things further. 5 | 6 | ---- 7 | 8 | **Crystallized** is a collection of quality-of-life DX enhancements to Lit or "vanilla" web components, inspired in part by [Stimulus](https://stimulus.hotwired.dev). Crystallized includes: 9 | 10 | * [`DeclarativeActionsController`](#using-declarativeactionscontroller) - lets you add action attributes to elements as a way of providing declarative event handlers. 11 | 12 | * [`TargetsController`](#using-targetscontroller) - lets you easily query child nodes in DOM using either selectors or explicit attribute-based identifies. 13 | 14 | You can use **[Lit](https://lit.dev)** (a library for building fast, reactive web components) along with these controllers, or you can build your own "vanilla" web components and enhance them with the full suite of Crystallized utilities. The entire library is under 7KB (before compression!) and has no dependencies. 15 | 16 | Crystallized can be used with any ESM bundler as well as directly in buildless HTML using `script type="module"`: 17 | 18 | ```js 19 | 27 | ``` 28 | 29 | Crystallized works great as a spice on top of server-rendered markup originating from backend frameworks like [Rails](https://rubyonrails.org) or static sites generators like [Bridgetown](https://www.bridgetownrb.com)—providing features not normally found in web component libraries that assume they're only concerned with client-rendered markup and event handling. 30 | 31 | You can build an entire suite of reactive frontend components just with Crystallized, along with a general strategy to enhance your site with a variety of emerging [web components](https://github.com/topics/web-components) and component libraries ([Shoelace](https://shoelace.style) for example). 32 | 33 | ## Installing 34 | 35 | ``` 36 | npm i @crystallized/controllers 37 | ``` 38 | 39 | or 40 | 41 | ```sh 42 | yarn add @crystallized/controllers 43 | ``` 44 | 45 | ## Adding Controller support to `HTMLElement` 46 | 47 | (Skip this section if you're using Lit) 48 | 49 | Crystallized functionality is added to custom elements via controllers, [a concept pioneered by Lit](https://lit.dev/docs/api/controllers/). To support using controllers with a basic custom element, simply use the `Controllable` mixin: 50 | 51 | ```js 52 | import { Controllable } from "@crystallized/controllers" 53 | 54 | class MyElement extends Controllable(HTMLElement) { 55 | 56 | } 57 | ``` 58 | 59 | ## Using DeclarativeActionsController 60 | 61 | ### With Lit 62 | 63 | It's very simple to add this controller to any Lit v2+ component. Let's set up a new test component: 64 | 65 | ```js 66 | import { LitElement, html } from "lit" 67 | import { DeclarativeActionsController } from "@crystallized/controllers" 68 | 69 | class TestElement extends LitElement { 70 | actions = new DeclarativeActionsController(this) 71 | 72 | clickMe() { 73 | this.shadowRoot.querySelector("test-msg").textContent = "clicked!" 74 | } 75 | 76 | render() { 77 | return html` 78 | 79 | 80 | ` 81 | } 82 | } 83 | customElements.define("test-element", TestElement) 84 | ``` 85 | 86 | You'll notice that currently nothing actually calls the `clickMe` method. Don't worry! We'll declaratively handle that in our regular HTML template: 87 | 88 | ```html 89 | 90 |
91 | 92 |
93 |
94 | ``` 95 | 96 | The tag name of the component (aka `test-element`) plus `action` sets up the event handler via an action attribute, with the method name `clickMe` being the value of the attribute. This is shorthand for `click#clickMe`. The controller defaults to `click` if no event type is specified (with a few exceptions, such as `submit` for forms and `input` or `change` for various form controls). 97 | 98 | Because `DeclarativeActionsController` uses a [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) to keep an eye on HTML in the DOM, at any time you can update the markup dynamically and actions will work as expected. 99 | 100 | In addition, _actions don't pass component boundaries_. In other words, if you were to add a `test-element` inside of another `test-element`, the action within the nested `test-element` would only call the method for that nested component. 101 | 102 | ### With Vanilla JS 103 | 104 | Using the controller with a vanilla web component is just as straightforward as with Lit: 105 | 106 | ```js 107 | import { Controllable, DeclarativeActionsController } from "@crystallized/controllers" 108 | 109 | const template = Object.assign(document.createElement("template"), { 110 | innerHTML: ` 111 | 112 | 113 | ` 114 | }) 115 | 116 | class TestElement extends Controllable(HTMLElement) { 117 | actions = new DeclarativeActionsController(this) 118 | 119 | constructor() { 120 | super() 121 | 122 | if (!this.shadowRoot) { 123 | this.attachShadow({ mode: "open" }).appendChild(template.content.cloneNode(true)) 124 | } 125 | } 126 | 127 | clickMe() { 128 | this.shadowRoot.querySelector("test-msg").textContent = "clicked!" 129 | } 130 | } 131 | customElements.define("test-element", TestElement) 132 | ``` 133 | 134 | ### Discovering Actions within the Shadow DOM 135 | 136 | By default, actions are only detected within "light DOM" and do not pierce the shadow DOM. To enable using actions within your component's shadow root (useful when you're not using a Lit template), create another controller and use the `shadow: true` option. 137 | 138 | ```js 139 | actions = new DeclarativeActionsController(this) 140 | shadowActions = new DeclarativeActionsController(this, { shadow: true }) 141 | ``` 142 | 143 | For actions declared in the shadow DOM, use the `host-action` attribute: 144 | 145 | ```js 146 | 147 | ``` 148 | 149 | ## Using TargetsController 150 | 151 | A target is a specific element, or elements, in your DOM you would like to access from your component. Like actions, you can specify targets using special HTML attributes. However, you can also target any element directly regardless of markup by using a selector. 152 | 153 | First, let's talk about using targets with the "light DOM". Similarly to the DeclarativeActionsController, you should include the tag name of the component (aka `test-element`) plus `target`, and include an identifier as the attribute value. Then in your targets configuration, you can use the same identifier as the key along with `@` as the value. So for `test-element-target="message"`, you'll add `message: "@"` to the targets config, which then allows you to access the target via `this.message` in your component. 154 | 155 | You can also use a different key than the identifier. For example, `test-element-target="thumbnail"` and a config of `thumbnailImage: "@thumbnail"` would allow `this.thumbnailImage` to access the `thumbnail` target. 156 | 157 | In addition, you can use a CSS-style selector instead. A config of `titleHeading: "h1.title"` would allow `this.titleHeading` to access the first `h1` tag with a `title` class. 158 | 159 | In any case where a matching tag isn't available, you'll get a `null` return value. 160 | 161 | You can also choose to access multiple matching elements for a target. Simply enclose the identifier/selector in an array within your targets config. So `paragraphs: ["p"]` would return an array of paragraph tags. If nothing matches, you'll receive an empty array. 162 | 163 | Here's an example of several target types in action: 164 | 165 | ```js 166 | import { LitElement, html } from "lit" 167 | import { TargetsController } from "@crystallized/controllers" 168 | 169 | class TestElement extends LitElement { 170 | static targets = { 171 | message: "@", 172 | dupMessage: "@message", 173 | extra: ["p.extra"] 174 | } 175 | 176 | targets = new TargetsController(this) 177 | 178 | clickMe() { 179 | this.shadowRoot.querySelector("test-msg").textContent = this.message.textContent + this.dupMessage.textContent + this.extra[0].textContent 180 | } 181 | 182 | render() { 183 | return html` 184 | 185 | 186 | 187 | ` 188 | } 189 | } 190 | customElements.define("test-element", TestElement) 191 | ``` 192 | 193 | ```html 194 | 195 | clicked! 196 |

howdy

197 |
198 | ``` 199 | 200 | In this example, if you click the component's button labeled "Click Me", it will access the `message` target, the `dupMessage` target (which actually happens to reference the same element), and the first element in the `extra` array, and concatenate all text content together to insert `clicked!clicked!howdy` into the `test-msg` element. 201 | 202 | Like with actions, targets don't cross component boundaries. So if you nest Tag B inside of Tag A and they're the same component tag, any targets you try to access in Tag A's code will not be contained within Tag B. 203 | 204 | ### Configuring Targets within the Shadow DOM 205 | 206 | You can choose _only_ to look for targets in your component's shadow root by passing `{ shadow: true }` as an option. If you want to configure both light DOM targets and shadow DOM targets, you'll need to pass the targets configuration explicitly. Here's an example using a vanilla web component: 207 | 208 | ```js 209 | class TestElement extends Controllable(HTMLElement) { 210 | targets = new TargetsController(this, { 211 | targets: { 212 | message: "@" 213 | } 214 | }) 215 | 216 | shadowTargets = new TargetsController(this, { 217 | shadow: true, 218 | targets: { 219 | items: ["li"] 220 | } 221 | }) 222 | 223 | //// 224 | } 225 | ``` 226 | 227 | For targets using `@` identifiers, use `host-target` as the attribute name: 228 | 229 | ```html 230 | Message 231 | ``` 232 | 233 | ## Private Fields 234 | 235 | If you can target modern browsers or use a bundler, you could mark the controller variables private since there's no reason they need to be made available as a public API. 236 | 237 | ```js 238 | class TestElement extends LitElement { 239 | #targets = new TargetsController(this) 240 | #actions = new DeclarativeActionsController(this) 241 | } 242 | ``` 243 | 244 | ## Crystallized Everywhere, All at Once 245 | 246 | We now provide an all-in-one `CrystallizedController` shortcut which is particularly helpful when writing vanilla web components. 247 | 248 | ```js 249 | #crystallized = new CrystallizedController(this) 250 | ``` 251 | 252 | This is equivalent to the following: 253 | 254 | ```js 255 | #actions = new DeclarativeActionsController(this) 256 | #shadowActions = new DeclarativeActionsController(this, { shadow: true }) 257 | #targets = new TargetsController(this, { shadow: true }) 258 | ``` 259 | 260 | ## Using with TypeScript 261 | 262 | While this project wasn't specifically built with TypeScript in mind, it's very easy to set up your TS project to support targets. (There's nothing necessary to set up for actions since they're entirely declarative and HTML-driven.) 263 | 264 | First, in a `types.d.ts` file or something to that effect, add: 265 | 266 | ``` 267 | declare module '@crystallized/controllers'; 268 | ``` 269 | 270 | Next, you'll want to add additional types to your Lit element class underneath the `targets` configuration. For example: 271 | 272 | ```js 273 | static targets = { 274 | message: "@" 275 | } 276 | 277 | message?: HTMLElement; 278 | ``` 279 | 280 | If you want to get more specific about the target element type, just specify the appropriate DOM class name. For example: 281 | 282 | ```js 283 | static targets = { 284 | namefield: "@", 285 | textareas: ["textarea"] 286 | } 287 | 288 | namefield?: HTMLInputElement; // single element 289 | textareas?: HTMLTextAreaElement[]; // array of elements 290 | ``` 291 | 292 | ---- 293 | 294 | ## Testing 295 | 296 | Crystallized uses the [Modern Web Test Runner](https://modern-web.dev/guides/test-runner/getting-started/) and [helpers from Open WC](https://open-wc.org/docs/testing/testing-package/) for its test suite. 297 | 298 | Run `npm run test` to run the test suite, or `npm run test:dev` to watch tests and re-run on every change. 299 | 300 | ## Contributing 301 | 302 | 1. Fork it (https://github.com/whitefusionhq/crystallized/fork) 303 | 2. Create your feature branch (`git checkout -b my-new-feature`) 304 | 3. Commit your changes (`git commit -am 'Add some feature'`) 305 | 4. Push to the branch (`git push origin my-new-feature`) 306 | 5. Create a new Pull Request 307 | 308 | ## License 309 | 310 | MIT 311 | 312 | [npm]: https://img.shields.io/npm/v/@crystallized/controllers.svg?style=for-the-badge 313 | [npm-url]: https://npmjs.com/package/@crystallized/controllers 314 | -------------------------------------------------------------------------------- /packages/controllers/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## Upcoming 6 | 7 | - Change preferred action delimiter to `#`, aka `input#changeValue` (previously was `->`) 8 | 9 | ## [5.0.0](https://github.com/whitefusionhq/crystallized/compare/v4.0.3...v5.0.0) - 2023-02-25 10 | 11 | - Rearchitect package in vanilla JavaScript 12 | - Add support for vanilla web components in addition to Lit 13 | - Add support for actions and targets inside shadow DOM 14 | 15 | ## [4.0.3](https://github.com/whitefusionhq/crystallized/compare/v4.0.2...v4.0.3) (2021-10-05) 16 | 17 | ### Bug Fixes 18 | 19 | * use conditional for wider compatibility ([a17e2fe](https://github.com/whitefusionhq/crystallized/commit/a17e2fe6972c53dfd4722f481d7bfbb139c54531)) 20 | 21 | ## [4.0.2](https://github.com/whitefusionhq/crystallized/compare/v4.0.0...v4.0.2) (2021-10-05) 22 | 23 | ### Bug Fixes 24 | 25 | * don't throw error when node_observer is missing ([c51570c](https://github.com/whitefusionhq/crystallized/commit/c51570c3e38bfedb76b9080abbc633e1b0630753)) 26 | 27 | ## [4.0.0](https://github.com/whitefusionhq/crystallized/compare/v3.0.0...v4.0.0) (2021-10-02) 28 | 29 | ### ⚠ BREAKING CHANGES 30 | 31 | * removed CrystallineElement 32 | 33 | * Migrate package to @crystallized/controllers ([9623916](https://github.com/whitefusionhq/crystallized/commit/96239167de6ece0399ebf10527b4805a3a7fb90f)) 34 | -------------------------------------------------------------------------------- /packages/controllers/LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020-present Jared White 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/controllers/README.md: -------------------------------------------------------------------------------- 1 | # ❄️ Crystallized Controllers 2 | 3 | [![npm][npm]][npm-url] 4 | 5 | This package, as part of the [Crystallized](https://github.com/whitefusionhq/crystallized) project, provides: 6 | 7 | * [`DeclarativeActionsController`](https://github.com/whitefusionhq/crystallized#using-declarativeactionscontroller) - lets you add action attributes to elements as a way of providing declarative event handlers. 8 | 9 | * [`TargetsController`](https://github.com/whitefusionhq/crystallized#using-targetscontroller) - lets you easily query child nodes in the DOM using either selectors or explicit attribute-based identifiers. 10 | 11 | You can use **[Lit](https://lit.dev)** (a library for building fast, reactive web components) along with these controllers, or you can build your own "vanilla" web components and enhance them with the full suite of Crystallized utilities. The entire library is under 7KB (before compression!) and has no dependencies. 12 | 13 | [Documentation is available in the main repo Readme.](https://github.com/whitefusionhq/crystallized) 14 | 15 | ## Contributing 16 | 17 | 1. Fork it (https://github.com/whitefusionhq/crystallized/fork) 18 | 2. Create your feature branch (`git checkout -b my-new-feature`) 19 | 3. Commit your changes (`git commit -am 'Add some feature'`) 20 | 4. Push to the branch (`git push origin my-new-feature`) 21 | 5. Create a new Pull Request 22 | 23 | ## License 24 | 25 | MIT 26 | 27 | [npm]: https://img.shields.io/npm/v/@crystallized/controllers.svg?style=for-the-badge 28 | [npm-url]: https://npmjs.com/package/@crystallized/controllers 29 | -------------------------------------------------------------------------------- /packages/controllers/esbuild.config.js: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild" 2 | 3 | let ctx = await esbuild.context({ 4 | entryPoints: ["src/index.js"], 5 | bundle: true, 6 | format: "esm", 7 | target: "es2022", 8 | outdir: "dist", 9 | plugins: [], 10 | }) 11 | 12 | if (process.argv.includes("--watch")) { 13 | await ctx.watch() 14 | console.log("esbuild watching...") 15 | } else { 16 | await ctx.rebuild() 17 | await ctx.dispose() 18 | } 19 | -------------------------------------------------------------------------------- /packages/controllers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@crystallized/controllers", 3 | "version": "5.0.1", 4 | "description": "A collection of quality-of-life DX enhancements to Lit or vanilla web components", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "type": "module", 8 | "repository": "https://github.com/whitefusionhq/crystallized", 9 | "author": "Jared White", 10 | "license": "MIT", 11 | "private": false, 12 | "engines": { 13 | "node": ">= 14" 14 | }, 15 | "scripts": { 16 | "start": "npm run build -- --watch", 17 | "build": "node esbuild.config.js", 18 | "test": "npm run build && web-test-runner --node-resolve", 19 | "test:dev": "npm run test -- --watch" 20 | }, 21 | "exports": { 22 | ".": "./dist/index.js", 23 | "./src/*": "./src/*" 24 | }, 25 | "files": [ 26 | "dist", 27 | "src" 28 | ], 29 | "dependencies": { 30 | "@crystallized/reactive-property": "1.0.0" 31 | }, 32 | "devDependencies": { 33 | "@open-wc/testing": "^3.1.7", 34 | "@web/test-runner": "^0.15.1", 35 | "@web/test-runner-playwright": "^0.9.0", 36 | "esbuild": "^0.17.10" 37 | }, 38 | "peerDependencies": { 39 | "lit": "^2.0.0" 40 | }, 41 | "prettier": { 42 | "printWidth": 100, 43 | "semi": false 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/controllers/src/DeclarativeActionsController.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Controller to loop through DOM on connection + mutations and find declared actions 3 | */ 4 | class DeclarativeActionsController { 5 | /** 6 | * @param {HTMLElement} host 7 | * @param {{ shadow: boolean } | undefined} options 8 | */ 9 | constructor(host, options) { 10 | /** @type {HTMLElement} */ 11 | this.host = host 12 | /** @type {boolean} */ 13 | this.shadow = options?.shadow || false 14 | /** @type {string} */ 15 | this.nodeName = this.host.localName 16 | 17 | host.addController(this) 18 | } 19 | 20 | /** 21 | * Depending on how the controller was configured, returns either the host element or its shadow root 22 | */ 23 | get target() { 24 | return this.shadow ? this.host.shadowRoot : this.host 25 | } 26 | 27 | /** 28 | * Set up MutationObserver and get ready to look for action definitions 29 | */ 30 | hostConnected() { 31 | this.registeredActions = [] 32 | if (this.shadow) { 33 | this.handleNodeChanges([]) 34 | } else { 35 | this.handleNodeChanges([{ type: "attributes", target: this.host }]) 36 | } 37 | this.nodeObserver = new MutationObserver(this.handleNodeChanges.bind(this)) 38 | let config = { attributes: true, childList: true, subtree: true } 39 | this.nodeObserver.observe(this.target, config) 40 | } 41 | 42 | /** 43 | * Disconnect the MutationObserver 44 | */ 45 | hostDisconnected() { 46 | // For some reason there are cases where this method is called before node_observer has been initialized. 47 | // So we can't assume the value is already present... 48 | if (this.nodeObserver) this.nodeObserver.disconnect() 49 | this.registeredActions = [] 50 | this.registeredActions 51 | } 52 | 53 | /** 54 | * Given an element, returns the default event name (submit, input, change, click) 55 | * 56 | * @param {HTMLElement} node 57 | * @returns {string} 58 | */ 59 | defaultActionForNode(node) { 60 | switch (node.nodeName.toLowerCase()) { 61 | case "form": 62 | return "submit" 63 | 64 | case "input": 65 | case "textarea": 66 | return node.getAttribute("type") == "submit" ? "click" : "input" 67 | 68 | case "select": 69 | return "change" 70 | 71 | default: 72 | return "click" 73 | } 74 | } 75 | 76 | /** 77 | * Returns the appropriate action delimiter to use 78 | * 79 | * @param {string} str 80 | */ 81 | actionPairDelimiter(str) { 82 | if (str.includes("->")) { 83 | return "->" 84 | } else { 85 | return "#" 86 | } 87 | } 88 | 89 | /** 90 | * Callback for MutationObserver 91 | * 92 | * @param {MutationRecord[]} changes 93 | */ 94 | handleNodeChanges(changes) { 95 | const actionAttr = this.shadow ? "host-action" : `${this.nodeName}-action` 96 | 97 | /** 98 | * Function to set up event listeners 99 | * 100 | * @param {HTMLElement} node 101 | * @param {boolean} onlyHostNode 102 | */ 103 | let setupListener = (node, onlyHostNode) => { // TODO: relocate to separate method 104 | // prettier-ignore 105 | if ( 106 | !onlyHostNode && 107 | Array.from( 108 | this.target.querySelectorAll(this.nodeName) 109 | ).filter((el) => el.contains(node)).length > 0 110 | ) 111 | return 112 | 113 | if (node.hasAttribute(actionAttr)) { 114 | for (let actionPair of node.getAttribute(actionAttr).split(" ")) { 115 | let [actionEvent, actionName] = actionPair.split(this.actionPairDelimiter(actionPair)) 116 | 117 | if (typeof actionName === "undefined") { 118 | actionName = actionEvent 119 | actionEvent = this.defaultActionForNode(node) 120 | } 121 | 122 | actionEvent = actionEvent.trim() 123 | 124 | if ( 125 | this.registeredActions.find( 126 | (action) => 127 | action.node == node && action.event == actionEvent && action.name == actionName 128 | ) 129 | ) 130 | continue 131 | 132 | if (this.host[actionName]) { 133 | node.addEventListener(actionEvent, this.host[actionName].bind(this.host)) 134 | 135 | this.registeredActions.push({ 136 | node, 137 | event: actionEvent, 138 | name: actionName, 139 | }) 140 | } else { 141 | // TODO: should we log a warning? 142 | } 143 | } 144 | } 145 | } 146 | 147 | if (!this.nodeObserver) { 148 | // It's a first run situation, so check all child nodes 149 | for (let node of this.target.querySelectorAll(`[${actionAttr}]`)) { 150 | setupListener(node, false) 151 | } 152 | } 153 | 154 | // Loop through all the mutations 155 | for (let change of changes) { 156 | if (change.type == "childList") { 157 | for (let node of change.addedNodes) { 158 | if (node.nodeType != 1) continue 159 | setupListener(node, false) 160 | 161 | for (let insideNode of node.querySelectorAll(`[${actionAttr}]`)) { 162 | setupListener(insideNode, false) 163 | } 164 | } 165 | } else if (change.type == "attributes") { 166 | setupListener(change.target, true) 167 | } 168 | } 169 | } 170 | 171 | /** 172 | * Handles a particular sort of Lit-specific lifecycle 173 | */ 174 | hostUpdated() { 175 | if (!this.firstUpdated) { 176 | this.handleNodeChanges([ 177 | { 178 | type: "childList", 179 | addedNodes: this.target.querySelectorAll("*"), 180 | removedNodes: [], 181 | }, 182 | ]) 183 | 184 | this.firstUpdated = true 185 | } 186 | } 187 | } 188 | 189 | export default DeclarativeActionsController 190 | -------------------------------------------------------------------------------- /packages/controllers/src/TargetsController.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Targets controller lets you query for elements in the DOM 3 | */ 4 | class TargetsController { 5 | /** 6 | * @param {HTMLElement} host 7 | * @param {{ targets?: any, shadow?: boolean } | undefined} options 8 | */ 9 | constructor(host, options) { 10 | /** @type {HTMLElement} */ 11 | this.host = host 12 | /** @type {boolean} */ 13 | this.shadow = options?.shadow || false 14 | /** @type {string} */ 15 | this.nodeName = this.host.localName 16 | 17 | const targets = options?.targets || this.host.constructor.targets 18 | 19 | // Add queries as instance properties 20 | if (targets) { 21 | for (let [name, selector] of Object.entries(targets)) { 22 | if (Array.isArray(selector)) { 23 | selector = this.targetizedSelector(name, selector[0]) 24 | 25 | Object.defineProperty(this.host, name, { 26 | get: () => 27 | Array.from(this.target.querySelectorAll(selector)).filter( 28 | (node) => this.shadow || node.closest(this.nodeName) == this.host 29 | ), 30 | }) 31 | } else { 32 | selector = this.targetizedSelector(name, selector) 33 | 34 | Object.defineProperty(this.host, name, { 35 | get: () => { 36 | const node = this.target.querySelector(selector) 37 | return node && (this.shadow || node.closest(this.nodeName) == this.host) ? node : null 38 | }, 39 | }) 40 | } 41 | } 42 | } 43 | } 44 | 45 | /** 46 | * Depending on how the controller was configured, returns either the host element or its shadow root 47 | */ 48 | get target() { 49 | return this.shadow ? this.host.shadowRoot : this.host 50 | } 51 | 52 | targetizedSelector(name, selector) { 53 | const prefix = this.shadow ? "host-target" : `${this.nodeName}-target` 54 | if (selector == "@") { 55 | return `*[${prefix}='${name}']` 56 | } else { 57 | return selector.replace(/@([a-z-]+)/g, `[${prefix}='$1']`) 58 | } 59 | } 60 | } 61 | 62 | export default TargetsController 63 | -------------------------------------------------------------------------------- /packages/controllers/src/index.js: -------------------------------------------------------------------------------- 1 | import DeclarativeActionsController from "./DeclarativeActionsController.js" 2 | import TargetsController from "./TargetsController.js" 3 | 4 | /** 5 | * @template T 6 | * @typedef {new(...args: any[]) => T} Constructor 7 | **/ 8 | 9 | /** 10 | * @template {Constructor<{}>} T 11 | * @param {T} Base 12 | */ 13 | export const Controllable = (Base) => { 14 | return class Controllable extends Base { 15 | addController(controller) { 16 | ;(this.__controllers ??= []).push(controller) 17 | if (this.initialized && this.isConnected) { 18 | controller.hostConnected?.() 19 | } 20 | } 21 | 22 | connectedCallback() { 23 | this.__controllers?.forEach((c) => c.hostConnected?.()) 24 | this.initialized = true 25 | } 26 | 27 | disconnectedCallback() { 28 | this.__controllers?.forEach((c) => c.hostDisconnected?.()) 29 | this.initialized = false 30 | } 31 | 32 | attributeChangedCallback(name, oldValue, newValue) { 33 | this.__controllers?.forEach((c) => c.hostAttributeChanged?.(name, oldValue, newValue)) 34 | } 35 | } 36 | } 37 | 38 | export class CrystallizedController { 39 | constructor(host) { 40 | this.host = host 41 | this.actions = new DeclarativeActionsController(host) 42 | this.shadowActions = new DeclarativeActionsController(host, { shadow: true }) 43 | this.targets = new TargetsController(host, { shadow: true }) 44 | } 45 | } 46 | 47 | export { DeclarativeActionsController, TargetsController } 48 | -------------------------------------------------------------------------------- /packages/controllers/test/DeclarativeActionsController.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, assert, aTimeout, html as testhtml } from "@open-wc/testing" 2 | import { LitElement, html } from "lit" 3 | 4 | import { DeclarativeActionsController } from "../dist" 5 | 6 | // Fixtures 7 | 8 | class TestElement extends LitElement { 9 | actions = new DeclarativeActionsController(this) 10 | shadowActions = new DeclarativeActionsController(this, { shadow: true }) 11 | 12 | clickMe() { 13 | this.shadowRoot.querySelector("test-msg").textContent = "clicked!" 14 | } 15 | 16 | shadowClick() { 17 | this.shadowRoot.querySelector("test-msg").textContent = "via shadow" 18 | } 19 | 20 | render() { 21 | return html` 22 | 23 | 24 | 25 | ` 26 | } 27 | } 28 | customElements.define("test-element", TestElement) 29 | 30 | // Tests 31 | 32 | describe("DeclarativeActionsController", () => { 33 | it("handles click properly", async () => { 34 | const el = await fixture(testhtml` 35 | 36 |
37 | 38 |
39 |
40 | `) 41 | 42 | el.querySelector("button").click() 43 | assert.equal(el.shadowRoot.querySelector("test-msg").textContent, "clicked!") 44 | }) 45 | 46 | it("handles click in the shadow DOM", async () => { 47 | const el = await fixture(testhtml` 48 | 49 | `) 50 | 51 | await aTimeout(100) 52 | 53 | el.shadowRoot.querySelector("button").click() 54 | assert.equal(el.shadowRoot.querySelector("test-msg").textContent, "via shadow") 55 | }) 56 | 57 | it("handles mutations properly", async () => { 58 | const el = await fixture(testhtml` 59 | 60 |
61 |
62 | `) 63 | 64 | await aTimeout(100) 65 | 66 | el.querySelector("article").innerHTML = '' 67 | await aTimeout(50) // ensure mutation handlers are run 68 | el.querySelector("button").click() 69 | 70 | assert.equal(el.shadowRoot.querySelector("test-msg").textContent, "clicked!") 71 | }) 72 | 73 | it("handles nesting properly", async () => { 74 | const el = await fixture(testhtml` 75 | 76 |
77 | 78 |
79 | 80 | 81 | 82 | 83 |
84 | `) 85 | 86 | el.querySelector("#nested button").click() 87 | assert.equal(el.querySelector("#nested").shadowRoot.querySelector("test-msg").textContent, "clicked!") 88 | assert.notEqual(el.shadowRoot.querySelector("test-msg").textContent, "clicked!") 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /packages/controllers/test/TargetsController.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, assert, aTimeout, html as testhtml } from "@open-wc/testing" 2 | import { LitElement, html } from "lit" 3 | 4 | import { TargetsController } from "../dist" 5 | 6 | // Fixtures 7 | 8 | class TestElement extends LitElement { 9 | targets = new TargetsController(this) 10 | 11 | static get targets() { 12 | return { 13 | message: "@", 14 | dupMessage: "@message", 15 | extra: ["extra-message"], 16 | } 17 | } 18 | 19 | clickMe() { 20 | this.shadowRoot.querySelector("test-msg").textContent = 21 | this.message.textContent + this.dupMessage.textContent + this.extra[0].textContent 22 | } 23 | 24 | render() { 25 | return html` 26 | 27 | 28 | 29 | ` 30 | } 31 | } 32 | customElements.define("test-element", TestElement) 33 | 34 | class NestedTargetsComponent extends LitElement { 35 | targets = new TargetsController(this) 36 | shadowTargets = new TargetsController(this, { 37 | shadow: true, 38 | targets: { 39 | message: "@" 40 | } 41 | }) 42 | 43 | static get targets() { 44 | return { 45 | button: "button", 46 | buttons: ["button"], 47 | } 48 | } 49 | 50 | render() { 51 | return html` 52 | 53 | Message 54 | ` 55 | } 56 | } 57 | customElements.define("targets-component", NestedTargetsComponent) 58 | 59 | // Tests 60 | 61 | describe("TargetsController", () => { 62 | it("finds the right elements", async () => { 63 | const el = await fixture(testhtml` 64 | 65 | clicked! 66 | howdy 67 | 68 | `) 69 | 70 | el.shadowRoot.querySelector("button").click() 71 | assert.equal(el.shadowRoot.querySelector("test-msg").textContent, "clicked!clicked!howdy") 72 | }) 73 | 74 | it("supports nested targets", async () => { 75 | const el = await fixture(testhtml` 76 | 77 | 78 | 79 | 80 | 81 | 82 | `) 83 | 84 | assert.equal(el.button, el.querySelector("#outer")) 85 | assert.equal(el.buttons.length, 1) 86 | assert.equal(el.buttons[0], el.querySelector("#outer")) 87 | 88 | const nestedEl = el.querySelector("targets-component") 89 | assert.equal(nestedEl.buttons.length, 1) 90 | assert.equal(nestedEl.buttons[0], el.querySelector("#inner")) 91 | 92 | assert.equal(nestedEl.message.textContent, "Message") 93 | nestedEl.message.textContent = "Message received" 94 | assert.equal(nestedEl.message.textContent, "Message received") 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /packages/controllers/web-test-runner.config.mjs: -------------------------------------------------------------------------------- 1 | import { playwrightLauncher } from "@web/test-runner-playwright" 2 | 3 | export default { 4 | browsers: [ 5 | playwrightLauncher({ product: "chromium" }), 6 | playwrightLauncher({ product: "firefox" }), 7 | playwrightLauncher({ product: "webkit" }), 8 | ], 9 | files: "test/**/*.test.js", 10 | } 11 | -------------------------------------------------------------------------------- /packages/reactive-property/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## 1.0.0 - 2023-03-19 6 | 7 | Initial release. -------------------------------------------------------------------------------- /packages/reactive-property/LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023-present Jared White 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/reactive-property/README.md: -------------------------------------------------------------------------------- 1 | # ❄️ Crystallized: ReactiveProperty 2 | 3 | [![npm][npm]][npm-url] 4 | 5 | A tiny library for data parsing and reactive sync for an element attribute/property using [Signals](https://github.com/preactjs/signals). Part of the Crystallized project. 6 | 7 | ## Installation 8 | 9 | ```sh 10 | npm i @crystallized/reactive-property @preact/signals-core 11 | ``` 12 | 13 | or 14 | 15 | ```sh 16 | yarn add @crystallized/reactive-property @preact/signals-core 17 | ``` 18 | 19 | ## Rationale 20 | 21 | The `ReactiveProperty` class takes advantage of fine-grained reactivity using Signals\* to solve a state problem often encountered in building vanilla web components. Here's an example of what we're dealing with: 22 | 23 | ```html 24 | 25 | 1 26 | 27 | 28 | 29 | ``` 30 | 31 | It's a typical "counter" example: click the + or - buttons, see the counter update. 32 | 33 | In component-speak, we see here that `count` is a prop of the `my-counter` component. In this particular instance it's set to `1`. Even with this very simple example, we run into an immediate conundrum: 34 | 35 | * The type of the `count` attribute is not `1`. It's `"1"` (a string). So the simplistic JavaScript code `"1" + 1` doesn't result in `2`, it's `11`. You need to parse the attribute's string value to a number before using it as a property value. 36 | * You need to build getters/setters so `myCounterInstance.count` is available through the component API. 37 | * In most cases, prop mutations should reflect back to the attribute. `myCounterInstance.count = 10` should result in `count="10"` on the HTML attribute. Generally this means serializing values to strings, but in some cases it means removing the attribute. `el.booleanProp = false` shouldn't result in a `booleanprop="false"` attribute but should remove `booleanprop` entirely. 38 | * Handling both attributes and properties with value parsing and reflection isn't the end of it. You also need to avoid an infinite loop (aka setting a property reflects the attribute which then sets the property which then reflects the attribute which then…). 39 | * _And_ to top it all off, you need to be able to observe attribute/prop changes in order to do something—the side effect of the mutation. 40 | 41 | Given all this, you're left with two choices: 42 | 43 | 1. You can build all of this boilerplate yourself and include that custom code in all of your web components. Because obviously trying to write this over and over by hand is a real PITA! (Believe me, I've done it! I know!) 44 | 2. You can reach for a helpful web component library which does this all for you. 😎 45 | 46 | Unfortunately, the second option typically doesn't mean reaching for a library which _only_ solves these problems, but one which attempts to address a host of other problems (templates, rendering lifecycles, and various other UI component model considerations). 47 | 48 | Personally, I tend to like libraries which **do one thing and one thing only—well**. ReactiveProperty doesn't care about templates. Doesn't care about rendering lifecycles. Doesn't care about element base classes or mixins or controllers or hooks or any of that. 49 | 50 | **All it does it give you reactive properties. Boom. Done** 51 | 52 | And it does this thanks to the amazing new Signals library from the folks at Preact. 53 | 54 | \* Signals is _not_ part of Preact. It's a very simple, small, zero-dependency library. It's usable _within_ React as well as any other framework. That's why you can easily use it with any vanilla JS code! And because ReactiveProperty only uses a fraction of the Preact Signals API, you can even opt for a different signals library as long as the interface is the same (aka provides a `value` getter/setter and a `subscribe` method for a watch callback). 55 | 56 | ## Usage 57 | 58 | ```js 59 | import { signal, effect } from "@preact/signals-core" 60 | import { ReactiveProperty } from "@crystallized/reactive-property" 61 | 62 | class MyCounter extends HTMLElement { 63 | static observedAttributes = ["count"] 64 | 65 | constructor() { 66 | super() 67 | 68 | // Set up some reactive property definitions 69 | this.attributeProps = { 70 | "count": new ReactiveProperty( // Add a reactive property for the `count` attribute 71 | this, // pass a reference to this element instance 72 | signal(0), // create a signal with an initial value 73 | { 74 | name: "count", // the name of the property 75 | // attribute: "count-attr", // if you need a different attribute name (make sure object key matches!) 76 | // reflect: false, // turn off the prop->attribute reflection if need be 77 | } 78 | ) 79 | } 80 | } 81 | 82 | connectedCallback() { 83 | setTimeout(() => { // I always wait a beat so the DOM tree is fully connected 84 | this.querySelector("#inc").addEventListener("click", () => this.count++) 85 | this.querySelector("#dec").addEventListener("click", () => this.count--) 86 | 87 | // Whenever the `count` value is mutated, update the DOM accordingly 88 | this._disposeEffect = effect(() => { 89 | const countValue = this.count // set up subscription 90 | 91 | // We'll only re-render once the component has "resumed", since on first run the HTML is 92 | // already server-rendered and present in the DOM 93 | if (this.resumed) this.querySelector("output").textContent = countValue 94 | }) 95 | 96 | // Allow any future changes to trigger a re-render 97 | this.resumed = true 98 | }) 99 | } 100 | 101 | disconnectedCallback() { 102 | // Dispose of the rendering effect since the element's been removed from the DOM 103 | this._disposeEffect?.() 104 | } 105 | 106 | attributeChangedCallback(name, oldValue, newValue) { 107 | this.attributeProps[name]?.refreshFromAttribute(newValue) 108 | } 109 | } 110 | 111 | customElements.define("my-counter", MyCounter) 112 | ``` 113 | 114 | And a reminder of the HTML again: 115 | 116 | ```html 117 | 118 | 1 119 | 120 | 121 | 122 | ``` 123 | 124 | What I love about this example is you can read the code _and immediately understand what is happening_. What ends up happening may feel a bit magical, but there's really no magic at all. 125 | 126 | Under the hood, there's a `count` signal which we've initialized with `0`. `this.count++` is effectively `prop.signal.value = prop.signal.value + 1` and `this.count--` is effectively `prop.signal.value = prop.signal.value - 1`. The side effect function we've written will take that value and update the `` element accordingly whenever the `count` attribute/property changes. 127 | 128 | ReactiveProperty knows that the initial value of the property is a number type, so it always typecasts attribute changes from strings to numbers. Same for booleans (`true/false`), arrays (`[]`), and objects (`{}`). Strings of course are easiest to deal with. 129 | 130 | So whether the `count` attribute is set/updated through HTML-based APIs, or the `count` prop is set/updated through JS-based APIs, the signal value is always updated accordingly, and that then will trigger your side-effect. 131 | 132 | And because you're using Signals, you can take advantage of computed values as well which unlocks a whole new arena of power: 133 | 134 | ```js 135 | import { signal, computed, effect } from "@preact/signals-core" 136 | 137 | // add to the bottom of your `constructor`: 138 | this.times100Signal = computed(() => this.count * 100 ) 139 | ``` 140 | 141 | Now every time the `count` prop mutates, the `this.times100Signal.value` signal will equal that number times one hundred. And you can access `this.times100Signal.value` directly in an effect to update UI with that value also! 142 | 143 | You can set up multiple effects to handle different part of your component UI, which is why this approach is termed "fine-grained" reactivity. Instead of a giant `render` method where your component has to supply a template handling all of the data your component supports, you can react to data updates in effects and surgically alter the DOM only when and where needed. And the potential is high for an additional, slightly-more-abstract library to take advantage of this to provide markup-based, declarative bindings between DOM and reactive data. 144 | 145 | Hmm… 🤔 146 | 147 | ---- 148 | 149 | ## Testing 150 | 151 | Crystallized uses the [Modern Web Test Runner](https://modern-web.dev/guides/test-runner/getting-started/) and [helpers from Open WC](https://open-wc.org/docs/testing/testing-package/) for its test suite. 152 | 153 | Run `npm run test` to run the test suite, or `npm run test:dev` to watch tests and re-run on every change. 154 | 155 | ## Contributing 156 | 157 | 1. Fork it (https://github.com/whitefusionhq/crystallized/fork) 158 | 2. Create your feature branch (`git checkout -b my-new-feature`) 159 | 3. Commit your changes (`git commit -am 'Add some feature'`) 160 | 4. Push to the branch (`git push origin my-new-feature`) 161 | 5. Create a new Pull Request 162 | 163 | ## License 164 | 165 | MIT 166 | 167 | [npm]: https://img.shields.io/npm/v/@crystallized/reactive-property.svg?style=for-the-badge 168 | [npm-url]: https://npmjs.com/package/@crystallized/reactive-property 169 | -------------------------------------------------------------------------------- /packages/reactive-property/esbuild.config.js: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild" 2 | 3 | let ctx = await esbuild.context({ 4 | entryPoints: ["src/index.js"], 5 | bundle: true, 6 | format: "esm", 7 | target: "es2022", 8 | outdir: "dist", 9 | plugins: [], 10 | }) 11 | 12 | if (process.argv.includes("--watch")) { 13 | await ctx.watch() 14 | console.log("esbuild watching...") 15 | } else { 16 | await ctx.rebuild() 17 | await ctx.dispose() 18 | } 19 | -------------------------------------------------------------------------------- /packages/reactive-property/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@crystallized/reactive-property", 3 | "version": "1.0.0", 4 | "description": "Data parsing and reactive sync for an element attribute/property using Signals.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "type": "module", 8 | "repository": "https://github.com/whitefusionhq/crystallized", 9 | "author": "Jared White", 10 | "license": "MIT", 11 | "private": false, 12 | "engines": { 13 | "node": ">= 14" 14 | }, 15 | "scripts": { 16 | "start": "npm run build -- --watch", 17 | "build": "node esbuild.config.js", 18 | "test": "npm run build && web-test-runner --node-resolve", 19 | "test:dev": "npm run test -- --watch" 20 | }, 21 | "exports": { 22 | ".": "./dist/index.js", 23 | "./src/*": "./src/*" 24 | }, 25 | "files": [ 26 | "dist", 27 | "src" 28 | ], 29 | "devDependencies": { 30 | "@open-wc/testing": "^3.1.7", 31 | "@web/test-runner": "^0.15.1", 32 | "@web/test-runner-playwright": "^0.9.0", 33 | "esbuild": "^0.17.10" 34 | }, 35 | "peerDependencies": { 36 | "@preact/signals-core": "^1.2.3" 37 | }, 38 | "prettier": { 39 | "printWidth": 100, 40 | "semi": false 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/reactive-property/src/ReactiveProperty.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef { import("@preact/signals-core").Signal } Signal 3 | */ 4 | 5 | class ReactiveProperty { 6 | /** 7 | * 8 | * @param {HTMLElement} element 9 | * @param {Signal} signal 10 | * @param {{ name: string, attribute?: string, reflect?: boolean }} options 11 | */ 12 | constructor(element, signal, options) { 13 | /** @type {HTMLElement} */ 14 | this.element = element 15 | /** @type {Signal} */ 16 | this.signal = signal 17 | /** @type {string} */ 18 | this.name = options.name 19 | /** @type {string} */ 20 | this.attribute = options.attribute || options.name 21 | /** @type {string | null} */ 22 | this.type = null 23 | 24 | if (options.reflect != false) this.setupReflection() 25 | 26 | Object.defineProperty(element, options.name, { 27 | get() { 28 | return signal.value 29 | }, 30 | set(value) { 31 | signal.value = value 32 | }, 33 | }) 34 | } 35 | 36 | /** 37 | * Sets up the signal subscription so when the property value changes, the attribute reflects a 38 | * string value (or the attribute is removed for null/false) 39 | */ 40 | setupReflection() { 41 | if (!this.reflects) { 42 | this.reflects = true 43 | this._signalling = true 44 | 45 | this.signal.subscribe((value) => { 46 | if (this._signalling) return 47 | 48 | this._inCallback = true 49 | 50 | if (Array.isArray(value) || (value !== null && typeof value === "object")) { 51 | this.element.setAttribute(this.attribute, JSON.stringify(value)) 52 | } else if (value == null || value === false) { 53 | this.element.removeAttribute(this.attribute) 54 | } else if (!value) { 55 | this.element.setAttribute(this.attribute, "") 56 | } else { 57 | this.element.setAttribute(this.attribute, value) 58 | } 59 | 60 | this._inCallback = false 61 | }) 62 | 63 | this._signalling = false 64 | } 65 | } 66 | 67 | /** 68 | * Parses a string attribute value and attempts to set the signal value accordingly 69 | * 70 | * @param value {string | undefined} - you can directly pass in the attribute value, or let it get 71 | * read in from the element 72 | */ 73 | refreshFromAttribute(value) { 74 | const newValue = 75 | typeof value === "undefined" ? this.element.getAttribute(this.attribute) : value 76 | 77 | if (this._inCallback) return 78 | 79 | this._signalling = true 80 | 81 | if (this.type === "boolean" || (this.type == null && typeof this.signal.peek() === "boolean")) { 82 | this.type = "boolean" 83 | this.signal.value = !!newValue 84 | } else if ( 85 | this.type === "number" || 86 | (this.type == null && typeof this.signal.peek() === "number") 87 | ) { 88 | this.type = "number" 89 | this.signal.value = Number(newValue == null ? null : newValue) 90 | } else if ( 91 | this.type === "object" || 92 | (this.type == null && 93 | (Array.isArray(this.signal.peek()) || typeof this.signal.peek() === "object")) 94 | ) { 95 | this.type = "object" 96 | try { 97 | this.signal.value = newValue ? JSON.parse(newValue) : this.signal.peek().constructor() 98 | } catch (ex) { 99 | console.warn(`${ex.message} for ${this.element.localName}[${this.attribute}]`) 100 | this.signal.value = this.signal.peek().constructor() 101 | } 102 | } else { 103 | this.type = "string" 104 | this.signal.value = newValue 105 | } 106 | 107 | this._signalling = false 108 | } 109 | } 110 | 111 | export default ReactiveProperty 112 | -------------------------------------------------------------------------------- /packages/reactive-property/src/index.js: -------------------------------------------------------------------------------- 1 | import ReactiveProperty from "./ReactiveProperty.js" 2 | 3 | export { ReactiveProperty } -------------------------------------------------------------------------------- /packages/reactive-property/test/ReactiveProperty.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, assert, aTimeout, html as testhtml } from "@open-wc/testing" 2 | import { signal, effect } from "@preact/signals-core" 3 | 4 | import { ReactiveProperty } from "../dist" 5 | 6 | class TestElement extends HTMLElement { 7 | static observedAttributes = ["str", "unreflected-str", "num", "arr", "obj"] 8 | 9 | static { 10 | customElements.define("test-element", this) 11 | } 12 | 13 | constructor() { 14 | super() 15 | 16 | this.attachShadow({ mode: "open" }) 17 | this.shadowRoot.append(document.createElement("p")) 18 | 19 | this.attributeProps = {} 20 | 21 | this.attributeProps["str"] = new ReactiveProperty(this, signal("yo"), { 22 | name: "str", 23 | }) 24 | 25 | this.attributeProps["unreflected-str"] = new ReactiveProperty(this, signal(""), { 26 | name: "unreflectedStr", 27 | attribute: "unreflected-str", 28 | reflect: false, 29 | }) 30 | 31 | this.attributeProps["num"] = new ReactiveProperty(this, signal(0), { 32 | name: "num", 33 | }) 34 | 35 | this.attributeProps["arr"] = new ReactiveProperty(this, signal([]), { 36 | name: "arr", 37 | }) 38 | 39 | this.attributeProps["obj"] = new ReactiveProperty(this, signal({}), { 40 | name: "obj", 41 | }) 42 | 43 | effect(() => { 44 | this.shadowRoot.querySelector("p").textContent = this.str.toUpperCase() 45 | }) 46 | } 47 | 48 | attributeChangedCallback(name, oldValue, newValue) { 49 | this.attributeProps[name]?.refreshFromAttribute(newValue) 50 | } 51 | } 52 | 53 | describe("ReactiveProperty", () => { 54 | context("string value", () => { 55 | it("uses data from the attribute", async () => { 56 | const el = await fixture(testhtml` 57 | 58 | `) 59 | assert.strictEqual(el.str, "yo") 60 | 61 | const el2 = await fixture(testhtml` 62 | 63 | `) 64 | assert.strictEqual(el2.str, "Hello World") 65 | }) 66 | 67 | it("reacts to attribute changes", async () => { 68 | const el = await fixture(testhtml` 69 | 70 | `) 71 | assert.strictEqual(el.str, "yo") 72 | 73 | el.setAttribute("str", "From Attribute") 74 | assert.strictEqual(el.str, "From Attribute") 75 | }) 76 | 77 | it("reflects back by default", async () => { 78 | const el = await fixture(testhtml` 79 | 80 | `) 81 | el.str = "setting the property" 82 | 83 | assert.strictEqual(el.getAttribute("str"), "setting the property") 84 | }) 85 | 86 | it("doesn't reflect when option is set", async () => { 87 | const el = await fixture(testhtml` 88 | 89 | `) 90 | el.unreflectedStr = "setting the property" 91 | 92 | assert.strictEqual(el.getAttribute("unreflected-str"), null) 93 | 94 | el.setAttribute("unreflected-str", "From Attribute") 95 | assert.strictEqual(el.unreflectedStr, "From Attribute") 96 | }) 97 | 98 | it("works fine with effects", async () => { 99 | const el = await fixture(testhtml` 100 | 101 | `) 102 | assert.strictEqual(el.shadowRoot.firstElementChild.textContent, "YO") 103 | 104 | el.str = "all upper case" 105 | assert.strictEqual(el.shadowRoot.firstElementChild.textContent, "ALL UPPER CASE") 106 | }) 107 | }) 108 | 109 | context("number value", () => { 110 | it("uses data from the attribute", async () => { 111 | const el = await fixture(testhtml` 112 | 113 | `) 114 | assert.strictEqual(el.num, 0) 115 | 116 | const el2 = await fixture(testhtml` 117 | 118 | `) 119 | assert.strictEqual(el2.num, 35_000) 120 | }) 121 | 122 | it("reacts to attribute changes", async () => { 123 | const el = await fixture(testhtml` 124 | 125 | `) 126 | assert.strictEqual(el.num, 0) 127 | 128 | el.setAttribute("num", "12345") 129 | assert.strictEqual(el.num, 12345) 130 | 131 | el.setAttribute("num", "") 132 | assert.strictEqual(el.num, 0) 133 | 134 | el.setAttribute("num", "1") 135 | assert.strictEqual(el.num, 1) 136 | 137 | el.removeAttribute("num") 138 | assert.strictEqual(el.num, 0) 139 | }) 140 | 141 | it("reflects back", async () => { 142 | const el = await fixture(testhtml` 143 | 144 | `) 145 | el.num = 112233 146 | 147 | assert.strictEqual(el.getAttribute("num"), "112233") 148 | }) 149 | }) 150 | 151 | context("array value", () => { 152 | it("uses data from the attribute", async () => { 153 | const el = await fixture(testhtml` 154 | 155 | `) 156 | assert.deepEqual(el.arr, []) 157 | 158 | const el2 = await fixture(testhtml` 159 | 160 | `) 161 | assert.deepEqual(el2.arr, [1, 2, 3]) 162 | }) 163 | 164 | it("reacts to attribute changes", async () => { 165 | const el = await fixture(testhtml` 166 | 167 | `) 168 | assert.deepEqual(el.arr, []) 169 | 170 | el.setAttribute("arr", '["1", 2]') 171 | assert.deepEqual(el.arr, ["1", 2]) 172 | 173 | el.setAttribute("arr", "") 174 | assert.deepEqual(el.arr, []) 175 | 176 | el.setAttribute("arr", "[1]") 177 | assert.deepEqual(el.arr, [1]) 178 | 179 | el.removeAttribute("arr") 180 | assert.deepEqual(el.arr, []) 181 | }) 182 | 183 | it("reflects back", async () => { 184 | const el = await fixture(testhtml` 185 | 186 | `) 187 | el.arr = [1, 2, "3"] 188 | 189 | assert.deepEqual(el.getAttribute("arr"), '[1,2,"3"]') 190 | }) 191 | }) 192 | 193 | context("object value", () => { 194 | it("uses data from the attribute", async () => { 195 | const el = await fixture(testhtml` 196 | 197 | `) 198 | assert.deepEqual(el.obj, {}) 199 | 200 | const el2 = await fixture(testhtml` 201 | 202 | `) 203 | assert.deepEqual(el2.obj, { foo: "bar" }) 204 | }) 205 | 206 | it("reacts to attribute changes", async () => { 207 | const el = await fixture(testhtml` 208 | 209 | `) 210 | assert.deepEqual(el.obj, {}) 211 | 212 | el.setAttribute("obj", '{"one": 1}') 213 | assert.deepEqual(el.obj, { one: 1 }) 214 | 215 | el.setAttribute("obj", "") 216 | assert.deepEqual(el.obj, {}) 217 | 218 | el.setAttribute("obj", '{"two": 2}') 219 | assert.deepEqual(el.obj, { two: 2 }) 220 | 221 | el.removeAttribute("obj") 222 | assert.deepEqual(el.obj, {}) 223 | }) 224 | 225 | it("reflects back", async () => { 226 | const el = await fixture(testhtml` 227 | 228 | `) 229 | el.obj = { three: [1, 2, 3] } 230 | 231 | assert.deepEqual(el.getAttribute("obj"), '{"three":[1,2,3]}') 232 | 233 | el.obj = { ...el.obj, name: "Worf" } 234 | 235 | assert.deepEqual(el.getAttribute("obj"), '{"three":[1,2,3],"name":"Worf"}') 236 | 237 | delete el.obj.three 238 | el.obj = { ...el.obj } 239 | 240 | assert.deepEqual(el.getAttribute("obj"), '{"name":"Worf"}') 241 | }) 242 | }) 243 | }) 244 | -------------------------------------------------------------------------------- /packages/reactive-property/web-test-runner.config.mjs: -------------------------------------------------------------------------------- 1 | import { playwrightLauncher } from "@web/test-runner-playwright" 2 | 3 | export default { 4 | browsers: [ 5 | playwrightLauncher({ product: "chromium" }), 6 | playwrightLauncher({ product: "firefox" }), 7 | playwrightLauncher({ product: "webkit" }), 8 | ], 9 | files: "test/**/*.test.js", 10 | } 11 | -------------------------------------------------------------------------------- /packages/shadow-effects/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## 0.6.0 - 2023-04-01 6 | 7 | - Add opt-in feature to process light DOM children too 8 | 9 | ## 0.5.0 - 2023-04-01 10 | 11 | Initial release. -------------------------------------------------------------------------------- /packages/shadow-effects/LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023-present Jared White 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/shadow-effects/README.md: -------------------------------------------------------------------------------- 1 | # ❄️ Crystallized: ShadowEffects 2 | 3 | [![npm][npm]][npm-url] 4 | 5 | A tiny library for handling declarative effects in shadow DOM HTML using [Signals](https://github.com/preactjs/signals). Part of the Crystallized project. 6 | 7 | ## Installation 8 | 9 | ```sh 10 | npm i @crystallized/shadow-effects @preact/signals-core 11 | ``` 12 | 13 | or 14 | 15 | ```sh 16 | yarn add @crystallized/shadow-effects @preact/signals-core 17 | ``` 18 | 19 | ## Usage 20 | 21 | docs coming soon… 22 | 23 | ---- 24 | 25 | ## Testing 26 | 27 | Crystallized uses the [Modern Web Test Runner](https://modern-web.dev/guides/test-runner/getting-started/) and [helpers from Open WC](https://open-wc.org/docs/testing/testing-package/) for its test suite. 28 | 29 | Run `npm run test` to run the test suite, or `npm run test:dev` to watch tests and re-run on every change. 30 | 31 | ## Contributing 32 | 33 | 1. Fork it (https://github.com/whitefusionhq/crystallized/fork) 34 | 2. Create your feature branch (`git checkout -b my-new-feature`) 35 | 3. Commit your changes (`git commit -am 'Add some feature'`) 36 | 4. Push to the branch (`git push origin my-new-feature`) 37 | 5. Create a new Pull Request 38 | 39 | ## License 40 | 41 | MIT 42 | 43 | [npm]: https://img.shields.io/npm/v/@crystallized/shadow-effects.svg?style=for-the-badge 44 | [npm-url]: https://npmjs.com/package/@crystallized/shadow-effects 45 | -------------------------------------------------------------------------------- /packages/shadow-effects/esbuild.config.js: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild" 2 | 3 | let ctx = await esbuild.context({ 4 | entryPoints: ["src/index.js"], 5 | bundle: true, 6 | format: "esm", 7 | target: "es2022", 8 | outdir: "dist", 9 | plugins: [], 10 | external: ["@preact/signals-core"] 11 | }) 12 | 13 | if (process.argv.includes("--watch")) { 14 | await ctx.watch() 15 | console.log("esbuild watching...") 16 | } else { 17 | await ctx.rebuild() 18 | await ctx.dispose() 19 | } 20 | -------------------------------------------------------------------------------- /packages/shadow-effects/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@crystallized/shadow-effects", 3 | "version": "0.6.0", 4 | "description": "Declarative effects in shadow DOM HTML using Signals.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.js", 7 | "type": "module", 8 | "repository": "https://github.com/whitefusionhq/crystallized", 9 | "author": "Jared White", 10 | "license": "MIT", 11 | "private": false, 12 | "engines": { 13 | "node": ">= 14" 14 | }, 15 | "scripts": { 16 | "start": "npm run build -- --watch", 17 | "build": "node esbuild.config.js", 18 | "test": "npm run build && web-test-runner --node-resolve", 19 | "test:dev": "npm run test -- --watch" 20 | }, 21 | "exports": { 22 | ".": "./dist/index.js", 23 | "./src/*": "./src/*" 24 | }, 25 | "files": [ 26 | "dist", 27 | "src" 28 | ], 29 | "devDependencies": { 30 | "@open-wc/testing": "^3.1.7", 31 | "@web/test-runner": "^0.15.1", 32 | "@web/test-runner-playwright": "^0.9.0", 33 | "esbuild": "^0.17.10" 34 | }, 35 | "peerDependencies": { 36 | "@preact/signals-core": "^1.2.3" 37 | }, 38 | "prettier": { 39 | "printWidth": 100, 40 | "semi": false 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/shadow-effects/src/ShadowEffects.js: -------------------------------------------------------------------------------- 1 | import { effect } from "@preact/signals-core" 2 | 3 | /** 4 | * @typedef { import("@preact/signals-core").Signal } Signal 5 | */ 6 | 7 | /** 8 | * @typedef { Record void> | undefined } Directives 9 | */ 10 | 11 | class ShadowEffects { 12 | /** 13 | * 14 | * @param {HTMLElement} host element 15 | * @param {Directives} directives 16 | */ 17 | constructor(element, directives) { 18 | /** @type {HTMLElement} */ 19 | this.element = element 20 | /** @type {Directives} */ 21 | this.directives = directives 22 | 23 | this.effectDisposals = [] 24 | 25 | this.processShadowRoot() 26 | } 27 | 28 | dispose() { 29 | this.effectDisposals.forEach(disposal => disposal()) 30 | this.effectDisposals = [] 31 | } 32 | 33 | processShadowRoot() { 34 | this.processNodes(this.element.shadowRoot.querySelectorAll(`[host-effect]`)) 35 | } 36 | 37 | /** 38 | * Only attempt this if you know what you're doing! 39 | */ 40 | processElementChildren() { 41 | this.processNodes(this.element.querySelectorAll(`[host-effect]`)) 42 | } 43 | 44 | /** 45 | * `host-effect="$el.textContent = count"` 46 | * `host-effect="someMethod($el, count)"` 47 | * `host-effect="$directive(count)"` 48 | * `host-effect="$el.textContent = count; $directive(count)"` 49 | */ 50 | processNodes(effectNodes) { 51 | effectNodes.forEach(node => { 52 | const syntax = node.getAttribute("host-effect") 53 | const statements = syntax.split(";").map(item => item.trim()) 54 | statements.forEach(statement => { 55 | if (statement.startsWith("$el.")) { 56 | // property assignment 57 | const expression = statement.split("=").map(item => item.trim()) 58 | expression[0] = expression[0].substring(4) 59 | 60 | this.effectDisposals.push(effect(() => { 61 | const value = this.element[expression[1]] 62 | 63 | if (this.element.resumed) node[expression[0]] = value 64 | })) 65 | } else if (statement.startsWith("$")) { 66 | // directive 67 | const [, directiveName, argsStr] = statement.trim().match(/(.*)\((.*)\)/) 68 | const argStrs = argsStr.split(",").map(item => item.trim()) 69 | argStrs.unshift("$el") 70 | 71 | if (this.directives && this.directives[directiveName.trim().substring(1)]) { 72 | this.effectDisposals.push(effect(() => { 73 | const args = argStrs.map(argStr => { 74 | if (argStr === "$el") { 75 | return node 76 | } 77 | 78 | if (argStr.startsWith("'")) { // string literal 79 | return argStr.substring(1, argStr.length -1) 80 | } 81 | 82 | return this.element[argStr] 83 | }) 84 | 85 | if (this.element.resumed) this.directives[directiveName.trim().substring(1)]?.(this, ...args) 86 | })) 87 | } 88 | } else { 89 | // method call 90 | const [, methodName, argsStr] = statement.trim().match(/(.*)\((.*)\)/) 91 | const argStrs = argsStr.split(",").map(item => item.trim()) 92 | 93 | this.effectDisposals.push(effect(() => { 94 | const args = argStrs.map(argStr => { 95 | if (argStr === "$el") { 96 | return node 97 | } 98 | 99 | if (argStr.startsWith("'")) { // string literal 100 | return argStr.substring(1, argStr.length -1) 101 | } 102 | 103 | return this.element[argStr] 104 | }) 105 | 106 | if (this.element.resumed) this.element[methodName.trim()]?.(...args) 107 | })) 108 | } 109 | }) 110 | }) 111 | } 112 | } 113 | 114 | export default ShadowEffects 115 | -------------------------------------------------------------------------------- /packages/shadow-effects/src/directives.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef { import("./ShadowEffects.js").default } ShadowEffects 3 | */ 4 | 5 | /** 6 | * Set element `hidden` to false if value is true 7 | * 8 | * @param {ShadowEffects} effects 9 | * @param {HTMLElement} el 10 | * @param {boolean} value 11 | */ 12 | export const show = (effects, el, value) => { 13 | el.hidden = !value 14 | } 15 | 16 | /** 17 | * Set element `hidden` to true if value is true 18 | * 19 | * @param {ShadowEffects} effects 20 | * @param {HTMLElement} el 21 | * @param {boolean} value 22 | */ 23 | export const hide = (effects, el, value) => { 24 | el.hidden = !!value 25 | } 26 | 27 | /** 28 | * Toggle classes based on a key/value object 29 | * 30 | * @param {ShadowEffects} effects 31 | * @param {HTMLElement} el 32 | * @param {Record} obj 33 | */ 34 | export const classMap = (effects, el, obj) => { 35 | Object.entries(obj).forEach(([k, v]) => { 36 | el.classList.toggle(k, v) 37 | }) 38 | } 39 | 40 | /** 41 | * Set inline styles based on a key/value object 42 | * 43 | * @param {ShadowEffects} effects 44 | * @param {HTMLElement} el 45 | * @param {Record} obj 46 | */ 47 | export const styleMap = (effects, el, obj) => { 48 | Object.entries(obj).forEach(([k, v]) => { 49 | el.style[k] = v 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /packages/shadow-effects/src/index.js: -------------------------------------------------------------------------------- 1 | import ShadowEffects from "./ShadowEffects.js" 2 | 3 | export { show, hide, classMap, styleMap } from "./directives.js" 4 | 5 | export { ShadowEffects } 6 | -------------------------------------------------------------------------------- /packages/shadow-effects/test/ShadowEffects.test.js: -------------------------------------------------------------------------------- 1 | import { fixture, assert, aTimeout, html as testhtml } from "@open-wc/testing" 2 | import { signal, computed } from "@preact/signals-core" 3 | 4 | import { ShadowEffects, show, classMap, styleMap } from "../dist" 5 | 6 | class TestElement extends HTMLElement { 7 | static { 8 | customElements.define("test-element", this) 9 | } 10 | 11 | constructor() { 12 | super() 13 | 14 | this.attachShadow({ mode: "open" }) 15 | const para = document.createElement("p") 16 | para.setAttribute("host-effect", "$el.textContent = count; $classMap(paraClasses); $uniqId(); styled($el, 'color', textColor)") 17 | this.shadowRoot.append(para) 18 | 19 | const output = document.createElement("output") 20 | output.textContent = "show if green" 21 | output.setAttribute("host-effect", "$show(textIsGreen); $styleMap(outputStyles)") 22 | this.shadowRoot.append(output) 23 | 24 | this.countSignal = signal(1) 25 | this.textColor = signal("red") 26 | this.textIsGreenSignal = computed(() => this.textColor.value === "green") 27 | } 28 | 29 | connectedCallback() { 30 | this.resumed = true 31 | this.effects = new ShadowEffects(this, { 32 | show, 33 | classMap, 34 | styleMap, 35 | uniqId: (_, el) => { 36 | el.id = "uniq123" 37 | } 38 | }) 39 | } 40 | 41 | disconnectedCallback() { 42 | this.effects.dispose() 43 | } 44 | 45 | get count() { 46 | return this.countSignal.value 47 | } 48 | 49 | get textIsGreen() { 50 | return this.textIsGreenSignal.value 51 | } 52 | 53 | get paraClasses() { 54 | return { 55 | "some-class": this.countSignal.value < 5, 56 | "another-class": false 57 | } 58 | } 59 | 60 | get outputStyles() { 61 | return { 62 | fontWeight: this.textIsGreen ? "bold" : "" 63 | } 64 | } 65 | 66 | styled(el, name, color) { 67 | el.style[name] = color 68 | } 69 | } 70 | 71 | describe("ShadowEffects", () => { 72 | context("property assignment", () => { 73 | it("textContent", async () => { 74 | const el = await fixture(testhtml` 75 | 76 | `) 77 | assert.strictEqual(el.shadowRoot.firstElementChild.textContent, "1") 78 | el.countSignal.value = 5 79 | assert.strictEqual(el.shadowRoot.firstElementChild.textContent, "5") 80 | }) 81 | }) 82 | 83 | context("methods", () => { 84 | it("styled", async () => { 85 | const el = await fixture(testhtml` 86 | 87 | `) 88 | assert.strictEqual(el.shadowRoot.firstElementChild.style.color, "red") 89 | el.textColor.value = "green" 90 | assert.strictEqual(el.shadowRoot.firstElementChild.style.color, "green") 91 | }) 92 | }) 93 | 94 | context("directives", () => { 95 | it("show", async () => { 96 | const el = await fixture(testhtml` 97 | 98 | `) 99 | assert.isTrue(el.shadowRoot.querySelector("output").hidden, "output should be hidden") 100 | el.textColor.value = "green" 101 | assert.isNotTrue(el.shadowRoot.querySelector("output").hidden, "output shouldn't be hidden") 102 | }) 103 | 104 | it("classMap", async () => { 105 | const el = await fixture(testhtml` 106 | 107 | `) 108 | assert.isTrue(el.shadowRoot.firstElementChild.classList.contains("some-class"), "some-class should be set") 109 | assert.isFalse(el.shadowRoot.firstElementChild.classList.contains("another-class"), "another-class shouldn't be set") 110 | el.countSignal.value = 10 111 | assert.isFalse(el.shadowRoot.firstElementChild.classList.contains("some-class"), "some-class should NOT be set after reactive update") 112 | }) 113 | 114 | it("styleMap", async () => { 115 | const el = await fixture(testhtml` 116 | 117 | `) 118 | assert.equal(el.shadowRoot.querySelector("output").style.fontWeight, "") 119 | el.textColor.value = "green" 120 | assert.equal(el.shadowRoot.querySelector("output").style.fontWeight, "bold") 121 | }) 122 | 123 | it("uniqId", async () => { 124 | const el = await fixture(testhtml` 125 | 126 | `) 127 | assert.strictEqual(el.shadowRoot.firstElementChild.id, "uniq123") 128 | }) 129 | }) 130 | }) 131 | -------------------------------------------------------------------------------- /packages/shadow-effects/web-test-runner.config.mjs: -------------------------------------------------------------------------------- 1 | import { playwrightLauncher } from "@web/test-runner-playwright" 2 | 3 | export default { 4 | browsers: [ 5 | playwrightLauncher({ product: "chromium" }), 6 | playwrightLauncher({ product: "firefox" }), 7 | playwrightLauncher({ product: "webkit" }), 8 | ], 9 | files: "test/**/*.test.js", 10 | } 11 | --------------------------------------------------------------------------------