wcb/src/WebComponent.js
Ayo 910a5096d0 feat: props Proxy for camelCase counterparts
- provide easy access to any observed attribute.
- update README examples
- update JSDoc examples
2023-11-17 23:10:02 +01:00

164 lines
4.2 KiB
JavaScript

/**
* A minimal base class to reduce the complexity of creating reactive custom elements
* @license MIT <https://opensource.org/licenses/MIT>
* @author Ayo Ayco <https://ayo.ayco.io>
* @see https://www.npmjs.com/package/web-component-base#readme
* @example
* ```js
* import WebComponent from "https://unpkg.com/web-component-base/index.js";
*
* class HelloWorld extends WebComponent {
* // tell the browser which attributes to cause a render
* static properties = ["data-name", "emotion"];
*
* // give the component a readonly template
* // note: props have kebab-case & camelCase counterparts
* get template() {
* return `
* <h1>Hello ${this.props.dataName}${this.props.emotion === "sad" ? ". 😭" : "! 🙌"}</h1>`;
* }
* }
*
* customElements.define('hello-world', HelloWorld);
* ```
*/
export class WebComponent extends HTMLElement {
/**
* Array of strings that tells the browsers which attributes will cause a render
* @type Array<string>
*/
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 "";
}
static get observedAttributes() {
return this.properties;
}
/**
* 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 `
* <h1>Hello ${this.props.myProp}</h1>
* <h2>Hello ${this["my-prop"]}</h2>
* `;
* }
* }
*/
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. Best for initializing the component like attaching event handlers.
* @returns void
*/
onInit() {}
/**
* Triggered when the component is disconnected from the DOM. Any initialization done in `onInit` must be undone here.
* @returns void
*/
onDestroy() {}
/**
* Triggered when an attribute value changes
* @typedef {{
* property: string,
* previousValue: any,
* currentValue: any
* }} Changes
* @param {Changes} changes
* @returns void
*/
onChanges(changes) {}
connectedCallback() {
this.onInit();
this.render();
this.afterViewInit();
}
disconnectedCallback() {
this.onDestroy();
}
/**
* @param {string} property
* @param {any} previousValue
* @param {any} currentValue
*/
attributeChangedCallback(property, previousValue, currentValue) {
const camelCaps = this.#getCamelCaps(property);
if (previousValue !== currentValue) {
this[property] = currentValue === "" || currentValue;
this.props[camelCaps] = this[property];
this.render();
this.onChanges({ property, previousValue, currentValue });
}
}
render() {
this.innerHTML = this.template;
}
/**
* Converts a kebab-cased string into camelCaps
* @param {string} kebab string in kebab-case
* @returns {string}
*/
#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;