Skip to content

Commit 1b37e1a

Browse files
committed
Merge branch 'main' of https://github.com/efflore/ui-element into main
2 parents b67178a + 65e7eb1 commit 1b37e1a

7 files changed

+79
-28
lines changed

README.md

+29-7
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
UIElement - the "look ma, no JS framework!" library bringing signals-based reactivity to vanilla Web Components
44

5+
Version 0.6.2
6+
57
## What is UIElement?
68

79
`UIElement` is a base class for your reactive Web Components. It extends the native `HTMLElement` class and adds a public property and a few methods that allow you to implement inter- and intra-component reactivity with ease. You extend the base class `UIElement` and call the static `define()` method on it to register a tag name in the `CustomElementsRegistry`.
@@ -10,19 +12,19 @@ UIElement - the "look ma, no JS framework!" library bringing signals-based react
1012

1113
`UIElement` implements a `Map`-like interface on top of `HTMLElement` to access and modify reactive states. The method names `this.has()`, `this.get()`, `this.set()` and `this.delete()` feel familar to JavaScript developers and mirror what you already know.
1214

13-
In the `connectedCallback()` you setup references to inner elements, add event listeners and pass reactive states to sub-components (`this.pass()`). Additionally, for every independent reactive state you define what happens when it changes in the callback of `this.effect()`. `UIElement` will automatically trigger these effects and bundle the surgical DOM updates when the browser refreshes the view on the next animation frame.
15+
In the `connectedCallback()` you setup references to inner elements, add event listeners and pass reactive states to sub-components (`this.pass()`). Additionally, for every independent reactive state you define what happens when it changes in the callback of `this.effect()`. `UIElement` will automatically trigger these effects and bundle the fine-grained DOM updates when the browser refreshes the view on the next animation frame.
1416

15-
`UIElement` is fast. In fact, faster than any JavaScript framework. Only direct surgical DOM updates in vanilla JavaScript can beat its performance. But then, you have no loose coupling of components and need to parse attributes and track changes yourself. This tends to get tedious and messy rather quickly. `UIElement` provides a structured way to keep your components simple, consistent and self-contained.
17+
`UIElement` is fast. In fact, faster than any JavaScript framework. Only direct fine-grained DOM updates in vanilla JavaScript can beat its performance. But then, you have no loose coupling of components and need to parse attributes and track changes yourself. This tends to get tedious and messy rather quickly. `UIElement` provides a structured way to keep your components simple, consistent and self-contained.
1618

17-
`UIElement` is tiny. 681 bytes gzipped over the wire. And it has zero dependiences. If you want to understand how it works, you have to study the source code of [one single file](./index.js).
19+
`UIElement` is tiny. 685 bytes gzipped over the wire. And it has zero dependiences. If you want to understand how it works, you have to study the source code of [one single file](./index.js).
1820

1921
That's all.
2022

2123
## What is UIElement intentionally not?
2224

2325
`UIElement` does not do many of the things JavaScript frameworks do.
2426

