Skip to content
This repository was archived by the owner on Feb 18, 2026. It is now read-only.

Commit d289890

Browse files
Merge pull request #3 from efflore/feature/v0.4.0
Feature/v0.4.0
2 parents 063d8fb + 1fb34fd commit d289890

15 files changed

+7531
-5569
lines changed

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,28 @@
11
# ui-element.js
22

3-
UIElement - minimal reactive framework based on Web Components
3+
UIElement - the "look ma, no JS framework!" library bringing signals-based reactivity to Web Components
44

55
## What is UIElement?
66

77
`UIElement` is a base class for your reactive Web Components. It extends the native `HTMLElement` class and adds 1 public property and 4 methods that allow you to implement inter- and intra-component reactivity with ease.
88

9-
It will parse attributes in `attributeChangedCallback()` and assign the values to reactive properties according to the mapping to key and primitive type in the `attributeMapping` property of your component. By declaratively setting `static observedAttributes` and `attributeMapping` you will almost never have to override `attributeChangedCallback()`. Your reactive properties will be automatically setup with initial values from attributes.
9+
It will parse attributes in `attributeChangedCallback()` and assign the values to reactive properties according to the mapping to key and primitive type in the `attributeMapping` property of your component. By declaratively setting `static observedAttributes` and `attributeMapping` you will almost never have to override `attributeChangedCallback()`. Your reactive states will be automatically setup with initial values from attributes.
1010

11-
`UIElement` implements a `Map`-like interface on top of `HTMLElement` to access and modify reactive properties. This allows to use any value as key for reactive properties, as opposed to using direct properties on the element object. This way, we can avoid accidental name clashes with global HTML attributes, JavaScript reserved words or method names and don't have to convert from kebab-case to camelCase and vice versa. The method names `this.has()`, `this.get()`, and `this.set()` feel familar to JavaScript developers and mirror what you already use to access and modify attributes.
11+
`UIElement` implements a `Map`-like interface on top of `HTMLElement` to access and modify reactive properties. This allows to use any value as key for reactive properties, as opposed to using direct properties on the element object. This way, we can avoid accidental name clashes with global HTML attributes, JavaScript reserved words or method names and don't have to convert from kebab-case to camelCase and vice versa. The method names `this.has()`, `this.get()`, `this.set()` and `this.delete()` feel familar to JavaScript developers and mirror what you already use to access and modify attributes.
1212

13-
In the `connectedCallback()` you setup references to inner elements, add event listeners and pass reactive properties to sub-components. Additionally, for every independent reactive property you define what happens when it changes with `effect()`. `UIElement` will automatically trigger these effects and bundle the surgical DOM updates when the browser refreshes the view.
13+
In the `connectedCallback()` you setup references to inner elements, add event listeners and pass reactive properties to sub-components. Additionally, for every independent reactive property you define what happens when it changes with `this.effect()`. `UIElement` will automatically trigger these effects and bundle the surgical DOM updates when the browser refreshes the view.
1414

1515
That's all.
1616

1717
## What is UIElement intentionally not?
1818

19-
UIElement does not do things other full-fledged frontend or full-stack frameworks do.
19+
UIElement does not do many of the things JavaScript frameworks do.
2020

2121
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 single-file 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.
2222

23-
UIElement does no routing. It is strictly for single-page applications. But of course, you can reuse the same components on many different pages, effectively creating tailored single-page applications for every pages you want to enhance with rich interactivity. We belive, 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.
23+
UIElement does no routing. It is strictly for single-page applications. But of course, you can reuse the same components on many different pages, effectively creating tailored single-page applications for every pages 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.
2424

25-
UIElement uses no Virtual DOM and doesn't do any dirty-checking or DOM diffing. Consider these approaches by other frameworks as technical debt, not needed anymore.
25+
UIElement uses no Virtual DOM and doesn't do any dirty-checking or DOM diffing. Consider these approaches by JavaScript frameworks as technical debt, not needed anymore.
2626

2727
## Getting Started
2828

