mi-element 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,13 +4,24 @@
4
4
 
5
5
  Only weights 2.3kB minified and gzipped.
6
6
 
7
- mi-element provides further features to build web applications through
7
+ mi-element provides features to build web applications through
8
8
  [Web Components][] like:
9
9
 
10
+ - type coercion with setAttribute
10
11
  - controllers to hook into the components lifecycle
11
12
  - ContextProvider, ContextConsumer for data provisioning from outside of a
12
13
  component
13
14
  - Store for managing shared state across components
15
+ - Signal for reactive behavior
16
+
17
+ The motivation to build this module comes from the confusions around attributes
18
+ and properties. "mi-element" solves this by providing the same results when
19
+ setting objects or functions either through `el.setAttribute(name, value)` or
20
+ properties `el[name] = value`.
21
+
22
+ Furthermore all observed attributes have a reactive behavior through the use of
23
+ signals and effects. It implements signals (loosely) following the
24
+ [TC39 JavaScript Signals standard proposal][].
14
25
 
15
26
  # Usage
16
27
 
@@ -23,7 +34,7 @@ npm i mi-element
23
34
  ```js
24
35
  /** @file ./mi-counter.js */
25
36
 
26
- import { MiElement, define, refsById } from 'mi-element'
37
+ import { MiElement, define, refsById, Signal } from 'mi-element'
27
38
 
28
39
  // define your Component
29
40
  class MiCounter extends MiElement {
@@ -31,13 +42,12 @@ class MiCounter extends MiElement {
31
42
  <style>
32
43
  :host { font-size: 1.25rem; }
33
44
  </style>
34
- <button id="decrement" aria-label="Decrement counter"> - </button>
35
- <span id aria-label="Counter value">0</span>
45
+ <div id aria-label="Counter value">0</div>
36
46
  <button id="increment" aria-label="Increment counter"> + </button>
37
47
  `
38
48
 
39
- // declare reactive attributes
40
49
  static get attributes() {
50
+ // declare reactive attribute(s)
41
51
  return { count: 0 }
42
52
  }
43
53
 
@@ -46,13 +56,14 @@ class MiCounter extends MiElement {
46
56
  // gather refs from template (here by id)
47
57
  this.refs = refsById(this.renderRoot)
48
58
  // apply event listeners
49
- this.refs.button.decrement.addEventListener('click', () => this.count--)
50
- this.refs.button.increment.addEventListener('click', () => this.count++)
51
- }
52
-
53
- // called on every change of an observed attributes via `this.requestUpdate()`
54
- update() {
55
- this.refs.span.textContent = this.count
59
+ this.refs.increment.addEventListener('click', () => {
60
+ // change observed and reactive attribute...
61
+ this.count++
62
+ })
63
+ Signal.effect(() => {
64
+ // ...triggers update on every change of `this.count`
65
+ this.refs.div.textContent = this.count
66
+ })
56
67
  }
57
68
  }
58
69
 