25-
Most importantly, it does not render components. We suggest, you render components (eighter Light DOM children or Declarative Shadow DOM) on the server side. There are existing solutions like [WebC](https://github.com/11ty/webc) or [Enhance](https://github.com/enhance-dev/enhance) that allow you to declare and render Web Components on the server side with (almost) pure HTML, CSS and JavaScript. `UIElement` is proven to work with either WebC or Enhance. But you could use any tech stack able to render HTML. There is no magic involved besides the building blocks of any website: HTML, CSS and JavaScript. `UIElement` does not make any assumptions about the structure of the inner HTML. In fact, it is up to you to reference inner elements and do surgical DOM updates in effects. This also means, there is no new language or format to learn. HTML, CSS and modern JavaScript (ES6) is all you need to know to develop your own web components with `UIElement`.
27+
Most importantly, it does not render components. We suggest, you render components (eighter Light DOM children or Declarative Shadow DOM) on the server side. There are existing solutions like [WebC](https://github.com/11ty/webc) or [Enhance](https://github.com/enhance-dev/enhance) that allow you to declare and render Web Components on the server side with (almost) pure HTML, CSS and JavaScript. `UIElement` is proven to work with either WebC or Enhance. But you could use any tech stack able to render HTML. There is no magic involved besides the building blocks of any website: HTML, CSS and JavaScript. `UIElement` does not make any assumptions about the structure of the inner HTML. In fact, it is up to you to reference inner elements and do fine-grained DOM updates in effects. This also means, there is no new language or format to learn. HTML, CSS and modern JavaScript (ES6) is all you need to know to develop your own web components with `UIElement`.
2628

2729
`UIElement` does no routing. It is strictly for single-page applications or reactive islands. But of course, you can reuse the same components on many different pages, effectively creating tailored single-page applications for every page you want to enhance with rich interactivity. We believe, this is the most efficient way to build rich multi-page applications, as only the scripts for the elements used on the current page are loaded, not a huge bundle for the whole app.
2830

@@ -160,11 +162,31 @@ Make sure the import of `UIElement` on the first line points to your installed p
160162
import UIElement from '@efflore/ui-element';
161163
```
162164

165+
If you use Debug Element as your base class for custom elements, you may call `super.connectedCallback();` (and the other lifecycle callbacks) to log when your element connects to the DOM.
166+
167+
To log when some DOM features of child elements are updated in effects, you need to enqueue all fine-grained DOM updated like this:
168+
169+
```js
170+
// in connectedCallback()
171+
this.effect(queue => queue(this.querySelector('span'), (el, text) => (el.textContent = text), this.get('value')));
172+
```
173+
174+
Otherwise Debug Element only knows which effect runs in which component, but not the exact elements targeted by your effect.
175+
176+
Enqueueing fine-grained DOM updates is always possible. It's a bit more verbose, but it ensures all updates of your effect happen at the same time. `autoEffects()` from DOM Utils (see next section) does this by default. All DOM utility functions receive the target element as first parameter, making it possible to use this shorter notation:
177+
178+
```js
179+
import { setText } from './lib/dom-utils';
180+
181+
// in connectedCallback()
182+
this.effect(queue => queue(this.querySelector('span'), setText, this.get('value')));
183+
```
184+
163185
[Source](./lib/debug-element.js)
164186

165187
### DOM Utils
166188

167-
A few utility functions for surgical DOM updates in `effect()`s that streamline the interface and save tedious existance and change checks:
189+
A few utility functions for fine-grained DOM updates in `effect()`s that streamline the interface and save tedious existance and change checks:
168190

169191
- `setText()` preserves comment nodes in contrast to `element.textContent` assignments
170192
- `setProp()` sets or deletes a property on an element
@@ -184,7 +206,7 @@ With all key/value pair attributes, you can provide several separated by `;`. Ea
184206

185207
Auto-Effects will be be applied to the Shadow DOM, if your component uses it; otherwise to the Light DOM sub-tree. The `ui-*` attributes will be removed from the DOM once your component is connected and the effects are set up. If you load a partial from the server containing these attributes, you will have to call `autoEffects()` again to have the effects auto-applied to the newly loaded partial as well.
186208

187-
By using these declarative attributes you can considerably reduce the amount of simple effects in your component's JavaScript. Almost all surgical DOM updates can be done this way. You gain Locality of Behavior (LoB) in your markup and can decide per case where effects shall be applied. On the other hand, you lose Separation of Concerns (SoC) - if you care about it. It's the same trade-off as with JSX, but in pure HTML.
209+
By using these declarative attributes you can considerably reduce the amount of simple effects in your component's JavaScript. Almost all fine-grained DOM updates can be done this way. You gain Locality of Behavior (LoB) in your markup and can decide per case where effects shall be applied. On the other hand, you lose Separation of Concerns (SoC) - if you care about it. It's the same trade-off as with JSX, but in pure HTML.
188210

189211
As not all users like the sort of magic of Auto-Effects, it's an optional opt-in and not part of the core `UIElement` library. Copy the source code and adapt it to your needs, if you like it.
190212

@@ -237,7 +259,7 @@ Where has the JavaScript gone? – It almost disappeared. To explain the magic:
237259
6. `UIElement` **auto-runs** the effect you did not even write again with a new `'value'` value
238260
7. `autoEffects()` knows which element's `textContent` to **auto-update**
239261

240-
By always following this pattern of data-flow, that is close to an optimal implementation in vanilla JavaScript, we can drastrically reduce need JavaScrpt both on the library side (`UIElement` + `dom-utils` ca. 1.3 kB gzipped) and on userland side.
262+
By always following this pattern of data-flow, that is close to an optimal implementation in vanilla JavaScript, we can drastrically reduce need JavaScrpt both on the library side (`UIElement` + `dom-utils` ca. 1.4 kB gzipped) and on userland side.
241263

242264
[Source](./lib/dom-utils.js)
243265

index.js

+22-6
Original file line numberDiff line numberDiff line change
@@ -173,21 +173,37 @@ export default class extends HTMLElement {
173173
*/
174174
effect(fn) {
175175
fn.targets = new Map();
176-
const scheduled = (/** @type {Element} */ element, /** @type {(...args: any []) => any} */ domFn) => {
176+
177+
/**
178+
* @since 0.6.1
179+
*
180+
* @param {Element} element
181+
* @param {import("./types").DOMUpdater} domFn
182+
* @param {any} key
183+
* @param {any} value
184+
* @returns {Map<any, any>}
185+
*/
186+
const queue = (element, domFn, key, value) => {
177187
!fn.targets.has(element) && fn.targets.set(element, new Map());
178188
const domFns = fn.targets.get(element);
179-
!domFns.has(domFn) && domFns.set(domFn, new Set());
180-
return domFns.get(domFn);
189+
!domFns.has(domFn) && domFns.set(domFn, new Map());
190+
const argsMap = domFns.get(domFn);
191+
key && argsMap.set(key, value);
192+
return argsMap;
181193
};
194+
195+
// effect callback function
182196
const next = () => {
183197
queueMicrotask(() => {
184198
const prev = active;
185199
active = next;
186-
const cleanup = fn(scheduled);
200+
const cleanup = fn(queue);
187201
active = prev;
202+
203+
// flush all queued effects
188204
for (const [el, domFns] of fn.targets.entries()) {
189-
for (const [domFn, argsSet] of domFns.entries()) {
190-
for (const args of argsSet.keys()) domFn(el, ...args);
205+
for (const [domFn, argsMap] of domFns.entries()) {
206+
for (const [key, value] of argsMap.entries()) domFn(el, key, value);
191207
}
192208
}
193209
// @ts-ignore

index.min.js

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/debug-element.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,9 @@ export default class extends UIElement {
9292
effect(fn) {
9393
if (!this.debug) return super.effect(fn);
9494
(typeof fn !== 'function') && this.error(new TypeError(`Effect handler in ${elementName(this)} is not a function`));
95-
const spy = (/** @type {import("../types").DOMEffects} */ scheduled) => {
95+
const spy = (/** @type {import("../types").DOMEffects} */ queue) => {
9696
fn.targets = new Map();
97-
fn(scheduled);
97+
fn(queue);
9898
if (spy.targets.size) {
9999
const elements = [];
100100
for (const el of spy.targets.keys()) elements.push(elementName(el));

lib/dom-utils.js

+20-8
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,20 @@ const _check = (element, value, old) => (element && value !== null) && (value !=
2121
/**
2222
* Update text content of an element while preserving comments (unlike element.textContent assignment)
2323
*
24+
* @type {import("../types").DOMUpdater}
2425
* @param {Element} element - element to update
26+
* @param {any} _ - unused
2527
* @param {any} content - new text content
2628
*/
27-
const t = (element, content) => {
29+
const t = (element, _, content) => {
2830
Array.from(element.childNodes).filter(node => node.nodeType !== Node.COMMENT_NODE).forEach(node => node.remove());
2931
element.append(document.createTextNode(content));
3032
};
3133

3234
/**
3335
* Update property of an element
3436
*
37+
* @type {import("../types").DOMUpdater}
3538
* @param {Element} element - element to update
3639
* @param {PropertyKey} key - property to update
3740
* @param {any} value - new property value
@@ -43,6 +46,7 @@ const p = (element, key, value) => _defined(value)
4346
/**
4447
* Update attribute of an element
4548
*
49+
* @type {import("../types").DOMUpdater}
4650
* @param {Element} element - element to update
4751
* @param {string} name - attribute to update
4852
* @param {string|boolean|undefined} value
@@ -56,6 +60,7 @@ const a = (element, name, value) => typeof value === 'boolean'
5660
/**
5761
* Update class token of an element
5862
*
63+
* @type {import("../types").DOMUpdater}
5964
* @param {Element} element - element to update
6065
* @param {string} token - class token to update
6166
* @param {boolean|undefined} force - whether to add or remove the token; if `undefined` the token will be toggled
@@ -65,6 +70,7 @@ const c = (element, token, force) => element.classList.toggle(token, force);
6570
/**
6671
* Update style property of an element
6772
*
73+
* @type {import("../types").DOMUpdater}
6874
* @param {HTMLElement} element - element to update
6975
* @param {string} name - style property to update
7076
* @param {string} value - new style property value
@@ -77,15 +83,18 @@ const s = (element, name, value) => _defined(value) ? element.style.setProperty(
7783
* Update text content of an element while preserving comments
7884
*
7985
* @since 0.6.0
86+
* @type {import("../types").DOMUpdater}
8087
* @param {Element} element - element to be updated
88+
* @param {any} key - ignored, but used for consistency; if third parameter is omitted, second parameter will be used as content
8189
* @param {string|null} content - new text content; `null` for opt-out of update
8290
*/
83-
const setText = (element, content) => _check(element, content, element.textContent) && t(element, content);
91+
const setText = (element, key, content = key) => _check(element, content, element.textContent) && t(element, true, content);
8492

8593
/**
8694
* Update property of an element
8795
*
8896
* @since 0.6.0
97+
* @type {import("../types").DOMUpdater}
8998
* @param {Element} element - element to be updated
9099
* @param {PropertyKey} key - property to be updated
91100
* @param {any} value - new property value; `''` or `true` for boolean attribute; `null` for opt-out of update; `undefined` or `false` will delete existing property
@@ -96,6 +105,7 @@ const setProp = (element, key, value) => _check(element, value, element[key]) &&
96105
* Update attribute of an element
97106
*
98107
* @since 0.6.0
108+
* @type {import("../types").DOMUpdater}
99109
* @param {Element} element - element to be updated
100110
* @param {string} name - attribute to be updated
101111
* @param {string|null} value - new attribute value; `null` for opt-out of update; `undefined` will remove existing attribute
@@ -106,6 +116,7 @@ const setAttr = (element, name, value) => _check(element, value, element.getAttr
106116
* Toggle class on an element
107117
*
108118
* @since 0.6.0
119+
* @type {import("../types").DOMUpdater}
109120
* @param {Element} element - element to be toggled
110121
* @param {string} token - class token to be toggled
111122
* @param {boolean|null|undefined} force - force toggle condition `true` or `false`; `null` for opt-out of update; `undefined` will toggle existing class
@@ -115,7 +126,8 @@ const setClass = (element, token, force) => _check(element, force, element.class
115126
/**
116127
* Update style property of an element
117128
*
118-
* @since 0.5.0
129+
* @since 0.6.0
130+
* @type {import("../types").DOMUpdater}
119131
* @param {HTMLElement} element - element to be updated
120132
* @param {string} property - style property to be updated
121133
* @param {string|null|undefined} value - new style property value; `null` for opt-out of update; `undefined` will remove existing style property
@@ -139,12 +151,12 @@ const autoEffects = (/** @type {import("../types").UIElement} */ element) => {
139151
// update text content
140152
if (attr === TEXT_ATTR) {
141153
const key = node.getAttribute(attr);
142-
const fallback = node.textContent;
154+
const fallback = node.textContent || '';
143155
element.set(key, fallback, false);
144-
element.effect((/** @type {import("../types").DOMEffects} */ scheduled) => {
156+
element.effect((/** @type {import("../types").DOMEffects} */ queue) => {
145157
if (element.has(key)) {
146158
const content = element.get(key);
147-
scheduled(node, t).add([_defined(content) ? content : fallback]);
159+
queue(node, t, true, _defined(content) ? content : fallback);
148160
}
149161
});
150162

@@ -169,8 +181,8 @@ const autoEffects = (/** @type {import("../types").UIElement} */ element) => {
169181
let [name, value] = key.split(':').map(s => s.trim());
170182
!value && (value = name);
171183
element.set(value, fallback(attr, node, name), false);
172-
element.effect((/** @type {(element: Element, fn: (...args: any[]) => any) => Set<any[]>} */ scheduled) => {
173-
element.has(value) && scheduled(node, setter[attr]).add([name, element.get(value)]);
184+
element.effect((/** @type {import("../types").DOMEffects} */ queue) => {
185+
element.has(value) && queue(node, setter[attr], name, element.get(value));
174186
});
175187
});
176188
}

0 commit comments

Comments
 (0)