├── .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 |
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 |
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 `