feat: attach-effect (#2)

We are now able to attach "side effects" to property value changes, from inside the component and outside.
This commit is contained in:
Ayo Ayco 2023-12-01 08:42:10 +01:00 committed by GitHub
parent 54a3819b83
commit 73dd374a3e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 186 additions and 42 deletions

View file

@ -40,7 +40,7 @@ The result is a reactive UI on property changes. [View on CodePen ↗](https://c
Import using [unpkg](https://unpkg.com/web-component-base) in your vanilla JS component. We will use this in the rest of our [usage examples](#usage). Import using [unpkg](https://unpkg.com/web-component-base) in your vanilla JS component. We will use this in the rest of our [usage examples](#usage).
```js ```js
import WebComponent from "https://unpkg.com/web-component-base@latest/WebComponent.min.js"; import { WebComponent } from "https://unpkg.com/web-component-base@latest/WebComponent.min.js";
``` ```
## Installation via npm ## Installation via npm
@ -56,7 +56,7 @@ In your component class:
```js ```js
// HelloWorld.mjs // HelloWorld.mjs
import WebComponent from "https://unpkg.com/web-component-base@latest/WebComponent.min.js"; import { WebComponent } from "https://unpkg.com/web-component-base@latest/WebComponent.min.js";
class HelloWorld extends WebComponent { class HelloWorld extends WebComponent {
static properties = ["my-name", "emotion"]; static properties = ["my-name", "emotion"];
@ -153,7 +153,7 @@ Here is an example of using a custom element in a single .html file.
<head> <head>
<title>WC Base Test</title> <title>WC Base Test</title>
<script type="module"> <script type="module">
import WebComponent from "https://unpkg.com/web-component-base@latest/WebComponent.min.js"; import { WebComponent } from "https://unpkg.com/web-component-base@latest/WebComponent.min.js";
class HelloWorld extends WebComponent { class HelloWorld extends WebComponent {
static properties = ["my-name"]; static properties = ["my-name"];
@ -186,7 +186,7 @@ Define behavior when certain events in the component's life cycle is triggered b
- Best for setting up the component - Best for setting up the component
```js ```js
import WebComponent from "https://unpkg.com/web-component-base@latest/WebComponent.min.js"; import { WebComponent } from "https://unpkg.com/web-component-base@latest/WebComponent.min.js";
class ClickableText extends WebComponent { class ClickableText extends WebComponent {
// gets called when the component is used in an HTML document // gets called when the component is used in an HTML document
@ -223,7 +223,7 @@ class ClickableText extends WebComponent {
- best for undoing any setup done in `onInit()` - best for undoing any setup done in `onInit()`
```js ```js
import WebComponent from "https://unpkg.com/web-component-base@latest/WebComponent.min.js"; import { WebComponent } from "https://unpkg.com/web-component-base@latest/WebComponent.min.js";
class ClickableText extends WebComponent { class ClickableText extends WebComponent {
@ -250,7 +250,7 @@ class ClickableText extends WebComponent {
- Triggered when an attribute value changed - Triggered when an attribute value changed
```js ```js
import WebComponent from "https://unpkg.com/web-component-base@latest/WebComponent.min.js"; import { WebComponent } from "https://unpkg.com/web-component-base@latest/WebComponent.min.js";
class ClickableText extends WebComponent { class ClickableText extends WebComponent {
// gets called when an attribute value changes // gets called when an attribute value changes

25
attach-effect/Counter.mjs Normal file
View file

@ -0,0 +1,25 @@
// @ts-check
import WebComponent from "../src/WebComponent.js";
import { attachEffect } from "../src/attach-effect.js";
export class Counter extends WebComponent {
static properties = ["count"];
onInit() {
this.props.count = 0;
this.onclick = () => ++this.props.count;
attachEffect(
this.props.count,
(count) => console.log(count)
);
}
afterViewInit(){
attachEffect(this.props.count, (count) => console.log(count + 100));
}
get template() {
return `<button id="btn">${this.props.count}</button>`;
}
}
customElements.define("my-counter", Counter);

13
attach-effect/index.html Normal file
View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WC demo</title>
<script type="module" src="Counter.mjs"></script>
</head>
<body>
<h1>Attach Effect Test</h1>
<my-counter></my-counter>
</body>
</html>

View file

@ -1,4 +1,4 @@
import WebComponent from "../src/WebComponent.js"; import WebComponent from "../../src/WebComponent.js";
export class BooleanPropTest extends WebComponent { export class BooleanPropTest extends WebComponent {
static properties = ["is-inline", "anotherone"]; static properties = ["is-inline", "anotherone"];

View file

@ -1,5 +1,5 @@
// @ts-check // @ts-check
import WebComponent from "../src/WebComponent.js"; import WebComponent from "../../src/WebComponent.js";
export class Counter extends WebComponent { export class Counter extends WebComponent {
static properties = ["count"]; static properties = ["count"];

View file

@ -1,5 +1,5 @@
// @ts-check // @ts-check
import WebComponent from "../src/WebComponent.js"; import WebComponent from "../../src/WebComponent.js";
export class HelloWorld extends WebComponent { export class HelloWorld extends WebComponent {
static properties = ["count", "emotion"]; static properties = ["count", "emotion"];

View file

@ -1,6 +1,5 @@
// @ts-check // @ts-check
import WebComponent from "../../src/WebComponent.js";
import { WebComponent } from "../src/WebComponent.js";
class SimpleText extends WebComponent { class SimpleText extends WebComponent {
clickCallback() { clickCallback() {

View file

@ -4,10 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WC demo</title> <title>WC demo</title>
<script type="module" src="HelloWorld.mjs"></script> <script type="module" src="./HelloWorld.mjs"></script>
<script type="module" src="SimpleText.mjs"></script> <script type="module" src="./SimpleText.mjs"></script>
<script type="module" src="BooleanPropTest.mjs"></script> <script type="module" src="./BooleanPropTest.mjs"></script>
<script type="module" src="Counter.mjs"></script> <script type="module" src="./Counter.mjs"></script>
</head> </head>
<body> <body>
<hello-world emotion="sad"></hello-world> <hello-world emotion="sad"></hello-world>

View file

@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WC demo</title>
<style>
* {
font-size: larger;
}
</style>
<script type="module">
import WebComponent from "https://unpkg.com/web-component-base@1.13.3/WebComponent.min.js";
/**
* @see https://ayco.io/n/web-component-base
*/
class Counter extends WebComponent {
static properties = ["count"];
onInit() {
this.props.count = 0;
this.onclick = () => ++this.props.count;
}
onChanges(changes) {
console.log(changes);
// now click the button & check your devtools console
}
get template() {
return `<button>${this.props.count}</button>`;
}
}
class Toggle extends WebComponent {
static properties = ["toggle"];
onInit() {
this.props.toggle = false;
this.onclick = () => (this.props.toggle = !this.props.toggle);
}
get template() {
return `<button>${this.props.toggle ? "On" : "Off"}</button>`;
}
}
customElements.define("my-counter", Counter);
customElements.define("my-toggle", Toggle);
</script>
</head>
<body>
<div>
Counter:
<my-counter />
</div>
<div>
Toggle:
<my-toggle />
</div>
</body>
</html>

View file

@ -1,5 +1,5 @@
// @ts-check // @ts-check
import WebComponent from "../src/WebComponent.js"; import WebComponent from "../../src/WebComponent.js";
export class Counter extends WebComponent { export class Counter extends WebComponent {
static properties = ["count"]; static properties = ["count"];

View file

@ -1,4 +1,4 @@
import WebComponent from "../src/WebComponent.js"; import WebComponent from "../../src/WebComponent.js";
export class HelloWorld extends WebComponent { export class HelloWorld extends WebComponent {
static properties = ["name"]; static properties = ["name"];

View file

@ -1,4 +1,4 @@
import WebComponent from "../src/WebComponent.js"; import WebComponent from "../../src/WebComponent.js";
export class ObjectText extends WebComponent { export class ObjectText extends WebComponent {
static properties = ["object"]; static properties = ["object"];

View file

@ -1,5 +1,5 @@
// @ts-check // @ts-check
import WebComponent from "../src/WebComponent.js"; import WebComponent from "../../src/WebComponent.js";
export class Toggle extends WebComponent { export class Toggle extends WebComponent {
static properties = ["toggle"]; static properties = ["toggle"];

View file

@ -4,10 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WC demo</title> <title>WC demo</title>
<script type="module" src="Counter.mjs"></script> <script type="module" src="./Counter.mjs"></script>
<script type="module" src="Toggle.mjs"></script> <script type="module" src="./Toggle.mjs"></script>
<script type="module" src="HelloWorld.mjs"></script> <script type="module" src="./HelloWorld.mjs"></script>
<script type="module" src="Object.mjs"></script> <script type="module" src="./Object.mjs"></script>
<style> <style>
* { * {
font-size: larger font-size: larger

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "web-component-base", "name": "web-component-base",
"version": "1.13.3", "version": "1.14.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "web-component-base", "name": "web-component-base",
"version": "1.13.3", "version": "1.14.6",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"site" "site"

View file

@ -1,9 +1,27 @@
{ {
"name": "web-component-base", "name": "web-component-base",
"version": "1.13.3", "version": "1.14.6",
"description": "A zero-dependency, ~600 Bytes (minified & gzipped), JS base class for creating reactive custom elements easily", "description": "A zero-dependency, ~600 Bytes (minified & gzipped), JS base class for creating reactive custom elements easily",
"main": "WebComponent.js",
"type": "module", "type": "module",
"main": "WebComponent.js",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./index.js"
},
"./WebComponent": {
"types": "./WebComponent.d.ts",
"import": "./WebComponent.js"
},
"./WebComponent.min.js": {
"types": "./WebComponent.d.ts",
"import": "./WebComponent.min.js"
},
"./attach-effect": {
"types": "./attach-effect.d.ts",
"import": "./attach-effect.js"
}
},
"scripts": { "scripts": {
"start": "npx simple-server .", "start": "npx simple-server .",
"demo": "npx simple-server .", "demo": "npx simple-server .",
@ -14,9 +32,12 @@
"generate:types": "tsc --allowJs src/* --outDir dist --declaration --emitDeclarationOnly", "generate:types": "tsc --allowJs src/* --outDir dist --declaration --emitDeclarationOnly",
"copy:meta": "node prepare.js && cp README.md ./dist && cp LICENSE ./dist", "copy:meta": "node prepare.js && cp README.md ./dist && cp LICENSE ./dist",
"copy:source": "cp ./src/* ./dist", "copy:source": "cp ./src/* ./dist",
"pub": "npm run clean && npm run build && cd ./dist && npm publish --access public", "pub": "npm run clean && npm run build && cd ./dist && npm publish",
"pub:beta": "npm run clean && npm run build && cd ./dist && npm publish --tag beta",
"publish:patch": "npm version patch && npm run pub", "publish:patch": "npm version patch && npm run pub",
"publish:patch:beta": "npm version patch && npm run pub:beta",
"publish:minor": "npm version minor && npm run pub", "publish:minor": "npm version minor && npm run pub",
"publish:minor:beta": "npm version minor && npm run pub:beta",
"check:size": "npm run build && size-limit ./dist/WebComponent.js" "check:size": "npm run build && size-limit ./dist/WebComponent.js"
}, },
"repository": "https://github.com/ayoayco/web-component-base", "repository": "https://github.com/ayoayco/web-component-base",

View file

@ -1,2 +0,0 @@
shamefully-hoist=true
strict-peer-dependencies=false

View file

@ -1,2 +1 @@
import McFly from "@mcflyjs/config"; export default defineNitroConfig({ extends: '@mcflyjs/config' });
export default defineNitroConfig({ ...McFly() });

View file

@ -22,9 +22,9 @@ export class WebComponent extends HTMLElement {
/** /**
* Read-only property containing camelCase counterparts of observed attributes. * Read-only property containing camelCase counterparts of observed attributes.
* @typedef {{[name: string]: any}} PropStringMap
* @see https://www.npmjs.com/package/web-component-base#prop-access * @see https://www.npmjs.com/package/web-component-base#prop-access
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset
* @typedef {{[name: string]: any}} PropStringMap
* @type {PropStringMap} * @type {PropStringMap}
*/ */
get props() { get props() {
@ -53,11 +53,12 @@ export class WebComponent extends HTMLElement {
/** /**
* Triggered when an attribute value changes * Triggered when an attribute value changes
* @param {{ * @typedef {{
* property: string, * property: string,
* previousValue: any, * previousValue: any,
* currentValue: any * currentValue: any
* }} changes * }} Changes
* @param {Changes} changes
*/ */
onChanges(changes) {} onChanges(changes) {}
@ -94,7 +95,7 @@ export class WebComponent extends HTMLElement {
this[property] = currentValue === "" || currentValue; this[property] = currentValue === "" || currentValue;
this[camelCaps] = this[property]; // remove on v2 this[camelCaps] = this[property]; // remove on v2
this.#handleUpdateProp(camelCaps, currentValue); this.#handleUpdateProp(camelCaps, this[property]);
this.render(); this.render();
this.onChanges({ property, previousValue, currentValue }); this.onChanges({ property, previousValue, currentValue });
@ -125,7 +126,11 @@ export class WebComponent extends HTMLElement {
} }
}; };
#handler(setter, typeMap) { #effectsMap = {};
#handler(setter, meta) {
const effectsMap = meta.#effectsMap;
const typeMap = meta.#typeMap;
const getKebab = (str) => { const getKebab = (str) => {
return str.replace( return str.replace(
/[A-Z]+(?![a-z])|[A-Z]/g, /[A-Z]+(?![a-z])|[A-Z]/g,
@ -141,14 +146,28 @@ export class WebComponent extends HTMLElement {
typeMap[prop] = typeof value; typeMap[prop] = typeof value;
} }
if (oldValue !== value) { if (value.attach === "effect") {
if (!effectsMap[prop]) {
effectsMap[prop] = [];
}
effectsMap[prop].push(value.callback);
} else if (oldValue !== value) {
obj[prop] = value; obj[prop] = value;
effectsMap[prop]?.forEach((f) => f(value));
const kebab = getKebab(prop); const kebab = getKebab(prop);
setter(kebab, value); setter(kebab, value);
} }
return true; return true;
}, },
get(obj, prop) {
// TODO: handle non-objects
if(obj[prop] !== null && obj[prop] !== undefined) {
Object.getPrototypeOf(obj[prop]).proxy = meta.#props;
Object.getPrototypeOf(obj[prop]).prop = prop;
}
return obj[prop];
},
}; };
} }
@ -156,10 +175,7 @@ export class WebComponent extends HTMLElement {
if (!this.#props) { if (!this.#props) {
this.#props = new Proxy( this.#props = new Proxy(
{}, {},
this.#handler( this.#handler((key, value) => this.setAttribute(key, value), this)
(key, value) => this.setAttribute(key, value),
this.#typeMap
)
); );
} }
} }

13
src/attach-effect.js Normal file
View file

@ -0,0 +1,13 @@
/**
* Attach a "side effect" function that gets triggered on property value changes
* @param {Object} obj
* @param {(newValue: any) => void} callback
*/
export function attachEffect(obj, callback) {
const { proxy, prop } = Object.getPrototypeOf(obj);
proxy[prop] = {
attach: "effect",
callback,
};
}

2
src/index.js Normal file
View file

@ -0,0 +1,2 @@
export {attachEffect} from './attach-effect.js';
export {default as WebComponent} from './WebComponent.js';