@@ -35,7 +35,7 @@ npm install @efflore/ui-element
3535
In JavaScript:
3636

3737
```js
38-
import UIElement, { effect } from '@efflore/ui-element';
38+
import UIElement from '@efflore/ui-element';
3939

4040
customElements.define('my-counter', class extends UIElement {
4141
static observedAttributes = ['value'];
@@ -46,7 +46,7 @@ customElements.define('my-counter', class extends UIElement {
4646
this.querySelector('.decrement').onclick = () => this.set('value', v => v - 1);
4747
this.querySelector('.increment').onclick = () => this.set('value', v => v + 1);
4848

49-
effect(() => this.querySelector('span').textContent = this.get('value'));
49+
this.effect(() => this.querySelector('span').textContent = this.get('value'));
5050
}
5151
});
5252
```
@@ -80,7 +80,7 @@ Important: Make sure you either bundle JavaScript on the server side or referenc
8080
So, for example, for server side:
8181

8282
```js
83-
import UIElement, { effect } from '@efflore/ui-element';
83+
import UIElement from '@efflore/ui-element';
8484

8585
customElements.define('my-counter', class extends UIElement {
8686
...

eslint.config.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// @ts-nocheck
2+
import globals from "globals";
3+
import pluginJs from "@eslint/js";
4+
5+
6+
export default [
7+
{languageOptions: { globals: globals.browser }},
8+
pluginJs.configs.recommended,
9+
];

index.js

Lines changed: 72 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
/* globals HTMLElement, requestAnimationFrame, setTimeout */
2-
31
/**
2+
* @name UIElement
3+
* @version 0.4.0
44
* @license
55
* Copyright 2024 Esther Brunner
66
* SPDX-License-Identifier: BSD-3-Clause
@@ -27,59 +27,65 @@ const isFunction = fn => typeof fn === 'function';
2727
const maybeCall = (fn, args = [], fallback = fn) => isFunction(fn) ? fn.call(...args) : fallback;
2828

2929
// hold the currently active effect
30-
let pending;
30+
let computing;
3131

32-
// set up an empty WeakMap to hold the reactivity tree
32+
// set up an empty WeakMap to hold the reactivity map
3333
const reactivityMap = new WeakMap();
3434

3535
/**
36-
* Get the set of effects dependent on a state from the reactivity tree
36+
* Get the set of targets dependent on a state from the reactivity map
3737
*
38-
* @param {Function} fn - getter function of the state as key for the lookup
39-
* @returns {Set} set of effects associated with the state
38+
* @param {import("./types").State<any>} state - state object as key for the lookup
39+
* @returns {Set} set of targets associated with the state
4040
*/
41-
const getEffects = fn => {
42-
!reactivityMap.has(fn) && reactivityMap.set(fn, new Set());
43-
return reactivityMap.get(fn);
41+
const getTargets = state => {
42+
!reactivityMap.has(state) && reactivityMap.set(state, new Set());
43+
return reactivityMap.get(state);
4444
};
4545

4646
/**
47-
* Define a state and return an object duck-typing Signal.State
47+
* Define a state and return an object duck-typing Signal.State instances
4848
*
49+
* @since 0.1.0
4950
* @param {any} value - initial value of the state; may be a function to be called on first access
50-
* @returns {Object} state object with `get` and `set` methods
51+
* @returns {import("./types").State<any>} state object with `get` and `set` methods
5152
* @see https://github.com/tc39/proposal-signals/
5253
*/
53-
export const cause = value => {
54-
const state = {
54+
const cause = value => {
55+
const s = {
5556
get: () => {
56-
pending && getEffects(state).add(pending); // track dependency
57-
return maybeCall(value);
57+
computing && getTargets(s).add(computing);
58+
return value;
5859
},
59-
set: updater => {
60-
const old = maybeCall(value);
61-
value = maybeCall(updater, [state, old]);
62-
!Object.is(value, old) && getEffects(state).forEach(effect => effect()); // trigger effects
60+
set: (/** @type {any} */ updater) => {
61+
const old = value;
62+
value = maybeCall(updater, [s, old], updater);
63+
!Object.is(value, old) && getTargets(s).forEach(t => t.get());
6364
}
6465
};
65-
return state;
66+
return s;
6667
};
6768

6869
/**
69-
* Define what happens when a reactive dependency changes; function may return a cleanup function to be executed on next tick
70+
* Define a derived state and return an object duck-typing Signal.Computed instances
7071
*
71-
* @param {Function} handler - callback function to be executed when a reactive dependency changes
72-
* @returns {void}
72+
* @since 0.4.0
73+
* @param {() => any} fn - computation function to be called
74+
* @returns {import("./types").Computed<any>} state object with `get` method
75+
* @see https://github.com/tc39/proposal-signals/
7376
*/
74-
export const effect = handler => {
75-
const next = () => {
76-
pending = next; // register the current effect
77-
const cleanup = handler(); // execute handler function
78-
isFunction(cleanup) && setTimeout(cleanup); // execute possibly returned cleanup function on next tick
79-
pending = null; // unregister the current effect
77+
const derive = fn => {
78+
const d = {
79+
get: () => {
80+
const prev = computing;
81+
computing = d;
82+
const value = fn();
83+
computing = prev;
84+
return value;
85+
}
8086
};
81-
requestAnimationFrame(next); // wait for the next animation frame to bundle DOM updates
82-
}
87+
return d;
88+
};
8389

8490
/* === Default export === */
8591

@@ -94,7 +100,8 @@ export default class extends HTMLElement {
94100
/**
95101
* Hold [name, type] or just type mapping to be used on attributeChangedCallback
96102
*
97-
* @property {Object} attributeMapping - mapping of attribute names to property keys and types or parser functions
103+
* @since 0.2.0
104+
* @property {Record<string, import("./types").AttributeParser | import("./types").MappedAttributeParser>} attributeMapping - mapping of attribute names to state keys and types or parser functions
98105
* @example
99106
* attributeMapping = {
100107
* heading: ['title'], // attribute mapped to a property with a different name; type 'string' is optional (default)
@@ -106,25 +113,25 @@ export default class extends HTMLElement {
106113
*/
107114
attributeMapping = {};
108115

109-
// @private hold states – use `has()`, `get()` and `set()` to access and modify
116+
// @private hold states – use `has()`, `get()`, `set()` and `delete()` to access and modify
110117
#state = new Map();
111118

112119
/**
113120
* Native callback function when an observed attribute of the custom element changes
114121
*
122+
* @since 0.1.0
115123
* @param {string} name - name of the modified attribute
116-
* @param {any} old - old value of the modified attribute
117-
* @param {any} value - new value of the modified attribute
118-
* @returns {void}
124+
* @param {string|undefined} old - old value of the modified attribute
125+
* @param {string|undefined} value - new value of the modified attribute
119126
*/
120127
attributeChangedCallback(name, old, value) {
121128
if (value !== old) {
122129
const input = this.attributeMapping[name];
123130
const [key, type] = Array.isArray(input) ? input : [name, input];
124131
const parser = {
125-
boolean: v => typeof v === 'string' ? true : false,
126-
integer: v => parseInt(v, 10),
127-
number: v => parseFloat(v),
132+
boolean: (/** @type {string|undefined} */ v) => typeof v === 'string' ? true : false,
133+
integer: (/** @type {string} */ v) => parseInt(v, 10),
134+
number: (/** @type {string} */ v) => parseFloat(v),
128135
};
129136
const parsed = maybeCall(type, [this, value, old], parser[type] ? parser[type](value) : value);
130137
this.set(key, parsed);
@@ -134,6 +141,7 @@ export default class extends HTMLElement {
134141
/**
135142
* Check whether a state is set
136143
*
144+
* @since 0.2.0
137145
* @param {any} key - state to be checked
138146
* @returns {boolean} `true` if this element has state with the passed key; `false` otherwise
139147
*/
@@ -143,7 +151,8 @@ export default class extends HTMLElement {
143151

144152
/**
145153
* Get the current value of a state
146-
*
154+
*
155+
* @since 0.2.0
147156
* @param {any} key - state to get value from
148157
* @returns {any} current value of state; undefined if state does not exist
149158
*/
@@ -154,14 +163,34 @@ export default class extends HTMLElement {
154163
/**
155164
* Create a state or update its value
156165
*
166+
* @since 0.2.0
157167
* @param {any} key - state to set value to
158168
* @param {any} value - initial or new value; may be a function (gets old value as parameter) to be evaluated when value is retrieved
159-
* @returns {void}
160169
*/
161170
set(key, value) {
162171
this.has(key)
163172
? maybeCall(this.#state.get(key).set, [this, value]) // update state value
164-
: this.#state.set(key, cause(value)); // create state
173+
: this.#state.set(key, isFunction(value) ? derive(value) : cause(value)); // create state
174+
}
175+
176+
/**
177+
* Delete a state, also removing all effects dependent on the state
178+
*
179+
* @since 0.4.0
180+
* @param {any} key - state to be deleted
181+
*/
182+
delete(key) {
183+
this.has(key) && this.#state.delete(key);
184+
}
185+
186+
/**
187+
* Define what happens when a reactive state changes
188+
*
189+
* @since 0.1.0
190+
* @param {() => (() => void) | void} fn - callback function to be executed when a state changes
191+
*/
192+
effect(fn) {
193+
requestAnimationFrame(() => derive(fn).get()); // wait for the next animation frame to bundle DOM updates
165194
}
166195

167196
}

index.min.js

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/cause-effect.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// hold the currently active effect
2+
let computing;
3+
4+
// set up an empty WeakMap to hold the reactivity map
5+
const reactivityMap = new WeakMap();
6+
7+
/**
8+
* Get the set of targets dependent on a state from the reactivity map
9+
*
10+
* @param {import("../types").State<any>} state - state object as key for the lookup
11+
* @returns {Set} set of targets associated with the state
12+
*/
13+
const getTargets = state => {
14+
!reactivityMap.has(state) && reactivityMap.set(state, new Set());
15+
return reactivityMap.get(state);
16+
};
17+
18+
/* === Public API === */
19+
20+
/**
21+
* Define a state and return an object duck-typing Signal.State instances
22+
*
23+
* @since 0.1.0
24+
* @param {any} value - initial value of the state; may be a function to be called on first access
25+
* @returns {import("../types").State<any>} state object with `get` and `set` methods
26+
* @see https://github.com/tc39/proposal-signals/
27+
*/
28+
const cause = value => {
29+
const s = {
30+
get: () => {
31+
computing && getTargets(s).add(computing);
32+
return value;
33+
},
34+
set: (/** @type {any} */ updater) => {
35+
const old = value;
36+
value = typeof updater === 'function' ? updater(old) : updater;
37+
!Object.is(value, old) && getTargets(s).forEach(t => t.get());
38+
}
39+
};
40+
return s;
41+
};
42+
43+
/**
44+
* Define a derived state and return an object duck-typing Signal.Computed instances
45+
*
46+
* @since 0.4.0
47+
* @param {() => any} fn - computation function to be called
48+
* @returns {import("../types").Computed<any>} state object with `get` method
49+
* @see https://github.com/tc39/proposal-signals/
50+
*/
51+
const derive = fn => {
52+
const d = {
53+
get: () => {
54+
const prev = computing;
55+
computing = d;
56+
const value = fn();
57+
computing = prev;
58+
return value;
59+
}
60+
};
61+
return d;
62+
};
63+
64+
/**
65+
* Define what happens when a reactive state changes
66+
*
67+
* @since 0.1.0
68+
* @param {() => void} fn - callback function to be executed when a state changes
69+
*/
70+
const effect = fn => derive(fn).get();
71+
72+
export { cause, derive, effect };

0 commit comments

Comments
 (0)