refactor(templating): attributes handling & JSX
- support unparented DOM nodes - simpler attributes handling - jsx-like syntax - use .mjs for utils
This commit is contained in:
parent
538aa01ecd
commit
ec50f0f86e
10 changed files with 258 additions and 48 deletions
|
@ -1,3 +1,4 @@
|
||||||
|
// @ts-check
|
||||||
import { WebComponent, html } from "../../src/index.js";
|
import { WebComponent, html } from "../../src/index.js";
|
||||||
|
|
||||||
export class Counter extends WebComponent {
|
export class Counter extends WebComponent {
|
||||||
|
@ -5,6 +6,19 @@ export class Counter extends WebComponent {
|
||||||
count: 123,
|
count: 123,
|
||||||
};
|
};
|
||||||
get template() {
|
get template() {
|
||||||
|
|
||||||
|
const list = ['a', 'b', 'c', 'what']
|
||||||
|
const links = [
|
||||||
|
{
|
||||||
|
url: 'https://ayco.io',
|
||||||
|
text: 'Ayo Ayco'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://ayco.io/gh/McFly',
|
||||||
|
text: 'McFly'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
class="hey"
|
class="hey"
|
||||||
|
@ -21,6 +35,15 @@ export class Counter extends WebComponent {
|
||||||
<label data-my-name="Ayo" for="the-input">Name</label>
|
<label data-my-name="Ayo" for="the-input">Name</label>
|
||||||
<input id="the-input" type="foo" value="Name:" />
|
<input id="the-input" type="foo" value="Name:" />
|
||||||
</form>
|
</form>
|
||||||
|
${
|
||||||
|
list.map(item => html`<p>${item}</p>`)
|
||||||
|
}
|
||||||
|
<h3 about="Elephant">Links</h3>
|
||||||
|
<ul>
|
||||||
|
${
|
||||||
|
links.map(link => html`<li><a href=${link.url} target="_blank">${link.text}</a></li>`)
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "web-component-base",
|
"name": "web-component-base",
|
||||||
"version": "2.0.0-beta.18",
|
"version": "2.0.0-beta.21",
|
||||||
"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",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
|
@ -1,42 +0,0 @@
|
||||||
import { getCamelCase } from "./get-camel-case.js";
|
|
||||||
|
|
||||||
export function createElement(tree) {
|
|
||||||
if (!tree.type) {
|
|
||||||
const leaves = typeof tree === "object" ? Object.keys(tree) : [];
|
|
||||||
if (leaves?.length > 1) {
|
|
||||||
return leaves.map((leaf) => createElement(tree[leaf]));
|
|
||||||
}
|
|
||||||
return document.createTextNode(tree);
|
|
||||||
} else {
|
|
||||||
const el = document.createElement(tree.type);
|
|
||||||
if (tree.props) {
|
|
||||||
Object.keys(tree.props).forEach((prop) => {
|
|
||||||
let domProp = prop.toLowerCase();
|
|
||||||
let value = tree.props[prop];
|
|
||||||
if (domProp.startsWith("data-")) {
|
|
||||||
const dataProp = domProp.replace("data-", "");
|
|
||||||
el.dataset[getCamelCase(dataProp)] = value;
|
|
||||||
} else if (domProp.startsWith("aria-")) {
|
|
||||||
el.setAttribute(domProp, value);
|
|
||||||
} else {
|
|
||||||
switch (domProp) {
|
|
||||||
case "class":
|
|
||||||
domProp = "className";
|
|
||||||
break;
|
|
||||||
case "for":
|
|
||||||
domProp = "htmlFor";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (domProp in el) el[domProp] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
tree.children?.forEach((child) => {
|
|
||||||
const childEl = createElement(child);
|
|
||||||
if (childEl) {
|
|
||||||
el.appendChild(childEl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
}
|
|
29
src/utils/create-element.mjs
Normal file
29
src/utils/create-element.mjs
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
export function createElement(tree) {
|
||||||
|
if (tree.type === undefined) {
|
||||||
|
if (Array.isArray(tree)) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
frag.replaceChildren(...tree.map(leaf => createElement(leaf)))
|
||||||
|
return frag;
|
||||||
|
}
|
||||||
|
return document.createTextNode(tree);
|
||||||
|
} else {
|
||||||
|
const el = document.createElement(tree.type);
|
||||||
|
if (tree.props) {
|
||||||
|
Object.keys(tree.props).forEach((prop) => {
|
||||||
|
const domProp = prop.toLowerCase();
|
||||||
|
if (domProp in el) {
|
||||||
|
el[domProp] = tree.props[prop];
|
||||||
|
} else {
|
||||||
|
el.setAttribute(prop, tree.props[prop])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tree.children?.forEach((child) => {
|
||||||
|
const childEl = createElement(child);
|
||||||
|
if (childEl instanceof Node) {
|
||||||
|
el.appendChild(childEl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
export { serialize } from "./serialize.js";
|
export { serialize } from "./serialize.mjs";
|
||||||
export { deserialize } from "./deserialize.js";
|
export { deserialize } from "./deserialize.mjs";
|
||||||
export { getCamelCase } from "./get-camel-case.js";
|
export { getCamelCase } from "./get-camel-case.mjs";
|
||||||
export { getKebabCase } from "./get-kebab-case.js";
|
export { getKebabCase } from "./get-kebab-case.mjs";
|
||||||
export {createElement} from "./create-element.js";
|
export {createElement} from "./create-element.mjs";
|
||||||
|
|
200
src/web-component-base.js
Normal file
200
src/web-component-base.js
Normal file
|
@ -0,0 +1,200 @@
|
||||||
|
import { createElement } from "./utils/create-element.mjs";
|
||||||
|
import { getCamelCase } from "./utils/get-camel-case.mjs";
|
||||||
|
import { getKebabCase } from "./utils/get-kebab-case.mjs";
|
||||||
|
import { serialize } from "./utils/serialize.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
export class WebComponent extends HTMLElement {
|
||||||
|
/**
|
||||||
|
* Array of strings that tells the browsers which attributes will cause a render
|
||||||
|
* @type {Array<string>}
|
||||||
|
*/
|
||||||
|
static properties = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blueprint for the Proxy props
|
||||||
|
*/
|
||||||
|
static props;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only string property that represents how the component will be rendered
|
||||||
|
* @returns {string | Node | (string | Node)[]}
|
||||||
|
* @see https://www.npmjs.com/package/web-component-base#template-vs-render
|
||||||
|
*/
|
||||||
|
get template() {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset
|
||||||
|
* @type {PropStringMap}
|
||||||
|
*/
|
||||||
|
get props() {
|
||||||
|
return this.#props;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {PropStringMap}
|
||||||
|
*/
|
||||||
|
#props;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggered after view is initialized
|
||||||
|
*/
|
||||||
|
afterViewInit() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggered when the component is connected to the DOM
|
||||||
|
*/
|
||||||
|
onInit() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggered when the component is disconnected from the DOM
|
||||||
|
*/
|
||||||
|
onDestroy() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggered when an attribute value changes
|
||||||
|
* @typedef {{
|
||||||
|
* property: string,
|
||||||
|
* previousValue: any,
|
||||||
|
* currentValue: any
|
||||||
|
* }} Changes
|
||||||
|
* @param {Changes} changes
|
||||||
|
*/
|
||||||
|
onChanges(changes) {}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.#initializeProps();
|
||||||
|
}
|
||||||
|
|
||||||
|
static get observedAttributes() {
|
||||||
|
const propKeys = this.props ? Object.keys(this.props).map(camelCase => getKebabCase(camelCase)) : [];
|
||||||
|
|
||||||
|
return [...(
|
||||||
|
new Set([
|
||||||
|
...this.properties,
|
||||||
|
...propKeys
|
||||||
|
])
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.onInit();
|
||||||
|
this.render();
|
||||||
|
this.afterViewInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(property, previousValue, currentValue) {
|
||||||
|
const camelCaps = getCamelCase(property);
|
||||||
|
|
||||||
|
if (previousValue !== currentValue) {
|
||||||
|
this[property] = currentValue === "" || currentValue;
|
||||||
|
this[camelCaps] = this[property];
|
||||||
|
|
||||||
|
this.#handleUpdateProp(camelCaps, this[property]);
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
this.onChanges({ property, previousValue, currentValue });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#handleUpdateProp(key, stringifiedValue) {
|
||||||
|
const restored = deserialize(stringifiedValue, this.#typeMap[key]);
|
||||||
|
if (restored !== this.props[key]) this.props[key] = restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
#typeMap = {};
|
||||||
|
#effectsMap = {};
|
||||||
|
|
||||||
|
#handler(setter, meta) {
|
||||||
|
const effectsMap = meta.#effectsMap;
|
||||||
|
const typeMap = meta.#typeMap;
|
||||||
|
|
||||||
|
return {
|
||||||
|
set(obj, prop, value) {
|
||||||
|
const oldValue = obj[prop];
|
||||||
|
|
||||||
|
if (!(prop in typeMap)) {
|
||||||
|
typeMap[prop] = typeof value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.attach === "effect") {
|
||||||
|
if (!effectsMap[prop]) {
|
||||||
|
effectsMap[prop] = [];
|
||||||
|
}
|
||||||
|
effectsMap[prop].push(value.callback);
|
||||||
|
} else if (typeMap[prop] !== typeof value) {
|
||||||
|
throw TypeError(`Cannot assign ${typeof value} to ${typeMap[prop]} property (setting '${prop}' of ${meta.constructor.name})`)
|
||||||
|
} else if (oldValue !== value) {
|
||||||
|
obj[prop] = value;
|
||||||
|
effectsMap[prop]?.forEach((f) => f(value));
|
||||||
|
const kebab = getKebabCase(prop);
|
||||||
|
setter(kebab, serialize(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#initializeProps() {
|
||||||
|
let initialProps = {}
|
||||||
|
if(this.constructor.props) {
|
||||||
|
initialProps = this.constructor.props;
|
||||||
|
Object.keys(initialProps).forEach(camelCase => {
|
||||||
|
const value = initialProps[camelCase]
|
||||||
|
this.#typeMap[camelCase] = typeof value
|
||||||
|
this.setAttribute(getKebabCase(camelCase), serialize(value))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!this.#props) {
|
||||||
|
this.#props = new Proxy(
|
||||||
|
initialProps,
|
||||||
|
this.#handler((key, value) => this.setAttribute(key, value), this)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#prevDOM;
|
||||||
|
render() {
|
||||||
|
if (typeof this.template === "string") {
|
||||||
|
this.innerHTML = this.template;
|
||||||
|
return;
|
||||||
|
} else if (typeof this.template === 'object') {
|
||||||
|
const tree = this.template;
|
||||||
|
|
||||||
|
// TODO: smart diffing
|
||||||
|
if (JSON.stringify(this.#prevDOM) !== JSON.stringify(tree)) {
|
||||||
|
// render
|
||||||
|
const el = createElement(tree);
|
||||||
|
if (el) {
|
||||||
|
if (Array.isArray(el)) this.replaceChildren(...el);
|
||||||
|
else this.replaceChildren(el);
|
||||||
|
}
|
||||||
|
this.#prevDOM = tree;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue