From 910a5096d0b2ac6c551b3cc91276841c38c6a9cf Mon Sep 17 00:00:00 2001 From: Ayo Date: Fri, 17 Nov 2023 23:10:02 +0100 Subject: [PATCH] feat: props Proxy for camelCase counterparts - provide easy access to any observed attribute. - update README examples - update JSDoc examples --- README.md | 8 +++-- demo/BooleanPropTest.mjs | 9 +----- demo/HelloWorld.mjs | 22 +++----------- demo/SimpleText.mjs | 2 +- demo/index.html | 2 +- src/WebComponent.js | 66 +++++++++++++++++++++++++++++++++++----- 6 files changed, 70 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 2ffccc1..c331081 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,10 @@ This mental model attempts to reduce the cognitive complexity of authoring compo ## Prop Access -Attributes are generally in `kebab-case`. You can access attribute properties in two ways -1. Use the camelCase counterpart: `this.myProp`, which is automatically filled. +A `WebComponent.props` read-only property exists to provide easy access to *any* observed attribute. This works like [`HTMLElement.dataset`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset) except `dataset` is only for attributes prefixed with `data-*`. Assigning a value to a camelCase counterpart using `WebComponent.props` will call `this.setAttribute` for any attribute name, with or without the `data-*` prefix. + +You can access attribute properties in two ways: +1. Use the camelCase counterpart: `this.props.myProp`, which is automatically filled. 1. Or stick with kebab-case: `this["my-prop"]` ```js @@ -106,7 +108,7 @@ class HelloWorld extends WebComponent { get template() { return ` -

Hello ${this.myProp}

+

Hello ${this.props.myProp}

Hello ${this["my-prop"]}

`; } diff --git a/demo/BooleanPropTest.mjs b/demo/BooleanPropTest.mjs index aa91e7b..0b0eba8 100644 --- a/demo/BooleanPropTest.mjs +++ b/demo/BooleanPropTest.mjs @@ -1,17 +1,10 @@ import WebComponent from "../src/WebComponent.js"; export class BooleanPropTest extends WebComponent { - isInline = false; - anotherone = false; - static properties = ["is-inline", "anotherone"]; - onChanges(changes) { - console.log(">>> boolean prop test", changes); - } - get template() { - return `

is-inline: ${this.isInline}

another-one: ${this.anotherone}

`; + return `

is-inline: ${this.props.isInline}

another-one: ${this.props.anotherone}

`; } } diff --git a/demo/HelloWorld.mjs b/demo/HelloWorld.mjs index 3eae46d..ab3a471 100644 --- a/demo/HelloWorld.mjs +++ b/demo/HelloWorld.mjs @@ -2,30 +2,16 @@ import WebComponent from "../src/index.js"; export class HelloWorld extends WebComponent { - dataName = "World"; - emotion = "excited"; - - static properties = ["data-name", "emotion"]; + static properties = ["my-name", "emotion"]; onInit() { let count = 0; - this.onclick = () => { - this.setAttribute("data-name", `Clicked ${++count}x!`); - }; - } - - afterViewInit() { - console.log("afterViewInit", this.querySelector("h1")); - } - - onChanges(changes) { - const { property, previousValue, currentValue } = changes; - console.log(`${property} changed`, { previousValue, currentValue }); + this.onclick = () => (this.props.myName = `Clicked ${++count}`); } get template() { - return ``; } } diff --git a/demo/SimpleText.mjs b/demo/SimpleText.mjs index dd898b3..59efb8d 100644 --- a/demo/SimpleText.mjs +++ b/demo/SimpleText.mjs @@ -16,7 +16,7 @@ class SimpleText extends WebComponent { } get template() { - return `Click me!`; + return `Click me!`; } } diff --git a/demo/index.html b/demo/index.html index 4a62c82..cb061d0 100644 --- a/demo/index.html +++ b/demo/index.html @@ -9,7 +9,7 @@ - +

diff --git a/src/WebComponent.js b/src/WebComponent.js index a31d3d4..0083172 100644 --- a/src/WebComponent.js +++ b/src/WebComponent.js @@ -8,9 +8,6 @@ * import WebComponent from "https://unpkg.com/web-component-base/index.js"; * * class HelloWorld extends WebComponent { - * // initialize prop - * dataName = 'World'; - * * // tell the browser which attributes to cause a render * static properties = ["data-name", "emotion"]; * @@ -18,7 +15,7 @@ * // note: props have kebab-case & camelCase counterparts * get template() { * return ` - *

Hello ${this.dataName}${this.emotion === "sad" ? ". 😭" : "! 🙌"}

`; + *

Hello ${this.props.dataName}${this.props.emotion === "sad" ? ". 😭" : "! 🙌"}

`; * } * } * @@ -27,12 +24,15 @@ */ export class WebComponent extends HTMLElement { /** + * Array of strings that tells the browsers which attributes will cause a render * @type Array */ static properties = []; /** + * Read-only string property that represents how the component will be rendered * @returns string + * @see https://www.npmjs.com/package/web-component-base#template-vs-render */ get template() { return ""; @@ -43,19 +43,48 @@ export class WebComponent extends HTMLElement { } /** - * Triggered after view is initialized + * Proxy object containing camelCase counterparts of observed attributes. + * This works like HTMLElement.dataset except dataset is only for attributes prefixed with `data-`. + * Assigning a value to a camelCase counterpart using `props` will automatically call `this.setAttribute` under the hood for any attribute name, with or without the `data-` prefix. + * @see https://www.npmjs.com/package/web-component-base#prop-access + * @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset + * + * @example + * + * class HelloWorld extends WebComponent { + * static properties = ["my-prop"]; + * get template() { + * return ` + *

Hello ${this.props.myProp}

+ *

Hello ${this["my-prop"]}

+ * `; + * } + * } + */ + props; + + constructor() { + super(); + if (!this.props) { + const { props, ...clone } = this; + this.props = new Proxy(clone, this.#handler(this)); + } + } + + /** + * Triggered after view is initialized. Best for querying DOM nodes that will only exist after render. * @returns void */ afterViewInit() {} /** - * Triggered when the component is connected to the DOM + * Triggered when the component is connected to the DOM. Best for initializing the component like attaching event handlers. * @returns void */ onInit() {} /** - * Triggered when the component is disconnected from the DOM + * Triggered when the component is disconnected from the DOM. Any initialization done in `onInit` must be undone here. * @returns void */ onDestroy() {} @@ -89,9 +118,12 @@ export class WebComponent extends HTMLElement { */ attributeChangedCallback(property, previousValue, currentValue) { const camelCaps = this.#getCamelCaps(property); + if (previousValue !== currentValue) { this[property] = currentValue === "" || currentValue; - this[camelCaps] = currentValue === "" || currentValue; + + this.props[camelCaps] = this[property]; + this.render(); this.onChanges({ property, previousValue, currentValue }); } @@ -109,6 +141,24 @@ export class WebComponent extends HTMLElement { #getCamelCaps(kebab) { return kebab.replace(/-./g, (x) => x[1].toUpperCase()); } + + #handler = (el) => ({ + set(obj, prop, newval) { + obj[prop] = newval; + + const kebabize = (str) => + str.replace( + /[A-Z]+(?![a-z])|[A-Z]/g, + ($, ofs) => (ofs ? "-" : "") + $.toLowerCase() + ); + + const kebab = kebabize(prop); + el.setAttribute(kebab, newval); + + // Indicate success + return true; + }, + }); } export default WebComponent;