@@ -60,7 +71,7 @@ class MiCounter extends MiElement {
60
71
  define('mi-counter', MiCounter)
61
72
  ```
62
73
 
63
- Now use in your HTML
74
+ Now use your now component in your HTML
64
75
 
65
76
  ```html
66
77
  <body>
@@ -77,18 +88,22 @@ In `./example` you'll find a working sample of a Todo App. Check it out with
77
88
 
78
89
  - [lifecycle][docs-lifecycle] mi-element's lifecycle
79
90
  - [controller][docs-controller] adding controllers to mi-element to hook into the lifecycle
80
- - [context][docs-context] Implementation of the [Context Protocol][].
91
+ - [signal][docs-signal] Signals and effect for reactive behavior
81
92
  - [store][docs-store] Manage shared state in an application
93
+ - [context][docs-context] Implementation of the [Context Protocol][].
82
94
  - [styling][docs-styling] Styling directives for "class" and "style"
83
95
 
84
96
  # License
85
97
 
86
98
  MIT licensed
87
99
 
88
- [Context Protocol]: https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md
89
- [Web Components]: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks
90
100
  [docs-lifecycle]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/lifecycle.md
91
101
  [docs-controller]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/controller.md
92
102
  [docs-context]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/context.md
103
+ [docs-signal]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/signal.md
93
104
  [docs-store]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/store.md
94
105
  [docs-styling]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/styling.md
106
+ [Context Protocol]: https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md
107
+ [Web Components]: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#custom_element_lifecycle_callbacks
108
+ [TC39 JavaScript Signals standard proposal]: https://github.com/tc39/proposal-signals
109
+ [krausest/js-framework-benchmark]: https://github.com/krausest/js-framework-benchmark
package/dist/signal.js CHANGED
@@ -1,54 +1,45 @@
1
1
  const context = [];
2
2
 
3
- class State {
4
- subscribers=new Set;
3
+ class State extends EventTarget {
4
+ #value;
5
+ #equals;
5
6
  constructor(value, options) {
7
+ super();
6
8
  const {equals: equals} = options || {};
7
- this.value = value, this.equals = equals ?? ((value, nextValue) => value === nextValue);
9
+ this.#value = value, this.#equals = equals ?? ((value, nextValue) => value === nextValue);
8
10
  }
9
11
  get() {
10
12
  const running = context[context.length - 1];
11
- return running && (this.subscribers.add(running), running.dependencies.add(this.subscribers)),
12
- this.value;
13
+ return running && running.add(this), this.#value;
13
14
  }
14
15
  set(nextValue) {
15
- if (!this.equals(this.value, nextValue)) {
16
- this.value = nextValue;
17
- for (const running of [ ...this.subscribers ]) running.execute();
18
- }
16
+ this.#equals(this.#value, nextValue) || (this.#value = nextValue, this.dispatchEvent(new CustomEvent('signal')));
19
17
  }
20
18
  }
21
19
 
22
- const createSignal = value => value instanceof State ? value : new State(value);
23
-
24
- function cleanup(running) {
25
- for (const dep of running.dependencies) dep.delete(running);
26
- running.dependencies.clear();
27
- }
20
+ const createSignal = (initialValue, options) => initialValue instanceof State ? initialValue : new State(initialValue, options);
28
21
 
29
22
  function effect(cb) {
30
- const execute = () => {
31
- cleanup(running), context.push(running);
32
- try {
33
- cb();
34
- } finally {
35
- context.pop();
36
- }
37
- }, running = {
38
- execute: execute,
39
- dependencies: new Set
40
- };
41
- return execute(), () => {
42
- cleanup(running);
23
+ const running = new Set;
24
+ context.push(running);
25
+ try {
26
+ cb();
27
+ } finally {
28
+ context.pop();
29
+ }
30
+ for (const dep of running) dep.addEventListener('signal', cb);
31
+ return () => {
32
+ for (const dep of running) dep.removeEventListener('signal', cb);
43
33
  };
44
34
  }
45
35
 
46
36
  class Computed {
37
+ #state;
47
38
  constructor(cb) {
48
- this.state = new State, effect((() => this.state.set(cb)));
39
+ this.#state = new State, effect((() => this.#state.set(cb())));
49
40
  }
50
41
  get() {
51
- return this.state.get();
42
+ return this.#state.get();
52
43
  }
53
44
  }
54
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mi-element",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Build lightweight reactive micro web-components",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/commenthol/mi-element/tree/main/packages/mi-element#readme",
@@ -95,6 +95,7 @@
95
95
  "scripts": {
96
96
  "all": "npm-run-all pretty lint test build types",
97
97
  "build": "rimraf dist && rollup -c && gzip -k dist/index.min.js && ls -al dist && rimraf dist/index.min.*",
98
+ "docs": "cd docs; for f in *.md; do markedpp -s -i $f; done",
98
99
  "example": "vite --open /example/",
99
100
  "lint": "eslint",
100
101
  "pretty": "prettier -w **/*.js",
package/src/signal.js CHANGED
@@ -1,27 +1,42 @@
1
+ /**
2
+ * tries to follow proposal JavaScript Signals standard proposal which is in
3
+ * stage 1
4
+ * @see https://github.com/tc39/proposal-signals
5
+ * @credits https://github.com/jsebrech/tiny-signals
6
+ */
7
+
1
8
  // global context for nested reactivity
2
9
  const context = []
3
10
 
4
11
  /**
5
12
  * @template T
6
- * @typedef {{equals: (value?: T|null, nextValue?: T|null) => boolean}} SignalOptions
13
+ * @typedef {(value?: T|null, nextValue?: T|null) => boolean} EqualsFn
14
+ * Custom comparison function between old and new value
15
+ * default `(value, nextValue) => value === nextValue`
7
16
  */
8
17
 
9
18
  /**
10
- * tries to follow proposal (a bit)
11
- * @see https://github.com/tc39/proposal-signals
12
19
  * @template T
20
+ * @typedef {{equals: EqualsFn<T>}} SignalOptions
13
21
  */
14
- export class State {
15
- subscribers = new Set()
22
+
23
+ /**
24
+ * read- write signal
25
+ * @template T
26
+ */
27
+ export class State extends EventTarget {
28
+ #value
29
+ #equals
16
30
 
17
31
  /**
18
32
  * @param {T|null} [value]
19
33
  * @param {SignalOptions<T>} [options]
20
34
  */
21
35
  constructor(value, options) {
36
+ super()
22
37
  const { equals } = options || {}
23
- this.value = value
24
- this.equals = equals ?? ((value, nextValue) => value === nextValue)
38
+ this.#value = value
39
+ this.#equals = equals ?? ((value, nextValue) => value === nextValue)
25
40
  }
26
41
 
27
42
  /**
@@ -30,73 +45,72 @@ export class State {
30
45
  get() {
31
46
  const running = context[context.length - 1]
32
47
  if (running) {
33
- this.subscribers.add(running)
34
- running.dependencies.add(this.subscribers)
48
+ running.add(this)
35
49
  }
36
- return this.value
50
+ return this.#value
37
51
  }
38
52
 
39
53
  /**
40
54
  * @param {T|null|undefined} nextValue
41
55
  */
42
56
  set(nextValue) {
43
- if (this.equals(this.value, nextValue)) {
57
+ if (this.#equals(this.#value, nextValue)) {
44
58
  return
45
59
  }
46
- this.value = nextValue
47
- for (const running of [...this.subscribers]) {
48
- // run the effect()
49
- running.execute()
50
- }
60
+ this.#value = nextValue
61
+ this.dispatchEvent(new CustomEvent('signal'))
51
62
  }
52
63
  }
53
64
 
54
65
  /**
55
66
  * @template T
56
- * @param {T} value
67
+ * @param {T} initialValue
68
+ * @param {SignalOptions<T>} [options]
57
69
  * @returns {State<T>}
58
70
  */
59
- export const createSignal = (value) =>
60
- value instanceof State ? value : new State(value)
61
-
62
- function cleanup(running) {
63
- // delete all dependent subscribers from state
64
- for (const dep of running.dependencies) {
65
- dep.delete(running)
66
- }
67
- running.dependencies.clear()
68
- }
71
+ export const createSignal = (initialValue, options) =>
72
+ initialValue instanceof State
73
+ ? initialValue
74
+ : new State(initialValue, options)
69
75
 
70
76
  /**
71
- * @param {() => void} cb
77
+ * effect subscribes to state at first run only. Do not hide a signal.get()
78
+ * inside conditionals!
79
+ * @param {() => void|Promise<void>} cb
72
80
  */
73
81
  export function effect(cb) {
74
- const execute = () => {
75
- cleanup(running)
76
- context.push(running)
77
- try {
78
- cb()
79
- } finally {
80
- context.pop()
81
- }
82
- }
82
+ const running = new Set()
83
83
 
84
- const running = { execute, dependencies: new Set() }
85
- execute()
84
+ context.push(running)
85
+ try {
86
+ cb()
87
+ } finally {
88
+ context.pop()
89
+ }
90
+ for (const dep of running) {
91
+ dep.addEventListener('signal', cb)
92
+ }
86
93
 
87
94
  return () => {
88
95
  // unsubscribe from all dependencies
89
- cleanup(running)
96
+ for (const dep of running) {
97
+ dep.removeEventListener('signal', cb)
98
+ }
90
99
  }
91
100
  }
92
101
 
102
+ /**
103
+ * @template T
104
+ */
93
105
  export class Computed {
106
+ #state
107
+
94
108
  /**
95
- * @param {() => void} cb
109
+ * @param {() => T} cb
96
110
  */
97
111
  constructor(cb) {
98
- this.state = new State()
99
- effect(() => this.state.set(cb))
112
+ this.#state = new State()
113
+ effect(() => this.#state.set(cb()))
100
114
  }
101
115
 
102
116
  /**
@@ -104,7 +118,7 @@ export class Computed {
104
118
  * @returns {T}
105
119
  */
106
120
  get() {
107
- return this.state.get()
121
+ return this.#state.get()
108
122
  }
109
123
  }
110
124
 
package/types/signal.d.ts CHANGED
@@ -1,25 +1,29 @@
1
1
  /**
2
- * @param {() => void} cb
2
+ * effect subscribes to state at first run only. Do not hide a signal.get()
3
+ * inside conditionals!
4
+ * @param {() => void|Promise<void>} cb
3
5
  */
4
- export function effect(cb: () => void): () => void;
6
+ export function effect(cb: () => void | Promise<void>): () => void;
5
7
  /**
6
8
  * @template T
7
- * @typedef {{equals: (value?: T|null, nextValue?: T|null) => boolean}} SignalOptions
9
+ * @typedef {(value?: T|null, nextValue?: T|null) => boolean} EqualsFn
10
+ * Custom comparison function between old and new value
11
+ * default `(value, nextValue) => value === nextValue`
8
12
  */
9
13
  /**
10
- * tries to follow proposal (a bit)
11
- * @see https://github.com/tc39/proposal-signals
12
14
  * @template T
15
+ * @typedef {{equals: EqualsFn<T>}} SignalOptions
13
16
  */
14
- export class State<T> {
17
+ /**
18
+ * read- write signal
19
+ * @template T
20
+ */
21
+ export class State<T> extends EventTarget {
15
22
  /**
16
23
  * @param {T|null} [value]
17
24
  * @param {SignalOptions<T>} [options]
18
25
  */
19
26
  constructor(value?: T | null | undefined, options?: SignalOptions<T> | undefined);
20
- subscribers: Set<any>;
21
- value: T | null | undefined;
22
- equals: (value?: T | null | undefined, nextValue?: T | null | undefined) => boolean;
23
27
  /**
24
28
  * @returns {T|null|undefined}
25
29
  */
@@ -28,19 +32,23 @@ export class State<T> {
28
32
  * @param {T|null|undefined} nextValue
29
33
  */
30
34
  set(nextValue: T | null | undefined): void;
35
+ #private;
31
36
  }
32
- export function createSignal<T>(value: T): State<T>;
33
- export class Computed {
37
+ export function createSignal<T>(initialValue: T, options?: SignalOptions<T> | undefined): State<T>;
38
+ /**
39
+ * @template T
40
+ */
41
+ export class Computed<T> {
34
42
  /**
35
- * @param {() => void} cb
43
+ * @param {() => T} cb
36
44
  */
37
- constructor(cb: () => void);
38
- state: State<any>;
45
+ constructor(cb: () => T);
39
46
  /**
40
47
  * @template T
41
48
  * @returns {T}
42
49
  */
43
- get<T>(): T;
50
+ get<T_1>(): T_1;
51
+ #private;
44
52
  }
45
53
  declare namespace _default {
46
54
  export { State };
@@ -49,6 +57,11 @@ declare namespace _default {
49
57
  export { Computed };
50
58
  }
51
59
  export default _default;
60
+ /**
61
+ * Custom comparison function between old and new value
62
+ * default `(value, nextValue) => value === nextValue`
63
+ */
64
+ export type EqualsFn<T> = (value?: T | null, nextValue?: T | null) => boolean;
52
65
  export type SignalOptions<T> = {
53
- equals: (value?: T | null, nextValue?: T | null) => boolean;
66
+ equals: EqualsFn<T>;
54
67
  };